pi-context-guard-jev 0.2.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/src/checkpoint.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  * entries), never kept in memory: a boundary's drafts can be replaced by a later handler or rejected
11
11
  * as a whole, and /tree or compaction change the branch.
12
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";
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";
15
16
  import type { ProjectedEntryLike } from "./types.ts";
16
17
 
17
18
  export interface CheckpointOptions extends CollectOptions {
@@ -116,7 +117,8 @@ export function collectCheckpoint(entries: readonly ProjectedEntryLike[], option
116
117
  for (const entry of span) {
117
118
  const candidate = candidateOf(entry, toolCalls, options);
118
119
  if (!candidate || candidate.turn === undefined || judged.has(candidate.entryId)) continue;
119
- if (candidate.turn > youngest) 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;
120
122
  const turn = candidate.turn;
121
123
  const superseded = supersededBy({ toolName: candidate.toolName, args: candidate.args, turn }, calls);
122
124
  candidates.push({ ...candidate, turn, laterCalls: laterCallsFor(turn, calls), ...(superseded ? { superseded } : {}) });
@@ -124,7 +126,7 @@ export function collectCheckpoint(entries: readonly ProjectedEntryLike[], option
124
126
 
125
127
  const latest = assistantTexts.at(-1)?.text ?? "";
126
128
  return {
127
- question: questionAt(entries, start),
129
+ question: runQuestion(entries, start),
128
130
  history: collectHistory(entries.slice(0, start), options.historyExchanges),
129
131
  latest,
130
132
  notes: assistantTexts
@@ -156,14 +158,16 @@ export function paysOff(pendingChars: number, turnsSoFar: number, rewriteChars:
156
158
  return pendingChars * Math.max(1, turnsSoFar) >= factor * rewriteChars;
157
159
  }
158
160
 
159
- /** Characters of model-visible text in the projected context (an estimate of its size). */
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
+ */
160
165
  export function contextChars(entries: readonly ProjectedEntryLike[]): number {
161
166
  let n = 0;
162
167
  for (const entry of entries) {
163
168
  for (const message of entry.messages) {
164
169
  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;
170
+ n += contentChars(message.content);
167
171
  }
168
172
  }
169
173
  return n;
@@ -187,7 +191,7 @@ export type BranchEntry = {
187
191
  customType?: string;
188
192
  data?: unknown;
189
193
  targetId?: string;
190
- message?: { role?: string; stopReason?: string; toolCallId?: string };
194
+ message?: { role?: string; stopReason?: string; toolCallId?: string; content?: unknown };
191
195
  };
192
196
 
193
197
  const isAnswered = (entry: BranchEntry): boolean =>
@@ -337,7 +341,8 @@ export function refreshOnReadThrough(
337
341
  baseline: number,
338
342
  ): string[] {
339
343
  if (!(cacheRead > 0) || !(baseline > 0) || cacheRead <= baseline) return [];
340
- const start = findRunStart(projection);
344
+ // The baseline is measured from the last user message (prompt or steering message, see runBaseline).
345
+ const start = lastUserIndex(projection);
341
346
  if (start < 0) return [];
342
347
  const position = positionsOf(branch);
343
348
  const refreshed: string[] = [];
@@ -391,10 +396,12 @@ export function anthropicRewriteChars(
391
396
  const read = Number.isFinite(first) ? trustedReadPoint(branch, log, model, now, first) : undefined;
392
397
  const readIndex = read ? projection.findIndex((entry) => entry.sourceEntry.id === read.id) : -1;
393
398
  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);
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);
396
403
  const earliest = projection.findIndex((entry) => candidateIds.has(entry.sourceEntry.id));
397
- const from = earliest >= 0 && earliest <= runStart ? 0 : runStart + 1;
404
+ const from = earliest >= 0 && earliest <= pin ? 0 : pin + 1;
398
405
  return contextChars(projection.slice(Math.max(0, from)));
399
406
  }
400
407
 
@@ -416,12 +423,28 @@ export function memoFromBranch(branch: readonly BranchEntry[], customType: strin
416
423
  }
417
424
 
418
425
  /**
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.
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.
421
428
  */
422
- export function exchangeMemo(branch: readonly BranchEntry[], customType: string): Set<string> {
429
+ export function branchRunStart(branch: readonly BranchEntry[]): number {
430
+ const isUser = (e: BranchEntry) => e.type === "message" && e.message?.role === "user";
423
431
  let start = branch.length - 1;
424
- while (start >= 0 && !(branch[start].type === "message" && branch[start].message?.role === "user")) start--;
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);
425
448
  const judged = new Set<string>();
426
449
  for (const entry of branch.slice(start + 1)) {
427
450
  if (entry.type !== "custom" || entry.customType !== customType) continue;
package/src/config.ts CHANGED
@@ -80,6 +80,11 @@ export interface GuardConfig {
80
80
  exchangeBreakEven: boolean;
81
81
  /** A pass omits exchanges only if they save at least this many characters together (when that costs a cache rewrite). */
82
82
  exchangeMinSavingChars: number;
83
+ /**
84
+ * Remove images from tool results once they are this many turns old (assistant messages after the
85
+ * result); `recall` shows them again. Images in user messages are always kept. 0 turns this off.
86
+ */
87
+ imageKeepTurns: number;
83
88
  }
84
89
 
85
90
  export const DEFAULT_CONFIG: GuardConfig = {
@@ -114,6 +119,7 @@ export const DEFAULT_CONFIG: GuardConfig = {
114
119
  exchangeOmitThreshold: 0.2,
115
120
  exchangeBreakEven: true,
116
121
  exchangeMinSavingChars: 8_000,
122
+ imageKeepTurns: 3,
117
123
  };
118
124
 
119
125
  const NUMBER_RANGES: Partial<Record<keyof GuardConfig, [number, number]>> = {
@@ -138,9 +144,10 @@ const NUMBER_RANGES: Partial<Record<keyof GuardConfig, [number, number]>> = {
138
144
  keepRecentExchanges: [0, 100],
139
145
  exchangeOmitThreshold: [0, 1],
140
146
  exchangeMinSavingChars: [0, 10_000_000],
147
+ imageKeepTurns: [0, 1_000],
141
148
  };
142
149
 
143
- const INTEGER_KEYS = new Set<keyof GuardConfig>(["minResultChars", "minRunChars", "timeoutMs", "concurrency", "maxSegmentChars", "maxChunksPerSegment", "historyExchanges", "midRunMinAgeTurns", "midRunBatchChars", "midRunBatchCharsOpenAI", "smallResultMinChars", "keepRecentExchanges", "exchangeMinSavingChars"]);
150
+ const INTEGER_KEYS = new Set<keyof GuardConfig>(["minResultChars", "minRunChars", "timeoutMs", "concurrency", "maxSegmentChars", "maxChunksPerSegment", "historyExchanges", "midRunMinAgeTurns", "midRunBatchChars", "midRunBatchCharsOpenAI", "smallResultMinChars", "keepRecentExchanges", "exchangeMinSavingChars", "imageKeepTurns"]);
144
151
 
145
152
  /** Merge untrusted JSON over the defaults, keeping only well-typed, in-range values. */
146
153
  export function normalizeConfig(raw: unknown): GuardConfig {
package/src/images.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Old images (docs/DESIGN.md → Images). A model looks at a screenshot once: it describes or acts on it in
3
+ * its next message and goes on. Every later request still sends the image (about w×h/750 tokens each), so
4
+ * long runs with screenshots pile up context. Images in tool results are removed once they are
5
+ * `imageKeepTurns` turns old (assistant messages after the result). No Jev request is needed: Jev reads
6
+ * text only, and age is what decides. The result's text stays; a stub in front says what was removed, and
7
+ * `recall` returns the images. Images in user messages are always kept.
8
+ */
9
+ import { MARKER, formatChars, toolLabel } from "./render.ts";
10
+ import { indexSpan } from "./run.ts";
11
+ import { contentChars, imageInfo } from "./size.ts";
12
+ import { type Block, type MessageLike, type ProjectedEntryLike, textOf } from "./types.ts";
13
+
14
+ export interface ImageItem {
15
+ /** Session entry id of the tool result. */
16
+ entryId: string;
17
+ toolName: string;
18
+ args?: Record<string, unknown>;
19
+ /** Position in the projection. */
20
+ index: number;
21
+ /** Assistant messages after the result. */
22
+ age: number;
23
+ images: number;
24
+ /** Estimated input tokens of the removed images. */
25
+ imageTokens: number;
26
+ /** Model-visible size before and after, in text characters (size.ts). */
27
+ beforeChars: number;
28
+ afterChars: number;
29
+ /** The new content: the stub, then the result's text blocks. */
30
+ replacement: { type: "text"; text: string }[];
31
+ }
32
+
33
+ const imageBlocks = (message: MessageLike | undefined): Block[] => (Array.isArray(message?.content) ? message.content.filter((b) => b.type === "image") : []);
34
+
35
+ /** True for a tool result whose images were removed by us (the raw entry has images, the model sees none). */
36
+ export const imagesRemoved = (projected: MessageLike | undefined, raw: MessageLike | undefined): boolean =>
37
+ projected?.role === "toolResult" && imageBlocks(raw).length > 0 && imageBlocks(projected).length === 0 && textOf(projected).startsWith(MARKER);
38
+
39
+ export function imageStub(item: Pick<ImageItem, "entryId" | "images" | "imageTokens" | "age">, sizes: string[]): string {
40
+ const what = item.images === 1 ? "1 image" : `${item.images} images`;
41
+ const dims = sizes.length > 0 ? `${sizes.slice(0, 3).join(", ")}${sizes.length > 3 ? ", …" : ""}; ` : "";
42
+ const them = item.images === 1 ? "it" : "them";
43
+ return `${MARKER} Removed ${what} from this output (${dims}~${formatChars(item.imageTokens)} tokens), ${item.age} turns old. recall({"entryId":"${item.entryId}"}) shows ${them} again.`;
44
+ }
45
+
46
+ /**
47
+ * Tool results in `entries` (the whole projection, earlier runs included) with images that are at least
48
+ * `keepTurns` turns old. Skipped: results another extension changed (recall returns the raw entry, so
49
+ * their edit could not be restored) and results without images in the model's view.
50
+ */
51
+ export function collectImages(entries: readonly ProjectedEntryLike[], keepTurns: number): ImageItem[] {
52
+ if (keepTurns <= 0) return [];
53
+ const { toolCalls } = indexSpan(entries);
54
+ // Assistant messages after each position.
55
+ const after = new Array<number>(entries.length + 1).fill(0);
56
+ for (let i = entries.length - 1; i >= 0; i--) after[i] = after[i + 1] + entries[i].messages.filter((m) => m.role === "assistant").length;
57
+ const items: ImageItem[] = [];
58
+ entries.forEach((entry, index) => {
59
+ const message = entry.messages[0];
60
+ if (entry.messages.length !== 1 || message?.role !== "toolResult" || !Array.isArray(message.content)) return;
61
+ const images = imageBlocks(message);
62
+ if (images.length === 0) return;
63
+ const age = after[index + 1];
64
+ if (age < keepTurns) return;
65
+ const raw = entry.sourceEntry.message;
66
+ if (raw && raw !== message && (textOf(raw) !== textOf(message) || imageBlocks(raw).length !== images.length)) return;
67
+ const call = message.toolCallId ? toolCalls.get(message.toolCallId) : undefined;
68
+ const infos = images.map(imageInfo);
69
+ const item = {
70
+ entryId: entry.sourceEntry.id,
71
+ toolName: message.toolName ?? call?.name ?? "tool",
72
+ ...(call?.args ? { args: call.args } : {}),
73
+ index,
74
+ age,
75
+ images: images.length,
76
+ imageTokens: infos.reduce((n, i) => n + i.tokens, 0),
77
+ };
78
+ const sizes = infos.flatMap((i) => (i.width && i.height ? [`${i.width}×${i.height}`] : []));
79
+ const replacement = [
80
+ { type: "text" as const, text: imageStub(item, sizes) },
81
+ ...message.content.filter((b) => b.type === "text").map((b) => ({ type: "text" as const, text: b.text ?? "" })),
82
+ ];
83
+ items.push({ ...item, beforeChars: contentChars(message.content), afterChars: contentChars(replacement), replacement });
84
+ });
85
+ return items;
86
+ }
87
+
88
+ export const imageLabel = (item: Pick<ImageItem, "toolName" | "args" | "images">): string =>
89
+ `${toolLabel({ toolName: item.toolName, args: item.args })} (${item.images === 1 ? "1 image" : `${item.images} images`})`;
package/src/index.ts CHANGED
@@ -35,11 +35,12 @@ import {
35
35
  } from "./checkpoint.ts";
36
36
  import { buildCheckpointRequest } from "./decide.ts";
37
37
  import { type GuardConfig, loadConfig, parseModelRef, saveConfigPatch } from "./config.ts";
38
- import { omittedIdsFor, originalOutput, recallText } from "./recall.ts";
39
- import { formatChars, MARKER } from "./render.ts";
38
+ import { collectImages, type ImageItem } from "./images.ts";
39
+ import { omittedIdsFor, originalContent, RECALL_MAX_IMAGES, recallText } from "./recall.ts";
40
+ import { formatChars, MARKER, RECALL_TOOL } from "./render.ts";
40
41
  import { collectExchanges, collectSmall, type ExchangeItem, type SmallItem, type WorkContext } from "./items.ts";
41
- import { deferExchanges, exchangeOmissions, type ProcessOptions, type ProcessOutcome, processItems } from "./process.ts";
42
- import { collectRun, findRunStart, type RunInfo } from "./run.ts";
42
+ import { addImages, deferExchanges, dropImages, emptyOutcome, exchangeOmissions, type ProcessOptions, type ProcessOutcome, processItems } from "./process.ts";
43
+ import { collectRun, findRunStart, lastUserIndex, type RunInfo } from "./run.ts";
43
44
  import {
44
45
  CUSTOM_TYPE,
45
46
  type Colorize,
@@ -154,8 +155,31 @@ export default function contextGuard(pi: ExtensionAPI) {
154
155
  pi.on("session_compact", async (_event, ctx) => refreshStatus(ctx));
155
156
  pi.on("agent_settled", async (_event, ctx) => refreshStatus(ctx));
156
157
 
157
- /** Process every kind of item with the busy status shown; undefined when distillation could not start. */
158
+ /**
159
+ * Process every kind of item with the busy status shown. Old images need no Jev request: with nothing
160
+ * else to judge, or when Jev is unavailable, the outcome holds just them. Undefined when there is
161
+ * nothing to do or distillation failed.
162
+ */
158
163
  const processWithStatus = async (
164
+ ctx: ExtensionContext,
165
+ run: RunInfo,
166
+ small: SmallItem[],
167
+ exchanges: ExchangeItem[],
168
+ images: ImageItem[],
169
+ work: WorkContext,
170
+ options: ProcessOptions,
171
+ ): Promise<ProcessOutcome | undefined> => {
172
+ const withImages = (outcome: ProcessOutcome | undefined): ProcessOutcome | undefined => {
173
+ if (images.length === 0) return outcome;
174
+ const result = outcome ?? emptyOutcome();
175
+ addImages(result, images);
176
+ return result;
177
+ };
178
+ if (run.candidates.length === 0 && small.length === 0 && exchanges.length === 0) return withImages(undefined);
179
+ return withImages(await judgeWithStatus(ctx, run, small, exchanges, work, options));
180
+ };
181
+
182
+ const judgeWithStatus = async (
159
183
  ctx: ExtensionContext,
160
184
  run: RunInfo,
161
185
  small: SmallItem[],
@@ -236,18 +260,25 @@ export default function contextGuard(pi: ExtensionAPI) {
236
260
  */
237
261
  const exchangesPayOff = (ctx: ExtensionContext, entries: ProjectedEntryLike[], outcome: ProcessOutcome): boolean => {
238
262
  const { savedChars, ids } = exchangeOmissions(outcome);
263
+ return earlyEditsPayOff(ctx, entries, outcome, ids, savedChars, config.exchangeMinSavingChars);
264
+ };
265
+
266
+ /** The same marginal-cost gate for edits `ids` that save `savedChars` (exchanges, images of earlier runs). */
267
+ const earlyEditsPayOff = (ctx: ExtensionContext, entries: ProjectedEntryLike[], outcome: ProcessOutcome, ids: ReadonlySet<string>, savedChars: number, minSavingChars: number): boolean => {
239
268
  if (ids.size === 0) return true;
240
269
  const others = new Set(outcome.edits.map((e) => e.targetId).filter((id) => !ids.has(id)));
241
270
  const rewriteWith = rewriteCharsFor(ctx, entries, new Set([...others, ...ids]));
242
271
  const rewriteWithout = others.size > 0 ? rewriteCharsFor(ctx, entries, others) : 0;
243
272
  const marginal = rewriteWith - rewriteWithout;
244
273
  if (marginal <= 0) return true;
245
- if (savedChars < config.exchangeMinSavingChars) return false;
274
+ if (savedChars < minSavingChars) return false;
246
275
  const requests = entries.filter((e) => e.messages[0]?.role === "assistant").length;
247
276
  const factor = ctx.model?.provider === "openai-codex" ? BREAK_EVEN_FACTOR.full : BREAK_EVEN_FACTOR.prefix;
248
277
  return paysOff(savedChars, requests, marginal, factor);
249
278
  };
250
279
 
280
+ const imageSaving = (images: readonly ImageItem[]): number => images.reduce((n, i) => n + i.beforeChars - i.afterChars, 0);
281
+
251
282
  pi.on("turn_end", async (event, ctx) => {
252
283
  if (!config.enabled || !config.midRun || event.outcome !== "completed") return;
253
284
  // The final turn of a run (no tool calls) is left to run-end distillation.
@@ -261,33 +292,43 @@ export default function contextGuard(pi: ExtensionAPI) {
261
292
  const runStart = findRunStart(entries);
262
293
  const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, memo, info.currentTurn - config.midRunMinAgeTurns) : [];
263
294
  let exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branch, CUSTOM_TYPE)) : [];
295
+ let images = collectImages(entries, config.imageKeepTurns);
264
296
  const outputChars = info.pendingChars + small.reduce((n, item) => n + item.text.length, 0);
265
- const exchangeChars = exchanges.reduce((n, item) => n + item.chars, 0);
266
- if (outputChars + exchangeChars < batchCharsFor(ctx)) return;
267
- // Only checkpoint when the one-time cache rewrite is likely to pay off (checkpoint.ts → paysOff).
268
- // Old exchanges come early, so they move the rewrite start back; if the whole batch does not pay
269
- // off, try the tool outputs alone.
270
- if (config.midRunBreakEven) {
271
- const factor = ctx.model?.provider === "openai-codex" ? BREAK_EVEN_FACTOR.full : BREAK_EVEN_FACTOR.prefix;
272
- const outputIds = new Set([...info.candidates.map((c) => c.entryId), ...small.map((s) => s.entryId)]);
273
- const allIds = new Set([...outputIds, ...exchanges.map((e) => e.entryId)]);
274
- const turns = info.currentTurn + 1;
275
- if (!paysOff(outputChars + exchangeChars, turns, rewriteCharsFor(ctx, entries, allIds), factor)) {
276
- exchanges = [];
277
- if (outputChars < batchCharsFor(ctx) || !paysOff(outputChars, turns, rewriteCharsFor(ctx, entries, outputIds), factor)) return;
278
- }
279
- }
297
+ // Only checkpoint when the batch is big enough and its one-time cache rewrite is likely to pay off
298
+ // (checkpoint.ts → paysOff). Old exchanges and images of earlier runs come early, so they move the
299
+ // rewrite start back: if the whole batch does not pay off, try without exchanges, then also without
300
+ // images from before the last user message.
301
+ const pin = lastUserIndex(entries);
302
+ const recentImages = images.filter((i) => i.index > pin);
303
+ const tiers = [
304
+ { exchanges, images },
305
+ { exchanges: [], images },
306
+ { exchanges: [], images: recentImages },
307
+ ];
308
+ const factor = ctx.model?.provider === "openai-codex" ? BREAK_EVEN_FACTOR.full : BREAK_EVEN_FACTOR.prefix;
309
+ const outputIds = [...info.candidates.map((c) => c.entryId), ...small.map((s) => s.entryId)];
310
+ const turns = info.currentTurn + 1;
311
+ const chosen = tiers.find((tier) => {
312
+ const chars = outputChars + imageSaving(tier.images) + tier.exchanges.reduce((n, item) => n + item.chars, 0);
313
+ if (chars < batchCharsFor(ctx)) return false;
314
+ if (!config.midRunBreakEven) return true;
315
+ const ids = new Set([...outputIds, ...tier.images.map((i) => i.entryId), ...tier.exchanges.map((e) => e.entryId)]);
316
+ return paysOff(chars, turns, rewriteCharsFor(ctx, entries, ids), factor);
317
+ });
318
+ if (!chosen) return;
319
+ exchanges = chosen.exchanges;
320
+ images = chosen.images;
280
321
 
281
322
  const run: RunInfo = { question: info.question, answer: "", notes: info.notes, candidates: info.candidates, toolResults: 0, history: info.history };
282
323
  const work: WorkContext = { question: info.question, history: info.history, latest: info.latest, notes: info.notes };
283
- const outcome = await processWithStatus(ctx, run, small, exchanges, work, {
324
+ const outcome = await processWithStatus(ctx, run, small, exchanges, images, work, {
284
325
  ...config,
285
326
  minRunChars: 0,
286
327
  chunkKeepThreshold: config.midRunChunkKeepThreshold,
287
328
  requestBuilder: (candidate, segment, segmentIndex, segmentCount, totalLines) =>
288
329
  buildCheckpointRequest(info, candidate as CheckpointCandidate, segment, segmentIndex, segmentCount, totalLines),
289
330
  });
290
- if (!outcome || outcome.requests === 0) return;
331
+ if (!outcome || (outcome.requests === 0 && outcome.edits.length === 0)) return;
291
332
 
292
333
  if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
293
334
  // The runner REPLACES the draft list with our return value: keep other extensions' drafts.
@@ -308,12 +349,19 @@ export default function contextGuard(pi: ExtensionAPI) {
308
349
  const runStart = findRunStart(entries);
309
350
  const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, new Set()) : [];
310
351
  const exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branchOf(ctx), CUSTOM_TYPE)) : [];
311
- if (run.candidates.length === 0 && small.length === 0 && exchanges.length === 0) return;
352
+ const images = collectImages(entries, config.imageKeepTurns);
353
+ if (run.candidates.length === 0 && small.length === 0 && exchanges.length === 0 && images.length === 0) return;
312
354
  const work: WorkContext = { question: run.question, history: run.history ?? { exchanges: [] }, answer: run.answer };
313
355
 
314
- const outcome = await processWithStatus(ctx, run, small, exchanges, work, config);
315
- if (!outcome || outcome.requests === 0) return;
356
+ const outcome = await processWithStatus(ctx, run, small, exchanges, images, work, config);
357
+ if (!outcome || (outcome.requests === 0 && outcome.edits.length === 0)) return;
316
358
  if (config.exchangeBreakEven && !exchangesPayOff(ctx, entries, outcome)) deferExchanges(outcome);
359
+ // Images of earlier runs (before this run's prompt) move the cache rewrite back like exchanges do.
360
+ const early = images.filter((i) => i.index < runStart);
361
+ if (config.exchangeBreakEven && !earlyEditsPayOff(ctx, entries, outcome, new Set(early.map((i) => i.entryId)), imageSaving(early), 0)) {
362
+ dropImages(outcome, new Set(early.map((i) => i.entryId)));
363
+ }
364
+ if (outcome.requests === 0 && outcome.edits.length === 0) return;
317
365
  if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
318
366
  // The runner REPLACES the draft list with our return value: keep other extensions' drafts.
319
367
  return { entries: [...event.entries, ...(outcome.edits as never[]), recordOf(outcome, "run-end")] };
@@ -353,11 +401,11 @@ export default function contextGuard(pi: ExtensionAPI) {
353
401
  });
354
402
 
355
403
  pi.registerTool({
356
- name: "recall",
404
+ name: RECALL_TOOL,
357
405
  label: "Recall",
358
406
  description:
359
407
  `Return the original of something context-guard distilled or omitted (its text starts with "${MARKER}"): a tool output, ` +
360
- "or a whole earlier exchange (prompt, replies, tool calls and results). Pass the entryId from that note. Optionally pass a regex `pattern` to get only matching lines (with line numbers), " +
408
+ "or a whole earlier exchange (prompt, replies, tool calls and results), or images removed from an output (they come back as images). Pass the entryId from that note. Optionally pass a regex `pattern` to get only matching lines (with line numbers), " +
361
409
  "and `offset`/`limit` (1-based) to page through a long output; with a `pattern` they count matching lines.",
362
410
  promptSnippet: `recall: fetch the full original of a tool output or earlier exchange marked "${MARKER}"`,
363
411
  parameters: Type.Object({
@@ -368,9 +416,13 @@ export default function contextGuard(pi: ExtensionAPI) {
368
416
  }),
369
417
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
370
418
  const branch = ctx.sessionManager.getBranch() as never;
371
- const original = originalOutput(ctx.sessionManager.getEntry(params.entryId) as never, params.entryId, branch, omittedIdsFor(branch, params.entryId, CUSTOM_TYPE));
372
- const text = recallText(original, params);
373
- return { content: [{ type: "text", text }], details: undefined };
419
+ const original = originalContent(ctx.sessionManager.getEntry(params.entryId) as never, params.entryId, branch, omittedIdsFor(branch, params.entryId, CUSTOM_TYPE));
420
+ const text = recallText(original.text, params);
421
+ // Images come with the first page of a plain recall, not with a pattern search.
422
+ const withImages = !params.pattern && !(params.offset && params.offset > 1);
423
+ const images = withImages ? original.images.slice(0, RECALL_MAX_IMAGES) : [];
424
+ const note = original.images.length > images.length ? `\n[${original.images.length} image(s) in the original${images.length > 0 ? `, ${images.length} attached` : ""}${withImages ? "" : "; recall without pattern/offset to see them"}]` : "";
425
+ return { content: [{ type: "text", text: text + note }, ...(images as never[])], details: undefined };
374
426
  },
375
427
  });
376
428
 
@@ -399,13 +451,14 @@ export default function contextGuard(pi: ExtensionAPI) {
399
451
  function describe(config: GuardConfig, stats: GuardStats, problem: string | undefined): string {
400
452
  const lines = [
401
453
  `context-guard is ${config.enabled ? "on" : "off"}${problem && config.enabled ? ` (${problem})` : ""} · model ${config.model}`,
402
- `Saving ~${formatTokens(stats.savedTokens)} tokens (${formatChars(stats.savedChars)} chars): ${stats.distilledResults} distilled tool output(s), ${stats.omittedExchanges} omitted exchange(s).`,
454
+ `Saving ~${formatTokens(stats.savedTokens)} tokens (${formatChars(stats.savedChars)} chars): ${stats.distilledResults} distilled tool output(s), ${stats.imageResults} output(s) without their images, ${stats.omittedExchanges} omitted exchange(s).`,
455
+ ...(stats.compacted ? [`Σ ~${formatTokens(stats.lifetimeTokens)} tokens saved on this branch in total; a compaction has since dropped the earlier edits from context.`] : []),
403
456
  `Jev: ${stats.runs - stats.checkpoints} run(s) and ${stats.checkpoints} mid-run checkpoint(s), ${stats.requests} request(s), ${formatCost(stats.costUsd)} on this branch.`,
404
- `Mid-run checkpoints are ${config.midRun ? `on (outputs ≥ ${config.midRunMinAgeTurns} turns old, batches of ≥ ${formatChars(config.midRunBatchChars)} chars)` : "off"}; old exchanges are ${config.pruneExchanges ? `judged (all but the last ${config.keepRecentExchanges})` : "kept"}.`,
457
+ `Mid-run checkpoints are ${config.midRun ? `on (outputs ≥ ${config.midRunMinAgeTurns} turns old, batches of ≥ ${formatChars(config.midRunBatchChars)} chars)` : "off"}; old exchanges are ${config.pruneExchanges ? `judged (all but the last ${config.keepRecentExchanges})` : "kept"}; tool-output images are ${config.imageKeepTurns > 0 ? `removed after ${config.imageKeepTurns} turns` : "kept"}.`,
405
458
  ];
406
459
  const last = stats.last;
407
460
  if (last) {
408
- const kinds = ["large", "small", "exchange"].map((kind) => `${last.results.filter((r) => (r.kind ?? "large") === kind).length} ${kind}`).join(", ");
461
+ const kinds = ["large", "small", "exchange", "image"].map((kind) => `${last.results.filter((r) => (r.kind ?? "large") === kind).length} ${kind}`).join(", ");
409
462
  lines.push(`Last ${last.phase === "mid-run" ? "checkpoint" : "run"} (${(last.ms / 1000).toFixed(1)} s${last.timedOut ? ", timed out" : ""}; ${kinds}):`);
410
463
  for (const result of last.results) {
411
464
  const sizes = result.outcome === "kept" ? formatChars(result.beforeChars) : `${formatChars(result.beforeChars)} → ${formatChars(result.afterChars)}`;
package/src/items.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * output becomes a one-line stub; an omitted exchange keeps a stub on its user prompt and omits every
5
5
  * other entry (`replacement: null`).
6
6
  */
7
- import { MARKER, formatChars, toolLabel } from "./render.ts";
7
+ import { MARKER, RECALL_TOOL, formatChars, toolLabel } from "./render.ts";
8
8
  import { supersededBy } from "./checkpoint.ts";
9
- import { type Candidate, type CollectOptions, candidateOf, indexSpan, type RunHistory } from "./run.ts";
9
+ import { contentChars } from "./size.ts";
10
+ import { type Candidate, type CollectOptions, candidateOf, indexSpan, isSteering, type RunHistory } from "./run.ts";
10
11
  import {
11
12
  type ClassifierQuestion,
12
13
  type ClassifierRequest,
@@ -18,7 +19,7 @@ import {
18
19
  textOf,
19
20
  } from "./types.ts";
20
21
 
21
- export type ItemKind = "large" | "small" | "exchange";
22
+ export type ItemKind = "large" | "small" | "exchange" | "image";
22
23
 
23
24
  // ---------------------------------------------------------------------------------------------
24
25
  // Small outputs
@@ -38,7 +39,8 @@ export interface SmallOptions extends CollectOptions {
38
39
  /**
39
40
  * Tool results of the run span `span` (entries after the run's user message) between
40
41
  * `smallResultMinChars` and `minResultChars`, under the same candidate rules as large outputs. With
41
- * `youngest`, only outputs whose call was made in a turn ≤ youngest (the mid-run age rule).
42
+ * `youngest`, only outputs whose call was made in a turn ≤ youngest (the mid-run age rule), and no
43
+ * recall outputs.
42
44
  */
43
45
  export function collectSmall(span: readonly ProjectedEntryLike[], options: SmallOptions, judged: ReadonlySet<string>, youngest = Number.POSITIVE_INFINITY): SmallItem[] {
44
46
  const { toolCalls } = indexSpan(span);
@@ -49,6 +51,8 @@ export function collectSmall(span: readonly ProjectedEntryLike[], options: Small
49
51
  if (!candidate || candidate.text.length >= options.minResultChars || judged.has(candidate.entryId)) continue;
50
52
  const turn = candidate.turn ?? 0;
51
53
  if (turn > youngest) continue;
54
+ // Mid-run (`youngest` given): recall outputs are left alone, see checkpoint.ts → collectCheckpoint.
55
+ if (Number.isFinite(youngest) && candidate.toolName === RECALL_TOOL) continue;
52
56
  const { toolCallId: _id, turn: _turn, ...plain } = candidate;
53
57
  // Skip outputs whose stub would not be shorter.
54
58
  if (smallStub(plain).length >= plain.text.length) continue;
@@ -91,17 +95,11 @@ const isEditable = (entry: ProjectedEntryLike): boolean => {
91
95
  return role === "user" || role === "assistant" || role === "toolResult";
92
96
  };
93
97
 
94
- const entryChars = (entry: ProjectedEntryLike): number =>
95
- entry.messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content ?? "").length), 0);
98
+ const entryChars = (entry: ProjectedEntryLike): number => entry.messages.reduce((n, m) => n + contentChars(m.content), 0);
96
99
 
97
100
  /** Exchanges smaller than this (model-visible characters) are never judged: the saving is not worth an edit. */
98
101
  export const EXCHANGE_MIN_CHARS = 2_000;
99
102
 
100
- const hasImage = (entry: ProjectedEntryLike): boolean =>
101
- [...entry.messages, ...(entry.sourceEntry.message ? [entry.sourceEntry.message] : [])].some(
102
- (m) => Array.isArray(m.content) && m.content.some((b) => b.type === "image"),
103
- );
104
-
105
103
  /**
106
104
  * True when the model sees something other than the raw entry and the difference is not ours: another
107
105
  * extension (or Pi's overflow recovery) replaced or omitted it. `recall` returns raw entries, so whatever
@@ -126,7 +124,8 @@ const editedByOthers = (entry: ProjectedEntryLike): boolean => {
126
124
  */
127
125
  export function collectExchanges(entries: readonly ProjectedEntryLike[], runStart: number, keepRecent: number, judged: ReadonlySet<string>): ExchangeItem[] {
128
126
  const starts: number[] = [];
129
- for (let i = 0; i < runStart; i++) if (entries[i].messages.some((m) => m.role === "user")) starts.push(i);
127
+ // Steering messages belong to the exchange they were sent in.
128
+ for (let i = 0; i < runStart; i++) if (entries[i].messages.some((m) => m.role === "user") && !isSteering(entries, i)) starts.push(i);
130
129
  const exchanges: ExchangeItem[] = [];
131
130
  starts.slice(0, Math.max(0, starts.length - keepRecent)).forEach((start, k) => {
132
131
  const end = k + 1 < starts.length ? starts[k + 1] : runStart;
@@ -135,8 +134,8 @@ export function collectExchanges(entries: readonly ProjectedEntryLike[], runStar
135
134
  if (!isEditable(user) || judged.has(user.sourceEntry.id)) return;
136
135
  const promptText = textOf(user.messages[0]);
137
136
  if (promptText.startsWith(MARKER)) return; // already omitted
138
- // Recoverability: recall returns raw entries as text, so no images and no foreign edits.
139
- if (span.some((e) => hasImage(e) || editedByOthers(e))) return;
137
+ // Recoverability: recall returns raw entries (with their images, see recall.ts), so no foreign edits.
138
+ if (span.some(editedByOthers)) return;
140
139
  const assistants = span.filter((e) => e.messages[0]?.role === "assistant");
141
140
  const lastAssistant = assistants.at(-1)?.messages[0];
142
141
  if (!lastAssistant || (Array.isArray(lastAssistant.content) && lastAssistant.content.some((b) => b.type === "toolCall"))) return;
package/src/process.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  smallStub,
21
21
  type WorkContext,
22
22
  } from "./items.ts";
23
+ import { type ImageItem, imageLabel } from "./images.ts";
23
24
  import { toolLabel } from "./render.ts";
24
25
  import type { RunInfo } from "./run.ts";
25
26
  import type { ClassifyFn, ContextEditDraft, OmitDraft } from "./types.ts";
@@ -143,6 +144,42 @@ export async function processItems(
143
144
 
144
145
 
145
146
 
147
+ export const emptyOutcome = (): ProcessOutcome => ({ edits: [], records: [], savedChars: 0, requests: 0, inputTokens: 0, costUsd: 0, ms: 0, timedOut: false });
148
+
149
+ /** Add the removal of old images (no Jev request; images.ts) to `outcome`. */
150
+ export function addImages(outcome: ProcessOutcome, items: readonly ImageItem[]): void {
151
+ for (const item of items) {
152
+ outcome.edits.push({ type: "context_edit", targetId: item.entryId, replacement: { content: item.replacement } });
153
+ outcome.records.push({
154
+ kind: "image",
155
+ entryId: item.entryId,
156
+ tool: item.toolName,
157
+ label: imageLabel(item),
158
+ outcome: "removed",
159
+ reason: `${item.age} turns old`,
160
+ beforeChars: item.beforeChars,
161
+ afterChars: item.afterChars,
162
+ keptLines: 0,
163
+ totalLines: 0,
164
+ chunks: 1,
165
+ requests: 0,
166
+ jev: [],
167
+ });
168
+ outcome.savedChars += item.beforeChars - item.afterChars;
169
+ }
170
+ }
171
+
172
+ /** Take image removals for `ids` back out of `outcome` (they stay eligible for a later pass). */
173
+ export function dropImages(outcome: ProcessOutcome, ids: ReadonlySet<string>): void {
174
+ if (ids.size === 0) return;
175
+ outcome.edits = outcome.edits.filter((e) => !ids.has(e.targetId));
176
+ outcome.records = outcome.records.filter((r) => {
177
+ if (r.kind !== "image" || !ids.has(r.entryId)) return true;
178
+ outcome.savedChars -= r.beforeChars - r.afterChars;
179
+ return false;
180
+ });
181
+ }
182
+
146
183
  /** Net characters the outcome's exchange omissions remove, and the entry ids they edit. */
147
184
  export function exchangeOmissions(outcome: ProcessOutcome): { savedChars: number; ids: Set<string> } {
148
185
  const ids = new Set<string>();