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.
- package/CHANGELOG.md +14 -0
- package/PRUNING.md +96 -54
- package/README.md +6 -1
- package/index.ts +28 -42
- 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 -35
- package/src/config.test.ts +27 -1
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +138 -16
- package/src/frontier.ts +0 -1
- 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 +471 -64
- package/src/pruner.ts +84 -54
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +7 -44
- 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 -49
- package/src/thinking-strip.test.ts +0 -257
- 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 !==
|
|
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");
|
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";
|
|
@@ -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
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* and
|
|
419
|
-
*
|
|
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
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
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
|
/**
|