pi-condense 2.4.3 → 2.6.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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/PRUNING.md +96 -54
  3. package/README.md +6 -1
  4. package/index.ts +28 -42
  5. package/package.json +1 -1
  6. package/src/batch-capture.test.ts +75 -1
  7. package/src/batch-capture.ts +22 -13
  8. package/src/chain-compressor.test.ts +114 -0
  9. package/src/chain-compressor.ts +29 -4
  10. package/src/chain-detector.test.ts +49 -0
  11. package/src/chain-detector.ts +7 -0
  12. package/src/chain-range-prune.test.ts +342 -7
  13. package/src/chain-range-prune.ts +161 -48
  14. package/src/commands.test.ts +31 -2
  15. package/src/commands.ts +25 -35
  16. package/src/config.test.ts +27 -1
  17. package/src/diagnostics.test.ts +114 -0
  18. package/src/diagnostics.ts +46 -0
  19. package/src/frontier.test.ts +138 -16
  20. package/src/frontier.ts +0 -1
  21. package/src/id-collision.integration.test.ts +251 -0
  22. package/src/indexer.test.ts +336 -0
  23. package/src/indexer.ts +168 -55
  24. package/src/occurrence-key.test.ts +57 -0
  25. package/src/occurrence-key.ts +36 -0
  26. package/src/orphan-sweep.test.ts +67 -0
  27. package/src/orphan-sweep.ts +40 -0
  28. package/src/oversized-spill.integration.test.ts +7 -2
  29. package/src/pruner.test.ts +471 -64
  30. package/src/pruner.ts +84 -54
  31. package/src/query-tool.test.ts +117 -0
  32. package/src/query-tool.ts +47 -31
  33. package/src/range-compression.integration.test.ts +7 -44
  34. package/src/recovery-grace.test.ts +13 -0
  35. package/src/recovery-grace.ts +12 -3
  36. package/src/spill.test.ts +108 -1
  37. package/src/spill.ts +5 -3
  38. package/src/summary-refs.test.ts +51 -1
  39. package/src/summary-refs.ts +15 -4
  40. package/src/test-support.ts +54 -0
  41. package/src/tree-browser.ts +2 -1
  42. package/src/types.ts +56 -49
  43. package/src/thinking-strip.test.ts +0 -257
  44. package/src/thinking-strip.ts +0 -83
package/src/spill.test.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { sanitizeId, blobDirFor, blobPathFor, headPreview, spillOversizedBatch } from "./spill.js";
6
6
  import { ToolCallIndexer } from "./indexer.js";
7
+ import { registerQueryTool } from "./query-tool.js";
8
+ import { occKey } from "./occurrence-key.js";
9
+ import { CUSTOM_TYPE_INDEX } from "./types.js";
7
10
  import type { CapturedBatch } from "./types.js";
8
11
 
9
12
  describe("sanitizeId", () => {
@@ -36,6 +39,94 @@ describe("headPreview", () => {
36
39
  });
37
40
  });
38
41
 
42
+ describe("occurrence-keyed spill", () => {
43
+ it("blobPathFor distinguishes two occurrences of one id", () => {
44
+ const a = blobPathFor("/tmp/s", "sess", occKey("bash_23", 1150));
45
+ const b = blobPathFor("/tmp/s", "sess", occKey("bash_23", 3150));
46
+ expect(a).not.toBe(b);
47
+ expect(a.endsWith("bash_23_1150.txt")).toBe(true);
48
+ });
49
+
50
+ it("legacy bare-id sidecar path is unchanged", () => {
51
+ expect(blobPathFor("/tmp/s", "sess", "bash_23").endsWith("bash_23.txt")).toBe(true);
52
+ });
53
+
54
+ it("registerDuplicate is called with occurrence keys on both sides", async () => {
55
+ const calls: string[][] = [];
56
+ const indexer = {
57
+ lookupByContent: () => "bash_1@1000",
58
+ registerDuplicate: (a: string, b: string) => calls.push([a, b]),
59
+ } as any;
60
+ const batch = {
61
+ turnIndex: 0,
62
+ timestamp: 2000,
63
+ assistantText: "",
64
+ toolCalls: [
65
+ { toolCallId: "bash_2", toolName: "bash", args: {}, resultText: "x".repeat(100), isError: false, resultTimestamp: 2150 },
66
+ ],
67
+ };
68
+ await spillOversizedBatch({
69
+ batch: batch as any,
70
+ indexer,
71
+ config: { spillThreshold: 10, spillPreviewBytes: 10, dedupByContentHash: true },
72
+ sessionDir: "/tmp/s",
73
+ sessionId: "sess",
74
+ appendEntry: () => {},
75
+ });
76
+ expect(calls).toEqual([["bash_2@2150", "bash_1@1000"]]);
77
+ });
78
+ });
79
+
80
+ describe("G4/C4: legacy bare-id sidecar recovery", () => {
81
+ it("a pre-upgrade legacy record whose spillPath points at a bare-id-named sidecar still resolves through context_tree_query", async () => {
82
+ const dir = await mkdtemp(join(tmpdir(), "spill-legacy-"));
83
+ try {
84
+ // A pre-occurrence-key sidecar, written and named exactly the way a
85
+ // pre-upgrade session would have (bare id, no resultTimestamp suffix).
86
+ const sidecarPath = blobPathFor(dir, "sid", "bash_7");
87
+ await mkdir(blobDirFor(dir, "sid"), { recursive: true });
88
+ await writeFile(sidecarPath, "OLD SPILLED BODY".repeat(20));
89
+
90
+ const indexEntry = {
91
+ type: "custom",
92
+ customType: CUSTOM_TYPE_INDEX,
93
+ data: {
94
+ toolCalls: [
95
+ {
96
+ toolCallId: "bash_7",
97
+ toolName: "fetch",
98
+ args: { url: "https://x" },
99
+ resultText: "",
100
+ resultPreview: "OLD SPILLED",
101
+ spillPath: sidecarPath,
102
+ spillBytes: 340,
103
+ isError: false,
104
+ turnIndex: 0,
105
+ timestamp: 500,
106
+ },
107
+ ],
108
+ },
109
+ };
110
+ // No matching ToolResultMessage in the branch (a genuinely pre-upgrade,
111
+ // truncated session) - an index entry persisted without resultTimestamp
112
+ // stays bare-keyed (no migration), so its sidecar keeps its bare-id
113
+ // filename and still resolves via the persisted spillPath.
114
+ const indexer = new ToolCallIndexer();
115
+ indexer.reconstructFromSession({ sessionManager: { getBranch: () => [indexEntry] } } as any);
116
+ expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true);
117
+
118
+ let registered: any;
119
+ registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, indexer);
120
+ const result = await registered.execute("call-1", { toolCallIds: ["bash_7"] }, undefined, undefined, undefined);
121
+ const text = result.content[0].text as string;
122
+
123
+ expect(text).toContain("OLD SPILLED BODY");
124
+ } finally {
125
+ await rm(dir, { recursive: true, force: true });
126
+ }
127
+ });
128
+ });
129
+
39
130
  describe("spillOversizedBatch", () => {
40
131
  const cfg = { spillThreshold: 10, spillPreviewBytes: 8, dedupByContentHash: true };
41
132
  const mkBatch = (toolCalls: any[]): CapturedBatch => ({ turnIndex: 0, timestamp: 1, assistantText: "", toolCalls });
@@ -85,6 +176,22 @@ describe("spillOversizedBatch", () => {
85
176
  }
86
177
  });
87
178
 
179
+ it("two occurrences of one toolCallId spill to distinct sidecar files", async () => {
180
+ const dir = await mkdtemp(join(tmpdir(), "spill-"));
181
+ try {
182
+ const indexer = new ToolCallIndexer();
183
+ const batch1 = mkBatch([{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "FIRST".repeat(20), isError: false, resultTimestamp: 1150 }]);
184
+ const batch2 = mkBatch([{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "SECOND".repeat(20), isError: false, resultTimestamp: 3150 }]);
185
+ await spillOversizedBatch({ batch: batch1, indexer, config: { ...cfg, dedupByContentHash: false }, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
186
+ await spillOversizedBatch({ batch: batch2, indexer, config: { ...cfg, dedupByContentHash: false }, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
187
+ const rec1 = indexer.getRecord("bash_23@1150")!;
188
+ const rec2 = indexer.getRecord("bash_23@3150")!;
189
+ expect(rec1.spillPath).not.toBe(rec2.spillPath);
190
+ expect(await readFile(rec1.spillPath!, "utf-8")).toBe("FIRST".repeat(20));
191
+ expect(await readFile(rec2.spillPath!, "utf-8")).toBe("SECOND".repeat(20));
192
+ } finally { await rm(dir, { recursive: true, force: true }); }
193
+ });
194
+
88
195
  it("dedups an oversized duplicate to the original without a second file", async () => {
89
196
  const dir = await mkdtemp(join(tmpdir(), "spill-"));
90
197
  try {
package/src/spill.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import type { CapturedBatch, CapturedToolCall } from "./types.js";
4
4
  import type { ToolCallIndexer } from "./indexer.js";
5
5
  import { hashToolResult } from "./content-hash.js";
6
+ import { occKey } from "./occurrence-key.js";
6
7
 
7
8
  /** Replace anything outside [A-Za-z0-9_-] so the id can't escape the blob dir. */
8
9
  export function sanitizeId(toolCallId: string): string {
@@ -50,18 +51,19 @@ export async function spillOversizedBatch(args: {
50
51
  for (const tc of batch.toolCalls) {
51
52
  if (tc.resultText.length < config.spillThreshold) continue;
52
53
 
54
+ const key = occKey(tc.toolCallId, tc.resultTimestamp);
53
55
  const hash = hashToolResult(tc.toolName, tc.resultText);
54
56
 
55
57
  if (config.dedupByContentHash) {
56
58
  const original = indexer.lookupByContent(tc.toolName, tc.resultText);
57
- if (original && original !== tc.toolCallId) {
58
- indexer.registerDuplicate(tc.toolCallId, original, appendEntry);
59
+ if (original && original !== key) {
60
+ indexer.registerDuplicate(key, original, appendEntry);
59
61
  handled.add(tc.toolCallId);
60
62
  continue;
61
63
  }
62
64
  }
63
65
 
64
- const path = blobPathFor(sessionDir, sessionId, tc.toolCallId);
66
+ const path = blobPathFor(sessionDir, sessionId, key);
65
67
  try {
66
68
  await mkdir(blobDirFor(sessionDir, sessionId), { recursive: true });
67
69
  await writeFile(path, tc.resultText, "utf-8");
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { substituteInlineRefs, formatSummaryToolCallRefs, type SummaryToolCallRef } from "./summary-refs.js";
2
+ import {
3
+ substituteInlineRefs,
4
+ formatSummaryToolCallRefs,
5
+ buildShortToolCallRefs,
6
+ normalizeSummaryToolCallRefs,
7
+ type SummaryToolCallRef,
8
+ } from "./summary-refs.js";
3
9
 
4
10
  describe("substituteInlineRefs", () => {
5
11
  const refs: SummaryToolCallRef[] = [
@@ -120,3 +126,47 @@ describe("substituteInlineRefs", () => {
120
126
  }
121
127
  });
122
128
  });
129
+
130
+ describe("occurrence-aware summary refs", () => {
131
+ test("buildShortToolCallRefs carries resultTimestamp through", () => {
132
+ const { refs, nextIndex } = buildShortToolCallRefs(
133
+ [
134
+ { toolCallId: "bash_23", resultTimestamp: 2150 },
135
+ { toolCallId: "bash_23", resultTimestamp: 3150 },
136
+ ],
137
+ 5,
138
+ );
139
+ expect(refs).toEqual([
140
+ { shortId: "t5", toolCallId: "bash_23", resultTimestamp: 2150 },
141
+ { shortId: "t6", toolCallId: "bash_23", resultTimestamp: 3150 },
142
+ ]);
143
+ expect(nextIndex).toBe(7);
144
+ });
145
+
146
+ test("buildShortToolCallRefs omits resultTimestamp when absent", () => {
147
+ const { refs } = buildShortToolCallRefs([{ toolCallId: "bash_1" }], 1);
148
+ expect(refs).toEqual([{ shortId: "t1", toolCallId: "bash_1" }]);
149
+ expect("resultTimestamp" in refs[0]).toBe(false);
150
+ });
151
+
152
+ test("normalizeSummaryToolCallRefs preserves a numeric resultTimestamp", () => {
153
+ const refs = normalizeSummaryToolCallRefs({
154
+ toolCallRefs: [{ shortId: "t1", toolCallId: "bash_1", resultTimestamp: 1150 }],
155
+ });
156
+ expect(refs).toEqual([{ shortId: "t1", toolCallId: "bash_1", resultTimestamp: 1150 }]);
157
+ });
158
+
159
+ test("normalizeSummaryToolCallRefs drops a non-numeric resultTimestamp", () => {
160
+ const refs = normalizeSummaryToolCallRefs({
161
+ toolCallRefs: [{ shortId: "t1", toolCallId: "bash_1", resultTimestamp: "nope" }],
162
+ });
163
+ expect(refs).toEqual([{ shortId: "t1", toolCallId: "bash_1" }]);
164
+ expect("resultTimestamp" in refs[0]).toBe(false);
165
+ });
166
+
167
+ test("legacy toolCallIds-only details still normalize", () => {
168
+ expect(normalizeSummaryToolCallRefs({ toolCallIds: ["bash_1"] })).toEqual([
169
+ { shortId: "bash_1", toolCallId: "bash_1" },
170
+ ]);
171
+ });
172
+ });
@@ -1,8 +1,11 @@
1
1
  import type { CapturedBatch } from "./types.js";
2
+ import { resultTimestampOf } from "./occurrence-key.js";
2
3
 
3
4
  export interface SummaryToolCallRef {
4
5
  shortId: string;
5
6
  toolCallId: string;
7
+ /** ToolResultMessage timestamp; combines with toolCallId into the occurrence key. */
8
+ resultTimestamp?: number;
6
9
  }
7
10
 
8
11
  export interface SummaryMessageDetailsLike {
@@ -13,12 +16,13 @@ export interface SummaryMessageDetailsLike {
13
16
  const SHORT_ID_PREFIX = "t";
14
17
 
15
18
  export function buildShortToolCallRefs(
16
- toolCallIds: string[],
19
+ calls: { toolCallId: string; resultTimestamp?: number }[],
17
20
  startIndex: number,
18
21
  ): { refs: SummaryToolCallRef[]; nextIndex: number } {
19
- const refs = toolCallIds.map((toolCallId, offset) => ({
22
+ const refs = calls.map((call, offset) => ({
20
23
  shortId: `${SHORT_ID_PREFIX}${startIndex + offset}`,
21
- toolCallId,
24
+ toolCallId: call.toolCallId,
25
+ ...(call.resultTimestamp !== undefined ? { resultTimestamp: call.resultTimestamp } : {}),
22
26
  }));
23
27
  return { refs, nextIndex: startIndex + refs.length };
24
28
  }
@@ -33,7 +37,14 @@ export function normalizeSummaryToolCallRefs(details: unknown): SummaryToolCallR
33
37
  (ref): ref is SummaryToolCallRef =>
34
38
  !!ref && typeof ref.shortId === "string" && typeof ref.toolCallId === "string",
35
39
  )
36
- .map((ref) => ({ shortId: ref.shortId, toolCallId: ref.toolCallId }));
40
+ .map((ref) => {
41
+ const resultTimestamp = resultTimestampOf((ref as any).resultTimestamp);
42
+ return {
43
+ shortId: ref.shortId,
44
+ toolCallId: ref.toolCallId,
45
+ ...(resultTimestamp !== undefined ? { resultTimestamp } : {}),
46
+ };
47
+ });
37
48
  }
38
49
 
39
50
  if (Array.isArray(raw.toolCallIds)) {
@@ -0,0 +1,54 @@
1
+ import { expect } from "bun:test";
2
+ import { sweepOrphanToolResults } from "./orphan-sweep.js";
3
+ import { pruneMessages } from "./pruner.js";
4
+ import { DiagnosticSink } from "./diagnostics.js";
5
+
6
+ /** Shared by chain-range-prune.test.ts and id-collision.integration.test.ts. */
7
+ export function expectNoOrphanToolResults(messages: any[]): void {
8
+ let open = new Set<string>();
9
+ for (const m of messages) {
10
+ if (m.role === "assistant") {
11
+ open = new Set((m.content ?? []).filter((c: any) => c.type === "toolCall").map((c: any) => c.id));
12
+ } else if (m.role === "toolResult") {
13
+ expect(open.has(m.toolCallId)).toBe(true);
14
+ open.delete(m.toolCallId);
15
+ }
16
+ }
17
+ }
18
+
19
+ /**
20
+ * G4/C3: proof that the orphan sweep (src/pruner.ts Phase 4) is a net, not a
21
+ * crutch. Runs the real `sweepOrphanToolResults` (the exact function backing
22
+ * Phase 4's diagnostic) over an already-pruned message array and fails if it
23
+ * finds anything to sweep. Equivalent to asserting the Phase 4 diagnostic
24
+ * never fires for this output: `sweepOrphanToolResults` returns the input
25
+ * array reference and an empty `sweptIds` when nothing is orphaned, which is
26
+ * precisely the condition under which `pruneMessages` skips the
27
+ * `diagnostics?.report("orphan-sweep", ...)` call.
28
+ */
29
+ export function expectZeroOrphanSweep(messages: any[]): void {
30
+ const { messages: swept, sweptIds } = sweepOrphanToolResults(messages);
31
+ expect(sweptIds).toEqual([]);
32
+ expect(swept).toBe(messages);
33
+ }
34
+
35
+ /**
36
+ * G4/C3: wraps a `pruneMessages` call with a counting `DiagnosticSink` and
37
+ * fails if the `orphan-sweep` diagnostic fires. For fixtures that already go
38
+ * through the full pruner pipeline (pruner.test.ts), this is the more direct
39
+ * proof than `expectZeroOrphanSweep` since it exercises the actual Phase 4
40
+ * call site, not just the underlying pure function.
41
+ */
42
+ export function pruneWithZeroSweepAssertion(
43
+ messages: any[],
44
+ indexer: any,
45
+ chainCompression?: any,
46
+ errorPurge?: any,
47
+ protection?: any,
48
+ recoveryGraceTurns: number = 0,
49
+ ): ReturnType<typeof pruneMessages> {
50
+ const sink = new DiagnosticSink(() => {});
51
+ const result = pruneMessages(messages, indexer, chainCompression, errorPurge, protection, recoveryGraceTurns, sink);
52
+ expect(sink.counts()["orphan-sweep"]).toBe(0);
53
+ return result;
54
+ }
@@ -7,6 +7,7 @@ import type { ToolCallRecord } from "./types.js";
7
7
  import { CUSTOM_TYPE_SUMMARY } from "./types.js";
8
8
  import { normalizeSummaryToolCallRefs } from "./summary-refs.js";
9
9
  import type { ToolCallIndexer } from "./indexer.js";
10
+ import { occKey } from "./occurrence-key.js";
10
11
 
11
12
  // ── Tree node types ─────────────────────────────────────────────────────────
12
13
 
@@ -119,7 +120,7 @@ export function buildPruneTree(
119
120
 
120
121
  const children: TreeNode[] = [];
121
122
  for (const ref of toolCallRefs) {
122
- const record = indexer.getRecord(ref.toolCallId);
123
+ const record = indexer.getRecord(occKey(ref.toolCallId, ref.resultTimestamp));
123
124
  if (!record) continue;
124
125
  children.push(toolCallNode(record, 1));
125
126
  }
package/src/types.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * event.toolResults = ToolResultMessage[] (one per tool call in this turn)
11
11
  *
12
12
  * STATE MODEL (Ph1 step 3):
13
- * - Runtime state: Map<toolCallId, ToolCallRecord> rebuilt on session_start
13
+ * - Runtime state: Map<occurrenceKey, ToolCallRecord> rebuilt on session_start
14
14
  * - Session metadata: pi.appendEntry("context-prune-index", IndexEntryData)
15
15
  * stored once per summarized batch; NOT in LLM context
16
16
  * - User config: .pi/settings.json → "contextPrune" key (JSON merge safe,
@@ -78,6 +78,22 @@ export const CUSTOM_TYPE_DEDUP_ALIAS = "context-prune-dedup-alias";
78
78
  */
79
79
  export const CUSTOM_TYPE_CHAIN = "context-prune-chain";
80
80
 
81
+ /**
82
+ * Written via pi.appendEntry(CUSTOM_TYPE_DIAGNOSTIC, data) when a prune-time
83
+ * invariant degrades. NOT in LLM context: zero tokens, zero cache-prefix
84
+ * change. Deduplication is a runtime concern of the diagnostic sink
85
+ * (src/diagnostics.ts), which takes a caller-supplied dedup key and never
86
+ * persists it - the persisted entry carries only `kind` plus a freeform `detail`.
87
+ */
88
+ export const CUSTOM_TYPE_DIAGNOSTIC = "context-prune-diagnostic";
89
+
90
+ export type DiagnosticKind = "unresolved-range" | "range-id-mismatch" | "orphan-sweep";
91
+
92
+ export interface DiagnosticEntryData {
93
+ kind: DiagnosticKind;
94
+ detail: string;
95
+ }
96
+
81
97
  /** The registered name of the recovery tool (src/query-tool.ts). Shared so the
82
98
  * grace checks in pruner.ts / chain-compressor.ts cannot drift from registration. */
83
99
  export const QUERY_TOOL_NAME = "context_tree_query";
@@ -163,19 +179,6 @@ export const ROLLING_WINDOW_PRESETS: { value: string; label: string }[] = [
163
179
  { value: "10", label: "10" },
164
180
  ];
165
181
 
166
- /**
167
- * Cycling preset values for the `thinkingStrip.keepLastTurns` setting.
168
- * Stored as strings because SettingsList cycles string values; converted to
169
- * number when applied. Counts ASSISTANT turns (messages), not closed chains.
170
- */
171
- export const KEEP_LAST_TURNS_PRESETS: { value: string; label: string }[] = [
172
- { value: "4", label: "4" },
173
- { value: "8", label: "8" },
174
- { value: "16", label: "16 (default)" },
175
- { value: "32", label: "32" },
176
- { value: "64", label: "64" },
177
- ];
178
-
179
182
  /**
180
183
  * Cycling preset values for the `minBatchChars` setting in the SettingsList.
181
184
  * Stored as strings because SettingsList cycles string values; converted to
@@ -349,8 +352,6 @@ export interface ContextPruneConfig {
349
352
  chainCompression: ChainCompressionConfig;
350
353
  /** Replace failed toolCall argument bodies with compact stubs after a cooldown window. */
351
354
  purgeErrors: ErrorPurgeConfig;
352
- /** Rolling main-loop thinking-block strip: keep thinking only on the last K assistant turns. */
353
- thinkingStrip: ThinkingStripConfig;
354
355
  /**
355
356
  * Pre-flush content-hash dedup pass. When `true`, each captured tool call
356
357
  * is hashed by `(toolName, normalize(resultText))` and compared against
@@ -412,13 +413,22 @@ export interface ChainRange {
412
413
  /** Timestamp of the user message that opens the chain. */
413
414
  startUserTimestamp: number;
414
415
  /**
415
- * All toolCallIds in the chain's middle (deduplicated).
416
- * Collected from both AssistantMessage ToolCall blocks AND matching
417
- * ToolResultMessages. Used to: (1) drop ToolResultMessages, (2) identify
418
- * and drop middle AssistantMessages, (3) suppress per-batch summary
419
- * CustomMessages whose toolCallRefs overlap.
416
+ * All toolCallIds in the chain's middle (deduplicated). Collected from both
417
+ * AssistantMessage ToolCall blocks AND matching ToolResultMessages.
418
+ * Identifies the chain's middle tool calls for detection, recovery-grace
419
+ * filtering and diagnostics. NOT used for the load-bearing indexer lookups
420
+ * (summary bodies, tool refs) - those maps are occurrence-keyed, so use
421
+ * the sibling `middleOccurrenceKeys` instead. Drops themselves are decided
422
+ * positionally by `resolveRange` in chain-range-prune.ts, not by these ids.
420
423
  */
421
424
  middleToolCallIds: string[];
425
+ /**
426
+ * Occurrence keys (`id@resultTimestamp`) for the chain's middle tool
427
+ * results, collected from the ToolResultMessages themselves. Used for
428
+ * indexer summary-body / toolRef lookups, which are occurrence-keyed.
429
+ * Optional so hand-built ChainRange fixtures need not set it.
430
+ */
431
+ middleOccurrenceKeys?: string[];
422
432
  /**
423
433
  * Subset of middleToolCallIds whose tool name ∈ protectedTools (detection-time
424
434
  * fact). The detector always emits it ([] when no protected tool ran); optional
@@ -440,12 +450,19 @@ export interface ChainCompressionEntry {
440
450
  /** Timestamp of the user message that opens the chain. Keep raw; synthetic inserted after. */
441
451
  startUserTimestamp: number;
442
452
  /**
443
- * ToolCallIds of all dropped middle messages.
444
- * Used at context-transform time to: drop matching ToolResultMessages,
445
- * drop AssistantMessages that contain any of these as ToolCall blocks,
446
- * and suppress per-batch summary messages whose toolCallRefs overlap.
453
+ * All toolCallIds in the chain's middle. **Diagnostic only** since the
454
+ * positional-range change: drops are decided by index range (see
455
+ * resolveRange in chain-range-prune.ts). Retained as a cross-check - a
456
+ * mismatch against the ids actually dropped emits `range-id-mismatch`.
447
457
  */
448
458
  droppedToolCallIds: string[];
459
+ /**
460
+ * Occurrence keys for the same calls as droppedToolCallIds. Load-bearing at
461
+ * render time: summaryBodies are occurrence-keyed, so the synthetic chain
462
+ * body is looked up by these. Absent on pre-upgrade entries, which fall back
463
+ * to droppedToolCallIds against their own bare-keyed summaryBodies.
464
+ */
465
+ droppedOccurrenceKeys?: string[];
449
466
  /**
450
467
  * Subset of droppedToolCallIds whose tool was user-protected. Membership is decided
451
468
  * per call by tool name (every call whose name ∈ protectedTools), not a per-id allowlist.
@@ -497,18 +514,6 @@ export interface ErrorPurgeConfig {
497
514
  minArgChars: number;
498
515
  }
499
516
 
500
- export interface ThinkingStripConfig {
501
- enabled: boolean;
502
- /**
503
- * Keep `thinking` blocks on the last K assistant turns; strip them from
504
- * older assistant messages (preserving text + toolCall blocks). Counts
505
- * assistant messages, not closed chains. Clamped to >= 1 so the most-recent
506
- * assistant turn always keeps its thinking (Anthropic requires the last
507
- * assistant turn's thinking during tool use). Default 16.
508
- */
509
- keepLastTurns: number;
510
- }
511
-
512
517
  export const DEFAULT_CONFIG: ContextPruneConfig = {
513
518
  enabled: false,
514
519
  showPruneStatusLine: true,
@@ -534,10 +539,6 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
534
539
  cooldownTurns: 2,
535
540
  minArgChars: 500,
536
541
  },
537
- thinkingStrip: {
538
- enabled: true,
539
- keepLastTurns: 16,
540
- },
541
542
  dedupByContentHash: true,
542
543
  autoBudgetThreshold: null,
543
544
  spillThreshold: 65536,
@@ -554,6 +555,13 @@ export interface CapturedToolCall {
554
555
  args: Record<string, unknown>;
555
556
  resultText: string;
556
557
  isError: boolean;
558
+ /**
559
+ * Timestamp of the ToolResultMessage this call was paired with. The
560
+ * occurrence discriminant (see src/occurrence-key.ts): provider ids repeat
561
+ * within a session, this does not. Optional so pre-upgrade persisted
562
+ * entries stay readable; absent => the record is legacy bare-id keyed.
563
+ */
564
+ resultTimestamp?: number;
557
565
  spillPath?: string;
558
566
  spillBytes?: number;
559
567
  resultPreview?: string;
@@ -596,6 +604,8 @@ export interface ToolCallRecord {
596
604
  isError: boolean;
597
605
  turnIndex: number;
598
606
  timestamp: number;
607
+ /** See CapturedToolCall.resultTimestamp. */
608
+ resultTimestamp?: number;
599
609
  /** Absolute path to the sidecar blob holding the full body (set only when the result was spilled). */
600
610
  spillPath?: string;
601
611
  /** Full byte length of the spilled body. */
@@ -634,6 +644,9 @@ export interface IndexEntryData {
634
644
  export interface DedupAliasEntryData {
635
645
  newToolCallId: string;
636
646
  originalToolCallId: string;
647
+ /** Occurrence timestamps for each side; absent on pre-upgrade entries. */
648
+ newResultTimestamp?: number;
649
+ originalResultTimestamp?: number;
637
650
  hash?: string;
638
651
  }
639
652
 
@@ -644,6 +657,8 @@ export interface DedupAliasEntryData {
644
657
  export interface SummaryToolCallRef {
645
658
  shortId: string;
646
659
  toolCallId: string;
660
+ /** ToolResultMessage timestamp; with toolCallId this forms the occurrence key. */
661
+ resultTimestamp?: number;
647
662
  }
648
663
 
649
664
  /**
@@ -730,14 +745,6 @@ export interface PruneFrontier {
730
745
  summaryCharCount: number;
731
746
  /** Whether the attempt actually pruned or was skipped for being oversized */
732
747
  outcome: PruneFrontierOutcome;
733
- /**
734
- * Assistant-message timestamp marking the flush-gated thinking-strip boundary:
735
- * thinking is stripped from every assistant message older than this. Advances
736
- * only at flushes (stays fixed between them so renders are prefix-stable and the
737
- * prompt cache survives a tool loop). Absent on pre-feature entries - the render
738
- * path then falls back to the live-count window. See src/thinking-strip.ts.
739
- */
740
- thinkingStripBoundaryTimestamp?: number;
741
748
  }
742
749
 
743
750
  /**