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 +198 -55
- package/docs/CACHE.md +135 -3
- package/docs/DESIGN.md +218 -30
- package/docs/JEV.md +95 -27
- package/docs/TESTING.md +95 -25
- package/package.json +2 -2
- package/src/cache-pin.ts +170 -0
- package/src/checkpoint.ts +436 -0
- package/src/config.ts +56 -2
- package/src/decide.ts +74 -0
- package/src/distill.ts +9 -2
- package/src/index.ts +243 -55
- package/src/items.ts +447 -0
- package/src/process.ts +176 -0
- package/src/recall.ts +63 -4
- package/src/run.ts +78 -27
- package/src/stats.ts +35 -5
- package/src/types.ts +7 -0
package/docs/TESTING.md
CHANGED
|
@@ -13,15 +13,17 @@ These need Node ≥ 22.18, which runs the `.ts` files directly. There is no netw
|
|
|
13
13
|
|
|
14
14
|
| File | Covers |
|
|
15
15
|
|---|---|
|
|
16
|
+
| `checkpoint.test.ts` | Mid-run eligibility and age, the memo and candidate rules, later calls, notes, superseded detection (edits, re-reads, reruns), cache anchors (read point only from logged entries of the same model within the TTL, invalidated by later edits and compaction, parallel-call batches never split, early return without pending edits), refresh on read-through (same model, cacheRead clearly past the entry, unchanged prefix; trusted after 5 minutes once refreshed), the run baseline, the Anthropic rewrite estimate (from the trusted read point, else from the question), pending edits, the memo from records, the break-even rule, context size with summaries |
|
|
17
|
+
| `items.test.ts` | Small outputs (size window, candidate rules, age, memo, stub length, superseded), exchange spans (custom messages, completed exchanges only, the current run and the recent exchanges never touched, already omitted, memo; images, foreign edits and foreign omissions skipped, our own markers allowed; system, `!` and custom messages; a compaction that cut an exchange; minimum size), stubs and edits, the small and exchange requests in both phases, batching, the shared limiter, `judgeItems` (parallel limit, failures keep, time budget), `processItems` (stubs, omissions, records with kinds, one `concurrency` across all kinds) |
|
|
16
18
|
| `run.test.ts` | Run span (including image-only prompts), question/answer/notes, candidate rules (errors, images, excluded tools, short, already-distilled and other-extension-edited results), steering messages; history (last N exchanges, first request, compaction/branch summaries, image-only and steering prompts, clipping, `historyExchanges` 0) |
|
|
17
19
|
| `chunk.test.ts` | grep detection, grouping by file, context lines and `--`, `-digits-` file names, split/merge rules, plain-text windows, segment limits |
|
|
18
|
-
| `decide.test.ts` |
|
|
20
|
+
| `decide.test.ts` | Checkpoint request shape and wording; run-end request shape, with and without `earlier_conversation` (state order, omitted parts, history wording vs. the unchanged single-run wording); the decision order (error, missing answers, `keep_whole`, strong/weak `whole`, chunks, `none`); `citedFiles` |
|
|
19
21
|
| `distill.test.ts` | Skips, edits, `removed`, `not-worth`, failures, time budget (including a classifier that never answers and late answers), parent abort, concurrency limit, multi-segment outputs, oversize segments never sent, `keepCitedFiles`, usage sums |
|
|
20
22
|
| `render.test.ts` | Labels, verbatim chunks with omission lines, `read` offsets, file lists, full removal |
|
|
21
|
-
| `cache-pin.test.ts` | API-key and OAuth payloads, trailing system messages, TTL selection and `ttl-conflict`, string content, tool-result-only messages, over-budget restore |
|
|
22
|
-
| `stats.test.ts` | Savings from the projection, run records, status texts and colours, cost format |
|
|
23
|
+
| `cache-pin.test.ts` | Question pin: API-key and OAuth payloads, trailing system messages, TTL selection and `ttl-conflict`, string content, tool-result-only messages, over-budget restore. Anchors: question > read > write priority, read anchor inside a batch of tool_result blocks, breakpoint budget (identity and tools breakpoints dropped, then write, then read; the question survives a foreign breakpoint), not found / last message / already marked, normalized tool ids, TTL order, `markedToolResults` |
|
|
24
|
+
| `stats.test.ts` | Savings from the projection, run records (checkpoints counted), status texts and colours, cost format |
|
|
23
25
|
| `recall.test.ts` | Original output, pattern (length cap, 4,000-char match window, catastrophic-regex timeout), offset/limit, line and byte caps, oversized lines |
|
|
24
|
-
| `config.test.ts` | Defaults, validation and ranges (incl. `historyExchanges` 0–10 integer), `provider/id` parsing, load/save round trip |
|
|
26
|
+
| `config.test.ts` | Defaults, validation and ranges (incl. `historyExchanges` 0–10 integer and the `midRun*` settings), `provider/id` parsing, load/save round trip |
|
|
25
27
|
| `extension.test.ts` | The real entry point (see below) |
|
|
26
28
|
|
|
27
29
|
`extension.test.ts` loads `src/index.ts` through **Pi's own extension loader** (`loadExtensions` from the
|
|
@@ -29,7 +31,26 @@ These need Node ≥ 22.18, which runs the `.ts` files directly. There is no netw
|
|
|
29
31
|
`PI_CODING_AGENT_DIR` and `PI_CONTEXT_GUARD_CONFIG` to a temporary directory, so `/guard on|off` never touches the real
|
|
30
32
|
`~/.pi/agent/context-guard.json`. It checks that the handlers, tool and command are registered, that drafts
|
|
31
33
|
are appended after other extensions' drafts, that unfinished runs and a missing model are ignored, that the
|
|
32
|
-
pin applies only to `anthropic-messages`, that `recall` works, and that `/guard off|on` persists.
|
|
34
|
+
pin applies only to `anthropic-messages`, that `recall` works, and that `/guard off|on` persists. For
|
|
35
|
+
`turn_end` it checks that the final turn is skipped, that a batch below `midRunBatchChars` and a checkpoint
|
|
36
|
+
that would not break even are skipped, that a due checkpoint appends edits and a `mid-run` record after other
|
|
37
|
+
drafts (never `continue`), that nothing is judged twice once the record is committed, that drafts a later
|
|
38
|
+
handler dropped (or Pi rejected) are judged again, that an unanswered output (error) is asked again, that the
|
|
39
|
+
memo follows `/tree`, and that run end judges outputs kept mid-run again. On a `pi-claude-auth`-shaped
|
|
40
|
+
payload (system prompt moved into the first user message) it checks the question pin on every request; on
|
|
41
|
+
the first request after edits a write anchor, and a read point only at an entry this process sent earlier
|
|
42
|
+
(also on a warming replay); a read point after run-end edits; and no read point for another model, after
|
|
43
|
+
the TTL (mocked clock), after a reload, for an anchor dropped for the budget, or for history made with
|
|
44
|
+
pinning off. Through `message_end` it checks that a response that read through a logged entry keeps it
|
|
45
|
+
usable past 5 minutes, and that a short read or another model's response does not. For whole items it
|
|
46
|
+
checks that run end omits an unrelated old exchange (stub on the prompt, `null` for the rest) and stubs a
|
|
47
|
+
small output, that the next run judges the next eligible exchange, that `recall` on the stub returns the
|
|
48
|
+
whole exchange, that `pruneExchanges: false` and `smallResultMinChars: 0` turn them off, and that the
|
|
49
|
+
break-even rule drops exchanges from a checkpoint batch that would not pay off with them. The run-end
|
|
50
|
+
exchange gate: a small omission is deferred and the accumulated batch is omitted at a later run end; a big
|
|
51
|
+
early exchange in a short session is deferred, and omitted with `exchangeBreakEven: false`; on OpenAI Codex
|
|
52
|
+
an exchange rides along when the pass edits something else and is deferred when it does not. The fake
|
|
53
|
+
session uses Pi's projection shape (an omitted entry stays with no messages).
|
|
33
54
|
|
|
34
55
|
Test through Pi's loader (or `pi -e`), not a plain Node import of the extension. Plain Node imports of
|
|
35
56
|
`@earendil-works/pi-coding-agent` can fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`, even though Pi loads the
|
|
@@ -41,7 +62,7 @@ This uses a real Pi, a real main model and real Jev requests. **It costs money o
|
|
|
41
62
|
main-model requests per turn, plus a few Jev requests (under $0.001 per distilled run).
|
|
42
63
|
|
|
43
64
|
```bash
|
|
44
|
-
node test/e2e/run-e2e.mjs --model <provider/id> [--ext <path>]... [--scenario badge|long|
|
|
65
|
+
node test/e2e/run-e2e.mjs --model <provider/id> [--ext <path>]... [--scenario badge|sequential|history|long|topics] \
|
|
45
66
|
[--config '<JSON>'] [--no-guard] [--turns N] [--thinking level]
|
|
46
67
|
```
|
|
47
68
|
|
|
@@ -49,7 +70,7 @@ node test/e2e/run-e2e.mjs --model <provider/id> [--ext <path>]... [--scenario ba
|
|
|
49
70
|
|---|---|---|
|
|
50
71
|
| `--model` | (required) | Main model, e.g. `openai-codex/gpt-6-luna` or `anthropic/claude-sonnet-5-5` |
|
|
51
72
|
| `--ext` | none | Extra extension to load; can be repeated (e.g. an auth extension) |
|
|
52
|
-
| `--scenario` | `badge` | `badge`: 3 turns about `StatusBadge` usages. `
|
|
73
|
+
| `--scenario` | `badge` | `badge`: 3 turns about `StatusBadge` usages. `sequential`: 2 turns with 10+ sequential tool calls, for the question pin. `history`: 3 turns for the earlier-conversation feature. `long`: one autonomous "fix all failing tests" run, for mid-run checkpoints. `topics`: 5 prompts on different topics, the last a follow-up on the first, for old exchanges (both below; use `--turns 5`). |
|
|
53
74
|
| `--config` | `{}` | context-guard settings for this run, e.g. `'{"pinAnthropicCache":false}'` |
|
|
54
75
|
| `--no-guard` | off | Control run without context-guard |
|
|
55
76
|
| `--turns` | `3` | Turns to run (capped by the scenario) |
|
|
@@ -80,7 +101,10 @@ How it works:
|
|
|
80
101
|
| turn 2 first request reads cache | `cacheRead` > 0 on the first request after the edit |
|
|
81
102
|
| turn 3 quotes the removed line exactly | The model quoted line 3 of `src/api/jobStatus.ts` from the "first search" |
|
|
82
103
|
| turn 3 used recall (line was distilled away) | Only when that line is missing from turn 3's payload: the model must have called `recall` |
|
|
83
|
-
|
|
|
104
|
+
| sequential: turn 1 produced context edits / 10+ tool calls / 10+ model requests | The sequential scenario really was long and sequential |
|
|
105
|
+
| long: the suite is green afterwards | `npm test` passes in the fixture after the run |
|
|
106
|
+
| long: at least one mid-run checkpoint (forced) | Only with `midRunBreakEven: false` in `--config`: a `mid-run` record was written (a default run may rightly make none) |
|
|
107
|
+
| long: request after the checkpoint at request N reads the cached prefix | Anthropic: the first request after each checkpoint reads at least what the run's first request read and wrote (system prompt and question) |
|
|
84
108
|
| history: turn 2 produced context edits | The broad search in turn 2 was distilled |
|
|
85
109
|
| history: turn 3 names exactly the clients that retry on 429 | The answer names the 9 clients whose fixture file has `=== 429`; a client named only to say it does *not* retry is allowed |
|
|
86
110
|
| anthropic requests stay within 4 breakpoints | For `anthropic/…` models: the pin never exceeds the limit |
|
|
@@ -93,6 +117,47 @@ summary has a `history` block: `keptApiRetryLines` / `kept429Lines` (src/api ret
|
|
|
93
117
|
distilled text), the turn-2 Jev results and ms, turn 3's tool calls and whether it used `recall`, and the
|
|
94
118
|
clients turn 3 named. Results are in [JEV.md](JEV.md#earlier-conversation-historyexchanges).
|
|
95
119
|
|
|
120
|
+
**`long` scenario.** `test/e2e/make-long-fixture.mjs` generates a small Node project (`ledgerly`: money
|
|
121
|
+
in integer cents, business-day calendars, CSV, pricing, a ledger report; 182 tests). Ten bugs sit in
|
|
122
|
+
different modules, in up to three layers: an import-time `ReferenceError` hides a whole test file, and two
|
|
123
|
+
tests fail first because of one bug and then, once it is fixed, because of another. A full `npm test` prints
|
|
124
|
+
12k characters when green and 25–30k with the failures, so every rerun is a large, quickly superseded tool
|
|
125
|
+
output. The prompt asks the agent to fix one bug at a time and run the plain suite after every fix, without
|
|
126
|
+
asking anything. (`--fixed` writes the bug-free version, `--bugs 1,4,…` a subset.) GPT-6 Luna needs 26–37
|
|
127
|
+
requests; Claude Sonnet 5.5 needs 8–14, because it batches several fixes per command and pipes test output
|
|
128
|
+
into `grep`/`head`. Load `--ext test/e2e/plain-test-runs.ts` for Claude to strip a pipe after `npm test`
|
|
129
|
+
(it logs each rewrite to stderr), so its suite runs return full logs like GPT's.
|
|
130
|
+
|
|
131
|
+
The summary gets a `long` block: per-request contexts (input + cacheRead + cacheWrite) and a sparkline,
|
|
132
|
+
totals of input/cacheRead/cacheWrite/output and the main-model cost Pi reports, every `context-guard`
|
|
133
|
+
record with its phase, saved characters, Jev ms and cost, and the request before and after it (context,
|
|
134
|
+
cacheRead, cacheWrite), whether the suite is green, `recall` calls, and repeated non-suite tool calls (lost
|
|
135
|
+
outputs searched again). Results are in the README ("Long autonomous runs") and in
|
|
136
|
+
[CACHE.md](CACHE.md#mid-run-checkpoints).
|
|
137
|
+
|
|
138
|
+
**`topics` scenario.** Five prompts in the generated StatusBadge repo: which API clients retry on 503; a
|
|
139
|
+
README paragraph for `StatusBar`; a count of HTTP 500 log lines; where `StatusBadge` is used; and "back to
|
|
140
|
+
the API clients that retry on 503: which of them also retry on 502?". The summary gets a `topics` block:
|
|
141
|
+
the context of each prompt's first request and its usage, the exchange results (phase, prompt, outcome,
|
|
142
|
+
P), the small-output results, the last prompt's tool calls and whether it used `recall`, and whether its
|
|
143
|
+
answer names exactly the clients whose file has both `=== 503` and `=== 502` (a crude check: it fails on
|
|
144
|
+
answers that also list clients to say they do not qualify). Results are in the README (example D) and in
|
|
145
|
+
[CACHE.md](CACHE.md#old-exchanges).
|
|
146
|
+
|
|
147
|
+
**API-shape probe.** `CG_OMIT_AT_TURN=2 node test/e2e/run-e2e.mjs --model … --no-guard --ext
|
|
148
|
+
test/e2e/omit-probe.ts` omits the session's first exchange after turn 2 the way the guard does, so turn 3
|
|
149
|
+
sends a stub user message right before the next user message, without the omitted assistant and
|
|
150
|
+
tool-result messages. Both GPT-6 Luna and Claude Sonnet 5.5 (`pi-claude-auth`) accepted it and answered.
|
|
151
|
+
|
|
152
|
+
**Whole-item probe.** `node test/e2e/probe-items.mjs [--reps 2] [--sessions <topics no-guard run dir>]…`
|
|
153
|
+
sends the hand-written exchanges and small outputs in `test/e2e/probe-data/` (and the `topics` exchanges)
|
|
154
|
+
to Jev and prints P per item and the accuracy per threshold (results in [JEV.md](JEV.md#old-exchanges)).
|
|
155
|
+
|
|
156
|
+
**Anthropic TTL experiment.** `node test/e2e/ttl-probe.mjs [--arms refresh,control,ttl1h,idle,idle1h]`
|
|
157
|
+
runs `test/e2e/ttl-probe.ts` with Claude: does a cache entry stay alive while later requests read a longer
|
|
158
|
+
prefix, and does it expire when nothing reads it for 6 minutes? About 9 minutes and roughly $0.9 at list price for all five arms.
|
|
159
|
+
Results: [CACHE.md](CACHE.md#do-cache-entries-on-the-read-path-stay-alive-measured).
|
|
160
|
+
|
|
96
161
|
With the guard on, the badge scenario has 9 checks, plus 1 for Anthropic models. Most guard checks are
|
|
97
162
|
skipped with `--no-guard`.
|
|
98
163
|
|
|
@@ -118,24 +183,31 @@ the runner disables installed extensions:
|
|
|
118
183
|
|
|
119
184
|
```bash
|
|
120
185
|
node test/e2e/run-e2e.mjs --model anthropic/claude-sonnet-5-5 --ext ~/.pi/agent/npm/node_modules/pi-claude-auth
|
|
121
|
-
node test/e2e/run-e2e.mjs --model anthropic/claude-sonnet-5-5 --ext ~/.pi/agent/npm/node_modules/pi-claude-auth --scenario
|
|
122
|
-
node test/e2e/run-e2e.mjs --model anthropic/claude-sonnet-5-5 --ext ~/.pi/agent/npm/node_modules/pi-claude-auth --scenario
|
|
186
|
+
node test/e2e/run-e2e.mjs --model anthropic/claude-sonnet-5-5 --ext ~/.pi/agent/npm/node_modules/pi-claude-auth --scenario sequential
|
|
187
|
+
node test/e2e/run-e2e.mjs --model anthropic/claude-sonnet-5-5 --ext ~/.pi/agent/npm/node_modules/pi-claude-auth --scenario sequential --config '{"pinAnthropicCache":false}'
|
|
123
188
|
node test/e2e/run-e2e.mjs --model openai-codex/gpt-6-luna
|
|
124
189
|
node test/e2e/run-e2e.mjs --model openai-codex/gpt-6-luna --no-guard
|
|
125
190
|
node test/e2e/run-e2e.mjs --model openai-codex/gpt-6-luna --scenario history
|
|
126
191
|
node test/e2e/run-e2e.mjs --model openai-codex/gpt-6-luna --scenario history --config '{"historyExchanges":0}'
|
|
192
|
+
# mid-run checkpoints (up to 4 in parallel)
|
|
193
|
+
node test/e2e/run-e2e.mjs --model openai-codex/gpt-6-luna --scenario long --no-guard
|
|
194
|
+
node test/e2e/run-e2e.mjs --model openai-codex/gpt-6-luna --scenario long --config '{"midRun":false}'
|
|
195
|
+
node test/e2e/run-e2e.mjs --model openai-codex/gpt-6-luna --scenario long
|
|
196
|
+
node test/e2e/run-e2e.mjs --model anthropic/claude-sonnet-5-5 --ext ~/.pi/agent/npm/node_modules/pi-claude-auth --ext test/e2e/plain-test-runs.ts --scenario long
|
|
127
197
|
```
|
|
128
198
|
|
|
129
|
-
### Results
|
|
199
|
+
### Results (Pi 1.0.3)
|
|
130
200
|
|
|
131
201
|
| Run | Result |
|
|
132
202
|
|---|---|
|
|
133
203
|
| GPT-6 Luna, badge | 36,113 → 1,501 chars, 2 Jev requests, 550 ms, $0.00083; turn 3 used `recall` with a regex; 9/9 checks pass |
|
|
134
204
|
| Claude Sonnet 5.5, badge | 36,113 → 3,066 chars, 2 Jev requests, 1.18 s, $0.00084; turn 2 cacheRead 5,777 / cacheWrite 2,036; turn 3 used `recall`; 10/10 checks pass |
|
|
135
|
-
| Claude Sonnet 5.5,
|
|
205
|
+
| Claude Sonnet 5.5, sequential, with / without pin | first request after the run: cacheRead 5,810 / 1,693, cacheWrite 8,913 / 13,068 |
|
|
136
206
|
| GPT-6 Luna, badge, with / without guard | turn 2 cacheRead 2,560 / 13,824 |
|
|
137
|
-
| GPT-6 Luna, badge,
|
|
207
|
+
| GPT-6 Luna, badge, with history (default) | 36,113 → ~2.3k chars; 9/9 checks pass (a run in parallel with 4 others had one OpenAI cache miss on turn 2: 8/9) |
|
|
138
208
|
| GPT-6 Luna / Claude Sonnet 5.5, history on / off | See [JEV.md](JEV.md#earlier-conversation-historyexchanges): 2 rounds × 4 runs |
|
|
209
|
+
| GPT-6 Luna / Claude Sonnet 5.5, long (mid-run checkpoints) | See the README, "Long autonomous runs"; all suites green, no `recall` |
|
|
210
|
+
| GPT-6 Luna / Claude Sonnet 5.5, topics, guard on / off | See the README, example D |
|
|
139
211
|
|
|
140
212
|
See [CACHE.md](CACHE.md) for what the cache numbers mean.
|
|
141
213
|
|
|
@@ -159,7 +231,7 @@ node test/e2e/examples.mjs --jobs 2
|
|
|
159
231
|
|
|
160
232
|
Each run gets its own workspace, session id and `PI_CONTEXT_GUARD_CONFIG` file (defaults). The Pi package is
|
|
161
233
|
public (npm); nothing private is sent to OpenRouter. Example B also records the test log's size
|
|
162
|
-
(`testLogChars`, which must be ≥ 8,000 to be a fair example; it was 14.
|
|
234
|
+
(`testLogChars`, which must be ≥ 8,000 to be a fair example; it was 14.4–15.8k).
|
|
163
235
|
|
|
164
236
|
Output: `test/e2e/out/examples-<stamp>/` with `results.md` (the table), `results.json`, and one directory per
|
|
165
237
|
run (`A-claude-on`, …) holding `summary.json` (per turn: tool calls, tool-result sizes, edits with kept/total
|
|
@@ -171,17 +243,15 @@ model saw.
|
|
|
171
243
|
Pi from list prices (most of it Claude in example A; GPT runs used a subscription). About 5 minutes with 4
|
|
172
244
|
parallel jobs.
|
|
173
245
|
|
|
174
|
-
**Findings
|
|
175
|
-
|
|
176
|
-
- Next-request context, on / off: A Claude 15.5k / 28.2k tokens, A GPT 11.5k / 34.3k, C GPT 9.2k / 35.3k
|
|
177
|
-
all follow-ups correct, none needed `recall`.
|
|
178
|
-
- The first request after an edit
|
|
179
|
-
- B
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
named two line numbers past the file lines (e.g. "lines 466–502" of a 1–500 read). Fixed: ranges are now
|
|
184
|
-
clamped to the file lines and a removed note gets no omission line.
|
|
246
|
+
**Findings** (details in the README):
|
|
247
|
+
|
|
248
|
+
- Next-request context, on / off: A Claude 15.5k / 28.2k tokens, A GPT 11.5k / 34.3k, C GPT 9.2k / 35.3k,
|
|
249
|
+
B GPT 5.7k / 7.5k; all follow-ups correct, none needed `recall`.
|
|
250
|
+
- The first request after an edit costs more with the guard on (cache rewrite): A Claude $0.033 / $0.016.
|
|
251
|
+
- B: the failing test log (an error result, distilled because `distillErrors` is on) went 15.8k → 8.6k
|
|
252
|
+
chars, keeping the 3 failures with their assertion messages.
|
|
253
|
+
- C: `read` outputs end with Pi's "[Showing lines …]" note; omission ranges are clamped to the file lines
|
|
254
|
+
and a removed note gets no omission line.
|
|
185
255
|
|
|
186
256
|
## Manual TUI check (tmux)
|
|
187
257
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-context-guard-jev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Keep
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Keep your Pi context lean: the Jev classifier trims big tool outputs to the lines your work still needs, drops stale small outputs and unrelated old exchanges, at run end and during long autonomous runs. Verbatim, recallable, cache-aware, savings in the status bar.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "alandarev",
|
package/src/cache-pin.ts
CHANGED
|
@@ -120,3 +120,173 @@ export function pinQuestionBreakpoint(payload: unknown): PinResult {
|
|
|
120
120
|
}
|
|
121
121
|
return "pinned";
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------------------------
|
|
125
|
+
// Read-point anchors after context edits (docs/CACHE.md → Mid-run checkpoints)
|
|
126
|
+
// ---------------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** Pi's Anthropic provider rewrites tool call ids like this (pi-ai anthropic-messages.js). */
|
|
129
|
+
export const normalizeToolUseId = (id: string): string => id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
|
|
130
|
+
|
|
131
|
+
export interface GuardBreakpointOptions {
|
|
132
|
+
/** Tool call ids that get a read anchor, highest priority first (after the question). */
|
|
133
|
+
anchors: readonly string[];
|
|
134
|
+
/** Tool call ids that get a breakpoint only to write a cache entry (lowest priority). */
|
|
135
|
+
writeAnchors?: readonly string[];
|
|
136
|
+
/** Pin the previous user question. It has the highest priority: it is the floor every edit falls back to. */
|
|
137
|
+
pinQuestion: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface GuardBreakpointResult {
|
|
141
|
+
/** Anchors placed (ids from `anchors` and `writeAnchors`). */
|
|
142
|
+
anchored: string[];
|
|
143
|
+
/** Anchors that could not be placed, with the reason. */
|
|
144
|
+
skipped: { id: string; reason: "not-found" | "in-last-message" | "already" | "over-budget" | "ttl-conflict" }[];
|
|
145
|
+
question: PinResult | "off";
|
|
146
|
+
/** Breakpoints Pi placed that were removed to make room. */
|
|
147
|
+
removed: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function findToolResult(messages: Json[], id: string, before: number): { index: number; block: Json } | undefined {
|
|
151
|
+
const ids = new Set([id, normalizeToolUseId(id)]);
|
|
152
|
+
for (let i = 0; i < messages.length; i++) {
|
|
153
|
+
const content = messages[i]?.content;
|
|
154
|
+
if (!Array.isArray(content)) continue;
|
|
155
|
+
const block = content.find((b: Json) => b?.type === "tool_result" && ids.has(b.tool_use_id));
|
|
156
|
+
if (block) return i < before ? { index: i, block } : { index: -1, block };
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Index of `target` among the cacheable blocks of the payload (prefix order). */
|
|
162
|
+
function positionOf(payload: Json, target: Json): number {
|
|
163
|
+
let n = 0;
|
|
164
|
+
let found = -1;
|
|
165
|
+
const visit = (value: unknown) => {
|
|
166
|
+
if (found >= 0) return;
|
|
167
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
168
|
+
else if (value && typeof value === "object") {
|
|
169
|
+
if (value === target) {
|
|
170
|
+
found = n;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
n++;
|
|
174
|
+
for (const [key, child] of Object.entries(value)) if (key !== "cache_control") visit(child);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
visit([payload.tools, payload.system, payload.messages]);
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** All blocks carrying cache_control, in prefix order (tools → system → messages). */
|
|
182
|
+
function breakpointBlocks(payload: Json): Json[] {
|
|
183
|
+
const out: Json[] = [];
|
|
184
|
+
const visit = (value: unknown) => {
|
|
185
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
186
|
+
else if (value && typeof value === "object") {
|
|
187
|
+
if ((value as Json).cache_control) out.push(value as Json);
|
|
188
|
+
for (const [key, child] of Object.entries(value)) if (key !== "cache_control") visit(child);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
visit([payload.tools, payload.system, payload.messages]);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Place the previous-question pin, read anchors and write anchors on an Anthropic Messages payload,
|
|
197
|
+
* in place, in that priority. Respects the 4-breakpoint limit (dropping, in this order, the redundant
|
|
198
|
+
* OAuth identity breakpoint, the tools breakpoint when the system prompt has one, then the
|
|
199
|
+
* lowest-priority guard marks) and the TTL order (anchors use the rolling breakpoint's TTL; a mark that
|
|
200
|
+
* cannot keep longer TTLs first is skipped).
|
|
201
|
+
*/
|
|
202
|
+
export function placeGuardBreakpoints(payload: unknown, options: GuardBreakpointOptions): GuardBreakpointResult {
|
|
203
|
+
const result: GuardBreakpointResult = { anchored: [], skipped: [], question: options.pinQuestion ? "no-question" : "off", removed: [] };
|
|
204
|
+
if (!payload || typeof payload !== "object") return { ...result, question: options.pinQuestion ? "not-anthropic" : "off" };
|
|
205
|
+
const body = payload as Json;
|
|
206
|
+
const messages = body.messages;
|
|
207
|
+
if (!Array.isArray(messages)) return result;
|
|
208
|
+
const rolling = lastCacheControl(messages);
|
|
209
|
+
if (!rolling) return { ...result, question: options.pinQuestion ? "no-cache" : "off" };
|
|
210
|
+
let last = messages.length - 1;
|
|
211
|
+
while (last >= 0 && messages[last]?.role === "system") last--;
|
|
212
|
+
|
|
213
|
+
// 1. Targets, highest priority first: question, read anchors, write anchors.
|
|
214
|
+
const marks: { id: string; block: Json; kind: "question" | "anchor" | "write" }[] = [];
|
|
215
|
+
if (options.pinQuestion) {
|
|
216
|
+
let target = -1;
|
|
217
|
+
for (let i = last - 1; i >= 0; i--) {
|
|
218
|
+
if (messages[i]?.role === "user" && hasText(messages[i])) {
|
|
219
|
+
target = i;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (target >= 0) {
|
|
224
|
+
const question = messages[target];
|
|
225
|
+
if (typeof question.content === "string") question.content = [{ type: "text", text: question.content }];
|
|
226
|
+
const block = question.content.findLast((b: Json) => b?.type === "text" && typeof b.text === "string" && b.text.trim());
|
|
227
|
+
if (block?.cache_control) result.question = "already";
|
|
228
|
+
else if (block) marks.push({ id: "question", block, kind: "question" });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const addAnchor = (id: string, kind: "anchor" | "write") => {
|
|
232
|
+
const found = findToolResult(messages, id, last);
|
|
233
|
+
if (!found) result.skipped.push({ id, reason: "not-found" });
|
|
234
|
+
else if (found.index < 0) result.skipped.push({ id, reason: "in-last-message" });
|
|
235
|
+
else if (found.block.cache_control || marks.some((m) => m.block === found.block)) result.skipped.push({ id, reason: "already" });
|
|
236
|
+
else marks.push({ id, block: found.block, kind });
|
|
237
|
+
};
|
|
238
|
+
for (const id of options.anchors) addAnchor(id, "anchor");
|
|
239
|
+
for (const id of options.writeAnchors ?? []) addAnchor(id, "write");
|
|
240
|
+
|
|
241
|
+
// 2. Apply with TTL order: the rolling TTL if it fits, else the other one, else skip.
|
|
242
|
+
const applied: typeof marks = [];
|
|
243
|
+
const { ttl: _rollingTtl, ...rollingRest } = rolling;
|
|
244
|
+
for (const mark of marks) {
|
|
245
|
+
const position = positionOf(body, mark.block);
|
|
246
|
+
const others = breakpointBlocks(body).filter((b) => b !== mark.block);
|
|
247
|
+
const before = others.filter((b) => positionOf(body, b) < position);
|
|
248
|
+
const after = others.filter((b) => positionOf(body, b) > position);
|
|
249
|
+
const fits = (ttl: number) => before.every((b) => ttlMinutes(b.cache_control) >= ttl) && after.every((b) => ttlMinutes(b.cache_control) <= ttl);
|
|
250
|
+
const ttl = [ttlMinutes(rolling), ttlMinutes(rolling) === 60 ? 5 : 60].find(fits);
|
|
251
|
+
if (ttl === undefined) {
|
|
252
|
+
if (mark.kind === "question") result.question = "ttl-conflict";
|
|
253
|
+
else result.skipped.push({ id: mark.id, reason: "ttl-conflict" });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
mark.block.cache_control = ttl === 60 ? { ...rollingRest, ttl: "1h" } : { ...rollingRest };
|
|
257
|
+
applied.push(mark);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 3. Budget.
|
|
261
|
+
const system = body.system;
|
|
262
|
+
if (countBreakpoints(body) > ANTHROPIC_MAX_BREAKPOINTS && Array.isArray(system) && system.length > 1 && system[0]?.cache_control && system.slice(1).some((b: Json) => b?.cache_control)) {
|
|
263
|
+
delete system[0].cache_control;
|
|
264
|
+
result.removed.push("system[0]");
|
|
265
|
+
}
|
|
266
|
+
const tools = Array.isArray(body.tools) ? body.tools : [];
|
|
267
|
+
if (countBreakpoints(body) > ANTHROPIC_MAX_BREAKPOINTS && Array.isArray(system) && system.some((b: Json) => b?.cache_control)) {
|
|
268
|
+
const toolBlock = tools.findLast((b: Json) => b?.cache_control);
|
|
269
|
+
if (toolBlock) {
|
|
270
|
+
delete toolBlock.cache_control;
|
|
271
|
+
result.removed.push("tools");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
while (countBreakpoints(body) > ANTHROPIC_MAX_BREAKPOINTS && applied.length > 0) {
|
|
275
|
+
const dropped = applied.pop() as (typeof applied)[number];
|
|
276
|
+
delete dropped.block.cache_control;
|
|
277
|
+
if (dropped.kind === "question") result.question = "over-budget";
|
|
278
|
+
else result.skipped.push({ id: dropped.id, reason: "over-budget" });
|
|
279
|
+
}
|
|
280
|
+
for (const mark of applied) {
|
|
281
|
+
if (mark.kind === "question") result.question = "pinned";
|
|
282
|
+
else result.anchored.push(mark.id);
|
|
283
|
+
}
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Tool-use ids of the tool_result blocks that carry a breakpoint in the final payload. */
|
|
288
|
+
export function markedToolResults(payload: unknown): string[] {
|
|
289
|
+
const messages = (payload as Json | undefined)?.messages;
|
|
290
|
+
if (!Array.isArray(messages)) return [];
|
|
291
|
+
return messages.flatMap((m: Json) => (Array.isArray(m?.content) ? m.content.filter((b: Json) => b?.type === "tool_result" && b.cache_control).map((b: Json) => String(b.tool_use_id)) : []));
|
|
292
|
+
}
|