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.
- package/CHANGELOG.md +10 -0
- package/PRUNING.md +111 -23
- package/README.md +6 -1
- package/index.ts +23 -13
- package/package.json +1 -1
- package/src/batch-capture.test.ts +75 -1
- package/src/batch-capture.ts +22 -13
- package/src/chain-compressor.test.ts +114 -0
- package/src/chain-compressor.ts +29 -4
- package/src/chain-detector.test.ts +49 -0
- package/src/chain-detector.ts +7 -0
- package/src/chain-range-prune.test.ts +342 -7
- package/src/chain-range-prune.ts +161 -48
- package/src/commands.test.ts +31 -2
- package/src/commands.ts +25 -10
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +1 -0
- package/src/id-collision.integration.test.ts +251 -0
- package/src/indexer.test.ts +336 -0
- package/src/indexer.ts +168 -55
- package/src/occurrence-key.test.ts +57 -0
- package/src/occurrence-key.ts +36 -0
- package/src/orphan-sweep.test.ts +67 -0
- package/src/orphan-sweep.ts +40 -0
- package/src/oversized-spill.integration.test.ts +7 -2
- package/src/pruner.test.ts +456 -25
- package/src/pruner.ts +84 -36
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +6 -1
- package/src/recovery-grace.test.ts +13 -0
- package/src/recovery-grace.ts +12 -3
- package/src/spill.test.ts +108 -1
- package/src/spill.ts +5 -3
- package/src/summary-refs.test.ts +51 -1
- package/src/summary-refs.ts +15 -4
- package/src/test-support.ts +54 -0
- package/src/tree-browser.ts +2 -1
- package/src/types.ts +56 -10
package/src/summary-refs.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
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
|
+
});
|
package/src/summary-refs.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
+
calls: { toolCallId: string; resultTimestamp?: number }[],
|
|
17
20
|
startIndex: number,
|
|
18
21
|
): { refs: SummaryToolCallRef[]; nextIndex: number } {
|
|
19
|
-
const refs =
|
|
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) =>
|
|
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
|
+
}
|
package/src/tree-browser.ts
CHANGED
|
@@ -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<
|
|
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
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* and
|
|
404
|
-
*
|
|
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
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
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
|
/**
|