pi-ui-extend 0.1.36 → 0.1.38
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/dist/app/app.d.ts +7 -0
- package/dist/app/app.js +40 -5
- package/dist/app/commands/command-controller.js +1 -0
- package/dist/app/commands/command-registry.d.ts +1 -0
- package/dist/app/commands/command-registry.js +8 -0
- package/dist/app/commands/command-session-actions.d.ts +2 -0
- package/dist/app/commands/command-session-actions.js +79 -1
- package/dist/app/extensions/extension-actions-controller.d.ts +4 -1
- package/dist/app/extensions/extension-actions-controller.js +31 -2
- package/dist/app/input/input-controller.d.ts +1 -0
- package/dist/app/input/input-controller.js +23 -2
- package/dist/app/input/terminal-edit-shortcuts.d.ts +1 -0
- package/dist/app/input/terminal-edit-shortcuts.js +7 -0
- package/dist/app/input/voice-controller.js +1 -1
- package/dist/app/popup/popup-action-controller.d.ts +1 -3
- package/dist/app/popup/popup-action-controller.js +1 -5
- package/dist/app/rendering/message-content.js +4 -3
- package/dist/app/rendering/render-controller.js +21 -38
- package/dist/app/rendering/status-line-renderer.d.ts +1 -0
- package/dist/app/rendering/status-line-renderer.js +14 -2
- package/dist/app/runtime.js +12 -2
- package/dist/app/screen/mouse-controller.js +2 -0
- package/dist/app/session/session-event-controller.d.ts +7 -0
- package/dist/app/session/session-event-controller.js +10 -13
- package/dist/app/terminal/terminal-controller.js +1 -0
- package/dist/app/terminal/terminal-output-buffer.d.ts +8 -6
- package/dist/app/terminal/terminal-output-buffer.js +24 -16
- package/dist/bundled-extensions/terminal-bell/index.js +118 -33
- package/dist/markdown-format.d.ts +1 -0
- package/dist/markdown-format.js +30 -16
- package/dist/schemas/pi-tools-suite-schema.d.ts +5 -0
- package/dist/schemas/pi-tools-suite-schema.js +5 -0
- package/dist/tool-renderers/apply-patch.js +6 -1
- package/dist/tool-renderers/patch-normalize.d.ts +24 -0
- package/dist/tool-renderers/patch-normalize.js +163 -0
- package/external/pi-tools-suite/README.md +3 -2
- package/external/pi-tools-suite/package.json +3 -3
- package/external/pi-tools-suite/src/antigravity-auth/index.ts +15 -2
- package/external/pi-tools-suite/src/antigravity-auth/status.ts +36 -19
- package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +5 -2
- package/external/pi-tools-suite/src/async-subagents/commands.ts +12 -2
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +8 -3
- package/external/pi-tools-suite/src/async-subagents/core/routing.ts +63 -28
- package/external/pi-tools-suite/src/async-subagents/core/tool-guard.ts +9 -4
- package/external/pi-tools-suite/src/comment-checker/config.ts +98 -0
- package/external/pi-tools-suite/src/comment-checker/detect.ts +215 -0
- package/external/pi-tools-suite/src/comment-checker/index.ts +294 -0
- package/external/pi-tools-suite/src/dcp/commands.ts +29 -15
- package/external/pi-tools-suite/src/dcp/compress-tool.ts +111 -60
- package/external/pi-tools-suite/src/dcp/config.ts +10 -6
- package/external/pi-tools-suite/src/dcp/debug-log.ts +235 -0
- package/external/pi-tools-suite/src/dcp/index.ts +204 -27
- package/external/pi-tools-suite/src/dcp/prompts.ts +25 -28
- package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +6 -10
- package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +19 -1
- package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +36 -58
- package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +18 -0
- package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +3 -3
- package/external/pi-tools-suite/src/dcp/pruner.ts +4 -2
- package/external/pi-tools-suite/src/dcp/state-persistence.ts +31 -2
- package/external/pi-tools-suite/src/dcp/state.ts +62 -4
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +18 -0
- package/external/pi-tools-suite/src/index.ts +1 -0
- package/external/pi-tools-suite/src/model-tools/index.ts +11 -3
- package/external/pi-tools-suite/src/telegram-mirror/index.ts +1 -1
- package/external/pi-tools-suite/src/todo/index.ts +24 -0
- package/external/pi-tools-suite/src/tool-descriptions.ts +3 -3
- package/external/pi-tools-suite/src/usage/index.ts +18 -4
- package/package.json +4 -4
- package/schemas/pi-tools-suite.json +24 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative } from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { parseApplyPatch } from "../model-tools/apply-patch.js";
|
|
5
|
+
import { detectSlopComments, type CommentFinding, type Edit, type Strictness } from "./detect.js";
|
|
6
|
+
import { loadCommentCheckerConfig } from "./config.js";
|
|
7
|
+
|
|
8
|
+
type ExtensionContext = import("@earendil-works/pi-coding-agent").ExtensionContext;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* comment-checker: AI-slop comment guard.
|
|
12
|
+
*
|
|
13
|
+
* Listens to the pi "tool_result" event for write/edit/apply_patch/ast_apply
|
|
14
|
+
* mutation tools, extracts the net-new comment lines the agent just added,
|
|
15
|
+
* classifies them, and appends a nudge to the tool result when they look
|
|
16
|
+
* unnecessary so the agent removes them on their next turn.
|
|
17
|
+
*
|
|
18
|
+
* Adapted from oh-my-opencode's comment-checker hook, but pure-TypeScript and
|
|
19
|
+
* headless (no external binary, no pending-calls machinery: pi's tool_result
|
|
20
|
+
* event already carries both the input and the result in one call).
|
|
21
|
+
*
|
|
22
|
+
* Per-session deduplication mirrors oh-my-opencode: at most one nudge per
|
|
23
|
+
* session within DEDUP_WINDOW_MS, to prevent a fix/remark loop.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const DEDUP_WINDOW_MS = 30_000;
|
|
27
|
+
const MAX_FINDINGS = 8;
|
|
28
|
+
|
|
29
|
+
const MUTATION_TOOL_NAMES = new Set(["write", "edit", "apply_patch", "ast_apply", "multiedit"]);
|
|
30
|
+
|
|
31
|
+
interface CommentCheckerOptions {
|
|
32
|
+
strictness: Strictness;
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadOptions(ctx: ExtensionContext): CommentCheckerOptions {
|
|
37
|
+
const config = loadCommentCheckerConfig(ctx.cwd);
|
|
38
|
+
return { strictness: config.strictness, enabled: config.enabled };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asString(value: unknown): string | undefined {
|
|
42
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asStringArray(value: unknown): readonly string[] | undefined {
|
|
46
|
+
if (!Array.isArray(value)) return undefined;
|
|
47
|
+
const out = value.filter((item): item is string => typeof item === "string");
|
|
48
|
+
return out.length > 0 ? out : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function splitLines(text: string | undefined): readonly string[] {
|
|
52
|
+
if (text === undefined) return [];
|
|
53
|
+
return text.split(/\r?\n/);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function editsFromWrite(input: Record<string, unknown>): Edit[] {
|
|
57
|
+
const filePath = asString(input.file_path) ?? asString(input.path);
|
|
58
|
+
const content = asString(input.content);
|
|
59
|
+
if (!filePath || content === undefined) return [];
|
|
60
|
+
// Full-file write: the added lines start at line 1 of the new file.
|
|
61
|
+
return [{ filePath, removedLines: [], addedLines: splitLines(content), baseLineNumber: 1 }];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function editsFromEdit(input: Record<string, unknown>): Edit[] {
|
|
65
|
+
const filePath = asString(input.file_path) ?? asString(input.path);
|
|
66
|
+
if (!filePath) return [];
|
|
67
|
+
|
|
68
|
+
// Claude/GLM alias shape: { old_string, new_string }.
|
|
69
|
+
const oldString = asString(input.old_string) ?? asString(input.oldString);
|
|
70
|
+
const newString = asString(input.new_string) ?? asString(input.newString);
|
|
71
|
+
if (oldString !== undefined || newString !== undefined) {
|
|
72
|
+
return [{ filePath, removedLines: splitLines(oldString), addedLines: splitLines(newString) }];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Pi builtin edit shape: { edits: [{ oldText, newText }] }.
|
|
76
|
+
const editsArray = asStringArray(input.edits) ?? input.edits;
|
|
77
|
+
if (Array.isArray(editsArray)) {
|
|
78
|
+
const removed: string[] = [];
|
|
79
|
+
const added: string[] = [];
|
|
80
|
+
for (const item of editsArray) {
|
|
81
|
+
if (item && typeof item === "object") {
|
|
82
|
+
const rec = item as Record<string, unknown>;
|
|
83
|
+
const o = asString(rec.oldText) ?? asString(rec.old_string);
|
|
84
|
+
const n = asString(rec.newText) ?? asString(rec.new_string);
|
|
85
|
+
removed.push(...splitLines(o));
|
|
86
|
+
added.push(...splitLines(n));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (added.length > 0 || removed.length > 0) {
|
|
90
|
+
return [{ filePath, removedLines: removed, addedLines: added }];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function editsFromApplyPatch(input: Record<string, unknown>): Edit[] {
|
|
98
|
+
const patch = asString(input.input) ?? asString(input.patch) ?? asString(input.command);
|
|
99
|
+
if (!patch) return [];
|
|
100
|
+
|
|
101
|
+
let operations: ReturnType<typeof parseApplyPatch> = [];
|
|
102
|
+
try {
|
|
103
|
+
operations = parseApplyPatch(patch);
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const edits: Edit[] = [];
|
|
109
|
+
for (const op of operations) {
|
|
110
|
+
if (op.kind === "delete") continue;
|
|
111
|
+
if (op.kind === "add") {
|
|
112
|
+
// Add File creates a new file; added lines start at line 1.
|
|
113
|
+
edits.push({ filePath: op.path, removedLines: [], addedLines: op.lines, baseLineNumber: 1 });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// update: reconstruct added/removed lines from hunks.
|
|
117
|
+
const removed: string[] = [];
|
|
118
|
+
const added: string[] = [];
|
|
119
|
+
for (const hunk of op.hunks) {
|
|
120
|
+
for (const line of hunk.lines) {
|
|
121
|
+
if (line.kind === "remove") removed.push(line.text);
|
|
122
|
+
else if (line.kind === "add") added.push(line.text);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (added.length > 0 || removed.length > 0) {
|
|
126
|
+
edits.push({ filePath: op.moveTo ?? op.path, removedLines: removed, addedLines: added });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return edits;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function editsFromAstApply(details: unknown): Edit[] {
|
|
133
|
+
void details;
|
|
134
|
+
// ast_apply does not expose per-file diffs cheaply in details; the caller
|
|
135
|
+
// would have to re-read. Skip diffing here and rely on write/edit/apply_patch.
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function extractEdits(toolName: string, input: Record<string, unknown>, details: unknown): Edit[] {
|
|
140
|
+
const base = toolName.includes(".") ? toolName.split(".").pop() ?? toolName : toolName;
|
|
141
|
+
const lower = base.toLowerCase();
|
|
142
|
+
|
|
143
|
+
if (lower === "write") return editsFromWrite(input);
|
|
144
|
+
if (lower === "edit" || lower === "multiedit") return editsFromEdit(input);
|
|
145
|
+
if (lower === "apply_patch") return editsFromApplyPatch(input);
|
|
146
|
+
if (lower === "ast_apply") return editsFromAstApply(details);
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isMutationTool(toolName: string): boolean {
|
|
151
|
+
const base = toolName.includes(".") ? toolName.split(".").pop() ?? toolName : toolName;
|
|
152
|
+
return MUTATION_TOOL_NAMES.has(base.toLowerCase());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve absolute line numbers for edits that do not already know them
|
|
157
|
+
* (edit / apply_patch update hunks) by reading the already-written file and
|
|
158
|
+
* locating the start of the added block. write / Add File already carry
|
|
159
|
+
* baseLineNumber=1 and are skipped.
|
|
160
|
+
*/
|
|
161
|
+
async function resolveBaseLineNumbers(edits: readonly Edit[]): Promise<void> {
|
|
162
|
+
for (const edit of edits) {
|
|
163
|
+
if (edit.baseLineNumber !== undefined) continue;
|
|
164
|
+
if (edit.addedLines.length === 0) continue;
|
|
165
|
+
edit.baseLineNumber = await findBlockStartLine(edit.filePath, edit.addedLines);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Find the 1-based line number where `addedLines` begins in the target file.
|
|
171
|
+
* Matches the leading non-empty lines of `addedLines` against the file; returns
|
|
172
|
+
* undefined if the file cannot be read or the block is not found.
|
|
173
|
+
*/
|
|
174
|
+
async function findBlockStartLine(filePath: string, addedLines: readonly string[]): Promise<number | undefined> {
|
|
175
|
+
let raw: string;
|
|
176
|
+
try {
|
|
177
|
+
raw = await readFile(filePath, "utf8");
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
const fileLines = raw.split(/\r?\n/);
|
|
182
|
+
|
|
183
|
+
// Anchor: up to 3 leading non-empty added lines.
|
|
184
|
+
const anchor: string[] = [];
|
|
185
|
+
for (const line of addedLines) {
|
|
186
|
+
if (line.trim().length === 0) {
|
|
187
|
+
if (anchor.length === 0) continue;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
anchor.push(line);
|
|
191
|
+
if (anchor.length >= 3) break;
|
|
192
|
+
}
|
|
193
|
+
if (anchor.length === 0) return undefined;
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i + anchor.length <= fileLines.length; i++) {
|
|
196
|
+
let match = true;
|
|
197
|
+
for (let j = 0; j < anchor.length; j++) {
|
|
198
|
+
if (fileLines[i + j] !== anchor[j]) {
|
|
199
|
+
match = false;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (match) return i + 1;
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const REASON_TAGS: Record<string, string> = {
|
|
209
|
+
"restate-code": "restate",
|
|
210
|
+
filler: "filler",
|
|
211
|
+
decorative: "decorative",
|
|
212
|
+
"generic-explanation": "generic",
|
|
213
|
+
"non-essential-comment": "slop",
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** Render a display path: relative when inside cwd, otherwise the raw path. */
|
|
217
|
+
function displayPath(filePath: string, cwd: string): string {
|
|
218
|
+
if (isAbsolute(filePath)) {
|
|
219
|
+
const rel = relative(cwd, filePath);
|
|
220
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
|
|
221
|
+
}
|
|
222
|
+
return filePath;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function formatNudge(findings: CommentFinding[], cwd: string): string {
|
|
226
|
+
// Group by display path, preserving first-seen order.
|
|
227
|
+
const groups = new Map<string, CommentFinding[]>();
|
|
228
|
+
const order: string[] = [];
|
|
229
|
+
for (const finding of findings) {
|
|
230
|
+
const key = displayPath(finding.filePath, cwd);
|
|
231
|
+
const bucket = groups.get(key);
|
|
232
|
+
if (bucket) bucket.push(finding);
|
|
233
|
+
else {
|
|
234
|
+
groups.set(key, [finding]);
|
|
235
|
+
order.push(key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Compact: one line per file. Each finding is `line:tag` (no comment text —
|
|
240
|
+
// the file already contains it, and the path+line locate it exactly). This
|
|
241
|
+
// keeps the nudge token-light while preserving location + failure category.
|
|
242
|
+
const out: string[] = [];
|
|
243
|
+
out.push("");
|
|
244
|
+
out.push("---");
|
|
245
|
+
out.push("💬 comment-checker — unnecessary comments at the lines below (line:reason). Remove any that only restate code / are filler; keep intent, contracts, rationale, TODO.");
|
|
246
|
+
out.push("");
|
|
247
|
+
for (const key of order) {
|
|
248
|
+
const entries = (groups.get(key) ?? []).map((finding) => {
|
|
249
|
+
const tag = REASON_TAGS[finding.reason] ?? finding.reason;
|
|
250
|
+
return finding.line !== undefined ? `${finding.line}:${tag}` : `?:${tag}`;
|
|
251
|
+
});
|
|
252
|
+
out.push(`${key} ${entries.join(" ")}`);
|
|
253
|
+
}
|
|
254
|
+
out.push("");
|
|
255
|
+
out.push("---");
|
|
256
|
+
return out.join("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Shared mutable state (module-scoped, like lsp's global manager). */
|
|
260
|
+
let lastNudgeTimestamp = 0;
|
|
261
|
+
|
|
262
|
+
export function __resetCommentCheckerState(): void {
|
|
263
|
+
lastNudgeTimestamp = 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export default function commentCheckerExtension(pi: ExtensionAPI): void {
|
|
267
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
268
|
+
if (event.isError) return undefined;
|
|
269
|
+
if (!isMutationTool(event.toolName)) return undefined;
|
|
270
|
+
|
|
271
|
+
const options = loadOptions(ctx);
|
|
272
|
+
if (!options.enabled) return undefined;
|
|
273
|
+
|
|
274
|
+
const edits = extractEdits(event.toolName, event.input, event.details);
|
|
275
|
+
if (edits.length === 0) return undefined;
|
|
276
|
+
|
|
277
|
+
// Resolve absolute line numbers for edit/apply_patch by reading the file
|
|
278
|
+
// the tool just wrote. Failures are non-fatal: findings just lack a line.
|
|
279
|
+
await resolveBaseLineNumbers(edits);
|
|
280
|
+
|
|
281
|
+
const findings = detectSlopComments(edits, options.strictness, MAX_FINDINGS);
|
|
282
|
+
if (findings.length === 0) return undefined;
|
|
283
|
+
|
|
284
|
+
// Per-session dedup: at most one nudge per DEDUP_WINDOW_MS.
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
if (now - lastNudgeTimestamp < DEDUP_WINDOW_MS) return undefined;
|
|
287
|
+
lastNudgeTimestamp = now;
|
|
288
|
+
|
|
289
|
+
const nudge = formatNudge(findings, ctx.cwd);
|
|
290
|
+
return {
|
|
291
|
+
content: [...event.content, { type: "text" as const, text: nudge }],
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { DcpState } from "./state.js"
|
|
|
4
4
|
import { modelKeysFromContext, resolveModelConfig, type DcpConfig } from "./config.js"
|
|
5
5
|
import type { DcpNudgeType } from "./pruner-types.js"
|
|
6
6
|
import { isToolRecordProtected, markToolPruned } from "./pruner.js"
|
|
7
|
-
import { safeGetContextUsage } from "../context-usage.js"
|
|
7
|
+
import { ignoreStaleExtensionContextError, safeGetContextUsage } from "../context-usage.js"
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// Constants
|
|
@@ -54,7 +54,7 @@ function customEntryData(entry: unknown, customType: string): Record<string, unk
|
|
|
54
54
|
return record.data as Record<string, unknown>
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function branchEntries(ctx: ExtensionCommandContext):
|
|
57
|
+
function branchEntries(ctx: ExtensionCommandContext): any[] {
|
|
58
58
|
try {
|
|
59
59
|
const branch = ctx.sessionManager?.getBranch?.()
|
|
60
60
|
return Array.isArray(branch) ? branch : []
|
|
@@ -63,6 +63,14 @@ function branchEntries(ctx: ExtensionCommandContext): unknown[] {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
async function staleSafe(action: () => void | Promise<void>): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
await action()
|
|
69
|
+
} catch (error) {
|
|
70
|
+
ignoreStaleExtensionContextError(error)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
interface DcpNudgeStats {
|
|
67
75
|
emitted: number
|
|
68
76
|
upgraded: number
|
|
@@ -262,7 +270,7 @@ async function handleSweep(
|
|
|
262
270
|
): Promise<void> {
|
|
263
271
|
await ctx.waitForIdle()
|
|
264
272
|
|
|
265
|
-
const branch = ctx
|
|
273
|
+
const branch = branchEntries(ctx)
|
|
266
274
|
|
|
267
275
|
// Build the full set of protected tool names.
|
|
268
276
|
const protectedTools = new Set<string>([
|
|
@@ -341,7 +349,9 @@ async function handleSweep(
|
|
|
341
349
|
}
|
|
342
350
|
}
|
|
343
351
|
|
|
344
|
-
|
|
352
|
+
await staleSafe(() => {
|
|
353
|
+
ctx.ui.notify(`Swept ${count} tool output${count === 1 ? "" : "s"}`, "info")
|
|
354
|
+
})
|
|
345
355
|
}
|
|
346
356
|
|
|
347
357
|
// ---------------------------------------------------------------------------
|
|
@@ -495,17 +505,19 @@ function handleRecompress(
|
|
|
495
505
|
async function handleCompress(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
496
506
|
await ctx.waitForIdle()
|
|
497
507
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
508
|
+
await staleSafe(() => {
|
|
509
|
+
pi.sendMessage(
|
|
510
|
+
{
|
|
511
|
+
customType: "dcp-compress-trigger",
|
|
512
|
+
content:
|
|
513
|
+
"Please compress stale conversation sections using the compress tool now.",
|
|
514
|
+
display: false,
|
|
515
|
+
},
|
|
516
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
517
|
+
)
|
|
507
518
|
|
|
508
|
-
|
|
519
|
+
ctx.ui.notify("Triggered compression", "info")
|
|
520
|
+
})
|
|
509
521
|
}
|
|
510
522
|
|
|
511
523
|
// ---------------------------------------------------------------------------
|
|
@@ -588,7 +600,9 @@ export function registerCommands(
|
|
|
588
600
|
break
|
|
589
601
|
}
|
|
590
602
|
} finally {
|
|
591
|
-
|
|
603
|
+
await staleSafe(() => {
|
|
604
|
+
hooks.onStateChanged?.(ctx)
|
|
605
|
+
})
|
|
592
606
|
}
|
|
593
607
|
},
|
|
594
608
|
})
|
|
@@ -6,11 +6,13 @@ import { Type } from "typebox"
|
|
|
6
6
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
7
7
|
import type { DcpState } from "./state.js"
|
|
8
8
|
import { modelKeysFromContext, resolveModelConfig, type DcpConfig } from "./config.js"
|
|
9
|
+
import { saveDcpState } from "./state-persistence.js"
|
|
9
10
|
import { clearDcpNudgeAnchors } from "./pruner.js"
|
|
10
11
|
import type { DcpCompressionVisualDetails } from "./ui.js"
|
|
11
12
|
import { normalizeDcpContextUsage } from "./ui.js"
|
|
12
13
|
import { COMPRESS_TOOL_DESCRIPTION } from "../tool-descriptions.js"
|
|
13
14
|
import { safeGetContextUsage } from "../context-usage.js"
|
|
15
|
+
import { summarizeDcpState, writeDcpDebugLog } from "./debug-log.js"
|
|
14
16
|
import {
|
|
15
17
|
createRangeCompressionBlock,
|
|
16
18
|
findCoveredAndPartialBlocks,
|
|
@@ -160,73 +162,111 @@ export function registerCompressTool(
|
|
|
160
162
|
let operationRemovedTokens = 0
|
|
161
163
|
let operationSummaryTokens = 0
|
|
162
164
|
|
|
165
|
+
const log = (event: string, details: Record<string, unknown> = {}) =>
|
|
166
|
+
writeDcpDebugLog(effectiveConfig, event, details, ctx)
|
|
167
|
+
|
|
168
|
+
log("compress.request", {
|
|
169
|
+
toolCallId: _toolCallId,
|
|
170
|
+
topic: params.topic,
|
|
171
|
+
ranges: ranges.map((range) => ({ startId: range.startId, endId: range.endId })),
|
|
172
|
+
messages: messages.map((entry) => ({ messageId: entry.messageId, topic: entry.topic })),
|
|
173
|
+
state: summarizeDcpState(state),
|
|
174
|
+
})
|
|
175
|
+
|
|
163
176
|
if (ranges.length === 0 && messages.length === 0) {
|
|
164
177
|
throw new Error("compress requires at least one ranges[] or messages[] entry")
|
|
165
178
|
}
|
|
166
179
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
180
|
+
let rangePlans: ResolvedRangePlan[]
|
|
181
|
+
try {
|
|
182
|
+
rangePlans = ranges.map((range) => {
|
|
183
|
+
const { startId, endId, summary } = range
|
|
184
|
+
|
|
185
|
+
// ── Resolve boundary timestamps ──────────────────────────────────
|
|
186
|
+
const startBoundary = resolveIdToBoundary(startId, "startTimestamp", state)
|
|
187
|
+
const endBoundary = resolveIdToBoundary(endId, "endTimestamp", state)
|
|
188
|
+
const startTimestamp = startBoundary.timestamp
|
|
189
|
+
const endTimestamp = endBoundary.timestamp
|
|
190
|
+
|
|
191
|
+
if (startTimestamp > endTimestamp) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Range start "${startId}" must appear before end "${endId}" in the conversation`,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Validate timestamps are finite ──────────────────────────────
|
|
198
|
+
if (!Number.isFinite(startTimestamp)) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Start ID "${startId}" resolved to a non-finite timestamp (${startTimestamp}). ` +
|
|
201
|
+
`This usually means the referenced message has a corrupted timestamp.`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
if (!Number.isFinite(endTimestamp)) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`End ID "${endId}" resolved to a non-finite timestamp (${endTimestamp}). ` +
|
|
207
|
+
`This usually means the referenced message has a corrupted timestamp.`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
startId,
|
|
213
|
+
endId,
|
|
214
|
+
summary,
|
|
215
|
+
startTimestamp,
|
|
216
|
+
endTimestamp,
|
|
217
|
+
startMessageId: startBoundary.stableId,
|
|
218
|
+
endMessageId: endBoundary.stableId,
|
|
219
|
+
}
|
|
220
|
+
})
|
|
206
221
|
|
|
207
|
-
|
|
222
|
+
validateNonOverlappingRanges(rangePlans)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
log("compress.resolve_failed", {
|
|
225
|
+
toolCallId: _toolCallId,
|
|
226
|
+
error: error instanceof Error ? error.message : String(error),
|
|
227
|
+
state: summarizeDcpState(state),
|
|
228
|
+
})
|
|
229
|
+
throw error
|
|
230
|
+
}
|
|
208
231
|
|
|
209
232
|
for (const range of rangePlans) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
try {
|
|
234
|
+
const anchor = resolveAnchorBoundary(range.endTimestamp, state)
|
|
235
|
+
|
|
236
|
+
const created = createRangeCompressionBlock({
|
|
237
|
+
topic: params.topic,
|
|
238
|
+
summary: range.summary,
|
|
239
|
+
startTimestamp: range.startTimestamp,
|
|
240
|
+
endTimestamp: range.endTimestamp,
|
|
241
|
+
startMessageId: range.startMessageId,
|
|
242
|
+
endMessageId: range.endMessageId,
|
|
243
|
+
anchorTimestamp: anchor.timestamp,
|
|
244
|
+
anchorMessageId: anchor.stableId,
|
|
245
|
+
createdByToolCallId: _toolCallId,
|
|
246
|
+
state,
|
|
247
|
+
config: effectiveConfig,
|
|
248
|
+
mode: "range",
|
|
249
|
+
})
|
|
250
|
+
const block = created.block
|
|
251
|
+
newBlockIds.push(block.id)
|
|
252
|
+
operationRemovedTokens += created.removedTokenEstimate
|
|
253
|
+
operationSummaryTokens += created.summaryTokenEstimate
|
|
254
|
+
log("compress.range_created", {
|
|
255
|
+
toolCallId: _toolCallId,
|
|
256
|
+
range: { startId: range.startId, endId: range.endId },
|
|
257
|
+
blockId: `b${block.id}`,
|
|
258
|
+
coveredBlockIds: block.coveredBlockIds ?? [],
|
|
259
|
+
anchorMessageId: block.anchorMessageId,
|
|
260
|
+
})
|
|
261
|
+
} catch (error) {
|
|
262
|
+
log("compress.range_failed", {
|
|
263
|
+
toolCallId: _toolCallId,
|
|
264
|
+
range: { startId: range.startId, endId: range.endId },
|
|
265
|
+
error: error instanceof Error ? error.message : String(error),
|
|
266
|
+
state: summarizeDcpState(state),
|
|
267
|
+
})
|
|
268
|
+
throw error
|
|
269
|
+
}
|
|
230
270
|
}
|
|
231
271
|
|
|
232
272
|
const skippedMessageIssues: MessageSkipIssue[] = []
|
|
@@ -329,6 +369,17 @@ export function registerCompressTool(
|
|
|
329
369
|
}
|
|
330
370
|
}
|
|
331
371
|
|
|
372
|
+
if (newBlockIds.length > 0) {
|
|
373
|
+
await saveDcpState(ctx, state)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
log("compress.success", {
|
|
377
|
+
toolCallId: _toolCallId,
|
|
378
|
+
newBlockIds: newBlockIds.map((id) => `b${id}`),
|
|
379
|
+
skippedMessages: skippedMessageIssues.length,
|
|
380
|
+
state: summarizeDcpState(state),
|
|
381
|
+
})
|
|
382
|
+
|
|
332
383
|
const usage = normalizeDcpContextUsage(safeGetContextUsage(ctx))
|
|
333
384
|
const operationTokensSaved = Math.max(0, operationRemovedTokens - operationSummaryTokens)
|
|
334
385
|
const itemCount = ranges.length + messages.length
|
|
@@ -10,6 +10,10 @@ import { parse as parseJsonc, type ParseError } from "jsonc-parser"
|
|
|
10
10
|
export interface DcpConfig {
|
|
11
11
|
enabled: boolean
|
|
12
12
|
debug: boolean
|
|
13
|
+
debugLog?: {
|
|
14
|
+
maxBytes?: number
|
|
15
|
+
maxBackups?: number
|
|
16
|
+
}
|
|
13
17
|
manualMode: {
|
|
14
18
|
enabled: boolean
|
|
15
19
|
automaticStrategies: boolean // run dedup/purge even in manual mode
|
|
@@ -90,27 +94,27 @@ const DEFAULT_CONFIG: DcpConfig = {
|
|
|
90
94
|
automaticStrategies: true,
|
|
91
95
|
},
|
|
92
96
|
compress: {
|
|
93
|
-
maxContextPercent: 0.
|
|
94
|
-
minContextPercent: 0.
|
|
97
|
+
maxContextPercent: 0.65,
|
|
98
|
+
minContextPercent: 0.40,
|
|
95
99
|
modelMaxContextPercent: {},
|
|
96
100
|
modelMinContextPercent: {},
|
|
97
101
|
summaryBuffer: true,
|
|
98
|
-
nudgeFrequency:
|
|
99
|
-
iterationNudgeThreshold:
|
|
102
|
+
nudgeFrequency: 2,
|
|
103
|
+
iterationNudgeThreshold: 8,
|
|
100
104
|
nudgeForce: "soft",
|
|
101
105
|
protectedTools: ["compress", "write", "edit"],
|
|
102
106
|
protectTags: false,
|
|
103
107
|
protectUserMessages: false,
|
|
104
108
|
autoCandidates: {
|
|
105
109
|
enabled: true,
|
|
106
|
-
minContextPercent: 0.
|
|
110
|
+
minContextPercent: 0.40,
|
|
107
111
|
keepRecentTurns: 1,
|
|
108
112
|
minMessages: 6,
|
|
109
113
|
minTokens: 1500,
|
|
110
114
|
},
|
|
111
115
|
messageMode: {
|
|
112
116
|
enabled: true,
|
|
113
|
-
minContextPercent: 0.
|
|
117
|
+
minContextPercent: 0.40,
|
|
114
118
|
keepRecentTurns: 1,
|
|
115
119
|
mediumTokens: 500,
|
|
116
120
|
highTokens: 5000,
|