pi-context-guard-jev 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alandarev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,294 @@
1
- # Temporary Holding Version
1
+ # pi-context-guard-jev
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Your agent ran a 50 KB search to answer one question. Every later request still carries all 50 KB.
4
+ This Pi extension keeps the lines that mattered.**
5
+
6
+ ![A broad 496-line search is distilled after the run; the footer shows about 10.1k tokens kept out of context](https://raw.githubusercontent.com/alandarev/pi-context-guard-jev/master/assets/screenshot.png)
7
+
8
+ When a run finishes, the extension asks the Jev classifier (by TypeSafe) which parts of each large tool
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.
13
+
14
+ ```bash
15
+ pi install npm:pi-context-guard-jev # needs an OpenRouter key: OPENROUTER_API_KEY or /login
16
+ ```
17
+
18
+ ## Why use it
19
+
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.
26
+ - **Verbatim, never rewritten.** Jev is a classifier: it answers yes/no and multiple-choice questions and
27
+ 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.
30
+ - **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.
32
+ - **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.
41
+ - **You can see what it does.** The footer shows `🛡 −10.1k · 1`. `/guard` lists every decision with Jev's
42
+ probabilities, and `/guard off` turns it off for good.
43
+
44
+ **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
48
+ ([Examples](#examples-measured)).
49
+ - **Follow-ups may need `recall`.** A question that goes somewhere new can need something that was
50
+ removed.
51
+
52
+ ## How it works
53
+
54
+ 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.
59
+ 3. **It chunks each output.** grep-style output is grouped by file. Other output is cut into windows of
60
+ roughly 0.8–4k characters, with breaks at blank lines where possible.
61
+ 4. **It sends one Jev request per segment** (up to 32,000 characters and 40 chunks). Each request asks three
62
+ kinds of question about the user's question and the final answer, and, when there is one, the earlier
63
+ conversation of the session (so output that the ongoing work still needs is kept even if this run's
64
+ question was about something else):
65
+ - `keep_whole`: is all of this output still needed?
66
+ - `focus`: does the answer use most of it, none of it, or a few chunks (and if so, which one matters most)?
67
+ - one yes/no question per chunk: is this chunk needed?
68
+ 5. **It writes a `context_edit` per result.** The edit keeps the needed chunks verbatim, replaces each run of
69
+ removed chunks with an omission line, and starts with a header that tells the model how to
70
+ `recall` the full output. If the kept part would still be more than 60% of the original, the result is
71
+ left as it is.
72
+
73
+ Errors, timeouts and unclear answers always mean *keep*. For the full algorithm, see
74
+ [docs/DESIGN.md](docs/DESIGN.md).
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pi install npm:pi-context-guard-jev
80
+ # or from GitHub
81
+ pi install git:github.com/alandarev/pi-context-guard-jev
82
+ # or, for development, load it for one session from a checkout
83
+ pi -e ./src/index.ts
84
+ ```
85
+
86
+ ### Requirements
87
+
88
+ - Pi ≥ 1.0.3
89
+ - An OpenRouter credential (`OPENROUTER_API_KEY` or `/login`) for the default classifier,
90
+ `openrouter/~typesafe/jev-latest`. Any classifier model in Pi's catalog can be set with the `model`
91
+ setting, but only the default has been tested.
92
+
93
+ If the model or credential is missing, the extension does nothing and the status bar says so.
94
+
95
+ ## Privacy
96
+
97
+ **This extension sends data to a third party, and it is on by default.** After every run that qualifies,
98
+ each Jev request sends the following to OpenRouter, which forwards it to TypeSafe:
99
+
100
+ - the user's question (up to 4,000 characters),
101
+ - the final answer (up to 6,000 characters),
102
+ - the assistant's notes written during the run (up to 2,000 characters),
103
+ - excerpts of the earlier conversation in this session: the latest compaction or branch summary (up to
104
+ 2,000 characters), the first prompt (up to 1,000), and the last 3 earlier exchanges, each one prompt (up to
105
+ 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,
107
+ - 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.
112
+
113
+ `/guard off` turns the extension off and saves that choice in the settings file, so it stays off in later
114
+ sessions. See OpenRouter's data policy:
115
+ <https://openrouter.ai/docs/guides/privacy/data-collection>.
116
+
117
+ ## Status bar
118
+
119
+ | Text | Meaning |
120
+ |---|---|
121
+ | `🛡 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 |
124
+ | `🛡 guard off` | Turned off with `/guard off` or `"enabled": false` |
125
+ | `🛡 no openrouter key` | No credential for the classifier's provider (the provider name is filled in) |
126
+ | `🛡 Jev model not found` | The configured `model` is not in Pi's classifier catalog |
127
+
128
+ 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
131
+ removes distilled results. It is not a measured token count and not a cumulative total.
132
+
133
+ ## `/guard` command
134
+
135
+ | Command | Effect |
136
+ |---|---|
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` |
138
+ | `/guard on` / `/guard off` | Turns the guard on or off and saves `enabled` to the settings file |
139
+ | `/guard reload` | Re-reads the settings file and shows any warnings again |
140
+
141
+ Settings are read at session start and on `/guard reload`.
142
+
143
+ ## `recall` tool
144
+
145
+ Every distilled result starts with `[context-guard]` and ends its header with
146
+ `Full output: recall({"entryId":"…"}).` The model can call:
147
+
148
+ | Parameter | Meaning |
149
+ |---|---|
150
+ | `entryId` (required) | The entry id from the header |
151
+ | `pattern` | JavaScript regex (≤ 500 chars, 1 s limit); returns only matching lines, prefixed with their line numbers |
152
+ | `offset` | First line to return, 1-based. When `pattern` is set, this counts matching lines. |
153
+ | `limit` | Most lines to return |
154
+
155
+ Like Pi's `read` tool, the output is capped at 2,000 lines and 50 KB, and a note says how to continue.
156
+
157
+ ## Configuration
158
+
159
+ The settings file is `~/.pi/agent/context-guard.json` (inside Pi's agent directory), or the path in
160
+ `PI_CONTEXT_GUARD_CONFIG`. Every key is optional. Values that are missing, the wrong type or out of range
161
+ fall back to the default.
162
+
163
+ | Key | Default | Meaning |
164
+ |---|---|---|
165
+ | `enabled` | `true` | Master switch (`/guard on` and `/guard off` write it) |
166
+ | `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 |
169
+ | `keepWholeThreshold` | `0.7` | Keep a segment untouched if P(`keep_whole`) is at least this |
170
+ | `focusWholeThreshold` | `0.6` | Keep a segment untouched if `focus` picks "whole" with at least this probability |
171
+ | `chunkKeepThreshold` | `0.6` | Keep a chunk if P(chunk needed) is at least this |
172
+ | `noneThreshold` | `0.5` | Remove a segment entirely if `focus` picks "none" with at least this probability and no chunk passes |
173
+ | `maxKeepRatio` | `0.6` | Skip the edit if the kept chunks are more than this fraction of the original |
174
+ | `keepCitedFiles` | `false` | Also keep grep chunks for files that the final answer names (see DESIGN.md for why it is off) |
175
+ | `timeoutMs` | `8000` | Total time budget for all Jev requests of one run; unfinished parts are kept |
176
+ | `concurrency` | `6` | Jev requests in parallel |
177
+ | `maxSegmentChars` | `32000` | Most characters of one output per Jev request (keeps the worst-case request under Jev's 32k-token context, see docs/JEV.md) |
178
+ | `maxChunksPerSegment` | `40` | Most chunks per Jev request |
179
+ | `excludeTools` | `["edit", "write"]` | Tools whose results are never distilled |
180
+ | `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
+ | `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)) |
183
+
184
+ Example:
185
+
186
+ ```json
187
+ { "minResultChars": 6000, "excludeTools": ["edit", "write", "bash"] }
188
+ ```
189
+
190
+ ## What it does not touch
191
+
192
+ - user and assistant messages, custom messages
193
+ - tool results that contain images
194
+ - error results, if `distillErrors` is `false`
195
+ - results of excluded tools (`edit`, `write` by default)
196
+ - results shorter than `minResultChars`, and runs below `minRunChars`
197
+ - aborted or failed runs
198
+ - results that are already distilled, or that another extension already edited
199
+ - single lines too long for one Jev request (kept as they are)
200
+ - earlier runs: only the run that just finished is edited
201
+
202
+ ## Limitations
203
+
204
+ - **Lossy by design.** Relevance is judged against the run's question and answer and the earlier
205
+ 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.
207
+ - **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).
209
+ - **Cost.** Each qualifying run costs a few Jev requests, measured at under $0.001 per run. The first prompt
210
+ after an edit also has a one-time partial prompt-cache miss ([docs/CACHE.md](docs/CACHE.md)).
211
+ - **Steering.** A message sent during a run starts a new span. Tool results from before it are never
212
+ distilled.
213
+
214
+ ## Examples (measured)
215
+
216
+ Real runs on public code with Pi 1.0.3: each example ran once with the guard on and once with it off
217
+ (`--no-guard`), from the same prompts. These are single runs, not a benchmark: the main model takes a
218
+ different path each time. Reproduce with `node test/e2e/examples.mjs` ([docs/TESTING.md](docs/TESTING.md)).
219
+
220
+ **A. Searching a real codebase.** In a copy of the published Pi package (`dist/` without source maps and
221
+ the minified bundle, plus `docs/`), the agent was asked where Pi decides to auto-compact, starting with
222
+ `rg -n -i compact dist docs`. Pi's bash tool returned the last 50 KB of that search. Jev kept the matches
223
+ in the compaction module, the settings and the decision points in `agent-session.js`, and dropped matches
224
+ in the grep tool, HTML export templates, the SDK and so on. Then the agent was asked "which of those
225
+ settings can a user change, and what are their defaults?".
226
+ - Claude Sonnet 5.5: 50,413 → 16,210 chars (141 of 431 lines kept). The first turn-2 request carried
227
+ **15.5k tokens instead of 28.2k**. The answer was correct (`enabled` true, `reserveTokens` 16384,
228
+ `keepRecentTokens` 20000), without `recall`.
229
+ - GPT-6 Luna: 51,216 → 13,023 chars (107 of 385 lines), and a 5.4k read of `compaction.js` → 1.8k; two
230
+ smaller outputs were left whole. Turn 2 carried **11.5k tokens instead of 34.3k** and was correct, without
231
+ `recall`.
232
+
233
+ **C. Reading a 164 KB source file.** The agent read `dist/core/agent-session.js` (3,535 lines) in
234
+ seven 500-line pieces and explained how a prompt reaches the model. Jev kept 45–164 lines of each of the first
235
+ four pieces and removed the last three completely (each became a one-line stub). The follow-up asked for a
236
+ detail the explanation never mentioned: the exact error thrown when a prompt arrives during compaction.
237
+ That line was among the kept ones, so the agent quoted it exactly without `recall`. The turn-2 request
238
+ carried **9.2k tokens instead of 35.3k**.
239
+
240
+ **B. A failing test run.** In a copy of this repository with one injected off-by-one bug, the agent ran
241
+ the unit tests (a 15.8k-character log, 3 failures among 116 tests) and reported the cause. Jev kept the
242
+ 3 failures with their assertion messages and the failing-test summary, and dropped the passing tests:
243
+ 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.)
246
+
247
+ What the model sees after distilling (example A, Claude; the first lines of the real replacement):
248
+
249
+ ```text
250
+ [context-guard] Distilled the output of bash `rg -n -i compact dist docs`: kept 141 of 431 lines (15.1k of 50.4k chars); the rest was judged not needed for the answer. Full output: recall({"entryId":"1704787f"}).
251
+ [… 79 lines omitted: matches in dist/core/tools/grep.js, dist/core/session-manager.js, dist/core/export-html/template.js, dist/core/export-html/template.css, dist/core/sdk.js and 5 more …]
252
+ dist/core/compaction/compaction.d.ts:25: /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
253
+ dist/core/compaction/compaction.d.ts:28:export interface CompactionSettings {
254
+ dist/core/compaction/compaction.d.ts:33:export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
255
+ dist/core/compaction/compaction.d.ts:54:/** Estimate projected context without trusting usage captured before a later edit or compaction. */
256
+ dist/core/compaction/compaction.d.ts:57: * Check if compaction should trigger based on context usage.
257
+ dist/core/compaction/compaction.d.ts:59:export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean;
258
+ [… 44 lines omitted: matches in dist/core/compaction/compaction.d.ts, dist/core/compaction/branch-summarization.d.ts, dist/core/compaction/index.d.ts, dist/core/session-manager.d.ts, dist/core/cache-stats.js and 2 more …]
259
+ ```
260
+
261
+ | Example | Model | Tool output before → after | Jev | Next request's context, on / off | Main-model cost of turn 2, on / off | Follow-up |
262
+ |---|---|---|---|---|---|---|
263
+ | A. code search | Claude Sonnet 5.5 | 50.4k → 16.2k chars | 3 requests, 0.8 s, $0.0012 | 15.5k / 28.2k tokens | $0.033 / $0.016 | correct, no `recall` |
264
+ | 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
+ | 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
+ | 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 |
267
+
268
+ **When it doesn't help.**
269
+
270
+ - **The first request after an edit costs more, not less.** The prompt cache only matches up to the
271
+ first changed message, so the next request writes the shorter history again instead of reading the long
272
+ one from cache. In A that made Claude's turn-2 request cost $0.033 instead of $0.016, and GPT's $0.0012
273
+ instead of $0.0005; OpenAI read nothing from cache on that request (in the fixture runs it read 2,560
274
+ tokens instead of 13,824: a partial miss).
275
+ At the per-token prices in these runs, the smaller context earns that back after about 7 more requests
276
+ (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)).
279
+ - **Small or fully needed outputs are left whole.** In A, GPT's 9k-character read of `agent-session.js` was
280
+ `not-worth` (too much of it was needed) and a 6k narrowed search was judged needed whole.
281
+
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).
284
+
285
+ ## Documentation
286
+
287
+ - [docs/DESIGN.md](docs/DESIGN.md): architecture and algorithm
288
+ - [docs/CACHE.md](docs/CACHE.md): prompt-cache impact (Anthropic, OpenAI) and the cost model
289
+ - [docs/JEV.md](docs/JEV.md): the Jev questions, probe results and threshold choices
290
+ - [docs/TESTING.md](docs/TESTING.md): unit tests, live end-to-end tests, manual TUI check
291
+
292
+ ## License
293
+
294
+ MIT
package/docs/CACHE.md ADDED
@@ -0,0 +1,149 @@
1
+ # Prompt-cache impact
2
+
3
+ **Summary.** An edit changes the context from the first distilled tool result onwards. The next prompt
4
+ therefore has a one-time partial cache miss: everything before the edited result can still be read from the
5
+ cache, and everything after it is written again. After that, each request carries the smaller, distilled
6
+ history. On Anthropic models, a long run can push the reusable cache entry out of reach. The extension adds
7
+ one cache breakpoint to prevent that (measured below). On OpenAI, caching is automatic. Pi's Codex transport
8
+ also loses its delta continuation once after an edit.
9
+
10
+ Pi facts were checked in the Pi 1.0.3 source (`@earendil-works/pi-ai/dist/api/anthropic-messages.js`,
11
+ `openai-codex-responses.js`). Provider facts come from the primary docs, read on 2026-10-06 (see
12
+ [Sources](#sources)). Numbers marked *measured* come from the live tests in [TESTING.md](TESTING.md). Single
13
+ runs, not averages.
14
+
15
+ ## Anthropic
16
+
17
+ ### What Anthropic does (documented)
18
+
19
+ - At most **4** cache breakpoints per request.
20
+ - For each breakpoint, the lookup goes back **at most 20 positions** to find an earlier cache entry.
21
+ Consecutive `tool_use` blocks count as one position, and so do consecutive `tool_result` blocks.
22
+ - Entries with a longer TTL must come before entries with a shorter TTL.
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.
25
+ - Pricing relative to base input: 5-minute write 1.25×, 1-hour write 2×, read 0.1× (0.05× on Opus 5.5).
26
+ - The minimum cacheable prompt is 512 tokens on Sonnet/Opus 5.5.
27
+
28
+ ### Where Pi puts breakpoints
29
+
30
+ | Breakpoint | Where |
31
+ |---|---|
32
+ | tools | the last tool definition |
33
+ | system | the system prompt block |
34
+ | messages | rolling: the last user or system message only |
35
+
36
+ With an API key that makes 3 breakpoints. With Anthropic OAuth, Pi also adds a `"You are Claude Code…"`
37
+ identity block as `system[0]`, with its own breakpoint, which makes 4. With `PI_CACHE_RETENTION=long`, all
38
+ breakpoints get `ttl: "1h"` where the model supports it.
39
+
40
+ The `pi-claude-auth` extension, used for the Claude subscription tests, rewrites the payload differently.
41
+ It moves the system prompt into the first user message and adds a billing block that is not cached. The
42
+ measured requests never had more than 4 breakpoints.
43
+
44
+ ### The problem
45
+
46
+ The first request of a run has the question as its last message, so it writes a cache entry ending at the
47
+ question. After the run is distilled, the next prompt's prefix matches up to the first edited result, which
48
+ includes the question. But that entry was written by the run's first request, and the rolling breakpoint is
49
+ now at the end of the conversation. If the run had more than about 20 positions between the question and the
50
+ end (a dozen sequential tool calls is enough), the lookback never reaches the question's entry. Only tools and
51
+ system are read, and the whole history is written again.
52
+
53
+ ### The fix, as implemented (`src/cache-pin.ts`)
54
+
55
+ In `before_provider_request`, for models with `api === "anthropic-messages"` (when `enabled` and
56
+ `pinAnthropicCache` are on):
57
+
58
+ 1. Find Pi's rolling `cache_control` (the last one in `messages`). Without one, caching is off: do nothing.
59
+ 2. Skip trailing mid-conversation `system` messages (effort or tool changes). Then find the most recent user
60
+ message with text that is **not** the last message. Tool-result-only user messages do not count. This is
61
+ the run's question during a run, and the previous question on the first request of a new prompt.
62
+ 3. Put a `cache_control` on that message's last text block (string content is converted to one text block
63
+ first). If it already has one, leave it. The TTL must keep Anthropic's order (every breakpoint before it
64
+ at least as long, every one after it no longer): the rolling breakpoint's TTL is used when that holds,
65
+ otherwise the other TTL; if neither fits, nothing is pinned (`ttl-conflict`). Pi itself always uses one
66
+ TTL for every breakpoint, so in practice the pin copies it.
67
+ 4. If that makes more than 4 breakpoints, remove the breakpoint from `system[0]` (the OAuth identity block).
68
+ The `system[1]` breakpoint covers that prefix too.
69
+ 5. If it is still more than 4, undo everything and leave the payload as Pi built it.
70
+
71
+ During a run, every request reads the question's entry directly, so the entry stays refreshed and the lookback
72
+ limit does not matter. Moving a breakpoint does not change the cached content, so existing entries stay
73
+ valid. The pin is added on every Anthropic request, whether or not anything was distilled.
74
+
75
+ ### Measured
76
+
77
+ Claude Sonnet 5.5 via `pi-claude-auth`, Pi 1.0.3:
78
+
79
+ | Case | First request after the distilled run |
80
+ |---|---|
81
+ | Short run (badge scenario), with pin | cacheRead 5,777, cacheWrite 2,036 |
82
+ | Long run (11 sequential tool calls, 36,113 → ~3.3k chars), **with pin** | cacheRead **5,810**, cacheWrite **8,913** |
83
+ | Same long run, **without pin** (`pinAnthropicCache: false`) | cacheRead **1,693** (tools + system only), cacheWrite **13,068** |
84
+
85
+ Without the pin, the 20-position lookback missed the question's entry. With the pin, it was hit. No request
86
+ had more than 4 breakpoints.
87
+
88
+ ## OpenAI
89
+
90
+ ### What OpenAI does (documented)
91
+
92
+ - Caching is automatic, with no breakpoints. The whole rendered prefix must match. A change prevents reuse
93
+ from that point on.
94
+ - GPT-5.6 and later: 1,024-token minimum, about 30 minutes retention, reads cost about 0.1×.
95
+
96
+ ### Pi's Codex transport
97
+
98
+ Over WebSocket, Pi's `openai-codex` transport sends only the new input items with `previous_response_id`, as
99
+ 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.
102
+
103
+ ### Measured
104
+
105
+ GPT-6 Luna (openai-codex), badge scenario, turn 2 first request:
106
+
107
+ | Case | cacheRead |
108
+ |---|---|
109
+ | Without the guard (control) | 13,824 |
110
+ | With the guard | 2,560 (only the stable prefix before the first edited item) |
111
+ | With the guard, one earlier run | 0 |
112
+
113
+ The pin does not apply to OpenAI.
114
+
115
+ ## Cost model
116
+
117
+ All units are input-token equivalents, with a 5-minute TTL (write 1.25×, read 0.1×).
118
+
119
+ **Measured, long Claude run, first request after the run:**
120
+
121
+ | | Read | Write | Cost |
122
+ |---|---|---|---|
123
+ | With pin | 5,810 × 0.1 | 8,913 × 1.25 | **≈ 11.7k** |
124
+ | Without pin | 1,693 × 0.1 | 13,068 × 1.25 | **≈ 16.5k** |
125
+
126
+ The prompt was the same size in both cases, about 14.7k tokens. The pin moved about 4.1k tokens from write to
127
+ read and made that request about 29% cheaper.
128
+
129
+ **Assumed sizes, to show the trade-off.** System prompt and tools 15k tokens, earlier history 30k, the run's
130
+ tool output 60k distilled to 5k:
131
+
132
+ | Next prompt | Cost |
133
+ |---|---|
134
+ | Not distilled (full cache hit) | read 105k × 0.1 → **≈ 10.5k** |
135
+ | Distilled, question entry hit (pin) | read 45k × 0.1 + write 5k × 1.25 → **≈ 10.8k**; every later request then saves about 5.5k |
136
+ | Distilled, only tools and system hit (no pin, long run) | read 15k × 0.1 + write 35k × 1.25 → **≈ 45k** one time; pays back after about 6 requests |
137
+
138
+ Notes:
139
+
140
+ - If the user waits longer than the cache TTL before the next prompt, everything is cold anyway, and the
141
+ edit costs nothing extra.
142
+ - Jev requests do not touch the main model's cache. They cost about $0.0008 per qualifying run (measured).
143
+ - On OpenAI, the one-time cost is the difference between 13,824 and 2,560 cached tokens on one request
144
+ (measured above), against a smaller context on every later request.
145
+
146
+ ## Sources
147
+
148
+ - Anthropic, prompt caching: <https://docs.claude.com/en/docs/build-with-claude/prompt-caching>
149
+ - OpenAI, prompt caching: <https://developers.openai.com/api/docs/guides/prompt-caching>