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.
- package/CHANGELOG.md +20 -0
- package/PRUNING.md +138 -23
- package/README.md +17 -1
- package/index.ts +305 -116
- 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 +168 -5
- package/src/commands.ts +44 -11
- package/src/context-metrics.test.ts +335 -0
- package/src/context-metrics.ts +152 -0
- 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/reload-rearm.integration.test.ts +647 -0
- package/src/spill.test.ts +108 -1
- package/src/spill.ts +5 -3
- package/src/summarizer-wiring.test.ts +2 -0
- 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 +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 !==
|
|
58
|
-
indexer.registerDuplicate(
|
|
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,
|
|
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
|
|
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,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
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* and
|
|
404
|
-
*
|
|
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
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
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
|