pi-context-guard-jev 0.1.0 → 0.2.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.
@@ -0,0 +1,436 @@
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 { toolLabel } from "./render.ts";
14
+ import { type Candidate, type CollectOptions, candidateOf, collectHistory, findRunStart, indexSpan, questionAt, type RunHistory, type ToolCallInfo } from "./run.ts";
15
+ import type { ProjectedEntryLike } from "./types.ts";
16
+
17
+ export interface CheckpointOptions extends CollectOptions {
18
+ midRunMinAgeTurns: number;
19
+ }
20
+
21
+ export interface CheckpointCandidate extends Candidate {
22
+ toolCallId?: string;
23
+ /** Turn (0-based assistant message index in the run) of the call that produced the output. */
24
+ turn: number;
25
+ /** Labels of the tool calls made after this output, oldest first (see `laterCallsFor`). */
26
+ laterCalls: string[];
27
+ /** Why the output is probably out of date, e.g. "this file was edited after this read (…)". */
28
+ superseded?: string;
29
+ }
30
+
31
+ export interface CheckpointInfo {
32
+ question: string;
33
+ history: RunHistory;
34
+ /** The latest assistant text of the run (the agent's current progress note). */
35
+ latest: string;
36
+ /** Earlier assistant text of the run, oldest first. */
37
+ notes: string;
38
+ currentTurn: number;
39
+ /** Eligible, not yet judged outputs. */
40
+ candidates: CheckpointCandidate[];
41
+ /** Total characters of `candidates`. */
42
+ pendingChars: number;
43
+ }
44
+
45
+ export const LATER_CALLS_LIMIT = 30;
46
+
47
+ /** Normalize a shell command for "ran again" detection. */
48
+ export const normalizeCommand = (command: string): string => command.replace(/\s+/g, " ").replace(/\s*2>&1\s*$/, "").trim();
49
+ const normalizePath = (path: string): string => path.replace(/^\.\//, "").replace(/\/+/g, "/");
50
+
51
+ const str = (args: Record<string, unknown> | undefined, ...keys: string[]): string | undefined => {
52
+ for (const key of keys) if (typeof args?.[key] === "string") return args[key] as string;
53
+ return undefined;
54
+ };
55
+ const pathOf = (args: Record<string, unknown> | undefined) => {
56
+ const path = str(args, "path", "file_path", "file");
57
+ return path === undefined ? undefined : normalizePath(path);
58
+ };
59
+ const commandOf = (args: Record<string, unknown> | undefined) => {
60
+ const command = str(args, "command", "cmd");
61
+ return command === undefined ? undefined : normalizeCommand(command);
62
+ };
63
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
64
+
65
+ /**
66
+ * Why an output is probably out of date: the file it read was edited or written later, the same
67
+ * file range was read again, or the same command (or the same search) ran again.
68
+ */
69
+ export function supersededBy(candidate: Pick<CheckpointCandidate, "toolName" | "args" | "turn">, later: readonly ToolCallInfo[]): string | undefined {
70
+ const path = pathOf(candidate.args);
71
+ const command = commandOf(candidate.args);
72
+ const offset = Number(candidate.args?.offset ?? 1) || 1;
73
+ const limit = candidate.args?.limit;
74
+ for (const call of later) {
75
+ if (call.turn <= candidate.turn) continue;
76
+ const when = `${plural(call.turn - candidate.turn, "turn")} later`;
77
+ const label = toolLabel({ toolName: call.name, args: call.args });
78
+ if (candidate.toolName === "read" && path) {
79
+ const callPath = pathOf(call.args);
80
+ if ((call.name === "edit" || call.name === "write") && callPath === path) return `this file was changed after this read (${label}, ${when})`;
81
+ if (call.name === "read" && callPath === path && (Number(call.args?.offset ?? 1) || 1) === offset && call.args?.limit === limit) {
82
+ return `the same file range was read again later (${label}, ${when})`;
83
+ }
84
+ } else if (command && commandOf(call.args) === command) {
85
+ return `the same command ran again later (${when})`;
86
+ } else if (!command && call.name === candidate.toolName && JSON.stringify(call.args ?? {}) === JSON.stringify(candidate.args ?? {})) {
87
+ return `the same ${call.name} call ran again later (${when})`;
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /** Labels of the calls after `turn`; above the limit, the first and last halves with a gap note. */
94
+ export function laterCallsFor(turn: number, calls: readonly ToolCallInfo[], limit = LATER_CALLS_LIMIT): string[] {
95
+ const labels = calls.filter((call) => call.turn > turn).map((call) => toolLabel({ toolName: call.name, args: call.args }));
96
+ if (labels.length <= limit) return labels;
97
+ const head = Math.floor(limit / 2);
98
+ const tail = limit - head;
99
+ return [...labels.slice(0, head), `[… ${labels.length - limit} more calls …]`, ...labels.slice(-tail)];
100
+ }
101
+
102
+ /**
103
+ * Eligible outputs for a checkpoint at the end of the current turn. `judged`: entry ids already
104
+ * judged mid-run (the memo). Returns undefined outside a run.
105
+ */
106
+ export function collectCheckpoint(entries: readonly ProjectedEntryLike[], options: CheckpointOptions, judged: ReadonlySet<string>): CheckpointInfo | undefined {
107
+ const start = findRunStart(entries);
108
+ if (start < 0) return undefined;
109
+ const span = entries.slice(start + 1);
110
+ const { toolCalls, assistantTexts, turns } = indexSpan(span);
111
+ const currentTurn = turns - 1;
112
+ const calls = [...toolCalls.values()];
113
+ const youngest = currentTurn - options.midRunMinAgeTurns;
114
+
115
+ const candidates: CheckpointCandidate[] = [];
116
+ for (const entry of span) {
117
+ const candidate = candidateOf(entry, toolCalls, options);
118
+ if (!candidate || candidate.turn === undefined || judged.has(candidate.entryId)) continue;
119
+ if (candidate.turn > youngest) continue;
120
+ const turn = candidate.turn;
121
+ const superseded = supersededBy({ toolName: candidate.toolName, args: candidate.args, turn }, calls);
122
+ candidates.push({ ...candidate, turn, laterCalls: laterCallsFor(turn, calls), ...(superseded ? { superseded } : {}) });
123
+ }
124
+
125
+ const latest = assistantTexts.at(-1)?.text ?? "";
126
+ return {
127
+ question: questionAt(entries, start),
128
+ history: collectHistory(entries.slice(0, start), options.historyExchanges),
129
+ latest,
130
+ notes: assistantTexts
131
+ .slice(0, -1)
132
+ .map((t) => t.text)
133
+ .join("\n\n"),
134
+ currentTurn,
135
+ candidates,
136
+ pendingChars: candidates.reduce((sum, c) => sum + c.text.length, 0),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Break-even rule (docs/CACHE.md → Mid-run checkpoints). A checkpoint makes the next request pay
142
+ * the full input price (OpenAI) or the cache-write price (Anthropic) once for the context it can no
143
+ * longer read from cache, and saves the removed characters (about 70% of the judged ones) at the
144
+ * cache-read price on every later request. Assuming the run goes on for about as many turns as it
145
+ * has had so far, it pays off when
146
+ * pendingChars × 0.7 × read × turnsSoFar ≥ (uncached − read) × rewriteChars,
147
+ * i.e. pendingChars × turnsSoFar ≥ factor × rewriteChars, with
148
+ * OpenAI Codex: input 10× cached → factor 0.9 / (0.1 × 0.7) ≈ 13, rewrite = the whole context
149
+ * (measured: the first request after an edit reads 0–2,560 tokens, wherever the edit is);
150
+ * Anthropic: write 1.25× vs read 0.1× of input → factor 1.15 / (0.1 × 0.7) ≈ 16, rewrite =
151
+ * everything after the first edited output (the read point keeps the prefix).
152
+ */
153
+ export const BREAK_EVEN_FACTOR = { full: 13, prefix: 16 } as const;
154
+
155
+ export function paysOff(pendingChars: number, turnsSoFar: number, rewriteChars: number, factor: number): boolean {
156
+ return pendingChars * Math.max(1, turnsSoFar) >= factor * rewriteChars;
157
+ }
158
+
159
+ /** Characters of model-visible text in the projected context (an estimate of its size). */
160
+ export function contextChars(entries: readonly ProjectedEntryLike[]): number {
161
+ let n = 0;
162
+ for (const entry of entries) {
163
+ for (const message of entry.messages) {
164
+ if (typeof message.summary === "string") n += message.summary.length;
165
+ if (typeof message.content === "string") n += message.content.length;
166
+ else for (const block of message.content ?? []) n += typeof block.text === "string" ? block.text.length : JSON.stringify(block).length;
167
+ }
168
+ }
169
+ return n;
170
+ }
171
+
172
+ /** Characters from the first of `entryIds` to the end: what a checkpoint makes the cache rewrite. */
173
+ export function charsFrom(entries: readonly ProjectedEntryLike[], entryIds: ReadonlySet<string>): number {
174
+ const first = entries.findIndex((entry) => entryIds.has(entry.sourceEntry.id));
175
+ return first < 0 ? 0 : contextChars(entries.slice(first));
176
+ }
177
+
178
+ /** Recent notes, clipped from the start so the latest ones survive. */
179
+ export function recentNotes(notes: string, max: number): string {
180
+ if (notes.length <= max) return notes;
181
+ return `[…]\n${notes.slice(notes.length - max + 4)}`;
182
+ }
183
+
184
+ export type BranchEntry = {
185
+ type?: string;
186
+ id?: string;
187
+ customType?: string;
188
+ data?: unknown;
189
+ targetId?: string;
190
+ message?: { role?: string; stopReason?: string; toolCallId?: string };
191
+ };
192
+
193
+ const isAnswered = (entry: BranchEntry): boolean =>
194
+ entry.type === "message" && entry.message?.role === "assistant" && entry.message.stopReason !== "error" && entry.message.stopReason !== "aborted";
195
+
196
+ /**
197
+ * Targets of the context edits that no model request has seen yet: the `context_edit` entries after
198
+ * the last assistant message on the branch (a failed or aborted response does not count, since the
199
+ * retry repeats the request). Cache-warming replays append no assistant message, so this stays the
200
+ * same until a real request after the edits has been answered.
201
+ */
202
+ export function pendingEdits(branch: readonly BranchEntry[]): Set<string> {
203
+ const targets = new Set<string>();
204
+ for (let i = branch.length - 1; i >= 0; i--) {
205
+ const entry = branch[i];
206
+ if (isAnswered(entry)) break;
207
+ if (entry.type === "context_edit" && typeof entry.targetId === "string") targets.add(entry.targetId);
208
+ }
209
+ return targets;
210
+ }
211
+
212
+ /** A cache entry this process wrote: a breakpoint it saw in a request it sent (see `cacheAnchors`). */
213
+ export interface WrittenEntry {
214
+ /** Session entry id of the tool result that ended the prefix. */
215
+ entryId: string;
216
+ /** `provider/id` of the model the request went to. */
217
+ model: string;
218
+ /** When the request was sent (ms). */
219
+ time: number;
220
+ /** Id of the last branch entry when the request was sent. */
221
+ leafId: string;
222
+ }
223
+
224
+ /** Anthropic's default cache TTL. */
225
+ export const CACHE_TTL_MS = 5 * 60_000;
226
+
227
+ export interface CacheAnchors {
228
+ /** Tool call id to read up to: an entry this process wrote, still valid, before the first edit. */
229
+ read?: string;
230
+ /** Tool call id that ends the first edited tool-result batch (writes an entry with the new content). */
231
+ write?: string;
232
+ }
233
+
234
+ /**
235
+ * Anthropic anchors for the request about to be sent. Only with pending edits (see `pendingEdits`);
236
+ * the previous-question pin is always placed too and is the floor.
237
+ *
238
+ * - `read`: the latest entry in `log` (breakpoints this process saw in requests it sent) that the same
239
+ * model wrote within the TTL, that is still on the branch, that ends with a tool result before the
240
+ * first edited entry, and whose prefix nothing has touched since: no `context_edit` at or before it
241
+ * and no compaction or branch summary on the branch after the request that wrote it.
242
+ * - `write`: the end of the tool-result batch holding the first edited entry, so that prefix gets an entry.
243
+ */
244
+ /** Branch index of every entry id. */
245
+ const positionsOf = (branch: readonly BranchEntry[]): Map<string, number> => {
246
+ const position = new Map<string, number>();
247
+ branch.forEach((entry, i) => {
248
+ if (entry.id) position.set(entry.id, i);
249
+ });
250
+ return position;
251
+ };
252
+
253
+ /**
254
+ * True when a logged entry's prefix is still what was cached: the entry and the leaf at write time are on
255
+ * the branch, and since then no `context_edit` touched anything at or before the entry and no compaction or
256
+ * branch summary was appended.
257
+ */
258
+ function unchangedSince(branch: readonly BranchEntry[], position: ReadonlyMap<string, number>, w: WrittenEntry): boolean {
259
+ const pos = position.get(w.entryId);
260
+ const since = position.get(w.leafId);
261
+ if (pos === undefined || since === undefined) return false;
262
+ for (let j = since + 1; j < branch.length; j++) {
263
+ const e = branch[j];
264
+ if (e.type === "compaction" || e.type === "branch_summary") return false;
265
+ if (e.type === "context_edit" && (position.get(e.targetId ?? "") ?? -1) <= pos) return false;
266
+ }
267
+ return true;
268
+ }
269
+
270
+ /**
271
+ * The latest trusted cache entry that ends with a tool result before branch index `before`: written (or
272
+ * refreshed) by the same model within the TTL, with its prefix unchanged since. Returns its entry.
273
+ */
274
+ export function trustedReadPoint(branch: readonly BranchEntry[], log: readonly WrittenEntry[], model: string, now: number, before: number, ttlMs = CACHE_TTL_MS): BranchEntry | undefined {
275
+ const position = positionsOf(branch);
276
+ let read: BranchEntry | undefined;
277
+ let readPos = -1;
278
+ for (const w of log) {
279
+ if (w.model !== model || now - w.time > ttlMs) continue;
280
+ const pos = position.get(w.entryId);
281
+ if (pos === undefined || pos >= before || pos <= readPos) continue;
282
+ if (branch[pos].message?.role !== "toolResult" || !unchangedSince(branch, position, w)) continue;
283
+ read = branch[pos];
284
+ readPos = pos;
285
+ }
286
+ return read;
287
+ }
288
+
289
+ export function cacheAnchors(branch: readonly BranchEntry[], log: readonly WrittenEntry[], model: string, now: number, ttlMs = CACHE_TTL_MS): CacheAnchors {
290
+ const pending = pendingEdits(branch);
291
+ if (pending.size === 0) return {};
292
+ const position = positionsOf(branch);
293
+ let first = Number.POSITIVE_INFINITY;
294
+ for (const id of pending) first = Math.min(first, position.get(id) ?? Number.POSITIVE_INFINITY);
295
+ if (!Number.isFinite(first)) return {};
296
+ const read = trustedReadPoint(branch, log, model, now, first, ttlMs);
297
+
298
+ let write: BranchEntry | undefined;
299
+ if (branch[first]?.message?.role === "toolResult") {
300
+ let end = first;
301
+ for (let j = first + 1; j < branch.length; j++) {
302
+ const e = branch[j];
303
+ if (e.type !== "message") continue;
304
+ if (e.message?.role !== "toolResult") break;
305
+ end = j;
306
+ }
307
+ write = branch[end];
308
+ }
309
+ return {
310
+ ...(read?.message?.toolCallId ? { read: read.message.toolCallId } : {}),
311
+ ...(write?.message?.toolCallId ? { write: write.message.toolCallId } : {}),
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Characters per token assumed when checking that a cache read went through an entry. Code and logs run
317
+ * at about 2.3 characters per token and prose at about 4 (docs/JEV.md), so dividing by 1.5 overestimates
318
+ * the tokens of the prefix: a refresh needs a read clearly beyond the entry.
319
+ */
320
+ export const REFRESH_CHARS_PER_TOKEN = 1.5;
321
+
322
+ /**
323
+ * Refresh logged entries that a request read through (docs/CACHE.md: reading a longer prefix keeps the
324
+ * entries on its path alive). For the response to the request sent at `requestTime` to `model` with
325
+ * `cacheRead` tokens read: every entry of the same model whose prefix is unchanged and lies within the
326
+ * part that was read gets `time = requestTime`. "Within" is conservative: cacheRead must reach `baseline`
327
+ * (the system prompt, tools and question: the full input of the run's first request) plus the projected
328
+ * characters from the question to the entry divided by REFRESH_CHARS_PER_TOKEN. Returns the refreshed ids.
329
+ */
330
+ export function refreshOnReadThrough(
331
+ projection: readonly ProjectedEntryLike[],
332
+ branch: readonly BranchEntry[],
333
+ log: WrittenEntry[],
334
+ model: string,
335
+ requestTime: number,
336
+ cacheRead: number,
337
+ baseline: number,
338
+ ): string[] {
339
+ if (!(cacheRead > 0) || !(baseline > 0) || cacheRead <= baseline) return [];
340
+ const start = findRunStart(projection);
341
+ if (start < 0) return [];
342
+ const position = positionsOf(branch);
343
+ const refreshed: string[] = [];
344
+ for (const w of log) {
345
+ if (w.model !== model || w.time >= requestTime || !unchangedSince(branch, position, w)) continue;
346
+ const index = projection.findIndex((entry) => entry.sourceEntry.id === w.entryId);
347
+ if (index <= start) continue;
348
+ const prefixTokens = baseline + contextChars(projection.slice(start + 1, index + 1)) / REFRESH_CHARS_PER_TOKEN;
349
+ if (cacheRead < prefixTokens) continue;
350
+ w.time = requestTime;
351
+ refreshed.push(w.entryId);
352
+ }
353
+ return refreshed;
354
+ }
355
+
356
+ /**
357
+ * Full input tokens (input + cacheRead + cacheWrite) of the first answered request after the run's user
358
+ * message: the system prompt, tools and question. Undefined until that response is on the branch.
359
+ */
360
+ export function runBaseline(branch: readonly (BranchEntry & { message?: { usage?: { input?: number; cacheRead?: number; cacheWrite?: number } } })[]): number | undefined {
361
+ let start = branch.length - 1;
362
+ while (start >= 0 && !(branch[start].type === "message" && branch[start].message?.role === "user")) start--;
363
+ if (start < 0) return undefined;
364
+ for (let i = start + 1; i < branch.length; i++) {
365
+ const m = branch[i];
366
+ if (m.type === "message" && m.message?.role === "assistant") {
367
+ const u = m.message.usage;
368
+ return u ? (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0) : undefined;
369
+ }
370
+ }
371
+ return undefined;
372
+ }
373
+
374
+ /**
375
+ * Characters an Anthropic request after a checkpoint would write to the cache again: everything after
376
+ * the read point it would get (the latest trusted entry before the first of `candidateIds`, as
377
+ * `cacheAnchors` would pick it), or, without one, everything after the run's question (the question pin
378
+ * is the floor).
379
+ */
380
+ export function anthropicRewriteChars(
381
+ projection: readonly ProjectedEntryLike[],
382
+ branch: readonly BranchEntry[],
383
+ log: readonly WrittenEntry[],
384
+ model: string,
385
+ now: number,
386
+ candidateIds: ReadonlySet<string>,
387
+ ): number {
388
+ const position = positionsOf(branch);
389
+ let first = Number.POSITIVE_INFINITY;
390
+ for (const id of candidateIds) first = Math.min(first, position.get(id) ?? Number.POSITIVE_INFINITY);
391
+ const read = Number.isFinite(first) ? trustedReadPoint(branch, log, model, now, first) : undefined;
392
+ const readIndex = read ? projection.findIndex((entry) => entry.sourceEntry.id === read.id) : -1;
393
+ if (readIndex >= 0) return contextChars(projection.slice(readIndex + 1));
394
+ // The question pin can be read only if every edit comes after it (an omitted old exchange comes before).
395
+ const runStart = findRunStart(projection);
396
+ const earliest = projection.findIndex((entry) => candidateIds.has(entry.sourceEntry.id));
397
+ const from = earliest >= 0 && earliest <= runStart ? 0 : runStart + 1;
398
+ return contextChars(projection.slice(Math.max(0, from)));
399
+ }
400
+
401
+ /** A result counts as judged unless Jev never answered for it (timeout, error): those are asked again. */
402
+ export const wasJudged = (result: { reason?: string }): boolean => !/timeout|error|aborted/.test(result.reason ?? "");
403
+
404
+ /** The memo: entry ids judged at a mid-run checkpoint, from our records persisted on the branch. */
405
+ export function memoFromBranch(branch: readonly BranchEntry[], customType: string): Set<string> {
406
+ const judged = new Set<string>();
407
+ for (const entry of branch) {
408
+ if (entry.type !== "custom" || entry.customType !== customType) continue;
409
+ const data = entry.data as { v?: number; phase?: string; results?: { entryId?: string; reason?: string; kind?: string }[] } | undefined;
410
+ if (data?.v !== 1 || data.phase !== "mid-run") continue;
411
+ for (const result of data.results ?? []) {
412
+ if (typeof result.entryId === "string" && result.kind !== "exchange" && wasJudged(result)) judged.add(result.entryId);
413
+ }
414
+ }
415
+ return judged;
416
+ }
417
+
418
+ /**
419
+ * Old exchanges judged during the current run (records of any phase after the last user message on the
420
+ * branch). An exchange is judged again in a later run, against that run's work.
421
+ */
422
+ export function exchangeMemo(branch: readonly BranchEntry[], customType: string): Set<string> {
423
+ let start = branch.length - 1;
424
+ while (start >= 0 && !(branch[start].type === "message" && branch[start].message?.role === "user")) start--;
425
+ const judged = new Set<string>();
426
+ for (const entry of branch.slice(start + 1)) {
427
+ if (entry.type !== "custom" || entry.customType !== customType) continue;
428
+ const data = entry.data as { v?: number; results?: { entryId?: string; reason?: string; kind?: string }[] } | undefined;
429
+ if (data?.v !== 1) continue;
430
+ for (const result of data.results ?? []) {
431
+ // A deferred omission (break-even) stays eligible: the exchange is judged again at a later pass.
432
+ if (result.kind === "exchange" && typeof result.entryId === "string" && wasJudged(result) && result.reason !== "deferred") judged.add(result.entryId);
433
+ }
434
+ }
435
+ return judged;
436
+ }
package/src/config.ts CHANGED
@@ -46,8 +46,40 @@ export interface GuardConfig {
46
46
  * against the ongoing work too. 0 sends no earlier conversation at all (no summary either).
47
47
  */
48
48
  historyExchanges: number;
49
- /** Add an Anthropic cache breakpoint at the previous user question (see docs/CACHE.md). */
49
+ /**
50
+ * Add Anthropic cache breakpoints: at the previous user question, and after context edits at a
51
+ * read point before the first edited output (see docs/CACHE.md).
52
+ */
50
53
  pinAnthropicCache: boolean;
54
+ /** Mid-run checkpoints: judge older tool outputs while a long run is still going. */
55
+ midRun: boolean;
56
+ /** A tool output is judged mid-run only once its turn is at least this many turns old. */
57
+ midRunMinAgeTurns: number;
58
+ /** Run a checkpoint only when the not-yet-judged eligible outputs total at least this (characters). */
59
+ midRunBatchChars: number;
60
+ /** Batch size for OpenAI Codex models, whose first request after an edit reads (almost) nothing from cache. */
61
+ midRunBatchCharsOpenAI: number;
62
+ /** Keep a chunk at a checkpoint when P(still needed) reaches this. */
63
+ midRunChunkKeepThreshold: number;
64
+ /** Skip a checkpoint unless its one-time prompt-cache rewrite is likely to pay off (docs/CACHE.md). */
65
+ midRunBreakEven: boolean;
66
+ /** Tool results from this size up to `minResultChars` are judged as whole items (0 turns this off). */
67
+ smallResultMinChars: number;
68
+ /** Keep a small output when P(still needed) reaches this. */
69
+ smallKeepThreshold: number;
70
+ /** Omit old exchanges judged unrelated to the current work. */
71
+ pruneExchanges: boolean;
72
+ /** The last this many exchanges before the current prompt are never omitted. */
73
+ keepRecentExchanges: number;
74
+ /** Omit an exchange only when P(still relevant) is below this. */
75
+ exchangeOmitThreshold: number;
76
+ /**
77
+ * Gate exchange omission at run end by the break-even rule (and `exchangeMinSavingChars`); deferred
78
+ * exchanges stay eligible and accumulate. `false` always omits unrelated exchanges.
79
+ */
80
+ exchangeBreakEven: boolean;
81
+ /** A pass omits exchanges only if they save at least this many characters together (when that costs a cache rewrite). */
82
+ exchangeMinSavingChars: number;
51
83
  }
52
84
 
53
85
  export const DEFAULT_CONFIG: GuardConfig = {
@@ -69,6 +101,19 @@ export const DEFAULT_CONFIG: GuardConfig = {
69
101
  distillErrors: true,
70
102
  historyExchanges: 3,
71
103
  pinAnthropicCache: true,
104
+ midRun: true,
105
+ midRunMinAgeTurns: 4,
106
+ midRunBatchChars: 60_000,
107
+ midRunBatchCharsOpenAI: 60_000,
108
+ midRunChunkKeepThreshold: 0.6,
109
+ midRunBreakEven: true,
110
+ smallResultMinChars: 400,
111
+ smallKeepThreshold: 0.45,
112
+ pruneExchanges: true,
113
+ keepRecentExchanges: 2,
114
+ exchangeOmitThreshold: 0.2,
115
+ exchangeBreakEven: true,
116
+ exchangeMinSavingChars: 8_000,
72
117
  };
73
118
 
74
119
  const NUMBER_RANGES: Partial<Record<keyof GuardConfig, [number, number]>> = {
@@ -84,9 +129,18 @@ const NUMBER_RANGES: Partial<Record<keyof GuardConfig, [number, number]>> = {
84
129
  maxSegmentChars: [2_000, 200_000],
85
130
  maxChunksPerSegment: [2, 60],
86
131
  historyExchanges: [0, 10],
132
+ midRunMinAgeTurns: [1, 100],
133
+ midRunBatchChars: [0, 10_000_000],
134
+ midRunBatchCharsOpenAI: [0, 10_000_000],
135
+ midRunChunkKeepThreshold: [0, 1],
136
+ smallResultMinChars: [0, 10_000_000],
137
+ smallKeepThreshold: [0, 1],
138
+ keepRecentExchanges: [0, 100],
139
+ exchangeOmitThreshold: [0, 1],
140
+ exchangeMinSavingChars: [0, 10_000_000],
87
141
  };
88
142
 
89
- const INTEGER_KEYS = new Set<keyof GuardConfig>(["minResultChars", "minRunChars", "timeoutMs", "concurrency", "maxSegmentChars", "maxChunksPerSegment", "historyExchanges"]);
143
+ const INTEGER_KEYS = new Set<keyof GuardConfig>(["minResultChars", "minRunChars", "timeoutMs", "concurrency", "maxSegmentChars", "maxChunksPerSegment", "historyExchanges", "midRunMinAgeTurns", "midRunBatchChars", "midRunBatchCharsOpenAI", "smallResultMinChars", "keepRecentExchanges", "exchangeMinSavingChars"]);
90
144
 
91
145
  /** Merge untrusted JSON over the defaults, keeping only well-typed, in-range values. */
92
146
  export function normalizeConfig(raw: unknown): GuardConfig {
package/src/decide.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * can only name the most important chunk; the per-chunk yes/no questions add the others.
12
12
  */
13
13
  import type { Chunk } from "./chunk.ts";
14
+ import type { CheckpointCandidate, CheckpointInfo } from "./checkpoint.ts";
15
+ import { recentNotes } from "./checkpoint.ts";
14
16
  import { type Candidate, hasHistory, type RunHistory, type RunInfo } from "./run.ts";
15
17
  import { type ClassifierQuestion, type ClassifierRequest, type ClassifierResponse, clip } from "./types.ts";
16
18
 
@@ -121,6 +123,78 @@ export function buildRequest(
121
123
  return { state, questions };
122
124
  }
123
125
 
126
+ const PROGRESS_LIMIT = 2_000;
127
+
128
+ /**
129
+ * Request for a mid-run checkpoint: there is no final answer yet, so Jev judges whether the agent
130
+ * will still need the output to finish the task (docs/JEV.md → Checkpoint wording). The run-end
131
+ * wording in `buildRequest` is untouched.
132
+ */
133
+ export function buildCheckpointRequest(
134
+ info: Pick<CheckpointInfo, "question" | "history" | "latest" | "notes">,
135
+ candidate: CheckpointCandidate,
136
+ segment: readonly Chunk[],
137
+ segmentIndex: number,
138
+ segmentCount: number,
139
+ totalLines: number,
140
+ ): ClassifierRequest {
141
+ const withHistory = hasHistory(info.history);
142
+ const state: Record<string, unknown> = {
143
+ situation:
144
+ "A coding agent is still working on user_question; it has not finished yet. This is the output of one of its earlier tool " +
145
+ "calls, split into chunks. From now on the agent will only see the chunks we keep, plus a note that the rest was removed " +
146
+ "and can be fetched again. later_tool_calls shows what the agent did after this call." +
147
+ (withHistory ? " earlier_conversation shows the session before this task." : ""),
148
+ };
149
+ if (withHistory) state.earlier_conversation = describeHistory(info.history);
150
+ state.user_question = clip(info.question, QUESTION_LIMIT);
151
+ const progress: Record<string, string> = {};
152
+ if (info.latest.trim()) progress.latest_note = clip(info.latest, PROGRESS_LIMIT);
153
+ if (info.notes.trim()) progress.earlier_notes = recentNotes(info.notes, PROGRESS_LIMIT);
154
+ if (Object.keys(progress).length > 0) state.agent_progress = progress;
155
+ state.later_tool_calls = candidate.laterCalls.length > 0 ? candidate.laterCalls : "none yet";
156
+ if (candidate.superseded) state.superseded = candidate.superseded;
157
+ state.tool = candidate.toolName;
158
+ if (candidate.isError) state.tool_status = "failed (the tool reported an error, e.g. a non-zero exit code).";
159
+ const args = describeArgs(candidate.args);
160
+ if (args) state.tool_arguments = args;
161
+ state.output_size = `${totalLines} lines${segmentCount > 1 ? `; this is part ${segmentIndex + 1} of ${segmentCount}` : ""}`;
162
+ state.chunks = Object.fromEntries(segment.map((chunk) => [chunkLabel(chunk), chunk.text]));
163
+
164
+ const scope = segmentCount > 1 ? "this part of the tool output" : "the tool output";
165
+ const focusCriteria: Record<string, string> = {
166
+ whole: `Most of ${scope} will still be needed`,
167
+ none: `Nothing in ${scope} will be needed again`,
168
+ };
169
+ for (const chunk of segment) {
170
+ const label = chunkLabel(chunk);
171
+ focusCriteria[label] = `A few chunks will still be needed; ${label} is the most important of them`;
172
+ }
173
+ const questions: Record<string, ClassifierQuestion> = {
174
+ keep_whole: {
175
+ type: "bool",
176
+ instructions: `To finish user_question, will the agent still need all of ${scope}, so that removing any chunk would lose information it is likely to use again?`,
177
+ criteria: { true: "Keep everything", false: "Some chunks will not be needed again and can be removed" },
178
+ },
179
+ focus: {
180
+ type: "choice",
181
+ instructions:
182
+ `Will the rest of the agent's work draw on most of ${scope}, or on a few chunks? If a few, pick the most important chunk. ` +
183
+ "If nothing in it will be needed again (for example because it is out of date or the agent has moved past it), pick none.",
184
+ criteria: focusCriteria,
185
+ },
186
+ };
187
+ for (const chunk of segment) {
188
+ const label = chunkLabel(chunk);
189
+ questions[label] = {
190
+ type: "bool",
191
+ instructions: `To finish user_question, will the agent still need the lines in ${label}? Lines that are out of date or that the agent has already acted on and moved past are not needed.`,
192
+ criteria: { true: `Keep ${label}`, false: `${label} will not be needed again` },
193
+ };
194
+ }
195
+ return { state, questions };
196
+ }
197
+
124
198
  /** Basenames and paths mentioned in the answer, for the citation safety net. */
125
199
  export function citedFiles(answer: string, chunks: readonly Chunk[]): Set<number> {
126
200
  const keep = new Set<number>();
package/src/distill.ts CHANGED
@@ -8,7 +8,10 @@ import { type Chunk, chunkOutput, DEFAULT_CHUNK_OPTIONS, segmentChunks } from ".
8
8
  import { buildRequest, citedFiles, type DecideThresholds, interpret, type SegmentDecision } from "./decide.ts";
9
9
  import { renderReplacement, toolLabel } from "./render.ts";
10
10
  import type { Candidate, RunInfo } from "./run.ts";
11
- import type { ClassifierResponse, ClassifyFn, ContextEditDraft } from "./types.ts";
11
+ import type { ClassifierRequest, ClassifierResponse, ClassifyFn, ContextEditDraft } from "./types.ts";
12
+
13
+ /** Builds the Jev request for one segment; defaults to the run-end request (`buildRequest`). */
14
+ export type RequestBuilder = (candidate: Candidate, segment: readonly Chunk[], segmentIndex: number, segmentCount: number, totalLines: number) => ClassifierRequest;
12
15
 
13
16
  export interface DistillOptions extends DecideThresholds {
14
17
  minRunChars: number;
@@ -18,6 +21,8 @@ export interface DistillOptions extends DecideThresholds {
18
21
  concurrency: number;
19
22
  maxSegmentChars: number;
20
23
  maxChunksPerSegment: number;
24
+ /** Mid-run checkpoints pass their own request builder. */
25
+ requestBuilder?: RequestBuilder;
21
26
  }
22
27
 
23
28
  export type ResultOutcome = "distilled" | "removed" | "kept";
@@ -128,7 +133,9 @@ export async function distillRun(
128
133
  try {
129
134
  await pool(tasks.filter((task) => !task.decision), options.concurrency, signal, async (task) => {
130
135
  const candidate = run.candidates[task.candidate];
131
- const request = buildRequest(run, candidate, task.segment, task.segmentIndex, task.segmentCount, task.totalLines);
136
+ const request = options.requestBuilder
137
+ ? options.requestBuilder(candidate, task.segment, task.segmentIndex, task.segmentCount, task.totalLines)
138
+ : buildRequest(run, candidate, task.segment, task.segmentIndex, task.segmentCount, task.totalLines);
132
139
  outcome.requests++;
133
140
  try {
134
141
  // Race the request against the budget: a provider that ignores the signal must not