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
@@ -1,7 +1,8 @@
1
- import { countLines, decodeText, looksLikeText, sniffImageMime } from "./text-sniff.js";
1
+ import { sniffImageMime } from "./text-sniff.js";
2
2
  import { saveUpload, uploadsRoot } from "./uploads.js";
3
3
  import { isAbsoluteWirePath, wireToAbs } from "./files-service.js";
4
4
  import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
5
+ import { saveAttachment } from "./attachment-store.js";
5
6
  /** 跨快照的视觉转写缓存:批次 hash(名称 + base64 头 + 提示词)→ 转写文本。
6
7
  * 编辑重问重发相同图片不再重复耗视觉 token。进程级共享即可。 */
7
8
  const visionBridgeCache = new Map();
@@ -25,9 +26,6 @@ export async function buildAttachmentMessages(ctx, attachments) {
25
26
  const { resolve, sep, relative, extname, basename } = await import("node:path");
26
27
  const root = resolve(ctx.cwd);
27
28
  const MAX_ATTACHMENT_BYTES = 200 * 1024;
28
- // Files at or below this size are inlined; larger files are referenced by
29
- // path only (the model reads them on demand — saves tokens for small edits).
30
- const MAX_INLINE_BYTES = Number(process.env.PI_WEB_INLINE_FILE_MAX ?? 12 * 1024);
31
29
  const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]);
32
30
  const MIME = {
33
31
  ".png": "image/png",
@@ -40,55 +38,30 @@ export async function buildAttachmentMessages(ctx, attachments) {
40
38
  };
41
39
  const out = [];
42
40
  /** Push the aside for a raw uploaded file (fresh fileData or a restored
43
- * uploadPath re-read from disk). Small text files are inlined so the
44
- * model sees them immediately; everything else becomes a path reference.
41
+ * uploadPath re-read from disk). Uploads are NEVER inlined: the model gets
42
+ * the persisted absolute path and reads it on demand with its read tool.
45
43
  * `upload: true` marks the card as a restorable upload — the browser
46
44
  * re-sends it by path when editing & re-asking a question. */
47
45
  const pushUploadAside = (name, wirePath, buf) => {
48
- if (buf.length <= MAX_INLINE_BYTES && looksLikeText(buf)) {
49
- const lines = countLines(buf);
50
- out.push({
51
- message: {
52
- customType: "file",
53
- content: [
54
- {
55
- type: "text",
56
- text: `\n<file path="${wirePath}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
57
- },
58
- ],
59
- display: true,
60
- details: {
61
- name,
62
- path: wirePath,
63
- mode: "inline",
64
- size: buf.length,
65
- lines,
66
- upload: true,
67
- },
68
- },
69
- });
70
- }
71
- else {
72
- out.push({
73
- message: {
74
- customType: "file",
75
- content: [
76
- {
77
- type: "text",
78
- text: `<file path="${wirePath}" size="${buf.length}" />`,
79
- },
80
- ],
81
- display: true,
82
- details: {
83
- name,
84
- path: wirePath,
85
- mode: "reference",
86
- size: buf.length,
87
- upload: true,
46
+ out.push({
47
+ message: {
48
+ customType: "file",
49
+ content: [
50
+ {
51
+ type: "text",
52
+ text: `<file path="${wirePath}" size="${buf.length}" />`,
88
53
  },
54
+ ],
55
+ display: true,
56
+ details: {
57
+ name,
58
+ path: wirePath,
59
+ mode: "reference",
60
+ size: buf.length,
61
+ upload: true,
89
62
  },
90
- });
91
- }
63
+ },
64
+ });
92
65
  };
93
66
  // -- Vision bridge ------------------------------------------------------
94
67
  // When the active model can't accept images (DeepSeek, GLM, …), pasted
@@ -244,8 +217,6 @@ export async function buildAttachmentMessages(ctx, attachments) {
244
217
  }
245
218
  }
246
219
  }
247
- /** Cap for reading a file in "lines" mode (selected slice is inlined). */
248
- const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
249
220
  for (const [idx, att] of attachments.entries()) {
250
221
  // Quoted conversation (left-panel right-click / global-search quote):
251
222
  // `path` is unused — the reference travels in conversationId (running
@@ -343,6 +314,16 @@ export async function buildAttachmentMessages(ctx, attachments) {
343
314
  });
344
315
  continue;
345
316
  }
317
+ let attachmentUrl;
318
+ let attachmentHash;
319
+ try {
320
+ const rec = await saveAttachment(Buffer.from(raw, "base64"), mimeType);
321
+ attachmentUrl = rec.url;
322
+ attachmentHash = rec.hash;
323
+ }
324
+ catch {
325
+ /* CAS 保存失败不阻断流程 */
326
+ }
346
327
  const transcript = bridgeTranscripts.get(idx);
347
328
  if (transcript) {
348
329
  // Bridged: the text-only main model can't see images, so it gets the
@@ -356,7 +337,12 @@ export async function buildAttachmentMessages(ctx, attachments) {
356
337
  type: "text",
357
338
  text: `\n<vision-bridge>\n${transcript}\n</vision-bridge>`,
358
339
  },
359
- { type: "image", data: raw, mimeType },
340
+ {
341
+ type: "image",
342
+ data: raw,
343
+ mimeType,
344
+ ...(attachmentUrl ? { source: { type: "url", url: attachmentUrl } } : {}),
345
+ },
360
346
  ],
361
347
  display: true,
362
348
  details: {
@@ -364,6 +350,8 @@ export async function buildAttachmentMessages(ctx, attachments) {
364
350
  path: undefined,
365
351
  mode: "bridged",
366
352
  size: bytes,
353
+ attachmentUrl,
354
+ attachmentHash,
367
355
  },
368
356
  },
369
357
  });
@@ -372,7 +360,14 @@ export async function buildAttachmentMessages(ctx, attachments) {
372
360
  out.push({
373
361
  message: {
374
362
  customType: "file",
375
- content: [{ type: "image", data: raw, mimeType }],
363
+ content: [
364
+ {
365
+ type: "image",
366
+ data: raw,
367
+ mimeType,
368
+ ...(attachmentUrl ? { source: { type: "url", url: attachmentUrl } } : {}),
369
+ },
370
+ ],
376
371
  display: true,
377
372
  details: {
378
373
  name: att.name ?? "image.png",
@@ -380,6 +375,8 @@ export async function buildAttachmentMessages(ctx, attachments) {
380
375
  path: undefined,
381
376
  mode: "image",
382
377
  size: bytes,
378
+ attachmentUrl,
379
+ attachmentHash,
383
380
  },
384
381
  },
385
382
  });
@@ -388,9 +385,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
388
385
  // Raw uploaded file (base64) — no workspace path involved. The bytes are
389
386
  // persisted under <dataDir>/uploads/<clientId>/ so the model can read
390
387
  // them on demand with its read tool (absolute path, no traversal guard
391
- // needed — the path is server-generated). Small text uploads are inlined
392
- // so the model sees them immediately; everything else becomes a path
393
- // reference.
388
+ // needed — the path is server-generated). Always a path reference.
394
389
  if (att.fileData) {
395
390
  const buf = Buffer.from(att.fileData, "base64");
396
391
  const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
@@ -601,6 +596,7 @@ ${transcript}
601
596
  });
602
597
  continue;
603
598
  }
599
+ /** Path-only aside — no file content is read or injected. */
604
600
  const makeReference = () => ({
605
601
  message: {
606
602
  customType: "file",
@@ -614,36 +610,12 @@ ${transcript}
614
610
  details: { name, path: rel, mode: "reference", size: stat.size },
615
611
  },
616
612
  });
617
- const makeInline = (buf) => {
618
- const lines = countLines(buf);
619
- return {
620
- message: {
621
- customType: "file",
622
- content: [
623
- {
624
- type: "text",
625
- text: `\n<file path="${rel}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
626
- },
627
- ],
628
- display: true,
629
- details: {
630
- name,
631
- path: rel,
632
- mode: "inline",
633
- size: stat.size,
634
- lines,
635
- },
636
- },
637
- };
638
- };
639
- // Reference mode is always honored and never reads the file.
640
- if (att.mode === "reference") {
641
- out.push(makeReference());
642
- continue;
643
- }
644
- // Line-range mode: inline only the selected 1-based inclusive range.
645
- // Reading is capped so a huge file can't exhaust memory even though
646
- // the selected slice is small.
613
+ // Everything else is a PATH REFERENCE: the file content is never injected
614
+ // into the prompt (small files included) — the model reads what it needs
615
+ // with its own read tool (built-in truncation/pagination).
616
+ // Line-range mode adds the `lines` attribute so the model knows which
617
+ // slice the user picked and only reads that part.
618
+ // A legacy `mode: "inline"` (old client / persisted draft) lands here too.
647
619
  if (att.mode === "lines") {
648
620
  const range = att.lines;
649
621
  if (!range || range.start < 1 || range.end < range.start) {
@@ -656,52 +628,13 @@ ${transcript}
656
628
  out.push(makeReference());
657
629
  continue;
658
630
  }
659
- if (stat.size > MAX_LINES_READ_BYTES) {
660
- ctx.emit({
661
- type: "notice",
662
- level: "warning",
663
- text: `文件过大,已改为仅引用:${att.path}`,
664
- textEn: `File too large, switched to reference-only: ${att.path}`,
665
- });
666
- out.push(makeReference());
667
- continue;
668
- }
669
- const buf = await fs.readFile(abs);
670
- if (buf.includes(0)) {
671
- ctx.emit({
672
- type: "notice",
673
- level: "warning",
674
- text: `二进制文件已改为仅引用:${att.path}`,
675
- textEn: `Binary file, switched to reference-only: ${att.path}`,
676
- });
677
- out.push(makeReference());
678
- continue;
679
- }
680
- const parts = decodeText(buf).split("\n");
681
- // A trailing newline yields an empty phantom line — drop it so line
682
- // numbers match the preview panel.
683
- if (parts.length > 0 && parts[parts.length - 1] === "")
684
- parts.pop();
685
- const start = Math.min(range.start, parts.length);
686
- const end = Math.min(range.end, parts.length);
687
- if (start < 1 || end < start) {
688
- ctx.emit({
689
- type: "notice",
690
- level: "warning",
691
- text: `选中行超出文件范围,已改为仅引用:${att.path}`,
692
- textEn: `Selected lines out of range, switched to reference-only: ${att.path}`,
693
- });
694
- out.push(makeReference());
695
- continue;
696
- }
697
- const selected = parts.slice(start - 1, end).join("\n");
698
631
  out.push({
699
632
  message: {
700
633
  customType: "file",
701
634
  content: [
702
635
  {
703
636
  type: "text",
704
- text: `\n<file path="${rel}" lines="${start}-${end}">\n\`\`\`\n${selected}\n\`\`\`\n</file>`,
637
+ text: `<file path="${rel}" lines="${range.start}-${range.end}" size="${stat.size}" />`,
705
638
  },
706
639
  ],
707
640
  display: true,
@@ -710,57 +643,15 @@ ${transcript}
710
643
  path: rel,
711
644
  mode: "lines",
712
645
  size: stat.size,
713
- lines: end - start + 1,
714
- startLine: start,
715
- endLine: end,
646
+ lines: range.end - range.start + 1,
647
+ startLine: range.start,
648
+ endLine: range.end,
716
649
  },
717
650
  },
718
651
  });
719
652
  continue;
720
653
  }
721
- // Forced inline has a hard cap to protect the model context.
722
- if (att.mode === "inline") {
723
- if (stat.size > MAX_INLINE_BYTES) {
724
- ctx.emit({
725
- type: "notice",
726
- level: "warning",
727
- text: `文件过大,已改为仅引用:${att.path}`,
728
- textEn: `File too large, switched to reference-only: ${att.path}`,
729
- });
730
- out.push(makeReference());
731
- continue;
732
- }
733
- const buf = await fs.readFile(abs);
734
- if (buf.includes(0)) {
735
- ctx.emit({
736
- type: "notice",
737
- level: "warning",
738
- text: `二进制文件已改为仅引用:${att.path}`,
739
- textEn: `Binary file, switched to reference-only: ${att.path}`,
740
- });
741
- out.push(makeReference());
742
- continue;
743
- }
744
- out.push(makeInline(buf));
745
- continue;
746
- }
747
- // Auto: small files inline, large files reference by path.
748
- if (stat.size > MAX_INLINE_BYTES) {
749
- out.push(makeReference());
750
- continue;
751
- }
752
- const buf = await fs.readFile(abs);
753
- if (buf.includes(0)) {
754
- ctx.emit({
755
- type: "notice",
756
- level: "warning",
757
- text: `二进制文件已跳过(仅引用路径):${att.path}`,
758
- textEn: `Binary file skipped (path referenced only): ${att.path}`,
759
- });
760
- out.push(makeReference());
761
- continue;
762
- }
763
- out.push(makeInline(buf));
654
+ out.push(makeReference());
764
655
  }
765
656
  return out;
766
657
  }
@@ -474,8 +474,10 @@ export class ClientStateStore {
474
474
  : (stored?.terminalToolsEnabled ?? false),
475
475
  terminalBash: stored?.terminalBash ?? false,
476
476
  terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
477
+ terminalBashMaxForegroundMs: stored?.terminalBashMaxForegroundMs ?? 60_000,
477
478
  toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(stored?.toolWatchdogTimeoutMs),
478
479
  readDirEnabled: stored?.readDirEnabled ?? true,
480
+ toolApprovalEnabled: stored?.toolApprovalEnabled ?? true,
479
481
  editSoftEnabled: stored?.disabledAgentTools !== undefined
480
482
  ? deriveLegacy(legacyToDisabled(stored)).editSoftEnabled
481
483
  : (stored?.editSoftEnabled ?? false),
@@ -531,8 +533,10 @@ export class ClientStateStore {
531
533
  terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? false,
532
534
  terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
533
535
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
536
+ terminalBashMaxForegroundMs: settings.terminalBashMaxForegroundMs ?? cur.terminalBashMaxForegroundMs ?? 60_000,
534
537
  toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(settings.toolWatchdogTimeoutMs ?? cur.toolWatchdogTimeoutMs ?? DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS),
535
538
  readDirEnabled: settings.readDirEnabled ?? cur.readDirEnabled ?? true,
539
+ toolApprovalEnabled: settings.toolApprovalEnabled ?? cur.toolApprovalEnabled ?? true,
536
540
  editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
537
541
  questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
538
542
  goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
@@ -0,0 +1,125 @@
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 { bilingual, 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: "Specific focus and requirements for context compaction based on the current issue or task. Clearly specify: 1) The active problem/goal being solved; 2) Crucial context to PRESERVE (key architectural decisions, code changes, conventions, user constraints); 3) Distractions or historical details to REMOVE or aggressively condense (failed attempts, resolved debugging outputs, off-topic discussions).\n根据当前问题/任务制定的上下文压缩重点。请指明:1) 当前正在解决的核心任务或问题;2) 必须保留的核心上下文(架构决策、已确认修改、约定规范等);3) 应当丢弃或极简概括的无关历史(错误尝试、冗长排查、其他不相关讨论等)。",
62
+ }),
63
+ keepRecentTokens: Type.Optional(Type.Number({
64
+ description: "Optional scope of recent tokens to keep uncompacted (e.g. 1000 - 64000). Smaller values (e.g. 3000-8000) compact more aggressively, retaining only immediate context. Larger values retain more recent history. If omitted, the system automatically calculates a reasonable retention scope based on session size.\n可选的近期不压缩保留 token 范围(1000 - 64000)。较小值(如 3000-8000)压缩更彻底,只保留紧贴当前的上下文;较大值保留更多近期操作。不传时系统会根据当前会话规模自动计算合理的保留范围。",
65
+ })),
66
+ summary: Type.Optional(Type.String({
67
+ description: "Optional custom structured summary text written directly by you. If provided, this summary will be used as the core basis for the compaction entry.\n可选由你自主直接编写的精炼结构化摘要正文。若提供,系统将以此摘要为核心作为压缩总结。",
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: bilingual("Compress/compact the conversation context based on the current task or problem. You can specify what context to preserve (key decisions, code structure, requirements related to the current issue) and what to drop or summarize heavily (unrelated explorations, verbose tool outputs, resolved debugging steps). You can also control the compaction scope by setting how many recent tokens to keep untouched. Compaction executes when the current turn/run settles, refreshing the context for subsequent turns.", "根据当前任务或问题主动压缩上下文。你可以自主指定需要保留的关键信息(与当前问题相关的决策、代码结构、核心规范)以及需要丢弃或深度压缩的无关内容(无关探索、冗长输出、已解决的历史排查)。还可以通过指定保留最近的 token 数量来控制压缩范围。压缩将在本轮结束后立即执行,并在后续对话中生效。"),
76
+ promptSnippet: "proactively compact conversation context focusing on the current issue",
77
+ promptGuidelines: [
78
+ "Use compact_context when the conversation has grown long, or after extensive debugging/exploration, to focus context strictly on the current problem.",
79
+ "Clearly specify in 'focus' what to keep (decisions, specs, active changes) and what to drop (failed attempts, voluminous command outputs).",
80
+ "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.",
81
+ ],
82
+ parameters: CompactContextParams,
83
+ execute: async (_id, p) => {
84
+ const l = getLang();
85
+ const params = p;
86
+ const focus = params.focus?.trim() || "";
87
+ if (!focus) {
88
+ 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.");
89
+ return {
90
+ content: [{ type: "text", text: errMsg }],
91
+ details: { ok: false, error: errMsg },
92
+ isError: true,
93
+ };
94
+ }
95
+ const stats = host.getContextStats();
96
+ // 如果整个会话消息条数太少(< 4 条)且 token 极少(< 1200),无需压缩
97
+ if (stats.messageCount < 4 && stats.estimatedTokens < 1200) {
98
+ 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.`);
99
+ return {
100
+ content: [{ type: "text", text: msg }],
101
+ details: { ok: false, skipped: true, ...stats },
102
+ };
103
+ }
104
+ const effectiveKeepTokens = calculateEffectiveKeepRecentTokens(params.keepRecentTokens, stats.estimatedTokens);
105
+ const pending = {
106
+ focus,
107
+ keepRecentTokens: effectiveKeepTokens,
108
+ summary: params.summary?.trim() || undefined,
109
+ requestedAt: Date.now(),
110
+ };
111
+ host.scheduleCompaction(pending);
112
+ 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.`);
113
+ return {
114
+ content: [{ type: "text", text: successMsg }],
115
+ details: {
116
+ ok: true,
117
+ scheduled: true,
118
+ focus,
119
+ keepRecentTokens: effectiveKeepTokens,
120
+ hasCustomSummary: !!pending.summary,
121
+ },
122
+ };
123
+ },
124
+ });
125
+ }