pi-firecode 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/config.example.jsonc +100 -0
- package/config.ts +474 -0
- package/deliver.ts +32 -0
- package/flame-frames.ts +460 -0
- package/format.ts +80 -0
- package/header.ts +92 -0
- package/herdr-client.ts +60 -0
- package/index.ts +62 -0
- package/jsonc.ts +34 -0
- package/master/event-card.ts +106 -0
- package/master/event-format.ts +52 -0
- package/master/index.ts +1200 -0
- package/master/prompt.ts +26 -0
- package/master/prompts/master.zh.md +17 -0
- package/master/prompts/worker.zh.md +1 -0
- package/master/role.ts +13 -0
- package/master/spawn.ts +193 -0
- package/master/state.ts +201 -0
- package/package.json +59 -0
- package/provider/claude-sub.ts +129 -0
- package/provider/openai-native/index.ts +8 -0
- package/provider/openai-native/src/compact-client.ts +362 -0
- package/provider/openai-native/src/config.ts +297 -0
- package/provider/openai-native/src/extension.ts +93 -0
- package/provider/openai-native/src/native-compaction.ts +151 -0
- package/provider/openai-native/src/native-details.ts +157 -0
- package/provider/openai-native/src/native-replay.ts +253 -0
- package/provider/openai-native/src/native-runtime.ts +144 -0
- package/provider/openai-native/src/options.ts +85 -0
- package/provider/openai-native/src/request-pipeline.ts +21 -0
- package/provider/openai-native/src/responses-input.ts +433 -0
- package/review/advisor.ts +112 -0
- package/review/card.ts +385 -0
- package/review/checkpoint.ts +384 -0
- package/review/evidence.ts +232 -0
- package/review/index.ts +1246 -0
- package/review/outcome.ts +74 -0
- package/review/progress.ts +324 -0
- package/review/prompt.ts +210 -0
- package/review/prompts/advisor.en.md +41 -0
- package/review/prompts/advisor.zh.md +42 -0
- package/review/prompts/review.en.md +75 -0
- package/review/prompts/review.zh.md +75 -0
- package/review/reviewer.ts +356 -0
- package/review/session.ts +129 -0
- package/review/state.ts +905 -0
- package/review/ui.ts +528 -0
- package/session/bark.ts +157 -0
- package/session/herdr-display.ts +87 -0
- package/session/presets.ts +257 -0
- package/session/rename.ts +46 -0
- package/session/stats.ts +310 -0
- package/session/working-flame.ts +116 -0
- package/statusbar/index.ts +118 -0
- package/statusbar/quota-cache.ts +60 -0
- package/statusbar/quota-parse.ts +85 -0
- package/statusbar/quota.ts +203 -0
- package/statusbar/render.ts +181 -0
- package/statusbar/tps.ts +87 -0
- package/theme.ts +84 -0
- package/tools/grouping.ts +233 -0
- package/tools/index.ts +183 -0
- package/tools/line.ts +194 -0
- package/tools/parts.ts +143 -0
- package/tools/timing.ts +22 -0
- package/watcher/card.ts +85 -0
- package/watcher/index.ts +204 -0
- package/watcher/observer.ts +75 -0
- package/watcher/prompts/watch.zh.md +46 -0
- package/watcher/transcript.ts +64 -0
package/watcher/index.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watcher 观察员:每个 turn 结束后异步评估主会话增量,要么沉默,要么发一条建议。
|
|
3
|
+
* 与 Master、fire-review 各自独立注册;观察过程不落盘。
|
|
4
|
+
*/
|
|
5
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
6
|
+
import {
|
|
7
|
+
getAgentDir,
|
|
8
|
+
ModelRuntime,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionContext,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { loadConfig, type WatcherConfig } from "../config.js";
|
|
13
|
+
import { deliver } from "../deliver.js";
|
|
14
|
+
import { formatModelName } from "../format.js";
|
|
15
|
+
import { InProcessSessionPool } from "../master/spawn.js";
|
|
16
|
+
import {
|
|
17
|
+
adviceMessage,
|
|
18
|
+
registerWatcherCardRenderer,
|
|
19
|
+
WATCHER_MESSAGE_TYPE,
|
|
20
|
+
type WatcherCard,
|
|
21
|
+
} from "./card.js";
|
|
22
|
+
import { createObserver, type Advice, type Observer } from "./observer.js";
|
|
23
|
+
import { renderTurn } from "./transcript.js";
|
|
24
|
+
|
|
25
|
+
/** review 模块发布的占用频道;观察员只订阅,不参与审查状态机。 */
|
|
26
|
+
const REVIEW_OCCUPANCY_CHANNEL = "herdr:blocked";
|
|
27
|
+
/** 观察会话自身上下文占比超过此值即重建。 */
|
|
28
|
+
const CONTEXT_RESET_PERCENT = 70;
|
|
29
|
+
|
|
30
|
+
interface WatcherDependencies {
|
|
31
|
+
resolveModel?: (id: string) => Promise<Model<any>>;
|
|
32
|
+
pool?: InProcessSessionPool;
|
|
33
|
+
createObserver?: typeof createObserver;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface WatcherRuntime {
|
|
37
|
+
ctx: ExtensionContext;
|
|
38
|
+
pending: string[];
|
|
39
|
+
lastTurnIndex: number;
|
|
40
|
+
evaluating: boolean;
|
|
41
|
+
observer?: Observer;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function registerWatcher(
|
|
45
|
+
pi: ExtensionAPI,
|
|
46
|
+
dependencies: WatcherDependencies = {},
|
|
47
|
+
subsession = false,
|
|
48
|
+
): void {
|
|
49
|
+
// 子会话不带观察员:级联抑制是代码规则,不靠进程环境。
|
|
50
|
+
if (subsession) return;
|
|
51
|
+
registerWatcherCardRenderer(pi);
|
|
52
|
+
const loaded = loadWatcherConfiguration();
|
|
53
|
+
// 配置有问题时拒绝启动:静默回退会拿用户没配的模型真实发起观察。
|
|
54
|
+
if ("error" in loaded) {
|
|
55
|
+
pi.registerCommand("fire-watch", {
|
|
56
|
+
description: "翻转当前会话的观察员开关",
|
|
57
|
+
handler: async (args, ctx) => ctx.ui.notify(args.trim() ? "/fire-watch 不接受参数" : loaded.error, "error"),
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const config = loaded;
|
|
62
|
+
const pool = dependencies.pool ?? new InProcessSessionPool();
|
|
63
|
+
const spawnObserver = dependencies.createObserver ?? createObserver;
|
|
64
|
+
let runtime: WatcherRuntime | undefined;
|
|
65
|
+
let reviewActive = false;
|
|
66
|
+
|
|
67
|
+
const deactivate = (owner = runtime) => {
|
|
68
|
+
if (!owner || runtime !== owner) return;
|
|
69
|
+
runtime = undefined;
|
|
70
|
+
owner.observer?.dispose();
|
|
71
|
+
owner.observer = undefined;
|
|
72
|
+
owner.ctx.ui.setStatus("watcher", undefined);
|
|
73
|
+
};
|
|
74
|
+
const resetObserver = (owner = runtime) => {
|
|
75
|
+
if (!owner || runtime !== owner) return;
|
|
76
|
+
runtime = {
|
|
77
|
+
ctx: owner.ctx,
|
|
78
|
+
pending: [],
|
|
79
|
+
lastTurnIndex: owner.lastTurnIndex,
|
|
80
|
+
evaluating: false,
|
|
81
|
+
};
|
|
82
|
+
owner.observer?.dispose();
|
|
83
|
+
owner.observer = undefined;
|
|
84
|
+
};
|
|
85
|
+
const activate = (ctx: ExtensionContext): WatcherRuntime => {
|
|
86
|
+
const owner = { ctx, pending: [], lastTurnIndex: 0, evaluating: false };
|
|
87
|
+
runtime = owner;
|
|
88
|
+
ctx.ui.setStatus("watcher", ctx.ui.theme.fg("dim", `👓 ${formatModelName(config.model)}/${config.thinking}`));
|
|
89
|
+
return owner;
|
|
90
|
+
};
|
|
91
|
+
// 与指挥官事件同构:忙时卡片经 steer 队列句缝追加,歇透时走前门唤起(见 deliver.ts)。
|
|
92
|
+
const speak = (owner: WatcherRuntime, advice: Advice, turnIndex: number) => {
|
|
93
|
+
const card: WatcherCard = { note: advice.note, turnIndex };
|
|
94
|
+
return deliver(pi, owner.ctx, { customType: WATCHER_MESSAGE_TYPE, content: adviceMessage(card), details: card });
|
|
95
|
+
};
|
|
96
|
+
const evaluate = async (owner: WatcherRuntime) => {
|
|
97
|
+
owner.evaluating = true;
|
|
98
|
+
try {
|
|
99
|
+
while (runtime === owner && owner.pending.length && !reviewActive) {
|
|
100
|
+
// 合并跳最新:评估期间到达的回合并进下一批,不排队补评估。
|
|
101
|
+
const increment = owner.pending.splice(0).join("\n");
|
|
102
|
+
const turnIndex = owner.lastTurnIndex;
|
|
103
|
+
let observer = owner.observer;
|
|
104
|
+
if (!observer) {
|
|
105
|
+
const cwd = owner.ctx.cwd;
|
|
106
|
+
const model = await (dependencies.resolveModel ?? resolveConfiguredModel)(config.model);
|
|
107
|
+
if (runtime !== owner) return;
|
|
108
|
+
observer = await spawnObserver({ cwd, model, thinking: config.thinking, pool });
|
|
109
|
+
if (runtime !== owner) {
|
|
110
|
+
observer.dispose();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
owner.observer = observer;
|
|
114
|
+
}
|
|
115
|
+
const advice = await observer.evaluate(increment);
|
|
116
|
+
if (runtime !== owner) return;
|
|
117
|
+
if (advice) {
|
|
118
|
+
await speak(owner, advice, turnIndex);
|
|
119
|
+
if (runtime !== owner) return;
|
|
120
|
+
}
|
|
121
|
+
// 自身上下文快满时也重新入场:观察员只需要当下,不需要完整历史。
|
|
122
|
+
if ((observer.contextPercent() ?? 0) >= CONTEXT_RESET_PERCENT) resetObserver(owner);
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// 被重新入场中断的评估不算故障:下一批增量会开一个新观察会话。
|
|
126
|
+
if (runtime !== owner) return;
|
|
127
|
+
owner.ctx.ui.notify(`观察员已停止:${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
128
|
+
deactivate(owner);
|
|
129
|
+
} finally {
|
|
130
|
+
owner.evaluating = false;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
pi.registerCommand("fire-watch", {
|
|
135
|
+
description: "翻转当前会话的观察员开关",
|
|
136
|
+
handler: async (args, ctx) => {
|
|
137
|
+
if (args.trim()) {
|
|
138
|
+
ctx.ui.notify("/fire-watch 不接受参数", "error");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (runtime) {
|
|
142
|
+
deactivate();
|
|
143
|
+
ctx.ui.notify("观察员已关闭", "info");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
activate(ctx);
|
|
147
|
+
ctx.ui.notify("观察员已开启", "info");
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_start", (_event, ctx) => {
|
|
152
|
+
deactivate();
|
|
153
|
+
if (!config.enabled) return;
|
|
154
|
+
activate(ctx);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("turn_end", (event) => {
|
|
158
|
+
const owner = runtime;
|
|
159
|
+
if (!owner) return;
|
|
160
|
+
owner.pending.push(renderTurn(event, config.context));
|
|
161
|
+
owner.lastTurnIndex = event.turnIndex;
|
|
162
|
+
if (!owner.evaluating && !reviewActive) void evaluate(owner);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// fire-review 活跃期静默:不与对抗审查的反馈打架,增量留着审查完合并评估。
|
|
166
|
+
pi.events?.on?.(REVIEW_OCCUPANCY_CHANNEL, (data: { active?: boolean } | undefined) => {
|
|
167
|
+
reviewActive = Boolean(data?.active);
|
|
168
|
+
const active = runtime;
|
|
169
|
+
if (reviewActive || !active || active.evaluating || !active.pending.length) return;
|
|
170
|
+
void evaluate(active);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// 主会话压缩:旧增量已不再对应主会话现场,观察员从当前尾部重新入场而不回放。
|
|
174
|
+
pi.on("session_compact", () => resetObserver());
|
|
175
|
+
pi.on("session_shutdown", () => deactivate());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function loadWatcherConfiguration(): WatcherConfig | { error: string } {
|
|
179
|
+
let loaded: ReturnType<typeof loadConfig>;
|
|
180
|
+
try {
|
|
181
|
+
loaded = loadConfig();
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return { error: `观察员配置读取失败:${error instanceof Error ? error.message : String(error)}` };
|
|
184
|
+
}
|
|
185
|
+
// features 也算阻断集:开关写成字符串 "false" 时 `!== false` 仍会注册,
|
|
186
|
+
// 而启用观察员意味着每个回合都对模型发起真实调用。
|
|
187
|
+
const problems = loaded.problems.filter((problem) =>
|
|
188
|
+
problem.startsWith("watcher") || problem.startsWith("未知字段 watcher.")
|
|
189
|
+
|| problem.startsWith("config.jsonc") || problem.startsWith("features"));
|
|
190
|
+
if (problems.length) return { error: `观察员配置有问题,已停止:${problems.join(";")}` };
|
|
191
|
+
return loaded.config.watcher;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function resolveConfiguredModel(id: string): Promise<Model<any>> {
|
|
195
|
+
const runtime = await ModelRuntime.create({
|
|
196
|
+
authPath: `${getAgentDir()}/auth.json`,
|
|
197
|
+
modelsPath: `${getAgentDir()}/models.json`,
|
|
198
|
+
});
|
|
199
|
+
const slash = id.indexOf("/");
|
|
200
|
+
const model = slash > 0 ? runtime.getModel(id.slice(0, slash), id.slice(slash + 1)) : undefined;
|
|
201
|
+
// 扩展注册的 provider 在无扩展子会话里不可解析(实测),必须明确引导而不是静默失败。
|
|
202
|
+
if (!model) throw new Error(`找不到模型:${id};观察员只能使用内置 provider 的模型`);
|
|
203
|
+
return model;
|
|
204
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 观察会话:进程内 memory 子会话 + 只读工具 + 唯一自定义工具 advise。
|
|
3
|
+
* 系统提示以 prompts/watch.zh.md 为唯一事实源(整体替换,不受项目文件改写)。
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
9
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { InProcessSessionPool } from "../master/spawn.js";
|
|
11
|
+
import type { ThinkingLevelValue } from "../config.js";
|
|
12
|
+
|
|
13
|
+
const PROMPT_PATH = join(dirname(fileURLToPath(import.meta.url)), "prompts", "watch.zh.md");
|
|
14
|
+
const OBSERVER_TOOLS = ["read", "grep", "find", "ls", "advise"];
|
|
15
|
+
|
|
16
|
+
export interface Advice {
|
|
17
|
+
note: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Observer {
|
|
21
|
+
/** 喂一段增量并等待评估;至多返回一条建议。 */
|
|
22
|
+
evaluate(increment: string): Promise<Advice | undefined>;
|
|
23
|
+
/** 观察会话自身上下文占比(百分数),未知时 undefined。 */
|
|
24
|
+
contextPercent(): number | undefined;
|
|
25
|
+
dispose(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ObserverOptions {
|
|
29
|
+
cwd: string;
|
|
30
|
+
model: Model<any>;
|
|
31
|
+
thinking: ThinkingLevelValue;
|
|
32
|
+
pool: InProcessSessionPool;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function createObserver(options: ObserverOptions): Promise<Observer> {
|
|
36
|
+
let advice: Advice | undefined;
|
|
37
|
+
const spawned = await options.pool.spawn({
|
|
38
|
+
cwd: options.cwd,
|
|
39
|
+
role: "observer",
|
|
40
|
+
model: options.model,
|
|
41
|
+
thinking: options.thinking,
|
|
42
|
+
tools: OBSERVER_TOOLS,
|
|
43
|
+
customTools: [adviseTool(() => advice, (next) => { advice = next; })],
|
|
44
|
+
systemPrompt: { mode: "replace", text: readFileSync(PROMPT_PATH, "utf8") },
|
|
45
|
+
contextFiles: true,
|
|
46
|
+
persistence: { type: "memory" },
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
async evaluate(increment) {
|
|
50
|
+
advice = undefined;
|
|
51
|
+
await spawned.prompt(increment);
|
|
52
|
+
return advice;
|
|
53
|
+
},
|
|
54
|
+
contextPercent: () => spawned.session.getContextUsage?.()?.percent ?? undefined,
|
|
55
|
+
dispose: () => spawned.dispose(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function adviseTool(current: () => Advice | undefined, capture: (advice: Advice) => void) {
|
|
60
|
+
return {
|
|
61
|
+
name: "advise",
|
|
62
|
+
label: "建议",
|
|
63
|
+
description: "向主代理提交一条供权衡的观察建议;每次评估只接受一条,请只提最要紧的那一条。",
|
|
64
|
+
parameters: Type.Object({
|
|
65
|
+
note: Type.String({ description: "一句话说清问题与定位。" }),
|
|
66
|
+
}),
|
|
67
|
+
execute(_id: string, params: Record<string, unknown>) {
|
|
68
|
+
if (current()) throw new Error("本次评估已提交过建议;余下的问题留到下一次评估");
|
|
69
|
+
const note = typeof params.note === "string" ? params.note.trim() : "";
|
|
70
|
+
if (!note) throw new Error("note 不能为空");
|
|
71
|
+
capture({ note });
|
|
72
|
+
return { content: [{ type: "text" as const, text: "已记录,本次评估结束。" }] };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# 观察员
|
|
2
|
+
|
|
3
|
+
你是 FireCode 观察员:一双持续盯着主会话的旁观的眼睛。你在自己的只读会话里工作,看到的是主会话
|
|
4
|
+
逐回合追加的增量记录。
|
|
5
|
+
|
|
6
|
+
## 角色
|
|
7
|
+
|
|
8
|
+
你是旁观者,不是第二指挥官。你不接管任务、不下命令、不规划步骤,也不替主代理做决定。
|
|
9
|
+
|
|
10
|
+
你的建议是供主代理权衡的第二意见,不是必须执行的指令——你写下的每一条建议都会以「供权衡,勿盲从」的
|
|
11
|
+
方式呈现。主代理有更完整的上下文和用户的直接授权;当它的做法与你的判断冲突时,它有理由继续走它的路。
|
|
12
|
+
|
|
13
|
+
你的建议会当场送达主代理:它忙时在句缝插入,它空闲时直接唤起一个回合。只有当这条话此刻送达会改变主代理下一步动作时才开口,否则沉默。
|
|
14
|
+
|
|
15
|
+
绝大多数回合不需要任何建议。什么都不说是常态,也是正确答案。
|
|
16
|
+
|
|
17
|
+
## 关注面
|
|
18
|
+
|
|
19
|
+
只在下面这几类问题上开口:
|
|
20
|
+
|
|
21
|
+
- **偏离**:正在做的事与用户的需求、spec 或工单不一致,或悄悄扩大到没被要求的范围。
|
|
22
|
+
- **过度工程**:为不存在的需求加抽象、加配置、加兼容层、加防御分支。
|
|
23
|
+
- **漏需求**:需求或工单里明确要求的部分被跳过、被忘记或被降级处理。
|
|
24
|
+
- **未收口**:改动留下失效的死代码、过时文档、未更新的事实源、没跑的验证,或票据没有收尾。
|
|
25
|
+
- **危险操作**:破坏性命令、不可逆的数据或历史改写、越出当前工作范围的写入、把秘密写进代码或提交。
|
|
26
|
+
|
|
27
|
+
风格偏好、可以但没必要的重构、你自己想到的更优雅写法,都不在关注面内。
|
|
28
|
+
|
|
29
|
+
## 输出契约
|
|
30
|
+
|
|
31
|
+
你只能通过 `advise` 工具说话。除此之外的任何文字都不会被任何人看到。
|
|
32
|
+
|
|
33
|
+
`advise` 只接受一个参数 `note`:一句话说清问题和定位,必要时补一句为什么。
|
|
34
|
+
|
|
35
|
+
每次评估最多提交一条建议。有多个问题时只提最要紧的那一条,其余的等下一次评估。
|
|
36
|
+
|
|
37
|
+
同一批增量里已被主代理自行纠正的问题不要再提:先看完整批增量再判断,失败后的成功重试、报错后的修正都算已纠正。
|
|
38
|
+
|
|
39
|
+
同一条建议不要重复提交。情况恶化到必须重提时,说明是什么变化让它变得紧迫。
|
|
40
|
+
|
|
41
|
+
## 证据纪律
|
|
42
|
+
|
|
43
|
+
指出问题必须带定位:哪个文件、哪个函数、哪次工具调用、需求的哪一条。说不出定位的直觉不要提交。
|
|
44
|
+
|
|
45
|
+
增量记录是被裁剪过的,它省略了推理过程与 diff 正文。凭增量猜出来的问题,先用只读工具(read / grep /
|
|
46
|
+
find / ls)核实现场再决定要不要开口;核实后发现是自己看漏了,就什么都不做。
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主会话回合的增量渲染:喂给观察会话的唯一素材,纯函数。
|
|
3
|
+
* minimal 省略 reasoning 与 diff 展开正文——观察员要的是「在做什么」,正文可以自己去读。
|
|
4
|
+
*/
|
|
5
|
+
import type { TurnEndEvent } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { WatcherContext } from "../config.js";
|
|
7
|
+
|
|
8
|
+
const RESULT_BUDGET = 400;
|
|
9
|
+
|
|
10
|
+
export function renderTurn(turn: TurnEndEvent, context: WatcherContext): string {
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
for (const part of blocks(turn.message.content)) {
|
|
13
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim())
|
|
14
|
+
lines.push(part.text.trim());
|
|
15
|
+
if (part.type === "thinking" && context === "full" && typeof part.thinking === "string")
|
|
16
|
+
lines.push(`(思考)${part.thinking.trim()}`);
|
|
17
|
+
if (part.type === "toolCall") lines.push(toolCallLine(part, context));
|
|
18
|
+
}
|
|
19
|
+
for (const result of turn.toolResults) {
|
|
20
|
+
const text = clipText(plainText(result.content), RESULT_BUDGET);
|
|
21
|
+
lines.push(`${result.isError ? "✗" : "→"} ${result.toolName}:${text || "(无输出)"}`);
|
|
22
|
+
}
|
|
23
|
+
return `<turn index="${turn.turnIndex}">\n${lines.join("\n")}\n</turn>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toolCallLine(part: Record<string, any>, context: WatcherContext): string {
|
|
27
|
+
const args = part.arguments && typeof part.arguments === "object" ? part.arguments : {};
|
|
28
|
+
const summary = Object.entries(args as Record<string, unknown>)
|
|
29
|
+
.map(([key, value]) => `${key}=${clipText(argumentText(key, value, context), 200)}`)
|
|
30
|
+
.join(" ");
|
|
31
|
+
return `· ${part.name ?? "工具"} ${summary}`.trimEnd();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** minimal 下 edit/write 的正文(新旧内容)只留长度:观察员判断的是动作,不是逐字 diff。 */
|
|
35
|
+
function argumentText(key: string, value: unknown, context: WatcherContext): string {
|
|
36
|
+
const text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
|
|
37
|
+
if (context === "full") return oneLine(text);
|
|
38
|
+
return BODY_KEYS.has(key) ? `<${text.length} 字符>` : oneLine(text);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const BODY_KEYS = new Set(["content", "oldText", "newText", "old_text", "new_text", "edits"]);
|
|
42
|
+
|
|
43
|
+
function blocks(content: unknown): Array<Record<string, any>> {
|
|
44
|
+
return Array.isArray(content) ? content.filter((part) => !!part && typeof part === "object") : [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function plainText(output: unknown): string {
|
|
48
|
+
if (typeof output === "string") return output;
|
|
49
|
+
if (Array.isArray(output))
|
|
50
|
+
return output
|
|
51
|
+
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text) : ""))
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join("\n");
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function oneLine(text: string): string {
|
|
58
|
+
return text.replace(/\s+/gu, " ").trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function clipText(text: string, budget: number): string {
|
|
62
|
+
const single = oneLine(text);
|
|
63
|
+
return single.length > budget ? `${single.slice(0, budget)}…` : single;
|
|
64
|
+
}
|