pi-web-ui 0.94.1 → 0.96.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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -259,6 +259,16 @@ export function normalizeWorkspaceRoots(v) {
259
259
  }
260
260
  return out;
261
261
  }
262
+ /** 跨平台(尤其是 Windows)路径归一化键:统一转绝对路径,并在 Windows 下转小写以消除大小写与正反斜杠差异。 */
263
+ export function normalizePathKey(p) {
264
+ try {
265
+ const resolved = resolve(p);
266
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
267
+ }
268
+ catch {
269
+ return process.platform === "win32" ? p.toLowerCase() : p;
270
+ }
271
+ }
262
272
  /**
263
273
  * Persists which workspace each browser client last used + which workspaces it
264
274
  * has opened, so a server restart / page reload restores the same project and
@@ -293,6 +303,26 @@ export class ClientStateStore {
293
303
  catch {
294
304
  this.cache = {};
295
305
  }
306
+ // 历史数据迁移:老版本将 removedProjects 仅记在各自临时 clientId 下,升级后新标签页无法继承。
307
+ // 启动/加载时自动将所有老 client 的墓碑合并到全局 __settings__,避免重启或新标签页后已删项目复活。
308
+ let migrated = false;
309
+ const globalState = (this.cache[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
310
+ const globalSet = new Set((globalState.removedProjects ?? []).map(normalizePathKey));
311
+ for (const [id, cState] of Object.entries(this.cache)) {
312
+ if (id !== ClientStateStore.GLOBAL_SETTINGS_KEY && cState.removedProjects?.length) {
313
+ for (const p of cState.removedProjects) {
314
+ const key = normalizePathKey(p);
315
+ if (!globalSet.has(key)) {
316
+ globalSet.add(key);
317
+ (globalState.removedProjects ??= []).push(p);
318
+ migrated = true;
319
+ }
320
+ }
321
+ }
322
+ }
323
+ if (migrated) {
324
+ this.save();
325
+ }
296
326
  return this.cache;
297
327
  }
298
328
  save() {
@@ -318,11 +348,15 @@ export class ClientStateStore {
318
348
  const state = (all[clientId] ??= { projects: [] });
319
349
  state.lastCwd = cwd;
320
350
  const now = Date.now();
321
- state.projects = [{ path: cwd, lastUsed: now }, ...state.projects.filter((p) => p.path !== cwd)].slice(0, 30);
351
+ const targetKey = normalizePathKey(cwd);
352
+ state.projects = [
353
+ { path: cwd, lastUsed: now },
354
+ ...state.projects.filter((p) => normalizePathKey(p.path) !== targetKey),
355
+ ].slice(0, 30);
322
356
  // Opening the workspace again clears its removal tombstone across all clients and global settings.
323
357
  for (const cState of Object.values(all)) {
324
358
  if (cState.removedProjects?.length) {
325
- cState.removedProjects = cState.removedProjects.filter((p) => p !== cwd);
359
+ cState.removedProjects = cState.removedProjects.filter((p) => normalizePathKey(p) !== targetKey);
326
360
  }
327
361
  }
328
362
  this.save();
@@ -335,20 +369,21 @@ export class ClientStateStore {
335
369
  * explicitly opens that project again. */
336
370
  removeProject(clientId, cwd) {
337
371
  const all = this.load();
372
+ const targetKey = normalizePathKey(cwd);
338
373
  const state = (all[clientId] ??= { projects: [] });
339
- state.projects = state.projects.filter((p) => p.path !== cwd);
340
- if (state.lastCwd === cwd)
374
+ state.projects = state.projects.filter((p) => normalizePathKey(p.path) !== targetKey);
375
+ if (state.lastCwd && normalizePathKey(state.lastCwd) === targetKey)
341
376
  delete state.lastCwd;
342
- const removed = new Set(state.removedProjects ?? []);
343
- removed.add(cwd);
344
- state.removedProjects = [...removed];
377
+ const removed = (state.removedProjects ?? []).filter((p) => normalizePathKey(p) !== targetKey);
378
+ removed.push(cwd);
379
+ state.removedProjects = removed;
345
380
  const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
346
- const globalRemoved = new Set(globalState.removedProjects ?? []);
347
- globalRemoved.add(cwd);
348
- globalState.removedProjects = [...globalRemoved];
381
+ const globalRemoved = (globalState.removedProjects ?? []).filter((p) => normalizePathKey(p) !== targetKey);
382
+ globalRemoved.push(cwd);
383
+ globalState.removedProjects = globalRemoved;
349
384
  for (const [id, cState] of Object.entries(all)) {
350
385
  if (id !== ClientStateStore.GLOBAL_SETTINGS_KEY && cState.projects) {
351
- cState.projects = cState.projects.filter((p) => p.path !== cwd);
386
+ cState.projects = cState.projects.filter((p) => normalizePathKey(p.path) !== targetKey);
352
387
  }
353
388
  }
354
389
  this.save();
@@ -363,7 +398,16 @@ export class ClientStateStore {
363
398
  return clientRemoved;
364
399
  if (clientRemoved.length === 0)
365
400
  return globalRemoved;
366
- return [...new Set([...clientRemoved, ...globalRemoved])];
401
+ const seen = new Set();
402
+ const result = [];
403
+ for (const p of [...clientRemoved, ...globalRemoved]) {
404
+ const key = normalizePathKey(p);
405
+ if (!seen.has(key)) {
406
+ seen.add(key);
407
+ result.push(p);
408
+ }
409
+ }
410
+ return result;
367
411
  }
368
412
  /** Last-used goal/review prefs for a client, or undefined if never set. */
369
413
  getGoalPrefs(clientId) {
@@ -474,8 +518,10 @@ export class ClientStateStore {
474
518
  : (stored?.terminalToolsEnabled ?? false),
475
519
  terminalBash: stored?.terminalBash ?? false,
476
520
  terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
521
+ terminalBashMaxForegroundMs: stored?.terminalBashMaxForegroundMs ?? 60_000,
477
522
  toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(stored?.toolWatchdogTimeoutMs),
478
523
  readDirEnabled: stored?.readDirEnabled ?? true,
524
+ toolApprovalEnabled: stored?.toolApprovalEnabled ?? true,
479
525
  editSoftEnabled: stored?.disabledAgentTools !== undefined
480
526
  ? deriveLegacy(legacyToDisabled(stored)).editSoftEnabled
481
527
  : (stored?.editSoftEnabled ?? false),
@@ -531,8 +577,10 @@ export class ClientStateStore {
531
577
  terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? false,
532
578
  terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
533
579
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
580
+ terminalBashMaxForegroundMs: settings.terminalBashMaxForegroundMs ?? cur.terminalBashMaxForegroundMs ?? 60_000,
534
581
  toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(settings.toolWatchdogTimeoutMs ?? cur.toolWatchdogTimeoutMs ?? DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS),
535
582
  readDirEnabled: settings.readDirEnabled ?? cur.readDirEnabled ?? true,
583
+ toolApprovalEnabled: settings.toolApprovalEnabled ?? cur.toolApprovalEnabled ?? true,
536
584
  editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
537
585
  questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
538
586
  goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
@@ -544,8 +592,13 @@ export class ClientStateStore {
544
592
  toolImagesEnabled: settings.toolImagesEnabled ?? cur.toolImagesEnabled ?? true,
545
593
  skillsFullText: normalizeSkillList(settings.skillsFullText ?? cur.skillsFullText),
546
594
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
547
- visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
548
- subagentDefaultModel: settings.subagentDefaultModel ?? cur.subagentDefaultModel ?? null,
595
+ // 按键存在性合并:null 是合法值(清除语义),`null ?? cur` 会把旧值
596
+ // 复活到磁盘(设置面板清空后重启又回来)。settings-service 持久化
597
+ // 时传全量对象,键总在;其他调用方传 partial,键缺 = 保持旧值。
598
+ visionBridgeModel: "visionBridgeModel" in settings ? (settings.visionBridgeModel ?? null) : (cur.visionBridgeModel ?? null),
599
+ subagentDefaultModel: "subagentDefaultModel" in settings
600
+ ? (settings.subagentDefaultModel ?? null)
601
+ : (cur.subagentDefaultModel ?? null),
549
602
  retryMaxAttempts: normalizeRetryMaxAttempts(settings.retryMaxAttempts ?? cur.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS),
550
603
  softCapTokens: normalizeSoftCapTokens(settings.softCapTokens ?? cur.softCapTokens ?? 0),
551
604
  softCapByModel: normalizeSoftCapByModel(settings.softCapByModel ?? cur.softCapByModel ?? {}),
@@ -0,0 +1,126 @@
1
+ // ---------------------------------------------------------------------------
2
+ // compact-context-tool.ts — 主动压缩上下文工具(compact_context)
3
+ // ---------------------------------------------------------------------------
4
+ // 让 AI 可以根据当前问题主动触发上下文压缩,自主决定保留和当前问题相关的内容,
5
+ // 去除或深度压缩与当前问题无关、弱相关的历史探索与冗余输出。
6
+ //
7
+ // 执行时机:当 AI 在当前回合调用本工具后,本工具记录 pending 压缩请求并返回成功;
8
+ // 在当前回合结束后(agent_settled 时会话处于完全 idle 状态),系统自动应用 AI
9
+ // 指定的保留范围(keepRecentTokens)与关注点(focus / summary),执行 SDK 的
10
+ // context compaction,使精炼后的上下文在后续交互中持续生效。
11
+ // ---------------------------------------------------------------------------
12
+ import { defineTool } from "@earendil-works/pi-coding-agent";
13
+ import { Type } from "typebox";
14
+ import { pick } from "./i18n.js";
15
+ import { COMPACT_CONTEXT_TOOL_NAME } from "./tool-manager.js";
16
+ /** 工具名(唯一登记在 tool-manager.ts,在此 re-export 供外部模块引用)。 */
17
+ export { COMPACT_CONTEXT_TOOL_NAME };
18
+ /** 最小允许保留近期 tokens 下限。 */
19
+ export const MIN_KEEP_RECENT_TOKENS = 1000;
20
+ /** 最大允许保留近期 tokens 上限。 */
21
+ export const MAX_KEEP_RECENT_TOKENS = 100000;
22
+ /** SDK 默认的常规保留 tokens。 */
23
+ export const DEFAULT_KEEP_RECENT_TOKENS = 20000;
24
+ /**
25
+ * 智能计算生效的 keepRecentTokens:
26
+ * - 若 AI 显式指定,钳制到合法区间 [MIN_KEEP_RECENT_TOKENS, MAX_KEEP_RECENT_TOKENS];
27
+ * - 若未显式指定,根据当前会话估算 tokens 动态计算:
28
+ * 会话较大时(>= 30000)保留默认 20000 tokens;
29
+ * 会话中等或较小时(< 30000),保留最近约 35%(且不低于 1500 tokens),
30
+ * 确保即便会话只有数千 tokens 时,也能成功切出前半段历史进行压缩,避免报 session too small。
31
+ * 纯函数。
32
+ */
33
+ export function calculateEffectiveKeepRecentTokens(requestedTokens, currentEstimatedTokens) {
34
+ if (typeof requestedTokens === "number" && Number.isFinite(requestedTokens)) {
35
+ return Math.min(MAX_KEEP_RECENT_TOKENS, Math.max(MIN_KEEP_RECENT_TOKENS, Math.floor(requestedTokens)));
36
+ }
37
+ const current = Math.max(0, currentEstimatedTokens ?? 0);
38
+ if (current >= 30000) {
39
+ return DEFAULT_KEEP_RECENT_TOKENS;
40
+ }
41
+ if (current > 0) {
42
+ // 动态保留约 35%,至少保留 1500 tokens,上限不超过 DEFAULT_KEEP_RECENT_TOKENS
43
+ const dynamicTokens = Math.max(1500, Math.floor(current * 0.35));
44
+ return Math.min(DEFAULT_KEEP_RECENT_TOKENS, dynamicTokens);
45
+ }
46
+ return DEFAULT_KEEP_RECENT_TOKENS;
47
+ }
48
+ /**
49
+ * 组装给 SDK compaction 的完整指示文本:
50
+ * 融入 focus 指示与 AI 自主提炼的 customSummary。纯函数。
51
+ */
52
+ export function buildCompactionInstructions(focus, customSummary) {
53
+ const trimmedFocus = focus.trim();
54
+ const trimmedSummary = customSummary?.trim();
55
+ if (!trimmedSummary)
56
+ return trimmedFocus;
57
+ return `${trimmedFocus}\n\n[Key Points / Summary to Retain]\n${trimmedSummary}`;
58
+ }
59
+ export const CompactContextParams = Type.Object({
60
+ focus: Type.String({
61
+ description: "Compression focus and requirements for the current issue/task. State: 1) the active problem/goal; 2) context to PRESERVE (key decisions, code changes, conventions, user constraints); 3) what to REMOVE or condense (failed attempts, resolved debugging, off-topic history).",
62
+ }),
63
+ keepRecentTokens: Type.Optional(Type.Number({
64
+ description: "Recent tokens to keep uncompacted (1000-64000). Smaller values (e.g. 3000-8000) compact more aggressively. Omit = auto-calculated from session size.",
65
+ })),
66
+ summary: Type.Optional(Type.String({
67
+ description: "Custom structured summary written by you; used as the core basis for the compaction entry if provided.",
68
+ })),
69
+ });
70
+ export function makeCompactContextTool(host, lang) {
71
+ const getLang = lang ?? (() => "en");
72
+ return defineTool({
73
+ name: COMPACT_CONTEXT_TOOL_NAME,
74
+ label: "Compact conversation context based on current issue",
75
+ description: "Compress/compact the conversation context around the current task: specify what to preserve (key decisions, code structure, active requirements) and what to drop or heavily summarize (unrelated exploration, verbose outputs, resolved debugging). " +
76
+ "Optionally control how many recent tokens stay untouched. Compaction executes when the current turn settles, refreshing context for subsequent turns.",
77
+ promptSnippet: "proactively compact conversation context focusing on the current issue",
78
+ promptGuidelines: [
79
+ "Use compact_context when the conversation has grown long, or after extensive debugging/exploration, to focus context strictly on the current problem.",
80
+ "Clearly specify in 'focus' what to keep (decisions, specs, active changes) and what to drop (failed attempts, voluminous command outputs).",
81
+ "After calling compact_context, conclude your current turn with a brief wrap-up and next steps; the system executes compaction right after this turn finishes.",
82
+ ],
83
+ parameters: CompactContextParams,
84
+ execute: async (_id, p) => {
85
+ const l = getLang();
86
+ const params = p;
87
+ const focus = params.focus?.trim() || "";
88
+ if (!focus) {
89
+ const errMsg = pick(l, "调用 compact_context 必须提供 focus 参数,说明针对当前问题的压缩要求与保留重点。", "compact_context requires a 'focus' parameter explaining what to keep and what to drop for the current issue.");
90
+ return {
91
+ content: [{ type: "text", text: errMsg }],
92
+ details: { ok: false, error: errMsg },
93
+ isError: true,
94
+ };
95
+ }
96
+ const stats = host.getContextStats();
97
+ // 如果整个会话消息条数太少(< 4 条)且 token 极少(< 1200),无需压缩
98
+ if (stats.messageCount < 4 && stats.estimatedTokens < 1200) {
99
+ const msg = pick(l, `当前会话历史较短(约 ${stats.estimatedTokens} tokens,${stats.messageCount} 条消息),无需压缩。建议在历史累积较长或切换任务焦点后再调用此工具。`, `Current conversation is very brief (~${stats.estimatedTokens} tokens, ${stats.messageCount} messages), no compaction needed yet. Call this tool when history grows longer or when switching task focus.`);
100
+ return {
101
+ content: [{ type: "text", text: msg }],
102
+ details: { ok: false, skipped: true, ...stats },
103
+ };
104
+ }
105
+ const effectiveKeepTokens = calculateEffectiveKeepRecentTokens(params.keepRecentTokens, stats.estimatedTokens);
106
+ const pending = {
107
+ focus,
108
+ keepRecentTokens: effectiveKeepTokens,
109
+ summary: params.summary?.trim() || undefined,
110
+ requestedAt: Date.now(),
111
+ };
112
+ host.scheduleCompaction(pending);
113
+ const successMsg = pick(l, `已成功登记上下文压缩请求。将在本轮回复结束后立即执行上下文压缩。\n- 压缩聚焦点:${focus}\n- 保留近期范围:~${effectiveKeepTokens.toLocaleString()} tokens${pending.summary ? "\n- 包含自主提炼的摘要正文" : ""}\n请在结束本轮回复后,在精炼后的上下文中继续后续工作。`, `Context compaction request scheduled. It will execute immediately after the current turn ends.\n- Focus: ${focus}\n- Retain scope: ~${effectiveKeepTokens.toLocaleString()} tokens${pending.summary ? "\n- Custom summary provided" : ""}\nPlease wrap up this turn, and continue work in the compacted context.`);
114
+ return {
115
+ content: [{ type: "text", text: successMsg }],
116
+ details: {
117
+ ok: true,
118
+ scheduled: true,
119
+ focus,
120
+ keepRecentTokens: effectiveKeepTokens,
121
+ hasCustomSummary: !!pending.summary,
122
+ },
123
+ };
124
+ },
125
+ });
126
+ }
@@ -62,6 +62,15 @@ export class ComposerDraftsStore {
62
62
  raw = JSON.parse(readFileSync(this.filePath, "utf8"));
63
63
  }
64
64
  catch {
65
+ // 坏文件改名留存而非直接覆盖:先有留存,后续 save 落盘的才是新数据
66
+ //(否则一次解析失败 → 空表 → 下次 save 把全部草稿的原始记录抹掉)。
67
+ try {
68
+ renameSync(this.filePath, `${this.filePath}.corrupt-${Date.now()}`);
69
+ console.warn(`[composer-drafts] 草稿文件解析失败,已改名留存:${this.filePath}(丢草稿可重打,不留坏副本)`);
70
+ }
71
+ catch {
72
+ // 改名失败(占用等)也无妨:空表继续,save 时照常覆盖
73
+ }
65
74
  raw = {};
66
75
  }
67
76
  const now = Date.now();
@@ -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
+ }