pi-web-ui 0.94.0 → 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.
- package/CHANGELOG.md +62 -0
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +751 -206
- package/dist/server/agent-service.js +1869 -85
- package/dist/server/approval-rules.js +550 -0
- package/dist/server/attachment-store.js +112 -0
- package/dist/server/attachments.js +62 -171
- package/dist/server/client-state.js +4 -0
- package/dist/server/compact-context-tool.js +125 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/dangling-tools.js +162 -0
- package/dist/server/dsh/dsh-agent-service.js +50 -60
- package/dist/server/edit-soft-tool.js +70 -5
- package/dist/server/eval-tool.js +535 -0
- package/dist/server/files-service.js +35 -7
- package/dist/server/goal-service.js +240 -68
- package/dist/server/hashline-engine.js +665 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/http-proxy.js +11 -1
- package/dist/server/index.js +157 -9
- package/dist/server/lsp-tool.js +679 -0
- package/dist/server/model-admin.js +20 -0
- package/dist/server/office-parse.js +309 -0
- package/dist/server/patch-tool.js +91 -0
- package/dist/server/plan-manager.js +104 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +47 -11
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugins.js +745 -161
- package/dist/server/process-utils.js +12 -6
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/serialize.js +13 -3
- package/dist/server/settings-service.js +108 -1
- package/dist/server/subagents.js +41 -0
- package/dist/server/terminals.js +69 -14
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +214 -6
- package/dist/server/workspace-snapshot.js +113 -0
- package/package.json +3 -2
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +20 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/dark-teal.css +20 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
- package/web/dist/assets/index-BXnCQL9p.js +364 -0
- package/web/dist/assets/index-RpIGFyi_.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-18jK6al2.js +0 -364
- package/web/dist/assets/index-B3S9MxnN.css +0 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
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).
|
|
44
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
{
|
|
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: [
|
|
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).
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|