pi-condense 2.5.0 → 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 (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/PRUNING.md +111 -23
  3. package/README.md +6 -1
  4. package/index.ts +23 -13
  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 -10
  16. package/src/diagnostics.test.ts +114 -0
  17. package/src/diagnostics.ts +46 -0
  18. package/src/frontier.test.ts +1 -0
  19. package/src/id-collision.integration.test.ts +251 -0
  20. package/src/indexer.test.ts +336 -0
  21. package/src/indexer.ts +168 -55
  22. package/src/occurrence-key.test.ts +57 -0
  23. package/src/occurrence-key.ts +36 -0
  24. package/src/orphan-sweep.test.ts +67 -0
  25. package/src/orphan-sweep.ts +40 -0
  26. package/src/oversized-spill.integration.test.ts +7 -2
  27. package/src/pruner.test.ts +456 -25
  28. package/src/pruner.ts +84 -36
  29. package/src/query-tool.test.ts +117 -0
  30. package/src/query-tool.ts +47 -31
  31. package/src/range-compression.integration.test.ts +6 -1
  32. package/src/recovery-grace.test.ts +13 -0
  33. package/src/recovery-grace.ts +12 -3
  34. package/src/spill.test.ts +108 -1
  35. package/src/spill.ts +5 -3
  36. package/src/summary-refs.test.ts +51 -1
  37. package/src/summary-refs.ts +15 -4
  38. package/src/test-support.ts +54 -0
  39. package/src/tree-browser.ts +2 -1
  40. package/src/types.ts +56 -10
@@ -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";
@@ -397,13 +413,22 @@ export interface ChainRange {
397
413
  /** Timestamp of the user message that opens the chain. */
398
414
  startUserTimestamp: number;
399
415
  /**
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.
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.
405
423
  */
406
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[];
407
432
  /**
408
433
  * Subset of middleToolCallIds whose tool name ∈ protectedTools (detection-time
409
434
  * fact). The detector always emits it ([] when no protected tool ran); optional
@@ -425,12 +450,19 @@ export interface ChainCompressionEntry {
425
450
  /** Timestamp of the user message that opens the chain. Keep raw; synthetic inserted after. */
426
451
  startUserTimestamp: number;
427
452
  /**
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.
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`.
432
457
  */
433
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[];
434
466
  /**
435
467
  * Subset of droppedToolCallIds whose tool was user-protected. Membership is decided
436
468
  * per call by tool name (every call whose name ∈ protectedTools), not a per-id allowlist.
@@ -523,6 +555,13 @@ export interface CapturedToolCall {
523
555
  args: Record<string, unknown>;
524
556
  resultText: string;
525
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;
526
565
  spillPath?: string;
527
566
  spillBytes?: number;
528
567
  resultPreview?: string;
@@ -565,6 +604,8 @@ export interface ToolCallRecord {
565
604
  isError: boolean;
566
605
  turnIndex: number;
567
606
  timestamp: number;
607
+ /** See CapturedToolCall.resultTimestamp. */
608
+ resultTimestamp?: number;
568
609
  /** Absolute path to the sidecar blob holding the full body (set only when the result was spilled). */
569
610
  spillPath?: string;
570
611
  /** Full byte length of the spilled body. */
@@ -603,6 +644,9 @@ export interface IndexEntryData {
603
644
  export interface DedupAliasEntryData {
604
645
  newToolCallId: string;
605
646
  originalToolCallId: string;
647
+ /** Occurrence timestamps for each side; absent on pre-upgrade entries. */
648
+ newResultTimestamp?: number;
649
+ originalResultTimestamp?: number;
606
650
  hash?: string;
607
651
  }
608
652
 
@@ -613,6 +657,8 @@ export interface DedupAliasEntryData {
613
657
  export interface SummaryToolCallRef {
614
658
  shortId: string;
615
659
  toolCallId: string;
660
+ /** ToolResultMessage timestamp; with toolCallId this forms the occurrence key. */
661
+ resultTimestamp?: number;
616
662
  }
617
663
 
618
664
  /**