pi-web-ui 0.94.1 → 0.95.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/index.js +157 -9
  21. package/dist/server/lsp-tool.js +679 -0
  22. package/dist/server/model-admin.js +20 -0
  23. package/dist/server/office-parse.js +309 -0
  24. package/dist/server/patch-tool.js +91 -0
  25. package/dist/server/plan-manager.js +104 -0
  26. package/dist/server/plugin-api-catalog.js +296 -0
  27. package/dist/server/plugin-install-spec.js +196 -0
  28. package/dist/server/plugin-installer.js +73 -0
  29. package/dist/server/plugin-manifest-validate.js +305 -0
  30. package/dist/server/plugin-project.js +47 -11
  31. package/dist/server/plugin-tool-guard.js +120 -0
  32. package/dist/server/plugins.js +745 -161
  33. package/dist/server/process-utils.js +12 -6
  34. package/dist/server/scheduler-tasks.js +6 -0
  35. package/dist/server/serialize.js +13 -3
  36. package/dist/server/settings-service.js +108 -1
  37. package/dist/server/subagents.js +41 -0
  38. package/dist/server/terminals.js +69 -14
  39. package/dist/server/tool-approval.js +84 -0
  40. package/dist/server/tool-manager.js +214 -6
  41. package/dist/server/workspace-snapshot.js +113 -0
  42. package/package.json +2 -2
  43. package/plugin-sdk/README.md +25 -0
  44. package/plugin-sdk/index.d.ts +31 -38
  45. package/plugin-sdk/index.mjs +35 -19
  46. package/plugins/catalog.json +20 -0
  47. package/themes/ayu-light.css +6 -6
  48. package/themes/catppuccin-latte.css +6 -6
  49. package/themes/claude-code-dark.css +144 -0
  50. package/themes/codex.css +6 -6
  51. package/themes/everforest-light.css +6 -6
  52. package/themes/geist.css +6 -6
  53. package/themes/gruvbox-light.css +6 -6
  54. package/themes/kanagawa-lotus.css +6 -6
  55. package/themes/rose-pine-dawn.css +6 -6
  56. package/themes/solarized-light.css +6 -6
  57. package/themes/vs-code-dark.css +146 -0
  58. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DlGBF2MD.js} +1 -1
  59. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  60. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  61. package/web/dist/index.html +2 -2
  62. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  63. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -0,0 +1,317 @@
1
+ /**
2
+ * context-budget.ts — 多级分层上下文预算裁剪(Hierarchical Context Budgeting)
3
+ *
4
+ * 借鉴 DeepSeek Harness (DSH) 的梯度裁剪策略:在触发昂贵的 LLM 全文摘要之前,
5
+ * 增加确定性梯度瘦身,大幅推迟全量压缩时间点,保留近期关键代码的完整细节记忆:
6
+ *
7
+ * 1. 第一级(远期工具输出裁剪):当上下文达到预警水位(例如 70%)时,自动将历史中
8
+ * 较早的工具巨量输出(如几千行的命令输出、超长文件读取)替换为轻量占位摘要
9
+ * (`[Tool output trimmed: N lines / M bytes]`),保留工具调用的元数据与首尾关键信息;
10
+ * 2. 第二级(已完成步骤折叠):折叠已通过的目标轮次中间调试日志;
11
+ * 3. 第三级(语义全量压缩):仅在前两级裁剪后依然超标时,才调用 LLM 执行深度语义压缩。
12
+ *
13
+ * 纯模块:纯函数与纯逻辑,无外部 I/O 副作用,易于单测与多端复用。
14
+ */
15
+ /** 默认第一级触发预警水位(70%)。 */
16
+ export const DEFAULT_PRUNE_WATERMARK = 0.7;
17
+ /** 默认第二级触发预警水位(85%)。 */
18
+ export const DEFAULT_STAGE2_WATERMARK = 0.85;
19
+ /** 触发远期工具裁剪的最小行数(超过此行数视作巨量输出)。 */
20
+ export const DEFAULT_MIN_TRIM_LINES = 20;
21
+ /** 触发远期工具裁剪的最小字节数(超过此体积视作巨量输出)。 */
22
+ export const DEFAULT_MIN_TRIM_BYTES = 1000;
23
+ /** 裁剪时保留的头部行数。 */
24
+ export const DEFAULT_HEAD_LINES = 3;
25
+ /** 裁剪时保留的尾部行数。 */
26
+ export const DEFAULT_TAIL_LINES = 3;
27
+ /** 保留最近若干轮不裁剪(近期关键工作记忆保护)。1 轮通常包含 user + assistant + toolResult。 */
28
+ export const DEFAULT_KEEP_RECENT_TURNS = 2;
29
+ /**
30
+ * 粗略估算字符串的 token 数(chars / 4 保守估算)。
31
+ */
32
+ export function estimateTextTokens(text) {
33
+ if (!text)
34
+ return 0;
35
+ return Math.ceil(text.length / 4);
36
+ }
37
+ /**
38
+ * 估算一条 AgentMessage 的 token 数。
39
+ */
40
+ export function estimateMessageTokens(message) {
41
+ let total = 0;
42
+ const msg = message;
43
+ if (typeof msg.content === "string") {
44
+ total += estimateTextTokens(msg.content);
45
+ }
46
+ else if (Array.isArray(msg.content)) {
47
+ for (const block of msg.content) {
48
+ if (!block || typeof block !== "object")
49
+ continue;
50
+ const b = block;
51
+ if (b.type === "text" && typeof b.text === "string") {
52
+ total += estimateTextTokens(b.text);
53
+ }
54
+ else if (b.type === "thinking" && typeof b.thinking === "string") {
55
+ total += estimateTextTokens(b.thinking);
56
+ }
57
+ else if (b.type === "toolCall" && b.arguments) {
58
+ total += estimateTextTokens(JSON.stringify(b.arguments));
59
+ }
60
+ else if (b.type === "image") {
61
+ total += 800; // 图片估算基准
62
+ }
63
+ }
64
+ }
65
+ if (msg.role === "bashExecution" && typeof msg.output === "string") {
66
+ total += estimateTextTokens(msg.output);
67
+ }
68
+ return Math.max(1, total);
69
+ }
70
+ /**
71
+ * 估算消息数组的总 token 数。
72
+ */
73
+ export function estimateMessagesTotalTokens(messages) {
74
+ let sum = 0;
75
+ for (const m of messages) {
76
+ sum += estimateMessageTokens(m);
77
+ }
78
+ return sum;
79
+ }
80
+ /**
81
+ * 查找属于“近期工作记忆”的消息起始索引。
82
+ * 从后往前数 keepRecentTurns 个 user 消息;在此之后的皆为近期交互,不参与第一级工具裁剪。
83
+ */
84
+ export function findRecentCutoffIndex(messages, keepRecentTurns = DEFAULT_KEEP_RECENT_TURNS) {
85
+ if (messages.length === 0 || keepRecentTurns <= 0)
86
+ return 0;
87
+ let userTurnsSeen = 0;
88
+ for (let i = messages.length - 1; i >= 0; i--) {
89
+ const m = messages[i];
90
+ if (m.role === "user") {
91
+ userTurnsSeen++;
92
+ if (userTurnsSeen >= keepRecentTurns) {
93
+ return i;
94
+ }
95
+ }
96
+ }
97
+ return 0;
98
+ }
99
+ /**
100
+ * 第一级裁剪:远期工具输出裁剪(Distant Tool Output Trimming)
101
+ *
102
+ * 将历史中较早的工具巨量输出(几千行命令输出、超长文件读取)替换为轻量占位摘要,
103
+ * 保留工具调用的元数据与首尾关键信息。
104
+ */
105
+ export function trimDistantToolOutputs(messages, options = {}) {
106
+ const minLines = options.minLines ?? DEFAULT_MIN_TRIM_LINES;
107
+ const minBytes = options.minBytes ?? DEFAULT_MIN_TRIM_BYTES;
108
+ const headLines = options.headLines ?? DEFAULT_HEAD_LINES;
109
+ const tailLines = options.tailLines ?? DEFAULT_TAIL_LINES;
110
+ const keepRecentTurns = options.keepRecentTurns ?? DEFAULT_KEEP_RECENT_TURNS;
111
+ const cutoffIndex = findRecentCutoffIndex(messages, keepRecentTurns);
112
+ let trimmedCount = 0;
113
+ let savedBytes = 0;
114
+ let savedTokens = 0;
115
+ const newMessages = messages.map((m, index) => {
116
+ // 近期消息不裁剪,保护最近的关键工作上下文
117
+ if (index >= cutoffIndex) {
118
+ return m;
119
+ }
120
+ const msg = m;
121
+ // 1. 处理 toolResult 消息
122
+ if (msg.role === "toolResult" && Array.isArray(msg.content)) {
123
+ let modified = false;
124
+ const newContent = msg.content.map((block) => {
125
+ if (!block || typeof block !== "object")
126
+ return block;
127
+ const b = block;
128
+ if (b.type === "text" && typeof b.text === "string") {
129
+ const text = b.text;
130
+ // 如果已经裁剪过,避免重复处理
131
+ if (text.includes("[Tool output trimmed:")) {
132
+ return block;
133
+ }
134
+ const byteLen = Buffer.byteLength(text, "utf8");
135
+ const lines = text.split("\n");
136
+ if (lines.length >= minLines || byteLen >= minBytes) {
137
+ if (lines.length > headLines + tailLines) {
138
+ const head = lines.slice(0, headLines).join("\n");
139
+ const tail = lines.slice(lines.length - tailLines).join("\n");
140
+ const trimmedLines = lines.length - headLines - tailLines;
141
+ const middleText = lines.slice(headLines, lines.length - tailLines).join("\n");
142
+ const trimmedBytes = Buffer.byteLength(middleText, "utf8");
143
+ const placeholder = `[Tool output trimmed: ${trimmedLines} lines / ${trimmedBytes} bytes]`;
144
+ const trimmedText = `${head}\n... ${placeholder} ...\n${tail}`;
145
+ const diffBytes = byteLen - Buffer.byteLength(trimmedText, "utf8");
146
+ if (diffBytes > 0) {
147
+ savedBytes += diffBytes;
148
+ savedTokens += estimateTextTokens(text) - estimateTextTokens(trimmedText);
149
+ trimmedCount++;
150
+ modified = true;
151
+ return { ...b, text: trimmedText };
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return block;
157
+ });
158
+ if (modified) {
159
+ return { ...msg, content: newContent };
160
+ }
161
+ }
162
+ // 2. 处理 bashExecution 消息
163
+ if (msg.role === "bashExecution" && typeof msg.output === "string") {
164
+ const text = msg.output;
165
+ if (!text.includes("[Tool output trimmed:")) {
166
+ const byteLen = Buffer.byteLength(text, "utf8");
167
+ const lines = text.split("\n");
168
+ if (lines.length >= minLines || byteLen >= minBytes) {
169
+ if (lines.length > headLines + tailLines) {
170
+ const head = lines.slice(0, headLines).join("\n");
171
+ const tail = lines.slice(lines.length - tailLines).join("\n");
172
+ const trimmedLines = lines.length - headLines - tailLines;
173
+ const middleText = lines.slice(headLines, lines.length - tailLines).join("\n");
174
+ const trimmedBytes = Buffer.byteLength(middleText, "utf8");
175
+ const placeholder = `[Tool output trimmed: ${trimmedLines} lines / ${trimmedBytes} bytes]`;
176
+ const trimmedText = `${head}\n... ${placeholder} ...\n${tail}`;
177
+ const diffBytes = byteLen - Buffer.byteLength(trimmedText, "utf8");
178
+ if (diffBytes > 0) {
179
+ savedBytes += diffBytes;
180
+ savedTokens += estimateTextTokens(text) - estimateTextTokens(trimmedText);
181
+ trimmedCount++;
182
+ return { ...msg, output: trimmedText };
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ return m;
189
+ });
190
+ return {
191
+ messages: newMessages,
192
+ trimmedCount,
193
+ savedBytes,
194
+ savedTokens: Math.max(0, savedTokens),
195
+ };
196
+ }
197
+ /**
198
+ * 第二级裁剪:已完成步骤折叠(Completed Steps Folding)
199
+ *
200
+ * 折叠已通过的目标轮次中间调试日志或已确认步骤的重试调试过程。
201
+ * 将已解决步骤中冗余的反复排查、中间反思文本折叠为轻量占位,保留最终决策和产物。
202
+ */
203
+ export function foldCompletedStepLogs(messages, options = {}) {
204
+ const keepRecentTurns = options.keepRecentTurns ?? 1;
205
+ const cutoffIndex = findRecentCutoffIndex(messages, keepRecentTurns);
206
+ let foldedCount = 0;
207
+ let savedBytes = 0;
208
+ let savedTokens = 0;
209
+ // 检查历史中是否存在已完成/通过的目标审查或成功修复标记
210
+ // 例如包含目标审查通过、已完成步骤标记等
211
+ const newMessages = messages.map((m, index) => {
212
+ if (index >= cutoffIndex) {
213
+ return m;
214
+ }
215
+ const msg = m;
216
+ // 对中间的 assistant 消息中的冗长调试日志/思考排查进行轻量折叠
217
+ // 若 assistant 消息包含明显的中间失败重试排查日志且不是最后结论
218
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
219
+ let modified = false;
220
+ const newContent = msg.content.map((block) => {
221
+ if (!block || typeof block !== "object")
222
+ return block;
223
+ const b = block;
224
+ // 如果有长 thinking 块,且在已完成历史中
225
+ if (b.type === "thinking" && typeof b.thinking === "string") {
226
+ const t = b.thinking;
227
+ if (t.length > 500 && !t.includes("[Completed step debug logs folded:")) {
228
+ const originalBytes = Buffer.byteLength(t, "utf8");
229
+ const lines = t.split("\n").length;
230
+ const foldedPlaceholder = `[Completed step debug logs folded: 1 entry / ${lines} lines]`;
231
+ const diffBytes = originalBytes - Buffer.byteLength(foldedPlaceholder, "utf8");
232
+ if (diffBytes > 0) {
233
+ savedBytes += diffBytes;
234
+ savedTokens += estimateTextTokens(t) - estimateTextTokens(foldedPlaceholder);
235
+ foldedCount++;
236
+ modified = true;
237
+ return { ...b, thinking: foldedPlaceholder };
238
+ }
239
+ }
240
+ }
241
+ return block;
242
+ });
243
+ if (modified) {
244
+ return { ...msg, content: newContent };
245
+ }
246
+ }
247
+ return m;
248
+ });
249
+ return {
250
+ messages: newMessages,
251
+ foldedCount,
252
+ savedBytes,
253
+ savedTokens: Math.max(0, savedTokens),
254
+ };
255
+ }
256
+ /**
257
+ * 多级分层上下文预算裁剪总调度器(Hierarchical Pruning Orchestrator)
258
+ *
259
+ * 按照梯度策略执行:
260
+ * 1. 评估当前 token 占用与有效预算阈值;
261
+ * 2. 达到 70% 水位时,执行第一级(远期工具输出裁剪);
262
+ * 3. 若仍超标或达到 85% 水位,执行第二级(已完成步骤折叠);
263
+ * 4. 仅在前两级裁剪后依然超标时,才标记 needsCompaction = true 进入第三级。
264
+ */
265
+ export function pruneContextHierarchically(messages, options) {
266
+ const contextWindow = options.contextWindow > 0 ? options.contextWindow : 128_000;
267
+ const reserveTokens = options.reserveTokens > 0 ? options.reserveTokens : 16_384;
268
+ const effectiveCap = options.softCap && options.softCap > 0
269
+ ? Math.min(options.softCap, contextWindow - reserveTokens)
270
+ : Math.max(1, contextWindow - reserveTokens);
271
+ const tier1Watermark = options.tier1Watermark ?? DEFAULT_PRUNE_WATERMARK;
272
+ const tier2Watermark = options.tier2Watermark ?? DEFAULT_STAGE2_WATERMARK;
273
+ const tier1Threshold = Math.floor(effectiveCap * tier1Watermark);
274
+ const tier2Threshold = Math.floor(effectiveCap * tier2Watermark);
275
+ const tokensBefore = estimateMessagesTotalTokens(messages);
276
+ let currentTokens = tokensBefore;
277
+ let currentMessages = messages;
278
+ const tier1Result = { trimmedCount: 0, savedBytes: 0, savedTokens: 0 };
279
+ const tier2Result = { foldedCount: 0, savedBytes: 0, savedTokens: 0 };
280
+ const reachedTier1 = options.force || currentTokens >= tier1Threshold;
281
+ let reachedTier2 = options.force || currentTokens >= tier2Threshold;
282
+ // 1. 第一级:远期工具输出裁剪
283
+ if (reachedTier1) {
284
+ const r1 = trimDistantToolOutputs(currentMessages);
285
+ if (r1.trimmedCount > 0) {
286
+ currentMessages = r1.messages;
287
+ tier1Result.trimmedCount = r1.trimmedCount;
288
+ tier1Result.savedBytes = r1.savedBytes;
289
+ tier1Result.savedTokens = r1.savedTokens;
290
+ currentTokens = estimateMessagesTotalTokens(currentMessages);
291
+ }
292
+ }
293
+ // 2. 第二级:已完成步骤折叠(如果仍达到第二级水位或仍超标)
294
+ reachedTier2 = reachedTier2 || currentTokens >= tier2Threshold;
295
+ if (reachedTier2 || (options.force && currentTokens >= effectiveCap)) {
296
+ const r2 = foldCompletedStepLogs(currentMessages);
297
+ if (r2.foldedCount > 0) {
298
+ currentMessages = r2.messages;
299
+ tier2Result.foldedCount = r2.foldedCount;
300
+ tier2Result.savedBytes = r2.savedBytes;
301
+ tier2Result.savedTokens = r2.savedTokens;
302
+ currentTokens = estimateMessagesTotalTokens(currentMessages);
303
+ }
304
+ }
305
+ // 3. 第三级判定:前两级瘦身后,是否依然超过有效预算上限
306
+ const needsCompaction = currentTokens >= effectiveCap;
307
+ return {
308
+ messages: currentMessages,
309
+ tokensBefore,
310
+ tokensAfter: currentTokens,
311
+ tier1: tier1Result,
312
+ tier2: tier2Result,
313
+ needsCompaction,
314
+ reachedTier1,
315
+ reachedTier2,
316
+ };
317
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * 悬空 toolCall 检测与修复(issue #280)。
3
+ *
4
+ * 背景:流式中模型卡死 → 工具看门狗 abort 无效 → forceResetConversation
5
+ * dispose 在飞运行时并从磁盘重建。内存里未落盘的工具结果静默蒸发,
6
+ * 会话文件尾留下一个「有调用、无结果」的悬空 toolCall;重建后继续
7
+ * prompt 会把非法转录链喂给 provider——请求有发起迹象但零落盘、零报错,
8
+ * 用户在往黑洞里打字。
9
+ *
10
+ * 本模块:
11
+ * - findDanglingToolCalls:纯函数,消息/条目数组里找「有调用、无后继结果」的 toolCall;
12
+ * - healDanglingToolCallFile:落盘版,文件尾追加合成 toolResult(append-only,不改历史字节);
13
+ * - 合成结果文案:DANGLING_TOOL_RESULT_TEXT(中英各一,toolResult content 只带一条文本)。
14
+ */
15
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
16
+ import { randomUUID } from "node:crypto";
17
+ export const DANGLING_TOOL_RESULT_TEXT = "(系统:上一次运行被强制终止(工具执行超时/模型流卡死),该工具调用没有返回结果。为避免对话记录损坏,已自动填入一条合成结果。请根据需要重新执行该工具或继续对话。)";
18
+ export const DANGLING_TOOL_RESULT_TEXT_EN = "(System: the previous run was force-terminated (tool timeout / hung model stream) and this tool call never returned. A synthetic result was inserted automatically to keep the transcript valid. Re-run the tool or continue as needed.)";
19
+ function toolCallsOfMessage(msg) {
20
+ if (typeof msg !== "object" || msg === null)
21
+ return [];
22
+ const m = msg;
23
+ if (m.role !== "assistant" || !Array.isArray(m.content))
24
+ return [];
25
+ const out = [];
26
+ for (const b of m.content) {
27
+ if (typeof b !== "object" || b === null || b.type !== "toolCall")
28
+ continue;
29
+ const id = typeof b.id === "string" ? b.id : "";
30
+ if (!id)
31
+ continue;
32
+ const name = typeof b.name === "string" && b.name ? b.name : "unknown";
33
+ out.push({ toolCallId: id, toolName: name });
34
+ }
35
+ return out;
36
+ }
37
+ function toolResultIdsOfMessage(msg) {
38
+ const ids = new Set();
39
+ if (typeof msg !== "object" || msg === null)
40
+ return ids;
41
+ const m = msg;
42
+ if (m.role === "toolResult" && typeof m.toolCallId === "string")
43
+ ids.add(m.toolCallId);
44
+ // 兼容:个别版本把结果放在 content 块里。
45
+ if (Array.isArray(m.content)) {
46
+ for (const b of m.content) {
47
+ if (typeof b !== "object" || b === null)
48
+ continue;
49
+ if ((b.type === "toolResult" || b.type === "tool_result") && typeof b.toolCallId === "string") {
50
+ ids.add(b.toolCallId);
51
+ }
52
+ }
53
+ }
54
+ return ids;
55
+ }
56
+ function messagesOfEntries(entries) {
57
+ return entries.map((e) => {
58
+ if (typeof e === "object" && e !== null && "message" in e) {
59
+ const msg = e.message;
60
+ if (typeof msg === "object" && msg !== null && "role" in msg)
61
+ return msg;
62
+ }
63
+ return e;
64
+ });
65
+ }
66
+ /**
67
+ * 找悬空 toolCall:出现过调用、但之后没有任何 toolResult 与之配对的。
68
+ * 输入既可以是 Message[](内存 agent.state.messages),也可以是
69
+ * SessionManager entry[](带 { message } 包装的落盘条目)——统一按顺序扫。
70
+ */
71
+ export function findDanglingToolCalls(messagesOrEntries) {
72
+ const messages = messagesOfEntries(messagesOrEntries);
73
+ const calls = [];
74
+ const results = new Map();
75
+ messages.forEach((msg, index) => {
76
+ for (const c of toolCallsOfMessage(msg))
77
+ calls.push({ call: c, index });
78
+ for (const id of toolResultIdsOfMessage(msg)) {
79
+ let arr = results.get(id);
80
+ if (!arr) {
81
+ arr = [];
82
+ results.set(id, arr);
83
+ }
84
+ arr.push(index);
85
+ }
86
+ });
87
+ const out = [];
88
+ const seen = new Set();
89
+ for (const { call, index } of calls) {
90
+ const later = (results.get(call.toolCallId) ?? []).some((ri) => ri > index);
91
+ if (!later && !seen.has(call.toolCallId)) {
92
+ seen.add(call.toolCallId);
93
+ out.push(call);
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+ /**
99
+ * 落盘修复:向会话文件尾追加合成 toolResult(每条悬空调用一条),
100
+ * parentId 链式接在当前尾行之后。append-only——历史字节不动,无需备份。
101
+ * 返回追加条数(0 = 健康,无需处理;-1 = 文件不可读/不可写)。
102
+ */
103
+ export function healDanglingToolCallFile(filePath) {
104
+ let raw;
105
+ try {
106
+ raw = readFileSync(filePath, "utf8");
107
+ }
108
+ catch {
109
+ return -1;
110
+ }
111
+ const lines = raw.split("\n");
112
+ const entries = [];
113
+ let lastId = null;
114
+ for (const line of lines) {
115
+ if (!line.trim())
116
+ continue;
117
+ try {
118
+ const parsed = JSON.parse(line);
119
+ entries.push(parsed);
120
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.id === "string") {
121
+ lastId = parsed.id;
122
+ }
123
+ }
124
+ catch {
125
+ // 脏行:SDK 加载时同样跳过,这里忽略。
126
+ }
127
+ }
128
+ if (entries.length === 0)
129
+ return 0;
130
+ const dangling = findDanglingToolCalls(entries);
131
+ if (dangling.length === 0)
132
+ return 0;
133
+ try {
134
+ let parentId = lastId;
135
+ for (const d of dangling) {
136
+ const id = `synthetic-tool-result-${randomUUID().slice(0, 8)}`;
137
+ const entry = {
138
+ type: "message",
139
+ id,
140
+ parentId,
141
+ timestamp: new Date().toISOString(),
142
+ message: {
143
+ role: "toolResult",
144
+ toolCallId: d.toolCallId,
145
+ toolName: d.toolName,
146
+ content: [{ type: "text", text: `${DANGLING_TOOL_RESULT_TEXT}\n${DANGLING_TOOL_RESULT_TEXT_EN}` }],
147
+ isError: true,
148
+ timestamp: Date.now(),
149
+ },
150
+ };
151
+ const line = JSON.stringify(entry);
152
+ if (!existsSync(filePath))
153
+ return -1;
154
+ appendFileSync(filePath, `${line}\n`, "utf8");
155
+ parentId = id;
156
+ }
157
+ return dangling.length;
158
+ }
159
+ catch {
160
+ return -1;
161
+ }
162
+ }