auto-model-router 0.2.32 → 0.3.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.
Files changed (67) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +208 -29
  3. package/docs/review-2026-09-05.md +267 -0
  4. package/omp-extension/configure-logic.ts +71 -15
  5. package/omp-extension/pi-coding-agent.d.ts +79 -2
  6. package/omp-extension/report-hub.ts +376 -0
  7. package/omp-extension/report-logic.ts +115 -0
  8. package/omp-extension/router-configure.ts +203 -51
  9. package/omp-extension/router-url.ts +52 -0
  10. package/omp-extension/toast-logic.ts +14 -2
  11. package/package.json +1 -1
  12. package/src/catalog/composite.ts +97 -0
  13. package/src/catalog/ollama-catalog.ts +309 -0
  14. package/src/catalog/ollama-prices.ts +85 -0
  15. package/src/catalog/openrouter-catalog.ts +39 -1
  16. package/src/catalog/types.ts +31 -1
  17. package/src/cli/args.ts +1 -0
  18. package/src/cli/config-wizard.ts +189 -28
  19. package/src/cli/explain.ts +2 -4
  20. package/src/cli/models.ts +2 -4
  21. package/src/cli/report.ts +37 -0
  22. package/src/config/defaults.ts +43 -2
  23. package/src/config/load.ts +25 -1
  24. package/src/config/omp-credentials.ts +31 -7
  25. package/src/config/schema.ts +27 -0
  26. package/src/config/types.ts +114 -2
  27. package/src/cost/ledger.ts +73 -4
  28. package/src/cost/report.ts +340 -0
  29. package/src/cost/types.ts +33 -1
  30. package/src/index.ts +5 -8
  31. package/src/router/candidates.ts +52 -4
  32. package/src/router/classify.ts +33 -6
  33. package/src/router/features.ts +13 -1
  34. package/src/router/select.ts +55 -8
  35. package/src/router/state.ts +6 -2
  36. package/src/router/tier-plan.ts +49 -11
  37. package/src/router/types.ts +10 -0
  38. package/src/server/http.ts +47 -6
  39. package/src/server/providers.ts +54 -0
  40. package/src/server/turn.ts +122 -34
  41. package/src/tokens/estimate.ts +16 -0
  42. package/src/upstream/multi.ts +26 -0
  43. package/src/upstream/ollama-usage.ts +157 -0
  44. package/src/upstream/ollama.ts +275 -0
  45. package/src/upstream/openrouter.ts +19 -1
  46. package/src/upstream/types.ts +2 -0
  47. package/src/util/sqlite.ts +25 -1
  48. package/test/catalog.test.ts +44 -0
  49. package/test/classify.test.ts +41 -5
  50. package/test/compaction.test.ts +1 -0
  51. package/test/config-wizard.test.ts +77 -1
  52. package/test/configure-logic.test.ts +129 -33
  53. package/test/embed-lifecycle.test.ts +1 -0
  54. package/test/failover.test.ts +148 -3
  55. package/test/features.test.ts +35 -0
  56. package/test/http-resilience.test.ts +24 -0
  57. package/test/ollama.test.ts +506 -0
  58. package/test/omp-credentials.test.ts +43 -1
  59. package/test/report-hub.test.ts +341 -0
  60. package/test/report-logic.test.ts +92 -0
  61. package/test/report.test.ts +217 -0
  62. package/test/select.test.ts +151 -1
  63. package/test/tier-plan.test.ts +159 -1
  64. package/test/toast-logic.test.ts +11 -2
  65. package/test/tokens.test.ts +71 -1
  66. package/test/trust-attribution.test.ts +2 -2
  67. package/test/turn.test.ts +124 -7
@@ -0,0 +1,267 @@
1
+ # Code + ledger review — 2026-09-05
2
+
3
+ > **Status (same day, follow-up session):** the "Suggested order" below has been
4
+ > worked through. See the *Applied* section at the end for what shipped, what
5
+ > is config-only, what the replays showed, and what still needs a restart.
6
+
7
+ Full read of `src/` and `omp-extension/` plus an analysis of the live ledger
8
+ (`~/.auto-model-router/router.db`, 17,184 dispatches, 2026-08-21 → 2026-09-05,
9
+ $180.22). Baseline: `bun run typecheck` clean, `bun test` 513/513.
10
+
11
+ Numbers below are from the ledger unless marked *replay* (`tools/replay.ts`).
12
+ "7d" = the seven days ending 2026-09-05T14:43Z ($101.18, 9,336 dispatches).
13
+
14
+ ## Where the money goes (7d)
15
+
16
+ | Slice | $ | Share | Note |
17
+ | --- | --- | --- | --- |
18
+ | Total | 101.18 | | ~$14.5/day |
19
+ | Sep 3 alone | 38.46 | 38% | key-scoped catalog was 8 models, glm absent 00:14–23:24 UTC; gemini-3.7/3.8-flash ($0.75/M) served everything |
20
+ | `hard` tier | 40.76 | 40% | heuristic 30.47 (mostly pre-v0.2.33 failed-tool rows), escalation 5.60, sticky 4.69 |
21
+ | model-switch turns (4% of dispatches) | 33.42 | 33% | cache hit 44% vs 91% on same-model turns; moderate→hard alone 25.61 |
22
+ | fresh prompt tokens | 49.40 | 56% | cache read 31.70, cache write 4.80, completion 1.99 |
23
+ | last 2 days, `hard` | 7.52 of 35.46 | 21% | escalation 4.42 now dominates; heuristic hard is down to 2.18 |
24
+
25
+ v0.2.33 (failed-tool damping) has been live since the 2026-09-03 restart
26
+ (damped rows appear from Sep 3). **v0.2.34 is not live**: both running
27
+ `omp.exe` processes started 2026-09-03T00:27–00:29Z, before the commit. All omp
28
+ windows need a restart for the circular-loop damping to take effect.
29
+
30
+ ## Findings, ranked by expected $ impact
31
+
32
+ ### 1. Adaptive tier floors now RELAX every tier below its configured floor
33
+
34
+ `tierPlanFor` takes `min(configured, quantile-band)`. The design comment assumes
35
+ a healthy catalog's bands sit above the configured floors; the 347-model catalog
36
+ the key admits since 2026-09-05 has a long tail of weak scored models, so the
37
+ bands sit far *below* them:
38
+
39
+ | Tier | Configured coding floor | Effective now |
40
+ | --- | --- | --- |
41
+ | simple | 40 | 26.6 |
42
+ | moderate | 60 | 45.8 |
43
+ | hard | 72 | 59.9 |
44
+
45
+ Consequences measured on Sep 5: `inclusionai/ling-3.0-flash` (coding 50.6) wins
46
+ `moderate` outright (433 dispatches, $0.27), and *replay* shows
47
+ `google/gemma-4-26b-a4b-it` (coding 39.3) taking 1,175 of 4,000 recent
48
+ dispatches at `simple`/`trivial`. ling's probe-escalation rate is 3.6% (16/447)
49
+ against glm-5.3-flash's 0.26% (22/8,346), and each escalation lands on
50
+ `claude-opus-5` via the capability floor: 7 escalated turns cost $3.02 in two
51
+ days, eleven times the cost of all 433 ling turns combined.
52
+
53
+ *Replay* of the 717 Sep-5 dispatches with `adaptiveTierFloors=false`: moderate
54
+ returns to glm-5.3-flash (+347 dispatches), direct cost +$0.28 (+28%) — before
55
+ counting the escalations replay cannot model, which cost more than that.
56
+
57
+ **Fix (code):** relax only when the configured floor leaves the tier thin. In
58
+ `buildCandidates`, count rankable models passing the configured floor on the
59
+ request axis; use the adaptive band only when that count is below a small
60
+ minimum (e.g. 3). That preserves the guardrail-narrowed case the feature was
61
+ built for and makes a wide catalog behave exactly like the config says.
62
+ Interim (config, hot-reloads): `adaptiveTierFloors: false`.
63
+
64
+ ### 2. Trust prices a failure as a same-model retry; the real price is the next tier
65
+
66
+ `effectiveUsd = expectedUsd / successRate` treats a 4% failure rate as a 4%
67
+ surcharge. The observed cost of a failure is a re-dispatch of the whole prompt
68
+ on the escalation target: for ling → opus that is ~700× the turn's own cost, so
69
+ the true surcharge is ~25×, not 4%. Every model's trust sits at 0.93–0.997, so
70
+ the divisor is effectively inert and `minTrust: 0.7` never trips.
71
+
72
+ **Fix (code):** `effectiveUsd = expectedUsd + escalationRate × forecast(escalation target)`,
73
+ where the target is the next tier's current winner. This is what makes cheap
74
+ flaky models lose to cheap reliable ones. Keep the divisor only for hard errors.
75
+
76
+ ### 3. Escalation re-dispatches the model that just failed (49 of 71 in 7d)
77
+
78
+ Probe escalation sets `escalateFrom` but never adds the failing slug to
79
+ `excludeSlugs` (only HTTP-error failover does). 7d: `gemini-3.8-flash moderate →
80
+ gemini-3.8-flash hard` ×11, `gemini-3.7-flash moderate → gemini-3.7-flash hard`
81
+ ×12, `gemini-3.7-flash simple → gemini-3.7-flash moderate` ×8, and so on — the
82
+ same model one tier up, paying the tier premium for a provider hiccup.
83
+ Escalated attempts cost $5.50 in 7d.
84
+
85
+ **Fix (code):** on a probe escalation, push `decision.slug` onto `failedSlugs`.
86
+ For `empty_completion`, `refusal` and `upstream_error` — signals that indict
87
+ the provider rather than the tier — try a same-tier sibling first, exactly as
88
+ `MAX_SAME_TIER_FAILOVERS` already does for 5xx/429, and only then step up.
89
+
90
+ ### 4. Cross-tier switches forfeit the cache with no economic check
91
+
92
+ The stay/switch arithmetic in `select.ts` step 4 runs only when the warm model
93
+ is a candidate in the *new* tier. A tier change therefore always switches
94
+ cold: 7d moderate→hard 117 switches, $25.61, 34% cache hit. 84 of those 215
95
+ uncaptured switches carried `lastToolFailed`, which v0.2.33 has since damped
96
+ (2d hard-heuristic spend is $2.18). What remains is structural:
97
+
98
+ - **Myopic stay/switch keeps expensive models warm.** After an escalation put
99
+ `kimi-k3` in a conversation, "stay $0.0589 ≤ switch $0.1131 × 1.3" held it at
100
+ `moderate` for 33 dispatches ($4.12, $0.125/turn) where glm would have been
101
+ $0.003/turn warm. The comparison is correct for one turn and wrong for the
102
+ 12-turn run that followed. Amortise the switch cost over an expected horizon
103
+ (deep loops average 25 dispatches per user turn): switch when
104
+ `H × (stayWarm − newWarm) > switchCold − stayWarm`, with H ≈ 5–10.
105
+ *Replay* with `switchMargin=1.0` changes 0 decisions — the margin is not the
106
+ lever, the horizon is.
107
+ - When a tier change is driven by a single soft signal on a mechanical
108
+ continuation, consider requiring the signal to persist for two turns before
109
+ paying a cold switch to a 20–50× model.
110
+
111
+ ### 5. Sep 3: a catalog collapse cost ~$32 in one day, silently
112
+
113
+ `no candidates in trivial (8 rejected)` on every Sep-3 row: the key-scoped
114
+ catalog had 8 models and glm was absent for 23 hours. `gemini-3.7-flash` served
115
+ 833 dispatches ($19.05) and `gemini-3.8-flash` 666 ($13.19) at 10× glm's rate.
116
+ Whether that was a guardrail edit or an upstream blip, the router accepted a
117
+ 352→8 shrink with no warning (`doRefresh` only rejects an *empty* payload).
118
+
119
+ **Fix (code):** log at `warn` and expose on `/health` when a refresh shrinks
120
+ the catalog by more than ~50%; optionally keep the previous snapshot for one
121
+ refresh interval before adopting the shrink, so a transient blip does not
122
+ reroute a whole day.
123
+
124
+ ### 6. Client abort after the generation finished is recorded as an error
125
+
126
+ 2,068 rows (12%) carry `error = "request aborted"`, and 1,842 of them have a
127
+ `finish_reason` and full usage — the upstream generation completed and the
128
+ client closed before `[DONE]` was read. On those turns `runTurn` returns from
129
+ `onUpstreamError` without saving state: `turn` is not incremented (the next row
130
+ reuses the same turn number in 1,087 of 1,107 cases), `currentSlug`,
131
+ `cacheWarmSlug`, `lastPromptTokens` and `compactionPlan` are not updated, the
132
+ agentdox transcript is skipped, and the row is excluded from latency stats.
133
+ The rate tracks the omp binary: 16–19%/day before the 2026-09-02 omp update,
134
+ 2–7% since — so the client-side cause is mostly gone, but the router should
135
+ not depend on it.
136
+
137
+ **Fix (code, `turn.ts`):** in the stream `catch`, an abort with
138
+ `finishReason !== null` is a completed generation: fall through to the normal
139
+ commit path (the dead sink no-ops), record `error: null`, save state.
140
+
141
+ ### 7. Compaction re-plans every turn because the budget is unreachable
142
+
143
+ `overBudget = compactedTokens > budgetTokens` is always true: post-compaction
144
+ prompts are 100–160k against a 40k budget, so `floorRatio` never rations and
145
+ a new edit is added the moment a tool result ages past `protectRecentTurns`.
146
+ 7d, same-model turns: plan changed 1,031× at 79.5% cache hit and $0.0120/turn
147
+ vs 92.6% and $0.0067 when the plan held. That churn is worth ~$3–5/week.
148
+
149
+ **Fix (code):** ration new edits — only extend the plan when the compacted
150
+ prompt has grown ≥ X% (say 10%) since the last re-plan, or every K turns;
151
+ carried edits still apply verbatim in between.
152
+
153
+ ### 8. Token calibration pairs the wrong bytes with the wrong tokens
154
+
155
+ `estimatePromptTokens` records *pre-compaction* bytes; `ledger.record` pairs
156
+ them with *post-compaction, post-context-block* billed tokens. With ~43k tokens
157
+ compacted per turn the learned ratio absorbs compaction: `actual / rawEstimate`
158
+ ≈ 1.10 but `actual / compactedEstimate` ≈ 1.50 — the size selection actually
159
+ uses is 33% low (forecasts, `minContext`, price tiers, switch arithmetic).
160
+
161
+ Separately, `nex-agi/nex-n2-mini` reports prompt tokens **8.4×** the estimate
162
+ (same bytes glm bills at 1.1×) and has poisoned the `qwen3` family to
163
+ 1.61 bytes/token; 27 catalog models carry that tokenizer and will be
164
+ over-estimated ~2×.
165
+
166
+ **Fix (code):** calibrate on dispatched bytes (`promptBytes − savedBytes +
167
+ contextBlock.length`), and reject calibration samples whose `actual/estimate`
168
+ is outside ~[0.4, 2.5].
169
+
170
+ ### 9. Hold-length experiment is diluted; exploration verdicts are blind
171
+
172
+ - `breakHoldOnMechanical` (on since Aug 30) breaks the hold on 96% of turns:
173
+ 7d 492 holds broken vs 233 held; sticky rows in the 4 turns after an
174
+ escalation total $0.97. Per-turn cost by arm (2/3/4) is 0.0103/0.0110/0.0086
175
+ with wildly different conversation lengths — no separable signal. Close it.
176
+ - Exploration's "cheaper tier sufficed" verdict is the probe, which rejects
177
+ ~1% of turns at every tier. Explored turns: hard→moderate 2/160 rejected,
178
+ moderate→simple 3/855 — indistinguishable from baseline. The probe only sees
179
+ structural failure, so exploration cannot learn quality. (It did save money:
180
+ 160 hard→moderate turns cost $1.26 against ~$24 at hard.) Either stop it, or
181
+ replace the verdict with an outcome proxy — loop length after the turn, or
182
+ whether the next tool call succeeded.
183
+
184
+ ### 10. Smaller classifier and scoring items
185
+
186
+ - **Stale-image weight.** `W_IMAGES` fires on `hasImages` (any image in
187
+ history); 5,603 of 7d heuristic rows carry one. 339 rows ($3.61) sit one tier
188
+ higher only because of that +0.04. Switch to `hasNewImage`, the same
189
+ principle `classifyTask` already applies.
190
+ - **Latency scoring is inert.** The reference wait uses 1,024 expected
191
+ completion tokens (34s at 30 tok/s) while actual completions average ~200;
192
+ glm's 9.1s mean TTFT and 152 turns >30s TTFT in 7d never earn a penalty, and
193
+ `maxExpectedWaitMs: 160000` never fires. Use the ledger's measured mean
194
+ completion tokens as the expected completion.
195
+ - **Reasoning weight** `medium: 0.07` rode on 662 dispatches ($5.80) in 7d;
196
+ still the single largest constant offset after the loop-depth ramp.
197
+
198
+ ## Code defects (not ledger-visible)
199
+
200
+ | Where | Defect | Effect |
201
+ | --- | --- | --- |
202
+ | `src/server/http.ts` `handleChatCompletions` | `acquireTurn()` succeeds, then a JSON/parse failure returns before `runTurn`, so `releaseTurn` never runs | after 24 malformed bodies the router answers 429 forever until restart |
203
+ | `src/router/escalate.ts` + `turn.ts` | `maxHoldMs` is checked only inside `observe()`, i.e. when a chunk arrives; keep-alive comments are dropped by `parseSse` | a stream that emits nothing holds the client until the upstream gives up: 47 `empty_completion` rows waited 9–150s (gemini-3.8-flash 14×, avg 40s). **Deliberately left alone**: enforcing the 8s ceiling on a timer would escalate every glm-5.3-flash turn whose first token arrives after 8s — its mean TTFT is 9.1s — so the accidental leniency is protective. A timer-driven check only makes sense with `maxHoldMs` raised to ~2× typical TTFT (30s). |
204
+ | `src/server/turn.ts` | probe escalation does not exclude the failing slug (finding 3) | 69% of escalations retry the same model |
205
+ | `src/server/turn.ts` | abort-after-finish path skips state save (finding 6) | stale hysteresis/cache/compaction state on 2–19% of turns |
206
+ | `src/router/tier-plan.ts` | `min(configured, adaptive)` with no thinness test (finding 1) | floors relaxed on a wide catalog |
207
+ | `src/tokens/estimate.ts` + `src/cost/ledger.ts` | calibration bytes/tokens mismatch, no outlier rejection (finding 8) | biased estimates, poisoned tokenizer family |
208
+ | `src/router/candidates.ts` | trust divisor (finding 2) | flaky cheap models never lose |
209
+ | `src/router/tier-plan.ts` `tierPlanFor` | memo keyed on the config object, which hot reload mutates in place | a `filters.includeFree` edit is ignored until the next catalog refresh (≤5 min); cosmetic |
210
+
211
+ ## Verified fine
212
+
213
+ - Cache-breakpoint placement, compaction carry-forward and validation, cost
214
+ breakdown arithmetic, unattributable-error trust exclusion, hot reload, the
215
+ per-slug newest-first index. Same-model cache hit is 91–92% inside the 5-min
216
+ warm window and 44% beyond it; the `cacheWarmTtlMs` model matches reality.
217
+ - Per-turn routing cost: `buildCandidates` runs in 20–28ms per tier on the
218
+ 347-model catalog including trust/latency queries (31ms for 31 slugs). Not a
219
+ bottleneck; the `trustWindowDays` note about growth still applies at 75k rows.
220
+ - The LLM adjudicator remains dead (0 `llm` rows); `ambiguityThreshold: 0` is
221
+ correct.
222
+ - `empty_completion` escalations are genuine empty streams, not the 8s hold
223
+ ceiling firing early (latency 2–150s, `ttft` null, `finish` stop/null).
224
+
225
+ ## Suggested order
226
+
227
+ 1. Config now (hot-reloads, routing-neutral for the narrowed-catalog case):
228
+ `adaptiveTierFloors: false`; restart all omp windows for v0.2.34.
229
+ 2. Code: findings 3 + 6 + the `http.ts` slot leak (small, low risk).
230
+ 3. Code: finding 1 (thinness-gated relaxation) so adaptive floors can go back on.
231
+ 4. Code: findings 2 and 4 (escalation-aware effective cost, horizon-amortised
232
+ switch). Validate each with `tools/replay.ts` on the post-Sep-5 population.
233
+ 5. Code: findings 7 and 8; catalog shrink warning (5).
234
+ 6. Close the hold-length experiment; decide what exploration should measure.
235
+
236
+ ## Applied (2026-09-05, follow-up session)
237
+
238
+ Typecheck clean, 539 tests pass. Schema is now **v16**. Everything below is in
239
+ the working tree (and, for `turn.ts`/`http.ts`, already in commit `332bfbe`,
240
+ which another session swept up while committing its own batched-signals
241
+ change — the message on that commit does not mention them).
242
+
243
+ | # | Finding | What shipped | Live effect |
244
+ | --- | --- | --- | --- |
245
+ | 1 | Adaptive floors relaxed on the wide catalog | `tier-plan.ts`: a configured floor stands whenever ≥3 rankable models meet it (`MIN_FLOOR_ADMITS`); only a thin tier relaxes to the band | config `adaptiveTierFloors: false` (hot-reloaded, in effect now); delete the key once every omp window runs this build |
246
+ | 2 | Trust divisor misprices failure | `filters.escalationCostWeight` (default 0) + `Ledger.escalationCost()`: a model's measured escalation rate × the ledger's measured $/prompt-token of escalated retries | **left off.** Replay on 5,000 Sep 2–5 dispatches at weight 1: 586 decisions move off ling-3.0-flash onto solar-pro4/mistral-nemo, direct cost +1.3%; the avoided-escalation benefit is not something replay can model. With the floors fixed the expensive case (ling at moderate → opus) no longer arises, so the term is a hedge, not a fix. Enable if ling-class models keep winning `simple`. |
247
+ | 3 | Escalation re-dispatches the same slug | `turn.ts`: the failing slug joins `excludeSlugs` on every probe escalation; `empty_completion`/`refusal`/`upstream_error` try a same-tier sibling (bounded by `MAX_SAME_TIER_FAILOVERS`) before stepping up | on restart |
248
+ | 4 | Cross-tier switch / myopic stay-switch | `hysteresis.switchHorizonTurns` (default 1): `H × stayWarm` vs `switchCold + (H−1) × newWarm` | replay at 8: **0 of 5,000 decisions change** while glm is in the catalog (the one-turn rule already switches away from a dear warm model when the winner is cheap cold). It only bites when the winner is itself dear cold — the Sep 3 kimi case. Add `switchHorizonTurns: 4` to config **after** the restart (the old schema rejects unknown keys); a commented block is in place. |
249
+ | 5 | Catalog collapse was silent | `openrouter-catalog.ts`: a refresh keeping <50% of ≥20 models logs at `warn` and is exposed as `catalog.shrink` on `/health`; cleared on recovery | on restart |
250
+ | 6 | Abort-after-finish recorded as error | `turn.ts`: an abort with `finishReason` set is a settled generation — commit path, state saved, `error: null` | on restart |
251
+ | 7 | Compaction re-plans every turn | `compaction.replanGrowthRatio` (default 1) + `conversations.compaction_plan_tokens` (v16): extend a plan only once the compacted prompt has grown by the ratio; fit-to-window never rationed | add `replanGrowthRatio: 1.1` **after** the restart; commented block in place |
252
+ | 8 | Calibration bias / poisoned qwen3 | `adjustPendingEstimate()` pairs billed tokens with the dispatched bytes (post-compaction, plus context block); samples outside 1.5–8 bytes/token are rejected; **v16 deletes every `token_calibration` row** so families relearn on correctly paired data | on restart (first open runs the migration) |
253
+ | 9 | Hold-length experiment diluted | config `exploration.holdTurns.enabled: false` | hot-reloaded |
254
+ | 10 | Stale-image weight | `classify.ts`: `W_IMAGES` fires on `hasNewImage` | on restart |
255
+ | defect | `http.ts` slot leak | released on parse failure; regression test | on restart |
256
+ | reviewer | unreachable "user-visible failure" branch | `features.ts` scans the tool run behind the newest user turn, so the full failed-tool weight is reachable; damping helper deduplicated; stale header comments and README row fixed | on restart |
257
+
258
+ Not done, by choice: the timer-driven `maxHoldMs` (see the defects table — it
259
+ would mis-escalate glm), and the exploration verdict redesign (a product
260
+ decision: what should "the cheaper tier sufficed" measure?).
261
+
262
+ **Deploy order.** Restart every omp window (two `omp.exe` processes started
263
+ 2026-09-03T00:27–00:29Z are still on v0.2.33 code). Then add the two
264
+ commented config keys and delete `adaptiveTierFloors: false`. Watch the first
265
+ ledger rows for `[re-plan rationed]`, `failover: … empty_completion`, and
266
+ `compaction_plan_tokens` populating; `token_calibration` will be empty until
267
+ ~20 samples per family have landed.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure configuration-driving logic for the `/router configure` slash command.
2
+ * Pure configuration-driving logic for the `/router config` slash command.
3
3
  *
4
4
  * Reuses the router's existing wizard field definitions and validation
5
5
  * (`src/cli/config-wizard.ts`) so the in-omp UI edits exactly the same set of
@@ -10,14 +10,17 @@
10
10
  */
11
11
 
12
12
  import type { FieldSpec, SectionSpec } from "../src/cli/config-wizard.ts";
13
- import { CLEAR_TOKEN, formatValue, validateField } from "../src/cli/config-wizard.ts";
13
+ import { CLEAR_TOKEN, displayValue, validateField } from "../src/cli/config-wizard.ts";
14
14
  import type { RouterConfig } from "../src/config/types.ts";
15
15
 
16
+ /** A selector entry: omp renders the description dimmed beside the label and returns the label. */
17
+ export type SelectOption = string | { label: string; description?: string };
18
+
16
19
  export interface ConfigUi {
17
20
  /** Show a selector, return the chosen option label, or undefined on cancel. */
18
- select(title: string, options: string[], selected?: number): Promise<string | undefined>;
21
+ select(title: string, options: SelectOption[]): Promise<string | undefined>;
19
22
  /** Show a text input with a placeholder, or undefined on cancel. */
20
- input(title: string, placeholder?: string, initial?: string): Promise<string | undefined>;
23
+ input(title: string, placeholder?: string): Promise<string | undefined>;
21
24
  /** Yes/no confirmation. */
22
25
  confirm(title: string, message: string): Promise<boolean>;
23
26
  /** Surface a status/result line. */
@@ -25,36 +28,50 @@ export interface ConfigUi {
25
28
  }
26
29
 
27
30
 
31
+ /** `Listen port (ms) · current: 8788` — every dialog names the value it would replace. */
32
+ function promptTitle(field: FieldSpec, current: unknown): string {
33
+ const hint = field.hint !== undefined ? ` (${field.hint})` : "";
34
+ return `${field.label}${hint} · current: ${displayValue(field, current)}`;
35
+ }
36
+
28
37
  /**
29
38
  * Prompts for one field, returning the parsed value or null when the user kept
30
- * the current value. An empty answer keeps the current; CLEAR_TOKEN clears an
31
- * optional field. Returns `undefined` when the user cancelled the dialog.
39
+ * the current value. The current value is shown in the dialog title, marked
40
+ * in select pickers, and used as the input placeholder; an empty answer keeps
41
+ * it and CLEAR_TOKEN clears an optional field. Returns `undefined` when the
42
+ * user cancelled the dialog.
32
43
  */
33
44
  export async function promptField(
34
45
  ui: ConfigUi,
35
46
  field: FieldSpec,
36
47
  current: unknown,
37
48
  ): Promise<{ value: unknown; changed: boolean } | undefined> {
38
- const label = field.hint !== undefined ? `${field.label} (${field.hint})` : field.label;
49
+ const title = promptTitle(field, current);
50
+ const mark = (label: string, isCurrent: boolean): SelectOption => (isCurrent ? { label, description: "current" } : label);
39
51
 
40
52
  if (field.kind === "boolean") {
41
- const chosen = await ui.select(label, ["true", "false"], current === true ? 0 : 1);
53
+ // An optional boolean can also be cleared back to "unset" (its default).
54
+ const unset = current === undefined || current === null;
55
+ const options: SelectOption[] = [mark("true", current === true), mark("false", current === false)];
56
+ if (field.optional === true) options.push(mark("unset", unset));
57
+ const chosen = await ui.select(title, options);
42
58
  if (chosen === undefined) return undefined;
59
+ if (chosen === "unset") return { value: null, changed: !unset };
43
60
  const value = chosen === "true";
44
61
  return { value, changed: value !== current };
45
62
  }
46
63
 
47
64
  if (field.kind === "enum") {
48
- const options = field.options ?? [];
49
- const idx = options.indexOf(String(current));
50
- const chosen = await ui.select(label, [...options], idx >= 0 ? idx : 0);
65
+ const options = (field.options ?? []).map((o) => mark(o, o === current));
66
+ const chosen = await ui.select(title, options);
51
67
  if (chosen === undefined) return undefined;
52
68
  return { value: chosen, changed: chosen !== current };
53
69
  }
54
70
 
55
- // string | number | stringArray: free-text input.
56
- const placeholder = formatValue(current);
57
- const answer = await ui.input(label, placeholder, "");
71
+ // string | number | stringArray | numberArray: free-text input. Secrets
72
+ // show set/unset rather than the value.
73
+ const placeholder = `${displayValue(field, current)} (Enter keeps${field.optional === true ? `, ${CLEAR_TOKEN} clears` : ""})`;
74
+ const answer = await ui.input(title, placeholder);
58
75
  if (answer === undefined) return undefined;
59
76
  if (answer.trim() === "") return { value: current, changed: false }; // keep
60
77
  if (answer.trim() === CLEAR_TOKEN) {
@@ -67,7 +84,46 @@ export async function promptField(
67
84
  ui.notify(`invalid: ${result.error}`, "warn");
68
85
  return { value: current, changed: false };
69
86
  }
70
- return { value: result.value, changed: result.value !== current };
87
+ // Arrays are re-parsed from text every time, so compare by content or an
88
+ // unchanged list would register as an edit.
89
+ const changed = Array.isArray(result.value) && Array.isArray(current)
90
+ ? JSON.stringify(result.value) !== JSON.stringify(current)
91
+ : result.value !== current;
92
+ return { value: result.value, changed };
93
+ }
94
+
95
+ /**
96
+ * Section editor for the omp command: a picker listing every field of the
97
+ * section with its current value (or the pending edit, marked), so the user
98
+ * sees the settings before choosing which one to change. Picking a field
99
+ * prompts for it; "Back" returns. Returns true if any field changed.
100
+ */
101
+ export async function editSectionMenu(
102
+ ui: ConfigUi,
103
+ section: SectionSpec,
104
+ cfg: RouterConfig,
105
+ answers: Record<string, unknown>,
106
+ ): Promise<boolean> {
107
+ let any = false;
108
+ for (;;) {
109
+ const options: SelectOption[] = section.fields.map((field) => {
110
+ const pending = field.path in answers;
111
+ const current = pending ? answers[field.path] : getPathValue(cfg, field.path);
112
+ return { label: field.label, description: `${displayValue(field, current)}${pending ? " (pending)" : ""}` };
113
+ });
114
+ options.push("Back");
115
+ const chosen = await ui.select(`${section.title}${any ? " (edited)" : ""}`, options);
116
+ if (chosen === undefined || chosen === "Back") return any;
117
+ const field = section.fields.find((f) => f.label === chosen);
118
+ if (field === undefined) continue;
119
+ const current = field.path in answers ? answers[field.path] : getPathValue(cfg, field.path);
120
+ const result = await promptField(ui, field, current);
121
+ if (result === undefined) continue; // cancelled the field dialog: back to the picker
122
+ if (result.changed) {
123
+ answers[field.path] = result.value;
124
+ any = true;
125
+ }
126
+ }
71
127
  }
72
128
 
73
129
  /**
@@ -43,12 +43,62 @@ declare module "@oh-my-pi/pi-coding-agent" {
43
43
  getSessionId(): string;
44
44
  }
45
45
 
46
+ /** A TUI component: rows at a width, optional key handling and teardown. */
47
+ export interface Component {
48
+ render(width: number): readonly string[];
49
+ handleInput?(data: string): void;
50
+ invalidate?(): void;
51
+ dispose?(): void;
52
+ }
53
+
54
+ /** The subset of omp's theme the report hub paints with. */
55
+ export interface Theme {
56
+ fg(color: string, text: string): string;
57
+ bg(color: string, text: string): string;
58
+ bold(text: string): string;
59
+ boxRound: {
60
+ topLeft: string;
61
+ topRight: string;
62
+ bottomLeft: string;
63
+ bottomRight: string;
64
+ horizontal: string;
65
+ vertical: string;
66
+ teeDown: string;
67
+ teeUp: string;
68
+ teeLeft: string;
69
+ teeRight: string;
70
+ };
71
+ nav: { cursor: string };
72
+ }
73
+
74
+ export interface TUI {
75
+ terminal?: { rows: number; columns: number };
76
+ requestRender(): void;
77
+ }
78
+
79
+ export interface KeybindingsManager {
80
+ matches(data: string, keybinding: string): boolean;
81
+ }
82
+
83
+ export interface OverlayOptions {
84
+ width?: number | string;
85
+ maxHeight?: number | string;
86
+ anchor?: string;
87
+ fullscreen?: boolean;
88
+ }
89
+
46
90
  /** Mirrors `ConfigUi` in configure-logic.ts, which is what /router drives. */
47
91
  export interface ExtensionUI {
48
- select(title: string, options: string[], selected?: number): Promise<string | undefined>;
49
- input(title: string, placeholder?: string, initial?: string): Promise<string | undefined>;
92
+ /** Returns the chosen label; an option may carry a dimmed description. */
93
+ select(title: string, options: Array<string | { label: string; description?: string }>): Promise<string | undefined>;
94
+ input(title: string, placeholder?: string): Promise<string | undefined>;
50
95
  confirm(title: string, message: string): Promise<boolean>;
51
96
  notify(text: string, level?: "info" | "warn" | "error"): void;
97
+ /** Show a custom component with keyboard focus; `overlay: true` floats it over the transcript. */
98
+ custom<T>(
99
+ factory: (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: T) => void) => Component,
100
+ options?: { overlay?: boolean; overlayOptions?: OverlayOptions },
101
+ ): Promise<T>;
52
102
  }
53
103
 
54
104
  export interface ExtensionContext {
@@ -66,11 +116,38 @@ declare module "@oh-my-pi/pi-coding-agent" {
66
116
  handler(args: string, ctx: ExtensionContext): void | Promise<void>;
67
117
  }
68
118
 
119
+ /**
120
+ * A custom transcript message. `display: true` renders it in the TUI;
121
+ * `content` is markdown. (Real type: `CustomMessagePayload<T>`.)
122
+ */
123
+ export interface CustomMessagePayload {
124
+ customType?: string;
125
+ content?: string;
126
+ display?: boolean;
127
+ details?: unknown;
128
+ }
129
+
69
130
  export interface ExtensionAPI {
70
131
  setLabel(label: string): void;
71
132
  on(event: string, handler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>): void;
72
133
  registerProvider(id: string, registration: ProviderRegistration): void;
73
134
  unregisterProvider(id: string): void;
74
135
  registerCommand(name: string, command: CommandDefinition): void;
136
+ /** Appends a custom message to the session; `triggerTurn: false` leaves the agent idle. */
137
+ sendMessage(message: CustomMessagePayload | string, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): void;
75
138
  }
76
139
  }
140
+
141
+ /**
142
+ * LOCAL stub for omp's TUI toolkit, which omp resolves for extensions at load
143
+ * time (its bundled example extensions import it the same way). Only the
144
+ * helpers the report hub uses are declared.
145
+ */
146
+ declare module "@oh-my-pi/pi-tui" {
147
+ /** Columns a string occupies on screen, ignoring ANSI styling. */
148
+ export function visibleWidth(text: string): number;
149
+ /** Cuts a (possibly styled) string to at most `width` columns. */
150
+ export function truncateToWidth(text: string, width: number, ellipsis?: string): string;
151
+ /** Matches raw terminal input against a key id such as `"left"` or `"ctrl+c"`. */
152
+ export function matchesKey(data: string, keyId: string): boolean;
153
+ }