pi-condense 2.5.0 → 2.7.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 +20 -0
  2. package/PRUNING.md +138 -23
  3. package/README.md +17 -1
  4. package/index.ts +305 -116
  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 +168 -5
  15. package/src/commands.ts +44 -11
  16. package/src/context-metrics.test.ts +335 -0
  17. package/src/context-metrics.ts +152 -0
  18. package/src/diagnostics.test.ts +114 -0
  19. package/src/diagnostics.ts +46 -0
  20. package/src/frontier.test.ts +1 -0
  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 +456 -25
  30. package/src/pruner.ts +84 -36
  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 +6 -1
  34. package/src/recovery-grace.test.ts +13 -0
  35. package/src/recovery-grace.ts +12 -3
  36. package/src/reload-rearm.integration.test.ts +647 -0
  37. package/src/spill.test.ts +108 -1
  38. package/src/spill.ts +5 -3
  39. package/src/summarizer-wiring.test.ts +2 -0
  40. package/src/summary-refs.test.ts +51 -1
  41. package/src/summary-refs.ts +15 -4
  42. package/src/test-support.ts +54 -0
  43. package/src/tree-browser.ts +2 -1
  44. package/src/types.ts +89 -10
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,4 +1,5 @@
1
1
  import { describe, it, expect, mock } from "bun:test";
2
+ import * as actualCompat from "@earendil-works/pi-ai/compat";
2
3
 
3
4
  // Stub pi-ai's `stream` so runSummarization can be exercised without a network
4
5
  // call. `streamImpl` is swapped per test to simulate primary/fallback outcomes.
@@ -6,6 +7,7 @@ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
6
7
  throw new Error("streamImpl not set");
7
8
  };
8
9
  mock.module("@earendil-works/pi-ai/compat", () => ({
10
+ ...actualCompat,
9
11
  stream: (...args: any[]) => streamImpl(...args),
10
12
  }));
11
13
 
@@ -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,29 @@ 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
+ /**
91
+ * Per-flush-attempt observability record. Written once per non-concurrent
92
+ * flushPending invocation, regardless of outcome (including "empty" and
93
+ * "error"). Append-only log: never in LLM context, never reconstructed.
94
+ */
95
+ export const CUSTOM_TYPE_FLUSH_METRICS = "context-prune-flush-metrics";
96
+
97
+ export type DiagnosticKind = "unresolved-range" | "range-id-mismatch" | "orphan-sweep";
98
+
99
+ export interface DiagnosticEntryData {
100
+ kind: DiagnosticKind;
101
+ detail: string;
102
+ }
103
+
81
104
  /** The registered name of the recovery tool (src/query-tool.ts). Shared so the
82
105
  * grace checks in pruner.ts / chain-compressor.ts cannot drift from registration. */
83
106
  export const QUERY_TOOL_NAME = "context_tree_query";
@@ -397,13 +420,22 @@ export interface ChainRange {
397
420
  /** Timestamp of the user message that opens the chain. */
398
421
  startUserTimestamp: number;
399
422
  /**
400
- * All toolCallIds in the chain's middle (deduplicated).
401
- * Collected from both AssistantMessage ToolCall blocks AND matching
402
- * ToolResultMessages. Used to: (1) drop ToolResultMessages, (2) identify
403
- * and drop middle AssistantMessages, (3) suppress per-batch summary
404
- * CustomMessages whose toolCallRefs overlap.
423
+ * All toolCallIds in the chain's middle (deduplicated). Collected from both
424
+ * AssistantMessage ToolCall blocks AND matching ToolResultMessages.
425
+ * Identifies the chain's middle tool calls for detection, recovery-grace
426
+ * filtering and diagnostics. NOT used for the load-bearing indexer lookups
427
+ * (summary bodies, tool refs) - those maps are occurrence-keyed, so use
428
+ * the sibling `middleOccurrenceKeys` instead. Drops themselves are decided
429
+ * positionally by `resolveRange` in chain-range-prune.ts, not by these ids.
405
430
  */
406
431
  middleToolCallIds: string[];
432
+ /**
433
+ * Occurrence keys (`id@resultTimestamp`) for the chain's middle tool
434
+ * results, collected from the ToolResultMessages themselves. Used for
435
+ * indexer summary-body / toolRef lookups, which are occurrence-keyed.
436
+ * Optional so hand-built ChainRange fixtures need not set it.
437
+ */
438
+ middleOccurrenceKeys?: string[];
407
439
  /**
408
440
  * Subset of middleToolCallIds whose tool name ∈ protectedTools (detection-time
409
441
  * fact). The detector always emits it ([] when no protected tool ran); optional
@@ -425,12 +457,19 @@ export interface ChainCompressionEntry {
425
457
  /** Timestamp of the user message that opens the chain. Keep raw; synthetic inserted after. */
426
458
  startUserTimestamp: number;
427
459
  /**
428
- * ToolCallIds of all dropped middle messages.
429
- * Used at context-transform time to: drop matching ToolResultMessages,
430
- * drop AssistantMessages that contain any of these as ToolCall blocks,
431
- * and suppress per-batch summary messages whose toolCallRefs overlap.
460
+ * All toolCallIds in the chain's middle. **Diagnostic only** since the
461
+ * positional-range change: drops are decided by index range (see
462
+ * resolveRange in chain-range-prune.ts). Retained as a cross-check - a
463
+ * mismatch against the ids actually dropped emits `range-id-mismatch`.
432
464
  */
433
465
  droppedToolCallIds: string[];
466
+ /**
467
+ * Occurrence keys for the same calls as droppedToolCallIds. Load-bearing at
468
+ * render time: summaryBodies are occurrence-keyed, so the synthetic chain
469
+ * body is looked up by these. Absent on pre-upgrade entries, which fall back
470
+ * to droppedToolCallIds against their own bare-keyed summaryBodies.
471
+ */
472
+ droppedOccurrenceKeys?: string[];
434
473
  /**
435
474
  * Subset of droppedToolCallIds whose tool was user-protected. Membership is decided
436
475
  * per call by tool name (every call whose name ∈ protectedTools), not a per-id allowlist.
@@ -523,6 +562,13 @@ export interface CapturedToolCall {
523
562
  args: Record<string, unknown>;
524
563
  resultText: string;
525
564
  isError: boolean;
565
+ /**
566
+ * Timestamp of the ToolResultMessage this call was paired with. The
567
+ * occurrence discriminant (see src/occurrence-key.ts): provider ids repeat
568
+ * within a session, this does not. Optional so pre-upgrade persisted
569
+ * entries stay readable; absent => the record is legacy bare-id keyed.
570
+ */
571
+ resultTimestamp?: number;
526
572
  spillPath?: string;
527
573
  spillBytes?: number;
528
574
  resultPreview?: string;
@@ -565,6 +611,8 @@ export interface ToolCallRecord {
565
611
  isError: boolean;
566
612
  turnIndex: number;
567
613
  timestamp: number;
614
+ /** See CapturedToolCall.resultTimestamp. */
615
+ resultTimestamp?: number;
568
616
  /** Absolute path to the sidecar blob holding the full body (set only when the result was spilled). */
569
617
  spillPath?: string;
570
618
  /** Full byte length of the spilled body. */
@@ -603,6 +651,9 @@ export interface IndexEntryData {
603
651
  export interface DedupAliasEntryData {
604
652
  newToolCallId: string;
605
653
  originalToolCallId: string;
654
+ /** Occurrence timestamps for each side; absent on pre-upgrade entries. */
655
+ newResultTimestamp?: number;
656
+ originalResultTimestamp?: number;
606
657
  hash?: string;
607
658
  }
608
659
 
@@ -613,6 +664,8 @@ export interface DedupAliasEntryData {
613
664
  export interface SummaryToolCallRef {
614
665
  shortId: string;
615
666
  toolCallId: string;
667
+ /** ToolResultMessage timestamp; with toolCallId this forms the occurrence key. */
668
+ resultTimestamp?: number;
616
669
  }
617
670
 
618
671
  /**
@@ -626,6 +679,30 @@ export interface SummaryMessageDetails {
626
679
  timestamp: number;
627
680
  }
628
681
 
682
+ /** Snapshot of what the pruner cannot (yet) reclaim. All token values are Math.round(JSON-chars / 4). */
683
+ export interface ContextMetricsSnapshot {
684
+ /** Est. tokens of thinking blocks retained in the trailing open segment. */
685
+ openCycleThinkingTokens: number;
686
+ /** max(largest closed chain, open segment) chars / total branch chars, 0-100. */
687
+ largestChainSharePct: number;
688
+ /** Est. tokens of summarization-eligible unsummarized toolResults after the frontier. */
689
+ frontierGapTokens: number;
690
+ }
691
+
692
+ export type FlushTrigger = "budget" | "delta" | "message-end" | "manual" | "rearmed";
693
+
694
+ /** Payload of CUSTOM_TYPE_FLUSH_METRICS. */
695
+ export interface FlushMetricsEntry {
696
+ ts: number;
697
+ trigger: FlushTrigger;
698
+ /** Batches after rescan+trim, before processing. */
699
+ capturedBatches: number;
700
+ processedBatches: number;
701
+ outcome: "summarized" | "skipped-oversized" | "skipped-deduped" | "skipped-trivial" | "empty" | "error";
702
+ /** Computed at flush ENTRY (pre-flush pressure). */
703
+ metrics: ContextMetricsSnapshot;
704
+ }
705
+
629
706
  // ── Summarizer stats ────────────────────────────────────────────────────────
630
707
 
631
708
  /**
@@ -748,6 +825,8 @@ export interface FlushOptions {
748
825
  * the frontier. All pending batches are restored so the next flush can retry.
749
826
  */
750
827
  signal?: AbortSignal;
828
+ /** Which trigger initiated this flush. Defaults to "manual" when absent. */
829
+ trigger?: FlushTrigger;
751
830
  /**
752
831
  * The final text-only assistant message that triggered an agent-message flush.
753
832
  * pi emits `message_end` to extensions before persisting it to the session, so it