pi-condense 2.0.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 +73 -0
- package/LICENSE +22 -0
- package/PRUNING.md +1028 -0
- package/README.md +243 -0
- package/index.ts +858 -0
- package/package.json +56 -0
- package/src/batch-capture.ts +226 -0
- package/src/block-refs.test.ts +42 -0
- package/src/block-refs.ts +16 -0
- package/src/budget.test.ts +66 -0
- package/src/budget.ts +39 -0
- package/src/chain-compressor.test.ts +283 -0
- package/src/chain-compressor.ts +132 -0
- package/src/chain-detector.test.ts +302 -0
- package/src/chain-detector.ts +128 -0
- package/src/chain-range-prune.test.ts +522 -0
- package/src/chain-range-prune.ts +128 -0
- package/src/commands.test.ts +67 -0
- package/src/commands.ts +1207 -0
- package/src/config.ts +126 -0
- package/src/content-hash.ts +35 -0
- package/src/error-purge.test.ts +186 -0
- package/src/error-purge.ts +71 -0
- package/src/frontier.ts +62 -0
- package/src/indexer.ts +393 -0
- package/src/nested-placeholders.test.ts +82 -0
- package/src/nested-placeholders.ts +20 -0
- package/src/oversized-spill.integration.test.ts +73 -0
- package/src/protected.test.ts +62 -0
- package/src/protected.ts +51 -0
- package/src/pruner.test.ts +508 -0
- package/src/pruner.ts +156 -0
- package/src/query-tool.ts +78 -0
- package/src/range-compression.integration.test.ts +252 -0
- package/src/spill.test.ts +102 -0
- package/src/spill.ts +90 -0
- package/src/stats.test.ts +114 -0
- package/src/stats.ts +190 -0
- package/src/summarizer.test.ts +17 -0
- package/src/summarizer.ts +262 -0
- package/src/summary-refs.ts +61 -0
- package/src/thinking-strip.test.ts +175 -0
- package/src/thinking-strip.ts +42 -0
- package/src/tree-browser.ts +382 -0
- package/src/types.ts +764 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ToolCallIndexer } from "./indexer.js";
|
|
6
|
+
|
|
7
|
+
export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): void {
|
|
8
|
+
pi.registerTool({
|
|
9
|
+
name: "context_tree_query",
|
|
10
|
+
label: "Query Original Tool History",
|
|
11
|
+
description:
|
|
12
|
+
"Retrieve original tool call results that have been pruned from active context. Pass the short refs from a pruner-summary message to get back the full original outputs.",
|
|
13
|
+
promptSnippet: "Retrieve original pruned tool outputs by short ref",
|
|
14
|
+
promptGuidelines: [
|
|
15
|
+
"When you need the full output of a tool call that was summarized and pruned from context, use context_tree_query with the short refs listed in the relevant pruner-summary message.",
|
|
16
|
+
],
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
toolCallIds: Type.Array(Type.String({ description: "One or more short refs or tool call IDs to retrieve" }), {
|
|
19
|
+
description: "List of short refs or toolCallIds to look up",
|
|
20
|
+
}),
|
|
21
|
+
}),
|
|
22
|
+
|
|
23
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
24
|
+
const foundRecords: Record<string, any> = {};
|
|
25
|
+
const blocks: string[] = [];
|
|
26
|
+
|
|
27
|
+
for (const id of params.toolCallIds) {
|
|
28
|
+
const record = indexer.getRecord(id);
|
|
29
|
+
|
|
30
|
+
if (!record) {
|
|
31
|
+
blocks.push(`## toolRef: ${id}\n(not found in index — may not have been summarized yet)`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
foundRecords[id] = record;
|
|
36
|
+
|
|
37
|
+
const status = record.isError ? "ERROR" : "OK";
|
|
38
|
+
const header = [
|
|
39
|
+
`## toolRef: ${id}`,
|
|
40
|
+
`Tool: ${record.toolName}`,
|
|
41
|
+
`Args: ${JSON.stringify(record.args, null, 2)}`,
|
|
42
|
+
`Status: ${status}`,
|
|
43
|
+
`Turn: ${record.turnIndex}`,
|
|
44
|
+
"",
|
|
45
|
+
].join("\n");
|
|
46
|
+
|
|
47
|
+
let raw = record.resultText;
|
|
48
|
+
if (record.spillPath) {
|
|
49
|
+
try {
|
|
50
|
+
raw = await readFile(record.spillPath, "utf-8");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(`context_tree_query: failed to read spilled output at ${record.spillPath}:`, err);
|
|
53
|
+
raw = record.resultPreview ?? "(spilled output unavailable — sidecar file missing)";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const t = truncateHead(raw, {
|
|
58
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
59
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let body = t.content;
|
|
63
|
+
if (t.truncated) {
|
|
64
|
+
body += `\n[Output truncated: ${t.outputLines}/${t.totalLines} lines shown]`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
blocks.push(`${header}\n${body}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const combined = blocks.join("\n\n---\n\n");
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text", text: combined }],
|
|
74
|
+
details: { results: foundRecords },
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ToolCallIndexer } from "./indexer.js";
|
|
3
|
+
import { BlockRefIssuer } from "./block-refs.js";
|
|
4
|
+
import { compressEligible } from "./chain-compressor.js";
|
|
5
|
+
import { pruneMessages } from "./pruner.js";
|
|
6
|
+
import { detectChains } from "./chain-detector.js";
|
|
7
|
+
import { isProtected } from "./protected.js";
|
|
8
|
+
import type { ChainRange, ChainCompressionConfig } from "./types.js";
|
|
9
|
+
|
|
10
|
+
// End-to-end of the in-memory B path (everything except the LLM call, which is
|
|
11
|
+
// the shared runSummarization already exercised live): a span's per-batch
|
|
12
|
+
// summaries are fused by compressEligible, the entry lands in the real indexer's
|
|
13
|
+
// chain registry, and pruneMessages renders the fused text as the synthetic body.
|
|
14
|
+
describe("range compression integration", () => {
|
|
15
|
+
test("fused range summary flows compressEligible → registry → render", async () => {
|
|
16
|
+
const indexer = new ToolCallIndexer();
|
|
17
|
+
const blockRefs = new BlockRefIssuer();
|
|
18
|
+
|
|
19
|
+
// Two per-batch summaries covering the span's two tool calls.
|
|
20
|
+
indexer.registerSummaryRefs([
|
|
21
|
+
{ shortId: "t1", toolCallId: "tc1" },
|
|
22
|
+
{ shortId: "t2", toolCallId: "tc2" },
|
|
23
|
+
]);
|
|
24
|
+
indexer.registerSummaryBody(["tc1"], "summary of batch 1");
|
|
25
|
+
indexer.registerSummaryBody(["tc2"], "summary of batch 2");
|
|
26
|
+
|
|
27
|
+
const chain: ChainRange = {
|
|
28
|
+
startUserTimestamp: 100,
|
|
29
|
+
middleToolCallIds: ["tc1", "tc2"],
|
|
30
|
+
finalAssistantTimestamp: 400,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const fuseInputs: string[] = [];
|
|
34
|
+
const { compressedEntries } = await compressEligible([chain], 0, {
|
|
35
|
+
indexer,
|
|
36
|
+
blockRefs,
|
|
37
|
+
appendEntry: () => {},
|
|
38
|
+
now: () => 999,
|
|
39
|
+
fuseRange: async (text) => {
|
|
40
|
+
fuseInputs.push(text);
|
|
41
|
+
return "FUSED COHESIVE SUMMARY";
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Fusion received the concatenated per-batch summaries and stored its result.
|
|
46
|
+
expect(fuseInputs).toEqual(["summary of batch 1\n\nsummary of batch 2"]);
|
|
47
|
+
expect(compressedEntries).toHaveLength(1);
|
|
48
|
+
expect(compressedEntries[0].rangeSummaryText).toBe("FUSED COHESIVE SUMMARY");
|
|
49
|
+
// Entry is now in the real registry (what the renderer reads).
|
|
50
|
+
expect(indexer.getChainEntries()[0].rangeSummaryText).toBe("FUSED COHESIVE SUMMARY");
|
|
51
|
+
|
|
52
|
+
const messages: any[] = [
|
|
53
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
54
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
|
|
55
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "o1" }], isError: false, timestamp: 210 },
|
|
56
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: {} }], timestamp: 300, usage: {}, stopReason: "tool_use" },
|
|
57
|
+
{ role: "toolResult", toolCallId: "tc2", toolName: "bash", content: [{ type: "text", text: "o2" }], isError: false, timestamp: 310 },
|
|
58
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const cc: ChainCompressionConfig = {
|
|
62
|
+
enabled: true,
|
|
63
|
+
rollingWindow: 0,
|
|
64
|
+
stripFinalAssistantThinking: true,
|
|
65
|
+
fuseRangeSummary: true,
|
|
66
|
+
};
|
|
67
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer, cc);
|
|
68
|
+
expect(pruned).toBe(true);
|
|
69
|
+
|
|
70
|
+
const synthetic = out.find(
|
|
71
|
+
(m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
|
|
72
|
+
);
|
|
73
|
+
expect(synthetic).toBeDefined();
|
|
74
|
+
// Renderer used the fused summary, not the per-batch concatenation.
|
|
75
|
+
expect(synthetic.content[0].text).toContain("FUSED COHESIVE SUMMARY");
|
|
76
|
+
expect(synthetic.content[0].text).not.toContain("summary of batch 1");
|
|
77
|
+
expect(synthetic.content[0].text).toContain('tools="t1,t2"');
|
|
78
|
+
|
|
79
|
+
// Middle tool turns + their results dropped; tool outputs still recoverable via the index entries (added below).
|
|
80
|
+
expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("protected tool output is relocated into synthetic block", async () => {
|
|
84
|
+
const indexer = new ToolCallIndexer();
|
|
85
|
+
const blockRefs = new BlockRefIssuer();
|
|
86
|
+
|
|
87
|
+
// tc1 = bash (non-protected), tc2 = todowrite (protected)
|
|
88
|
+
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]);
|
|
89
|
+
indexer.registerSummaryBody(["tc1"], "bash output summary");
|
|
90
|
+
|
|
91
|
+
const chain: ChainRange = {
|
|
92
|
+
startUserTimestamp: 100,
|
|
93
|
+
middleToolCallIds: ["tc1", "tc2"],
|
|
94
|
+
finalAssistantTimestamp: 400,
|
|
95
|
+
protectedToolCallIds: ["tc2"],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const { compressedEntries } = await compressEligible([chain], 0, {
|
|
99
|
+
indexer,
|
|
100
|
+
blockRefs,
|
|
101
|
+
appendEntry: () => {},
|
|
102
|
+
now: () => 999,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(compressedEntries).toHaveLength(1);
|
|
106
|
+
// protectedToolCallIds round-trips through the real registry
|
|
107
|
+
expect(indexer.getChainEntries()[0].protectedToolCallIds).toEqual(["tc2"]);
|
|
108
|
+
|
|
109
|
+
const messages: any[] = [
|
|
110
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
111
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
|
|
112
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "bash-result" }], isError: false, timestamp: 210 },
|
|
113
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "todowrite", arguments: {} }], timestamp: 300, usage: {}, stopReason: "tool_use" },
|
|
114
|
+
{ role: "toolResult", toolCallId: "tc2", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE-XYZ" }], isError: false, timestamp: 310 },
|
|
115
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const cc: ChainCompressionConfig = {
|
|
119
|
+
enabled: true,
|
|
120
|
+
rollingWindow: 0,
|
|
121
|
+
stripFinalAssistantThinking: true,
|
|
122
|
+
fuseRangeSummary: false,
|
|
123
|
+
};
|
|
124
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer, cc);
|
|
125
|
+
expect(pruned).toBe(true);
|
|
126
|
+
|
|
127
|
+
const synthetic = out.find(
|
|
128
|
+
(m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
|
|
129
|
+
);
|
|
130
|
+
expect(synthetic).toBeDefined();
|
|
131
|
+
// Protected output is embedded in the synthetic block
|
|
132
|
+
expect(synthetic.content[0].text).toContain('<protected-output tool="todowrite">');
|
|
133
|
+
expect(synthetic.content[0].text).toContain("PLAN-STATE-XYZ");
|
|
134
|
+
// Protected toolResult is no longer a standalone message
|
|
135
|
+
expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0);
|
|
136
|
+
// protected tool has no short ref in production → absent from the tools= attribute
|
|
137
|
+
expect(synthetic.content[0].text).not.toContain("t2");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("path-protected output is relocated via detectChains predicate", async () => {
|
|
141
|
+
const indexer = new ToolCallIndexer();
|
|
142
|
+
const blockRefs = new BlockRefIssuer();
|
|
143
|
+
|
|
144
|
+
// tc1 = read /h/src/app.ts (unprotected), tc2 = read /h/skills/x/SKILL.md (protected)
|
|
145
|
+
const messages: any[] = [
|
|
146
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
147
|
+
{
|
|
148
|
+
role: "assistant",
|
|
149
|
+
content: [{ type: "toolCall", id: "tc1", name: "read", input: { path: "/h/src/app.ts" } }],
|
|
150
|
+
timestamp: 200,
|
|
151
|
+
usage: {},
|
|
152
|
+
stopReason: "tool_use",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
role: "toolResult",
|
|
156
|
+
toolCallId: "tc1",
|
|
157
|
+
toolName: "read",
|
|
158
|
+
content: [{ type: "text", text: "app-source-code" }],
|
|
159
|
+
isError: false,
|
|
160
|
+
timestamp: 210,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
role: "assistant",
|
|
164
|
+
content: [{ type: "toolCall", id: "tc2", name: "read", input: { path: "/h/skills/x/SKILL.md" } }],
|
|
165
|
+
timestamp: 300,
|
|
166
|
+
usage: {},
|
|
167
|
+
stopReason: "tool_use",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
role: "toolResult",
|
|
171
|
+
toolCallId: "tc2",
|
|
172
|
+
toolName: "read",
|
|
173
|
+
content: [{ type: "text", text: "SKILL-VERBATIM-CONTENT" }],
|
|
174
|
+
isError: false,
|
|
175
|
+
timestamp: 310,
|
|
176
|
+
},
|
|
177
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
const pred = (name: string, args: unknown) =>
|
|
181
|
+
isProtected(name, args, { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] });
|
|
182
|
+
const chains = detectChains(messages, pred);
|
|
183
|
+
|
|
184
|
+
expect(chains).toHaveLength(1);
|
|
185
|
+
expect(chains[0].protectedToolCallIds).toEqual(["tc2"]);
|
|
186
|
+
|
|
187
|
+
// Only unprotected tc1 has a per-batch summary; tc2 is protected, no short ref.
|
|
188
|
+
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]);
|
|
189
|
+
indexer.registerSummaryBody(["tc1"], "read app.ts summary");
|
|
190
|
+
|
|
191
|
+
const { compressedEntries } = await compressEligible(chains, 0, {
|
|
192
|
+
indexer,
|
|
193
|
+
blockRefs,
|
|
194
|
+
appendEntry: () => {},
|
|
195
|
+
now: () => 999,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(compressedEntries).toHaveLength(1);
|
|
199
|
+
expect(indexer.getChainEntries()[0].protectedToolCallIds).toEqual(["tc2"]);
|
|
200
|
+
|
|
201
|
+
const cc: ChainCompressionConfig = {
|
|
202
|
+
enabled: true,
|
|
203
|
+
rollingWindow: 0,
|
|
204
|
+
stripFinalAssistantThinking: true,
|
|
205
|
+
fuseRangeSummary: false,
|
|
206
|
+
};
|
|
207
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer, cc);
|
|
208
|
+
expect(pruned).toBe(true);
|
|
209
|
+
|
|
210
|
+
const synthetic = out.find(
|
|
211
|
+
(m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
|
|
212
|
+
);
|
|
213
|
+
expect(synthetic).toBeDefined();
|
|
214
|
+
// Protected SKILL.md output is embedded verbatim
|
|
215
|
+
expect(synthetic.content[0].text).toContain('<protected-output tool="read">');
|
|
216
|
+
expect(synthetic.content[0].text).toContain("SKILL-VERBATIM-CONTENT");
|
|
217
|
+
// Unprotected result text is not present in the synthetic block
|
|
218
|
+
expect(synthetic.content[0].text).not.toContain("app-source-code");
|
|
219
|
+
// No standalone toolResult messages remain
|
|
220
|
+
expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0);
|
|
221
|
+
// Protected tc2 has no short ref → absent from the tools= attribute
|
|
222
|
+
expect(synthetic.content[0].text).not.toContain("t2");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("falls back to per-batch concat when fuseRange is absent", async () => {
|
|
226
|
+
const indexer = new ToolCallIndexer();
|
|
227
|
+
const blockRefs = new BlockRefIssuer();
|
|
228
|
+
indexer.registerSummaryRefs([
|
|
229
|
+
{ shortId: "t1", toolCallId: "tc1" },
|
|
230
|
+
{ shortId: "t2", toolCallId: "tc2" },
|
|
231
|
+
]);
|
|
232
|
+
indexer.registerSummaryBody(["tc1"], "batch one body");
|
|
233
|
+
indexer.registerSummaryBody(["tc2"], "batch two body");
|
|
234
|
+
|
|
235
|
+
const chain: ChainRange = { startUserTimestamp: 100, middleToolCallIds: ["tc1", "tc2"], finalAssistantTimestamp: 400 };
|
|
236
|
+
await compressEligible([chain], 0, { indexer, blockRefs, appendEntry: () => {}, now: () => 1 });
|
|
237
|
+
|
|
238
|
+
const messages: any[] = [
|
|
239
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
240
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
|
|
241
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "o1" }], isError: false, timestamp: 210 },
|
|
242
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: {} }], timestamp: 300, usage: {}, stopReason: "tool_use" },
|
|
243
|
+
{ role: "toolResult", toolCallId: "tc2", toolName: "bash", content: [{ type: "text", text: "o2" }], isError: false, timestamp: 310 },
|
|
244
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
|
|
245
|
+
];
|
|
246
|
+
const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false };
|
|
247
|
+
const { messages: out } = pruneMessages(messages, indexer, cc);
|
|
248
|
+
const synthetic = out.find((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith("<compressed-chain"));
|
|
249
|
+
expect(synthetic.content[0].text).toContain("batch one body");
|
|
250
|
+
expect(synthetic.content[0].text).toContain("batch two body");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { sanitizeId, blobDirFor, blobPathFor, headPreview, spillOversizedBatch } from "./spill.js";
|
|
6
|
+
import { ToolCallIndexer } from "./indexer.js";
|
|
7
|
+
import type { CapturedBatch } from "./types.js";
|
|
8
|
+
|
|
9
|
+
describe("sanitizeId", () => {
|
|
10
|
+
it("replaces path separators and unsafe chars", () => {
|
|
11
|
+
expect(sanitizeId("toolu_abc-123")).toBe("toolu_abc-123");
|
|
12
|
+
expect(sanitizeId("../../etc/passwd")).toBe("______etc_passwd");
|
|
13
|
+
expect(sanitizeId("a/b\\c")).toBe("a_b_c");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("blobDirFor / blobPathFor", () => {
|
|
18
|
+
it("builds <sessionDir>/<sessionId>-blobs/<id>.txt", () => {
|
|
19
|
+
expect(blobDirFor("/s", "sid")).toBe(join("/s", "sid-blobs"));
|
|
20
|
+
expect(blobPathFor("/s", "sid", "tc1")).toBe(join("/s", "sid-blobs", "tc1.txt"));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("headPreview", () => {
|
|
25
|
+
it("returns the whole string when under the byte cap", () => {
|
|
26
|
+
expect(headPreview("hello", 1024)).toBe("hello");
|
|
27
|
+
});
|
|
28
|
+
it("cuts at a line boundary when one exists in budget", () => {
|
|
29
|
+
expect(headPreview("aaaa\nbbbb\ncccc", 7)).toBe("aaaa");
|
|
30
|
+
});
|
|
31
|
+
it("never exceeds the byte cap and stays valid UTF-8", () => {
|
|
32
|
+
const s = "é".repeat(100);
|
|
33
|
+
const out = headPreview(s, 11);
|
|
34
|
+
expect(Buffer.byteLength(out, "utf8")).toBeLessThanOrEqual(11);
|
|
35
|
+
expect(() => Buffer.from(out, "utf8").toString("utf8")).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("spillOversizedBatch", () => {
|
|
40
|
+
const cfg = { spillThreshold: 10, spillPreviewBytes: 8, dedupByContentHash: true };
|
|
41
|
+
const mkBatch = (toolCalls: any[]): CapturedBatch => ({ turnIndex: 0, timestamp: 1, assistantText: "", toolCalls });
|
|
42
|
+
|
|
43
|
+
it("spills an oversized result: writes file, mutates record, indexes it", async () => {
|
|
44
|
+
const dir = await mkdtemp(join(tmpdir(), "spill-"));
|
|
45
|
+
try {
|
|
46
|
+
const indexer = new ToolCallIndexer();
|
|
47
|
+
const batch = mkBatch([{ toolCallId: "tc1", toolName: "fetch", args: {}, resultText: "X".repeat(50), isError: false }]);
|
|
48
|
+
const spilled = await spillOversizedBatch({ batch, indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
|
|
49
|
+
expect(spilled.has("tc1")).toBe(true);
|
|
50
|
+
const rec = indexer.getRecord("tc1")!;
|
|
51
|
+
expect(rec.spillPath).toBe(blobPathFor(dir, "sid", "tc1"));
|
|
52
|
+
expect(rec.spillBytes).toBe(50);
|
|
53
|
+
expect(rec.resultText).toBe("");
|
|
54
|
+
expect(rec.resultPreview!.length).toBeGreaterThan(0);
|
|
55
|
+
expect(await readFile(rec.spillPath!, "utf-8")).toBe("X".repeat(50));
|
|
56
|
+
expect(indexer.isSummarized("tc1")).toBe(true);
|
|
57
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("leaves a small result untouched (not spilled)", async () => {
|
|
61
|
+
const dir = await mkdtemp(join(tmpdir(), "spill-"));
|
|
62
|
+
try {
|
|
63
|
+
const indexer = new ToolCallIndexer();
|
|
64
|
+
const batch = mkBatch([{ toolCallId: "tc1", toolName: "bash", args: {}, resultText: "tiny", isError: false }]);
|
|
65
|
+
const spilled = await spillOversizedBatch({ batch, indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
|
|
66
|
+
expect(spilled.size).toBe(0);
|
|
67
|
+
expect(indexer.isSummarized("tc1")).toBe(false);
|
|
68
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("leaves the tool call untouched when the sidecar write fails", async () => {
|
|
72
|
+
const base = await mkdtemp(join(tmpdir(), "spill-"));
|
|
73
|
+
const filePath = join(base, "not-a-dir");
|
|
74
|
+
await writeFile(filePath, "x"); // sessionDir is a FILE → mkdir under it throws
|
|
75
|
+
try {
|
|
76
|
+
const indexer = new ToolCallIndexer();
|
|
77
|
+
const big = "Z".repeat(50);
|
|
78
|
+
const batch = mkBatch([{ toolCallId: "tc1", toolName: "fetch", args: {}, resultText: big, isError: false }]);
|
|
79
|
+
const spilled = await spillOversizedBatch({ batch, indexer, config: cfg, sessionDir: filePath, sessionId: "sid", appendEntry: () => {} });
|
|
80
|
+
expect(spilled.size).toBe(0);
|
|
81
|
+
expect(indexer.isSummarized("tc1")).toBe(false);
|
|
82
|
+
expect(batch.toolCalls[0].resultText).toBe(big); // untouched
|
|
83
|
+
} finally {
|
|
84
|
+
await rm(base, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("dedups an oversized duplicate to the original without a second file", async () => {
|
|
89
|
+
const dir = await mkdtemp(join(tmpdir(), "spill-"));
|
|
90
|
+
try {
|
|
91
|
+
const indexer = new ToolCallIndexer();
|
|
92
|
+
const body = "Y".repeat(50);
|
|
93
|
+
const append = () => {};
|
|
94
|
+
await spillOversizedBatch({ batch: mkBatch([{ toolCallId: "tc1", toolName: "fetch", args: {}, resultText: body, isError: false }]), indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: append });
|
|
95
|
+
const spilled2 = await spillOversizedBatch({ batch: mkBatch([{ toolCallId: "tc2", toolName: "fetch", args: {}, resultText: body, isError: false }]), indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: append });
|
|
96
|
+
expect(spilled2.has("tc2")).toBe(true);
|
|
97
|
+
expect(indexer.isSummarized("tc2")).toBe(true);
|
|
98
|
+
expect(indexer.getRecord("tc2")!.toolCallId).toBe("tc1");
|
|
99
|
+
await expect(readFile(blobPathFor(dir, "sid", "tc2"), "utf-8")).rejects.toBeDefined();
|
|
100
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/spill.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
4
|
+
import type { ToolCallIndexer } from "./indexer.js";
|
|
5
|
+
import { hashToolResult } from "./content-hash.js";
|
|
6
|
+
|
|
7
|
+
/** Replace anything outside [A-Za-z0-9_-] so the id can't escape the blob dir. */
|
|
8
|
+
export function sanitizeId(toolCallId: string): string {
|
|
9
|
+
return toolCallId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function blobDirFor(sessionDir: string, sessionId: string): string {
|
|
13
|
+
return join(sessionDir, `${sessionId}-blobs`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function blobPathFor(sessionDir: string, sessionId: string, toolCallId: string): string {
|
|
17
|
+
return join(blobDirFor(sessionDir, sessionId), `${sanitizeId(toolCallId)}.txt`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Head of `text` capped at `maxBytes` (UTF-8 safe), preferring a line boundary. */
|
|
21
|
+
export function headPreview(text: string, maxBytes: number): string {
|
|
22
|
+
const buf = Buffer.from(text, "utf8");
|
|
23
|
+
if (buf.length <= maxBytes) return text;
|
|
24
|
+
let end = maxBytes;
|
|
25
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
|
|
26
|
+
let slice = buf.subarray(0, end).toString("utf8");
|
|
27
|
+
const lastNl = slice.lastIndexOf("\n");
|
|
28
|
+
if (lastNl > 0) slice = slice.slice(0, lastNl);
|
|
29
|
+
return slice;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SpillConfig {
|
|
33
|
+
spillThreshold: number;
|
|
34
|
+
spillPreviewBytes: number;
|
|
35
|
+
dedupByContentHash: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function spillOversizedBatch(args: {
|
|
39
|
+
batch: CapturedBatch;
|
|
40
|
+
indexer: ToolCallIndexer;
|
|
41
|
+
config: SpillConfig;
|
|
42
|
+
sessionDir: string;
|
|
43
|
+
sessionId: string;
|
|
44
|
+
appendEntry: (customType: string, data?: unknown) => void;
|
|
45
|
+
}): Promise<Set<string>> {
|
|
46
|
+
const { batch, indexer, config, sessionDir, sessionId, appendEntry } = args;
|
|
47
|
+
const handled = new Set<string>();
|
|
48
|
+
const toIndex: CapturedToolCall[] = [];
|
|
49
|
+
|
|
50
|
+
for (const tc of batch.toolCalls) {
|
|
51
|
+
if (tc.resultText.length < config.spillThreshold) continue;
|
|
52
|
+
|
|
53
|
+
const hash = hashToolResult(tc.toolName, tc.resultText);
|
|
54
|
+
|
|
55
|
+
if (config.dedupByContentHash) {
|
|
56
|
+
const original = indexer.lookupByContent(tc.toolName, tc.resultText);
|
|
57
|
+
if (original && original !== tc.toolCallId) {
|
|
58
|
+
indexer.registerDuplicate(tc.toolCallId, original, appendEntry);
|
|
59
|
+
handled.add(tc.toolCallId);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const path = blobPathFor(sessionDir, sessionId, tc.toolCallId);
|
|
65
|
+
try {
|
|
66
|
+
await mkdir(blobDirFor(sessionDir, sessionId), { recursive: true });
|
|
67
|
+
await writeFile(path, tc.resultText, "utf-8");
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`spill: failed to write sidecar for ${tc.toolCallId} at ${path}:`, err);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
tc.spillBytes = Buffer.byteLength(tc.resultText, "utf8");
|
|
74
|
+
tc.resultPreview = headPreview(tc.resultText, config.spillPreviewBytes);
|
|
75
|
+
tc.spillPath = path;
|
|
76
|
+
tc.contentHash = hash;
|
|
77
|
+
tc.resultText = "";
|
|
78
|
+
toIndex.push(tc);
|
|
79
|
+
handled.add(tc.toolCallId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (toIndex.length > 0) {
|
|
83
|
+
indexer.addBatch(
|
|
84
|
+
{ turnIndex: batch.turnIndex, timestamp: batch.timestamp, assistantText: "", toolCalls: toIndex },
|
|
85
|
+
appendEntry,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return handled;
|
|
90
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
StatsAccumulator,
|
|
4
|
+
emitExternalCost,
|
|
5
|
+
} from "./stats.js";
|
|
6
|
+
import {
|
|
7
|
+
CUSTOM_TYPE_STATS,
|
|
8
|
+
EXTERNAL_COST_CHANNEL,
|
|
9
|
+
EXTERNAL_COST_SOURCE,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
// Minimal Usage shape matching the private interface in stats.ts
|
|
13
|
+
function makeUsage(input: number, output: number, costTotal: number) {
|
|
14
|
+
return {
|
|
15
|
+
input,
|
|
16
|
+
output,
|
|
17
|
+
cacheRead: 0,
|
|
18
|
+
cacheWrite: 0,
|
|
19
|
+
totalTokens: input + output,
|
|
20
|
+
cost: {
|
|
21
|
+
input: 0,
|
|
22
|
+
output: 0,
|
|
23
|
+
cacheRead: 0,
|
|
24
|
+
cacheWrite: 0,
|
|
25
|
+
total: costTotal,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("StatsAccumulator.getSessionDelta", () => {
|
|
31
|
+
it("(a) fresh accumulator: delta equals what was added", () => {
|
|
32
|
+
const acc = new StatsAccumulator();
|
|
33
|
+
acc.add(makeUsage(100, 50, 0.01));
|
|
34
|
+
const delta = acc.getSessionDelta();
|
|
35
|
+
expect(delta.totalCost).toBeCloseTo(0.01);
|
|
36
|
+
expect(delta.inputTokens).toBe(100);
|
|
37
|
+
expect(delta.outputTokens).toBe(50);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("(b) after reconstructFromSession, prior totals are excluded from delta", () => {
|
|
41
|
+
const acc = new StatsAccumulator();
|
|
42
|
+
|
|
43
|
+
// Build a mock ctx that returns one CUSTOM_TYPE_STATS entry with prior totals
|
|
44
|
+
const priorStats = {
|
|
45
|
+
totalInputTokens: 500,
|
|
46
|
+
totalOutputTokens: 250,
|
|
47
|
+
totalCost: 0.05,
|
|
48
|
+
callCount: 3,
|
|
49
|
+
chainsCompressed: 1,
|
|
50
|
+
rangesSummarized: 0,
|
|
51
|
+
};
|
|
52
|
+
const mockCtx = {
|
|
53
|
+
sessionManager: {
|
|
54
|
+
getBranch: () => [
|
|
55
|
+
{
|
|
56
|
+
type: "custom",
|
|
57
|
+
customType: CUSTOM_TYPE_STATS,
|
|
58
|
+
data: priorStats,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
} as any;
|
|
63
|
+
|
|
64
|
+
acc.reconstructFromSession(mockCtx);
|
|
65
|
+
|
|
66
|
+
// Delta should be zero right after reconstruction
|
|
67
|
+
const deltaAfterRecon = acc.getSessionDelta();
|
|
68
|
+
expect(deltaAfterRecon.totalCost).toBeCloseTo(0);
|
|
69
|
+
expect(deltaAfterRecon.inputTokens).toBe(0);
|
|
70
|
+
expect(deltaAfterRecon.outputTokens).toBe(0);
|
|
71
|
+
|
|
72
|
+
// A subsequent add should show only the new spend
|
|
73
|
+
acc.add(makeUsage(200, 80, 0.02));
|
|
74
|
+
const deltaAfterAdd = acc.getSessionDelta();
|
|
75
|
+
expect(deltaAfterAdd.totalCost).toBeCloseTo(0.02);
|
|
76
|
+
expect(deltaAfterAdd.inputTokens).toBe(200);
|
|
77
|
+
expect(deltaAfterAdd.outputTokens).toBe(80);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("StatsAccumulator.getLiveReclaim / setLiveReclaim", () => {
|
|
82
|
+
it("(c) undefined initially; round-trips after set", () => {
|
|
83
|
+
const acc = new StatsAccumulator();
|
|
84
|
+
expect(acc.getLiveReclaim()).toBeUndefined();
|
|
85
|
+
acc.setLiveReclaim(1000, 200);
|
|
86
|
+
expect(acc.getLiveReclaim()).toEqual({ beforeChars: 1000, afterChars: 200 });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("emitExternalCost", () => {
|
|
91
|
+
it("(d) emits exactly one event on EXTERNAL_COST_CHANNEL with session delta", () => {
|
|
92
|
+
const acc = new StatsAccumulator();
|
|
93
|
+
acc.add(makeUsage(100, 50, 0.01));
|
|
94
|
+
|
|
95
|
+
const calls: Array<{ channel: string; data: unknown }> = [];
|
|
96
|
+
const fakePi = {
|
|
97
|
+
events: {
|
|
98
|
+
emit: (channel: string, data: unknown) => {
|
|
99
|
+
calls.push({ channel, data });
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
} as any;
|
|
103
|
+
|
|
104
|
+
emitExternalCost(fakePi, acc);
|
|
105
|
+
|
|
106
|
+
expect(calls).toHaveLength(1);
|
|
107
|
+
expect(calls[0].channel).toBe(EXTERNAL_COST_CHANNEL);
|
|
108
|
+
const payload = calls[0].data as any;
|
|
109
|
+
expect(payload.source).toBe(EXTERNAL_COST_SOURCE);
|
|
110
|
+
expect(payload.totalCost).toBeCloseTo(0.01);
|
|
111
|
+
expect(payload.inputTokens).toBe(100);
|
|
112
|
+
expect(payload.outputTokens).toBe(50);
|
|
113
|
+
});
|
|
114
|
+
});
|