pi-context-guard-jev 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/DESIGN.md CHANGED
@@ -9,26 +9,33 @@ 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 |
16
19
  | `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 |
20
+ | `src/cache-pin.ts` | Anthropic breakpoints: the previous user question, and the read point after edits (`placeGuardBreakpoints`) |
18
21
  | `src/stats.ts` | Savings and Jev usage computed from the session; status-bar text |
19
- | `src/recall.ts` | The text returned by `recall` |
22
+ | `src/recall.ts` | The text returned by `recall` (a tool output, or the transcript of an omitted exchange) |
20
23
  | `src/types.ts` | Loose structural types for the Pi objects used, so the pure modules can be tested without Pi |
21
24
 
22
25
  Everything except `index.ts` is pure. `distillRun` gets the classifier as an injected function.
23
26
 
24
- ## The boundary: `agent_before_settle`
27
+ ## The boundaries: `agent_before_settle` and `turn_end`
25
28
 
26
- The work happens in `agent_before_settle`, and only when `event.outcome === "completed"` and the guard is on.
29
+ Run-end distillation happens in `agent_before_settle`, and only when `event.outcome === "completed"` and
30
+ the guard is on. Long runs also get mid-run checkpoints at `turn_end` (see
31
+ [Mid-run checkpoints](#mid-run-checkpoints)); everything below about drafts applies to both.
27
32
 
28
33
  Why there:
29
34
 
30
35
  - The final answer exists, so relevance can be judged against it. Pruning during a run would take raw
31
36
  data away from the model while it still needs it, and every edit would break the prompt cache again.
37
+ Mid-run checkpoints accept those costs only for outputs that are several turns old, in rare batches,
38
+ and only when the break-even rule says they pay off.
32
39
  - A boundary handler can return session entry drafts. A `context_edit` draft is append-only: it changes
33
40
  only what the model sees from then on. The raw entry stays in the session file, the UI and exports, and
34
41
  `ctx.sessionManager.getEntry(id)` still returns it. That is what `recall` reads.
@@ -45,9 +52,11 @@ Hooks used:
45
52
  | Hook | Use |
46
53
  |---|---|
47
54
  | `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 |
55
+ | `turn_end` | Mid-run checkpoint, when a batch is due |
56
+ | `agent_before_settle` | Distill the finished run (skipping outputs judged mid-run) |
57
+ | `before_provider_request` | Anthropic breakpoints (only for `anthropic-messages` models) |
58
+ | `session_tree`, `session_compact` | Redraw the status (checkpoint state is read from the branch anyway) |
59
+ | `agent_settled` | Redraw the status |
51
60
 
52
61
  ## Algorithm
53
62
 
@@ -186,6 +195,168 @@ In this order:
186
195
  usually already cleared at this boundary, so Esc does not cancel it; the time budget is the limit.
187
196
  - An exception from the whole pass shows a warning and leaves the run untouched.
188
197
 
198
+ ## Items: what one pass judges
199
+
200
+ Every pass (run end, or a mid-run checkpoint) collects three kinds of item and judges them in parallel
201
+ (`processItems`); their edits and results go into one record.
202
+
203
+ | Kind | What | How it is judged | Dropped item becomes |
204
+ |---|---|---|---|
205
+ | `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 |
206
+ | `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":"…"}).` |
207
+ | `exchange` | Old exchanges (below) | Whole: one bool per exchange, several per request | A stub on the prompt, every other entry omitted |
208
+
209
+ **Small outputs** (`collectSmall`). An output whose stub would not be shorter is skipped. Each item carries
210
+ the tool label, `status: failed` for error results, `superseded` when a later call made it out of date
211
+ (`supersededBy`, as for large outputs mid-run), and the output (at most 4,000 characters). The question is
212
+ "will the agent still need the output in item_N?" mid-run, or "does item_N contain anything that
213
+ final_answer relies on, or that a likely follow-up would need?" at run end; both say that an output marked
214
+ superseded is out of date. An item is kept when P ≥ `smallKeepThreshold` (0.45; probe in
215
+ [JEV.md](JEV.md#small-outputs)). Mid-run, small outputs follow the same age rule and memo as large ones; at
216
+ run end every small output without the marker is judged, including those kept at a checkpoint.
217
+
218
+ **Old exchanges** (`collectExchanges`). An exchange is a user prompt and every entry up to the next one:
219
+ assistant messages, tool calls and results, custom messages such as subagent notices. Eligible are
220
+ exchanges before the current run's user message, without the last `keepRecentExchanges` (2) before it,
221
+ that completed (their last assistant message has no tool calls, so a span cut by a steering message is
222
+ never one), that are not already omitted (their prompt shows the stub), that were not judged during the
223
+ current run (`exchangeMemo`: exchange results in records after the last user message, except deferred
224
+ ones), and that can be recovered and are worth it: no message with an image, no entry whose model-visible
225
+ content differs from the raw entry for any reason other than our own marker (another extension's edit,
226
+ or an omission by someone else, which Pi keeps in the projection as an entry with no messages), at least
227
+ 2,000 characters of editable content, and a stub shorter than that content. A kept exchange is
228
+ judged again in a later run, against that run's work. Compaction and branch summaries are never part of an
229
+ exchange; entries Pi cannot edit (system messages, `!` shell executions) stay in place.
230
+
231
+ Jev sees the current work (`user_question`, `earlier_conversation`, and `agent_progress` mid-run or
232
+ `final_answer` at run end) and, per exchange, the prompt (at most 600 characters), the last assistant text
233
+ (800), the tool calls as `toolLabel()`s (at most 20) and the size. The question: "Is exchange_N still
234
+ relevant to the current work? Relevant means the agent may need its details: the same files, task,
235
+ decisions or facts." An exchange is omitted only when P < `exchangeOmitThreshold` (0.2; probe in
236
+ [JEV.md](JEV.md#old-exchanges)). Then the prompt entry gets the stub
237
+ `[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>"}).`
238
+ and every other editable entry gets `replacement: null`, which omits it from model context. The record's
239
+ result for the exchange lists exactly those entry ids (`omitted`), and `recall` on the stub shows exactly
240
+ the prompt and those entries. Tool calls and
241
+ their results are omitted together. The resulting shapes (a stub user message right before the next user
242
+ message; spans without assistant and tool-result messages) are accepted by Anthropic (with
243
+ `pi-claude-auth`) and OpenAI Codex, checked live with `test/e2e/omit-probe.ts` and the `topics` scenario.
244
+
245
+ **Run-end break-even for exchanges** (`exchangeBreakEven`, on). After the pass is judged, its exchange
246
+ omissions are checked: their marginal cache cost is the rewrite with them minus the rewrite the pass's
247
+ other edits cause anyway (`rewriteCharsFor`, with the trusted read point and floor rules of
248
+ [CACHE.md](CACHE.md); on OpenAI Codex any edit costs the whole context, so exchanges ride along for free
249
+ when the pass edits anything else). If that cost is above zero, the omissions must save at least
250
+ `exchangeMinSavingChars` (8,000) and pass `paysOff` with as many later requests as the session has had so
251
+ far (model-visible assistant messages). Otherwise they are deferred: their edits are dropped, their results
252
+ say `kept` / `deferred`, and they are judged again at later run ends against the work of that run, so
253
+ deferred exchanges accumulate until their total pays off. `exchangeBreakEven: false` always omits
254
+ unrelated exchanges.
255
+
256
+ **Batching.** Whole items are grouped into requests of at most `maxSegmentChars` characters of item text and
257
+ `maxChunksPerSegment` (40) items, under the same `timeoutMs` budget; the three kinds run at the same time,
258
+ and one shared limiter keeps at most `concurrency` (6) Jev requests in flight across all of them. An item without an
259
+ answer (error, timeout) is kept and asked again next time.
260
+
261
+ ## Mid-run checkpoints
262
+
263
+ A 30–120 minute autonomous run would otherwise carry every tool output until it ends; Pi compacts only
264
+ near the window minus 16k tokens. Checkpoints judge older outputs while the run is still going.
265
+
266
+ **Hook (verified on Pi 1.0.3).** `turn_end` is an actionable boundary (`TurnEndEvent extends
267
+ BoundaryState`). `agent-session.js` dispatches it from `agent.finishTurn`, commits the returned drafts
268
+ (`_commitBoundaryDrafts` → `_refreshFinalizedContext`), and only then runs `prepareNextTurn`, which rebuilds
269
+ the messages from the session projection and runs the turn-end compaction check
270
+ (`_compactBeforeNextAssistantResponse`). So a `context_edit` returned at `turn_end` reaches the very next
271
+ provider request, before any compaction decision; the e2e runs confirm it (the next request's context
272
+ drops by the removed amount). The runner replaces the draft list as for `agent_before_settle`, so the
273
+ handler returns `[...event.entries, ...edits, record]`, and it never asks for `continue`.
274
+
275
+ **When.** At the end of a turn whose assistant message has tool calls (the final turn of a run is left to
276
+ run-end distillation), with `outcome: "completed"` and `midRun` on:
277
+
278
+ 1. **Eligible items:** large outputs (`collectCheckpoint`) and small outputs (`collectSmall`) of the current
279
+ run whose call was made at least `midRunMinAgeTurns` (4) turns before the current turn (turn = assistant
280
+ message index in the run) and that are not in the memo, and old exchanges not judged during this run.
281
+ 2. **Batch:** their total must reach `midRunBatchChars` (60,000; `midRunBatchCharsOpenAI` for
282
+ `openai-codex` models), so edits come in rare batches.
283
+ 3. **Break-even** (`midRunBreakEven`): the checkpoint must be likely to pay for its one-time cache
284
+ rewrite: `pending chars × turns so far ≥ factor × rewrite chars`, with factor 13 and the whole context
285
+ for OpenAI Codex; factor 16 for Anthropic, where the rewrite is the context after the read point the
286
+ next request would get (the latest trusted entry before the first eligible output, `trustedReadPoint`)
287
+ or, without one, after the question (the floor; with `pinAnthropicCache` off, the whole context); and
288
+ factor 16 and the context from the first eligible output on for other providers
289
+ (derivation in `checkpoint.ts` and [CACHE.md](CACHE.md#mid-run-checkpoints)). "Turns so far" stands in
290
+ for the turns still to come. The rewrite starts at the earliest edited entry: an old exchange comes
291
+ before the question, so with exchanges in the batch the Anthropic rewrite is everything after the latest
292
+ trusted entry before that exchange, or the whole context. If the whole batch does not pay off, the rule
293
+ is tried for the tool outputs alone, and the exchanges wait for run end.
294
+
295
+ Then all eligible outputs go to `distillRun` with the checkpoint request builder and
296
+ `chunkKeepThreshold = midRunChunkKeepThreshold` (0.6), under the same `timeoutMs` budget, abort signal and
297
+ keep-on-error rules, with the busy status and working message shown.
298
+
299
+ **The checkpoint request** (`buildCheckpointRequest`; separate from the run-end request): there is no final
300
+ answer yet. The state holds `situation` (the agent is still working; this is an earlier call's output),
301
+ `earlier_conversation`, `user_question`, `agent_progress` (`latest_note`: the latest assistant text, at
302
+ most 2,000 characters; `earlier_notes`: earlier text of the run, clipped from the start to 2,000),
303
+ `later_tool_calls` (labels of the calls made after this output, oldest first; above 30 the first and last
304
+ 15 with a gap note), `superseded` when it applies, and then tool, status, arguments, size and chunks as at
305
+ run end. The questions ask whether the agent will still need the output (or chunk) to finish
306
+ `user_question`; out-of-date lines and lines it has already acted on and moved past are not needed. The
307
+ probe behind the wording and the threshold is in [JEV.md](JEV.md#checkpoint-wording).
308
+
309
+ **Superseded** (`supersededBy`), judged from the later tool calls: a `read` whose file was later changed by
310
+ `edit`/`write` ("this file was changed after this read (edit src/a.ts, 3 turns later)") or read again with
311
+ the same offset and limit; a command that ran again (whitespace and a trailing `2>&1` normalized); any other
312
+ tool called again with identical arguments.
313
+
314
+ **State from the branch.** The memo and the pending edits are derived, at that moment, from the entries
315
+ persisted on the active branch (`ctx.sessionManager.getBranch()`). A boundary's drafts are not final: a
316
+ later handler can replace the list, and one invalid draft makes Pi discard all of them; state taken from
317
+ the returned drafts would then skip those outputs forever. Reading the branch also makes `/tree`,
318
+ compaction and reloads correct without any rebuild step. The only in-memory state is the log of cache
319
+ entries this process wrote (below), which only ever adds a read point.
320
+
321
+ **Memo.** Each output is judged mid-run at most once. The memo is the set of output results in the mid-run
322
+ records on the branch, except those Jev never answered for (`timeout`, `error`, `aborted`), which the next
323
+ checkpoint asks again. A "keep" is therefore not re-asked at every checkpoint. Old exchanges have their own
324
+ memo per run (see Items).
325
+
326
+ **Run end judges kept outputs again.** An output kept at a checkpoint (whole, or `not-worth`) is a
327
+ candidate again at run end, this time with the final answer: files the agent was still editing are usually not
328
+ needed once the task is done. Distilled outputs carry the marker and are skipped by the candidate rules.
329
+
330
+ **Anthropic breakpoints** (`placeGuardBreakpoints`). Every Anthropic request gets the previous-question
331
+ pin (the floor; with `pi-claude-auth` that block also carries the relocated system prompt). On the first
332
+ request after **pending edits** (`context_edit` entries on the branch after the last answered assistant
333
+ message, whether from a checkpoint, from run end on the next prompt, or from another extension; a failed or
334
+ aborted response does not count, since its retry repeats the request), `cacheAnchors` adds up to two more:
335
+
336
+ - **Read point**, only at a cache entry this process saw itself write. After placing its breakpoints, the
337
+ extension records every `tool_result` block that carries a breakpoint in the payload (Pi's rolling one
338
+ and its own): entry id, model, time, and the last branch entry at that moment. An entry is trusted only
339
+ if the same model wrote it, or read through it, within the TTL (5 minutes), the entry and that last
340
+ branch entry are still on the branch, and since then no `context_edit` touched anything at or before it
341
+ and no compaction or branch summary was appended. The latest trusted entry before the first edited entry
342
+ is the read point. The log is cleared on `session_start` (and so on reload); anything uncertain falls
343
+ back to the question pin.
344
+ - **Refresh on read-through** (`message_end`, `refreshOnReadThrough`): reading a longer prefix keeps the
345
+ entries on its path alive (CACHE.md), so a response moves a logged entry's time to its request's time
346
+ when the request went to the same model, the entry's prefix is unchanged, and `cacheRead` reaches past
347
+ the entry with a margin: at least the run's baseline (the full input of its first request: system prompt,
348
+ tools, question) plus the projected characters from the question to the entry divided by 1.5. Code and
349
+ logs run at about 2.3 characters per token and prose at about 4, so this overestimates the tokens up to
350
+ the entry. Without a baseline, or with any doubt, nothing is refreshed.
351
+ - **Write anchor:** the end of the tool-result batch holding the first edited entry, so the edited prefix
352
+ gets an entry that a later checkpoint can read. It counts as written only once it was seen in a request
353
+ the extension sent (the same log).
354
+
355
+ Priority under the 4-breakpoint budget: after dropping Pi's redundant identity and tools breakpoints, the
356
+ question comes first, then the read point, then the write anchor. Cache-warming replays add no assistant
357
+ message, so they get the same breakpoints. Measured effect and the TTL findings:
358
+ [CACHE.md](CACHE.md#mid-run-checkpoints).
359
+
189
360
  ## Replacement format (`render.ts`)
190
361
 
191
362
  Kept chunks are copied verbatim, in order. Each run of removed chunks becomes one omission line. For
@@ -208,21 +379,27 @@ The draft is `{ type: "context_edit", targetId, replacement: { content: [{ type:
208
379
 
209
380
  ## The `context-guard` record
210
381
 
211
- Each run that made at least one Jev request also appends a custom entry (`customType: "context-guard"`):
382
+ Each run (and each mid-run checkpoint) that made at least one Jev request also appends a custom entry
383
+ (`customType: "context-guard"`):
212
384
 
213
385
  | Field | Meaning |
214
386
  |---|---|
215
387
  | `v` | Format version, `1` |
388
+ | `phase` | `"mid-run"` for checkpoints, `"run-end"` otherwise (a record without it counts as run end) |
216
389
  | `model` | Classifier used |
217
390
  | `savedChars` | Characters removed by this run's edits |
218
391
  | `requests`, `inputTokens`, `costUsd`, `ms` | Jev usage for the run |
219
392
  | `timedOut` | The time budget ran out |
220
- | `results[]` | One per candidate: `entryId`, `tool`, `label`, `outcome` (`distilled`, `removed`, `kept`), `reason`, `beforeChars`, `afterChars`, `jev` |
393
+ | `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
394
 
222
395
  `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
396
+ `no-answer`, `error` or `timeout` (several are joined with commas) for large outputs, `needed` or
397
+ `not-needed` for small outputs, and `relevant`, `unrelated` or `deferred` for exchanges (an omitted exchange's
398
+ result also has `omitted`, the entry ids that got `replacement: null`). Whole items have the trace
399
+ `p.06` (P of the bool). `jev` holds one trace per segment, such as
224
400
  `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.
401
+ P(`keep_whole`), the focus choice and its probability, and the result. `/guard` prints these for the last
402
+ record (run or checkpoint) and counts the checkpoints.
226
403
 
227
404
  The extension does not write Jev's usage into Pi's own usage entries. It is recorded only here.
228
405
 
@@ -231,48 +408,59 @@ The extension does not write Jev's usage into Pi's own usage entries. It is reco
231
408
  Nothing is kept in memory. Every redraw recomputes from the session:
232
409
 
233
410
  - **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.
411
+ model-visible text starts with `[context-guard]` while the raw entry does not; each one adds raw length
412
+ minus visible length. A prompt that shows the exchange stub adds the raw length of its exchange (the
413
+ prompt and the entries up to the next user message that the model no longer sees: Pi keeps an omitted
414
+ entry in the projection with no messages) minus the stub.
415
+ Tokens are estimated as characters ÷ 4.
236
416
  - **Jev usage:** the `context-guard` records on the active branch (`getBranch()`) are summed.
237
417
 
238
418
  So the numbers follow branches, `/tree`, reloads and compaction.
239
419
 
240
- The ready-state footer is `🛡 −4.2k · 1`: ≈ tokens kept out of context · distilled results (the first part
420
+ The ready-state footer is `🛡 −4.2k · 1`: ≈ tokens kept out of context · distilled outputs plus omitted
421
+ exchanges (the first part
241
422
  in the theme's success colour, the rest dimmed). Before anything is distilled it shows `🛡 0 saved`.
242
423
 
243
424
  ## Recall
244
425
 
245
426
  `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
427
+ entry. For a tool result it returns the output; for a user prompt (the stub of an omitted exchange) it
428
+ returns a transcript of the raw exchange from the branch: `## user`, `## assistant` with the text and
429
+ `[tool call] name {arguments}` lines, `## tool result (tool)` and `## custom message (type)` sections, for
430
+ exactly the entries the latest record lists as `omitted` (without such a record, up to the next user
431
+ message). The tool returns the whole text, or only lines that match `pattern` (with line numbers), or an
247
432
  `offset`/`limit` window. Output is capped at 2,000 lines and 50 KB, with a continuation note; a single
248
433
  line longer than 50 KB is cut with a `[line N cut at 50KB]` note. `pattern` is a JavaScript regex of at most
249
434
  500 characters, matched against the first 4,000 characters of each line inside a `node:vm` sandbox with a
250
435
  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.
436
+ neither a tool result nor a user prompt on the branch gives an error that tells the model to use the id
437
+ from a `[context-guard]` note.
252
438
 
253
439
  ## Pitfalls
254
440
 
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.
441
+ - **Never advance state on returned drafts.** A later handler may replace them, or Pi may reject the list;
442
+ read state back from the branch.
443
+ - **Never omit half of a tool call.** `replacement: null` on a tool result alone would leave its `tool_use`
444
+ without a result, and providers reject the request. A single output that is not needed is replaced with
445
+ a one-line stub; `null` is only used for whole exchanges, which omit the assistant message with the call
446
+ and its results together.
447
+ - **Never replace an assistant message's content.** A replacement turns the content into one text block,
448
+ which drops thinking, signatures and tool calls. Assistant messages are only omitted, as part of a whole
449
+ exchange.
259
450
  - **Steering messages move the span.** A user message sent during a run starts a new span. Tool results
260
451
  from before it are never candidates, now or later. That is the safe direction.
261
452
  - **`keepCitedFiles` is off by default.** Answers also name files to rule them out. In a live run, Claude's
262
453
  answer said `test/fixtures/entities.json` was *not* a usage, and the option kept 40 lines of noise from
263
454
  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.
455
+ - **Earlier runs are only edited as whole exchanges.** Editing single outputs of older runs would break the
456
+ prompt cache from that point on for little gain; an old exchange is omitted as a whole, once, when it is
457
+ unrelated to the current work, and only its first later request pays the rewrite.
266
458
  - **Don't distill from a `context` hook.** Output that varies between requests makes every request miss the
267
459
  cache.
268
460
 
269
461
  ## Open questions
270
462
 
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
463
+ 1. Other classifier entries (e.g. `typesafe/jev-latest` with a 64k context) and non-code outputs have
276
464
  not been tested.
277
- 4. The OpenAI Codex WebSocket transport loses its delta continuation after an edit
465
+ 2. The OpenAI Codex WebSocket transport loses its delta continuation after an edit
278
466
  ([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.