auto-model-router 0.2.2 → 0.2.8

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 (51) hide show
  1. package/.claude/skills/agentdox/SKILL.md +143 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/marketplace.json +2 -2
  4. package/CLAUDE.md +129 -0
  5. package/README.md +64 -0
  6. package/docs/AGENTDOX-BRIDGE.md +132 -0
  7. package/docs/context-optimization.md +362 -0
  8. package/omp-extension/embed-logic.ts +31 -0
  9. package/omp-extension/router-embed.ts +7 -1
  10. package/package.json +1 -1
  11. package/src/cli/config-cmd.ts +20 -5
  12. package/src/cli/explain.ts +1 -0
  13. package/src/config/defaults.ts +33 -1
  14. package/src/config/load.ts +13 -0
  15. package/src/config/schema.ts +27 -0
  16. package/src/config/types.ts +79 -5
  17. package/src/context/agentdox.ts +113 -0
  18. package/src/context/bridge.ts +166 -0
  19. package/src/context/index.ts +33 -0
  20. package/src/context/store.ts +82 -0
  21. package/src/context/types.ts +78 -0
  22. package/src/cost/ledger.ts +53 -14
  23. package/src/cost/types.ts +15 -4
  24. package/src/router/candidates.ts +19 -8
  25. package/src/router/classify.ts +26 -12
  26. package/src/router/compaction.ts +163 -0
  27. package/src/router/features.ts +26 -13
  28. package/src/router/select.ts +37 -4
  29. package/src/router/state.ts +12 -2
  30. package/src/router/types.ts +33 -1
  31. package/src/server/http.ts +18 -1
  32. package/src/server/turn.ts +88 -1
  33. package/src/upstream/openrouter.ts +8 -1
  34. package/src/util/sqlite.ts +34 -1
  35. package/src/wire/openai/request.ts +86 -1
  36. package/src/wire/types.ts +36 -0
  37. package/test/classify.test.ts +63 -5
  38. package/test/compaction.test.ts +148 -0
  39. package/test/context-bridge.test.ts +337 -0
  40. package/test/embed-logic.test.ts +32 -0
  41. package/test/escalate.test.ts +1 -0
  42. package/test/exploration.test.ts +6 -2
  43. package/test/failover.test.ts +51 -6
  44. package/test/features.test.ts +45 -0
  45. package/test/helpers/inject.ts +23 -0
  46. package/test/hold-exploration.test.ts +4 -2
  47. package/test/select.test.ts +86 -3
  48. package/test/tokens.test.ts +1 -0
  49. package/test/trust-attribution.test.ts +49 -9
  50. package/test/turn.test.ts +20 -10
  51. package/tools/agentdox-e2e.ts +123 -0
@@ -0,0 +1,362 @@
1
+ # Context optimization (compaction)
2
+
3
+ Status: **design, not implemented.** Decisions locked 2026-08-28. Phase 1 is the
4
+ build target; Phase 2 is documented so the schema and pipeline reserve room for it.
5
+
6
+ ## Goal
7
+
8
+ Before dispatching a turn to the chosen model, the router transforms the outgoing
9
+ message history to remove **stale, low-value bulk** — chiefly old tool output —
10
+ so that:
11
+
12
+ 1. long agentic conversations keep **fitting the candidate pool** (a smaller
13
+ prompt stops `candidates.ts` from rejecting narrower-window models with
14
+ `context_too_small`, which today silently shrinks the pool as a conversation
15
+ grows), and
16
+ 2. per-turn **spend drops** on the 100k-token loops we actually observe (glm-5.3-flash
17
+ routinely serves turns at 67k–105k prompt tokens; the bulk is re-sent every turn).
18
+
19
+ A smaller haystack is also a mild mitigation for the weak-model loop failure
20
+ (fewer tokens for a cheap model to lose the thread in), but that is a side effect,
21
+ not the objective.
22
+
23
+ ## Non-goals
24
+
25
+ - Not a replacement for tier escalation on runaway loops (tracked separately).
26
+ - Not summarizing or dropping the user's actual current intent or recent turns.
27
+ - Not a lossy digest of the whole conversation (see Phase 2 for bounded, pinned
28
+ summarization; rolling whole-history digests are explicitly rejected — a coding
29
+ agent lives on exact tool state).
30
+
31
+ ## Motivation (observed)
32
+
33
+ From the live ledger (post-restart window):
34
+
35
+ - Prompts of 67k–105k tokens are normal on deep loops; the dominant mass is tool
36
+ results (file reads, `bash`/grep dumps) that the model already consumed turns ago.
37
+ - Loops reach tool-loop depth 200+ on cheap tiers, re-sending the full transcript
38
+ each turn.
39
+ - `context_too_small` rejections remove otherwise-good models from long
40
+ conversations, forcing escalation or pool exhaustion on model switches.
41
+
42
+ ## Constraints and invariants (from the codebase)
43
+
44
+ These are hard; the design is shaped by them.
45
+
46
+ - **Single hook.** `renderUpstreamBody` (`src/wire/openai/request.ts`) owns the exact
47
+ bytes sent upstream via `structuredClone(original)`. `NormMessage`
48
+ (`src/wire/types.ts`) is explicitly a *lossy* view "never dispatched." Compaction
49
+ therefore **decides** using `NormMessage` per-index metadata (`role`, `textBytes`,
50
+ `images`, `toolCalls`, `toolCallId`, `toolName`) and **applies** edits to the raw
51
+ original messages by index.
52
+ - **Tool-call pairing is load-bearing.** Every `role:"tool"` message must keep its
53
+ matching `assistant` `tool_call` id, or OpenRouter rejects the request (400).
54
+ Compaction may shrink a tool **result's content**, but must never remove a tool
55
+ result without its call, nor an assistant tool_call without its result, nor
56
+ reorder the pair.
57
+ - **Never touch:** system/developer messages (they carry the system prompt and the
58
+ agentdox-injected block), the **volatile tail** (newest user-authored run, or the
59
+ trailing tool-result run of the current loop — the same window
60
+ `features.ts`/`cache-control.ts` treat as fresh), and image parts (they are
61
+ capability-relevant; see Edge cases).
62
+ - **Determinism.** `auto-model-router explain` replays a past decision offline.
63
+ Phase 1 is a pure function of `(messages, target budget, model window)` →
64
+ replayable. Phase 2 breaks this unless the summary is **pinned and persisted**
65
+ (see Phase 2), exactly as the agentdox bridge already does.
66
+ - **Prompt cache.** Editing early messages busts the OpenRouter prompt-cache prefix.
67
+ The governing law is already stated for the agentdox bridge
68
+ (`src/context/bridge.ts`): *the same bytes are re-injected verbatim and the cache
69
+ survives; a refresh rides on a cache miss that was happening anyway.* Compaction
70
+ obeys it — Phase 1 rules are **stable** (identical input → identical elision each
71
+ turn), so a message elided last turn is elided byte-identically this turn and the
72
+ prefix does not churn; Phase 2 summaries are pinned per conversation and refreshed
73
+ only when the cache is already cold.
74
+
75
+ ## Precedent to reuse
76
+
77
+ The agentdox bridge (`src/context/`) already manipulates context under exactly these
78
+ constraints: content-addressed blocks in `context_blocks`, a per-conversation
79
+ version in `conversations.context_version`, cache-cold-only refresh, and graceful
80
+ degradation to a no-op when unavailable. Phase 2 mirrors this machinery.
81
+
82
+ ## Pipeline placement
83
+
84
+ Compaction follows the codebase's existing division of labour: **the core computes a
85
+ plan; the wire applies bytes.** `cacheBreakpointMessageIndices` and the agentdox
86
+ `contextBlock` are already computed during routing / in `turn.ts` and applied inside
87
+ `renderUpstreamBody`. Compaction is the same shape: a **`compactionPlan`** on the
88
+ `Decision` (which tool-result indices to shrink, and to what head/tail), computed
89
+ during routing, applied in `renderUpstreamBody`.
90
+
91
+ ```
92
+ request → classify (on ORIGINAL turn)
93
+ → compute compaction FLOOR (model-agnostic safe elision when over budget)
94
+ recomputes compactedPromptTokens
95
+ → candidate selection + forecast ← use compactedPromptTokens
96
+ context_too_small filter uses the FLOOR, so a narrow-window model is
97
+ not rejected when safe compaction would make it fit
98
+ → chosen model known → finalize compactionPlan (fit trigger may extend it)
99
+ → renderUpstreamBody:
100
+ clone → APPLY compactionPlan (shrink tool-result content)
101
+ → stripAssistantReasoning
102
+ → injectContextBlock (agentdox, appends to last system msg)
103
+ → apply cache_control at breakpoint indices
104
+ → dispatch
105
+ ```
106
+
107
+ - **Classification runs on the original turn.** Compacting old tool output must not
108
+ change the tier: the human's intent and the loop/continuation structure are
109
+ unchanged. `promptTokens` shrinks, but the tier scorer keys on the tail
110
+ (`isToolResultContinuation`, `toolLoopDepth`, `newContentTokens`), so the effect
111
+ is negligible and intentionally not fed back into scoring.
112
+ - **Selection and forecast use `compactedPromptTokens`** so spend predictions and the
113
+ `context_too_small` filter reflect what is really sent — restoring narrower-window
114
+ models that fit *after* safe compaction. A model rejected against the floor is
115
+ genuinely too small (even max-safe compaction cannot fit it) and is correctly dropped.
116
+ - **Breakpoint indices are NOT recomputed for Phase 1.** They are computed in
117
+ `select()` on the original array and adjusted by `injectContextBlock`. Because
118
+ Phase 1 shrinks *content only* (never adds or removes a message), every index stays
119
+ valid — the same reason `injectContextBlock` appends instead of inserts. Phase 2,
120
+ which changes message count, MUST return adjusted indices (see Phase 2).
121
+
122
+ ## Trigger (locked: fit + cost budget)
123
+
124
+ Two conditions arm compaction; both compact to the same safe floor, they differ only
125
+ in *whether* to bother:
126
+
127
+ - **Budget:** `estimateTokens(promptBytes + injectedBlockBytes) > compaction.budgetTokens`.
128
+ The injected agentdox block counts toward the budget (it is resolved before render
129
+ and bounded by `context.maxBlockChars`).
130
+ - **Fit:** the estimate exceeds `model.contextLength × filters.contextHeadroom` (minus
131
+ expected completion) for a model under consideration — so the `context_too_small`
132
+ filter tests each model against the compacted floor rather than the raw size.
133
+
134
+ Requests below `budgetTokens` and within every viable window are dispatched untouched —
135
+ the common small-prompt path allocates nothing.
136
+
137
+ ## Interaction with the agentdox bridge
138
+
139
+ The router already has a shipped context subsystem (`src/context/`, see
140
+ `docs/AGENTDOX-BRIDGE.md`). Compaction must cooperate with it, not fight it.
141
+
142
+ - **Complementary regions.** agentdox *adds* a pinned project-context block to the
143
+ system prefix (`injectContextBlock` appends to the last system message); compaction
144
+ *shrinks* stale tool-result content in the history. Different regions — they compose.
145
+ - **Same cache law, one cold turn.** `bridge.ts shouldRefresh` fires on
146
+ `pin === null || modelSwitching || retrying || staleness`. Phase 2's summary refresh
147
+ MUST key off the same signals (already carried on `ContextResolveInput`) so the block
148
+ refresh and the summary refresh land on the *same* cache miss — "the refresh rides a
149
+ miss that was happening anyway," once, for both mutations.
150
+ - **Never touch the injected block.** It lives in a system message, which the safety
151
+ boundary already excludes. Reaffirmed.
152
+ - **Budget includes the block.** See Trigger.
153
+ - **Apply order in `renderUpstreamBody`:** clone → apply `compactionPlan` → strip
154
+ reasoning → `injectContextBlock` → cache_control. Phase 1 preserves message
155
+ count/order, so `injectContextBlock`'s breakpoint bookkeeping is unaffected.
156
+ - **Write-back is orthogonal.** agentdox records user+assistant prose, not tool
157
+ results, so compacting tool output does not degrade the recorded transcript. (The
158
+ bridge's open write-back bug, AGENTDOX-BRIDGE §5, is unrelated.)
159
+ - **Phase 3 synergy (flag only, not committed):** agentdox durably stores project
160
+ memory/history, so elided tool content could eventually be recoverable from project
161
+ memory rather than solely by re-running the tool.
162
+
163
+ ## Phase 1 — deterministic pruning (build target)
164
+
165
+ A pure function `compact(messages, target, protectFrom) → { messages, saved, notes }`.
166
+
167
+ ### Safety boundary
168
+
169
+ - `protectRecentTurns` (default 4): the last N user/assistant turns and the entire
170
+ volatile tail are never modified.
171
+ - System/developer messages are never modified.
172
+ - A message is never **removed**; only a tool **result's content** is shortened.
173
+ Structure (roles, ordering, tool_call↔result ids) is invariant.
174
+
175
+ ### Rules, applied cheapest-and-safest first, stopping once under `target`
176
+
177
+ 1. **Collapse duplicates** (`collapseDuplicateResults`): byte-identical tool results
178
+ for the same `toolName` → keep the first occurrence, replace later copies with a
179
+ one-line breadcrumb.
180
+ 2. **Elide superseded reads** (`elideSupersededReads`): when the same resource is
181
+ fetched twice (same `toolName` + same primary argument, e.g. a `path`/`key`
182
+ parsed from the tool_call args JSON — schema-agnostic heuristic), the **older**
183
+ result's content is elided down to a breadcrumb pointing forward. The latest
184
+ fetch is authoritative.
185
+ 3. **Truncate large stale results** (`maxToolResultBytes`): remaining tool results
186
+ outside the protected window whose content exceeds `maxToolResultBytes` are
187
+ reduced to `keepHeadBytes` + breadcrumb + `keepTailBytes`. Applied to the
188
+ **largest/oldest first** until under `target` or exhausted.
189
+ 4. **Age-drop assistant reasoning:** reasoning fields on assistant messages outside
190
+ the protected window are dropped. (Largely redundant with the model-driven
191
+ `stripAssistantReasoning`; matters only for reasoning-replay authors.)
192
+
193
+ ### Recoverability contract (the single most important safety rule)
194
+
195
+ Elisions are **never silent**. Every elision leaves a self-describing, in-band
196
+ breadcrumb in the tool result content, e.g.:
197
+
198
+ ```
199
+ [omp-router: elided 1180 of 1200 lines to save context. Re-run this tool to restore.]
200
+ <first keepHeadBytes…>
201
+
202
+ <last keepTailBytes…>
203
+ ```
204
+
205
+ This converts a hard context loss into a **recoverable** one: if the model actually
206
+ needs the elided content, it re-issues the tool call and the fresh full result
207
+ returns (uncompacted, because it is now in the protected recent window). A rising
208
+ "re-fetch after elision" rate is the tuning signal that compaction is too
209
+ aggressive.
210
+
211
+ ### Idempotence / stability
212
+
213
+ Because omp re-sends the full original history every turn, Phase 1 re-derives the
214
+ same elisions deterministically each turn → the dispatched prefix is byte-identical
215
+ turn-over-turn → the prompt cache survives without pinning.
216
+
217
+ ## Observability
218
+
219
+ - `decision.reasons` gains a line, e.g.
220
+ `compaction: 6 tool results elided, ~38k tokens saved (prompt 104k→66k)`, surfaced
221
+ by `explain` and the toast extension.
222
+ - Ledger migration (mirrors the `v7`/`v8` additive pattern in `src/util/sqlite.ts`):
223
+ add `prompt_tokens_saved INTEGER` (and optionally `messages_elided INTEGER`) so
224
+ savings, re-fetch rate, and any upstream 400s can be measured and tuned.
225
+
226
+ ## Configuration (proposed; off by default)
227
+
228
+ A new top-level `compaction` section (distinct from `cache` and the agentdox
229
+ `context`). Off by default — like every behavior-changing feature here, it is never
230
+ implicit.
231
+
232
+ ```yaml
233
+ compaction:
234
+ enabled: false
235
+ # Trigger
236
+ budgetTokens: 40000 # Stage 1: compact when estimated prompt exceeds this
237
+ fitToWindow: true # Stage 2: also compact to fit chosen model window×headroom
238
+ # Safety boundary
239
+ protectRecentTurns: 4 # never touch the last N user/assistant turns or the tail
240
+ # Rules
241
+ maxToolResultBytes: 4096 # stale tool results larger than this are truncated
242
+ keepHeadBytes: 512
243
+ keepTailBytes: 512
244
+ elideSupersededReads: true
245
+ collapseDuplicateResults: true
246
+ # Phase 2 (documented, not built)
247
+ summarize:
248
+ enabled: false
249
+ model: "" # cheap summarizer slug; empty ⇒ Phase 1 only
250
+ triggerTokens: 80000 # only after Phase-1 rules, still above this
251
+ maxSummaryTokens: 2000
252
+ ```
253
+
254
+ Schema lands in `src/config/schema.ts` (a `z.strictObject`, all fields optional),
255
+ types in `src/config/types.ts`, defaults in `src/config/defaults.ts`, and — per the
256
+ existing pattern — a small set of fields in the config wizard (`src/cli/config-wizard.ts`).
257
+
258
+ ## Testing strategy
259
+
260
+ Unit (`test/compaction.test.ts`):
261
+ - tool_call↔result pairing preserved after every rule;
262
+ - breadcrumb present and content non-empty after truncation;
263
+ - deterministic + idempotent (compact(compact(x)) == compact(x) for stable input);
264
+ - `target` respected (result ≤ budget when achievable) and never over-shrinks the
265
+ protected window;
266
+ - superseded-read detection matches same-resource, spares different resources;
267
+ - images/multimodal parts are never elided.
268
+
269
+ Integration (extend `test/select.test.ts` / a new fixture):
270
+ - a 100k-token fixture compacts under `budgetTokens`;
271
+ - a narrow-window model that was `context_too_small` becomes eligible after Stage 1;
272
+ - `explain` replays byte-identical dispatched messages for the same input.
273
+
274
+ ## Rollout
275
+
276
+ 1. Ship Phase 1 off by default.
277
+ 2. Enable **budget-only** first (`fitToWindow: false`) on the live install; watch
278
+ `prompt_tokens_saved`, re-fetch rate, upstream 400s (pairing regressions), and
279
+ loop/abort rates.
280
+ 3. Enable `fitToWindow` once pairing is proven clean.
281
+ 4. Reassess Phase 2 only if prose-heavy history (not tool output) is still the
282
+ dominant residual cost after Phase 1.
283
+
284
+ ### Testing & deployment gotchas (from AGENTDOX-BRIDGE §3–4, §6)
285
+
286
+ Verifying compaction *through omp* hits the same traps that cost hours on the bridge:
287
+
288
+ - **Headless omp does not bind its own router.** `omp -p` reads
289
+ `$AUTO_MODEL_ROUTER_HOME/embed.port` and routes to whatever router already holds
290
+ that port — possibly stale code. To test *this* build, point `embed.port` at your
291
+ standalone `serve` and restore it after.
292
+ - **Windows port-kill:** `pkill -f "src/index.ts serve"` does not work in Git Bash;
293
+ free the port via `Get-NetTCPConnection -LocalPort <p> -State Listen` →
294
+ `Stop-Process -Force`.
295
+ - **Multiple installed copies exist** (repo, marketplace cache, global npm). This is
296
+ the same reason the deep-loop scorer fix "never landed" live. Before trusting any
297
+ compaction e2e result, confirm which router process is actually serving.
298
+ - Use `AUTO_MODEL_ROUTER_DB=<scratch>.db` so tests never touch the live ledger.
299
+
300
+ ## Phase 2 — pinned, persisted LLM summarization (documented, not built)
301
+
302
+ Phase 1 cannot safely compress genuinely-needed long **prose** (dense reasoning,
303
+ multi-turn design discussion). Phase 2 adds bounded summarization, gated behind
304
+ Phase 1 and behind the same cache/determinism discipline the agentdox bridge uses.
305
+
306
+ ### Mechanism
307
+
308
+ - Runs only when, **after** Phase-1 rules, the estimate still exceeds
309
+ `summarize.triggerTokens`.
310
+ - Summarizes the **oldest** compactable region (from just after the system/injected
311
+ block up to the edge of the protected window) into a single bounded note
312
+ (`maxSummaryTokens`), produced by `summarize.model` (a cheap slug).
313
+ - The note **replaces** that region's messages; tool_call↔result pairs inside the
314
+ region are summarized as facts ("read src/foo.ts (480 lines); edited lines 12–20"),
315
+ never left dangling.
316
+
317
+ ### Determinism and cache — restored by pinning (the agentdox pattern)
318
+
319
+ - The summary is **content-addressed** (hash of the covered region) and **persisted**
320
+ in a new `compaction_summaries` table, with the active version pinned on the
321
+ conversation row (a `compaction_version` column, alongside `context_version`).
322
+ - Subsequent turns re-inject the **same** summary bytes verbatim → cache-stable and
323
+ `explain`-replayable (replay reads the pinned summary from the store; it never
324
+ re-summarizes).
325
+ - The summary is refreshed (recomputed to cover more history) **only when the cache
326
+ is already cold**, reusing the *same* cold-turn signal as `bridge.ts shouldRefresh`
327
+ (`pin === null || modelSwitching || retrying || staleness`) so the agentdox block
328
+ refresh and the summary refresh share one cache miss rather than each causing their own.
329
+ - **Breakpoint indices are adjusted, not stale.** A summary that replaces a message
330
+ range changes message count, so — exactly like `injectContextBlock` does when it
331
+ prepends a system message — Phase 2 returns adjusted `cacheBreakpointMessageIndices`
332
+ (shift the trailing indices by the count delta), or recomputes them on the collapsed
333
+ array. This is the one place Phase-1's "indices stay valid" guarantee does not hold.
334
+
335
+ ### Degradation
336
+
337
+ Summarization is enrichment, not a dependency: if the summarizer errors, times out,
338
+ or is unconfigured, the turn falls back to Phase-1 output and dispatches normally —
339
+ mirroring the agentdox bridge's "degrade to null, never throw" contract.
340
+
341
+ ### Risks and mitigations
342
+
343
+ - **A bad pinned summary persists** and poisons later turns → versioned + hashed so it
344
+ can be invalidated; never covers the protected recent window or tool structure;
345
+ Phase-1 breadcrumbs remain, so the model can still re-fetch elided specifics.
346
+ - **Detail loss induces re-derivation loops** → summaries state actions and outcomes,
347
+ never replace the *latest* authoritative tool results; recent window is untouched.
348
+ - **Added latency/cost** on the compute turn → amortized by pinning + cache-cold-only
349
+ refresh.
350
+
351
+ ## Open questions
352
+
353
+ - Primary-argument extraction for superseded-read detection across arbitrary tool
354
+ schemas (heuristic: first string arg that looks like a path/id; configurable
355
+ per-tool later if needed).
356
+ - Token-estimate accuracy at the budget boundary (uses `estimateTokens` +
357
+ `token_calibration`; acceptable — the budget is a soft threshold, not a hard limit).
358
+ - Protocol coverage: OpenAI wire (`src/wire/openai/`) first; other protocols reuse the
359
+ same `compact()` core behind their own `renderUpstreamBody`.
360
+ - Interaction with `forcedToolChoice` turns (rare; compaction of history is still
361
+ safe, but verify the forced call is in the protected window).
362
+ ```
@@ -52,6 +52,30 @@ export interface EmbedConfig {
52
52
  baseUrl: string;
53
53
  models: EmbedModelSpec[];
54
54
  harnessId?: string;
55
+ /**
56
+ * agentdox project scope sent as `X-Agentdox-Scope`. Selects which
57
+ * project's shared context is injected into every turn, so switching
58
+ * models never loses the project's memory/docs/brief.
59
+ */
60
+ agentdoxScope?: string;
61
+ }
62
+
63
+ /**
64
+ * Derives an agentdox project slug from the omp workspace directory.
65
+ *
66
+ * The workspace basename is the one identifier that is already stable, already
67
+ * per-project, and requires no configuration — the same convention agentdox's
68
+ * own `project_ensure` slugs follow. An explicitly configured
69
+ * `context.defaultScope` always wins over this.
70
+ */
71
+ export function deriveAgentdoxScope(cwd: string): string {
72
+ // Both separators: omp reports a Windows cwd with backslashes.
73
+ const cleaned = cwd.replace(/[\\/]+$/, "");
74
+ const base = cleaned.split(/[\\/]/).pop() ?? "";
75
+ return base
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9]+/g, "-")
78
+ .replace(/^-+|-+$/g, "");
55
79
  }
56
80
 
57
81
  /**
@@ -113,7 +137,10 @@ export function buildProviderConfig(
113
137
  server: { host: string; harnessId?: string };
114
138
  profiles: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>;
115
139
  ledger: { fallbackBlend: { inputPerMtok: number; outputPerMtok: number } };
140
+ context?: { enabled: boolean; defaultScope: string };
116
141
  },
142
+ /** omp's workspace directory, used to derive a scope when none is configured. */
143
+ cwd?: string,
117
144
  ): EmbedConfig {
118
145
  const host = cfg.server.host === "0.0.0.0" || cfg.server.host === "::" ? "127.0.0.1" : cfg.server.host;
119
146
  const round = (v: number): number => Math.round(v * 1e4) / 1e4;
@@ -137,5 +164,9 @@ export function buildProviderConfig(
137
164
  if (cfg.server.harnessId !== undefined && cfg.server.harnessId !== "") {
138
165
  out.harnessId = cfg.server.harnessId;
139
166
  }
167
+ if (cfg.context?.enabled === true) {
168
+ const scope = cfg.context.defaultScope !== "" ? cfg.context.defaultScope : deriveAgentdoxScope(cwd ?? "");
169
+ if (scope !== "") out.agentdoxScope = scope;
170
+ }
140
171
  return out;
141
172
  }
@@ -44,7 +44,9 @@ import {
44
44
  * registry at a specific bound port.
45
45
  */
46
46
  function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfig, sessionId: string): void {
47
- const providerConfig = buildProviderConfig(port, cfg);
47
+ // cwd is omp's workspace, which is what the agentdox scope is derived from
48
+ // when none is configured explicitly.
49
+ const providerConfig = buildProviderConfig(port, cfg, process.cwd());
48
50
  const headers: Record<string, string> = {};
49
51
  if (providerConfig.harnessId !== undefined && providerConfig.harnessId !== "") {
50
52
  headers["X-Omp-Harness"] = providerConfig.harnessId;
@@ -52,6 +54,10 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
52
54
  // Per-session scoping: lets the toast surface only this session's decisions
53
55
  // even when several omp sessions share one embedded router's ledger.
54
56
  if (sessionId !== "") headers["X-Omp-Session"] = sessionId;
57
+ // Which agentdox project's shared context this workspace's turns draw on.
58
+ if (providerConfig.agentdoxScope !== undefined && providerConfig.agentdoxScope !== "") {
59
+ headers["X-Agentdox-Scope"] = providerConfig.agentdoxScope;
60
+ }
55
61
  pi.registerProvider(EMBED_PROVIDER_ID, {
56
62
  baseUrl: providerConfig.baseUrl,
57
63
  api: "openai-completions",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.2",
3
+ "version": "0.2.8",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -57,6 +57,22 @@ export interface SpliceResult {
57
57
  * `cost` is in omp's units: USD per MILLION tokens. The catalog works in
58
58
  * per-token rates, so everything is scaled by 1e6 exactly once, here.
59
59
  */
60
+ /**
61
+ * Headers omp attaches to every request through this provider. Both are
62
+ * optional: absent harness id ⇒ single-harness defaults, absent agentdox scope
63
+ * ⇒ the router falls back to its own `context.defaultScope`.
64
+ */
65
+ function providerHeaders(cfg: RouterConfig): Record<string, string> {
66
+ const headers: Record<string, string> = {};
67
+ if (cfg.server.harnessId !== undefined && cfg.server.harnessId !== "") {
68
+ headers["X-Omp-Harness"] = cfg.server.harnessId;
69
+ }
70
+ if (cfg.context.enabled && cfg.context.defaultScope !== "") {
71
+ headers["X-Agentdox-Scope"] = cfg.context.defaultScope;
72
+ }
73
+ return headers;
74
+ }
75
+
60
76
  export function renderProviderBlock(cfg: RouterConfig, blend: BlendedRate | null): string {
61
77
  const input = blend?.inputPerMtok ?? cfg.ledger.fallbackBlend.inputPerMtok;
62
78
  const output = blend?.outputPerMtok ?? cfg.ledger.fallbackBlend.outputPerMtok;
@@ -71,11 +87,10 @@ export function renderProviderBlock(cfg: RouterConfig, blend: BlendedRate | null
71
87
  baseUrl: `http://${host}:${cfg.server.port}/v1`,
72
88
  api: "openai-completions",
73
89
  auth: "none",
74
- // When a harness id is configured, tag every request so the router can
75
- // scope daily budgets and toasts per harness.
76
- ...(cfg.server.harnessId !== undefined && cfg.server.harnessId !== ""
77
- ? { headers: { "X-Omp-Harness": cfg.server.harnessId } }
78
- : {}),
90
+ // Per-request tags. Harness id scopes daily budgets and toasts;
91
+ // agentdox scope selects which project's shared context is injected
92
+ // and which project's sessions the turns are recorded into.
93
+ ...(Object.keys(providerHeaders(cfg)).length > 0 ? { headers: providerHeaders(cfg) } : {}),
79
94
  models: cfg.profiles.map((p) => ({
80
95
  id: p.id,
81
96
  name: p.name,
@@ -38,6 +38,7 @@ const ALWAYS_SHOW: Record<string, true> = {
38
38
  toolLoopDepth: true,
39
39
  lastToolFailed: true,
40
40
  repeatedToolCall: true,
41
+ circularToolCall: true,
41
42
  turnDepth: true,
42
43
  };
43
44
 
@@ -69,9 +69,13 @@ export const DEFAULT_CONFIG: RouterConfig = {
69
69
  trustScopedByHarness: false,
70
70
  contextHeadroom: 1.25,
71
71
  // Latency scoring is off by default (weight 0): opt in after establishing a
72
- // baseline. TTFT above the reference inflates a model's effective cost.
72
+ // baseline. Expected total wait (TTFT + expected completion / throughput)
73
+ // above the references inflates a model's effective cost.
73
74
  latencyWeight: 0,
74
75
  latencyReferenceMs: 5000,
76
+ // 30 tok/s: below this a model streams noticeably slowly. Only genuinely
77
+ // slow models (e.g. deepseek-v4-flash ~20 tok/s) fall under it.
78
+ latencyReferenceTokensPerSec: 30,
75
79
  latencyMinSamples: 20,
76
80
  },
77
81
  classifier: {
@@ -141,6 +145,34 @@ export const DEFAULT_CONFIG: RouterConfig = {
141
145
  maxBreakpoints: 4,
142
146
  minPromptTokens: 2_048,
143
147
  },
148
+ context: {
149
+ // Off until an agentdox URL + token are configured. Enabling this changes
150
+ // what every model sees, so it is never implicit.
151
+ enabled: false,
152
+ baseUrl: "",
153
+ token: "",
154
+ defaultScope: "",
155
+ timeoutMs: 3_000,
156
+ // Matches agentdox's own auto-context job cadence (900s): refreshing
157
+ // faster than the server reassembles buys nothing but cache misses.
158
+ maxStalenessMs: 900_000,
159
+ maxBlockChars: 24_000,
160
+ recordTurns: true,
161
+ maxQueue: 64,
162
+ },
163
+ compaction: {
164
+ // Off by default: shrinking context is behavior-changing, never implicit.
165
+ enabled: false,
166
+ // ~40k tokens: above this the prompt is dominated by re-sent tool output.
167
+ budgetTokens: 40_000,
168
+ fitToWindow: true,
169
+ protectRecentTurns: 4,
170
+ maxToolResultBytes: 4_096,
171
+ keepHeadBytes: 512,
172
+ keepTailBytes: 512,
173
+ elideSupersededReads: true,
174
+ collapseDuplicateResults: true,
175
+ },
144
176
  budget: {
145
177
  // No caps by default; at a configured ceiling, downgrade rather than fail.
146
178
  onExceeded: "downgrade",
@@ -108,6 +108,19 @@ export function loadConfig(opts?: { path?: string; overrides?: Partial<RouterCon
108
108
  }
109
109
  const envDb = process.env.AUTO_MODEL_ROUTER_DB;
110
110
  if (envDb !== undefined && envDb !== "") putSection("ledger", "path", envDb);
111
+
112
+ // agentdox bridge. A URL + token are enough to turn it on: requiring
113
+ // `context.enabled` in a config file as well would make the common case
114
+ // (export two vars, restart) silently do nothing.
115
+ const envDoxUrl = process.env.AGENTDOX_URL;
116
+ const envDoxToken = process.env.AGENTDOX_TOKEN;
117
+ const envDoxScope = process.env.AGENTDOX_SCOPE;
118
+ if (envDoxUrl !== undefined && envDoxUrl !== "") putSection("context", "baseUrl", envDoxUrl);
119
+ if (envDoxToken !== undefined && envDoxToken !== "") putSection("context", "token", envDoxToken);
120
+ if (envDoxScope !== undefined && envDoxScope !== "") putSection("context", "defaultScope", envDoxScope);
121
+ if (envDoxUrl !== undefined && envDoxUrl !== "" && envDoxToken !== undefined && envDoxToken !== "") {
122
+ putSection("context", "enabled", true);
123
+ }
111
124
  cfg = deepMerge(cfg, envInput);
112
125
 
113
126
  // Explicit programmatic overrides win last.
@@ -67,6 +67,7 @@ const filters = z.strictObject({
67
67
  contextHeadroom: z.number().positive().optional(),
68
68
  latencyWeight: z.number().nonnegative().optional(),
69
69
  latencyReferenceMs: z.number().positive().optional(),
70
+ latencyReferenceTokensPerSec: z.number().positive().optional(),
70
71
  latencyMinSamples: z.number().int().nonnegative().optional(),
71
72
  });
72
73
 
@@ -128,6 +129,30 @@ const cache = z.strictObject({
128
129
  minPromptTokens: z.number().int().nonnegative().optional(),
129
130
  });
130
131
 
132
+ const context = z.strictObject({
133
+ enabled: z.boolean().optional(),
134
+ baseUrl: z.string().optional(),
135
+ token: z.string().optional(),
136
+ defaultScope: z.string().optional(),
137
+ timeoutMs: z.number().int().positive().optional(),
138
+ maxStalenessMs: z.number().int().nonnegative().optional(),
139
+ maxBlockChars: z.number().int().positive().optional(),
140
+ recordTurns: z.boolean().optional(),
141
+ maxQueue: z.number().int().positive().optional(),
142
+ });
143
+
144
+ const compaction = z.strictObject({
145
+ enabled: z.boolean().optional(),
146
+ budgetTokens: z.number().int().positive().optional(),
147
+ fitToWindow: z.boolean().optional(),
148
+ protectRecentTurns: z.number().int().positive().optional(),
149
+ maxToolResultBytes: z.number().int().positive().optional(),
150
+ keepHeadBytes: z.number().int().nonnegative().optional(),
151
+ keepTailBytes: z.number().int().nonnegative().optional(),
152
+ elideSupersededReads: z.boolean().optional(),
153
+ collapseDuplicateResults: z.boolean().optional(),
154
+ });
155
+
131
156
  const budget = z.strictObject({
132
157
  perTurnUsd: z.number().nonnegative().optional(),
133
158
  perConversationUsd: z.number().nonnegative().optional(),
@@ -187,6 +212,8 @@ export const configInputSchema = z.strictObject({
187
212
  hysteresis: hysteresis.optional(),
188
213
  exploration: exploration.optional(),
189
214
  cache: cache.optional(),
215
+ context: context.optional(),
216
+ compaction: compaction.optional(),
190
217
  budget: budget.optional(),
191
218
  profiles: z.array(profile).optional(),
192
219
  ledger: ledger.optional(),