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 +21 -0
- package/README.md +293 -2
- package/docs/CACHE.md +149 -0
- package/docs/DESIGN.md +278 -0
- package/docs/JEV.md +179 -0
- package/docs/TESTING.md +218 -0
- package/package.json +55 -4
- package/src/cache-pin.ts +122 -0
- package/src/chunk.ts +186 -0
- package/src/config.ts +147 -0
- package/src/decide.ts +184 -0
- package/src/distill.ts +248 -0
- package/src/index.ts +229 -0
- package/src/recall.ts +110 -0
- package/src/render.ts +116 -0
- package/src/run.ts +157 -0
- package/src/stats.ts +96 -0
- package/src/types.ts +89 -0
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
|
-
#
|
|
1
|
+
# pi-context-guard-jev
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+

|
|
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>
|