pi-condense 2.6.0 → 2.8.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 +19 -0
- package/PRUNING.md +32 -3
- package/README.md +12 -1
- package/index.ts +288 -109
- package/package.json +1 -1
- package/src/budget.test.ts +49 -2
- package/src/budget.ts +22 -8
- package/src/commands.test.ts +138 -4
- package/src/commands.ts +32 -11
- package/src/context-metrics.test.ts +335 -0
- package/src/context-metrics.ts +152 -0
- package/src/reload-rearm.integration.test.ts +647 -0
- package/src/summarizer-wiring.test.ts +2 -0
- package/src/types.ts +48 -6
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { computeContextMetrics } from "./context-metrics.js";
|
|
3
|
+
import type { PruneFrontier } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// ── Minimal message factories (mirrors src/chain-detector.test.ts style) ───
|
|
6
|
+
|
|
7
|
+
function userMsg(timestamp: number, text = "do the thing"): any {
|
|
8
|
+
return { role: "user", content: [{ type: "text", text }], timestamp };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function assistantWithTools(timestamp: number, toolCallIds: string[]): any {
|
|
12
|
+
return {
|
|
13
|
+
role: "assistant",
|
|
14
|
+
content: [
|
|
15
|
+
{ type: "text", text: "working..." },
|
|
16
|
+
...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })),
|
|
17
|
+
],
|
|
18
|
+
timestamp,
|
|
19
|
+
usage: {},
|
|
20
|
+
stopReason: "toolUse",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assistantWithToolsAndThinking(timestamp: number, toolCallIds: string[], thinking = "hmm"): any {
|
|
25
|
+
return {
|
|
26
|
+
role: "assistant",
|
|
27
|
+
content: [
|
|
28
|
+
{ type: "thinking", thinking, thinkingSignature: "sig" },
|
|
29
|
+
{ type: "text", text: "working..." },
|
|
30
|
+
...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })),
|
|
31
|
+
],
|
|
32
|
+
timestamp,
|
|
33
|
+
usage: {},
|
|
34
|
+
stopReason: "toolUse",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toolResult(timestamp: number, toolCallId: string, toolName = "bash", text = "output"): any {
|
|
39
|
+
return {
|
|
40
|
+
role: "toolResult",
|
|
41
|
+
toolCallId,
|
|
42
|
+
toolName,
|
|
43
|
+
content: [{ type: "text", text }],
|
|
44
|
+
isError: false,
|
|
45
|
+
timestamp,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assistantText(timestamp: number, text = "done"): any {
|
|
50
|
+
return { role: "assistant", content: [{ type: "text", text }], timestamp, usage: {}, stopReason: "stop" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assistantTextWithThinking(timestamp: number, thinking = "closing thought", text = "done"): any {
|
|
54
|
+
return {
|
|
55
|
+
role: "assistant",
|
|
56
|
+
content: [
|
|
57
|
+
{ type: "thinking", thinking, thinkingSignature: "sig" },
|
|
58
|
+
{ type: "text", text },
|
|
59
|
+
],
|
|
60
|
+
timestamp,
|
|
61
|
+
usage: {},
|
|
62
|
+
stopReason: "stop",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const noSummarized = () => false;
|
|
67
|
+
const noProtected = () => false;
|
|
68
|
+
|
|
69
|
+
function fullFrontier(overrides: Partial<PruneFrontier>): PruneFrontier {
|
|
70
|
+
return {
|
|
71
|
+
lastAttemptedToolCallId: "tc1",
|
|
72
|
+
lastAttemptedToolName: "bash",
|
|
73
|
+
lastAttemptedTurnIndex: 0,
|
|
74
|
+
lastAttemptedTimestamp: 0,
|
|
75
|
+
attemptedBatchCount: 1,
|
|
76
|
+
attemptedToolCallCount: 1,
|
|
77
|
+
rawCharCount: 0,
|
|
78
|
+
summaryCharCount: 0,
|
|
79
|
+
outcome: "summarized",
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("computeContextMetrics", () => {
|
|
85
|
+
test("empty branch -> all zeros", () => {
|
|
86
|
+
const result = computeContextMetrics([], null, noSummarized, noProtected);
|
|
87
|
+
expect(result).toEqual({ openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("open segment thinking: only counts thinking blocks strictly after the last text-only assistant", () => {
|
|
91
|
+
const openThinkingBlock = { type: "thinking", thinking: "open thought that survives", thinkingSignature: "sig" };
|
|
92
|
+
const msgs = [
|
|
93
|
+
userMsg(100),
|
|
94
|
+
assistantTextWithThinking(200, "excluded thought A"), // text-only -> excluded (at/before boundary)
|
|
95
|
+
userMsg(300),
|
|
96
|
+
assistantWithTools(400, ["tc1"]),
|
|
97
|
+
toolResult(500, "tc1"),
|
|
98
|
+
assistantTextWithThinking(600, "excluded thought B"), // new last text-only assistant
|
|
99
|
+
userMsg(700),
|
|
100
|
+
{
|
|
101
|
+
role: "assistant",
|
|
102
|
+
content: [openThinkingBlock, { type: "text", text: "working" }, { type: "toolCall", id: "tc2", name: "bash", arguments: {} }],
|
|
103
|
+
timestamp: 800,
|
|
104
|
+
usage: {},
|
|
105
|
+
stopReason: "toolUse",
|
|
106
|
+
},
|
|
107
|
+
toolResult(900, "tc2"),
|
|
108
|
+
];
|
|
109
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
110
|
+
const expected = Math.round(JSON.stringify(openThinkingBlock).length / 4);
|
|
111
|
+
expect(result.openCycleThinkingTokens).toBe(expected);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("zero text-only assistants -> whole branch is the open segment", () => {
|
|
115
|
+
const thinkingBlock = { type: "thinking", thinking: "the only thought", thinkingSignature: "sig" };
|
|
116
|
+
const msgs = [
|
|
117
|
+
userMsg(100),
|
|
118
|
+
{ role: "assistant", content: [thinkingBlock, { type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "toolUse" },
|
|
119
|
+
toolResult(300, "tc1"),
|
|
120
|
+
];
|
|
121
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
122
|
+
expect(result.openCycleThinkingTokens).toBe(Math.round(JSON.stringify(thinkingBlock).length / 4));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("no thinking blocks anywhere -> openCycleThinkingTokens is 0", () => {
|
|
126
|
+
const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")];
|
|
127
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
128
|
+
expect(result.openCycleThinkingTokens).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("largestChainSharePct: closed chain larger than open segment -> chain dominates", () => {
|
|
132
|
+
const msgs = [
|
|
133
|
+
userMsg(100),
|
|
134
|
+
assistantWithTools(200, ["tc1"]),
|
|
135
|
+
toolResult(300, "tc1", "bash", "x".repeat(2000)), // big closed chain
|
|
136
|
+
assistantText(400),
|
|
137
|
+
userMsg(500),
|
|
138
|
+
assistantText(600), // tiny open segment (single text-only assistant, itself excluded from open... )
|
|
139
|
+
];
|
|
140
|
+
// Recompute manually to avoid relying on the implementation under test.
|
|
141
|
+
const chars = msgs.map((m) => JSON.stringify(m).length);
|
|
142
|
+
const totalChars = chars.reduce((a, b) => a + b, 0);
|
|
143
|
+
const chainChars = chars[0] + chars[1] + chars[2] + chars[3]; // userMsg..assistantText(400)
|
|
144
|
+
const openSegmentChars = 0; // last text-only assistant is msgs[5] itself; open segment is empty (after index 5)
|
|
145
|
+
const expectedPct = Math.round((100 * Math.max(chainChars, openSegmentChars)) / totalChars);
|
|
146
|
+
|
|
147
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
148
|
+
expect(result.largestChainSharePct).toBe(expectedPct);
|
|
149
|
+
expect(chainChars).toBeGreaterThan(openSegmentChars);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("largestChainSharePct: open segment larger than any closed chain -> open segment dominates", () => {
|
|
153
|
+
const msgs = [
|
|
154
|
+
userMsg(100),
|
|
155
|
+
assistantWithTools(200, ["tc1"]),
|
|
156
|
+
toolResult(300, "tc1"), // small closed chain
|
|
157
|
+
assistantText(400), // closes chain 1
|
|
158
|
+
userMsg(500),
|
|
159
|
+
assistantWithTools(600, ["tc2"]),
|
|
160
|
+
toolResult(700, "tc2", "bash", "y".repeat(3000)), // large open segment (never closes)
|
|
161
|
+
];
|
|
162
|
+
const chars = msgs.map((m) => JSON.stringify(m).length);
|
|
163
|
+
const totalChars = chars.reduce((a, b) => a + b, 0);
|
|
164
|
+
const chainChars = chars[0] + chars[1] + chars[2] + chars[3];
|
|
165
|
+
const openSegmentChars = chars[4] + chars[5] + chars[6];
|
|
166
|
+
const expectedPct = Math.round((100 * Math.max(chainChars, openSegmentChars)) / totalChars);
|
|
167
|
+
|
|
168
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
169
|
+
expect(result.largestChainSharePct).toBe(expectedPct);
|
|
170
|
+
expect(openSegmentChars).toBeGreaterThan(chainChars);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("largestChainSharePct: a projected custom_message entry (role \"custom\") counts toward the denominator only, never the chain numerator", () => {
|
|
174
|
+
// Mirrors index.ts's branch projection for persisted summary custom_message
|
|
175
|
+
// entries: role "custom" never matches the user/assistant/toolResult roles
|
|
176
|
+
// detectChains and isTextOnlyAssistant key off, so it cannot join a chain
|
|
177
|
+
// or the open segment -- it only inflates totalChars (the denominator).
|
|
178
|
+
// The customEntry sits between two final text-only assistant messages, so
|
|
179
|
+
// it lands outside both the chain range and the open-cycle segment --
|
|
180
|
+
// isolating the denominator effect from any open-segment interaction.
|
|
181
|
+
const chainMsgs = [
|
|
182
|
+
userMsg(100),
|
|
183
|
+
assistantWithTools(200, ["tc1"]),
|
|
184
|
+
toolResult(300, "tc1", "bash", "x".repeat(2000)),
|
|
185
|
+
assistantText(400),
|
|
186
|
+
];
|
|
187
|
+
const customEntry = { role: "custom", customType: "pi-condense:summary", content: "s".repeat(3000), display: true, timestamp: 450 };
|
|
188
|
+
const closer = assistantText(500, "ok");
|
|
189
|
+
|
|
190
|
+
const withoutCustom = computeContextMetrics(chainMsgs, null, noSummarized, noProtected);
|
|
191
|
+
const withCustom = computeContextMetrics([...chainMsgs, customEntry, closer], null, noSummarized, noProtected);
|
|
192
|
+
|
|
193
|
+
const chainChars = chainMsgs.map((m) => JSON.stringify(m).length).reduce((a, b) => a + b, 0);
|
|
194
|
+
const totalWithCustom = [...chainMsgs, customEntry, closer].map((m) => JSON.stringify(m).length).reduce((a, b) => a + b, 0);
|
|
195
|
+
const expectedPctWithCustom = Math.round((100 * chainChars) / totalWithCustom);
|
|
196
|
+
|
|
197
|
+
expect(withoutCustom.largestChainSharePct).toBe(100);
|
|
198
|
+
expect(withCustom.largestChainSharePct).toBe(expectedPctWithCustom);
|
|
199
|
+
expect(withCustom.largestChainSharePct).toBeLessThan(withoutCustom.largestChainSharePct);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("largestChainSharePct: interrupted chain (null finalAssistantTimestamp) is counted", () => {
|
|
203
|
+
const msgs = [
|
|
204
|
+
userMsg(100),
|
|
205
|
+
assistantWithTools(200, ["tc1"]),
|
|
206
|
+
toolResult(300, "tc1", "bash", "z".repeat(5000)), // huge interrupted chain
|
|
207
|
+
userMsg(400), // interrupts before text-only close
|
|
208
|
+
assistantText(500), // closes the second (tiny) chain
|
|
209
|
+
];
|
|
210
|
+
const chars = msgs.map((m) => JSON.stringify(m).length);
|
|
211
|
+
const totalChars = chars.reduce((a, b) => a + b, 0);
|
|
212
|
+
const interruptedChainChars = chars[0] + chars[1] + chars[2]; // startIdx..(nextUserIdx - 1)
|
|
213
|
+
const openSegmentChars = 0; // last text-only assistant is msgs[4] itself
|
|
214
|
+
const expectedPct = Math.round((100 * Math.max(interruptedChainChars, openSegmentChars)) / totalChars);
|
|
215
|
+
|
|
216
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
217
|
+
expect(result.largestChainSharePct).toBe(expectedPct);
|
|
218
|
+
expect(expectedPct).toBeGreaterThan(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("largestChainSharePct: empty branch denominator is 0 -> 0 (not NaN)", () => {
|
|
222
|
+
const result = computeContextMetrics([], null, noSummarized, noProtected);
|
|
223
|
+
expect(result.largestChainSharePct).toBe(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("frontierGapTokens: null frontier -> whole branch counted", () => {
|
|
227
|
+
const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")];
|
|
228
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
229
|
+
const expected = Math.round(JSON.stringify(msgs[2]).length / 4) + Math.round(JSON.stringify(msgs[3]).length / 4);
|
|
230
|
+
expect(result.frontierGapTokens).toBe(expected);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("frontierGapTokens: boundary mid-turn split excludes at-or-before calls, includes later calls in same turn", () => {
|
|
234
|
+
const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")];
|
|
235
|
+
const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 0 });
|
|
236
|
+
const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected);
|
|
237
|
+
const expected = Math.round(JSON.stringify(msgs[3]).length / 4); // only tc2's result
|
|
238
|
+
expect(result.frontierGapTokens).toBe(expected);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("frontierGapTokens: bare-id miss (id not present in the matched turn) -> whole branch counted", () => {
|
|
242
|
+
const msgs = [userMsg(100), assistantWithTools(200, ["tc1", "tc2"]), toolResult(300, "tc1"), toolResult(310, "tc2")];
|
|
243
|
+
const frontier = fullFrontier({ lastAttemptedToolCallId: "tc-does-not-exist", lastAttemptedTurnIndex: 0 });
|
|
244
|
+
const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected);
|
|
245
|
+
const expected = Math.round(JSON.stringify(msgs[2]).length / 4) + Math.round(JSON.stringify(msgs[3]).length / 4);
|
|
246
|
+
expect(result.frontierGapTokens).toBe(expected);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("frontierGapTokens: turn index not found in branch -> whole branch counted", () => {
|
|
250
|
+
const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")];
|
|
251
|
+
const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 99 });
|
|
252
|
+
const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected);
|
|
253
|
+
const expected = Math.round(JSON.stringify(msgs[2]).length / 4);
|
|
254
|
+
expect(result.frontierGapTokens).toBe(expected);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("frontierGapTokens: excludes a toolResult whose occurrence key is already summarized", () => {
|
|
258
|
+
const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")];
|
|
259
|
+
const isSummarized = (key: string) => key === "tc1@300";
|
|
260
|
+
const result = computeContextMetrics(msgs, null, isSummarized, noProtected);
|
|
261
|
+
expect(result.frontierGapTokens).toBe(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("frontierGapTokens: excludes a toolResult from a protected tool (args looked up from the pairing toolCall)", () => {
|
|
265
|
+
const msgs = [
|
|
266
|
+
userMsg(100),
|
|
267
|
+
{
|
|
268
|
+
role: "assistant",
|
|
269
|
+
content: [{ type: "toolCall", id: "tc1", name: "read", arguments: { path: "/skills/secret.md" } }],
|
|
270
|
+
timestamp: 200,
|
|
271
|
+
usage: {},
|
|
272
|
+
stopReason: "toolUse",
|
|
273
|
+
},
|
|
274
|
+
toolResult(300, "tc1", "read"),
|
|
275
|
+
];
|
|
276
|
+
const isProtected = (toolName: string, args: unknown) =>
|
|
277
|
+
toolName === "read" && typeof (args as any)?.path === "string" && (args as any).path.includes("/skills/");
|
|
278
|
+
const result = computeContextMetrics(msgs, null, noSummarized, isProtected);
|
|
279
|
+
expect(result.frontierGapTokens).toBe(0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("exact-value pin: frontierGapTokens equals Math.round(JSON.stringify(msg).length / 4) for a single toolResult", () => {
|
|
283
|
+
const result_msg = toolResult(300, "tc1", "bash", "a fixed output payload");
|
|
284
|
+
const msgs = [userMsg(100), assistantWithTools(200, ["tc1"]), result_msg];
|
|
285
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
286
|
+
expect(result.frontierGapTokens).toBe(Math.round(JSON.stringify(result_msg).length / 4));
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("frontierGapTokens: a toolCall id reused in a LATER turn (past the boundary) still counts its result — exclusion is positional, not id-global", () => {
|
|
290
|
+
const laterResult = toolResult(700, "tc1", "bash", "y".repeat(196)); // ~49 tokens
|
|
291
|
+
const msgs = [
|
|
292
|
+
userMsg(100),
|
|
293
|
+
assistantWithTools(200, ["tc1"]), // turn 0 — this is the boundary turn
|
|
294
|
+
toolResult(300, "tc1"), // boundary result — correctly excluded
|
|
295
|
+
assistantText(400), // closes turn 0's chain
|
|
296
|
+
userMsg(500),
|
|
297
|
+
assistantWithTools(600, ["tc1"]), // turn 1 — reuses bare id "tc1" (legal: ids are only unique per turn)
|
|
298
|
+
laterResult, // must be counted: it is positionally after the boundary
|
|
299
|
+
];
|
|
300
|
+
const frontier = fullFrontier({ lastAttemptedToolCallId: "tc1", lastAttemptedTurnIndex: 0 });
|
|
301
|
+
const result = computeContextMetrics(msgs, frontier, noSummarized, noProtected);
|
|
302
|
+
const expected = Math.round(JSON.stringify(laterResult).length / 4);
|
|
303
|
+
expect(result.frontierGapTokens).toBe(expected);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("frontierGapTokens: args pairing for a reused id uses the nearest preceding assistant's toolCall, not a global id map", () => {
|
|
307
|
+
const msgs = [
|
|
308
|
+
userMsg(100),
|
|
309
|
+
{
|
|
310
|
+
role: "assistant",
|
|
311
|
+
content: [{ type: "toolCall", id: "tc1", name: "read", arguments: { path: "/normal.md" } }],
|
|
312
|
+
timestamp: 200,
|
|
313
|
+
usage: {},
|
|
314
|
+
stopReason: "toolUse",
|
|
315
|
+
}, // turn 0 — unprotected args
|
|
316
|
+
toolResult(300, "tc1", "read"),
|
|
317
|
+
assistantText(400),
|
|
318
|
+
userMsg(500),
|
|
319
|
+
{
|
|
320
|
+
role: "assistant",
|
|
321
|
+
content: [{ type: "toolCall", id: "tc1", name: "read", arguments: { path: "/skills/secret.md" } }],
|
|
322
|
+
timestamp: 600,
|
|
323
|
+
usage: {},
|
|
324
|
+
stopReason: "toolUse",
|
|
325
|
+
}, // turn 1 — reuses bare id "tc1" with protected args
|
|
326
|
+
toolResult(700, "tc1", "read"),
|
|
327
|
+
];
|
|
328
|
+
const isProtected = (toolName: string, args: unknown) =>
|
|
329
|
+
toolName === "read" && typeof (args as any)?.path === "string" && (args as any).path.includes("/skills/");
|
|
330
|
+
const result = computeContextMetrics(msgs, null, noSummarized, isProtected);
|
|
331
|
+
// turn 0's result (unprotected args) counts; turn 1's result (protected args) is excluded.
|
|
332
|
+
const expected = Math.round(JSON.stringify(msgs[2]).length / 4);
|
|
333
|
+
expect(result.frontierGapTokens).toBe(expected);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { detectChains } from "./chain-detector.js";
|
|
2
|
+
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
3
|
+
import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
|
|
4
|
+
|
|
5
|
+
function charsOf(msg: any): number {
|
|
6
|
+
return JSON.stringify(msg).length;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function tokensOf(msg: any): number {
|
|
10
|
+
return Math.round(charsOf(msg) / 4);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isTextOnlyAssistant(msg: any): boolean {
|
|
14
|
+
if (msg.role !== "assistant") return false;
|
|
15
|
+
if (!Array.isArray(msg.content)) return true;
|
|
16
|
+
return !msg.content.some((b: any) => b.type === "toolCall");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toolCallBlocksOf(msg: any): { id: string; input?: unknown; arguments?: unknown }[] {
|
|
20
|
+
if (!Array.isArray(msg.content)) return [];
|
|
21
|
+
return msg.content.filter((b: any) => b.type === "toolCall");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function findArgsForToolCallId(branch: any[], resultIdx: number, toolCallId: string): unknown {
|
|
25
|
+
for (let i = resultIdx - 1; i >= 0; i--) {
|
|
26
|
+
const m = branch[i];
|
|
27
|
+
if (m.role !== "assistant") continue;
|
|
28
|
+
const call = toolCallBlocksOf(m).find((c) => c.id === toolCallId);
|
|
29
|
+
if (call) return (call as any).input ?? (call as any).arguments;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pure snapshot of what the pruner cannot (yet) reclaim: thinking tokens
|
|
36
|
+
* trapped in the trailing open cycle, the largest single chain's share of
|
|
37
|
+
* the branch, and unsummarized toolResult tokens past the prune frontier.
|
|
38
|
+
*/
|
|
39
|
+
export function computeContextMetrics(
|
|
40
|
+
branch: any[],
|
|
41
|
+
frontier: PruneFrontier | null,
|
|
42
|
+
isSummarized: (occurrenceKey: string) => boolean,
|
|
43
|
+
isProtected: (toolName: string, args: unknown) => boolean,
|
|
44
|
+
): ContextMetricsSnapshot {
|
|
45
|
+
if (branch.length === 0) {
|
|
46
|
+
return { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Open segment: strictly after the last text-only assistant ──────────
|
|
50
|
+
let lastTextOnlyIdx = -1;
|
|
51
|
+
for (let i = 0; i < branch.length; i++) {
|
|
52
|
+
if (isTextOnlyAssistant(branch[i])) lastTextOnlyIdx = i;
|
|
53
|
+
}
|
|
54
|
+
const openStart = lastTextOnlyIdx + 1;
|
|
55
|
+
|
|
56
|
+
let thinkingChars = 0;
|
|
57
|
+
for (let i = openStart; i < branch.length; i++) {
|
|
58
|
+
const m = branch[i];
|
|
59
|
+
if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
60
|
+
for (const block of m.content) {
|
|
61
|
+
if (block.type === "thinking") thinkingChars += JSON.stringify(block).length;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const openCycleThinkingTokens = Math.round(thinkingChars / 4);
|
|
65
|
+
|
|
66
|
+
// ── Largest chain share ─────────────────────────────────────────────────
|
|
67
|
+
const branchChars = branch.map(charsOf);
|
|
68
|
+
const sumChars = (start: number, end: number): number => {
|
|
69
|
+
let sum = 0;
|
|
70
|
+
for (let i = start; i <= end; i++) sum += branchChars[i];
|
|
71
|
+
return sum;
|
|
72
|
+
};
|
|
73
|
+
const totalChars = branchChars.reduce((a, b) => a + b, 0);
|
|
74
|
+
|
|
75
|
+
const chains = detectChains(branch, isProtected);
|
|
76
|
+
let largestClosedChainChars = 0;
|
|
77
|
+
for (const range of chains) {
|
|
78
|
+
const startIdx = branch.findIndex((m) => m.role === "user" && m.timestamp === range.startUserTimestamp);
|
|
79
|
+
if (startIdx === -1) continue;
|
|
80
|
+
let endIdx: number;
|
|
81
|
+
if (range.finalAssistantTimestamp !== null) {
|
|
82
|
+
endIdx = branch.findIndex(
|
|
83
|
+
(m, i) => i >= startIdx && m.role === "assistant" && m.timestamp === range.finalAssistantTimestamp,
|
|
84
|
+
);
|
|
85
|
+
if (endIdx === -1) continue;
|
|
86
|
+
} else {
|
|
87
|
+
let nextUserIdx = -1;
|
|
88
|
+
for (let i = startIdx + 1; i < branch.length; i++) {
|
|
89
|
+
if (branch[i].role === "user") {
|
|
90
|
+
nextUserIdx = i;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
endIdx = nextUserIdx === -1 ? branch.length - 1 : nextUserIdx - 1;
|
|
95
|
+
}
|
|
96
|
+
if (endIdx < startIdx) continue;
|
|
97
|
+
const chainChars = sumChars(startIdx, endIdx);
|
|
98
|
+
if (chainChars > largestClosedChainChars) largestClosedChainChars = chainChars;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const openSegmentChars = openStart < branch.length ? sumChars(openStart, branch.length - 1) : 0;
|
|
102
|
+
const numerator = Math.max(largestClosedChainChars, openSegmentChars);
|
|
103
|
+
const largestChainSharePct = totalChars === 0 ? 0 : Math.round((100 * numerator) / totalChars);
|
|
104
|
+
|
|
105
|
+
// ── Frontier gap ─────────────────────────────────────────────────────────
|
|
106
|
+
// Exclusion is positional: only toolResults belonging to the boundary turn's
|
|
107
|
+
// own calls (up to and including the last-attempted call) are excluded.
|
|
108
|
+
// Ids are only unique per turn (see occurrence-key.ts), so a later turn may
|
|
109
|
+
// legally reuse a bare id — its result must still count toward the gap.
|
|
110
|
+
let boundaryIdx = -1;
|
|
111
|
+
let boundaryTurnEndIdx = branch.length;
|
|
112
|
+
const boundaryExcludedIds = new Set<string>();
|
|
113
|
+
if (frontier) {
|
|
114
|
+
let counter = 0;
|
|
115
|
+
for (let i = 0; i < branch.length; i++) {
|
|
116
|
+
const m = branch[i];
|
|
117
|
+
if (m.role !== "assistant") continue;
|
|
118
|
+
const turnIdx = counter;
|
|
119
|
+
counter++;
|
|
120
|
+
if (turnIdx === frontier.lastAttemptedTurnIndex) {
|
|
121
|
+
const calls = toolCallBlocksOf(m);
|
|
122
|
+
const k = calls.findIndex((c) => c.id === frontier.lastAttemptedToolCallId);
|
|
123
|
+
if (k !== -1) {
|
|
124
|
+
boundaryIdx = i;
|
|
125
|
+
for (let j = 0; j <= k; j++) boundaryExcludedIds.add(calls[j].id);
|
|
126
|
+
for (let j = i + 1; j < branch.length; j++) {
|
|
127
|
+
if (branch[j].role === "assistant") {
|
|
128
|
+
boundaryTurnEndIdx = j;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let frontierGapTokens = 0;
|
|
139
|
+
const scanStart = boundaryIdx === -1 ? 0 : boundaryIdx + 1;
|
|
140
|
+
for (let i = scanStart; i < branch.length; i++) {
|
|
141
|
+
const m = branch[i];
|
|
142
|
+
if (m.role !== "toolResult") continue;
|
|
143
|
+
if (i < boundaryTurnEndIdx && boundaryExcludedIds.has(m.toolCallId)) continue;
|
|
144
|
+
const key = occKey(m.toolCallId, resultTimestampOf(m.timestamp));
|
|
145
|
+
if (isSummarized(key)) continue;
|
|
146
|
+
const args = findArgsForToolCallId(branch, i, m.toolCallId);
|
|
147
|
+
if (isProtected(m.toolName, args)) continue;
|
|
148
|
+
frontierGapTokens += tokensOf(m);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { openCycleThinkingTokens, largestChainSharePct, frontierGapTokens };
|
|
152
|
+
}
|