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/src/index.ts CHANGED
@@ -6,18 +6,41 @@
6
6
  * entries then replace each output with the kept chunks, verbatim. The raw output stays in the
7
7
  * session; the `recall` tool returns it. The footer shows how much context is being saved.
8
8
  *
9
- * Also pins an Anthropic cache breakpoint at the previous user question, so the distilled
10
- * history still hits the prompt cache (docs/CACHE.md).
9
+ * During long runs, mid-run checkpoints judge older outputs at turn boundaries (turn_end), in
10
+ * batches, with a checkpoint-specific Jev request (docs/DESIGN.md → Mid-run checkpoints).
11
+ *
12
+ * Also places Anthropic cache breakpoints (the previous user question, and after edits a read point
13
+ * before the first edited output), so the distilled history still hits the prompt cache (docs/CACHE.md).
11
14
  */
12
15
  import { join } from "node:path";
13
16
  import { Type } from "@earendil-works/pi-ai";
14
17
  import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
15
- import { pinQuestionBreakpoint } from "./cache-pin.ts";
18
+ import { markedToolResults, normalizeToolUseId, pinQuestionBreakpoint, placeGuardBreakpoints } from "./cache-pin.ts";
19
+ import {
20
+ type CheckpointCandidate,
21
+ collectCheckpoint,
22
+ anthropicRewriteChars,
23
+ BREAK_EVEN_FACTOR,
24
+ CACHE_TTL_MS,
25
+ cacheAnchors,
26
+ exchangeMemo,
27
+ charsFrom,
28
+ contextChars,
29
+ memoFromBranch,
30
+ paysOff,
31
+ pendingEdits,
32
+ refreshOnReadThrough,
33
+ runBaseline,
34
+ type WrittenEntry,
35
+ } from "./checkpoint.ts";
36
+ import { buildCheckpointRequest } from "./decide.ts";
16
37
  import { type GuardConfig, loadConfig, parseModelRef, saveConfigPatch } from "./config.ts";
17
- import { type DistillOutcome, distillRun } from "./distill.ts";
18
- import { originalOutput, recallText } from "./recall.ts";
19
- import { formatChars, MARKER } from "./render.ts";
20
- import { collectRun } from "./run.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";
41
+ import { collectExchanges, collectSmall, type ExchangeItem, type SmallItem, type WorkContext } from "./items.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";
21
44
  import {
22
45
  CUSTOM_TYPE,
23
46
  type Colorize,
@@ -40,6 +63,33 @@ export default function contextGuard(pi: ExtensionAPI) {
40
63
  let busy = false;
41
64
  let problem: string | undefined;
42
65
  let warned = new Set<string>();
66
+ // No checkpoint state is kept in memory: the memo and the read point are derived from the entries
67
+ // persisted on the active branch at every boundary and request (checkpoint.ts).
68
+ const branchOf = (ctx: ExtensionContext) => ctx.sessionManager.getBranch() as unknown as Parameters<typeof pendingEdits>[0];
69
+ /**
70
+ * Cache entries this process wrote: the tool-result breakpoints it saw in the Anthropic requests it
71
+ * sent (after its own changes). The only source for read points; cleared on session start or reload,
72
+ * so anything uncertain falls back to the question pin.
73
+ */
74
+ let written: WrittenEntry[] = [];
75
+ const recordWritten = (branch: ReturnType<typeof branchOf>, payload: unknown, model: string, now: number) => {
76
+ const leafId = branch.at(-1)?.id;
77
+ if (!leafId) return;
78
+ const byToolUseId = new Map<string, string>();
79
+ for (const e of branch) {
80
+ const id = e.message?.role === "toolResult" ? e.message.toolCallId : undefined;
81
+ if (id && e.id) byToolUseId.set(normalizeToolUseId(id), e.id);
82
+ }
83
+ const fresh = written.filter((w) => now - w.time <= CACHE_TTL_MS);
84
+ for (const toolUseId of markedToolResults(payload)) {
85
+ const entryId = byToolUseId.get(toolUseId);
86
+ if (entryId) fresh.push({ entryId, model, time: now, leafId });
87
+ }
88
+ written = fresh.slice(-200);
89
+ };
90
+ const modelKey = (ctx: ExtensionContext) => `${ctx.model?.provider}/${ctx.model?.id}`;
91
+ /** The last Anthropic request this process saw, to match its response in message_end. */
92
+ let lastRequest: { model: string; time: number } | undefined;
43
93
 
44
94
  const warnOnce = (ctx: ExtensionContext, key: string, message: string) => {
45
95
  if (warned.has(key)) return;
@@ -97,6 +147,7 @@ export default function contextGuard(pi: ExtensionAPI) {
97
147
  warned = new Set();
98
148
  busy = false;
99
149
  reload(ctx);
150
+ written = [];
100
151
  if (config.enabled) resolveClassifier(ctx);
101
152
  refreshStatus(ctx);
102
153
  });
@@ -104,77 +155,259 @@ export default function contextGuard(pi: ExtensionAPI) {
104
155
  pi.on("session_compact", async (_event, ctx) => refreshStatus(ctx));
105
156
  pi.on("agent_settled", async (_event, ctx) => refreshStatus(ctx));
106
157
 
107
- pi.on("agent_before_settle", async (event, ctx) => {
108
- if (!config.enabled || event.outcome !== "completed") return;
109
- const run = collectRun(event.context.contextEntries as unknown as ProjectedEntryLike[], config);
110
- if (!run || run.candidates.length === 0) return;
111
- if (run.candidates.reduce((sum, c) => sum + c.text.length, 0) < config.minRunChars) return;
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
+ */
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
+ };
112
181
 
182
+ const judgeWithStatus = async (
183
+ ctx: ExtensionContext,
184
+ run: RunInfo,
185
+ small: SmallItem[],
186
+ exchanges: ExchangeItem[],
187
+ work: WorkContext,
188
+ options: ProcessOptions,
189
+ ): Promise<ProcessOutcome | undefined> => {
113
190
  const classify = resolveClassifier(ctx);
114
191
  if (!classify) {
115
192
  refreshStatus(ctx);
116
- return;
193
+ return undefined;
117
194
  }
118
-
119
195
  busy = true;
120
196
  refreshStatus(ctx);
121
- if (ctx.hasUI) ctx.ui.setWorkingMessage("context-guard: distilling tool output with Jev…");
122
- let outcome: DistillOutcome;
197
+ if (ctx.hasUI) ctx.ui.setWorkingMessage("context-guard: distilling old context with Jev…");
123
198
  try {
124
- outcome = await distillRun(run, classify, config, ctx.signal);
199
+ return await processItems(run, small, exchanges, work, classify, options, ctx.signal);
125
200
  } catch (err) {
126
201
  warnOnce(ctx, "distill", `distillation failed: ${err instanceof Error ? err.message : String(err)}`);
127
- return;
202
+ return undefined;
128
203
  } finally {
129
204
  busy = false;
130
205
  if (ctx.hasUI) ctx.ui.setWorkingMessage();
131
206
  refreshStatus(ctx);
132
207
  }
133
- if (outcome.requests === 0) return;
134
-
135
- const record: CustomDraft = {
136
- type: "custom",
137
- customType: CUSTOM_TYPE,
138
- data: {
139
- v: 1,
140
- model: config.model,
141
- savedChars: outcome.savedChars,
142
- requests: outcome.requests,
143
- inputTokens: outcome.inputTokens,
144
- costUsd: outcome.costUsd,
145
- ms: outcome.ms,
146
- timedOut: outcome.timedOut,
147
- results: outcome.records.map(({ entryId, tool, label, outcome, reason, beforeChars, afterChars, jev }) => ({
148
- entryId,
149
- tool,
150
- label,
151
- outcome,
152
- reason,
153
- beforeChars,
154
- afterChars,
155
- jev,
156
- })),
157
- } satisfies RunRecordData,
158
- };
159
- if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some results were left as they are.`);
208
+ };
209
+
210
+ const recordOf = (outcome: ProcessOutcome, phase: "mid-run" | "run-end"): CustomDraft => ({
211
+ type: "custom",
212
+ customType: CUSTOM_TYPE,
213
+ data: {
214
+ v: 1,
215
+ phase,
216
+ model: config.model,
217
+ savedChars: outcome.savedChars,
218
+ requests: outcome.requests,
219
+ inputTokens: outcome.inputTokens,
220
+ costUsd: outcome.costUsd,
221
+ ms: outcome.ms,
222
+ timedOut: outcome.timedOut,
223
+ results: outcome.records.map(({ kind, entryId, tool, label, outcome, reason, beforeChars, afterChars, jev, omitted }) => ({
224
+ kind,
225
+ ...(omitted ? { omitted } : {}),
226
+ entryId,
227
+ tool,
228
+ label,
229
+ outcome,
230
+ reason,
231
+ beforeChars,
232
+ afterChars,
233
+ jev,
234
+ })),
235
+ } satisfies RunRecordData,
236
+ });
237
+
238
+ /** Checkpoint batch size for the current main model (OpenAI Codex loses its whole cache on every edit). */
239
+ const batchCharsFor = (ctx: ExtensionContext): number =>
240
+ ctx.model?.provider === "openai-codex" ? config.midRunBatchCharsOpenAI : config.midRunBatchChars;
241
+
242
+ /** Characters the next request would write to the cache again if `ids` (entry ids) were edited. */
243
+ const rewriteCharsFor = (ctx: ExtensionContext, entries: ProjectedEntryLike[], ids: ReadonlySet<string>): number => {
244
+ // OpenAI Codex: the whole context. Anthropic: everything after the read point it would get, or after
245
+ // the question when every edit comes after it, else everything (with pinning off: everything).
246
+ // Other providers: everything from the earliest edited entry.
247
+ if (ctx.model?.provider === "openai-codex") return contextChars(entries);
248
+ if (ctx.model?.api === "anthropic-messages") {
249
+ return config.pinAnthropicCache ? anthropicRewriteChars(entries, branchOf(ctx), written, modelKey(ctx), Date.now(), ids) : contextChars(entries);
250
+ }
251
+ return charsFrom(entries, ids);
252
+ };
253
+
254
+ /**
255
+ * Run-end gate for exchange omissions (checkpoint.ts → paysOff). Their marginal cache cost is the
256
+ * rewrite with them minus the rewrite the pass's other edits cause anyway (on OpenAI Codex any edit
257
+ * already costs the whole context, so with other edits the omissions are free). When that is above
258
+ * zero, they must save at least `exchangeMinSavingChars` and pay off over as many later requests as
259
+ * the session has had so far.
260
+ */
261
+ const exchangesPayOff = (ctx: ExtensionContext, entries: ProjectedEntryLike[], outcome: ProcessOutcome): boolean => {
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 => {
268
+ if (ids.size === 0) return true;
269
+ const others = new Set(outcome.edits.map((e) => e.targetId).filter((id) => !ids.has(id)));
270
+ const rewriteWith = rewriteCharsFor(ctx, entries, new Set([...others, ...ids]));
271
+ const rewriteWithout = others.size > 0 ? rewriteCharsFor(ctx, entries, others) : 0;
272
+ const marginal = rewriteWith - rewriteWithout;
273
+ if (marginal <= 0) return true;
274
+ if (savedChars < minSavingChars) return false;
275
+ const requests = entries.filter((e) => e.messages[0]?.role === "assistant").length;
276
+ const factor = ctx.model?.provider === "openai-codex" ? BREAK_EVEN_FACTOR.full : BREAK_EVEN_FACTOR.prefix;
277
+ return paysOff(savedChars, requests, marginal, factor);
278
+ };
279
+
280
+ const imageSaving = (images: readonly ImageItem[]): number => images.reduce((n, i) => n + i.beforeChars - i.afterChars, 0);
281
+
282
+ pi.on("turn_end", async (event, ctx) => {
283
+ if (!config.enabled || !config.midRun || event.outcome !== "completed") return;
284
+ // The final turn of a run (no tool calls) is left to run-end distillation.
285
+ const message = event.message as { role?: string; content?: unknown };
286
+ if (message.role !== "assistant" || !Array.isArray(message.content) || !message.content.some((b: { type?: string }) => b?.type === "toolCall")) return;
287
+ const entries = event.context.contextEntries as unknown as ProjectedEntryLike[];
288
+ const branch = branchOf(ctx);
289
+ const memo = memoFromBranch(branch, CUSTOM_TYPE);
290
+ const info = collectCheckpoint(entries, config, memo);
291
+ if (!info) return;
292
+ const runStart = findRunStart(entries);
293
+ const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, memo, info.currentTurn - config.midRunMinAgeTurns) : [];
294
+ let exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branch, CUSTOM_TYPE)) : [];
295
+ let images = collectImages(entries, config.imageKeepTurns);
296
+ const outputChars = info.pendingChars + small.reduce((n, item) => n + item.text.length, 0);
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;
321
+
322
+ const run: RunInfo = { question: info.question, answer: "", notes: info.notes, candidates: info.candidates, toolResults: 0, history: info.history };
323
+ const work: WorkContext = { question: info.question, history: info.history, latest: info.latest, notes: info.notes };
324
+ const outcome = await processWithStatus(ctx, run, small, exchanges, images, work, {
325
+ ...config,
326
+ minRunChars: 0,
327
+ chunkKeepThreshold: config.midRunChunkKeepThreshold,
328
+ requestBuilder: (candidate, segment, segmentIndex, segmentCount, totalLines) =>
329
+ buildCheckpointRequest(info, candidate as CheckpointCandidate, segment, segmentIndex, segmentCount, totalLines),
330
+ });
331
+ if (!outcome || (outcome.requests === 0 && outcome.edits.length === 0)) return;
332
+
333
+ if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
160
334
  // The runner REPLACES the draft list with our return value: keep other extensions' drafts.
161
- return { entries: [...event.entries, ...outcome.edits, record] };
335
+ // Never ask for `continue`: the run goes on by itself.
336
+ // The memo only advances once Pi has committed this record to the branch.
337
+ return { entries: [...event.entries, ...(outcome.edits as never[]), recordOf(outcome, "mid-run")] };
338
+ });
339
+
340
+ pi.on("agent_before_settle", async (event, ctx) => {
341
+ if (!config.enabled || event.outcome !== "completed") return;
342
+ const entries = event.context.contextEntries as unknown as ProjectedEntryLike[];
343
+ // Outputs kept at a mid-run checkpoint are judged again, now with the final answer; distilled ones
344
+ // carry the marker and are skipped by the candidate rules.
345
+ const run = collectRun(entries, config);
346
+ if (!run) return;
347
+ const largeChars = run.candidates.reduce((sum, c) => sum + c.text.length, 0);
348
+ if (largeChars < config.minRunChars) run.candidates = [];
349
+ const runStart = findRunStart(entries);
350
+ const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, new Set()) : [];
351
+ const exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branchOf(ctx), CUSTOM_TYPE)) : [];
352
+ const images = collectImages(entries, config.imageKeepTurns);
353
+ if (run.candidates.length === 0 && small.length === 0 && exchanges.length === 0 && images.length === 0) return;
354
+ const work: WorkContext = { question: run.question, history: run.history ?? { exchanges: [] }, answer: run.answer };
355
+
356
+ const outcome = await processWithStatus(ctx, run, small, exchanges, images, work, config);
357
+ if (!outcome || (outcome.requests === 0 && outcome.edits.length === 0)) return;
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;
365
+ if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
366
+ // The runner REPLACES the draft list with our return value: keep other extensions' drafts.
367
+ return { entries: [...event.entries, ...(outcome.edits as never[]), recordOf(outcome, "run-end")] };
368
+ });
369
+
370
+ // Refresh logged cache entries that the response shows were read through (checkpoint.ts →
371
+ // refreshOnReadThrough). Without a clear hit beyond the system prompt and question, nothing changes.
372
+ pi.on("message_end", async (event, ctx) => {
373
+ const message = event.message as { role?: string; provider?: string; model?: string; usage?: { cacheRead?: number } };
374
+ const request = lastRequest;
375
+ if (message.role !== "assistant" || !request || written.length === 0 || !ctx.sessionManager) return;
376
+ lastRequest = undefined;
377
+ if (`${message.provider}/${message.model}` !== request.model) return;
378
+ const branch = branchOf(ctx);
379
+ const baseline = runBaseline(branch);
380
+ if (baseline === undefined) return;
381
+ const projection = ctx.sessionManager.buildSessionProjection().entries as unknown as ProjectedEntryLike[];
382
+ refreshOnReadThrough(projection, branch, written, request.model, request.time, message.usage?.cacheRead ?? 0, baseline);
162
383
  });
163
384
 
164
385
  pi.on("before_provider_request", (event, ctx) => {
165
386
  if (!config.enabled || !config.pinAnthropicCache) return;
166
387
  if (ctx.model?.api !== "anthropic-messages") return;
167
- pinQuestionBreakpoint(event.payload); // mutates in place; returning undefined keeps the payload
388
+ // Mutates in place; returning undefined keeps the payload. The previous question is always pinned
389
+ // (the floor). The first request after edits (checkpoint, run end or another extension) also gets
390
+ // a read point, only at an entry this process saw itself write (`written`), and a write anchor
391
+ // after the first edited batch (checkpoint.ts → cacheAnchors).
392
+ const model = modelKey(ctx);
393
+ const branch = ctx.sessionManager ? branchOf(ctx) : [];
394
+ const now = Date.now();
395
+ lastRequest = { model, time: now };
396
+ const anchors = cacheAnchors(branch, written, model, now);
397
+ if (anchors.read || anchors.write) {
398
+ placeGuardBreakpoints(event.payload, { anchors: anchors.read ? [anchors.read] : [], writeAnchors: anchors.write ? [anchors.write] : [], pinQuestion: true });
399
+ } else pinQuestionBreakpoint(event.payload);
400
+ recordWritten(branch, event.payload, model, now);
168
401
  });
169
402
 
170
403
  pi.registerTool({
171
- name: "recall",
404
+ name: RECALL_TOOL,
172
405
  label: "Recall",
173
406
  description:
174
- `Return the original output of a tool result that context-guard distilled (its text starts with "${MARKER}"). ` +
175
- "Pass the entryId from that header. Optionally pass a regex `pattern` to get only matching lines (with line numbers), " +
407
+ `Return the original of something context-guard distilled or omitted (its text starts with "${MARKER}"): a tool output, ` +
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), " +
176
409
  "and `offset`/`limit` (1-based) to page through a long output; with a `pattern` they count matching lines.",
177
- promptSnippet: `recall: fetch the full original output of a tool result marked "${MARKER}"`,
410
+ promptSnippet: `recall: fetch the full original of a tool output or earlier exchange marked "${MARKER}"`,
178
411
  parameters: Type.Object({
179
412
  entryId: Type.String({ description: "Entry id from the context-guard header" }),
180
413
  pattern: Type.Optional(Type.String({ description: "JavaScript regex; return only matching lines" })),
@@ -182,9 +415,14 @@ export default function contextGuard(pi: ExtensionAPI) {
182
415
  limit: Type.Optional(Type.Number({ description: "Most lines to return (matching lines when pattern is set)" })),
183
416
  }),
184
417
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
185
- const original = originalOutput(ctx.sessionManager.getEntry(params.entryId) as never, params.entryId);
186
- const text = recallText(original, params);
187
- return { content: [{ type: "text", text }], details: undefined };
418
+ const branch = ctx.sessionManager.getBranch() as never;
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 };
188
426
  },
189
427
  });
190
428
 
@@ -213,15 +451,18 @@ export default function contextGuard(pi: ExtensionAPI) {
213
451
  function describe(config: GuardConfig, stats: GuardStats, problem: string | undefined): string {
214
452
  const lines = [
215
453
  `context-guard is ${config.enabled ? "on" : "off"}${problem && config.enabled ? ` (${problem})` : ""} · model ${config.model}`,
216
- `Saving ~${formatTokens(stats.savedTokens)} tokens (${formatChars(stats.savedChars)} chars) across ${stats.distilledResults} distilled tool result(s).`,
217
- `Jev: ${stats.runs} run(s), ${stats.requests} request(s), ${formatCost(stats.costUsd)} on this branch.`,
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.`] : []),
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.`,
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"}.`,
218
458
  ];
219
459
  const last = stats.last;
220
460
  if (last) {
221
- lines.push(`Last run (${(last.ms / 1000).toFixed(1)} s${last.timedOut ? ", timed out" : ""}):`);
461
+ const kinds = ["large", "small", "exchange", "image"].map((kind) => `${last.results.filter((r) => (r.kind ?? "large") === kind).length} ${kind}`).join(", ");
462
+ lines.push(`Last ${last.phase === "mid-run" ? "checkpoint" : "run"} (${(last.ms / 1000).toFixed(1)} s${last.timedOut ? ", timed out" : ""}; ${kinds}):`);
222
463
  for (const result of last.results) {
223
464
  const sizes = result.outcome === "kept" ? formatChars(result.beforeChars) : `${formatChars(result.beforeChars)} → ${formatChars(result.afterChars)}`;
224
- lines.push(` ${result.outcome.padEnd(9)} ${sizes.padEnd(13)} ${result.label}${result.reason ? ` (${result.reason})` : ""}`);
465
+ lines.push(` ${result.outcome.padEnd(9)} ${(result.kind ?? "large").padEnd(9)} ${sizes.padEnd(13)} ${result.label}${result.reason ? ` (${result.reason})` : ""}`);
225
466
  for (const trace of result.jev ?? []) lines.push(` jev: ${trace}`);
226
467
  }
227
468
  }