auto-model-router 0.2.21 → 0.2.23

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.
@@ -43,7 +43,8 @@ Deterministic, so the same folder always resolves to the same slug:
43
43
  stop and ask for a distinguishing slug.
44
44
  - **Near match** (`my-app` vs `my-app-v2`) — a stop sign: ask, rather than fork a second
45
45
  namespace for one project.
46
- 4. `project_ensure {slug, name}` — `name` is the readable form of the folder.
46
+ 4. `project_ensure {slug}` — `name` is optional and only used if the project is new; it
47
+ defaults to the slug, so pass one only when the readable form differs.
47
48
  5. **Make `.env.agentdox` un-committable *before* writing it.** Run `git check-ignore -v
48
49
  .env.agentdox`. If it is not ignored, **add `.env.agentdox` to `.gitignore`** (create that
49
50
  file if there is none) — do not merely check and move on. Patterns like `.env` and
@@ -136,7 +137,7 @@ await fetch("http://localhost:3003/memory", { method: "POST", headers: H,
136
137
 
137
138
  | Step | MCP | REST |
138
139
  | --- | --- | --- |
139
- | Ensure the project | `project_ensure {slug, name}` | `POST /projects {slug,name}` (idempotent) |
140
+ | Ensure the project | `project_ensure {slug}` | `POST /projects {slug}` (idempotent) |
140
141
  | Read the brief | `context_brief {scope}` | `GET /context/brief?scope=<scope>` |
141
142
 
142
143
  The brief is the cumulative on-ramp: overview, repo layout, code style, build/test,
@@ -180,12 +181,29 @@ while memory, docs, or the brief for the area you touched is stale.
180
181
  | Architecture / conventions | `docs_update {id, content}` | `PATCH /docs/:id {title?, content?, tags?}` |
181
182
  | A genuinely new doc | `docs_write {slug, title, content, scope}` | `POST /docs {slug, title, content, scope}` |
182
183
  | List / read docs | `docs_read` · `docs_search` | `GET /docs?scope=<scope>` · `GET /docs/search?q=…` · `GET /docs/slug/:slug` |
184
+ | Find the *part* of a doc that answers something | `docs_passages` | `GET /docs/passages?q=…&scope=<scope>` |
183
185
  | A decision you made | `context_brief_record {scope, title, decision, rationale}` | `POST /context/brief/decision {scope, title, decision, rationale}` |
184
186
  | Edit brief sections | — | `PUT /context/brief {scope, overview?, repoLayout?, codeStyle?, buildTest?, assetConventions?, gotchas?}` |
185
187
 
186
188
  **Search before you add.** Update the existing entry rather than leaving two contradictory
187
189
  facts. Record the *why* of a decision, not just the *what*.
188
190
 
191
+ ## Searching well
192
+
193
+ Retrieval is hybrid — keyword *and* meaning — so you do not have to guess the stored wording.
194
+ Ask in your own words; exact identifiers (`SettlementLayout.Build`, `AGENTDOX_TOKEN`) work too.
195
+
196
+ **Prefer `docs_passages` over `docs_search`** when you want the part of a doc that answers a
197
+ question. `docs_search` hands back whole documents, which then get truncated — and the
198
+ truncation is rarely the relevant part. A passage arrives with its slug and heading, so
199
+ `docs_read` the full doc when the passage is not enough.
200
+
201
+ If results look thin or stale, check `index_stats {scope}` before concluding the store is
202
+ empty: it reports how much of the scope is indexed and whether the embedding provider is
203
+ reachable. `embedded` far below `total`, or an unreachable provider, means you are getting
204
+ keyword-only results. `index_rebuild` fixes an index that has drifted; ordinary writes index
205
+ themselves, so you should rarely need it.
206
+
189
207
  ## Two inconsistencies that cause silent mistakes
190
208
 
191
209
  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
package/README.md CHANGED
@@ -32,6 +32,93 @@ This router exists for the things a prompt classifier structurally cannot do:
32
32
  | **Closed-loop trust** | Per-model escalation and error rates from *your* traffic demote cheap-but-flaky models automatically. |
33
33
  | **Explainability** | Every decision — candidates, rejections, forecasts, reasons — is persisted and replayable via `auto-model-router explain`. |
34
34
 
35
+ ## Measured against Claude Opus 5
36
+
37
+ Five benchmark runs, 88 graded task runs, 2026-08-29. Each task is a real omp
38
+ session working in a pristine git workspace from a written spec. Hidden tests are
39
+ copied in only *after* the agent exits, so they cannot be read or edited by it;
40
+ every task is verified to fail an untouched workspace and to pass a reference
41
+ solution. Both arms are metered from omp's own event stream, run under an
42
+ identical tool surface, and are checked per turn against their expected provider.
43
+ The router arm routes freely — nothing pinned. The baseline is `claude-opus-5`
44
+ on Anthropic first-party.
45
+
46
+ ### Core suite — 10 coding tasks × 3 trials
47
+
48
+ | | auto-model-router | Claude Opus 5 |
49
+ | --- | --- | --- |
50
+ | Tasks solved | **30 / 30** | 30 / 30 |
51
+ | Total cost | **$0.63** | $16.61 |
52
+ | Cost per solved task | **$0.0209** | $0.5538 |
53
+ | Turns to finish | **278** | 303 |
54
+ | Tool calls | **265** | 337 |
55
+ | Wall clock | **2 057 s** | 3 185 s |
56
+ | Median time to first token | 5 776 ms | **1 490 ms** |
57
+
58
+ **26.5× cheaper at identical correctness** — and in fewer turns, fewer tool
59
+ calls, and 19 minutes less wall clock. The saving is not bought by grinding out
60
+ extra turns. The one regression is time to first token: a routed turn pays for
61
+ classification and dispatch before anything streams back.
62
+
63
+ Per task the ratio ranges from 9× to 264×. The widest gaps are tasks where the
64
+ single-model baseline entered long tool loops — `semver` and `queue-order` cost
65
+ it $2.99 each across three trials against a $1.32 median, 36% of its entire bill.
66
+
67
+ ### Difficulty ladder — 7 rungs, run twice
68
+
69
+ A second suite of deliberately escalating difficulty, ending in npm semver range
70
+ semantics and a minimal diff with a specified tie-break.
71
+
72
+ | | auto-model-router | Claude Opus 5 |
73
+ | --- | --- | --- |
74
+ | Run 1 | 5 / 7 · $0.30 | 5 / 7 · $6.25 |
75
+ | Run 2 | 5 / 7 · $0.46 | **6 / 7** · $6.60 |
76
+
77
+ At the top of the ladder the engines separate: they fail different rungs, and on
78
+ the second run the single-model baseline finished one more. Both arms timed out
79
+ on the semver rung at the 10-minute cap.
80
+
81
+ ### What it routed to
82
+
83
+ Across 464 routed turns in all five runs:
84
+
85
+ | Model | Turns | Input price | Role |
86
+ | --- | --- | --- | --- |
87
+ | `z-ai/glm-5.3-flash` | 389 (84%) | $0.07 / MTok | default |
88
+ | `google/gemini-3.7-flash` | 56 (12%) | $0.75 / MTok | escalation target |
89
+ | `x-ai/grok-4.6` | 18 (4%) | $2.00 / MTok | escalation target |
90
+
91
+ **Tier escalation converts to a costlier model roughly one-for-one**: on the
92
+ ladder, the count of turns classified `hard` matched the count served by
93
+ something other than the default (6/6, 4/4, 3/3, 5/5, 7/7, 1/1 across rungs and
94
+ runs). The escalation *target* is chosen live from trust and latency history, so
95
+ it differs between runs on the same catalog — run 1 stepped up to
96
+ `gemini-3.7-flash`, run 2 to `grok-4.6`.
97
+
98
+ Escalation stays inside the cheaper half of the catalog. A model priced above a
99
+ tier's `maxInputPerMtok` is excluded before ranking, and at `hard` the
100
+ `(quality/100)^qualityExponent ÷ expected cost` score favours cheaper models that
101
+ score nearly as well. If your workload needs a frontier model on hard turns,
102
+ raise the tier price ceiling and `qualityExponent` — measured thresholds are in
103
+ [`docs/routing-benchmark-findings.md`](docs/routing-benchmark-findings.md).
104
+
105
+ ### Scope
106
+
107
+ These are small, self-contained tasks of one to three files, solved in under 25
108
+ turns. On the core suite both engines solved everything, so it measures cost at
109
+ equal correctness rather than capability; the ladder is where capability
110
+ separates. The cost multiple varied between 14× and 32× across runs depending on
111
+ which task the baseline stalled on — treat "well over an order of magnitude" as
112
+ the claim, not a specific figure.
113
+
114
+ For sustained work on a large codebase the economics differ: cost there is
115
+ dominated by the conversation being resent each turn rather than by per-token
116
+ price. Replaying a week of real omp traffic (6 918 billed turns, 410:1
117
+ input-to-output) against a single-model baseline gives **≈15×**.
118
+
119
+ Harness, tasks and raw per-turn data:
120
+ [`docs/routing-benchmark-findings.md`](docs/routing-benchmark-findings.md).
121
+
35
122
  ## Architecture
36
123
 
37
124
  ```mermaid
@@ -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
@@ -13,15 +13,18 @@ of my own earlier conclusions were wrong and are corrected in place.
13
13
 
14
14
  1. **The router is 20–32× cheaper at equal solve rate.** On 10 easy tasks both
15
15
  arms solved 20/20; on a 7-rung difficulty ladder both solved 5/7.
16
- 2. **It never escalates.** Across 86 routed turns on the ladder — including the
17
- rungs it *failed* there were zero escalation signals, zero retry
18
- dispatches, and only two models ever served: `glm-5.3-flash` and
19
- `gemini-3.7-flash`.
20
- 3. **Root cause is two independent gates, and both must move.** The price
21
- ceiling hard-excludes expensive models below `hard`; and the ranking
22
- arithmetic makes quality unable to outweigh price at any sane exponent.
23
- 4. **Escalation does work once both gates are opened** — verified end to end,
24
- with `claude-opus-5` serving 3 turns of a task the cheap-only config failed.
16
+ 2. **Tier escalation works.** A turn classified `hard` reliably selects a
17
+ costlier modelthe hard-turn count matches the non-flash turn count ~1:1
18
+ across two independent ladder runs (to `gemini-3.7-flash` in one, to
19
+ `grok-4.6` in the other). *An earlier version of this document claimed the
20
+ opposite; that was my error, corrected in §3.*
21
+ 3. **What never happens is reaching `claude-opus-5`.** Two gates: the price
22
+ ceiling excludes it below `hard`, and at `hard` it is outranked by cheaper
23
+ high-quality models. Whether that is a defect depends on whether
24
+ `grok-4.6`-class is "good enough" this benchmark cannot say.
25
+ 4. **Mid-stream escalation signals never fired** — `escalation_signal` was empty
26
+ on every one of ~190 routed ladder turns. That is a different mechanism from
27
+ tier selection and may simply mean nothing malformed occurred.
25
28
  5. **The standalone `serve` process died 4 times inside one normally-completing
26
29
  run.** The crash is real and mid-run; the *trigger* is unidentified — my first
27
30
  explanation (client disconnect mid-stream) does not reproduce. Details in §7.
@@ -33,6 +36,11 @@ of my own earlier conclusions were wrong and are corrected in place.
33
36
  - **Harness**: omp in `-p --mode=json` print mode. Every turn's tokens,
34
37
  duration, TTFT and tool calls are read off omp's own event stream, so both
35
38
  arms are measured by the same instrument.
39
+ - **Provider guard**: each arm declares its expected omp provider and every
40
+ completion turn is checked against it — `anthropic` + `claude-opus-5` for the
41
+ baseline (first-party, never OpenRouter), `benchrouter` for the router arm.
42
+ Zero violations across the re-runs. Added after an early pilot silently
43
+ resolved `--model auto` to OpenRouter's own `auto` meta-router.
36
44
  - **Router arm**: the standalone `auto-model-router serve` endpoint registered
37
45
  as a plain OpenAI-compatible provider — the documented non-omp path (README
38
46
  § "Standalone alternative"). Nothing pinned, nothing stubbed; the router
@@ -46,22 +54,29 @@ of my own earlier conclusions were wrong and are corrected in place.
46
54
  verified to fail an untouched workspace and to pass a reference solution
47
55
  before any run (`bench/validate-ladder.ts`).
48
56
 
49
- > **A note on print mode.** The embedded extension only registers the
50
- > `auto-model-router` provider inside an interactive session `ctx.hasUI` is
51
- > false in `-p`, so it takes the subagent path and looks for an existing
52
- > `embed.port`. In print mode with no interactive session running, the provider
53
- > is absent from the model registry entirely and `--model auto-model-router/auto`
54
- > fails with "Model not found". Worth documenting, or worth having the subagent
55
- > path fall back to binding its own server.
57
+ > **Print mode resolved.** During the benchmark the embedded extension only
58
+ > registered the provider inside a session with a UI, so `omp -p` could not see
59
+ > `auto-model-router/auto` at all; that is why the harness drives the standalone
60
+ > `serve` endpoint instead. Fixed upstream in **0.2.20** (`fix(embed): resolve the
61
+ > router in print mode and subagents`) verified: `omp -p --model
62
+ > auto-model-router/auto` now resolves natively with no standalone server. The
63
+ > harness keeps the `serve` path because it gives the benchmark its own isolated
64
+ > ledger, not because the embedded path is broken.
56
65
 
57
66
  ---
58
67
 
59
68
  ## 2. Cost result
60
69
 
61
- | Suite | Router | Opus 5 | Ratio |
62
- |---|---|---|---|
63
- | 10 easy tasks × 2 trials | 20/20 · $0.3577 | 20/20 · $11.4040 | 31.9× |
64
- | 7-rung ladder × 1 trial | 5/7 · $0.3026 | 5/7 · $6.2506 | 20.7× |
70
+ | Suite | Run | Router | Opus 5 | Ratio |
71
+ |---|---|---|---|---|
72
+ | 10 easy tasks | trials 1+2 | 20/20 · $0.3577 | 20/20 · $11.4040 | 31.9× |
73
+ | 10 easy tasks | re-run | 10/10 · $0.2695 | 10/10 · $5.2092 | 19.3× |
74
+ | 7-rung ladder | first | 5/7 · $0.3026 | 5/7 · $6.2506 | 20.7× |
75
+ | 7-rung ladder | re-run | 5/7 · $0.4619 | **6/7** · $6.5967 | 14.3× |
76
+
77
+ Across four independent runs the ratio lands between **14× and 32×**. On the
78
+ ladder re-run Opus 5 edged the router on correctness for the first time (6/7 vs
79
+ 5/7), which is the outcome the ladder was built to be able to detect.
65
80
 
66
81
  Turn counts are comparable (184 vs 217 on the easy suite; 86 vs 91 on the
67
82
  ladder), so the saving is not bought with extra turns. Median TTFT is the
@@ -74,30 +89,56 @@ magnitude" is defensible; a precise figure is not.
74
89
 
75
90
  ---
76
91
 
77
- ## 3. The router never escalates
78
-
79
- The ladder was built specifically to force an escalation decision: seven rungs
80
- ending in npm semver range semantics and a minimal-diff with a specified
81
- tie-break.
92
+ ## 3. Escalation corrected
82
93
 
83
- | Rung | Task | Router | Opus 5 | Escalated? |
84
- |---|---|---|---|---|
85
- | 1 | in-range | PASS | PASS | no |
86
- | 2 | round-half-even | PASS | PASS | no |
87
- | 3 | csv-document | PASS | PASS | no |
88
- | 4 | sliding-limiter | PASS | **FAIL** | no |
89
- | 5 | savepoints | **FAIL** | PASS | **no** |
90
- | 6 | semver-ranges | timeout | timeout | no |
91
- | 7 | minimal-diff | PASS | PASS | no |
94
+ **I got this wrong first time.** I reported that the router "never escalates",
95
+ having looked at the served-model list, seen only `glm-5.3-flash` and
96
+ `gemini-3.7-flash`, and mentally filed both as "flash models, therefore no
97
+ escalation". I never checked whether the counts lined up with the tier. They do.
92
98
 
93
- Rung 6 timed out on both arms at the 10-minute cap; it is not evidence about
94
- capability either way.
99
+ ### Tier escalation works, and is close to deterministic
95
100
 
96
- **The router assigned its `hard` tier on six of the seven rungs**, so the
97
- classifier is not blind — the tier simply never converts into a more capable
98
- model. On rung 5 it failed while continuing to dispatch to `glm-5.3-flash`.
101
+ Hard-tier turns versus turns served by something other than the cheap default:
99
102
 
100
- ---
103
+ | Rung | ladder-1 hard / non-flash | v2-ladder hard / non-flash |
104
+ |---|---|---|
105
+ | L1 in-range | 0 / 0 | 0 / 0 |
106
+ | L2 round-half-even | 6 / 6 | 0 / 0 |
107
+ | L3 csv-document | 4 / 4 | 0 / 0 |
108
+ | L4 sliding-limiter | 3 / 6 | 5 / 5 |
109
+ | L5 savepoints | 3 / 3 | 7 / 7 |
110
+ | L6 semver-ranges | 2 / 3 | 0 / 0 |
111
+ | L7 minimal-diff | 3 / 4 | 1 / 1 |
112
+
113
+ The escalation *target* differs by run, which is itself worth knowing:
114
+ `ladder-1` stepped up to `gemini-3.7-flash` ($0.75/MTok, ~10× the default);
115
+ `v2-ladder` stepped up to `grok-4.6` ($2.00/MTok, ~28×). Same config, same
116
+ catalog — the choice moves with trust and latency history, which are live inputs
117
+ to the ranking. Escalation behaviour is therefore **not reproducible run to run**,
118
+ even though it is reliable *within* a run.
119
+
120
+ ### What genuinely never happens
121
+
122
+ `anthropic/claude-opus-5` was never selected in any run. §4 explains why, and
123
+ that analysis stands: the price ceiling excludes it below `hard`, and at `hard`
124
+ it loses the quality-per-dollar ranking to cheaper models scoring nearly as well.
125
+
126
+ Whether that is a *problem* is not something this benchmark answers. If
127
+ `grok-4.6` is good enough for the hard turns, the router is behaving correctly
128
+ and cheaply. The one weak signal available: rung 5 failed in `ladder-1` when it
129
+ escalated only as far as `gemini-3.7-flash` (3 turns) and passed in `v2-ladder`
130
+ when it escalated to `grok-4.6` (7 turns). Rung 4 pushes the other way — it
131
+ passed in `ladder-1` without much escalation and failed in `v2-ladder` *with*
132
+ `grok-4.6` on 5 turns. With n=1 per cell these cancel out; nothing is
133
+ established.
134
+
135
+ ### Mid-stream escalation is a separate mechanism, and it never fired
136
+
137
+ `escalation_signal` — the abort-and-redispatch-upward path for malformed tool
138
+ calls, empty completions and repeated calls — was empty on every routed ladder
139
+ turn, as was `attempt > 0`. That is consistent with "no turn ever came back
140
+ malformed" rather than evidence of a defect; the ladder gave it nothing to react
141
+ to.
101
142
 
102
143
  ## 4. Root cause: two gates, both hard
103
144
 
@@ -80,17 +80,29 @@ export function deriveAgentdoxScope(cwd: string): string {
80
80
  }
81
81
 
82
82
  /**
83
- * Resolves the desired bind port: an explicit `AUTO_MODEL_ROUTER_PORT` when set and
84
- * valid, else `0` so Bun assigns a free ephemeral port (the "random port" that
85
- * lets multiple local omp sessions coexist without colliding). Returning 0
86
- * means "let the OS pick"; the caller must read the actual port back off the
87
- * started server.
83
+ * Resolves the port the embedded router should serve on, in precedence order:
84
+ * an explicit `AUTO_MODEL_ROUTER_PORT`, else the configured `server.port`, else
85
+ * 0 (let the OS pick a free one).
86
+ *
87
+ * A STABLE port is what keeps omp's model resolution honest. omp resolves
88
+ * `modelRoles.default` from `models.yml` during startup — BEFORE extensions
89
+ * load, so before this session can bind and rewrite that file. With an
90
+ * ephemeral port the block names the PREVIOUS session's port, which is dead
91
+ * once that session exits, and every main-agent turn fails with "Unable to
92
+ * connect" while utility calls (resolved later, from the live registration)
93
+ * still work. A deterministic port makes the pre-bind block correct by
94
+ * construction. Sessions sharing that port share one router, which is already
95
+ * how subagents behave.
96
+ *
97
+ * `0` is still honoured when asked for explicitly, and remains the fallback
98
+ * when the desired port is occupied by something that is not our router.
88
99
  */
89
- export function resolveEmbedPort(envPort: string | undefined): number {
100
+ export function resolveEmbedPort(envPort: string | undefined, configuredPort = 0): number {
90
101
  if (envPort !== undefined && envPort !== "") {
91
102
  const port = Number.parseInt(envPort, 10);
92
103
  if (Number.isInteger(port) && port >= 0 && port <= 65_535) return port;
93
104
  }
105
+ if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort <= 65_535) return configuredPort;
94
106
  return 0;
95
107
  }
96
108
 
@@ -86,8 +86,6 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
86
86
  export default function (pi: ExtensionAPI): void {
87
87
  pi.setLabel("auto-model-router embed");
88
88
 
89
- const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
90
-
91
89
  // Shared port file, written only by the main session's router.
92
90
  const homeRaw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
93
91
  const home =
@@ -95,11 +93,15 @@ export default function (pi: ExtensionAPI): void {
95
93
  ? join(homedir(), homeRaw.slice(1))
96
94
  : homeRaw;
97
95
  const portFile = embedPortPath(home);
98
- const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: requestedPort } } });
96
+ // Load first WITHOUT a port override so `server.port` from config.yml is
97
+ // visible, then let it (or the env var) decide the bind port.
98
+ const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1" } } });
99
+ const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT, cfg.server.port);
100
+ cfg.server.port = requestedPort;
99
101
 
100
102
  let app: StartedServer | null = null;
101
103
 
102
- pi.on("session_start", (_event, ctx) => {
104
+ pi.on("session_start", async (_event, ctx) => {
103
105
  // The omp UI session id tags every request so the toast can scope its
104
106
  // notifications to that exact session (see router-toast.ts).
105
107
  const sessionId = ctx.sessionManager.getSessionId();
@@ -108,7 +110,7 @@ export default function (pi: ExtensionAPI): void {
108
110
  // shared router: one process, one ledger, one place to inspect.
109
111
  // The main writes the port file before spawning subagents.
110
112
  const shared = readEmbedPort(portFile);
111
- if (shared !== null && probeEmbed(shared)) {
113
+ if (shared !== null && (await probeEmbed(shared))) {
112
114
  registerRouterProvider(pi, shared, cfg, sessionId);
113
115
  return;
114
116
  }
@@ -128,12 +130,35 @@ export default function (pi: ExtensionAPI): void {
128
130
  return;
129
131
  }
130
132
 
131
- // Main interactive session: bind the router once, then register the
132
- // provider against the exact bound port. Registration happens only
133
- // here never at factory load, where a stale shared port would be
134
- // captured into omp's model registry and defeat the correct bound URL.
133
+ // Main interactive session. The port is deterministic (see
134
+ // resolveEmbedPort), which matters because omp resolves
135
+ // `modelRoles.default` from models.yml BEFORE this extension loads: the
136
+ // URL that block names must be one this session will actually serve.
135
137
  if (app) return;
136
- const started = startServer(cfg);
138
+
139
+ // Another live session already serving this port? Share it rather than
140
+ // fighting over the socket — subagents already share one router, and the
141
+ // ledger and DB are shared regardless.
142
+ if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
143
+ writeEmbedPort(portFile, requestedPort);
144
+ syncModelsYml(cfg, requestedPort);
145
+ registerRouterProvider(pi, requestedPort, cfg, sessionId);
146
+ pi.setLabel(`auto-model-router embed (shared :${requestedPort})`);
147
+ return;
148
+ }
149
+
150
+ // Bind the desired port; if something that is NOT our router holds it,
151
+ // fall back to an ephemeral port rather than leaving the session with no
152
+ // provider at all. models.yml is rewritten either way, so headless runs
153
+ // and subagents still resolve.
154
+ let started: StartedServer;
155
+ try {
156
+ started = startServer(cfg);
157
+ } catch (err) {
158
+ if (requestedPort === 0) throw err;
159
+ cfg.server.port = 0;
160
+ started = startServer(cfg);
161
+ }
137
162
  const actualPort = started.server.port;
138
163
  if (actualPort === undefined) return;
139
164
  app = started;
@@ -145,8 +170,7 @@ export default function (pi: ExtensionAPI): void {
145
170
  // subagent processes resolve models from models.yml in a FRESH registry
146
171
  // — extension registration does not reach them — so without this they
147
172
  // fail with "Model not found" when no interactive session is live
148
- // (the print-mode gap the external benchmark hit). Registration still
149
- // wins at runtime, so a live session always overrides a stale block.
173
+ // (the print-mode gap the external benchmark hit).
150
174
  const syncAction = syncModelsYml(cfg, actualPort);
151
175
  if (syncAction !== null) pi.setLabel(`auto-model-router embed (models.yml ${syncAction})`);
152
176
  registerRouterProvider(pi, actualPort, cfg, sessionId);
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.23",
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. */