pi-context-guard-jev 0.1.0 → 0.3.1
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/README.md +98 -253
- package/docs/CACHE.md +139 -3
- package/docs/DESIGN.md +263 -39
- package/docs/JEV.md +95 -27
- package/docs/REFERENCE.md +164 -0
- package/docs/RESULTS.md +146 -0
- package/docs/TESTING.md +116 -27
- package/package.json +2 -2
- package/src/cache-pin.ts +170 -0
- package/src/checkpoint.ts +459 -0
- package/src/config.ts +63 -2
- package/src/decide.ts +74 -0
- package/src/distill.ts +9 -2
- package/src/images.ts +89 -0
- package/src/index.ts +299 -58
- package/src/items.ts +446 -0
- package/src/process.ts +213 -0
- package/src/recall.ts +98 -6
- package/src/render.ts +2 -0
- package/src/run.ts +143 -32
- package/src/size.ts +114 -0
- package/src/stats.ts +77 -15
- package/src/types.ts +9 -0
package/src/cache-pin.ts
CHANGED
|
@@ -120,3 +120,173 @@ export function pinQuestionBreakpoint(payload: unknown): PinResult {
|
|
|
120
120
|
}
|
|
121
121
|
return "pinned";
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------------------------
|
|
125
|
+
// Read-point anchors after context edits (docs/CACHE.md → Mid-run checkpoints)
|
|
126
|
+
// ---------------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** Pi's Anthropic provider rewrites tool call ids like this (pi-ai anthropic-messages.js). */
|
|
129
|
+
export const normalizeToolUseId = (id: string): string => id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
|
|
130
|
+
|
|
131
|
+
export interface GuardBreakpointOptions {
|
|
132
|
+
/** Tool call ids that get a read anchor, highest priority first (after the question). */
|
|
133
|
+
anchors: readonly string[];
|
|
134
|
+
/** Tool call ids that get a breakpoint only to write a cache entry (lowest priority). */
|
|
135
|
+
writeAnchors?: readonly string[];
|
|
136
|
+
/** Pin the previous user question. It has the highest priority: it is the floor every edit falls back to. */
|
|
137
|
+
pinQuestion: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface GuardBreakpointResult {
|
|
141
|
+
/** Anchors placed (ids from `anchors` and `writeAnchors`). */
|
|
142
|
+
anchored: string[];
|
|
143
|
+
/** Anchors that could not be placed, with the reason. */
|
|
144
|
+
skipped: { id: string; reason: "not-found" | "in-last-message" | "already" | "over-budget" | "ttl-conflict" }[];
|
|
145
|
+
question: PinResult | "off";
|
|
146
|
+
/** Breakpoints Pi placed that were removed to make room. */
|
|
147
|
+
removed: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function findToolResult(messages: Json[], id: string, before: number): { index: number; block: Json } | undefined {
|
|
151
|
+
const ids = new Set([id, normalizeToolUseId(id)]);
|
|
152
|
+
for (let i = 0; i < messages.length; i++) {
|
|
153
|
+
const content = messages[i]?.content;
|
|
154
|
+
if (!Array.isArray(content)) continue;
|
|
155
|
+
const block = content.find((b: Json) => b?.type === "tool_result" && ids.has(b.tool_use_id));
|
|
156
|
+
if (block) return i < before ? { index: i, block } : { index: -1, block };
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Index of `target` among the cacheable blocks of the payload (prefix order). */
|
|
162
|
+
function positionOf(payload: Json, target: Json): number {
|
|
163
|
+
let n = 0;
|
|
164
|
+
let found = -1;
|
|
165
|
+
const visit = (value: unknown) => {
|
|
166
|
+
if (found >= 0) return;
|
|
167
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
168
|
+
else if (value && typeof value === "object") {
|
|
169
|
+
if (value === target) {
|
|
170
|
+
found = n;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
n++;
|
|
174
|
+
for (const [key, child] of Object.entries(value)) if (key !== "cache_control") visit(child);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
visit([payload.tools, payload.system, payload.messages]);
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** All blocks carrying cache_control, in prefix order (tools → system → messages). */
|
|
182
|
+
function breakpointBlocks(payload: Json): Json[] {
|
|
183
|
+
const out: Json[] = [];
|
|
184
|
+
const visit = (value: unknown) => {
|
|
185
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
186
|
+
else if (value && typeof value === "object") {
|
|
187
|
+
if ((value as Json).cache_control) out.push(value as Json);
|
|
188
|
+
for (const [key, child] of Object.entries(value)) if (key !== "cache_control") visit(child);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
visit([payload.tools, payload.system, payload.messages]);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Place the previous-question pin, read anchors and write anchors on an Anthropic Messages payload,
|
|
197
|
+
* in place, in that priority. Respects the 4-breakpoint limit (dropping, in this order, the redundant
|
|
198
|
+
* OAuth identity breakpoint, the tools breakpoint when the system prompt has one, then the
|
|
199
|
+
* lowest-priority guard marks) and the TTL order (anchors use the rolling breakpoint's TTL; a mark that
|
|
200
|
+
* cannot keep longer TTLs first is skipped).
|
|
201
|
+
*/
|
|
202
|
+
export function placeGuardBreakpoints(payload: unknown, options: GuardBreakpointOptions): GuardBreakpointResult {
|
|
203
|
+
const result: GuardBreakpointResult = { anchored: [], skipped: [], question: options.pinQuestion ? "no-question" : "off", removed: [] };
|
|
204
|
+
if (!payload || typeof payload !== "object") return { ...result, question: options.pinQuestion ? "not-anthropic" : "off" };
|
|
205
|
+
const body = payload as Json;
|
|
206
|
+
const messages = body.messages;
|
|
207
|
+
if (!Array.isArray(messages)) return result;
|
|
208
|
+
const rolling = lastCacheControl(messages);
|
|
209
|
+
if (!rolling) return { ...result, question: options.pinQuestion ? "no-cache" : "off" };
|
|
210
|
+
let last = messages.length - 1;
|
|
211
|
+
while (last >= 0 && messages[last]?.role === "system") last--;
|
|
212
|
+
|
|
213
|
+
// 1. Targets, highest priority first: question, read anchors, write anchors.
|
|
214
|
+
const marks: { id: string; block: Json; kind: "question" | "anchor" | "write" }[] = [];
|
|
215
|
+
if (options.pinQuestion) {
|
|
216
|
+
let target = -1;
|
|
217
|
+
for (let i = last - 1; i >= 0; i--) {
|
|
218
|
+
if (messages[i]?.role === "user" && hasText(messages[i])) {
|
|
219
|
+
target = i;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (target >= 0) {
|
|
224
|
+
const question = messages[target];
|
|
225
|
+
if (typeof question.content === "string") question.content = [{ type: "text", text: question.content }];
|
|
226
|
+
const block = question.content.findLast((b: Json) => b?.type === "text" && typeof b.text === "string" && b.text.trim());
|
|
227
|
+
if (block?.cache_control) result.question = "already";
|
|
228
|
+
else if (block) marks.push({ id: "question", block, kind: "question" });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const addAnchor = (id: string, kind: "anchor" | "write") => {
|
|
232
|
+
const found = findToolResult(messages, id, last);
|
|
233
|
+
if (!found) result.skipped.push({ id, reason: "not-found" });
|
|
234
|
+
else if (found.index < 0) result.skipped.push({ id, reason: "in-last-message" });
|
|
235
|
+
else if (found.block.cache_control || marks.some((m) => m.block === found.block)) result.skipped.push({ id, reason: "already" });
|
|
236
|
+
else marks.push({ id, block: found.block, kind });
|
|
237
|
+
};
|
|
238
|
+
for (const id of options.anchors) addAnchor(id, "anchor");
|
|
239
|
+
for (const id of options.writeAnchors ?? []) addAnchor(id, "write");
|
|
240
|
+
|
|
241
|
+
// 2. Apply with TTL order: the rolling TTL if it fits, else the other one, else skip.
|
|
242
|
+
const applied: typeof marks = [];
|
|
243
|
+
const { ttl: _rollingTtl, ...rollingRest } = rolling;
|
|
244
|
+
for (const mark of marks) {
|
|
245
|
+
const position = positionOf(body, mark.block);
|
|
246
|
+
const others = breakpointBlocks(body).filter((b) => b !== mark.block);
|
|
247
|
+
const before = others.filter((b) => positionOf(body, b) < position);
|
|
248
|
+
const after = others.filter((b) => positionOf(body, b) > position);
|
|
249
|
+
const fits = (ttl: number) => before.every((b) => ttlMinutes(b.cache_control) >= ttl) && after.every((b) => ttlMinutes(b.cache_control) <= ttl);
|
|
250
|
+
const ttl = [ttlMinutes(rolling), ttlMinutes(rolling) === 60 ? 5 : 60].find(fits);
|
|
251
|
+
if (ttl === undefined) {
|
|
252
|
+
if (mark.kind === "question") result.question = "ttl-conflict";
|
|
253
|
+
else result.skipped.push({ id: mark.id, reason: "ttl-conflict" });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
mark.block.cache_control = ttl === 60 ? { ...rollingRest, ttl: "1h" } : { ...rollingRest };
|
|
257
|
+
applied.push(mark);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 3. Budget.
|
|
261
|
+
const system = body.system;
|
|
262
|
+
if (countBreakpoints(body) > ANTHROPIC_MAX_BREAKPOINTS && Array.isArray(system) && system.length > 1 && system[0]?.cache_control && system.slice(1).some((b: Json) => b?.cache_control)) {
|
|
263
|
+
delete system[0].cache_control;
|
|
264
|
+
result.removed.push("system[0]");
|
|
265
|
+
}
|
|
266
|
+
const tools = Array.isArray(body.tools) ? body.tools : [];
|
|
267
|
+
if (countBreakpoints(body) > ANTHROPIC_MAX_BREAKPOINTS && Array.isArray(system) && system.some((b: Json) => b?.cache_control)) {
|
|
268
|
+
const toolBlock = tools.findLast((b: Json) => b?.cache_control);
|
|
269
|
+
if (toolBlock) {
|
|
270
|
+
delete toolBlock.cache_control;
|
|
271
|
+
result.removed.push("tools");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
while (countBreakpoints(body) > ANTHROPIC_MAX_BREAKPOINTS && applied.length > 0) {
|
|
275
|
+
const dropped = applied.pop() as (typeof applied)[number];
|
|
276
|
+
delete dropped.block.cache_control;
|
|
277
|
+
if (dropped.kind === "question") result.question = "over-budget";
|
|
278
|
+
else result.skipped.push({ id: dropped.id, reason: "over-budget" });
|
|
279
|
+
}
|
|
280
|
+
for (const mark of applied) {
|
|
281
|
+
if (mark.kind === "question") result.question = "pinned";
|
|
282
|
+
else result.anchored.push(mark.id);
|
|
283
|
+
}
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Tool-use ids of the tool_result blocks that carry a breakpoint in the final payload. */
|
|
288
|
+
export function markedToolResults(payload: unknown): string[] {
|
|
289
|
+
const messages = (payload as Json | undefined)?.messages;
|
|
290
|
+
if (!Array.isArray(messages)) return [];
|
|
291
|
+
return messages.flatMap((m: Json) => (Array.isArray(m?.content) ? m.content.filter((b: Json) => b?.type === "tool_result" && b.cache_control).map((b: Json) => String(b.tool_use_id)) : []));
|
|
292
|
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-run checkpoints (docs/DESIGN.md → Mid-run checkpoints).
|
|
3
|
+
*
|
|
4
|
+
* At the end of a turn that will be followed by another one, tool results of the current run
|
|
5
|
+
* that are at least `midRunMinAgeTurns` turns old and were not judged before are "eligible".
|
|
6
|
+
* When their total size reaches the batch size, all of them are judged in one checkpoint.
|
|
7
|
+
* Each output is judged mid-run at most once (outputs Jev never answered for are asked again).
|
|
8
|
+
*
|
|
9
|
+
* All state is derived from what is persisted on the active branch (our records, the context_edit
|
|
10
|
+
* entries), never kept in memory: a boundary's drafts can be replaced by a later handler or rejected
|
|
11
|
+
* as a whole, and /tree or compaction change the branch.
|
|
12
|
+
*/
|
|
13
|
+
import { RECALL_TOOL, toolLabel } from "./render.ts";
|
|
14
|
+
import { type Candidate, type CollectOptions, candidateOf, collectHistory, findRunStart, isBranchSteering, indexSpan, lastUserIndex, type RunHistory, runQuestion, type ToolCallInfo } from "./run.ts";
|
|
15
|
+
import { contentChars } from "./size.ts";
|
|
16
|
+
import type { ProjectedEntryLike } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export interface CheckpointOptions extends CollectOptions {
|
|
19
|
+
midRunMinAgeTurns: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CheckpointCandidate extends Candidate {
|
|
23
|
+
toolCallId?: string;
|
|
24
|
+
/** Turn (0-based assistant message index in the run) of the call that produced the output. */
|
|
25
|
+
turn: number;
|
|
26
|
+
/** Labels of the tool calls made after this output, oldest first (see `laterCallsFor`). */
|
|
27
|
+
laterCalls: string[];
|
|
28
|
+
/** Why the output is probably out of date, e.g. "this file was edited after this read (…)". */
|
|
29
|
+
superseded?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CheckpointInfo {
|
|
33
|
+
question: string;
|
|
34
|
+
history: RunHistory;
|
|
35
|
+
/** The latest assistant text of the run (the agent's current progress note). */
|
|
36
|
+
latest: string;
|
|
37
|
+
/** Earlier assistant text of the run, oldest first. */
|
|
38
|
+
notes: string;
|
|
39
|
+
currentTurn: number;
|
|
40
|
+
/** Eligible, not yet judged outputs. */
|
|
41
|
+
candidates: CheckpointCandidate[];
|
|
42
|
+
/** Total characters of `candidates`. */
|
|
43
|
+
pendingChars: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const LATER_CALLS_LIMIT = 30;
|
|
47
|
+
|
|
48
|
+
/** Normalize a shell command for "ran again" detection. */
|
|
49
|
+
export const normalizeCommand = (command: string): string => command.replace(/\s+/g, " ").replace(/\s*2>&1\s*$/, "").trim();
|
|
50
|
+
const normalizePath = (path: string): string => path.replace(/^\.\//, "").replace(/\/+/g, "/");
|
|
51
|
+
|
|
52
|
+
const str = (args: Record<string, unknown> | undefined, ...keys: string[]): string | undefined => {
|
|
53
|
+
for (const key of keys) if (typeof args?.[key] === "string") return args[key] as string;
|
|
54
|
+
return undefined;
|
|
55
|
+
};
|
|
56
|
+
const pathOf = (args: Record<string, unknown> | undefined) => {
|
|
57
|
+
const path = str(args, "path", "file_path", "file");
|
|
58
|
+
return path === undefined ? undefined : normalizePath(path);
|
|
59
|
+
};
|
|
60
|
+
const commandOf = (args: Record<string, unknown> | undefined) => {
|
|
61
|
+
const command = str(args, "command", "cmd");
|
|
62
|
+
return command === undefined ? undefined : normalizeCommand(command);
|
|
63
|
+
};
|
|
64
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Why an output is probably out of date: the file it read was edited or written later, the same
|
|
68
|
+
* file range was read again, or the same command (or the same search) ran again.
|
|
69
|
+
*/
|
|
70
|
+
export function supersededBy(candidate: Pick<CheckpointCandidate, "toolName" | "args" | "turn">, later: readonly ToolCallInfo[]): string | undefined {
|
|
71
|
+
const path = pathOf(candidate.args);
|
|
72
|
+
const command = commandOf(candidate.args);
|
|
73
|
+
const offset = Number(candidate.args?.offset ?? 1) || 1;
|
|
74
|
+
const limit = candidate.args?.limit;
|
|
75
|
+
for (const call of later) {
|
|
76
|
+
if (call.turn <= candidate.turn) continue;
|
|
77
|
+
const when = `${plural(call.turn - candidate.turn, "turn")} later`;
|
|
78
|
+
const label = toolLabel({ toolName: call.name, args: call.args });
|
|
79
|
+
if (candidate.toolName === "read" && path) {
|
|
80
|
+
const callPath = pathOf(call.args);
|
|
81
|
+
if ((call.name === "edit" || call.name === "write") && callPath === path) return `this file was changed after this read (${label}, ${when})`;
|
|
82
|
+
if (call.name === "read" && callPath === path && (Number(call.args?.offset ?? 1) || 1) === offset && call.args?.limit === limit) {
|
|
83
|
+
return `the same file range was read again later (${label}, ${when})`;
|
|
84
|
+
}
|
|
85
|
+
} else if (command && commandOf(call.args) === command) {
|
|
86
|
+
return `the same command ran again later (${when})`;
|
|
87
|
+
} else if (!command && call.name === candidate.toolName && JSON.stringify(call.args ?? {}) === JSON.stringify(candidate.args ?? {})) {
|
|
88
|
+
return `the same ${call.name} call ran again later (${when})`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Labels of the calls after `turn`; above the limit, the first and last halves with a gap note. */
|
|
95
|
+
export function laterCallsFor(turn: number, calls: readonly ToolCallInfo[], limit = LATER_CALLS_LIMIT): string[] {
|
|
96
|
+
const labels = calls.filter((call) => call.turn > turn).map((call) => toolLabel({ toolName: call.name, args: call.args }));
|
|
97
|
+
if (labels.length <= limit) return labels;
|
|
98
|
+
const head = Math.floor(limit / 2);
|
|
99
|
+
const tail = limit - head;
|
|
100
|
+
return [...labels.slice(0, head), `[… ${labels.length - limit} more calls …]`, ...labels.slice(-tail)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Eligible outputs for a checkpoint at the end of the current turn. `judged`: entry ids already
|
|
105
|
+
* judged mid-run (the memo). Returns undefined outside a run.
|
|
106
|
+
*/
|
|
107
|
+
export function collectCheckpoint(entries: readonly ProjectedEntryLike[], options: CheckpointOptions, judged: ReadonlySet<string>): CheckpointInfo | undefined {
|
|
108
|
+
const start = findRunStart(entries);
|
|
109
|
+
if (start < 0) return undefined;
|
|
110
|
+
const span = entries.slice(start + 1);
|
|
111
|
+
const { toolCalls, assistantTexts, turns } = indexSpan(span);
|
|
112
|
+
const currentTurn = turns - 1;
|
|
113
|
+
const calls = [...toolCalls.values()];
|
|
114
|
+
const youngest = currentTurn - options.midRunMinAgeTurns;
|
|
115
|
+
|
|
116
|
+
const candidates: CheckpointCandidate[] = [];
|
|
117
|
+
for (const entry of span) {
|
|
118
|
+
const candidate = candidateOf(entry, toolCalls, options);
|
|
119
|
+
if (!candidate || candidate.turn === undefined || judged.has(candidate.entryId)) continue;
|
|
120
|
+
// A recall output was asked for in this run: judging it again mid-run made models recall the recall.
|
|
121
|
+
if (candidate.turn > youngest || candidate.toolName === RECALL_TOOL) continue;
|
|
122
|
+
const turn = candidate.turn;
|
|
123
|
+
const superseded = supersededBy({ toolName: candidate.toolName, args: candidate.args, turn }, calls);
|
|
124
|
+
candidates.push({ ...candidate, turn, laterCalls: laterCallsFor(turn, calls), ...(superseded ? { superseded } : {}) });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const latest = assistantTexts.at(-1)?.text ?? "";
|
|
128
|
+
return {
|
|
129
|
+
question: runQuestion(entries, start),
|
|
130
|
+
history: collectHistory(entries.slice(0, start), options.historyExchanges),
|
|
131
|
+
latest,
|
|
132
|
+
notes: assistantTexts
|
|
133
|
+
.slice(0, -1)
|
|
134
|
+
.map((t) => t.text)
|
|
135
|
+
.join("\n\n"),
|
|
136
|
+
currentTurn,
|
|
137
|
+
candidates,
|
|
138
|
+
pendingChars: candidates.reduce((sum, c) => sum + c.text.length, 0),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Break-even rule (docs/CACHE.md → Mid-run checkpoints). A checkpoint makes the next request pay
|
|
144
|
+
* the full input price (OpenAI) or the cache-write price (Anthropic) once for the context it can no
|
|
145
|
+
* longer read from cache, and saves the removed characters (about 70% of the judged ones) at the
|
|
146
|
+
* cache-read price on every later request. Assuming the run goes on for about as many turns as it
|
|
147
|
+
* has had so far, it pays off when
|
|
148
|
+
* pendingChars × 0.7 × read × turnsSoFar ≥ (uncached − read) × rewriteChars,
|
|
149
|
+
* i.e. pendingChars × turnsSoFar ≥ factor × rewriteChars, with
|
|
150
|
+
* OpenAI Codex: input 10× cached → factor 0.9 / (0.1 × 0.7) ≈ 13, rewrite = the whole context
|
|
151
|
+
* (measured: the first request after an edit reads 0–2,560 tokens, wherever the edit is);
|
|
152
|
+
* Anthropic: write 1.25× vs read 0.1× of input → factor 1.15 / (0.1 × 0.7) ≈ 16, rewrite =
|
|
153
|
+
* everything after the first edited output (the read point keeps the prefix).
|
|
154
|
+
*/
|
|
155
|
+
export const BREAK_EVEN_FACTOR = { full: 13, prefix: 16 } as const;
|
|
156
|
+
|
|
157
|
+
export function paysOff(pendingChars: number, turnsSoFar: number, rewriteChars: number, factor: number): boolean {
|
|
158
|
+
return pendingChars * Math.max(1, turnsSoFar) >= factor * rewriteChars;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Size of the projected context in text characters (an estimate, see size.ts: images count by their
|
|
163
|
+
* pixels, not their base64 length).
|
|
164
|
+
*/
|
|
165
|
+
export function contextChars(entries: readonly ProjectedEntryLike[]): number {
|
|
166
|
+
let n = 0;
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
for (const message of entry.messages) {
|
|
169
|
+
if (typeof message.summary === "string") n += message.summary.length;
|
|
170
|
+
n += contentChars(message.content);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return n;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Characters from the first of `entryIds` to the end: what a checkpoint makes the cache rewrite. */
|
|
177
|
+
export function charsFrom(entries: readonly ProjectedEntryLike[], entryIds: ReadonlySet<string>): number {
|
|
178
|
+
const first = entries.findIndex((entry) => entryIds.has(entry.sourceEntry.id));
|
|
179
|
+
return first < 0 ? 0 : contextChars(entries.slice(first));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Recent notes, clipped from the start so the latest ones survive. */
|
|
183
|
+
export function recentNotes(notes: string, max: number): string {
|
|
184
|
+
if (notes.length <= max) return notes;
|
|
185
|
+
return `[…]\n${notes.slice(notes.length - max + 4)}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export type BranchEntry = {
|
|
189
|
+
type?: string;
|
|
190
|
+
id?: string;
|
|
191
|
+
customType?: string;
|
|
192
|
+
data?: unknown;
|
|
193
|
+
targetId?: string;
|
|
194
|
+
message?: { role?: string; stopReason?: string; toolCallId?: string; content?: unknown };
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const isAnswered = (entry: BranchEntry): boolean =>
|
|
198
|
+
entry.type === "message" && entry.message?.role === "assistant" && entry.message.stopReason !== "error" && entry.message.stopReason !== "aborted";
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Targets of the context edits that no model request has seen yet: the `context_edit` entries after
|
|
202
|
+
* the last assistant message on the branch (a failed or aborted response does not count, since the
|
|
203
|
+
* retry repeats the request). Cache-warming replays append no assistant message, so this stays the
|
|
204
|
+
* same until a real request after the edits has been answered.
|
|
205
|
+
*/
|
|
206
|
+
export function pendingEdits(branch: readonly BranchEntry[]): Set<string> {
|
|
207
|
+
const targets = new Set<string>();
|
|
208
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
209
|
+
const entry = branch[i];
|
|
210
|
+
if (isAnswered(entry)) break;
|
|
211
|
+
if (entry.type === "context_edit" && typeof entry.targetId === "string") targets.add(entry.targetId);
|
|
212
|
+
}
|
|
213
|
+
return targets;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A cache entry this process wrote: a breakpoint it saw in a request it sent (see `cacheAnchors`). */
|
|
217
|
+
export interface WrittenEntry {
|
|
218
|
+
/** Session entry id of the tool result that ended the prefix. */
|
|
219
|
+
entryId: string;
|
|
220
|
+
/** `provider/id` of the model the request went to. */
|
|
221
|
+
model: string;
|
|
222
|
+
/** When the request was sent (ms). */
|
|
223
|
+
time: number;
|
|
224
|
+
/** Id of the last branch entry when the request was sent. */
|
|
225
|
+
leafId: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Anthropic's default cache TTL. */
|
|
229
|
+
export const CACHE_TTL_MS = 5 * 60_000;
|
|
230
|
+
|
|
231
|
+
export interface CacheAnchors {
|
|
232
|
+
/** Tool call id to read up to: an entry this process wrote, still valid, before the first edit. */
|
|
233
|
+
read?: string;
|
|
234
|
+
/** Tool call id that ends the first edited tool-result batch (writes an entry with the new content). */
|
|
235
|
+
write?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Anthropic anchors for the request about to be sent. Only with pending edits (see `pendingEdits`);
|
|
240
|
+
* the previous-question pin is always placed too and is the floor.
|
|
241
|
+
*
|
|
242
|
+
* - `read`: the latest entry in `log` (breakpoints this process saw in requests it sent) that the same
|
|
243
|
+
* model wrote within the TTL, that is still on the branch, that ends with a tool result before the
|
|
244
|
+
* first edited entry, and whose prefix nothing has touched since: no `context_edit` at or before it
|
|
245
|
+
* and no compaction or branch summary on the branch after the request that wrote it.
|
|
246
|
+
* - `write`: the end of the tool-result batch holding the first edited entry, so that prefix gets an entry.
|
|
247
|
+
*/
|
|
248
|
+
/** Branch index of every entry id. */
|
|
249
|
+
const positionsOf = (branch: readonly BranchEntry[]): Map<string, number> => {
|
|
250
|
+
const position = new Map<string, number>();
|
|
251
|
+
branch.forEach((entry, i) => {
|
|
252
|
+
if (entry.id) position.set(entry.id, i);
|
|
253
|
+
});
|
|
254
|
+
return position;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* True when a logged entry's prefix is still what was cached: the entry and the leaf at write time are on
|
|
259
|
+
* the branch, and since then no `context_edit` touched anything at or before the entry and no compaction or
|
|
260
|
+
* branch summary was appended.
|
|
261
|
+
*/
|
|
262
|
+
function unchangedSince(branch: readonly BranchEntry[], position: ReadonlyMap<string, number>, w: WrittenEntry): boolean {
|
|
263
|
+
const pos = position.get(w.entryId);
|
|
264
|
+
const since = position.get(w.leafId);
|
|
265
|
+
if (pos === undefined || since === undefined) return false;
|
|
266
|
+
for (let j = since + 1; j < branch.length; j++) {
|
|
267
|
+
const e = branch[j];
|
|
268
|
+
if (e.type === "compaction" || e.type === "branch_summary") return false;
|
|
269
|
+
if (e.type === "context_edit" && (position.get(e.targetId ?? "") ?? -1) <= pos) return false;
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The latest trusted cache entry that ends with a tool result before branch index `before`: written (or
|
|
276
|
+
* refreshed) by the same model within the TTL, with its prefix unchanged since. Returns its entry.
|
|
277
|
+
*/
|
|
278
|
+
export function trustedReadPoint(branch: readonly BranchEntry[], log: readonly WrittenEntry[], model: string, now: number, before: number, ttlMs = CACHE_TTL_MS): BranchEntry | undefined {
|
|
279
|
+
const position = positionsOf(branch);
|
|
280
|
+
let read: BranchEntry | undefined;
|
|
281
|
+
let readPos = -1;
|
|
282
|
+
for (const w of log) {
|
|
283
|
+
if (w.model !== model || now - w.time > ttlMs) continue;
|
|
284
|
+
const pos = position.get(w.entryId);
|
|
285
|
+
if (pos === undefined || pos >= before || pos <= readPos) continue;
|
|
286
|
+
if (branch[pos].message?.role !== "toolResult" || !unchangedSince(branch, position, w)) continue;
|
|
287
|
+
read = branch[pos];
|
|
288
|
+
readPos = pos;
|
|
289
|
+
}
|
|
290
|
+
return read;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function cacheAnchors(branch: readonly BranchEntry[], log: readonly WrittenEntry[], model: string, now: number, ttlMs = CACHE_TTL_MS): CacheAnchors {
|
|
294
|
+
const pending = pendingEdits(branch);
|
|
295
|
+
if (pending.size === 0) return {};
|
|
296
|
+
const position = positionsOf(branch);
|
|
297
|
+
let first = Number.POSITIVE_INFINITY;
|
|
298
|
+
for (const id of pending) first = Math.min(first, position.get(id) ?? Number.POSITIVE_INFINITY);
|
|
299
|
+
if (!Number.isFinite(first)) return {};
|
|
300
|
+
const read = trustedReadPoint(branch, log, model, now, first, ttlMs);
|
|
301
|
+
|
|
302
|
+
let write: BranchEntry | undefined;
|
|
303
|
+
if (branch[first]?.message?.role === "toolResult") {
|
|
304
|
+
let end = first;
|
|
305
|
+
for (let j = first + 1; j < branch.length; j++) {
|
|
306
|
+
const e = branch[j];
|
|
307
|
+
if (e.type !== "message") continue;
|
|
308
|
+
if (e.message?.role !== "toolResult") break;
|
|
309
|
+
end = j;
|
|
310
|
+
}
|
|
311
|
+
write = branch[end];
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
...(read?.message?.toolCallId ? { read: read.message.toolCallId } : {}),
|
|
315
|
+
...(write?.message?.toolCallId ? { write: write.message.toolCallId } : {}),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Characters per token assumed when checking that a cache read went through an entry. Code and logs run
|
|
321
|
+
* at about 2.3 characters per token and prose at about 4 (docs/JEV.md), so dividing by 1.5 overestimates
|
|
322
|
+
* the tokens of the prefix: a refresh needs a read clearly beyond the entry.
|
|
323
|
+
*/
|
|
324
|
+
export const REFRESH_CHARS_PER_TOKEN = 1.5;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Refresh logged entries that a request read through (docs/CACHE.md: reading a longer prefix keeps the
|
|
328
|
+
* entries on its path alive). For the response to the request sent at `requestTime` to `model` with
|
|
329
|
+
* `cacheRead` tokens read: every entry of the same model whose prefix is unchanged and lies within the
|
|
330
|
+
* part that was read gets `time = requestTime`. "Within" is conservative: cacheRead must reach `baseline`
|
|
331
|
+
* (the system prompt, tools and question: the full input of the run's first request) plus the projected
|
|
332
|
+
* characters from the question to the entry divided by REFRESH_CHARS_PER_TOKEN. Returns the refreshed ids.
|
|
333
|
+
*/
|
|
334
|
+
export function refreshOnReadThrough(
|
|
335
|
+
projection: readonly ProjectedEntryLike[],
|
|
336
|
+
branch: readonly BranchEntry[],
|
|
337
|
+
log: WrittenEntry[],
|
|
338
|
+
model: string,
|
|
339
|
+
requestTime: number,
|
|
340
|
+
cacheRead: number,
|
|
341
|
+
baseline: number,
|
|
342
|
+
): string[] {
|
|
343
|
+
if (!(cacheRead > 0) || !(baseline > 0) || cacheRead <= baseline) return [];
|
|
344
|
+
// The baseline is measured from the last user message (prompt or steering message, see runBaseline).
|
|
345
|
+
const start = lastUserIndex(projection);
|
|
346
|
+
if (start < 0) return [];
|
|
347
|
+
const position = positionsOf(branch);
|
|
348
|
+
const refreshed: string[] = [];
|
|
349
|
+
for (const w of log) {
|
|
350
|
+
if (w.model !== model || w.time >= requestTime || !unchangedSince(branch, position, w)) continue;
|
|
351
|
+
const index = projection.findIndex((entry) => entry.sourceEntry.id === w.entryId);
|
|
352
|
+
if (index <= start) continue;
|
|
353
|
+
const prefixTokens = baseline + contextChars(projection.slice(start + 1, index + 1)) / REFRESH_CHARS_PER_TOKEN;
|
|
354
|
+
if (cacheRead < prefixTokens) continue;
|
|
355
|
+
w.time = requestTime;
|
|
356
|
+
refreshed.push(w.entryId);
|
|
357
|
+
}
|
|
358
|
+
return refreshed;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Full input tokens (input + cacheRead + cacheWrite) of the first answered request after the run's user
|
|
363
|
+
* message: the system prompt, tools and question. Undefined until that response is on the branch.
|
|
364
|
+
*/
|
|
365
|
+
export function runBaseline(branch: readonly (BranchEntry & { message?: { usage?: { input?: number; cacheRead?: number; cacheWrite?: number } } })[]): number | undefined {
|
|
366
|
+
let start = branch.length - 1;
|
|
367
|
+
while (start >= 0 && !(branch[start].type === "message" && branch[start].message?.role === "user")) start--;
|
|
368
|
+
if (start < 0) return undefined;
|
|
369
|
+
for (let i = start + 1; i < branch.length; i++) {
|
|
370
|
+
const m = branch[i];
|
|
371
|
+
if (m.type === "message" && m.message?.role === "assistant") {
|
|
372
|
+
const u = m.message.usage;
|
|
373
|
+
return u ? (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0) : undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Characters an Anthropic request after a checkpoint would write to the cache again: everything after
|
|
381
|
+
* the read point it would get (the latest trusted entry before the first of `candidateIds`, as
|
|
382
|
+
* `cacheAnchors` would pick it), or, without one, everything after the run's question (the question pin
|
|
383
|
+
* is the floor).
|
|
384
|
+
*/
|
|
385
|
+
export function anthropicRewriteChars(
|
|
386
|
+
projection: readonly ProjectedEntryLike[],
|
|
387
|
+
branch: readonly BranchEntry[],
|
|
388
|
+
log: readonly WrittenEntry[],
|
|
389
|
+
model: string,
|
|
390
|
+
now: number,
|
|
391
|
+
candidateIds: ReadonlySet<string>,
|
|
392
|
+
): number {
|
|
393
|
+
const position = positionsOf(branch);
|
|
394
|
+
let first = Number.POSITIVE_INFINITY;
|
|
395
|
+
for (const id of candidateIds) first = Math.min(first, position.get(id) ?? Number.POSITIVE_INFINITY);
|
|
396
|
+
const read = Number.isFinite(first) ? trustedReadPoint(branch, log, model, now, first) : undefined;
|
|
397
|
+
const readIndex = read ? projection.findIndex((entry) => entry.sourceEntry.id === read.id) : -1;
|
|
398
|
+
if (readIndex >= 0) return contextChars(projection.slice(readIndex + 1));
|
|
399
|
+
// The question pin (on the last user message with text: after steering, the steering message) can be
|
|
400
|
+
// read only if every edit comes after it (an omitted old exchange, or an output from before a steering
|
|
401
|
+
// message, comes before).
|
|
402
|
+
const pin = lastUserIndex(projection);
|
|
403
|
+
const earliest = projection.findIndex((entry) => candidateIds.has(entry.sourceEntry.id));
|
|
404
|
+
const from = earliest >= 0 && earliest <= pin ? 0 : pin + 1;
|
|
405
|
+
return contextChars(projection.slice(Math.max(0, from)));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** A result counts as judged unless Jev never answered for it (timeout, error): those are asked again. */
|
|
409
|
+
export const wasJudged = (result: { reason?: string }): boolean => !/timeout|error|aborted/.test(result.reason ?? "");
|
|
410
|
+
|
|
411
|
+
/** The memo: entry ids judged at a mid-run checkpoint, from our records persisted on the branch. */
|
|
412
|
+
export function memoFromBranch(branch: readonly BranchEntry[], customType: string): Set<string> {
|
|
413
|
+
const judged = new Set<string>();
|
|
414
|
+
for (const entry of branch) {
|
|
415
|
+
if (entry.type !== "custom" || entry.customType !== customType) continue;
|
|
416
|
+
const data = entry.data as { v?: number; phase?: string; results?: { entryId?: string; reason?: string; kind?: string }[] } | undefined;
|
|
417
|
+
if (data?.v !== 1 || data.phase !== "mid-run") continue;
|
|
418
|
+
for (const result of data.results ?? []) {
|
|
419
|
+
if (typeof result.entryId === "string" && result.kind !== "exchange" && wasJudged(result)) judged.add(result.entryId);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return judged;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Index of the branch entry with the user message that started the current run (steering messages
|
|
427
|
+
* skipped, as in run.ts → findRunStart), or -1.
|
|
428
|
+
*/
|
|
429
|
+
export function branchRunStart(branch: readonly BranchEntry[]): number {
|
|
430
|
+
const isUser = (e: BranchEntry) => e.type === "message" && e.message?.role === "user";
|
|
431
|
+
let start = branch.length - 1;
|
|
432
|
+
while (start >= 0 && !isUser(branch[start])) start--;
|
|
433
|
+
while (start > 0 && isBranchSteering(branch, start)) {
|
|
434
|
+
let p = start - 1;
|
|
435
|
+
while (p >= 0 && !isUser(branch[p])) p--;
|
|
436
|
+
if (p < 0) break;
|
|
437
|
+
start = p;
|
|
438
|
+
}
|
|
439
|
+
return start;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Old exchanges judged during the current run (records of any phase after the user message that started
|
|
444
|
+
* the run on the branch). An exchange is judged again in a later run, against that run's work.
|
|
445
|
+
*/
|
|
446
|
+
export function exchangeMemo(branch: readonly BranchEntry[], customType: string): Set<string> {
|
|
447
|
+
const start = branchRunStart(branch);
|
|
448
|
+
const judged = new Set<string>();
|
|
449
|
+
for (const entry of branch.slice(start + 1)) {
|
|
450
|
+
if (entry.type !== "custom" || entry.customType !== customType) continue;
|
|
451
|
+
const data = entry.data as { v?: number; results?: { entryId?: string; reason?: string; kind?: string }[] } | undefined;
|
|
452
|
+
if (data?.v !== 1) continue;
|
|
453
|
+
for (const result of data.results ?? []) {
|
|
454
|
+
// A deferred omission (break-even) stays eligible: the exchange is judged again at a later pass.
|
|
455
|
+
if (result.kind === "exchange" && typeof result.entryId === "string" && wasJudged(result) && result.reason !== "deferred") judged.add(result.entryId);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return judged;
|
|
459
|
+
}
|