From 2b74c30d364d886cf3ec03c1cb3dcd3423e4c263 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Fri, 10 Jul 2026 08:31:27 +0800 Subject: [PATCH] Initial commit --- .env.example | 3 + .gitignore | 4 + .idea/.gitignore | 5 + .idea/inspectionProfiles/Project_Default.xml | 13 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + .idea/writing-agent.iml | 10 + docs/architecture.md | 150 + docs/book-storage.md | 246 ++ docs/context-assembly.md | 189 ++ docs/creation-playbook.md | 83 + docs/implementation-guide.md | 354 +++ docs/orchestrator-skill-format.md | 184 ++ docs/preset-format.md | 297 ++ docs/run-snapshot.md | 53 + docs/runtime-state-machine.md | 136 + docs/skill-design-guide.md | 184 ++ docs/skill-format.md | 510 +++ docs/tag-blackboard.md | 573 ++++ docs/tool-contracts.md | 125 + docs/worker-skill-format.md | 279 ++ electron/main.cjs | 229 ++ package-lock.json | 2791 +++++++++++++++++ package.json | 27 + skills/README.md | 40 + .../dialogue/character-card-author/README.md | 15 + skills/dialogue/character-card-play/README.md | 13 + .../roleplay-game-theory/llm-bindings.yaml | 16 + .../roleplay-game-theory/orchestrator.md | 441 +++ .../roleplay-game-theory/shared-context.md | 164 + .../workers/present-round/SKILL.md | 98 + .../workers/role-decide/SKILL.md | 135 + .../workers/setup-scenario/SKILL.md | 162 + .../workers/world-engine/SKILL.md | 204 ++ skills/dialogue/scene-roleplay/README.md | 41 + skills/dialogue/world-simulator/README.md | 37 + .../instantiate-orchestrator.md | 310 ++ skills/novel/basic/orchestrator.md | 85 + skills/novel/basic/workers/outline/SKILL.md | 57 + skills/novel/interactive-novel/README.md | 27 + skills/novel/novel-standard/README.md | 20 + skills/novel/quick-write/README.md | 30 + .../novel/weird-rules-short/orchestrator.md | 286 ++ .../novel/weird-rules-short/shared-context.md | 68 + .../workers/review-author/SKILL.md | 117 + .../workers/review-infer/SKILL.md | 110 + .../workers/write-rules/SKILL.md | 103 + skills/registry.yaml | 16 + src/blackboard/blackboard.ts | 117 + src/blackboard/tag-match.ts | 13 + src/book/orchestrators.ts | 28 + src/book/run-snapshot-store.ts | 96 + src/book/session-store.ts | 46 + src/book/skill-id.ts | 37 + src/book/snapshot-filters.ts | 53 + src/book/store.ts | 117 + src/cli/phase-demo.ts | 169 + src/cli/run.ts | 134 + src/config/api-profiles.ts | 159 + src/config/env.ts | 56 + src/config/settings.ts | 101 + src/config/user-data-dir.ts | 25 + src/intake/extract.ts | 86 + src/intake/intake.ts | 170 + src/llm/client.ts | 389 +++ src/llm/preset-wrapper.ts | 58 + src/llm/token-tracker.ts | 85 + src/main-agent/main-agent.ts | 191 ++ src/main-agent/tool-loop.ts | 173 + src/main-agent/tools.ts | 121 + src/preset/assembler.ts | 49 + src/preset/entries.ts | 46 + src/preset/importer.ts | 235 ++ src/preset/store.ts | 67 + src/runtime/llm-factory.ts | 78 + src/runtime/orchestrator.ts | 291 ++ src/runtime/phase-machine.ts | 793 +++++ src/runtime/phase-runtime.ts | 763 +++++ src/runtime/state-machine.ts | 8 + src/runtime/tool-registry.ts | 119 + src/server/agent-view.ts | 362 +++ src/server/book-handlers.ts | 190 ++ src/server/session-manager.ts | 829 +++++ src/server/settings-handlers.ts | 267 ++ src/server/skill-catalog.ts | 285 ++ src/server/stats-handlers.ts | 37 + src/server/web-server.ts | 203 ++ src/skills/loader.ts | 502 +++ src/skills/snapshot.ts | 19 + src/skills/types.ts | 76 + src/skills/worker-llm.ts | 146 + src/stats/token-store.ts | 247 ++ src/types/blackboard.ts | 29 + src/types/book-session.ts | 34 + src/types/book.ts | 37 + src/types/intake.ts | 26 + src/types/preset.ts | 66 + src/types/run-snapshot.ts | 56 + src/types/runtime.ts | 257 ++ src/types/tools.ts | 61 + src/worker/executor.ts | 218 ++ src/worker/resolve-id.ts | 10 + start-app-hidden.vbs | 4 + start-app.bat | 56 + start.bat | 53 + tests/agent-view.test.ts | 151 + tests/blackboard.test.ts | 33 + tests/book-store.test.ts | 20 + tests/intake.test.ts | 117 + tests/orchestrator.test.ts | 26 + tests/phase-machine.test.ts | 172 + tests/phase-runtime.test.ts | 77 + tests/phase-skill.test.ts | 156 + tests/preset.test.ts | 84 + tests/run-snapshot.test.ts | 173 + tests/session-persist.test.ts | 115 + tests/snapshot-filters.test.ts | 58 + tests/token-usage.test.ts | 144 + tests/tool-loop.test.ts | 66 + tests/worker-inputs.test.ts | 50 + tests/worker-llm.test.ts | 100 + tests/worker-resolve.test.ts | 10 + tsconfig.json | 20 + vitest.config.ts | 7 + web/agent-ui.js | 276 ++ web/app.js | 459 +++ web/export.js | 108 + web/index.html | 99 + web/intake-ui.js | 96 + web/settings.css | 420 +++ web/settings.html | 105 + web/settings.js | 441 +++ web/styles.css | 215 ++ 134 files changed, 21801 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/writing-agent.iml create mode 100644 docs/architecture.md create mode 100644 docs/book-storage.md create mode 100644 docs/context-assembly.md create mode 100644 docs/creation-playbook.md create mode 100644 docs/implementation-guide.md create mode 100644 docs/orchestrator-skill-format.md create mode 100644 docs/preset-format.md create mode 100644 docs/run-snapshot.md create mode 100644 docs/runtime-state-machine.md create mode 100644 docs/skill-design-guide.md create mode 100644 docs/skill-format.md create mode 100644 docs/tag-blackboard.md create mode 100644 docs/tool-contracts.md create mode 100644 docs/worker-skill-format.md create mode 100644 electron/main.cjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 skills/README.md create mode 100644 skills/dialogue/character-card-author/README.md create mode 100644 skills/dialogue/character-card-play/README.md create mode 100644 skills/dialogue/roleplay-game-theory/llm-bindings.yaml create mode 100644 skills/dialogue/roleplay-game-theory/orchestrator.md create mode 100644 skills/dialogue/roleplay-game-theory/shared-context.md create mode 100644 skills/dialogue/roleplay-game-theory/workers/present-round/SKILL.md create mode 100644 skills/dialogue/roleplay-game-theory/workers/role-decide/SKILL.md create mode 100644 skills/dialogue/roleplay-game-theory/workers/setup-scenario/SKILL.md create mode 100644 skills/dialogue/roleplay-game-theory/workers/world-engine/SKILL.md create mode 100644 skills/dialogue/scene-roleplay/README.md create mode 100644 skills/dialogue/world-simulator/README.md create mode 100644 skills/dialogue/world-simulator/instantiate-orchestrator.md create mode 100644 skills/novel/basic/orchestrator.md create mode 100644 skills/novel/basic/workers/outline/SKILL.md create mode 100644 skills/novel/interactive-novel/README.md create mode 100644 skills/novel/novel-standard/README.md create mode 100644 skills/novel/quick-write/README.md create mode 100644 skills/novel/weird-rules-short/orchestrator.md create mode 100644 skills/novel/weird-rules-short/shared-context.md create mode 100644 skills/novel/weird-rules-short/workers/review-author/SKILL.md create mode 100644 skills/novel/weird-rules-short/workers/review-infer/SKILL.md create mode 100644 skills/novel/weird-rules-short/workers/write-rules/SKILL.md create mode 100644 skills/registry.yaml create mode 100644 src/blackboard/blackboard.ts create mode 100644 src/blackboard/tag-match.ts create mode 100644 src/book/orchestrators.ts create mode 100644 src/book/run-snapshot-store.ts create mode 100644 src/book/session-store.ts create mode 100644 src/book/skill-id.ts create mode 100644 src/book/snapshot-filters.ts create mode 100644 src/book/store.ts create mode 100644 src/cli/phase-demo.ts create mode 100644 src/cli/run.ts create mode 100644 src/config/api-profiles.ts create mode 100644 src/config/env.ts create mode 100644 src/config/settings.ts create mode 100644 src/config/user-data-dir.ts create mode 100644 src/intake/extract.ts create mode 100644 src/intake/intake.ts create mode 100644 src/llm/client.ts create mode 100644 src/llm/preset-wrapper.ts create mode 100644 src/llm/token-tracker.ts create mode 100644 src/main-agent/main-agent.ts create mode 100644 src/main-agent/tool-loop.ts create mode 100644 src/main-agent/tools.ts create mode 100644 src/preset/assembler.ts create mode 100644 src/preset/entries.ts create mode 100644 src/preset/importer.ts create mode 100644 src/preset/store.ts create mode 100644 src/runtime/llm-factory.ts create mode 100644 src/runtime/orchestrator.ts create mode 100644 src/runtime/phase-machine.ts create mode 100644 src/runtime/phase-runtime.ts create mode 100644 src/runtime/state-machine.ts create mode 100644 src/runtime/tool-registry.ts create mode 100644 src/server/agent-view.ts create mode 100644 src/server/book-handlers.ts create mode 100644 src/server/session-manager.ts create mode 100644 src/server/settings-handlers.ts create mode 100644 src/server/skill-catalog.ts create mode 100644 src/server/stats-handlers.ts create mode 100644 src/server/web-server.ts create mode 100644 src/skills/loader.ts create mode 100644 src/skills/snapshot.ts create mode 100644 src/skills/types.ts create mode 100644 src/skills/worker-llm.ts create mode 100644 src/stats/token-store.ts create mode 100644 src/types/blackboard.ts create mode 100644 src/types/book-session.ts create mode 100644 src/types/book.ts create mode 100644 src/types/intake.ts create mode 100644 src/types/preset.ts create mode 100644 src/types/run-snapshot.ts create mode 100644 src/types/runtime.ts create mode 100644 src/types/tools.ts create mode 100644 src/worker/executor.ts create mode 100644 src/worker/resolve-id.ts create mode 100644 start-app-hidden.vbs create mode 100644 start-app.bat create mode 100644 start.bat create mode 100644 tests/agent-view.test.ts create mode 100644 tests/blackboard.test.ts create mode 100644 tests/book-store.test.ts create mode 100644 tests/intake.test.ts create mode 100644 tests/orchestrator.test.ts create mode 100644 tests/phase-machine.test.ts create mode 100644 tests/phase-runtime.test.ts create mode 100644 tests/phase-skill.test.ts create mode 100644 tests/preset.test.ts create mode 100644 tests/run-snapshot.test.ts create mode 100644 tests/session-persist.test.ts create mode 100644 tests/snapshot-filters.test.ts create mode 100644 tests/token-usage.test.ts create mode 100644 tests/tool-loop.test.ts create mode 100644 tests/worker-inputs.test.ts create mode 100644 tests/worker-llm.test.ts create mode 100644 tests/worker-resolve.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 web/agent-ui.js create mode 100644 web/app.js create mode 100644 web/export.js create mode 100644 web/index.html create mode 100644 web/intake-ui.js create mode 100644 web/settings.css create mode 100644 web/settings.html create mode 100644 web/settings.js create mode 100644 web/styles.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2308146 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.deepseek.com +OPENAI_MODEL=deepseek-v4-pro diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..10b731c --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ca9dfbc --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..8954244 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/writing-agent.iml b/.idea/writing-agent.iml new file mode 100644 index 0000000..9477bc8 --- /dev/null +++ b/.idea/writing-agent.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9136b19 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,150 @@ +# 整体架构 + +## 1. 方向 + +**标签驱动黑板** + **agent tool loop** + **5 相位阶段机** + **skill 能力库**。 + +```text +Skill 包(orchestrator manifest + workers/*/SKILL.md) 静态能力定义 +Session + 黑板 tag 一次运行的实参 +Book 跨 Session 的项目与资产 + +Agent(总管) running 相位内 tool loop,调度 invoke 哪个 skill +Runtime 拼接上下文、校验 tool、写黑板、驱动阶段机边界 +阶段机 谁可动、何时等用户、产物生命周期——不是流水线剧本 +``` + +核心文档: + +```text +docs/tag-blackboard.md 黑板、标签、分工 +docs/context-assembly.md ★ 上下文拼接(上半固定、下半动态) +docs/tool-contracts.md 总管 / worker tool +docs/runtime-state-machine.md 5 相位、tool 边界 +docs/book-storage.md 长期存储(过程 / 资产 / 游玩) +docs/skill-design-guide.md 新 skill 包设计方法 +docs/implementation-guide.md 代码文件职责 +``` + +--- + +## 2. 术语 + +| 词 | 含义 | +|----|------| +| **skill** | `SKILL.md` 定义的能力(设计期能力库中的一条) | +| **worker** | 某 skill 被 invoke 的一次执行 | +| **stage** | 业务阶段:`design`(实例化)→ `play`(运行)→ `done` | +| **phase** | 运行相位:`idle` \| `running` \| `waiting_user` \| `done` \| `error` | + +不使用 **step** 指代设计步骤编号,避免与管道混淆。 + +--- + +## 3. Agent tool loop + +```text +用户硬事件(输入 / 确认 / 验收) + → phase = running,toolLoopBurst 计数归零 + → while running && burst < N: + LLM(messages, tools) + → 循环 tool(read_blackboard …)→ 结果 append 到 messages + → 边界 tool(run_worker / ask_user / finish)→ 退出 burst + → 若需用户 → waiting_user +``` + +- **burst 上限 N**:两次用户操作之间的最大推理轮数,非 Session 终身额度。 +- **循环 tool**:不改 phase,只追加 agent 对话。 +- **边界 tool**:触发阶段机转移(等用户、跑 worker、结束)。 + +代码:`src/main-agent/tool-loop.ts`、`src/runtime/phase-runtime.ts`。 + +目标态:worker 执行也用 tool loop(`submit` / `ask_user`),上下文仍由 Runtime 拼接,见 `docs/tool-contracts.md`。 + +--- + +## 4. 阶段机做什么 + +阶段机 **不是** 编排表执行器,而是: + +```text +1. Actor 门禁 此刻用户 / agent / worker 谁在场 +2. Tool 边界 当前态允许哪些 tool +3. 事实生命周期 draft → accepted;用户才能 accept +4. 等待原因 waitingReason 细分等什么 +``` + +业务「先跑哪个 skill」由 **agent 在 tool loop 里决定**,不由 orchestrator 逐步剧本写死。 + +--- + +## 5. 实例化:skill 能力库 + +实例化 = agent 在 `design` stage 按需 invoke **instantiate skill**(原「设计步骤」),不是固定 1→14 管道。 + +```text +交互范式 skill → 产出 设计.run_skill清单(run 阶段需要哪些 skill) +每个 run skill 倒推 → 缺什么 instantiate skill → agent invoke +declare_instance_ready → 进入 play stage +``` + +orchestrator.md = **manifest**(有哪些 skill、约束、验收策略),不是逐步思维链。 + +--- + +## 6. 上下文拼接 + +**上半固定、下半动态**。规则在 skill 定义 + 实例 contextProfile;Runtime 执行。 +见 `docs/context-assembly.md`。 + +--- + +## 7. 模块 + +```text +phase-machine.ts 纯函数:event → phase(边界规则) +phase-runtime.ts Session IO、tool loop 驱动、worker 执行 +main-agent/ tool loop、tool 定义 +worker/executor.ts assembleWorkerContext、run skill +blackboard.ts tag 池 +skills/loader.ts 解析 orchestrator + SKILL.md +book/ Book、快照、持久化 +``` + +--- + +## 8. 数据流(目标态) + +```text +选 orchestrator 包 + → design:agent burst → invoke instantiate skills → 设计.* tag + → declare ready → play + → play:用户输入 → agent burst → invoke run skills → 运行.* tag + → 过程/资产/游玩 归档 Book(见 book-storage.md) +``` + +--- + +## 9. 边界 + +```text +Agent 调度 skill;read_blackboard;不传 inputTags;不写黑板(边界 tool 除外) +Runtime 拼接上下文;校验 tool;写黑板;执行 worker +Worker LLM 在拼接后的 prompt 内产出;submit 写 declared outputTags +用户 approve、accept、输入 +阶段机 只响应 event,不调 LLM +``` + +--- + +## 10. 实现进度(摘要) + +```text +✅ phase-machine、phase-runtime、skills loader +✅ 总管 tool loop(read_blackboard、run_worker、…) +⬜ toolLoopBurst 按用户事件归零 +⬜ assembleWorkerContext(contextSegments) +⬜ worker tool loop +⬜ Book:designTrace / CardAsset / PlayBook +⬜ orchestrator 从编排表迁为 manifest +``` diff --git a/docs/book-storage.md b/docs/book-storage.md new file mode 100644 index 0000000..b0d854f --- /dev/null +++ b/docs/book-storage.md @@ -0,0 +1,246 @@ +# Book 存储模型 + +## 1. 定位 + +Book = **长期项目容器**。Session = 一次打开的运行进程。黑板 = Session 内运行时 tag。 + +存储须同时满足: + +```text +1. 保存「创建过程」(designTrace / playTrace) +2. 保存「创建结果」(可复用资产,如角色卡) +3. 保存「游玩过程」(对话、轮次、变量、存档点) +``` + +第一版约束:不存 API key;历史与当前分离;accepted 才升为「当前事实」。 + +--- + +## 2. 三种 Book 形态 + +| 形态 | 用途 | 典型 kind | +|------|------|-----------| +| **CardBook** | 创作角色卡(design stage) | `character_card_design` | +| **CardAsset** | 从 CardBook 导出的可复用卡 | 资产条目,可挂卡库 | +| **PlayBook** | 用某张卡(+ 可选世界)游玩 | `character_card_play` / `roleplay` | +| **ProjectBook** | 小说等项目(沿用) | `novel` | + +同一用户可:CardBook 创作 → 导出 CardAsset → 新建 PlayBook 引用该卡。 + +--- + +## 3. CardBook(创建角色卡) + +### 3.1 存什么 + +```ts +type CardBook = { + id: string; + title: string; + orchestratorId: string; + lifecycleStage: "design" | "archived"; + + /** 创作过程:可时间线展示、可续作设计 */ + designTrace: DesignTraceEntry[]; + + /** 运行时黑板快照(设计.* tag) */ + designArtifacts: TaggedArtifact[]; + + /** 用户可见对话;agent tool 对话可另存 agentTrace */ + messages: PersistedChatMessage[]; + + runSkillManifest?: RunSkillManifest; // 设计完成后:建议如何游玩 + readiness?: InstanceReadiness; + + createdAt: string; + updatedAt: string; +}; +``` + +```ts +type DesignTraceEntry = { + at: string; + type: + | "user_input" + | "agent_tool" + | "skill_invoked" + | "user_confirmed" + | "user_rejected" + | "declare_ready"; + skillId?: string; + tool?: string; + summary: string; + outputTags?: string[]; +}; +``` + +**过程** = `designTrace` + `messages`(+ 可选 `agentTrace`)。 +**不是**预定实体槽填表;agent 调了哪些 instantiate skill,过程里就记哪些。 + +### 3.2 导出 CardAsset(创建结果) + +```ts +type CardAsset = { + id: string; + cardBookId: string; + title: string; + tags: Record; // 角色卡.确认稿、角色.设定、口吻… + runSkillManifest?: RunSkillManifest; + confirmedAt: string; +}; +``` + +游玩时 **加载 CardAsset**,不必重跑完整 design。 + +--- + +## 4. PlayBook(游玩角色卡 / 扮演) + +```ts +type PlayBook = { + id: string; + title: string; + orchestratorId: string; + lifecycleStage: "play" | "archived"; + + cardRef: { assetId: string; version?: string }; + worldRef?: { assetId?: string }; + + contextProfile: ContextProfile; // 实例化选的 variant + runSkillManifest: RunSkillManifest; + + playTrace: PlayTraceEntry[]; + runState: { + turn: number; + variables: Record; + eventStream: string; + }; + + messages: PersistedChatMessage[]; + snapshotIds: string[]; + + createdAt: string; + updatedAt: string; +}; +``` + +```ts +type PlayTraceEntry = { + at: string; + turn?: number; + type: "user_input" | "skill_invoked" | "artifact" | "snapshot"; + skillId?: string; + summary: string; +}; +``` + +**游玩过程** = `playTrace` + `messages` + `runState`。 +**游玩存档** = `RunSnapshot`(kind=`run`),见 `run-snapshot.md`。 + +--- + +## 5. ProjectBook(小说等) + +保留原 `BookProject` + `BookContentRecord` 思路: + +```ts +type BookProject = { + id: string; + title: string; + orchestratorId: string; + kind: "novel" | "forum" | "weird_rules" | "custom"; + lifecycleStage: "design" | "play" | "done"; + currentContentId?: string; + historyContentIds: string[]; + designTrace?: DesignTraceEntry[]; + sessionIds: string[]; + createdAt: string; + updatedAt: string; +}; +``` + +小说正文仍用 `BookContentRecord`(outline/chapter);design 阶段 tag 可进 `designArtifacts`。 + +--- + +## 6. Session 与 Book + +```text +Session(PersistedBookSession) + 当前打开的 working copy:RuntimeSession + 黑板 + messages + 关闭时可合并进 Book 的 trace / runState + +Book + 稳定事实 + 过程记录 + 资产引用 + +沉淀规则 + design accepted tag → CardBook.designArtifacts / 导出 CardAsset + play accepted tag → PlayBook.runState + 黑板归档 + draft / rejected → trace 记一笔,默认不升 current +``` + +--- + +## 7. 与状态机 + +```text +worker_completed → draft +user_accepted_artifact / no_confirmation / programmatic_pass + → 可写 Book(由 Runtime,非 agent 直接写) +``` + +--- + +## 8. 与黑板 + +| 阶段 | 黑板前缀示例 | 归档到 | +|------|--------------|--------| +| design | `设计.*`、`用户.需求` | CardBook / ProjectBook design | +| play | `运行.*`、`世界.*`、`输出.*` | PlayBook runState | + +上下文拼接读黑板;Book 存 **确认稿与过程**,见 `context-assembly.md`。 + +--- + +## 9. 存储布局(建议) + +```text +books/{bookId}/project.json +books/{bookId}/design-artifacts.json +books/{bookId}/traces.jsonl +books/{bookId}/contents/{contentId}.json # 小说正文 +assets/cards/{assetId}.json # CardAsset 卡库 +books/{bookId}/snapshots/{snapshotId}.json +sessions/{sessionId}.json # 可选 working copy +``` + +--- + +## 10. 实现优先级 + +```text +P0 文档对齐(本文) +P1 designTrace / playTrace 写入 +P2 CardAsset 导出与 PlayBook.cardRef +P3 与现有 PersistedBookSession / RunSnapshot 字段合并 +P4 卡库 UI、跨 Book 引用 +``` + +代码现状:`src/types/book.ts`、`run-snapshot.ts` 仍为简化模型,实现时按本文扩展。 + +--- + +## 11. 角色卡流程示例 + +```text +1. 新建 CardBook → design stage +2. 用户与 agent 迭代 → designTrace 追加 +3. invoke persona-* skills → 设计.* tag +4. 用户确认 → 导出 CardAsset +5. 新建 PlayBook,cardRef = assetId +6. play stage:每轮 playTrace + messages +7. 手动 PlaySnapshot 存档 +8. 下次打开 PlayBook 或读 snapshot 续玩 +``` + +创建过程、卡本身、游玩过程 **三者分开存**,互不覆盖。 diff --git a/docs/context-assembly.md b/docs/context-assembly.md new file mode 100644 index 0000000..154fc52 --- /dev/null +++ b/docs/context-assembly.md @@ -0,0 +1,189 @@ +# 上下文拼接 + +## 1. 定位 + +Worker / skill 执行时,Runtime 将黑板 tag 与固定体裁说明拼成 LLM prompt。 +**拼接规则在 skill 定义期定制;拼接执行由 Runtime 机械完成;agent 不临场改 inputTags。** + +详见 `docs/worker-skill-format.md`(字段)、`docs/tag-blackboard.md`(标签原则)。 + +--- + +## 2. 上半固定、下半动态 + +每条 worker prompt 分为两段: + +```text +┌─ 上半:固定上下文(Static)────────────────────────┐ +│ shared-context.md(包级体裁约束) │ +│ worker SKILL.md 正文(能力说明、自检) │ +│ contextSegments 中 tier=static 的 tag │ +│ 例:角色卡.确认稿、世界.蓝图、设计.交互范式 │ +└────────────────────────────────────────────────────┘ +┌─ 下半:动态上下文(Dynamic)────────────────────────┐ +│ contextSegments 中 tier=dynamic 的 tag │ +│ 例:运行.事件流、可见信息、用户.最新输入 │ +│ 按 policy 裁剪(tail_lines、tail_tokens、concat) │ +└────────────────────────────────────────────────────┘ +``` + +原则: + +```text +越稳定、越少改 → 越靠上(static) +越增量、每轮变 → 越靠下(dynamic) +``` + +**不是** agent 在游玩时自由往 prompt 里插段落;agent 只决定 **invoke 哪个 skill**;该 skill 的契约决定看见什么。 + +--- + +## 3. 三分工 + +| 谁 | 管什么 | 何时定 | +|----|--------|--------| +| **Skill 定义**(`workers/*/SKILL.md`) | inputTags、contextSegments、tier、policy、隔离 | 写 skill 时 | +| **实例 manifest** | 启用哪些 run skill、`contextProfile` 选哪档 variant | 实例化时 agent 产出 | +| **Agent** | 何时 invoke 哪个 skill;可用 read_blackboard 辅助决策 | 运行中 | +| **Runtime** | `assembleWorkerContext()` 唯一拼接点 | 每次 invoke | + +--- + +## 4. contextSegments(Worker Skill 字段) + +在 `SKILL.md` frontmatter 声明(`inputTags` 仍保留,作为取数白名单): + +```yaml +contextSegments: + - id: persona + tier: static + tags: ["角色卡.确认稿"] + label: "## 角色设定" + - id: world + tier: static + tags: ["世界.蓝图"] + label: "## 世界" + - id: history + tier: dynamic + tags: ["运行.事件流"] + policy: tail_lines_80 + - id: turn + tier: dynamic + tags: ["可见信息", "用户.最新输入"] + label: "## 本轮" +``` + +| 字段 | 含义 | +|------|------| +| `tier` | `static`(上半)或 `dynamic`(下半) | +| `tags` | 从黑板取的 pattern,须在 `inputTags` 内 | +| `label` | 拼进 prompt 的 Markdown 标题(可选) | +| `policy` | 动态段裁剪,见 §5 | + +未声明 `contextSegments` 时,Runtime 回退:按 `inputTags` 顺序输出 JSON `inputs`(当前实现)。 + +--- + +## 5. 动态段裁剪 policy + +| policy | 行为 | +|--------|------| +| `latest` | 每 pattern 取最新一条(默认) | +| `concat` | 同 pattern 多条合并 | +| `tail_lines_N` | 事件流等取最后 N 行 | +| `tail_tokens_N` | 按估算 token 截断(预留) | + +上下文过长时: + +1. 优先靠 policy 裁剪动态段 +2. 实例 manifest 可覆盖 variant(如 `historyPolicy: last_10_turns`) +3. agent 可 invoke 显式 **compress-history** skill 写摘要 tag(调度 skill,不是随手删 prompt) + +--- + +## 6. contextProfile(实例 manifest) + +实例化阶段产出(写入 `设计.run_skill清单` 或 Book manifest),agent **只选预置档位**,不列 tag: + +```json +{ + "runSkills": ["world-simulator", "narrator"], + "contextProfile": { + "narrator": { "variant": "card_rp", "historyPolicy": "last_15_turns" }, + "world-simulator": { "variant": "light_rules" } + } +} +``` + +`variant` 在 skill 包内预定义多组 `contextSegments` 覆盖或 policy 差异。 +例:`narrator` 的 `card_rp` 强制 static 含 `角色卡.确认稿`;`short_emotion_flow` 缩短 dynamic 历史。 + +--- + +## 7. 隔离 + +与 `inputTags` 正交,由 skill 声明 `contextIsolation`: + +```yaml +contextIsolation: none | role_pov | blind_review +``` + +- `role_pov`:role-decide 等,Runtime 调用 `filterInputsForRolePerspective` +- `blind_review`:review 不可见指定 tag(如 `核心.危险.隐藏`) + +隔离在 **取数之后、拼接之前** 应用。 + +--- + +## 8. 与 agent tool loop 的边界 + +两套上下文 **不得混用**: + +| | Agent tool loop `messages[]` | Worker prompt | +|--|------------------------------|---------------| +| 用途 | 总管推理、选 skill | 具体 skill 执行 | +| 内容 | tool 结果、read_blackboard | assembleWorkerContext 输出 | +| 增长 | 两次用户操作之间的 burst 内累积 | 每次 invoke 按契约重建 | + +总管 `read_blackboard` **不注入** worker prompt;只帮助 agent 决定下一个 `run_worker`。 + +--- + +## 9. 拼接结果形态(目标) + +```text +system: + {shared-context} + {worker SKILL body} + {固定输出协议} + +user: + {按 segment 顺序格式化的 Markdown 或结构化块} +``` + +实现:`src/worker/executor.ts` → `assembleWorkerContext()`(待从纯 JSON inputs 升级)。 + +--- + +## 10. 设计 checklist + +```text +□ 列出 static / dynamic 各需要哪些 tag +□ static 写入 contextSegments tier=static +□ dynamic 写入 tier=dynamic 并选 policy +□ inputTags 覆盖 segments 中全部 pattern +□ 画隔离表:谁不可见哪些 tag +□ 若有多游玩模式,在包内预置 contextProfile variant +□ 实例化 manifest 只选 variant,不临场改 tag 列表 +``` + +--- + +## 11. 相关文档 + +| 文档 | 关系 | +|------|------| +| `worker-skill-format.md` | frontmatter 字段定义 | +| `skill-design-guide.md` | 如何倒推 tag 与 skill 能力 | +| `tag-blackboard.md` | 标签命名与黑板 | +| `tool-contracts.md` | agent 不得传 inputTags | diff --git a/docs/creation-playbook.md b/docs/creation-playbook.md new file mode 100644 index 0000000..3a052ac --- /dev/null +++ b/docs/creation-playbook.md @@ -0,0 +1,83 @@ +# 创作流程指南(Creation Playbook) + +## 0. 内容在哪 + +**流程定义在 skill 包里**(orchestrator manifest + `workers/*/SKILL.md`)。本文只保留概念。 + +写新包 → `skill-design-guide.md` → 新建 `skills/.../orchestrator.md`。 + +--- + +## 1. 定位 + +```text +Orchestrator 包 能力库 + manifest(静态) +黑板 tag 一次 Session 的实参(动态) +Book 跨 Session:过程、资产、游玩 +``` + +| 层 | 管什么 | +|----|--------| +| **运行相位** | `idle` / `running` / `waiting_user` — 系统在等什么 | +| **业务 stage** | `design`(实例化)→ `play`(运行)→ `done` | +| **Agent** | tool loop 内 invoke 哪个 skill | +| **Skill** | 读哪些 tag、写哪些 tag、上下文怎么拼 | +| **Book** | 长期存储,见 `book-storage.md` | + +--- + +## 2. 启动 + +```text +skill_selection 选 orchestrator 包 +intake 启动询问(最小信息) +design stage agent 按需 invoke instantiate skill +declare ready 进入 play +play stage 用户输入 → agent burst → run skill +done 归档 Book +``` + +`startupCompleted` / `instanceReady`:agent 声明 + 程序校验「当前 run_skill清单 可运行」,非固定 prerequisite 打勾。 + +--- + +## 3. Agent 与 Runtime + +| | Agent | Runtime | +|--|-------|---------| +| 决定 | invoke 哪个 skill、何时 ask_user/finish | — | +| 拼接上下文 | 只用 read_blackboard 辅助决策 | assembleWorkerContext | +| 写黑板 | 否(边界 tool 驱动 worker 写) | 校验 outputTags 后写入 | + +Agent **不指定 inputTags**。见 `context-assembly.md`。 + +--- + +## 4. Tool loop burst + +每次用户硬事件后,agent 进入 `running`,在 **burst 上限内** 多轮 tool;碰到边界 tool 或需用户则停。 +见 `tool-contracts.md`、`runtime-state-machine.md`。 + +--- + +## 5. 角色卡(一种 Book 形态) + +```text +创建过程 designTrace + 对话 → CardBook(design) +创建结果 角色卡.确认稿 等 → CardAsset(可导入资产) +游玩过程 playTrace + runState → PlayBook +游玩存档 PlaySnapshot +``` + +不拆独立 author/play 包;见 `book-storage.md` §角色卡。 + +--- + +## 6. 相关文档 + +| 文档 | 关系 | +|------|------| +| `architecture.md` | 总览 | +| `skill-design-guide.md` | 设计方法 | +| `context-assembly.md` | 上下文拼接 | +| `tag-blackboard.md` | 标签 | diff --git a/docs/implementation-guide.md b/docs/implementation-guide.md new file mode 100644 index 0000000..ade0759 --- /dev/null +++ b/docs/implementation-guide.md @@ -0,0 +1,354 @@ +# 实现指南 + +## 1. 写代码的规则 + +```text +1. 先读文档,再写对应文件。 +2. 一次只写一个文件(或一组强绑定的 types + 实现)。 +3. 每写完一个文件,对照本文档的「文件职责」自检。 +4. 文件行为与文档冲突时,先改文档,再改代码。 +5. 不要跳步实现:下游文件不能先于上游文件。 +``` + +文档是规格,代码是文档的实现。 + +--- + +## 2. 文档地图 + +| 文档 | 管什么 | +|---|---| +| `tag-blackboard.md` | **★ 主规格**:标签黑板、skill 分工 | +| `context-assembly.md` | **★ 上下文拼接**:上半固定、下半动态 | +| `architecture.md` | 总览、agent tool loop、模块边界 | +| `runtime-state-machine.md` | 5 相位、tool 边界、burst | +| `tool-contracts.md` | 总管 / worker tool | +| `orchestrator-skill-format.md` | manifest 写法(非编排表) | +| `worker-skill-format.md` | SKILL.md、contextSegments | +| `skill-format.md` | 包存储、registry | +| `skill-design-guide.md` | 新包设计方法 | +| `creation-playbook.md` | 创作流程概念 | +| `book-storage.md` | Book、CardAsset、PlayBook、过程存储 | +| `run-snapshot.md` | 手动存档 | +| `preset-format.md` | 预设导入 | +| `implementation-guide.md` | 本文件:文件顺序与职责 | + +--- + +## 3. 实现分期 + +### Phase A0 — 可运行阶段机(当前优先) + +目标:**不依赖 LLM**,阶段机整体可运行、可手动驱动、可脚本跑通最小闭环。 + +| # | 文件 | 状态 | 干嘛的 | +|---|---|---|---| +| A06 | `src/types/runtime.ts` | done | 5 相位、事件、会话类型 | +| A09 | `src/runtime/phase-machine.ts` | done | 纯函数:`applyEvent`,不调 LLM | +| A20 | `src/runtime/phase-runtime.ts` | done | 运行层:处理 effects,stub worker | +| A21 | `src/cli/phase-demo.ts` | done | 交互式演示 CLI,手动发事件 | +| A22 | `tests/phase-runtime.test.ts` | done | 闭环 + worker 中途提问测试 | + +**运行方式:** +```bash +npm run phase-script # 非交互,自动跑最小闭环 +npm run phase-demo # 交互,手动 /decide /approve /worker-ask +npm run phase-demo -- --auto # worker 自动占位完成 +``` + +### Phase A1 — 总管 LLM 接入(阶段机之上) + +目标:在 PhaseRuntime 之上接 Main Agent,不改动 phase-machine 规则。 + +| # | 文件 | 状态 | 干嘛的 | +|---|---|---|---| +| A11–A19 | blackboard、llm、main-agent、orchestrator、run.ts | done | 带 Mock/真实 LLM 的完整栈 | +| A15 | `src/main-agent/prompts.ts` | todo | prompt 拆分 | + +### Phase A2 — Skill 层(创作指南) + +目标:`skills/` 批量存储 SKILL.md;启动第一个询问是选 skill;总管读 activeSkill。 + +| # | 文件 | 状态 | 干嘛的 | +|---|---|---|---| +| S00 | `skills/registry.yaml` | done | skill 索引 | +| S01 | `skills/novel-standard/SKILL.md` | done | 小说指南(含启动询问) | +| S02 | `skills/theater-roleplay/SKILL.md` | done | 剧场指南(含启动询问) | +| S03 | `src/skills/types.ts` | todo | ParsedSkill、SkillIndexEntry | +| S04 | `src/skills/loader.ts` | todo | 解析 SKILL.md | +| S05 | `src/skills/registry.ts` | todo | listSkills() | +| S06 | `src/skills/resolver.ts` | todo | 推断当前 stage | +| S07 | `src/main-agent/skill-context.ts` | todo | 注入总管 prompt | +| S08 | `src/runtime/phase-machine.ts` | todo | 增加 skill_selection / skill_selected | +| S09 | `src/cli/phase-demo.ts` | todo | 启动时 /select-skill | + +详见 `docs/skill-format.md` §5。 + +### Phase B — Tool Call 化 + +目标:总管 / worker 从 JSON 决策改为 tool call;Runtime 做 tool 校验与事件转换。 + +### Phase C — 真实 Worker + +目标:outline / drafting 等固定 worker 接真实 LLM;worker 可中途 ask_user。 + +### Phase D — 持久化 + +目标:book 存储;preset 导入。 + +### Phase E — 标签黑板迁移(当前文档已完成,代码待做) + +目标:代码与 `tag-blackboard.md` 对齐。 + +| # | 文件 | 状态 | 干嘛的 | +|---|---|---|---| +| E01 | `src/types/blackboard.ts` | done | `BlackboardItem`、按 tag 查询 | +| E02 | `src/blackboard/blackboard.ts` | done | listTagIndex、前缀匹配 | +| E03 | `src/skills/types.ts` + `loader.ts` | done | 解析 inputTags / outputTags(兼容 outputKeys) | +| E04 | `src/worker/executor.ts` | done | 按 tag 取 context,校验 outputTags | +| E05 | `src/types/runtime.ts` + `main-agent.ts` + `phase-runtime` | done | 决策去掉 keys;runtime 按 Worker Skill 执行 | +| E06 | `skills/novel/weird-rules-short/` | todo | tag 化 skill 样板 + inputTags frontmatter | +| E07 | `skills/novel/quick-write/` | todo | 简易全量 LLM skill | + +编排写在各包 `orchestrator.md`,不单独维护 execution-flow YAML。 + +--- + +## 4. Phase A 文件顺序 + +按序号逐个实现。状态列:`done` = 已有初版,`todo` = 未写或未按文档对齐。 + +### 4.1 项目配置 + +| # | 文件 | 状态 | 干嘛的 | +|---|---|---|---| +| A01 | `package.json` | done | 项目元数据、npm scripts(dev / test / demo) | +| A02 | `tsconfig.json` | done | TypeScript 编译选项 | +| A03 | `vitest.config.ts` | done | 测试入口配置 | +| A04 | `.env.example` | done | LLM API 环境变量模板,不含真实 key | +| A05 | `.gitignore` | done | 忽略 node_modules、dist、.env | + +**A01 职责:** 声明依赖(typescript、tsx、vitest)和三条命令。不含业务逻辑。 + +--- + +### 4.2 类型层(只放数据结构,不含逻辑) + +| # | 文件 | 状态 | 干嘛的 | 对应文档 | +|---|---|---|---|---| +| A06 | `src/types/runtime.ts` | done | 相位、事件、会话、产物、ResumeContext | `runtime-state-machine.md` §2–5 | +| A07 | `src/types/blackboard.ts` | done | BlackboardItem、TagIndex | `tag-blackboard.md` §2 | +| A08 | `src/types/tools.ts` | todo | 总管 tool、worker tool 的参数与结果类型 | `tool-contracts.md` | + +**A06 职责:** +- 定义 `RuntimePhase`(5 种) +- 定义 `WaitingReason`(waiting_user 的子原因) +- 定义 `RuntimeEvent`(唯一改变相位的输入) +- 定义 `RuntimeSession`(运行时快照) +- 定义 `PhaseEffect`(阶段机产生的副作用,如 invoke_main_agent) + +**A07 职责(Phase E 迁移后):** +- 定义 `BlackboardItem`(id、tag、content、source、metadata) +- 定义 `BlackboardTagIndex`(给总管:tag + source,无 content) +- 定义 `BlackboardWrite`(worker 写回) + +**A08 职责(Phase B 再写):** +- `MainAgentToolName`、`WorkerToolName` +- 每个 tool 的 params / result 类型 +- tool → event 映射表(类型级注释) + +--- + +### 4.3 阶段机(纯函数,不调用 LLM) + +| # | 文件 | 状态 | 干嘛的 | 对应文档 | +|---|---|---|---|---| +| A09 | `src/runtime/phase-machine.ts` | done | 5 相位阶段机:`applyEvent`、`canApplyEvent` | `runtime-state-machine.md` §6–8 | +| A10 | `src/runtime/state-machine.ts` | done | 废弃别名,re-export phase-machine | — | + +**A09 职责:** +- `createSession()` — 创建 idle 会话 +- `getAllowedEvents()` — 当前相位允许哪些事件 +- `canApplyEvent()` — 事件是否合法(含 waitingReason 校验) +- `applyEvent()` — 纯函数:session + event → 新 session + effects +- `createArtifact()` — 创建产物记录 + +**约束:** +- 不 import llm、blackboard、main-agent +- 不做 IO +- 所有相位转移必须写进 history + +**A10 职责:** 兼容旧 import 路径,后续可删。 + +--- + +### 4.4 黑板 + +| # | 文件 | 状态 | 干嘛的 | 对应文档 | +|---|---|---|---|---| +| A11 | `src/blackboard/blackboard.ts` | done | listTagIndex、queryByPatterns、write | `tag-blackboard.md` §2 | + +**A11 职责:** +- `listTagIndex()` — 给总管 +- `queryByPatterns()` — 给 worker 注入 +- `write()` — 按 tag 写回 +- `getContentByTag()` / `getLatestByTag()` + +--- + +### 4.5 配置与 LLM + +| # | 文件 | 状态 | 干嘛的 | 对应文档 | +|---|---|---|---|---| +| A12 | `src/config/env.ts` | done | 从环境变量读 baseUrl、apiKey、model | `architecture.md` §2 config | +| A13 | `src/llm/client.ts` | done | OpenAI 兼容 API + MockLlmProvider | — | + +**A12 职责:** +- `loadLlmConfig()` — 必须有 key,否则抛错 +- `loadLlmConfigOptional()` — 无 key 返回 null,CLI 切 mock + +**A13 职责:** +- `LlmProvider` 接口:`complete(messages) → string` +- `OpenAiCompatibleProvider` — 真实 API 调用 +- `MockLlmProvider` — 测试 / demo 用预设响应 +- apiKey 不出现在 session 或项目文件里 + +--- + +### 4.6 总管 LLM + +| # | 文件 | 状态 | 干嘛的 | 对应文档 | +|---|---|---|---|---| +| A14 | `src/main-agent/main-agent.ts` | done | 总管:只选 worker,读 tag 索引 | `tag-blackboard.md` §6 | +| A15 | `src/main-agent/prompts.ts` | todo | 总管 prompt 拆分 | `tag-blackboard.md` §6 | + +**A14 职责:** +- `MainAgent.decide(context)` — 调 LLM,返回 `MainAgentDecision` +- `parseMainAgentDecision(raw)` — 解析 JSON(Phase B 改为 parse tool calls) +- `buildMainAgentUserPrompt(context)` — 拼 user prompt +- `DEFAULT_WORKERS` — 第一版可用 worker 列表 + +**约束:** +- 总管不读黑板 value +- 总管不直接改 phase +- 不含 question-worker(提问是 worker 能力) + +**A15 职责(可选拆分):** 把 prompt 从 main-agent.ts 抽出来,方便迭代。 + +--- + +### 4.7 编排层 + +| # | 文件 | 状态 | 干嘛的 | 对应文档 | +|---|---|---|---|---| +| A16 | `src/runtime/orchestrator.ts` | deprecated | 旧入口;用 phase-runtime | `architecture.md` | + +**A16 职责:** +- `RuntimeOrchestrator` — 对外 API:start、submitUserInput、approve、accept 等 +- `dispatch(event)` — 调 `applyEvent`,处理 `PhaseEffect` +- `runMainAgent()` — phase=running 时调总管 +- `runStubWorker()` — 第一版占位 worker(Phase C 替换) +- `resumeStubWorker()` — worker 中途提问后恢复 + +**约束:** +- 唯一调用阶段机和总管的地方 +- user 事件(approve / accept)只从 CLI / API 进入,不从 LLM 进入 + +--- + +### 4.8 入口与测试 + +| # | 文件 | 状态 | 干嘛的 | +|---|---|---|---| +| A17 | `src/cli/run.ts` | done | 交互式 CLI:输入、/approve、/accept、/status | +| A18 | `tests/phase-machine.test.ts` | done | 阶段机单元测试 | +| A19 | `tests/orchestrator.test.ts` | done | 编排层 + Mock LLM 集成测试 | + +**A17 职责:** +- 读 stdin,转成 orchestrator 方法调用 +- 打印 `phase` + `waitingReason` +- `--mock` 或无 API key 时用 MockLlmProvider + +--- + +## 5. Phase B 文件顺序(下一步) + +| # | 文件 | 干嘛的 | +|---|---|---| +| B01 | `src/types/tools.ts` | tool 类型定义 | +| B02 | `src/runtime/tool-registry.ts` | 注册 tool、校验参数、tool → event | +| B03 | `src/main-agent/main-agent.ts` | 改为 tool calling 模式 | +| B04 | `src/runtime/tool-handlers/main-agent.ts` | 总管 tool 处理器 | +| B05 | `src/runtime/tool-handlers/worker.ts` | worker tool 处理器(ask_user / submit) | + +**B02 职责:** +- 白名单:当前 phase + waitingReason 下允许哪些 tool +- LLM 调了不允许的 tool → 拒绝,不转事件 + +--- + +## 6. Phase C 文件顺序 + +| # | 文件 | 干嘛的 | +|---|---|---| +| C01 | `src/workers/types.ts` | WorkerDefinition、WorkerRunOutcome | +| C02 | `src/workers/base-worker.ts` | prompt 拼接、tool 执行循环 | +| C03 | `src/workers/outline-worker.ts` | 大纲 worker | +| C04 | `src/workers/drafting-worker.ts` | 正文 worker | +| C05 | `src/runtime/worker-runner.ts` | 替代 orchestrator 里的 stub | + +**WorkerRunOutcome(Phase C 核心):** + +```ts +type WorkerRunOutcome = + | { kind: "complete"; writes: BlackboardWrite[]; summary: string } + | { kind: "suspend"; questions: string[]; partialWrites?: BlackboardWrite[] } + | { kind: "fail"; reason: string }; +``` + +- `complete` → `worker_completed` 事件 +- `suspend` → `worker_needs_input` 事件 +- `fail` → `runtime_failed` 事件 + +--- + +## 7. Phase D 文件顺序 + +| # | 文件 | 干嘛的 | +|---|---|---| +| D01 | `src/flows/types.ts` | ExecutionFlow、ExecutionStep | +| D02 | `src/flows/ghostwriting-flow.ts` | 代笔 flow 配置 | +| D03 | `src/book-storage/store.ts` | Book 持久化 | +| D04 | `src/preset/importer.ts` | SillyTavern 预设导入 | + +--- + +## 8. 单文件完成检查清单 + +每写完一个文件,确认: + +```text +[ ] 文件顶部的职责是否只对应文档里的一节 +[ ] 是否 import 了不该 import 的上层模块(阶段机不应 import LLM) +[ ] 是否有对应测试(内核文件必须有) +[ ] 是否更新了本表的状态列(done / todo) +[ ] 是否在 PR / 提交说明里写「这个文件干嘛的」一句话 +``` + +--- + +## 9. 当前进度 + +```text +Phase A0(可运行阶段机) ✅ 完成 + phase-machine + phase-runtime + phase-demo + 测试 + +Phase A1(总管 LLM) ✅ 有初版(orchestrator + run.ts) + 下一步:orchestrator 应基于 PhaseRuntime 重构 + +Phase B(tool call) 未开始 +Phase C(真实 worker) 未开始 +Phase D(flow / 存储) 未开始 +``` + +**下一个应写文件:** 让 `orchestrator.ts` 内部组合 `PhaseRuntime`,而不是重复阶段机逻辑。写之前会先说明该文件职责。 diff --git a/docs/orchestrator-skill-format.md b/docs/orchestrator-skill-format.md new file mode 100644 index 0000000..7f30a54 --- /dev/null +++ b/docs/orchestrator-skill-format.md @@ -0,0 +1,184 @@ +# 总管 Skill 格式(Orchestrator / Manifest) + +## 1. 定位 + +**orchestrator.md** = 本包的 **manifest**:注册有哪些 skill、如何验收、如何声明 instance ready。 +**不**写逐步流水线剧本;**不**写「总管思维链」逐步调度。 + +```text +用户选包 + → design:agent 按需 invoke instantiate skill + → declare ready → play:agent invoke run skill + → done:归档 Book +``` + +| 谁决定 | 什么 | +|--------|------| +| **Agent** | 何时 invoke 哪个 skill(tool loop) | +| **Manifest** | 可用 skill 列表、验收策略、readiness 规则 | +| **Worker SKILL.md** | inputTags、contextSegments、怎么做 | + +`shared-context.md`:包级 **固定上下文上半**,注入各 worker prompt。总管不读。 + +见 `architecture.md`、`skill-design-guide.md`、`context-assembly.md`。 + +--- + +## 2. 存储位置 + +```text +skills/novel/weird-rules-short/ +├── orchestrator.md +├── shared-context.md # 可选 +└── workers/ + └── write-rules/SKILL.md +``` + +- 文件固定名 **`orchestrator.md`**。 +- `registry.yaml` 的 `path` 指向它。 + +--- + +## 3. Frontmatter + +```yaml +--- +name: weird-rules-short +description: >- + 何时选用:用户要写规则怪谈、守则类短篇。 +category: novel +bookKind: novel +workers: + - write-rules + - review-infer + - review-author +sharedContext: shared-context.md +--- +``` + +| 字段 | 用途 | +|------|------| +| `description` | 何时选本包 | +| `workers` | run / design 可调度 skill id 白名单(loader 用) | +| `bookKind` | Book 存储形态 | + +--- + +## 4. 正文章节(推荐) + +```markdown +# 标题 + +## 启动询问 # 最小 intake +## Skill 注册表 # instantiate + run skill 清单与说明(见 §5) +## 验收策略 # 哪些 skill 产出需 user_confirmed / approve +## Instance Ready # 按 run_skill清单 的动态最低可行性 +## contextProfile # 可选 variant 说明(见 context-assembly.md) +## 用户回合 # user-turn 等(可选) +## 禁用行为 +``` + +**不应出现:** + +- 逐步 **Worker 编排表** / **总管思维链**(已废弃为主流程) +- `inputTags` / `outputTags` 列表 → 在 `workers/*/SKILL.md` +- 写作细则 → Worker SKILL 正文 + +--- + +## 5. Skill 注册表(替代编排表) + +列出本包 **能力库**,供 agent `list_workers` 与 manifest 校验: + +```markdown +## Skill 注册表 + +### Instantiate(design stage) + +| id | 说明 | 典型 outputTags | +|----|------|-----------------| +| interaction-paradigm | 定交互范式与 run_skill清单 | 设计.run_skill清单 | +| world-blueprint | 世界背景 | 设计.世界.蓝图 | +| persona-draft | 角色卡草稿 | 角色卡.草稿 | + +### Run(play stage) + +| id | 说明 | 默认 acceptance | +|----|------|-----------------| +| write-rules | 写规则 | user_confirmed | +| review-infer | 读者视角验收 | programmatic_review | +``` + +agent **按需 invoke**,不必按表顺序跑全。 + +--- + +## 6. 启动询问与 Instance Ready + +**启动询问:** 只收集 agent 无法从空推断的最小信息 → `用户.需求` 等。 + +**Instance Ready:** 不写死「14 步全完成」,而写: + +```markdown +## Instance Ready + +当 `设计.run_skill清单` 已 accepted,且清单中每个 run skill 的 +**最低 input 要求**(见各 SKILL.md)已在黑板存在或已记录跳过理由。 +由 agent `declare_instance_ready` + Runtime 校验。 +``` + +--- + +## 7. 验收策略 + +```markdown +## 验收策略 + +| skill | requiresApproval | acceptanceMode | +|-------|------------------|----------------| +| setup-scenario | true | user_confirmed | +| world-engine | false | no_confirmation | +| present-round | false | user_confirmed | +``` + +agent 通过 `run_worker(..., requiresApproval)` 触发;默认值也可写在 manifest 供 Runtime 填充。 + +--- + +## 8. contextProfile + +若同一 skill 有多种游玩/创作模式,在 manifest 列出 variant 名与含义;实例化写入 Book。 +格式见 `context-assembly.md`。 + +--- + +## 9. 与 Worker SKILL 的关系 + +```text +orchestrator.md 注册 + 验收 + readiness +workers/*/SKILL.md 能力 + 上下文契约(contextSegments) +``` + +Prompt 注入: + +```text +Agent:session 摘要 + tool;可用 read_blackboard +Worker:shared-context + SKILL + assembleWorkerContext(上固定下动态) +``` + +--- + +## 10. 迁移说明 + +旧包中的 `## Worker 编排`、`## 总管思维链` 仍可作为 **人工参考**,但新包与 world-simulator **不应**以此为主流程。 +Runtime 目标态以 agent tool loop + manifest 注册表为准。 + +--- + +## 11. 相关文档 + +| 文档 | 关系 | +|------|------| +| `skill-design-guide.md` | 设计方法 | +| `worker-skill-format.md` | SKILL.md 字段 | +| `creation-playbook.md` | 概念 | diff --git a/docs/preset-format.md b/docs/preset-format.md new file mode 100644 index 0000000..a94b899 --- /dev/null +++ b/docs/preset-format.md @@ -0,0 +1,297 @@ +# 预设格式 + +## 1. 定位 + +Preset 是一次 LLM 请求的上下文编排资产。它负责描述 prompt manager 中有哪些条目、这些条目的启用状态与排列顺序,以及本次生成使用哪些模型参数。 + +第一版的 preset 目标是兼容 SillyTavern 类预设文件中的核心部分,而不是完整复刻 SillyTavern 的所有扩展能力。 + +需要支持的内容: + +```text +prompts + prompt manager 条目列表。每个条目描述一段可插入上下文的 prompt。 + +prompt_order + 条目顺序与启用关系。它决定本次请求实际按什么顺序装配 prompt。 + +generation parameters + 生成参数,例如 temperature、top_p、top_k、min_p、frequency_penalty、presence_penalty、max tokens 等。 +``` + +第一版不支持的内容: + +```text +regex_scripts + 正则隐藏、正文提取、格式美化等后处理脚本。 + +extension scripts + 预设内携带的前端脚本、按钮、插件配置。 + +UI-only fields + 只影响 SillyTavern 界面展示、不影响 LLM 请求组装的字段。 +``` + +## 2. 从 SillyTavern 预设中读取什么 + +一个真实的 SillyTavern 预设通常同时包含 prompt manager 条目、顺序关系、生成参数和扩展配置。我们的导入器只读取前三类。 + +### 2.1 Prompt 条目 + +从 `prompts` 数组读取 prompt manager 条目。 + +典型字段: + +```ts +type ImportedPromptEntry = { + identifier: string; + name: string; + enabled: boolean; + role: "system" | "user" | "assistant"; + content?: string; + injection_position?: number; + injection_depth?: number; + injection_order?: number; + system_prompt?: boolean; + marker?: boolean; + forbid_overrides?: boolean; +}; +``` + +字段含义: + +```text +identifier + 条目唯一标识。prompt_order 通过它引用条目。 + +name + 给用户看的条目名称。 + +enabled + 条目自身默认启用状态。最终是否启用还需要结合 prompt_order。 + +role + 条目插入请求时使用的消息角色。 + +content + 条目文本。部分内置 marker 条目可能没有 content,需要运行时从 session 或角色数据中补齐。 + +injection_position / injection_depth / injection_order + SillyTavern 的插入位置、深度和顺序信息。第一版先保留,不完全模拟深度插入语义。 + +system_prompt / marker + 标识这个条目是不是内置占位条目,例如角色描述、世界书、聊天历史等。 + +forbid_overrides + 标识条目是否禁止被覆盖。第一版先保留字段,不实现复杂覆盖策略。 +``` + +### 2.2 顺序与启用关系 + +从 `prompt_order` 读取实际装配顺序。 + +典型字段: + +```ts +type ImportedPromptOrder = Array<{ + character_id: number; + order: Array<{ + identifier: string; + enabled: boolean; + }>; +}>; +``` + +第一版采用的规则: + +```text +1. 以 prompt_order[0].order 作为主顺序。 +2. 按 order 数组顺序遍历 identifier。 +3. 找到对应 prompts 条目。 +4. 只有 order.enabled 和 prompt.enabled 都为 true 时,条目才参与本轮上下文装配。 +5. 如果 prompt_order 引用了不存在的 identifier,导入时记录 warning,但不阻断导入。 +6. 如果 prompts 中存在但 prompt_order 未引用,默认不参与请求,但保留在 preset 中。 +``` + +`prompt_order` 比 `prompts` 中的排列更重要。`prompts` 是条目仓库,`prompt_order` 才是实际启用的编排表。 + +### 2.3 生成参数 + +从预设顶层读取生成参数。 + +第一版优先支持这些字段: + +```ts +type ImportedGenerationParameters = { + temperature?: number; + top_p?: number; + top_k?: number; + min_p?: number; + frequency_penalty?: number; + presence_penalty?: number; + repetition_penalty?: number; + openai_max_context?: number; + openai_max_tokens?: number; + stream_openai?: boolean; + reasoning_effort?: string; + verbosity?: string; + seed?: number; + n?: number; +}; +``` + +字段分为两类: + +```text +通用采样参数 + temperature、top_p、top_k、min_p、frequency_penalty、presence_penalty、repetition_penalty。 + +请求容量与行为参数 + openai_max_context、openai_max_tokens、stream_openai、reasoning_effort、verbosity、seed、n。 +``` + +不同供应商不一定支持全部字段。导入后应先保存原始字段,再由 LLM adapter 决定哪些字段可以发送。 + +## 3. 内部归一化格式 + +导入 SillyTavern preset 后,不应该直接在业务层使用原始 JSON。需要归一化成我们自己的结构。 + +```ts +type PresetPackage = { + id: string; + name: string; + source: "native" | "sillytavern"; + prompts: PresetPromptEntry[]; + promptOrder: PresetPromptOrderItem[]; + generation: GenerationParameters; + unsupported: UnsupportedPresetSection[]; + raw?: unknown; +}; + +type PresetPromptEntry = { + id: string; + name: string; + enabled: boolean; + role: "system" | "user" | "assistant"; + content: string; + marker: boolean; + sourceIdentifier: string; + injection?: { + position?: number; + depth?: number; + order?: number; + }; +}; + +type PresetPromptOrderItem = { + promptId: string; + enabled: boolean; + orderIndex: number; +}; + +type GenerationParameters = { + temperature?: number; + topP?: number; + topK?: number; + minP?: number; + frequencyPenalty?: number; + presencePenalty?: number; + repetitionPenalty?: number; + maxContextTokens?: number; + maxOutputTokens?: number; + stream?: boolean; + reasoningEffort?: string; + verbosity?: string; + seed?: number; + variants?: number; +}; + +type UnsupportedPresetSection = { + path: string; + reason: string; +}; +``` + +归一化后的 `PresetPackage` 是系统内部唯一使用的 preset 结构。原始 SillyTavern JSON 只作为导入来源和调试证据保留。 + +## 4. 上下文装配规则 + +一次请求的上下文装配按以下顺序执行: + +```text +1. 读取 PresetPackage.promptOrder。 +2. 过滤 enabled=false 的顺序项。 +3. 找到对应 PresetPromptEntry。 +4. 再过滤 entry.enabled=false 的条目。 +5. 对 marker 条目进行运行时替换。 +6. 对普通 content 条目进行变量替换。 +7. 按 role 合并成 LLM messages。 +8. 应用 generation 参数。 +``` + +其中 marker 条目用于挂载运行时上下文: + +```text +charDescription + 可以映射为当前创作项目的设定说明。 + +charPersonality + 可以映射为文风、叙事人称、角色行为约束。 + +worldInfoBefore / worldInfoAfter + 可以映射为长期设定、世界观、知识库片段。 + +scenario + 可以映射为当前创作目标或当前阶段说明。 + +chatHistory + 可以映射为历史会话、已有正文、用户反馈摘要。 +``` + +这些映射不是 SillyTavern 原义的完整复刻,而是为了兼容预设资产,让它们能服务我们的写作 agent。 + +## 5. 与创作流程的关系 + +Preset 不决定创作流程,也不决定验收模式。 + +```text +Preset + 决定本轮请求如何拼接 prompt、哪些条目启用、使用哪些生成参数。 + +Execution Flow + 决定阶段顺序、每阶段使用哪个 worker、每阶段采用哪种验收模式。 + +Worker + 决定某个创作能力如何执行,例如大纲、剧情线、文风、正文草稿。 + +Runtime Session + 记录当前事实、历史事件、产物和审批结果。 +``` + +因此,同一个 preset 可以用于多个创作流程;同一个创作流程也可以切换不同 preset。二者是正交关系。 + +## 6. 第一版导入策略 + +第一版导入器只做保守转换: + +```text +保留 prompts。 +保留 prompt_order。 +保留生成参数。 +记录但忽略 regex_scripts。 +记录但忽略 extension scripts。 +记录未知字段,不丢弃原始 JSON。 +``` + +导入结果应该给用户可读的报告: + +```text +导入 prompt 条目数量 +启用条目数量 +未被 prompt_order 引用的条目数量 +缺失 identifier 的 order 项 +已读取的生成参数 +被忽略的扩展字段 +``` + +这个报告比静默导入更重要。预设文件经常很大,且混有脚本、正则、UI 配置和模型参数,必须让用户知道哪些内容真正进入了我们的运行时。 \ No newline at end of file diff --git a/docs/run-snapshot.md b/docs/run-snapshot.md new file mode 100644 index 0000000..a502718 --- /dev/null +++ b/docs/run-snapshot.md @@ -0,0 +1,53 @@ +# Book 快照 + +用户手动保存、多档位;与自动续作 `session.json` 分离。 + +## 两种 kind(更新语义) + +| kind | 名称 | 何时存 | 存什么 | +|------|------|--------|--------| +| **`instance`** | 设计完成 / 资产截面 | design 完成或从 play 剥离设定 | **CardAsset 级** tag(角色卡.确认稿、世界蓝图…);**不含**轮次、事件流 | +| **`run`** | 游玩存档 | play 任意时刻 | instance 层 + `运行.*`、轮次、变量、对话 | + +```text +CardBook design 完成 → 可存 instance(导出角色卡 / 世界设定) +PlayBook 进行中 → 可存 run(第 N 轮续玩) +``` + +**instance** 不再表示「14 步 pipeline 填完」,而是 **agent declare ready 时的可复用资产截面**。详见 `book-storage.md`。 + +## 与三种存储需求 + +| 需求 | 用什么 | +|------|--------| +| 创建角色卡**过程** | CardBook `designTrace` + messages(快照不替代,须 Book 级) | +| 创建好的**卡** | CardAsset 或 `instance` 快照 | +| 游玩**过程** | PlayBook `playTrace` + `run` 快照 | + +## 与自动续作 + +| | `session.json` | `run-snapshots/` | +|--|----------------|------------------| +| 触发 | 自动 | 用户手动 | +| 数量 | 每 Book 1 份 | 多档、可命名 | +| 打开作品 | 默认恢复 | 选档 **加载** | + +## API + +```text +GET /api/books/:bookId/saves +POST /api/books/:bookId/saves { label, kind: "instance"|"run", note?, sessionId? } +POST /api/books/:bookId/saves/:id/load +DELETE /api/books/:bookId/saves/:id +``` + +## 实现 + +```text +src/types/run-snapshot.ts +src/book/run-snapshot-store.ts +src/book/snapshot-filters.ts instance 时剥离 运行.* 等 +src/server/session-manager.ts +``` + +存储:`books/{bookId}/run-snapshots/{id}.json` diff --git a/docs/runtime-state-machine.md b/docs/runtime-state-machine.md new file mode 100644 index 0000000..9bd6acc --- /dev/null +++ b/docs/runtime-state-machine.md @@ -0,0 +1,136 @@ +# 运行阶段机 + +## 1. 定位 + +阶段机 = **并发与权限模型**:谁在场、哪些 tool 可用、何时必须等用户、产物何时算事实。 + +**不是** 流水线执行器;「下一步 invoke 哪个 skill」由 agent tool loop 决定。 + +```text +业务 stage(Book / Session) + design(实例化)→ play(运行)→ done + +运行相位(RuntimePhase) + idle | running | waiting_user | done | error +``` + +--- + +## 2. 五个运行相位 + +```ts +type RuntimePhase = "idle" | "running" | "waiting_user" | "done" | "error"; +``` + +| phase | 含义 | +|-------|------| +| `idle` | 会话已创建 | +| `running` | agent burst 或 worker 执行中 | +| `waiting_user` | 等用户(见 waitingReason) | +| `done` | 正常结束 | +| `error` | 不可恢复 | + +--- + +## 3. waitingReason + +```ts +type WaitingReason = + | { kind: "skill_selection"; availableSkills: SkillIndexEntry[] } + | { kind: "intake"; prompt: string } + | { kind: "input"; message?: string } + | { kind: "approve_step"; decisionId: string } + | { kind: "review_artifact"; artifactId: string } + | { kind: "worker_questions"; workerId: string; questions: string[] } + | { kind: "revision"; instruction?: string }; +``` + +--- + +## 4. 阶段机 vs tool + +| 角色 | 说明 | +|------|------| +| **循环 tool** | 仅当 `running` 且无阻塞 worker;结果进 agent messages | +| **边界 tool** | 触发 phase / waitingReason 变化 | +| **用户 event** | 唯一 approve、accept;burst 计数归零 | +| **Worker** | `running` 且 `currentWorkerId` set 时,总管暂停 | + +目标态工具表见 `tool-contracts.md`。 + +--- + +## 5. Tool loop burst + +```text +用户硬事件 + → toolLoopBurstCount = 0 + → phase = running(若适用) + → agent while burst < maxBurst: + 循环 tool … + 边界 tool → 可能 waiting_user / 启动 worker + → worker 完成 → 按 acceptanceMode 可能 waiting_user +``` + +`maxBurst`:**两次用户操作之间**的上限(默认 12)。 + +--- + +## 6. 典型转移(简化) + +```text +idle → skill_selection +skill_selected → intake 或 input +intake 完成 / confirm → running → agent burst +running → run_worker → approve_step 或 worker 执行 +worker_completed → review_artifact(user_confirmed) +user_accepted → running → agent burst +finish → done +``` + +--- + +## 7. 会话结构 + +```ts +type RuntimeSession = { + id: string; + phase: RuntimePhase; + waitingReason?: WaitingReason; + currentWorkerId?: string; + acceptanceMode?: AcceptanceMode; + resumeContext?: ResumeContext; + slots: Record; + artifacts: ArtifactRecord[]; + pendingDecision?: MainAgentDecision; + history: RuntimeEvent[]; + // 目标:toolLoopBurstCount, lifecycleStage +}; +``` + +--- + +## 8. 产物生命周期 + +```text +drafted → under_review → accepted | rejected | revision_requested +accepted 前不得当下游事实 +``` + +--- + +## 9. 与编排表的关系 + +**已废弃为主流程:** orchestrator「思维链 / 逐步编排表」驱动步骤。 +orchestrator 改为 **skill 注册表 + 验收策略**;阶段机不解析「第几步」。 + +`semi_auto` / `pauseCheckpoint` 仍可作为可选策略,由 manifest 声明,第一版未实现。 + +--- + +## 10. 代码 + +`src/runtime/phase-machine.ts` — 纯函数 `applyEvent` +`src/runtime/phase-runtime.ts` — Session IO、tool loop 入口 + +旧 11 态已合并为 5 phase + waitingReason。 diff --git a/docs/skill-design-guide.md b/docs/skill-design-guide.md new file mode 100644 index 0000000..1d8b129 --- /dev/null +++ b/docs/skill-design-guide.md @@ -0,0 +1,184 @@ +# Skill 设计指南 + +指导 **如何从零设计一个 orchestrator 包**(skill 能力库 + run skill 组合)。 +格式见 `skill-format.md`、`orchestrator-skill-format.md`、`worker-skill-format.md`;上下文见 `context-assembly.md`。 + +**设计顺序:** + +```text +1. 用户意图 → run skill 清单(交互范式 skill 产出) +2. 每个 run skill 倒推需要哪些 instantiate skill / tag +3. 定义各 skill 的 inputTags、outputTags、contextSegments +4. 上下文隔离与验收边界 +5. orchestrator manifest(能力注册,非逐步剧本) +``` + +--- + +## 1. 实例化:agent 选 skill,不是填步骤 + +### 1.1 两层倒推 + +```text +用户意图(长篇 / 短篇 / 思想实验 / 角色卡扮演 …) + → 交互范式 skill:产出 设计.run_skill清单 + → 每个 run skill 需要什么输入? + → agent 按需 invoke instantiate skill(能力库中的 SKILL.md) + → declare_instance_ready → play stage +``` + +**能力库**(如 world-simulator 包内十几个 instantiate skill)不是 1→N 管道;agent 跳过不需要的 skill,并记录 `设计.跳过.{skillId}`。 + +### 1.2 示例:不同意图的 run skill 组合 + +| 用户意图 | run skill 示例 | 实例化倒推 | +|----------|----------------|------------| +| 长篇小说 | 变量管理、大纲推荐、转述者、世界机 | 变量目录 skill、叙事指南、世界蓝图… | +| 短篇 | 情感流生成器 | 美学纲领、情感曲线约定 | +| 思想实验 | 世界模拟器 | 规则、行动格式;**无**转述者 | +| 角色卡扮演 | 转述者(+ 可选世界机) | 角色卡确认稿、口吻、回复格式 | + +### 1.3 run 阶段交互形态 + +agent 在 play stage 的 burst 内调度 run skill,形态因包而异: + +| 形态 | 适用 | sketch | +|------|------|--------| +| 单 skill 直出 | 简单大纲 | `outline` | +| 行动–反应循环 | 博弈、世界模拟 | 世界机 ↔ 角色决策 ↔ 展示 | +| 分叉验收 | 规则怪谈 | `write` → 双 `review` | + +形态写在各 skill 的 **能力说明**里,**不**写进全局编排表逐步剧本。 + +--- + +## 2. 暂停与用户 + +### 2.1 边界 tool 即暂停 + +agent tool loop 在下列情况 **退出 burst**,进入 `waiting_user`: + +```text +ask_user / review_blackboard +run_worker + requiresApproval +worker 完成 + user_confirmed → review_artifact +worker ask_user → worker_questions +``` + +暂停策略写在 orchestrator manifest 的 **验收策略**,不是「第几步必须停」的管道表。 + +### 2.2 user-turn + +用户亲自决策的环节:独立 skill,`用户.最新输入` 写入与 LLM 角色同形 tag,供世界机裁决。见 `worker-skill-format.md`。 + +--- + +## 3. 上下文:上半固定、下半动态 + +每个 skill(`SKILL.md`)声明: + +```text +inputTags / outputTags 黑板接口 +contextSegments static(上)+ dynamic(下) +contextIsolation 谁看不见什么 +contextProfile variants 实例化时选档位 +``` + +原则:**稳定在上、增量在下**;Runtime 拼接,agent 不改。 +全文见 `docs/context-assembly.md`。 + +### 3.1 分层示例(roleplay-game-theory) + +| tier | 含义 | tag 示例 | +|------|------|----------| +| static | 前提、实体设定 | `情境.实验.设定`、`角色.{id}.设定` | +| dynamic | 历史、本轮 | `运行.事件流`、`可见信息` | + +--- + +## 4. 倒推标签 + +对每个 skill 填表: + +```text +skill id | 职责 | stage(design/play) | inputTags | outputTags | contextSegments | 验收者 +``` + +### 4.1 角色扮演博弈(摘录) + +| skill | 读取 | 写入 | 验收 | +|-------|------|------|------| +| setup-scenario | `用户.博弈需求` | 情境、规则、角色设定 | 用户 | +| world-engine | 情境、规则、事件流、行动 | 可见信息、事件流 | 程序 | +| role-decide | 本人设定、事件流、可见信息 | `.思考`、`.行动` | 程序 | +| present-round | 本轮产物 | `输出.用户展示` | 用户 | + +展示类 skill 单独存在;agent 调度,不拼长文。 + +--- + +## 5. 上下文隔离(必查) + +```text +□ 哪些 tag 只给用户、不注入生产 skill? +□ 盲读 review 不可见哪些 tag? +□ 草稿 vs 确认稿:下游何时可当作事实? +□ 多角色:role_pov 是否生效? +``` + +靠 `inputTags` + `contextIsolation` + Runtime 过滤,不靠 prompt 口头禁止。 + +--- + +## 6. orchestrator manifest(非编排表) + +orchestrator.md 应包含: + +```text +□ 启动询问(最小 intake) +□ instantiate skill 注册表(id、stage、description) +□ run skill 注册表 +□ 验收策略(哪些 skill 产出需 user_confirmed) +□ contextProfile 可选 variant 说明 +□ declare_instance_ready 最低可行性(按 run_skill清单 动态校验) +``` + +**不应包含:** 逐步思维链、「第 5 步必须跑 world-engine」类管道脚本。 + +--- + +## 7. checklist + +```text +□ 1. 一句话:本包 play stage 的交互形态 +□ 2. 交互范式 skill 产出 run_skill清单 的 schema +□ 3. 每个 run skill 倒推 instantiate skill 需求 +□ 4. 各 SKILL.md:input/output、contextSegments、隔离 +□ 5. shared-context.md(static 上半) +□ 6. manifest:注册表 + 验收 + readiness +□ 7. skills/README.md 注册 +``` + +--- + +## 8. 示例包 + +| 包 | 特点 | +|----|------| +| `basic` | 单 run skill,最小上下文 | +| `weird-rules-short` | 分叉验收 | +| `roleplay-game-theory` | 行动–反应循环 | +| `world-simulator`(规划) | 大能力库 + agent 实例化 | + +对照时复用 **方法**,不照搬 tag 名或 skill 数量。 + +--- + +## 9. 相关文档 + +| 文档 | 关系 | +|------|------| +| `context-assembly.md` | 拼接规格 | +| `creation-playbook.md` | 概念 | +| `orchestrator-skill-format.md` | manifest 写法 | +| `book-storage.md` | 过程与资产归档 | diff --git a/docs/skill-format.md b/docs/skill-format.md new file mode 100644 index 0000000..72da4f2 --- /dev/null +++ b/docs/skill-format.md @@ -0,0 +1,510 @@ +# Skill 格式与存储 + +## 1. 定位:两层 Skill + +本项目有 **两种 Skill 文档**,不要混在一个文件里: + +| 类型 | 路径 | 消费者 | 写什么 | +|------|------|--------|--------| +| **总管 Skill** | `skills/{bookKind}/{name}/orchestrator.md` | Main Agent | **何时**调哪个 worker、验收方式、启动询问 | +| **Worker Skill** | `workers/{workerId}/SKILL.md` | Worker Agent | **inputTags/outputTags**、怎么做 | + +```text +选 weird-rules-short(总管 Skill) + → 总管:brief 齐了 → run ruleset-worker,input=[project.brief] + → Worker:读 workers/ruleset-worker/SKILL.md → 写 core / rules / commentary + → 总管:rules accepted → run review-worker + → Worker:读 workers/review-worker/SKILL.md → 评估怎么做 +``` + +**何时评估** = 总管 Skill 的 Worker 编排表。 +**如何评估** = review-worker 的 Worker Skill。 + +详细规范: + +- 总管 Skill → `docs/orchestrator-skill-format.md` +- Worker Skill → `docs/worker-skill-format.md` + +旧称「Skill = 创作说明书」仍成立,但说明书 **拆成编排(总管)与执行(worker)两份**。 + +```text +会话开始 + → 询问 1:选哪个总管 Skill(skills/{bookKind}/{name}/orchestrator.md) + → 加载总管 Skill + → 询问 2:读总管 Skill「## 启动询问」 + → 之后总管按「Worker 编排」调度;Worker 读本 skill 包内 workers/{id}/SKILL.md +``` + +--- + +## 2. 存储位置 + +Skill 以 **Skill 包(skill pack)** 为单位:一个总管 + 其专属 workers,**同包绑定,不跨包复用 worker**。 + +```text +skills/ +├── registry.yaml +├── novel/ # Book 形态 +│ ├── basic/ +│ │ └── orchestrator.md # 总管 Skill +│ ├── weird-rules-short/ +│ │ ├── orchestrator.md # 总管:何时调谁、黑板 key +│ │ └── workers/ # 本总管专属,不与其他 skill 共享 +│ │ ├── write-rules/ +│ │ │ └── SKILL.md # 规则怪谈:怎么写规则+解析 +│ │ └── review/ +│ │ └── SKILL.md # 规则怪谈:怎么检查 +│ └── novel-standard/ +│ ├── orchestrator.md +│ └── workers/ +│ ├── outline/SKILL.md +│ └── drafting/SKILL.md +└── dialogue/ + └── theater-roleplay/ + ├── orchestrator.md + └── workers/ + └── turn/SKILL.md +``` + +规则: + +```text +第一层文件夹 = bookKind(novel | dialogue),决定 Book 存储结构 +第二层文件夹 = 一个 skill 包,名与 frontmatter.name 一致 + orchestrator.md 总管 Skill(编排、启动询问、验收) + workers/{id}/ 本包专属 worker;id 在包内唯一即可 +Worker 不复用:novel-standard 的 outline worker ≠ weird-rules-short 的任何 worker +registry.yaml 的 path 指向 orchestrator.md,如 novel/weird-rules-short/orchestrator.md +``` + +**为何不复用 worker:** 同一「产出形状」(如规则表)在不同总管下的写法、Rubric、自检完全不同;共享 worker 会把体裁细节塞进总管或搞混上下文。需要相似流程时 **复制 worker 包再改**,而不是引用全局 worker。 + +与 Cursor skill 的区别: + +| | Cursor Skill | 本项目 Skill | +|---|---|---| +| 位置 | `.cursor/skills/` | `skills/` | +| 触发 | Agent 自动或显式引用 | **会话开始必须选一个** | +| 内容 | 通用任务指南 | **创作流程 + 思维链 + 询问策略** | +| 消费者 | Cursor Agent | 总管 LLM | + +--- + +## 3. SKILL.md 结构 + +### 3.0 两层分类(重要) + +Skill 分类分**两层**,不要混为一层: + +```text +第一层:Book 形态(category / bookKind,选定后不可换) + novel 类小说存储:卷、章、大纲、正文(含各类小说子类型) + dialogue 多轮多角色对话:回合、角色、场景(roleplay / 剧场) + +第二层:具体 Skill 包(name,用户启动时选) + 每个包 = orchestrator.md + workers/,不是 category 下的平铺 .md 枚举。 +``` + +示例: + +```text +category: novel ← 第一层:Book 怎么存 +name: novel-standard ← 第二层:标准长篇流程 +name: weird-rules-short ← 第二层:规则怪谈短篇(仍是 novel Book) +name: basic ← 第二层:最小演示 + +category: dialogue +name: theater-roleplay ← 第二层:剧场式多角色互动 +``` + +**不要把「规则怪谈」做成与 novel 平级的 category。** +规则怪谈是 **novel 形态下的专精 skill**,用 `name` + `tags` 区分: + +```yaml +--- +name: weird-rules-short +description: 短篇规则怪谈:从隐含核心反推护命规则,再成章撰写。 +category: novel +bookKind: novel +tags: [novel, weird_rules, short, horror] +--- +``` + +| 层级 | 字段 | 谁选 | 例子 | +|---|---|---|---| +| Book 形态 | `category` / `bookKind` | 选 skill 时确定,之后不变 | `novel` / `dialogue` | +| 具体流程 | `name` | 启动询问 1:选哪个 SKILL.md | `weird-rules-short` | +| 体裁标签 | `tags` | 可选,供匹配与过滤 | `weird_rules`, `standard` | + +启动 UI 可以按 `category` 分组展示,组内列出多个 `name`(如「小说」下:标准长篇、规则怪谈短篇、…)。 + +参考 Cursor `SKILL.md`:YAML frontmatter + Markdown 正文。 + +```markdown +--- +name: novel-standard +description: >- + 标准长篇小说创作:先简报、再大网、事件细化、正文。 + 适用于用户要写小说、章节、大纲时使用。 +category: novel +version: 1 +defaultFlowId: ghostwriting-flow +tags: [novel, outline, draft] +--- + +# 标准小说创作 + +## 启动询问 + +(流程 2:选 skill 后向用户展示什么、必收集项、写入 book.brief) + +## 创作总纲 + +(给总管:这类内容是什么、总体顺序、禁忌) + +## 总管思维链 + +(每轮决策前先检查什么、如何选 worker) + +## 推荐阶段 + +(brief → outline → plotline → style → draft) + +## 询问策略 + +### 总管应先问 +### 交给 Worker 问 + +## 推荐 Worker + +## 示例 +``` + +### 3.1 Frontmatter 字段 + +```yaml +--- +name: novel-standard # 必需,唯一 id,[a-z0-9-] +description: > # 必需,供启动时向用户展示、供总管匹配 + 第三人称描述 WHAT + WHEN。 +category: novel # 必需:Book 形态,见 §3.0 +bookKind: novel # 建议与 category 对齐;选定后 Book 结构固定 +version: 1 +defaultFlowId: ghostwriting-flow # 可选,绑定 execution flow +defaultPresetId: writing-default # 可选 +tags: [novel, standard] # 体裁/子类型标签,如 weird_rules、short +suggestedWorkers: # 可选,本 skill 常用 worker + - outline-worker + - plotline-worker + - drafting-worker +--- +``` + +| 字段 | 必需 | 用途 | +|---|---|---| +| `name` | ✅ | skill id;通常与文件名一致(不含 .md) | +| `description` | ✅ | 启动选择列表展示;总管判断用户描述是否匹配 | +| `category` | ✅ | 与 `bookKind` 一致:`novel` \| `dialogue` | +| `bookKind` | 建议 | 选定后 Book 结构固定;缺省时由所在文件夹推断 | +| `tags` | | 体裁细分:`weird_rules`、`standard` 等 | +| `path` | registry | 相对路径,如 `novel/weird-rules-short.md` | +| `defaultFlowId` | | 选中后默认 execution flow | +| `suggestedWorkers` | | 总管选 worker 时的白名单提示 | + +### 3.2 撰写标准:写作生命周期(推荐) + +Skill 文件本质上是**给 LLM 与 Runtime 读的字符串规格**。下面这套「触发 → 写前 → 写中 → 写后 → 质量维度」与现有阶段机、验收模式对齐,**应作为所有 skill `.md` 的撰写标准**。 + +```text +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 触发条件 │ → │ 写作前 │ → │ 写作中 │ → │ 写作后 │ → │ 质量维度 │ +│ 何时激活 │ │ 需求分析 │ │ 分步引导 │ │ 自检润色 │ │ 可量化 Rubric│ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ + frontmatter 启动询问 推荐阶段 自检清单 质量评估标准 + + tags + 创作总纲 + 示例/约束 + 验收策略 + 接受度(预留) + + 询问策略 + 禁用行为 +``` + +#### 与正文章节的对应关系 + +| 生命周期 | 对应章节 | 写什么 | +|---|---|---| +| **触发条件** | frontmatter `description` + `tags` | 何时应选本 skill;用户说什么话时应匹配(如「规则怪谈」「短篇怪谈」) | +| **写作前 · 需求分析** | `## 启动询问` + `## 创作总纲` | 对象、受众、语气、目标、禁忌;**禁止直接动笔**;写入哪个 key | +| **写作中 · 分步引导** | `## 推荐阶段` + `## 示例` + `## 禁用行为` | 阶段链、每步约束、好/坏示例;对应 worker 与产出 key | +| **写作后 · 自检润色** | `## 自检清单` + `## 验收策略` | 产出前检查点;LLM 自审 vs 人工 vs 程序验收 | +| **质量评估** | `## 质量评估标准` | 可量化维度 + 各 stage 的 acceptanceMode | +| **编排** | `## 总管思维链` + `## 询问策略` + `## 推荐 Worker` | 总管如何调度;谁向用户提问 | + +不必每个 skill 都写独立 `# 写作前` 大标题;**用统一章节名即可**,内容覆盖上表即可。 + +#### `## 质量评估标准`(必需) + +避免只写「写得更好」。每个 skill 应列出 **可检查的质量维度**,格式建议: + +```markdown +## 质量评估标准 + +| 维度 | 说明 | 检查方式 | +|---|---|---| +| 完整性 | 是否满足启动询问中的必收集项 | programmatic / 人工 | +| 体裁符合 | 是否符合创作总纲(如规则怪谈:规则可反推危险) | LLM 自审 + 人工 | +| 一致性 | 与已 accepted 上游产物是否矛盾 | programmatic | +| **接受度** | 用户/系统是否接受该产物(预留) | user_confirmed → 记录 accept/reject | + +各 stage 默认 acceptanceMode 见「验收策略」。 +``` + +**「接受度」维度(预留):** + +- 第一版:**不强制数值打分**;用阶段机的 `user_accepted_artifact` / `reject` 记录二元结果即可。 +- 后续可在 Book / Session 元数据写入 `acceptanceScore` 或 `acceptanceNotes`(字符串或 1–5 分),与 Skill Rubric 对齐。 +- Skill 里写清楚:**接受度由验收事件沉淀,不由 LLM 自报分数代替人工。** + +#### `## 自检清单`(必需) + +写作后、提交验收前,worker 或总管应过的检查点(字符串列表即可): + +```markdown +## 自检清单 + +### rules.draft 提交前 +- [ ] 每条规则能否对应非玄学的危险动机? +- [ ] 是否未直接写出 core.danger? +- [ ] 是否无「违反即抹杀」空规则? + +### content.chapter.* 提交前 +- [ ] 是否遵守 rules.draft 已 accepted 版本? +- [ ] … +``` + +与 `programmatic_review` 的关系:自检清单 = LLM/人读的规范;程序验收 = 可机械执行的子集。 + +#### 模板骨架(`skills/{bookKind}/{name}.md`) + +```markdown +--- +name: … +description: … # 触发条件(WHEN) +tags: … +category / bookKind: … +suggestedWorkers: … +--- + +# 标题 + +## 启动询问 # 写作前 · 需求分析 +## 创作总纲 +## 总管思维链 +## 推荐阶段 # 写作中 · 分步引导 +## 询问策略 +## 推荐 Worker +## 示例 # 写作中 · 约束与范例 +## 禁用行为 +## 自检清单 # 写作后 +## 验收策略 # 写作后 · 与 acceptanceMode 绑定 +## 质量评估标准 # Rubric + 接受度(预留) +## Book 结构 # 可选,novel / dialogue 形态说明 +``` + +代码当前**结构化解析**的仍主要是 `## 启动询问`;其余章节整段注入总管 prompt(待 `buildSkillContext`)。**全部是 Markdown 字符串,不矛盾。** + +### 3.3 正文必需章节(检查清单) + +| 章节 | 生命周期 | 内容 | +|---|---|---| +| frontmatter | 触发 | name、description、tags、bookKind | +| **启动询问** | 写前 | 必收集项、写入 key | +| **创作总纲** | 写前 | 顺序、边界、体裁原则 | +| **推荐阶段** | 写中 | 阶段链、worker、产出 key、prerequisites | +| **示例** | 写中 | 好/坏对照或完整流程范例 | +| **禁用行为** | 写中 | 绝对不要做的事 | +| **自检清单** | 写后 | 提交验收前的检查点 | +| **验收策略** | 写后 | 各 stage 的 acceptanceMode | +| **质量评估标准** | 质量 | 可量化维度 + **接受度(预留)** | +| **总管思维链** | 编排 | 每轮决策检查 | +| **询问策略** | 编排 | 总管问 vs worker 问 | +| **推荐 Worker** | 编排 | 与 suggestedWorkers 一致 | + +可选:`## Book 结构`、`examples.md` 外链。 + +--- + +## 4. registry.yaml(可选) + +启动时列举可用 skill,不必扫描目录: + +```yaml +skills: + - name: novel-standard + description: 标准长篇小说:大纲 → 事件 → 正文 + category: novel + - name: weird-rules-short + description: 短篇规则怪谈:隐含核心 → 护命规则 → 成章 + category: novel + - name: theater-roleplay + description: 剧场式角色扮演:角色 → 场景 → 回合互动 + category: dialogue + - name: forum-thread + description: 论坛体连载:楼主身份 → 回帖风格 → 楼层 + category: novel +``` + +若无 `registry.yaml`,Runtime 扫描 `skills/novel/*.md` 与 `skills/dialogue/*.md`。 +**registry 列举的是第二层 skill(name),不是 category。** + +--- + +## 5. 会话启动:第一个询问是选 Skill + +Skill 选择发生在**任何创作逻辑之前**。 + +### 5.1 启动转移 + +```text +idle + session_started + → waiting_user(skill_selection) +``` + +新增 `waitingReason`: + +```ts +| { kind: "skill_selection"; availableSkills: SkillIndexEntry[] } +``` + +### 5.2 向用户展示 + +```text +请选择创作类型: + +【小说】(Book 形态:卷 / 章) + 1. novel-standard — 标准长篇:大纲 → 事件 → 正文 + 2. weird-rules-short — 短篇规则怪谈:核心 → 规则 → 成章 + 3. basic — 最小演示 + +【对话】(Book 形态:回合 / 多角色) + 4. theater-roleplay — 剧场式角色扮演 + +也可直接描述你想写什么,我会帮你匹配 skill name。 +``` + +### 5.3 用户回答方式 + +```text +输入编号或 name:novel-standard +输入自然语言:我想写一个剧场扮演 +输入自定义:用 novel-standard,但是偏悬疑 +``` + +Runtime 解析为 `skill_selected` 事件: + +```ts +{ type: "skill_selected"; payload: { skillId: string; userHint?: string } } +``` + +然后: + +```text +加载 skills/{skillId}/SKILL.md +解析 ## 启动询问 → session.slots.activeSkill + → waiting_user(input) + message 来自 SKILL.md「启动询问·向用户展示」 + 必收集项 / 写入目标 同样来自该节 +``` + +### 5.4 两阶段启动 + +```text +询问 1(系统) skill_selection 「用哪个 skill?」→ registry / description +询问 2(skill) input 「启动询问」章节 → 每类内容问的不同 +``` + +**只有询问 1 是系统固定的。询问 2 及之后所有创作逻辑,都在 SKILL.md 里。** + +--- + +## 6. 总管如何使用已选 Skill + +`session.slots.activeSkill` 加载后,总管 prompt 注入: + +```text +当前 skill: novel-standard +category: novel +创作总纲: (SKILL.md 摘要或全文) +当前推荐阶段: outline(由 resolver 根据黑板推断) +询问策略: 总管应先问 brief;outline 细节交给 worker +建议 worker: outline-worker, drafting-worker +defaultFlowId: ghostwriting-flow +``` + +总管决策仍通过 tool / JSON 决策,**不**直接改 phase。 + +--- + +## 7. 示例 + +完整示例见仓库内真实文件(不要只在文档里维护一份): + +```text +skills/novel/weird-rules-short.md +skills/dialogue/theater-roleplay.md +``` + +--- + +## 8. 解析与加载(将来代码) + +| 文件 | 干嘛的 | +|---|---| +| `src/skills/types.ts` | SkillIndexEntry、ParsedSkill 类型 | +| `src/skills/loader.ts` | 扫描 skills/、解析 frontmatter + 正文 | +| `src/skills/registry.ts` | 读 registry.yaml 或目录扫描 | +| `src/skills/resolver.ts` | 根据黑板 index 推断当前 stage | + +加载流程: + +```text +listSkills() → SkillIndexEntry[] +loadSkill(skillId) → ParsedSkill(含 startupInquiry 解析自 ## 启动询问) +selectSkill(session, skillId) → session.slots.activeSkill +getStartupPrompt(activeSkill) → 流程 2 展示文案 +buildSkillContext(activeSkill, blackboardIndex) → 总管 prompt 片段 +``` + +--- + +## 10. 与 creation-playbook.md 的关系 + +`creation-playbook.md` 描述**概念与数据结构**(Playbook、Stage、InquiryPolicy)。 + +**本文件**描述**落盘格式**(SKILL.md 怎么写、放哪、启动时怎么选)。 + +关系: + +```text +Creation Playbook(概念) + = SKILL.md(存储)+ stages.yaml(可选结构化) + + session.slots.activeSkill(运行时) +``` + +`creation-playbook.md` 中的 TS 类型,实现时可从 SKILL.md 解析或从 stages.yaml 读取。 + +--- + +## 11. 第一版范围 + +```text +skills/ 目录 + 2 个示例 SKILL.md(novel、theater) +启动 → skill_selection → 用户选择 → 加载 skill +总管 prompt 注入 skill 摘要 +registry.yaml 可选 +``` + +不做: + +```text +skill 可视化编辑器 +运行时 LLM 自动生成 skill +与 Cursor .cursor/skills 混用(路径独立) +``` diff --git a/docs/tag-blackboard.md b/docs/tag-blackboard.md new file mode 100644 index 0000000..d59edf1 --- /dev/null +++ b/docs/tag-blackboard.md @@ -0,0 +1,573 @@ +# 标签驱动黑板 + +## 1. 定位 + +本系统用于多 worker 协作式文本生成,覆盖: + +```text +简易小说快速撰写(quick-write) +规则怪谈 / 短篇结构化创作(weird-rules-short 等) +长篇小说 / 交互式写作助手(interactive-novel,TODO) +场景扮演模拟(scene-roleplay,TODO) +角色扮演 / 角色卡互动(并入 scene-roleplay 的 instantiate + run,见 §2) +``` + +核心思想: + +```text +黑板 = 标签化数据池 +标签 = skill 之间的接口 +skill = SKILL.md 定义的能力;worker = 一次 invoke +Agent = tool loop 内调度 invoke 哪个 skill +Runtime = 按 skill 契约拼接上下文(上半固定、下半动态) +``` + +一句话:**标签驱动 + agent 选 skill + Runtime 拼上下文**——不是固定流水线,也不是 LLM 自由分发上下文。 + +上下文拼接见 `docs/context-assembly.md`。不要让 agent 临场改 inputTags;分发由 `inputTags` + `contextSegments` 静态声明。 + +--- + +## 2. Skill 定义、实例化与 Book + +### 2.1 类比 + +```text +Skill 包(orchestrator manifest + workers) = 能力定义(静态) +Session + 黑板 tag = 一次运行的实参 +Book = 过程、资产、游玩(见 book-storage.md) +play stage = agent invoke run skill +``` + +**写角色卡、收集设定、启动询问** 都不是独立「产品模式」,而是 **实例化阶段** 的不同形态:把 prerequisite tags 写满,然后才进入运行阶段。 + +### 2.2 三层阶段(勿混淆) + +```text +运行相位(RuntimePhase) + idle | running | waiting_user | done | error + 系统在等什么。见 runtime-state-machine.md + +业务 stage(Book / Session) + design(实例化)→ play(运行)→ done + +tag 阶段(黑板条目 tag 名中的段) + 候选 | 草稿 | 确认稿 | 当前 | 更新 + 单条数据的 lifecycle +``` + +业务 stage **写在各 skill 的 orchestrator.md**,不扩运行相位 enum。 + +### 2.3 实例化(design) + +**职责:** agent 按需 invoke instantiate skill,沉淀 `设计.*` tag;产出 `设计.run_skill清单` 后 declare ready。 + +```text +选 orchestrator 包 + → design stage:启动询问 → agent invoke instantiate skill(能力库) + → declare_instance_ready + → play stage:agent invoke run skill + → done → 归档 Book(过程 + 资产,见 book-storage.md) +``` + +可从 Book / CardAsset 加载已有 tag,跳过部分 instantiate skill。 + +运行相位上,实例化阶段多为 `waiting_user(input)`(启动询问、总管 ask_user);实例化也可调用 **setup worker**,但仍是 worker(固定 inputTags/outputTags),不是第二个生产总管。 + +### 2.4 每个 skill 声明什么 + +在 orchestrator.md 中写清(见 orchestrator-skill-format.md): + +```text +## 启动询问 / ## 实例化 + prerequisiteTags:本 skill 运行前必须有的 tag + instanceReadyWhen:何时可进入 run stage(文字条件 + tag 列表) + 写入目标 tag(可多个;不必再塞进单一 book.brief) + +## 阶段定义 + instantiate(或沿用 stageId brief)→ run(write / review …)→ done + +## Worker 编排 + 仅 run stage 及之后调度生产 worker;instantiate 阶段只 ask_user 或 run setup worker +``` + +### 2.5 已有雏形:weird-rules-short + +| 业务 stage | 现 stageId | 含义 | +|---|---|---| +| 实例化 | `brief` | 启动询问 → `book.brief`(≈ `需求.核心要点`)→ `startupCompleted` | +| 运行 | `write` | `write-rules` 产出规则与 core | +| 运行 | `review` | 双验收 worker | +| 结束 | `done` | finish | + +`basic` 同理:`brief` = 实例化,`outline` worker = 运行。 +文档与实现迁移时,可将 stageId 改名为 `instantiate`,或保留 `brief` 但在 ## 阶段定义 注明 **brief ≡ instantiate**。 + +### 2.6 角色卡 + +不再单独维护 `character-card-author` skill 包。角色设定、口吻、行为边界等 tag 在 **扮演类 skill 的 instantiate 段** 收集或生成(可选 setup worker)。 +若需跨 Session 复用,将 `角色.A.设定`、`角色卡.确认稿` 等 **确认稿** 存入 Book,新 Session **加载 Book** 而非再跑完整实例化。 + +### 2.7 Book 与黑板 + +```text +黑板(Session 内) 运行时 tag 池;worker 读写 +Book(项目级) 长期实例;accepted 确认稿归档;下一场 Session 可加载 +``` + +详见 `docs/book-storage.md`。Session 是一次运行;Book 是一个创作项目(一本小说、一个扮演项目)。 + +--- + +## 3. 黑板条目 + +### 2.1 最小结构 + +```ts +type BlackboardItem = { + id: string; + tag: string; + content: string; + source: string; + metadata?: Record; +}; +``` + +### 2.2 可选增强 + +```ts +type BlackboardItem = { + id: string; + tag: string; + content: string; + source: string; + scope?: string; + createdAt?: number; + updatedAt?: number; + dependencies?: string[]; + metadata?: Record; +}; +``` + +### 2.3 字段含义 + +```text +id 唯一标识,追踪与依赖 +tag 标签本体,决定身份与消费路径(核心) +content 具体内容(字符串) +source 产生该条目的 worker id,调试用 +scope 作用范围,如当前章节、场景、项目(可选) +dependencies 依赖的其他黑板条目 id(可选) +metadata 置信度、真实性、控制模式等(不参与基础路由) +``` + +注意: + +```text +tag 是核心路由依据。 +source 不是路由依据,只用于调试与溯源。 +metadata 不参与基础路由,除非某 skill 明确约定。 +``` + +**已废弃:** 旧模型的 `key`、`summary`、`tags[]`、`readableBy`、`writableBy` 作为路由字段。迁移期代码可能仍保留 `BlackboardEntry`,以本文为准逐步替换。 + +--- + +## 4. 标签原则 + +标签不是信息分类学,而是 **工作流接口**。 + +### 3.1 设计原则 + +```text +标签越少越好,但必须能区分不同消费路径。 +两个信息若永远被同一批 worker 消费,可合并 tag。 +若在不同阶段被不同 worker 消费,必须拆分 tag。 +若可能被误当成事实,必须加阶段段。 +若涉及角色私有认知,tag 里必须体现角色归属。 +``` + +### 3.2 推荐格式 + +```text +对象.内容 +对象.内容.阶段 +领域.对象.内容.阶段 +``` + +不强制四段式;按复杂度逐级增加。 + +示例: + +```text +大纲.草稿 +事件.草稿 +正文.草稿 +正文.确认稿 + +角色.A.行动.候选 +角色.A.台词.候选 +角色.A.内心想法.候选 +角色.A.记忆.当前 +``` + +### 3.3 标签替代的旧字段 + +```text +角色.A.内心想法.候选 +``` + +已表达:归属 A、类型为内心想法、阶段为候选、消费路径由声明 inputTags 的 worker 决定。 + +**不需要** 再写 `owner`、`visibleTo`、`type=action` 等平行字段。 + +### 3.4 匹配规则(Runtime) + +Worker frontmatter 声明 `inputTags`: + +```yaml +inputTags: + - "需求.核心要点" # 精确匹配 + - "角色.A.*" # 前缀匹配:tag 以「角色.A.」开头 + - "大纲.*.草稿" # 前缀匹配 +``` + +规则: + +```text +无通配 → 精确匹配 tag +以 .* 结尾 → 前缀匹配(实现优先于完整正则) +多条命中 → 默认按 updatedAt 取最新;或 worker 声明 inputMerge: concat | latest +``` + +总管调度时也可对 **tag 索引**(不含 content)做存在性判断,例如「是否有 规则.确认稿」。 + +--- + +## 5. 阶段标签 + +阶段表示同一类信息在不同生命周期下的语义。 + +```text +候选 worker 生成的可能内容,不等于事实 +草稿 生成中的文本或结构 +确认稿 用户或流程确认,可进入长期状态 +当前 当前生效状态 +更新 状态变化结果 +``` + +重要规则: + +```text +候选 ≠ 已发生。 +草稿 ≠ 确认稿。 +角色行动候选不能直接写入长期记忆。 +长期记忆优先从正文.确认稿 与明确 记忆.更新 生成。 +``` + +验收(`user_confirmed` / `programmatic_review`)通过后,Runtime 将对应产物 tag 从 `.草稿` 升级为 `.确认稿`(或写入新的确认稿条目并标记旧草稿 superseded)。 + +--- + +## 6. Worker + +每个 worker 是固定的标签消费者和生产者。 + +### 5.1 类型 + +```ts +type WorkerDefinition = { + id: string; + name: string; + description: string; + inputTags: string[]; + outputTags: string[]; + inputMerge?: "latest" | "concat"; + run: (context: WorkerContext) => Promise; +}; + +type WorkerContext = { + taskId: string; + workerId: string; + items: BlackboardItem[]; + params?: Record; +}; + +type WorkerResult = { + items: BlackboardItem[]; + logs?: string[]; + askUser?: string[]; +}; +``` + +### 5.2 规则 + +```text +worker 只能读取 inputTags 声明的标签(含前缀规则)。 +worker 只能输出 outputTags 声明的标签。 +worker 不读取全量黑板。 +LLM 不决定自己能看什么。 +Runtime 校验 outputs 的 tag ⊆ outputTags。 +``` + +### 5.3 Worker Skill 落盘 + +见 `docs/worker-skill-format.md`。正文写「怎么做」;`inputTags` / `outputTags` 写在 frontmatter。 + +### 5.4 ask_user + +提问是 worker **能力**,不是独立 worker。中途提问时 `resumeContext` 保存 workerId,**不**保存 inputTags(恢复时仍从 Worker Skill 读 inputTags)。 + +--- + +## 7. 总管(Main Agent) + +总管只负责流程推进。 + +### 6.1 职责 + +```text +判断当前任务属于哪种 skill / 业务阶段 +选择下一个 worker(run_worker) +判断是否需要追问用户(ask_user) +判断是否需要用户确认下一步(requiresApproval) +判断是否 finish +``` + +### 6.2 不负责 + +```text +不决定某条信息给谁看 +不手动拼接 worker 上下文 +不让 LLM 判断信息权限 +不把全量黑板交给 worker +不在 run_worker 里指定 inputTags / outputTags +``` + +### 6.3 决策结构 + +```ts +type MainAgentDecision = { + id: string; + action: "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish"; + reason: string; + workerId?: string; + requiresApproval: boolean; + statePatchAllowed: false; +}; +``` + +执行链: + +```text +总管选择 worker +→ Runtime 读取该 worker 的 inputTags +→ 从黑板取匹配条目,组装 WorkerContext +→ 调用 worker +→ worker 输出固定 outputTags +→ 写回黑板 +→ 按 acceptanceMode 验收 +``` + +Tool 合约见 `docs/tool-contracts.md`。 + +--- + +## 8. Skill 包与 manifest + +Skill 包 = `orchestrator.md`(manifest)+ `workers/*/SKILL.md`。详见 `orchestrator-skill-format.md`。 + +manifest 包含: + +```text +Skill 注册表(instantiate + run) +验收策略 +Instance Ready 规则 +``` + +**不写** 逐步编排表。inputTags / contextSegments 在 Worker SKILL.md。 + +### 8.1 已启用包 + +| 包 | 说明 | +|---|---| +| `novel/basic` | 演示:需求 → 大纲 | +| `novel/weird-rules-short` | 规则怪谈:写 + 双验收 | + +### 8.2 规划包(见 `skills/README.md`) + +| 包 | 说明 | +|---|---| +| `novel/quick-write` | **简易档**:几乎无 tag 路由,上下文全量给 LLM | +| `novel/interactive-novel` | 长篇 / 写作助手:instantiate + 多轮 run | +| `novel/novel-standard` | 标准流水线(或与 interactive 合并) | +| `dialogue/scene-roleplay` | 场景扮演(含角色设定 instantiate + 互动 run) | + +--- + +## 9. 各模式核心 tag(参考) + +实现 skill 时从下列词汇出发,不必一次全部实现。 + +### 8.1 简易小说(quick-write) + +极简;可大量依赖 session + 全量上下文,tag 仅作交付锚点: + +```text +用户.输入 +需求.摘要 +正文.草稿 +正文.确认稿 +``` + +### 8.2 结构化短篇(如 weird-rules-short) + +迁移目标示例(与现 key 对照实施): + +```text +需求.核心要点 ← book.brief +核心.危险.隐藏 ← core.danger +规则.草稿 ← rules.draft +规则.说明.草稿 ← rules.commentary +验收.读者视角.记录 ← review.infer.notes +验收.作者视角.记录 ← review.author.notes +``` + +### 8.3 交互式长篇 + +```text +用户.原始输入 | 用户.意图转述 | 用户.确认结果 +项目.设定 | 项目.风格要求 +大纲.当前 | 大纲.候选修改 | 大纲.确认稿 +事件.当前 | 事件.确认稿 +正文.原文 | 正文.续写锚点 | 正文.草稿 | 正文.确认稿 +记忆.长期摘要 | 记忆.确认稿 +``` + +### 8.4 场景扮演 + +```text +用户.行动输入 | 用户.行动意图 +世界.规则 | 世界.当前状态 | 世界.隐藏状态 +场景.可见信息 | 场景.隐藏信息 +角色.{id}.记忆 | 信念 | 行动.候选 | 台词.候选 +行动.裁决结果 +输出.场景反馈 +更新.世界状态 | 更新.角色状态 +``` + +### 8.5 角色卡 + +撰写:`角色卡.草稿` → `角色卡.确认稿` +游玩:读 `角色卡.确认稿` + `用户.控制模式` + `NPC.*` + +--- + +## 10. 正文 worker 与角色候选 + +角色 worker 输出 `角色.*.台词.候选` 等,**不**直接进入正文事实。 + +正文 worker 读取候选 + 风格约束,输出 `正文.草稿`;用户确认后为 `正文.确认稿`。 + +```text +角色候选 → 提供意图 +正文 worker → 文本化、风格化、叙事化 +正文.确认稿 → 最终发生与表达 +``` + +--- + +## 11. 可选增强 + +### 10.1 采用记录 + +```text +tag: 采用.记录 +``` + +记录正文采用了哪些候选条目,避免未采用候选污染记忆。早期可省略,记忆 worker 只从 `正文.确认稿` 抽取。 + +### 10.2 真实性 / 置信度(metadata) + +```ts +type TruthMode = + | "truth" | "belief" | "claim" | "lie" | "rumor" | "plan" | "unknown"; +``` + +不参与基础路由。 + +### 10.3 RAG + +RAG 不替代黑板。检索结果也应写成 tag,例如 `角色.A.相关记忆摘要`,由 worker 通过 inputTags 读取。 + +--- + +## 12. 与阶段机的关系 + +运行相位、业务 stage、tag 阶段 **三者正交**(详见 §2.2): + +```text +运行相位 系统在等什么(见 runtime-state-machine.md) +业务 stage instantiate → run → done(orchestrator.md;brief 即 instantiate) +tag 阶段 黑板条目 lifecycle(候选 / 草稿 / 确认稿) +``` + +实例化阶段在运行相位上通常体现为 `waiting_user(input)`;进入 run stage 后为 `running` + worker 验收循环。 +阶段机 enum 不增加 `instantiate` 相位——业务 stage 由 orchestrator + tag 索引判断。 + +阶段机规则本身不因 tag 迁移而改变。变的是:黑板读写、worker 上下文、总管决策字段。 + +--- + +## 13. 与 Book 存储 + +Book 存长期实例;黑板存 **当前 Session** 的运行时 tag。实例化产物与 run 阶段确认稿可归档到 Book。 +详见 `docs/book-storage.md` 与 §2.7。 + +--- + +## 14. 实现原则(必须遵守) + +```text +1. worker 不读取全量黑板(quick-write 等显式声明全量 inputTags 的 skill 除外)。 +2. worker 只能读取 inputTags 声明的标签。 +3. worker 只能输出 outputTags 声明的标签。 +4. LLM 不决定上下文分发。 +5. 总管 run_worker 时不带 inputTags / outputTags。 +6. 标签是 worker 之间的接口。 +7. 候选不等于事实;草稿不等于确认稿。 +8. 正文 worker 可重写角色候选产物。 +9. 长期记忆优先从正文.确认稿 更新。 +10. 简单 skill 用简单 tag;复杂 skill 再增加对象与阶段段。 +11. 角色卡与设定在 instantiate 阶段写入 tag;跨 Session 复用走 Book 加载,不单独 author skill 包。 +12. 代码与旧文档冲突时,以本文为准改代码。 +``` + +--- + +## 15. 代码迁移顺序(参考) + +```text +1. src/types/blackboard.ts → BlackboardItem + listByTag / matchPrefix +2. src/skills/types.ts + loader → 解析 inputTags / outputTags +3. src/worker/executor.ts → 按 tag 组装 context,校验 outputTags +4. src/types/runtime.ts + main-agent → 决策去掉 inputKeys / outputKeys +5. skills/novel/weird-rules-short → 第一个 tag 化样板 +6. skills/novel/quick-write → 简易全量 LLM 档 +``` + +当前代码仍为旧 key 模型;实现前以本文为规格。 + +--- + +## 16. 相关文档 + +| 文档 | 内容 | +|---|---| +| `orchestrator-skill-format.md` | 总管 orchestrator.md 写法 | +| `worker-skill-format.md` | Worker SKILL.md 写法 | +| `tool-contracts.md` | 总管 / worker tool | +| `skill-format.md` | Skill 包存储 | +| `runtime-state-machine.md` | 5 相位阶段机 | +| `implementation-guide.md` | 写代码顺序 | +| `skills/README.md` | Skill 包索引与 TODO | diff --git a/docs/tool-contracts.md b/docs/tool-contracts.md new file mode 100644 index 0000000..1f37562 --- /dev/null +++ b/docs/tool-contracts.md @@ -0,0 +1,125 @@ +# Tool 合约 + +## 1. 定位 + +LLM 通过 **tool call** 表达意图;Runtime 校验后执行 tool 或转为 **RuntimeEvent** 改变 phase。 + +**真 tool loop**:循环 tool 的返回值 append 到 agent `messages[]`;边界 tool 结束 burst。 +不依赖「副作用列表」隐式推进(迁移方向,见 `architecture.md`)。 + +黑板与上下文:`tag-blackboard.md`、`context-assembly.md`。 + +--- + +## 2. 调用者 + +| 调用者 | 可用 tool | 禁止 | +|--------|-----------|------| +| 总管 Agent | 见 §3 | 改 phase 直接写黑板;传 inputTags;accept 产物 | +| Worker LLM | `ask_user`、`submit`(目标态) | 调度其他 skill;写未声明 tag | +| 用户 / CLI | submit_input、approve、reject、accept | — | +| Runtime | 发射 event、拼接上下文、执行 worker | — | + +--- + +## 3. 总管 Tool + +### 3.1 循环 tool(burst 内,不改 phase) + +| name | 作用 | +|------|------| +| `read_blackboard` | `tags: string[]` 读正文 | +| `list_workers` | 当前包可调度 skill 列表 | +| `list_artifacts` | 产物状态 | + +### 3.2 边界 tool(结束 burst) + +| name | 行为 | +|------|------| +| `run_worker` | 执行 skill;`requiresApproval` → `approve_step` | +| `ask_user` | `waiting_user(input)` | +| `review_blackboard` | 向用户展示概况 → `input` | +| `finish` | `done` | + +```ts +type RunWorkerParams = { + workerId: string; + reason: string; + requiresApproval: boolean; + roleId?: string; +}; +``` + +Runtime 从 Worker Skill 读 `inputTags` / `outputTags`,总管 **不得传入**。 + +### 3.3 Tool loop burst + +```text +计数器 toolLoopBurstCount 在每次用户硬事件时归零: + user_submitted_input、user_confirmed_intake、user_approved_next_step、 + user_rejected_next_step、user_accepted_artifact、user_rejected_artifact、… + +running 内每轮 LLM+tool 使 burst+1;超过 maxBurst(默认 12,可配置)→ 强制 waiting_user +``` + +**maxBurst = 两次用户操作之间的上限**,非 Session 累计。 + +代码:`src/main-agent/tool-loop.ts`、`src/runtime/tool-registry.ts`。 + +--- + +## 4. Worker Tool(目标态) + +| name | 行为 | +|------|------| +| `ask_user` | `worker_questions` + resumeContext | +| `submit` | 校验 tag ⊆ outputTags → 写黑板 → `worker_completed` | + +Phase A 仍用 JSON `outputs` + `askUser`,语义等价。 + +--- + +## 5. 用户硬事件 + +| waitingReason | 用户动作 | +|---------------|----------| +| `skill_selection` | 选包 | +| `intake` / `input` | 输入 | +| `approve_step` | approve / reject | +| `review_artifact` | accept / reject | +| `worker_questions` | 输入 | +| `revision` | 输入修改说明 | + +--- + +## 6. 校验 + +```text +1. phase + waitingReason 允许该 actor +2. tool 参数 schema +3. workerId ∈ manifest 注册表 +4. submit tag ⊆ outputTags +5. 总管不得带 inputTags +6. burst ≤ maxBurst +``` + +--- + +## 7. 代码对应 + +| 章节 | 文件 | +|------|------| +| 总管 tool loop | `src/main-agent/tool-loop.ts` | +| tool 定义 | `src/main-agent/tools.ts` | +| 解析/校验 | `src/runtime/tool-registry.ts` | +| worker | `src/worker/executor.ts` | +| 阶段机 | `src/runtime/phase-machine.ts` | + +--- + +## 8. 已废弃 + +- 总管 JSON 一次性决策(保留 fallback 解析) +- `inputKeys` / `outputKeys` +- orchestrator 思维链逐步调度 +- 以 `PhaseEffect.invoke_main_agent` 链为主的路径(迁向 burst 入口) diff --git a/docs/worker-skill-format.md b/docs/worker-skill-format.md new file mode 100644 index 0000000..2b9812c --- /dev/null +++ b/docs/worker-skill-format.md @@ -0,0 +1,279 @@ +# Worker Skill 格式 + +## 1. 定位 + +**Worker Skill** 服务 **Worker Agent**:规定 **读哪些 tag、写哪些 tag、怎么做** 本阶段产出。 + +Worker **从属于某一个总管 Skill 包**,不与其它 skill 共享。 + +```text +总管:run_worker(write-rules) + → Runtime 读 workers/write-rules/SKILL.md 的 inputTags / outputTags + → 从黑板取匹配条目 → Worker 执行 → 写回 outputTags +``` + +规格背景见 `docs/tag-blackboard.md`、`docs/context-assembly.md`。 + +--- + +## 2. 存储位置 + +```text +skills/novel/weird-rules-short/ +├── orchestrator.md +└── workers/ + ├── write-rules/SKILL.md + └── review-infer/SKILL.md +``` + +- 目录名 = 包内 **worker id**。 +- 文件统一 **`SKILL.md`**。 +- **没有** 全局共享 worker 目录。 + +--- + +## 3. Frontmatter + +```yaml +--- +id: write-rules +skill: weird-rules-short +name: 规则与解析创作 +description: >- + 从 需求.核心要点 推演内部核心,产出规则与说明。 +version: 1 +inputTags: + - "需求.核心要点" + - "验收.读者视角.记录" + - "验收.作者视角.记录" + - "用户.修改说明" +outputTags: + - "核心.危险.隐藏" + - "规则.草稿" + - "规则.说明.草稿" +inputMerge: latest +--- +``` + +| 字段 | 用途 | +|------|------| +| `id` | 包内 skill id,与 manifest 注册表一致 | +| `skill` | 所属 orchestrator 包 name | +| `inputTags` | Runtime 从黑板取数的 tag(精确或 `前缀.*`) | +| `outputTags` | 允许写回的 tag;Runtime 校验 | +| `inputMerge` | 可选,`latest`(默认)或 `concat` | +| `contextSegments` | 可选,上下文拼接:上半 static、下半 dynamic(见 §3.1) | +| `contextIsolation` | 可选:`none` \| `role_pov` \| `blind_review` | + +### 3.1 contextSegments(上下文拼接) + +见 `docs/context-assembly.md`。示例: + +```yaml +contextSegments: + - id: brief + tier: static + tags: ["book.brief"] + label: "## 创作需求" + - id: history + tier: dynamic + tags: ["运行.事件流"] + policy: tail_lines_80 + - id: turn + tier: dynamic + tags: ["可见信息", "用户.最新输入"] + label: "## 本轮" +``` + +未声明时 Runtime 回退为 JSON `inputs`(当前实现)。 + +**review-infer 示例**(不得读隐藏核心): + +```yaml +inputTags: + - "需求.核心要点" + - "规则.草稿" + - "规则.说明.草稿" +outputTags: + - "验收.读者视角.记录" +``` + +**review-author 示例**(可读隐藏核心): + +```yaml +inputTags: + - "需求.核心要点" + - "核心.危险.隐藏" + - "规则.草稿" + - "规则.说明.草稿" +outputTags: + - "验收.作者视角.记录" +``` + +--- + +## 4. 正文章节 + +```markdown +# 标题 + +## 角色与口吻 +## 能力范围 # 能做什么 / 不能做什么 +## 思维链与自检 +## 上下文用法 # 各 inputTag 如何使用(不重复 frontmatter 列表) +## 输出格式 # 各 outputTag 的 content 格式 +## 示例 # 可选 +``` + +正文中用 **tag 名** 指代上下文,例如「读 `需求.核心要点`」而非旧 key `book.brief`。 + +### 评估类 Worker + +总管 orchestrator 只写:`rules 确认后 → run review-infer`。 + +本 SKILL 写 **评估怎么做**、verdict 写入 `验收.*.记录` 的 JSON 形状等。 + +### 用户回合 worker(user-turn) + +**用途:** 该环节 **完全由用户输入** 组成,LLM 不替用户选行动(21 点玩家、线下人类一方等)。 + +**与 role-decide 的区别:** + +| | role-decide | user-turn | +|--|-------------|-----------| +| 决策 | LLM 产出 `.思考` + `.行动` | 用户经 ask_user 提供;worker **只**写 `.行动` | +| LLM | 需要 | 仅需展示/校验/格式化(可无生成模型) | + +**frontmatter 示例:** + +```yaml +id: user-turn +skill: blackjack-roleplay +name: 用户回合 +description: 展示局面,收集用户合法行动,写入角色.用户.行动 +inputTags: + - "角色.用户.可见信息" + - "场景.公开叙述" +outputTags: + - "角色.用户.行动" +``` + +**SKILL 正文要点:** + +```markdown +## 角色 +你是 **用户操作的采集器**,不是玩家 AI。禁止替用户选择行动。 + +## 执行 +1. 读可见信息与合法行动集 +2. ask_user:简短展示局面 + 列出可选行动 +3. 校验用户输入是否在合法集内;不合法则再问 +4. 写 `角色.用户.行动`(行动选择 + 可选说话) + +## 禁止 +- 调用 LLM 模拟用户策略 +- 写入 `.思考`(用户无内心 tag,或仅 UI 留空) +``` + +编排:总管在轮到用户时 `run_worker(user-turn)`;world-engine 与 role-decide **同一套** 读 `.行动` 规则。 + +--- + +## 5. 运行时输出协议 + +Worker LLM 返回 JSON(Phase A);Phase B 改为 tool call。语义不变: + +```json +{ + "outputs": { + "规则.草稿": "...", + "规则.说明.草稿": "..." + }, + "summary": "50字以内摘要", + "askUser": null +} +``` + +- `outputs` 的 key 必须是 **outputTags 中的 tag**(或与 tag 一一映射的别名,由 Runtime 归一化)。 +- 缺信息时 `askUser` 提问,不臆造。 + +Runtime 写黑板: + +```ts +{ + id: "...", + tag: "规则.草稿", + content: "...", + source: "write-rules", +} +``` + +--- + +## 6. ask_user + +任何 worker 可中途提问。Runtime 暂停并保存 `resumeContext`(workerId 等);恢复时 **重新** 从 SKILL 读 inputTags,不依赖总管。 + +--- + +## 7. 命名原则 + +Worker id 按 **本包流程职责** 命名,包内唯一: + +| 包 | worker id | 职责 | +|----|-----------|------| +| weird-rules-short | write-rules | 写规则 | +| weird-rules-short | review-infer | 读者视角验收 | +| novel-standard | outline | 大纲 | + +不要设计全局共享 worker id。 + +--- + +## 8. 与代码的关系 + +| 文档 | 代码 | +|------|------| +| frontmatter inputTags / outputTags | `src/skills/loader.ts` → `ParsedWorkerSkill` | +| 运行时取数 | `src/worker/executor.ts` | +| 角色 worker 独立 LLM | `llmProfileId` / `llm-bindings.yaml` | `src/skills/worker-llm.ts` | + +当前代码仍为旧 `inputKeys` / `outputKeys` 模型;迁移以 `tag-blackboard.md` 为准。 + +--- + +## 9. Worker 独立 LLM(可选,预留多 AI 博弈) + +默认:worker 与会话 **同一 ApiProfile**(设置页当前选中的 profile)。 + +### 9.1 Worker SKILL frontmatter + +```yaml +llmProfileId: "" +``` + +省略 = 走 skill 包 `llm-bindings.yaml` 或会话默认。 + +### 9.2 Skill 包 llm-bindings.yaml + +```yaml +defaultProfileId: null # null = 会话默认 + +workers: + world-engine: {} + role-decide: + byRole: + A: "" + B: "" +``` + +Runtime 解析顺序见 `src/skills/worker-llm.ts`。 +`role-decide` 按 `slots.世界.当前角色.id` 匹配 `byRole`。 + +### 9.3 设计意图 + +- 配置仍在 **profiles.json**(或 .env),不在 SKILL 里写密钥 +- 同一 skill 可让不同角色用不同模型/API,实现真实多 agent 博弈 +- 总管 LLM 不受 worker 绑定影响(始终会话默认) + +--- diff --git a/electron/main.cjs b/electron/main.cjs new file mode 100644 index 0000000..212e004 --- /dev/null +++ b/electron/main.cjs @@ -0,0 +1,229 @@ +const { app, BrowserWindow, shell } = require("electron"); +const { spawn, execSync } = require("child_process"); +const http = require("http"); +const path = require("path"); + +const PORT = Number(process.env.PORT || 23337); +const PROJECT_ROOT = path.join(__dirname, ".."); +const APP_URL = `http://127.0.0.1:${PORT}/`; +const isDev = process.env.WRITING_AGENT_ELECTRON_DEV === "1"; + +/** @type {import('child_process').ChildProcess | null} */ +let serverProcess = null; +/** @type {import('electron').BrowserWindow | null} */ +let mainWindow = null; +let cleaningUp = false; + +function getPidsOnPort(port) { + const pids = new Set(); + + try { + if (process.platform === "win32") { + const out = execSync(`netstat -ano | findstr ":${port} " | findstr "LISTENING"`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + for (const line of out.split(/\r?\n/)) { + const pid = line.trim().split(/\s+/).pop(); + if (pid && /^\d+$/.test(pid) && pid !== "0") { + pids.add(Number(pid)); + } + } + } else { + const out = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + for (const line of out.split(/\r?\n/)) { + const pid = Number(line.trim()); + if (pid > 0) pids.add(pid); + } + } + } catch { + /* no listeners */ + } + + return [...pids]; +} + +function killPidTree(pid) { + if (!pid || pid <= 0) return; + + try { + if (process.platform === "win32") { + execSync(`taskkill /pid ${pid} /f /t`, { stdio: "ignore" }); + } else { + process.kill(pid, "SIGTERM"); + } + } catch { + /* already exited */ + } +} + +function killPortListeners(port) { + for (const pid of getPidsOnPort(port)) { + killPidTree(pid); + } +} + +function waitForServer(maxAttempts = 60) { + return new Promise((resolve, reject) => { + let attempts = 0; + + const tryOnce = () => { + const req = http.get(`${APP_URL}api/health`, (res) => { + res.resume(); + if (res.statusCode === 200) { + resolve(); + } else { + scheduleRetry(); + } + }); + req.on("error", scheduleRetry); + req.setTimeout(2000, () => { + req.destroy(); + scheduleRetry(); + }); + }; + + const scheduleRetry = () => { + attempts += 1; + if (attempts >= maxAttempts) { + reject(new Error(`后端未在 ${PORT} 端口就绪`)); + return; + } + setTimeout(tryOnce, 500); + }; + + tryOnce(); + }); +} + +function startServer() { + const isWin = process.platform === "win32"; + serverProcess = spawn(isWin ? "npm.cmd" : "npm", ["run", "web"], { + cwd: PROJECT_ROOT, + env: { ...process.env, PORT: String(PORT) }, + stdio: "inherit", + shell: isWin, + }); + + serverProcess.on("error", (err) => { + console.error("[electron] 启动后端失败:", err); + }); +} + +function killServer() { + if (cleaningUp) return; + cleaningUp = true; + + const pid = serverProcess?.pid; + serverProcess = null; + + if (pid) { + killPidTree(pid); + } + + killPortListeners(PORT); +} + +function registerCleanupHandlers() { + const cleanup = () => { + killServer(); + if (process.platform !== "darwin") { + app.quit(); + } + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); +} + +function registerDevToolsShortcuts(win) { + win.webContents.on("before-input-event", (_event, input) => { + const f12 = input.key === "F12"; + const ctrlShiftI = + input.control && input.shift && input.key.toLowerCase() === "i"; + if (f12 || ctrlShiftI) { + win.webContents.toggleDevTools(); + } + }); +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1280, + height: 860, + minWidth: 800, + minHeight: 600, + title: "Writing Agent", + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + + mainWindow.once("ready-to-show", () => { + mainWindow?.show(); + }); + + mainWindow.loadURL(APP_URL); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith("http://") || url.startsWith("https://")) { + shell.openExternal(url); + } + return { action: "deny" }; + }); + + registerDevToolsShortcuts(mainWindow); + + if (isDev) { + mainWindow.webContents.openDevTools({ mode: "detach" }); + } + + mainWindow.on("closed", () => { + mainWindow = null; + killServer(); + }); +} + +app.whenReady().then(async () => { + registerCleanupHandlers(); + startServer(); + try { + await waitForServer(); + createWindow(); + } catch (err) { + console.error("[electron]", err instanceof Error ? err.message : err); + killServer(); + app.quit(); + } +}); + +app.on("window-all-closed", () => { + killServer(); + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("before-quit", () => { + killServer(); +}); + +app.on("will-quit", () => { + killServer(); +}); + +app.on("activate", async () => { + if (mainWindow === null && serverProcess) { + try { + await waitForServer(10); + createWindow(); + } catch { + /* server gone */ + } + } +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fe84737 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2791 @@ +{ + "name": "writing-agent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "writing-agent", + "version": "0.1.0", + "dependencies": { + "yaml": "^2.9.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "electron": "^35.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron": { + "version": "35.7.5", + "resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz", + "integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4800a2d --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "writing-agent", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "electron/main.cjs", + "scripts": { + "build": "tsc", + "test": "vitest run", + "dev": "tsx src/cli/run.ts", + "demo": "tsx src/cli/run.ts --mock", + "phase-demo": "tsx src/cli/phase-demo.ts", + "phase-script": "tsx src/cli/phase-demo.ts --script --auto", + "web": "tsx src/server/web-server.ts", + "electron": "electron ." + }, + "devDependencies": { + "@types/node": "^22.10.0", + "electron": "^35.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + }, + "dependencies": { + "yaml": "^2.9.0" + } +} diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..d88827d --- /dev/null +++ b/skills/README.md @@ -0,0 +1,40 @@ +# Skill 包索引 + +总管 skill 以 `orchestrator.md` + `registry.yaml` 注册后才会出现在启动列表。 +本目录下 **仅有 README 的文件夹** 为规划占位,AI 不会加载。 + +## 业务 stage 通则 + +每个 skill 包内 orchestrator 应区分: + +```text +instantiate(实例化) 启动询问 / setup worker → prerequisite tags;现 stageId 常叫 brief +run(运行) 生产 worker 流水线(write / review / outline …) +done finish → 确认稿归档 Book +``` + +详见 `docs/tag-blackboard.md` §2。 + +## 已启用 + +| 包 | 路径 | instantiate → run | +|---|---|---| +| basic | `novel/basic/` | `book.brief` → outline | +| weird-rules-short | `novel/weird-rules-short/` | `book.brief` → write-rules + 双 review | +| roleplay-game-theory | `dialogue/roleplay-game-theory/` | instantiate → world-engine + role-decide × N + present-round(多 AI 可选) | + +## 规划中(TODO) + +| 包 | 路径 | 说明 | +|---|---|---| +| quick-write | `novel/quick-write/` | 简易档:弱化 tag,全量 LLM | +| interactive-novel | `novel/interactive-novel/` | 长篇:多 tag instantiate + 多轮 run | +| novel-standard | `novel/novel-standard/` | 标准流水线(或与 interactive 合并) | +| scene-roleplay | `dialogue/scene-roleplay/` | 扮演:角色/世界 instantiate + 互动 run | +| world-simulator | `dialogue/world-simulator/` | 世界模拟器:Step1–14 实例化设计 + run(大工程,设计期) | + +**已取消独立包:** `character-card-author` / `character-card-play` — 角色设定与互动并入 `scene-roleplay` 的 instantiate / run,跨 Session 复用走 Book。 + +占位目录 `dialogue/character-card-*` 仅保留说明,不注册。 + +标签命名规范等细节以后补 `docs/tag-vocabulary.md`(低优先级)。 diff --git a/skills/dialogue/character-card-author/README.md b/skills/dialogue/character-card-author/README.md new file mode 100644 index 0000000..ea7cdf7 --- /dev/null +++ b/skills/dialogue/character-card-author/README.md @@ -0,0 +1,15 @@ +# character-card-author(已合并概念) + +**不再作为独立 skill 包。** + +角色卡撰写 = **实例化(instantiate)阶段** 的一种产出形态:在扮演类 skill(规划中的 `scene-roleplay`)里,通过启动询问或 setup worker 写入 tag,例如: + +```text +用户.角色需求 | 用户.互动偏好 +角色.A.设定 | 角色卡.口吻样例 | 角色卡.行为边界 +角色卡.确认稿 +``` + +跨 Session 复用角色 → 从 **Book** 加载已有 tag,不必再跑完整实例化。 + +见 `docs/tag-blackboard.md` §2.6、`skills/README.md`。 diff --git a/skills/dialogue/character-card-play/README.md b/skills/dialogue/character-card-play/README.md new file mode 100644 index 0000000..001b021 --- /dev/null +++ b/skills/dialogue/character-card-play/README.md @@ -0,0 +1,13 @@ +# character-card-play(已合并概念) + +**不再作为独立 skill 包。** + +角色卡游玩 = 同一 skill 在 **run 阶段** 的互动流水线:prerequisite tags(含 `角色卡.确认稿` 或 `角色.*.设定`)已在 instantiate 填好或从 Book 加载后,总管调度扮演 worker。 + +```text +instantiate 收集/加载角色与世界 tag +run 多轮互动、用户.控制模式、场景反馈 +done 归档 Book +``` + +见 `docs/tag-blackboard.md` §2、`skills/dialogue/scene-roleplay/README.md`(规划)。 diff --git a/skills/dialogue/roleplay-game-theory/llm-bindings.yaml b/skills/dialogue/roleplay-game-theory/llm-bindings.yaml new file mode 100644 index 0000000..341f85e --- /dev/null +++ b/skills/dialogue/roleplay-game-theory/llm-bindings.yaml @@ -0,0 +1,16 @@ +# 可选:为本 skill 包的 worker 指定独立 LLM API(profiles.json 中的 profile id) +# +# 省略本文件、或 profileId 为空 → 全部 worker 使用会话当前默认 API(与总管相同)。 +# 配置多个 profile 后,可为不同角色绑定不同模型,实现「多 AI 博弈」。 + +# defaultProfileId: null + +workers: + setup-scenario: {} + world-engine: {} + role-decide: + # 示例:按决策角色 id 分配不同 profile(把 uuid 换成你本地的 ApiProfile.id) + # byRole: + # A: "00000000-0000-0000-0000-000000000001" + # B: "00000000-0000-0000-0000-000000000002" + present-round: {} diff --git a/skills/dialogue/roleplay-game-theory/orchestrator.md b/skills/dialogue/roleplay-game-theory/orchestrator.md new file mode 100644 index 0000000..6f67c42 --- /dev/null +++ b/skills/dialogue/roleplay-game-theory/orchestrator.md @@ -0,0 +1,441 @@ +--- +name: roleplay-game-theory +description: >- + 何时选用:用户想模拟多个不同角色在简单博弈/思想实验处境下的决策与互动 + (如囚徒困境、最后通牒、公共池、信任游戏等)。 + 不适用:自由剧场扮演、写小说章节、长篇叙事、需要复杂世界观的 RPG。 + 产出:结构化博弈实例 +(后续 run 阶段)多角色决策模拟记录。 +category: dialogue +bookKind: dialogue +version: 1 +tags: + - game_theory + - roleplay + - simulation +workers: + - setup-scenario + - world-engine + - role-decide + - present-round +sharedContext: shared-context.md +--- + +# 角色扮演博弈 · 总管 + +你是本 skill 的 **总管**,只负责 **流程调度**:读黑板 → 判断阶段 → `run_worker` / `ask_user` / `finish`。 +不写角色决策、不替 worker 模拟回合——执行细节在包内 `workers/*/SKILL.md`。 +固定体裁规则在 `shared-context.md`,由 Runtime 注入 **本包所有 worker**,总管不读。 + +**当前进度:** instantiate + run(单轮/多轮 simulate + present-round)已定义;`semi_auto` 推进与 programmatic 验收待接入。 + +设计方法见 `docs/skill-design-guide.md`(抽象循环 → L0~L3 分层 → 倒推标签)。 + +--- + +## 架构:三实体 + 展示 + 信息隔离 + +```text +world-engine 中立世界机:发 L3 可见信息 → 收齐行动 → 裁决 → 追加 L2 事件流 +role-decide 各角色独立决策:读 L0~L3 → 写 `.思考`(仅用户)与 `.行动`(agent 可见) +present-round 展示:读 L0 + 本轮产物 + 思考 → 写 `输出.用户展示` +总管 调度轮次;role-decide 须带 workerContext.roleId +``` + +**抽象循环:** 角色行动 → 世界反应 → 角色行动 → …(每轮末 present-round → 用户验收) + +**分工:** 角色内心由 `role-decide` 产出;世界只写客观事实;**用户可见编排由 present-round 产出**,总管不拼长文。 + +**多 AI 博弈(可选):** 包内 `llm-bindings.yaml` 为 `role-decide.byRole` 指定不同 `ApiProfile.id`;默认全部用会话同一 API。 + +--- + +## 启动询问 + +选定本 skill 后,**第一个创作询问**。系统从本节读取问什么、写入哪。 + +**向用户展示:** + +```text +你选择了「角色扮演博弈」。在开始模拟之前,请告诉我: + +1. 实验情境 + - 可直接说经典名(囚徒困境、最后通牒、公共池、信任游戏……) + - 或用自己的话描述一个「每人要选行动、结果取决于组合」的简单局面 + +2. 参与角色(2~4 人即可) + - 每个角色用一句话说明策略倾向(如:算计型、讲公平、怕吃亏、爱冒险) + - 若有想用的称呼可一并说 + +3. 进程 + - 单轮定胜负 / 重复多轮 / 有限 N 轮 / 直到某条件(如有人破产) + +4. 信息结构(可选) + - 大家知道的都一样?有无私密信息或误解? + +5. 输出偏好(可选) + - 要不要看角色思考(`.思考` tag,仅你可见)? + - 偏冷静报告还是带一点场景描写? + +6. 特殊规则或收益(可选) + - 例如:背叛惩罚加倍、允许口头承诺但不具约束力 + +可以一次说完。不必懂博弈论术语——我会整理成可模拟的结构。 +``` + +**必须收集:** + +- 情境(玩什么局面:经典名或自定义) +- 角色(至少 **2 个**参与者,各一句策略倾向) +- 进程(怎么进行、何时结束;未说明时 setup 按单轮默认并标注) + +**可选收集:** + +- 信息结构 +- 输出偏好(思考可见性、叙事风格) +- 特殊规则或收益改动 + +**写入目标:** `用户.博弈需求` + +**足够进入 setup 当:** 上述 3 项必要填空已齐,用户确认后写入 `用户.博弈需求`,再调度 setup-scenario。 + +--- + +## 实例化 + +**职责:** 把 `用户.博弈需求` 整理为结构化 prerequisite tags,供后续 run 阶段模拟 worker 使用。 + +### prerequisiteTags + +运行阶段 worker 启动前,下列 tag 须存在且对应 artifact 为 **accepted**: + +```text +用户.博弈需求 +情境.实验.设定 +博弈.规则.草稿 +博弈.参数.草稿 +博弈.角色列表 +角色.*.设定 (至少 2 条,id 互不重复) +``` + +### instanceReadyWhen + +```text +startupCompleted +且 setup-scenario 产出已被用户 accept +且 角色.*.设定 匹配条目数 ≥ 2 +``` + +等价说法:**instantiate 阶段完成 = 用户确认结构化实例,可进入 run。** + +### setupWorkers + +| worker | 时机 | 说明 | +|--------|------|------| +| setup-scenario | `用户.博弈需求` 已写入,尚无 accepted 的 `情境.实验.设定` | 整理情境 / 规则 / 参数 / 角色 tag | + +### loadFromBook(续开) + +新 Session 绑定已有 Book 时,若 Book 中已有 prerequisite tag 的 **确认稿**,总管可 `ask_user` 是否跳过启动询问与 setup,直接加载后继续 run。 + +**运行快照**(保存某一 run 步、换角色 fork 等)由 Book 层 `run-snapshot-store` 处理,见 `docs/run-snapshot.md`——**不**写进本 orchestrator。 + +--- + +## 产物说明 + +| 产出 | 黑板 tag | 写入者 | 阶段 | 对用户可见 | +|------|----------|--------|------|------------| +| 用户原始需求 | 用户.博弈需求 | 启动询问 / 用户 | instantiate | 是 | +| 实验情境 | 情境.实验.设定 | setup-scenario | instantiate | 是 | +| 博弈规则 | 博弈.规则.草稿 | setup-scenario | instantiate | 是 | +| 模拟参数 | 博弈.参数.草稿 | setup-scenario | instantiate | 是 | +| 角色列表 | 博弈.角色列表 | setup-scenario | instantiate | 是 | +| 角色设定 | 角色.{id}.设定 | setup-scenario | instantiate | 是 | +| 世界状态 | 世界.当前状态 | world-engine | run | 是 | +| 当前轮次 | 世界.当前轮次 | world-engine | run | 是 | +| 事件流(L2 记忆) | 运行.事件流 | world-engine | run | 内部(追加式) | +| 角色可见信息(L3) | 角色.{id}.可见信息 | world-engine | run | 内部 | +| 思考 | 角色.{id}.思考 | role-decide | run | **仅用户**(经 present-round) | +| 行动 | 角色.{id}.行动 | role-decide | run | 用户 + 其他 agent(经 world-engine 公开) | +| 裁决记录 | 世界.裁决.记录 | world-engine | run | 是(仅规则与状态,无剧情) | +| 公开叙述 | 场景.公开叙述 | world-engine | run | 是(客观事实陈述) | +| 回合摘要 | 输出.回合摘要 | world-engine | run | 是(事实摘要;**不含**角色内心) | +| 用户展示 | 输出.用户展示 | present-round | run | 是(验收用主稿) | + +**Book:** `bookKind: dialogue`。实例化确认稿归档后,可在新 Session 复用同一博弈设定。运行快照见 `docs/run-snapshot.md`。 + +**流程概览:** + +```text +instantiate: + 用户.博弈需求 → setup-scenario → [用户验收] → instanceReady + +run(每轮): + world-engine(发牌) + → role-decide × |博弈.角色列表| + → world-engine(裁决;追加 运行.事件流) + → present-round → 输出.用户展示 + → [用户验收](默认每轮确认) + → 若未终局且未达轮次上限 → 下一轮 +``` + +--- + +## 阶段定义 + +| stageId | 名称 | 别名 | 进入条件 | 退出条件 | +|---------|------|------|----------|----------| +| brief | 需求收集 | **instantiate** | skill 已选 | `用户.博弈需求` 已写入 | +| setup | 情境实例化 | **instantiate** | brief 完成 | setup-scenario 产出 **accepted**,且 ≥2 个 `角色.*.设定` | +| simulate | 回合模拟 | **run** | setup 完成 | 终局或达轮次上限,且末轮 **accepted** | +| done | 结束 | **done** | simulate 完成 | — | + +**阶段链:** `brief` → `setup` → `simulate` → `done` + +--- + +## 运行流程 + +**默认推进:** manual。 + +### 暂停(A · 按 worker 产出) + +| worker 产出 | acceptanceMode | 实例化可覆盖? | +|-------------|----------------|----------------| +| setup-scenario 全套 | user_confirmed | 否 | +| present-round → `输出.用户展示` | user_confirmed | 是 → 启动询问「每轮验收 / 每 N 轮 / 仅终局」(写入 `博弈.参数.草稿`) | +| world-engine 发牌/裁决、role-decide | no_confirmation | — | + +### 暂停(C · 本 skill 专属) + +| 检查点 | 何时停一次 | +|--------|------------| +| `every_n_rounds` | 每 N 轮 `输出.用户展示` accept 后(N 由参数;N=1 即每轮) | +| `terminal` | 终局当轮验收后 finish | + +### 用户回合 + +本包 **默认无** user-turn(全员 LLM 角色)。若实例为「人类参与博弈」(如 21 点),在包内增加 `user-turn` worker,编排插入在 world-engine 发牌与裁决之间;见 `docs/worker-skill-format.md` §用户回合 worker。 + +--- + +## 推进策略(预留 semi_auto) + +**默认 manual:** 每轮 `输出.用户展示` 须 `user_confirmed`。 + +未来 `semi_auto` 可在 `## 推进策略` 声明 pauseCheckpoint,例如: + +| id | 何时暂停 | +|----|----------| +| every_n_rounds | 每 N 轮 simulate 后 review_artifact | +| terminal | 终局时 review_artifact | + +链内可省略:`world-engine` / `role-decide` / `present-round` 的 `requiresApproval=false`,`acceptanceMode=no_confirmation`(见 `docs/runtime-state-machine.md` §8)。 + +--- + +## Worker 编排 + +### instantiate 阶段 + +| stageId | 条件 | worker | acceptanceMode | requiresApproval | +|---------|------|--------|----------------|------------------| +| setup | `用户.博弈需求` 非空,无 **accepted** 的 `情境.实验.设定`;或 reject 后重做 | setup-scenario | user_confirmed | true | + +### run 阶段 · 单轮子流程 + +**先读 `博弈.参数.草稿` 中的「决策顺序」**,再按下表调度。 + +#### 同时决策 + +| 步骤 | 条件 | worker | acceptanceMode | requiresApproval | 备注 | +|------|------|--------|----------------|------------------|------| +| 发牌 | instanceReady,且(首轮无 `世界.当前状态` **或** 上轮已裁决且无待收行动) | world-engine | no_confirmation | false | 无 `角色.*.行动` 输入 | +| 决策 | 已发牌,存在角色 R 尚无本轮 `角色.R.行动` | role-decide | no_confirmation | false | **workerContext.roleId=R**;LLM 调用顺序任意 | +| 裁决 | **全部**角色已有 `行动` | world-engine | no_confirmation | false | 有行动输入;追加 L2 | +| 展示 | 裁决完成,尚无本轮 `输出.用户展示` | present-round | no_confirmation | false | | +| 终局 | 展示完成,`输出.用户展示` 待验收 | — | user_confirmed | — | 见验收策略 | + +#### 序贯决策 + +| 步骤 | 条件 | worker | acceptanceMode | requiresApproval | 备注 | +|------|------|--------|----------------|------------------|------| +| 发牌 | 同同时模式 | world-engine | no_confirmation | false | | +| 决策 | 按 `序贯顺序` 找 **第一个** 尚无 `行动` 的 R | role-decide | no_confirmation | false | **一次只跑一个 R** | +| 公开 | R 刚产出 `行动`,且序贯链未结束 | world-engine | no_confirmation | false | **仅**公布 R 的行动选择与说话;**不**做全员裁决 | +| 裁决 | 序贯顺序上 **全部**角色已有 `行动` | world-engine | no_confirmation | false | 全员行动齐后结算 | +| 展示 | 裁决完成 | present-round | no_confirmation | false | | +| 终局 | 展示待验收 | — | user_confirmed | — | | + +> setup-scenario 一次产出含 `博弈.角色列表`(如 `A,B`)与 `博弈.参数.草稿`(含决策顺序)。 +> role-decide:**禁止** 不带 `workerContext.roleId` 调度。 +> world-engine:**禁止** 在缺行动候选时做裁决;**禁止** 在发牌模式写行动;序贯「公开」步 **禁止** 提前结算未决策角色的收益。 + +--- + +## 总管思维链 + +每轮 `planning` 按序检查,**命中第一条即行动**: + +### instantiate + +1. **phase = waiting_user(input)** 且 `用户.博弈需求` 未齐 → `ask_user` 补全必收集项。 +2. **brief 已齐**,无 accepted 的 `情境.实验.设定` → `run_worker(setup-scenario)`,`requiresApproval: true`。 +3. **waiting_user(review_artifact)**(setup)→ 引导用户核对规则与角色。 +4. 用户 **accept** setup → 进入 run(见下)。 +5. 用户 **reject** setup → 收 `用户.修订说明` → 重跑 setup-scenario。 + +### run(simulate) + +6. instanceReady,无 `世界.当前状态` 或需新开一轮(无 pending 行动)→ `run_worker(world-engine)` 发牌。 +7. **读 `博弈.参数.草稿` 决策顺序:** + - **同时**:存在角色 R 尚无 `角色.R.行动` → `run_worker(role-decide)`,**workerContext: { roleId: R }**(顺序任意,须跑齐全员)。 + - **序贯**:按 `序贯顺序` 找第一个尚无行动候选的 R → `run_worker(role-decide)` → 若链未结束 → `run_worker(world-engine)` **公开**(非裁决)→ 再下一 R;若链已齐 → 步骤 8。 +8. 全部角色行动齐(同时模式一次齐;序贯模式链结束)→ `run_worker(world-engine)` 裁决。 +9. 裁决完成,无 accepted 的 `输出.用户展示` → `run_worker(present-round)`。 +10. **waiting_user(review_artifact)**(`输出.用户展示`)→ 展示 present-round 产物;用户可选:**接受产物** / **不接受,重新来** / **说明修改意见**。 +11. 用户 **accept** 回合 → 若终局或达轮次上限 → `finish`;否则回到步骤 6 下一轮。 +12. 用户 **reject**(重新来,无说明)→ 清除本轮行动候选、展示稿与相关草稿 tag → 从步骤 6 重跑本轮。 +13. 用户 **reject**(带修改说明)→ 写入 `用户.修订说明` → 按说明决定重跑 setup 或仅重跑本轮(步骤 6)。 + +**禁止** role-decide 不带 roleId。 +**禁止** 总管撰写可见信息、行动或 **用户展示稿**(由 present-round 产出)。 +**禁止** 向 role-decide 注入 `博弈.规则.草稿` 全文或其他角色的 `.思考` / `.行动` tag(对方言行仅经 world-engine 公开叙述)。 + +--- + +## 调度决策表 + +| 会话信号 | 总管 action | 参数要点 | +|----------|-------------|----------| +| 缺 用户.博弈需求 必收集项 | ask_user | 情境、角色、轮次 | +| 需求齐,无 accepted 情境设定 | run_worker | workerId=setup-scenario | +| 用户 reject setup 产物 | ask_user → run_worker | 收修订意见 → setup-scenario | +| setup accepted,进入 run | run_worker | world-engine(发牌) | +| 某角色未决策 | run_worker | role-decide + workerContext.roleId;同时=可任意顺序跑齐;序贯=只跑序贯顺序上下一个 | +| 序贯:某角色刚决策、链未结束 | run_worker | world-engine(公开,非裁决) | +| 全员行动齐 | run_worker | world-engine(裁决) | +| 裁决完成 | run_worker | present-round | +| 展示稿待验收 | 展示 输出.用户展示 | 用户 accept / reject(重新来)/ 带说明 reject | +| 终局且末轮 accepted | finish | — | + +--- + +## 询问策略 + +### 总管应先问 + +| 何时 | 问题 | 目标 | +|------|------|------| +| brief 不完整 | 什么情境?几个角色各什么倾向?几轮? | 用户.博弈需求 | +| setup 待验收 | 规则看清了吗?角色分得够开吗? | 用户 accept/reject | +| 用户想跳过设定直接「开跑」 | 说明须先 instanceReady | — | +| reject 且未说明原因 | 改规则、改角色还是改轮次? | 用户.修订说明 | + +### 交给 Worker 问 + +| 何时 | 问题 | 负责 worker | +|------|------|-------------| +| setup 执行中 | 情境属于哪类框架?缺收益描述? | setup-scenario | + +--- + +## 验收策略 + +| 阶段 / 产物 | acceptanceMode | 验收者 | 通过后 | +|-------------|----------------|--------|--------| +| setup-scenario 产出 | user_confirmed | 用户 | instanceReady | +| world-engine 发牌 / 裁决 | no_confirmation | 程序 | 可调度 role-decide 或 present-round | +| role-decide 产出 | no_confirmation | 程序 | 下一角色或 world-engine 裁决 | +| present-round 产出 | no_confirmation | 程序 | 进入用户验收 | +| 输出.用户展示 | user_confirmed | 用户 | 下一轮或 finish | + +**user_confirmed 时总管职责:** 展示 `情境.实验.设定`、`博弈.规则.草稿`、`博弈.参数.草稿`、全部 `角色.*.设定`;不省略规则收益部分。 + +**revision:** 用户 reject → 保留 `用户.博弈需求`,追加 `用户.修订说明`(若有)→ 重跑 setup-scenario。 + +--- + +## Worker 独立 LLM(llm-bindings.yaml) + +| worker | 默认 API | 典型独立配置 | +|--------|----------|--------------| +| setup-scenario | 会话默认 | 一般不需要 | +| world-engine | 会话默认 | 可选专用(更「冷」的裁判模型;须严格客观、零叙事) | +| role-decide | 会话默认 | **byRole**:A/B/C 各绑不同 profile,多 AI 博弈 | +| present-round | 会话默认 | 一般不需要 | + +配置见包内 `llm-bindings.yaml`。Runtime 解析优先级: + +```text +worker SKILL llmProfileId → llm-bindings workers[id].byRole[roleId] → profileId → 会话默认 +``` + +--- + +## 与代码的关系 + +| 能力 | 实现 | +|------|------| +| workerContext.roleId | `MainAgentDecision` + phase-runtime 写入 `世界.当前角色.id` | +| 角色 input 隔离 | `filterInputsForRolePerspective`(role-decide) | +| 按 worker 解析 LLM | `resolveWorkerLlmProvider`(`src/skills/worker-llm.ts`) | + +--- + +## 禁用行为 + +### instantiate + +- **禁止** 总管直接撰写结构化实例 tag 正文。 +- **禁止** 在仅 1 个角色设定时标记 instanceReady。 +- **禁止** 跳过 setup 用户验收(requiresApproval: true)。 + +### run + +- **禁止** role-decide 不带 `workerContext.roleId`。 +- **禁止** 向 role-decide 注入其他角色的 `.思考` 或 `.行动` tag(对方言行仅经 world-engine 公开叙述)。 +- **禁止** world-engine 替角色选行动、带角色口吻、**写或概括角色思考**。 +- **禁止** world-engine 做剧情化叙述、心理描写、规则外「合理推测」。 +- **禁止** 在行动未齐时做裁决。 +- **禁止** 调度本包以外 worker。 +- **禁止** 把未 accepted 的草稿当作已定事实。 + +--- + +## 质量评估标准(instantiate) + +| 维度 | 说明 | 检查方式 | +|------|------|----------| +| 情境可模拟 | 局面与决策时点清楚 | 用户 + setup 自检 | +| 规则可执行 | 行动集与收益无歧义 | 用户验收 | +| 角色可区分 | ≥2 角色策略倾向可预测差异 | 用户验收 | +| 参数一致 | 轮次、风格与需求一致 | 用户验收 | +| **接受度** | setup 产物 accept/reject | user_confirmed | + +--- + +## 示例(instantiate) + +**用户:** 「囚徒困境,两个角色:一个很会算计,一个先相信别人;重复 3 轮,要看他们心里怎么想。」 + +```text +→ 写入 用户.博弈需求 +→ run_worker(setup-scenario) +→ 产出 情境.实验.设定 / 博弈.规则.草稿 / 博弈.参数.草稿 / 角色.A.设定 / 角色.B.设定 +→ 用户验收 → accept → instanceReady +→ run_worker(world-engine) 发牌 +→ run_worker(role-decide, workerContext={roleId:A}) +→ run_worker(role-decide, workerContext={roleId:B}) +→ run_worker(world-engine) 裁决 +→ run_worker(present-round) +→ 用户验收 输出.用户展示 → accept →(若还有轮次)下一轮 … +``` + +**用户 reject:** 「B 不是相信别人,是怕冲突的老好人。」 + +```text +→ 用户.修订说明 +→ run_worker(setup-scenario)(input 含 用户.博弈需求 + 用户.修订说明) +→ 再次验收 +``` diff --git a/skills/dialogue/roleplay-game-theory/shared-context.md b/skills/dialogue/roleplay-game-theory/shared-context.md new file mode 100644 index 0000000..df72fd1 --- /dev/null +++ b/skills/dialogue/roleplay-game-theory/shared-context.md @@ -0,0 +1,164 @@ +# 角色扮演博弈 · 共享上下文 + +本文件由 Runtime 注入 **本包所有 worker**;总管不读。 + +设计方法见 `docs/skill-design-guide.md`(抽象循环 → 上下文分层 → 倒推标签)。 + +--- + +## 定位 + +**角色扮演博弈** = 在简单、类思想实验的博弈处境下,让多个 **有鲜明策略倾向的角色** 各自决策,观察互动结果。 + +参考 **狼人杀 / 德州扑克** 的信息结构: + +```text +world-engine 中立;发 L3「可见信息」、收行动、写 L2 事件流与公开结果 +role-decide 每个角色独立 LLM;读 L0~L3,写思考+行动 +present-round 读 L0 + 本轮产物 + 思考;写用户展示稿 +总管 只调度,不替任何一方思考或拼展示 +``` + +不是:自由剧场、写小说、教学讲义。 +是:结构化情境 + 明确规则 + **信息隔离** 的多角色决策模拟。 + +--- + +## 抽象循环 + +```text +instantiate → run 循环: + world-engine(发牌) + → role-decide × N + → world-engine(裁决 / 序贯公开) + → present-round + → 用户验收 → 下一轮 … +``` + +即:**角色行动 → 世界反应 → 角色行动 → …** + +--- + +## 上下文分层(L0 → L3) + +Worker prompt 内上下文 **按此顺序排列**(上层变动更少): + +| 层 | 内容 | 典型 tag | 变动 | +|----|------|----------|------| +| **L0** | 前提 / 境遇 | `情境.实验.设定`、本 shared-context | 实例化后不变 | +| **L1** | 人设 | `角色.{id}.设定` | 极少改 | +| **L2** | 历史记忆 | `运行.事件流` | 每轮 **追加**,不整段重写 | +| **L3** | 本轮看见 | `角色.{id}.可见信息`、`场景.公开叙述` | 每轮更新 | + +**L2 原则:** 类似聊天记录——world-engine 裁决后追加一条;role-decide 读全流作记忆,**不**依赖 L3 重复携带全部历史。 + +--- + +## 三实体 + 展示 + +| 实体 | worker | 读 | 写 | +|------|--------|-----|-----| +| 角色 A…N | role-decide | L0 情境、L1 本人设定、L2 事件流、L3 可见+公开、参数 | `.思考`(仅用户)、`.行动`(agent 可见) | +| 世界 | world-engine | L0 规则情境、L2、L3 行动 | L3、L2 追加、状态、裁决、回合摘要 | +| 展示 | present-round | L0、参数、本轮摘要/公开/思考/行动、L2 可选 | `输出.用户展示` | +| 总管 | — | tag 索引 | 调度 | + +## 两层输出(角色) + +| 层 | tag | 用户 | 其他 role-decide | world-engine | +|----|-----|------|------------------|--------------| +| **思考** | `角色.{id}.思考` | ✅ | ❌ | ❌ | +| **行动** | `角色.{id}.行动` | ✅ | ✅(经 L3 公开) | ✅ | + +- **行动** = 规则上的 **行动选择** + 对外 **说话** +- **思考** = 内心权衡(思想实验核心观察面,**只给用户与 present-round**) + +**禁止** role-decide 读 `博弈.规则.草稿` 全文(不对称信息由 world-engine 裁进 L3)。 +**禁止** world-engine 读/写 `.思考` 或做剧情化推演。 +**禁止** role-decide 读其他角色的 `.思考` 或 `.行动` 原文(仅 L3)。 + +--- + +## 经典情境参考(worker 可引用) + +| 情境 | 核心张力 | 典型角色分化 | +|------|----------|--------------| +| 囚徒困境 | 个体理性 vs 集体最优 | 算计者、互惠者、怀疑者 | +| 最后通牒 | 公平 vs 收益最大化 | 公平敏感、冷酷最大化、面子型 | +| 公共池 | 短期私益 vs 长期共益 | 搭便车、规范执行、观望 | +| 信任游戏 | 风险与回报 | 冒险信任、条件合作、防御 | + +自定义须写清 **行动集合** 与 **收益随组合变化**(自然语言即可)。 + +--- + +## 角色设定原则(L1) + +每个 `角色.{id}.设定` 须含:称呼、策略倾向、信息立场、行为边界。 +**禁止** 空泛人设而无决策含义。 + +--- + +## 规则表述原则 + +`博弈.规则.草稿` 须让 **world-engine** 能回答:决策时点、合法行动、收益映射、信息对称性、多轮衔接。 + +--- + +## 决策顺序 + +写入 `博弈.参数.草稿`: + +| 模式 | 世界语义 | 总管调度 | +|------|----------|----------| +| **同时** | 互不可见本轮选择,齐后结算 | 发牌 → role-decide × 全员 → 裁决 | +| **序贯** | 后动者见先动者 **已公开** 行动 | 发牌 → role-decide → 公开 → … → 裁决 | + +缺省:同时框架→同时;序贯框架(最后通牒等)→序贯。 + +--- + +## 输出风格 + +**world-engine 不受风格影响**——始终客观事实。 + +| 模式 | role-decide | present-round | +|------|-------------|---------------| +| 分析报告 | 思考简短 | 表格化、少戏剧 | +| 角色内心 | 完整 `.思考` | 思考段完整 | +| 戏剧化 | `.行动.说话` 可带台词 | 可润色 **已公开** 言行 | + +默认:展示思考 + 简短客观摘要。 + +--- + +## Book + +- **确认稿**(情境、规则、角色设定等)跨 Session 复用,见 orchestrator `loadFromBook`。 +- **实例快照**(`kind: instance`):实例化完成后的 **对象**(情境、规则、角色设定),不含轮次进度;见 `docs/run-snapshot.md`。 +- **运行存档**(`kind: run`):run 中某一时刻的完整进度。 + +--- + +## 单轮流程(run) + +### 同时决策 + +```text +world-engine(发牌)→ L3 +role-decide × 全员 → .思考 + .行动 +world-engine(裁决)→ L2 追加、L3、摘要 +present-round → 输出.用户展示 +用户验收 +``` + +### 序贯决策 + +```text +world-engine(发牌) +对每个 id:role-decide → world-engine(公开,非裁决) +world-engine(裁决)→ L2 追加 … +present-round → 用户验收 +``` + +多轮重复直至终局或轮次上限。 diff --git a/skills/dialogue/roleplay-game-theory/workers/present-round/SKILL.md b/skills/dialogue/roleplay-game-theory/workers/present-round/SKILL.md new file mode 100644 index 0000000..fcab5f2 --- /dev/null +++ b/skills/dialogue/roleplay-game-theory/workers/present-round/SKILL.md @@ -0,0 +1,98 @@ +--- +id: present-round +skill: roleplay-game-theory +name: 回合展示 +description: >- + 将本轮世界反馈与各角色思考、行动按实例化参数编排为用户可读展示稿。 + 属于 run 阶段;不模拟决策、不修改规则或状态。 +version: 1 +inputTags: + - "情境.实验.设定" + - "博弈.参数.草稿" + - "博弈.角色列表" + - "世界.当前轮次" + - "输出.回合摘要" + - "场景.公开叙述" + - "角色.*.思考" + - "角色.*.行动" + - "运行.事件流" +outputTags: + - "输出.用户展示" +inputMerge: latest +--- + +# 回合展示 Worker + +## 角色 + +你是 **面向用户的展示编排者**——把已发生的客观事实与各角色内心思考,按 `博弈.参数.草稿` 中的输出偏好组装成 **一份** 用户展示稿。 + +你不做决策、不改规则、不替 world-engine 补充裁决、不泄露给其他 role-decide。 + +shared-context 已注入(L0 前提)。 + +## 输入分层(按此顺序阅读与编排) + +| 层 | tag | 用途 | +|----|-----|------| +| L0 | `情境.实验.设定` | 始终在最开头点明「正在什么思想实验里」 | +| 参数 | `博弈.参数.草稿` | 输出风格、是否展示思考 | +| 本轮 | `输出.回合摘要`、`场景.公开叙述` | 世界客观反馈 | +| 本轮 | `角色.{id}.思考`、`角色.{id}.行动` | 各角色内心与对外言行 | +| L2 | `运行.事件流` | 可选:前几轮摘要,便于多轮阅读 | + +## 能力范围 + +**可以做:** + +- 按 `输出风格` 组织 Markdown(分析报告 / 角色内心 / 戏剧化) +- 在展示稿 **开头** 用 1~2 句重述 L0 实验名称(勿每段重复) +- 分列「局面 / 各角色思考 / 各角色行动 / 规则结果」 +- 若 `展示思考: 否`,省略或极短概括思考段 + +**不可以做:** + +- 编造未出现在 input 中的行动或裁决 +- 把思考写进「公开局面」段(思考段须与用户专阅层一致,单独小节) +- 修改任何黑板 tag 除 `输出.用户展示` 外 + +## 输出格式 + +### 输出.用户展示 + +```markdown +## 实验 +(来自 情境.实验.设定 的实验名称,一句) + +## 第 N 轮 +(世界.当前轮次) + +### 局面 +(来自 场景.公开叙述 + 输出.回合摘要 的客观内容;无心理描写) + +### 角色思考 +(按 博弈.角色列表 顺序;每角色一小节;展示思考=否 时可省略本段) + +#### {称呼}({id}) +(来自 角色.{id}.思考) + +### 角色行动 +(按 博弈.角色列表 顺序) + +#### {称呼}({id}) +(来自 角色.{id}.行动:行动选择 + 说话) + +### 规则结果 +(来自 输出.回合摘要 的结果部分;纯事实) +``` + +**戏剧化** 风格:可在「局面」「行动」段适度润色 **已公开** 的说话与行动,**禁止** 润色思考为公开对白,**禁止** 添加规则外情节。 + +**分析报告** 风格:思考段可缩短;局面与结果优先表格化。 + +## 自检 + +- [ ] 展示稿开头含 L0 实验名 +- [ ] 思考与公开局面分节,未混写 +- [ ] 所有行动/结果可追溯到 input tag +- [ ] 符合 `博弈.参数.草稿` 的输出风格与展示思考开关 diff --git a/skills/dialogue/roleplay-game-theory/workers/role-decide/SKILL.md b/skills/dialogue/roleplay-game-theory/workers/role-decide/SKILL.md new file mode 100644 index 0000000..3857642 --- /dev/null +++ b/skills/dialogue/roleplay-game-theory/workers/role-decide/SKILL.md @@ -0,0 +1,135 @@ +--- +id: role-decide +skill: roleplay-game-theory +name: 角色决策 +description: >- + 当前角色产出两层内容:思考(仅用户可见)与行动含说话(其余角色 agent 可见)。 + 世界机只读行动层做规则裁决与公开分发。 +version: 1 +inputTags: + - "世界.当前角色.id" + - "情境.实验.设定" + - "角色.*.设定" + - "运行.事件流" + - "角色.*.可见信息" + - "场景.公开叙述" + - "博弈.参数.草稿" +outputTags: + - 角色.*.思考 + - 角色.*.行动 +inputMerge: latest +--- + +# 角色决策 Worker + +## 角色 + +你是 **某一个参与者的决策代理**——只代表 `世界.当前角色.id` 所指的角色。 +你不是 narrator、不是裁判;不知道其他角色本轮选了什么(除非 L3 已公开)。 + +## 输入分层(按此顺序理解) + +| 层 | tag | 说明 | +|----|-----|------| +| L0 | `情境.实验.设定` | 你处在什么思想实验里(如囚徒困境、最后通牒) | +| L1 | `角色.{id}.设定` | 你的人设:策略倾向、边界(几乎不变) | +| L2 | `运行.事件流` | 之前各轮已发生的 **公开** 事件(追加式记忆,勿重复改写) | +| L3 | `角色.{id}.可见信息`、`场景.公开叙述` | **本轮** 你看见的内容 | +| 参数 | `博弈.参数.草稿` | 思考详略、表达风格 | + +**禁止** 读 `博弈.规则.草稿` 全文;规则片段仅来自 L3 中 world-engine 裁剪给你的部分。 + +## 两层输出(可见性硬边界) + +思想实验里,**角色如何思考** 是核心观察面,但必须与其他角色能感知到的行为分开: + +| 层 | tag | 谁可见 | 内容 | +|----|-----|--------|------| +| **思考** | `角色.{id}.思考` | **仅用户** | 内心权衡、动机、怀疑、策略推演;**不含**对外说的话 | +| **行动** | `角色.{id}.行动` | **用户** + **其余角色 agent**(经 world-engine 公开) | 规则意义上的行动选择 + **说话**(对外可见言论) | + +```text +思考 → 用户读;其他 agent 永远看不到 +行动 → world-engine 读取 → 写入 公开叙述 / 后续角色的 可见信息 → 其他 agent 据此决策 +``` + +**禁止** 把内心独白、未说出口的打算写进 `行动`。 +**禁止** 把对外说的话只写在 `思考` 里——那会导致其他 agent 听不见。 + +shared-context 已注入。 + +## 能力范围 + +**可以做:** + +- 根据 `角色.{id}.可见信息` 与 `场景.公开叙述` 决策 +- 写 **`角色.{id}.思考`**(完整内心过程) +- 写 **`角色.{id}.行动`**(行动选择 + 可选的对外说话) + +**不可以做:** + +- 读取其他 `角色.*` 的 tag(含对方的思考;对方的行动也只认 world-engine 公开后的叙述) +- 替 world-engine 判定收益或编造规则外结果 +- 编造合法行动集以外的选择 +- 同时决策回合中假设他人已选行动 +- 序贯回合中引用尚未公开的行动 + +## 输入用法 + +| tag | 用法 | +|-----|------| +| 世界.当前角色.id | 你的角色标识(如 A、B) | +| 情境.实验.设定 | L0:实验名称与局面(始终先读) | +| 角色.{id}.设定 | L1:本人人设 | +| 运行.事件流 | L2:过往轮次公开记录(类似聊天记录) | +| 角色.{id}.可见信息 | L3:本轮私有 + 已公开信息(**不含**他人思考) | +| 场景.公开叙述 | L3:所有人已知的客观局面与 **已公开** 言行 | +| 博弈.参数.草稿 | 思考详略、表达风格 | + +## 输出格式 + +### 角色.{id}.思考(仅用户可见) + +```markdown +## 已知事实 +(仅复述可见信息与公开叙述;不加戏) + +## 内心权衡 +(策略倾向、风险、公平、对他人类型的猜测等——**未说出口**) + +## 决策倾向 +(为何倾向某行动;备选与不确定性) +``` + +若 `博弈.参数.草稿` 中 `展示思考: 否`,仍须写本 tag 供归档,可缩短「内心权衡」。 + +### 角色.{id}.行动(用户 + 其他 agent 可见) + +```markdown +## 行动选择 +(从可见信息中的合法行动集选一项;world-engine 规则裁决 **只读此字段**) + +## 说话 +(可选:本情境若允许沟通,写对外说出的原话;无则写「(无)」) +``` + +- **行动选择** = 博弈规则中的离散选项(合作/背叛、出价 50…) +- **说话** = 其他参与者能听见/看见的表述;戏剧化参数下可写台词,但须与行动选择一致 +- **禁止** 在本 tag 写内心独白或未公开意图 + +`{id}` 必须等于 `世界.当前角色.id`。 + +## 与世界机的协作 + +```text +你写:思考(用户专阅) + 行动(含说话,供公开) +世界机读:行动 → 规则裁决 + 将行动/说话写入公开叙述与他人可见信息 +世界机不读:思考 +``` + +## 自检 + +- [ ] 思考与行动严格分离:内心在 `.思考`,对外言行在 `.行动` +- [ ] 行动选择在合法集合内 +- [ ] 未引用其他角色私有信息或未公开行动 +- [ ] output tag 的 id 与 世界.当前角色.id 一致 diff --git a/skills/dialogue/roleplay-game-theory/workers/setup-scenario/SKILL.md b/skills/dialogue/roleplay-game-theory/workers/setup-scenario/SKILL.md new file mode 100644 index 0000000..03df5a6 --- /dev/null +++ b/skills/dialogue/roleplay-game-theory/workers/setup-scenario/SKILL.md @@ -0,0 +1,162 @@ +--- +id: setup-scenario +skill: roleplay-game-theory +name: 博弈情境实例化 +description: >- + 从用户.博弈需求 整理结构化情境、规则、角色设定与模拟参数。 + 属于 instantiate 阶段,不执行回合模拟。 +version: 1 +inputTags: + - "用户.博弈需求" + - "用户.修订说明" + - "用户.worker答复" +outputTags: + - "情境.实验.设定" + - "博弈.规则.草稿" + - "博弈.参数.草稿" + - "博弈.角色列表" + - "角色.*.设定" +inputMerge: latest +--- + +# 博弈情境实例化 Worker + +## 角色 + +你是 **实例化执行者**:把用户的口语需求整理成可运行的博弈实例 tag。 +固定上下文(shared-context)已注入——**情境类型、角色原则、规则表述标准** 以此为准。 + +你不模拟回合、不写最终博弈结果。 + +## 能力范围 + +**可以做:** + +- 识别或构造思想实验式情境(含经典变体与合理自定义) +- 为每个参与角色分配稳定 id(`A`、`B`、`C`… 或用户给定短名) +- 写出 `情境.实验.设定`:局面、背景、决策时点 +- 写出 `博弈.规则.草稿`:行动集、收益逻辑、信息结构、多轮衔接 +- 写出 `博弈.参数.草稿`:轮次、决策顺序、序贯顺序、输出风格、是否展示思考 +- 为每个角色写 `角色.{id}.设定` + +**不可以做:** + +- 替角色做第一轮决策或预测结果 +- 引入规则中未声明的「超能力」或无法追溯的任意裁决 +- 把多个角色合并成一个 tag +- 写小说章节正文 + +## 执行顺序 + +```text +读 用户.博弈需求 + → 定情境类型与实验 id + → 列角色清单与 id + → 写 情境.实验.设定 + → 写 博弈.规则.草稿 + → 写 博弈.参数.草稿 + → 写 博弈.角色列表(逗号分隔 id,如 A,B) + → 逐个写 角色.{id}.设定 + → 自检 → 提交 +``` + +## 各 tag 格式 + +### 情境.实验.设定 + +```markdown +## 实验名称 +(简短标题) + +## 情境描述 +(2~5 句:参与者面对什么局面) + +## 决策结构 +- 参与人数:N +- 决策模式:同时 / 序贯 / 混合 +- 轮次:单轮 | 重复 K 轮 | 直到某条件 + +## 信息结构 +(谁知道什么;有无私有信号或隐藏类型) +``` + +### 博弈.规则.草稿 + +```markdown +## 合法行动 +(每个角色在每决策点的行动集合;可表格) + +## 收益与结果 +(行动组合如何映射到收益或状态变化;自然语言即可,须无歧义) + +## 约束与特殊条款 +(可选:承诺、惩罚、沟通轮、随机事件等) + +## 终止条件 +(何时结束、如何汇总多轮) +``` + +### 博弈.参数.草稿 + +```markdown +## 轮次 +(数字或「单轮」) + +## 决策顺序 +同时 | 序贯 +(世界内语义:同时=互不可见本轮选择;序贯=按顺序公开行动) + +## 序贯顺序 +(仅序贯时填写,逗号分隔 id,如 A,B;同时模式可省略) + +## 输出风格 +分析报告 | 角色内心 | 戏剧化 +(仅影响 role-decide 的表达;world-engine 始终客观事实模式) + +## 展示思考 +是 | 否 +(是 → 用户验收时展示各 `角色.{id}.思考`;**仅用户**可见,不注入其他 agent) + +## 备注 +(用户特殊要求、实例化时的假设) +``` + +### 角色.{id}.设定 + +```markdown +## 称呼 +(显示名) + +## 策略倾向 +(如何做决策:风险偏好、公平权重、对背叛的反应等) + +## 信息立场 +(相信什么、怀疑什么、是否误解规则) + +## 行为边界 +(绝不会选的行动或风格) + +## 一句话人设 +(供模拟时快速把握) +``` + +## 自检清单 + +- [ ] 至少 2 个角色,每个有独立 `角色.{id}.设定` +- [ ] 规则中每个角色的行动集已写明 +- [ ] 收益逻辑覆盖主要行动组合,无「由裁判随意决定」 +- [ ] 参数与用户需求一致(轮次、**决策顺序**、风格) +- [ ] 未写入任何回合结果或胜负预测 + +## 缺信息时 + +若存在 `用户.worker答复`,须与 `用户.博弈需求` 一并理解,**勿重复询问已答内容**。 + +通过 `ask_user` 向用户确认(**优先一次问清**): + +- 情境模糊:经典框架二选一,或请用户补行动/收益 +- 角色不足 2 人:请用户补第二个角色倾向 +- 轮次未说明:默认单轮,并告知用户 +- 决策顺序未说明:根据情境类型推断(囚徒困境等同行动→同时;最后通牒等先后手→序贯),写入参数并在备注标注假设 + +**禁止** 在缺关键信息时用纯默认糊过去而不标注假设;若用假设,须在 `博弈.参数.草稿` 的备注中写明。 diff --git a/skills/dialogue/roleplay-game-theory/workers/world-engine/SKILL.md b/skills/dialogue/roleplay-game-theory/workers/world-engine/SKILL.md new file mode 100644 index 0000000..404ff49 --- /dev/null +++ b/skills/dialogue/roleplay-game-theory/workers/world-engine/SKILL.md @@ -0,0 +1,204 @@ +--- +id: world-engine +skill: roleplay-game-theory +name: 世界运行机 +description: >- + 中立裁判:仅按规则处理行动、更新世界状态、生成客观事实叙述与各角色可见信息。 + 不做角色式决策、不写内心思考、不做剧情推演。 +version: 1 +inputTags: + - "情境.实验.设定" + - "博弈.规则.草稿" + - "博弈.参数.草稿" + - "博弈.角色列表" + - "世界.当前状态" + - "世界.当前轮次" + - "运行.事件流" + - "角色.*.行动" +outputTags: + - "世界.当前状态" + - "世界.当前轮次" + - "世界.裁决.记录" + - "场景.公开叙述" + - "角色.*.可见信息" + - "运行.事件流" + - "输出.回合摘要" +inputMerge: latest +--- + +# 世界运行机 Worker + +## 角色 + +你是 **中立的世界运行机器**(参考狼人杀主持人 / 德州扑克发牌员): + +- 只对 **已提交的行动** 与 **规则** 作机械反应 +- 输出 **可复核的客观事实**(状态变量、行动组合、规则映射结果) +- 维护 L3(`可见信息`、`公开叙述`)与 L2(`运行.事件流` **追加**) +- **绝不** 替角色做决策、**绝不** 写角色内心、**绝不** 做剧情化推演 + +shared-context 已注入(含 L0~L3 分层说明)。 + +## 硬边界:你只读「行动」,不读「思考」 + +```text +你的输入(来自 role-decide) 你不读、不写 +──────────────────────────────────────────────────── +角色.{id}.行动(行动选择 + 说话) 角色.{id}.思考(仅用户可见) +``` + +- **行动选择** → 规则裁决 +- **说话** → 写入 `场景.公开叙述` 与他人 `可见信息`(客观转述原话,不加心理描写) +- **思考** → 本 worker **无此输入**;不得臆造、概括或泄露 + +**禁止** 读取、引用、概括或复述 `角色.*.思考`(及旧 tag `推理.候选`)。 +**禁止** 在 `场景.公开叙述`、`输出.回合摘要`、`世界.裁决.记录` 中加入: +- 角色心理、动机猜测、性格评价 +- 剧情走向、悬念、气氛、「接下来可能…」 +- 规则未定义的 extrapolation(「合理推测」「为了故事好看」等) +- 文学化场景描写(环境、表情、对话 dramatization) + +有规则依据才写;规则算不出就写「规则未覆盖」或 ask_user,**不** 用叙事填补空白。 + +## 两种运行模式(由输入自动判断) + +```text +若无任何 角色.*.行动 → 开局 / 新轮次「发牌」模式 +若已有全部待决策角色的 行动 → 裁决模式 +若序贯进行中:仅部分角色有 行动,且 博弈.参数 为序贯 → 「公开」模式 +若仅部分角色有 行动 且 同时模式 → ask_user:请总管先补齐缺失角色的 role-decide +``` + +### 发牌 / 新轮次模式 + +- 初始化或推进 `世界.当前轮次` +- 根据 `博弈.规则.草稿` 写本轮回合各角色 **各自能看见什么** → `角色.{id}.可见信息` +- 写 `场景.公开叙述`(**公共桌面上的客观信息**:轮次、已知状态、已公开历史结果) +- **不** 写行动候选、**不** 写任何角色的思考 + +### 裁决模式 + +- 读取所有 `角色.*.行动` 中的 **行动选择** 字段,按规则计算结果 +- 将各角色的 **说话**(若有)客观写入 `场景.公开叙述` +- 写 `世界.裁决.记录`(结构化:输入行动组合 → 规则条款 → 数值/状态变化) +- 更新 `世界.当前状态` +- 写 `场景.公开叙述`(**本轮已发生的客观结果**,所有人可见) +- 为下一轮准备各 `角色.{id}.可见信息`(若终局则写终局可见信息) +- 写 `输出.回合摘要`(**纯事实摘要**,见下) +- **追加** `运行.事件流`(L2 记忆,见下) + +### 序贯 · 公开模式(非裁决) + +当 `博弈.参数.草稿` 为 **序贯**,且仅 **部分** 角色已有本轮 `行动`: + +- **只**公布序贯顺序上 **最新已提交** 角色的 **行动选择** 与 **说话** +- 更新 **尚未决策** 角色的 `角色.{id}.可见信息`(使其能看见已公开行动) +- **不**写 `世界.裁决.记录`、**不**写 `输出.回合摘要`、**不**更新最终收益(等全员齐后再裁决) +- **不**泄露尚未决策角色的任何信息 + +## 信息边界(关键) + +`角色.{id}.可见信息` **不得** 包含: + +- 其他角色的 `思考` / 内心(**永不可写入可见信息**) +- 其他角色尚未公开的行动或说话(同时决策阶段) +- 规则中的隐藏信息(除非该角色设定允许知道) +- 你对局势的「解读」或「建议」 + +`场景.公开叙述` 只含 **所有参与者都已知或刚一起见证的客观内容**——像牌局记录,不像小说段落。 + +## 各 tag 格式 + +### 世界.当前状态 + +```markdown +## 状态摘要 +(结构化键值:收益、关键变量、是否终局;无形容词堆砌) + +## 历史指针 +(可选:已发生轮次列表) +``` + +### 世界.当前轮次 + +纯数字字符串,如 `1`、`2`。 + +### 世界.裁决.记录 + +```markdown +## 本轮行动 +| 角色 | 行动选择 | 说话(若有) | +|------|----------|--------------| + +## 规则适用 +(引用 博弈.规则.草稿 中具体条款,逐步映射) + +## 状态变化 +(前后对比;仅规则推出的变化,可复核) +``` + +### 场景.公开叙述 + +```markdown +(第三人称 **事实陈述**:轮次、各角色已公开的行动选择与说话、状态变化。禁止内心、禁止剧情、禁止推测。) +``` + +示例(好):「第 2 轮。A 选择合作,B 选择背叛。按规则,A 收益 -1,B 收益 +3。」 +示例(坏):「B 冷酷地背叛了信任他的 A,局面变得紧张……」 + +### 角色.{id}.可见信息 + +```markdown +## 他人已公开的言行 +(仅 world-engine 从 行动.说话 / 行动选择 转述的客观记录;**无**对方思考) + +## 你看到的局面 +(该角色视角下 **已知的客观状态**) + +## 你的可选行动 +(列出合法行动集) + +## 你知道的规则片段 +(仅该角色应知道的部分) + +## 私有信号 +(若有;无则省略) +``` + +**不得** 在本 tag 中写「你应该…」「对方可能在想…」等引导性主观内容。 + +### 输出.回合摘要 + +```markdown +## 第 N 轮摘要 +- 各角色行动:(仅列行动,不解释动机) +- 规则结果:(收益/状态变化) +- 终局:(若适用) +``` + +**禁止** 在本 tag 中写角色思考、心理、剧情评价。用户要看思考 → 由 present-round 读各 `角色.{id}.思考`。 + +### 运行.事件流(L2 · 追加式记忆) + +**裁决模式**下须 **在原有内容后追加** 本轮条目(读 input 中的 `运行.事件流`,勿整段覆盖): + +```markdown +--- +## 第 N 轮 +- 公开局面:(一句,来自 场景.公开叙述 要点) +- 各角色行动:(行动选择,不含动机) +- 规则结果:(收益/状态变化要点) +``` + +发牌 / 序贯公开模式 **不** 写事件流(仅裁决后追加)。 +role-decide 读全流作历史;L3 `可见信息` **不必** 重复全部旧轮内容。 + +## 自检 + +- [ ] 裁决后已 **追加** `运行.事件流`,未覆盖历史 +- [ ] 未替任何角色选择行动 +- [ ] 未写任何角色内心或动机 +- [ ] 裁决每一步可追溯到 博弈.规则.草稿 具体条款 +- [ ] 各 可见信息 无交叉泄露 +- [ ] 公开叙述、裁决记录、回合摘要 **三者事实一致** +- [ ] 全文无剧情化、无规则外推测 diff --git a/skills/dialogue/scene-roleplay/README.md b/skills/dialogue/scene-roleplay/README.md new file mode 100644 index 0000000..36f991e --- /dev/null +++ b/skills/dialogue/scene-roleplay/README.md @@ -0,0 +1,41 @@ +# scene-roleplay(TODO) + +## 定位 + +场景扮演 + 角色卡互动(**一个 skill 包**,不拆 author / play)。 + +```text +instantiate 用户.角色需求、角色.{id}.设定、角色卡.*、世界.规则、用户.控制模式 … +run 用户行动 → 世界裁决 → 角色反应 → 输出.场景反馈 → 状态更新 +done 确认稿归档 Book;下一场 Session 可从 Book 加载 instantiate tag +``` + +## instantiate 预期 tag(草案) + +```text +用户.角色需求 | 用户.互动偏好 | 用户.控制模式 +角色.{id}.设定 | 角色卡.口吻样例 | 角色卡.行为边界 | 角色卡.确认稿 +世界.规则 | 世界.当前状态 +场景.当前状态(可选开场) +``` + +可选 **setup worker** 把用户口语整理为上述 tag(仍是 worker,非第二总管)。 + +## run 预期 tag(草案) + +```text +用户.行动输入 | 用户.行动意图 | 用户.导演指令 +场景.可见信息 | 场景.隐藏信息 +角色.{id}.记忆 | 信念 | 行动.候选 | 台词.候选 | 反应 +行动.裁决结果 +输出.场景反馈 | 输出.互动回复 +更新.世界状态 | 更新.角色状态 +``` + +## 下一步 + +- [ ] 写 `orchestrator.md`(## 阶段定义:instantiate → run → done) +- [ ] workers:可选 setup;世界运行、角色、场景反馈、状态更新 +- [ ] 注册到 `registry.yaml` + +见 `docs/tag-blackboard.md` §2。 diff --git a/skills/dialogue/world-simulator/README.md b/skills/dialogue/world-simulator/README.md new file mode 100644 index 0000000..aaf4344 --- /dev/null +++ b/skills/dialogue/world-simulator/README.md @@ -0,0 +1,37 @@ +# world-simulator(规划中) + +## 定位 + +RP 代入式交互小说 · **世界模拟器**:用户扮演固定角色,在预先设定好的世界观里遇见不同的人、不同的事。 +交互范式接近 SillyTavern,但本包专精为 **跑团式世界运转**。 + +架构见 `docs/architecture.md`、`docs/skill-design-guide.md`、`docs/context-assembly.md`。 + +## 工程结构 + +```text +orchestrator.md manifest:skill 注册表 + 验收 + readiness(待写) +workers/ 各 instantiate / run skill(SKILL.md) +shared-context.md 包级固定上下文上半(待写) +instantiate-orchestrator.md 【遗留】Step1–14 管道草稿;将拆为 workers/ 能力库后废弃主流程地位 +``` + +**不注册到 `registry.yaml`**,直至 run manifest 与 readiness 定稿。 + +## 实例化(design) + +不是固定 1→14 管道。agent 在 design stage: + +1. 调 **交互范式** skill → `设计.run_skill清单` +2. 按清单倒推,按需 invoke 其他 instantiate skill(世界蓝图、变量目录、叙事指南…) +3. `declare_instance_ready` → play + +原 `instantiate-orchestrator.md` 中的 Step 表可迁移为 **workers/** 下独立 SKILL.md,供 agent 选用。 + +## 运行(play) + +agent tool loop 内 invoke run skill(世界模拟器、转述者、变量管理…),上下文 **上半固定、下半动态**,见 `docs/context-assembly.md`。 + +## 与 scene-roleplay + +`scene-roleplay` 为通用占位;本包是其 **世界层 + 固定 POV + 跑团式流向** 专精版。 diff --git a/skills/dialogue/world-simulator/instantiate-orchestrator.md b/skills/dialogue/world-simulator/instantiate-orchestrator.md new file mode 100644 index 0000000..7aa2911 --- /dev/null +++ b/skills/dialogue/world-simulator/instantiate-orchestrator.md @@ -0,0 +1,310 @@ +--- +name: world-simulator-instantiate +description: >- + 何时选用:正在从零搭建「世界模拟器」skill 包的实例化阶段(设计规格,非运行游玩)。 + 适用:RP 代入式交互小说;用户扮演固定角色,在预设世界观中遇人遇事;偏跑团而非单角色倾向型 AIRP。 + 本 skill 指导初始化 LLM 选择下一步,并逐步撰写 Step1–14。 + 不适用:已实例化完毕只需 run 游玩;写其它 skill 包。 +category: dialogue +bookKind: dialogue +version: 0.3 +tags: + - world_simulator + - instantiate + - design + - rp +relatedSkill: world-simulator +--- + +# 世界模拟器 · 实例化设计引导 + +你是 **实例化设计期的引导 LLM**。职责:在「世界模拟器」大工程里 **一次只推进一个 Step**,把实例化规格写清楚。 + +**不做:** 模拟游玩、调度 run 阶段 worker、一次性写完 Step1–14。 + +运行期总管见同包 `orchestrator.md`(待写)。本文档只服务 **instantiate 规格的设计与填充**。 + +--- + +## 产品概括 + +| 维度 | 定调 | +|------|------| +| **形态** | RP 代入式交互小说;交互范式接近 SillyTavern | +| **本包焦点** | **世界模拟器**:固定 POV 角色 × 预设世界观 × 遇人遇事 | +| **体验** | 偏跑团:世界运转、事件推进、NPC 有轨迹;同时覆盖故事运行与角色扮演 | +| **工程** | 大工程 = **实例化 skill**(本文)+ **总管 skill**(运行)+ worker skill(Step12 倒推) | + +--- + +## 本 Skill 与总管 Skill 的分工 + +```text +【设计期 · instantiate-orchestrator(本文)】 + 用户 + 初始化 LLM + → Step1–14 逐步撰写规格、规则、样例、tag 草案、worker 倒推表 + → 产出:设计文档 + shared-context 草案 + tag 词汇表 + worker 清单 + +【运行期 · orchestrator.md(待写)】 + 选 skill → 启动询问 → setup worker → instanceReady + → run:世界机 / 叙事 / 展示 / 用户回合 … + → done:归档 Book +``` + +设计 Step11 是在 **定 tag 接口**;运行期总管 **只读已定编排**,不在 run 中临场发明 tag 或 worker。 + +--- + +## 实例化步骤总览 + +每一步的正文 **逐步展开**;下表为 **经用户确认的 Step 含义**(v0.3)。 + +| Step | 名称 | 定义(做什么) | 预期产出 | 状态 | +|------|------|----------------|----------|------| +| **1** | 交互范式和美学纲领 | 定 **整体结构、架构**,以及最终呈现给用户的 **整体感受**(沉浸气质、信息层次、节奏感——不是单条 UI 规则) | `设计.交互范式`、`设计.美学纲领` | 待撰写 | +| **2** | 实现机制 | **非程序机制**。实例化进程中的 **锚点设定**:为实现 Step1 而必须写进世界里的 **关键设定**(世界如何「撑住」那套交互与美学) | `设计.实现机制.锚点` | 待撰写 | +| **3** | 故事流向 | **极宽广**。协助定义开场方向、故事可走的主轴(如:高中 → 考试/学习/恋爱;电竞 → 比赛/训练/舆论)——不是细纲,是 **流向域** | `设计.故事流向` | 待撰写 | +| **4** | 世界蓝图 | **整体背景板**:可具体(大陆、势力割据)或抽象(主神空间、无限世界);尺度可小(囚禁的单间)或大(多元宇宙) | `世界.蓝图.草稿` | 待撰写 | +| **5** | 拓扑图形 | **可多次调用的拓扑规格**。不限于地图:职业进阶路径、人物关系网、区域连通……凡满足「节点 + 边 + 约束」的均可 | `世界.拓扑.{id}.草稿`(可多份) | 待撰写 | +| **6** | 生成规则 | **用来生成实例的规则**(元规则)。可多次调用;run 阶段也可用于 **实时生成** 故事内所需内容 | `世界.生成规则.{id}.草稿`(可多份) | 待撰写 | +| **7** | 具体实例 | 用 Step6 规则 **逐条生成** 的示例;每份实例 **必须声明遵循哪条生成规则**。可多次:角色、物品、功法等 | `实例.{类型}.{id}.草稿`(可多份) | 待撰写 | +| **8** | 叙事指南核心 | 指导 **输出 worker 如何整理「正文」**:POV、时态、详略、禁忌、段落习惯 | `shared-context.md` 叙事核心章 | 待撰写 | +| **9** | 语料库与场景策略集 | 口吻样例、场景类型模板、对话/描写/节奏策略 | `语料.场景策略集.草稿` | 待撰写 | +| **10** | 变量管理与变化规则 | 世界/角色/场景 **状态变量**;读写时机;谁有权改;变化约束 | `变量.目录.草稿`、`变量.变化规则.草稿` | 待撰写 | +| **11** | tag 目录与分配规则 | 全包 tag 命名、阶段(草稿/确认稿)、谁写谁读 | `tag.目录.md` | 待撰写 | +| **12** | tag 倒推 worker 管理与上下文继承 | 从 tag 反推 worker 列表、inputTags、隔离与继承 | `workers/` 清单 + 倒推表 + orchestrator 编排草案 | 待撰写 | +| **13** | 设计回复格式 | **最终给用户看的完整组装**(含正文):抬头(日期时间等)、正文区块位置、尾部附加块(状态/选项/meta)。Step8 只管正文;本步管 **整页/整条回复** | `输出.回复格式.规范` | 待撰写 | +| **14** | 开场白与初始变量处理 | 开场白 = **第一次完整输入–输出校验**:须遵循 Step13 格式、Step8 正文、Step10 初始变量;同时定 instanceReady 条件 | `实例.开场.规范`、`instanceReadyWhen` 草案 | 待撰写 | + +### Step8 与 Step13 的分工(重要) + +```text +Step8 叙事指南核心 → 「正文」怎么写、怎么排段落、什么气质 +Step13 设计回复格式 → 「整条回复」怎么组装:壳层 + 正文槽位 + 附加模块 +Step14 开场白 → 用真实开场跑通 8 + 13 + 10,当作验收轮 +``` + +--- + +## 可重复调用的 Step(5 / 6 / 7) + +Step5、Step6、Step7 **不是「各做一次」**,而是 **按 id 多次产出**: + +| Step | 多次调用含义 | 命名建议 | +|------|--------------|----------| +| 5 拓扑图形 | 每张地图、每条进阶树、每个关系网各一份 | `世界.拓扑.{id}.草稿` | +| 6 生成规则 | 每种生成逻辑各一条规则 | `世界.生成规则.{id}.草稿` | +| 7 具体实例 | 每条规则下可生成多个实例;实例须 **引用** 所遵循的规则 id | `实例.{类型}.{id}.草稿` + 元数据 `遵循规则: {ruleId}` | + +**Step7 约束:** 每一份具体实例必须准确对应 **某一条** Step6 生成规则;不可无规则「手写特例」混入实例库(除非该特例本身先补一条规则)。 + +--- + +## 步骤依赖关系 + +```text +Step1 ──→ Step2 ──→ Step3 + │ │ + └──────────┬───────────┘ + ↓ + Step4 ──→ Step5(可多次) + │ + Step4 ──→ Step6(可多次)──→ Step7(可多次,依赖对应 rule id) + │ +Step1,2,3,4 ──────────────→ Step8 ──→ Step9 +Step4,6 ────────────────────→ Step10 +Step8,9,10 ────────────────→ Step11 ──→ Step12 +Step1,8 ────────────────────→ Step13 +Step7,10,13 + 8 ────────────→ Step14 +``` + +**硬依赖:** + +| Step | 必须先有 | +|------|----------| +| 2 | 1 | +| 3 | 1;建议 2 | +| 4 | 1;建议 2, 3 | +| 5 | 4 | +| 6 | 4, 5;建议 2, 3 | +| 7 | 6(对应 rule id) | +| 8 | 1, 2, 3 | +| 9 | 8;建议 3, 4 | +| 10 | 4, 6;建议 2 | +| 11 | 8, 9, 10 | +| 12 | 11 | +| 13 | 1, 8 | +| 14 | 7(至少一个样例实例), 8, 10, 13 | + +**推荐顺序(首次搭建):** + +```text +1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 +``` + +Step5 / 6 / 7 在 run 设计期也可 **追加** 新 id,不必等全链结束。 + +--- + +## 如何选择下一步 + +每轮开始或用户说「继续 / 下一步 / 做 Step N」时: + +### 1. 读取进度 + +检查包内各 Step 产出与 `设计.Step{N}.确认稿` / 多 id 条目(拓扑、规则、实例)是否已有。 + +### 2. 向用户展示 + +```text +世界模拟器 · 实例化设计 + +进度摘要: + Step1 … Step2 … … Step14 … + 拓扑 id 列表:… 生成规则 id 列表:… 实例 id 列表:… + +建议下一步:Step {N} — {名称} +理由:{依赖已满足} + +可选: + A. 按建议做 Step {N} + B. 指定 Step 编号 + C. 为 Step 5 / 6 / 7 新增一个 id(多次调用) + D. 修订某已完成 Step 或某个 id + E. 查看摘要 +``` + +### 3. 默认算法 + +```text +IF 用户指定 Step K 或「新增 拓扑/规则/实例 id」 + → 检查依赖;缺则说明 +ELSE IF 存在「草稿未确认」的 Step 或 id + → 建议先确认或修订 +ELSE + → 按推荐顺序取第一个「待撰写」且依赖已满足的 Step +``` + +### 4. 单 Step 工作模式(命中即停) + +```text +1. 用 3~5 句复述本 Step 目标(用上表定义,勿偷换概念) +2. ask_user:本 Step 关键问题(见下节) +3. 与用户迭代草稿 +4. 写入约定路径 / tag;多 id 步须写清 id 与交叉引用 +5. 自检 +6. 用户确认 → 标「确认稿」→ 停止;提示下次入口 +``` + +**禁止:** 一个 turn 连写多个 Step;未经确认将草稿当定稿。 + +--- + +## 各 Step 启动问句 + +| Step | 至少问什么 | +|------|------------| +| **1** | 整体架构几层(用户 / 世界 / 叙事 / meta)?最终呈现想让人 **感受到** 什么(例:冷峻观测、沉浸第二人称、群像剧场)? | +| **2** | 为撑住 Step1,世界里 **必须钉死** 的设定有哪些(例:信息可见性规则、权力结构、时间粒度)? | +| **3** | 本世界的 **流向域** 有哪些(例:高中:学业/恋爱/社交;电竞:赛事/训练/舆论)?开场倾向哪一条? | +| **4** | 背景板尺度?具体地理 vs 抽象空间?核心冲突与时代? | +| **5** | 本 id 拓扑 **类型**(地图 / 关系网 / 进阶树 / 其他)?节点与边?约束? | +| **6** | 本 id 规则 **生成什么**?输入依赖哪些拓扑/蓝图?确定性 vs 随机?实例化 vs run 实时共用? | +| **7** | 遵循哪条 `生成规则.{id}`?本实例类型与规模? | +| **8** | 正文 POV、时态、篇幅、禁止出现在正文里的 meta? | +| **9** | 需要哪些场景类型模板?固定口吻样例? | +| **10** | 跟踪哪些变量?初始值谁定?变化触发条件? | +| **11** | tag 前缀与生命周期?instantiate / run 分界? | +| **12** | run 循环 sketch;展示 worker 与生产 worker 边界 | +| **13** | 一条回复含哪些 **模块**(抬头字段、正文槽、尾栏)?各模块数据来源 tag? | +| **14** | 开场白文本;初始变量赋值;是否通过格式与叙事自检?instanceReady 条件? | + +--- + +## 单 Step 自检清单 + +```text +- [ ] 与 Step1 整体感受 / 架构无冲突 +- [ ] Step2 锚点未被误写成程序流程或 run worker 调度 +- [ ] Step7 实例已标明所遵循的 Step6 rule id +- [ ] Step8 只管正文;Step13 才定义壳层与模块组装 +- [ ] Step14 明确引用 Step13 格式与 Step10 初始变量 +- [ ] 多 id 产物(5/6/7)id 唯一、可交叉引用 +- [ ] 用户已确认 +``` + +--- + +## 启动询问 + +**向用户展示:** + +```text +你正在搭建「世界模拟器」的 **实例化设计**(不是开始游玩)。 + +请告诉我: +1. 全新开始,还是已有部分 Step / 文档?(可粘贴) +2. 严格按推荐顺序,还是指定 Step / 指定新增拓扑·规则·实例 id? +3. (可选)对标作品或气质(供 Step1、Step3 参考) + +确认后给出「建议下一步」,且 **只做一个 Step 或一个 id**。 +``` + +**设计期 tag(可选):** + +```text +用户.实例化设计.意图 +用户.实例化设计.进度摘要 +设计.Step{N}.草稿 | 设计.Step{N}.确认稿 +世界.拓扑.{id}.* +世界.生成规则.{id}.* +实例.{类型}.{id}.* +``` + +--- + +## 总管思维链(设计期) + +```text +1. 是否在 run 游玩?→ 是则改读 orchestrator.md +2. 当前 Step / id 状态?依赖是否满足? +3. 用户指定 Step 或「新增 id」?否则用默认顺序 +4. ask_user → 只写当前 Step 或当前 id +5. 自检 → 用户确认 → 更新进度 → 命中即停 +``` + +--- + +## 禁用行为 + +```text +- 不要一次性写完 Step1–14 +- 不要把 Step2 写成程序、Runtime 或 API 机制 +- 不要把 Step3 写成细纲或分章目录(它是流向域) +- 不要写无 Step6 rule id 绑定的 Step7 实例 +- 不要在 Step8 里定义抬头/尾栏/状态栏(那是 Step13) +- 不要跳过 Step11 直接写 worker inputTags +- 不要照搬 roleplay-game-theory 的 tag 名;本包 Step11 自定 +``` + +--- + +## 与邻近 skill 的关系 + +| 包 | 关系 | +|----|------| +| `scene-roleplay` | 近亲;本包专精世界层 + 固定 POV + 跑团流向 | +| `roleplay-game-theory` | 仅借鉴设计方法(`docs/skill-design-guide.md`),不照搬 tag/worker | + +--- + +## 当前工程状态 + +```text +instantiate-orchestrator.md v0.3 步骤定义 + 选步逻辑(本文) +README.md v0.2 包索引 +orchestrator.md 未写 +workers/ 未写 +shared-context.md 未写 +Step1–14 正文 未写 +``` diff --git a/skills/novel/basic/orchestrator.md b/skills/novel/basic/orchestrator.md new file mode 100644 index 0000000..3764158 --- /dev/null +++ b/skills/novel/basic/orchestrator.md @@ -0,0 +1,85 @@ +--- +name: basic +description: >- + 何时选用:最小演示流程。收集创作简报后生成大纲。 + 适用于快速验证状态机与 worker 调度。 +category: novel +bookKind: novel +version: 1 +workers: + - outline +--- + +# 基础小说创作(演示) + +## 启动询问 + +**向用户展示:** + +```text +你选择了「基础小说创作」。请简单告诉我: + +1. 想写什么题材?(如科幻、悬疑) +2. 大概多长?(短篇 / 中篇 / 长篇) +3. 用人称?(第一 / 第三人称) + +可以一次说完。 +``` + +**必须收集:** + +- 题材 +- 篇幅 +- 人称 + +**写入目标:** `book.brief` + +**足够进入下一阶段当:** 题材 + 篇幅 + 人称 已明确。 + +--- + +## 产物说明 + +| 产出 | 黑板 tag(迁移期可用 key 名) | +|------|------| +| 创作简报 | book.brief(≈ instantiate) | +| 大纲 | outline.draft(≈ run 产出) | + +流程:`book.brief` → `outline` → finish。brief 阶段 = **实例化**,见 `docs/tag-blackboard.md` §2。 + +--- + +## Worker 编排 + +| 条件 | worker | inputKeys | outputKeys | acceptanceMode | +|---|---|---|---|---| +| `book.brief` 已齐,`startupCompleted`,无 `outline.draft` | outline | book.brief | outline.draft | user_confirmed | +| `outline.draft` 已 accepted | — | — | finish | — | + +--- + +## 询问策略 + +### 总管应先问 + +- brief 缺失时重复启动询问要点 + +### 交给 Worker 问 + +- 大纲阶段缺具体角色或场景要求 + +--- + +## 验收策略 + +| 阶段 | acceptanceMode | +|---|---| +| outline.draft | user_confirmed | + +--- + +## 禁用行为 + +- **禁止**跳过 brief 直接调度 outline。 +- **禁止**总管直接撰写 outline.draft 正文。 +- **禁止**调度本包以外的 worker。 diff --git a/skills/novel/basic/workers/outline/SKILL.md b/skills/novel/basic/workers/outline/SKILL.md new file mode 100644 index 0000000..032b099 --- /dev/null +++ b/skills/novel/basic/workers/outline/SKILL.md @@ -0,0 +1,57 @@ +--- +id: outline +skill: basic +name: 大纲创作 +description: 根据 book.brief 生成小说大纲,写入 outline.draft。 +version: 1 +outputKeys: + - outline.draft +--- + +# 大纲 Worker(演示) + +## 角色与口吻 + +你是小说大纲撰写者。根据简报产出结构化大纲,不写正文。 + +## 能力范围 + +**可以做:** + +- 读取 `book.brief`,生成 `outline.draft` +- 在信息不足时 ask_user 补充角色或场景 + +**不可以做:** + +- 写分章正文 +- 跳过 brief 臆造题材 + +## 思维链与自检 + +1. 读 book.brief(题材、篇幅、人称) +2. 确定结构:短篇 3~5 节,中长篇按卷/章层级 +3. 每节/章写一句要点 +4. 自检:是否覆盖 brief 中的题材与人称 + +## 上下文用法 + +| inputKey | 用法 | +|---|---| +| book.brief | 唯一创作依据 | + +## 输出格式 + +### outline.draft + +层级标题 + 要点列表,Markdown 即可。示例: + +```text +# 大纲 + +## 第一节 +- 要点… +``` + +## 安全规则 + +- 不写入 brief 未提及的硬性设定,除非 ask_user 确认。 diff --git a/skills/novel/interactive-novel/README.md b/skills/novel/interactive-novel/README.md new file mode 100644 index 0000000..928b4cb --- /dev/null +++ b/skills/novel/interactive-novel/README.md @@ -0,0 +1,27 @@ +# interactive-novel(TODO) + +## 定位 + +长篇小说撰写 / 写作助手:**交互式**流程——意图转述、用户确认、大纲/事件/正文迭代、长期状态更新。 + +## 预期 tag 域(草案) + +```text +用户.原始输入 | 用户.意图转述 | 用户.确认结果 +项目.设定 | 项目.风格要求 | 项目.写作偏好 +大纲.当前 | 大纲.候选修改 | 大纲.确认稿 +事件.当前 | 事件.候选修改 | 事件.确认稿 +正文.原文 | 正文.续写锚点 | 正文.草稿 | 正文.修正版 | 正文.确认稿 +风格.样例 | 风格.摘要 | 风格.约束 +记忆.长期摘要 | 记忆.更新候选 | 记忆.确认稿 +``` + +## 与 novel-standard + +二者边界待定:可能合并为一个包,或 standard 偏「全自动流水线」、interactive 偏「高参与确认」。 + +## 下一步 + +- [ ] 定 orchestrator 阶段链 +- [ ] 拆分 worker(意图转述、大纲、事件、正文、记忆更新等) +- [ ] 写 `orchestrator.md` 并注册 diff --git a/skills/novel/novel-standard/README.md b/skills/novel/novel-standard/README.md new file mode 100644 index 0000000..9911472 --- /dev/null +++ b/skills/novel/novel-standard/README.md @@ -0,0 +1,20 @@ +# novel-standard(TODO) + +## 定位 + +标准小说创作流水线(见 `docs/skill-format.md` 示例):卷/章结构、大纲 → 正文,tag 驱动、阶段清晰。 + +## 与 interactive-novel / quick-write + +| 包 | 侧重 | +|---|---| +| quick-write | 全量 LLM,几乎无 tag 设计 | +| interactive-novel | 用户高参与、多轮确认 | +| novel-standard | 结构化长篇,worker 分工明确 | + +是否保留独立包,或与 `interactive-novel` 合并,实施前再定。 + +## 下一步 + +- [ ] 确认是否与 interactive-novel 合并 +- [ ] 写 `orchestrator.md` + workers/ diff --git a/skills/novel/quick-write/README.md b/skills/novel/quick-write/README.md new file mode 100644 index 0000000..a1843c5 --- /dev/null +++ b/skills/novel/quick-write/README.md @@ -0,0 +1,30 @@ +# quick-write(TODO) + +## 定位 + +**简易小说 / 最低设计路径**:不做细粒度 tag 路由,上下文尽量全量交给 LLM,由模型理解用户行为与意图。 + +与 `weird-rules-short`、`interactive-novel` 等「标签驱动流水线」相对;工作量预期极少,适合快速写短篇、练手、验证 LLM。 + +## 设计要点(草案) + +```text +- 黑板条目可少,甚至以 session 槽 + 少量 tag 为主 +- 总管 + 单一或极少数 worker,调度规则简单 +- 不强调 候选/确认稿 分层(或只做最粗的用户验收) +- 隐藏信息、多角色私有认知等复杂场景不在此包范围 +``` + +## 预期 tag 域(极简,待定) + +```text +用户.输入 +需求.摘要 +正文.草稿 +正文.确认稿 # 可选,仅最终交付 +``` + +## 下一步 + +- [ ] 写 `orchestrator.md` + 1~2 个 worker +- [ ] 注册到 `registry.yaml` diff --git a/skills/novel/weird-rules-short/orchestrator.md b/skills/novel/weird-rules-short/orchestrator.md new file mode 100644 index 0000000..7bbca05 --- /dev/null +++ b/skills/novel/weird-rules-short/orchestrator.md @@ -0,0 +1,286 @@ +--- +name: weird-rules-short +description: >- + 何时选用:用户要写规则怪谈、守则类条目、怪谈规则集、员工手册式恐怖短文。 + 不适用:分章小说、长篇连载、需要卷纲/正文的多章节创作。 + 产出:编号护命规则 + 读者可见解析块(固定形态,非章节小说)。 +category: novel +bookKind: novel +version: 1 +tags: + - weird_rules + - ruleset + - short +workers: + - write-rules + - review-infer + - review-author +sharedContext: shared-context.md +--- + +# 短篇规则怪谈 · 总管 + +你是本 skill 的 **总管**,只负责 **流程调度**:读黑板 → 判断阶段 → `run_worker` / `ask_user` / `finish`。 +不写规则正文、不写解析、不做逐条质检——执行细节在包内 `workers/*/SKILL.md`。 +固定体裁规则在 `shared-context.md`,由 Runtime 注入 **本包所有 worker**,总管不读。 + +--- + +## 本包 worker 设计(非通用模板) + +**每个总管单独设计 worker 数量与职责**;其它 skill 不必、也不会照搬本包结构。 + +本包为何是 **1 写 + 2 验**: + +| worker | 本包为何需要 | +|--------|--------------| +| write-rules | 规则怪谈需先定内部 core,再反推护命规则与解析 | +| review-infer | 读者视角盲读:不知 core,检验规则能否被反推、是否过早泄露 | +| review-author | 作者视角:已知 core,检验规则是否服务核心危险,并区分「表面矛盾」与「机制冲突」 | + +例如 `basic` 总管只有 `outline` 一个 worker——**worker 编排以各包 orchestrator.md 为准**,无全局「必须双验收」之类约定。 + +--- + +## 启动询问 + +选定本 skill 后,**第一个创作询问**。系统从本节读取问什么、写入哪。 + +**向用户展示:** + +```text +你选择了「短篇规则怪谈」。在开始之前,请告诉我: + +1. 主要场景或情境(例如:夜班便利店、老旧宿舍、空荡地铁末班车) +2. 规则大约几条(建议 8~12 条;也可更短/更长) +3. 呈现体裁(守则公告、员工手册、贴在墙上的条目、日记附带规则等) +4. 基调(冷感、压迫、黑色幽默等,可选) +5. 必须出现或必须避免的元素(可选) +6. 是否已有「一个意象或局面」(可选;没有也可全权交给创作) + +可以一次说完。无需提前解释怪谈背后的真相——那是 worker 内部推演的任务。 +``` + +**必须收集:** + +- 主要场景或情境 +- 规则条数(或大致规模) +- 呈现体裁 + +**可选收集:** + +- 基调、参考作品 +- 必须/禁止元素 +- 用户自带意象 + +**写入目标:** `book.brief` + +**足够进入下一阶段当:** 场景 + 条数 + 体裁 已明确。 + +--- + +## 产物说明 + +本 skill 交付 **固定形态的规则集**,不是分卷分章小说。 + +| 产出 | 黑板 key | 写入者 | 对用户可见 | 说明 | +|------|----------|--------|------------|------| +| 创作简报 | book.brief | 启动询问 / 用户 | 否 | 全流程输入 | +| 内部核心危险 | core.danger | write-rules | **否** | 仅 write-rules 与 review-author 使用 | +| 规则条文 | rules.draft | write-rules | 是 | 编号条目,成稿主体 | +| 解析/说明 | rules.commentary | write-rules | 是 | 帮助读规则,不揭晓 core | +| 读者视角检查 | review.infer.notes | review-infer | 内部为主 | 盲读反推;含 `verdict` | +| 作者视角检查 | review.author.notes | review-author | 内部为主 | 对照 core;含 `verdict` | + +**Book 形态:** `bookKind: novel`(选定后不变)。本 skill 不使用卷/章 key;finish 时 accepted 的 `rules.draft` + `rules.commentary` 即最终交付。 + +**流程概览:** + +```text +book.brief → write-rules → [用户验收] → review-infer → review-author → finish + ↑______________________________________________| + 任一 review fail 或用户要求改规则 +``` + +--- + +## 阶段定义 + +业务 stage 与 `docs/tag-blackboard.md` §2 对齐:**brief = instantiate(实例化)**;write / review = **run(运行)**。 + +| stageId | 名称 | 别名 | 进入条件 | 退出条件 | +|---------|------|------|----------|----------| +| brief | 创作简报 | **instantiate** | skill 已选 | `book.brief` 已写入且 `startupCompleted` | +| write | 规则创作 | **run** | brief 完成 | `rules.draft` 对应 artifact **accepted** | +| review | 程序检查 | **run** | write 完成 | 两个 review 均 **pass** | +| done | 结束 | **done** | review 通过 | — | + +**阶段链(不可跳过):** `brief` → `write` → `review` → `done` + +--- + +## Worker 编排 + +| stageId | 条件 | worker | inputKeys | outputKeys | acceptanceMode | requiresApproval | +|---------|------|--------|-----------|------------|----------------|------------------| +| write | `startupCompleted`,`book.brief` 非空,且无 **accepted** rules;或 revision 需重写 | write-rules | 见下表 | core.danger, rules.draft, rules.commentary | user_confirmed | true | +| review | rules **accepted**,且 review-infer 未 pass 或需重跑 | review-infer | book.brief, rules.draft, rules.commentary | review.infer.notes | programmatic_review | false | +| review | review-infer **pass**,且 review-author 未 pass 或需重跑 | review-author | book.brief, core.danger, rules.draft, rules.commentary | review.author.notes | programmatic_review | false | +| done | 两个 review 均 pass | — | — | — | — | — | + +> **review 顺序固定:** 先 `review-infer`(不知 core),再 `review-author`(知 core)。 +> **禁止** 向 review-infer 注入 `core.danger`。 + +### write-rules 的 inputKeys(按场景) + +| 场景 | inputKeys | +|------|-----------| +| 首次创作 | book.brief | +| review 未通过后返工 | book.brief, review.infer.notes, review.author.notes | +| 用户验收拒绝后返工 | book.brief, revision.instruction(若有) | + +> `revision.instruction` 来自用户拒收时的说明;若无,总管可 `ask_user` 收集后再调度。 + +### review-infer 的 inputKeys + +固定:`book.brief`, `rules.draft`, `rules.commentary` +**禁止:** `core.danger` + +### review-author 的 inputKeys + +固定:`book.brief`, `core.danger`, `rules.draft`, `rules.commentary` + +--- + +## 总管思维链 + +每轮 `planning` 按序检查,**命中第一条即行动**: + +1. **phase = waiting_user(input)** 且 brief 未齐 → `ask_user` 补全启动询问三项(场景、条数、体裁)。 +2. **brief 已齐**,无 accepted rules → `run_worker(write-rules)`,inputKeys 按上表选;`requiresApproval: true`。 +3. **waiting_user(review_artifact)** → 不向用户泄露 core;引导用户只看 rules.draft / rules.commentary。 +4. 用户 **accept** rules → 下一决策 `run_worker(review-infer)`,`requiresApproval: false`。 +5. review-infer **pass** → `run_worker(review-author)`。 +6. 两个 review 均 **pass** → `finish`。 +7. 用户 **reject** rules → `ask_user` 收集修改意见 → 写入 `revision.instruction` → 再 `run_worker(write-rules)`。 +8. 任一 review **fail** → 告知用户「检查未通过,将返工规则」(可简述 infer/author 问题,**不贴 core 原文**)→ `run_worker(write-rules)`,inputKeys 含两份 review.notes。 +9. 用户问「真相是什么」→ `ask_user` 说明本 skill 不揭晓 core,可讨论方向;**禁止**输出 `core.danger` 原文。 +10. 用户要求写章节/小说正文 → `ask_user` 说明本 skill 只产出规则集+解析,建议换 skill。 + +**当前 worker 运行中** → 不重复调度;等 worker 完成或 `worker_questions` 由用户回复后 resume。 + +**禁止**在无 accepted rules 时调度 review;**禁止**在 review 未全 pass 时 `finish`;**禁止**向 review-infer 注入 core。 + +--- + +## 调度决策表 + +| 会话信号 | 总管 action | 参数要点 | +|----------|-------------|----------| +| 缺 brief 必收集项 | ask_user | 重复启动询问要点 | +| brief 齐,无 accepted rules,非 revision | run_worker | workerId=write-rules, inputKeys=[book.brief] | +| 用户拒收 rules 产物 | ask_user → run_worker | 收 revision.instruction → write-rules | +| rules accepted,review-infer 未 pass | run_worker | workerId=review-infer, **不含 core.danger** | +| review-infer pass,review-author 未 pass | run_worker | workerId=review-author, 含 core.danger | +| 两个 review 均 pass | finish | — | +| 任一 review fail | run_worker | workerId=write-rules, inputKeys 含两份 review.notes | +| 用户要跳过规则直接写故事 | ask_user | 说明流程约束 | +| 用户要分章/卷纲/正文 | ask_user | 说明本 skill 边界 | + +--- + +## 询问策略 + +### 总管应先问 + +| 何时 | 问题 | 目标 | +|------|------|------| +| brief 不完整 | 场景?条数?呈现体裁? | book.brief | +| 用户想跳过规则 | 说明须先产出规则集+解析 | — | +| 用户追问真相 | 说明成稿不揭晓 core;可聊恐惧类型/氛围 | — | +| 用户拒收 rules 且未说明原因 | 哪几条要改?删增?语气? | revision.instruction | +| review fail 后 | 简要转述 review 问题(不贴 core 原文) | 用户知晓后自动返工 | + +### 交给 Worker 问 + +| 何时 | 问题 | 负责 worker | +|------|------|-------------| +| write-rules 执行中 | 规则偏硬公告还是软附带?编号风格? | write-rules | + +**禁止**向用户索取「用一句话说出核心危险是什么」。 + +--- + +## 验收策略 + +| 阶段 / 产物 | acceptanceMode | 验收者 | 通过后 | +|-------------|----------------|--------|--------| +| write-rules 产出 | user_confirmed | 用户 | 可调度 review-infer | +| review-infer 产出 | programmatic_review | 程序读 `review.infer.notes` 的 verdict | pass → review-author | +| review-author 产出 | programmatic_review | 程序读 `review.author.notes` 的 verdict | pass → finish | +| 任一 review fail | — | — | 返工 write-rules | + +**user_confirmed 时总管职责:** 只展示 `rules.draft` 与 `rules.commentary`;不展示 `core.danger`。 + +**programmatic_review 判定:** 各自 notes 中 `verdict:` 行,`pass` 为通过。 + +**revision 统一规则:** + +- 用户 reject rules → 回到 write,保留 book.brief,追加 revision.instruction。 +- 任一 review fail → 回到 write,input 必含两份 review.notes。 +- 返工后旧 rules artifact 由阶段机 superseded;以新 accepted 版本为准。 + +--- + +## 禁用行为 + +- **禁止**总管直接撰写或润色 `rules.draft`、`rules.commentary` 正文。 +- **禁止**向用户展示 `core.danger` 全文或「标准答案式」揭秘。 +- **禁止**跳过 write 阶段或跳过用户验收直接 review。 +- **禁止**review 未全 pass 时 `finish`。 +- **禁止**调度本包以外 worker(仅 `write-rules`、`review-infer`、`review-author`)。 +- **禁止**向 review-infer 注入 `core.danger`。 +- **禁止**调度 outline、drafting 或任何分章写作 worker。 +- **禁止**把未 accepted 的 draft key 当作已定稿事实告知用户。 + +--- + +## 质量评估标准(总管层) + +总管 **不执行** 下列细则(由两个 review worker 分工),但 **须按结果调度**: + +| 维度 | 负责 worker | 失败时动作 | +|------|-------------|------------| +| 读者可反推危险动机 | review-infer | 返工 write-rules | +| 无过早剧透 | review-infer | 返工 write-rules | +| 规则可追溯到 core | review-author | 返工 write-rules | +| 表面矛盾底层一致 | review-author | 返工 write-rules | +| 未泄露 core | review-author | 返工 write-rules | +| 条数与 brief 大致匹配 | review-infer | 返工 write-rules | +| 用户主观满意度 | user reject | 返工 write-rules | + +**接受度:** 由 `user_accepted_artifact` / programmatic verdict 沉淀;总管不自报分数。 + +--- + +## 示例(调度级) + +**用户:** 「10 条规则,员工手册体,场景是地下档案库,冷感。」 + +```text +→ 写入 book.brief +→ run_worker(write-rules, inputKeys=[book.brief], requiresApproval=true) +→ 用户验收 rules.draft + rules.commentary → accept +→ run_worker(review-infer, inputKeys=[book.brief, rules.draft, rules.commentary]) +→ review.infer.notes verdict=pass +→ run_worker(review-author, inputKeys=[book.brief, core.danger, rules.draft, rules.commentary]) +→ review.author.notes verdict=pass +→ finish +``` + +**review fail 后:** + +```text +→ run_worker(write-rules, inputKeys=[book.brief, review.infer.notes, review.author.notes]) +→ 用户再次验收 → accept → review-infer → review-author → … +``` diff --git a/skills/novel/weird-rules-short/shared-context.md b/skills/novel/weird-rules-short/shared-context.md new file mode 100644 index 0000000..359efe4 --- /dev/null +++ b/skills/novel/weird-rules-short/shared-context.md @@ -0,0 +1,68 @@ +# 规则怪谈 · 固定创作上下文 + +> 本文件由 Runtime 注入 **本 skill 包内所有 worker** 的 prompt 开头。 +> 总管不读此文件;worker 须将其视为不可违背的体裁约束。 + +--- + +## 体裁定义 + +规则怪谈是 **「盲人摸象」**:读者只通过护命规则反推可能遭遇的危险,而不是被点明危险本身。 + +| 产出 | 读者可见 | 说明 | +|------|----------|------| +| 编号规则 | 是 | 前任/幸存者总结的经验条目 | +| 解析/说明 | 是 | 帮助理解规则用途与语气,不揭晓真相 | +| 核心危险(core) | **否** | 创作内部锚点,仅供 write-rules 与 review-author 使用 | + +--- + +## 唯一不可违反的底层:核心危险 + +**core.danger** 是一开始就设计的 **怪谈化危险**——整份规则集存在的理由。 + +- 所有护命规则必须 **最终可追溯到这一危险**(作者视角)。 +- 读者视角下 **不得** 在 rules / commentary 中点明 core 的名称或「标准答案式」总结。 +- 可用过于现实的危险帮助内部理解,但成稿中不得直接写出。 + +**表面矛盾 ≠ 逻辑矛盾。** 规则可以看起来互相冲突,只要它们共享同一套底层危险逻辑: + +```text +例:人行道红灯时,车辆可通行,行人不可穿越。 + → 表面:对车、对人要求相反 + → 底层:同一套「此时段道路归属与风险分配」逻辑,完全一致 +``` + +验收时:**禁止** 把「对不同对象/情境的差异化要求」误判为矛盾。 +应追问:若 core 成立,这些差异是否 **同一机制下的合理分支**? + +--- + +## 规则怎么写 + +规则是 **帮助避害的经验**,不是迫害主角的玄学刑罚。 + +| 好的规则 | 坏的规则 | +|----------|----------| +| 红灯停、绿灯行——因为可能有车 | 红灯行会被规则抹杀 | +| 禁止下水——因为可能溺水 | 下水即违反规则,必死 | +| 23:00 后不要独自走 corridor 尽头——那里曾有人失踪 | 违反第 3 条者消失 | + +每条规则必须能对应 **具体、可理解的危险动机**(即使正文不点明危险名称)。 + +--- + +## 解析块(commentary)怎么写 + +- 说明规则背景、使用情境、语气与体裁(公告/手册/日记附带等)。 +- **不** 写成「真相是…」「作者揭秘」。 +- **不** 复制 core.danger 中的关键词或直白总结。 + +--- + +## 全体 worker 安全规则 + +- 不向用户展示 `core.danger` 全文作为「答案」。 +- 指出问题时只引用 rules 中的 **片段**,不拼出完整真相。 +- 若 brief 要求禁忌元素,遵守并在产出中体现边界。 +- 不写分章正文、章纲、卷结构(本 skill 只产出规则集 + 解析)。 diff --git a/skills/novel/weird-rules-short/workers/review-author/SKILL.md b/skills/novel/weird-rules-short/workers/review-author/SKILL.md new file mode 100644 index 0000000..80534a2 --- /dev/null +++ b/skills/novel/weird-rules-short/workers/review-author/SKILL.md @@ -0,0 +1,117 @@ +--- +id: review-author +skill: weird-rules-short +name: 作者视角一致性验收 +description: >- + 读取 core.danger。从作者视角检验规则是否服务于核心危险、表面矛盾是否底层一致、 + 是否有规则偏离 core 或自相矛盾于同一机制。 +version: 1 +outputKeys: + - review.author.notes +--- + +# 作者视角一致性验收 Worker + +## 角色与口吻 + +你是 **规则怪谈的作者**,已知内部 `core.danger`。你检验成稿规则是否 **忠实服务于这一危险**,并判断「看起来矛盾」的条目是否在 **同一底层机制** 下合理。 + +你不重写全文,只报告问题与修改建议。 + +## 能力范围 + +**可以做:** + +- 对照 `core.danger` 检查每条规则是否可追溯到核心危险 +- 识别 **真正的逻辑矛盾**(与 core 或与同机制其他规则冲突) +- 识别 **合理的表面矛盾**(对不同对象/情境的差异化要求,底层一致) +- 检查 rules / commentary 是否泄露 core 关键词 +- 输出结构化 `review.author.notes`(pass / fail + 理由) + +**不可以做:** + +- 修改 rules 或 commentary +- 向用户揭晓 core.danger 原文 +- 把合理的差异化规则误判为 fail(见固定上下文「表面矛盾 ≠ 逻辑矛盾」) + +## 思维链与自检 + +### 检查步骤 + +1. 读 `core.danger`,提取 **禁止出现在成稿中的关键词/短语**。 +2. 读 `book.brief`,确认体裁与条数预期。 +3. 逐条读 `rules.draft`,对每条问: + - 若 core 成立,这条规则是 **必要分支** 还是 **无关/矛盾**? + - 与其他规则对比:差异是 **对象/情境不同**,还是 **机制打架**? + - 是否泄露 core 关键词? + - 是否空泛玄学惩罚而无 core 动机? +4. 读 `rules.commentary`:是否点明 core 或锁死唯一解读? +5. 汇总为 `review.author.notes`。 + +### 表面矛盾 vs 真正矛盾 + +| 类型 | 处理 | +|------|------| +| 表面矛盾、底层一致 | **pass**(可在 notes 中说明为何合理,如「对人/对车差异化要求」) | +| 与 core 机制冲突 | **fail** | +| 规则 A 假定安全、规则 B 在同一条件下假定危险且无解释 | **fail** | +| 泄露 core 关键词 | **fail** | + +### 通过标准 + +**pass** 当且仅当: + +- 每条规则可追溯到 `core.danger` +- 无与 core 或同机制规则 **无法调和** 的冲突 +- rules 与 commentary 均未泄露 core 关键词 +- 无空泛玄学惩罚占多数 + +任一严重项失败 → **fail**。 + +### 提交前自检 + +- [ ] 已逐条对照 core,非扫读 +- [ ] 未把合理表面矛盾标为 fail +- [ ] review.author.notes 含明确 pass 或 fail +- [ ] fail 时给出可操作的修改建议 +- [ ] review 中 **禁止** 复制 core.danger 全文 + +## 上下文用法 + +| inputKey | 用法 | +|----------|------| +| book.brief | 体裁、条数、基调 | +| core.danger | 唯一不可违反的底层;对照泄露与一致性 | +| rules.draft | 主要检查对象 | +| rules.commentary | 检查泄露与体裁 | + +## 输出格式 + +### review.author.notes + +```text +verdict: pass | fail + +checks: +- [pass|fail] core 追溯:… +- [pass|fail] 机制一致(含表面矛盾甄别):… +- [pass|fail] core 泄露:… +- [pass|fail] 体裁一致:… + +surface_paradox_ok: +- 规则 X 与 Y:…(若存在合理表面矛盾,说明底层一致理由) + +issues: +- 规则 3:… +- commentary:… + +suggestions: +- … +``` + +程序验收读取 `verdict:` 行。 + +## 安全规则 + +- review.author.notes 中 **禁止** 复制 core.danger 全文。 +- 指出泄露时只引用 rules 中的 **片段**,不拼出「正确答案」。 diff --git a/skills/novel/weird-rules-short/workers/review-infer/SKILL.md b/skills/novel/weird-rules-short/workers/review-infer/SKILL.md new file mode 100644 index 0000000..c214bed --- /dev/null +++ b/skills/novel/weird-rules-short/workers/review-infer/SKILL.md @@ -0,0 +1,110 @@ +--- +id: review-infer +skill: weird-rules-short +name: 读者视角反推验收 +description: >- + 不读取 core.danger。从 rules 与 commentary 反推隐含危险,评价规则是否可被读者理解、 + 是否有可反推动机、是否过早泄露真相。 +version: 1 +outputKeys: + - review.infer.notes +--- + +# 读者视角反推验收 Worker + +## 角色与口吻 + +你是 **第一次读到这份规则集的读者**。你不知道作者预设的核心危险,也不应尝试读取 `core.danger`。 + +你的任务:仅凭 `rules.draft` 与 `rules.commentary`,反推「这份规则在防什么」,并评价规则作为 **读者体验** 是否合格。 + +## 能力范围 + +**可以做:** + +- 从规则条文归纳你推断的隐含危险(写入 review,供作者返工参考,**不对用户当作标准答案**) +- 逐条检查规则是否有 **非玄学** 的可反推动机 +- 检查 commentary 是否过早揭晓或暗示唯一真相 +- 输出结构化 `review.infer.notes`(pass / fail + 理由) + +**不可以做:** + +- 读取或使用 `core.danger`(本 worker **不得** 注入该 key) +- 修改 rules 或 commentary +- 用「整体感觉不错」代替逐条检查 +- 把「对不同对象/情境的差异化要求」误判为逻辑矛盾(见固定上下文「表面矛盾 ≠ 逻辑矛盾」) + +## 思维链与自检 + +### 检查步骤 + +1. 读 `book.brief`,了解场景、体裁、条数预期。 +2. **盲读** `rules.draft` 与 `rules.commentary`,写下你推断的隐含危险(2~4 句,标注为「读者推断,非标准答案」)。 +3. 逐条读 `rules.draft`: + - 读者能否反推「为什么要有这条规则」? + - 是否空泛「违反即死/抹杀/清除」而无具体动机? + - 是否像作者在直接剧透危险名称? +4. 读 `rules.commentary`: + - 是否写成「真相是…」或唯一标准解读? + - 是否与 brief 要求的体裁、基调一致? +5. 汇总为 `review.infer.notes`。 + +### 通过标准 + +**pass** 当且仅当: + +- 读者能形成 **连贯、可理解** 的危险推断(不必与作者 core 一致,但不能互相打架到读不懂) +- 每条规则有可反推的危险动机 +- rules 与 commentary 均未 **过早剧透** 或锁死唯一解读 +- 无空泛玄学惩罚条款占多数 +- 条数与 brief 规模大致匹配(允许 ±2 条) + +任一严重项失败 → **fail**,列出具体条目编号与理由。 + +### 提交前自检 + +- [ ] 确认未使用 core.danger +- [ ] 已逐条检查,非扫读 +- [ ] review.infer.notes 含明确 pass 或 fail +- [ ] fail 时给出可操作的修改建议(供 write-rules revision) + +## 上下文用法 + +| inputKey | 用法 | +|----------|------| +| book.brief | 场景、条数、体裁、基调 | +| rules.draft | 主要检查对象 | +| rules.commentary | 检查是否剧透、体裁是否一致 | + +**禁止注入:** `core.danger` + +## 输出格式 + +### review.infer.notes + +```text +verdict: pass | fail + +reader_inference: +(读者视角推断的隐含危险,2~4 句;标注非标准答案) + +checks: +- [pass|fail] 可反推动机:… +- [pass|fail] 无过早剧透:… +- [pass|fail] 体裁一致:… +- [pass|fail] 条数规模:… + +issues: +- 规则 3:… +- commentary:… + +suggestions: +- … +``` + +程序验收读取 `verdict:` 行:`pass` 则本 worker 通过,`fail` 则触发 revision。 + +## 安全规则 + +- 推断的危险写入 review 仅供返工,**禁止** 向用户当作「正确答案」展示。 +- 指出剧透时只引用 rules 中的 **片段**。 diff --git a/skills/novel/weird-rules-short/workers/write-rules/SKILL.md b/skills/novel/weird-rules-short/workers/write-rules/SKILL.md new file mode 100644 index 0000000..c893ed5 --- /dev/null +++ b/skills/novel/weird-rules-short/workers/write-rules/SKILL.md @@ -0,0 +1,103 @@ +--- +id: write-rules +skill: weird-rules-short +name: 规则与解析创作 +description: >- + 从 book.brief 推演内部 core.danger,产出编号护命规则与解析块。 + 不写章节正文、不写卷纲。 +version: 1 +outputKeys: + - core.danger + - rules.draft + - rules.commentary +--- + +# 规则与解析 Worker + +## 角色与口吻 + +你是规则怪谈创作执行者。固定创作上下文(shared-context)已注入 prompt 开头——**体裁原则、好/坏规则对照、表面矛盾与底层一致** 均以此为准。 + +你根据简报推演 **内部核心危险**,再反推 **护命规则** 与 **读者可见的解析块**。 + +## 能力范围 + +**可以做:** + +- 从 `book.brief` 推演 `core.danger`(内部,不对读者揭晓) +- 撰写编号规则条文 `rules.draft` +- 撰写解析/说明块 `rules.commentary`(帮助读者理解规则用途,但不点明 core) + +**不可以做:** + +- 在 rules 或 commentary 中直接写出 core 所指的危险名称或「真相总结」 +- 写分章正文、章纲、卷结构 +- 用「违反第 N 条即抹杀」替代具体危险动机 +- 制造 **与 core 机制无法调和** 的规则;表面矛盾须底层一致(见 shared-context) + +## 思维链与自检 + +### 执行顺序 + +```text +读 book.brief → 定 core.danger → 写 rules.draft → 写 rules.commentary → 自检 → 提交 +``` + +### 核心(core)怎么定 + +- **核心** = 主角可能遭遇的「怪谈化危险」(灵异、不可名状、环境异变等)。 +- `core.danger` 是 **唯一不可违反的底层**;所有规则须可追溯到它。 +- 写规则时可设计 **表面看似矛盾、底层一致** 的分支(如对不同对象/时段的差异化要求)。 + +### 提交前自检 + +- [ ] `core.danger` 已写入,且未复制进 rules / commentary +- [ ] 每条规则有可反推的非玄学动机,且可追溯到 core +- [ ] 表面矛盾条目已自检:底层与 core 一致,非机制打架 +- [ ] 规则条数与 brief 中的规模大致一致 +- [ ] 呈现体裁与 brief 一致 +- [ ] commentary 不泄露 core 关键词 + +## 上下文用法 + +| inputKey | 用法 | +|----------|------| +| book.brief | 场景、条数、体裁、基调、禁忌;推演 core 与规则风格 | +| review.infer.notes | revision 时读取读者视角失败理由 | +| review.author.notes | revision 时读取作者视角失败理由 | +| revision.instruction | 用户拒收时的修改说明 | + +缺 brief 时 **ask_user**,不要臆造场景。 + +## 输出格式 + +### core.danger + +内部段落,2~5 句。描述怪谈化危险与氛围,可含创作用类比,标注「不可写入成稿」。 + +### rules.draft + +编号条目,每条约 1~3 句。示例: + +```text +1. … +2. … +``` + +### rules.commentary + +读者可见的说明块:规则背景、使用情境、语气说明。不揭晓 core,不写成「作者揭秘」。 + +## 示例 + +核心若是「夜间空荡处有人跟踪」(应怪谈化处理),规则可写: + +```text +3. 23:00 后不要独自经过地下二层通道;若听见第二脚步声,不要回头,前往最近有灯光的房间。 +``` + +而非: + +```text +3. 23:00 后经过地下二层者,违反规则将被清除。 +``` diff --git a/skills/registry.yaml b/skills/registry.yaml new file mode 100644 index 0000000..42bf696 --- /dev/null +++ b/skills/registry.yaml @@ -0,0 +1,16 @@ +skills: + - name: basic + description: 最小演示:创作简报 → 大纲。 + category: novel + bookKind: novel + path: novel/basic/orchestrator.md + - name: weird-rules-short + description: 规则怪谈:隐含核心 → 护命规则 + 解析 → 检查。 + category: novel + bookKind: novel + path: novel/weird-rules-short/orchestrator.md + - name: roleplay-game-theory + description: 角色扮演博弈:思想实验情境下多角色决策模拟(instantiate 可用,run 待建)。 + category: dialogue + bookKind: dialogue + path: dialogue/roleplay-game-theory/orchestrator.md diff --git a/src/blackboard/blackboard.ts b/src/blackboard/blackboard.ts new file mode 100644 index 0000000..216aa1d --- /dev/null +++ b/src/blackboard/blackboard.ts @@ -0,0 +1,117 @@ +import { randomUUID } from "node:crypto"; +import { isFullAccessPattern, tagMatchesPattern } from "./tag-match.js"; +import type { + BlackboardItem, + BlackboardTagIndex, + BlackboardWriteInput, +} from "../types/blackboard.js"; + +export class Blackboard { + private items = new Map(); + private writeSeq = 0; + + listTagIndex(): BlackboardTagIndex[] { + return [...this.items.values()] + .map(({ id, tag, source, scope, updatedAt }) => ({ + id, + tag, + source, + scope, + updatedAt, + })) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + get(id: string): BlackboardItem | undefined { + return this.items.get(id); + } + + getLatestByTag(tag: string): BlackboardItem | undefined { + const matches = [...this.items.values()].filter((item) => item.tag === tag); + if (matches.length === 0) return undefined; + return matches.sort((a, b) => compareItemsByRecency(a, b))[0]; + } + + getContentByTag(tag: string): string | undefined { + return this.getLatestByTag(tag)?.content; + } + + /** + * 按 worker inputTags 模式取条目。 + * 同一 pattern 多条命中时:latest 取最新一条;concat 合并 content。 + */ + queryByPatterns( + patterns: string[], + merge: "latest" | "concat" = "latest", + ): BlackboardItem[] { + if (patterns.some(isFullAccessPattern)) { + return [...this.items.values()].sort((a, b) => + a.updatedAt.localeCompare(b.updatedAt), + ); + } + + const result: BlackboardItem[] = []; + + for (const pattern of patterns) { + const matched = [...this.items.values()] + .filter((item) => tagMatchesPattern(item.tag, pattern)) + .sort(compareItemsByRecency); + + if (matched.length === 0) continue; + + if (merge === "concat" && matched.length > 1) { + result.push({ + ...matched[0], + id: `merged:${pattern}`, + content: matched + .slice() + .reverse() + .map((m) => m.content) + .join("\n\n---\n\n"), + }); + } else { + result.push(matched[0]); + } + } + + return result; + } + + write(input: BlackboardWriteInput): BlackboardItem { + const now = new Date().toISOString(); + this.writeSeq += 1; + const existing = this.getLatestByTag(input.tag); + const item: BlackboardItem = { + id: randomUUID(), + tag: input.tag, + content: input.content, + source: input.source, + scope: input.scope ?? existing?.scope, + createdAt: now, + updatedAt: `${now}#${this.writeSeq}`, + dependencies: input.dependencies, + metadata: input.metadata, + }; + this.items.set(item.id, item); + return item; + } + + seed(items: BlackboardItem[]): void { + for (const item of items) { + this.items.set(item.id, item); + } + } + + exportItems(): BlackboardItem[] { + return [...this.items.values()]; + } + + /** 测试 / 调试:当前条目数 */ + size(): number { + return this.items.size; + } +} + +function compareItemsByRecency(a: BlackboardItem, b: BlackboardItem): number { + return b.updatedAt.localeCompare(a.updatedAt); +} diff --git a/src/blackboard/tag-match.ts b/src/blackboard/tag-match.ts new file mode 100644 index 0000000..803ca37 --- /dev/null +++ b/src/blackboard/tag-match.ts @@ -0,0 +1,13 @@ +/** 判断 itemTag 是否匹配 worker 声明的 inputTag 模式(精确或 前缀.*) */ +export function tagMatchesPattern(itemTag: string, pattern: string): boolean { + if (pattern === "**") return true; + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -2); + return itemTag === prefix || itemTag.startsWith(`${prefix}.`); + } + return itemTag === pattern; +} + +export function isFullAccessPattern(pattern: string): boolean { + return pattern === "**"; +} diff --git a/src/book/orchestrators.ts b/src/book/orchestrators.ts new file mode 100644 index 0000000..d5fcf3e --- /dev/null +++ b/src/book/orchestrators.ts @@ -0,0 +1,28 @@ +import { listSkills } from "../skills/loader.js"; +import type { SkillPackInfo } from "../types/book.js"; + +/** @deprecated 使用 listSkillPacks;保留兼容旧 import */ +export async function listOrchestrators(): Promise { + return listSkillPacks(); +} + +export async function listSkillPacks(): Promise { + const skills = await listSkills(); + return skills.map((s) => ({ + id: s.name, + name: s.name, + description: s.description, + category: s.category, + bookKind: s.bookKind, + })); +} + +export async function getSkillPack(id: string): Promise { + const all = await listSkillPacks(); + return all.find((o) => o.id === id) ?? null; +} + +/** @deprecated */ +export async function getOrchestrator(id: string): Promise { + return getSkillPack(id); +} diff --git a/src/book/run-snapshot-store.ts b/src/book/run-snapshot-store.ts new file mode 100644 index 0000000..4c70333 --- /dev/null +++ b/src/book/run-snapshot-store.ts @@ -0,0 +1,96 @@ +import { mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; +import type { RunSnapshot, RunSnapshotMeta } from "../types/run-snapshot.js"; +import { toRunSnapshotMeta } from "../types/run-snapshot.js"; + +function snapshotsDir(bookId: string): string { + return path.join(getUserDataDir(), "books", bookId, "run-snapshots"); +} + +function snapshotPath(bookId: string, snapshotId: string): string { + return path.join(snapshotsDir(bookId), `${snapshotId}.json`); +} + +function isValidRunSnapshot(value: unknown): value is RunSnapshot { + if (!value || typeof value !== "object") return false; + const s = value as RunSnapshot; + const kind = s.kind ?? "run"; + return ( + s.version === 1 && + typeof s.id === "string" && + typeof s.bookId === "string" && + typeof s.label === "string" && + (kind === "instance" || kind === "run") && + typeof s.orchestratorId === "string" && + s.runtimeSession != null && + Array.isArray(s.blackboardItems) && + Array.isArray(s.messages) && + typeof s.createdAt === "string" + ); +} + +function normalizeSnapshot(raw: RunSnapshot): RunSnapshot { + return { ...raw, kind: raw.kind ?? "run" }; +} + +/** 保存运行快照(同 bookId + snapshotId 则覆盖) */ +export function saveRunSnapshot(snapshot: RunSnapshot): void { + ensureUserDataDirs(); + const dir = snapshotsDir(snapshot.bookId); + mkdirSync(dir, { recursive: true }); + writeFileSync(snapshotPath(snapshot.bookId, snapshot.id), JSON.stringify(snapshot, null, 2), "utf8"); +} + +/** 读取单个运行快照;不存在或格式无效时返回 null */ +export function loadRunSnapshot(bookId: string, snapshotId: string): RunSnapshot | null { + try { + const raw = readFileSync(snapshotPath(bookId, snapshotId), "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isValidRunSnapshot(parsed) || parsed.bookId !== bookId || parsed.id !== snapshotId) { + return null; + } + return normalizeSnapshot(parsed); + } catch { + return null; + } +} + +/** 删除单个运行快照;成功删除返回 true */ +export function deleteRunSnapshot(bookId: string, snapshotId: string): boolean { + try { + unlinkSync(snapshotPath(bookId, snapshotId)); + return true; + } catch { + return false; + } +} + +/** 列出某 Book 下全部运行快照(按 createdAt 降序) */ +export function listRunSnapshots(bookId: string): RunSnapshotMeta[] { + ensureUserDataDirs(); + let files: string[]; + try { + files = readdirSync(snapshotsDir(bookId)).filter((f) => f.endsWith(".json")); + } catch { + return []; + } + + const metas: RunSnapshotMeta[] = []; + for (const file of files) { + const id = file.replace(/\.json$/, ""); + const snapshot = loadRunSnapshot(bookId, id); + if (snapshot) metas.push(toRunSnapshotMeta(snapshot)); + } + + return metas.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +/** 删除某 Book 下全部运行快照(删 Book 时调用) */ +export function deleteAllRunSnapshots(bookId: string): void { + try { + rmSync(snapshotsDir(bookId), { recursive: true, force: true }); + } catch { + /* ignore */ + } +} diff --git a/src/book/session-store.ts b/src/book/session-store.ts new file mode 100644 index 0000000..bc1cc00 --- /dev/null +++ b/src/book/session-store.ts @@ -0,0 +1,46 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; +import type { PersistedBookSession } from "../types/book-session.js"; + +const SESSION_FILENAME = "session.json"; + +function bookDir(bookId: string): string { + return path.join(getUserDataDir(), "books", bookId); +} + +function sessionPath(bookId: string): string { + return path.join(bookDir(bookId), SESSION_FILENAME); +} + +export function saveBookSession(snapshot: PersistedBookSession): void { + ensureUserDataDirs(); + const dir = bookDir(snapshot.bookId); + mkdirSync(dir, { recursive: true }); + writeFileSync(sessionPath(snapshot.bookId), JSON.stringify(snapshot, null, 2), "utf8"); +} + +export function loadBookSession(bookId: string): PersistedBookSession | null { + try { + const raw = readFileSync(sessionPath(bookId), "utf8"); + const parsed = JSON.parse(raw) as PersistedBookSession; + if (parsed.version !== 1 || !parsed.sessionId || !parsed.runtimeSession) { + return null; + } + return parsed; + } catch { + return null; + } +} + +export function deleteBookSession(bookId: string): void { + try { + rmSync(bookDir(bookId), { recursive: true, force: true }); + } catch { + /* ignore */ + } +} + +export function hasBookSession(bookId: string): boolean { + return loadBookSession(bookId) !== null; +} diff --git a/src/book/skill-id.ts b/src/book/skill-id.ts new file mode 100644 index 0000000..dce1030 --- /dev/null +++ b/src/book/skill-id.ts @@ -0,0 +1,37 @@ +import type { BookProject } from "../types/book.js"; +import type { PersistedBookSession } from "../types/book-session.js"; +import type { RunSnapshot } from "../types/run-snapshot.js"; +import type { RuntimeSession } from "../types/runtime.js"; +import type { ActiveSkillSnapshot } from "../types/runtime.js"; + +/** 作品或快照绑定的 skill 包 id(兼容旧 orchestratorId 字段) */ +export function bookSkillPackId(book: BookProject): string | undefined { + return book.activeSkillId ?? book.orchestratorId; +} + +export function sessionSkillPackId(session: RuntimeSession): string | undefined { + const snap = session.slots.activeSkill as ActiveSkillSnapshot | undefined; + return snap?.name; +} + +export function persistedSkillPackId(snapshot: PersistedBookSession): string | undefined { + return ( + sessionSkillPackId(snapshot.runtimeSession) ?? snapshot.orchestratorId ?? undefined + ); +} + +export function runSnapshotSkillPackId(snapshot: RunSnapshot): string | undefined { + return ( + sessionSkillPackId(snapshot.runtimeSession) ?? snapshot.orchestratorId ?? undefined + ); +} + +export function skillPacksMatch( + book: BookProject, + snapshotSkill: string | undefined, +): boolean { + const bookSkill = bookSkillPackId(book); + if (!snapshotSkill) return true; + if (!bookSkill) return true; + return bookSkill === snapshotSkill; +} diff --git a/src/book/snapshot-filters.ts b/src/book/snapshot-filters.ts new file mode 100644 index 0000000..ce25741 --- /dev/null +++ b/src/book/snapshot-filters.ts @@ -0,0 +1,53 @@ +import type { BlackboardItem } from "../types/blackboard.js"; +import type { RuntimeSession } from "../types/runtime.js"; + +/** + * 判断黑板 tag 是否属于 run 阶段(非实例化确认稿)。 + * 实例快照保存/加载时会剥离这些 tag,只保留「实例化后的对象」。 + */ +export function isRunPhaseBlackboardTag(tag: string): boolean { + // 实例化层:保留 + if (/^角色\.[^.]+\.设定$/.test(tag)) return false; + if (/^用户\./.test(tag)) return false; + if (/^book\./.test(tag)) return false; + if (/^情境\./.test(tag)) return false; + if (/^博弈\./.test(tag)) return false; + + // run 层:剥离 + return ( + /^运行\./.test(tag) || + /^世界\.(当前|裁决)/.test(tag) || + /^场景\.公开/.test(tag) || + /^输出\./.test(tag) || + /^角色\.[^.]+\.(可见信息|思考|行动)$/.test(tag) || + /^review\./.test(tag) + ); +} + +export function filterBlackboardForInstance(items: BlackboardItem[]): BlackboardItem[] { + return items.filter((item) => !isRunPhaseBlackboardTag(item.tag)); +} + +/** 读档实例时:去掉 run 产物记录,清空 pending,便于从 instanceReady 重新开跑 */ +export function prepareRuntimeSessionForInstance(session: RuntimeSession): RuntimeSession { + const copy = structuredClone(session); + copy.pendingArtifactId = undefined; + copy.pendingDecision = undefined; + copy.currentWorkerId = undefined; + copy.waitingReason = undefined; + copy.artifacts = copy.artifacts.filter((a) => + a.outputTags.every((tag) => !isRunPhaseBlackboardTag(tag)), + ); + if (copy.phase === "done") copy.phase = "running"; + return copy; +} + +export function materializeInstanceSnapshotPayload(payload: { + runtimeSession: RuntimeSession; + blackboardItems: BlackboardItem[]; +}): { runtimeSession: RuntimeSession; blackboardItems: BlackboardItem[] } { + return { + blackboardItems: filterBlackboardForInstance(payload.blackboardItems), + runtimeSession: prepareRuntimeSessionForInstance(payload.runtimeSession), + }; +} diff --git a/src/book/store.ts b/src/book/store.ts new file mode 100644 index 0000000..fa65688 --- /dev/null +++ b/src/book/store.ts @@ -0,0 +1,117 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { BookProject, BookSummary } from "../types/book.js"; +import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; +import { deleteBookSession } from "./session-store.js"; +import { deleteAllRunSnapshots } from "./run-snapshot-store.js"; + +function booksDir(): string { + return path.join(getUserDataDir(), "books"); +} + +function bookPath(id: string): string { + return path.join(booksDir(), `${id}.json`); +} + +export function listBooks(): BookSummary[] { + ensureUserDataDirs(); + mkdirSync(booksDir(), { recursive: true }); + let files: string[]; + try { + files = readdirSync(booksDir()).filter((f) => f.endsWith(".json")); + } catch { + return []; + } + + const books: BookProject[] = []; + for (const file of files) { + try { + const raw = readFileSync(path.join(booksDir(), file), "utf8"); + books.push(JSON.parse(raw) as BookProject); + } catch { + /* skip */ + } + } + + return books + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map((b) => ({ + id: b.id, + title: b.title, + activeSkillId: b.activeSkillId ?? b.orchestratorId, + activeSkillName: b.activeSkillName ?? b.orchestratorName, + preview: b.preview, + updatedAt: b.updatedAt, + orchestratorId: b.orchestratorId, + orchestratorName: b.orchestratorName, + })); +} + +export function getBook(id: string): BookProject | null { + try { + const raw = readFileSync(bookPath(id), "utf8"); + return JSON.parse(raw) as BookProject; + } catch { + return null; + } +} + +export function createBook(input: { title?: string }): BookProject { + ensureUserDataDirs(); + mkdirSync(booksDir(), { recursive: true }); + const now = new Date().toISOString(); + const book: BookProject = { + id: randomUUID(), + title: input.title?.trim() || "未命名作品", + preview: "新建作品,选择 skill 包开始…", + sessionIds: [], + createdAt: now, + updatedAt: now, + }; + writeFileSync(bookPath(book.id), JSON.stringify(book, null, 2), "utf8"); + return book; +} + +export function updateBook( + id: string, + patch: Partial< + Pick< + BookProject, + | "title" + | "preview" + | "sessionIds" + | "activeSessionId" + | "activeSkillId" + | "activeSkillName" + > + >, +): BookProject { + const book = getBook(id); + if (!book) throw new Error("Book 不存在"); + const updated: BookProject = { + ...book, + ...patch, + updatedAt: new Date().toISOString(), + }; + writeFileSync(bookPath(id), JSON.stringify(updated, null, 2), "utf8"); + return updated; +} + +export function appendBookSession(id: string, sessionId: string): void { + const book = getBook(id); + if (!book) return; + if (!book.sessionIds.includes(sessionId)) { + updateBook(id, { sessionIds: [...book.sessionIds, sessionId] }); + } +} + +export function deleteBook(id: string): void { + deleteBookSession(id); + deleteAllRunSnapshots(id); + try { + unlinkSync(bookPath(id)); + } catch { + /* ignore */ + } +} diff --git a/src/cli/phase-demo.ts b/src/cli/phase-demo.ts new file mode 100644 index 0000000..de01f77 --- /dev/null +++ b/src/cli/phase-demo.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * 阶段机 + Skill 演示 CLI(不依赖 LLM) + * + * 运行:npm run phase-demo + * npm run phase-script + */ +import * as readline from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { + createDecision, + formatSession, + PhaseRuntime, + runMinimalClosedLoop, +} from "../runtime/phase-runtime.js"; +import { getAllowedEvents } from "../runtime/phase-machine.js"; + +const autoStub = process.argv.includes("--auto"); +const runScript = process.argv.includes("--script"); + +function printHelp(): void { + console.log(`阶段机 + Skill 演示 + +启动流程: + 1. /start → 列出 skills/ 下的 SKILL.md + 2. 输入 skill name 或编号(如 basic 或 1) + 3. 按 SKILL.md「启动询问」回答 + 4. /decide worker outline-worker approve → /approve → … + +命令: + /start 开始会话 + /skills 列出可用 skill + /status 当前 phase + /decide ... 模拟总管决策(见下) + /approve /accept 用户确认 + /worker-start 手动启动 worker + /worker-done worker 完成 + /quit + +/decide 子命令: + /decide worker [approve] + /decide finish [理由] + +加 --auto 时 worker 自动占位完成。`); +} + +async function main(): Promise { + const runtime = new PhaseRuntime({ + autoStubWorker: autoStub || runScript, + onMessage: (msg) => console.log(msg), + }); + + if (runScript) { + const session = await runMinimalClosedLoop(runtime); + console.log(formatSession(session)); + console.log(session.phase === "done" ? "✓ 闭环完成" : "✗ 未完成"); + process.exit(session.phase === "done" ? 0 : 1); + } + + console.log("阶段机 + Skill 演示\n"); + printHelp(); + + const rl = readline.createInterface({ input, output }); + + try { + while (true) { + const line = (await rl.question("> ")).trim(); + if (!line) continue; + + try { + if (line === "/quit") break; + if (line === "/help") { + printHelp(); + continue; + } + if (line === "/start") { + await runtime.start(); + console.log(formatSession(runtime.getSession())); + continue; + } + if (line === "/skills") { + for (const [i, s] of runtime.getAvailableSkills().entries()) { + console.log(` ${i + 1}. ${s.name} — ${s.description}`); + } + continue; + } + if (line === "/status") { + console.log(formatSession(runtime.getSession())); + if (runtime.getActiveSkill()) { + console.log("activeSkill:", runtime.getActiveSkill()?.name); + } + continue; + } + if (line === "/events") { + console.log(getAllowedEvents(runtime.getSession()).join(", ")); + continue; + } + if (line === "/approve") { + await runtime.approve(); + console.log(formatSession(runtime.getSession())); + continue; + } + if (line === "/accept") { + await runtime.acceptArtifact(); + console.log(formatSession(runtime.getSession())); + continue; + } + if (line === "/worker-start") { + await runtime.startPendingWorker(); + console.log(formatSession(runtime.getSession())); + continue; + } + if (line === "/worker-done") { + await runtime.workerComplete(); + console.log(formatSession(runtime.getSession())); + continue; + } + if (line.startsWith("/decide ")) { + await handleDecide(runtime, line.slice("/decide ".length)); + console.log(formatSession(runtime.getSession())); + continue; + } + + await runtime.submitInput(line); + console.log(formatSession(runtime.getSession())); + + if (runtime.getSession().phase === "done") { + console.log("流程已完成。"); + break; + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + } + } + } finally { + rl.close(); + } +} + +async function handleDecide(runtime: PhaseRuntime, args: string): Promise { + const parts = args.split(" "); + if (parts[0] === "finish") { + await runtime.submitDecision( + createDecision({ + action: "finish", + reason: parts.slice(1).join(" ") || "完成", + requiresApproval: false, + }), + ); + return; + } + if (parts[0] === "worker") { + const workerId = parts[1]; + if (!workerId) throw new Error("用法: /decide worker [approve]"); + await runtime.submitDecision( + createDecision({ + action: "run_worker", + reason: `调度 ${workerId}`, + workerId, + requiresApproval: parts[2] === "approve", + }), + ); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 0000000..86fc179 --- /dev/null +++ b/src/cli/run.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Skill 流程 + 总管 LLM 演示 CLI + * + * npm run dev # 真实 LLM(需 OPENAI_API_KEY) + * npm run demo # Mock LLM + */ +import * as readline from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { loadLlmConfigOptional } from "../config/env.js"; +import { + createMockMainAgentResponse, + MockLlmProvider, +} from "../llm/client.js"; +import { createDefaultMainAgentLlm } from "../runtime/llm-factory.js"; +import { formatSession, PhaseRuntime } from "../runtime/phase-runtime.js"; + +const useMock = process.argv.includes("--mock"); + +function printSession(runtime: PhaseRuntime): void { + console.log("\n--- 会话 ---"); + console.log(formatSession(runtime.getSession())); + const index = runtime.getBlackboard().listTagIndex(); + if (index.length > 0) { + console.log("blackboard tags:"); + for (const entry of index) { + console.log(` - ${entry.tag} [${entry.source}]`); + } + } + console.log("----------------\n"); +} + +function printHelp(): void { + console.log(`命令: + <文本> 选 skill / 提交输入 / 驳回时发修改意见 + /approve 确认总管建议的 worker + /accept 接受当前产物 + /reject-art 拒绝当前产物 + /status 查看状态 + /quit 退出`); +} + +async function main(): Promise { + const llm = useMock + ? new MockLlmProvider([ + createMockMainAgentResponse({ + action: "run_worker", + reason: "建议生成大纲", + workerId: "outline-worker", + requiresApproval: true, + }), + createMockMainAgentResponse({ + action: "finish", + reason: "完成", + requiresApproval: false, + }), + ]) + : loadLlmConfigOptional() + ? createDefaultMainAgentLlm() + : null; + + if (!llm) { + console.error("请设置 OPENAI_API_KEY 或使用 --mock"); + process.exit(1); + } + + if (useMock || !loadLlmConfigOptional()) { + console.log("使用 Mock LLM\n"); + } + + const runtime = new PhaseRuntime({ + autoStubWorker: true, + llm, + onMessage: (msg) => console.log(msg), + }); + + await runtime.start(); + printSession(runtime); + printHelp(); + + const rl = readline.createInterface({ input, output }); + + try { + while (true) { + const line = (await rl.question("> ")).trim(); + if (!line) continue; + + if (line === "/quit") break; + if (line === "/help") { + printHelp(); + continue; + } + if (line === "/status") { + printSession(runtime); + continue; + } + if (line === "/approve") { + await runtime.approve(); + printSession(runtime); + continue; + } + if (line === "/accept") { + await runtime.acceptArtifact(); + printSession(runtime); + continue; + } + if (line === "/reject-art") { + await runtime.rejectArtifact("用户拒绝产物"); + printSession(runtime); + continue; + } + + const reason = runtime.getSession().waitingReason; + if (reason?.kind === "approve_step") { + await runtime.rejectStep(line); + } else { + await runtime.submitInput(line); + } + printSession(runtime); + + if (runtime.getSession().phase === "done") { + console.log("流程已完成。"); + break; + } + } + } finally { + rl.close(); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/src/config/api-profiles.ts b/src/config/api-profiles.ts new file mode 100644 index 0000000..0168474 --- /dev/null +++ b/src/config/api-profiles.ts @@ -0,0 +1,159 @@ +import { randomUUID } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { LlmConfig } from "./env.js"; +import { loadLlmConfigOptional } from "./env.js"; +import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js"; + +export type ApiProfile = { + id: string; + name: string; + baseUrl: string; + apiKey: string; + model: string; + createdAt: string; + updatedAt: string; +}; + +type ApiProfilesFile = { + version: 1; + profiles: ApiProfile[]; +}; + +const FILE_NAME = "profiles.json"; + +function profilesPath(): string { + return path.join(getUserDataDir(), FILE_NAME); +} + +function readFile(): ApiProfilesFile { + ensureUserDataDirs(); + try { + const raw = readFileSync(profilesPath(), "utf8"); + const parsed = JSON.parse(raw) as ApiProfilesFile; + if (parsed.version !== 1 || !Array.isArray(parsed.profiles)) { + return { version: 1, profiles: [] }; + } + return parsed; + } catch { + return { version: 1, profiles: [] }; + } +} + +function writeFile(data: ApiProfilesFile): void { + ensureUserDataDirs(); + writeFileSync(profilesPath(), JSON.stringify(data, null, 2), "utf8"); +} + +function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile { + if (data.profiles.length > 0) return data; + const env = loadLlmConfigOptional(); + if (!env) return data; + const now = new Date().toISOString(); + const profile: ApiProfile = { + id: randomUUID(), + name: "环境变量 (.env)", + baseUrl: env.baseUrl, + apiKey: env.apiKey, + model: env.model, + createdAt: now, + updatedAt: now, + }; + return { version: 1, profiles: [profile] }; +} + +export function listApiProfiles(): ApiProfile[] { + const data = seedFromEnvIfEmpty(readFile()); + if (data.profiles.length !== readFile().profiles.length) { + writeFile(data); + } + return data.profiles; +} + +export function getApiProfile(id: string): ApiProfile | null { + return listApiProfiles().find((p) => p.id === id) ?? null; +} + +export function createApiProfile(input: { + name: string; + baseUrl: string; + apiKey: string; + model: string; +}): ApiProfile { + const data = readFile(); + const now = new Date().toISOString(); + const profile: ApiProfile = { + id: randomUUID(), + name: input.name.trim() || "未命名", + baseUrl: input.baseUrl.trim() || "https://api.deepseek.com", + apiKey: input.apiKey.trim(), + model: input.model.trim() || "deepseek-v4-pro", + createdAt: now, + updatedAt: now, + }; + data.profiles.push(profile); + writeFile(data); + return profile; +} + +export function updateApiProfile( + id: string, + input: Partial>, +): ApiProfile { + const data = readFile(); + const index = data.profiles.findIndex((p) => p.id === id); + if (index < 0) throw new Error("API 配置不存在"); + const current = data.profiles[index]; + const updated: ApiProfile = { + ...current, + name: input.name?.trim() || current.name, + baseUrl: input.baseUrl?.trim() || current.baseUrl, + apiKey: input.apiKey !== undefined && input.apiKey.trim() !== "" + ? input.apiKey.trim() + : current.apiKey, + model: input.model?.trim() || current.model, + updatedAt: new Date().toISOString(), + }; + data.profiles[index] = updated; + writeFile(data); + return updated; +} + +export function deleteApiProfile(id: string): void { + const data = readFile(); + data.profiles = data.profiles.filter((p) => p.id !== id); + writeFile(data); +} + +export function profileToLlmConfig(profile: ApiProfile): LlmConfig { + return { + baseUrl: profile.baseUrl, + apiKey: profile.apiKey, + model: profile.model, + }; +} + +export async function testApiProfile(profile: ApiProfile): Promise<{ + ok: boolean; + message: string; +}> { + const url = `${profile.baseUrl.replace(/\/$/, "")}/models`; + try { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${profile.apiKey}` }, + }); + if (response.ok) { + return { ok: true, message: "连接成功" }; + } + const body = await response.text(); + return { + ok: false, + message: `HTTP ${response.status}: ${body.slice(0, 200)}`, + }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : "连接失败", + }; + } +} diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..040bf5f --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +export type LlmConfig = { + baseUrl: string; + apiKey: string; + model: string; +}; + +export function loadLlmConfig(): LlmConfig { + const baseUrl = + process.env.OPENAI_BASE_URL ?? "https://api.deepseek.com"; + const apiKey = process.env.OPENAI_API_KEY ?? ""; + const model = process.env.OPENAI_MODEL ?? "deepseek-v4-pro"; + + if (!apiKey) { + throw new Error( + "Missing OPENAI_API_KEY. Set it in environment or use --mock for offline demo.", + ); + } + + return { baseUrl, apiKey, model }; +} + +export function loadLlmConfigOptional(): LlmConfig | null { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) return null; + return { + baseUrl: process.env.OPENAI_BASE_URL ?? "https://api.deepseek.com", + apiKey, + model: process.env.OPENAI_MODEL ?? "deepseek-v4-pro", + }; +} + +export function loadDotEnv(cwd = process.cwd()): void { + const envPath = path.join(cwd, ".env"); + if (!existsSync(envPath)) return; + + const content = readFileSync(envPath, "utf8"); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + if (process.env[key] !== undefined) continue; + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } +} diff --git a/src/config/settings.ts b/src/config/settings.ts new file mode 100644 index 0000000..1530101 --- /dev/null +++ b/src/config/settings.ts @@ -0,0 +1,101 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { listApiProfiles } from "./api-profiles.js"; +import { loadLlmConfigOptional } from "./env.js"; +import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js"; + +export type AppSettings = { + version: 1; + activeProfileId: string | null; + activePresetId: string | null; +}; + +const FILE_NAME = "settings.json"; + +function settingsPath(): string { + return path.join(getUserDataDir(), FILE_NAME); +} + +function defaultSettings(): AppSettings { + return { + version: 1, + activeProfileId: null, + activePresetId: null, + }; +} + +export function loadAppSettings(): AppSettings { + ensureUserDataDirs(); + try { + const raw = readFileSync(settingsPath(), "utf8"); + const parsed = JSON.parse(raw) as AppSettings; + if (parsed.version !== 1) return defaultSettings(); + return { + version: 1, + activeProfileId: parsed.activeProfileId ?? null, + activePresetId: parsed.activePresetId ?? null, + }; + } catch { + return defaultSettings(); + } +} + +export function saveAppSettings(settings: AppSettings): void { + ensureUserDataDirs(); + writeFileSync(settingsPath(), JSON.stringify(settings, null, 2), "utf8"); +} + +export function setActiveProfileId(id: string | null): AppSettings { + const settings = loadAppSettings(); + settings.activeProfileId = id; + saveAppSettings(settings); + return settings; +} + +export function setActivePresetId(id: string | null): AppSettings { + const settings = loadAppSettings(); + settings.activePresetId = id; + saveAppSettings(settings); + return settings; +} + +/** 解析当前应使用的 API profile:settings > 首个 profile > .env */ +export function resolveActiveProfile() { + const settings = loadAppSettings(); + const profiles = listApiProfiles(); + + if (settings.activeProfileId) { + const found = profiles.find((p) => p.id === settings.activeProfileId); + if (found?.apiKey?.trim()) return found; + } + + if (profiles.length > 0) { + const withKey = profiles.find((p) => p.apiKey?.trim()); + if (withKey) return withKey; + } + + const env = loadLlmConfigOptional(); + if (env) { + return { + id: "__env__", + name: "环境变量 (.env)", + baseUrl: env.baseUrl, + apiKey: env.apiKey, + model: env.model, + createdAt: "", + updatedAt: "", + }; + } + + return null; +} + +export function ensureActiveProfileDefault(): AppSettings { + const settings = loadAppSettings(); + const profiles = listApiProfiles(); + if (!settings.activeProfileId && profiles.length > 0) { + settings.activeProfileId = profiles[0].id; + saveAppSettings(settings); + } + return settings; +} diff --git a/src/config/user-data-dir.ts b/src/config/user-data-dir.ts new file mode 100644 index 0000000..c056065 --- /dev/null +++ b/src/config/user-data-dir.ts @@ -0,0 +1,25 @@ +import { mkdirSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** 本地用户数据目录(不进 git、不同步) */ +export function getUserDataDir(): string { + if (process.env.WRITING_AGENT_DATA_DIR) { + return path.resolve(process.env.WRITING_AGENT_DATA_DIR); + } + return path.join(os.homedir(), ".writing-agent"); +} + +export function getPresetsDir(): string { + return path.join(getUserDataDir(), "presets"); +} + +export function getBooksDir(): string { + return path.join(getUserDataDir(), "books"); +} + +export function ensureUserDataDirs(): void { + mkdirSync(getPresetsDir(), { recursive: true }); + mkdirSync(getBooksDir(), { recursive: true }); + mkdirSync(path.join(getUserDataDir(), "stats"), { recursive: true }); +} diff --git a/src/intake/extract.ts b/src/intake/extract.ts new file mode 100644 index 0000000..56af599 --- /dev/null +++ b/src/intake/extract.ts @@ -0,0 +1,86 @@ +import type { LlmProvider } from "../llm/client.js"; +import type { IntakeFieldDef } from "../types/intake.js"; +import { extractIntakeHeuristic } from "./intake.js"; + +export async function extractIntakeFromMessage( + text: string, + fields: IntakeFieldDef[], + current: Record, + llm?: LlmProvider, +): Promise> { + if (!text.trim()) return { ...current }; + if (llm) { + try { + return await extractWithLlm(text, fields, current, llm); + } catch { + /* fallback */ + } + } + return extractIntakeHeuristic(text, fields, current); +} + +async function extractWithLlm( + text: string, + fields: IntakeFieldDef[], + current: Record, + llm: LlmProvider, +): Promise> { + const fieldList = fields.map((f) => ({ + id: f.id, + label: f.label, + required: f.required, + current: current[f.id] ?? null, + })); + + const result = await llm.complete( + [ + { + role: "system", + content: `你是信息抽取助手。根据用户最新消息,更新「填空题」各字段的值。 +规则: +1. 只输出 JSON:{ "updates": { "": "<完整字段值或 null>" } } +2. 仅更新用户本条消息明确提到或能推断的字段;未提及的字段不要出现在 updates 中 +3. 若某字段已有 current 值且用户是在补充,合并新旧内容 +4. 不要臆造用户未说的细节 +5. 「角色」须能识别出至少 2 个参与者或其倾向;「进程」包含轮次、局数或终止条件(如破产)`, + }, + { + role: "user", + content: JSON.stringify( + { fields: fieldList, userMessage: text }, + null, + 2, + ), + }, + ], + { responseFormat: "json_object", caller: "intake_extract" }, + ); + + let parsed: unknown; + try { + parsed = JSON.parse(result.content); + } catch { + return extractIntakeHeuristic(text, fields, current); + } + + const updates = (parsed as { updates?: Record }).updates; + if (!updates || typeof updates !== "object") { + return extractIntakeHeuristic(text, fields, current); + } + + const next = { ...current }; + for (const field of fields) { + const val = updates[field.id]; + if (val === null || val === undefined) continue; + if (typeof val !== "string" || !val.trim()) continue; + const merged = next[field.id]?.trim() + ? `${next[field.id].trim()}\n${val.trim()}` + : val.trim(); + next[field.id] = merged; + } + + if (Object.keys(updates).length === 0) { + return extractIntakeHeuristic(text, fields, current); + } + return next; +} diff --git a/src/intake/intake.ts b/src/intake/intake.ts new file mode 100644 index 0000000..7592107 --- /dev/null +++ b/src/intake/intake.ts @@ -0,0 +1,170 @@ +import type { + IntakeFieldDef, + IntakeFieldStatus, + IntakeProgress, +} from "../types/intake.js"; +import type { StartupInquiry } from "../skills/types.js"; + +export function intakeFieldId( + label: string, + index: number, + required: boolean, +): string { + const slug = label + .replace(/[((][^))]*[))]/g, "") + .replace(/\*\*/g, "") + .replace(/至少\s*\d+\s*个/g, "") + .trim() + .slice(0, 28) + .replace(/[^\w\u4e00-\u9fff-]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + return `${required ? "r" : "o"}-${index}-${slug || "field"}`; +} + +export function intakeFieldsFromInquiry(inquiry: StartupInquiry): IntakeFieldDef[] { + const required = inquiry.requiredFields.map((label, i) => ({ + id: intakeFieldId(label, i, true), + label, + required: true as const, + })); + const optional = (inquiry.optionalFields ?? []).map((label, i) => ({ + id: intakeFieldId(label, i, false), + label, + required: false as const, + })); + return [...required, ...optional]; +} + +export function readIntakeValues( + slots: Record, +): Record { + const raw = slots.intakeValues; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === "string" && v.trim()) out[k] = v.trim(); + } + return out; +} + +export function buildIntakeProgress( + fields: IntakeFieldDef[], + values: Record, +): IntakeProgress { + const items: IntakeFieldStatus[] = fields.map((f) => { + const value = values[f.id]?.trim(); + return { + id: f.id, + label: f.label, + required: f.required, + value: value || undefined, + filled: Boolean(value), + }; + }); + const required = items.filter((i) => i.required); + const optional = items.filter((i) => !i.required); + return { + fields: items, + requiredTotal: required.length, + requiredFilled: required.filter((i) => i.filled).length, + optionalTotal: optional.length, + optionalFilled: optional.filter((i) => i.filled).length, + ready: required.length > 0 ? required.every((i) => i.filled) : items.some((i) => i.filled), + }; +} + +/** 将各字段拼成写入黑板 / slots 的需求文档 */ +export function synthesizeDemandText( + fields: IntakeFieldDef[], + values: Record, +): string { + const parts: string[] = []; + for (const f of fields) { + const v = values[f.id]?.trim(); + if (!v) continue; + parts.push(`## ${f.label}\n${v}`); + } + return parts.join("\n\n"); +} + +/** 必要项未齐时的一次追问文案 */ +export function buildIntakeFollowUpMessage(progress: IntakeProgress): string { + const missing = progress.fields.filter((f) => f.required && !f.filled); + if (!missing.length) return ""; + const lines = missing.map((f) => `- ${f.label}`); + return `还缺以下必要项,请补充(可简短回答):\n${lines.join("\n")}`; +} + +/** 合并用户在启动阶段的原始输入(worker 兜底用) */ +export function collectUserInputTranscript( + slots: Record, +): string { + const parts: string[] = []; + const userInputs = slots.userInputs; + if (Array.isArray(userInputs)) { + for (const entry of userInputs) { + if (typeof entry === "string" && entry.trim()) { + parts.push(entry.trim()); + } + } + } + return parts.join("\n\n"); +} + +/** 无 LLM 时的启发式:优先填第一个未填必要项,并尝试关键词匹配 */ +export function extractIntakeHeuristic( + text: string, + fields: IntakeFieldDef[], + current: Record, +): Record { + const trimmed = text.trim(); + if (!trimmed) return { ...current }; + + const next = { ...current }; + const unfilledRequired = fields.filter((f) => f.required && !next[f.id]?.trim()); + + for (const field of fields) { + if (next[field.id]?.trim()) continue; + const keywords = fieldKeywords(field.label); + if (keywords.some((kw) => trimmed.includes(kw))) { + next[field.id] = mergeFieldValue(next[field.id], trimmed); + } + } + + if (unfilledRequired.length === 1 && !next[unfilledRequired[0].id]?.trim()) { + next[unfilledRequired[0].id] = trimmed; + } else if ( + unfilledRequired.length > 1 && + !fields.some((f) => next[f.id]?.trim() && !current[f.id]?.trim()) + ) { + next[unfilledRequired[0].id] = mergeFieldValue(next[unfilledRequired[0].id], trimmed); + } + + return next; +} + +function mergeFieldValue(existing: string | undefined, addition: string): string { + const a = addition.trim(); + if (!existing?.trim()) return a; + if (existing.includes(a)) return existing; + return `${existing.trim()}\n${a}`; +} + +function fieldKeywords(label: string): string[] { + const words: string[] = []; + if (/情境|实验|局面|框架/.test(label)) { + words.push("情境", "实验", "囚徒", "通牒", "扑克", "博弈", "游戏"); + } + if (/角色|参与/.test(label)) words.push("角色", "参与", "玩家", "人", "赌徒"); + if (/轮次|轮|进程|结束|破产/.test(label)) { + words.push("轮", "单轮", "多轮", "进程", "结束", "破产", "直到"); + } + if (/题材/.test(label)) words.push("题材", "科幻", "悬疑", "言情"); + if (/篇幅/.test(label)) words.push("篇幅", "短篇", "中篇", "长篇"); + if (/人称/.test(label)) words.push("人称", "第一", "第三"); + if (/输出|思考|描写/.test(label)) words.push("思考", "描写", "报告", "场景"); + if (/信息/.test(label)) words.push("信息", "私密", "公开"); + if (words.length === 0) words.push(label.slice(0, 4)); + return words; +} diff --git a/src/llm/client.ts b/src/llm/client.ts new file mode 100644 index 0000000..b567563 --- /dev/null +++ b/src/llm/client.ts @@ -0,0 +1,389 @@ +import type { LlmConfig } from "../config/env.js"; +import type { GenerationParameters } from "../types/preset.js"; + +export type ToolCallPayload = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type ChatMessage = + | { role: "system" | "user"; content: string } + | { + role: "assistant"; + content: string | null; + tool_calls?: ToolCallPayload[]; + } + | { role: "tool"; content: string; tool_call_id: string }; + +export type ToolDefinition = { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +}; + +export type ParsedToolCall = { + id: string; + name: string; + arguments: string; +}; + +export type TokenUsage = { + promptTokens: number; + completionTokens: number; + totalTokens: number; + /** Prompt tokens served from provider cache (OpenAI cached_tokens, DeepSeek prompt_cache_hit_tokens) */ + cachedTokens?: number; + /** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */ + cacheMissTokens?: number; +}; + +export type CompleteResult = { + content: string; + /** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */ + reasoning?: string; + usage?: TokenUsage; + model?: string; +}; + +export type CompleteOptions = { + responseFormat?: "json_object" | "text"; + generation?: GenerationParameters; + /** 统计用途,如 main_agent / worker:write-rules */ + caller?: string; +}; + +export type CompleteWithToolsOptions = CompleteOptions & { + tools: ToolDefinition[]; +}; + +export type CompleteWithToolsResult = { + content: string | null; + toolCalls: ParsedToolCall[]; + reasoning?: string; + usage?: TokenUsage; + model?: string; +}; + +export type LlmProvider = { + complete( + messages: ChatMessage[], + options?: CompleteOptions, + ): Promise; + completeWithTools( + messages: ChatMessage[], + options: CompleteWithToolsOptions, + ): Promise; +}; + +function buildRequestBody( + config: LlmConfig, + messages: ChatMessage[], + options?: CompleteOptions & { tools?: ToolDefinition[] }, +): Record { + const gen = options?.generation ?? {}; + const body: Record = { + model: config.model, + messages, + }; + + if (options?.tools?.length) { + body.tools = options.tools; + body.tool_choice = "auto"; + } + + if (gen.temperature !== undefined) body.temperature = gen.temperature; + else body.temperature = 0.2; + + if (gen.topP !== undefined) body.top_p = gen.topP; + if (gen.topK !== undefined) body.top_k = gen.topK; + if (gen.minP !== undefined) body.min_p = gen.minP; + if (gen.frequencyPenalty !== undefined) { + body.frequency_penalty = gen.frequencyPenalty; + } + if (gen.presencePenalty !== undefined) { + body.presence_penalty = gen.presencePenalty; + } + if (gen.repetitionPenalty !== undefined) { + body.repetition_penalty = gen.repetitionPenalty; + } + if (gen.maxOutputTokens !== undefined) { + body.max_tokens = gen.maxOutputTokens; + } + if (gen.seed !== undefined) body.seed = gen.seed; + if (gen.reasoningEffort !== undefined) { + body.reasoning_effort = gen.reasoningEffort; + } + + if (options?.responseFormat === "json_object") { + body.response_format = { type: "json_object" }; + } + + return body; +} + +function readFiniteNumber(value: unknown): number | undefined { + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +function readCachedTokens(u: Record): number | undefined { + const details = u.prompt_tokens_details ?? u.promptTokensDetails; + if (details && typeof details === "object") { + const d = details as Record; + const cached = readFiniteNumber(d.cached_tokens ?? d.cachedTokens); + if (cached != null) return cached; + } + return readFiniteNumber(u.prompt_cache_hit_tokens ?? u.promptCacheHitTokens); +} + +function readCacheMissTokens(u: Record): number | undefined { + return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens); +} + +/** Parse OpenAI-compatible usage object, including provider-specific cache fields. */ +export function parseUsage(raw: unknown): TokenUsage | undefined { + if (!raw || typeof raw !== "object") return undefined; + const u = raw as Record; + const prompt = Number(u.prompt_tokens ?? u.promptTokens); + const completion = Number(u.completion_tokens ?? u.completionTokens); + const total = Number(u.total_tokens ?? u.totalTokens); + if (!Number.isFinite(total) && !Number.isFinite(prompt)) return undefined; + + const cachedTokens = readCachedTokens(u); + const cacheMissTokens = readCacheMissTokens(u); + + return { + promptTokens: Number.isFinite(prompt) ? prompt : 0, + completionTokens: Number.isFinite(completion) ? completion : 0, + totalTokens: Number.isFinite(total) + ? total + : (Number.isFinite(prompt) ? prompt : 0) + + (Number.isFinite(completion) ? completion : 0), + ...(cachedTokens != null ? { cachedTokens } : {}), + ...(cacheMissTokens != null ? { cacheMissTokens } : {}), + }; +} + +function extractMessageParts(message: Record | undefined): { + content: string | null; + reasoning?: string; + toolCalls: ParsedToolCall[]; +} { + if (!message) return { content: "", toolCalls: [] }; + const rawContent = message.content; + const content = + typeof rawContent === "string" + ? rawContent.trim() || null + : rawContent == null + ? null + : ""; + const reasoning = + typeof message.reasoning_content === "string" + ? message.reasoning_content.trim() + : undefined; + + const toolCalls: ParsedToolCall[] = []; + const rawCalls = message.tool_calls; + if (Array.isArray(rawCalls)) { + for (const call of rawCalls) { + if (!call || typeof call !== "object") continue; + const c = call as Record; + const fn = c.function; + if (!fn || typeof fn !== "object") continue; + const f = fn as Record; + const name = typeof f.name === "string" ? f.name : ""; + const id = typeof c.id === "string" ? c.id : ""; + const args = + typeof f.arguments === "string" ? f.arguments : "{}"; + if (name && id) { + toolCalls.push({ id, name, arguments: args }); + } + } + } + + if (toolCalls.length > 0) { + return { content, reasoning: reasoning || undefined, toolCalls }; + } + if (content) return { content, reasoning: reasoning || undefined, toolCalls }; + if (reasoning) return { content: reasoning, reasoning, toolCalls }; + return { content: "", toolCalls }; +} + +export class OpenAiCompatibleProvider implements LlmProvider { + constructor(private readonly config: LlmConfig) {} + + async complete( + messages: ChatMessage[], + options?: CompleteOptions, + ): Promise { + const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify(buildRequestBody(this.config, messages, options)), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`LLM request failed (${response.status}): ${body}`); + } + + const data = (await response.json()) as { + model?: string; + usage?: unknown; + choices?: Array<{ message?: Record }>; + }; + const parts = extractMessageParts(data.choices?.[0]?.message); + if (!parts.content && parts.toolCalls.length === 0) { + throw new Error("LLM returned empty content"); + } + return { + content: parts.content ?? "", + reasoning: parts.reasoning, + usage: parseUsage(data.usage), + model: data.model ?? this.config.model, + }; + } + + async completeWithTools( + messages: ChatMessage[], + options: CompleteWithToolsOptions, + ): Promise { + const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify( + buildRequestBody(this.config, messages, { + ...options, + tools: options.tools, + }), + ), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`LLM request failed (${response.status}): ${body}`); + } + + const data = (await response.json()) as { + model?: string; + usage?: unknown; + choices?: Array<{ message?: Record }>; + }; + const parts = extractMessageParts(data.choices?.[0]?.message); + if (!parts.content && parts.toolCalls.length === 0) { + throw new Error("LLM returned empty content and no tool calls"); + } + return { + content: parts.content, + toolCalls: parts.toolCalls, + reasoning: parts.reasoning, + usage: parseUsage(data.usage), + model: data.model ?? this.config.model, + }; + } +} + +export type MockLlmStep = + | string + | { + toolCalls: Array<{ name: string; arguments: Record; id?: string }>; + content?: string | null; + }; + +export class MockLlmProvider implements LlmProvider { + private readonly responses: MockLlmStep[]; + private index = 0; + + constructor(responses: MockLlmStep[]) { + this.responses = responses; + } + + private nextStep(): MockLlmStep { + const step = this.responses[this.index] ?? this.responses.at(-1)!; + this.index += 1; + return step; + } + + async complete( + _messages: ChatMessage[], + options?: CompleteOptions, + ): Promise { + const step = this.nextStep(); + const response = + typeof step === "string" + ? step + : (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" })); + const approx = Math.max(1, Math.ceil(response.length / 4)); + return { + content: response, + usage: { + promptTokens: approx, + completionTokens: approx, + totalTokens: approx * 2, + }, + model: "mock", + ...(options?.caller ? {} : {}), + }; + } + + async completeWithTools( + _messages: ChatMessage[], + _options: CompleteWithToolsOptions, + ): Promise { + const step = this.nextStep(); + if (typeof step === "string") { + return { + content: step, + toolCalls: [], + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + model: "mock", + }; + } + const toolCalls: ParsedToolCall[] = step.toolCalls.map((tc, i) => ({ + id: tc.id ?? `mock_call_${this.index}_${i}`, + name: tc.name, + arguments: JSON.stringify(tc.arguments), + })); + return { + content: step.content ?? null, + toolCalls, + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + model: "mock", + }; + } +} + +export function createMockMainAgentResponse( + overrides: Record = {}, +): string { + return JSON.stringify({ + action: "ask_user", + reason: "请告诉我你想创作什么类型的作品、目标篇幅和风格偏好。", + workerId: null, + requiresApproval: false, + ...overrides, + }); +} + +export function createMockToolCall( + name: string, + args: Record, + id?: string, +): MockLlmStep { + return { toolCalls: [{ name, arguments: args, id }] }; +} diff --git a/src/llm/preset-wrapper.ts b/src/llm/preset-wrapper.ts new file mode 100644 index 0000000..d54b61e --- /dev/null +++ b/src/llm/preset-wrapper.ts @@ -0,0 +1,58 @@ +import { assemblePresetMessages, mergeMessages } from "../preset/assembler.js"; +import type { PresetPackage } from "../types/preset.js"; +import type { + ChatMessage, + CompleteOptions, + CompleteResult, + CompleteWithToolsOptions, + CompleteWithToolsResult, + LlmProvider, +} from "./client.js"; + +/** + * 在所有 LLM 请求前注入当前 preset 的 prompt 片段与生成参数。 + */ +export class PresetLlmProvider implements LlmProvider { + constructor( + private readonly inner: LlmProvider, + private readonly getPreset: () => PresetPackage | null, + ) {} + + async complete( + messages: ChatMessage[], + options?: CompleteOptions, + ): Promise { + const preset = this.getPreset(); + if (!preset) { + return this.inner.complete(messages, options); + } + + const presetMessages = assemblePresetMessages(preset); + const merged = mergeMessages(presetMessages, messages); + const generation = options?.generation ?? preset.generation; + + return this.inner.complete(merged, { + ...options, + generation, + }); + } + + async completeWithTools( + messages: ChatMessage[], + options: CompleteWithToolsOptions, + ): Promise { + const preset = this.getPreset(); + if (!preset) { + return this.inner.completeWithTools(messages, options); + } + + const presetMessages = assemblePresetMessages(preset); + const merged = mergeMessages(presetMessages, messages); + const generation = options.generation ?? preset.generation; + + return this.inner.completeWithTools(merged, { + ...options, + generation, + }); + } +} diff --git a/src/llm/token-tracker.ts b/src/llm/token-tracker.ts new file mode 100644 index 0000000..85e8220 --- /dev/null +++ b/src/llm/token-tracker.ts @@ -0,0 +1,85 @@ +import type { + CompleteOptions, + CompleteResult, + CompleteWithToolsOptions, + CompleteWithToolsResult, + LlmProvider, +} from "./client.js"; +import { + recordTokenUsage, + toMessageTokenUsage, + type MessageTokenUsage, +} from "../stats/token-store.js"; + +export type LlmTrackingContext = { + sessionId?: string; + bookId?: string; + bookTitle?: string; + orchestratorId?: string; + /** Set after each LLM call; consumed when the next system chat message is created */ + pendingUsage?: MessageTokenUsage; + pendingReasoning?: string; +}; + +export class TokenTrackingProvider implements LlmProvider { + constructor( + private readonly inner: LlmProvider, + private readonly getContext: () => LlmTrackingContext, + ) {} + + async complete( + messages: Parameters[0], + options?: CompleteOptions, + ): Promise { + const result = await this.inner.complete(messages, options); + const ctx = this.getContext(); + if (result.usage) { + const record = recordTokenUsage({ + sessionId: ctx.sessionId, + bookId: ctx.bookId, + bookTitle: ctx.bookTitle, + orchestratorId: ctx.orchestratorId, + caller: options?.caller ?? "unknown", + model: result.model ?? "unknown", + promptTokens: result.usage.promptTokens, + completionTokens: result.usage.completionTokens, + totalTokens: result.usage.totalTokens, + cachedTokens: result.usage.cachedTokens, + cacheMissTokens: result.usage.cacheMissTokens, + }); + ctx.pendingUsage = toMessageTokenUsage(record); + } + if (result.reasoning?.trim()) { + ctx.pendingReasoning = result.reasoning.trim(); + } + return result; + } + + async completeWithTools( + messages: Parameters[0], + options: CompleteWithToolsOptions, + ): Promise { + const result = await this.inner.completeWithTools(messages, options); + const ctx = this.getContext(); + if (result.usage) { + const record = recordTokenUsage({ + sessionId: ctx.sessionId, + bookId: ctx.bookId, + bookTitle: ctx.bookTitle, + orchestratorId: ctx.orchestratorId, + caller: options.caller ?? "unknown", + model: result.model ?? "unknown", + promptTokens: result.usage.promptTokens, + completionTokens: result.usage.completionTokens, + totalTokens: result.usage.totalTokens, + cachedTokens: result.usage.cachedTokens, + cacheMissTokens: result.usage.cacheMissTokens, + }); + ctx.pendingUsage = toMessageTokenUsage(record); + } + if (result.reasoning?.trim()) { + ctx.pendingReasoning = result.reasoning.trim(); + } + return result; + } +} diff --git a/src/main-agent/main-agent.ts b/src/main-agent/main-agent.ts new file mode 100644 index 0000000..700d04c --- /dev/null +++ b/src/main-agent/main-agent.ts @@ -0,0 +1,191 @@ +import { randomUUID } from "node:crypto"; +import type { LlmProvider } from "../llm/client.js"; +import type { BlackboardTagIndex } from "../types/blackboard.js"; +import type { MainAgentDecision, RuntimeSession } from "../types/runtime.js"; +import { runMainAgentToolLoop, type ToolLoopHandlers } from "./tool-loop.js"; + +export type MainAgentContext = { + session: RuntimeSession; + blackboardIndex: BlackboardTagIndex[]; + availableWorkers: Array<{ id: string; description: string }>; +}; + +export type MainAgentRunOptions = { + handlers: ToolLoopHandlers; +}; + +function buildMainAgentSystemPrompt( + workers: Array<{ id: string; description: string }>, +): string { + const workerLines = + workers.length > 0 + ? workers.map((w) => `- ${w.id}:${w.description}`).join("\n") + : "- (当前 skill 未加载 worker 列表)"; + + return `你是写作系统的总管(Main Agent)。你的职责是调度 worker,而不是直接创作正文。 + +规则: +1. 你不能直接生成小说/文章正文。 +2. 你不能修改运行状态;statePatchAllowed 必须始终为 false。 +3. 你只能建议下一步动作:ask_user、run_worker、create_temp_worker、review_blackboard、finish。 +4. 当信息不足时,使用 ask_user 向用户提问。 +5. 当需要执行任务时,使用 run_worker,只指定 workerId。不要指定 inputTags 或 outputTags——Runtime 从 Worker Skill 读取。 +6. requiresApproval 表示运行 worker 前是否需要用户确认。代笔模式通常为 true。 +7. run_worker 可选 workerContext:{ "roleId": "A" },用于 role-decide 等指定当前决策角色(Runtime 写入 世界.当前角色.id)。 +8. 你不能把未验收内容当作事实。 +9. 向用户提问是 worker 的能力(ask_user tool),不是独立 worker。总管只在调度层提问。 +10. blackboardIndex 只有 tag 索引,不含正文 content。 + +当前 skill 可用 worker(workerId 必须与下列 id 完全一致): +${workerLines} + +输出必须是 JSON 对象,字段: +{ + "action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish", + "reason": "string", + "workerId": "string | null", + "requiresApproval": boolean, + "workerContext": { "roleId": "string" } | null +}`; +} + +export class MainAgent { + constructor(private readonly llm: LlmProvider) {} + + /** @deprecated 单次 JSON 决策;请用 runToolLoop */ + async decide(context: MainAgentContext): Promise { + const userPrompt = buildMainAgentUserPrompt(context); + const systemPrompt = buildMainAgentSystemPrompt(context.availableWorkers); + const result = await this.llm.complete( + [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + { responseFormat: "json_object", caller: "main_agent" }, + ); + return parseMainAgentDecision(result.content); + } + + /** running 相位:tool loop 直到终止 tool */ + async runToolLoop( + context: MainAgentContext, + options: MainAgentRunOptions, + ): Promise { + const { decision } = await runMainAgentToolLoop( + this.llm, + context, + options.handlers, + ); + return decision; + } +} + +export function buildMainAgentUserPrompt(context: MainAgentContext): string { + const { session, blackboardIndex, availableWorkers } = context; + return JSON.stringify( + { + runtimePhase: session.phase, + waitingReason: session.waitingReason, + flowId: session.flowId, + currentStepId: session.currentStepId, + currentWorkerId: session.currentWorkerId, + acceptanceMode: session.acceptanceMode, + slots: session.slots, + pendingDecision: session.pendingDecision + ? { + id: session.pendingDecision.id, + action: session.pendingDecision.action, + reason: session.pendingDecision.reason, + } + : null, + pendingArtifactId: session.pendingArtifactId, + artifacts: session.artifacts.map((a) => ({ + id: a.id, + workerId: a.workerId, + status: a.status, + summary: a.summary, + outputTags: a.outputTags, + })), + blackboardIndex, + availableWorkers, + instruction: + "根据当前状态决定下一步。若 collecting_input 或 revision_requested,优先理解用户最新输入。若 planning 且已有足够信息,建议 run_worker(仅 workerId)。", + }, + null, + 2, + ); +} + +export function parseMainAgentDecision(raw: string): MainAgentDecision { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Main Agent returned invalid JSON: ${raw.slice(0, 200)}`); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("Main Agent decision must be an object"); + } + + const obj = parsed as Record; + const action = obj.action; + if ( + action !== "ask_user" && + action !== "run_worker" && + action !== "create_temp_worker" && + action !== "review_blackboard" && + action !== "finish" + ) { + throw new Error(`Invalid action: ${String(action)}`); + } + + const reason = typeof obj.reason === "string" ? obj.reason : ""; + if (!reason) { + throw new Error("Main Agent decision requires reason"); + } + + const workerId = + typeof obj.workerId === "string" + ? obj.workerId + : obj.workerId === null || obj.workerId === undefined + ? undefined + : undefined; + + let workerContext: MainAgentDecision["workerContext"]; + const ctxRaw = obj.workerContext; + if (ctxRaw && typeof ctxRaw === "object" && !Array.isArray(ctxRaw)) { + const roleIdRaw = (ctxRaw as Record).roleId; + const roleId = typeof roleIdRaw === "string" ? roleIdRaw.trim() : undefined; + if (roleId) workerContext = { roleId }; + } + + return { + id: randomUUID(), + action, + reason, + workerId, + workerContext, + requiresApproval: Boolean(obj.requiresApproval), + statePatchAllowed: false, + }; +} + +export const DEFAULT_WORKERS = [ + { + id: "write-rules", + description: "规则怪谈:内部 core.danger + 护命规则 rules.draft + 解析", + }, + { + id: "outline", + description: "根据 book.brief 生成 outline.draft", + }, + { + id: "review-infer", + description: "读者视角验收 rules(不含 core.danger)", + }, + { + id: "review-author", + description: "作者视角验收 rules 与 core 一致性", + }, +] as const; diff --git a/src/main-agent/tool-loop.ts b/src/main-agent/tool-loop.ts new file mode 100644 index 0000000..e65ba33 --- /dev/null +++ b/src/main-agent/tool-loop.ts @@ -0,0 +1,173 @@ +import type { ChatMessage, LlmProvider, ParsedToolCall } from "../llm/client.js"; +import { toolCallToDecision, validateLoopToolCall } from "../runtime/tool-registry.js"; +import type { MainAgentDecision } from "../types/runtime.js"; +import { isMainAgentTerminalTool } from "../types/tools.js"; +import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js"; +import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js"; +import type { MainAgentContext } from "./main-agent.js"; + +export type ToolLoopHandlers = { + readBlackboard: (tags: string[]) => Record; + listWorkers: () => Array<{ id: string; description: string }>; + listArtifacts: () => Array<{ + id: string; + workerId: string; + status: string; + summary?: string; + outputTags: string[]; + }>; + onToolCall?: (name: string, detail: string) => void; +}; + +export type ToolLoopResult = { + decision: MainAgentDecision; + iterations: number; + toolTrace: string[]; +}; + +const MAX_TOOL_LOOP_ITERATIONS = 12; + +function buildToolLoopSystemPrompt( + workers: Array<{ id: string; description: string }>, +): string { + const workerLines = + workers.length > 0 + ? workers.map((w) => `- ${w.id}:${w.description}`).join("\n") + : "- (当前 skill 未加载 worker 列表)"; + + return `你是写作系统的总管(Main Agent)。你在 running 相位通过 **tool call** 推进流程。 + +规则: +1. 你不能直接生成小说/文章正文。 +2. 你不能修改运行状态。 +3. 先用 read_blackboard / list_workers / list_artifacts 收集信息,再决定下一步。 +4. 终止动作只能用 tool:ask_user、run_worker、review_blackboard、finish。 +5. run_worker 只传 workerId;inputTags/outputTags 由 Runtime 从 Worker Skill 读取。 +6. requiresApproval=true 时 run_worker 需用户确认后再执行。 +7. run_worker 可选 roleId,用于 role-decide 等指定当前决策角色。 +8. 不能把未验收产物当作已定事实。 + +当前 skill 可用 worker: +${workerLines} + +在信息足够前可多次调用 read_blackboard 等;一旦调用终止 tool,本轮循环结束。`; +} + +function executeLoopTool( + call: ParsedToolCall, + handlers: ToolLoopHandlers, +): string { + const name = validateLoopToolCall(call); + const args = JSON.parse(call.arguments || "{}") as Record; + + switch (name) { + case "read_blackboard": { + const tags = Array.isArray(args.tags) + ? args.tags.filter((t): t is string => typeof t === "string") + : []; + if (tags.length === 0) { + return JSON.stringify({ error: "tags must be a non-empty string array" }); + } + return JSON.stringify(handlers.readBlackboard(tags)); + } + case "list_workers": + return JSON.stringify(handlers.listWorkers()); + case "list_artifacts": + return JSON.stringify(handlers.listArtifacts()); + default: + return JSON.stringify({ error: `Unhandled loop tool: ${name}` }); + } +} + +function assistantMessageFromToolCalls( + content: string | null, + toolCalls: ParsedToolCall[], +): ChatMessage { + return { + role: "assistant", + content, + tool_calls: toolCalls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { name: tc.name, arguments: tc.arguments }, + })), + }; +} + +/** + * 总管 tool loop:在 running 相位内可多轮调用 read_blackboard 等, + * 直到调用终止 tool 并返回 MainAgentDecision。 + */ +export async function runMainAgentToolLoop( + llm: LlmProvider, + context: MainAgentContext, + handlers: ToolLoopHandlers, +): Promise { + const messages: ChatMessage[] = [ + { + role: "system", + content: buildToolLoopSystemPrompt(context.availableWorkers), + }, + { role: "user", content: buildMainAgentUserPrompt(context) }, + ]; + + const toolTrace: string[] = []; + + for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) { + const result = await llm.completeWithTools(messages, { + tools: MAIN_AGENT_TOOL_DEFINITIONS, + caller: "main_agent", + }); + + if (result.toolCalls.length === 0) { + if (result.content?.trim()) { + const decision = parseMainAgentDecision(result.content); + return { decision, iterations: iteration, toolTrace }; + } + throw new Error("Main Agent returned no tool calls and no content"); + } + + const terminalCalls = result.toolCalls.filter((tc) => + isMainAgentTerminalTool(tc.name), + ); + const loopCalls = result.toolCalls.filter( + (tc) => !isMainAgentTerminalTool(tc.name), + ); + + if (terminalCalls.length > 1) { + throw new Error( + `Main Agent returned multiple terminal tools: ${terminalCalls.map((t) => t.name).join(", ")}`, + ); + } + + messages.push( + assistantMessageFromToolCalls(result.content, result.toolCalls), + ); + + if (terminalCalls.length === 1) { + const terminal = terminalCalls[0]; + handlers.onToolCall?.(terminal.name, terminal.arguments); + toolTrace.push(`${terminal.name} (terminal)`); + return { + decision: toolCallToDecision(terminal), + iterations: iteration, + toolTrace, + }; + } + + for (const call of loopCalls) { + const output = executeLoopTool(call, handlers); + handlers.onToolCall?.(call.name, output.slice(0, 200)); + toolTrace.push(call.name); + messages.push({ + role: "tool", + content: output, + tool_call_id: call.id, + }); + } + } + + throw new Error( + `Main Agent tool loop exceeded ${MAX_TOOL_LOOP_ITERATIONS} iterations`, + ); +} diff --git a/src/main-agent/tools.ts b/src/main-agent/tools.ts new file mode 100644 index 0000000..ca4b109 --- /dev/null +++ b/src/main-agent/tools.ts @@ -0,0 +1,121 @@ +import type { ToolDefinition } from "../llm/client.js"; + +/** 总管可用 tool 的 OpenAI function 定义 */ +export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [ + { + type: "function", + function: { + name: "read_blackboard", + description: + "读取黑板 tag 正文。tags 可为精确 tag 或带 * 前缀模式。调度前用此了解已有内容。", + parameters: { + type: "object", + properties: { + tags: { + type: "array", + items: { type: "string" }, + description: "要读取的 tag 或模式列表", + }, + }, + required: ["tags"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "list_workers", + description: "列出当前 skill 可调度的 worker id 与说明。", + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "list_artifacts", + description: "列出当前会话 worker 产物(id、workerId、status、summary)。", + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "ask_user", + description: "信息不足时向用户提问,将暂停 agent 循环等待用户输入。", + parameters: { + type: "object", + properties: { + reason: { type: "string", description: "为何需要用户输入" }, + message: { type: "string", description: "展示给用户的问题或说明" }, + }, + required: ["reason"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "run_worker", + description: + "调度 worker 执行任务。只传 workerId,inputTags/outputTags 由 Runtime 从 Worker Skill 读取。", + parameters: { + type: "object", + properties: { + workerId: { type: "string" }, + reason: { type: "string" }, + requiresApproval: { + type: "boolean", + description: "true 时需用户确认后才执行", + }, + roleId: { + type: "string", + description: "role-decide 等 worker 的当前角色 id", + }, + }, + required: ["workerId", "reason", "requiresApproval"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "review_blackboard", + description: "向用户说明当前黑板与进度概况,并暂停等待用户回复。", + parameters: { + type: "object", + properties: { + reason: { type: "string" }, + summary: { type: "string", description: "给用户看的概况说明" }, + }, + required: ["reason", "summary"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "finish", + description: "正常结束当前创作流程。", + parameters: { + type: "object", + properties: { + reason: { type: "string" }, + }, + required: ["reason"], + additionalProperties: false, + }, + }, + }, +]; diff --git a/src/preset/assembler.ts b/src/preset/assembler.ts new file mode 100644 index 0000000..9c51f6e --- /dev/null +++ b/src/preset/assembler.ts @@ -0,0 +1,49 @@ +import type { ChatMessage } from "../llm/client.js"; +import type { PresetPackage } from "../types/preset.js"; + +export type MarkerResolver = (identifier: string) => string | null; + +const defaultMarkerResolver: MarkerResolver = () => null; + +/** + * 按 prompt_order 装配 preset 消息,插入在所有业务 prompt 之前。 + */ +export function assemblePresetMessages( + preset: PresetPackage, + resolveMarker: MarkerResolver = defaultMarkerResolver, +): ChatMessage[] { + const promptById = new Map(preset.prompts.map((p) => [p.id, p])); + const messages: ChatMessage[] = []; + + const ordered = [...preset.promptOrder].sort( + (a, b) => a.orderIndex - b.orderIndex, + ); + + for (const orderItem of ordered) { + if (!orderItem.enabled) continue; + const entry = promptById.get(orderItem.promptId); + if (!entry || !entry.enabled) continue; + + let content = entry.content.trim(); + if (!content && entry.marker) { + const resolved = resolveMarker(entry.sourceIdentifier); + if (resolved?.trim()) content = resolved.trim(); + } + + if (!content) continue; + + messages.push({ + role: entry.role, + content, + }); + } + + return messages; +} + +export function mergeMessages( + presetMessages: ChatMessage[], + taskMessages: ChatMessage[], +): ChatMessage[] { + return [...presetMessages, ...taskMessages]; +} diff --git a/src/preset/entries.ts b/src/preset/entries.ts new file mode 100644 index 0000000..13f5eba --- /dev/null +++ b/src/preset/entries.ts @@ -0,0 +1,46 @@ +import type { PresetPackage, PresetPromptRole } from "../types/preset.js"; + +export type PresetEnabledEntryView = { + orderIndex: number; + id: string; + name: string; + role: PresetPromptRole; + marker: boolean; + content: string; + /** 实际会注入 LLM 请求(有非空 content) */ + willInject: boolean; +}; + +/** 按 prompt_order 列出所有启用条目及其内容 */ +export function listEnabledPresetEntries( + preset: PresetPackage, +): PresetEnabledEntryView[] { + const promptById = new Map(preset.prompts.map((p) => [p.id, p])); + const ordered = [...preset.promptOrder].sort( + (a, b) => a.orderIndex - b.orderIndex, + ); + const entries: PresetEnabledEntryView[] = []; + + for (const orderItem of ordered) { + if (!orderItem.enabled) continue; + const entry = promptById.get(orderItem.promptId); + if (!entry || !entry.enabled) continue; + + const content = entry.content.trim(); + entries.push({ + orderIndex: orderItem.orderIndex, + id: entry.id, + name: entry.name, + role: entry.role, + marker: entry.marker, + content, + willInject: content.length > 0, + }); + } + + return entries; +} + +export function countInjectingEntries(entries: PresetEnabledEntryView[]): number { + return entries.filter((e) => e.willInject).length; +} diff --git a/src/preset/importer.ts b/src/preset/importer.ts new file mode 100644 index 0000000..a43a1de --- /dev/null +++ b/src/preset/importer.ts @@ -0,0 +1,235 @@ +import { randomUUID } from "node:crypto"; +import type { + GenerationParameters, + PresetImportReport, + PresetPackage, + PresetPromptEntry, + PresetPromptOrderItem, + UnsupportedPresetSection, +} from "../types/preset.js"; + +type StPrompt = { + identifier?: string; + name?: string; + enabled?: boolean; + role?: string; + content?: string; + marker?: boolean; + system_prompt?: boolean; + injection_position?: number; + injection_depth?: number; + injection_order?: number; + forbid_overrides?: boolean; +}; + +type StPromptOrderBlock = { + character_id?: number; + order?: Array<{ identifier: string; enabled: boolean }>; +}; + +type SillyTavernPreset = { + temperature?: number; + top_p?: number; + top_k?: number; + min_p?: number; + frequency_penalty?: number; + presence_penalty?: number; + repetition_penalty?: number; + openai_max_context?: number; + openai_max_tokens?: number; + stream_openai?: boolean; + reasoning_effort?: string; + verbosity?: string; + seed?: number; + n?: number; + prompts?: StPrompt[]; + prompt_order?: StPromptOrderBlock[]; + regex_scripts?: unknown; + extensions?: unknown; + [key: string]: unknown; +}; + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^\w\u4e00-\u9fff-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); +} + +function normalizeRole(role: string | undefined): PresetPromptEntry["role"] { + if (role === "user" || role === "assistant" || role === "system") { + return role; + } + return "system"; +} + +function readGeneration(raw: SillyTavernPreset): GenerationParameters { + return { + temperature: raw.temperature, + topP: raw.top_p, + topK: raw.top_k, + minP: raw.min_p, + frequencyPenalty: raw.frequency_penalty, + presencePenalty: raw.presence_penalty, + repetitionPenalty: raw.repetition_penalty, + maxContextTokens: raw.openai_max_context, + maxOutputTokens: raw.openai_max_tokens, + stream: raw.stream_openai, + reasoningEffort: raw.reasoning_effort, + verbosity: raw.verbosity, + seed: raw.seed !== undefined && raw.seed >= 0 ? raw.seed : undefined, + variants: raw.n, + }; +} + +function selectPromptOrder( + raw: SillyTavernPreset, + promptMap: Map, +): StPromptOrderBlock { + const orders = raw.prompt_order ?? []; + if (orders.length === 0) return { order: [] }; + if (orders.length === 1) return orders[0]; + + let best = orders[0]; + let bestScore = -1; + for (const block of orders) { + let score = 0; + for (const item of block.order ?? []) { + if (!item.enabled) continue; + const prompt = promptMap.get(item.identifier); + if (!prompt) continue; + if (prompt.content?.trim()) score += 3; + else if (prompt.marker) score += 0; + else score += 1; + } + if (score > bestScore) { + bestScore = score; + best = block; + } + } + return best; +} + +function collectUnsupported(raw: SillyTavernPreset): UnsupportedPresetSection[] { + const unsupported: UnsupportedPresetSection[] = []; + if (raw.regex_scripts) { + unsupported.push({ + path: "regex_scripts", + reason: "第一版不支持正则脚本", + }); + } + if (raw.extensions) { + unsupported.push({ + path: "extensions", + reason: "第一版不支持扩展脚本", + }); + } + return unsupported; +} + +export function importSillyTavernPreset( + rawInput: unknown, + options: { name?: string; id?: string } = {}, +): PresetImportReport { + const raw = rawInput as SillyTavernPreset; + const warnings: string[] = []; + const stPrompts = raw.prompts ?? []; + const promptMap = new Map(); + + for (const p of stPrompts) { + const id = p.identifier ?? randomUUID(); + if (promptMap.has(id)) { + warnings.push(`重复 identifier: ${id}`); + } + promptMap.set(id, p); + } + + const selectedOrder = selectPromptOrder(raw, promptMap); + if ((raw.prompt_order?.length ?? 0) > 1) { + warnings.push( + `检测到 ${raw.prompt_order!.length} 套 prompt_order,已自动选用启用内容最多的一套 (character_id=${selectedOrder.character_id ?? "?"})`, + ); + } + + const prompts: PresetPromptEntry[] = stPrompts.map((p) => { + const sourceId = p.identifier ?? randomUUID(); + return { + id: sourceId, + name: p.name ?? sourceId, + enabled: p.enabled !== false, + role: normalizeRole(p.role), + content: p.content ?? "", + marker: Boolean(p.marker), + sourceIdentifier: sourceId, + injection: { + position: p.injection_position, + depth: p.injection_depth, + order: p.injection_order, + }, + }; + }); + + const promptOrder: PresetPromptOrderItem[] = []; + const missingIdentifiers: string[] = []; + const orderItems = selectedOrder.order ?? []; + + orderItems.forEach((item, index) => { + if (!promptMap.has(item.identifier)) { + missingIdentifiers.push(item.identifier); + return; + } + promptOrder.push({ + promptId: item.identifier, + enabled: item.enabled, + orderIndex: index, + }); + }); + + const referenced = new Set(promptOrder.map((o) => o.promptId)); + const unreferencedCount = prompts.filter( + (p) => !referenced.has(p.id), + ).length; + + const enabledCount = promptOrder.filter((o) => { + if (!o.enabled) return false; + const entry = prompts.find((p) => p.id === o.promptId); + return entry?.enabled !== false; + }).length; + + const generation = readGeneration(raw); + const generationFields = Object.entries(generation) + .filter(([, v]) => v !== undefined) + .map(([k]) => k); + + const presetName = + options.name?.trim() || + (typeof rawInput === "object" && + rawInput && + "name" in rawInput && + typeof (rawInput as { name: unknown }).name === "string" + ? (rawInput as { name: string }).name + : "导入的预设"); + + const preset: PresetPackage = { + id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`, + name: presetName, + source: "sillytavern", + prompts, + promptOrder, + generation, + unsupported: collectUnsupported(raw), + importedAt: new Date().toISOString(), + raw: rawInput, + }; + + return { + preset, + promptCount: prompts.length, + enabledCount, + unreferencedCount, + missingIdentifiers, + generationFields, + warnings, + }; +} diff --git a/src/preset/store.ts b/src/preset/store.ts new file mode 100644 index 0000000..4054fa8 --- /dev/null +++ b/src/preset/store.ts @@ -0,0 +1,67 @@ +import { readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { PresetPackage } from "../types/preset.js"; +import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js"; +import { importSillyTavernPreset } from "./importer.js"; + +function presetPath(id: string): string { + return path.join(getPresetsDir(), `${id}.json`); +} + +export function listPresets(): PresetPackage[] { + ensureUserDataDirs(); + let files: string[]; + try { + files = readdirSync(getPresetsDir()).filter((f) => f.endsWith(".json")); + } catch { + return []; + } + + const presets: PresetPackage[] = []; + for (const file of files) { + try { + const raw = readFileSync(path.join(getPresetsDir(), file), "utf8"); + presets.push(JSON.parse(raw) as PresetPackage); + } catch { + /* skip corrupt files */ + } + } + return presets.sort((a, b) => a.name.localeCompare(b.name, "zh-CN")); +} + +export function getPreset(id: string): PresetPackage | null { + try { + const raw = readFileSync(presetPath(id), "utf8"); + return JSON.parse(raw) as PresetPackage; + } catch { + return null; + } +} + +export function savePreset(preset: PresetPackage): PresetPackage { + ensureUserDataDirs(); + writeFileSync(presetPath(preset.id), JSON.stringify(preset, null, 2), "utf8"); + return preset; +} + +export function deletePreset(id: string): void { + try { + unlinkSync(presetPath(id)); + } catch { + /* ignore */ + } +} + +export function importAndSavePreset( + raw: unknown, + options: { name?: string } = {}, +) { + const report = importSillyTavernPreset(raw, options); + savePreset(report.preset); + return report; +} + +export function resolveActivePreset(activePresetId: string | null): PresetPackage | null { + if (!activePresetId) return null; + return getPreset(activePresetId); +} diff --git a/src/runtime/llm-factory.ts b/src/runtime/llm-factory.ts new file mode 100644 index 0000000..26b0633 --- /dev/null +++ b/src/runtime/llm-factory.ts @@ -0,0 +1,78 @@ +import { profileToLlmConfig } from "../config/api-profiles.js"; +import { + ensureActiveProfileDefault, + loadAppSettings, + resolveActiveProfile, +} from "../config/settings.js"; +import { resolveActivePreset } from "../preset/store.js"; +import { + createMockMainAgentResponse, + MockLlmProvider, + OpenAiCompatibleProvider, + type LlmProvider, +} from "../llm/client.js"; +import { PresetLlmProvider } from "../llm/preset-wrapper.js"; +import { + TokenTrackingProvider, + type LlmTrackingContext, +} from "../llm/token-tracker.js"; + +export type LlmTrackingRef = { current: LlmTrackingContext }; + +function buildInnerLlm(): LlmProvider { + ensureActiveProfileDefault(); + const profile = resolveActiveProfile(); + + if (profile?.apiKey?.trim()) { + return new OpenAiCompatibleProvider(profileToLlmConfig(profile)); + } + + return new MockLlmProvider([ + createMockMainAgentResponse({ + action: "run_worker", + reason: "信息已足够,建议运行 outline-worker 生成大纲。", + workerId: "outline-worker", + requiresApproval: true, + }), + createMockMainAgentResponse({ + action: "finish", + reason: "创作流程结束", + requiresApproval: false, + }), + ]); +} + +function wrapWithPreset(inner: LlmProvider): LlmProvider { + const settings = loadAppSettings(); + const preset = resolveActivePreset(settings.activePresetId); + if (!preset) return inner; + return new PresetLlmProvider(inner, () => + resolveActivePreset(loadAppSettings().activePresetId), + ); +} + +/** Web/CLI 默认 LLM:本地 profile + 全局 preset + token 统计 */ +export function createDefaultMainAgentLlm( + trackingRef?: LlmTrackingRef, +): LlmProvider { + const llm = wrapWithPreset(buildInnerLlm()); + if (!trackingRef) return llm; + return new TokenTrackingProvider(llm, () => trackingRef.current); +} + +export function hasRealLlmConfig(): boolean { + ensureActiveProfileDefault(); + return Boolean(resolveActiveProfile()?.apiKey?.trim()); +} + +/** @deprecated 包装层下 instanceof 不可靠,请用 hasRealLlmConfig */ +export function isMockLlm(_llm: LlmProvider): boolean { + return !hasRealLlmConfig(); +} + +/** 设置页切换 profile / preset 后调用,返回新 LLM 实例 */ +export function reloadDefaultMainAgentLlm( + trackingRef?: LlmTrackingRef, +): LlmProvider { + return createDefaultMainAgentLlm(trackingRef); +} diff --git a/src/runtime/orchestrator.ts b/src/runtime/orchestrator.ts new file mode 100644 index 0000000..7ff5f7b --- /dev/null +++ b/src/runtime/orchestrator.ts @@ -0,0 +1,291 @@ +/** + * @deprecated 请使用 PhaseRuntime(已含 Skill 流程 + 总管 LLM 循环)。 + * 本文件保留供对照测试,新入口见 phase-demo / web / run.ts。 + */ +import { randomUUID } from "node:crypto"; +import { Blackboard } from "../blackboard/blackboard.js"; +import type { LlmProvider } from "../llm/client.js"; +import { + DEFAULT_WORKERS, + MainAgent, +} from "../main-agent/main-agent.js"; +import { + applyEvent, + createArtifact, + createSession, +} from "../runtime/phase-machine.js"; +import type { + AcceptanceMode, + MainAgentDecision, + PhaseEffect, + RuntimeEvent, + RuntimeSession, +} from "../types/runtime.js"; + +export type RuntimeOrchestratorOptions = { + presetId?: string; + flowId?: string; + llm: LlmProvider; + blackboard?: Blackboard; + acceptanceMode?: AcceptanceMode; + onMessage?: (message: string) => void; +}; + +export class RuntimeOrchestrator { + private session: RuntimeSession; + private readonly blackboard: Blackboard; + private readonly mainAgent: MainAgent; + private readonly acceptanceMode: AcceptanceMode; + private readonly onMessage: (message: string) => void; + + constructor(options: RuntimeOrchestratorOptions) { + this.session = createSession(options.presetId ?? "default", options.flowId); + this.blackboard = options.blackboard ?? new Blackboard(); + this.mainAgent = new MainAgent(options.llm); + this.acceptanceMode = options.acceptanceMode ?? "user_confirmed"; + this.onMessage = options.onMessage ?? (() => {}); + } + + getSession(): RuntimeSession { + return this.session; + } + + getBlackboard(): Blackboard { + return this.blackboard; + } + + async start(): Promise { + await this.dispatch({ + type: "session_started", + payload: { + presetId: this.session.presetId, + flowId: this.session.flowId, + availableSkills: [{ name: "basic", description: "fallback", category: "novel" }], + }, + }); + return this.session; + } + + async submitUserInput(text: string): Promise { + await this.dispatch({ + type: "user_submitted_input", + payload: { text }, + }); + return this.session; + } + + async approveNextStep(): Promise { + const decisionId = this.session.pendingDecision?.id; + if (!decisionId) { + throw new Error("No pending decision to approve"); + } + await this.dispatch({ + type: "user_approved_next_step", + payload: { decisionId }, + }); + return this.session; + } + + async rejectNextStep(reason?: string): Promise { + const decisionId = this.session.pendingDecision?.id; + if (!decisionId) { + throw new Error("No pending decision to reject"); + } + await this.dispatch({ + type: "user_rejected_next_step", + payload: { decisionId, reason }, + }); + return this.session; + } + + async acceptArtifact(artifactId?: string): Promise { + const id = artifactId ?? this.session.pendingArtifactId; + if (!id) { + throw new Error("No pending artifact to accept"); + } + await this.dispatch({ + type: "user_accepted_artifact", + payload: { artifactId: id }, + }); + return this.session; + } + + async rejectArtifact(reason?: string, artifactId?: string): Promise { + const id = artifactId ?? this.session.pendingArtifactId; + if (!id) { + throw new Error("No pending artifact to reject"); + } + await this.dispatch({ + type: "user_rejected_artifact", + payload: { artifactId: id, reason }, + }); + return this.session; + } + + async completeFlow(): Promise { + await this.dispatch({ type: "flow_completed", payload: {} }); + return this.session; + } + + private async dispatch(event: RuntimeEvent): Promise { + let result = applyEvent(this.session, event); + this.session = result.session; + + if (result.error && event.type !== "runtime_failed") { + this.onMessage(result.error); + } + + await this.processEffects(result.effects); + + if (result.effects.some((e) => e.type === "invoke_main_agent")) { + await this.runMainAgent(); + } + } + + private async processEffects(effects: PhaseEffect[]): Promise { + for (const effect of effects) { + switch (effect.type) { + case "emit_message": + this.onMessage(effect.message); + break; + case "run_worker": + await this.runStubWorker(effect); + break; + case "resume_worker": + await this.resumeStubWorker(); + break; + case "run_programmatic_review": + await this.runProgrammaticReview(effect.artifactId); + break; + case "invoke_main_agent": + break; + } + } + } + + private async runMainAgent(): Promise { + if (this.session.phase !== "running") { + return; + } + + const decision = await this.mainAgent.decide({ + session: this.session, + blackboardIndex: this.blackboard.listTagIndex(), + availableWorkers: [...DEFAULT_WORKERS], + }); + + this.onMessage(`[总管] ${decision.action}: ${decision.reason}`); + + const result = applyEvent(this.session, { + type: "main_agent_decision_created", + payload: { decision }, + }); + this.session = result.session; + await this.processEffects(result.effects); + + if (result.effects.some((e) => e.type === "invoke_main_agent")) { + await this.runMainAgent(); + } + } + + private async runStubWorker( + effect: Extract, + ): Promise { + await this.executeStubWorker(effect.workerId); + } + + private async resumeStubWorker(): Promise { + const ctx = this.session.resumeContext; + if (!ctx) { + throw new Error("No resume context"); + } + await this.executeStubWorker(ctx.workerId); + } + + private async executeStubWorker(workerId: string): Promise { + const stepId = this.session.currentStepId; + + this.session = applyEvent(this.session, { + type: "worker_started", + payload: { + workerId, + stepId, + acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode, + }, + }).session; + + this.onMessage(`[Worker 占位] 正在执行 ${workerId} ...`); + + const lastInput = String(this.session.slots.lastUserInput ?? ""); + const summary = `${workerId} 针对用户输入生成的占位产物`; + const value = `[${workerId} 占位输出]\n用户输入: ${lastInput}\n说明: 这是第一版 stub worker,后续会替换为真实 LLM worker。`; + + const outputTags = ["output.草稿"]; + + for (const tag of outputTags) { + this.blackboard.write({ + tag, + content: value, + source: workerId, + }); + } + + const artifact = createArtifact({ + workerId, + stepId, + outputTags, + summary, + }); + this.session = { + ...this.session, + artifacts: [...this.session.artifacts, artifact], + }; + + const completed = applyEvent(this.session, { + type: "worker_completed", + payload: { artifactId: artifact.id }, + }); + this.session = completed.session; + await this.processEffects(completed.effects); + + if (completed.effects.some((e) => e.type === "invoke_main_agent")) { + await this.runMainAgent(); + } + } + + private async runProgrammaticReview(artifactId: string): Promise { + this.session = applyEvent(this.session, { + type: "programmatic_review_started", + payload: { artifactId }, + }).session; + + const artifact = this.session.artifacts.find((a) => a.id === artifactId); + const passed = Boolean(artifact?.summary && artifact.summary.length > 0); + + const reviewEvent: RuntimeEvent = passed + ? { type: "programmatic_review_passed", payload: { artifactId } } + : { + type: "programmatic_review_failed", + payload: { artifactId, reason: "产物 summary 为空" }, + }; + + const result = applyEvent(this.session, reviewEvent); + this.session = result.session; + await this.processEffects(result.effects); + + if (result.effects.some((e) => e.type === "invoke_main_agent")) { + await this.runMainAgent(); + } + } +} + +export function createDecision( + partial: Omit & + Partial>, +): MainAgentDecision { + return { + id: partial.id ?? randomUUID(), + statePatchAllowed: false, + ...partial, + }; +} diff --git a/src/runtime/phase-machine.ts b/src/runtime/phase-machine.ts new file mode 100644 index 0000000..179d752 --- /dev/null +++ b/src/runtime/phase-machine.ts @@ -0,0 +1,793 @@ +/** + * 纯函数运行阶段机。 + * + * - 无副作用:不读写文件、不调用 LLM、不启动 worker + * - 输入 (session, event),输出 { session, effects } + * - phase 只能由 applyEvent 改变;非法事件返回 error 并进入 phase=error + * + * 上层调用方:PhaseRuntime.dispatch() → applyEvent → processEffects() + */ +import { randomUUID } from "node:crypto"; +import type { + ApplyEventResult, + ArtifactRecord, + PhaseEffect, + ResumeContext, + RuntimeEvent, + RuntimePhase, + RuntimeSession, + WaitingReason, +} from "../types/runtime.js"; +import type { ActiveSkillSnapshot } from "../types/runtime.js"; +import { + buildIntakeProgress, + buildIntakeFollowUpMessage, + readIntakeValues, + synthesizeDemandText, +} from "../intake/intake.js"; + +function nowIso(): string { + return new Date().toISOString(); +} + +/** 更新 updatedAt 时间戳 */ +function touch(session: RuntimeSession): RuntimeSession { + return { ...session, updatedAt: nowIso() }; +} + +/** 将用户补充文本追加到 slot(用于合并多轮 ask_user / worker 答复到需求 tag) */ +function mergeSlotText( + slots: Record, + key: string, + text: string, +): void { + const trimmed = text.trim(); + if (!trimmed) return; + const prev = String(slots[key] ?? "").trim(); + slots[key] = prev ? `${prev}\n\n${trimmed}` : trimmed; +} + +/** 追加事件到 history 并 touch */ +function appendHistory( + session: RuntimeSession, + event: RuntimeEvent, +): RuntimeSession { + return touch({ ...session, history: [...session.history, event] }); +} + +/** 进入 waiting_user,并设置 waitingReason */ +function waiting(session: RuntimeSession, reason: WaitingReason): RuntimeSession { + return touch({ ...session, phase: "waiting_user", waitingReason: reason }); +} + +/** 进入 running,清除 waitingReason */ +function running(session: RuntimeSession): RuntimeSession { + return touch({ ...session, phase: "running", waitingReason: undefined }); +} + +function findArtifact( + session: RuntimeSession, + artifactId: string, +): ArtifactRecord | undefined { + return session.artifacts.find((a) => a.id === artifactId); +} + +function updateArtifact( + session: RuntimeSession, + artifactId: string, + patch: Partial, +): RuntimeSession { + return { + ...session, + artifacts: session.artifacts.map((a) => + a.id === artifactId ? { ...a, ...patch, updatedAt: nowIso() } : a, + ), + }; +} + +/** 创建 idle 状态的新会话 */ +export function createSession( + presetId: string, + flowId?: string, +): RuntimeSession { + const ts = nowIso(); + return { + id: randomUUID(), + phase: "idle", + presetId, + flowId, + slots: {}, + artifacts: [], + history: [], + createdAt: ts, + updatedAt: ts, + }; +} + +/** + * 按当前 phase 返回允许的事件类型列表(第一层校验)。 + * waitingReason 的细粒度匹配见 canApplyEvent。 + */ +export function getAllowedEvents( + session: RuntimeSession, +): RuntimeEvent["type"][] { + switch (session.phase) { + case "idle": + return ["session_started", "runtime_failed"]; + case "running": + return [ + "main_agent_decision_created", + "worker_started", + "worker_completed", + "worker_needs_input", + "programmatic_review_started", + "programmatic_review_passed", + "programmatic_review_failed", + "flow_completed", + "runtime_failed", + ]; + case "waiting_user": + return [ + "skill_selected", + "user_submitted_input", + "user_confirmed_intake", + "user_approved_next_step", + "user_rejected_next_step", + "user_accepted_artifact", + "user_rejected_artifact", + "user_requested_revision", + "main_agent_decision_created", + "runtime_failed", + ]; + case "done": + case "error": + return []; + default: + return []; + } +} + +/** + * 判断事件是否可在当前会话状态下应用。 + * 除 phase 白名单外,waiting_user 还需 waitingReason 与事件类型匹配。 + */ +export function canApplyEvent( + session: RuntimeSession, + event: RuntimeEvent, +): boolean { + if (!getAllowedEvents(session).includes(event.type)) { + return false; + } + + const reason = session.waitingReason; + + switch (event.type) { + case "skill_selected": + return reason?.kind === "skill_selection"; + case "user_submitted_input": + return ( + reason?.kind === "intake" || + reason?.kind === "input" || + reason?.kind === "worker_questions" || + reason?.kind === "revision" + ); + case "user_confirmed_intake": + return reason?.kind === "intake"; + case "user_approved_next_step": + case "user_rejected_next_step": + return reason?.kind === "approve_step"; + case "user_accepted_artifact": + case "user_rejected_artifact": + return reason?.kind === "review_artifact"; + case "user_requested_revision": + return ( + reason?.kind === "review_artifact" || reason?.kind === "approve_step" + ); + case "main_agent_decision_created": + // running 时可决策;waiting_user(input) 时允许总管在启动阶段插话 + return session.phase === "running" || reason?.kind === "input"; + default: + return true; + } +} + +/** 校验失败或 runtime_failed:进入 error phase 并 emit_message */ +function fail(session: RuntimeSession, reason: string): ApplyEventResult { + return { + session: touch({ ...session, phase: "error", waitingReason: undefined }), + effects: [{ type: "emit_message", message: reason }], + error: reason, + }; +} + +/** + * worker_completed 的核心分支:按 acceptanceMode 决定下一步。 + * user_confirmed 时 caller 需再包一层 waiting(review_artifact)。 + */ +function handleWorkerCompleted( + session: RuntimeSession, + event: RuntimeEvent & { type: "worker_completed" }, +): ApplyEventResult { + const artifact = findArtifact(session, event.payload.artifactId); + if (!artifact) { + return fail(session, `Artifact not found: ${event.payload.artifactId}`); + } + + const mode = session.acceptanceMode ?? "user_confirmed"; + let next = appendHistory(session, event); + + if (mode === "user_confirmed") { + next = updateArtifact(next, artifact.id, { status: "under_review" }); + return { + session: touch({ + ...next, + pendingArtifactId: artifact.id, + currentWorkerId: undefined, + resumeContext: undefined, + }), + effects: [], + // worker_completed case 外层会补 waiting(review_artifact) + }; + } + + if (mode === "no_confirmation") { + next = updateArtifact(next, artifact.id, { status: "accepted" }); + return { + session: touch({ + ...running(next), + pendingArtifactId: undefined, + currentWorkerId: undefined, + pendingDecision: undefined, + resumeContext: undefined, + }), + effects: [{ type: "invoke_main_agent" }], + }; + } + + // programmatic_review + next = updateArtifact(next, artifact.id, { status: "under_review" }); + return { + session: touch({ + ...running(next), + pendingArtifactId: artifact.id, + currentWorkerId: undefined, + }), + effects: [{ type: "run_programmatic_review", artifactId: artifact.id }], + }; +} + +/** + * 应用单个 RuntimeEvent,返回新会话与副作用列表。 + * 不合法的事件不会抛异常,而是返回 error 并将 phase 置为 error。 + */ +export function applyEvent( + session: RuntimeSession, + event: RuntimeEvent, +): ApplyEventResult { + if (!canApplyEvent(session, event)) { + return fail( + session, + `Event ${event.type} is not allowed in phase ${session.phase}`, + ); + } + + switch (event.type) { + // ── 启动:选 skill ── + case "session_started": { + const next = appendHistory( + { + ...session, + presetId: event.payload.presetId, + flowId: event.payload.flowId, + }, + event, + ); + const skills = event.payload.availableSkills; + return { + session: waiting(next, { + kind: "skill_selection", + availableSkills: skills, + }), + effects: [ + { + type: "emit_message", + message: formatSkillSelectionPrompt(skills), + }, + ], + }; + } + + case "skill_selected": { + const { skill } = event.payload; + const next = appendHistory( + { + ...session, + flowId: skill.defaultFlowId ?? session.flowId, + slots: { + ...session.slots, + activeSkill: skill, + }, + }, + event, + ); + return { + session: waiting(next, { + kind: "intake", + prompt: skill.startupPrompt, + }), + effects: [{ type: "emit_message", message: skill.startupPrompt }], + }; + } + + case "user_confirmed_intake": { + if (session.waitingReason?.kind !== "intake") { + return fail(session, "user_confirmed_intake requires intake waiting"); + } + const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined; + if (!activeSkill?.startupTargetKey || !activeSkill.intakeFields?.length) { + return fail(session, "No intake fields on active skill"); + } + const values = readIntakeValues(session.slots); + const progress = buildIntakeProgress(activeSkill.intakeFields, values); + if (!progress.ready) { + return fail(session, "必要项尚未填完,无法确认"); + } + const demandText = synthesizeDemandText(activeSkill.intakeFields, values); + const slots: Record = { + ...session.slots, + intakeValues: values, + startupCompleted: true, + [activeSkill.startupTargetKey]: demandText, + lastUserInput: demandText, + }; + const next = appendHistory({ ...session, slots, resumeContext: undefined }, event); + return { + session: touch(running(next)), + effects: [{ type: "invoke_main_agent" }], + }; + } + + // ── 用户输入 ── + case "user_submitted_input": { + const hadResume = Boolean(session.resumeContext); + const activeSkill = session.slots.activeSkill as + | { startupTargetKey?: string } + | undefined; + const slots: Record = { + ...session.slots, + lastUserInput: event.payload.text, + userInputs: [ + ...((session.slots.userInputs as string[] | undefined) ?? []), + event.payload.text, + ], + }; + const demandKey = activeSkill?.startupTargetKey; + const text = event.payload.text.trim(); + + if (session.waitingReason?.kind === "intake") { + const skill = activeSkill as ActiveSkillSnapshot | undefined; + const intakeValues = + event.payload.intakeValues ?? + readIntakeValues(session.slots); + slots.intakeValues = intakeValues; + slots.intakeSubmitCount = + ((session.slots.intakeSubmitCount as number) ?? 0) + 1; + if (skill?.intakeFields?.length) { + const preview = synthesizeDemandText(skill.intakeFields, intakeValues); + if (preview.trim()) { + slots[demandKey ?? "intake.preview"] = preview; + } else if (text) { + mergeSlotText(slots, demandKey ?? "intake.preview", text); + } + } else if (text && demandKey) { + mergeSlotText(slots, demandKey, text); + } + + const progress = skill?.intakeFields?.length + ? buildIntakeProgress(skill.intakeFields, intakeValues) + : null; + const followUpSent = Boolean(session.slots.intakeFollowUpSent); + const effects: PhaseEffect[] = []; + if ( + progress && + !progress.ready && + !followUpSent && + (slots.intakeSubmitCount as number) >= 1 + ) { + const msg = buildIntakeFollowUpMessage(progress); + if (msg) { + slots.intakeFollowUpSent = true; + effects.push({ type: "emit_message", message: msg }); + } + } + + const next = appendHistory( + { + ...session, + slots, + resumeContext: hadResume ? session.resumeContext : undefined, + }, + event, + ); + const prompt = + session.waitingReason.prompt ?? skill?.startupPrompt ?? ""; + return { + session: waiting(touch(next), { kind: "intake", prompt }), + effects, + }; + } + + if (session.waitingReason?.kind === "worker_questions") { + slots["用户.worker答复"] = text; + if (demandKey && text) { + mergeSlotText(slots, demandKey, text); + } + } else if (demandKey && session.waitingReason?.kind === "input" && text) { + if (!session.slots.startupCompleted) { + slots[demandKey] = text; + slots.startupCompleted = true; + } else { + mergeSlotText(slots, demandKey, text); + } + } + const next = appendHistory( + { + ...session, + slots, + resumeContext: hadResume ? session.resumeContext : undefined, + }, + event, + ); + + if (hadResume) { + // worker 提问后的回复 → 恢复 worker + return { + session: touch(running({ ...next, resumeContext: session.resumeContext })), + effects: [{ type: "resume_worker" }], + }; + } + + // 常规定稿用户输入 → 等总管下一步 + return { + session: touch(running({ ...next, resumeContext: undefined })), + effects: [{ type: "invoke_main_agent" }], + }; + } + + // ── 总管决策 ── + case "main_agent_decision_created": { + const { decision } = event.payload; + if (decision.statePatchAllowed !== false) { + return fail(session, "Main Agent decision must set statePatchAllowed to false"); + } + + const next = appendHistory({ ...session, pendingDecision: decision }, event); + + switch (decision.action) { + case "ask_user": + case "review_blackboard": + return { + session: waiting( + { ...next, pendingDecision: undefined }, + { kind: "input", message: decision.reason }, + ), + effects: [], + }; + case "finish": + return { + session: touch({ + ...next, + phase: "done", + waitingReason: undefined, + pendingDecision: undefined, + }), + effects: [ + { type: "emit_message", message: decision.reason || "流程已完成。" }, + ], + }; + case "create_temp_worker": + case "run_worker": { + if (!decision.workerId) { + return fail(session, "run_worker requires workerId"); + } + if (decision.requiresApproval) { + // 需用户确认后才真正 run_worker + return { + session: waiting(next, { + kind: "approve_step", + decisionId: decision.id, + }), + effects: [ + { type: "emit_message", message: `等待确认:${decision.reason}` }, + ], + }; + } + return { + session: touch(running({ ...next, currentWorkerId: decision.workerId })), + effects: [ + { + type: "run_worker", + workerId: decision.workerId, + workerContext: decision.workerContext, + }, + ], + }; + } + default: + return fail(session, "Unknown decision action"); + } + } + + case "user_approved_next_step": { + const decision = session.pendingDecision; + if (!decision || decision.id !== event.payload.decisionId) { + return fail(session, "No matching pending decision to approve"); + } + if (!decision.workerId) { + return fail(session, "Approved decision has no workerId"); + } + return { + session: touch( + running( + appendHistory( + { ...session, currentWorkerId: decision.workerId }, + event, + ), + ), + ), + effects: [ + { + type: "run_worker", + workerId: decision.workerId, + workerContext: decision.workerContext, + }, + ], + }; + } + + case "user_rejected_next_step": { + return { + session: touch( + running( + appendHistory( + { ...session, pendingDecision: undefined }, + event, + ), + ), + ), + effects: [{ type: "invoke_main_agent" }], + }; + } + + // ── Worker 生命周期 ── + case "worker_started": { + return { + session: touch( + running( + appendHistory( + { + ...session, + currentWorkerId: event.payload.workerId, + currentStepId: event.payload.stepId, + acceptanceMode: event.payload.acceptanceMode, + }, + event, + ), + ), + ), + effects: [], + }; + } + + case "worker_needs_input": { + const questions = event.payload.questions + .map((q) => q.trim()) + .filter(Boolean); + const normalized = + questions.length > 0 + ? questions + : ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"]; + const ctx: ResumeContext = { + workerId: event.payload.workerId, + stepId: event.payload.stepId ?? session.currentStepId, + acceptanceMode: session.acceptanceMode ?? "user_confirmed", + questions: normalized, + }; + return { + session: waiting( + appendHistory( + { ...session, resumeContext: ctx, currentWorkerId: event.payload.workerId }, + event, + ), + { + kind: "worker_questions", + workerId: event.payload.workerId, + questions: normalized, + }, + ), + effects: [ + { + type: "emit_message", + message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q}`).join("\n")}`, + }, + ], + }; + } + + case "worker_completed": { + const result = handleWorkerCompleted(session, event); + const mode = session.acceptanceMode ?? "user_confirmed"; + if (mode === "user_confirmed" && result.session.pendingArtifactId) { + return { + ...result, + session: waiting(result.session, { + kind: "review_artifact", + artifactId: result.session.pendingArtifactId, + }), + }; + } + return result; + } + + // ── 产物验收 ── + case "user_accepted_artifact": { + const artifact = findArtifact(session, event.payload.artifactId); + if (!artifact) { + return fail(session, `Artifact not found: ${event.payload.artifactId}`); + } + const next = appendHistory( + updateArtifact(session, artifact.id, { status: "accepted" }), + event, + ); + return { + session: touch( + running({ + ...next, + pendingArtifactId: undefined, + pendingDecision: undefined, + }), + ), + effects: [{ type: "invoke_main_agent" }], + }; + } + + case "user_rejected_artifact": { + const artifact = findArtifact(session, event.payload.artifactId); + if (!artifact) { + return fail(session, `Artifact not found: ${event.payload.artifactId}`); + } + const next = appendHistory( + updateArtifact(session, artifact.id, { status: "rejected" }), + event, + ); + return { + session: waiting(next, { + kind: "revision", + instruction: event.payload.reason, + }), + effects: [ + { + type: "emit_message", + message: event.payload.reason ?? "产物已被拒绝,等待修改指示。", + }, + ], + }; + } + + case "user_requested_revision": { + if (session.waitingReason?.kind === "approve_step") { + // 在确认步骤时发修改意见,等同拒绝并回到总管 + return { + session: touch( + running( + appendHistory({ ...session, pendingDecision: undefined }, event), + ), + ), + effects: [{ type: "invoke_main_agent" }], + }; + } + return { + session: waiting( + appendHistory({ ...session, pendingArtifactId: undefined }, event), + { kind: "revision", instruction: event.payload.instruction }, + ), + effects: [ + { + type: "emit_message", + message: `返工请求:${event.payload.instruction}`, + }, + ], + }; + } + + // ── 程序验收 ── + case "programmatic_review_started": + return { session: appendHistory(running(session), event), effects: [] }; + + case "programmatic_review_passed": { + const artifact = findArtifact(session, event.payload.artifactId); + if (!artifact) { + return fail(session, `Artifact not found: ${event.payload.artifactId}`); + } + const next = appendHistory( + updateArtifact(session, artifact.id, { status: "accepted" }), + event, + ); + return { + session: touch( + running({ ...next, pendingArtifactId: undefined }), + ), + effects: [{ type: "invoke_main_agent" }], + }; + } + + case "programmatic_review_failed": { + const artifact = findArtifact(session, event.payload.artifactId); + if (!artifact) { + return fail(session, `Artifact not found: ${event.payload.artifactId}`); + } + const next = appendHistory( + updateArtifact(session, artifact.id, { status: "revision_requested" }), + event, + ); + return { + session: waiting(next, { + kind: "revision", + instruction: event.payload.reason, + }), + effects: [ + { + type: "emit_message", + message: `程序验收失败:${event.payload.reason}`, + }, + ], + }; + } + + // ── 终止 ── + case "flow_completed": + return { + session: touch({ + ...appendHistory(session, event), + phase: "done", + waitingReason: undefined, + }), + effects: [], + }; + + case "runtime_failed": + return fail(appendHistory(session, event), event.payload.reason); + + default: + return fail(session, "Unhandled event type"); + } +} + +/** 创建 worker 产物记录(drafted 状态),由 runtime 层在 worker_complete 前写入 session.artifacts */ +export function createArtifact(params: { + workerId: string; + stepId?: string; + outputTags: string[]; + summary?: string; +}): ArtifactRecord { + const ts = nowIso(); + return { + id: randomUUID(), + workerId: params.workerId, + stepId: params.stepId, + outputTags: params.outputTags, + status: "drafted", + summary: params.summary, + createdAt: ts, + updatedAt: ts, + }; +} + +export { type RuntimePhase, type WaitingReason }; + +function formatSkillSelectionPrompt( + skills: Array<{ name: string; description: string }>, +): string { + const lines = skills.map((s, i) => ` ${i + 1}. ${s.name} — ${s.description}`); + return ["请选择创作 skill(输入 name 或编号):", ...lines].join("\n"); +} diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts new file mode 100644 index 0000000..7b6f753 --- /dev/null +++ b/src/runtime/phase-runtime.ts @@ -0,0 +1,763 @@ +/** + * 阶段机运行层(有副作用的执行器)。 + * + * 职责: + * - 持有可变 session,统一 dispatch(event) 入口 + * - 调用纯函数 applyEvent,再 processEffects 执行 IO(消息、worker、验收) + * - 启动时从 skills/ 加载 registry,驱动 skill 选择与 SKILL.md 启动询问 + * + * 与 phase-machine.ts 的分工:前者「算状态」,本文件「跑状态」。 + */ +import { randomUUID } from "node:crypto"; +import { + applyEvent, + createArtifact, + createSession, + getAllowedEvents, +} from "./phase-machine.js"; +import { Blackboard } from "../blackboard/blackboard.js"; +import type { LlmProvider } from "../llm/client.js"; +import { DEFAULT_WORKERS, MainAgent } from "../main-agent/main-agent.js"; +import { listSkills, listWorkerSkills, loadSkill, loadWorkerSkill, resolveSkillId } from "../skills/loader.js"; +import { toActiveSkillSnapshot } from "../skills/snapshot.js"; +import { runWorkerSkill } from "../worker/executor.js"; +import { resolveWorkerId } from "../worker/resolve-id.js"; +import { resolveWorkerLlmProvider } from "../skills/worker-llm.js"; +import { extractIntakeFromMessage } from "../intake/extract.js"; +import { readIntakeValues } from "../intake/intake.js"; +import type { + AcceptanceMode, + ActiveSkillSnapshot, + ApplyEventResult, + MainAgentDecision, + PhaseEffect, + RuntimeEvent, + RuntimeSession, + SkillIndexEntry, +} from "../types/runtime.js"; +import type { BlackboardItem } from "../types/blackboard.js"; + +export type PhaseRuntimeOptions = { + presetId?: string; + flowId?: string; + /** worker 完成后的默认验收模式,会写入 worker_started 事件 */ + acceptanceMode?: AcceptanceMode; + /** true 时 run_worker 自动走占位 worker(演示 / 测试用) */ + autoStubWorker?: boolean; + /** 传入后 invoke_main_agent 会自动调用总管 LLM */ + llm?: LlmProvider; + /** 运行时黑板;worker 产出写入此处 */ + blackboard?: Blackboard; + /** 副作用 emit_message 与用户提示的回调(Web / CLI 接入点) */ + onMessage?: (message: string) => void; + /** 从磁盘恢复时使用,跳过 createSession */ + initialSession?: RuntimeSession; + /** 与 initialSession 一并恢复黑板 */ + initialBlackboardItems?: BlackboardItem[]; +}; + +/** + * 阶段机运行层:包装纯函数 phase-machine,处理副作用。 + * 启动时加载 skills/,先选 skill,再读 SKILL.md 启动询问。 + */ +export class PhaseRuntime { + private session: RuntimeSession; + private readonly acceptanceMode: AcceptanceMode; + private autoStubWorker: boolean; + private readonly onMessage: (message: string) => void; + private readonly blackboard: Blackboard; + private llm?: LlmProvider; + private mainAgent?: MainAgent; + /** autoStubWorker=false 时,run_worker effect 暂存于此,等 startPendingWorker() */ + private pendingWorkerEffect: Extract | null = + null; + /** start() 时从 registry 加载,供 skill_selection 展示与编号解析 */ + private availableSkills: SkillIndexEntry[] = []; + + constructor(options: PhaseRuntimeOptions = {}) { + this.session = + options.initialSession ?? + createSession(options.presetId ?? "default", options.flowId); + this.acceptanceMode = options.acceptanceMode ?? "user_confirmed"; + this.autoStubWorker = options.autoStubWorker ?? false; + this.onMessage = options.onMessage ?? (() => {}); + this.blackboard = options.blackboard ?? new Blackboard(); + if (options.initialBlackboardItems?.length) { + this.blackboard.seed(options.initialBlackboardItems); + } + if (options.llm) { + this.llm = options.llm; + this.mainAgent = new MainAgent(options.llm); + } + } + + getBlackboard(): Blackboard { + return this.blackboard; + } + + hasMainAgent(): boolean { + return Boolean(this.mainAgent); + } + + /** 设置变更后热更新 LLM(API profile / preset) */ + reloadLlm(llm: LlmProvider, autoStubWorker?: boolean): void { + this.llm = llm; + this.mainAgent = new MainAgent(llm); + if (autoStubWorker !== undefined) { + this.autoStubWorker = autoStubWorker; + } + } + + getLlm(): LlmProvider | undefined { + return this.llm; + } + + getSession(): RuntimeSession { + return this.session; + } + + setLifecycleStage(stage: "design" | "play"): void { + this.session = { + ...this.session, + slots: { ...this.session.slots, uiLifecycleStage: stage }, + updatedAt: new Date().toISOString(), + }; + } + + getAvailableSkills(): SkillIndexEntry[] { + return this.availableSkills; + } + + /** 恢复会话或首次 start 前加载 skill 列表(供 UI 展示) */ + async ensureAvailableSkills(): Promise { + if (this.availableSkills.length === 0) { + this.availableSkills = await listSkills(); + } + } + + getActiveSkill(): ActiveSkillSnapshot | undefined { + return this.session.slots.activeSkill as ActiveSkillSnapshot | undefined; + } + + getAllowedEventTypes(): RuntimeEvent["type"][] { + return getAllowedEvents(this.session); + } + + /** + * 是否处于「running 且无事可做,等总管决策」状态。 + * Web 层据此显示「生成大纲」等按钮。 + */ + needsMainAgentDecision(): boolean { + return ( + this.session.phase === "running" && + !this.session.currentWorkerId && + !this.pendingWorkerEffect + ); + } + + /** 加载 skills/registry → 触发 session_started → waiting_user(skill_selection) */ + async start(): Promise { + this.availableSkills = await listSkills(); + if (this.availableSkills.length === 0) { + throw new Error("skills/ 下没有找到任何 skill 文件(novel/*.md 或 dialogue/*.md)"); + } + await this.dispatch({ + type: "session_started", + payload: { + presetId: this.session.presetId, + flowId: this.session.flowId, + availableSkills: this.availableSkills, + }, + }); + return this.session; + } + + /** 启动并预选总管,跳过 skill_selection */ + async startWithOrchestrator(orchestratorId: string): Promise { + await this.start(); + if (this.session.waitingReason?.kind === "skill_selection") { + await this.selectSkill(orchestratorId); + } + return this.session; + } + + /** 解析并加载 SKILL.md → 触发 skill_selected → waiting_user(input) */ + async selectSkill(skillIdOrName: string): Promise { + const dir = await resolveSkillId(skillIdOrName); + if (!dir) { + throw new Error(`未找到 skill: ${skillIdOrName}`); + } + const parsed = await loadSkill(dir); + await this.dispatch({ + type: "skill_selected", + payload: { skill: toActiveSkillSnapshot(parsed) }, + }); + return this.session; + } + + /** 统一事件入口:applyEvent + 更新 session + 处理 effects + 可选总管 LLM */ + async dispatch(event: RuntimeEvent): Promise { + const result = applyEvent(this.session, event); + this.session = result.session; + + if ( + event.type === "user_submitted_input" || + event.type === "user_confirmed_intake" + ) { + this.syncSlotsToBlackboard(this.session); + } + + if (result.error && event.type !== "runtime_failed") { + this.onMessage(result.error); + } + + await this.processEffects(result.effects); + await this.maybeRunMainAgent(result.effects); + return result; + } + + /** 将 slots 中的启动目标等同步为黑板 tag(迁移期 tag 名可与旧 key 相同) */ + private syncSlotsToBlackboard(session: RuntimeSession): void { + const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined; + if (activeSkill?.startupTargetKey) { + const tag = activeSkill.startupTargetKey; + const content = session.slots[tag]; + if (typeof content === "string" && content.trim()) { + this.blackboard.write({ + tag, + content: content.trim(), + source: "user", + }); + } + } + const workerReply = session.slots["用户.worker答复"]; + if (typeof workerReply === "string" && workerReply.trim()) { + this.blackboard.write({ + tag: "用户.worker答复", + content: workerReply.trim(), + source: "user", + }); + } + const revision = session.slots.revisionInstruction; + if (typeof revision === "string" && revision.trim()) { + this.blackboard.write({ + tag: "用户.修改说明", + content: revision.trim(), + source: "user", + }); + } + } + + /** + * 用户文本输入的统一入口。 + * skill_selection 阶段会先解析为 selectSkill,否则走 user_submitted_input。 + */ + async submitInput(text: string): Promise { + if (this.session.waitingReason?.kind === "skill_selection") { + const picked = await this.resolveSkillFromUserInput(text); + if (!picked) { + throw new Error(`无法识别 skill: ${text}。请输入 name 或列表编号。`); + } + return this.selectSkill(picked); + } + if (this.session.waitingReason?.kind === "intake") { + const skill = this.getActiveSkill(); + const fields = skill?.intakeFields ?? []; + const current = readIntakeValues(this.session.slots); + const intakeValues = await extractIntakeFromMessage( + text, + fields, + current, + this.llm, + ); + await this.dispatch({ + type: "user_submitted_input", + payload: { text, intakeValues }, + }); + return this.session; + } + await this.dispatch({ type: "user_submitted_input", payload: { text } }); + return this.session; + } + + /** 必要项已填完:确认进入实例化 / 下一阶段 */ + async confirmIntake(): Promise { + if (this.session.waitingReason?.kind !== "intake") { + throw new Error("当前不在填空收集阶段"); + } + await this.dispatch({ type: "user_confirmed_intake", payload: {} }); + this.syncSlotsToBlackboard(this.session); + return this.session; + } + + /** 支持按编号(1-based)或 skill name 解析 */ + private async resolveSkillFromUserInput(text: string): Promise { + const trimmed = text.trim(); + const num = Number(trimmed); + if (Number.isInteger(num) && num >= 1 && num <= this.availableSkills.length) { + const entry = this.availableSkills[num - 1]; + return resolveSkillId(entry.name); + } + return resolveSkillId(trimmed); + } + + /** 提交总管决策(通常来自 Main Agent / 手动按钮) */ + async submitDecision(decision: MainAgentDecision): Promise { + await this.dispatch({ + type: "main_agent_decision_created", + payload: { decision }, + }); + return this.session; + } + + /** 用户确认 pendingDecision(approve_step) */ + async approve(): Promise { + const id = this.session.pendingDecision?.id; + if (!id) throw new Error("当前没有待确认的决策"); + await this.dispatch({ type: "user_approved_next_step", payload: { decisionId: id } }); + return this.session; + } + + /** 用户拒绝 pendingDecision,回到总管 */ + async rejectStep(reason?: string): Promise { + const id = this.session.pendingDecision?.id; + if (!id) throw new Error("当前没有待拒绝的决策"); + await this.dispatch({ + type: "user_rejected_next_step", + payload: { decisionId: id, reason }, + }); + return this.session; + } + + /** 用户接受 pendingArtifact */ + async acceptArtifact(artifactId?: string): Promise { + const id = artifactId ?? this.session.pendingArtifactId; + if (!id) throw new Error("当前没有待验收的产物"); + await this.dispatch({ type: "user_accepted_artifact", payload: { artifactId: id } }); + return this.session; + } + + /** 用户拒绝 pendingArtifact,进入 revision */ + async rejectArtifact(reason?: string, artifactId?: string): Promise { + const id = artifactId ?? this.session.pendingArtifactId; + if (!id) throw new Error("当前没有待拒绝的产物"); + await this.dispatch({ + type: "user_rejected_artifact", + payload: { artifactId: id, reason }, + }); + return this.session; + } + + /** worker 调用 ask_user 能力时,由外层触发此事件 */ + async workerAsk(questions: string[]): Promise { + const workerId = this.session.currentWorkerId; + if (!workerId) throw new Error("当前没有运行中的 worker"); + await this.dispatch({ + type: "worker_needs_input", + payload: { workerId, stepId: this.session.currentStepId, questions }, + }); + return this.session; + } + + /** + * 手动标记 worker 完成:先创建 artifact 写入 session,再 dispatch worker_completed。 + * 真实 worker 集成时,产物内容应在此之前写入 slots / blackboard。 + */ + async workerComplete(summary?: string): Promise { + const workerId = this.session.currentWorkerId; + if (!workerId) throw new Error("当前没有运行中的 worker"); + + const artifact = createArtifact({ + workerId, + stepId: this.session.currentStepId, + outputTags: ["output.草稿"], + summary: summary ?? `${workerId} 产物`, + }); + this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] }; + + await this.dispatch({ + type: "worker_completed", + payload: { artifactId: artifact.id }, + }); + return this.session; + } + + /** 非 autoStub 模式下,手动启动 pendingWorkerEffect */ + async startPendingWorker(): Promise { + const effect = this.pendingWorkerEffect; + if (!effect) throw new Error("没有待启动的 worker"); + this.pendingWorkerEffect = null; + await this.runStubWorker(effect, false); + return this.session; + } + + /** 消费 applyEvent 返回的 PhaseEffect 列表 */ + private async processEffects(effects: PhaseEffect[]): Promise { + for (const effect of effects) { + switch (effect.type) { + case "emit_message": + this.onMessage(effect.message); + break; + case "invoke_main_agent": + if (!this.mainAgent) { + this.onMessage( + "[阶段机] running:等待总管决策。请用 /decide 提交 run_worker 或 finish。", + ); + } + break; + case "run_worker": + if (this.autoStubWorker) { + await this.runStubWorker(effect); + } else if (this.llm) { + await this.runRealWorker(effect); + } else { + this.pendingWorkerEffect = effect; + this.onMessage( + `[阶段机] 待启动 worker: ${effect.workerId}。输入 /worker-start`, + ); + } + break; + case "resume_worker": { + const ctx = this.session.resumeContext; + if (!ctx) break; + const workerEffect: Extract = { + type: "run_worker", + workerId: ctx.workerId, + }; + if (this.autoStubWorker) { + await this.runStubWorker(workerEffect); + } else if (this.llm) { + await this.runRealWorker(workerEffect); + } else { + this.pendingWorkerEffect = workerEffect; + this.onMessage(`[阶段机] 恢复 worker: ${ctx.workerId}。输入 /worker-start`); + } + break; + } + case "run_programmatic_review": + await this.runProgrammaticReview(effect.artifactId); + break; + } + } + } + + private async runRealWorker( + effect: Extract, + ): Promise { + const activeSkill = this.getActiveSkill(); + if (!activeSkill?.name) { + throw new Error("当前没有 active skill,无法运行 worker"); + } + if (!this.llm) { + throw new Error("未配置 LLM,无法运行 worker"); + } + + const workerId = resolveWorkerId(effect.workerId); + let slots = { ...this.session.slots }; + + this.syncSlotsToBlackboard(this.session); + + if (effect.workerContext?.roleId?.trim()) { + const roleId = effect.workerContext.roleId.trim(); + slots["世界.当前角色.id"] = roleId; + this.blackboard.write({ + tag: "世界.当前角色.id", + content: roleId, + source: "runtime", + }); + this.session = { ...this.session, slots }; + } + + this.session = ( + await this.dispatch({ + type: "worker_started", + payload: { + workerId: effect.workerId, + stepId: this.session.currentStepId, + acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode, + }, + }) + ).session; + + const skill = await loadSkill(activeSkill.name); + const worker = await loadWorkerSkill(activeSkill.name, effect.workerId); + const workerLlm = resolveWorkerLlmProvider({ + worker, + bindings: skill.workerLlmBindings, + slots, + fallbackLlm: this.llm, + }); + + this.onMessage(`[Worker] ${workerId} 执行中…`); + + const result = await runWorkerSkill({ + skillName: activeSkill.name, + workerId: effect.workerId, + slots, + blackboard: this.blackboard, + llm: workerLlm, + }); + + if (result.askUser?.length) { + await this.workerAsk(result.askUser); + return; + } + + slots = { ...this.session.slots }; + for (const [tag, content] of Object.entries(result.outputs)) { + this.blackboard.write({ + tag, + content, + source: workerId, + }); + slots[tag] = content; + } + this.session = { ...this.session, slots }; + + const artifact = createArtifact({ + workerId: effect.workerId, + stepId: this.session.currentStepId, + outputTags: Object.keys(result.outputs), + summary: result.summary, + }); + this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] }; + + this.onMessage( + `[Worker] ${workerId} 已完成\n\n${result.preview}${result.preview.length >= 4000 ? "\n\n…" : ""}`, + ); + + await this.dispatch({ + type: "worker_completed", + payload: { artifactId: artifact.id }, + }); + } + + /** + * 占位 worker:worker_started → 可选自动 worker_completed。 + * 演示与单测用,真实环境应替换为真实 worker 调度。 + */ + private async runStubWorker( + effect: Extract, + autoComplete = true, + ): Promise { + this.session = ( + await this.dispatch({ + type: "worker_started", + payload: { + workerId: effect.workerId, + stepId: this.session.currentStepId, + acceptanceMode: this.session.resumeContext?.acceptanceMode ?? this.acceptanceMode, + }, + }) + ).session; + + this.onMessage(`[Worker 占位] ${effect.workerId} 已开始。`); + + if (!autoComplete) return; + + const lastInput = String(this.session.slots.lastUserInput ?? ""); + const value = `[${effect.workerId} 占位输出]\n${lastInput ? `输入: ${lastInput}\n` : ""}说明: stub worker,后续替换为真实 LLM worker。`; + + const outputTags = await this.resolveWorkerOutputTags(effect.workerId); + const tagsToWrite = outputTags.length > 0 ? outputTags : ["output.草稿"]; + + for (const tag of tagsToWrite) { + this.blackboard.write({ + tag, + content: value, + source: effect.workerId, + }); + } + + const artifact = createArtifact({ + workerId: effect.workerId, + stepId: this.session.currentStepId, + outputTags: tagsToWrite, + summary: `${effect.workerId} 占位产物`, + }); + this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] }; + + await this.dispatch({ + type: "worker_completed", + payload: { artifactId: artifact.id }, + }); + } + + private async resolveWorkerOutputTags(workerId: string): Promise { + const active = this.getActiveSkill(); + if (!active?.name) return []; + try { + const worker = await loadWorkerSkill(active.name, workerId); + return worker.outputTags; + } catch { + return []; + } + } + + /** 简易程序验收:summary 非空则通过,否则 revision */ + private async runProgrammaticReview(artifactId: string): Promise { + await this.dispatch({ type: "programmatic_review_started", payload: { artifactId } }); + + const artifact = this.session.artifacts.find((a) => a.id === artifactId); + const passed = Boolean(artifact?.summary && artifact.summary.length > 0); + + await this.dispatch( + passed + ? { type: "programmatic_review_passed", payload: { artifactId } } + : { + type: "programmatic_review_failed", + payload: { artifactId, reason: "产物 summary 为空" }, + }, + ); + } + + /** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */ + private async maybeRunMainAgent(effects: PhaseEffect[]): Promise { + if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) { + await this.runMainAgent(); + } + } + + private async runMainAgent(): Promise { + if (!this.mainAgent || this.session.phase !== "running") { + return; + } + + const availableWorkers = await this.resolveAvailableWorkers(); + + const decision = await this.mainAgent.runToolLoop( + { + session: this.session, + blackboardIndex: this.blackboard.listTagIndex(), + availableWorkers, + }, + { + handlers: { + readBlackboard: (tags) => this.readBlackboardForAgent(tags), + listWorkers: () => availableWorkers, + listArtifacts: () => + this.session.artifacts.map((a) => ({ + id: a.id, + workerId: a.workerId, + status: a.status, + summary: a.summary, + outputTags: a.outputTags, + })), + onToolCall: (name, detail) => { + const preview = + detail.length > 120 ? `${detail.slice(0, 120)}…` : detail; + this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`); + }, + }, + }, + ); + + this.onMessage(`[总管] ${decision.action}: ${decision.reason}`); + + const result = applyEvent(this.session, { + type: "main_agent_decision_created", + payload: { decision }, + }); + this.session = result.session; + + if (result.error) { + this.onMessage(result.error); + } + + await this.processEffects(result.effects); + await this.maybeRunMainAgent(result.effects); + } + + /** 总管 read_blackboard tool:按 tag 或模式读取正文 */ + private readBlackboardForAgent(tags: string[]): Record { + const out: Record = {}; + for (const pattern of tags) { + const items = this.blackboard.queryByPatterns([pattern], "latest"); + if (items.length === 0) { + const slotVal = this.session.slots[pattern]; + if (typeof slotVal === "string" && slotVal.trim()) { + out[pattern] = slotVal.trim(); + } else { + out[pattern] = ""; + } + continue; + } + for (const item of items) { + out[item.tag] = item.content; + } + } + return out; + } + + private async resolveAvailableWorkers(): Promise< + Array<{ id: string; description: string }> + > { + const active = this.getActiveSkill(); + if (!active?.name) return [...DEFAULT_WORKERS]; + try { + const workers = await listWorkerSkills(active.name); + if (workers.length === 0) return [...DEFAULT_WORKERS]; + return workers.map((w) => ({ id: w.id, description: w.description })); + } catch { + return [...DEFAULT_WORKERS]; + } + } +} + +/** 构造总管决策,强制 statePatchAllowed=false */ +export function createDecision( + partial: Omit & + Partial>, +): MainAgentDecision { + return { + id: partial.id ?? randomUUID(), + statePatchAllowed: false, + ...partial, + }; +} + +/** CLI 调试:单行摘要当前会话状态 */ +export function formatSession(session: RuntimeSession): string { + const active = session.slots.activeSkill as ActiveSkillSnapshot | undefined; + const lines = [ + `phase: ${session.phase}`, + session.waitingReason ? `waitingReason: ${session.waitingReason.kind}` : null, + active ? `skill: ${active.name}` : null, + session.slots.startupCompleted ? "startup: done" : null, + session.slots["book.brief"] ? "book.brief: yes" : null, + session.currentWorkerId ? `worker: ${session.currentWorkerId}` : null, + `artifacts: ${session.artifacts.length}`, + `events: ${session.history.length}`, + ]; + return lines.filter(Boolean).join("\n"); +} + +/** 集成测试用:从 start 到 finish 的最小闭环脚本 */ +export async function runMinimalClosedLoop( + runtime: PhaseRuntime, +): Promise { + await runtime.start(); + await runtime.selectSkill("basic"); + await runtime.submitInput("科幻长篇,第三人称,约 20 万字"); + await runtime.confirmIntake(); + + const decision = createDecision({ + action: "run_worker", + reason: "生成大纲", + workerId: "outline-worker", + requiresApproval: true, + }); + await runtime.submitDecision(decision); + await runtime.approve(); + + if (runtime.getSession().currentWorkerId) { + await runtime.workerComplete(); + } + + await runtime.acceptArtifact(); + await runtime.submitDecision( + createDecision({ action: "finish", reason: "完成", requiresApproval: false }), + ); + + return runtime.getSession(); +} diff --git a/src/runtime/state-machine.ts b/src/runtime/state-machine.ts new file mode 100644 index 0000000..e0e887b --- /dev/null +++ b/src/runtime/state-machine.ts @@ -0,0 +1,8 @@ +/** @deprecated 请使用 phase-machine.ts */ +export { + applyEvent, + canApplyEvent, + createArtifact, + createSession, + getAllowedEvents, +} from "./phase-machine.js"; diff --git a/src/runtime/tool-registry.ts b/src/runtime/tool-registry.ts new file mode 100644 index 0000000..95ce02f --- /dev/null +++ b/src/runtime/tool-registry.ts @@ -0,0 +1,119 @@ +import { randomUUID } from "node:crypto"; +import type { ParsedToolCall } from "../llm/client.js"; +import type { MainAgentDecision } from "../types/runtime.js"; +import { + isMainAgentLoopTool, + isMainAgentTerminalTool, + type MainAgentToolName, +} from "../types/tools.js"; + +export class ToolValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ToolValidationError"; + } +} + +function parseArgs(raw: string): Record { + try { + const parsed = JSON.parse(raw || "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ToolValidationError("Tool arguments must be a JSON object"); + } + return parsed as Record; + } catch (e) { + if (e instanceof ToolValidationError) throw e; + throw new ToolValidationError(`Invalid tool arguments JSON: ${raw.slice(0, 120)}`); + } +} + +function requireString( + args: Record, + key: string, +): string { + const val = args[key]; + if (typeof val !== "string" || !val.trim()) { + throw new ToolValidationError(`Tool parameter "${key}" must be a non-empty string`); + } + return val.trim(); +} + +function optionalString( + args: Record, + key: string, +): string | undefined { + const val = args[key]; + if (typeof val !== "string") return undefined; + const trimmed = val.trim(); + return trimmed || undefined; +} + +/** 将终止 tool call 转为 MainAgentDecision */ +export function toolCallToDecision(call: ParsedToolCall): MainAgentDecision { + const name = call.name; + if (!isMainAgentTerminalTool(name)) { + throw new ToolValidationError(`Not a terminal tool: ${name}`); + } + + const args = parseArgs(call.arguments); + + switch (name as MainAgentToolName) { + case "ask_user": { + const reason = requireString(args, "reason"); + const message = optionalString(args, "message"); + return { + id: randomUUID(), + action: "ask_user", + reason: message ? `${reason}\n${message}` : reason, + requiresApproval: false, + statePatchAllowed: false, + }; + } + case "run_worker": { + const workerId = requireString(args, "workerId"); + const reason = requireString(args, "reason"); + const requiresApproval = Boolean(args.requiresApproval); + const roleId = optionalString(args, "roleId"); + return { + id: randomUUID(), + action: "run_worker", + reason, + workerId, + workerContext: roleId ? { roleId } : undefined, + requiresApproval, + statePatchAllowed: false, + }; + } + case "review_blackboard": { + const reason = requireString(args, "reason"); + const summary = requireString(args, "summary"); + return { + id: randomUUID(), + action: "review_blackboard", + reason: `${reason}\n${summary}`, + requiresApproval: false, + statePatchAllowed: false, + }; + } + case "finish": { + const reason = requireString(args, "reason"); + return { + id: randomUUID(), + action: "finish", + reason, + requiresApproval: false, + statePatchAllowed: false, + }; + } + default: + throw new ToolValidationError(`Unknown terminal tool: ${name}`); + } +} + +export function validateLoopToolCall(call: ParsedToolCall): MainAgentToolName { + if (!isMainAgentLoopTool(call.name)) { + throw new ToolValidationError(`Unknown loop tool: ${call.name}`); + } + parseArgs(call.arguments); + return call.name; +} diff --git a/src/server/agent-view.ts b/src/server/agent-view.ts new file mode 100644 index 0000000..a7f2de3 --- /dev/null +++ b/src/server/agent-view.ts @@ -0,0 +1,362 @@ +import type { RuntimeSession, WaitingReason } from "../types/runtime.js"; +import type { IntakeProgress } from "../types/intake.js"; +import { + buildSkillCatalog, + inferLifecycleStage, + canEnterPlay, + TOOL_LOOP_BURST_MAX, + type LifecycleStage, + type SkillCatalogEntry, +} from "./skill-catalog.js"; + +export type AgentMessageKind = + | "user_input" + | "orchestrator_decision" + | "orchestrator_prompt" + | "agent_tool" + | "worker_running" + | "worker_output" + | "worker_questions" + | "worker_stub" + | "system_info" + | "error"; + +export type SessionFocus = { + actorType: "orchestrator" | "worker" | "user" | "idle"; + actorId?: string; + actorLabel: string; + action: string; + detail?: string; +}; + +export type EnrichedMessage = { + kind: AgentMessageKind; + actor?: string; + title: string; + body: string; + text: string; +}; + +export type ToolTraceEntry = { + at: string; + name: string; + summary: string; +}; + +export type BurstState = { + count: number; + max: number; +}; + +/** @deprecated 用 skillCatalog + lifecycleStage */ +export type StageStep = { + id: string; + label: string; + status: "pending" | "active" | "done"; +}; + +const WORKER_QUESTION_FALLBACK = + "请补充当前步骤所需的信息(情境、参数或你的具体设想)。"; + +function formatWorkerQuestionBody(raw: string | undefined): string { + const lines = (raw ?? "") + .split("\n") + .map((line) => line.replace(/^\s*[-*•]\s*/, "").trim()) + .filter((line) => line.length > 0 && !/^askUser$/i.test(line)); + if (lines.length === 0) return WORKER_QUESTION_FALLBACK; + return lines.map((line) => `- ${line}`).join("\n"); +} + +export function classifyAgentMessage(text: string): EnrichedMessage { + const trimmed = text.trim(); + + const agentTool = trimmed.match(/^\[总管 tool\]\s*([^\s:]+)(?::\s*([\s\S]*))?$/); + if (agentTool) { + const name = agentTool[1]; + const detail = agentTool[2]?.trim() ?? ""; + return { + kind: "agent_tool", + actor: "orchestrator", + title: `Tool · ${name}`, + body: detail || "(无输出)", + text: trimmed, + }; + } + + const orchestrator = trimmed.match(/^\[总管\]\s*(\w+):\s*([\s\S]+)$/); + if (orchestrator) { + return { + kind: "orchestrator_decision", + actor: "orchestrator", + title: `Agent · ${orchestrator[1]}`, + body: orchestrator[2].trim(), + text: trimmed, + }; + } + + const workerRunning = trimmed.match(/^\[Worker\]\s*(\S+)\s*执行中/); + if (workerRunning) { + return { + kind: "worker_running", + actor: workerRunning[1], + title: `Worker · ${workerRunning[1]}`, + body: "正在调用模型执行 SKILL…", + text: trimmed, + }; + } + + const workerDone = trimmed.match(/^\[Worker\]\s*(\S+)\s*已完成\s*\n?\n?([\s\S]*)$/); + if (workerDone) { + return { + kind: "worker_output", + actor: workerDone[1], + title: `Worker · ${workerDone[1]} 产出`, + body: workerDone[2]?.trim() || "(无正文)", + text: trimmed, + }; + } + + if (trimmed.startsWith("[Worker 占位]")) { + const stub = trimmed.match(/^\[Worker 占位\]\s*(\S+)/); + return { + kind: "worker_stub", + actor: stub?.[1], + title: `占位 Worker · ${stub?.[1] ?? "?"}`, + body: trimmed, + text: trimmed, + }; + } + + const workerAskTagged = trimmed.match( + /^\[Worker\]\s*(\S+)\s*提问[::]\s*\n?([\s\S]*)$/, + ); + if (workerAskTagged) { + const body = formatWorkerQuestionBody(workerAskTagged[2]); + return { + kind: "worker_questions", + actor: workerAskTagged[1], + title: `Worker · ${workerAskTagged[1]} 提问`, + body, + text: trimmed, + }; + } + + const workerQuestions = trimmed.match(/^Worker 提问[::]\s*\n?([\s\S]*)$/); + if (workerQuestions) { + const body = formatWorkerQuestionBody(workerQuestions[1]); + return { + kind: "worker_questions", + title: "Worker 需要你补充", + body, + text: trimmed, + }; + } + + if (trimmed.startsWith("[请求失败]")) { + return { + kind: "error", + title: "请求失败", + body: trimmed.replace(/^\[请求失败\]\s*/, ""), + text: trimmed, + }; + } + + if (trimmed.startsWith("[阶段机]")) { + return { + kind: "system_info", + title: "阶段机", + body: trimmed.replace(/^\[阶段机\]\s*/, ""), + text: trimmed, + }; + } + + if ( + trimmed.includes("请告诉我") || + trimmed.includes("启动询问") || + /^你选择了/.test(trimmed) + ) { + return { + kind: "orchestrator_prompt", + actor: "orchestrator", + title: "Agent · 启动询问", + body: trimmed, + text: trimmed, + }; + } + + return { + kind: "system_info", + title: "系统", + body: trimmed, + text: trimmed, + }; +} + +/** @deprecated 保留兼容;返回空数组 */ +export function buildPipeline(_session: RuntimeSession): StageStep[] { + return []; +} + +export function buildToolTrace( + messages: Array<{ kind?: AgentMessageKind; text: string; createdAt: string }>, +): ToolTraceEntry[] { + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].kind === "user_input") { + lastUserIdx = i; + break; + } + } + const slice = lastUserIdx >= 0 ? messages.slice(lastUserIdx + 1) : messages; + return slice + .filter((m) => m.kind === "agent_tool") + .map((m) => { + const match = m.text.match(/^\[总管 tool\]\s*([^\s:]+)/); + return { + at: m.createdAt, + name: match?.[1] ?? "tool", + summary: m.text.replace(/^\[总管 tool\]\s*\S+:?\s*/, "").slice(0, 200), + }; + }); +} + +export function buildBurstState( + messages: Array<{ kind?: AgentMessageKind }>, + session: RuntimeSession, +): BurstState { + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].kind === "user_input") { + lastUserIdx = i; + break; + } + } + const slice = lastUserIdx >= 0 ? messages.slice(lastUserIdx + 1) : messages; + const toolCount = slice.filter( + (m) => + m.kind === "agent_tool" || + m.kind === "orchestrator_decision", + ).length; + const stored = + typeof session.slots.toolLoopBurstCount === "number" + ? session.slots.toolLoopBurstCount + : toolCount; + return { + count: Math.max(stored, toolCount), + max: TOOL_LOOP_BURST_MAX, + }; +} + +export function buildFocus( + session: RuntimeSession, + reason?: WaitingReason, + intake?: IntakeProgress, + lifecycle?: LifecycleStage, +): SessionFocus { + const stage = lifecycle ?? inferLifecycleStage(session); + + if (session.phase === "done") { + return { + actorType: "idle", + actorLabel: "流程", + action: "已完成", + detail: stage === "design" ? "设计阶段结束" : "游玩会话结束", + }; + } + + if (reason?.kind === "intake") { + const detail = + intake && intake.requiredTotal > 0 + ? `必要项 ${intake.requiredFilled}/${intake.requiredTotal}` + : "完成必要项后可进入实例化"; + return { + actorType: "user", + actorLabel: "你", + action: "填写创作信息", + detail, + }; + } + + if (reason?.kind === "input") { + return { + actorType: "user", + actorLabel: "你", + action: "补充说明", + detail: reason.message?.slice(0, 120), + }; + } + + if (reason?.kind === "approve_step") { + const worker = session.pendingDecision?.workerId ?? "skill"; + return { + actorType: "orchestrator", + actorId: "orchestrator", + actorLabel: "Agent", + action: `建议 invoke ${worker}`, + detail: session.pendingDecision?.reason, + }; + } + + if (reason?.kind === "worker_questions") { + const q = reason.questions?.filter((s) => s?.trim()).join(";") ?? ""; + return { + actorType: "user", + actorId: reason.workerId, + actorLabel: "你", + action: `回答 · ${reason.workerId}`, + detail: q.slice(0, 200) || "请在下框补充", + }; + } + + if (reason?.kind === "review_artifact") { + const art = session.artifacts.find((a) => a.id === session.pendingArtifactId); + return { + actorType: "user", + actorLabel: "你", + action: "验收产物", + detail: art?.summary ?? art?.workerId, + }; + } + + if (session.currentWorkerId) { + return { + actorType: "worker", + actorId: session.currentWorkerId, + actorLabel: `Skill · ${session.currentWorkerId}`, + action: "执行中", + detail: "模型按 SKILL 产出…", + }; + } + + if (session.phase === "running" && !reason) { + return { + actorType: "orchestrator", + actorId: "orchestrator", + actorLabel: "Agent", + action: stage === "design" ? "设计 burst" : "游玩 burst", + detail: "tool loop:读黑板 → 选 skill", + }; + } + + if (reason?.kind === "skill_selection") { + return { + actorType: "user", + actorLabel: "你", + action: "选择 Skill 包", + }; + } + + return { + actorType: "idle", + actorLabel: "系统", + action: "待命", + }; +} + +export { + inferLifecycleStage, + canEnterPlay, + buildSkillCatalog, + type LifecycleStage, + type SkillCatalogEntry, +}; diff --git a/src/server/book-handlers.ts b/src/server/book-handlers.ts new file mode 100644 index 0000000..877d1a2 --- /dev/null +++ b/src/server/book-handlers.ts @@ -0,0 +1,190 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { listSkills } from "../skills/loader.js"; +import { createBook, deleteBook, getBook, listBooks, updateBook } from "../book/store.js"; +import { sessionManager } from "./session-manager.js"; + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function json(res: ServerResponse, status: number, data: unknown): void { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(data)); +} + +export async function handleBooksApi( + req: IncomingMessage, + res: ServerResponse, + pathname: string, +): Promise { + if (pathname === "/api/skills" && req.method === "GET") { + const skills = await listSkills(); + json(res, 200, { + skills: skills.map((s) => ({ + id: s.name, + name: s.name, + description: s.description, + category: s.category, + bookKind: s.bookKind, + })), + }); + return true; + } + + if (pathname === "/api/books" && req.method === "GET") { + json(res, 200, { books: listBooks() }); + return true; + } + + if (pathname === "/api/books" && req.method === "POST") { + const body = JSON.parse(await readBody(req)) as { title?: string }; + const book = createBook({ title: body.title }); + const session = await sessionManager.createForBook(book.id); + json(res, 201, { book, session }); + return true; + } + + const savesListMatch = pathname.match(/^\/api\/books\/([^/]+)\/saves$/); + if (savesListMatch) { + const bookId = decodeURIComponent(savesListMatch[1]); + const book = getBook(bookId); + if (!book) { + json(res, 404, { error: "Book 不存在" }); + return true; + } + + if (req.method === "GET") { + try { + const saves = sessionManager.listGameSnapshots(bookId); + json(res, 200, { saves }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "读取存档失败", + }); + } + return true; + } + + if (req.method === "POST") { + const body = JSON.parse(await readBody(req)) as { + label?: string; + note?: string; + sessionId?: string; + kind?: "instance" | "run"; + }; + const sessionId = + body.sessionId?.trim() || + sessionManager.getActiveSessionForBook(bookId)?.id; + if (!sessionId) { + json(res, 400, { error: "当前作品没有活跃会话,无法存档" }); + return true; + } + const kind = body.kind === "instance" ? "instance" : "run"; + try { + const save = sessionManager.saveGameSnapshot( + sessionId, + body.label ?? "", + kind, + body.note, + ); + json(res, 201, { save }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "存档失败", + }); + } + return true; + } + } + + const saveItemMatch = pathname.match( + /^\/api\/books\/([^/]+)\/saves\/([^/]+)(\/load)?$/, + ); + if (saveItemMatch) { + const bookId = decodeURIComponent(saveItemMatch[1]); + const saveId = decodeURIComponent(saveItemMatch[2]); + const isLoad = saveItemMatch[3] === "/load"; + + if (isLoad && req.method === "POST") { + try { + const session = await sessionManager.loadGameSnapshot(bookId, saveId); + json(res, 200, { session }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "读档失败", + }); + } + return true; + } + + if (req.method === "DELETE") { + try { + sessionManager.deleteGameSnapshot(bookId, saveId); + json(res, 200, { ok: true }); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "删除失败", + }); + } + return true; + } + } + + const bookMatch = pathname.match(/^\/api\/books\/([^/]+)(\/open)?$/); + if (bookMatch) { + const bookId = decodeURIComponent(bookMatch[1]); + const isOpen = bookMatch[2] === "/open"; + + if (isOpen && req.method === "POST") { + const book = getBook(bookId); + if (!book) { + json(res, 404, { error: "Book 不存在" }); + return true; + } + const session = await sessionManager.openBook(book.id); + json(res, 200, { book, session }); + return true; + } + + if (req.method === "GET") { + const book = getBook(bookId); + if (!book) { + json(res, 404, { error: "Book 不存在" }); + return true; + } + json(res, 200, { book }); + return true; + } + + if (req.method === "PUT") { + const body = JSON.parse(await readBody(req)) as { title?: string }; + try { + const book = updateBook(bookId, { title: body.title?.trim() }); + json(res, 200, { book }); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "更新失败", + }); + } + return true; + } + + if (req.method === "DELETE") { + const book = getBook(bookId); + if (!book) { + json(res, 404, { error: "Book 不存在" }); + return true; + } + sessionManager.dropBookSessions(bookId); + deleteBook(bookId); + json(res, 200, { ok: true }); + return true; + } + } + + return false; +} diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts new file mode 100644 index 0000000..72676e4 --- /dev/null +++ b/src/server/session-manager.ts @@ -0,0 +1,829 @@ +import { randomUUID } from "node:crypto"; +import { appendBookSession, getBook, updateBook } from "../book/store.js"; +import { loadBookSession, saveBookSession } from "../book/session-store.js"; +import { + deleteRunSnapshot as deleteRunSnapshotFile, + listRunSnapshots, + loadRunSnapshot as loadRunSnapshotFile, + saveRunSnapshot as saveRunSnapshotFile, +} from "../book/run-snapshot-store.js"; +import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-snapshot.js"; +import { toRunSnapshotMeta } from "../types/run-snapshot.js"; +import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js"; +import { PhaseRuntime, createDecision } from "../runtime/phase-runtime.js"; +import { createSession } from "../runtime/phase-machine.js"; +import { + createDefaultMainAgentLlm, + hasRealLlmConfig, + reloadDefaultMainAgentLlm, + type LlmTrackingRef, +} from "../runtime/llm-factory.js"; +import { getSessionTokenTotals, type MessageTokenUsage } from "../stats/token-store.js"; +import type { PersistedBookSession } from "../types/book-session.js"; +import type { RuntimeSession, WaitingReason } from "../types/runtime.js"; +import type { IntakeProgress } from "../types/intake.js"; +import { + buildFocus, + buildPipeline, + buildToolTrace, + buildBurstState, + buildSkillCatalog, + inferLifecycleStage, + canEnterPlay, + classifyAgentMessage, + type AgentMessageKind, + type SessionFocus, + type StageStep, + type LifecycleStage, + type SkillCatalogEntry, + type ToolTraceEntry, + type BurstState, +} from "./agent-view.js"; +import { + buildIntakeProgress, + readIntakeValues, +} from "../intake/intake.js"; +import { + bookSkillPackId, + persistedSkillPackId, + runSnapshotSkillPackId, + sessionSkillPackId, + skillPacksMatch, +} from "../book/skill-id.js"; +import type { ActiveSkillSnapshot } from "../types/runtime.js"; + +export type ChatMessage = { + id: string; + role: "system" | "user"; + text: string; + createdAt: string; + kind?: AgentMessageKind; + actor?: string; + title?: string; + body?: string; + /** LLM 思维链 / reasoning_content */ + thinking?: string; + tokenUsage?: MessageTokenUsage; +}; + +export type SessionView = { + id: string; + bookId?: string; + bookTitle?: string; + activeSkill?: string; + phase: RuntimeSession["phase"]; + waitingReason?: WaitingReason; + startupCompleted: boolean; + skills: Array<{ name: string; description: string; category: string }>; + messages: ChatMessage[]; + hints: string[]; + actions: SessionAction[]; + /** @deprecated 用 skillCatalog */ + pipeline: StageStep[]; + lifecycleStage: LifecycleStage; + playReady: boolean; + skillCatalog: SkillCatalogEntry[]; + toolTrace: ToolTraceEntry[]; + burst: BurstState; + focus: SessionFocus; + /** 启动填空进度(waitingReason.kind === intake 时有值) */ + intake?: IntakeProgress; + intakePrompt?: string; + resumed?: boolean; + tokenStats?: { + sessionTotal: number; + sessionCached?: number; + sessionCacheMiss?: number; + lastCaller?: string; + lastTotal?: number; + byCaller?: Record< + string, + { totalTokens: number; cachedTokens: number; cacheMissTokens: number; calls: number } + >; + }; +}; + +export type SessionAction = + | { type: "send_message"; label: string; placeholder: string } + | { type: "confirm_intake"; label: string } + | { type: "approve"; label: string } + | { type: "accept"; label: string } + | { type: "reject"; label: string } + | { type: "run_outline"; label: string } + | { type: "finish"; label: string }; + +type ManagedSession = { + runtime: PhaseRuntime; + messages: ChatMessage[]; + bookId?: string; + trackingRef: LlmTrackingRef; +}; + +export class SessionManager { + private readonly sessions = new Map(); + /** bookId → 当前内存中的 sessionId */ + private readonly activeBookSessions = new Map(); + + async create(): Promise { + return this.createForBook(); + } + + setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView { + const s = this.require(sessionId); + const session = s.runtime.getSession(); + if (stage === "play" && !canEnterPlay(session)) { + throw new Error("实例尚未就绪,无法进入游玩"); + } + s.runtime.setLifecycleStage(stage); + if (s.bookId) this.persist(s); + return this.toView(sessionId); + } + + /** + * 打开 Book:优先恢复磁盘快照;无快照则新建 Session。 + * 若该 Book 已在内存中,直接返回当前视图。 + */ + async openBook(bookId: string): Promise { + const book = getBook(bookId); + if (!book) throw new Error("Book 不存在"); + + const inMemory = this.getActiveSessionForBook(bookId); + if (inMemory) return inMemory; + + const snapshot = loadBookSession(bookId); + if (snapshot && skillPacksMatch(book, persistedSkillPackId(snapshot))) { + return this.restoreFromSnapshot(snapshot); + } + + const legacyPreselect = bookSkillPackId(book); + return this.createForBook(bookId, legacyPreselect); + } + + async createForBook( + bookId?: string, + preselectSkillId?: string, + ): Promise { + const id = randomUUID(); + const messages: ChatMessage[] = []; + const book = bookId ? getBook(bookId) : null; + const initialSession = createSession("default"); + initialSession.id = id; + const trackingRef: LlmTrackingRef = { + current: { + sessionId: id, + bookId, + bookTitle: book?.title, + orchestratorId: preselectSkillId ?? (book ? bookSkillPackId(book) : undefined), + }, + }; + + const managed: ManagedSession = { + runtime: null as unknown as PhaseRuntime, + messages, + bookId, + trackingRef, + }; + const onMessage = this.buildOnMessageHandler(() => managed); + + const llm = createDefaultMainAgentLlm(trackingRef); + const runtime = new PhaseRuntime({ + autoStubWorker: !hasRealLlmConfig(), + llm, + onMessage, + initialSession, + }); + managed.runtime = runtime; + + if (preselectSkillId) { + await runtime.startWithOrchestrator(preselectSkillId); + } else { + await runtime.start(); + } + + this.sessions.set(id, managed); + if (bookId) { + this.activeBookSessions.set(bookId, id); + appendBookSession(bookId, id); + } + return this.toView(id); + } + + get(id: string): SessionView | null { + if (!this.sessions.has(id)) return null; + return this.toView(id); + } + + getActiveSessionForBook(bookId: string): SessionView | null { + const sessionId = this.activeBookSessions.get(bookId); + if (!sessionId) return null; + return this.get(sessionId); + } + + /** + * 保存快照。 + * - instance:实例化后的对象(情境/规则/角色设定等),不含 run 轮次状态 + * - run:运行存档,完整进度 + */ + saveGameSnapshot( + sessionId: string, + label: string, + kind: SnapshotKind = "run", + note?: string, + ): RunSnapshotMeta { + const s = this.require(sessionId); + if (!s.bookId) throw new Error("仅绑定作品时可存档"); + const book = getBook(s.bookId); + if (!book) throw new Error("Book 不存在"); + + const trimmed = label.trim(); + if (!trimmed) throw new Error("请输入存档名称"); + + let runtimeSession = structuredClone(s.runtime.getSession()); + let blackboardItems = s.runtime.getBlackboard().exportItems(); + + if (kind === "instance") { + if (!runtimeSession.slots.startupCompleted) { + throw new Error("实例尚未完成(需先完成启动与 setup 验收),无法保存实例快照"); + } + ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ + runtimeSession, + blackboardItems, + })); + } + + const skillPackId = + sessionSkillPackId(runtimeSession) ?? bookSkillPackId(book) ?? ""; + const snapshot: RunSnapshot = { + version: 1, + id: randomUUID(), + bookId: s.bookId, + label: trimmed, + kind, + orchestratorId: skillPackId, + runtimeSession, + blackboardItems, + messages: s.messages.map((m) => ({ ...m })), + createdAt: new Date().toISOString(), + note: note?.trim() || undefined, + }; + saveRunSnapshotFile(snapshot); + return toRunSnapshotMeta(snapshot); + } + + /** 列出某 Book 的全部存档 */ + listGameSnapshots(bookId: string): RunSnapshotMeta[] { + if (!getBook(bookId)) throw new Error("Book 不存在"); + return listRunSnapshots(bookId); + } + + /** 从存档读档:替换当前作品进度,可继续创作 */ + async loadGameSnapshot(bookId: string, snapshotId: string): Promise { + const snapshot = loadRunSnapshotFile(bookId, snapshotId); + if (!snapshot) throw new Error("存档不存在"); + + const book = getBook(bookId); + if (!book) throw new Error("Book 不存在"); + if (!skillPacksMatch(book, runSnapshotSkillPackId(snapshot))) { + throw new Error("存档与当前作品 skill 包不匹配,无法读档"); + } + + this.dropBookSessions(bookId); + + const newSessionId = randomUUID(); + const messages: ChatMessage[] = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[]; + let runtimeSession = structuredClone(snapshot.runtimeSession); + let blackboardItems = snapshot.blackboardItems; + + if (snapshot.kind === "instance") { + ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ + runtimeSession, + blackboardItems, + })); + } + + runtimeSession.id = newSessionId; + + const resumeHint = + snapshot.kind === "instance" + ? `已加载实例「${snapshot.label}」。可开始运行,或调整角色设定后再模拟。` + : `已从存档「${snapshot.label}」读档,可继续创作。`; + + return this.mountRestoredSession({ + sessionId: newSessionId, + bookId, + skillPackId: runSnapshotSkillPackId(snapshot) ?? "", + runtimeSession, + blackboardItems, + messages, + resumeHint, + }); + } + + /** 删除单个存档 */ + deleteGameSnapshot(bookId: string, snapshotId: string): void { + if (!getBook(bookId)) throw new Error("Book 不存在"); + if (!deleteRunSnapshotFile(bookId, snapshotId)) { + throw new Error("存档不存在"); + } + } + + /** 删除 Book 时清理内存中的会话 */ + dropBookSessions(bookId: string): void { + for (const [sessionId, managed] of this.sessions) { + if (managed.bookId === bookId) { + this.sessions.delete(sessionId); + } + } + this.activeBookSessions.delete(bookId); + } + + /** 将当前 API / 预设设置应用到所有活跃会话 */ + reloadAllLlms(): number { + let count = 0; + for (const s of this.sessions.values()) { + const llm = reloadDefaultMainAgentLlm(s.trackingRef); + s.runtime.reloadLlm(llm, !hasRealLlmConfig()); + count += 1; + } + return count; + } + + async sendMessage(id: string, text: string): Promise { + const s = this.require(id); + s.messages.push(this.msg("user", text)); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + const reason = s.runtime.getSession().waitingReason; + try { + if (reason?.kind === "approve_step") { + await s.runtime.rejectStep(text); + } else if (reason?.kind === "review_artifact") { + await s.runtime.rejectArtifact(text); + } else { + await s.runtime.submitInput(text); + } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error("[session] sendMessage failed:", detail); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + } + return this.toView(id); + } + + async approve(id: string): Promise { + const s = this.require(id); + try { + await s.runtime.approve(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error("[session] approve failed:", detail); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + } + return this.toView(id); + } + + async confirmIntake(id: string): Promise { + const s = this.require(id); + try { + await s.runtime.confirmIntake(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error("[session] confirmIntake failed:", detail); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + } + return this.toView(id); + } + + async accept(id: string): Promise { + const s = this.require(id); + await s.runtime.acceptArtifact(); + return this.toView(id); + } + + /** 拒绝当前待确认步骤或待验收产物(无说明时触发重新来 / 回到总管) */ + async reject(id: string, reason?: string): Promise { + const s = this.require(id); + const waiting = s.runtime.getSession().waitingReason; + try { + if (waiting?.kind === "approve_step") { + await s.runtime.rejectStep(reason ?? "用户暂不执行"); + } else if (waiting?.kind === "review_artifact") { + await s.runtime.rejectArtifact(reason ?? "用户要求重新来"); + } else { + throw new Error("当前没有可拒绝的确认或验收"); + } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error("[session] reject failed:", detail); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + } + return this.toView(id); + } + + async runOutline(id: string): Promise { + const s = this.require(id); + await s.runtime.submitDecision( + createDecision({ + action: "run_worker", + reason: "根据创作简报生成大纲", + workerId: "outline", + requiresApproval: true, + }), + ); + return this.toView(id); + } + + async finish(id: string): Promise { + const s = this.require(id); + await s.runtime.submitDecision( + createDecision({ + action: "finish", + reason: "创作流程结束", + requiresApproval: false, + }), + ); + return this.toView(id); + } + + private async restoreFromSnapshot( + snapshot: PersistedBookSession, + ): Promise { + return this.mountRestoredSession({ + sessionId: snapshot.sessionId, + bookId: snapshot.bookId, + skillPackId: persistedSkillPackId(snapshot) ?? "", + runtimeSession: snapshot.runtimeSession, + blackboardItems: snapshot.blackboardItems, + messages: snapshot.messages.map((m) => ({ ...m })) as ChatMessage[], + resumeHint: "已从上次进度恢复,可继续创作。", + }); + } + + private async mountRestoredSession(params: { + sessionId: string; + bookId: string; + skillPackId: string; + runtimeSession: RuntimeSession; + blackboardItems: import("../types/blackboard.js").BlackboardItem[]; + messages: ChatMessage[]; + resumeHint: string; + }): Promise { + const book = getBook(params.bookId); + const trackingRef: LlmTrackingRef = { + current: { + sessionId: params.sessionId, + bookId: params.bookId, + bookTitle: book?.title, + orchestratorId: params.skillPackId || undefined, + }, + }; + + let managedRef: ManagedSession | null = null; + const onMessage = this.buildOnMessageHandler(() => managedRef); + + const runtime = new PhaseRuntime({ + autoStubWorker: !hasRealLlmConfig(), + llm: createDefaultMainAgentLlm(trackingRef), + onMessage, + initialSession: params.runtimeSession, + initialBlackboardItems: params.blackboardItems, + }); + await runtime.ensureAvailableSkills(); + + managedRef = { + runtime, + messages: params.messages, + bookId: params.bookId, + trackingRef, + }; + this.sessions.set(params.sessionId, managedRef); + this.activeBookSessions.set(params.bookId, params.sessionId); + appendBookSession(params.bookId, params.sessionId); + this.persist(managedRef); + + return this.toView(params.sessionId, true, params.resumeHint); + } + + private buildOnMessageHandler(getManaged: () => ManagedSession | null) { + return (text: string) => { + const managed = getManaged(); + if (!managed) return; + const usage = managed.trackingRef.current.pendingUsage; + const thinking = managed.trackingRef.current.pendingReasoning; + managed.trackingRef.current.pendingUsage = undefined; + managed.trackingRef.current.pendingReasoning = undefined; + managed.messages.push(this.msg("system", text, usage, thinking)); + if (managed.bookId) { + this.syncBookPreview(managed.bookId, managed.messages); + this.persist(managed); + } + }; + } + + private persist(s: ManagedSession): void { + if (!s.bookId) return; + const book = getBook(s.bookId); + if (!book) return; + + const session = s.runtime.getSession(); + const skillPackId = + sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined) ?? ""; + const snapshot: PersistedBookSession = { + version: 1, + sessionId: session.id, + bookId: s.bookId, + orchestratorId: skillPackId || undefined, + runtimeSession: session, + blackboardItems: s.runtime.getBlackboard().exportItems(), + messages: s.messages, + savedAt: new Date().toISOString(), + }; + saveBookSession(snapshot); + this.syncBookSkill(s.bookId, session); + updateBook(s.bookId, { activeSessionId: session.id }); + } + + private syncBookSkill(bookId: string, session: RuntimeSession): void { + const snap = session.slots.activeSkill as ActiveSkillSnapshot | undefined; + if (!snap?.name) return; + try { + updateBook(bookId, { + activeSkillId: snap.name, + activeSkillName: snap.name, + }); + } catch { + /* book may have been deleted */ + } + } + + private syncBookPreview(bookId: string, messages: ChatMessage[]): void { + const preview = this.previewFromMessages(messages); + try { + updateBook(bookId, { preview }); + } catch { + /* book may have been deleted */ + } + } + + private previewFromMessages(messages: ChatMessage[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const t = messages[i].text?.trim(); + if (t) return t.slice(0, 80); + } + return "等待开始…"; + } + + private require(id: string): ManagedSession { + const s = this.sessions.get(id); + if (!s) throw new Error("会话不存在"); + return s; + } + + private enrichDisplayMessages( + session: RuntimeSession, + messages: ChatMessage[], + ): ChatMessage[] { + const reason = session.waitingReason; + if (reason?.kind === "intake" && !session.slots.startupCompleted) { + const prompt = reason.prompt?.trim(); + if (!prompt) return messages; + const needle = prompt.slice(0, 48); + const hasStartup = messages.some( + (m) => + m.kind === "orchestrator_prompt" || + (typeof m.text === "string" && m.text.includes(needle)), + ); + if (hasStartup) return messages; + return [...messages, this.msg("system", prompt)]; + } + if (reason?.kind !== "input" || session.slots.startupCompleted) { + return messages; + } + const prompt = reason.message?.trim(); + if (!prompt) return messages; + const needle = prompt.slice(0, 48); + const hasStartup = messages.some( + (m) => + m.kind === "orchestrator_prompt" || + (typeof m.text === "string" && m.text.includes(needle)), + ); + if (hasStartup) return messages; + return [...messages, this.msg("system", prompt)]; + } + + private msg( + role: ChatMessage["role"], + text: string, + tokenUsage?: MessageTokenUsage, + thinking?: string, + ): ChatMessage { + const base = { + id: randomUUID(), + role, + text, + createdAt: new Date().toISOString(), + ...(tokenUsage ? { tokenUsage } : {}), + ...(thinking ? { thinking } : {}), + }; + if (role === "user") { + return { + ...base, + kind: "user_input", + title: "你的输入", + body: text, + }; + } + const classified = classifyAgentMessage(text); + return { ...base, ...classified }; + } + + private toView(id: string, resumed = false, resumeHint?: string): SessionView { + const s = this.require(id); + const session = s.runtime.getSession(); + const reason = session.waitingReason; + const book = s.bookId ? getBook(s.bookId) : null; + const hints: string[] = []; + const actions: SessionAction[] = []; + let intake: IntakeProgress | undefined; + let intakePrompt: string | undefined; + + const activeSnap = session.slots.activeSkill as ActiveSkillSnapshot | undefined; + + if (resumeHint) { + hints.push(resumeHint); + } else if (resumed) { + hints.push("已从上次进度恢复,可继续创作。"); + } + + if (reason?.kind === "intake" && activeSnap?.intakeFields?.length) { + intake = buildIntakeProgress( + activeSnap.intakeFields, + readIntakeValues(session.slots), + ); + intakePrompt = reason.prompt; + if (intake.ready) { + hints.push("必要项已齐,可确认进入实例化;也可继续补充可选项。"); + actions.push({ + type: "confirm_intake", + label: "确认,进入实例化", + }); + actions.push({ + type: "send_message", + label: "继续补充", + placeholder: "补充可选项或修正已填内容…", + }); + } else { + hints.push( + `填写必要项(${intake.requiredFilled}/${intake.requiredTotal})后可确认进入实例化`, + ); + actions.push({ + type: "send_message", + label: "发送", + placeholder: "按上方说明补充信息,可一次说多项…", + }); + } + } else if (reason?.kind === "skill_selection") { + hints.push("请选择创作类型(输入 skill 名称或编号)"); + actions.push({ + type: "send_message", + label: "发送", + placeholder: "例如:basic 或 1", + }); + } else if (reason?.kind === "intake") { + hints.push(reason.prompt?.trim() ?? "请按填空项补充创作信息"); + actions.push({ + type: "send_message", + label: "发送", + placeholder: "描述你想写什么…", + }); + } else if (reason?.kind === "input") { + if (reason.message?.trim()) { + hints.push(reason.message.trim()); + } else { + hints.push("请回答启动问题,或补充创作目标"); + } + actions.push({ + type: "send_message", + label: "发送", + placeholder: "描述你想写什么…", + }); + } else if (reason?.kind === "worker_questions") { + const qs = reason.questions.filter((q) => q?.trim()); + if (qs.length) { + hints.push(`Worker · ${reason.workerId} 提问:${qs.join(" ")}`); + } else { + hints.push(`Worker · ${reason.workerId} 需要更多信息,请补充说明`); + } + const placeholder = + qs[0]?.slice(0, 120) ?? "回答 Worker 的问题,或补充情境与参数…"; + actions.push({ + type: "send_message", + label: "发送", + placeholder, + }); + } else if (reason?.kind === "approve_step") { + actions.push({ type: "approve", label: "确认执行" }); + actions.push({ type: "reject", label: "暂不执行" }); + } else if (reason?.kind === "review_artifact") { + actions.push({ type: "accept", label: "接受产物" }); + actions.push({ type: "reject", label: "不接受,重新来" }); + } else if ( + session.phase === "running" && + s.runtime.needsMainAgentDecision() && + !s.runtime.hasMainAgent() + ) { + hints.push("简报已就绪,可手动生成大纲(未配置 Agent LLM)"); + actions.push({ type: "run_outline", label: "生成大纲" }); + actions.push({ + type: "send_message", + label: "发送", + placeholder: "补充说明…", + }); + } else if (session.phase === "running" && s.runtime.needsMainAgentDecision()) { + hints.push("Agent 正在调度…"); + } else if (session.phase === "running" && session.pendingArtifactId) { + /* worker 运行中 */ + } else if ( + session.phase === "running" && + session.slots.startupCompleted && + !session.pendingDecision + ) { + actions.push({ type: "finish", label: "结束流程" }); + } else if (session.phase === "done") { + hints.push("流程已完成"); + } else { + actions.push({ + type: "send_message", + label: "发送", + placeholder: "输入消息…", + }); + } + + if (s.bookId) { + this.persist(s); + } + + const tokens = getSessionTokenTotals(id); + const messages = this.enrichDisplayMessages(session, [...s.messages]); + const lifecycleStage = inferLifecycleStage(session); + const skillPackId = + sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined); + const skillCatalog = buildSkillCatalog( + session, + skillPackId, + lifecycleStage, + ); + + return { + id, + bookId: s.bookId, + bookTitle: book?.title, + activeSkill: s.runtime.getActiveSkill()?.name ?? skillPackId, + phase: session.phase, + waitingReason: reason, + startupCompleted: Boolean(session.slots.startupCompleted), + skills: s.runtime.getAvailableSkills(), + messages, + hints, + actions, + pipeline: buildPipeline(session), + lifecycleStage, + playReady: canEnterPlay(session), + skillCatalog, + toolTrace: buildToolTrace(messages), + burst: buildBurstState(messages, session), + focus: buildFocus(session, reason, intake, lifecycleStage), + intake, + intakePrompt, + resumed: resumed || undefined, + tokenStats: { + sessionTotal: tokens.totalTokens, + sessionCached: tokens.totalCached || undefined, + sessionCacheMiss: tokens.totalCacheMiss || undefined, + lastCaller: tokens.last?.caller, + lastTotal: tokens.last?.totalTokens, + byCaller: tokens.byCaller, + }, + }; + } +} + +export const sessionManager = new SessionManager(); + +function formatRuntimeError(detail: string): string { + if (detail.includes("401") || detail.toLowerCase().includes("authentication")) { + return ( + "[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL," + + "或点击「测试连接」验证。" + ); + } + if (detail.startsWith("LLM request failed")) { + return `[请求失败] ${detail.replace(/^LLM request failed \(\d+\): /, "").slice(0, 300)}`; + } + if (detail.startsWith("Main Agent returned invalid JSON")) { + return "[请求失败] 总管返回了无效 JSON,请检查模型是否支持 json 输出,或暂时关闭预设后重试。"; + } + return `[请求失败] ${detail.slice(0, 300)}`; +} diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts new file mode 100644 index 0000000..4673142 --- /dev/null +++ b/src/server/settings-handlers.ts @@ -0,0 +1,267 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + createApiProfile, + deleteApiProfile, + getApiProfile, + listApiProfiles, + testApiProfile, + updateApiProfile, +} from "../config/api-profiles.js"; +import { + ensureActiveProfileDefault, + loadAppSettings, + saveAppSettings, + setActivePresetId, + setActiveProfileId, +} from "../config/settings.js"; +import { + deletePreset, + getPreset, + importAndSavePreset, + listPresets, +} from "../preset/store.js"; +import { + countInjectingEntries, + listEnabledPresetEntries, +} from "../preset/entries.js"; +import { sessionManager } from "./session-manager.js"; + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function json(res: ServerResponse, status: number, data: unknown): void { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(data)); +} + +export async function handleSettingsApi( + req: IncomingMessage, + res: ServerResponse, + pathname: string, +): Promise { + if (pathname === "/api/settings" && req.method === "GET") { + ensureActiveProfileDefault(); + const settings = loadAppSettings(); + const profiles = listApiProfiles(); + const presets = listPresets().map((p) => { + const entries = listEnabledPresetEntries(p); + return { + id: p.id, + name: p.name, + source: p.source, + enabledCount: p.promptOrder.filter((o) => o.enabled).length, + injectingCount: countInjectingEntries(entries), + importedAt: p.importedAt, + }; + }); + json(res, 200, { settings, profiles, presets }); + return true; + } + + if (pathname === "/api/settings" && req.method === "PUT") { + const body = JSON.parse(await readBody(req)) as { + activeProfileId?: string | null; + activePresetId?: string | null; + }; + const settings = loadAppSettings(); + if (body.activeProfileId !== undefined) { + settings.activeProfileId = body.activeProfileId; + } + if (body.activePresetId !== undefined) { + settings.activePresetId = body.activePresetId; + } + saveAppSettings(settings); + json(res, 200, { settings }); + return true; + } + + if (pathname === "/api/profiles" && req.method === "GET") { + ensureActiveProfileDefault(); + json(res, 200, { profiles: listApiProfiles() }); + return true; + } + + if (pathname === "/api/profiles" && req.method === "POST") { + const body = JSON.parse(await readBody(req)) as { + name?: string; + baseUrl?: string; + apiKey?: string; + model?: string; + }; + const profile = createApiProfile({ + name: body.name ?? "新配置", + baseUrl: body.baseUrl ?? "https://api.deepseek.com", + apiKey: body.apiKey ?? "", + model: body.model ?? "deepseek-v4-pro", + }); + const activate = (body as { activate?: boolean }).activate === true; + if (activate) { + setActiveProfileId(profile.id); + } + const reloadedSessions = activate ? sessionManager.reloadAllLlms() : 0; + json(res, 201, { profile, activeProfileId: activate ? profile.id : null, reloadedSessions }); + return true; + } + + const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)(\/test)?$/); + if (profileMatch) { + const id = decodeURIComponent(profileMatch[1]); + const isTest = profileMatch[2] === "/test"; + + if (isTest && req.method === "POST") { + const profile = getApiProfile(id); + if (!profile) { + json(res, 404, { error: "配置不存在" }); + return true; + } + const result = await testApiProfile(profile); + json(res, 200, result); + return true; + } + + if (req.method === "GET") { + const profile = getApiProfile(id); + if (!profile) { + json(res, 404, { error: "配置不存在" }); + return true; + } + json(res, 200, { profile }); + return true; + } + + if (req.method === "PUT") { + const body = JSON.parse(await readBody(req)) as { + name?: string; + baseUrl?: string; + apiKey?: string; + model?: string; + }; + try { + const profile = updateApiProfile(id, body); + json(res, 200, { profile }); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "更新失败", + }); + } + return true; + } + + if (req.method === "DELETE") { + deleteApiProfile(id); + const settings = loadAppSettings(); + if (settings.activeProfileId === id) { + settings.activeProfileId = listApiProfiles()[0]?.id ?? null; + saveAppSettings(settings); + } + json(res, 200, { ok: true }); + return true; + } + } + + const activateProfileMatch = pathname.match( + /^\/api\/profiles\/([^/]+)\/activate$/, + ); + if (activateProfileMatch && req.method === "POST") { + const id = decodeURIComponent(activateProfileMatch[1]); + if (!getApiProfile(id)) { + json(res, 404, { error: "配置不存在" }); + return true; + } + setActiveProfileId(id); + const reloadedSessions = sessionManager.reloadAllLlms(); + json(res, 200, { activeProfileId: id, reloadedSessions }); + return true; + } + + if (pathname === "/api/presets" && req.method === "GET") { + json(res, 200, { presets: listPresets() }); + return true; + } + + if (pathname === "/api/presets/import" && req.method === "POST") { + const body = JSON.parse(await readBody(req)) as { + raw?: unknown; + name?: string; + }; + if (!body.raw) { + json(res, 400, { error: "缺少 raw 字段" }); + return true; + } + const report = importAndSavePreset(body.raw, { name: body.name }); + const activate = (body as { activate?: boolean }).activate === true; + if (activate) { + setActivePresetId(report.preset.id); + sessionManager.reloadAllLlms(); + } + json(res, 201, { ...report, activePresetId: activate ? report.preset.id : null }); + return true; + } + + const presetEntriesMatch = pathname.match( + /^\/api\/presets\/([^/]+)\/entries$/, + ); + if (presetEntriesMatch && req.method === "GET") { + const id = decodeURIComponent(presetEntriesMatch[1]); + const preset = getPreset(id); + if (!preset) { + json(res, 404, { error: "预设不存在" }); + return true; + } + const entries = listEnabledPresetEntries(preset); + json(res, 200, { + presetId: preset.id, + presetName: preset.name, + generation: preset.generation, + entries, + injectingCount: countInjectingEntries(entries), + }); + return true; + } + + const presetMatch = pathname.match(/^\/api\/presets\/([^/]+)(\/activate)?$/); + if (presetMatch) { + const id = decodeURIComponent(presetMatch[1]); + const isActivate = presetMatch[2] === "/activate"; + + if (isActivate && req.method === "POST") { + if (!getPreset(id)) { + json(res, 404, { error: "预设不存在" }); + return true; + } + setActivePresetId(id); + const reloadedSessions = sessionManager.reloadAllLlms(); + json(res, 200, { activePresetId: id, reloadedSessions }); + return true; + } + + if (req.method === "GET") { + const preset = getPreset(id); + if (!preset) { + json(res, 404, { error: "预设不存在" }); + return true; + } + const entries = listEnabledPresetEntries(preset); + json(res, 200, { preset, entries, injectingCount: countInjectingEntries(entries) }); + return true; + } + + if (req.method === "DELETE") { + deletePreset(id); + const settings = loadAppSettings(); + if (settings.activePresetId === id) { + settings.activePresetId = null; + saveAppSettings(settings); + } + json(res, 200, { ok: true }); + return true; + } + } + + return false; +} diff --git a/src/server/skill-catalog.ts b/src/server/skill-catalog.ts new file mode 100644 index 0000000..a9944dc --- /dev/null +++ b/src/server/skill-catalog.ts @@ -0,0 +1,285 @@ +import type { RuntimeSession } from "../types/runtime.js"; + +export type LifecycleStage = "design" | "play"; + +export type SkillCatalogEntry = { + id: string; + stage: "design" | "run"; + label: string; + /** 这一步要干嘛(占位说明,详细设计后续补充) */ + purpose: string; + status: "pending" | "active" | "done" | "skipped"; +}; + +type CatalogTemplate = { + id: string; + stage: "design" | "run"; + label: string; + purpose: string; +}; + +const GENERIC_DESIGN: CatalogTemplate[] = [ + { + id: "interaction-paradigm", + stage: "design", + label: "交互范式", + purpose: "弄清用户要什么体验,产出 run skill 清单(要哪些能力)。", + }, + { + id: "intake", + stage: "design", + label: "启动收集", + purpose: "收集最小需求,写入用户.需求 / book.brief。", + }, + { + id: "world-blueprint", + stage: "design", + label: "世界蓝图", + purpose: "定背景板与核心冲突,供后续 skill 引用。", + }, + { + id: "narrative-guide", + stage: "design", + label: "叙事指南", + purpose: "定 POV、时态、文风(static 上下文上半)。", + }, + { + id: "declare-ready", + stage: "design", + label: "实例就绪", + purpose: "agent 确认设计够开跑,进入游玩阶段。", + }, +]; + +const GENERIC_RUN: CatalogTemplate[] = [ + { + id: "agent-burst", + stage: "run", + label: "Agent 调度", + purpose: "总管 tool loop:读黑板 → 选择 invoke 哪个 run skill。", + }, + { + id: "narrator", + stage: "run", + label: "转述 / 展示", + purpose: "把世界状态编排成给用户看的叙事回复。", + }, + { + id: "world-simulator", + stage: "run", + label: "世界模拟", + purpose: "裁决规则、更新事件流与可见信息。", + }, +]; + +const BY_SKILL_PACK: Record = { + basic: [ + { + id: "intake", + stage: "design", + label: "创作简报", + purpose: "收集题材、篇幅、风格 → book.brief。", + }, + { + id: "declare-ready", + stage: "design", + label: "进入运行", + purpose: "简报确认后 declare ready。", + }, + { + id: "outline", + stage: "run", + label: "生成大纲", + purpose: "根据 brief 生成 outline 产物。", + }, + ], + "weird-rules-short": [ + { + id: "intake", + stage: "design", + label: "创作简报", + purpose: "收集规则怪谈情境与条数。", + }, + { + id: "write-rules", + stage: "run", + label: "写规则", + purpose: "产出规则草稿与隐藏 core。", + }, + { + id: "review-infer", + stage: "run", + label: "读者验收", + purpose: "盲读规则,不写 core。", + }, + { + id: "review-author", + stage: "run", + label: "作者验收", + purpose: "对照 core 查一致性。", + }, + ], + "roleplay-game-theory": [ + { + id: "intake", + stage: "design", + label: "博弈需求", + purpose: "收集情境、角色、轮次 → 用户.博弈需求。", + }, + { + id: "setup-scenario", + stage: "design", + label: "结构化设定", + purpose: "整理为情境、规则、角色设定 tag。", + }, + { + id: "declare-ready", + stage: "design", + label: "开始模拟", + purpose: "setup 验收后进入 run。", + }, + { + id: "world-engine", + stage: "run", + label: "世界机", + purpose: "发可见信息、收行动、裁决回合。", + }, + { + id: "role-decide", + stage: "run", + label: "角色决策", + purpose: "各角色独立产出思考与行动。", + }, + { + id: "present-round", + stage: "run", + label: "回合展示", + purpose: "编排给用户看的本轮摘要。", + }, + ], + "world-simulator": [ + { + id: "interaction-paradigm", + stage: "design", + label: "交互范式", + purpose: "定体验与 run skill 清单。", + }, + { + id: "world-blueprint", + stage: "design", + label: "世界蓝图", + purpose: "背景板与核心设定。", + }, + { + id: "topology", + stage: "design", + label: "拓扑 / 关系", + purpose: "地图、关系网或进阶路径(按需)。", + }, + { + id: "generation-rules", + stage: "design", + label: "生成规则", + purpose: "元规则:如何生成实例内容。", + }, + { + id: "narrative-guide", + stage: "design", + label: "叙事指南", + purpose: "正文气质与禁忌(static 上)。", + }, + { + id: "variable-catalog", + stage: "design", + label: "变量目录", + purpose: "要跟踪的状态与更新格式。", + }, + { + id: "declare-ready", + stage: "design", + label: "实例就绪", + purpose: "agent 声明可开跑。", + }, + { + id: "world-simulator", + stage: "run", + label: "世界模拟器", + purpose: "每轮推进世界状态与事件流。", + }, + { + id: "narrator", + stage: "run", + label: "转述者", + purpose: "把状态写成用户可见叙事。", + }, + ], +}; + +function templatesFor(skillPackId?: string): CatalogTemplate[] { + if (skillPackId && BY_SKILL_PACK[skillPackId]) { + return BY_SKILL_PACK[skillPackId]; + } + return [...GENERIC_DESIGN, ...GENERIC_RUN]; +} + +export function inferLifecycleStage(session: RuntimeSession): LifecycleStage { + const override = session.slots.uiLifecycleStage; + if (override === "design" || override === "play") { + return override; + } + if (!session.slots.startupCompleted) return "design"; + if (session.phase === "done") return "play"; + return "play"; +} + +export function canEnterPlay(session: RuntimeSession): boolean { + return Boolean(session.slots.startupCompleted); +} + +export function buildSkillCatalog( + session: RuntimeSession, + skillPackId?: string, + lifecycle: LifecycleStage = inferLifecycleStage(session), +): SkillCatalogEntry[] { + const templates = templatesFor(skillPackId); + const filtered = templates.filter((t) => + lifecycle === "design" ? t.stage === "design" : t.stage === "run", + ); + + const workerIds = new Set( + session.artifacts.map((a) => a.workerId).filter(Boolean), + ); + const acceptedWorkers = new Set( + session.artifacts + .filter((a) => a.status === "accepted") + .map((a) => a.workerId), + ); + + return filtered.map((t) => { + let status: SkillCatalogEntry["status"] = "pending"; + + if (t.id === "intake") { + if (session.slots.startupCompleted) status = "done"; + else if ( + session.waitingReason?.kind === "intake" || + session.waitingReason?.kind === "input" + ) { + status = "active"; + } + } else if (t.id === "declare-ready") { + if (session.slots.startupCompleted) status = "done"; + } else if (t.id === "agent-burst") { + if (session.phase === "running" && !session.currentWorkerId) { + status = "active"; + } + } else if (workerIds.has(t.id)) { + status = acceptedWorkers.has(t.id) ? "done" : "active"; + } else if (session.currentWorkerId === t.id) { + status = "active"; + } + + return { ...t, status }; + }); +} + +export const TOOL_LOOP_BURST_MAX = 12; diff --git a/src/server/stats-handlers.ts b/src/server/stats-handlers.ts new file mode 100644 index 0000000..2a85d7a --- /dev/null +++ b/src/server/stats-handlers.ts @@ -0,0 +1,37 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + readRecords, + summarizeTokenUsage, + type TokenStatsQuery, +} from "../stats/token-store.js"; + +function json(res: ServerResponse, status: number, data: unknown): void { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(data)); +} + +export async function handleStatsApi( + _req: IncomingMessage, + res: ServerResponse, + pathname: string, + searchParams: URLSearchParams, +): Promise { + if (pathname !== "/api/stats/tokens") return false; + + const query: TokenStatsQuery = { + bookId: searchParams.get("bookId") ?? undefined, + orchestratorId: searchParams.get("orchestratorId") ?? undefined, + sessionId: searchParams.get("sessionId") ?? undefined, + from: searchParams.get("from") ?? undefined, + to: searchParams.get("to") ?? undefined, + limit: searchParams.get("limit") + ? Number(searchParams.get("limit")) + : undefined, + }; + + json(res, 200, { + summary: summarizeTokenUsage(query), + records: readRecords({ ...query, limit: query.limit ?? 100 }), + }); + return true; +} diff --git a/src/server/web-server.ts b/src/server/web-server.ts new file mode 100644 index 0000000..a00ca0a --- /dev/null +++ b/src/server/web-server.ts @@ -0,0 +1,203 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadDotEnv } from "../config/env.js"; +import { sessionManager } from "./session-manager.js"; +import { handleSettingsApi } from "./settings-handlers.js"; +import { handleBooksApi } from "./book-handlers.js"; +import { handleStatsApi } from "./stats-handlers.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(__dirname, "../.."); +loadDotEnv(PROJECT_ROOT); +const WEB_ROOT = path.resolve(__dirname, "../../web"); +const PORT = Number(process.env.PORT ?? 23337); + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function json(res: ServerResponse, status: number, data: unknown): void { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(data)); +} + +async function serveStatic(res: ServerResponse, filePath: string): Promise { + const ext = path.extname(filePath); + const types: Record = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + }; + const content = await readFile(filePath); + res.writeHead(200, { "Content-Type": types[ext] ?? "application/octet-stream" }); + res.end(content); +} + +const server = createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + const url = new URL(req.url ?? "/", `http://${req.headers.host}`); + + try { + if (req.method === "GET" && url.pathname === "/api/health") { + json(res, 200, { + ok: true, + version: "0.1.0", + routes: [ + "GET /api/health", + "GET /api/settings", + "GET /api/profiles", + "POST /api/profiles", + "GET /api/presets", + "GET /api/books", + "GET /api/skills", + "GET /api/stats/tokens", + ], + }); + return; + } + + if (await handleSettingsApi(req, res, url.pathname)) { + return; + } + + if (await handleBooksApi(req, res, url.pathname)) { + return; + } + + if (await handleStatsApi(req, res, url.pathname, url.searchParams)) { + return; + } + + if (req.method === "POST" && url.pathname === "/api/sessions") { + const body = await readBody(req).catch(() => ""); + let bookId: string | undefined; + let orchestratorId: string | undefined; + if (body) { + try { + const parsed = JSON.parse(body) as { + bookId?: string; + orchestratorId?: string; + }; + bookId = parsed.bookId; + orchestratorId = parsed.orchestratorId; + } catch { + /* empty body ok */ + } + } + const view = await sessionManager.createForBook(bookId, orchestratorId); + json(res, 201, view); + return; + } + + const sessionMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)(\/.*)?$/); + if (sessionMatch) { + const sessionId = decodeURIComponent(sessionMatch[1]); + const sub = sessionMatch[2] ?? ""; + + if (req.method === "GET" && sub === "") { + const view = sessionManager.get(sessionId); + if (!view) { + json(res, 404, { error: "会话不存在" }); + return; + } + json(res, 200, view); + return; + } + + if (req.method === "POST" && sub === "/messages") { + const body = JSON.parse(await readBody(req)) as { text?: string }; + if (!body.text?.trim()) { + json(res, 400, { error: "text 不能为空" }); + return; + } + const view = await sessionManager.sendMessage(sessionId, body.text.trim()); + json(res, 200, view); + return; + } + + if (req.method === "POST" && sub === "/lifecycle") { + const body = JSON.parse(await readBody(req)) as { stage?: string }; + if (body.stage !== "design" && body.stage !== "play") { + json(res, 400, { error: "stage 须为 design 或 play" }); + return; + } + try { + const view = sessionManager.setLifecycleStage(sessionId, body.stage); + json(res, 200, view); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "无法切换模式", + }); + } + return; + } + + if (req.method === "POST" && sub === "/actions") { + const body = JSON.parse(await readBody(req)) as { action?: string }; + let view; + switch (body.action) { + case "approve": + view = await sessionManager.approve(sessionId); + break; + case "confirm_intake": + view = await sessionManager.confirmIntake(sessionId); + break; + case "accept": + view = await sessionManager.accept(sessionId); + break; + case "reject": + view = await sessionManager.reject(sessionId); + break; + case "run_outline": + view = await sessionManager.runOutline(sessionId); + break; + case "finish": + view = await sessionManager.finish(sessionId); + break; + default: + json(res, 400, { error: "未知 action" }); + return; + } + json(res, 200, view); + return; + } + } + + let file = url.pathname === "/" ? "/index.html" : url.pathname; + const safe = path.normalize(file).replace(/^(\.\.[/\\])+/, ""); + const full = path.join(WEB_ROOT, safe); + if (!full.startsWith(WEB_ROOT)) { + json(res, 403, { error: "Forbidden" }); + return; + } + try { + await serveStatic(res, full); + } catch { + json(res, 404, { error: "Not found" }); + } + } catch (err) { + console.error("[api]", req.method, url.pathname, err); + json(res, 500, { + error: err instanceof Error ? err.message : "服务器错误", + }); + } +}); + +server.listen(PORT, () => { + console.log(`Writing Agent 对话页: http://localhost:${PORT}`); +}); diff --git a/src/skills/loader.ts b/src/skills/loader.ts new file mode 100644 index 0000000..7f0b34e --- /dev/null +++ b/src/skills/loader.ts @@ -0,0 +1,502 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; +import type { BookKind } from "../types/runtime.js"; +import type { + ParsedSkill, + ParsedWorkerSkill, + SkillIndexEntry, + SkillWorkerLlmBindings, + StartupInquiry, +} from "./types.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SKILLS_ROOT = path.resolve(__dirname, "../../skills"); + +export const ORCHESTRATOR_FILENAME = "orchestrator.md"; +export const WORKER_SKILL_FILENAME = "SKILL.md"; +export const DEFAULT_SHARED_CONTEXT_FILENAME = "shared-context.md"; +export const LLM_BINDINGS_FILENAME = "llm-bindings.yaml"; + +/** 按 Book 形态分文件夹;skill 可为平铺 .md 或 {name}/orchestrator.md 包 */ +export const SKILL_BOOK_KIND_FOLDERS: BookKind[] = ["novel", "dialogue"]; + +type RegistryDoc = { + skills?: Array< + SkillIndexEntry & { path?: string; bookKind?: BookKind } + >; +}; + +/** 解析 YAML frontmatter(仅支持本项目用到的简单字段) */ +function parseFrontmatter(raw: string): { + meta: Record; + body: string; +} { + if (!raw.startsWith("---")) { + return { meta: {}, body: raw }; + } + const end = raw.indexOf("\n---", 3); + if (end === -1) { + return { meta: {}, body: raw }; + } + const yaml = raw.slice(3, end).trim(); + const body = raw.slice(end + 4).trim(); + const meta: Record = {}; + + let currentKey = ""; + let listItems: string[] = []; + let inList = false; + + const flushList = () => { + if (inList && currentKey) { + meta[currentKey] = listItems; + listItems = []; + inList = false; + } + }; + + for (const line of yaml.split("\n")) { + const listMatch = line.match(/^\s+-\s+(.+)$/); + if (listMatch && inList) { + listItems.push(listMatch[1].trim()); + continue; + } + flushList(); + const kv = line.match(/^([\w-]+):\s*(.*)$/); + if (!kv) continue; + const [, key, value] = kv; + currentKey = key; + if (value === "" || value === ">-" || value === "|") { + inList = true; + listItems = []; + } else if (value === ">-" || value.startsWith(">")) { + meta[key] = value; + } else { + meta[key] = value.trim(); + inList = false; + } + } + flushList(); + return { meta, body }; +} + +function extractSection(body: string, heading: string): string { + const re = new RegExp(`^## ${heading}\\s*$`, "m"); + const match = re.exec(body); + if (!match) return ""; + const start = match.index + match[0].length; + const rest = body.slice(start); + const next = rest.search(/^## /m); + return (next === -1 ? rest : rest.slice(0, next)).trim(); +} + +function parseStartupInquiry(section: string): StartupInquiry { + const promptBlock = section.match(/```text\n([\s\S]*?)```/); + const prompt = promptBlock?.[1]?.trim() ?? section.slice(0, 500); + + const targetMatch = section.match(/\*\*写入目标:\*\*\s*`([^`]+)`/); + const targetKey = targetMatch?.[1]?.trim() ?? "book.brief"; + + const required: string[] = []; + const reqSection = section.match(/\*\*必须收集[::]*\*\*([\s\S]*?)(?=\n\*\*|$)/); + if (reqSection) { + for (const line of reqSection[1].split("\n")) { + const item = line.match(/^-\s+(.+)/); + if (item) required.push(item[1].trim()); + } + } + + const optional: string[] = []; + const optSection = section.match(/\*\*可选收集[::]*\*\*([\s\S]*?)(?=\n\*\*|$)/); + if (optSection) { + for (const line of optSection[1].split("\n")) { + const item = line.match(/^-\s+(.+)/); + if (item) optional.push(item[1].trim()); + } + } + + return { prompt, targetKey, requiredFields: required, optionalFields: optional }; +} + +function metaString(meta: Record, key: string): string { + const v = meta[key]; + return typeof v === "string" ? v : ""; +} + +function metaStringArray(meta: Record, key: string): string[] { + const v = meta[key]; + return Array.isArray(v) ? v : []; +} + +function parseBookKind(value: string): BookKind | undefined { + if (value === "novel" || value === "dialogue") return value; + return undefined; +} + +function bookKindFromRelativePath(relativePath: string): BookKind | undefined { + const folder = relativePath.split("/")[0]; + return parseBookKind(folder); +} + +function skillPackRootFromPath(relativePath: string): string | undefined { + const normalized = relativePath.replace(/\\/g, "/"); + if (path.basename(normalized) === ORCHESTRATOR_FILENAME) { + return path.posix.dirname(normalized); + } + return undefined; +} + +function workerIdsFromMeta(meta: Record): string[] { + const workers = metaStringArray(meta, "workers"); + if (workers.length > 0) return workers; + return metaStringArray(meta, "suggestedWorkers"); +} + +async function readRegistry(skillsRoot = SKILLS_ROOT): Promise { + const registryPath = path.join(skillsRoot, "registry.yaml"); + try { + const raw = await readFile(registryPath, "utf8"); + const doc = parseYaml(raw) as RegistryDoc; + return doc.skills ?? []; + } catch { + return []; + } +} + +async function isDirectory(fullPath: string): Promise { + try { + const s = await stat(fullPath); + return s.isDirectory(); + } catch { + return false; + } +} + +/** 扫描 skills/novel/*.md 与 skill 包 novel/{name}/orchestrator.md */ +async function scanSkillFiles(skillsRoot = SKILLS_ROOT): Promise { + const files: string[] = []; + for (const folder of SKILL_BOOK_KIND_FOLDERS) { + const dir = path.join(skillsRoot, folder); + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(dir, entry); + if (entry.endsWith(".md")) { + files.push(`${folder}/${entry}`); + continue; + } + if (await isDirectory(full)) { + const orchestrator = path.join(full, ORCHESTRATOR_FILENAME); + try { + await readFile(orchestrator, "utf8"); + files.push(`${folder}/${entry}/${ORCHESTRATOR_FILENAME}`); + } catch { + // not a skill pack + } + } + } + } + return files.sort(); +} + +async function loadWorkerLlmBindings( + packRoot: string | undefined, + skillsRoot = SKILLS_ROOT, +): Promise { + if (!packRoot) return undefined; + const bindingsPath = path.join(skillsRoot, packRoot, LLM_BINDINGS_FILENAME); + try { + const raw = await readFile(bindingsPath, "utf8"); + const doc = parseYaml(raw) as SkillWorkerLlmBindings; + if (!doc || typeof doc !== "object") return undefined; + return doc; + } catch { + return undefined; + } +} + +async function parseSkillFile( + relativePath: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const fullPath = path.join(skillsRoot, relativePath); + const raw = await readFile(fullPath, "utf8"); + const { meta, body } = parseFrontmatter(raw); + const startupSection = extractSection(body, "启动询问"); + const folderBookKind = bookKindFromRelativePath(relativePath); + const category = metaString(meta, "category") || folderBookKind || "custom"; + const bookKind = + parseBookKind(metaString(meta, "bookKind")) ?? + folderBookKind ?? + parseBookKind(category); + + const normalizedPath = relativePath.replace(/\\/g, "/"); + const fileStem = path.basename(normalizedPath, ".md"); + const packRoot = skillPackRootFromPath(normalizedPath); + const defaultName = packRoot ? path.basename(packRoot) : fileStem; + const workerLlmBindings = await loadWorkerLlmBindings(packRoot, skillsRoot); + + return { + name: metaString(meta, "name") || defaultName, + description: metaString(meta, "description"), + category, + bookKind, + path: normalizedPath, + skillPackRoot: packRoot, + version: typeof meta.version === "number" ? meta.version : Number(meta.version) || 1, + defaultFlowId: metaString(meta, "defaultFlowId") || undefined, + suggestedWorkers: workerIdsFromMeta(meta), + tags: metaStringArray(meta, "tags"), + sharedContextPath: resolveSharedContextPath(meta, packRoot), + workerLlmBindings, + startupInquiry: parseStartupInquiry(startupSection), + body, + }; +} + +function resolveSharedContextPath( + meta: Record, + packRoot: string | undefined, +): string | undefined { + if (!packRoot) return undefined; + const explicit = metaString(meta, "sharedContext"); + return explicit || DEFAULT_SHARED_CONTEXT_FILENAME; +} + +async function parseWorkerSkillFile( + relativePath: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const fullPath = path.join(skillsRoot, relativePath); + const raw = await readFile(fullPath, "utf8"); + const { meta, body } = parseFrontmatter(raw); + const normalizedPath = relativePath.replace(/\\/g, "/"); + const idFromPath = path.posix.basename(path.posix.dirname(normalizedPath)); + const inputTags = + metaStringArray(meta, "inputTags").length > 0 + ? metaStringArray(meta, "inputTags") + : metaStringArray(meta, "inputKeys"); + const outputTags = + metaStringArray(meta, "outputTags").length > 0 + ? metaStringArray(meta, "outputTags") + : metaStringArray(meta, "outputKeys"); + const inputMergeRaw = metaString(meta, "inputMerge"); + const inputMerge = + inputMergeRaw === "concat" || inputMergeRaw === "latest" + ? inputMergeRaw + : undefined; + const llmProfileId = metaString(meta, "llmProfileId") || undefined; + + return { + id: metaString(meta, "id") || idFromPath, + skill: metaString(meta, "skill"), + name: metaString(meta, "name") || idFromPath, + description: metaString(meta, "description"), + version: typeof meta.version === "number" ? meta.version : Number(meta.version) || 1, + inputTags, + outputTags, + inputMerge, + llmProfileId, + path: normalizedPath, + body, + }; +} + +/** 列举 skill:优先 registry.yaml,否则扫描 novel/ 与 dialogue/ */ +export async function listSkills(skillsRoot = SKILLS_ROOT): Promise { + const registry = (await readRegistry(skillsRoot)) ?? []; + if (registry.length > 0) { + return registry.map((entry) => ({ + name: entry.name, + description: entry.description, + category: entry.category, + bookKind: entry.bookKind ?? parseBookKind(entry.category), + path: entry.path, + })); + } + + const entries: SkillIndexEntry[] = []; + for (const relativePath of await scanSkillFiles(skillsRoot)) { + try { + const parsed = await parseSkillFile(relativePath, skillsRoot); + entries.push({ + name: parsed.name, + description: parsed.description, + category: parsed.category, + bookKind: parsed.bookKind, + path: parsed.path, + }); + } catch { + // skip unreadable + } + } + return entries; +} + +/** 按 skill name 解析相对路径 */ +export async function resolveSkillPath( + skillIdOrName: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const trimmed = skillIdOrName.trim(); + const registry = (await readRegistry(skillsRoot)) ?? []; + + const fromRegistry = registry.find((s) => s.name === trimmed); + if (fromRegistry?.path) { + return fromRegistry.path.replace(/\\/g, "/"); + } + + for (const folder of SKILL_BOOK_KIND_FOLDERS) { + const packCandidate = `${folder}/${trimmed}/${ORCHESTRATOR_FILENAME}`; + try { + await readFile(path.join(skillsRoot, packCandidate), "utf8"); + return packCandidate; + } catch { + // continue + } + const flatCandidate = `${folder}/${trimmed}.md`; + try { + await readFile(path.join(skillsRoot, flatCandidate), "utf8"); + return flatCandidate; + } catch { + // continue + } + } + + for (const relativePath of await scanSkillFiles(skillsRoot)) { + try { + const parsed = await parseSkillFile(relativePath, skillsRoot); + if (parsed.name === trimmed) { + return relativePath; + } + } catch { + continue; + } + } + + return null; +} + +/** 加载并解析总管 skill(orchestrator.md 或平铺 .md) */ +export async function loadSkill( + skillIdOrName: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const relativePath = await resolveSkillPath(skillIdOrName, skillsRoot); + if (!relativePath) { + throw new Error(`未找到 skill: ${skillIdOrName}`); + } + return parseSkillFile(relativePath, skillsRoot); +} + +/** 解析 skill 包内 worker 的 SKILL.md 相对路径 */ +export async function resolveWorkerSkillPath( + skillIdOrName: string, + workerId: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const skill = await loadSkill(skillIdOrName, skillsRoot); + if (!skill.skillPackRoot) { + return null; + } + const relativePath = `${skill.skillPackRoot}/workers/${workerId}/${WORKER_SKILL_FILENAME}`; + try { + await readFile(path.join(skillsRoot, relativePath), "utf8"); + return relativePath; + } catch { + return null; + } +} + +/** 加载 skill 包固定上下文(注入所有 worker prompt 开头) */ +export async function loadSkillSharedContext( + skillIdOrName: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const skill = await loadSkill(skillIdOrName, skillsRoot); + if (!skill.skillPackRoot || !skill.sharedContextPath) return null; + const fullPath = path.join(skillsRoot, skill.skillPackRoot, skill.sharedContextPath); + try { + return await readFile(fullPath, "utf8"); + } catch { + return null; + } +} + +/** 加载 worker skill 正文,可选拼接 skill 包固定上下文 */ +export async function loadWorkerSkillWithContext( + skillIdOrName: string, + workerId: string, + skillsRoot = SKILLS_ROOT, +): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> { + const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot); + const sharedContext = await loadSkillSharedContext(skillIdOrName, skillsRoot); + const promptBody = sharedContext + ? `# 固定创作上下文\n\n${sharedContext}\n\n---\n\n${worker.body}` + : worker.body; + return { worker, sharedContext, promptBody }; +} + +/** 加载 skill 包内专属 worker skill */ +export async function loadWorkerSkill( + skillIdOrName: string, + workerId: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const relativePath = await resolveWorkerSkillPath(skillIdOrName, workerId, skillsRoot); + if (!relativePath) { + throw new Error(`未找到 worker skill: ${skillIdOrName}/${workerId}`); + } + return parseWorkerSkillFile(relativePath, skillsRoot); +} + +/** 列举 skill 包内所有 worker skill */ +export async function listWorkerSkills( + skillIdOrName: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const skill = await loadSkill(skillIdOrName, skillsRoot); + if (!skill.skillPackRoot) { + return []; + } + const workersDir = path.join(skillsRoot, skill.skillPackRoot, "workers"); + let entries: string[]; + try { + entries = await readdir(workersDir); + } catch { + return []; + } + const workers: ParsedWorkerSkill[] = []; + for (const entry of entries.sort()) { + const skillPath = `${skill.skillPackRoot}/workers/${entry}/${WORKER_SKILL_FILENAME}`; + try { + workers.push(await parseWorkerSkillFile(skillPath, skillsRoot)); + } catch { + // skip + } + } + return workers; +} + +/** 按 name 或文件名(不含 .md)匹配 skill,返回 loadSkill 可用的 id */ +export async function resolveSkillId( + input: string, + skillsRoot = SKILLS_ROOT, +): Promise { + const trimmed = input.trim(); + const relativePath = await resolveSkillPath(trimmed, skillsRoot); + if (!relativePath) return null; + try { + const parsed = await parseSkillFile(relativePath, skillsRoot); + return parsed.name; + } catch { + return null; + } +} + +export { SKILLS_ROOT }; diff --git a/src/skills/snapshot.ts b/src/skills/snapshot.ts new file mode 100644 index 0000000..4293e6b --- /dev/null +++ b/src/skills/snapshot.ts @@ -0,0 +1,19 @@ +import type { ActiveSkillSnapshot } from "../types/runtime.js"; +import type { IntakeFieldDef } from "../types/intake.js"; +import type { ParsedSkill } from "./types.js"; +import { intakeFieldsFromInquiry } from "../intake/intake.js"; + +export function toActiveSkillSnapshot(skill: ParsedSkill): ActiveSkillSnapshot { + const intakeFields: IntakeFieldDef[] = intakeFieldsFromInquiry(skill.startupInquiry); + return { + name: skill.name, + description: skill.description, + category: skill.category, + bookKind: skill.bookKind, + defaultFlowId: skill.defaultFlowId, + suggestedWorkers: skill.suggestedWorkers, + startupPrompt: skill.startupInquiry.prompt, + startupTargetKey: skill.startupInquiry.targetKey, + intakeFields, + }; +} diff --git a/src/skills/types.ts b/src/skills/types.ts new file mode 100644 index 0000000..0ab7f52 --- /dev/null +++ b/src/skills/types.ts @@ -0,0 +1,76 @@ +import type { BlackboardInputMerge } from "../types/blackboard.js"; +import type { AdvancePolicy } from "../types/runtime.js"; + +export type WorkerLlmBinding = { + /** 固定 ApiProfile.id;省略 = 会话默认 */ + profileId?: string | null; + /** 按当前决策角色 id 选用 profile(多 AI 博弈) */ + byRole?: Record; +}; + +/** skill 包 llm-bindings.yaml 解析结果(可选) */ +export type SkillWorkerLlmBindings = { + defaultProfileId?: string | null; + workers?: Record; +}; + +/** skills/registry.yaml 或目录扫描得到的索引项 */ +export type SkillIndexEntry = { + name: string; + description: string; + /** 与 bookKind 一致,兼容旧字段名 */ + category: string; + bookKind?: "novel" | "dialogue"; + /** 相对 skills/ 的路径,如 novel/weird-rules-short/orchestrator.md 或 novel/basic.md */ + path?: string; +}; + +/** 来自 skill 文件 ## 启动询问 */ +export type StartupInquiry = { + prompt: string; + targetKey: string; + requiredFields: string[]; + optionalFields: string[]; +}; + +/** 解析后的总管 skill,供 session.slots.activeSkill 使用 */ +export type ParsedSkill = { + name: string; + description: string; + category: string; + bookKind?: "novel" | "dialogue"; + /** 相对 skills/ 的路径(orchestrator.md 或平铺 .md) */ + path: string; + /** skill 包根目录,如 novel/weird-rules-short;平铺 .md 时为 undefined */ + skillPackRoot?: string; + version: number; + defaultFlowId?: string; + /** 本包可调度 worker id(frontmatter workers 或 suggestedWorkers) */ + suggestedWorkers: string[]; + tags: string[]; + /** 包内固定上下文相对路径(可选);有则注入该包 worker prompt */ + sharedContextPath?: string; + /** llm-bindings.yaml(可选);见 docs/worker-skill-format.md §9 */ + workerLlmBindings?: SkillWorkerLlmBindings; + startupInquiry: StartupInquiry; + /** 推进策略(预留)。loader 第一版不解析 orchestrator ## 推进策略 */ + advancePolicy?: AdvancePolicy; + body: string; +}; + +/** 解析后的 worker skill(skills/{pack}/workers/{id}/SKILL.md) */ +export type ParsedWorkerSkill = { + id: string; + skill: string; + name: string; + description: string; + version: number; + inputTags: string[]; + outputTags: string[]; + inputMerge?: BlackboardInputMerge; + /** ApiProfile.id;省略 = 走 llm-bindings 或会话默认 */ + llmProfileId?: string; + /** 相对 skills/ 的路径 */ + path: string; + body: string; +}; diff --git a/src/skills/worker-llm.ts b/src/skills/worker-llm.ts new file mode 100644 index 0000000..a47b940 --- /dev/null +++ b/src/skills/worker-llm.ts @@ -0,0 +1,146 @@ +import { getApiProfile, profileToLlmConfig } from "../config/api-profiles.js"; +import { + OpenAiCompatibleProvider, + type LlmProvider, +} from "../llm/client.js"; +import { PresetLlmProvider } from "../llm/preset-wrapper.js"; +import { resolveActivePreset } from "../preset/store.js"; +import { loadAppSettings } from "../config/settings.js"; +import { + TokenTrackingProvider, + type LlmTrackingContext, +} from "../llm/token-tracker.js"; +import type { ParsedWorkerSkill, SkillWorkerLlmBindings } from "./types.js"; + +type LlmTrackingRef = { current: LlmTrackingContext }; + +/** 按 ApiProfile.id 构建 LLM;找不到 profile 时回退 fallback */ +export function createLlmForProfileId( + profileId: string, + fallback: LlmProvider, + trackingRef?: LlmTrackingRef, +): LlmProvider { + const profile = getApiProfile(profileId); + if (!profile?.apiKey?.trim()) { + return fallback; + } + + let inner: LlmProvider = new OpenAiCompatibleProvider( + profileToLlmConfig(profile), + ); + const preset = resolveActivePreset(loadAppSettings().activePresetId); + if (preset) { + inner = new PresetLlmProvider(inner, () => + resolveActivePreset(loadAppSettings().activePresetId), + ); + } + if (!trackingRef) return inner; + return new TokenTrackingProvider(inner, () => trackingRef.current); +} + +/** + * 解析 worker 应使用的 LLM。 + * + * 优先级: + * 1. worker SKILL frontmatter `llmProfileId` + * 2. llm-bindings.yaml `workers[id].byRole[roleId]`(roleId 来自 slots.世界.当前角色.id) + * 3. llm-bindings.yaml `workers[id].profileId` + * 4. fallback(会话默认 profile,与总管相同) + */ +export function resolveWorkerLlmProvider(options: { + worker: ParsedWorkerSkill; + bindings?: SkillWorkerLlmBindings; + slots: Record; + fallbackLlm: LlmProvider; + trackingRef?: LlmTrackingRef; +}): LlmProvider { + const { worker, bindings, slots, fallbackLlm, trackingRef } = options; + + if (worker.llmProfileId?.trim()) { + return createLlmForProfileId( + worker.llmProfileId.trim(), + fallbackLlm, + trackingRef, + ); + } + + const workerBinding = bindings?.workers?.[worker.id]; + if (workerBinding?.byRole) { + const roleId = slotString(slots, "世界.当前角色.id"); + if (roleId && workerBinding.byRole[roleId]?.trim()) { + return createLlmForProfileId( + workerBinding.byRole[roleId].trim(), + fallbackLlm, + trackingRef, + ); + } + } + + if (workerBinding?.profileId?.trim()) { + return createLlmForProfileId( + workerBinding.profileId.trim(), + fallbackLlm, + trackingRef, + ); + } + + if (bindings?.defaultProfileId?.trim()) { + return createLlmForProfileId( + bindings.defaultProfileId.trim(), + fallbackLlm, + trackingRef, + ); + } + + return fallbackLlm; +} + +function slotString(slots: Record, key: string): string | undefined { + const val = slots[key]; + return typeof val === "string" && val.trim() ? val.trim() : undefined; +} + +/** 角色 tag:仅用户可见 */ +export const ROLE_USER_ONLY_SUFFIXES = [".思考", ".推理.候选"] as const; + +/** 角色 tag:对其余角色 agent 可见(经 world-engine 公开) */ +export const ROLE_AGENT_VISIBLE_SUFFIXES = [".行动", ".行动.候选"] as const; + +export function isRoleUserOnlyTag(tag: string): boolean { + return ROLE_USER_ONLY_SUFFIXES.some((s) => tag.endsWith(s)); +} + +export function isRoleAgentVisibleTag(tag: string): boolean { + return ROLE_AGENT_VISIBLE_SUFFIXES.some((s) => tag.endsWith(s)); +} + +/** + * 供 executor:角色 worker 只能看到当前角色的私有 tag。 + * 其他角色的思考永不可见;对方行动仅经 world-engine 分发后的可见信息/公开叙述获知。 + */ +export function filterInputsForRolePerspective( + inputs: Record, + slots: Record, +): Record { + const roleId = slotString(slots, "世界.当前角色.id"); + if (!roleId) return inputs; + + const filtered: Record = {}; + for (const [tag, content] of Object.entries(inputs)) { + const rolePrefix = `角色.${roleId}.`; + const otherRoleMatch = tag.match(/^角色\.([^.]+)\./); + if (otherRoleMatch && otherRoleMatch[1] !== roleId) { + continue; + } + if (tag.startsWith("角色.") && !tag.startsWith(rolePrefix)) { + continue; + } + if (tag.startsWith(rolePrefix) && isRoleUserOnlyTag(tag)) { + continue; + } + filtered[tag] = content; + } + return filtered; +} + +export type { LlmTrackingContext }; diff --git a/src/stats/token-store.ts b/src/stats/token-store.ts new file mode 100644 index 0000000..61d42e4 --- /dev/null +++ b/src/stats/token-store.ts @@ -0,0 +1,247 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; + +export type TokenUsageRecord = { + id: string; + at: string; + bookId?: string; + bookTitle?: string; + orchestratorId?: string; + sessionId?: string; + caller: string; + model: string; + promptTokens: number; + completionTokens: number; + totalTokens: number; + cachedTokens?: number; + cacheMissTokens?: number; + messageId?: string; +}; + +/** Token usage attached to a chat message in SessionView */ +export type MessageTokenUsage = { + totalTokens: number; + promptTokens: number; + completionTokens: number; + cachedTokens?: number; + cacheMissTokens?: number; + caller: string; + model?: string; + recordId?: string; +}; + +export type CallerTokenBreakdown = { + totalTokens: number; + cachedTokens: number; + cacheMissTokens: number; + calls: number; +}; + +export type TokenStatsQuery = { + bookId?: string; + orchestratorId?: string; + sessionId?: string; + from?: string; + to?: string; + limit?: number; +}; + +export type TokenStatsSummary = { + totalCalls: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + totalCached: number; + totalCacheMiss: number; + byCaller: Record; + byCallerDetailed: Record; + byBook: Record; + byOrchestrator: Record; +}; + +function statsDir(): string { + return path.join(getUserDataDir(), "stats"); +} + +function dayFile(date = new Date()): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return path.join(statsDir(), `${y}-${m}-${d}.jsonl`); +} + +function ensureStatsDir(): void { + ensureUserDataDirs(); + mkdirSync(statsDir(), { recursive: true }); +} + +export function recordTokenUsage( + partial: Omit & { at?: string }, +): TokenUsageRecord { + ensureStatsDir(); + const record: TokenUsageRecord = { + id: randomUUID(), + at: partial.at ?? new Date().toISOString(), + bookId: partial.bookId, + bookTitle: partial.bookTitle, + orchestratorId: partial.orchestratorId, + sessionId: partial.sessionId, + caller: partial.caller, + model: partial.model, + promptTokens: partial.promptTokens, + completionTokens: partial.completionTokens, + totalTokens: partial.totalTokens, + cachedTokens: partial.cachedTokens, + cacheMissTokens: partial.cacheMissTokens, + messageId: partial.messageId, + }; + appendFileSync(dayFile(new Date(record.at)), `${JSON.stringify(record)}\n`, "utf8"); + return record; +} + +function listStatFiles(): string[] { + ensureStatsDir(); + const dir = statsDir(); + try { + return readdirSync(dir) + .filter((f) => f.endsWith(".jsonl")) + .sort() + .map((f) => path.join(dir, f)); + } catch { + return []; + } +} + +export function readRecords(query: TokenStatsQuery = {}): TokenUsageRecord[] { + const files = listStatFiles(); + const records: TokenUsageRecord[] = []; + const fromMs = query.from ? Date.parse(query.from) : NaN; + const toMs = query.to ? Date.parse(query.to) : NaN; + const limit = query.limit ?? 500; + + for (let i = files.length - 1; i >= 0 && records.length < limit; i--) { + const file = files[i]; + if (!existsSync(file)) continue; + const lines = readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean); + for (let j = lines.length - 1; j >= 0 && records.length < limit; j--) { + try { + const record = JSON.parse(lines[j]) as TokenUsageRecord; + if (query.bookId && record.bookId !== query.bookId) continue; + if (query.orchestratorId && record.orchestratorId !== query.orchestratorId) { + continue; + } + if (query.sessionId && record.sessionId !== query.sessionId) continue; + const atMs = Date.parse(record.at); + if (Number.isFinite(fromMs) && atMs < fromMs) continue; + if (Number.isFinite(toMs) && atMs > toMs) continue; + records.push(record); + } catch { + /* skip bad line */ + } + } + } + + return records.sort((a, b) => b.at.localeCompare(a.at)); +} + +function accumulateCaller( + map: Record, + caller: string, + record: Pick< + TokenUsageRecord, + "totalTokens" | "cachedTokens" | "cacheMissTokens" + >, +): void { + const prev = map[caller] ?? { + totalTokens: 0, + cachedTokens: 0, + cacheMissTokens: 0, + calls: 0, + }; + map[caller] = { + totalTokens: prev.totalTokens + record.totalTokens, + cachedTokens: prev.cachedTokens + (record.cachedTokens ?? 0), + cacheMissTokens: prev.cacheMissTokens + (record.cacheMissTokens ?? 0), + calls: prev.calls + 1, + }; +} + +export function summarizeTokenUsage(query: TokenStatsQuery = {}): TokenStatsSummary { + const records = readRecords({ ...query, limit: query.limit ?? 2000 }); + const summary: TokenStatsSummary = { + totalCalls: records.length, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + totalCached: 0, + totalCacheMiss: 0, + byCaller: {}, + byCallerDetailed: {}, + byBook: {}, + byOrchestrator: {}, + }; + + for (const r of records) { + summary.promptTokens += r.promptTokens; + summary.completionTokens += r.completionTokens; + summary.totalTokens += r.totalTokens; + summary.totalCached += r.cachedTokens ?? 0; + summary.totalCacheMiss += r.cacheMissTokens ?? 0; + summary.byCaller[r.caller] = (summary.byCaller[r.caller] ?? 0) + r.totalTokens; + accumulateCaller(summary.byCallerDetailed, r.caller, r); + if (r.bookId) { + const label = r.bookTitle ? `${r.bookTitle} (${r.bookId.slice(0, 8)})` : r.bookId; + summary.byBook[label] = (summary.byBook[label] ?? 0) + r.totalTokens; + } + if (r.orchestratorId) { + summary.byOrchestrator[r.orchestratorId] = + (summary.byOrchestrator[r.orchestratorId] ?? 0) + r.totalTokens; + } + } + + return summary; +} + +export function getSessionTokenTotals(sessionId: string): { + totalTokens: number; + totalCached: number; + totalCacheMiss: number; + byCaller: Record; + last?: TokenUsageRecord; + records: TokenUsageRecord[]; +} { + const records = readRecords({ sessionId, limit: 500 }); + let totalTokens = 0; + let totalCached = 0; + let totalCacheMiss = 0; + const byCaller: Record = {}; + for (const r of records) { + totalTokens += r.totalTokens; + totalCached += r.cachedTokens ?? 0; + totalCacheMiss += r.cacheMissTokens ?? 0; + accumulateCaller(byCaller, r.caller, r); + } + return { + totalTokens, + totalCached, + totalCacheMiss, + byCaller, + last: records[0], + records, + }; +} + +export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage { + return { + totalTokens: record.totalTokens, + promptTokens: record.promptTokens, + completionTokens: record.completionTokens, + cachedTokens: record.cachedTokens, + cacheMissTokens: record.cacheMissTokens, + caller: record.caller, + model: record.model, + recordId: record.id, + }; +} diff --git a/src/types/blackboard.ts b/src/types/blackboard.ts new file mode 100644 index 0000000..e9cc28b --- /dev/null +++ b/src/types/blackboard.ts @@ -0,0 +1,29 @@ +/** worker 读取多条同 tag 时的合并策略 */ +export type BlackboardInputMerge = "latest" | "concat"; + +export type BlackboardItem = { + id: string; + tag: string; + content: string; + source: string; + scope?: string; + createdAt: string; + updatedAt: string; + dependencies?: string[]; + metadata?: Record; +}; + +/** 总管可见:无 content */ +export type BlackboardTagIndex = Pick< + BlackboardItem, + "id" | "tag" | "source" | "scope" | "updatedAt" +>; + +export type BlackboardWriteInput = { + tag: string; + content: string; + source: string; + scope?: string; + dependencies?: string[]; + metadata?: Record; +}; diff --git a/src/types/book-session.ts b/src/types/book-session.ts new file mode 100644 index 0000000..ba397ad --- /dev/null +++ b/src/types/book-session.ts @@ -0,0 +1,34 @@ +import type { BlackboardItem } from "../types/blackboard.js"; +import type { RuntimeSession } from "../types/runtime.js"; + +/** 磁盘上保存的 Book 绑定的 Session 快照(用于续作) */ +export type PersistedBookSession = { + version: 1; + sessionId: string; + bookId: string; + orchestratorId?: string; + runtimeSession: RuntimeSession; + blackboardItems: BlackboardItem[]; + messages: PersistedChatMessage[]; + savedAt: string; +}; + +/** 与 session-manager ChatMessage 同形,独立类型避免循环依赖 */ +export type PersistedChatMessage = { + id: string; + role: "system" | "user"; + text: string; + createdAt: string; + kind?: string; + actor?: string; + title?: string; + body?: string; + thinking?: string; + tokenUsage?: { + totalTokens: number; + cachedTokens?: number; + cacheMissTokens?: number; + caller?: string; + model?: string; + }; +}; diff --git a/src/types/book.ts b/src/types/book.ts new file mode 100644 index 0000000..8b5f0dc --- /dev/null +++ b/src/types/book.ts @@ -0,0 +1,37 @@ +export type BookProject = { + id: string; + title: string; + /** 用户选定或 agent 确认的 skill 包 */ + activeSkillId?: string; + activeSkillName?: string; + preview: string; + sessionIds: string[]; + /** 最近一次持久化的 sessionId,用于续作 */ + activeSessionId?: string; + createdAt: string; + updatedAt: string; + /** @deprecated 旧字段,等同 activeSkillId */ + orchestratorId?: string; + /** @deprecated 旧字段,等同 activeSkillName */ + orchestratorName?: string; +}; + +export type BookSummary = Pick< + BookProject, + | "id" + | "title" + | "activeSkillId" + | "activeSkillName" + | "preview" + | "updatedAt" + | "orchestratorId" + | "orchestratorName" +>; + +export type SkillPackInfo = { + id: string; + name: string; + description: string; + category: string; + bookKind?: string; +}; diff --git a/src/types/intake.ts b/src/types/intake.ts new file mode 100644 index 0000000..62a246b --- /dev/null +++ b/src/types/intake.ts @@ -0,0 +1,26 @@ +/** 启动「填空题」字段定义(来自 SKILL ## 启动询问) */ +export type IntakeFieldDef = { + id: string; + label: string; + required: boolean; +}; + +/** 单字段展示状态 */ +export type IntakeFieldStatus = { + id: string; + label: string; + required: boolean; + value?: string; + filled: boolean; +}; + +/** 填空进度(供 Web 展示) */ +export type IntakeProgress = { + fields: IntakeFieldStatus[]; + requiredTotal: number; + requiredFilled: number; + optionalTotal: number; + optionalFilled: number; + /** 全部必要项已填 */ + ready: boolean; +}; diff --git a/src/types/preset.ts b/src/types/preset.ts new file mode 100644 index 0000000..65212f9 --- /dev/null +++ b/src/types/preset.ts @@ -0,0 +1,66 @@ +export type PresetPromptRole = "system" | "user" | "assistant"; + +export type PresetPromptEntry = { + id: string; + name: string; + enabled: boolean; + role: PresetPromptRole; + content: string; + marker: boolean; + sourceIdentifier: string; + injection?: { + position?: number; + depth?: number; + order?: number; + }; +}; + +export type PresetPromptOrderItem = { + promptId: string; + enabled: boolean; + orderIndex: number; +}; + +export type GenerationParameters = { + temperature?: number; + topP?: number; + topK?: number; + minP?: number; + frequencyPenalty?: number; + presencePenalty?: number; + repetitionPenalty?: number; + maxContextTokens?: number; + maxOutputTokens?: number; + stream?: boolean; + reasoningEffort?: string; + verbosity?: string; + seed?: number; + variants?: number; +}; + +export type UnsupportedPresetSection = { + path: string; + reason: string; +}; + +export type PresetPackage = { + id: string; + name: string; + source: "native" | "sillytavern"; + prompts: PresetPromptEntry[]; + promptOrder: PresetPromptOrderItem[]; + generation: GenerationParameters; + unsupported: UnsupportedPresetSection[]; + importedAt: string; + raw?: unknown; +}; + +export type PresetImportReport = { + preset: PresetPackage; + promptCount: number; + enabledCount: number; + unreferencedCount: number; + missingIdentifiers: string[]; + generationFields: string[]; + warnings: string[]; +}; diff --git a/src/types/run-snapshot.ts b/src/types/run-snapshot.ts new file mode 100644 index 0000000..6322f10 --- /dev/null +++ b/src/types/run-snapshot.ts @@ -0,0 +1,56 @@ +import type { BlackboardItem } from "./blackboard.js"; +import type { PersistedChatMessage } from "./book-session.js"; +import type { RuntimeSession } from "./runtime.js"; + +/** 实例化完成后的对象,或 run 中某一时刻的完整进度 */ +export type SnapshotKind = "instance" | "run"; + +/** + * Book 快照(手动保存,可多档) + * - instance:实例化后的对象(情境、规则、角色设定等),用于复用思想实验框架、换角色重跑 + * - run:运行存档,含轮次进度,读档后续玩 + */ +export type RunSnapshot = { + version: 1; + id: string; + bookId: string; + label: string; + kind: SnapshotKind; + /** skill 包 id(兼容旧字段名) */ + orchestratorId?: string; + runtimeSession: RuntimeSession; + blackboardItems: BlackboardItem[]; + /** 保存时的对话;实例快照通常较短(到 setup 验收为止) */ + messages: PersistedChatMessage[]; + createdAt: string; + note?: string; +}; + +/** 列表展示用,不含正文 payload */ +export type RunSnapshotMeta = { + id: string; + bookId: string; + label: string; + kind: SnapshotKind; + /** skill 包 id(兼容旧字段名) */ + orchestratorId?: string; + createdAt: string; + note?: string; +}; + +export function toRunSnapshotMeta(snapshot: RunSnapshot): RunSnapshotMeta { + return { + id: snapshot.id, + bookId: snapshot.bookId, + label: snapshot.label, + kind: snapshot.kind, + orchestratorId: snapshot.orchestratorId, + createdAt: snapshot.createdAt, + note: snapshot.note, + }; +} + +export const SNAPSHOT_KIND_LABELS: Record = { + instance: "实例", + run: "进度", +}; diff --git a/src/types/runtime.ts b/src/types/runtime.ts new file mode 100644 index 0000000..d36f739 --- /dev/null +++ b/src/types/runtime.ts @@ -0,0 +1,257 @@ +/** + * 运行阶段机类型契约。 + * + * 设计原则: + * - 运行相位(RuntimePhase)只有 5 种,表示「系统在等什么」 + * - 业务细节不扩 phase,而用 waitingReason、slots、artifacts 承载 + * - LLM / 总管不能直接改 phase,只能通过 RuntimeEvent 驱动 applyEvent + */ + +import type { IntakeFieldDef } from "./intake.js"; + +/** 运行相位:系统当前在等什么。只有 5 种。 */ +export type RuntimePhase = + | "idle" // 会话已创建,尚未开始 + | "running" // 正在推进(总管决策、worker 执行、程序验收) + | "waiting_user" // 等待用户介入 + | "done" // 流程正常结束 + | "error"; // 不可恢复错误 + +/** + * waiting_user 时的具体原因。 + * 用单一 phase + reason 替代多个独立 status,避免「胖状态机」。 + */ +export type WaitingReason = + | { kind: "skill_selection"; availableSkills: SkillIndexEntry[] } // 启动:选 SKILL.md + | { kind: "intake"; prompt: string } // 启动填空:必要/可选项收集 + | { kind: "input"; message?: string } // 总管 ask_user / 返工说明(启动完成后) + | { kind: "approve_step"; decisionId: string } // 总管建议 run_worker,等用户确认 + | { kind: "review_artifact"; artifactId: string } // worker 产物待验收 + | { kind: "worker_questions"; workerId: string; questions: string[] } // worker 中途提问 + | { kind: "revision"; instruction?: string }; // 产物被拒或程序验收失败 + +/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */ +export type SkillIndexEntry = { + name: string; + description: string; + category: string; +}; + +/** + * Book 存储形态(由 SKILL.md 选定,选 skill 后不可变更)。 + * 第一版仅两种:小说线性结构 / 多轮多角色对话。 + */ +export type BookKind = "novel" | "dialogue"; + +/** 选中的 skill 快照,写入 session.slots.activeSkill,供启动询问与后续流程使用 */ +export type ActiveSkillSnapshot = { + name: string; + description: string; + category: string; + /** 选定 skill 时确定,对应 Book 最终产物结构 */ + bookKind?: BookKind; + defaultFlowId?: string; + suggestedWorkers: string[]; + /** 来自 SKILL.md ## 启动询问 的展示文案 */ + startupPrompt: string; + /** 用户首次输入写入的 slots 键,如 book.brief */ + startupTargetKey: string; + /** 启动填空字段(必要 + 可选) */ + intakeFields: IntakeFieldDef[]; +}; + +/** worker 产物验收方式 */ +export type AcceptanceMode = + | "user_confirmed" // 默认:产物完成后等用户验收 + | "no_confirmation" // 自动接受,直接回到总管 + | "programmatic_review"; // 走程序验收规则 + +/** + * Worker 链推进模式(预留)。 + * + * - manual:默认。每次 run_worker 可要求 approve_step;产物默认 user_confirmed。 + * - semi_auto:半自动串联 worker;仅在 skill 声明的 pauseCheckpoint 处强制暂停。 + * + * 第一版 Runtime 仅实现 manual;semi_auto 由 orchestrator 文档化,待后续接入。 + * 见 docs/runtime-state-machine.md §10、docs/orchestrator-skill-format.md §10。 + */ +export type WorkerAdvanceMode = "manual" | "semi_auto"; + +/** + * semi_auto 下的暂停检查点(由各 skill 在 orchestrator 中声明)。 + * + * `when` 为 skill 自定义的暂停条件描述或未来可解析的表达式; + * 具体变量(轮次、token 预算、阶段界等)不由全局 enum 限定。 + */ +export type AdvancePauseCheckpoint = { + id: string; + description: string; + /** 文档化条件;未来可绑定 slot / tag / 计数器,现阶段 Runtime 不解析 */ + when?: string; +}; + +/** + * 会话级 worker 推进策略(预留)。 + * 未设置或未解析时等价 `{ mode: "manual" }`。 + */ +export type AdvancePolicy = { + mode: WorkerAdvanceMode; + pauseCheckpoints?: AdvancePauseCheckpoint[]; +}; + +/** 总管(Main Agent)可执行的意图,对应 tool call 的 action 字段 */ +export type MainAgentAction = + | "ask_user" // 向用户提问 + | "run_worker" // 调度已有 worker + | "create_temp_worker" // 临时 worker(与 run_worker 共用阶段机分支) + | "review_blackboard" // 查看黑板后向用户说明 + | "finish"; // 结束流程 + +/** + * 总管的一次决策。 + * statePatchAllowed 必须为 false,禁止 LLM 直接 patch 会话状态。 + */ +export type MainAgentDecision = { + id: string; + action: MainAgentAction; + reason: string; + workerId?: string; + /** 调度 role-decide 等时指定当前决策角色,Runtime 写入 世界.当前角色.id */ + workerContext?: { roleId?: string }; + /** true 时进入 waiting_user(approve_step),等用户确认后才 run_worker */ + requiresApproval: boolean; + statePatchAllowed: false; +}; + +/** worker 产物的生命周期状态 */ +export type ArtifactStatus = + | "drafted" + | "under_review" + | "accepted" + | "rejected" + | "revision_requested" + | "superseded"; + +/** worker 产出的一条产物记录 */ +export type ArtifactRecord = { + id: string; + workerId: string; + stepId?: string; + outputTags: string[]; + status: ArtifactStatus; + summary?: string; + createdAt: string; + updatedAt: string; +}; + +/** worker 因 ask_user 中途暂停时保存的上下文,用户回复后用于 resume_worker */ +export type ResumeContext = { + workerId: string; + stepId?: string; + acceptanceMode: AcceptanceMode; + questions: string[]; +}; + +/** + * 阶段机唯一合法的状态变更入口。 + * 所有 phase 转移都必须通过 dispatch → applyEvent 处理某种 RuntimeEvent。 + */ +export type RuntimeEvent = + | { + type: "session_started"; + payload: { presetId: string; flowId?: string; availableSkills: SkillIndexEntry[] }; + } + | { type: "skill_selected"; payload: { skill: ActiveSkillSnapshot } } + | { + type: "user_submitted_input"; + payload: { text: string; intakeValues?: Record }; + } + | { type: "user_confirmed_intake"; payload: Record } + | { + type: "main_agent_decision_created"; + payload: { decision: MainAgentDecision }; + } + | { type: "user_approved_next_step"; payload: { decisionId: string } } + | { + type: "user_rejected_next_step"; + payload: { decisionId: string; reason?: string }; + } + | { + type: "worker_started"; + payload: { + workerId: string; + stepId?: string; + acceptanceMode: AcceptanceMode; + }; + } + | { type: "worker_completed"; payload: { artifactId: string } } + | { + type: "worker_needs_input"; + payload: { workerId: string; stepId?: string; questions: string[] }; + } + | { type: "user_accepted_artifact"; payload: { artifactId: string } } + | { + type: "user_rejected_artifact"; + payload: { artifactId: string; reason?: string }; + } + | { + type: "user_requested_revision"; + payload: { artifactId?: string; instruction: string }; + } + | { type: "programmatic_review_started"; payload: { artifactId: string } } + | { type: "programmatic_review_passed"; payload: { artifactId: string } } + | { + type: "programmatic_review_failed"; + payload: { artifactId: string; reason: string }; + } + | { type: "flow_completed"; payload: Record } + | { type: "runtime_failed"; payload: { reason: string } }; + +/** 一次会话的完整运行时快照 */ +export type RuntimeSession = { + id: string; + phase: RuntimePhase; + waitingReason?: WaitingReason; + presetId: string; + flowId?: string; + /** 业务 flow 当前步骤(execution flow 层,与 phase 正交) */ + currentStepId?: string; + currentWorkerId?: string; + acceptanceMode?: AcceptanceMode; + /** 推进策略(预留)。缺省 = manual,见 WorkerAdvanceMode */ + advancePolicy?: AdvancePolicy; + reviewRequirements?: string[]; + resumeContext?: ResumeContext; + /** 轻量槽位:activeSkill、book.brief、startupCompleted 等 */ + slots: Record; + artifacts: ArtifactRecord[]; + /** 待用户确认的总管决策(approve_step 时有效) */ + pendingDecision?: MainAgentDecision; + /** 待验收的产物 id(review_artifact 时有效) */ + pendingArtifactId?: string; + /** 已应用的 RuntimeEvent 日志,便于调试与回放 */ + history: RuntimeEvent[]; + createdAt: string; + updatedAt: string; +}; + +/** + * applyEvent 返回的副作用清单。 + * 纯函数 phase-machine 不执行 IO,由 phase-runtime 的 processEffects 消费。 + */ +export type PhaseEffect = + | { type: "invoke_main_agent" } + | { type: "run_worker"; workerId: string; workerContext?: { roleId?: string } } + | { type: "resume_worker" } + | { type: "run_programmatic_review"; artifactId: string } + | { type: "emit_message"; message: string }; + +/** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */ +export type ApplyEventResult = { + session: RuntimeSession; + effects: PhaseEffect[]; + error?: string; +}; + +/** @deprecated 使用 RuntimePhase */ +export type RuntimeStatus = RuntimePhase; diff --git a/src/types/tools.ts b/src/types/tools.ts new file mode 100644 index 0000000..6b1102e --- /dev/null +++ b/src/types/tools.ts @@ -0,0 +1,61 @@ +/** 总管 tool 名称 */ +export type MainAgentToolName = + | "read_blackboard" + | "list_workers" + | "list_artifacts" + | "ask_user" + | "run_worker" + | "review_blackboard" + | "finish"; + +/** 可在 tool loop 内反复调用、不改变运行相位的 tool */ +export const MAIN_AGENT_LOOP_TOOLS: MainAgentToolName[] = [ + "read_blackboard", + "list_workers", + "list_artifacts", +]; + +/** 调用后应退出 loop、交给阶段机的终止 tool */ +export const MAIN_AGENT_TERMINAL_TOOLS: MainAgentToolName[] = [ + "ask_user", + "run_worker", + "review_blackboard", + "finish", +]; + +export function isMainAgentTerminalTool(name: string): name is MainAgentToolName { + return (MAIN_AGENT_TERMINAL_TOOLS as string[]).includes(name); +} + +export function isMainAgentLoopTool(name: string): name is MainAgentToolName { + return (MAIN_AGENT_LOOP_TOOLS as string[]).includes(name); +} + +export type ReadBlackboardParams = { + tags: string[]; +}; + +export type ListWorkersParams = Record; + +export type ListArtifactsParams = Record; + +export type AskUserParams = { + reason: string; + message?: string; +}; + +export type RunWorkerParams = { + workerId: string; + reason: string; + requiresApproval: boolean; + roleId?: string; +}; + +export type ReviewBlackboardParams = { + reason: string; + summary: string; +}; + +export type FinishParams = { + reason: string; +}; diff --git a/src/worker/executor.ts b/src/worker/executor.ts new file mode 100644 index 0000000..a8f74a1 --- /dev/null +++ b/src/worker/executor.ts @@ -0,0 +1,218 @@ +import type { Blackboard } from "../blackboard/blackboard.js"; +import type { LlmProvider } from "../llm/client.js"; +import { loadWorkerSkillWithContext } from "../skills/loader.js"; +import { filterInputsForRolePerspective } from "../skills/worker-llm.js"; +import { collectUserInputTranscript } from "../intake/intake.js"; +import { resolveWorkerId } from "./resolve-id.js"; +import type { BlackboardInputMerge } from "../types/blackboard.js"; + +export type WorkerRunParams = { + skillName: string; + workerId: string; + slots: Record; + blackboard: Blackboard; + llm: LlmProvider; +}; + +export type WorkerRunResult = { + outputs: Record; + summary: string; + preview: string; + askUser?: string[]; +}; + +const WORKER_OUTPUT_INSTRUCTION = ` + +--- + +## 运行时输出协议(必须遵守) + +请输出 **单个 JSON 对象**(不要 markdown 代码块),字段: +{ + "outputs": { "": "<内容字符串>" }, + "summary": "50字以内产物摘要", + "askUser": null 或 ["需要用户补充的问题"] +} + +- outputs 的 key 必须是要求的 outputTags +- 若信息不足,outputs 可为空对象,askUser 填入问题 +- 若 inputs 中 \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问 +- summary 用于界面展示`; + +function slotValueForTag( + tag: string, + slots: Record, +): string | undefined { + const val = slots[tag]; + if (typeof val === "string" && val.trim()) return val.trim(); + return undefined; +} + +function gatherInputs( + inputTags: string[], + inputMerge: BlackboardInputMerge, + slots: Record, + blackboard: Blackboard, +): Record { + const inputs: Record = {}; + const items = blackboard.queryByPatterns(inputTags, inputMerge); + + for (const item of items) { + inputs[item.tag] = item.content; + } + + for (const pattern of inputTags) { + if (pattern.endsWith(".*") || pattern === "**") continue; + const slotVal = slotValueForTag(pattern, slots); + if (slotVal) inputs[pattern] = slotVal; + } + + const workerReply = slotValueForTag("用户.worker答复", slots); + if (workerReply && !inputs["用户.worker答复"]) { + inputs["用户.worker答复"] = workerReply; + } + + if (inputTags.includes("用户.博弈需求") && !inputs["用户.博弈需求"]) { + const fromSlot = slotValueForTag("用户.博弈需求", slots); + if (fromSlot) { + inputs["用户.博弈需求"] = fromSlot; + } else { + const transcript = collectUserInputTranscript(slots); + if (transcript) inputs["用户.博弈需求"] = transcript; + } + } + + return inputs; +} + +function parseWorkerResponse( + raw: string, + outputTags: string[], +): WorkerRunResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + const fallback: Record = {}; + if (outputTags.length === 1) { + fallback[outputTags[0]] = raw; + } else { + fallback[outputTags[0] ?? "output.草稿"] = raw; + } + return { + outputs: fallback, + summary: raw.slice(0, 80), + preview: raw.slice(0, 600), + }; + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("Worker 返回了无效 JSON"); + } + + const obj = parsed as Record; + const outputsRaw = obj.outputs; + const outputs: Record = {}; + + if (outputsRaw && typeof outputsRaw === "object") { + for (const tag of outputTags) { + const val = (outputsRaw as Record)[tag]; + if (typeof val === "string" && val.trim()) { + outputs[tag] = val.trim(); + } + } + } + + const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim()); + + let askUser: string[] | undefined; + if (Array.isArray(obj.askUser)) { + askUser = obj.askUser.filter( + (q): q is string => + typeof q === "string" && q.trim().length > 0 && !isMetaAskToken(q), + ); + } else if (typeof obj.askUser === "string" && obj.askUser.trim()) { + const q = obj.askUser.trim(); + askUser = isMetaAskToken(q) ? undefined : [q]; + } + if ((!askUser || askUser.length === 0) && Object.keys(outputs).length === 0) { + const summaryText = + typeof obj.summary === "string" ? obj.summary.trim() : ""; + if ( + summaryText && + !isMetaAskToken(summaryText) && + summaryText.length > 8 && + /[??]/.test(summaryText) + ) { + askUser = [summaryText]; + } else if (isMetaAskToken(summaryText)) { + askUser = ["请补充当前步骤所需的信息(情境、参数或你的具体设想)。"]; + } + } + + const summary = + typeof obj.summary === "string" && obj.summary.trim() + ? obj.summary.trim() + : Object.values(outputs)[0]?.slice(0, 80) ?? "Worker 已完成"; + + const preview = + Object.entries(outputs) + .map(([tag, v]) => `### ${tag}\n\n${v}`) + .join("\n\n") + .slice(0, 4000) || summary; + + return { outputs, summary, preview, askUser: askUser?.length ? askUser : undefined }; +} + +export async function runWorkerSkill(params: WorkerRunParams): Promise { + const workerId = resolveWorkerId(params.workerId); + const { worker, promptBody } = await loadWorkerSkillWithContext( + params.skillName, + workerId, + ); + + const inputMerge = worker.inputMerge ?? "latest"; + let inputs = gatherInputs( + worker.inputTags, + inputMerge, + params.slots, + params.blackboard, + ); + + if (worker.id === "role-decide") { + inputs = filterInputsForRolePerspective(inputs, params.slots); + } + + const userPayload = { + workerId: worker.id, + workerName: worker.name, + inputTags: worker.inputTags, + outputTags: worker.outputTags, + inputs, + instruction: + "根据 SKILL 说明完成创作任务。若 inputs 不足,使用 askUser 提问而非臆造。inputs 中已有用户.博弈需求 / book.brief 时,应直接据此产出,勿重复索要已提供信息。", + }; + + const result = await params.llm.complete( + [ + { role: "system", content: promptBody + WORKER_OUTPUT_INSTRUCTION }, + { role: "user", content: JSON.stringify(userPayload, null, 2) }, + ], + { + responseFormat: "json_object", + caller: `worker:${worker.id}`, + }, + ); + + return parseWorkerResponse(result.content, worker.outputTags); +} + +/** @internal 供单测验证 inputTags → inputs 拼接 */ +export function gatherWorkerInputs( + inputTags: string[], + inputMerge: BlackboardInputMerge, + slots: Record, + blackboard: Blackboard, +): Record { + return gatherInputs(inputTags, inputMerge, slots, blackboard); +} diff --git a/src/worker/resolve-id.ts b/src/worker/resolve-id.ts new file mode 100644 index 0000000..1045beb --- /dev/null +++ b/src/worker/resolve-id.ts @@ -0,0 +1,10 @@ +/** 总管/历史命名与 skill 包内 worker id 的映射 */ +const WORKER_ALIASES: Record = { + "rules-worker": "write-rules", + "outline-worker": "outline", + "drafting-worker": "drafting", +}; + +export function resolveWorkerId(workerId: string): string { + return WORKER_ALIASES[workerId] ?? workerId; +} diff --git a/start-app-hidden.vbs b/start-app-hidden.vbs new file mode 100644 index 0000000..26cfee8 --- /dev/null +++ b/start-app-hidden.vbs @@ -0,0 +1,4 @@ +Set WshShell = CreateObject("WScript.Shell") +Set Fso = CreateObject("Scripting.FileSystemObject") +appDir = Fso.GetParentFolderName(WScript.ScriptFullName) +WshShell.Run "cmd /c cd /d """ & appDir & """ && start-app.bat _hidden", 0, False diff --git a/start-app.bat b/start-app.bat new file mode 100644 index 0000000..c806048 --- /dev/null +++ b/start-app.bat @@ -0,0 +1,56 @@ +@echo off +if "%~1"=="" ( + start "" wscript.exe "%~dp0start-app-hidden.vbs" + exit /b 0 +) + +chcp 65001 >nul +title Writing Agent - 桌面版 +set PORT=23337 + +cd /d "%~dp0" + +echo. +echo Writing Agent - 桌面版 (Electron) +echo -------------------------------- +echo. + +where node >nul 2>&1 +if errorlevel 1 ( + echo [错误] 未找到 Node.js,请先安装: https://nodejs.org/ + if not "%~1"=="_hidden" pause + exit /b 1 +) + +if not exist "node_modules\electron\" ( + echo [提示] 首次运行,正在安装依赖(含 Electron,可能较慢)... + call npm install + if errorlevel 1 ( + echo [错误] npm install 失败 + if not "%~1"=="_hidden" pause + exit /b 1 + ) + echo. +) + +echo [信息] 检查端口 %PORT% ... +for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%PORT% " ^| findstr "LISTENING"') do ( + echo [信息] 结束占用端口的旧进程 PID=%%a + taskkill /F /PID %%a >nul 2>&1 +) + +if exist ".env" ( + echo [信息] 将加载 .env(由服务端读取) +) else if exist ".env.example" ( + echo [提示] 未找到 .env,可复制 .env.example 并填入 API Key +) + +echo [信息] 启动桌面窗口 http://localhost:%PORT% +echo [信息] 调试: F12 或 Ctrl+Shift+I 打开开发者工具 +echo [信息] 自动打开 DevTools: set WRITING_AGENT_ELECTRON_DEV=1 ^&^& npm run electron +echo [信息] 关闭窗口即停止服务 +echo. + +call npm run electron + +if not "%~1"=="_hidden" pause diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..2bf291e --- /dev/null +++ b/start.bat @@ -0,0 +1,53 @@ +@echo off +chcp 65001 >nul +title Writing Agent - 浏览器版 +set PORT=23337 + +cd /d "%~dp0" + +echo. +echo Writing Agent - 浏览器版 +echo -------------------------------- +echo. + +where node >nul 2>&1 +if errorlevel 1 ( + echo [错误] 未找到 Node.js,请先安装: https://nodejs.org/ + pause + exit /b 1 +) + +if not exist "node_modules\" ( + echo [提示] 首次运行,正在安装依赖... + call npm install + if errorlevel 1 ( + echo [错误] npm install 失败 + pause + exit /b 1 + ) + echo. +) + +echo [信息] 检查端口 %PORT% ... +for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%PORT% " ^| findstr "LISTENING"') do ( + echo [信息] 结束占用端口的旧进程 PID=%%a + taskkill /F /PID %%a >nul 2>&1 +) + +if exist ".env" ( + echo [信息] 将加载 .env(由服务端读取) +) else if exist ".env.example" ( + echo [提示] 未找到 .env,可复制 .env.example 并填入 API Key +) + +echo [信息] 启动服务 http://localhost:%PORT% +echo [信息] 调试: 浏览器 F12 打开开发者工具 +echo [信息] 桌面壳: 运行 start-app.bat +echo [信息] 按 Ctrl+C 停止 +echo. + +start "" "http://localhost:%PORT%" + +call npm run web + +pause diff --git a/tests/agent-view.test.ts b/tests/agent-view.test.ts new file mode 100644 index 0000000..a680657 --- /dev/null +++ b/tests/agent-view.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { + classifyAgentMessage, + buildFocus, + buildSkillCatalog, + buildToolTrace, + inferLifecycleStage, + canEnterPlay, +} from "../src/server/agent-view.js"; +import { createSession } from "../src/runtime/phase-machine.js"; + +describe("classifyAgentMessage", () => { + it("parses orchestrator decisions", () => { + const m = classifyAgentMessage("[总管] run_worker: 生成规则"); + expect(m.kind).toBe("orchestrator_decision"); + expect(m.body).toBe("生成规则"); + }); + + it("parses agent tool calls", () => { + const m = classifyAgentMessage("[总管 tool] invoke_worker: write-rules"); + expect(m.kind).toBe("agent_tool"); + expect(m.title).toContain("invoke_worker"); + expect(m.body).toBe("write-rules"); + }); + + it("parses worker output", () => { + const m = classifyAgentMessage("[Worker] write-rules 已完成\n\n### rules\n\n1. foo"); + expect(m.kind).toBe("worker_output"); + expect(m.actor).toBe("write-rules"); + expect(m.body).toContain("1. foo"); + }); + + it("parses worker questions", () => { + const m = classifyAgentMessage( + "[Worker] setup-scenario 提问:\n- 请提供玩家人数", + ); + expect(m.kind).toBe("worker_questions"); + expect(m.actor).toBe("setup-scenario"); + expect(m.body).toContain("请提供玩家人数"); + }); + + it("falls back when worker question body is empty", () => { + const m = classifyAgentMessage("Worker 提问:\n"); + expect(m.kind).toBe("worker_questions"); + expect(m.body).toContain("请补充当前步骤"); + }); +}); + +describe("lifecycle", () => { + it("infers design before startup completed", () => { + const session = createSession(); + expect(inferLifecycleStage(session)).toBe("design"); + expect(canEnterPlay(session)).toBe(false); + }); + + it("infers play after startup completed", () => { + const session = { + ...createSession(), + slots: { ...createSession().slots, startupCompleted: true }, + }; + expect(inferLifecycleStage(session)).toBe("play"); + expect(canEnterPlay(session)).toBe(true); + }); + + it("respects uiLifecycleStage override", () => { + const session = { + ...createSession(), + slots: { ...createSession().slots, uiLifecycleStage: "play" }, + }; + expect(inferLifecycleStage(session)).toBe("play"); + }); +}); + +describe("buildSkillCatalog", () => { + it("lists design skills for world-simulator", () => { + const session = createSession(); + const catalog = buildSkillCatalog(session, "world-simulator", "design"); + expect(catalog.length).toBeGreaterThan(0); + expect(catalog.every((s) => s.stage === "design")).toBe(true); + expect(catalog.some((s) => s.id === "interaction-paradigm")).toBe(true); + expect(catalog[0].purpose.length).toBeGreaterThan(0); + }); + + it("marks intake active during intake wait", () => { + const session = { + ...createSession(), + waitingReason: { kind: "intake" as const, prompt: "请填写" }, + }; + const catalog = buildSkillCatalog(session, "basic", "design"); + const intake = catalog.find((s) => s.id === "intake"); + expect(intake?.status).toBe("active"); + }); +}); + +describe("buildToolTrace", () => { + it("collects agent_tool messages since last user input", () => { + const trace = buildToolTrace([ + { + kind: "user_input", + text: "hello", + createdAt: "2026-01-01T00:00:00Z", + }, + { + kind: "agent_tool", + text: "[总管 tool] read_blackboard: tags=3", + createdAt: "2026-01-01T00:00:01Z", + }, + ]); + expect(trace).toHaveLength(1); + expect(trace[0].name).toBe("read_blackboard"); + }); +}); + +describe("buildFocus", () => { + it("focuses user on input wait", () => { + const session = { + ...createSession(), + phase: "waiting_user" as const, + waitingReason: { kind: "input" as const, message: "请描述场景" }, + }; + const focus = buildFocus(session, session.waitingReason); + expect(focus.actorType).toBe("user"); + }); + + it("focuses user on worker questions", () => { + const session = { + ...createSession(), + phase: "waiting_user" as const, + waitingReason: { + kind: "worker_questions" as const, + workerId: "setup-scenario", + questions: ["请提供玩家人数"], + }, + }; + const focus = buildFocus(session, session.waitingReason); + expect(focus.actorType).toBe("user"); + expect(focus.action).toContain("setup-scenario"); + expect(focus.detail).toContain("请提供玩家人数"); + }); + + it("shows design burst when running in design lifecycle", () => { + const session = { + ...createSession(), + phase: "running" as const, + }; + const focus = buildFocus(session, undefined, undefined, "design"); + expect(focus.actorLabel).toBe("Agent"); + expect(focus.action).toContain("设计"); + }); +}); + \ No newline at end of file diff --git a/tests/blackboard.test.ts b/tests/blackboard.test.ts new file mode 100644 index 0000000..389f88d --- /dev/null +++ b/tests/blackboard.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { tagMatchesPattern } from "../src/blackboard/tag-match.js"; + +describe("tag matching", () => { + it("matches exact and prefix patterns", () => { + expect(tagMatchesPattern("角色.A.台词.候选", "角色.A.台词.候选")).toBe(true); + expect(tagMatchesPattern("角色.A.台词.候选", "角色.A.*")).toBe(true); + expect(tagMatchesPattern("角色.B.台词.候选", "角色.A.*")).toBe(false); + expect(tagMatchesPattern("任意.tag", "**")).toBe(true); + }); +}); + +describe("blackboard", () => { + it("writes and queries by tag pattern", () => { + const bb = new Blackboard(); + bb.write({ tag: "book.brief", content: "科幻短篇", source: "user" }); + bb.write({ tag: "outline.draft", content: "第一章…", source: "outline" }); + + expect(bb.getContentByTag("book.brief")).toBe("科幻短篇"); + expect(bb.listTagIndex()).toHaveLength(2); + + const items = bb.queryByPatterns(["book.brief", "outline.*"]); + expect(items.map((i) => i.tag).sort()).toEqual(["book.brief", "outline.draft"]); + }); + + it("returns latest item for duplicate tags", () => { + const bb = new Blackboard(); + bb.write({ tag: "正文.草稿", content: "v1", source: "w" }); + bb.write({ tag: "正文.草稿", content: "v2", source: "w" }); + expect(bb.getContentByTag("正文.草稿")).toBe("v2"); + }); +}); diff --git a/tests/book-store.test.ts b/tests/book-store.test.ts new file mode 100644 index 0000000..d1ceff2 --- /dev/null +++ b/tests/book-store.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, beforeAll } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +describe("book store", () => { + beforeAll(() => { + process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( + path.join(tmpdir(), "wa-book-test-"), + ); + }); + + it("creates and lists books", async () => { + const { createBook, getBook, listBooks } = await import("../src/book/store.js"); + const book = createBook({ title: "测试之书" }); + expect(book.id).toBeTruthy(); + expect(getBook(book.id)?.title).toBe("测试之书"); + expect(listBooks().some((b) => b.id === book.id)).toBe(true); + }); +}); diff --git a/tests/intake.test.ts b/tests/intake.test.ts new file mode 100644 index 0000000..e9f7f52 --- /dev/null +++ b/tests/intake.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + buildIntakeFollowUpMessage, + buildIntakeProgress, + extractIntakeHeuristic, + intakeFieldsFromInquiry, + synthesizeDemandText, +} from "../src/intake/intake.js"; +import { applyEvent, createSession } from "../src/runtime/phase-machine.js"; +import { toActiveSkillSnapshot } from "../src/skills/snapshot.js"; +import { loadSkill } from "../src/skills/loader.js"; + +describe("intake fields", () => { + it("parses required and optional from roleplay skill", async () => { + const skill = await loadSkill("roleplay-game-theory"); + const fields = intakeFieldsFromInquiry(skill.startupInquiry); + expect(fields.filter((f) => f.required).length).toBe(3); + expect(fields.filter((f) => !f.required).length).toBe(3); + }); + + it("marks ready when all required filled", () => { + const fields = intakeFieldsFromInquiry({ + prompt: "p", + targetKey: "用户.博弈需求", + requiredFields: ["情境", "角色", "轮次"], + optionalFields: ["输出偏好"], + }); + const partial = buildIntakeProgress(fields, { + [fields[0].id]: "德州扑克", + }); + expect(partial.ready).toBe(false); + expect(partial.requiredFilled).toBe(1); + + const full = buildIntakeProgress(fields, { + [fields[0].id]: "德州扑克", + [fields[1].id]: "玩家A算计,玩家B保守", + [fields[2].id]: "单轮", + }); + expect(full.ready).toBe(true); + }); + + it("heuristic fills scenario and rounds keywords", () => { + const fields = intakeFieldsFromInquiry({ + prompt: "p", + targetKey: "t", + requiredFields: ["实验情境", "轮次模式"], + optionalFields: [], + }); + const values = extractIntakeHeuristic( + "德州扑克,单轮定胜负", + fields, + {}, + ); + expect(Object.keys(values).length).toBeGreaterThan(0); + }); + + it("parses roleplay intake fields as 3 required + optional", async () => { + const skill = await loadSkill("roleplay-game-theory"); + const fields = intakeFieldsFromInquiry(skill.startupInquiry); + expect(fields.filter((f) => f.required).length).toBe(3); + expect(fields.filter((f) => !f.required).length).toBe(3); + expect(fields.some((f) => f.label.includes("情境"))).toBe(true); + expect(fields.some((f) => f.label.includes("进程"))).toBe(true); + }); + + it("buildIntakeFollowUpMessage lists missing required fields", () => { + const fields = intakeFieldsFromInquiry({ + prompt: "p", + targetKey: "t", + requiredFields: ["情境", "角色", "进程"], + optionalFields: [], + }); + const progress = buildIntakeProgress(fields, { + [fields[0].id]: "德州扑克", + }); + const msg = buildIntakeFollowUpMessage(progress); + expect(msg).toContain("还缺以下必要项"); + expect(msg).toContain("角色"); + expect(msg).toContain("进程"); + }); + + it("confirm intake writes structured demand tag", async () => { + const snap = toActiveSkillSnapshot(await loadSkill("basic")); + let session = createSession("default"); + session = applyEvent(session, { + type: "session_started", + payload: { + presetId: "default", + availableSkills: [{ name: "basic", description: "", category: "novel" }], + }, + }).session; + session = applyEvent(session, { + type: "skill_selected", + payload: { skill: snap }, + }).session; + expect(session.waitingReason?.kind).toBe("intake"); + + const fields = snap.intakeFields; + const values: Record = {}; + for (const f of fields) { + values[f.id] = `值-${f.label}`; + } + session = applyEvent(session, { + type: "user_submitted_input", + payload: { text: "一次性提交", intakeValues: values }, + }).session; + expect(session.waitingReason?.kind).toBe("intake"); + + session = applyEvent(session, { + type: "user_confirmed_intake", + payload: {}, + }).session; + expect(session.slots.startupCompleted).toBe(true); + expect(String(session.slots["book.brief"])).toContain("题材"); + expect(synthesizeDemandText(fields, values)).toContain("值-题材"); + }); +}); diff --git a/tests/orchestrator.test.ts b/tests/orchestrator.test.ts new file mode 100644 index 0000000..3d7a57b --- /dev/null +++ b/tests/orchestrator.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { createMockMainAgentResponse, MockLlmProvider } from "../src/llm/client.js"; +import { parseMainAgentDecision } from "../src/main-agent/main-agent.js"; + +describe("main agent", () => { + it("parses valid JSON decision", () => { + const raw = createMockMainAgentResponse({ + action: "run_worker", + workerId: "outline-worker", + requiresApproval: true, + }); + const decision = parseMainAgentDecision(raw); + expect(decision.action).toBe("run_worker"); + expect(decision.workerId).toBe("outline-worker"); + expect(decision.statePatchAllowed).toBe(false); + }); +}); + +// 带 skill 的完整闭环见 tests/phase-skill.test.ts(PhaseRuntime) +// RuntimeOrchestrator 接 skill 层留待 Phase A1 + +describe("runtime orchestrator", () => { + it("placeholder until orchestrator uses PhaseRuntime + skill", () => { + expect(MockLlmProvider).toBeDefined(); + }); +}); diff --git a/tests/phase-machine.test.ts b/tests/phase-machine.test.ts new file mode 100644 index 0000000..1f184ba --- /dev/null +++ b/tests/phase-machine.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { createDecision } from "../src/runtime/orchestrator.js"; +import { + applyEvent, + canApplyEvent, + createArtifact, + createSession, +} from "../src/runtime/phase-machine.js"; +import { loadSkill } from "../src/skills/loader.js"; +import { toActiveSkillSnapshot } from "../src/skills/snapshot.js"; + +const mockSkills = [{ name: "basic", description: "基础", category: "novel" }]; + +describe("phase machine", () => { + it("runs worker loop after skill and startup input", async () => { + const { loadSkill } = await import("../src/skills/loader.js"); + const { toActiveSkillSnapshot } = await import("../src/skills/snapshot.js"); + const snap = toActiveSkillSnapshot(await loadSkill("basic")); + + let session = createSession("default"); + session = applyEvent(session, { + type: "session_started", + payload: { presetId: "default", availableSkills: mockSkills }, + }).session; + session = applyEvent(session, { + type: "skill_selected", + payload: { skill: snap }, + }).session; + const values: Record = {}; + for (const f of snap.intakeFields) { + values[f.id] = "科幻短篇,第一人称"; + } + session = applyEvent(session, { + type: "user_submitted_input", + payload: { text: "科幻短篇,第一人称", intakeValues: values }, + }).session; + expect(session.waitingReason?.kind).toBe("intake"); + session = applyEvent(session, { + type: "user_confirmed_intake", + payload: {}, + }).session; + expect(session.phase).toBe("running"); + expect(session.slots["book.brief"]).toBeTruthy(); + + const decision = createDecision({ + action: "run_worker", + reason: "生成大纲", + workerId: "outline-worker", + requiresApproval: true, + }); + session = applyEvent(session, { + type: "main_agent_decision_created", + payload: { decision }, + }).session; + expect(session.waitingReason?.kind).toBe("approve_step"); + + session = applyEvent(session, { + type: "user_approved_next_step", + payload: { decisionId: decision.id }, + }).session; + session = applyEvent(session, { + type: "worker_started", + payload: { workerId: "outline-worker", acceptanceMode: "user_confirmed" }, + }).session; + + const artifact = createArtifact({ + workerId: "outline-worker", + outputTags: ["outline.draft"], + }); + session = { ...session, artifacts: [artifact] }; + session = applyEvent(session, { + type: "worker_completed", + payload: { artifactId: artifact.id }, + }).session; + expect(session.waitingReason?.kind).toBe("review_artifact"); + + session = applyEvent(session, { + type: "user_accepted_artifact", + payload: { artifactId: artifact.id }, + }).session; + expect(session.phase).toBe("running"); + + session = applyEvent(session, { type: "flow_completed", payload: {} }).session; + expect(session.phase).toBe("done"); + }); + + it("rejects illegal events from idle", () => { + const session = createSession("default"); + expect(canApplyEvent(session, { type: "user_submitted_input", payload: { text: "x" } })).toBe( + false, + ); + const result = applyEvent(session, { + type: "user_submitted_input", + payload: { text: "x" }, + }); + expect(result.session.phase).toBe("error"); + }); + + it("merges follow-up input after intake confirm via ask_user", async () => { + const { createDecision } = await import("../src/runtime/orchestrator.js"); + const snap = toActiveSkillSnapshot(await loadSkill("roleplay-game-theory")); + + let session = createSession("default"); + session = applyEvent(session, { + type: "session_started", + payload: { presetId: "default", availableSkills: mockSkills }, + }).session; + session = applyEvent(session, { + type: "skill_selected", + payload: { skill: snap }, + }).session; + + const fields = snap.intakeFields; + const values: Record = {}; + for (const f of fields.filter((x) => x.required)) { + values[f.id] = "已填"; + } + session = applyEvent(session, { + type: "user_submitted_input", + payload: { text: "首次", intakeValues: values }, + }).session; + session = applyEvent(session, { + type: "user_confirmed_intake", + payload: {}, + }).session; + expect(session.slots.startupCompleted).toBe(true); + + session = applyEvent(session, { + type: "main_agent_decision_created", + payload: { + decision: createDecision({ + action: "ask_user", + reason: "输出偏好?", + }), + }, + }).session; + expect(session.waitingReason?.kind).toBe("input"); + + session = applyEvent(session, { + type: "user_submitted_input", + payload: { text: "需要思考标签,带场景描写" }, + }).session; + const demand = String(session.slots["用户.博弈需求"]); + expect(demand).toContain("需要思考标签"); + }); + + it("emits one follow-up when roleplay intake required fields incomplete", async () => { + const snap = toActiveSkillSnapshot(await loadSkill("roleplay-game-theory")); + let session = createSession("default"); + session = applyEvent(session, { + type: "session_started", + payload: { presetId: "default", availableSkills: mockSkills }, + }).session; + session = applyEvent(session, { + type: "skill_selected", + payload: { skill: snap }, + }).session; + const scenarioField = snap.intakeFields.find((f) => f.label.includes("情境")); + expect(scenarioField).toBeDefined(); + const result = applyEvent(session, { + type: "user_submitted_input", + payload: { + text: "德州扑克", + intakeValues: { [scenarioField!.id]: "德州扑克" }, + }, + }); + expect(result.effects.some((e) => e.type === "emit_message")).toBe(true); + const msg = result.effects.find((e) => e.type === "emit_message"); + expect(msg && "message" in msg && msg.message).toContain("还缺以下必要项"); + expect(result.session.slots.intakeFollowUpSent).toBe(true); + }); +}); diff --git a/tests/phase-runtime.test.ts b/tests/phase-runtime.test.ts new file mode 100644 index 0000000..e92d122 --- /dev/null +++ b/tests/phase-runtime.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + createMockMainAgentResponse, + createMockToolCall, + MockLlmProvider, +} from "../src/llm/client.js"; +import { buildIntakeProgress, readIntakeValues } from "../src/intake/intake.js"; +import { PhaseRuntime } from "../src/runtime/phase-runtime.js"; + +describe("phase runtime", () => { + it("selects basic skill and shows startup inquiry", async () => { + const runtime = new PhaseRuntime(); + await runtime.start(); + expect(runtime.getSession().waitingReason?.kind).toBe("skill_selection"); + + await runtime.selectSkill("basic"); + expect(runtime.getSession().waitingReason?.kind).toBe("intake"); + expect(runtime.getActiveSkill()?.name).toBe("basic"); + }); + + it("writes book.brief after intake confirm", async () => { + const runtime = new PhaseRuntime(); + await runtime.start(); + await runtime.selectSkill("basic"); + await runtime.submitInput("科幻中篇,第三人称"); + await runtime.confirmIntake(); + expect(runtime.getSession().slots["book.brief"]).toContain("科幻"); + expect(runtime.getSession().slots.startupCompleted).toBe(true); + }); + + it("syncs demand tag to blackboard after intake confirm", async () => { + const runtime = new PhaseRuntime(); + await runtime.start(); + await runtime.selectSkill("roleplay-game-theory"); + const fields = runtime.getActiveSkill()?.intakeFields ?? []; + await runtime.submitInput("德州扑克,经典博弈情境"); + await runtime.submitInput("玩家A算计型,玩家B怕吃亏"); + await runtime.submitInput("单轮定胜负,需要思考标签与场景描写"); + const progress = buildIntakeProgress( + fields, + readIntakeValues(runtime.getSession().slots), + ); + if (!progress.ready) { + const values = readIntakeValues(runtime.getSession().slots); + for (const f of fields.filter((x) => x.required && !values[x.id])) { + values[f.id] = "补充"; + } + await runtime.dispatch({ + type: "user_submitted_input", + payload: { text: "补充", intakeValues: values }, + }); + } + await runtime.confirmIntake(); + const demand = runtime.getBlackboard().getContentByTag("用户.博弈需求") ?? ""; + expect(demand).toContain("德州扑克"); + expect(runtime.getSession().slots.startupCompleted).toBe(true); + }); + + it("mock main agent tool loop reads blackboard then proposes worker", async () => { + const runtime = new PhaseRuntime({ + llm: new MockLlmProvider([ + createMockToolCall("read_blackboard", { tags: ["book.brief"] }), + createMockToolCall("run_worker", { + workerId: "outline", + reason: "已读需求,生成大纲", + requiresApproval: true, + }), + ]), + }); + await runtime.start(); + await runtime.selectSkill("basic"); + await runtime.submitInput("科幻中篇,第三人称"); + await runtime.confirmIntake(); + expect(runtime.getSession().waitingReason?.kind).toBe("approve_step"); + expect(runtime.getSession().pendingDecision?.workerId).toBe("outline"); + }); +}); diff --git a/tests/phase-skill.test.ts b/tests/phase-skill.test.ts new file mode 100644 index 0000000..ebe702f --- /dev/null +++ b/tests/phase-skill.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { loadSkill, listSkills } from "../src/skills/loader.js"; +import { toActiveSkillSnapshot } from "../src/skills/snapshot.js"; +import { createDecision } from "../src/runtime/phase-runtime.js"; +import { + applyEvent, + canApplyEvent, + createSession, +} from "../src/runtime/phase-machine.js"; + +const mockSkills = [ + { name: "basic", description: "基础小说", category: "novel" }, +]; + +describe("skill loader", () => { + it("lists basic skill", async () => { + const skills = await listSkills(); + expect(skills.some((s) => s.name === "basic")).toBe(true); + }); + + it("loads startup inquiry from SKILL.md", async () => { + const skill = await loadSkill("basic"); + expect(skill.startupInquiry.targetKey).toBe("book.brief"); + expect(skill.startupInquiry.prompt).toContain("基础小说创作"); + const snap = toActiveSkillSnapshot(skill); + expect(snap.startupPrompt).toBe(skill.startupInquiry.prompt); + }); + + it("loads weird-rules-short skill pack", async () => { + const skill = await loadSkill("weird-rules-short"); + expect(skill.name).toBe("weird-rules-short"); + expect(skill.path).toBe("novel/weird-rules-short/orchestrator.md"); + expect(skill.skillPackRoot).toBe("novel/weird-rules-short"); + expect(skill.bookKind).toBe("novel"); + expect(skill.tags).toContain("weird_rules"); + expect(skill.suggestedWorkers).toEqual(["write-rules", "review-infer", "review-author"]); + expect(skill.sharedContextPath).toBe("shared-context.md"); + expect(skill.startupInquiry.prompt).toContain("规则怪谈"); + expect(skill.startupInquiry.prompt).not.toContain("叙事人称"); + }); + + it("loads roleplay-game-theory skill pack (instantiate)", async () => { + const skill = await loadSkill("roleplay-game-theory"); + expect(skill.name).toBe("roleplay-game-theory"); + expect(skill.path).toBe("dialogue/roleplay-game-theory/orchestrator.md"); + expect(skill.bookKind).toBe("dialogue"); + expect(skill.suggestedWorkers).toEqual(["setup-scenario", "world-engine", "role-decide", "present-round"]); + expect(skill.startupInquiry.targetKey).toBe("用户.博弈需求"); + expect(skill.startupInquiry.prompt).toContain("角色扮演博弈"); + }); + + it("loads worker skills from skill pack", async () => { + const { + loadWorkerSkill, + loadWorkerSkillWithContext, + loadSkillSharedContext, + listWorkerSkills, + } = await import("../src/skills/loader.js"); + const workers = await listWorkerSkills("weird-rules-short"); + expect(workers.map((w) => w.id).sort()).toEqual([ + "review-author", + "review-infer", + "write-rules", + ]); + + const writeRules = await loadWorkerSkill("weird-rules-short", "write-rules"); + expect(writeRules.outputTags).toContain("rules.draft"); + expect(writeRules.body).toContain("shared-context"); + + const reviewInfer = await loadWorkerSkill("weird-rules-short", "review-infer"); + expect(reviewInfer.outputTags).toContain("review.infer.notes"); + expect(reviewInfer.body).toContain("verdict:"); + + const reviewAuthor = await loadWorkerSkill("weird-rules-short", "review-author"); + expect(reviewAuthor.outputTags).toContain("review.author.notes"); + expect(reviewAuthor.body).toContain("表面矛盾"); + + const shared = await loadSkillSharedContext("weird-rules-short"); + expect(shared).toContain("表面矛盾 ≠ 逻辑矛盾"); + + const withCtx = await loadWorkerSkillWithContext("weird-rules-short", "write-rules"); + expect(withCtx.sharedContext).toContain("盲人摸象"); + expect(withCtx.promptBody).toContain("固定创作上下文"); + }); + + it("loads roleplay role-decide with thinking/action output tags", async () => { + const { loadWorkerSkill } = await import("../src/skills/loader.js"); + const roleDecide = await loadWorkerSkill("roleplay-game-theory", "role-decide"); + expect(roleDecide.outputTags).toContain("角色.*.思考"); + expect(roleDecide.outputTags).toContain("角色.*.行动"); + expect(roleDecide.body).toContain("仅用户可见"); + }); + + it("loads basic skill pack", async () => { + const skill = await loadSkill("basic"); + expect(skill.path).toBe("novel/basic/orchestrator.md"); + expect(skill.skillPackRoot).toBe("novel/basic"); + expect(skill.suggestedWorkers).toEqual(["outline"]); + }); + + it("lists only registered skills", async () => { + const skills = await listSkills(); + const names = skills.map((s) => s.name).sort(); + expect(names).toEqual(["basic", "roleplay-game-theory", "weird-rules-short"]); + }); +}); + +describe("phase machine with skill", () => { + it("starts with skill_selection", () => { + let session = createSession("default"); + const result = applyEvent(session, { + type: "session_started", + payload: { presetId: "default", availableSkills: mockSkills }, + }); + expect(result.session.phase).toBe("waiting_user"); + expect(result.session.waitingReason?.kind).toBe("skill_selection"); + }); + + it("skill_selected shows startup prompt from skill", async () => { + const skill = await loadSkill("basic"); + const snap = toActiveSkillSnapshot(skill); + + let session = applyEvent(createSession("default"), { + type: "session_started", + payload: { presetId: "default", availableSkills: mockSkills }, + }).session; + + const selected = applyEvent(session, { + type: "skill_selected", + payload: { skill: snap }, + }); + expect(selected.session.waitingReason?.kind).toBe("input"); + expect(selected.session.slots.activeSkill).toBeDefined(); + expect((selected.session.slots.activeSkill as { name: string }).name).toBe("basic"); + }); + + it("rejects input before skill selected", () => { + const session = applyEvent(createSession("default"), { + type: "session_started", + payload: { presetId: "default", availableSkills: mockSkills }, + }).session; + expect(canApplyEvent(session, { type: "user_submitted_input", payload: { text: "x" } })).toBe( + false, + ); + }); +}); + +describe("phase runtime with skill", () => { + it("runs full loop with basic skill", async () => { + const { PhaseRuntime, runMinimalClosedLoop } = await import("../src/runtime/phase-runtime.js"); + const session = await runMinimalClosedLoop(new PhaseRuntime({ autoStubWorker: true })); + expect(session.phase).toBe("done"); + expect(session.slots.startupCompleted).toBe(true); + expect(session.slots["book.brief"]).toContain("科幻"); + }); +}); diff --git a/tests/preset.test.ts b/tests/preset.test.ts new file mode 100644 index 0000000..3725b18 --- /dev/null +++ b/tests/preset.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { assemblePresetMessages } from "../src/preset/assembler.js"; +import { listEnabledPresetEntries, countInjectingEntries } from "../src/preset/entries.js"; +import { importSillyTavernPreset } from "../src/preset/importer.js"; + +const samplePreset = { + temperature: 1, + top_p: 0.95, + openai_max_tokens: 60000, + prompts: [ + { + identifier: "main", + name: "主要", + role: "system", + content: "", + marker: true, + }, + { + identifier: "custom-a", + name: "A", + role: "system", + content: "instruction A", + }, + { + identifier: "custom-b", + name: "B", + role: "assistant", + content: "prefill B", + }, + ], + prompt_order: [ + { + character_id: 100000, + order: [{ identifier: "main", enabled: true }], + }, + { + character_id: 100001, + order: [ + { identifier: "custom-a", enabled: true }, + { identifier: "custom-b", enabled: true }, + { identifier: "main", enabled: true }, + ], + }, + ], +}; + +describe("importSillyTavernPreset", () => { + it("picks prompt_order with most enabled content", () => { + const report = importSillyTavernPreset(samplePreset, { name: "test" }); + expect(report.enabledCount).toBe(3); + expect(report.preset.promptOrder.map((o) => o.promptId)).toEqual([ + "custom-a", + "custom-b", + "main", + ]); + expect(report.preset.generation.temperature).toBe(1); + expect(report.preset.generation.maxOutputTokens).toBe(60000); + expect(report.warnings.some((w) => w.includes("prompt_order"))).toBe(true); + }); +}); + +describe("assemblePresetMessages", () => { + it("skips empty markers and emits enabled content in order", () => { + const report = importSillyTavernPreset(samplePreset); + const messages = assemblePresetMessages(report.preset); + expect(messages).toEqual([ + { role: "system", content: "instruction A" }, + { role: "assistant", content: "prefill B" }, + ]); + }); +}); + +describe("listEnabledPresetEntries", () => { + it("lists enabled order with inject flags", () => { + const report = importSillyTavernPreset(samplePreset); + const entries = listEnabledPresetEntries(report.preset); + expect(entries.length).toBe(3); + expect(countInjectingEntries(entries)).toBe(2); + expect(entries.filter((e) => e.willInject).map((e) => e.content)).toEqual([ + "instruction A", + "prefill B", + ]); + }); +}); diff --git a/tests/run-snapshot.test.ts b/tests/run-snapshot.test.ts new file mode 100644 index 0000000..6904e5d --- /dev/null +++ b/tests/run-snapshot.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, beforeAll } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { createSession } from "../src/runtime/phase-machine.js"; +import type { RunSnapshot } from "../src/types/run-snapshot.js"; + +describe("run snapshot store", () => { + beforeAll(() => { + process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( + path.join(tmpdir(), "wa-run-snapshot-"), + ); + }); + + function makeSnapshot(bookId: string, label: string, kind: "instance" | "run" = "run"): RunSnapshot { + const runtimeSession = createSession("default"); + runtimeSession.phase = "running"; + runtimeSession.slots.startupCompleted = true; + return { + version: 1, + id: randomUUID(), + bookId, + label, + kind, + orchestratorId: "roleplay-game-theory", + runtimeSession, + blackboardItems: [ + { + id: randomUUID(), + tag: "运行.事件流", + content: "## 第 1 轮\n- 测试", + source: "worker", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + messages: [ + { + id: randomUUID(), + role: "user", + text: "存档时的对话", + createdAt: new Date().toISOString(), + }, + ], + createdAt: new Date().toISOString(), + note: "测试快照", + }; + } + + it("saveRunSnapshot / loadRunSnapshot roundtrip", async () => { + const { saveRunSnapshot, loadRunSnapshot } = await import( + "../src/book/run-snapshot-store.js" + ); + const bookId = randomUUID(); + const snapshot = makeSnapshot(bookId, "第 1 轮后"); + + saveRunSnapshot(snapshot); + const loaded = loadRunSnapshot(bookId, snapshot.id); + + expect(loaded).not.toBeNull(); + expect(loaded!.label).toBe("第 1 轮后"); + expect(loaded!.blackboardItems).toHaveLength(1); + expect(loaded!.note).toBe("测试快照"); + }); + + it("listRunSnapshots returns saved items newest first", async () => { + const { saveRunSnapshot, listRunSnapshots } = await import( + "../src/book/run-snapshot-store.js" + ); + const bookId = randomUUID(); + const older = makeSnapshot(bookId, "较早"); + older.createdAt = "2020-01-01T00:00:00.000Z"; + const newer = makeSnapshot(bookId, "较新"); + newer.createdAt = "2025-01-01T00:00:00.000Z"; + + saveRunSnapshot(older); + saveRunSnapshot(newer); + + const list = listRunSnapshots(bookId); + expect(list).toHaveLength(2); + expect(list[0].label).toBe("较新"); + expect(list[1].label).toBe("较早"); + }); + + it("deleteRunSnapshot removes one snapshot", async () => { + const { saveRunSnapshot, loadRunSnapshot, deleteRunSnapshot } = await import( + "../src/book/run-snapshot-store.js" + ); + const bookId = randomUUID(); + const snapshot = makeSnapshot(bookId, "待删"); + + saveRunSnapshot(snapshot); + expect(loadRunSnapshot(bookId, snapshot.id)).not.toBeNull(); + + expect(deleteRunSnapshot(bookId, snapshot.id)).toBe(true); + expect(loadRunSnapshot(bookId, snapshot.id)).toBeNull(); + expect(deleteRunSnapshot(bookId, snapshot.id)).toBe(false); + }); + + it("deleteAllRunSnapshots clears book snapshots", async () => { + const { saveRunSnapshot, listRunSnapshots, deleteAllRunSnapshots } = + await import("../src/book/run-snapshot-store.js"); + const bookId = randomUUID(); + saveRunSnapshot(makeSnapshot(bookId, "a")); + saveRunSnapshot(makeSnapshot(bookId, "b")); + + deleteAllRunSnapshots(bookId); + expect(listRunSnapshots(bookId)).toHaveLength(0); + }); + + it("deleteBook removes run snapshots", async () => { + const { createBook, deleteBook } = await import("../src/book/store.js"); + const { saveRunSnapshot, listRunSnapshots } = await import( + "../src/book/run-snapshot-store.js" + ); + + const book = createBook({ title: "快照清理测试" }); + saveRunSnapshot(makeSnapshot(book.id, "保留前")); + + deleteBook(book.id); + expect(listRunSnapshots(book.id)).toHaveLength(0); + }); + + it("saveGameSnapshot / loadGameSnapshot restores playable state", async () => { + const { createBook } = await import("../src/book/store.js"); + const { SessionManager } = await import("../src/server/session-manager.js"); + + const book = createBook({ title: "读档测试" }); + + const mgr = new SessionManager(); + const view1 = await mgr.createForBook(book.id, "basic"); + await mgr.sendMessage(view1.id, "读档前消息"); + + const save = mgr.saveGameSnapshot(view1.id, "第一章末"); + expect(save.label).toBe("第一章末"); + + await mgr.sendMessage(view1.id, "读档后不应保留的消息"); + + const view2 = await mgr.loadGameSnapshot(book.id, save.id); + expect(view2.messages.some((m) => m.text === "读档前消息")).toBe(true); + expect(view2.messages.some((m) => m.text === "读档后不应保留的消息")).toBe(false); + expect(view2.hints.some((h) => h.includes("第一章末"))).toBe(true); + }); + + it("instance snapshot strips run progress on save and load", async () => { + const { createBook } = await import("../src/book/store.js"); + const { SessionManager } = await import("../src/server/session-manager.js"); + const { loadRunSnapshot } = await import("../src/book/run-snapshot-store.js"); + + const book = createBook({ title: "实例快照" }); + + const mgr = new SessionManager(); + const view = await mgr.createForBook(book.id, "basic"); + const managed = mgr["require"](view.id) as { + runtime: { getSession: () => { slots: Record }; getBlackboard: () => { write: (i: object) => void } }; + }; + managed.runtime.getSession().slots.startupCompleted = true; + const bb = managed.runtime.getBlackboard(); + bb.write({ tag: "情境.实验.设定", content: "囚徒困境", source: "worker" }); + bb.write({ tag: "运行.事件流", content: "第1轮", source: "worker" }); + + const save = mgr.saveGameSnapshot(view.id, "标准局", "instance"); + expect(save.kind).toBe("instance"); + + const stored = loadRunSnapshot(book.id, save.id)!; + expect(stored.blackboardItems.some((i) => i.tag === "情境.实验.设定")).toBe(true); + expect(stored.blackboardItems.some((i) => i.tag === "运行.事件流")).toBe(false); + + const loaded = await mgr.loadGameSnapshot(book.id, save.id); + expect(loaded.hints.some((h) => h.includes("实例"))).toBe(true); + }); +}); diff --git a/tests/session-persist.test.ts b/tests/session-persist.test.ts new file mode 100644 index 0000000..e52a702 --- /dev/null +++ b/tests/session-persist.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, beforeAll } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { createSession } from "../src/runtime/phase-machine.js"; +import type { PersistedBookSession } from "../src/types/book-session.js"; + +describe("session persistence", () => { + beforeAll(() => { + process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( + path.join(tmpdir(), "wa-session-persist-"), + ); + }); + + it("saveBookSession / loadBookSession roundtrip", async () => { + const { createBook } = await import("../src/book/store.js"); + const { saveBookSession, loadBookSession } = await import( + "../src/book/session-store.js" + ); + + const book = createBook({ title: "持久化测试" }); + const sessionId = randomUUID(); + const runtimeSession = createSession("default"); + runtimeSession.id = sessionId; + runtimeSession.phase = "running"; + runtimeSession.slots = { startupCompleted: true, userGoal: "测试目标" }; + + const snapshot: PersistedBookSession = { + version: 1, + sessionId, + bookId: book.id, + orchestratorId: "basic", + runtimeSession, + blackboardItems: [ + { + id: randomUUID(), + tag: "测试.条目", + content: "hello", + source: "worker", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + messages: [ + { + id: randomUUID(), + role: "user", + text: "用户消息", + createdAt: new Date().toISOString(), + }, + ], + savedAt: new Date().toISOString(), + }; + + saveBookSession(snapshot); + const loaded = loadBookSession(book.id); + expect(loaded).not.toBeNull(); + expect(loaded!.sessionId).toBe(sessionId); + expect(loaded!.runtimeSession.phase).toBe("running"); + expect(loaded!.runtimeSession.slots.userGoal).toBe("测试目标"); + expect(loaded!.blackboardItems).toHaveLength(1); + expect(loaded!.messages[0].text).toBe("用户消息"); + }); + + it("openBook restores persisted session after restart", async () => { + const { createBook, getBook } = await import("../src/book/store.js"); + const { SessionManager } = await import("../src/server/session-manager.js"); + + const book = createBook({ title: "续作测试" }); + + const mgr1 = new SessionManager(); + const view1 = await mgr1.createForBook(book.id, "basic"); + await mgr1.sendMessage(view1.id, "测试输入"); + + const mgr2 = new SessionManager(); + const view2 = await mgr2.openBook(book.id); + + expect(view2.id).toBe(view1.id); + expect(view2.resumed).toBe(true); + expect(view2.hints.some((h) => h.includes("恢复"))).toBe(true); + expect(view2.messages.some((m) => m.text === "测试输入")).toBe(true); + expect(getBook(book.id)?.activeSessionId).toBe(view1.id); + }); + + it("deleteBook removes session snapshot", async () => { + const { createBook, deleteBook } = await import("../src/book/store.js"); + const { hasBookSession } = await import("../src/book/session-store.js"); + const { SessionManager } = await import("../src/server/session-manager.js"); + + const book = createBook({ title: "删除测试" }); + + const mgr = new SessionManager(); + await mgr.createForBook(book.id, "basic"); + expect(hasBookSession(book.id)).toBe(true); + + deleteBook(book.id); + expect(hasBookSession(book.id)).toBe(false); + }); + + it("createForBook captures startup inquiry in messages", async () => { + const { createBook, deleteBook } = await import("../src/book/store.js"); + const { SessionManager } = await import("../src/server/session-manager.js"); + + const book = createBook({ title: "启动询问" }); + + const mgr = new SessionManager(); + const view = await mgr.createForBook(book.id, "roleplay-game-theory"); + expect(view.waitingReason?.kind).toBe("intake"); + expect(view.messages.some((m) => m.text?.includes("角色扮演博弈"))).toBe(true); + expect(view.messages.some((m) => m.kind === "orchestrator_prompt")).toBe(true); + + deleteBook(book.id); + }); +}); diff --git a/tests/snapshot-filters.test.ts b/tests/snapshot-filters.test.ts new file mode 100644 index 0000000..bf13681 --- /dev/null +++ b/tests/snapshot-filters.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + filterBlackboardForInstance, + isRunPhaseBlackboardTag, + materializeInstanceSnapshotPayload, +} from "../src/book/snapshot-filters.js"; +import { createSession } from "../src/runtime/phase-machine.js"; + +describe("snapshot-filters", () => { + it("keeps instance tags, strips run tags", () => { + expect(isRunPhaseBlackboardTag("情境.实验.设定")).toBe(false); + expect(isRunPhaseBlackboardTag("角色.A.设定")).toBe(false); + expect(isRunPhaseBlackboardTag("博弈.规则.草稿")).toBe(false); + expect(isRunPhaseBlackboardTag("运行.事件流")).toBe(true); + expect(isRunPhaseBlackboardTag("角色.A.思考")).toBe(true); + expect(isRunPhaseBlackboardTag("世界.当前状态")).toBe(true); + }); + + it("materializeInstanceSnapshotPayload removes run blackboard items", () => { + const session = createSession("default"); + session.slots.startupCompleted = true; + session.artifacts = [ + { + id: "a1", + workerId: "setup-scenario", + outputTags: ["情境.实验.设定"], + status: "accepted", + createdAt: "", + updatedAt: "", + }, + { + id: "a2", + workerId: "world-engine", + outputTags: ["运行.事件流"], + status: "accepted", + createdAt: "", + updatedAt: "", + }, + ]; + + const { blackboardItems, runtimeSession } = materializeInstanceSnapshotPayload({ + runtimeSession: session, + blackboardItems: [ + { id: "1", tag: "情境.实验.设定", content: "x", source: "worker", createdAt: "", updatedAt: "" }, + { id: "2", tag: "运行.事件流", content: "y", source: "worker", createdAt: "", updatedAt: "" }, + { id: "3", tag: "角色.A.设定", content: "z", source: "worker", createdAt: "", updatedAt: "" }, + ], + }); + + expect(blackboardItems.map((i) => i.tag).sort()).toEqual([ + "情境.实验.设定", + "角色.A.设定", + ]); + expect(runtimeSession.artifacts).toHaveLength(1); + expect(runtimeSession.artifacts[0].workerId).toBe("setup-scenario"); + expect(filterBlackboardForInstance).toBeDefined(); + }); +}); diff --git a/tests/token-usage.test.ts b/tests/token-usage.test.ts new file mode 100644 index 0000000..a11de32 --- /dev/null +++ b/tests/token-usage.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { parseUsage } from "../src/llm/client.js"; +import { + recordTokenUsage, + summarizeTokenUsage, +} from "../src/stats/token-store.js"; +import { TokenTrackingProvider, type LlmTrackingContext } from "../src/llm/token-tracker.js"; +import type { CompleteResult, LlmProvider } from "../src/llm/client.js"; + +describe("parseUsage", () => { + it("parses standard OpenAI usage", () => { + const usage = parseUsage({ + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }); + expect(usage).toEqual({ + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }); + }); + + it("parses OpenAI cached_tokens via prompt_tokens_details", () => { + const usage = parseUsage({ + prompt_tokens: 1000, + completion_tokens: 20, + total_tokens: 1020, + prompt_tokens_details: { cached_tokens: 800 }, + }); + expect(usage?.cachedTokens).toBe(800); + expect(usage?.cacheMissTokens).toBeUndefined(); + }); + + it("parses DeepSeek cache hit and miss fields", () => { + const usage = parseUsage({ + prompt_tokens: 500, + completion_tokens: 30, + total_tokens: 530, + prompt_cache_hit_tokens: 400, + prompt_cache_miss_tokens: 100, + }); + expect(usage?.cachedTokens).toBe(400); + expect(usage?.cacheMissTokens).toBe(100); + }); + + it("returns undefined for empty usage", () => { + expect(parseUsage(null)).toBeUndefined(); + expect(parseUsage({})).toBeUndefined(); + }); +}); + +describe("TokenTrackingProvider", () => { + it("stores pending usage with cache fields on context", async () => { + const inner: LlmProvider = { + async complete(): Promise { + return { + content: "ok", + model: "test-model", + usage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + cachedTokens: 8, + cacheMissTokens: 2, + }, + }; + }, + }; + + const ctx: LlmTrackingContext = { sessionId: "sess-1" }; + const provider = new TokenTrackingProvider(inner, () => ctx); + await provider.complete([], { caller: "worker:write-rules" }); + + expect(ctx.pendingUsage).toMatchObject({ + totalTokens: 15, + cachedTokens: 8, + cacheMissTokens: 2, + caller: "worker:write-rules", + model: "test-model", + }); + expect(ctx.pendingUsage?.recordId).toBeTruthy(); + }); + + it("stores pending reasoning when usage is absent", async () => { + const inner: LlmProvider = { + async complete(): Promise { + return { + content: "ok", + model: "test-model", + reasoning: " chain of thought ", + }; + }, + }; + + const ctx: LlmTrackingContext = { sessionId: "sess-2" }; + const provider = new TokenTrackingProvider(inner, () => ctx); + await provider.complete([], { caller: "worker:test" }); + + expect(ctx.pendingUsage).toBeUndefined(); + expect(ctx.pendingReasoning).toBe("chain of thought"); + }); +}); + +describe("summarizeTokenUsage", () => { + it("aggregates cache totals in byCallerDetailed", () => { + const sessionId = `test-${Date.now()}`; + recordTokenUsage({ + sessionId, + caller: "main_agent", + model: "m", + promptTokens: 100, + completionTokens: 10, + totalTokens: 110, + cachedTokens: 50, + }); + recordTokenUsage({ + sessionId, + caller: "worker:write-rules", + model: "m", + promptTokens: 200, + completionTokens: 20, + totalTokens: 220, + cachedTokens: 100, + cacheMissTokens: 100, + }); + + const updated = summarizeTokenUsage({ sessionId, limit: 10 }); + expect(updated.totalCalls).toBe(2); + expect(updated.totalCached).toBe(150); + expect(updated.totalCacheMiss).toBe(100); + expect(updated.byCallerDetailed["main_agent"]).toMatchObject({ + totalTokens: 110, + cachedTokens: 50, + calls: 1, + }); + expect(updated.byCallerDetailed["worker:write-rules"]).toMatchObject({ + totalTokens: 220, + cachedTokens: 100, + cacheMissTokens: 100, + calls: 1, + }); + }); +}); diff --git a/tests/tool-loop.test.ts b/tests/tool-loop.test.ts new file mode 100644 index 0000000..0c442e9 --- /dev/null +++ b/tests/tool-loop.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + createMockMainAgentResponse, + createMockToolCall, + MockLlmProvider, +} from "../src/llm/client.js"; +import { runMainAgentToolLoop } from "../src/main-agent/tool-loop.js"; +import { toolCallToDecision } from "../src/runtime/tool-registry.js"; +import { createSession } from "../src/runtime/phase-machine.js"; + +describe("main agent tool loop", () => { + const baseContext = { + session: createSession("default"), + blackboardIndex: [{ id: "1", tag: "book.brief", source: "user" }], + availableWorkers: [{ id: "outline", description: "生成大纲" }], + }; + + const handlers = { + readBlackboard: (tags: string[]) => + Object.fromEntries(tags.map((t) => [t, t === "book.brief" ? "科幻中篇" : ""])), + listWorkers: () => [{ id: "outline", description: "生成大纲" }], + listArtifacts: () => [], + }; + + it("runs loop tools then returns terminal decision", async () => { + const llm = new MockLlmProvider([ + createMockToolCall("read_blackboard", { tags: ["book.brief"] }), + createMockToolCall("run_worker", { + workerId: "outline", + reason: "需求已齐,生成大纲", + requiresApproval: true, + }), + ]); + + const result = await runMainAgentToolLoop(llm, baseContext, handlers); + expect(result.iterations).toBe(2); + expect(result.toolTrace).toEqual(["read_blackboard", "run_worker (terminal)"]); + expect(result.decision.action).toBe("run_worker"); + expect(result.decision.workerId).toBe("outline"); + expect(result.decision.requiresApproval).toBe(true); + }); + + it("falls back to JSON content when no tool calls", async () => { + const llm = new MockLlmProvider([ + createMockMainAgentResponse({ + action: "ask_user", + reason: "请补充篇幅", + requiresApproval: false, + }), + ]); + + const result = await runMainAgentToolLoop(llm, baseContext, handlers); + expect(result.decision.action).toBe("ask_user"); + expect(result.decision.reason).toContain("篇幅"); + }); + + it("maps finish tool to decision", () => { + const decision = toolCallToDecision({ + id: "c1", + name: "finish", + arguments: JSON.stringify({ reason: "完成" }), + }); + expect(decision.action).toBe("finish"); + expect(decision.reason).toBe("完成"); + }); +}); diff --git a/tests/worker-inputs.test.ts b/tests/worker-inputs.test.ts new file mode 100644 index 0000000..1a6d3f3 --- /dev/null +++ b/tests/worker-inputs.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { gatherWorkerInputs } from "../src/worker/executor.js"; + +describe("worker input gathering", () => { + it("merges userInputs fallback when demand tag missing from blackboard", () => { + const blackboard = new Blackboard(); + const inputs = gatherWorkerInputs( + ["用户.博弈需求", "用户.worker答复"], + "latest", + { + userInputs: ["德州扑克", "需要思考标签"], + }, + blackboard, + ); + expect(inputs["用户.博弈需求"]).toBe("德州扑克\n\n需要思考标签"); + }); + + it("prefers blackboard and slots over userInputs fallback", () => { + const blackboard = new Blackboard(); + blackboard.write({ + tag: "用户.博弈需求", + content: "来自黑板", + source: "user", + }); + const inputs = gatherWorkerInputs( + ["用户.博弈需求"], + "latest", + { + "用户.博弈需求": "来自 slots", + userInputs: ["旧输入"], + }, + blackboard, + ); + expect(inputs["用户.博弈需求"]).toBe("来自 slots"); + }); + + it("falls back to userInputs transcript when demand slot empty", () => { + const blackboard = new Blackboard(); + const longMsg = + "1.德州扑克 2.角色:赌徒、职业玩家 3.终止:有人破产 4.规则:标准德州 5.要看思考"; + const inputs = gatherWorkerInputs( + ["用户.博弈需求"], + "latest", + { userInputs: [longMsg] }, + blackboard, + ); + expect(inputs["用户.博弈需求"]).toBe(longMsg); + }); +}); diff --git a/tests/worker-llm.test.ts b/tests/worker-llm.test.ts new file mode 100644 index 0000000..6e7fcbd --- /dev/null +++ b/tests/worker-llm.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { + filterInputsForRolePerspective, + isRoleUserOnlyTag, +} from "../src/skills/worker-llm.js"; +import type { ParsedWorkerSkill } from "../src/skills/types.js"; +import { resolveWorkerLlmProvider } from "../src/skills/worker-llm.js"; +import { MockLlmProvider } from "../src/llm/client.js"; + +describe("filterInputsForRolePerspective", () => { + it("keeps only current role tags when 世界.当前角色.id is set", () => { + const inputs = { + "世界.当前角色.id": "A", + "角色.A.可见信息": "hand A", + "角色.B.可见信息": "hand B", + "场景.公开叙述": "public", + }; + const filtered = filterInputsForRolePerspective(inputs, { + "世界.当前角色.id": "A", + }); + expect(filtered["角色.A.可见信息"]).toBe("hand A"); + expect(filtered["角色.B.可见信息"]).toBeUndefined(); + expect(filtered["场景.公开叙述"]).toBe("public"); + }); + + it("strips thinking tags even for current role", () => { + const inputs = { + "角色.A.可见信息": "hand A", + "角色.A.思考": "inner monologue", + "角色.A.推理.候选": "legacy thinking", + "角色.B.行动": "should not see", + }; + const filtered = filterInputsForRolePerspective(inputs, { + "世界.当前角色.id": "A", + }); + expect(filtered["角色.A.可见信息"]).toBe("hand A"); + expect(filtered["角色.A.思考"]).toBeUndefined(); + expect(filtered["角色.A.推理.候选"]).toBeUndefined(); + expect(filtered["角色.B.行动"]).toBeUndefined(); + }); + + it("isRoleUserOnlyTag detects thinking suffixes", () => { + expect(isRoleUserOnlyTag("角色.A.思考")).toBe(true); + expect(isRoleUserOnlyTag("角色.A.推理.候选")).toBe(true); + expect(isRoleUserOnlyTag("角色.A.行动")).toBe(false); + }); +}); + +describe("resolveWorkerLlmProvider", () => { + const fallback = new MockLlmProvider([]); + + it("returns fallback when no binding", () => { + const worker: ParsedWorkerSkill = { + id: "role-decide", + skill: "roleplay-game-theory", + name: "x", + description: "", + version: 1, + inputTags: [], + outputTags: [], + path: "", + body: "", + }; + expect( + resolveWorkerLlmProvider({ + worker, + slots: { "世界.当前角色.id": "A" }, + fallbackLlm: fallback, + }), + ).toBe(fallback); + }); + + it("uses byRole when bindings match", () => { + const worker: ParsedWorkerSkill = { + id: "role-decide", + skill: "roleplay-game-theory", + name: "x", + description: "", + version: 1, + inputTags: [], + outputTags: [], + path: "", + body: "", + }; + const resolved = resolveWorkerLlmProvider({ + worker, + bindings: { + workers: { + "role-decide": { + byRole: { A: "nonexistent-profile-id" }, + }, + }, + }, + slots: { "世界.当前角色.id": "A" }, + fallbackLlm: fallback, + }); + // profile 不存在时回退 fallback + expect(resolved).toBe(fallback); + }); +}); diff --git a/tests/worker-resolve.test.ts b/tests/worker-resolve.test.ts new file mode 100644 index 0000000..fbdb907 --- /dev/null +++ b/tests/worker-resolve.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { resolveWorkerId } from "../src/worker/resolve-id.js"; + +describe("resolveWorkerId", () => { + it("maps legacy ids to skill worker ids", () => { + expect(resolveWorkerId("rules-worker")).toBe("write-rules"); + expect(resolveWorkerId("outline-worker")).toBe("outline"); + expect(resolveWorkerId("write-rules")).toBe("write-rules"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b869de3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..19384e8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/web/agent-ui.js b/web/agent-ui.js new file mode 100644 index 0000000..19c785c --- /dev/null +++ b/web/agent-ui.js @@ -0,0 +1,276 @@ +import { renderIntakePanel } from "./intake-ui.js"; + +const HIDE_KINDS = new Set(["worker_stub"]); + +const MSG_CLASS = { + user_input: "user", + agent_tool: "agent", + orchestrator_decision: "agent", + orchestrator_prompt: "agent", + worker_running: "skill", + worker_output: "skill", + worker_questions: "skill", + error: "error", +}; + +const MSG_LABEL = { + user_input: "你", + agent_tool: "Tool", + orchestrator_decision: "Agent", + orchestrator_prompt: "Agent", + worker_running: "Skill", + worker_output: "Skill", + worker_questions: "Skill 提问", + error: "错误", + system_info: "系统", +}; + +function esc(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function fmtTime(iso) { + if (!iso) return ""; + return new Date(iso).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); +} + +function msgLabel(msg) { + const k = msg.kind ?? (msg.role === "user" ? "user_input" : "system_info"); + if (k.startsWith("worker") && msg.actor) return `Skill · ${msg.actor}`; + return MSG_LABEL[k] ?? k; +} + +function msgBody(msg, view) { + let body = (msg.body ?? msg.text ?? "").trim(); + if (msg.kind === "worker_questions" && !body) { + const wr = view.waitingReason; + if (wr?.kind === "worker_questions") { + const qs = (wr.questions ?? []).filter(Boolean); + if (qs.length) body = qs.map((q) => `- ${q}`).join("\n"); + } + if (!body) body = "请补充当前 skill 需要的信息。"; + } + return body; +} + +export function renderSkillPicker(view, onPick) { + const el = document.getElementById("skill-picker"); + if (!el) return; + const selecting = view.waitingReason?.kind === "skill_selection"; + const skills = view.skills ?? []; + if (!selecting || !skills.length) { + el.hidden = true; + el.innerHTML = ""; + return; + } + el.hidden = false; + el.innerHTML = ` +

选择 skill 包

+

Agent 需要知道你要用哪套能力。选一项开始实例化(也可在下方输入名称或编号)。

+
`; + const grid = el.querySelector("#skill-grid"); + skills.forEach((skill, i) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "skill-card"; + btn.innerHTML = ` +
${esc(skill.name)}
+
${esc(skill.description || "无说明")}
+
${esc(skill.category || "")} · #${i + 1}
`; + btn.addEventListener("click", () => onPick(skill.name)); + grid.appendChild(btn); + }); +} + +export function renderLifecycle(view) { + const toggle = document.getElementById("lifecycle-toggle"); + if (!toggle) return; + const stage = view.lifecycleStage ?? "design"; + document.body.dataset.lifecycle = stage; + toggle.querySelectorAll("[data-stage]").forEach((btn) => { + const s = btn.getAttribute("data-stage"); + btn.classList.toggle("active", s === stage); + if (s === "play") { + btn.disabled = !view.playReady; + btn.title = view.playReady ? "" : "实例就绪后可切换"; + } else { + btn.disabled = false; + } + }); +} + +export function renderSkillGuide(view) { + const wrap = document.getElementById("skill-guide"); + const list = document.getElementById("skill-guide-list"); + if (!wrap || !list) return; + const show = view.lifecycleStage === "design" && (view.skillCatalog?.length ?? 0) > 0; + if (!show) { + wrap.hidden = true; + return; + } + wrap.hidden = false; + list.innerHTML = view.skillCatalog + .map( + (s) => ` +
+ +
+
${esc(s.label)} ${esc(s.id)}
+
${esc(s.purpose)}
+
+
`, + ) + .join(""); +} + +export function renderAgentPanel(view, loading) { + const focusEl = document.getElementById("agent-focus"); + const traceEl = document.getElementById("tool-trace"); + const timelineEl = document.getElementById("agent-timeline"); + const badgeEl = document.getElementById("burst-badge"); + if (!focusEl || !timelineEl) return; + + const f = view.focus; + if (f) { + focusEl.innerHTML = ` +
${esc(f.actorLabel)}
+
${esc(loading ? "处理中…" : f.action)}
+ ${f.detail ? `
${esc(f.detail)}
` : ""}`; + } else { + focusEl.innerHTML = `
待命
`; + } + + if (badgeEl && view.burst) { + const show = view.burst.count > 0 || view.phase === "running"; + badgeEl.hidden = !show; + badgeEl.textContent = `burst ${view.burst.count}/${view.burst.max}`; + } + + if (traceEl) { + const trace = view.toolTrace ?? []; + if (!trace.length) { + traceEl.hidden = true; + traceEl.innerHTML = ""; + } else { + traceEl.hidden = false; + traceEl.innerHTML = trace + .map( + (t) => ` +
+ ${esc(t.name)} + ${fmtTime(t.at)} +
${esc(t.summary || "—")}
+
`, + ) + .join(""); + } + } + + const items = []; + for (const msg of view.messages ?? []) { + if (msg.role === "user") { + items.push({ kind: "user_input", title: "你", body: msg.text, at: msg.createdAt }); + continue; + } + const kind = msg.kind ?? "system_info"; + if (HIDE_KINDS.has(kind)) continue; + items.push({ + kind, + title: msg.title ?? msgLabel(msg), + body: msgBody(msg, view).slice(0, 160), + at: msg.createdAt, + }); + } + if (loading && f) { + items.push({ kind: "pending", title: f.action, body: f.detail ?? "", at: "" }); + } + if (!items.length) { + timelineEl.innerHTML = `

调度记录将出现在这里

`; + return; + } + timelineEl.innerHTML = items + .map( + (it) => ` +
+ ${esc(it.title)} + ${fmtTime(it.at)} +
${esc(it.body)}
+
`, + ) + .join(""); + timelineEl.scrollTop = timelineEl.scrollHeight; +} + +export function renderMessageFeed(view, loading) { + const feed = document.getElementById("message-feed"); + if (!feed) return; + + const intake = + view.waitingReason?.kind === "intake" && view.intake?.fields?.length; + const selecting = view.waitingReason?.kind === "skill_selection"; + + feed.innerHTML = ""; + + if (intake) { + const box = document.createElement("div"); + box.className = "intake-panel"; + box.innerHTML = renderIntakePanel(view.intake, { variant: "feed" }); + feed.appendChild(box); + } + + const visible = (view.messages ?? []).filter((m) => { + if (m.role === "user") return true; + return !HIDE_KINDS.has(m.kind ?? ""); + }); + + if (!visible.length && !loading && !intake) { + const p = document.createElement("p"); + p.className = "empty"; + if (selecting) { + p.textContent = "在上方选择 skill 包,或在下方输入 skill 名称。"; + } else if (view.lifecycleStage === "play") { + p.textContent = "游玩模式:Agent 将调度 run skill,推进世界与叙事。"; + } else { + p.textContent = "创作模式:与 Agent 对话,完成实例化后可切到游玩。"; + } + feed.appendChild(p); + return; + } + + for (const msg of visible) { + const isUser = msg.role === "user"; + const kind = msg.kind ?? (isUser ? "user_input" : "system_info"); + const card = document.createElement("article"); + card.className = `msg ${MSG_CLASS[kind] ?? "system"}`; + card.innerHTML = ` +
+ ${esc(msgLabel(msg))} + ${esc(msg.title ?? "")} + ${fmtTime(msg.createdAt)} +
+
${esc(msgBody(msg, view))}
`; + feed.appendChild(card); + } + + if (loading) { + const pending = document.createElement("article"); + pending.className = "msg agent msg-pending"; + pending.innerHTML = ` +
进行中${esc(view.focus?.action ?? "处理中")}
+
`; + feed.appendChild(pending); + } + + feed.scrollTop = feed.scrollHeight; +} + +export function renderWorkspace(view, loading, onPickSkill) { + renderLifecycle(view); + renderSkillPicker(view, onPickSkill); + renderMessageFeed(view, loading); + renderSkillGuide(view); + renderAgentPanel(view, loading); +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..960a8f2 --- /dev/null +++ b/web/app.js @@ -0,0 +1,459 @@ +import { renderWorkspace } from "./agent-ui.js"; +import { downloadMarkdown, sessionToMarkdown } from "./export.js"; +import { renderIntakePanel } from "./intake-ui.js"; + +let sessionId = null; +let activeBookId = null; +let lastView = null; +let books = []; +let composerForceInput = false; + +const $ = (id) => document.getElementById(id); + +const PHASE = { idle: "待命", running: "执行中", waiting_user: "等待你", done: "已完成", error: "出错" }; +const REASON = { + skill_selection: "选择 skill", + intake: "填写需求", + input: "补充说明", + worker_questions: "回答提问", + approve_step: "确认执行", + review_artifact: "验收产物", +}; + +async function api(path, options = {}) { + const res = await fetch(path, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "请求失败"); + return data; +} + +function esc(s) { + return String(s).replace(/&/g, "&").replace(//g, ">"); +} + +function statusFor(view, loading) { + if (loading) return { cls: "running", text: "处理中…" }; + if (view.phase === "done") return { cls: "done", text: "已完成" }; + if (view.phase === "running" && !view.waitingReason) { + return { cls: "running", text: view.focus?.action ?? "Agent 运行中" }; + } + if (view.waitingReason) { + return { cls: "waiting", text: REASON[view.waitingReason.kind] ?? view.waitingReason.kind }; + } + return { cls: "", text: PHASE[view.phase] ?? view.phase }; +} + +function isAgentBusy(view, loading) { + if (loading) return true; + return view.phase === "running" && !view.waitingReason; +} + +function resolveComposer(view, loading) { + if (view.phase === "done") return { mode: "idle", text: "会话已结束" }; + if (isAgentBusy(view, loading)) { + return { mode: "waiting", text: view.focus?.action ?? "Agent 或 Skill 执行中…" }; + } + + const confirmIntake = view.actions?.find((a) => a.type === "confirm_intake"); + if (view.waitingReason?.kind === "intake" && view.intake) { + const send = view.actions?.find((a) => a.type === "send_message"); + return { + mode: view.intake.ready && confirmIntake ? "intake_ready" : "intake", + intake: view.intake, + intakePrompt: view.intakePrompt, + confirmIntake, + placeholder: send?.placeholder ?? "补充信息…", + }; + } + + const approve = view.actions?.find((a) => a.type === "approve"); + const accept = view.actions?.find((a) => a.type === "accept"); + if ((approve || accept) && !composerForceInput) { + return { + mode: "action", + primary: approve ?? accept, + primaryType: approve ? "approve" : "accept", + hint: approve ? view.focus?.detail : "验收 Skill 产出", + showReject: true, + }; + } + + const send = view.actions?.find((a) => a.type === "send_message"); + if ( + send || + view.waitingReason?.kind === "input" || + view.waitingReason?.kind === "skill_selection" || + view.waitingReason?.kind === "worker_questions" || + composerForceInput + ) { + let hint = view.hints?.[0] ?? null; + if (view.waitingReason?.kind === "skill_selection") { + hint = "选择 skill 包,或输入名称 / 编号"; + } + return { mode: "input", placeholder: send?.placeholder ?? "输入消息…", hint }; + } + + const finish = view.actions?.find((a) => a.type === "finish"); + if (finish) { + return { mode: "action", primary: finish, primaryType: "finish", hint: null, showReject: false }; + } + + return { mode: "idle", text: "暂无可用操作" }; +} + +function renderComposer(view, loading) { + const root = $("composer"); + if (!root) return; + const spec = resolveComposer(view, loading); + + if (spec.mode === "waiting") { + root.innerHTML = `
${esc(spec.text)}
`; + return; + } + if (spec.mode === "idle") { + root.innerHTML = `
${esc(spec.text)}
`; + return; + } + + if (spec.mode === "intake" || spec.mode === "intake_ready") { + const confirm = + spec.mode === "intake_ready" && spec.confirmIntake + ? `
` + : ""; + root.innerHTML = ` + ${spec.intakePrompt ? `

${esc(spec.intakePrompt)}

` : ""} +
${renderIntakePanel(spec.intake, { variant: "composer" })}
+ ${confirm} +
+ + +
`; + wireComposerForm(); + root.querySelector("[data-act=confirm_intake]")?.addEventListener("click", () => runAction("confirm_intake")); + return; + } + + if (spec.mode === "action") { + const reject = spec.showReject + ? ` + ` + : ""; + root.innerHTML = ` + ${spec.hint ? `

${esc(spec.hint)}

` : ""} +
+ + ${reject} +
`; + root.querySelector(`[data-act="${spec.primaryType}"]`)?.addEventListener("click", () => runAction(spec.primaryType)); + root.querySelector("[data-act=reject]")?.addEventListener("click", () => runAction("reject")); + root.querySelector("[data-act=force-input]")?.addEventListener("click", () => { + composerForceInput = true; + renderComposer(view, loading); + }); + return; + } + + root.innerHTML = ` + ${spec.hint ? `

${esc(spec.hint)}

` : ""} +
+ + +
`; + wireComposerForm(); +} + +function wireComposerForm() { + const form = $("composer-form"); + const input = $("composer-input"); + form?.addEventListener("submit", async (e) => { + e.preventDefault(); + await sendText(input?.value ?? ""); + }); + input?.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + form?.requestSubmit(); + } + }); + input?.focus(); +} + +function renderBookList() { + const list = $("book-list"); + if (!list) return; + if (!books.length) { + list.innerHTML = ``; + $("btn-new-inline")?.addEventListener("click", openNewBookDialog); + return; + } + list.innerHTML = ""; + for (const book of books) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `book-item${book.id === activeBookId ? " active" : ""}`; + const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "未选 skill"; + btn.innerHTML = ` +
${esc(book.title)}
+
${esc(skill)} · ${esc(book.preview?.slice(0, 40) || "")}
`; + btn.addEventListener("click", () => openBook(book.id)); + list.appendChild(btn); + } +} + +function renderHeader(view, loading) { + const st = statusFor(view, loading); + $("work-title").textContent = view.bookTitle ?? "未命名作品"; + const skill = view.activeSkill ?? "未选 skill"; + const phase = view.waitingReason + ? REASON[view.waitingReason.kind] ?? view.phase + : PHASE[view.phase] ?? view.phase; + $("work-meta").textContent = `${skill} · ${phase}`; + $("status-dot").className = `status-dot ${st.cls}`; + $("status-text").textContent = st.text; + $("btn-delete").hidden = !view.bookId; + $("btn-saves").hidden = !view.bookId; + $("btn-export").disabled = !(view.messages?.length); +} + +function renderEmpty() { + sessionId = null; + lastView = null; + activeBookId = null; + composerForceInput = false; + $("work-title").textContent = "未打开作品"; + $("work-meta").textContent = ""; + $("status-dot").className = "status-dot"; + $("status-text").textContent = "—"; + $("btn-delete").hidden = true; + $("btn-saves").hidden = true; + $("btn-export").disabled = true; + $("message-feed").innerHTML = `

点击左侧 + 新建作品

`; + $("skill-picker").hidden = true; + $("skill-guide").hidden = true; + $("agent-focus").innerHTML = ""; + $("agent-timeline").innerHTML = ""; + $("tool-trace").hidden = true; + document.body.dataset.lifecycle = "design"; + $("composer").innerHTML = `
暂无打开的作品
`; + renderBookList(); +} + +function renderSession(view, loading = false) { + lastView = view; + sessionId = view.id; + activeBookId = view.bookId ?? activeBookId; + if (!loading && !["approve_step", "review_artifact"].includes(view.waitingReason?.kind)) { + composerForceInput = false; + } + renderHeader(view, loading); + renderBookList(); + renderWorkspace(view, loading, (skillId) => sendText(skillId)); + renderComposer(view, loading); +} + +async function sendText(text) { + const trimmed = text?.trim(); + if (!trimmed || !sessionId) return; + try { + renderSession(lastView, true); + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { + method: "POST", + body: JSON.stringify({ text: trimmed }), + }); + renderSession(view, false); + } catch (err) { + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } +} + +async function runAction(action) { + if (!sessionId) return; + try { + renderSession(lastView, true); + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, { + method: "POST", + body: JSON.stringify({ action }), + }); + renderSession(view, false); + } catch (err) { + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } +} + +async function setLifecycle(stage) { + if (!sessionId || stage === lastView?.lifecycleStage) return; + if (stage === "play" && !lastView?.playReady) return; + try { + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/lifecycle`, { + method: "POST", + body: JSON.stringify({ stage }), + }); + renderSession(view, false); + } catch (err) { + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } +} + +async function loadBooks() { + const data = await api("/api/books"); + books = data.books ?? []; + renderBookList(); +} + +function openNewBookDialog() { + $("input-book-title").value = ""; + $("dialog-new-book").showModal(); + $("input-book-title").focus(); +} + +async function createBook() { + const title = $("input-book-title").value.trim() || "未命名作品"; + $("btn-create-book").disabled = true; + try { + const data = await api("/api/books", { + method: "POST", + body: JSON.stringify({ title }), + }); + $("dialog-new-book").close(); + if (data.book) books.unshift(data.book); + activeBookId = data.book?.id; + renderSession(data.session, false); + } catch (err) { + alert(err.message); + } finally { + $("btn-create-book").disabled = false; + } +} + +async function openBook(bookId) { + activeBookId = bookId; + renderBookList(); + if (lastView) renderSession(lastView, true); + try { + const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, { method: "POST" }); + if (data.book) { + const i = books.findIndex((b) => b.id === bookId); + if (i >= 0) books[i] = { ...books[i], ...data.book }; + } + renderSession(data.session, false); + } catch (err) { + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } +} + +async function deleteBook() { + if (!activeBookId || !lastView?.bookTitle) return; + if (!confirm(`删除「${lastView.bookTitle}」?不可恢复。`)) return; + try { + await api(`/api/books/${encodeURIComponent(activeBookId)}`, { method: "DELETE" }); + books = books.filter((b) => b.id !== activeBookId); + if (books.length) await openBook(books[0].id); + else renderEmpty(); + } catch (err) { + alert(err.message); + } +} + +function exportSession() { + if (!lastView) return; + const name = (lastView.bookTitle ?? "session").replace(/[\\/:*?"<>|]/g, "_"); + downloadMarkdown(`${name}.md`, sessionToMarkdown(lastView)); +} + +async function refreshSaves() { + if (!activeBookId) return; + const data = await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`); + const list = $("saves-list"); + const saves = data.saves ?? []; + if (!saves.length) { + list.innerHTML = `

暂无存档

`; + return; + } + list.innerHTML = saves + .map( + (s) => ` +
+ ${esc(s.label)} ${esc(s.kind)} + + + + +
`, + ) + .join(""); + list.querySelectorAll("[data-load]").forEach((btn) => { + btn.addEventListener("click", async () => { + try { + const data = await api( + `/api/books/${encodeURIComponent(activeBookId)}/saves/${encodeURIComponent(btn.getAttribute("data-load"))}/load`, + { method: "POST" }, + ); + $("dialog-saves").close(); + renderSession(data.session, false); + } catch (err) { + alert(err.message); + } + }); + }); + list.querySelectorAll("[data-del]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (!confirm("删除此存档?")) return; + await api( + `/api/books/${encodeURIComponent(activeBookId)}/saves/${encodeURIComponent(btn.getAttribute("data-del"))}`, + { method: "DELETE" }, + ); + refreshSaves(); + }); + }); +} + +async function createSave() { + const label = $("input-save-label").value.trim(); + const kind = document.querySelector('input[name="save-kind"]:checked')?.value ?? "run"; + if (!label) return alert("请输入名称"); + try { + await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, { + method: "POST", + body: JSON.stringify({ label, kind, sessionId }), + }); + $("input-save-label").value = ""; + refreshSaves(); + } catch (err) { + alert(err.message); + } +} + +$("btn-new-book")?.addEventListener("click", openNewBookDialog); +$("btn-new-inline")?.addEventListener("click", openNewBookDialog); +$("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close()); +$("form-new-book")?.addEventListener("submit", (e) => { + e.preventDefault(); + createBook(); +}); +$("lifecycle-toggle")?.addEventListener("click", (e) => { + const btn = e.target.closest("[data-stage]"); + if (!btn?.disabled) setLifecycle(btn.getAttribute("data-stage")); +}); +$("btn-delete")?.addEventListener("click", deleteBook); +$("btn-export")?.addEventListener("click", exportSession); +$("btn-saves")?.addEventListener("click", () => { + $("dialog-saves").showModal(); + refreshSaves(); +}); +$("btn-close-saves")?.addEventListener("click", () => $("dialog-saves").close()); +$("btn-save-create")?.addEventListener("click", createSave); + +async function init() { + try { + await loadBooks(); + if (books.length) await openBook(books[0].id); + else renderEmpty(); + } catch { + $("status-text").textContent = "加载失败"; + } +} + +init(); diff --git a/web/export.js b/web/export.js new file mode 100644 index 0000000..ce2fb8c --- /dev/null +++ b/web/export.js @@ -0,0 +1,108 @@ +/** 将会话消息导出为可读 Markdown 文本 */ + +function formatExportTime(iso) { + if (!iso) return ""; + const d = new Date(iso); + return d.toLocaleString("zh-CN", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +const KIND_LABELS = { + user_input: "用户输入", + orchestrator_decision: "总管决策", + orchestrator_prompt: "总管询问", + agent_tool: "Agent Tool", + worker_running: "Worker 执行", + worker_output: "Worker 产出", + worker_stub: "Worker 占位", + system_info: "系统", + error: "错误", +}; + +export function sessionToMarkdown(view) { + if (!view) return ""; + + const lines = []; + lines.push(`# ${view.bookTitle ?? "未命名作品"}`); + lines.push(""); + lines.push(`- Skill: \`${view.activeSkill ?? "—"}\``); + lines.push(`- 阶段: ${view.phase ?? "—"}`); + if (view.waitingReason?.kind) { + lines.push(`- 等待: ${view.waitingReason.kind}`); + } + lines.push(`- 导出时间: ${new Date().toLocaleString("zh-CN")}`); + lines.push(""); + + if (view.skillCatalog?.length) { + const stageLabel = view.lifecycleStage === "play" ? "游玩" : "创作"; + lines.push(`## ${stageLabel}能力清单`); + lines.push(""); + for (const skill of view.skillCatalog) { + const mark = + skill.status === "done" + ? "x" + : skill.status === "active" + ? ">" + : " "; + lines.push(`- [${mark}] **${skill.label}** (\`${skill.id}\`) — ${skill.purpose}`); + } + lines.push(""); + } else if (view.pipeline?.length) { + lines.push("## 流程进度"); + lines.push(""); + for (const step of view.pipeline) { + const mark = + step.status === "done" ? "x" : step.status === "active" ? ">" : " "; + lines.push(`- [${mark}] ${step.label}`); + } + lines.push(""); + } + + lines.push("## 对话与调度记录"); + lines.push(""); + + for (const msg of view.messages ?? []) { + const kind = msg.kind ?? (msg.role === "user" ? "user_input" : "system_info"); + const label = msg.title ?? KIND_LABELS[kind] ?? kind; + lines.push(`### ${label}`); + lines.push(""); + lines.push(`*${formatExportTime(msg.createdAt)}${msg.actor ? ` · ${msg.actor}` : ""}*`); + + if (msg.tokenUsage?.totalTokens) { + lines.push(`*Token: ${msg.tokenUsage.totalTokens}*`); + } + lines.push(""); + + if (msg.thinking?.trim()) { + lines.push("**思维链**"); + lines.push(""); + lines.push(msg.thinking.trim()); + lines.push(""); + } + + const body = (msg.body ?? msg.text ?? "").trim(); + if (body) { + lines.push(body); + lines.push(""); + } + + lines.push("---"); + lines.push(""); + } + + return lines.join("\n").trim() + "\n"; +} + +export function downloadMarkdown(filename, content) { + const blob = new Blob([content], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5965df6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,99 @@ + + + + + + Writing Agent + + + +
+ + +
+
+
+

未打开作品

+ +
+
+
+ + +
+
+
+ 设置 + + + + +
+
+ +
+
+ +
+

点击左侧 + 新建作品

+
+ +
+ + +
+ +
+
+
+ + +
+

新建作品

+

只需起名。打开后由 Agent 引导你选择 skill 包并开始实例化。

+ +
+ + +
+
+
+ + +
+

存档

+ +
+

实例 = 设计完成后的设定;进度 = 含运行状态的完整快照。

+
+ + + + +
+
+
+ + + + diff --git a/web/intake-ui.js b/web/intake-ui.js new file mode 100644 index 0000000..5415565 --- /dev/null +++ b/web/intake-ui.js @@ -0,0 +1,96 @@ +function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function intakeItemRow(f, { showEmpty = true } = {}) { + if (!f.filled && !showEmpty) return ""; + const status = f.filled ? "filled" : "empty"; + const preview = f.value + ? `${escapeHtml(f.value)}` + : `未填写`; + return `
  • + + ${escapeHtml(f.label)} + ${preview} +
  • `; +} + +function intakeListSection(title, items, countLabel, { showEmpty = true } = {}) { + if (!items.length) return ""; + const filled = items.filter((f) => f.filled).length; + const rows = items.map((f) => intakeItemRow(f, { showEmpty })).filter(Boolean); + if (!rows.length) return ""; + return `
    +
    + ${escapeHtml(title)} + ${filled}/${items.length} ${countLabel} +
    +
      ${rows.join("")}
    +
    `; +} + +function renderFilledSummary(fields) { + const filled = fields.filter((f) => f.filled && f.value); + if (!filled.length) return ""; + const body = filled + .map( + (f) => + `
    +

    ${escapeHtml(f.label)}

    +
    ${escapeHtml(f.value)}
    +
    `, + ) + .join(""); + return `
    + 已填写内容 ${filled.length} 项 +
    ${body}
    +
    `; +} + +function renderOptionalBlock(optional) { + if (!optional.length) return ""; + const filled = optional.filter((f) => f.filled).length; + const open = filled > 0 ? " open" : ""; + const rows = optional.map((f) => intakeItemRow(f, { showEmpty: true })).join(""); + return `
    + 可选项(选填) ${filled}/${optional.length} 已填 +
    +

    以下可补充;不填也可在确认后进入实例化。

    +
      ${rows}
    +
    +
    `; +} + +/** + * @param {object | undefined} intake + * @param {{ variant?: "composer" | "feed" }} [options] + */ +export function renderIntakePanel(intake, options = {}) { + if (!intake?.fields?.length) return ""; + const variant = options.variant ?? "composer"; + const required = intake.fields.filter((f) => f.required); + const optional = intake.fields.filter((f) => !f.required); + const requiredSection = intakeListSection("必要项", required, "已填"); + const filledSummary = renderFilledSummary(intake.fields); + const optionalBlock = renderOptionalBlock(optional); + const headBadge = + required.length > 0 + ? `${intake.requiredFilled}/${intake.requiredTotal} 必要项` + : ""; + + const className = + variant === "feed" ? "intake-panel intake-panel-feed" : "intake-panel"; + + return `
    +
    + 填空进度 + ${headBadge} +
    + ${requiredSection} + ${filledSummary} + ${optionalBlock} +
    `; +} diff --git a/web/settings.css b/web/settings.css new file mode 100644 index 0000000..e0769da --- /dev/null +++ b/web/settings.css @@ -0,0 +1,420 @@ +.settings-sidebar { + min-width: var(--sidebar-width); +} + +.settings-content { + max-width: 720px; + width: 100%; +} + +.active-settings-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 24px; + background: var(--surface-hover); + border-bottom: 1px solid var(--border); + font-size: 0.8125rem; +} + +.active-settings-bar .label { + color: var(--text-tertiary); + margin-right: 4px; +} + +.active-settings-bar .active-tag { + padding: 3px 10px; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + font-weight: 500; +} + +.active-settings-bar .active-tag.missing { + color: var(--danger); + border-color: rgba(245, 74, 69, 0.35); +} + +.settings-toast { + margin: 0 24px 0; + padding: 10px 14px; + border-radius: var(--radius-md); + background: rgba(0, 181, 120, 0.12); + color: #00875a; + font-size: 0.82rem; +} + +.settings-toast.error { + background: rgba(245, 74, 69, 0.1); + color: var(--danger); +} + +.config-card-actions .btn-activate { + background: var(--text) !important; + color: #fff !important; + border: none !important; + border-radius: 999px; + padding: 6px 16px; + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; +} + +.config-card-actions .btn-activate:hover { + background: #000 !important; +} + +.config-card-actions .btn-active-label { + background: var(--accent-soft); + color: var(--accent); + border: none; + border-radius: 999px; + padding: 6px 14px; + font-size: 0.78rem; + cursor: default; +} + +.import-report-actions { + margin-top: 10px; + display: flex; + gap: 8px; +} + +.panel-body { + flex: 1; + overflow-y: auto; + padding: 24px 32px 40px; + background: var(--surface); + display: flex; + justify-content: center; +} + +.settings-section.hidden { + display: none; +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-nav-icon { + font-size: 0.9rem; + opacity: 0.85; +} + +.card-list { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 720px; +} + +.config-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.config-card:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); +} + +.config-card.active { + border-color: rgba(51, 112, 255, 0.45); + box-shadow: 0 0 0 1px var(--accent-soft), 0 4px 16px rgba(51, 112, 255, 0.08); +} + +.config-card-head { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.config-card-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 0.85rem; + flex-shrink: 0; + color: #fff; + font-weight: 700; +} + +.config-card-icon.api { + background: linear-gradient(135deg, #6b9fff, var(--agent-blue)); +} + +.config-card-icon.preset { + background: linear-gradient(135deg, #ff85c0, var(--agent-pink)); +} + +.config-card-info { + flex: 1; + min-width: 0; +} + +.config-card-title { + font-weight: 600; + font-size: 0.9rem; +} + +.config-card-meta { + color: var(--text-tertiary); + font-size: 0.78rem; + word-break: break-all; + margin-top: 4px; + line-height: 1.5; +} + +.config-card-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding-left: 48px; +} + +.config-card-actions button:not(.btn-danger) { + background: var(--surface-hover); + color: var(--text); + border: none; + border-radius: 999px; + padding: 5px 14px; + font-size: 0.78rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.config-card-actions button:not(.btn-danger):hover { + background: var(--accent-soft); + color: var(--accent); +} + +.test-result { + font-size: 0.78rem; + margin-top: 2px; + padding-left: 48px; +} + +.test-result.ok { + color: var(--success); +} + +.test-result.fail { + color: var(--danger); +} + +.import-report { + margin-top: 16px; + padding: 14px 16px; + background: var(--bubble); + border-radius: var(--radius-md); + font-size: 0.76rem; + overflow-x: auto; + white-space: pre-wrap; + color: var(--text-secondary); + max-width: 720px; + line-height: 1.6; +} + +.preset-entries-panel { + margin-top: 4px; + padding-left: 48px; +} + +.preset-entries-inner { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-hover); + padding: 12px 14px; +} + +.preset-gen-params { + font-size: 0.76rem; + color: var(--text-secondary); + margin-bottom: 10px; + line-height: 1.5; +} + +.preset-entries-summary { + font-size: 0.74rem; + color: var(--text-tertiary); + margin: 0 0 10px; +} + +.preset-entry { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin-bottom: 8px; +} + +.preset-entry:last-child { + margin-bottom: 0; +} + +.preset-entry.skipped { + opacity: 0.72; +} + +.preset-entry-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 0.78rem; +} + +.entry-index { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + display: grid; + place-items: center; + font-size: 0.68rem; + font-weight: 700; + flex-shrink: 0; +} + +.entry-role { + padding: 2px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; +} + +.entry-role.role-system { + background: rgba(51, 112, 255, 0.12); + color: var(--accent); +} + +.entry-role.role-user { + background: rgba(0, 181, 120, 0.12); + color: var(--mention); +} + +.entry-role.role-assistant { + background: rgba(114, 46, 209, 0.12); + color: var(--agent-purple); +} + +.entry-name { + font-weight: 600; + color: var(--text); +} + +.entry-skip { + font-size: 0.68rem; + color: var(--text-tertiary); +} + +.preset-entry-content { + margin: 0; + padding: 10px 12px; + background: var(--bubble); + border-radius: var(--radius-sm); + font-size: 0.76rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + max-height: 240px; + overflow: auto; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; +} + +.preset-entry-empty { + margin: 0; + font-size: 0.74rem; + color: var(--text-tertiary); + font-style: italic; +} + +.profile-dialog { + border: none; + border-radius: var(--radius-lg); + padding: 0; + background: var(--surface); + color: var(--text); + max-width: 420px; + width: calc(100% - 32px); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15); +} + +.profile-dialog::backdrop { + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); +} + +.profile-dialog form { + padding: 24px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.profile-dialog h3 { + margin: 0 0 4px; + font-size: 1rem; + font-weight: 600; +} + +.profile-dialog label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.78rem; + color: var(--text-secondary); + font-weight: 500; +} + +.profile-dialog input { + padding: 10px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + font-size: 0.875rem; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.profile-dialog input:focus { + outline: none; + border-color: rgba(51, 112, 255, 0.5); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +@media (max-width: 900px) { + .config-card-actions { + padding-left: 0; + } + + .test-result { + padding-left: 0; + } + + .preset-entries-panel { + padding-left: 0; + } +} diff --git a/web/settings.html b/web/settings.html new file mode 100644 index 0000000..81591ee --- /dev/null +++ b/web/settings.html @@ -0,0 +1,105 @@ + + + + + + 设置 · Writing Agent + + + + + + + +
    + + +
    + + +
    +
    +
    +

    API 配置

    + 管理 LLM 连接 +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    +

    新增 API 配置

    + + + + +
    + + + +
    +
    +
    + + + + + diff --git a/web/settings.js b/web/settings.js new file mode 100644 index 0000000..5a4ae36 --- /dev/null +++ b/web/settings.js @@ -0,0 +1,441 @@ +let state = { + settings: { activeProfileId: null, activePresetId: null }, + profiles: [], + presets: [], +}; + +let editingProfileId = null; +let activeSection = "api"; +let lastImportedPresetId = null; +const expandedPresetEntries = new Map(); + +const profilesListEl = document.getElementById("profiles-list"); +const presetsListEl = document.getElementById("presets-list"); +const importReportEl = document.getElementById("import-report"); +const profileDialog = document.getElementById("profile-dialog"); +const profileForm = document.getElementById("profile-form"); +const profileDialogTitle = document.getElementById("profile-dialog-title"); +const presetFileEl = document.getElementById("preset-file"); +const panelTitleEl = document.getElementById("panel-title"); +const panelSubtitleEl = document.getElementById("panel-subtitle"); +const panelActionsEl = document.getElementById("panel-actions"); +const sectionApiEl = document.getElementById("section-api"); +const sectionPresetEl = document.getElementById("section-preset"); +const activeSettingsBarEl = document.getElementById("active-settings-bar"); +const settingsToastEl = document.getElementById("settings-toast"); + +async function api(path, options = {}) { + const res = await fetch(path, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "请求失败"); + return data; +} + +function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function maskKey(key) { + if (!key) return "(未设置)"; + if (key.length <= 8) return "****"; + return `${key.slice(0, 4)}…${key.slice(-4)}`; +} + +let toastTimer = null; + +function showToast(message, isError = false) { + settingsToastEl.hidden = false; + settingsToastEl.textContent = message; + settingsToastEl.classList.toggle("error", isError); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + settingsToastEl.hidden = true; + }, 4000); +} + +function renderActiveBar() { + const profile = state.profiles.find( + (p) => p.id === state.settings.activeProfileId, + ); + const preset = state.presets.find( + (p) => p.id === state.settings.activePresetId, + ); + + activeSettingsBarEl.innerHTML = ` + 当前生效 + + API: ${profile ? escapeHtml(`${profile.name} · ${profile.model}`) : "未选用"} + + + 预设: ${preset ? escapeHtml(preset.name) : "未选用"} + `; +} + +function switchSection(section) { + activeSection = section; + document.querySelectorAll(".sidebar-nav-item").forEach((el) => { + el.classList.toggle("active", el.dataset.section === section); + }); + sectionApiEl.classList.toggle("hidden", section !== "api"); + sectionPresetEl.classList.toggle("hidden", section !== "preset"); + renderPanelHeader(); +} + +function renderPanelHeader() { + panelActionsEl.innerHTML = ""; + if (activeSection === "api") { + panelTitleEl.textContent = "API 配置"; + panelSubtitleEl.textContent = "保存后点击「选用」或「保存并选用」立即生效"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn-primary"; + btn.textContent = "新增配置"; + btn.addEventListener("click", () => openProfileDialog()); + panelActionsEl.appendChild(btn); + } else { + panelTitleEl.textContent = "预设 Preset"; + panelSubtitleEl.textContent = "导入后点击「选用此预设」注入所有 LLM 请求"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn-primary"; + btn.textContent = "导入 JSON"; + btn.addEventListener("click", () => presetFileEl.click()); + panelActionsEl.appendChild(btn); + } +} + +function activateButtonHtml(active, id, action) { + if (active) { + return ``; + } + return ``; +} + +function renderProfiles() { + profilesListEl.innerHTML = ""; + if (!state.profiles.length) { + profilesListEl.innerHTML = + '

    暂无配置,点击右上角「新增配置」。

    '; + return; + } + + for (const p of state.profiles) { + const active = p.id === state.settings.activeProfileId; + const card = document.createElement("div"); + card.className = `config-card${active ? " active" : ""}`; + card.innerHTML = ` +
    +
    ${escapeHtml(p.name.charAt(0).toUpperCase())}
    +
    +
    + ${escapeHtml(p.name)} + ${active ? '当前' : ""} +
    +
    ${escapeHtml(p.model)} · ${escapeHtml(p.baseUrl)}
    +
    Key: ${escapeHtml(maskKey(p.apiKey))}
    +
    +
    +
    + ${activateButtonHtml(active, p.id, "activate-profile")} + + + +
    +
    `; + profilesListEl.appendChild(card); + } +} + +function renderPresets() { + presetsListEl.innerHTML = ""; + if (!state.presets.length) { + presetsListEl.innerHTML = + '

    暂无预设,点击右上角「导入 JSON」。

    '; + return; + } + + for (const p of state.presets) { + const active = p.id === state.settings.activePresetId; + const expanded = expandedPresetEntries.get(p.id); + const card = document.createElement("div"); + card.className = `config-card${active ? " active" : ""}`; + card.innerHTML = ` +
    +
    P
    +
    +
    + ${escapeHtml(p.name)} + ${active ? '当前' : ""} +
    +
    + ${escapeHtml(p.source)} · 启用 ${p.enabledCount} 条 + · 注入 ${p.injectingCount ?? "?"} 条 + · ${escapeHtml(p.importedAt?.slice(0, 10) ?? "")} +
    +
    +
    +
    + ${activateButtonHtml(active, p.id, "activate-preset")} + + +
    +
    `; + presetsListEl.appendChild(card); + if (expanded) { + renderPresetEntriesPanel(p.id, expanded); + } + } +} + +function renderPresetEntriesPanel(presetId, data) { + const panel = document.getElementById(`preset-entries-${presetId}`); + if (!panel || !data) return; + + const gen = data.generation ?? {}; + const genLines = Object.entries(gen) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}: ${v}`); + + const entriesHtml = data.entries + .map((entry, idx) => { + const roleClass = `role-${entry.role}`; + const status = entry.willInject + ? "" + : entry.marker + ? 'marker · 无内容' + : '空内容 · 不注入'; + return ` +
    +
    + ${idx + 1} + ${escapeHtml(entry.role)} + ${escapeHtml(entry.name)} + ${status} +
    + ${entry.content + ? `
    ${escapeHtml(entry.content)}
    ` + : `

    (无文本内容)

    `} +
    `; + }) + .join(""); + + panel.innerHTML = ` +
    + ${genLines.length ? `
    生成参数 ${escapeHtml(genLines.join(" · "))}
    ` : ""} +

    共 ${data.entries.length} 条启用顺序,${data.injectingCount} 条会注入请求

    + ${entriesHtml || '

    无启用条目

    '} +
    `; +} + +async function togglePresetEntries(presetId) { + if (expandedPresetEntries.has(presetId)) { + expandedPresetEntries.delete(presetId); + renderPresets(); + return; + } + const data = await api(`/api/presets/${encodeURIComponent(presetId)}/entries`); + expandedPresetEntries.set(presetId, data); + renderPresets(); +} + +async function loadAll() { + const data = await api("/api/settings"); + state.settings = data.settings; + state.profiles = data.profiles; + state.presets = data.presets; + renderActiveBar(); + renderProfiles(); + renderPresets(); +} + +function openProfileDialog(profile = null) { + editingProfileId = profile?.id ?? null; + profileDialogTitle.textContent = profile ? "编辑 API 配置" : "新增 API 配置"; + profileForm.name.value = profile?.name ?? ""; + profileForm.baseUrl.value = profile?.baseUrl ?? "https://api.deepseek.com"; + profileForm.apiKey.value = profile?.apiKey ?? ""; + profileForm.model.value = profile?.model ?? "deepseek-v4-pro"; + profileDialog.showModal(); +} + +async function saveProfile(activate) { + const payload = { + name: profileForm.name.value, + baseUrl: profileForm.baseUrl.value, + model: profileForm.model.value, + activate, + }; + const apiKey = profileForm.apiKey.value.trim(); + if (apiKey || !editingProfileId) { + payload.apiKey = apiKey; + } + + let result; + if (editingProfileId) { + result = await api(`/api/profiles/${editingProfileId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); + if (activate) { + result = await api(`/api/profiles/${editingProfileId}/activate`, { + method: "POST", + }); + } + } else { + result = await api("/api/profiles", { + method: "POST", + body: JSON.stringify(payload), + }); + } + + profileDialog.close(); + await loadAll(); + + if (activate) { + const n = result.reloadedSessions ?? 0; + showToast(`已选用并生效${n > 0 ? `,已更新 ${n} 个活跃会话` : ""}`); + } else { + showToast("已保存。点击「选用并生效」后才会用于 LLM 请求。"); + } +} + +async function activateProfile(id) { + const result = await api(`/api/profiles/${id}/activate`, { method: "POST" }); + await loadAll(); + const n = result.reloadedSessions ?? 0; + showToast(`API 配置已生效${n > 0 ? `(${n} 个会话已更新)` : ""}`); +} + +async function activatePreset(id) { + const result = await api(`/api/presets/${id}/activate`, { method: "POST" }); + await loadAll(); + const n = result.reloadedSessions ?? 0; + showToast(`预设已生效${n > 0 ? `(${n} 个会话已更新)` : ""}`); +} + +document.querySelectorAll(".sidebar-nav-item").forEach((btn) => { + btn.addEventListener("click", () => switchSection(btn.dataset.section)); +}); + +document.getElementById("profile-cancel").addEventListener("click", () => { + profileDialog.close(); +}); + +document.getElementById("profile-save").addEventListener("click", () => { + saveProfile(false).catch((err) => showToast(err.message, true)); +}); + +profileForm.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + await saveProfile(true); + } catch (err) { + showToast(err.message, true); + } +}); + +profilesListEl.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-action]"); + if (!btn) return; + const id = btn.dataset.id; + const action = btn.dataset.action; + + try { + if (action === "activate-profile") { + await activateProfile(id); + } else if (action === "edit-profile") { + openProfileDialog(state.profiles.find((p) => p.id === id)); + } else if (action === "test-profile") { + const el = document.getElementById(`test-${id}`); + el.textContent = "测试中…"; + el.className = "test-result"; + const result = await api(`/api/profiles/${id}/test`, { method: "POST" }); + el.textContent = result.message; + el.className = `test-result ${result.ok ? "ok" : "fail"}`; + } else if (action === "delete-profile") { + if (!confirm("确定删除此 API 配置?")) return; + await api(`/api/profiles/${id}`, { method: "DELETE" }); + await loadAll(); + showToast("已删除"); + } + } catch (err) { + showToast(err.message, true); + } +}); + +presetsListEl.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-action]"); + if (!btn) return; + const id = btn.dataset.id; + const action = btn.dataset.action; + + try { + if (action === "activate-preset") { + await activatePreset(id); + } else if (action === "toggle-preset-entries") { + await togglePresetEntries(id); + } else if (action === "delete-preset") { + if (!confirm("确定删除此预设?")) return; + await api(`/api/presets/${id}`, { method: "DELETE" }); + await loadAll(); + showToast("已删除"); + } + } catch (err) { + showToast(err.message, true); + } +}); + +presetFileEl.addEventListener("change", async () => { + const file = presetFileEl.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const raw = JSON.parse(text); + const name = file.name.replace(/\.json$/i, ""); + const report = await api("/api/presets/import", { + method: "POST", + body: JSON.stringify({ raw, name }), + }); + lastImportedPresetId = report.preset.id; + importReportEl.hidden = false; + importReportEl.innerHTML = ` +
    ${[ + `已导入: ${report.preset.name}`, + `条目: ${report.promptCount},启用: ${report.enabledCount}`, + report.warnings.length ? `警告: ${report.warnings.join("; ")}` : "", + ] + .filter(Boolean) + .join("
    ")}
    +
    + +
    `; + document + .getElementById("activate-imported-preset") + ?.addEventListener("click", async () => { + try { + await activatePreset(lastImportedPresetId); + } catch (err) { + showToast(err.message, true); + } + }); + await loadAll(); + showToast("预设已导入,请点击「选用此预设并生效」"); + } catch (err) { + showToast(err.message, true); + } finally { + presetFileEl.value = ""; + } +}); + +renderPanelHeader(); +loadAll().catch((err) => { + showToast(`加载设置失败: ${err.message}`, true); +}); diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..199a34f --- /dev/null +++ b/web/styles.css @@ -0,0 +1,215 @@ +/* Functional layout — agent-first, no production-line chrome */ + +:root { + --bg: #f4f4f5; + --surface: #fff; + --border: #d4d4d8; + --text: #18181b; + --muted: #71717a; + --accent: #2563eb; + --accent-soft: #dbeafe; + --ok: #16a34a; + --warn: #ca8a04; + --danger: #dc2626; + --books-w: 220px; + --agent-w: 300px; + --head-h: 48px; + --composer-min: 72px; + font-family: system-ui, "Segoe UI", "PingFang SC", sans-serif; + font-size: 14px; + color: var(--text); +} + +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +button, input, textarea { font: inherit; } +a.link { color: var(--accent); text-decoration: none; } +a.link:hover { text-decoration: underline; } + +.layout { + display: grid; + grid-template-columns: var(--books-w) 1fr; + height: 100vh; + background: var(--bg); +} + +.panel { background: var(--surface); border: 1px solid var(--border); min-height: 0; } +.panel-books { border-right: none; display: flex; flex-direction: column; } +.panel-head { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 12px; border-bottom: 1px solid var(--border); + font-size: 13px; +} +.panel-main { display: grid; grid-template-rows: var(--head-h) 1fr var(--composer-min); min-width: 0; } + +.main-head { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + padding: 0 12px; + border-bottom: 1px solid var(--border); +} +.main-head-left { min-width: 0; } +.main-head-right { display: flex; align-items: center; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } +.work-title { margin: 0; font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.work-meta { font-size: 12px; color: var(--muted); } + +.main-body { + display: grid; + grid-template-columns: 1fr var(--agent-w); + min-height: 0; +} +.panel-feed { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--border); } +.panel-agent { display: flex; flex-direction: column; min-height: 0; background: #fafafa; } + +.btn, .btn-sm { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 6px; + cursor: pointer; + padding: 6px 10px; +} +.btn-sm { padding: 4px 8px; font-size: 12px; } +.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); } +.btn-danger { color: var(--danger); border-color: #fecaca; } +.btn:disabled, .btn-sm:disabled { opacity: 0.5; cursor: not-allowed; } + +.status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); } +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); } +.status-dot.running { background: var(--accent); } +.status-dot.waiting { background: var(--warn); } +.status-dot.done { background: var(--ok); } + +/* Books */ +.book-list { flex: 1; overflow-y: auto; padding: 6px; } +.book-item { + display: block; width: 100%; text-align: left; + border: 1px solid transparent; border-radius: 6px; + background: transparent; padding: 8px; cursor: pointer; +} +.book-item:hover { background: var(--bg); } +.book-item.active { border-color: var(--accent); background: var(--accent-soft); } +.book-item-title { font-weight: 600; font-size: 13px; } +.book-item-sub { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; } + +/* Lifecycle */ +.lifecycle-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; padding: 2px; } +.lifecycle-btn { + border: none; background: transparent; padding: 4px 14px; border-radius: 6px; + font-size: 13px; cursor: pointer; color: var(--muted); +} +.lifecycle-btn.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +body[data-lifecycle="play"] { --accent: #16a34a; --accent-soft: #dcfce7; --bg: #f0fdf4; } +body[data-lifecycle="play"] .panel-agent { background: #f7fef9; } + +/* Skill picker — primary action when starting */ +.skill-picker { + padding: 16px; + border-bottom: 2px solid var(--accent); + background: var(--accent-soft); +} +.skill-picker h2 { margin: 0 0 4px; font-size: 15px; } +.skill-picker p { margin: 0 0 12px; font-size: 13px; color: var(--muted); } +.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; } +.skill-card { + text-align: left; padding: 12px; border: 1px solid var(--border); + border-radius: 8px; background: var(--surface); cursor: pointer; +} +.skill-card:hover { border-color: var(--accent); } +.skill-card-name { font-weight: 600; margin-bottom: 4px; } +.skill-card-desc { font-size: 12px; color: var(--muted); line-height: 1.4; } +.skill-card-tag { font-size: 11px; color: var(--accent); margin-top: 6px; } + +/* Feed */ +.message-feed { flex: 1; overflow-y: auto; padding: 12px; min-height: 0; } +.empty { color: var(--muted); text-align: center; padding: 40px 16px; } + +.msg { + border: 1px solid var(--border); border-radius: 8px; + padding: 10px 12px; margin-bottom: 8px; background: var(--surface); +} +.msg.user { border-left: 3px solid var(--warn); } +.msg.agent { border-left: 3px solid var(--accent); } +.msg.skill { border-left: 3px solid var(--ok); } +.msg.system { border-left: 3px solid var(--border); } +.msg.error { border-left: 3px solid var(--danger); } +.msg-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; font-size: 12px; } +.msg-tag { font-weight: 600; } +.msg-time { color: var(--muted); margin-left: auto; } +.msg-body { white-space: pre-wrap; word-break: break-word; line-height: 1.5; font-size: 13px; } +.msg-pending { opacity: 0.85; } + +/* Skill guide */ +.skill-guide { border-top: 1px solid var(--border); padding: 10px 12px; max-height: 180px; overflow-y: auto; background: #fafafa; } +.skill-guide-head { font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 8px; } +.skill-guide-item { display: grid; grid-template-columns: 8px 1fr; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; } +.skill-guide-item:last-child { border-bottom: none; } +.skill-guide-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); margin-top: 4px; } +.skill-guide-item.active .skill-guide-dot { background: var(--accent); } +.skill-guide-item.done .skill-guide-dot { background: var(--ok); } +.skill-guide-label { font-weight: 600; } +.skill-guide-purpose { color: var(--muted); margin-top: 2px; } + +/* Agent panel */ +.agent-focus { padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; } +.agent-focus-who { font-weight: 600; } +.agent-focus-action { margin-top: 4px; } +.agent-focus-detail { margin-top: 4px; font-size: 12px; color: var(--muted); } +.burst-badge { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--accent-soft); color: var(--accent); } +.tool-trace { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; } +.tool-trace-item { padding: 4px 0; border-bottom: 1px dashed var(--border); } +.tool-trace-item:last-child { border-bottom: none; } +.tool-trace-name { font-family: ui-monospace, monospace; font-weight: 600; color: var(--accent); } +.agent-timeline { flex: 1; overflow-y: auto; padding: 8px 12px; font-size: 12px; } +.timeline-item { padding: 6px 0; border-bottom: 1px solid var(--border); } +.timeline-empty { color: var(--muted); } + +/* Composer */ +.composer { + border-top: 1px solid var(--border); + padding: 10px 12px; + min-height: var(--composer-min); + background: var(--surface); +} +.composer-waiting, .composer-idle { + padding: 12px; text-align: center; color: var(--muted); font-size: 13px; +} +.composer-hint { margin: 0 0 8px; font-size: 12px; color: var(--muted); } +.composer-form { display: flex; gap: 8px; align-items: flex-end; } +.composer-form textarea { + flex: 1; min-height: 40px; max-height: 120px; resize: vertical; + border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; +} +.composer-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } +.composer-actions .btn-primary { flex: 1; } + +/* Intake (from intake-ui.js) */ +.intake-panel { border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 10px; background: #fffbeb; } +.intake-section-head { display: flex; justify-content: space-between; font-size: 12px; font-weight: 600; margin-bottom: 6px; } +.intake-list { list-style: none; margin: 0; padding: 0; } +.intake-item { display: grid; grid-template-columns: 16px 1fr auto; gap: 6px; font-size: 12px; padding: 3px 0; } +.intake-item.filled .intake-mark { color: var(--ok); } +.intake-value.empty { color: var(--muted); } + +/* Dialog */ +.dialog { border: 1px solid var(--border); border-radius: 10px; padding: 16px; max-width: 400px; } +.dialog-wide { max-width: 520px; } +.dialog::backdrop { background: rgba(0,0,0,0.3); } +.dialog h2 { margin: 0 0 8px; font-size: 16px; } +.dialog-desc { margin: 0 0 12px; font-size: 13px; color: var(--muted); } +.field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; } +.field input { padding: 8px; border: 1px solid var(--border); border-radius: 6px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } +.dialog-head-row { display: flex; justify-content: space-between; align-items: center; } +.saves-new { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; } +.saves-list { max-height: 240px; overflow-y: auto; font-size: 13px; } +.save-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); } + +@media (max-width: 900px) { + .layout { grid-template-columns: 1fr; } + .panel-books { display: none; } + .main-body { grid-template-columns: 1fr; } + .panel-agent { display: none; } +}