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/docs/DESIGN.md CHANGED
@@ -9,26 +9,35 @@ evidence behind the thresholds are in [JEV.md](JEV.md). Cache behaviour is in [C
9
9
  |---|---|
10
10
  | `src/index.ts` | The only module that uses the Pi API: hooks, `recall` tool, `/guard` command, status bar, classifier lookup |
11
11
  | `src/config.ts` | `DEFAULT_CONFIG`, validation (`normalizeConfig`), load/save of `context-guard.json`, `provider/id` parsing |
12
- | `src/run.ts` | `collectRun`: finds the run that just finished, its question, answer, notes and candidate tool results |
12
+ | `src/run.ts` | `collectRun`: finds the run that just finished, its question, answer, notes and candidate tool results; the shared span and candidate rules |
13
+ | `src/checkpoint.ts` | Mid-run checkpoints: eligible outputs (`collectCheckpoint`), superseded detection, the memo and pending edits from the branch, the read point, the break-even rule |
13
14
  | `src/chunk.ts` | `chunkOutput` (grep-aware chunking) and `segmentChunks` (grouping chunks into Jev requests) |
14
- | `src/decide.ts` | `buildRequest` (the Jev request) and `interpret` (answers → keep set); `citedFiles` |
15
+ | `src/decide.ts` | `buildRequest` (the run-end Jev request), `buildCheckpointRequest` (the mid-run one) and `interpret` (answers → keep set); `citedFiles` |
15
16
  | `src/distill.ts` | `distillRun`: chunk, run Jev requests in parallel within a time budget, decide per result, build `context_edit` drafts |
17
+ | `src/items.ts` | Whole items: small outputs (`collectSmall`) and old exchanges (`collectExchanges`), their Jev requests, stubs and edits, and `judgeItems` (batched, parallel, time budget) |
18
+ | `src/process.ts` | `processItems`: large outputs, small outputs and old exchanges of one pass, judged in parallel and merged into one list of edits and records; `addImages` adds the image removals |
19
+ | `src/images.ts` | Old images: `collectImages` (tool results with images at least `imageKeepTurns` old), their stub and replacement |
20
+ | `src/size.ts` | Context size in text characters: images by pixels (width × height ÷ 750 tokens × 4), thinking signatures at half, image headers (PNG/JPEG/GIF/WebP) |
16
21
  | `src/render.ts` | The replacement text: header, verbatim chunks, omission lines; the `[context-guard]` marker |
17
- | `src/cache-pin.ts` | Anthropic breakpoint at the previous user question |
22
+ | `src/cache-pin.ts` | Anthropic breakpoints: the previous user question, and the read point after edits (`placeGuardBreakpoints`) |
18
23
  | `src/stats.ts` | Savings and Jev usage computed from the session; status-bar text |
19
- | `src/recall.ts` | The text returned by `recall` |
24
+ | `src/recall.ts` | What `recall` returns (a tool output with its images, or the transcript of an omitted exchange with its user images) |
20
25
  | `src/types.ts` | Loose structural types for the Pi objects used, so the pure modules can be tested without Pi |
21
26
 
22
27
  Everything except `index.ts` is pure. `distillRun` gets the classifier as an injected function.
23
28
 
24
- ## The boundary: `agent_before_settle`
29
+ ## The boundaries: `agent_before_settle` and `turn_end`
25
30
 
26
- The work happens in `agent_before_settle`, and only when `event.outcome === "completed"` and the guard is on.
31
+ Run-end distillation happens in `agent_before_settle`, and only when `event.outcome === "completed"` and
32
+ the guard is on. Long runs also get mid-run checkpoints at `turn_end` (see
33
+ [Mid-run checkpoints](#mid-run-checkpoints)); everything below about drafts applies to both.
27
34
 
28
35
  Why there:
29
36
 
30
37
  - The final answer exists, so relevance can be judged against it. Pruning during a run would take raw
31
38
  data away from the model while it still needs it, and every edit would break the prompt cache again.
39
+ Mid-run checkpoints accept those costs only for outputs that are several turns old, in rare batches,
40
+ and only when the break-even rule says they pay off.
32
41
  - A boundary handler can return session entry drafts. A `context_edit` draft is append-only: it changes
33
42
  only what the model sees from then on. The raw entry stays in the session file, the UI and exports, and
34
43
  `ctx.sessionManager.getEntry(id)` still returns it. That is what `recall` reads.
@@ -45,18 +54,24 @@ Hooks used:
45
54
  | Hook | Use |
46
55
  |---|---|
47
56
  | `session_start` | Load settings, check the classifier model and credential, draw the status |
48
- | `agent_before_settle` | Distill the finished run |
49
- | `before_provider_request` | Pin the Anthropic cache breakpoint (only for `anthropic-messages` models) |
50
- | `session_tree`, `session_compact`, `agent_settled` | Redraw the status |
57
+ | `turn_end` | Mid-run checkpoint, when a batch is due |
58
+ | `agent_before_settle` | Distill the finished run (skipping outputs judged mid-run) |
59
+ | `before_provider_request` | Anthropic breakpoints (only for `anthropic-messages` models) |
60
+ | `session_tree`, `session_compact` | Redraw the status (checkpoint state is read from the branch anyway) |
61
+ | `agent_settled` | Redraw the status |
51
62
 
52
63
  ## Algorithm
53
64
 
54
65
  ### 1. Run span
55
66
 
56
- `collectRun` walks back through `event.context.contextEntries` (Pi's projected entries) to the last entry
57
- that has a user message, with or without text. Its text is the **question**; a prompt with only an image
58
- gets the placeholder `[the user sent only an image]`, so an older question is never reused. Everything
59
- after it is the run. Assistant
67
+ `collectRun` walks back through `event.context.contextEntries` (Pi's projected entries) to the user
68
+ message that started the run (`findRunStart`), with or without text. **Steering messages** (user messages
69
+ sent while the agent was working: the message before them, custom notices aside, is a tool result or an
70
+ assistant message with tool calls) do not start a run; the walk goes on to the prompt before them. The
71
+ **question** is that prompt's text followed by every steering message of the run, each prefixed with
72
+ `[The user added during the run]`; a message with only an image gets the placeholder
73
+ `[the user sent only an image]`, so an older question is never reused. Everything after the prompt is the
74
+ run. Assistant
60
75
  text in the run gives the **answer** (the last text) and the **notes** (all earlier text). Tool calls are
61
76
  indexed by id so each result can be matched with its tool name and arguments.
62
77
 
@@ -66,7 +81,7 @@ default 3; `0` turns all of it off):
66
81
  - `summary`: the text (`summary` field) of the latest `compactionSummary` or `branchSummary` message, at
67
82
  most 2,000 characters.
68
83
  - `exchanges`: the last N earlier exchanges, oldest first. Every user message starts one (steering messages
69
- included; an image-only prompt shows as `[the user sent only an image]`). Each holds the prompt (at most
84
+ of earlier runs included; an image-only prompt shows as `[the user sent only an image]`). Each holds the prompt (at most
70
85
  800 characters) and the **last** assistant text before the next user message (at most 1,200). Tool calls
71
86
  and tool results are left out.
72
87
  - `firstRequest`: the first user prompt in the projection (at most 1,000 characters), only when it is not
@@ -186,6 +201,184 @@ In this order:
186
201
  usually already cleared at this boundary, so Esc does not cancel it; the time budget is the limit.
187
202
  - An exception from the whole pass shows a warning and leaves the run untouched.
188
203
 
204
+ ## Items: what one pass judges
205
+
206
+ Every pass (run end, or a mid-run checkpoint) collects three kinds of item and judges them in parallel
207
+ (`processItems`); their edits and results go into one record.
208
+
209
+ | Kind | What | How it is judged | Dropped item becomes |
210
+ |---|---|---|---|
211
+ | `large` | Tool results of the current run of at least `minResultChars` (4,000), under the candidate rules above | Chunked, one request per segment (above) | The kept chunks verbatim with omission lines |
212
+ | `small` | Tool results of the current run from `smallResultMinChars` (400) up to `minResultChars`, under the same rules | Whole: one bool per item, many items per request | A one-line stub: `[context-guard] Omitted the output of bash \`…\` (1.2k chars): judged no longer needed. Full output: recall({"entryId":"…"}).` |
213
+ | `exchange` | Old exchanges (below) | Whole: one bool per exchange, several per request | A stub on the prompt, every other entry omitted |
214
+
215
+ **Small outputs** (`collectSmall`). An output whose stub would not be shorter is skipped. Each item carries
216
+ the tool label, `status: failed` for error results, `superseded` when a later call made it out of date
217
+ (`supersededBy`, as for large outputs mid-run), and the output (at most 4,000 characters). The question is
218
+ "will the agent still need the output in item_N?" mid-run, or "does item_N contain anything that
219
+ final_answer relies on, or that a likely follow-up would need?" at run end; both say that an output marked
220
+ superseded is out of date. An item is kept when P ≥ `smallKeepThreshold` (0.45; probe in
221
+ [JEV.md](JEV.md#small-outputs)). Mid-run, small outputs follow the same age rule and memo as large ones; at
222
+ run end every small output without the marker is judged, including those kept at a checkpoint.
223
+
224
+ **Old exchanges** (`collectExchanges`). An exchange is a user prompt and every entry up to the next one:
225
+ assistant messages, tool calls and results, custom messages such as subagent notices. Eligible are
226
+ exchanges before the current run's user message, without the last `keepRecentExchanges` (2) before it,
227
+ that completed (their last assistant message has no tool calls; a steering message belongs to the exchange
228
+ it was sent in), that are not already omitted (their prompt shows the stub), that were not judged during the
229
+ current run (`exchangeMemo`: exchange results in records after the run's prompt, except deferred ones), and that can be recovered and are worth it: no entry whose model-visible
230
+ content differs from the raw entry for any reason other than our own marker (images are fine: `recall` returns
231
+ them) (another extension's edit,
232
+ or an omission by someone else, which Pi keeps in the projection as an entry with no messages), at least
233
+ 2,000 characters of editable content, and a stub shorter than that content. A kept exchange is
234
+ judged again in a later run, against that run's work. Compaction and branch summaries are never part of an
235
+ exchange; entries Pi cannot edit (system messages, `!` shell executions) stay in place.
236
+
237
+ Jev sees the current work (`user_question`, `earlier_conversation`, and `agent_progress` mid-run or
238
+ `final_answer` at run end) and, per exchange, the prompt (at most 600 characters), the last assistant text
239
+ (800), the tool calls as `toolLabel()`s (at most 20) and the size. The question: "Is exchange_N still
240
+ relevant to the current work? Relevant means the agent may need its details: the same files, task,
241
+ decisions or facts." An exchange is omitted only when P < `exchangeOmitThreshold` (0.2; probe in
242
+ [JEV.md](JEV.md#old-exchanges)). Then the prompt entry gets the stub
243
+ `[context-guard] Omitted an earlier exchange judged unrelated to the current work: "<first 120 characters of the prompt>" (N messages, ~Mk tokens). Full exchange: recall({"entryId":"<prompt entry id>"}).`
244
+ and every other editable entry gets `replacement: null`, which omits it from model context. The record's
245
+ result for the exchange lists exactly those entry ids (`omitted`), and `recall` on the stub shows exactly
246
+ the prompt and those entries. Tool calls and
247
+ their results are omitted together. The resulting shapes (a stub user message right before the next user
248
+ message; spans without assistant and tool-result messages) are accepted by Anthropic (with
249
+ `pi-claude-auth`) and OpenAI Codex, checked live with `test/e2e/omit-probe.ts` and the `topics` scenario.
250
+
251
+ **Run-end break-even for exchanges** (`exchangeBreakEven`, on). After the pass is judged, its exchange
252
+ omissions are checked: their marginal cache cost is the rewrite with them minus the rewrite the pass's
253
+ other edits cause anyway (`rewriteCharsFor`, with the trusted read point and floor rules of
254
+ [CACHE.md](CACHE.md); on OpenAI Codex any edit costs the whole context, so exchanges ride along for free
255
+ when the pass edits anything else). If that cost is above zero, the omissions must save at least
256
+ `exchangeMinSavingChars` (8,000) and pass `paysOff` with as many later requests as the session has had so
257
+ far (model-visible assistant messages). Otherwise they are deferred: their edits are dropped, their results
258
+ say `kept` / `deferred`, and they are judged again at later run ends against the work of that run, so
259
+ deferred exchanges accumulate until their total pays off. `exchangeBreakEven: false` always omits
260
+ unrelated exchanges.
261
+
262
+ **Old images** (`images.ts → collectImages`, `imageKeepTurns`, 3). Every tool result in the projection,
263
+ earlier runs included, whose model-visible content has images and that at least `imageKeepTurns` assistant
264
+ messages follow. Skipped: user messages (the images you send are usually the spec), results another extension
265
+ changed (text or image count differs from the raw entry), and results whose images are already gone. No Jev
266
+ request: Jev reads text only, and a model reacts to an image in its next message, whose text stays. The
267
+ replacement is a text-only content: the stub
268
+ `[context-guard] Removed 1 image from this output (1280×960; ~1.6k tokens), 4 turns old. recall({"entryId":"…"}) shows it again.`
269
+ followed by the result's own text blocks. Because the stub leads, the result counts as ours (`editedByOthers`
270
+ is false, stats count it), and as already distilled (its text is not judged again). Its size before and after
271
+ uses `size.ts` (an image is width × height ÷ 750 tokens, 4 characters per token; header unreadable: 1,600
272
+ tokens; at most 6,700). Records have `kind: "image"`, `requests: 0`, reason `N turns old`. When Jev is
273
+ unavailable, a pass still removes images. At run end, images from before the run's prompt go through the
274
+ same marginal-cost gate as exchanges (`exchangeBreakEven`, with no minimum saving); deferred ones are just
275
+ collected again next time. Mid-run, images count towards the batch; the tiers are: everything; without
276
+ exchanges; without exchanges and images from before the last user message.
277
+
278
+ **Batching.** Whole items are grouped into requests of at most `maxSegmentChars` characters of item text and
279
+ `maxChunksPerSegment` (40) items, under the same `timeoutMs` budget; the three kinds run at the same time,
280
+ and one shared limiter keeps at most `concurrency` (6) Jev requests in flight across all of them. An item without an
281
+ answer (error, timeout) is kept and asked again next time.
282
+
283
+ ## Mid-run checkpoints
284
+
285
+ A 30–120 minute autonomous run would otherwise carry every tool output until it ends; Pi compacts only
286
+ near the window minus 16k tokens. Checkpoints judge older outputs while the run is still going.
287
+
288
+ **Hook (verified on Pi 1.0.3).** `turn_end` is an actionable boundary (`TurnEndEvent extends
289
+ BoundaryState`). `agent-session.js` dispatches it from `agent.finishTurn`, commits the returned drafts
290
+ (`_commitBoundaryDrafts` → `_refreshFinalizedContext`), and only then runs `prepareNextTurn`, which rebuilds
291
+ the messages from the session projection and runs the turn-end compaction check
292
+ (`_compactBeforeNextAssistantResponse`). So a `context_edit` returned at `turn_end` reaches the very next
293
+ provider request, before any compaction decision; the e2e runs confirm it (the next request's context
294
+ drops by the removed amount). The runner replaces the draft list as for `agent_before_settle`, so the
295
+ handler returns `[...event.entries, ...edits, record]`, and it never asks for `continue`.
296
+
297
+ **When.** At the end of a turn whose assistant message has tool calls (the final turn of a run is left to
298
+ run-end distillation), with `outcome: "completed"` and `midRun` on:
299
+
300
+ 1. **Eligible items:** large outputs (`collectCheckpoint`) and small outputs (`collectSmall`) of the current
301
+ run whose call was made at least `midRunMinAgeTurns` (4) turns before the current turn (turn = assistant
302
+ message index in the run) and that are not in the memo, and old exchanges not judged during this run.
303
+ 2. **Batch:** their total must reach `midRunBatchChars` (60,000; `midRunBatchCharsOpenAI` for
304
+ `openai-codex` models), so edits come in rare batches.
305
+ 3. **Break-even** (`midRunBreakEven`): the checkpoint must be likely to pay for its one-time cache
306
+ rewrite: `pending chars × turns so far ≥ factor × rewrite chars`, with factor 13 and the whole context
307
+ for OpenAI Codex; factor 16 for Anthropic, where the rewrite is the context after the read point the
308
+ next request would get (the latest trusted entry before the first eligible output, `trustedReadPoint`)
309
+ or, without one, after the question (the floor; with `pinAnthropicCache` off, the whole context); and
310
+ factor 16 and the context from the first eligible output on for other providers
311
+ (derivation in `checkpoint.ts` and [CACHE.md](CACHE.md#mid-run-checkpoints)). "Turns so far" stands in
312
+ for the turns still to come. The rewrite starts at the earliest edited entry: an old exchange comes
313
+ before the question, so with exchanges in the batch the Anthropic rewrite is everything after the latest
314
+ trusted entry before that exchange, or the whole context. If the whole batch does not pay off, the rule
315
+ is tried for the tool outputs alone, and the exchanges wait for run end.
316
+
317
+ Then all eligible outputs go to `distillRun` with the checkpoint request builder and
318
+ `chunkKeepThreshold = midRunChunkKeepThreshold` (0.6), under the same `timeoutMs` budget, abort signal and
319
+ keep-on-error rules, with the busy status and working message shown.
320
+
321
+ **The checkpoint request** (`buildCheckpointRequest`; separate from the run-end request): there is no final
322
+ answer yet. The state holds `situation` (the agent is still working; this is an earlier call's output),
323
+ `earlier_conversation`, `user_question`, `agent_progress` (`latest_note`: the latest assistant text, at
324
+ most 2,000 characters; `earlier_notes`: earlier text of the run, clipped from the start to 2,000),
325
+ `later_tool_calls` (labels of the calls made after this output, oldest first; above 30 the first and last
326
+ 15 with a gap note), `superseded` when it applies, and then tool, status, arguments, size and chunks as at
327
+ run end. The questions ask whether the agent will still need the output (or chunk) to finish
328
+ `user_question`; out-of-date lines and lines it has already acted on and moved past are not needed. The
329
+ probe behind the wording and the threshold is in [JEV.md](JEV.md#checkpoint-wording).
330
+
331
+ **Superseded** (`supersededBy`), judged from the later tool calls: a `read` whose file was later changed by
332
+ `edit`/`write` ("this file was changed after this read (edit src/a.ts, 3 turns later)") or read again with
333
+ the same offset and limit; a command that ran again (whitespace and a trailing `2>&1` normalized); any other
334
+ tool called again with identical arguments.
335
+
336
+ **State from the branch.** The memo and the pending edits are derived, at that moment, from the entries
337
+ persisted on the active branch (`ctx.sessionManager.getBranch()`). A boundary's drafts are not final: a
338
+ later handler can replace the list, and one invalid draft makes Pi discard all of them; state taken from
339
+ the returned drafts would then skip those outputs forever. Reading the branch also makes `/tree`,
340
+ compaction and reloads correct without any rebuild step. The only in-memory state is the log of cache
341
+ entries this process wrote (below), which only ever adds a read point.
342
+
343
+ **Memo.** Each output is judged mid-run at most once. The memo is the set of output results in the mid-run
344
+ records on the branch, except those Jev never answered for (`timeout`, `error`, `aborted`), which the next
345
+ checkpoint asks again. A "keep" is therefore not re-asked at every checkpoint. Old exchanges have their own
346
+ memo per run (see Items).
347
+
348
+ **Run end judges kept outputs again.** An output kept at a checkpoint (whole, or `not-worth`) is a
349
+ candidate again at run end, this time with the final answer: files the agent was still editing are usually not
350
+ needed once the task is done. Distilled outputs carry the marker and are skipped by the candidate rules.
351
+
352
+ **Anthropic breakpoints** (`placeGuardBreakpoints`). Every Anthropic request gets the previous-question
353
+ pin (the floor; with `pi-claude-auth` that block also carries the relocated system prompt). On the first
354
+ request after **pending edits** (`context_edit` entries on the branch after the last answered assistant
355
+ message, whether from a checkpoint, from run end on the next prompt, or from another extension; a failed or
356
+ aborted response does not count, since its retry repeats the request), `cacheAnchors` adds up to two more:
357
+
358
+ - **Read point**, only at a cache entry this process saw itself write. After placing its breakpoints, the
359
+ extension records every `tool_result` block that carries a breakpoint in the payload (Pi's rolling one
360
+ and its own): entry id, model, time, and the last branch entry at that moment. An entry is trusted only
361
+ if the same model wrote it, or read through it, within the TTL (5 minutes), the entry and that last
362
+ branch entry are still on the branch, and since then no `context_edit` touched anything at or before it
363
+ and no compaction or branch summary was appended. The latest trusted entry before the first edited entry
364
+ is the read point. The log is cleared on `session_start` (and so on reload); anything uncertain falls
365
+ back to the question pin.
366
+ - **Refresh on read-through** (`message_end`, `refreshOnReadThrough`): reading a longer prefix keeps the
367
+ entries on its path alive (CACHE.md), so a response moves a logged entry's time to its request's time
368
+ when the request went to the same model, the entry's prefix is unchanged, and `cacheRead` reaches past
369
+ the entry with a margin: at least the run's baseline (the full input of its first request: system prompt,
370
+ tools, question) plus the projected characters from the question to the entry divided by 1.5. Code and
371
+ logs run at about 2.3 characters per token and prose at about 4, so this overestimates the tokens up to
372
+ the entry. Without a baseline, or with any doubt, nothing is refreshed.
373
+ - **Write anchor:** the end of the tool-result batch holding the first edited entry, so the edited prefix
374
+ gets an entry that a later checkpoint can read. It counts as written only once it was seen in a request
375
+ the extension sent (the same log).
376
+
377
+ Priority under the 4-breakpoint budget: after dropping Pi's redundant identity and tools breakpoints, the
378
+ question comes first, then the read point, then the write anchor. Cache-warming replays add no assistant
379
+ message, so they get the same breakpoints. Measured effect and the TTL findings:
380
+ [CACHE.md](CACHE.md#mid-run-checkpoints).
381
+
189
382
  ## Replacement format (`render.ts`)
190
383
 
191
384
  Kept chunks are copied verbatim, in order. Each run of removed chunks becomes one omission line. For
@@ -208,21 +401,27 @@ The draft is `{ type: "context_edit", targetId, replacement: { content: [{ type:
208
401
 
209
402
  ## The `context-guard` record
210
403
 
211
- Each run that made at least one Jev request also appends a custom entry (`customType: "context-guard"`):
404
+ Each run (and each mid-run checkpoint) that made at least one Jev request also appends a custom entry
405
+ (`customType: "context-guard"`):
212
406
 
213
407
  | Field | Meaning |
214
408
  |---|---|
215
409
  | `v` | Format version, `1` |
410
+ | `phase` | `"mid-run"` for checkpoints, `"run-end"` otherwise (a record without it counts as run end) |
216
411
  | `model` | Classifier used |
217
412
  | `savedChars` | Characters removed by this run's edits |
218
413
  | `requests`, `inputTokens`, `costUsd`, `ms` | Jev usage for the run |
219
414
  | `timedOut` | The time budget ran out |
220
- | `results[]` | One per candidate: `entryId`, `tool`, `label`, `outcome` (`distilled`, `removed`, `kept`), `reason`, `beforeChars`, `afterChars`, `jev` |
415
+ | `results[]` | One per item: `kind` (`large`, `small`, `exchange`; absent means large), `entryId` (for an exchange, its prompt entry), `tool`, `label`, `outcome` (`distilled`, `removed`, `kept`), `reason`, `beforeChars`, `afterChars`, `jev` |
221
416
 
222
417
  `reason` is one of `chunks`, `none-needed`, `not-worth`, `all-chunks`, `whole-needed`, `whole-chosen`,
223
- `no-answer`, `error` or `timeout` (several are joined with commas). `jev` holds one trace per segment, such as
418
+ `no-answer`, `error` or `timeout` (several are joined with commas) for large outputs, `needed` or
419
+ `not-needed` for small outputs, and `relevant`, `unrelated` or `deferred` for exchanges (an omitted exchange's
420
+ result also has `omitted`, the entry ids that got `replacement: null`). Whole items have the trace
421
+ `p.06` (P of the bool). `jev` holds one trace per segment, such as
224
422
  `kw.15 focus=chunk_4:.52 keep 9/29` or `kw.82 focus=whole:.71 keep all (whole-needed)`. That is
225
- P(`keep_whole`), the focus choice and its probability, and the result. `/guard` prints these for the last run.
423
+ P(`keep_whole`), the focus choice and its probability, and the result. `/guard` prints these for the last
424
+ record (run or checkpoint) and counts the checkpoints.
226
425
 
227
426
  The extension does not write Jev's usage into Pi's own usage entries. It is recorded only here.
228
427
 
@@ -231,48 +430,73 @@ The extension does not write Jev's usage into Pi's own usage entries. It is reco
231
430
  Nothing is kept in memory. Every redraw recomputes from the session:
232
431
 
233
432
  - **Savings:** Pi's session projection (`buildSessionProjection()`) is checked for tool results whose
234
- model-visible text starts with `[context-guard]` while the raw entry does not. Each one adds raw length
235
- minus visible length. Tokens are estimated as characters ÷ 4.
236
- - **Jev usage:** the `context-guard` records on the active branch (`getBranch()`) are summed.
433
+ model-visible text starts with `[context-guard]` while the raw entry does not; each one adds raw size
434
+ minus visible size (`size.ts`: text length, images by pixels). Results that lost only their images count
435
+ as image results. A prompt that shows the exchange stub adds the raw size of its exchange (the
436
+ prompt and the entries up to the next prompt that the model no longer sees: Pi keeps an omitted
437
+ entry in the projection with no messages) minus the stub.
438
+ Tokens are estimated as characters ÷ 4.
439
+ - **Lifetime:** the `savedChars` of every `context-guard` record on the active branch, and whether a
440
+ compaction came after a record that saved something (`compacted`).
441
+ - **Jev usage:** the same records are summed.
237
442
 
238
443
  So the numbers follow branches, `/tree`, reloads and compaction.
239
444
 
240
- The ready-state footer is `🛡 −4.2k · 1`: ≈ tokens kept out of context · distilled results (the first part
241
- in the theme's success colour, the rest dimmed). Before anything is distilled it shows `🛡 0 saved`.
445
+ The ready-state footer is `🛡 −4.2k · 1`: ≈ tokens kept out of context · distilled outputs, outputs without
446
+ their images and omitted exchanges (the first part in the theme's success colour, the rest dimmed). Before
447
+ anything is distilled it shows `🛡 0 saved`. After a compaction dropped earlier edits it adds the lifetime
448
+ total: `🛡 0 · Σ−79k`, or `🛡 −4.2k · 3 · Σ−83k` once new edits exist (only when Σ is larger).
242
449
 
243
450
  ## Recall
244
451
 
245
452
  `recall` reads the raw entry with `ctx.sessionManager.getEntry(entryId)`. `context_edit` never changes that
246
- entry. The tool returns the whole text, or only lines that match `pattern` (with line numbers), or an
453
+ entry. For a tool result it returns the output and its images (image blocks after the text, at most 6, only
454
+ for a plain recall: with `pattern` or `offset` a note says how many there are); for a user prompt (the stub of an omitted exchange) it
455
+ returns a transcript of the raw exchange from the branch: `## user`, `## assistant` with the text and
456
+ `[tool call] name {arguments}` lines, `## tool result (tool)` and `## custom message (type)` sections, for
457
+ exactly the entries the latest record lists as `omitted` (without such a record, up to the next prompt).
458
+ Images of the exchange's user messages are attached (at most 6); a tool result's images are named with a
459
+ `recall({"entryId":…})` line for that result. The tool returns the whole text, or only lines that match `pattern` (with line numbers), or an
247
460
  `offset`/`limit` window. Output is capped at 2,000 lines and 50 KB, with a continuation note; a single
248
461
  line longer than 50 KB is cut with a `[line N cut at 50KB]` note. `pattern` is a JavaScript regex of at most
249
462
  500 characters, matched against the first 4,000 characters of each line inside a `node:vm` sandbox with a
250
463
  1-second timeout, so a catastrophic pattern returns an error instead of freezing Pi. An id that is
251
- not a tool result gives an error that tells the model to use the id from a `[context-guard]` header.
464
+ neither a tool result nor a user prompt on the branch gives an error that tells the model to use the id
465
+ from a `[context-guard]` note.
252
466
 
253
467
  ## Pitfalls
254
468
 
255
- - **Never use `replacement: null` on a tool result.** The matching `tool_use` would lose its result, and
256
- providers reject the request. A fully removed result is always replaced with a one-line stub.
257
- - **Never edit assistant messages.** A replacement turns the content into one text block, which drops
258
- thinking, signatures and tool calls.
259
- - **Steering messages move the span.** A user message sent during a run starts a new span. Tool results
260
- from before it are never candidates, now or later. That is the safe direction.
469
+ - **Never advance state on returned drafts.** A later handler may replace them, or Pi may reject the list;
470
+ read state back from the branch.
471
+ - **Never omit half of a tool call.** `replacement: null` on a tool result alone would leave its `tool_use`
472
+ without a result, and providers reject the request. A single output that is not needed is replaced with
473
+ a one-line stub; `null` is only used for whole exchanges, which omit the assistant message with the call
474
+ and its results together.
475
+ - **Never replace an assistant message's content.** A replacement turns the content into one text block,
476
+ which drops thinking, signatures and tool calls. Assistant messages are only omitted, as part of a whole
477
+ exchange.
478
+ - **Steering messages continue the run.** Until 0.2.0 a user message sent during a run started a new span,
479
+ and tool results from before it were never candidates again. In real sessions that left 0.1–0.4M
480
+ characters per steered run in context for good (they also blocked omitting the exchange, which contains
481
+ images). Now the span goes back to the prompt and the steering messages join the question. The
482
+ Anthropic question pin sits on the last user message with text (the steering message), so an edit before
483
+ it has no readable pin: `anthropicRewriteChars` counts the whole context unless a trusted read point
484
+ applies.
485
+ - **Recall outputs are not judged mid-run.** The model just asked for them. Judging them at the next
486
+ checkpoint made models recall the same entry again (seen in real sessions). At run end they are judged
487
+ like other outputs.
261
488
  - **`keepCitedFiles` is off by default.** Answers also name files to rule them out. In a live run, Claude's
262
489
  answer said `test/fixtures/entities.json` was *not* a usage, and the option kept 40 lines of noise from
263
490
  that file. See [JEV.md](JEV.md).
264
- - **Only the run that just finished is edited.** Editing older runs would break the prompt cache from that
265
- point on, for every later turn.
491
+ - **Earlier runs are only edited as whole exchanges.** Editing single outputs of older runs would break the
492
+ prompt cache from that point on for little gain; an old exchange is omitted as a whole, once, when it is
493
+ unrelated to the current work, and only its first later request pays the rewrite.
266
494
  - **Don't distill from a `context` hook.** Output that varies between requests makes every request miss the
267
495
  cache.
268
496
 
269
497
  ## Open questions
270
498
 
271
- 1. Do Pi's cache-warming replays (`cache-warmer.js`, `streamSimple`) go through `before_provider_request`?
272
- If not, warm requests do not carry the question breakpoint. Not checked.
273
- 2. Should the pin go on the last unedited tool result instead of the question? That would keep more of the
274
- run cached on the next prompt. Not tried.
275
- 3. Other classifier entries (e.g. `typesafe/jev-latest` with a 64k context) and non-code outputs have
499
+ 1. Other classifier entries (e.g. `typesafe/jev-latest` with a 64k context) and non-code outputs have
276
500
  not been tested.
277
- 4. The OpenAI Codex WebSocket transport loses its delta continuation after an edit
501
+ 2. The OpenAI Codex WebSocket transport loses its delta continuation after an edit
278
502
  ([CACHE.md](CACHE.md#openai)). It is not clear whether anything can be done about that from an extension.
package/docs/JEV.md CHANGED
@@ -53,7 +53,7 @@ question per chunk separated them clearly: cited files 0.90–0.93, noise 0.14
53
53
  A 30k-character grep with 28 chunks and 30 questions: **13,155 input tokens, 745 ms, $0.00055**. Code is
54
54
  about 2.3 characters per token.
55
55
 
56
- **Worst-case budget probe** (after earlier conversation was added to the state): every history part at its
56
+ **Worst-case budget probe** (with earlier conversation in the state): every history part at its
57
57
  clip limit (2,000-character summary, 1,000-character first request, 800 + 1,200 characters per exchange),
58
58
  a 4,000-character question, a 6,000-character answer, 2,000 characters of notes, and one 40-chunk segment of
59
59
  31,848 characters of dense `rg` output over Pi's `dist/core`:
@@ -64,8 +64,8 @@ a 4,000-character question, a 6,000-character answer, 2,000 characters of notes,
64
64
  | 10 (maximum) | 83,214 | **23,619** | 799 ms | $0.00099 |
65
65
 
66
66
  Both fit Jev's 32k-token context with room to spare (the target was ≤ 28k), so `maxSegmentChars` is
67
- **32,000** (lowered from 40,000 to keep the worst case well below 32k now that history is included) and
68
- `maxChunksPerSegment` stays 40. Nested objects in the state (`earlier_conversation`) are accepted.
67
+ **32,000**, which keeps the worst case well below 32k with history included, and `maxChunksPerSegment`
68
+ is 40. Nested objects in the state (`earlier_conversation`) are accepted.
69
69
 
70
70
  In the live end-to-end runs, one qualifying run took 2 Jev requests: 550 ms and $0.00083 (GPT-6 Luna run), and
71
71
  1.18 s and $0.00084 (Claude run).
@@ -98,14 +98,14 @@ noise. It is off by default.
98
98
  ### Small reads are not worth it
99
99
 
100
100
  `read` results of about 2.3k characters always ended as `not-worth`: Jev kept 2 of 3 chunks. So
101
- `minResultChars` was raised to 4,000.
101
+ `minResultChars` is 4,000.
102
102
 
103
103
  ### Earlier conversation (`historyExchanges`)
104
104
 
105
105
  Without history, Jev only sees the run's own question and answer. A broad search in a session whose goal was
106
106
  stated earlier ("I'm about to migrate every API client to a shared retry helper") then loses exactly the
107
107
  lines the ongoing work needs, because this run's question ("where is StatusBadge used?") does not mention
108
- them. So the state now carries `earlier_conversation`: the latest compaction/branch summary, the first
108
+ them. So the state carries `earlier_conversation`: the latest compaction/branch summary, the first
109
109
  prompt and the last 3 exchanges (prompt + last assistant text), clipped (docs/DESIGN.md).
110
110
 
111
111
  **Wording.** Offline A/B on the real turn-2 run of the e2e `history` scenario (Claude's `rg -n -i status`,
@@ -126,28 +126,93 @@ still borderline (the relevant chunks sit around the threshold), so history make
126
126
  what the ongoing work needs, not reliably all of it; `recall` remains the safety net.
127
127
 
128
128
  **Live A/B** (`node test/e2e/run-e2e.mjs --scenario history`, with and without `--config
129
- '{"historyExchanges":0}'`). Turn 1 states the retry-migration goal, turn 2 is the broad StatusBadge search,
130
- turn 3 asks which clients retry on 429 without a new search (9 of the 10 clients do). Round 1 used the
131
- first wording above, round 2 the final one; one run per cell:
132
-
133
- | Model, history | Round | src/api retry lines kept in turn 2 (of 80; with 429) | Turn-2 output, chars before → after | Jev | Turn 3 used `recall` | Turn 3 answer |
134
- |---|---|---|---|---|---|---|
135
- | GPT-6 Luna, on | 1 | 0 | 36,113 → 1,772 | 518 ms | yes | correct |
136
- | GPT-6 Luna, off | 1 | 0 | 36,113 → 2,100 | 594 ms | no | wrong ("can't determine") |
137
- | Claude Sonnet 5.5, on | 1 | 0 | 36,113 → 2,612 | 685 ms | yes | correct |
138
- | Claude Sonnet 5.5, off | 1 | 8 (3) | 36,113 → 4,439 | 588 ms | yes | correct list; headline says "10" |
139
- | GPT-6 Luna, on | 2 | **72 (21)** | 36,113 → 12,402 | 568 ms | yes | correct |
140
- | GPT-6 Luna, off | 2 | 0 | 36,113 → 1,753 | 564 ms | yes | correct |
141
- | Claude Sonnet 5.5, on | 2 | 5 (1) | 22,641 → 2,103 (Claude ran `rg … \| head -200`) | 555 ms | yes | missed `ticketStatus` (cut by Claude's own `head -200`) |
142
- | Claude Sonnet 5.5, off | 2 | 0 | 36,113 → 2,638 | 547 ms | yes | correct list; headline says "eight" |
143
-
144
- Honest reading: with the first wording, history made no difference to what was kept (0 retry lines with
145
- history; the one run that kept 8 had history off). With the final wording, GPT round 2 kept 72 of the 80
146
- retry lines (9 of the 10 clients), at the cost of keeping 12.4k instead of ~1.8k characters; Claude round 2
147
- ran a different, truncated search (`| head -200`) and kept 5. It did not change turn-3 behaviour: the main models used `recall` in 7 of 8
148
- runs either way, and the turn-3 answers were about as good with and without history. Where history pays
149
- off is the context the model already has after turn 2, which avoids the recall round trip only when enough
150
- is kept. Single runs per cell; the variance between rounds is large.
129
+ '{"historyExchanges":0}'`), with the wording `src/decide.ts` sends. Turn 1 states the retry-migration goal,
130
+ turn 2 is the broad StatusBadge search, turn 3 asks which clients retry on 429 without a new search (9 of
131
+ the 10 clients do); one run per cell:
132
+
133
+ | Model, history | src/api retry lines kept in turn 2 (of 80; with 429) | Turn-2 output, chars before → after | Jev | Turn 3 used `recall` | Turn 3 answer |
134
+ |---|---|---|---|---|---|
135
+ | GPT-6 Luna, on | **72 (21)** | 36,113 → 12,402 | 568 ms | yes | correct |
136
+ | GPT-6 Luna, off | 0 | 36,113 → 1,753 | 564 ms | yes | correct |
137
+ | Claude Sonnet 5.5, on | 5 (1) | 22,641 → 2,103 (Claude ran `rg … \| head -200`) | 555 ms | yes | missed `ticketStatus` (cut by Claude's own `head -200`) |
138
+ | Claude Sonnet 5.5, off | 0 | 36,113 → 2,638 | 547 ms | yes | correct list; headline says "eight" |
139
+
140
+ With history, GPT kept 72 of the 80 retry lines (9 of the 10 clients), at the cost of keeping 12.4k instead
141
+ of ~1.8k characters; Claude ran a truncated search and kept 5. A round with the second wording of the
142
+ table above kept no retry lines with history. History did not change turn-3 behaviour: the models used
143
+ `recall` either way, and the answers were about as good. Where it pays off is the context the model already
144
+ has after turn 2. Single runs per cell; the variance between runs is large.
145
+
146
+ ### Checkpoint wording
147
+
148
+ Mid-run checkpoints ask a different question: there is no final answer, so Jev judges whether the agent
149
+ will still need an output to finish the task (`buildCheckpointRequest`; state and wording in
150
+ [DESIGN.md](DESIGN.md#mid-run-checkpoints)). Probed offline on real mid-run states from two no-guard runs
151
+ of the e2e `long` scenario (`fix all failing tests`), two repetitions per variant, reporting the share of
152
+ the output's characters that would be kept at chunk thresholds 0.5 / 0.55 / 0.6:
153
+
154
+ | Output (state) | What should happen | base wording | "acted" wording |
155
+ |---|---|---|---|
156
+ | GPT, 6 old `npm test` logs, each rerun later (`superseded`) | mostly go | 0.5: 15–69%, 0.6: **8–36%** | 0.5: 15–45%, 0.6: 15–30% |
157
+ | Claude, first `npm test` log (still the only full failure list; later runs were filtered) | keep the unresolved failures | 0.5: 73–80%, 0.6: 35–41% | 0.5: 80–87%, 0.6: 33–46% |
158
+ | Claude, `cat` of 5 source files, **all fixed since** | go | 0.5: 49–62%, 0.6: **11%** | 11% at every threshold |
159
+ | Claude, the same `cat` one turn after it ran, **files not fixed yet** | stay | 0.5: 100%, 0.6: **78%** | 0.5: 90%, 0.6: **10%** |
160
+
161
+ - *base*: "To finish user_question, will the agent still need the lines in chunk_N? Lines that are out of
162
+ date or that the agent has already acted on and moved past are not needed."
163
+ - *acted*: "Will the agent need to look at chunk_N again before it finishes user_question? Say no if
164
+ later_tool_calls show the agent already acted on it, or if superseded says a newer version exists."
165
+ It dropped the in-use files (10%) and was rejected.
166
+ - A stronger `superseded` sentence ("the agent still sees that newer output…"), with the acted wording, did not help (15–65% at 0.5).
167
+
168
+ Mid-run probabilities are compressed (per-chunk 0.14–0.76, against 0.14–0.93 at run end). At 0.5,
169
+ superseded logs and finished files kept 50–70%, which `maxKeepRatio` (0.6) would then turn into "not
170
+ worth it", so most checkpoints would edit nothing. **Base wording at 0.6** separated the cases: in-use
171
+ files 78%, finished files 11%, superseded logs 8–36%. So `midRunChunkKeepThreshold` defaults to 0.6, the
172
+ same as run end, not lower: the brief proposed starting at 0.5 to keep more, and the probe showed that
173
+ 0.5 keeps nearly everything.
174
+
175
+ ### Old exchanges
176
+
177
+ `buildExchangeRequest` asks one bool per exchange: "Is exchange_N still relevant to the current work
178
+ (user_question and what the agent is doing for it)? Relevant means the agent may need its details: the
179
+ same files, task, decisions or facts." Probed with `node test/e2e/probe-items.mjs` (2 repetitions):
180
+
181
+ - three hand-written sessions (`test/e2e/probe-data/exchanges.json`) modeled on the shape of real long
182
+ main-thread sessions (a prompt, a final answer, tool-call labels, a size; 5 old exchanges each, mixed
183
+ relevant and unrelated to the current task);
184
+ - the eligible exchanges at prompts 4 and 5 of two no-guard runs of the `topics` scenario (GPT and Claude,
185
+ public test repo): the first exchange (API retries) is unrelated to prompt 4 (`StatusBadge`) and relevant
186
+ to prompt 5 (its follow-up).
187
+
188
+ | Label | P(relevant), 42 answers |
189
+ |---|---|
190
+ | relevant (16) | 0.47–0.96 (the weakest: "add a disk-usage alert for the backup volume" for a failing-backup task, 0.47–0.49; "how is contextTokens estimated with edits" for a compaction-settings task, 0.68) |
191
+ | unrelated (26) | 0.03–0.10 |
192
+
193
+ | Omit when P < | Accuracy | Relevant exchanges omitted | Unrelated exchanges omitted |
194
+ |---|---|---|---|
195
+ | 0.1 – 0.4 | 42/42 | 0/16 | 26/26 |
196
+ | 0.5 | 41/42 | 1/16 | 26/26 |
197
+
198
+ `exchangeOmitThreshold` is 0.2: omit only when Jev is clearly sure, with a wide margin to the weakest
199
+ relevant exchange (0.47). The sessions are synthetic or generated, so real sessions may be less clear-cut;
200
+ the margin is meant to absorb that. Repetitions agreed within 0.03.
201
+
202
+ ### Small outputs
203
+
204
+ `buildSmallRequest` asks one bool per output ("will the agent still need the output in item_N?" mid-run;
205
+ "does item_N contain anything that final_answer relies on, or that a likely follow-up would need?" at run
206
+ end), with `superseded` shown when a later call made the output out of date.
207
+
208
+ | Probe | Needed | Not needed |
209
+ |---|---|---|
210
+ | Two hand-written mid-run states on the `long` fixture's code (`test/e2e/probe-data/small.json`), 24 answers | 0.59–0.74 | 0.06–0.32 |
211
+ | A `long` run of GPT-6 Luna (public fixture), mid-run at request 12 and at run end: reads of source files | not superseded: 0.48–0.68 | superseded (the file was edited afterwards): 0.19–0.41 |
212
+
213
+ At 0.6 (the chunk threshold) 2 of 10 needed outputs would be dropped; at 0.45 none, and every superseded or
214
+ unneeded output goes. `smallKeepThreshold` is 0.45. Without the `superseded` hint, Jev kept reads of files
215
+ the agent had already edited (0.49–0.86), so the hint is part of the request.
151
216
 
152
217
  ## The final question set
153
218
 
@@ -175,5 +240,8 @@ answer did not quote but that a natural next question would need.
175
240
  | `noneThreshold` | 0.5 | Remove a whole output only if `none` wins and no chunk passes its own question |
176
241
  | `maxKeepRatio` | 0.6 | If most of the output is kept anyway, the saving does not justify the cache miss and the lost context |
177
242
  | `minResultChars` | 4,000 | Reads around 2.3k characters were never worth an edit |
243
+ | `smallKeepThreshold` | 0.45 | Small-output probe above: needed outputs ≥ 0.48, superseded or unneeded ≤ 0.41 |
244
+ | `exchangeOmitThreshold` | 0.2 | Exchange probe above: unrelated ≤ 0.10, relevant ≥ 0.47; omit only when Jev is clearly sure |
245
+ | `midRunChunkKeepThreshold` | 0.6 | Checkpoint probe above: 0.5 kept 50–100% of everything; 0.6 kept in-use files and dropped finished ones |
178
246
 
179
247
  When an answer is missing, the request fails or the budget runs out, the extension keeps everything.