pi-context-guard-jev 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/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` | 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` |
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|history] \
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. `long`: 2 turns with 10+ sequential tool calls, for the cache pin. `history`: 3 turns for the earlier-conversation feature (below). |
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
- | long: turn 1 produced context edits / 10+ tool calls / 10+ model requests | The long scenario really was long and sequential |
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 long
122
- node test/e2e/run-e2e.mjs --model anthropic/claude-sonnet-5-5 --ext ~/.pi/agent/npm/node_modules/pi-claude-auth --scenario long --config '{"pinAnthropicCache":false}'
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 so far (Pi 1.0.3)
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, long, with / without pin | first request after the run: cacheRead 5,810 / 1,693, cacheWrite 8,913 / 13,068 |
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, after the history feature | 36,113 → ~2.3k chars; 9/9 checks pass (an earlier run in parallel with 4 others had one OpenAI cache miss on turn 2: 8/9) |
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.4k).
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 of the first full run** (details in the README):
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 cost more with the guard on (cache rewrite): A Claude $0.033 / $0.016.
179
- - B was not distilled at all: the failing test command is an error result, which context-guard then never
180
- edited. Fixed by `distillErrors` (default on); a rerun distilled the log 15.8k → 8.6k chars, keeping the
181
- 3 failures with their assertion messages, and turn 2 still fixed the bug.
182
- - In C, `read` outputs carry Pi's two-line "[Showing lines …]" note, so omission ranges at the end of a piece
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.1.0",
4
- "description": "Keep big tool outputs from bloating your Pi context: after each run, the Jev classifier keeps only the lines your work still needs, verbatim and recallable, with savings in the status bar. Claude + OpenAI, cache-aware.",
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
+ }