pi-ui-extend 0.1.36 → 0.1.37

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 (70) hide show
  1. package/dist/app/app.d.ts +7 -0
  2. package/dist/app/app.js +40 -5
  3. package/dist/app/commands/command-controller.js +1 -0
  4. package/dist/app/commands/command-registry.d.ts +1 -0
  5. package/dist/app/commands/command-registry.js +8 -0
  6. package/dist/app/commands/command-session-actions.d.ts +2 -0
  7. package/dist/app/commands/command-session-actions.js +79 -1
  8. package/dist/app/extensions/extension-actions-controller.d.ts +4 -1
  9. package/dist/app/extensions/extension-actions-controller.js +31 -2
  10. package/dist/app/input/input-controller.d.ts +1 -0
  11. package/dist/app/input/input-controller.js +23 -2
  12. package/dist/app/input/terminal-edit-shortcuts.d.ts +1 -0
  13. package/dist/app/input/terminal-edit-shortcuts.js +7 -0
  14. package/dist/app/input/voice-controller.js +1 -1
  15. package/dist/app/popup/popup-action-controller.d.ts +1 -3
  16. package/dist/app/popup/popup-action-controller.js +1 -5
  17. package/dist/app/rendering/message-content.js +4 -3
  18. package/dist/app/rendering/render-controller.js +21 -38
  19. package/dist/app/rendering/status-line-renderer.d.ts +1 -0
  20. package/dist/app/rendering/status-line-renderer.js +14 -2
  21. package/dist/app/runtime.js +12 -2
  22. package/dist/app/screen/mouse-controller.js +2 -0
  23. package/dist/app/session/session-event-controller.d.ts +7 -0
  24. package/dist/app/session/session-event-controller.js +10 -13
  25. package/dist/app/terminal/terminal-controller.js +1 -0
  26. package/dist/app/terminal/terminal-output-buffer.d.ts +8 -6
  27. package/dist/app/terminal/terminal-output-buffer.js +24 -16
  28. package/dist/bundled-extensions/terminal-bell/index.js +118 -33
  29. package/dist/markdown-format.d.ts +1 -0
  30. package/dist/markdown-format.js +30 -16
  31. package/dist/schemas/pi-tools-suite-schema.d.ts +5 -0
  32. package/dist/schemas/pi-tools-suite-schema.js +5 -0
  33. package/dist/tool-renderers/apply-patch.js +6 -1
  34. package/dist/tool-renderers/patch-normalize.d.ts +24 -0
  35. package/dist/tool-renderers/patch-normalize.js +163 -0
  36. package/external/pi-tools-suite/README.md +3 -2
  37. package/external/pi-tools-suite/package.json +3 -3
  38. package/external/pi-tools-suite/src/antigravity-auth/index.ts +15 -2
  39. package/external/pi-tools-suite/src/antigravity-auth/status.ts +36 -19
  40. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +5 -2
  41. package/external/pi-tools-suite/src/async-subagents/commands.ts +12 -2
  42. package/external/pi-tools-suite/src/async-subagents/core/config.ts +8 -3
  43. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +63 -28
  44. package/external/pi-tools-suite/src/async-subagents/core/tool-guard.ts +9 -4
  45. package/external/pi-tools-suite/src/comment-checker/config.ts +98 -0
  46. package/external/pi-tools-suite/src/comment-checker/detect.ts +215 -0
  47. package/external/pi-tools-suite/src/comment-checker/index.ts +294 -0
  48. package/external/pi-tools-suite/src/dcp/commands.ts +29 -15
  49. package/external/pi-tools-suite/src/dcp/compress-tool.ts +111 -60
  50. package/external/pi-tools-suite/src/dcp/config.ts +10 -6
  51. package/external/pi-tools-suite/src/dcp/debug-log.ts +235 -0
  52. package/external/pi-tools-suite/src/dcp/index.ts +204 -27
  53. package/external/pi-tools-suite/src/dcp/prompts.ts +25 -28
  54. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +6 -10
  55. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +19 -1
  56. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +36 -58
  57. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +18 -0
  58. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +3 -3
  59. package/external/pi-tools-suite/src/dcp/pruner.ts +4 -2
  60. package/external/pi-tools-suite/src/dcp/state-persistence.ts +31 -2
  61. package/external/pi-tools-suite/src/dcp/state.ts +62 -4
  62. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +18 -0
  63. package/external/pi-tools-suite/src/index.ts +1 -0
  64. package/external/pi-tools-suite/src/model-tools/index.ts +11 -3
  65. package/external/pi-tools-suite/src/telegram-mirror/index.ts +1 -1
  66. package/external/pi-tools-suite/src/todo/index.ts +24 -0
  67. package/external/pi-tools-suite/src/tool-descriptions.ts +3 -3
  68. package/external/pi-tools-suite/src/usage/index.ts +18 -4
  69. package/package.json +4 -4
  70. 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): unknown[] {
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.sessionManager.getBranch()
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
- ctx.ui.notify(`Swept ${count} tool output${count === 1 ? "" : "s"}`, "info")
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
- pi.sendMessage(
499
- {
500
- customType: "dcp-compress-trigger",
501
- content:
502
- "Please compress stale conversation sections using the compress tool now.",
503
- display: false,
504
- },
505
- { triggerTurn: true, deliverAs: "followUp" },
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
- ctx.ui.notify("Triggered compression", "info")
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
- hooks.onStateChanged?.(ctx)
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
- const rangePlans: ResolvedRangePlan[] = ranges.map((range) => {
168
- const { startId, endId, summary } = range
169
-
170
- // ── Resolve boundary timestamps ──────────────────────────────────
171
- const startBoundary = resolveIdToBoundary(startId, "startTimestamp", state)
172
- const endBoundary = resolveIdToBoundary(endId, "endTimestamp", state)
173
- const startTimestamp = startBoundary.timestamp
174
- const endTimestamp = endBoundary.timestamp
175
-
176
- if (startTimestamp > endTimestamp) {
177
- throw new Error(
178
- `Range start "${startId}" must appear before end "${endId}" in the conversation`,
179
- )
180
- }
181
-
182
- // ── Validate timestamps are finite ──────────────────────────────
183
- if (!Number.isFinite(startTimestamp)) {
184
- throw new Error(
185
- `Start ID "${startId}" resolved to a non-finite timestamp (${startTimestamp}). ` +
186
- `This usually means the referenced message has a corrupted timestamp.`,
187
- )
188
- }
189
- if (!Number.isFinite(endTimestamp)) {
190
- throw new Error(
191
- `End ID "${endId}" resolved to a non-finite timestamp (${endTimestamp}). ` +
192
- `This usually means the referenced message has a corrupted timestamp.`,
193
- )
194
- }
195
-
196
- return {
197
- startId,
198
- endId,
199
- summary,
200
- startTimestamp,
201
- endTimestamp,
202
- startMessageId: startBoundary.stableId,
203
- endMessageId: endBoundary.stableId,
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
- validateNonOverlappingRanges(rangePlans)
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
- const anchor = resolveAnchorBoundary(range.endTimestamp, state)
211
-
212
- const created = createRangeCompressionBlock({
213
- topic: params.topic,
214
- summary: range.summary,
215
- startTimestamp: range.startTimestamp,
216
- endTimestamp: range.endTimestamp,
217
- startMessageId: range.startMessageId,
218
- endMessageId: range.endMessageId,
219
- anchorTimestamp: anchor.timestamp,
220
- anchorMessageId: anchor.stableId,
221
- createdByToolCallId: _toolCallId,
222
- state,
223
- config: effectiveConfig,
224
- mode: "range",
225
- })
226
- const block = created.block
227
- newBlockIds.push(block.id)
228
- operationRemovedTokens += created.removedTokenEstimate
229
- operationSummaryTokens += created.summaryTokenEstimate
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.55,
94
- minContextPercent: 0.20,
97
+ maxContextPercent: 0.65,
98
+ minContextPercent: 0.40,
95
99
  modelMaxContextPercent: {},
96
100
  modelMinContextPercent: {},
97
101
  summaryBuffer: true,
98
- nudgeFrequency: 1,
99
- iterationNudgeThreshold: 4,
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.20,
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.20,
117
+ minContextPercent: 0.40,
114
118
  keepRecentTurns: 1,
115
119
  mediumTokens: 500,
116
120
  highTokens: 5000,