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/README.md CHANGED
@@ -7,9 +7,11 @@ This Pi extension keeps the lines that mattered.**
7
7
 
8
8
  When a run finishes, the extension asks the Jev classifier (by TypeSafe) which parts of each large tool
9
9
  output the work still needs: a broad `rg`, a full-file `read`, a failing test log. It then uses Pi's
10
- append-only `context_edit` entries to replace each output with only those parts, copied verbatim. Later
11
- turns carry the answer and its evidence instead of everything that was searched. The raw output stays in
12
- the session file, and the model can fetch it again with the `recall` tool.
10
+ append-only `context_edit` entries to replace each output with only those parts, copied verbatim. Small
11
+ outputs the work no longer needs become one-line notes, and earlier exchanges that are unrelated to the
12
+ current work are omitted down to a one-line note. Later turns carry the answer and its evidence instead of
13
+ everything that was searched. The raw entries stay in the session file, and the model can fetch them again
14
+ with the `recall` tool.
13
15
 
14
16
  ```bash
15
17
  pi install npm:pi-context-guard-jev # needs an OpenRouter key: OPENROUTER_API_KEY or /login
@@ -17,45 +19,53 @@ pi install npm:pi-context-guard-jev # needs an OpenRouter key: OPENROUTER_AP
17
19
 
18
20
  ## Why use it
19
21
 
20
- - **Most of a session's context is tool output that stopped mattering.** Pi keeps every output for the
21
- rest of the session, so every later request re-sends it, the context window fills, and compaction (a
22
- lossy summary of everything) comes sooner. In the [measured examples](#examples-measured), the next
23
- request carried **9.2k instead of 35.3k tokens** after reading a 164 KB file, **11.5k instead of 34.3k**
24
- after a code search, and **5.7k instead of 7.5k** after a failing test run. Every follow-up was still
25
- answered correctly.
22
+ - **Long sessions fill up with things that stopped mattering.** Old search results, file reads and test
23
+ logs, and whole earlier exchanges about other tasks, stay in the context for the rest of the session.
24
+ Every later request re-sends them, the context window fills, and compaction (a lossy summary of
25
+ everything) comes sooner. In the [measured examples](#examples-measured), the next request carried
26
+ **9.2k instead of 35.3k tokens** after reading a 164 KB file, **11.5k instead of 34.3k** after a code
27
+ search, and **5.7k instead of 7.5k** after a failing test run. In an autonomous 30-request bug-fixing
28
+ run, the context peaked at **45k instead of 68–88k tokens** ([Long autonomous runs](#long-autonomous-runs)).
29
+ Every follow-up and every task still succeeded.
26
30
  - **Verbatim, never rewritten.** Jev is a classifier: it answers yes/no and multiple-choice questions and
27
31
  does not write text. Kept lines are copied byte for byte, and each removed stretch becomes one line that
28
- says what was there (`[… 79 lines omitted: matches in dist/core/tools/grep.js, …]`). Unlike a summary,
29
- nothing the model sees afterwards is paraphrased or invented.
32
+ says what was there (`[… 79 lines omitted: matches in dist/core/tools/grep.js, …]`). Small outputs and
33
+ earlier exchanges that are no longer needed are removed whole and leave a one-line note. Unlike a
34
+ summary, nothing the model sees afterwards is paraphrased or invented.
30
35
  - **Nothing is lost.** The original stays in the session file. The model can `recall` it (whole, by
31
- regex, or by line range), and `/tree` back to before the edit restores it.
36
+ regex, or by line range), including a whole omitted exchange, and `/tree` back to before the edit
37
+ restores it.
32
38
  - **Judged against the work, not by size.** Truncation and size limits can't tell a needed line from
33
- noise. Jev sees the question, the final answer and the earlier conversation, and decides chunk by chunk,
34
- so lines the ongoing task needs survive even when this run's question was about something else. A
35
- failed command the agent already got past is removed outright.
36
- - **Cheap and quick.** $0.0004–0.003 and 0.6–1 s per qualifying run (measured). It runs once, after the
37
- run has finished, never while the agent works.
38
- - **Claude and OpenAI, cache-aware.** Tested with Claude Sonnet 5.5 and GPT-6 Luna. On Anthropic it adds a
39
- cache breakpoint at the previous question, so the request after an edit still reads the conversation
40
- up to there from cache.
39
+ noise. Jev sees the task, the agent's progress or final answer, and the earlier conversation, and decides
40
+ per chunk, per small output and per earlier exchange, so what the ongoing task needs survives. A failed
41
+ command the agent already got past, or a file read made stale by a later edit, goes.
42
+ - **Works during long autonomous runs and in subagents.** Besides the end of each run, it checkpoints
43
+ during long runs, once old output has piled up, so a 2-hour run or a long worker doesn't carry
44
+ everything until it ends.
45
+ - **Cheap, and careful with the prompt cache.** Jev costs $0.0003–0.007 and 0.6–2 s per pass (measured).
46
+ Edits are batched and made only when the smaller context pays for the prompt-cache rewrite they cause.
47
+ On Anthropic, cache breakpoints keep everything before the first edit readable from cache. Tested with
48
+ Claude Sonnet 5.5 and GPT-6 Luna.
41
49
  - **You can see what it does.** The footer shows `🛡 −10.1k · 1`. `/guard` lists every decision with Jev's
42
50
  probabilities, and `/guard off` turns it off for good.
43
51
 
44
52
  **Trade-offs:**
45
- - **It sends data to a third party.** Tool output goes to OpenRouter and TypeSafe ([Privacy](#privacy)).
46
- - **It can cost a little more in short sessions.** The first request after an edit rewrites the prompt
47
- cache, so the first gain is context room; the bill shrinks only in longer sessions
53
+ - **It sends data to a third party.** Tool output and conversation excerpts go to OpenRouter and TypeSafe
54
+ ([Privacy](#privacy)).
55
+ - **It can cost a little more in short sessions.** The first request after an edit rewrites part of the
56
+ prompt cache, so the first gain is context room; the bill shrinks only in longer sessions
48
57
  ([Examples](#examples-measured)).
49
- - **Follow-ups may need `recall`.** A question that goes somewhere new can need something that was
50
- removed.
58
+ - **Follow-ups may need `recall`.** A question that goes back to something removed makes the model recall
59
+ it or search again.
51
60
 
52
61
  ## How it works
53
62
 
54
63
  1. **A run finishes.** When Pi is about to settle a completed run (`agent_before_settle`), the extension
55
- looks at that run only: everything after the last user message.
56
- 2. **It picks candidates.** These are text-only tool results of at least 4,000 characters, including the
57
- output of failed commands. The run is
58
- skipped if the candidates add up to less than 8,000 characters.
64
+ looks at the tool outputs of that run (everything after the last user message) and at earlier
65
+ exchanges (steps 6 and 7). Long runs also get checkpoints before they end
66
+ ([Long autonomous runs](#long-autonomous-runs)).
67
+ 2. **It picks large outputs.** These are text-only tool results of at least 4,000 characters, including
68
+ the output of failed commands. They are skipped if they add up to less than 8,000 characters.
59
69
  3. **It chunks each output.** grep-style output is grouped by file. Other output is cut into windows of
60
70
  roughly 0.8–4k characters, with breaks at blank lines where possible.
61
71
  4. **It sends one Jev request per segment** (up to 32,000 characters and 40 chunks). Each request asks three
@@ -69,10 +79,97 @@ pi install npm:pi-context-guard-jev # needs an OpenRouter key: OPENROUTER_AP
69
79
  removed chunks with an omission line, and starts with a header that tells the model how to
70
80
  `recall` the full output. If the kept part would still be more than 60% of the original, the result is
71
81
  left as it is.
82
+ 6. **Small outputs** (400 characters up to the 4,000-character limit) are judged as whole items, many per
83
+ Jev request: "will the agent still need this output?" An output that is not needed becomes
84
+ `[context-guard] Omitted the output of bash `…` (1.2k chars): judged no longer needed. Full output: recall(…).`
85
+ 7. **Old exchanges** (a user prompt and everything up to the next prompt: replies, tool calls and results,
86
+ custom messages) from earlier, finished runs are judged against the current work, several per request:
87
+ "is this earlier exchange still relevant?" The last 2 exchanges before the current prompt are never
88
+ touched. An unrelated exchange keeps a one-line note on its prompt
89
+ (`[context-guard] Omitted an earlier exchange judged unrelated to the current work: "…" (N messages, ~Mk tokens). Full exchange: recall(…).`)
90
+ and every other entry of it is omitted (`replacement: null`), so tool calls and their results always go
91
+ together. Exchanges with images, with entries another extension changed, or under 2,000 characters
92
+ are not judged. An omission early in the session makes the next request rewrite the prompt cache
93
+ from there, so at run end it happens only when it pays off over as many later requests as the session
94
+ has had so far and saves at least 8,000 characters (`exchangeBreakEven`); otherwise the exchanges
95
+ wait, and are judged again at later run ends until their total pays off. All three kinds are judged
96
+ in parallel, at most `concurrency` (6) Jev requests at a time.
97
+
98
+ During long runs, **mid-run checkpoints** do the same for older items before the run ends (next
99
+ section).
72
100
 
73
101
  Errors, timeouts and unclear answers always mean *keep*. For the full algorithm, see
74
102
  [docs/DESIGN.md](docs/DESIGN.md).
75
103
 
104
+ ## Long autonomous runs
105
+
106
+ Run-end distillation helps the *next* prompt. A 30–120 minute autonomous run (the main agent or a long
107
+ worker) would still carry every tool output until it ends, and Pi compacts only near the window
108
+ minus 16k tokens. So, while a run is going, the extension also checkpoints:
109
+
110
+ 1. **At a turn boundary** (`turn_end`; Pi applies the edit to the very next request, before its compaction
111
+ check), large and small outputs of the current run that are at least `midRunMinAgeTurns` (4) turns old
112
+ and not judged before are eligible, and so are old exchanges not judged during this run. The final turn
113
+ of a run is left to run-end distillation.
114
+ 2. **In batches:** a checkpoint runs only when the eligible items add up to `midRunBatchChars` (60,000)
115
+ characters, so edits, and the cache rewrites they cause, are rare.
116
+ 3. **Only when it pays:** an edit makes the next request rewrite the prompt cache once (OpenAI Codex: the
117
+ whole context; Anthropic: everything after the latest cache entry it can still read, at worst after the
118
+ question). The break-even rule
119
+ (`midRunBreakEven`) skips a checkpoint unless the expected saving over the rest of the run covers that.
120
+ Old exchanges come early in the context, so they move the rewrite back to the start; if the batch with
121
+ them does not pay off, the checkpoint runs on the tool outputs alone, and the exchanges wait for run end.
122
+ 4. **Jev gets a checkpoint question:** no final answer exists yet, so it sees the task, the agent's latest
123
+ notes, the tool calls made since this output, and whether the output was superseded (the file was
124
+ edited afterwards, or the same command ran again). It answers whether the agent will still need each
125
+ chunk to finish. Each output is judged mid-run at most once; run-end distillation judges the rest,
126
+ including outputs kept at a checkpoint, with the final answer.
127
+ 5. **On Anthropic** the previous question stays pinned, and the first request after a checkpoint also gets
128
+ a breakpoint at the latest cache entry this process wrote before the first edited output, so the
129
+ unchanged part of the run is still read from cache.
130
+
131
+ **Measured** (Pi 1.0.3, e2e scenario `long`: "fix all failing tests" in a generated project with 10 bugs,
132
+ the full suite after every fix; single runs, not a benchmark; costs are what Pi reports at list price):
133
+
134
+ | Main model | Configuration | Requests | Peak context | Main-model cost | Checkpoints (Jev) | Suite green |
135
+ |---|---|---|---|---|---|---|
136
+ | GPT-6 Luna | guard off | 32 / 37 | 68.4k / 87.7k | $0.020 / $0.030 | – | yes / yes |
137
+ | GPT-6 Luna | `midRun: false` (run end only) | 26 | 86.3k | $0.022 | – (run end $0.006) | yes |
138
+ | GPT-6 Luna | default | 29 | **45.0k** | $0.021 | 2 at requests 16 and 24, 0.8 s each, removed 62k and 60k chars ($0.006 with run end) | yes |
139
+ | GPT-6 Luna | `smallResultMinChars: 0` (large outputs only) | 31 | 72.4k | $0.024 | 1 at request 19, 1.0 s, removed 154k chars ($0.007 with run end) | yes |
140
+ | Claude Sonnet 5.5 | guard off | 14 | 89.8k | $0.41 | – | yes |
141
+ | Claude Sonnet 5.5 | `smallResultMinChars: 0` | 8 | 65.7k | $0.24 | none: too short to pay off (run end $0.003) | yes |
142
+ | Claude Sonnet 5.5 | forced: `midRunBreakEven: false`, `midRunMinAgeTurns: 2`, `midRunBatchChars: 20000`, `smallResultMinChars: 0` | 12 | 43.3k | $0.34 | 3 ($0.0032 with run end) | yes |
143
+
144
+ Context per request (every 5th request), GPT-6 Luna:
145
+
146
+ | Request | 1 | 6 | 11 | 16 | 21 | 26 | 31 | 36 |
147
+ |---|---|---|---|---|---|---|---|---|
148
+ | guard off (37 requests) | 3.0k | 14.6k | 29.3k | 41.4k | 52.1k | 62.5k | 73.3k | 83.4k |
149
+ | default (29 requests) | 3.2k | 17.0k | 35.3k | 41.0k | 39.3k | 28.9k | – | – |
150
+
151
+ At the default run's first checkpoint, Jev distilled four superseded test logs (for example 30,164 → 8,163)
152
+ and replaced 4 of 7 small reads of files the agent had already edited with one-line notes (for example
153
+ 3,386 → 145); the next request carried 20.1k instead of 41.0k tokens. No run needed `recall`.
154
+
155
+ **Trade-offs:**
156
+ - **OpenAI Codex reads nothing from cache after any edit** (2,560 or 0 tokens on the next request, after
157
+ every checkpoint measured, wherever the edit was). Each checkpoint re-sends the context uncached once and
158
+ pays back after about 8–10 later requests. In these 26–37-request runs the main-model cost came out
159
+ about the same with and without checkpoints, plus about $0.005 of Jev per run; the gain is a smaller
160
+ context.
161
+ - **On Anthropic a checkpoint rewrites the cache after the latest entry the next request can read** (at worst after the question; writing costs 12.5× the read price).
162
+ In the forced run, the requests after the three checkpoints read 5.8k / 16.7k / 16.7k tokens and wrote
163
+ 10.9k / 22.8k / 20.0k. After the first checkpoint of a run only the system prompt and question can be
164
+ read, because the run's first output is usually the first one edited. Claude solved the task in 8–14
165
+ requests, too few for a checkpoint to pay off, so with the default break-even rule it never
166
+ checkpointed; forced checkpoints in such short runs cost more than they saved. Checkpoints are meant
167
+ for runs that go on for dozens of requests.
168
+ - **Latency mid-run:** a checkpoint pauses the run for the Jev requests (0.5–2.2 s measured, at most
169
+ `timeoutMs`).
170
+ - Details: [docs/CACHE.md](docs/CACHE.md#mid-run-checkpoints), [docs/DESIGN.md](docs/DESIGN.md#mid-run-checkpoints),
171
+ wording probe in [docs/JEV.md](docs/JEV.md#checkpoint-wording).
172
+
76
173
  ## Install
77
174
 
78
175
  ```bash
@@ -103,12 +200,19 @@ each Jev request sends the following to OpenRouter, which forwards it to TypeSaf
103
200
  - excerpts of the earlier conversation in this session: the latest compaction or branch summary (up to
104
201
  2,000 characters), the first prompt (up to 1,000), and the last 3 earlier exchanges, each one prompt (up to
105
202
  800) and the last assistant text after it (up to 1,200). Tool calls and tool outputs of earlier runs are not
106
- included. Set `historyExchanges` to `0` to send none of it,
203
+ included in this part. Set `historyExchanges` to `0` to send none of it,
107
204
  - the tool name and arguments,
108
- - the full text of the large tool output being judged. This can include source code, logs or anything else
109
- the tools returned.
110
-
111
- Small results, edit/write results and runs below the size limits are never sent.
205
+ - the full text of the large tool output being judged, and of small outputs (from 400 characters; up to
206
+ 4,000 characters each). This can include source code, logs or anything else the tools returned,
207
+ - excerpts of old exchanges being judged: each prompt (up to 600 characters), the last assistant text (up to
208
+ 800), the tool calls as short labels (tool name and the command or path, up to 80 characters each) and the
209
+ size. Set `pruneExchanges` to `false` to send none of it.
210
+
211
+ Never sent: tool outputs below `smallResultMinChars` (400 characters), results of excluded tools (`edit`,
212
+ `write`), outputs with images, old exchanges with images or with entries another extension changed,
213
+ exchanges below 2,000 characters, and the last `keepRecentExchanges` (2) exchanges before the current
214
+ prompt. Large outputs are sent only when the run's large outputs add up to `minRunChars` (8,000); small
215
+ outputs and old exchanges are sent at every run end and every mid-run checkpoint that has any.
112
216
 
113
217
  `/guard off` turns the extension off and saves that choice in the settings file, so it stays off in later
114
218
  sessions. See OpenRouter's data policy:
@@ -119,22 +223,22 @@ sessions. See OpenRouter's data policy:
119
223
  | Text | Meaning |
120
224
  |---|---|
121
225
  | `🛡 0 saved` | On and ready; nothing is distilled on this branch yet |
122
- | `🛡 distilling…` | Jev is judging the outputs of the run that just finished |
123
- | `🛡 −4.2k · 1` | ≈ tokens kept out of context · distilled results: about 4.2k tokens are currently kept out of context, by 1 distilled tool result |
226
+ | `🛡 distilling…` | Jev is judging the outputs of the run that just finished, or of a mid-run checkpoint |
227
+ | `🛡 −4.2k · 1` | ≈ tokens kept out of context · items: about 4.2k tokens are currently kept out of context, by 1 distilled or omitted tool output or old exchange |
124
228
  | `🛡 guard off` | Turned off with `/guard off` or `"enabled": false` |
125
229
  | `🛡 no openrouter key` | No credential for the classifier's provider (the provider name is filled in) |
126
230
  | `🛡 Jev model not found` | The configured `model` is not in Pi's classifier catalog |
127
231
 
128
232
  The token figure is Pi's usual estimate, characters ÷ 4. It counts what is kept out of the model's context
129
- **on the current branch right now**: original length minus replacement length, for every distilled result
130
- that is still in the context. It goes down after `/tree` to a point before an edit, and after compaction
233
+ **on the current branch right now**: original length minus replacement length, for every distilled output
234
+ and omitted exchange that is still in the context. It goes down after `/tree` to a point before an edit, and after compaction
131
235
  removes distilled results. It is not a measured token count and not a cumulative total.
132
236
 
133
237
  ## `/guard` command
134
238
 
135
239
  | Command | Effect |
136
240
  |---|---|
137
- | `/guard` or `/guard status` | Shows whether the guard is on, the model, the savings on this branch, Jev runs, requests and cost, and the last run's result for each tool output, including Jev traces such as `kw.15 focus=chunk_4:.52 keep 9/29` |
241
+ | `/guard` or `/guard status` | Shows whether the guard is on, the model, the savings on this branch (distilled outputs and omitted exchanges), Jev runs and mid-run checkpoints, requests and cost, whether checkpoints and exchange pruning are on, and the last run's or checkpoint's result for each item (large, small, exchange), including Jev traces such as `kw.15 focus=chunk_4:.52 keep 9/29` or `p.06` |
138
242
  | `/guard on` / `/guard off` | Turns the guard on or off and saves `enabled` to the settings file |
139
243
  | `/guard reload` | Re-reads the settings file and shows any warnings again |
140
244
 
@@ -142,8 +246,9 @@ Settings are read at session start and on `/guard reload`.
142
246
 
143
247
  ## `recall` tool
144
248
 
145
- Every distilled result starts with `[context-guard]` and ends its header with
146
- `Full output: recall({"entryId":"…"}).` The model can call:
249
+ Every distilled output and omitted exchange starts with `[context-guard]` and ends with
250
+ `recall({"entryId":"…"})`. For an omitted exchange, recall returns the whole exchange as a transcript
251
+ (prompt, replies, tool calls and results, custom messages). The model can call:
147
252
 
148
253
  | Parameter | Meaning |
149
254
  |---|---|
@@ -164,8 +269,8 @@ fall back to the default.
164
269
  |---|---|---|
165
270
  | `enabled` | `true` | Master switch (`/guard on` and `/guard off` write it) |
166
271
  | `model` | `"openrouter/~typesafe/jev-latest"` | Classifier as `provider/id`; the first `/` separates the provider |
167
- | `minResultChars` | `4000` | Ignore tool results shorter than this many characters |
168
- | `minRunChars` | `8000` | Skip the run if its candidates add up to less than this |
272
+ | `minResultChars` | `4000` | Tool results from this size are chunked; shorter ones are judged whole (see `smallResultMinChars`) |
273
+ | `minRunChars` | `8000` | Distill a run's large outputs only if they add up to at least this (small outputs and exchanges are judged regardless) |
169
274
  | `keepWholeThreshold` | `0.7` | Keep a segment untouched if P(`keep_whole`) is at least this |
170
275
  | `focusWholeThreshold` | `0.6` | Keep a segment untouched if `focus` picks "whole" with at least this probability |
171
276
  | `chunkKeepThreshold` | `0.6` | Keep a chunk if P(chunk needed) is at least this |
@@ -179,7 +284,20 @@ fall back to the default.
179
284
  | `excludeTools` | `["edit", "write"]` | Tools whose results are never distilled |
180
285
  | `distillErrors` | `true` | Also distill error results, such as the log of a failing test or build; Jev is told the call failed, and that its details are usually no longer needed once the agent got past it |
181
286
  | `historyExchanges` | `3` | Earlier exchanges (prompt + last assistant text) sent to Jev so it judges relevance against the ongoing work; `0` sends no earlier conversation at all (integer 0–10) |
182
- | `pinAnthropicCache` | `true` | Add an Anthropic cache breakpoint at the previous user question ([docs/CACHE.md](docs/CACHE.md)) |
287
+ | `pinAnthropicCache` | `true` | Anthropic cache breakpoints: at the previous user question on every request, and on the first request after context edits a read point (at a cache entry this process wrote) and a write anchor ([docs/CACHE.md](docs/CACHE.md)) |
288
+ | `midRun` | `true` | Mid-run checkpoints during long runs ([Long autonomous runs](#long-autonomous-runs)) |
289
+ | `midRunMinAgeTurns` | `4` | Judge an output mid-run only once it is at least this many turns old (integer 1–100) |
290
+ | `midRunBatchChars` | `60000` | Run a checkpoint only when the not-yet-judged eligible outputs add up to this many characters |
291
+ | `midRunBatchCharsOpenAI` | `60000` | The same, for `openai-codex` models |
292
+ | `midRunChunkKeepThreshold` | `0.6` | Keep a chunk at a checkpoint if P(still needed) is at least this (probe in docs/JEV.md) |
293
+ | `midRunBreakEven` | `true` | Skip a checkpoint unless its one-time prompt-cache rewrite is likely to pay off; `false` checkpoints purely for context room |
294
+ | `smallResultMinChars` | `400` | Tool results from this size up to `minResultChars` are judged as whole items; `0` turns this off |
295
+ | `smallKeepThreshold` | `0.45` | Keep a small output if P(still needed) is at least this (probe in docs/JEV.md) |
296
+ | `pruneExchanges` | `true` | Judge old exchanges and omit the unrelated ones |
297
+ | `keepRecentExchanges` | `2` | The last this many exchanges before the current prompt are never omitted |
298
+ | `exchangeOmitThreshold` | `0.2` | Omit an exchange only if P(still relevant) is below this (probe in docs/JEV.md) |
299
+ | `exchangeBreakEven` | `true` | At run end, omit unrelated exchanges only when the cache rewrite they cause pays off; deferred ones are judged again at later run ends and omitted once their total pays off. `false` always omits them |
300
+ | `exchangeMinSavingChars` | `8000` | A run end omits exchanges only if they save at least this many characters together (when they cost a cache rewrite) |
183
301
 
184
302
  Example:
185
303
 
@@ -189,23 +307,28 @@ Example:
189
307
 
190
308
  ## What it does not touch
191
309
 
192
- - user and assistant messages, custom messages
310
+ - messages of the current run other than tool results, and the last `keepRecentExchanges` exchanges before
311
+ the current prompt
312
+ - compaction and branch summaries, exchanges that did not finish (the last reply has tool calls), and
313
+ messages Pi cannot edit (system messages, `!` shell executions), which stay in place inside an omitted
314
+ exchange
193
315
  - tool results that contain images
194
316
  - error results, if `distillErrors` is `false`
195
317
  - results of excluded tools (`edit`, `write` by default)
196
- - results shorter than `minResultChars`, and runs below `minRunChars`
318
+ - results shorter than `smallResultMinChars` (400), and large results of runs below `minRunChars`
197
319
  - aborted or failed runs
198
320
  - results that are already distilled, or that another extension already edited
199
321
  - single lines too long for one Jev request (kept as they are)
200
- - earlier runs: only the run that just finished is edited
322
+ - tool outputs of earlier runs one by one: earlier runs are only touched as whole exchanges
201
323
 
202
324
  ## Limitations
203
325
 
204
326
  - **Lossy by design.** Relevance is judged against the run's question and answer and the earlier
205
327
  conversation (the ongoing task), so a follow-up that goes somewhere new may need something that was
206
- removed. The header and omission lines say what is missing, and `recall` returns it.
328
+ removed. The header and omission lines say what is missing, and `recall` returns it. An earlier task
329
+ that comes back after several unrelated ones may have been omitted as a whole exchange.
207
330
  - **Latency.** Settling waits for Jev. This adds about 0.5–1.2 s after each qualifying run (measured), and
208
- never more than `timeoutMs` (8 s).
331
+ never more than `timeoutMs` (8 s). A mid-run checkpoint pauses the run the same way.
209
332
  - **Cost.** Each qualifying run costs a few Jev requests, measured at under $0.001 per run. The first prompt
210
333
  after an edit also has a one-time partial prompt-cache miss ([docs/CACHE.md](docs/CACHE.md)).
211
334
  - **Steering.** A message sent during a run starts a new span. Tool results from before it are never
@@ -216,6 +339,8 @@ Example:
216
339
  Real runs on public code with Pi 1.0.3: each example ran once with the guard on and once with it off
217
340
  (`--no-guard`), from the same prompts. These are single runs, not a benchmark: the main model takes a
218
341
  different path each time. Reproduce with `node test/e2e/examples.mjs` ([docs/TESTING.md](docs/TESTING.md)).
342
+ Examples A–C judge large outputs only (their numbers match `smallResultMinChars: 0`); example D shows the
343
+ default configuration and `exchangeBreakEven: false`.
219
344
 
220
345
  **A. Searching a real codebase.** In a copy of the published Pi package (`dist/` without source maps and
221
346
  the minified bundle, plus `docs/`), the agent was asked where Pi decides to auto-compact, starting with
@@ -241,8 +366,7 @@ carried **9.2k tokens instead of 35.3k**.
241
366
  the unit tests (a 15.8k-character log, 3 failures among 116 tests) and reported the cause. Jev kept the
242
367
  3 failures with their assertion messages and the failing-test summary, and dropped the passing tests:
243
368
  15.8k → 8.6k chars. Turn 2 ("now fix it") carried **5.7k tokens instead of 7.5k**, and both runs fixed
244
- the bug. (In the first run of this example, failing commands were never distilled; `distillErrors` was
245
- added because of it.)
369
+ the bug. Failing commands are distilled because `distillErrors` is on by default.
246
370
 
247
371
  What the model sees after distilling (example A, Claude; the first lines of the real replacement):
248
372
 
@@ -264,6 +388,24 @@ dist/core/compaction/compaction.d.ts:59:export declare function shouldCompact(co
264
388
  | A. code search | GPT-6 Luna | 56.6k → 14.8k chars (2 outputs; 2 more, 15k, left whole) | 5 requests, 0.6 s, $0.0015 | 11.5k / 34.3k tokens | $0.0012 / $0.0005 | correct, no `recall` |
265
389
  | C. 164 KB file | GPT-6 Luna | 163k → 26k chars (7 reads) | 7 requests, 0.9 s, $0.0027 | 9.2k / 35.3k tokens | $0.0007 / $0.0005 | correct, no `recall` |
266
390
  | B. failing test log | GPT-6 Luna | 15.8k → 8.6k chars | 1 request, 0.8 s, $0.0004 | 5.7k / 7.5k tokens | $0.0022 / $0.0016 | fixed in both runs |
391
+ | D. five topics | Claude Sonnet 5.5, `exchangeBreakEven: false` | 1 old exchange omitted (8.8k chars) | 2 requests, $0.0003 | 8.0k / 12.8k tokens (prompt 5) | $0.017 / $0.006 (prompt 5's first request) | correct, searched again |
392
+
393
+ **D. Several topics in one session.** In the generated test repo, five prompts in a row: which API
394
+ clients retry on 503; a README paragraph for `StatusBar`; a count of HTTP 500 lines in a log; where
395
+ `StatusBadge` is used; and then "back to the clients that retry on 503: which of them also retry on 502?"
396
+ (`node test/e2e/run-e2e.mjs --scenario topics --turns 5`). The README exchange is under 2,000 characters
397
+ and is never judged; the last 2 exchanges before each prompt are never judged either.
398
+
399
+ | Model, configuration | First exchange at the end of prompt 4 | At the end of prompt 5 | Prompt 5 | Context of prompt 5's first request | Main-model cost | Jev |
400
+ |---|---|---|---|---|---|---|
401
+ | GPT-6 Luna, default | judged unrelated (P = 0.07), **deferred**: too small to pay for the cache rewrite in a 4-prompt session | relevant (P = 0.95), kept | correct, no `recall` | 7.5k, 6,656 read from cache | $0.0018 | $0.0005 |
402
+ | Claude Sonnet 5.5, default | unrelated (P = 0.07), deferred | relevant (P = 0.97), kept | correct, no `recall` | 11.5k, 11,049 read from cache | $0.073 | $0.0003 |
403
+ | Claude Sonnet 5.5, `exchangeBreakEven: false` | unrelated (P = 0.08), **omitted** (8.8k characters, 5 entries) | – | correct, searched again | 8.0k: 1,760 read, 6,280 written | $0.087 | $0.0003 |
404
+ | Claude Sonnet 5.5, guard off | – | – | – | 12.8k | $0.083 | – |
405
+
406
+ In this short session the gate kept the first exchange, which the follow-up then needed. With the gate off,
407
+ the omission made the next request rewrite the cache from the omitted exchange on (it comes before the
408
+ question pin) and the model searched again; the smaller context pays off only in longer sessions.
267
409
 
268
410
  **When it doesn't help.**
269
411
 
@@ -274,13 +416,14 @@ dist/core/compaction/compaction.d.ts:59:export declare function shouldCompact(co
274
416
  tokens instead of 13,824: a partial miss).
275
417
  At the per-token prices in these runs, the smaller context earns that back after about 7 more requests
276
418
  (Claude, A), about 3 (GPT, A) or about 1 (GPT, C). Before that, the benefit is a smaller context window,
277
- not a lower bill. Anthropic requests also get a cache breakpoint at the previous question, which raised
278
- the first request's cache read from 1,693 to 5,810 tokens in a long run ([docs/CACHE.md](docs/CACHE.md)).
419
+ not a lower bill. Anthropic requests also get a cache breakpoint at the previous question; in a run with
420
+ 11 sequential tool calls it raised the first request's cache read from 1,693 to 5,810 tokens compared
421
+ with `pinAnthropicCache: false` ([docs/CACHE.md](docs/CACHE.md)).
279
422
  - **Small or fully needed outputs are left whole.** In A, GPT's 9k-character read of `agent-session.js` was
280
423
  `not-worth` (too much of it was needed) and a 6k narrowed search was judged needed whole.
281
424
 
282
- Earlier numbers on the generated StatusBadge test repo (96% removed, `recall` of a removed line) are in
283
- [docs/TESTING.md](docs/TESTING.md#results-so-far-pi-103).
425
+ Numbers on the generated StatusBadge test repo (96% removed, `recall` of a removed line) are in
426
+ [docs/TESTING.md](docs/TESTING.md#results-pi-103).
284
427
 
285
428
  ## Documentation
286
429
 
package/docs/CACHE.md CHANGED
@@ -21,7 +21,8 @@ runs, not averages.
21
21
  Consecutive `tool_use` blocks count as one position, and so do consecutive `tool_result` blocks.
22
22
  - Entries with a longer TTL must come before entries with a shorter TTL.
23
23
  - Reading an entry refreshes its TTL. The docs do **not** say that reading a longer prefix refreshes a
24
- shorter entry that was written separately.
24
+ shorter entry that was written separately; measured below, it does
25
+ ([Mid-run checkpoints](#mid-run-checkpoints)).
25
26
  - Pricing relative to base input: 5-minute write 1.25×, 1-hour write 2×, read 0.1× (0.05× on Opus 5.5).
26
27
  - The minimum cacheable prompt is 512 tokens on Sonnet/Opus 5.5.
27
28
 
@@ -97,8 +98,10 @@ had more than 4 breakpoints.
97
98
 
98
99
  Over WebSocket, Pi's `openai-codex` transport sends only the new input items with `previous_response_id`, as
99
100
  long as the input only grows. After an edit, the input is no longer an extension of the previous one, so
100
- Pi sends the full input. The server-side continuation is lost for that request, and the prompt cache is reused
101
- only up to the first edited item. This happens once, on the first request after the edit.
101
+ Pi sends the full input. The server-side continuation is lost for that request, and in every measurement the
102
+ cache read was at most the 2,560-token stable prefix, **wherever the first edited item was** (also when it
103
+ was 18 turns into a run; see [Mid-run checkpoints](#mid-run-checkpoints)). This happens once, on the first
104
+ request after the edit.
102
105
 
103
106
  ### Measured
104
107
 
@@ -112,6 +115,135 @@ GPT-6 Luna (openai-codex), badge scenario, turn 2 first request:
112
115
 
113
116
  The pin does not apply to OpenAI.
114
117
 
118
+ ## Mid-run checkpoints
119
+
120
+ A checkpoint edits outputs from earlier in the *current* run, so the next request cannot read the run from
121
+ cache beyond the first edited output. Two questions decide the design: does a cache entry at the
122
+ unchanged prefix still exist when the checkpoint comes, possibly many minutes after it was written; and
123
+ how much does the one-time rewrite cost against the per-request saving.
124
+
125
+ ### Do cache entries on the read path stay alive? (measured)
126
+
127
+ `node test/e2e/ttl-probe.mjs` (Claude Sonnet 5.5, `pi-claude-auth`). The model `cat`s a ~31k-token file
128
+ (entry A ends at that result, written once as Pi's rolling breakpoint), then makes further requests; at the
129
+ end, the request after A is changed so that only A can be read.
130
+
131
+ | Arm | Between writing A and the test request | Test request |
132
+ |---|---|---|
133
+ | refresh | 8 requests in 7.6 min, each marking A (5m) and reading the longer prefix | cacheRead **31,001** (A alive) |
134
+ | control | the same 8 requests, A **not** marked again | cacheRead **30,999** (A alive) |
135
+ | ttl1h | as refresh, A at `ttl: "1h"` | cacheRead 31,001; but switching the earlier breakpoints to 1h was itself a full miss (cacheRead 0 → 31,001 written at the 1h price) |
136
+ | idle (5m) | **no request for 362 s** (Pi's cache warming stopped) | cacheRead **1,431**, cacheWrite 29,633 (A expired) |
137
+ | idle1h | no request for 365 s, A at 1h | cacheRead 30,977 (A alive) |
138
+
139
+ So the 5-minute TTL is real (idle), but **every request that reads a longer prefix keeps the shorter entries
140
+ on its path alive** (control). During an active run (requests seconds apart, and Pi's own cache warming,
141
+ setting `cacheWarming: "streaming"` by default, during long tool calls), every turn boundary that was once
142
+ the rolling breakpoint stays readable. A 1-hour TTL is not needed and would cost a full rewrite at 2× once.
143
+ The extension therefore keeps Pi's TTL (5m by default). Pi's warming requests go through
144
+ `before_provider_request` (seen in the capture), so they carry the extension's breakpoints too.
145
+
146
+ What the lookback limit still breaks: the request after an edit has its rolling breakpoint at the end; the
147
+ first edited output is usually many more than 20 positions back. So every Anthropic request keeps the
148
+ previous-question pin (the floor: system prompt and question; with `pi-claude-auth`, the relocated system
149
+ prompt sits in that same user message), and the first request after any edits (checkpoint, run end, or
150
+ another extension; the `context_edit` entries after the last answered assistant message) gets two more
151
+ breakpoints (`cacheAnchors` in `src/checkpoint.ts`):
152
+
153
+ - a **read point**, only at a cache entry this process saw itself write: a `tool_result` block that carried
154
+ a breakpoint in a request it sent, to the same model, still on the branch, and with no `context_edit` at
155
+ or before it and no compaction or branch summary since, written or read through within the last 5
156
+ minutes. A response refreshes an entry when its `cacheRead` shows the request read clearly past it: at
157
+ least the run's baseline (the full input of the run's first request) plus the characters from the
158
+ question to the entry divided by 1.5, which overestimates the tokens (code ≈ 2.3 characters per token).
159
+ This follows the control arm above. The latest trusted entry before the first edited entry is used. The
160
+ log lives in memory and is empty after a reload, so a read point is never inferred;
161
+ - a **write anchor** at the end of the first edited tool-result batch, so the edited prefix gets an entry
162
+ that the next checkpoint can read. It enters the log only if it was really in the request.
163
+
164
+ Budget: Pi uses 3 (API key: tools, system, rolling) or 4 breakpoints (OAuth: plus the identity
165
+ `system[0]`; `pi-claude-auth`: tools, `system[1]`, question, rolling). To make room, the identity breakpoint
166
+ (covered by `system[1]`) and then the tools breakpoint (covered by the system breakpoint) are dropped. If
167
+ that is not enough, the write anchor goes first, then the read point; the question stays. Marks use the
168
+ rolling breakpoint's TTL and are skipped if they would break the TTL order. Only breakpoints actually left in
169
+ the payload are logged, so an anchor dropped for the budget is never treated as written.
170
+
171
+ ### The first request after a checkpoint (measured)
172
+
173
+ Claude Sonnet 5.5, long scenario with settings that force checkpoints into a short run
174
+ (`midRunBreakEven: false`, `midRunMinAgeTurns: 2`, `midRunBatchChars: 20000`), with `pi-claude-auth` and
175
+ `test/e2e/plain-test-runs.ts`:
176
+
177
+ | Configuration | Checkpoint | First edited output | Next request |
178
+ |---|---|---|---|
179
+ | default anchors | 1st (after request 3) | the run's first output | cacheRead **5,832** (system prompt + question, via the question pin), cacheWrite 10,867 |
180
+ | default anchors | 2nd (after request 8) | 3 turns into the run | cacheRead **16,699** (read at the entry written after the 1st checkpoint), cacheWrite 22,778 |
181
+ | default anchors | 3rd (after request 10) | 5 turns into the run | cacheRead **16,699**, cacheWrite 20,008 |
182
+ | `pinAnthropicCache: false` | 1st | the run's first output | cacheRead 5,832, cacheWrite 29,570 |
183
+ | `pinAnthropicCache: false` | 2nd, 3rd, 4th | 2–6 turns into the run | cacheRead **5,832** each time, cacheWrite 41,162 / 32,183 / 33,872 |
184
+
185
+ Without the guard's breakpoints, every request after a checkpoint read only the system-and-question entry
186
+ and rewrote the whole run. With them, the later checkpoints read up to the entry written by the first
187
+ request after the first checkpoint. The first checkpoint of a run usually edits the run's first output, so
188
+ only the system prompt and question can be read then. When a read point is placed, the write anchor often
189
+ does not fit the budget (question, read point, `system[1]` and rolling take all four slots).
190
+
191
+ GPT-6 Luna (openai-codex), long scenario, 8 checkpoints in 5 runs (default batch and age settings), at
192
+ requests 13–30: the first request after every checkpoint read **2,560 or 0** tokens from cache (17k–29k
193
+ uncached), against 35k–72k read on the request before it. That is the transport, not the position of the
194
+ edit.
195
+
196
+ ### Economics and the break-even rule
197
+
198
+ At the prices in these runs (Sonnet 5.5: input $2/M, 5m write $2.5/M, read $0.2/M; GPT-6 Luna: input
199
+ $0.10/M, cached $0.01/M), a checkpoint costs once, and saves on every later request:
200
+
201
+ - OpenAI Codex: the whole context is sent uncached once: extra ≈ 0.9 × context × input price.
202
+ - Anthropic: everything after the read point is written again: extra ≈ (1.25 − 0.1) × rewritten tokens.
203
+ - Saving per later request: the removed tokens × the cache-read price. Jev removed 60–90% of checkpoint
204
+ outputs in the runs.
205
+
206
+ Measured (GPT, a run with two checkpoints): checkpoint 1 removed 57k characters; the next request was 17,144 tokens uncached
207
+ (≈ $0.0015 extra) and every later request carried about 19k tokens less (≈ $0.0002 saved each): break-even
208
+ after about 8 requests, and the run went on for 12 more. Checkpoint 2 needed about 9 requests and the run
209
+ ended 2 requests later. On Anthropic the ratio of write to read price is larger (12.5:1 vs 10:1).
210
+
211
+ So checkpoints pay for themselves only when the run goes on for roughly 8–30 more requests. The
212
+ **break-even rule** (`midRunBreakEven`, on) skips a checkpoint unless
213
+ `pending chars × turns so far ≥ factor × rewrite chars` (OpenAI: factor 13, rewrite = whole context;
214
+ Anthropic: factor 16, rewrite = context after the read point the next request would get, or after the
215
+ question when no logged entry is trusted, or the whole context with pinning off; other providers: factor
216
+ 16, rewrite = context from the first eligible output), assuming the run goes on
217
+ about as long as it has run so far. In the measured Claude runs (8–14 requests) it never allowed a
218
+ checkpoint; in the GPT runs (26–37 requests) it allowed one or two. Turn it off to checkpoint purely for
219
+ context room.
220
+
221
+ ## Old exchanges
222
+
223
+ An omitted exchange comes before the current question, so the first request after it cannot read the
224
+ question's cache entry: that prefix contains the edit. `cacheAnchors` handles this like any other edit: the
225
+ read point is the latest trusted logged entry before the earliest edited entry, and the question pin is
226
+ still placed (it writes a new entry for the requests that follow). Without such an entry, only the tools and
227
+ system prompt are read. The break-even estimate (`anthropicRewriteChars`) follows the same rule: with an edit
228
+ at or before the question and no trusted read point, the rewrite is the whole context.
229
+
230
+ Measured (`topics` scenario, Claude Sonnet 5.5, first request of prompt 5 after the first exchange was
231
+ omitted at the end of prompt 4 with `exchangeBreakEven: false`): cacheRead 1,760 (tools and system),
232
+ cacheWrite 6,280, against cacheRead 12,376 and cacheWrite 438 with the guard off. With the default gate the
233
+ omission was deferred and prompt 5 read 11,049 tokens from cache. On OpenAI Codex any edit makes the next
234
+ request read at most the 2,560-token stable prefix (see above).
235
+
236
+ So each omission costs one rewrite of everything after the omitted exchange on the next request (Pi's
237
+ default `cacheWarming: "streaming"` keeps the cache warm between prompts that come within the TTL) and saves
238
+ the exchange on every request after it. At run end, omissions therefore go through a break-even gate
239
+ (`exchangeBreakEven`): their marginal rewrite (beyond what the pass's other edits rewrite anyway) must pay
240
+ off over as many later requests as the session has had so far, and they must save at least
241
+ `exchangeMinSavingChars` (8,000 characters); otherwise they wait and accumulate. On OpenAI Codex a pass that
242
+ edits anything else already costs the whole context, so exchange omissions in that pass are free. Mid-run, exchanges join the checkpoint batch only when the batch with them pays off; their early
243
+ position makes the rewrite most of the context, so with factor 16 the batch must reach about
244
+ 1/16 of the context per turn so far (for example, old exchanges that are half of the context pay off from
245
+ about turn 32).
246
+
115
247
  ## Cost model
116
248
 
117
249
  All units are input-token equivalents, with a 5-minute TTL (write 1.25×, read 0.1×).