Initial commit
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_BASE_URL=https://api.deepseek.com
|
||||||
|
OPENAI_MODEL=deepseek-v4-pro
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
5
.idea/.gitignore
generated
vendored
Normal file
5
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
13
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
13
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredIdentifiers">
|
||||||
|
<list>
|
||||||
|
<option value="requests.models.Response.__getitem__" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/writing-agent.iml" filepath="$PROJECT_DIR$/.idea/writing-agent.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/writing-agent.iml
generated
Normal file
10
.idea/writing-agent.iml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
150
docs/architecture.md
Normal file
150
docs/architecture.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
246
docs/book-storage.md
Normal file
246
docs/book-storage.md
Normal file
@@ -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<string, string>; // 角色卡.确认稿、角色.设定、口吻…
|
||||||
|
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<string, unknown>;
|
||||||
|
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 续玩
|
||||||
|
```
|
||||||
|
|
||||||
|
创建过程、卡本身、游玩过程 **三者分开存**,互不覆盖。
|
||||||
189
docs/context-assembly.md
Normal file
189
docs/context-assembly.md
Normal file
@@ -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 |
|
||||||
83
docs/creation-playbook.md
Normal file
83
docs/creation-playbook.md
Normal file
@@ -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` | 标签 |
|
||||||
354
docs/implementation-guide.md
Normal file
354
docs/implementation-guide.md
Normal file
@@ -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`,而不是重复阶段机逻辑。写之前会先说明该文件职责。
|
||||||
184
docs/orchestrator-skill-format.md
Normal file
184
docs/orchestrator-skill-format.md
Normal file
@@ -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` | 概念 |
|
||||||
297
docs/preset-format.md
Normal file
297
docs/preset-format.md
Normal file
@@ -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 配置和模型参数,必须让用户知道哪些内容真正进入了我们的运行时。
|
||||||
53
docs/run-snapshot.md
Normal file
53
docs/run-snapshot.md
Normal file
@@ -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`
|
||||||
136
docs/runtime-state-machine.md
Normal file
136
docs/runtime-state-machine.md
Normal file
@@ -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<string, unknown>;
|
||||||
|
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。
|
||||||
184
docs/skill-design-guide.md
Normal file
184
docs/skill-design-guide.md
Normal file
@@ -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` | 过程与资产归档 |
|
||||||
510
docs/skill-format.md
Normal file
510
docs/skill-format.md
Normal file
@@ -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 混用(路径独立)
|
||||||
|
```
|
||||||
573
docs/tag-blackboard.md
Normal file
573
docs/tag-blackboard.md
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 可选增强
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type BlackboardItem = {
|
||||||
|
id: string;
|
||||||
|
tag: string;
|
||||||
|
content: string;
|
||||||
|
source: string;
|
||||||
|
scope?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
dependencies?: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<WorkerResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkerContext = {
|
||||||
|
taskId: string;
|
||||||
|
workerId: string;
|
||||||
|
items: BlackboardItem[];
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 |
|
||||||
125
docs/tool-contracts.md
Normal file
125
docs/tool-contracts.md
Normal file
@@ -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 入口)
|
||||||
279
docs/worker-skill-format.md
Normal file
279
docs/worker-skill-format.md
Normal file
@@ -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: "<profiles.json 中的 ApiProfile.id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
省略 = 走 skill 包 `llm-bindings.yaml` 或会话默认。
|
||||||
|
|
||||||
|
### 9.2 Skill 包 llm-bindings.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
defaultProfileId: null # null = 会话默认
|
||||||
|
|
||||||
|
workers:
|
||||||
|
world-engine: {}
|
||||||
|
role-decide:
|
||||||
|
byRole:
|
||||||
|
A: "<profile-id-1>"
|
||||||
|
B: "<profile-id-2>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime 解析顺序见 `src/skills/worker-llm.ts`。
|
||||||
|
`role-decide` 按 `slots.世界.当前角色.id` 匹配 `byRole`。
|
||||||
|
|
||||||
|
### 9.3 设计意图
|
||||||
|
|
||||||
|
- 配置仍在 **profiles.json**(或 .env),不在 SKILL 里写密钥
|
||||||
|
- 同一 skill 可让不同角色用不同模型/API,实现真实多 agent 博弈
|
||||||
|
- 总管 LLM 不受 worker 绑定影响(始终会话默认)
|
||||||
|
|
||||||
|
---
|
||||||
229
electron/main.cjs
Normal file
229
electron/main.cjs
Normal file
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
2791
package-lock.json
generated
Normal file
2791
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
skills/README.md
Normal file
40
skills/README.md
Normal file
@@ -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`(低优先级)。
|
||||||
15
skills/dialogue/character-card-author/README.md
Normal file
15
skills/dialogue/character-card-author/README.md
Normal file
@@ -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`。
|
||||||
13
skills/dialogue/character-card-play/README.md
Normal file
13
skills/dialogue/character-card-play/README.md
Normal file
@@ -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`(规划)。
|
||||||
16
skills/dialogue/roleplay-game-theory/llm-bindings.yaml
Normal file
16
skills/dialogue/roleplay-game-theory/llm-bindings.yaml
Normal file
@@ -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: {}
|
||||||
441
skills/dialogue/roleplay-game-theory/orchestrator.md
Normal file
441
skills/dialogue/roleplay-game-theory/orchestrator.md
Normal file
@@ -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 含 用户.博弈需求 + 用户.修订说明)
|
||||||
|
→ 再次验收
|
||||||
|
```
|
||||||
164
skills/dialogue/roleplay-game-theory/shared-context.md
Normal file
164
skills/dialogue/roleplay-game-theory/shared-context.md
Normal file
@@ -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 → 用户验收
|
||||||
|
```
|
||||||
|
|
||||||
|
多轮重复直至终局或轮次上限。
|
||||||
@@ -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
|
||||||
|
- [ ] 符合 `博弈.参数.草稿` 的输出风格与展示思考开关
|
||||||
@@ -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 一致
|
||||||
@@ -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 人:请用户补第二个角色倾向
|
||||||
|
- 轮次未说明:默认单轮,并告知用户
|
||||||
|
- 决策顺序未说明:根据情境类型推断(囚徒困境等同行动→同时;最后通牒等先后手→序贯),写入参数并在备注标注假设
|
||||||
|
|
||||||
|
**禁止** 在缺关键信息时用纯默认糊过去而不标注假设;若用假设,须在 `博弈.参数.草稿` 的备注中写明。
|
||||||
@@ -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 `可见信息` **不必** 重复全部旧轮内容。
|
||||||
|
|
||||||
|
## 自检
|
||||||
|
|
||||||
|
- [ ] 裁决后已 **追加** `运行.事件流`,未覆盖历史
|
||||||
|
- [ ] 未替任何角色选择行动
|
||||||
|
- [ ] 未写任何角色内心或动机
|
||||||
|
- [ ] 裁决每一步可追溯到 博弈.规则.草稿 具体条款
|
||||||
|
- [ ] 各 可见信息 无交叉泄露
|
||||||
|
- [ ] 公开叙述、裁决记录、回合摘要 **三者事实一致**
|
||||||
|
- [ ] 全文无剧情化、无规则外推测
|
||||||
41
skills/dialogue/scene-roleplay/README.md
Normal file
41
skills/dialogue/scene-roleplay/README.md
Normal file
@@ -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。
|
||||||
37
skills/dialogue/world-simulator/README.md
Normal file
37
skills/dialogue/world-simulator/README.md
Normal file
@@ -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 + 跑团式流向** 专精版。
|
||||||
310
skills/dialogue/world-simulator/instantiate-orchestrator.md
Normal file
310
skills/dialogue/world-simulator/instantiate-orchestrator.md
Normal file
@@ -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 正文 未写
|
||||||
|
```
|
||||||
85
skills/novel/basic/orchestrator.md
Normal file
85
skills/novel/basic/orchestrator.md
Normal file
@@ -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。
|
||||||
57
skills/novel/basic/workers/outline/SKILL.md
Normal file
57
skills/novel/basic/workers/outline/SKILL.md
Normal file
@@ -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 确认。
|
||||||
27
skills/novel/interactive-novel/README.md
Normal file
27
skills/novel/interactive-novel/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# interactive-novel(TODO)
|
||||||
|
|
||||||
|
## 定位
|
||||||
|
|
||||||
|
长篇小说撰写 / 写作助手:**交互式**流程——意图转述、用户确认、大纲/事件/正文迭代、长期状态更新。
|
||||||
|
|
||||||
|
## 预期 tag 域(草案)
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户.原始输入 | 用户.意图转述 | 用户.确认结果
|
||||||
|
项目.设定 | 项目.风格要求 | 项目.写作偏好
|
||||||
|
大纲.当前 | 大纲.候选修改 | 大纲.确认稿
|
||||||
|
事件.当前 | 事件.候选修改 | 事件.确认稿
|
||||||
|
正文.原文 | 正文.续写锚点 | 正文.草稿 | 正文.修正版 | 正文.确认稿
|
||||||
|
风格.样例 | 风格.摘要 | 风格.约束
|
||||||
|
记忆.长期摘要 | 记忆.更新候选 | 记忆.确认稿
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与 novel-standard
|
||||||
|
|
||||||
|
二者边界待定:可能合并为一个包,或 standard 偏「全自动流水线」、interactive 偏「高参与确认」。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [ ] 定 orchestrator 阶段链
|
||||||
|
- [ ] 拆分 worker(意图转述、大纲、事件、正文、记忆更新等)
|
||||||
|
- [ ] 写 `orchestrator.md` 并注册
|
||||||
20
skills/novel/novel-standard/README.md
Normal file
20
skills/novel/novel-standard/README.md
Normal file
@@ -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/
|
||||||
30
skills/novel/quick-write/README.md
Normal file
30
skills/novel/quick-write/README.md
Normal file
@@ -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`
|
||||||
286
skills/novel/weird-rules-short/orchestrator.md
Normal file
286
skills/novel/weird-rules-short/orchestrator.md
Normal file
@@ -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 → …
|
||||||
|
```
|
||||||
68
skills/novel/weird-rules-short/shared-context.md
Normal file
68
skills/novel/weird-rules-short/shared-context.md
Normal file
@@ -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 只产出规则集 + 解析)。
|
||||||
117
skills/novel/weird-rules-short/workers/review-author/SKILL.md
Normal file
117
skills/novel/weird-rules-short/workers/review-author/SKILL.md
Normal file
@@ -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 中的 **片段**,不拼出「正确答案」。
|
||||||
110
skills/novel/weird-rules-short/workers/review-infer/SKILL.md
Normal file
110
skills/novel/weird-rules-short/workers/review-infer/SKILL.md
Normal file
@@ -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 中的 **片段**。
|
||||||
103
skills/novel/weird-rules-short/workers/write-rules/SKILL.md
Normal file
103
skills/novel/weird-rules-short/workers/write-rules/SKILL.md
Normal file
@@ -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 后经过地下二层者,违反规则将被清除。
|
||||||
|
```
|
||||||
16
skills/registry.yaml
Normal file
16
skills/registry.yaml
Normal file
@@ -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
|
||||||
117
src/blackboard/blackboard.ts
Normal file
117
src/blackboard/blackboard.ts
Normal file
@@ -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<string, BlackboardItem>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
13
src/blackboard/tag-match.ts
Normal file
13
src/blackboard/tag-match.ts
Normal file
@@ -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 === "**";
|
||||||
|
}
|
||||||
28
src/book/orchestrators.ts
Normal file
28
src/book/orchestrators.ts
Normal file
@@ -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<SkillPackInfo[]> {
|
||||||
|
return listSkillPacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSkillPacks(): Promise<SkillPackInfo[]> {
|
||||||
|
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<SkillPackInfo | null> {
|
||||||
|
const all = await listSkillPacks();
|
||||||
|
return all.find((o) => o.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
|
export async function getOrchestrator(id: string): Promise<SkillPackInfo | null> {
|
||||||
|
return getSkillPack(id);
|
||||||
|
}
|
||||||
96
src/book/run-snapshot-store.ts
Normal file
96
src/book/run-snapshot-store.ts
Normal file
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/book/session-store.ts
Normal file
46
src/book/session-store.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
37
src/book/skill-id.ts
Normal file
37
src/book/skill-id.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
53
src/book/snapshot-filters.ts
Normal file
53
src/book/snapshot-filters.ts
Normal file
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
117
src/book/store.ts
Normal file
117
src/book/store.ts
Normal file
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/cli/phase-demo.ts
Normal file
169
src/cli/phase-demo.ts
Normal file
@@ -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 <id> [approve]
|
||||||
|
/decide finish [理由]
|
||||||
|
|
||||||
|
加 --auto 时 worker 自动占位完成。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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 <id> [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);
|
||||||
|
});
|
||||||
134
src/cli/run.ts
Normal file
134
src/cli/run.ts
Normal file
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
159
src/config/api-profiles.ts
Normal file
159
src/config/api-profiles.ts
Normal file
@@ -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<Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model">>,
|
||||||
|
): 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 : "连接失败",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/config/env.ts
Normal file
56
src/config/env.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/config/settings.ts
Normal file
101
src/config/settings.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
25
src/config/user-data-dir.ts
Normal file
25
src/config/user-data-dir.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
86
src/intake/extract.ts
Normal file
86
src/intake/extract.ts
Normal file
@@ -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<string, string>,
|
||||||
|
llm?: LlmProvider,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
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<string, string>,
|
||||||
|
llm: LlmProvider,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
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": { "<fieldId>": "<完整字段值或 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<string, unknown> }).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;
|
||||||
|
}
|
||||||
170
src/intake/intake.ts
Normal file
170
src/intake/intake.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const raw = slots.intakeValues;
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (typeof v === "string" && v.trim()) out[k] = v.trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIntakeProgress(
|
||||||
|
fields: IntakeFieldDef[],
|
||||||
|
values: Record<string, string>,
|
||||||
|
): 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, string>,
|
||||||
|
): 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, unknown>,
|
||||||
|
): 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<string, string>,
|
||||||
|
): Record<string, string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
389
src/llm/client.ts
Normal file
389
src/llm/client.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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<CompleteResult>;
|
||||||
|
completeWithTools(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
options: CompleteWithToolsOptions,
|
||||||
|
): Promise<CompleteWithToolsResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildRequestBody(
|
||||||
|
config: LlmConfig,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
options?: CompleteOptions & { tools?: ToolDefinition[] },
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const gen = options?.generation ?? {};
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
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<string, unknown>): number | undefined {
|
||||||
|
const details = u.prompt_tokens_details ?? u.promptTokensDetails;
|
||||||
|
if (details && typeof details === "object") {
|
||||||
|
const d = details as Record<string, unknown>;
|
||||||
|
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<string, unknown>): 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<string, unknown>;
|
||||||
|
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<string, unknown> | 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<string, unknown>;
|
||||||
|
const fn = c.function;
|
||||||
|
if (!fn || typeof fn !== "object") continue;
|
||||||
|
const f = fn as Record<string, unknown>;
|
||||||
|
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<CompleteResult> {
|
||||||
|
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<string, unknown> }>;
|
||||||
|
};
|
||||||
|
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<CompleteWithToolsResult> {
|
||||||
|
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<string, unknown> }>;
|
||||||
|
};
|
||||||
|
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<string, unknown>; 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<CompleteResult> {
|
||||||
|
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<CompleteWithToolsResult> {
|
||||||
|
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, unknown> = {},
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
action: "ask_user",
|
||||||
|
reason: "请告诉我你想创作什么类型的作品、目标篇幅和风格偏好。",
|
||||||
|
workerId: null,
|
||||||
|
requiresApproval: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockToolCall(
|
||||||
|
name: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
id?: string,
|
||||||
|
): MockLlmStep {
|
||||||
|
return { toolCalls: [{ name, arguments: args, id }] };
|
||||||
|
}
|
||||||
58
src/llm/preset-wrapper.ts
Normal file
58
src/llm/preset-wrapper.ts
Normal file
@@ -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<CompleteResult> {
|
||||||
|
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<CompleteWithToolsResult> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/llm/token-tracker.ts
Normal file
85
src/llm/token-tracker.ts
Normal file
@@ -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<LlmProvider["complete"]>[0],
|
||||||
|
options?: CompleteOptions,
|
||||||
|
): Promise<CompleteResult> {
|
||||||
|
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<LlmProvider["completeWithTools"]>[0],
|
||||||
|
options: CompleteWithToolsOptions,
|
||||||
|
): Promise<CompleteWithToolsResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/main-agent/main-agent.ts
Normal file
191
src/main-agent/main-agent.ts
Normal file
@@ -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<MainAgentDecision> {
|
||||||
|
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<MainAgentDecision> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>).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;
|
||||||
173
src/main-agent/tool-loop.ts
Normal file
173
src/main-agent/tool-loop.ts
Normal file
@@ -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<string, string>;
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<ToolLoopResult> {
|
||||||
|
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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/main-agent/tools.ts
Normal file
121
src/main-agent/tools.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
49
src/preset/assembler.ts
Normal file
49
src/preset/assembler.ts
Normal file
@@ -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];
|
||||||
|
}
|
||||||
46
src/preset/entries.ts
Normal file
46
src/preset/entries.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
235
src/preset/importer.ts
Normal file
235
src/preset/importer.ts
Normal file
@@ -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<string, StPrompt>,
|
||||||
|
): 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<string, StPrompt>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
67
src/preset/store.ts
Normal file
67
src/preset/store.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
78
src/runtime/llm-factory.ts
Normal file
78
src/runtime/llm-factory.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
291
src/runtime/orchestrator.ts
Normal file
291
src/runtime/orchestrator.ts
Normal file
@@ -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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
await this.dispatch({
|
||||||
|
type: "user_submitted_input",
|
||||||
|
payload: { text },
|
||||||
|
});
|
||||||
|
return this.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveNextStep(): Promise<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
await this.dispatch({ type: "flow_completed", payload: {} });
|
||||||
|
return this.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatch(event: RuntimeEvent): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<PhaseEffect, { type: "run_worker" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.executeStubWorker(effect.workerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resumeStubWorker(): Promise<void> {
|
||||||
|
const ctx = this.session.resumeContext;
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("No resume context");
|
||||||
|
}
|
||||||
|
await this.executeStubWorker(ctx.workerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeStubWorker(workerId: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<MainAgentDecision, "id" | "statePatchAllowed"> &
|
||||||
|
Partial<Pick<MainAgentDecision, "id">>,
|
||||||
|
): MainAgentDecision {
|
||||||
|
return {
|
||||||
|
id: partial.id ?? randomUUID(),
|
||||||
|
statePatchAllowed: false,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
793
src/runtime/phase-machine.ts
Normal file
793
src/runtime/phase-machine.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
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<ArtifactRecord>,
|
||||||
|
): 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<string, unknown> = {
|
||||||
|
...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<string, unknown> = {
|
||||||
|
...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");
|
||||||
|
}
|
||||||
763
src/runtime/phase-runtime.ts
Normal file
763
src/runtime/phase-runtime.ts
Normal file
@@ -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<PhaseEffect, { type: "run_worker" }> | 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<void> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<ApplyEventResult> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<string | null> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
await this.dispatch({
|
||||||
|
type: "main_agent_decision_created",
|
||||||
|
payload: { decision },
|
||||||
|
});
|
||||||
|
return this.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户确认 pendingDecision(approve_step) */
|
||||||
|
async approve(): Promise<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<RuntimeSession> {
|
||||||
|
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<void> {
|
||||||
|
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<PhaseEffect, { type: "run_worker" }> = {
|
||||||
|
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<PhaseEffect, { type: "run_worker" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<PhaseEffect, { type: "run_worker" }>,
|
||||||
|
autoComplete = true,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) {
|
||||||
|
await this.runMainAgent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runMainAgent(): Promise<void> {
|
||||||
|
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<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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<MainAgentDecision, "id" | "statePatchAllowed"> &
|
||||||
|
Partial<Pick<MainAgentDecision, "id">>,
|
||||||
|
): 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<RuntimeSession> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
8
src/runtime/state-machine.ts
Normal file
8
src/runtime/state-machine.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @deprecated 请使用 phase-machine.ts */
|
||||||
|
export {
|
||||||
|
applyEvent,
|
||||||
|
canApplyEvent,
|
||||||
|
createArtifact,
|
||||||
|
createSession,
|
||||||
|
getAllowedEvents,
|
||||||
|
} from "./phase-machine.js";
|
||||||
119
src/runtime/tool-registry.ts
Normal file
119
src/runtime/tool-registry.ts
Normal file
@@ -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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ToolValidationError) throw e;
|
||||||
|
throw new ToolValidationError(`Invalid tool arguments JSON: ${raw.slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireString(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
362
src/server/agent-view.ts
Normal file
362
src/server/agent-view.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
190
src/server/book-handlers.ts
Normal file
190
src/server/book-handlers.ts
Normal file
@@ -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<string> {
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|
||||||
829
src/server/session-manager.ts
Normal file
829
src/server/session-manager.ts
Normal file
@@ -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<string, ManagedSession>();
|
||||||
|
/** bookId → 当前内存中的 sessionId */
|
||||||
|
private readonly activeBookSessions = new Map<string, string>();
|
||||||
|
|
||||||
|
async create(): Promise<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
const s = this.require(id);
|
||||||
|
await s.runtime.acceptArtifact();
|
||||||
|
return this.toView(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拒绝当前待确认步骤或待验收产物(无说明时触发重新来 / 回到总管) */
|
||||||
|
async reject(id: string, reason?: string): Promise<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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<SessionView> {
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
267
src/server/settings-handlers.ts
Normal file
267
src/server/settings-handlers.ts
Normal file
@@ -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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
285
src/server/skill-catalog.ts
Normal file
285
src/server/skill-catalog.ts
Normal file
@@ -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<string, CatalogTemplate[]> = {
|
||||||
|
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;
|
||||||
37
src/server/stats-handlers.ts
Normal file
37
src/server/stats-handlers.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
203
src/server/web-server.ts
Normal file
203
src/server/web-server.ts
Normal file
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
".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}`);
|
||||||
|
});
|
||||||
502
src/skills/loader.ts
Normal file
502
src/skills/loader.ts
Normal file
@@ -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<string, string | string[] | number>;
|
||||||
|
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<string, string | string[] | number> = {};
|
||||||
|
|
||||||
|
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<string, string | string[] | number>, key: string): string {
|
||||||
|
const v = meta[key];
|
||||||
|
return typeof v === "string" ? v : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaStringArray(meta: Record<string, string | string[] | number>, 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, string | string[] | number>): string[] {
|
||||||
|
const workers = metaStringArray(meta, "workers");
|
||||||
|
if (workers.length > 0) return workers;
|
||||||
|
return metaStringArray(meta, "suggestedWorkers");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRegistry(skillsRoot = SKILLS_ROOT): Promise<RegistryDoc["skills"]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string[]> {
|
||||||
|
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<SkillWorkerLlmBindings | undefined> {
|
||||||
|
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<ParsedSkill> {
|
||||||
|
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<string, string | string[] | number>,
|
||||||
|
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<ParsedWorkerSkill> {
|
||||||
|
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<SkillIndexEntry[]> {
|
||||||
|
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<string | null> {
|
||||||
|
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<ParsedSkill> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<ParsedWorkerSkill> {
|
||||||
|
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<ParsedWorkerSkill[]> {
|
||||||
|
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<string | null> {
|
||||||
|
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 };
|
||||||
19
src/skills/snapshot.ts
Normal file
19
src/skills/snapshot.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
76
src/skills/types.ts
Normal file
76
src/skills/types.ts
Normal file
@@ -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<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** skill 包 llm-bindings.yaml 解析结果(可选) */
|
||||||
|
export type SkillWorkerLlmBindings = {
|
||||||
|
defaultProfileId?: string | null;
|
||||||
|
workers?: Record<string, WorkerLlmBinding>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
};
|
||||||
146
src/skills/worker-llm.ts
Normal file
146
src/skills/worker-llm.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>, 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<string, string>,
|
||||||
|
slots: Record<string, unknown>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const roleId = slotString(slots, "世界.当前角色.id");
|
||||||
|
if (!roleId) return inputs;
|
||||||
|
|
||||||
|
const filtered: Record<string, string> = {};
|
||||||
|
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 };
|
||||||
247
src/stats/token-store.ts
Normal file
247
src/stats/token-store.ts
Normal file
@@ -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<string, number>;
|
||||||
|
byCallerDetailed: Record<string, CallerTokenBreakdown>;
|
||||||
|
byBook: Record<string, number>;
|
||||||
|
byOrchestrator: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<TokenUsageRecord, "id" | "at"> & { 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<string, CallerTokenBreakdown>,
|
||||||
|
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<string, CallerTokenBreakdown>;
|
||||||
|
last?: TokenUsageRecord;
|
||||||
|
records: TokenUsageRecord[];
|
||||||
|
} {
|
||||||
|
const records = readRecords({ sessionId, limit: 500 });
|
||||||
|
let totalTokens = 0;
|
||||||
|
let totalCached = 0;
|
||||||
|
let totalCacheMiss = 0;
|
||||||
|
const byCaller: Record<string, CallerTokenBreakdown> = {};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
29
src/types/blackboard.ts
Normal file
29
src/types/blackboard.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 总管可见:无 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<string, unknown>;
|
||||||
|
};
|
||||||
34
src/types/book-session.ts
Normal file
34
src/types/book-session.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
37
src/types/book.ts
Normal file
37
src/types/book.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
26
src/types/intake.ts
Normal file
26
src/types/intake.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
66
src/types/preset.ts
Normal file
66
src/types/preset.ts
Normal file
@@ -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[];
|
||||||
|
};
|
||||||
56
src/types/run-snapshot.ts
Normal file
56
src/types/run-snapshot.ts
Normal file
@@ -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<SnapshotKind, string> = {
|
||||||
|
instance: "实例",
|
||||||
|
run: "进度",
|
||||||
|
};
|
||||||
257
src/types/runtime.ts
Normal file
257
src/types/runtime.ts
Normal file
@@ -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<string, string> };
|
||||||
|
}
|
||||||
|
| { type: "user_confirmed_intake"; payload: Record<string, never> }
|
||||||
|
| {
|
||||||
|
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<string, never> }
|
||||||
|
| { 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<string, unknown>;
|
||||||
|
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;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user