auto-model-router 0.2.21 → 0.2.22

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.
@@ -180,12 +180,29 @@ while memory, docs, or the brief for the area you touched is stale.
180
180
  | Architecture / conventions | `docs_update {id, content}` | `PATCH /docs/:id {title?, content?, tags?}` |
181
181
  | A genuinely new doc | `docs_write {slug, title, content, scope}` | `POST /docs {slug, title, content, scope}` |
182
182
  | List / read docs | `docs_read` · `docs_search` | `GET /docs?scope=<scope>` · `GET /docs/search?q=…` · `GET /docs/slug/:slug` |
183
+ | Find the *part* of a doc that answers something | `docs_passages` | `GET /docs/passages?q=…&scope=<scope>` |
183
184
  | A decision you made | `context_brief_record {scope, title, decision, rationale}` | `POST /context/brief/decision {scope, title, decision, rationale}` |
184
185
  | Edit brief sections | — | `PUT /context/brief {scope, overview?, repoLayout?, codeStyle?, buildTest?, assetConventions?, gotchas?}` |
185
186
 
186
187
  **Search before you add.** Update the existing entry rather than leaving two contradictory
187
188
  facts. Record the *why* of a decision, not just the *what*.
188
189
 
190
+ ## Searching well
191
+
192
+ Retrieval is hybrid — keyword *and* meaning — so you do not have to guess the stored wording.
193
+ Ask in your own words; exact identifiers (`SettlementLayout.Build`, `AGENTDOX_TOKEN`) work too.
194
+
195
+ **Prefer `docs_passages` over `docs_search`** when you want the part of a doc that answers a
196
+ question. `docs_search` hands back whole documents, which then get truncated — and the
197
+ truncation is rarely the relevant part. A passage arrives with its slug and heading, so
198
+ `docs_read` the full doc when the passage is not enough.
199
+
200
+ If results look thin or stale, check `index_stats {scope}` before concluding the store is
201
+ empty: it reports how much of the scope is indexed and whether the embedding provider is
202
+ reachable. `embedded` far below `total`, or an unreachable provider, means you are getting
203
+ keyword-only results. `index_rebuild` fixes an index that has drifted; ordinary writes index
204
+ themselves, so you should rarely need it.
205
+
189
206
  ## Two inconsistencies that cause silent mistakes
190
207
 
191
208
  1. **Memory uses `category`; everything else uses `scope`.** `memory_add` / `memory_search` /
package/CLAUDE.md CHANGED
@@ -28,6 +28,18 @@ another project. Getting `omp-router` right is on you, not on RBAC.
28
28
  | Server | `http://localhost:3003` — Docker container `agentdox-server` |
29
29
  | Admin token (to re-mint the global PAT) | `E:/projects/agentdox/deploy/.env` |
30
30
 
31
+ **Searching agentdox.** Retrieval is hybrid — BM25 keyword matching fused with embeddings — and
32
+ runs over *passages* of docs, not whole files. So ask in your own words; exact identifiers work
33
+ too. Two habits worth having:
34
+
35
+ - **Prefer `docs_passages` over `docs_search`.** It returns the section that answers the
36
+ question. `docs_search` returns whole documents, which then get truncated, and the truncation
37
+ is rarely the relevant part.
38
+ - **If results look thin, run `index_stats {scope}` before concluding the store is empty.** It
39
+ reports how much of the scope is indexed and whether the embedding provider is reachable;
40
+ `embedded` far below `total`, or an unreachable provider, means you are getting keyword-only
41
+ results.
42
+
31
43
  `.env.agentdox` is the durable record; the environment variable is what Claude Code actually
32
44
  substitutes into `.mcp.json` at MCP-server startup. If agentdox MCP returns **401**, the
33
45
  variable is missing from the environment — re-set it from `.env.agentdox` and restart Claude
@@ -130,14 +130,14 @@ request → classify (on ORIGINAL turn)
130
130
  valid — the same reason `injectContextBlock` appends instead of inserts. Phase 2,
131
131
  which changes message count, MUST return adjusted indices (see Phase 2).
132
132
 
133
- ## Trigger (locked: fit + cost budget)
133
+ ## Trigger (locked: fit + cost budget), and plan hysteresis
134
134
 
135
- Two conditions arm compaction; both compact to the same safe floor, they differ only
136
- in *whether* to bother:
135
+ Two conditions arm compaction; they differ only in *whether* to bother:
137
136
 
138
- - **Budget:** `estimateTokens(promptBytes + injectedBlockBytes) > compaction.budgetTokens`.
139
- The injected agentdox block counts toward the budget (it is resolved before render
140
- and bounded by `context.maxBlockChars`).
137
+ - **Budget:** the **compacted** estimate i.e. the prompt as it would be dispatched
138
+ with the plan already carried from the previous turn exceeds
139
+ `compaction.budgetTokens`. The injected agentdox block counts toward the budget (it
140
+ is resolved before render and bounded by `context.maxBlockChars`).
141
141
  - **Fit:** the estimate exceeds `model.contextLength × filters.contextHeadroom` (minus
142
142
  expected completion) for a model under consideration — so the `context_too_small`
143
143
  filter tests each model against the compacted floor rather than the raw size.
@@ -145,6 +145,32 @@ in *whether* to bother:
145
145
  Requests below `budgetTokens` and within every viable window are dispatched untouched —
146
146
  the common small-prompt path allocates nothing.
147
147
 
148
+ ### The plan is state, not a per-turn derivation
149
+
150
+ A prompt cache is a **byte-prefix** cache: change any byte and everything after it is
151
+ a miss. That makes the compaction plan cache-visible state, subject to two rules.
152
+
153
+ 1. **A dispatched edit is permanent and verbatim.** `ConversationState.compactionPlan`
154
+ persists the plan (schema v13, `conversations.compaction_plan`); `select()` re-emits
155
+ it every turn and the planner is *seeded* with it (`planCompaction(..., carried)`),
156
+ so an existing edit is never re-derived into a different shape and never dropped
157
+ when the turn alone would not have triggered compaction. `validatePlan` first checks
158
+ each edit still lands on a tool message of the recorded byte length, so a
159
+ client-side history rewrite invalidates the edit instead of corrupting the prompt.
160
+ Re-applying is safe because omp re-sends the original bytes every turn.
161
+ 2. **Re-planning is rationed.** The trigger compares the **compacted** size against the
162
+ budget, and when it fires the planner targets `budgetTokens × compaction.floorRatio`
163
+ rather than stopping just under the budget. Comparing the *raw* size re-planned every
164
+ single turn, so the plan gained one more edit per turn — a cache invalidation per turn
165
+ for a marginal saving.
166
+
167
+ Measured (`tools/verify-plan-persist.ts`, 20-turn agentic conversation): `floorRatio`
168
+ 1.0 changes the plan on **10 of 10** compacting turns, 0.75 on **3**, 0.6 on **2**. On
169
+ live ledger data (7 long conversations, 894 compacted dispatches) a changed-plan
170
+ dispatch ran **15.4% cold** vs **8.9%** when the plan held, and a cold prompt costs
171
+ **4.34x** a warm one per token ($0.1839 vs $0.0424 per Mtok). `floorRatio` ships at 1
172
+ (today's behaviour, elision is never implicit); 0.75 is the recommended setting.
173
+
148
174
  ## Interaction with the agentdox bridge
149
175
 
150
176
  The router already has a shipped context subsystem (`src/context/`, see
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -193,6 +193,20 @@ export const DEFAULT_CONFIG: RouterConfig = {
193
193
  enabled: false,
194
194
  // ~40k tokens: above this the prompt is dominated by re-sent tool output.
195
195
  budgetTokens: 40_000,
196
+ // Once compaction fires, compact down to this fraction of the budget
197
+ // instead of stopping just under it. Below 1 the plan overshoots and then
198
+ // holds for several turns; at 1 it gains an edit almost every turn, and
199
+ // every plan change rewrites already-cached prompt bytes.
200
+ //
201
+ // Measured (tools/verify-plan-persist.ts, 20-turn agentic conversation):
202
+ // 1.0 changes the plan on 10 of 10 compacting turns, 0.75 on 3, 0.6 on 2.
203
+ // Live ledger: a changed-plan dispatch runs 15.4% cold vs 8.9% when the
204
+ // plan holds, and a cold prompt costs 4.34x a warm one per token.
205
+ //
206
+ // Ships at 1 because elision is lossy and compaction is never implicit
207
+ // here — the same reason `enabled` is false. 0.75 is the recommended
208
+ // setting once a deployment has watched its own ledger.
209
+ floorRatio: 1,
196
210
  fitToWindow: true,
197
211
  protectRecentTurns: 4,
198
212
  maxToolResultBytes: 4_096,
@@ -151,6 +151,7 @@ const context = z.strictObject({
151
151
  const compaction = z.strictObject({
152
152
  enabled: z.boolean().optional(),
153
153
  budgetTokens: z.number().int().positive().optional(),
154
+ floorRatio: z.number().positive().max(1).optional(),
154
155
  fitToWindow: z.boolean().optional(),
155
156
  protectRecentTurns: z.number().int().positive().optional(),
156
157
  maxToolResultBytes: z.number().int().positive().optional(),
@@ -432,6 +432,13 @@ export interface CompactionConfig {
432
432
  enabled: boolean;
433
433
  /** Compact when the estimated prompt exceeds this many tokens. */
434
434
  budgetTokens: number;
435
+ /**
436
+ * Target fraction of `budgetTokens` to compact DOWN to once compaction
437
+ * fires. Below 1 the plan overshoots, so it stays byte-stable for several
438
+ * turns instead of gaining an edit per turn; every plan change rewrites
439
+ * already-cached prompt bytes, and a cold prompt costs ~4.3x a warm one.
440
+ */
441
+ floorRatio: number;
435
442
  /** Also compact when the prompt would overflow the profile's context window. */
436
443
  fitToWindow: boolean;
437
444
  /** Never touch the last N user/assistant turns or the volatile tail. */
@@ -37,6 +37,27 @@ export function compactedBytes(originalBytes: number, edit: CompactionEdit | und
37
37
  return Math.min(originalBytes, kept);
38
38
  }
39
39
 
40
+ /**
41
+ * Validates a persisted compaction plan against this turn's messages before it
42
+ * is re-applied. Every edit must land on a tool-role message whose string
43
+ * content still has the original byte length the edit was planned against.
44
+ * A failed check means the client rewrote or truncated its history — the edit
45
+ * is dropped rather than applied to the wrong bytes.
46
+ */
47
+ export function validatePlan(
48
+ plan: readonly CompactionEdit[],
49
+ messages: readonly NormMessage[],
50
+ ): CompactionEdit[] {
51
+ const valid: CompactionEdit[] = [];
52
+ for (const e of plan) {
53
+ const m = messages[e.index];
54
+ if (m === undefined || m.role !== "tool") continue;
55
+ if (m.textBytes !== e.bytes) continue;
56
+ valid.push(e);
57
+ }
58
+ return valid;
59
+ }
60
+
40
61
  /**
41
62
  * First string value in a tool call's argument JSON — a schema-agnostic proxy
42
63
  * for the resource a call operates on (a `path`, `id`, `query`, ...). Used to
@@ -81,6 +102,16 @@ interface ToolResult {
81
102
  key: string | null;
82
103
  }
83
104
 
105
+ /**
106
+ * Result for a turn that adds nothing: the carried plan alone, with its
107
+ * savings recomputed against this turn's messages.
108
+ */
109
+ function carriedOnly(carried: readonly CompactionEdit[]): CompactionResult {
110
+ const edits = [...carried].sort((a, b) => a.index - b.index);
111
+ const savedBytes = edits.reduce((sum, e) => sum + (e.bytes - compactedBytes(e.bytes, e)), 0);
112
+ return { edits, savedBytes };
113
+ }
114
+
84
115
  /**
85
116
  * Plans compaction for a turn's messages toward `targetBytes` of total prompt.
86
117
  * Duplicate and superseded elisions (pure stale-data wins) are always applied;
@@ -96,17 +127,27 @@ interface ToolResult {
96
127
  * fresh edits at arbitrarily early indices on later turns, rewriting history
97
128
  * the upstream had already cached and collapsing cache reads to the system
98
129
  * prefix (measured: 61% cache read, bimodal, vs 76-82% before compaction).
130
+ *
131
+ * `carried` is the plan already applied to this conversation on a previous
132
+ * dispatch (validated by `validatePlan`). It is re-emitted verbatim and its
133
+ * savings count toward the target, so an existing edit is never re-derived
134
+ * differently and the planner only ever ADDS. Re-applying it costs nothing:
135
+ * the client re-sends the original bytes every turn, so the same edit produces
136
+ * the same output.
99
137
  */
100
138
  export function planCompaction(
101
139
  messages: readonly NormMessage[],
102
140
  cfg: CompactionConfig,
103
141
  targetBytes: number,
104
142
  promptBytes: number,
143
+ carried: readonly CompactionEdit[] = [],
105
144
  ): CompactionResult {
106
145
  if (!cfg.enabled) return EMPTY;
107
146
 
108
147
  const protectStart = protectFromIndex(messages, cfg.protectRecentTurns);
109
- if (protectStart <= 0) return EMPTY;
148
+ // Carried edits still apply even when nothing new is eligible this turn:
149
+ // dropping them would re-inflate bytes the upstream has already cached.
150
+ if (protectStart <= 0) return carriedOnly(carried);
110
151
 
111
152
  // Assistant tool_call id → name/args, to key tool results by their call.
112
153
  const callById = new Map<string, { name: string; args: string }>();
@@ -128,16 +169,19 @@ export function planCompaction(
128
169
  key: call === undefined ? null : primaryArg(call.args),
129
170
  });
130
171
  }
131
- if (tools.length === 0) return EMPTY;
132
-
133
- const edits: CompactionEdit[] = [];
134
- const done = new Set<number>();
135
- let saved = 0;
172
+ if (tools.length === 0) return carriedOnly(carried);
173
+
174
+ // Seed with the carried plan: those indices are settled, and their savings
175
+ // already count against the target, so the target math asks "how much MORE
176
+ // is needed" rather than re-deriving the whole plan.
177
+ const edits: CompactionEdit[] = [...carried];
178
+ const done = new Set<number>(carried.map((e) => e.index));
179
+ let saved = carried.reduce((sum, e) => sum + (e.bytes - compactedBytes(e.bytes, e)), 0);
136
180
  const stub = (t: ToolResult, note: string): void => {
137
181
  if (done.has(t.index)) return;
138
182
  const gain = t.bytes - BREADCRUMB_BYTES;
139
183
  if (gain <= 0) return; // already smaller than a breadcrumb
140
- edits.push({ index: t.index, mode: "stub", keepHead: 0, keepTail: 0, note });
184
+ edits.push({ index: t.index, mode: "stub", keepHead: 0, keepTail: 0, note, bytes: t.bytes });
141
185
  done.add(t.index);
142
186
  saved += gain;
143
187
  };
@@ -172,7 +216,7 @@ export function planCompaction(
172
216
  const truncatable = tools.filter((t) => !done.has(t.index) && t.bytes > cfg.maxToolResultBytes && t.bytes > keepBudget);
173
217
  for (const t of truncatable) {
174
218
  if (promptBytes - saved <= targetBytes) break;
175
- edits.push({ index: t.index, mode: "truncate", keepHead: cfg.keepHeadBytes, keepTail: cfg.keepTailBytes, note: `large ${t.name || "tool"} result` });
219
+ edits.push({ index: t.index, mode: "truncate", keepHead: cfg.keepHeadBytes, keepTail: cfg.keepTailBytes, note: `large ${t.name || "tool"} result`, bytes: t.bytes });
176
220
  done.add(t.index);
177
221
  saved += t.bytes - keepBudget;
178
222
  }
@@ -12,7 +12,7 @@ import { priceAt } from "../cost/forecast.ts";
12
12
  import type { Ledger } from "../cost/types.ts";
13
13
  import { explorationDraw } from "./explore.ts";
14
14
  import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
15
- import { planCompaction } from "./compaction.ts";
15
+ import { compactedBytes, planCompaction, validatePlan, type CompactionResult } from "./compaction.ts";
16
16
  import { planCacheBreakpoints } from "./cache-control.ts";
17
17
  import { buildCandidates } from "./candidates.ts";
18
18
  import {
@@ -153,28 +153,54 @@ export function select(args: SelectArgs): Decision {
153
153
  // Deterministic and content-only (never removes a message), so downstream
154
154
  // forecasting, the context_too_small filter, cache breakpoints, and the
155
155
  // agentdox block append all operate on the compacted size / stay valid.
156
+ //
157
+ // Two properties make this cache-safe, and both are load-bearing:
158
+ //
159
+ // 1. The plan is PERSISTED per conversation and re-applied verbatim. omp
160
+ // re-sends the original bytes every turn, so a re-applied edit yields
161
+ // byte-identical output; a plan re-derived from scratch could differ
162
+ // (a looser target, a re-tuned knob) and rewrite already-cached bytes.
163
+ // 2. Compaction is triggered on the COMPACTED size and then overshoots
164
+ // to `floorRatio` of the budget. Comparing the RAW prompt against the
165
+ // budget re-planned on every single turn, so the plan gained one more
166
+ // edit per turn — and each plan change rewrites cached prompt bytes.
167
+ // Measured on live ledger data (7 long conversations, 894 compacted
168
+ // dispatches): a turn whose plan changed ran 15.4% cold vs 8.9% when
169
+ // the plan held, and a cold prompt costs 4.34x a warm one per token.
170
+ // Overshooting buys several byte-stable turns per plan change.
156
171
  let compactionPlan: CompactionEdit[] = [];
157
172
  let promptTokensSaved = 0;
158
173
  let effFeatures = features;
159
174
  if (cfg.compaction.enabled && req.promptBytes > 0 && features.promptTokens > 0) {
175
+ const bytesPerToken = req.promptBytes / features.promptTokens;
176
+ const carried = validatePlan(state.compactionPlan ?? [], req.messages);
177
+ const carriedSavedBytes = carried.reduce((sum, e) => sum + (e.bytes - compactedBytes(e.bytes, e)), 0);
178
+ const tokensOf = (savedBytes: number): number =>
179
+ Math.min(features.promptTokens - 1, Math.round(features.promptTokens * (savedBytes / req.promptBytes)));
180
+ // What the upstream would actually receive if nothing new were planned.
181
+ const compactedTokens = features.promptTokens - tokensOf(carriedSavedBytes);
182
+
160
183
  const headroom = cfg.filters.contextHeadroom;
161
- const overBudget = features.promptTokens > cfg.compaction.budgetTokens;
184
+ const overBudget = compactedTokens > cfg.compaction.budgetTokens;
162
185
  const overWindow =
163
- cfg.compaction.fitToWindow && features.promptTokens * headroom + EXPECTED_COMPLETION_TOKENS > profile.contextWindow;
186
+ cfg.compaction.fitToWindow && compactedTokens * headroom + EXPECTED_COMPLETION_TOKENS > profile.contextWindow;
187
+ let plan: CompactionResult = { edits: carried, savedBytes: carriedSavedBytes };
164
188
  if (overBudget || overWindow) {
165
189
  const targets: number[] = [];
166
- if (overBudget) targets.push(cfg.compaction.budgetTokens);
190
+ // Overshoot the budget so the next re-plan is several turns away.
191
+ if (overBudget) targets.push(Math.max(1, Math.floor(cfg.compaction.budgetTokens * cfg.compaction.floorRatio)));
167
192
  if (overWindow) targets.push(Math.max(1, Math.floor((profile.contextWindow - EXPECTED_COMPLETION_TOKENS) / headroom)));
168
- const targetBytes = Math.min(...targets) * (req.promptBytes / features.promptTokens);
169
- const plan = planCompaction(req.messages, cfg.compaction, targetBytes, req.promptBytes);
170
- if (plan.edits.length > 0) {
171
- compactionPlan = plan.edits;
172
- promptTokensSaved = Math.min(features.promptTokens - 1, Math.round(features.promptTokens * (plan.savedBytes / req.promptBytes)));
173
- effFeatures = { ...features, promptTokens: features.promptTokens - promptTokensSaved };
174
- reasons.push(
175
- `compaction: ${plan.edits.length} tool result(s) shrunk, ~${promptTokensSaved} tokens saved (prompt ${features.promptTokens}→${effFeatures.promptTokens})`,
176
- );
177
- }
193
+ const targetBytes = Math.min(...targets) * bytesPerToken;
194
+ plan = planCompaction(req.messages, cfg.compaction, targetBytes, req.promptBytes, carried);
195
+ }
196
+ if (plan.edits.length > 0) {
197
+ compactionPlan = [...plan.edits];
198
+ promptTokensSaved = tokensOf(plan.savedBytes);
199
+ effFeatures = { ...features, promptTokens: features.promptTokens - promptTokensSaved };
200
+ const added = plan.edits.length - carried.length;
201
+ reasons.push(
202
+ `compaction: ${plan.edits.length} tool result(s) shrunk (${carried.length} carried, ${added} new), ~${promptTokensSaved} tokens saved (prompt ${features.promptTokens}→${effFeatures.promptTokens})`,
203
+ );
178
204
  }
179
205
  }
180
206
 
@@ -11,8 +11,10 @@
11
11
 
12
12
  import type { Database, Statement } from "bun:sqlite";
13
13
 
14
+ import type { CompactionEdit } from "../wire/types.ts";
14
15
  import type { ConversationState, ConversationStore, Tier } from "./types.ts";
15
16
 
17
+
16
18
  /** Row shape as stored; column names are snake_case per the schema. */
17
19
  interface Row {
18
20
  key: string;
@@ -28,6 +30,7 @@ interface Row {
28
30
  cache_warm_at_ms: number;
29
31
  context_version: string | null;
30
32
  context_fetched_at_ms: number;
33
+ compaction_plan: string | null;
31
34
  updated_at_ms: number;
32
35
  }
33
36
 
@@ -47,6 +50,7 @@ function toState(row: Row): ConversationState {
47
50
  cacheWarmAtMs: row.cache_warm_at_ms,
48
51
  contextVersion: row.context_version,
49
52
  contextFetchedAtMs: row.context_fetched_at_ms,
53
+ compactionPlan: row.compaction_plan === null ? null : (JSON.parse(row.compaction_plan) as CompactionEdit[]),
50
54
  updatedAtMs: row.updated_at_ms,
51
55
  };
52
56
  }
@@ -65,10 +69,10 @@ export function createConversationStore(db: Database): ConversationStore {
65
69
  INSERT INTO conversations (
66
70
  key, session_id, turn, current_slug, current_tier, sticky_until_turn,
67
71
  last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
68
- context_version, context_fetched_at_ms, updated_at_ms
72
+ context_version, context_fetched_at_ms, compaction_plan, updated_at_ms
69
73
  ) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
70
74
  $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
71
- $contextVersion, $contextFetchedAtMs, $updatedAtMs)
75
+ $contextVersion, $contextFetchedAtMs, $compactionPlan, $updatedAtMs)
72
76
  ON CONFLICT(key) DO UPDATE SET
73
77
  session_id = excluded.session_id,
74
78
  turn = excluded.turn,
@@ -80,6 +84,7 @@ export function createConversationStore(db: Database): ConversationStore {
80
84
  cache_warm_at_ms = excluded.cache_warm_at_ms,
81
85
  context_version = excluded.context_version,
82
86
  context_fetched_at_ms = excluded.context_fetched_at_ms,
87
+ compaction_plan = excluded.compaction_plan,
83
88
  updated_at_ms = excluded.updated_at_ms
84
89
  `);
85
90
  // Read-modify-write in JS lost money: an aborted or failed dispatch is still
@@ -127,6 +132,7 @@ export function createConversationStore(db: Database): ConversationStore {
127
132
  $currentTier: state.currentTier,
128
133
  $stickyUntilTurn: state.stickyUntilTurn,
129
134
  $lastPromptTokens: state.lastPromptTokens,
135
+ $compactionPlan: state.compactionPlan === null ? null : JSON.stringify(state.compactionPlan),
130
136
  $cacheWarmSlug: state.cacheWarmSlug,
131
137
  $cacheWarmAtMs: state.cacheWarmAtMs,
132
138
  $contextVersion: state.contextVersion,
@@ -170,6 +170,13 @@ export interface ConversationState {
170
170
  * cache survives; refreshed only when the cache is already cold.
171
171
  */
172
172
  contextVersion: string | null;
173
+ /**
174
+ * The compaction plan applied on the previous dispatch. Re-applied verbatim
175
+ * each turn (after byte-length validation) so already-shrunk tool results
176
+ * stay shrunk: dropping them re-inflates mid-prefix bytes, which both breaks
177
+ * the prompt cache and un-saves the tokens. Fresh planning only extends it.
178
+ */
179
+ compactionPlan: CompactionEdit[] | null;
173
180
  /** When that block was fetched, for the staleness TTL. */
174
181
  contextFetchedAtMs: number;
175
182
  updatedAtMs: number;
@@ -457,6 +457,10 @@ export async function runTurn(
457
457
  state.spentUsd += reportedUsd ?? decision.forecast.expectedUsd;
458
458
  state.escalations += escalations;
459
459
  state.lastPromptTokens = usage.promptTokens;
460
+ // Persist the plan that was actually dispatched. The next turn re-applies
461
+ // it verbatim (after byte-length validation), keeping shrunk tool results
462
+ // shrunk so the prompt cache survives and the savings compound.
463
+ state.compactionPlan = decision.compactionPlan.length > 0 ? decision.compactionPlan : null;
460
464
  if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
461
465
  // Non-zero cache traffic is direct evidence the upstream cache exists.
462
466
  state.cacheWarmSlug = servedSlug ?? decision.slug;
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
 
20
20
  /** Bump when a migration is added; guarded below so reopening never regresses it. */
21
- const USER_VERSION = 12;
21
+ const USER_VERSION = 13;
22
22
 
23
23
  const MIGRATIONS = `
24
24
  CREATE TABLE IF NOT EXISTS catalog_cache (
@@ -211,6 +211,14 @@ const MIGRATE_V12 = `
211
211
  ALTER TABLE ledger ADD COLUMN prompt_tokens_saved INTEGER;
212
212
  `;
213
213
 
214
+ // v13: conversations persist the last compaction plan (JSON array of
215
+ // CompactionEdit). Re-applying it verbatim each turn keeps already-shrunk tool
216
+ // results shrunk — without it, protectRecentTurns drift drops edits and
217
+ // re-inflates mid-prefix bytes, breaking the prompt cache for zero savings.
218
+ const MIGRATE_V13 = `
219
+ ALTER TABLE conversations ADD COLUMN compaction_plan TEXT;
220
+ `;
221
+
214
222
  // v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
215
223
  // BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
216
224
  // whole new table, created idempotently by the MIGRATIONS block above, so there
@@ -242,9 +250,10 @@ export function openDb(path: string): Database {
242
250
  if (!ledgerCols.some((c) => c.name === "features")) db.exec(MIGRATE_V6);
243
251
  if (!ledgerCols.some((c) => c.name === "explored_from")) db.exec(MIGRATE_V7);
244
252
  if (!ledgerCols.some((c) => c.name === "hold_arm")) db.exec(MIGRATE_V8);
253
+ if (!ledgerCols.some((c) => c.name === "prompt_tokens_saved")) db.exec(MIGRATE_V12);
245
254
  const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
246
255
  if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
247
- if (!ledgerCols.some((c) => c.name === "prompt_tokens_saved")) db.exec(MIGRATE_V12);
256
+ if (!convCols.some((c) => c.name === "compaction_plan")) db.exec(MIGRATE_V13);
248
257
  db.exec(`PRAGMA user_version = ${USER_VERSION}`);
249
258
  }
250
259
  return db;
package/src/wire/types.ts CHANGED
@@ -148,6 +148,14 @@ export interface CompactionEdit {
148
148
  keepHead: number;
149
149
  keepTail: number;
150
150
  note: string;
151
+ /**
152
+ * Original (pre-edit) byte length of the targeted message's string content,
153
+ * captured when the edit was planned. Persisted with the plan so a later
154
+ * turn can verify the history it is re-applying to is byte-identical before
155
+ * re-applying — a client-side rewrite or an upstream difference invalidates
156
+ * the edit instead of corrupting the prompt.
157
+ */
158
+ bytes: number;
151
159
  }
152
160
 
153
161
  export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter" | "error";
@@ -1,13 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import type { CompactionConfig } from "../src/config/types.ts";
4
- import { planCompaction } from "../src/router/compaction.ts";
4
+ import { planCompaction, validatePlan } from "../src/router/compaction.ts";
5
5
  import { parseChatRequest } from "../src/wire/openai/request.ts";
6
6
  import type { NormMessage } from "../src/wire/types.ts";
7
7
 
8
8
  const CFG: CompactionConfig = {
9
9
  enabled: true,
10
10
  budgetTokens: 1,
11
+ floorRatio: 1,
11
12
  fitToWindow: false,
12
13
  protectRecentTurns: 2,
13
14
  maxToolResultBytes: 50,
@@ -159,7 +160,7 @@ describe("renderUpstreamBody applies compaction", () => {
159
160
  { role: "tool", tool_call_id: "c1", content: "HEAD" + "x".repeat(500) + "TAIL" },
160
161
  ];
161
162
  const req = parseChatRequest(bodyWith(raw), new Headers());
162
- const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "truncate", keepHead: 4, keepTail: 4, note: "large read result" }] });
163
+ const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "truncate", keepHead: 4, keepTail: 4, note: "large read result", bytes: 508 }] });
163
164
  const messages = out.messages as { role: string; content: unknown }[];
164
165
  expect(messages).toHaveLength(3); // no message removed → pairing intact
165
166
  const content = messages[2]?.content;
@@ -178,8 +179,93 @@ describe("renderUpstreamBody applies compaction", () => {
178
179
  { role: "tool", tool_call_id: "c1", content: "a".repeat(300) },
179
180
  ];
180
181
  const req = parseChatRequest(bodyWith(raw), new Headers());
181
- const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "stub", keepHead: 0, keepTail: 0, note: "identical repeated read result" }] });
182
+ const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "stub", keepHead: 0, keepTail: 0, note: "identical repeated read result", bytes: 300 }] });
182
183
  const messages = out.messages as { content: string }[];
183
184
  expect(messages[2]?.content).toBe("[omp-router: identical repeated read result elided to save context; re-run the tool to restore]");
184
185
  });
185
186
  });
187
+
188
+ describe("plan byte-stability across turns", () => {
189
+ // The prompt cache is a byte-prefix cache: changing any already-sent byte
190
+ // invalidates everything after it. So an edit, once dispatched, must be
191
+ // re-emitted identically on every later turn — which means the planner has
192
+ // to be told what it already did rather than re-deriving it.
193
+ test("edits carry their original byte length for persistence", () => {
194
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
195
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
196
+ expect(edits).toHaveLength(1);
197
+ expect(edits[0]?.bytes).toBe(Buffer.byteLength(big("A")));
198
+ });
199
+
200
+ test("validatePlan keeps edits whose target is byte-identical and role-correct", () => {
201
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
202
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
203
+ expect(validatePlan(edits, msgs)).toEqual(edits);
204
+ });
205
+
206
+ test("validatePlan drops edits when history changed under them", () => {
207
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
208
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
209
+ // Client re-wrote history: the tool result is a different length now.
210
+ const rewritten = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", "short"), ...PAD];
211
+ expect(validatePlan(edits, rewritten)).toEqual([]);
212
+ });
213
+
214
+ test("validatePlan drops edits that fall off the message array", () => {
215
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
216
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
217
+ // Conversation compacted away client-side: index 2 no longer exists.
218
+ expect(validatePlan(edits, [user("go"), ...PAD.slice(1)])).toEqual([]);
219
+ });
220
+
221
+ test("a carried plan produces identical edits to a fresh plan over the same bytes", () => {
222
+ // Determinism contract: re-planning over unchanged bytes re-derives the
223
+ // persisted plan, so the merge in select.ts is a no-op, not a rewrite.
224
+ const msgs = [
225
+ user("go"),
226
+ asst("c1", "read", '{"path":"a.ts"}'),
227
+ toolMsg("c1", "read", big("A")),
228
+ asst("c2", "read", '{"path":"b.ts"}'),
229
+ toolMsg("c2", "read", big("B")),
230
+ ...PAD,
231
+ ];
232
+ const first = planCompaction(msgs, CFG, 1, 10_000);
233
+ const again = planCompaction(msgs, CFG, 1, 10_000);
234
+ expect(again.edits).toEqual(first.edits);
235
+ });
236
+
237
+ test("a carried edit is re-emitted verbatim even when nothing new is eligible", () => {
238
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
239
+ const carried = planCompaction(msgs, CFG, 1, 10_000).edits;
240
+ // Target already met, so a stateless planner would emit nothing at all.
241
+ const next = planCompaction(msgs, CFG, 1_000_000, 10_000, carried);
242
+ expect(next.edits).toEqual(carried);
243
+ expect(next.savedBytes).toBeGreaterThan(0);
244
+ });
245
+
246
+ test("carried savings count toward the target, so the planner only adds what is still needed", () => {
247
+ const msgs = [
248
+ user("go"),
249
+ asst("c1", "read", '{"path":"a.ts"}'),
250
+ toolMsg("c1", "read", big("A")),
251
+ asst("c2", "read", '{"path":"b.ts"}'),
252
+ toolMsg("c2", "read", big("B")),
253
+ ...PAD,
254
+ ];
255
+ const promptBytes = 10_000;
256
+ // Carry the first edit, then re-plan with a target the carried edit alone
257
+ // already satisfies: no second edit may be added.
258
+ const carried = [planCompaction(msgs, CFG, 1, promptBytes).edits[0]!];
259
+ const target = promptBytes - (carried[0]!.bytes - CFG.keepHeadBytes - CFG.keepTailBytes - 120);
260
+ const next = planCompaction(msgs, CFG, target, promptBytes, carried);
261
+ expect(next.edits.map((e) => e.index)).toEqual(carried.map((e) => e.index));
262
+ });
263
+
264
+ test("a carried edit is never re-planned into a different shape", () => {
265
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
266
+ // Carried as a stub; a fresh plan would have chosen truncate.
267
+ const carried = [{ index: 2, mode: "stub" as const, keepHead: 0, keepTail: 0, note: "carried", bytes: Buffer.byteLength(big("A")) }];
268
+ const next = planCompaction(msgs, CFG, 1, 10_000, carried);
269
+ expect(next.edits.filter((e) => e.index === 2)).toEqual(carried);
270
+ });
271
+ });
@@ -56,6 +56,7 @@ function state(over: Partial<ConversationState> = {}): ConversationState {
56
56
  cacheWarmAtMs: 0,
57
57
  contextVersion: null,
58
58
  contextFetchedAtMs: 0,
59
+ compactionPlan: null,
59
60
  updatedAtMs: NOW,
60
61
  ...over,
61
62
  };
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
70
70
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
71
71
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
72
72
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
73
- compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
73
+ compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
74
74
  budget: { onExceeded: "downgrade" },
75
75
  profiles: [],
76
76
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
@@ -267,6 +267,7 @@ function mkConversations(): { store: ConversationStore; map: Map<string, Convers
267
267
  cacheWarmAtMs: 0,
268
268
  contextVersion: null,
269
269
  contextFetchedAtMs: 0,
270
+ compactionPlan: null,
270
271
  updatedAtMs: 0,
271
272
  };
272
273
  map.set(k, fresh);
@@ -66,6 +66,7 @@ function state(over: Partial<ConversationState> = {}): ConversationState {
66
66
  cacheWarmAtMs: 0,
67
67
  contextVersion: null,
68
68
  contextFetchedAtMs: 0,
69
+ compactionPlan: null,
69
70
  updatedAtMs: Date.now(),
70
71
  ...over,
71
72
  };
@@ -601,6 +602,7 @@ describe("context compaction", () => {
601
602
  compaction: {
602
603
  enabled: true,
603
604
  budgetTokens: 1_000,
605
+ floorRatio: 1,
604
606
  fitToWindow: false,
605
607
  protectRecentTurns: 1,
606
608
  maxToolResultBytes: 100,
@@ -664,4 +666,80 @@ describe("context compaction", () => {
664
666
  expect(d.compactionPlan).toEqual([]);
665
667
  expect(d.promptTokensSaved).toBe(0);
666
668
  });
669
+
670
+ test("a carried plan is re-applied even when the turn is now under budget", () => {
671
+ // The prompt cache is a byte-prefix cache: dropping an edit that was
672
+ // already dispatched rewrites history the upstream had cached, and
673
+ // re-sends the tokens the edit saved. So a carried plan survives a turn
674
+ // that would not have triggered compaction on its own.
675
+ const req = loopReq();
676
+ const over = extractFeatures(req, 5_000);
677
+ const first = select({
678
+ req,
679
+ features: over,
680
+ classification: scoreHeuristic(over, COMPACT_CFG),
681
+ profile: PROFILE,
682
+ state: state(),
683
+ snapshot: SNAPSHOT,
684
+ ledger: null,
685
+ cfg: COMPACT_CFG,
686
+ nowMs: Date.now(),
687
+ });
688
+ expect(first.compactionPlan.length).toBeGreaterThan(0);
689
+
690
+ const under = extractFeatures(req, 500); // under budgetTokens=1000
691
+ const second = select({
692
+ req,
693
+ features: under,
694
+ classification: scoreHeuristic(under, COMPACT_CFG),
695
+ profile: PROFILE,
696
+ state: state({ compactionPlan: first.compactionPlan }),
697
+ snapshot: SNAPSHOT,
698
+ ledger: null,
699
+ cfg: COMPACT_CFG,
700
+ nowMs: Date.now(),
701
+ });
702
+ expect(second.compactionPlan).toEqual(first.compactionPlan);
703
+ expect(second.promptTokensSaved).toBeGreaterThan(0);
704
+ });
705
+
706
+ test("floorRatio below 1 compacts strictly past the budget so the plan holds longer", () => {
707
+ // Each plan change rewrites cached prompt bytes, so compaction overshoots
708
+ // deliberately: eliding more now buys byte-stable turns later.
709
+ const req = parseChatRequest(
710
+ {
711
+ model: "auto",
712
+ tools: TOOLS,
713
+ messages: [
714
+ { role: "system", content: "You are a coding agent." },
715
+ { role: "user", content: "read the files" },
716
+ ...[1, 2, 3, 4, 5, 6].flatMap((n) => [
717
+ { role: "assistant", content: null, tool_calls: [{ id: `c${n}`, type: "function", function: { name: "read", arguments: `{"path":"f${n}.ts"}` } }] },
718
+ { role: "tool", tool_call_id: `c${n}`, content: `F${n}${"x".repeat(2000)}` },
719
+ ]),
720
+ { role: "user", content: "continue" },
721
+ ],
722
+ },
723
+ new Headers(),
724
+ );
725
+ // Prompt is ~12k bytes; claim 4000 tokens against a 1000-token budget, so
726
+ // floorRatio 1 targets 1000 and floorRatio 0.5 targets 500.
727
+ const features = extractFeatures(req, 4_000);
728
+ const run = (floorRatio: number) =>
729
+ select({
730
+ req,
731
+ features,
732
+ classification: scoreHeuristic(features, COMPACT_CFG),
733
+ profile: PROFILE,
734
+ state: state(),
735
+ snapshot: SNAPSHOT,
736
+ ledger: null,
737
+ cfg: { ...COMPACT_CFG, compaction: { ...COMPACT_CFG.compaction, floorRatio } },
738
+ nowMs: Date.now(),
739
+ });
740
+ const tight = run(0.5);
741
+ const loose = run(1);
742
+ expect(tight.compactionPlan.length).toBeGreaterThan(loose.compactionPlan.length);
743
+ expect(tight.promptTokensSaved).toBeGreaterThan(loose.promptTokensSaved);
744
+ });
667
745
  });
@@ -244,11 +244,11 @@ describe("v4 migration", () => {
244
244
  }
245
245
  });
246
246
 
247
- test("schema is at user_version 12", () => {
247
+ test("schema is at user_version 13", () => {
248
248
  const db = openDb(":memory:");
249
249
  try {
250
250
  const row = db.query("PRAGMA user_version").get() as { user_version: number };
251
- expect(row.user_version).toBe(12);
251
+ expect(row.user_version).toBe(13);
252
252
  } finally {
253
253
  db.close();
254
254
  }
package/test/turn.test.ts CHANGED
@@ -71,7 +71,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
71
71
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
72
72
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
73
73
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
74
- compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
74
+ compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
75
75
  budget: { onExceeded: "downgrade" },
76
76
  profiles: [],
77
77
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
@@ -268,6 +268,7 @@ function mkConversations(): {
268
268
  cacheWarmAtMs: 0,
269
269
  contextVersion: null,
270
270
  contextFetchedAtMs: 0,
271
+ compactionPlan: null,
271
272
  updatedAtMs: 0,
272
273
  };
273
274
  map.set(k, fresh);
@@ -0,0 +1,127 @@
1
+ /**
2
+ * End-to-end verification of compaction plan stability.
3
+ *
4
+ * Two properties, both measured against a real router process over a growing
5
+ * agentic conversation:
6
+ *
7
+ * 1. STABILITY — an edit applied on one turn is re-applied byte-identically on
8
+ * every later turn. Any change to already-sent bytes invalidates the
9
+ * upstream prompt cache from that message onward.
10
+ * 2. CHURN — how many turns change the plan at all. Each change is a cache
11
+ * invalidation; live ledger data puts a changed-plan turn at 15.4% cold vs
12
+ * 8.9% when the plan holds, and a cold prompt costs 4.34x a warm one per
13
+ * token. `compaction.floorRatio` trades a little extra elision for far
14
+ * fewer changes.
15
+ *
16
+ * Run: bun tools/verify-plan-persist.ts [floorRatio]
17
+ */
18
+ import { mkdtempSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ import { loadConfig } from "../src/config/load.ts";
23
+ import { startServer } from "../src/server/http.ts";
24
+ import { startMockOpenRouter } from "./mock-openrouter.ts";
25
+
26
+ const floorRatio = Number.parseFloat(process.argv[2] ?? "0.75");
27
+ const home = mkdtempSync(join(tmpdir(), "verify-plan-persist-"));
28
+ const mock = await startMockOpenRouter("test/fixtures/openrouter-models.json");
29
+
30
+ const cfg = loadConfig({});
31
+ cfg.server = { host: "127.0.0.1", port: 0 };
32
+ cfg.openrouter.baseUrl = `${mock.url}/api/v1`;
33
+ cfg.openrouter.apiKey = "sk-mock";
34
+ cfg.ledger.path = join(home, "router.db");
35
+ cfg.logLevel = "error";
36
+ cfg.classifier.ambiguityThreshold = 0;
37
+ cfg.benchmarks.enabled = false;
38
+ cfg.context.enabled = false;
39
+ // Scaled-down budget so the fixture behaves like a 40k-budget real conversation.
40
+ cfg.compaction.enabled = true;
41
+ cfg.compaction.budgetTokens = 1_500;
42
+ cfg.compaction.floorRatio = floorRatio;
43
+ cfg.compaction.maxToolResultBytes = 256;
44
+ cfg.compaction.keepHeadBytes = 16;
45
+ cfg.compaction.keepTailBytes = 16;
46
+
47
+ const app = startServer(cfg);
48
+ const base = `http://127.0.0.1:${app.server.port}`;
49
+
50
+ const big = (marker: string): string => `${marker}: ${"payload ".repeat(60)}`;
51
+ const call = (id: string, name: string, args: unknown): unknown => ({
52
+ role: "assistant",
53
+ content: null,
54
+ tool_calls: [{ id, type: "function", function: { name, arguments: JSON.stringify(args) } }],
55
+ });
56
+ const result = (id: string, content: string): unknown => ({ role: "tool", tool_call_id: id, content });
57
+ const cycle = (n: number): unknown[] => [
58
+ call(`c${n}`, "read", { path: `src/file${n}.ts` }),
59
+ result(`c${n}`, big(`READ${n}`)),
60
+ { role: "assistant", content: `read file${n}` },
61
+ ];
62
+
63
+ async function dispatch(messages: unknown[]): Promise<{ role: string; content: unknown }[]> {
64
+ const res = await fetch(`${base}/v1/chat/completions`, {
65
+ method: "POST",
66
+ headers: { "content-type": "application/json" },
67
+ body: JSON.stringify({ model: "auto", messages, stream: true }),
68
+ });
69
+ await res.text();
70
+ const body = mock.requests.at(-1)?.body as Record<string, unknown>;
71
+ return body.messages as { role: string; content: unknown }[];
72
+ }
73
+
74
+ const fail = (label: string, detail?: unknown): never => {
75
+ console.error(`FAIL ${label}`, detail === undefined ? "" : JSON.stringify(detail).slice(0, 500));
76
+ process.exit(1);
77
+ };
78
+ const shrunkOf = (msgs: { content: unknown }[]): Map<number, string> => {
79
+ const out = new Map<number, string>();
80
+ msgs.forEach((m, i) => {
81
+ if (typeof m.content === "string" && m.content.includes("omp-router: elided")) out.set(i, m.content);
82
+ });
83
+ return out;
84
+ };
85
+
86
+ // A 20-cycle conversation, dispatched turn by turn exactly as omp would: the
87
+ // full history every time, one cycle longer each turn.
88
+ const TURNS = 20;
89
+ let history: unknown[] = [{ role: "user", content: "audit the project" }];
90
+ let prev = new Map<number, string>();
91
+ let changes = 0;
92
+ let firstPlanTurn = 0;
93
+
94
+ for (let n = 1; n <= TURNS; n++) {
95
+ history = [...history, ...cycle(n)];
96
+ const msgs = await dispatch(history);
97
+ const shrunk = shrunkOf(msgs);
98
+
99
+ // STABILITY: every previously-shrunk message must still be shrunk, with the
100
+ // same bytes. A dropped or altered edit rewrites the cached prefix.
101
+ for (const [i, content] of prev) {
102
+ const now = shrunk.get(i);
103
+ if (now === undefined) fail(`turn ${n}: edit at message ${i} was DROPPED (bytes re-inflated)`, { turn: n, i });
104
+ if (now !== content) fail(`turn ${n}: edit at message ${i} changed bytes`, { was: content.slice(0, 90), now: now.slice(0, 90) });
105
+ }
106
+
107
+ const added = [...shrunk.keys()].filter((i) => !prev.has(i));
108
+ if (added.length > 0) {
109
+ changes++;
110
+ if (firstPlanTurn === 0) firstPlanTurn = n;
111
+ console.log(`turn ${String(n).padStart(2)}: plan CHANGED (+${added.length} edits, ${shrunk.size} total)`);
112
+ } else if (shrunk.size > 0) {
113
+ console.log(`turn ${String(n).padStart(2)}: plan held (${shrunk.size} edits)`);
114
+ } else {
115
+ console.log(`turn ${String(n).padStart(2)}: no compaction`);
116
+ }
117
+ prev = shrunk;
118
+ }
119
+
120
+ const planningTurns = TURNS - firstPlanTurn + 1;
121
+ console.log(`\nfloorRatio ${floorRatio}`);
122
+ console.log(`PASS stability: no edit was ever dropped or rewritten across ${TURNS} turns`);
123
+ console.log(`plan changes: ${changes} over ${planningTurns} compacting turns (${((changes / planningTurns) * 100).toFixed(0)}% of turns invalidate cache)`);
124
+
125
+ app.stop(true);
126
+ await mock.stop();
127
+ process.exit(0);