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
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-condense",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Pi extension that summarizes completed tool-call batches, replaces raw outputs with short stubs in future context, and recovers any original on demand via context_tree_query.",
|
|
5
|
+
"author": "Jacek Juraszek",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jjuraszek/pi-condense.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/jjuraszek/pi-condense#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/jjuraszek/pi-condense/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-coding-agent",
|
|
20
|
+
"context",
|
|
21
|
+
"context-management",
|
|
22
|
+
"pruning",
|
|
23
|
+
"summarization",
|
|
24
|
+
"ai"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"index.ts",
|
|
31
|
+
"src/**/*.ts",
|
|
32
|
+
"README.md",
|
|
33
|
+
"CHANGELOG.md",
|
|
34
|
+
"PRUNING.md"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "bun test src/"
|
|
38
|
+
},
|
|
39
|
+
"pi": {
|
|
40
|
+
"extensions": [
|
|
41
|
+
"./index.ts"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
47
|
+
"@earendil-works/pi-tui": "*",
|
|
48
|
+
"@sinclair/typebox": "*"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@earendil-works/pi-ai": "^0.78.0",
|
|
52
|
+
"@earendil-works/pi-coding-agent": "^0.78.0",
|
|
53
|
+
"@earendil-works/pi-tui": "^0.78.0",
|
|
54
|
+
"@sinclair/typebox": "^0.34.49"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Joins the text blocks of a ToolResultMessage into a single string. */
|
|
4
|
+
export function extractToolResultText(msg: any): string {
|
|
5
|
+
const content: any[] = Array.isArray(msg?.content) ? msg.content : [];
|
|
6
|
+
return content
|
|
7
|
+
.filter((c: any) => c.type === "text")
|
|
8
|
+
.map((c: any) => c.text)
|
|
9
|
+
.join("\n");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Converts turn_end event data into a CapturedBatch.
|
|
14
|
+
* @param message AssistantMessage (content: Array of TextContent|ThinkingContent|ToolCall)
|
|
15
|
+
* @param toolResults ToolResultMessage[]
|
|
16
|
+
*/
|
|
17
|
+
export function captureBatch(
|
|
18
|
+
message: any,
|
|
19
|
+
toolResults: any[],
|
|
20
|
+
turnIndex: number,
|
|
21
|
+
timestamp: number
|
|
22
|
+
): CapturedBatch {
|
|
23
|
+
const content: any[] = Array.isArray(message?.content) ? message.content : [];
|
|
24
|
+
|
|
25
|
+
// Collect assistant prose text
|
|
26
|
+
const assistantText = content
|
|
27
|
+
.filter((block: any) => block.type === "text")
|
|
28
|
+
.map((block: any) => block.text)
|
|
29
|
+
.join("\n")
|
|
30
|
+
.trim();
|
|
31
|
+
|
|
32
|
+
// Collect tool calls, matching each to its result
|
|
33
|
+
const toolCalls: CapturedToolCall[] = content
|
|
34
|
+
.filter((block: any) => block.type === "toolCall")
|
|
35
|
+
.map((block: any) => {
|
|
36
|
+
const match = toolResults.find((result: any) => result.toolCallId === block.id);
|
|
37
|
+
|
|
38
|
+
let resultText = "(no result)";
|
|
39
|
+
let isError = false;
|
|
40
|
+
|
|
41
|
+
if (match) {
|
|
42
|
+
resultText = extractToolResultText(match);
|
|
43
|
+
isError = match.isError ?? false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
toolCallId: block.id,
|
|
48
|
+
toolName: block.name,
|
|
49
|
+
args: block.input ?? block.args ?? block.arguments ?? {},
|
|
50
|
+
resultText,
|
|
51
|
+
isError,
|
|
52
|
+
} satisfies CapturedToolCall;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { turnIndex, timestamp, assistantText, toolCalls };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scans a session branch for unsummarized tool results and groups them into CapturedBatches.
|
|
60
|
+
* Useful for capturing results from the current in-progress turn when a prune is triggered.
|
|
61
|
+
*
|
|
62
|
+
* @param branch The session message branch (from ctx.sessionManager.getBranch())
|
|
63
|
+
* @param indexer The pruner indexer to check for already-summarized IDs
|
|
64
|
+
* @param exclude Optional predicate; matching tool calls are skipped (user-protected tools/paths)
|
|
65
|
+
*/
|
|
66
|
+
export function captureUnindexedBatchesFromSession(
|
|
67
|
+
branch: any[],
|
|
68
|
+
indexer: { isSummarized(id: string): boolean },
|
|
69
|
+
exclude: (toolName: string, args: unknown) => boolean = () => false
|
|
70
|
+
): CapturedBatch[] {
|
|
71
|
+
// branch is SessionEntry[]. Each message entry has { type: "message", message: AgentMessage }.
|
|
72
|
+
// We must unwrap the SessionEntry wrapper before accessing role/toolCallId.
|
|
73
|
+
const resultMap = new Map<string, any>();
|
|
74
|
+
for (const entry of branch) {
|
|
75
|
+
if (entry.type !== "message") continue;
|
|
76
|
+
const m = entry.message;
|
|
77
|
+
if (m.role === "toolResult" && m.toolCallId) {
|
|
78
|
+
resultMap.set(m.toolCallId, m);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const batches: CapturedBatch[] = [];
|
|
83
|
+
// turnCounter increments for EVERY assistant message (not just prunable ones).
|
|
84
|
+
// This makes turnIndex stable across multiple prune cycles: pruning removes
|
|
85
|
+
// ToolResultMessages from the context event but leaves AssistantMessages in the
|
|
86
|
+
// session branch, so the count of all assistant messages never decreases and
|
|
87
|
+
// always matches Pi's own event.turnIndex numbering.
|
|
88
|
+
let turnCounter = 0;
|
|
89
|
+
|
|
90
|
+
// userTurnGroup increments on every user message seen while walking the branch.
|
|
91
|
+
// All assistant tool-call batches between two consecutive user messages share the
|
|
92
|
+
// same userTurnGroup. This is used by groupBatchesByMode to merge turns within
|
|
93
|
+
// a single user → final-agent-message span when batchingMode === "agent-message".
|
|
94
|
+
let userTurnGroup = 0;
|
|
95
|
+
|
|
96
|
+
for (const entry of branch) {
|
|
97
|
+
if (entry.type !== "message") continue;
|
|
98
|
+
const msg = entry.message;
|
|
99
|
+
|
|
100
|
+
// Advance userTurnGroup on every user message so all subsequent assistant
|
|
101
|
+
// batches get a new group number.
|
|
102
|
+
if (msg.role === "user") {
|
|
103
|
+
userTurnGroup++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (msg.role !== "assistant") continue;
|
|
108
|
+
|
|
109
|
+
// Stable turn index: count every assistant message regardless of pruning state
|
|
110
|
+
const currentTurnIndex = turnCounter++;
|
|
111
|
+
|
|
112
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
113
|
+
const toolCallBlocks = content.filter((c: any) => c.type === "toolCall");
|
|
114
|
+
|
|
115
|
+
// Find tool calls that have results in this branch and are not yet summarized
|
|
116
|
+
const readyToPrune = toolCallBlocks.filter((tc: any) => {
|
|
117
|
+
const id = tc.id;
|
|
118
|
+
if (!id) return false;
|
|
119
|
+
if (indexer.isSummarized(id)) return false;
|
|
120
|
+
if (exclude(tc.name, tc.input ?? tc.arguments)) return false;
|
|
121
|
+
return resultMap.has(id);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (readyToPrune.length > 0) {
|
|
125
|
+
const results = readyToPrune.map((tc: any) => resultMap.get(tc.id));
|
|
126
|
+
const readyIds = new Set(readyToPrune.map((tc: any) => tc.id));
|
|
127
|
+
// We pass the full message but then trim back down to only the tool calls
|
|
128
|
+
// whose results already exist in the session. This lets a flush prune
|
|
129
|
+
// an intermediate completed subset in the middle of a longer tool chain
|
|
130
|
+
// without accidentally capturing later unresolved calls from the same
|
|
131
|
+
// assistant message as "(no result)" placeholders.
|
|
132
|
+
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : (msg.timestamp ?? Date.now());
|
|
133
|
+
const batch = captureBatch(msg, results, currentTurnIndex, ts);
|
|
134
|
+
batches.push({
|
|
135
|
+
...batch,
|
|
136
|
+
toolCalls: batch.toolCalls.filter((tc) => readyIds.has(tc.toolCallId)),
|
|
137
|
+
// Tag with the current group so flushPending can merge by mode
|
|
138
|
+
userTurnGroup,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return batches;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Serializes a single CapturedBatch into readable text for the summarizer LLM. */
|
|
147
|
+
export function serializeBatchForSummarizer(batch: CapturedBatch): string {
|
|
148
|
+
const parts: string[] = [];
|
|
149
|
+
|
|
150
|
+
if (batch.assistantText) {
|
|
151
|
+
parts.push(`Assistant said: ${batch.assistantText}\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const toolParts = batch.toolCalls.map((tc) => {
|
|
155
|
+
const status = tc.isError ? "ERROR" : "OK";
|
|
156
|
+
const argsJson = JSON.stringify(tc.args, null, 2);
|
|
157
|
+
|
|
158
|
+
let resultText = tc.resultText;
|
|
159
|
+
const MAX_CHARS = 2000;
|
|
160
|
+
if (resultText.length > MAX_CHARS) {
|
|
161
|
+
const remaining = resultText.length - MAX_CHARS;
|
|
162
|
+
resultText = resultText.slice(0, MAX_CHARS) + ` ...[${remaining} chars truncated]`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return `Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
parts.push(toolParts.join("\n---\n"));
|
|
169
|
+
|
|
170
|
+
return parts.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Groups CapturedBatches according to the chosen batching mode.
|
|
175
|
+
*
|
|
176
|
+
* - "turn" : returns the input array unchanged (one summary per assistant turn).
|
|
177
|
+
* - "agent-message" : merges all consecutive batches that share the same `userTurnGroup`
|
|
178
|
+
* into a single CapturedBatch, producing one summary per
|
|
179
|
+
* user → final-agent-message span.
|
|
180
|
+
*
|
|
181
|
+
* Batches without a `userTurnGroup` (e.g. from the live `turn_end` capture path) are
|
|
182
|
+
* always passed through one-per-batch regardless of mode — grouping only applies to
|
|
183
|
+
* batches captured from the session branch scan.
|
|
184
|
+
*
|
|
185
|
+
* Merge rules:
|
|
186
|
+
* - `assistantText` = non-empty values joined with "\n\n"
|
|
187
|
+
* - `toolCalls` = concatenation in original order
|
|
188
|
+
* - `turnIndex` = last batch's turnIndex (latest turn in the group)
|
|
189
|
+
* - `timestamp` = last batch's timestamp
|
|
190
|
+
* - `userTurnGroup` = shared group value of the merged batches
|
|
191
|
+
*/
|
|
192
|
+
export function groupBatchesByMode(batches: CapturedBatch[], mode: BatchingMode): CapturedBatch[] {
|
|
193
|
+
if (mode !== "agent-message") return batches;
|
|
194
|
+
|
|
195
|
+
const out: CapturedBatch[] = [];
|
|
196
|
+
// current tracks the mutable merged batch being built for the current group.
|
|
197
|
+
// We spread into a plain object so we can mutate it without affecting the source.
|
|
198
|
+
let current: CapturedBatch & { userTurnGroup: number } | null = null;
|
|
199
|
+
|
|
200
|
+
for (const batch of batches) {
|
|
201
|
+
// Batches without a group key are passed through individually; they break
|
|
202
|
+
// any open merge group too since we can't confidently assign them a span.
|
|
203
|
+
if (batch.userTurnGroup === undefined) {
|
|
204
|
+
current = null;
|
|
205
|
+
out.push(batch);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (current !== null && current.userTurnGroup === batch.userTurnGroup) {
|
|
210
|
+
// Same span — merge into the current accumulated batch
|
|
211
|
+
const textParts = [current.assistantText, batch.assistantText].filter(Boolean);
|
|
212
|
+
current.assistantText = textParts.join("\n\n");
|
|
213
|
+
current.toolCalls = current.toolCalls.concat(batch.toolCalls);
|
|
214
|
+
// Advance to the latest turn metadata
|
|
215
|
+
current.turnIndex = batch.turnIndex;
|
|
216
|
+
current.timestamp = batch.timestamp;
|
|
217
|
+
} else {
|
|
218
|
+
// New group — create a fresh accumulated batch (shallow copy so mutations
|
|
219
|
+
// to `current` do not bleed back into the original `batch` object)
|
|
220
|
+
current = { ...batch, userTurnGroup: batch.userTurnGroup };
|
|
221
|
+
out.push(current);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BlockRefIssuer } from "./block-refs.js";
|
|
3
|
+
|
|
4
|
+
describe("BlockRefIssuer", () => {
|
|
5
|
+
test("first issue() returns b1", () => {
|
|
6
|
+
const issuer = new BlockRefIssuer();
|
|
7
|
+
expect(issuer.issue()).toBe("b1");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("subsequent calls are monotonic", () => {
|
|
11
|
+
const issuer = new BlockRefIssuer();
|
|
12
|
+
expect(issuer.issue()).toBe("b1");
|
|
13
|
+
expect(issuer.issue()).toBe("b2");
|
|
14
|
+
expect(issuer.issue()).toBe("b3");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("rebuildFrom([]) → next is b1", () => {
|
|
18
|
+
const issuer = new BlockRefIssuer();
|
|
19
|
+
issuer.rebuildFrom([]);
|
|
20
|
+
expect(issuer.issue()).toBe("b1");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("rebuildFrom([b1, b2]) → next is b3", () => {
|
|
24
|
+
const issuer = new BlockRefIssuer();
|
|
25
|
+
issuer.rebuildFrom(["b1", "b2"]);
|
|
26
|
+
expect(issuer.issue()).toBe("b3");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rebuildFrom gapped sequence → max+1, not gap-fill", () => {
|
|
30
|
+
const issuer = new BlockRefIssuer();
|
|
31
|
+
issuer.rebuildFrom(["b1", "b3"]);
|
|
32
|
+
expect(issuer.issue()).toBe("b4");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("rebuildFrom resets counter even after prior issues", () => {
|
|
36
|
+
const issuer = new BlockRefIssuer();
|
|
37
|
+
issuer.issue(); // b1
|
|
38
|
+
issuer.issue(); // b2
|
|
39
|
+
issuer.rebuildFrom(["b5"]);
|
|
40
|
+
expect(issuer.issue()).toBe("b6");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class BlockRefIssuer {
|
|
2
|
+
private next = 1;
|
|
3
|
+
|
|
4
|
+
issue(): string {
|
|
5
|
+
return `b${this.next++}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
rebuildFrom(existingBlockIds: string[]): void {
|
|
9
|
+
if (existingBlockIds.length === 0) {
|
|
10
|
+
this.next = 1;
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const max = Math.max(...existingBlockIds.map((id) => parseInt(id.slice(1), 10)));
|
|
14
|
+
this.next = max + 1;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./budget.js";
|
|
3
|
+
|
|
4
|
+
const usage = (tokens: number | null, contextWindow: number) =>
|
|
5
|
+
({ tokens, contextWindow, percent: null }) as any;
|
|
6
|
+
|
|
7
|
+
describe("shouldBudgetFlush", () => {
|
|
8
|
+
it("is false when threshold is null", () => {
|
|
9
|
+
expect(shouldBudgetFlush(usage(900, 1000), null)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
it("is false for non-positive or >1 thresholds", () => {
|
|
12
|
+
expect(shouldBudgetFlush(usage(900, 1000), 0)).toBe(false);
|
|
13
|
+
expect(shouldBudgetFlush(usage(900, 1000), 1.5)).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
it("is false when usage is undefined", () => {
|
|
16
|
+
expect(shouldBudgetFlush(undefined, 0.8)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
it("is false when tokens is null (post-compaction)", () => {
|
|
19
|
+
expect(shouldBudgetFlush(usage(null, 1000), 0.8)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
it("is false when contextWindow is non-positive", () => {
|
|
22
|
+
expect(shouldBudgetFlush(usage(900, 0), 0.8)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
it("is true at or over the threshold, false under", () => {
|
|
25
|
+
expect(shouldBudgetFlush(usage(800, 1000), 0.8)).toBe(true);
|
|
26
|
+
expect(shouldBudgetFlush(usage(900, 1000), 0.8)).toBe(true);
|
|
27
|
+
expect(shouldBudgetFlush(usage(799, 1000), 0.8)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("treats threshold of exactly 1.0 as valid (flush only at 100%)", () => {
|
|
31
|
+
expect(shouldBudgetFlush(usage(1000, 1000), 1)).toBe(true);
|
|
32
|
+
expect(shouldBudgetFlush(usage(999, 1000), 1)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("usageFraction", () => {
|
|
37
|
+
it("returns null for undefined / null tokens / non-positive window", () => {
|
|
38
|
+
expect(usageFraction(undefined)).toBeNull();
|
|
39
|
+
expect(usageFraction(usage(null, 1000))).toBeNull();
|
|
40
|
+
expect(usageFraction(usage(900, 0))).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
it("returns the 0–1 fraction", () => {
|
|
43
|
+
expect(usageFraction(usage(750, 1000))).toBe(0.75);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("shouldDeltaFlush", () => {
|
|
48
|
+
it("is false when delta is null, non-positive, or >1", () => {
|
|
49
|
+
expect(shouldDeltaFlush(usage(900, 1000), 0.5, null)).toBe(false);
|
|
50
|
+
expect(shouldDeltaFlush(usage(900, 1000), 0.5, 0)).toBe(false);
|
|
51
|
+
expect(shouldDeltaFlush(usage(900, 1000), 0.5, 1.5)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it("is false when previousFraction is null (first turn / post-restart)", () => {
|
|
54
|
+
expect(shouldDeltaFlush(usage(900, 1000), null, 0.15)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
it("is false when usage missing or tokens null", () => {
|
|
57
|
+
expect(shouldDeltaFlush(undefined, 0.5, 0.15)).toBe(false);
|
|
58
|
+
expect(shouldDeltaFlush(usage(null, 1000), 0.5, 0.15)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it("fires when the jump meets the delta, not below", () => {
|
|
61
|
+
expect(shouldDeltaFlush(usage(700, 1000), 0.5, 0.15)).toBe(true); // 0.20 >= 0.15
|
|
62
|
+
expect(shouldDeltaFlush(usage(650, 1000), 0.5, 0.15)).toBe(true); // 0.15 exactly
|
|
63
|
+
expect(shouldDeltaFlush(usage(640, 1000), 0.5, 0.15)).toBe(false); // 0.14 < 0.15
|
|
64
|
+
expect(shouldDeltaFlush(usage(600, 1000), 0.5, 0.15)).toBe(false); // 0.10 < 0.15
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/budget.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ContextUsage } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True iff a budget-triggered flush should fire. Computes the ratio ourselves
|
|
5
|
+
* (tokens / contextWindow, a 0–1 fraction) rather than using ContextUsage.percent
|
|
6
|
+
* (a 0–100 value, null when tokens is null). tokens is also null right after a
|
|
7
|
+
* compaction — guarded here.
|
|
8
|
+
*/
|
|
9
|
+
export function shouldBudgetFlush(
|
|
10
|
+
usage: ContextUsage | undefined,
|
|
11
|
+
threshold: number | null,
|
|
12
|
+
): boolean {
|
|
13
|
+
if (threshold == null || threshold <= 0 || threshold > 1) return false;
|
|
14
|
+
if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return false;
|
|
15
|
+
return usage.tokens / usage.contextWindow >= threshold;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 0–1 usage fraction, or null when usage is missing / tokens null / window non-positive. */
|
|
19
|
+
export function usageFraction(usage: ContextUsage | undefined): number | null {
|
|
20
|
+
if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return null;
|
|
21
|
+
return usage.tokens / usage.contextWindow;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* True iff this turn's usage fraction rose by at least `delta` versus the previous turn.
|
|
26
|
+
* Mirrors shouldBudgetFlush's guards. previousFraction === null (first turn or post-restart)
|
|
27
|
+
* never fires; the absolute autoBudgetThreshold covers that gap.
|
|
28
|
+
*/
|
|
29
|
+
export function shouldDeltaFlush(
|
|
30
|
+
usage: ContextUsage | undefined,
|
|
31
|
+
previousFraction: number | null,
|
|
32
|
+
delta: number | null,
|
|
33
|
+
): boolean {
|
|
34
|
+
if (delta == null || delta <= 0 || delta > 1) return false;
|
|
35
|
+
if (previousFraction == null) return false;
|
|
36
|
+
const current = usageFraction(usage);
|
|
37
|
+
if (current == null) return false;
|
|
38
|
+
return current - previousFraction >= delta;
|
|
39
|
+
}
|