auto-model-router 0.2.14 → 0.2.16

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agentdox
3
- description: "Use agentdox — the shared memory, docs, and context server — the same way every session. Trigger on connect in any repo whose CLAUDE.md or .env.agentdox names an agentdox scope, and whenever the user mentions agentdox, project memory, remembering/recalling facts, project docs, the project brief, decisions, or session history. Also trigger BEFORE asking the user something they may have already told you, and BEFORE finishing any task that changed architecture, conventions, or decisions."
3
+ description: "Use agentdox — the shared memory, docs, and context server — the same way every session. Trigger on connect in any repo the scope comes from .env.agentdox, CLAUDE.md, or the repo folder name, and you create it if it has never been set — and whenever the user mentions agentdox, project memory, remembering/recalling facts, project docs, the project brief, decisions, or session history. Also trigger BEFORE asking the user something they may have already told you, and BEFORE finishing any task that changed architecture, conventions, or decisions."
4
4
  ---
5
5
 
6
6
  # agentdox — the standard interaction protocol
@@ -13,16 +13,93 @@ store collapses if each session writes it differently.
13
13
 
14
14
  ## 0. Resolve the scope before anything else
15
15
 
16
- Everything is namespaced by a **scope** = the project slug. Get it, in this order:
16
+ Everything is namespaced by a **scope** = the project slug, and **the scope is derived from the
17
+ project folder you are working in — never from your credential.** Resolve it in this order and
18
+ stop at the first hit:
17
19
 
18
20
  1. `AGENTDOX_SCOPE` in the repo's `.env.agentdox`
19
21
  2. The slug named in the repo's `CLAUDE.md`
20
- 3. List projects and match the repo name
22
+ 3. `project_list` an existing project whose slug matches the repo folder name
23
+ 4. **Nothing yet → derive it from the folder name and create it** (below). Don't ask first;
24
+ a folder with no scope is just a project that hasn't been onboarded, and the global token
25
+ already covers it.
21
26
 
22
27
  Known scopes: `ashlands` (E:/projects/ashlands/ashlands), `omp-router` (E:/projects/omp-router).
23
28
 
24
- **Never write outside your scope.** If you cannot determine it, ask do not guess, and do not
25
- fall back to a default.
29
+ ### Creating the scope for a folder that has never had one
30
+
31
+ Deterministic, so the same folder always resolves to the same slug:
32
+
33
+ 1. Take the **repo root** folder name — `git rev-parse --show-toplevel`, not the cwd. A
34
+ subdirectory must never become its own project.
35
+ 2. Slugify it: lowercase, every run of non-alphanumerics → a single `-`, trim leading/trailing
36
+ `-`. `E:/projects/My_App` → `my-app`.
37
+ 3. `project_list` **before creating.**
38
+ - **Exact match** — usually this repo, already onboarded elsewhere; adopt it. But an exact
39
+ match reached from a folder that has never been onboarded can also be a *collision*: two
40
+ unrelated repos with the same folder name (`E:/projects/foo/api` and `E:/work/api`) both
41
+ slugify to `api`, and adopting blindly merges two projects into one namespace. Check the
42
+ existing project's brief/description first; if it clearly describes a different codebase,
43
+ stop and ask for a distinguishing slug.
44
+ - **Near match** (`my-app` vs `my-app-v2`) — a stop sign: ask, rather than fork a second
45
+ namespace for one project.
46
+ 4. `project_ensure {slug, name}` — `name` is the readable form of the folder.
47
+ 5. **Make `.env.agentdox` un-committable *before* writing it.** Run `git check-ignore -v
48
+ .env.agentdox`. If it is not ignored, **add `.env.agentdox` to `.gitignore`** (create that
49
+ file if there is none) — do not merely check and move on. Patterns like `.env` and
50
+ `.env.*.local` do **not** match `.env.agentdox`; that exact gap existed in the agentdox repo
51
+ itself. This file carries the global token, which is instance admin: committing it is the
52
+ worst outcome available here.
53
+ 6. **Persist the scope, or the next session redoes all of this.** Write `.env.agentdox` at the
54
+ repo root:
55
+
56
+ ```ini
57
+ AGENTDOX_URL=http://localhost:3003
58
+ AGENTDOX_SCOPE=<slug>
59
+ AGENTDOX_TOKEN=<the global PAT>
60
+ ```
61
+
62
+ Take the token value from the `AGENTDOX_TOKEN` environment variable (`$env:AGENTDOX_TOKEN` on
63
+ Windows), or copy it from another agentdox repo's `.env.agentdox`. If neither exists, this
64
+ machine has no global PAT yet — stop and ask the user to mint one. Never invent a token.
65
+ 7. Add the `.mcp.json` from §1 if the repo has none. **It does not take effect this session** —
66
+ MCP config is read once, at harness startup. Finish *this* session over REST, and tell the
67
+ user to restart the harness to get the MCP tools.
68
+ 8. Copy the skill into the repo: `~/.claude/skills/agentdox/SKILL.md` →
69
+ `.claude/skills/agentdox/SKILL.md`. Claude Code finds the user-level copy anyway, but omp and
70
+ other harnesses only discover **project-relative** skills — without this, the protocol
71
+ silently stops applying there.
72
+ 9. Add an agentdox section to the repo's `CLAUDE.md` (create the file if absent). This is
73
+ resolution step 2 above, and it is what makes the *next* agent, in any harness, follow the
74
+ protocol:
75
+
76
+ > ## agentdox — shared context/memory (**MANDATORY to keep updated**)
77
+ >
78
+ > agentdox is this repo's memory + docs + live-conversation system. The project slug is
79
+ > **`<slug>`** — ALWAYS scope agentdox writes to it. `.mcp.json` uses **`AGENTDOX_TOKEN`**,
80
+ > one global bearer token shared by every agentdox-wired repo; the scope comes from *this
81
+ > folder* (`AGENTDOX_SCOPE` in `.env.agentdox`, gitignored), not from the token. That token
82
+ > grants every scope, so a wrong slug is **not** rejected — it silently writes into another
83
+ > project.
84
+ >
85
+ > Keeping agentdox current is part of completing a task, not optional. Full protocol:
86
+ > `.claude/skills/agentdox/SKILL.md`.
87
+ 10. Give the brief an overview. `context_brief_seed {scope}` builds one **from existing memory
88
+ and docs**, so on a scope you just created it returns 200 and an *empty* brief — there is
89
+ nothing to seed from yet. Write it directly instead:
90
+ `PUT /context/brief {scope, overview, repoLayout?, buildTest?, gotchas?}`. Seed later, once
91
+ the scope has material.
92
+ 11. **Verify before you claim it works:** write one memory in the new scope and read it back
93
+ (`memory_add` → `memory_search`, or `POST /memory` → `GET /memory?category=<slug>`). A 401
94
+ here means `AGENTDOX_TOKEN` is missing from the environment, not that onboarding failed.
95
+
96
+ Then tell the user, briefly: the scope you created, the files you added, and that the MCP tools
97
+ need a harness restart. Don't make them discover that a new project appeared.
98
+
99
+ **Never write outside your scope.** The bearer token is global (see §1) and grants *every*
100
+ scope, so a wrong slug will **not** be rejected — it will silently succeed and file this
101
+ project's data under another project's namespace. Nothing catches that but you. If you cannot
102
+ determine the scope, ask — do not guess, and do not fall back to a default.
26
103
 
27
104
  ## 1. Pick your transport — MCP or REST
28
105
 
@@ -35,13 +112,15 @@ Both hit the same live store with the same RBAC. **Check which you have, then us
35
112
  - **No such tools** — **use the REST API directly.** Never skip recording just because MCP
36
113
  tools are absent; that is the most likely way this protocol silently stops happening.
37
114
 
38
- If you expected MCP tools and don't have them, the usual cause is the bearer env var missing
115
+ If you expected MCP tools and don't have them, the usual cause is `AGENTDOX_TOKEN` missing
39
116
  from the **launching shell's** environment. A Windows *User*-scope variable only reaches
40
117
  processes started after it was set, so an already-running terminal won't have it. Either
41
118
  restart the shell/harness or fall back to REST for this session — don't just skip the writes.
42
119
 
43
- REST auth: `Authorization: Bearer <token>`, where the token is `AGENTDOX_TOKEN` from the
44
- repo's `.env.agentdox`.
120
+ REST auth: `Authorization: Bearer <token>`, where the token is `AGENTDOX_TOKEN` **one
121
+ global PAT shared by every repo** (non-expiring, wildcard grants), held in the Windows User
122
+ environment and mirrored into each repo's `.env.agentdox`. It is deliberately *not*
123
+ project-scoped: a new project folder needs no new token, only its own `AGENTDOX_SCOPE`.
45
124
 
46
125
  Cleanest REST call path — a throwaway `bun` script, which avoids PowerShell mangling `$` in
47
126
  inline JSON and avoids quoting pain in `curl`:
@@ -128,10 +207,11 @@ facts. Record the *why* of a decision, not just the *what*.
128
207
 
129
208
  ## When agentdox fails
130
209
 
131
- - **401** → the bearer token env var referenced by `.mcp.json` is missing from the
132
- environment. Re-set it from the repo's `.env.agentdox` and restart the harness (`${VAR}`
133
- substitution happens once, at MCP-server startup).
134
- - **403** → you are writing outside your granted scope. Re-check the slug.
210
+ - **401** → `AGENTDOX_TOKEN` is missing from the launching shell's environment. Re-set it from
211
+ the repo's `.env.agentdox` and restart the harness (`${VAR}` substitution happens once, at
212
+ MCP-server startup).
213
+ - **403** → should not happen with the global token. If it does, that token was revoked or
214
+ replaced by a scoped one — check `.env.agentdox` against agentdox's `/auth/tokens` list.
135
215
  - **Connection refused** → the `agentdox-server` Docker container is not running.
136
216
 
137
217
  Report the failure rather than proceeding as if the store were up to date.
@@ -141,3 +221,9 @@ Report the failure rather than proceeding as if the store were up to date.
141
221
  *Canonical copy: `~/.claude/skills/agentdox/SKILL.md`. omp only discovers skills from
142
222
  **project-relative** dirs (`.claude/skills/`, `.omp/skills`, `.agent/skills`, …), so this file
143
223
  is copied into each participating repo. Edit the canonical copy, then re-copy.*
224
+
225
+ *A short pointer also lives in `~/.omp/agent/AGENTS.md` — omp's global instruction file, loaded
226
+ in every session in every directory (empirically the only user-level path omp reads: `~/.agent`,
227
+ `~/.agents`, `~/.codex`, `~/.config/opencode`, `~/.omp`, and `~/AGENTS.md` were all ignored).
228
+ Keep it a **pointer**: the protocol lives here, and that file is charged to the context window of
229
+ every omp session, including ones with nothing to do with agentdox.*
package/.mcp.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "type": "http",
5
5
  "url": "http://localhost:3003/mcp",
6
6
  "headers": {
7
- "Authorization": "Bearer ${AGENTDOX_OMP_TOKEN}"
7
+ "Authorization": "Bearer ${AGENTDOX_TOKEN}"
8
8
  }
9
9
  }
10
10
  }
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.2.14",
10
+ "version": "0.2.16",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.2.14",
17
+ "version": "0.2.16",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/CLAUDE.md CHANGED
@@ -13,17 +13,20 @@ It presents one keyless OpenAI-compatible provider and picks a concrete OpenRout
13
13
 
14
14
  agentdox is this repo's memory + docs + live-conversation system. The project slug is
15
15
  **`omp-router`** — ALWAYS scope agentdox writes to it. The HTTP MCP server in `.mcp.json`
16
- uses **`AGENTDOX_OMP_TOKEN`** (a bearer token with admin on the `omp-router` scope only).
16
+ uses **`AGENTDOX_TOKEN`**, one **global** bearer token shared by every agentdox-wired repo.
17
+ The scope comes from *this folder* (`AGENTDOX_SCOPE` in `.env.agentdox`), not from the token:
18
+ the token grants every scope, so a wrong slug is **not** rejected — it silently writes into
19
+ another project. Getting `omp-router` right is on you, not on RBAC.
17
20
 
18
21
  **Where the credentials live:**
19
22
 
20
23
  | What | Where |
21
24
  | --- | --- |
22
25
  | Token + URL + scope | `.env.agentdox` in this repo root (**gitignored** via `.env.*` — never commit) |
23
- | What `.mcp.json` reads | the `AGENTDOX_OMP_TOKEN` **environment variable**, not the file |
24
- | Persisted env value | Windows **User** environment (`[Environment]::GetEnvironmentVariable('AGENTDOX_OMP_TOKEN','User')`) |
26
+ | What `.mcp.json` reads | the `AGENTDOX_TOKEN` **environment variable**, not the file |
27
+ | Persisted env value | Windows **User** environment (`[Environment]::GetEnvironmentVariable('AGENTDOX_TOKEN','User')`) |
25
28
  | Server | `http://localhost:3003` — Docker container `agentdox-server` |
26
- | Admin token (to re-mint scoped PATs) | `E:/projects/agentdox/deploy/.env` |
29
+ | Admin token (to re-mint the global PAT) | `E:/projects/agentdox/deploy/.env` |
27
30
 
28
31
  `.env.agentdox` is the durable record; the environment variable is what Claude Code actually
29
32
  substitutes into `.mcp.json` at MCP-server startup. If agentdox MCP returns **401**, the
@@ -60,7 +63,7 @@ served from this repo's `.mcp.json`.
60
63
  headers. It mounts them **prefixed**: `agentdox_memory_add`, `agentdox_context_assemble`, …
61
64
  (fully qualified `mcp__agentdox_*`). All 17 tools load.
62
65
 
63
- The one prerequisite is `AGENTDOX_OMP_TOKEN` being present in the **launching shell's**
66
+ The one prerequisite is `AGENTDOX_TOKEN` being present in the **launching shell's**
64
67
  environment. It is persisted at Windows *User* scope, so only shells started afterwards
65
68
  inherit it — an already-open terminal will show no agentdox tools until restarted.
66
69
 
@@ -69,7 +72,7 @@ inherited) MUST use the REST API directly** — same live store, same RBAC. Don'
69
72
  just because the MCP tools are absent.
70
73
 
71
74
  REST basics: base `http://localhost:3003`, header `Authorization: Bearer <token>` where the token
72
- is `AGENTDOX_TOKEN` from `.env.agentdox` (admin on the `omp-router` scope). **memory uses
75
+ is `AGENTDOX_TOKEN` from `.env.agentdox` (global; grants every scope). **memory uses
73
76
  `category`, everything else uses `scope`; both are always `"omp-router"`.** MCP-tool → REST map:
74
77
 
75
78
  | Duty / MCP tool | REST |
@@ -111,7 +114,7 @@ Turning the bridge on for the router itself (distinct from the MCP wiring above)
111
114
 
112
115
  ```bash
113
116
  export AGENTDOX_URL=http://localhost:3003
114
- export AGENTDOX_TOKEN=<same PAT as AGENTDOX_OMP_TOKEN>
117
+ export AGENTDOX_TOKEN=<the global PAT, same value .mcp.json uses>
115
118
  export AGENTDOX_SCOPE=omp-router
116
119
  ```
117
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -167,6 +167,15 @@ export const DEFAULT_CONFIG: RouterConfig = {
167
167
  // unbounded, this scope reached 15 memory entries = 23.5k chars (~5.9k
168
168
  // tokens) injected into every turn, against a 24k cap it was about to hit.
169
169
  memoryLimit: 8,
170
+ // Docs are WHOLE DOCUMENTS, so they are the easiest way to blow the cap:
171
+ // this was left unbounded and a single ashlands note-doc measured 41,921
172
+ // chars — larger than maxBlockChars on its own, with three of them
173
+ // assembling a 104k-char block. Bounded rather than off, because a scope
174
+ // whose docs are genuinely short summaries benefits from them; set 0 where
175
+ // docs mirror whole repo files (agentdox ingest does this), since the
176
+ // content is retrievable on demand via docs_read and does not belong in
177
+ // every prompt's prefix.
178
+ docsLimit: 2,
170
179
  // Session messages are cheap today but grow once recordTurns is on, and
171
180
  // they feed straight back into the next assembly.
172
181
  sessionLimit: 6,
@@ -139,6 +139,7 @@ const context = z.strictObject({
139
139
  maxStalenessMs: z.number().int().nonnegative().optional(),
140
140
  maxBlockChars: z.number().int().positive().optional(),
141
141
  memoryLimit: z.number().int().positive().optional(),
142
+ docsLimit: z.number().int().nonnegative().optional(),
142
143
  sessionLimit: z.number().int().nonnegative().optional(),
143
144
  recordTurns: z.boolean().optional(),
144
145
  maxQueue: z.number().int().positive().optional(),
@@ -374,6 +374,9 @@ export interface ContextConfig {
374
374
  maxBlockChars: number;
375
375
  /** Max memory entries agentdox may select for the block. */
376
376
  memoryLimit: number;
377
+ /** Max docs agentdox may select for the block. Docs are whole documents, so
378
+ * this is the easiest way to blow `maxBlockChars`; 0 disables them. */
379
+ docsLimit: number;
377
380
  /** Max recent session messages agentdox may select for the block. */
378
381
  sessionLimit: number;
379
382
  /** Write settled turns back to agentdox sessions, tagged with the served model. */
@@ -18,6 +18,7 @@ export interface AgentDoxClientOptions {
18
18
  /** Bounds on what agentdox may select for one block. */
19
19
  export interface AssembleLimits {
20
20
  memoryLimit: number;
21
+ docsLimit: number;
21
22
  sessionLimit: number;
22
23
  }
23
24
 
@@ -88,6 +89,7 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
88
89
  scope,
89
90
  query,
90
91
  memoryLimit: limits.memoryLimit,
92
+ docsLimit: limits.docsLimit,
91
93
  sessionLimit: limits.sessionLimit,
92
94
  });
93
95
  if (res !== null && res.status === 200) {
@@ -36,6 +36,7 @@ export interface BridgeOptions {
36
36
  * useful entry instead of severing whatever straddles the cap.
37
37
  */
38
38
  memoryLimit: number;
39
+ docsLimit: number;
39
40
  sessionLimit: number;
40
41
  /** Record settled turns back into agentdox sessions. */
41
42
  recordTurns: boolean;
@@ -80,7 +81,7 @@ function appendFragment(prior: string, next: string): string {
80
81
  }
81
82
 
82
83
  export function createContextBridge(opts: BridgeOptions): ContextBridge {
83
- const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, sessionLimit, recordTurns, maxQueue } = opts;
84
+ const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, recordTurns, maxQueue } = opts;
84
85
 
85
86
  // Serialized write-back queue. Session appends for one conversation must
86
87
  // stay ordered, and agentdox is a local service — one worker is plenty.
@@ -118,7 +119,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
118
119
  return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
119
120
  }
120
121
 
121
- const raw = await client.assemble(input.scope, input.query, { memoryLimit, sessionLimit });
122
+ const raw = await client.assemble(input.scope, input.query, { memoryLimit, docsLimit, sessionLimit });
122
123
  if (raw === null) {
123
124
  // agentdox unreachable or empty. Keep serving the pinned block if we
124
125
  // have one: stale shared context beats none, and re-using it also
@@ -28,6 +28,7 @@ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): Context
28
28
  maxStalenessMs: c.maxStalenessMs,
29
29
  maxBlockChars: c.maxBlockChars,
30
30
  memoryLimit: c.memoryLimit,
31
+ docsLimit: c.docsLimit,
31
32
  sessionLimit: c.sessionLimit,
32
33
  recordTurns: c.recordTurns,
33
34
  maxQueue: c.maxQueue,
@@ -49,19 +49,27 @@ const CAP_LOOP_DEPTH = -0.06;
49
49
  // the agent keeps grinding without human input. Unlike the mechanical-step
50
50
  // penalty above, this term ACCUMULATES with depth past the agentic threshold so
51
51
  // long loops climb out of trivial. The ramp slope is calibrated on recorded
52
- // coding turns; the cap governs the ceiling. `moderate` is NOT enough to break a
53
- // distinct-read loop: on the coding axis the cheapest model above the moderate
54
- // floor (deepseek-v4-flash, coding 69.1) also clears it after the latency
55
- // penalty, so escalating to moderate never swaps the weak model off — live
56
- // evidence (conv 888e5bddc1) is a loop grinding to depth 27+ on it. Only `hard`
57
- // (floor 72) excludes that model and forces a materially stronger, faster one.
58
- // So the cap lets a RUNAWAY loop (~depth 38 of pure continuations, no other
59
- // signal) reach `hard`; the calibrated mid-range still tops out in `moderate`,
60
- // and any corroborating stuck signal (circular call, tool failure, keywords)
61
- // reaches `hard` far sooner.
52
+ // coding turns; the cap governs the ceiling.
53
+ //
54
+ // The cap was briefly 0.70, which let pure depth reach `hard`. That was aimed at
55
+ // a real problem a distinct-read loop grinding at depth 27+ on
56
+ // deepseek-v4-flash (coding 69.1), which clears the moderate floor, so escalating
57
+ // to moderate swapped nothing. But it overcorrected: on live data 152 of 155
58
+ // `hard` dispatches were depth-driven and carried 63.5% of ALL spend, and those
59
+ // same rows also carry the `-0.28 tool-result continuation (mechanical)` term
60
+ // the classifier already knew the work was mechanical and the depth bonus
61
+ // overrode it. `hard` (floor 72, no price ceiling) forces gemini-3.7-flash at
62
+ // roughly 8x the cost of the model moderate picks.
63
+ //
64
+ // The original grinding failure is now covered independently: windowed latency
65
+ // scoring inflates a chronically slow model's effective cost, and it removed
66
+ // deepseek-v4-flash from selection entirely. So depth alone tops out at
67
+ // `moderate` again, and `hard` stays reachable via a CORROBORATING stuck signal
68
+ // (circular call, tool failure, keywords) — which is what should buy a $2/Mtok
69
+ // model, not depth by itself.
62
70
  const W_AUTONOMOUS_LOOP = 0.06; // base bonus at the threshold
63
71
  const W_AUTONOMOUS_LOOP_PER_DEPTH = 0.018; // added per loop step beyond the threshold
64
- const CAP_AUTONOMOUS_LOOP = 0.7; // pure depth ramps through moderate into hard for a runaway loop
72
+ const CAP_AUTONOMOUS_LOOP = 0.45; // pure depth ramps into moderate, never alone into hard
65
73
  const W_IMAGES = 0.04;
66
74
  const W_TOOLS_OFFERED = 0.03;
67
75
 
@@ -213,16 +213,24 @@ describe("scoreHeuristic", () => {
213
213
  }
214
214
  });
215
215
 
216
- test("pure loop depth ramps to moderate in the mid-range and hard only when runaway", () => {
216
+ test("pure loop depth never reaches hard on its own, however runaway", () => {
217
217
  // A sustained-but-not-runaway loop tops out in moderate: the calibrated
218
218
  // ramp ceiling for ordinary deep work.
219
219
  const midRange = scoreHeuristic(contFeatures(30), BASE);
220
220
  expect(midRange.tier).toBe("moderate");
221
- // A runaway loop of pure continuations (no other signal) must reach hard.
222
- // moderate never swaps the cheapest coding model off it clears the 60
223
- // floor even after the latency penalty so only hard's 72 floor breaks it.
221
+ // And so does a runaway one. This reverses an earlier cap of 0.70 that let
222
+ // raw depth buy `hard`: on live data 152 of 155 hard dispatches were
223
+ // depth-driven and carried 63.5% of ALL spend, while those same rows also
224
+ // scored the mechanical tool-result-continuation penalty. `hard` has no
225
+ // price ceiling, so depth alone was buying a ~8x model for work the
226
+ // classifier already knew was mechanical. Depth is a weak signal of
227
+ // DIFFICULTY; hard must be bought by a corroborating stuck signal (see the
228
+ // circular-tool-call test below), which is the case that actually needs a
229
+ // stronger model.
224
230
  const runaway = scoreHeuristic(contFeatures(90), BASE);
225
- expect(runaway.tier).toBe("hard");
231
+ expect(runaway.tier).toBe("moderate");
232
+ // The ceiling must still be a real ceiling, not an accident of the ramp.
233
+ expect(scoreHeuristic(contFeatures(400), BASE).tier).toBe("moderate");
226
234
  });
227
235
 
228
236
  test("a circular tool call on a deep loop escalates to hard", () => {
@@ -53,6 +53,7 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
53
53
  maxStalenessMs: 900_000,
54
54
  maxBlockChars: 24_000,
55
55
  memoryLimit: 8,
56
+ docsLimit: 2,
56
57
  sessionLimit: 6,
57
58
  recordTurns: true,
58
59
  maxQueue: 64,
@@ -95,16 +96,18 @@ describe("context bridge refresh policy", () => {
95
96
  });
96
97
 
97
98
  test("assembly is bounded, so the block cannot grow until bytes get severed", async () => {
98
- // The block reached 23.5k chars (~5.9k tokens, 15 memory entries) against a
99
- // 24k maxBlockChars cap, at which point renderBlock slices mid-entry. Byte
100
- // truncation is blind to relevance, so the server must be told to rank and
101
- // select instead. The REST endpoint ignores snake_case limit keys, which
102
- // silently reads as unboundedhence pinning that the limits are passed.
99
+ // The block reached 23.5k chars against a 24k maxBlockChars cap, at which
100
+ // point renderBlock slices mid-entry. Byte truncation is blind to relevance,
101
+ // so the server must be told to rank and select instead. `docsLimit`
102
+ // especially: docs are WHOLE documents and were left unbounded, and a single
103
+ // ashlands note-doc measured 41,921 chars over the whole cap by itself.
104
+ // The REST endpoint also ignores snake_case limit keys, which silently reads
105
+ // as unbounded, so pin that all three limits actually reach the client.
103
106
  const client = mkClient();
104
- const { bridge, db } = mkBridge(client, { memoryLimit: 5, sessionLimit: 2 });
107
+ const { bridge, db } = mkBridge(client, { memoryLimit: 5, docsLimit: 1, sessionLimit: 2 });
105
108
  try {
106
109
  await bridge.resolve(input());
107
- expect(client.lastLimits).toEqual({ memoryLimit: 5, sessionLimit: 2 });
110
+ expect(client.lastLimits).toEqual({ memoryLimit: 5, docsLimit: 1, sessionLimit: 2 });
108
111
  } finally {
109
112
  db.close();
110
113
  }
@@ -239,6 +242,7 @@ describe("context bridge refresh policy", () => {
239
242
  maxStalenessMs: 900_000,
240
243
  maxBlockChars: 24_000,
241
244
  memoryLimit: 8,
245
+ docsLimit: 2,
242
246
  sessionLimit: 6,
243
247
  recordTurns: true,
244
248
  maxQueue: 64,
@@ -69,7 +69,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
69
69
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
70
70
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
71
71
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
72
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
72
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
73
73
  compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
74
74
  budget: { onExceeded: "downgrade" },
75
75
  profiles: [],
package/test/turn.test.ts CHANGED
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
70
70
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
71
71
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
72
72
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
73
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
73
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
74
74
  compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
75
75
  budget: { onExceeded: "downgrade" },
76
76
  profiles: [],
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Offline decision replay — re-run REAL routing over recorded ledger rows.
3
+ *
4
+ * Every routing change is behavior-changing and cost-relevant, so the standing
5
+ * rule is to validate on the ledger before enabling. This is the tool for that:
6
+ * it feeds recorded `features` back through the real `scoreHeuristic` and
7
+ * `select`, under two config variants, and diffs the decisions.
8
+ *
9
+ * bun tools/replay.ts --limit 500
10
+ * bun tools/replay.ts --set tiers.hard.minQuality=70
11
+ * bun tools/replay.ts --set filters.latencyWeight=0 --verbose
12
+ * bun tools/replay.ts --where "task='coding'" --set classifier.ambiguityThreshold=0
13
+ *
14
+ * `--set` overrides variant B; `--a` overrides the baseline too (default:
15
+ * config as it currently stands on disk). Read-only: opens the ledger DB
16
+ * readonly and never writes.
17
+ *
18
+ * WHAT IT MODELS FAITHFULLY
19
+ * - The recorded `features` blob is the exact classifier input from that turn,
20
+ * so no re-tokenization or re-derivation is involved.
21
+ * - The real catalog snapshot is hydrated from `catalog_cache` via `peek()`,
22
+ * so pricing, context windows, capabilities and joined benchmark scores are
23
+ * the ones that were actually in play. No network.
24
+ * - The real `Ledger` supplies trust and latency, so the trust divisor and the
25
+ * throughput multiplier behave as they do live.
26
+ * - `explorationDraw` keys on `conversationKey:turn`, both recorded, so
27
+ * exploration reproduces deterministically and cancels out in a diff.
28
+ *
29
+ * WHAT IT DOES NOT MODEL — read this before trusting a conclusion
30
+ * - `messages` are not recorded, so compaction cannot be re-planned. Replay
31
+ * forces `compaction.enabled=false` and feeds the POST-compaction prompt
32
+ * size (`usage.promptTokens`), i.e. the prompt selection actually saw.
33
+ * - Conversation state is not recoverable historically (only the current row
34
+ * survives), so replay uses a neutral state: no sticky tier, no warm cache,
35
+ * no accumulated spend. Hysteresis, cache-warmth tie-breaks and the
36
+ * per-conversation budget guard are therefore NOT exercised.
37
+ * - `requestedReasoning` is the one `Features` field the ledger omits; it
38
+ * replays as undefined.
39
+ *
40
+ * Because of those gaps, the report leads with a FIDELITY figure: how often the
41
+ * baseline variant reproduces the model that actually served. Low fidelity means
42
+ * the unmodelled parts dominate and any delta below is weak evidence.
43
+ */
44
+
45
+ import { Database } from "bun:sqlite";
46
+
47
+ import { createCatalog } from "../src/catalog/openrouter-catalog.ts";
48
+ import type { CatalogModel } from "../src/catalog/types.ts";
49
+ import { loadConfig } from "../src/config/load.ts";
50
+ import type { RouterConfig } from "../src/config/types.ts";
51
+ import { computeCost } from "../src/cost/forecast.ts";
52
+ import { createLedger } from "../src/cost/ledger.ts";
53
+ import type { UsageCounts } from "../src/cost/types.ts";
54
+ import { scoreHeuristic } from "../src/router/classify.ts";
55
+ import { select } from "../src/router/select.ts";
56
+ import type { ConversationState, Decision, Features, Tier } from "../src/router/types.ts";
57
+ import type { UpstreamClient } from "../src/upstream/types.ts";
58
+ import type { NormMessage, NormRequest, NormTool } from "../src/wire/types.ts";
59
+
60
+ interface Args {
61
+ limit: number;
62
+ where: string;
63
+ setB: string[];
64
+ setA: string[];
65
+ verbose: boolean;
66
+ db: string;
67
+ }
68
+
69
+ function parseArgs(argv: string[]): Args {
70
+ const a: Args = { limit: 500, where: "", setB: [], setA: [], verbose: false, db: "" };
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const k = argv[i];
73
+ const v = argv[i + 1];
74
+ if (k === "--limit" && v !== undefined) (a.limit = Number.parseInt(v, 10)), i++;
75
+ else if (k === "--where" && v !== undefined) (a.where = v), i++;
76
+ else if (k === "--set" && v !== undefined) (a.setB.push(v), i++);
77
+ else if (k === "--a" && v !== undefined) (a.setA.push(v), i++);
78
+ else if (k === "--db" && v !== undefined) (a.db = v), i++;
79
+ else if (k === "--verbose") a.verbose = true;
80
+ }
81
+ return a;
82
+ }
83
+
84
+ /** Coerce a CLI string to the JSON-ish type the config field expects. */
85
+ function coerce(raw: string): unknown {
86
+ if (raw === "true") return true;
87
+ if (raw === "false") return false;
88
+ if (raw === "null") return null;
89
+ const n = Number(raw);
90
+ if (raw.trim() !== "" && !Number.isNaN(n)) return n;
91
+ return raw;
92
+ }
93
+
94
+ /** Applies `a.b.c=value` overrides onto a deep clone, so variants never alias. */
95
+ function withOverrides(cfg: RouterConfig, sets: readonly string[]): RouterConfig {
96
+ const next = structuredClone(cfg);
97
+ for (const entry of sets) {
98
+ const eq = entry.indexOf("=");
99
+ if (eq < 0) throw new Error(`--set expects path=value, got: ${entry}`);
100
+ const path = entry.slice(0, eq).split(".");
101
+ const value = coerce(entry.slice(eq + 1));
102
+ let node: Record<string, unknown> = next as unknown as Record<string, unknown>;
103
+ for (const seg of path.slice(0, -1)) {
104
+ const child = node[seg];
105
+ if (typeof child !== "object" || child === null) throw new Error(`--set path not found: ${entry}`);
106
+ node = child as Record<string, unknown>;
107
+ }
108
+ const leaf = path[path.length - 1];
109
+ if (leaf === undefined || !(leaf in node)) throw new Error(`--set path not found: ${entry}`);
110
+ node[leaf] = value;
111
+ }
112
+ return next;
113
+ }
114
+
115
+ interface Row {
116
+ id: string;
117
+ conversation_key: string;
118
+ turn: number;
119
+ requested_model: string;
120
+ harness_id: string;
121
+ served_slug: string | null;
122
+ tier: string;
123
+ features: string;
124
+ usage: string;
125
+ reported_usd: number | null;
126
+ predicted_usd: number;
127
+ }
128
+
129
+ /** Rebuilds the classifier input. The ledger stores 20 of 21 Features fields. */
130
+ function featuresOf(row: Row, promptTokens: number): Features {
131
+ const f = JSON.parse(row.features) as Partial<Features>;
132
+ return { ...(f as Features), promptTokens, requestedReasoning: undefined };
133
+ }
134
+
135
+ /**
136
+ * Minimal request carrying only what `select`/`buildCandidates` read: tool count
137
+ * and schema bytes, image presence, harness id (trust/latency scoping),
138
+ * conversation key and profile id.
139
+ */
140
+ function requestOf(row: Row, f: Features): NormRequest {
141
+ const perTool = f.toolCount > 0 ? Math.round(f.toolSchemaBytes / f.toolCount) : 0;
142
+ const tools: NormTool[] = Array.from({ length: f.toolCount }, (_v, i) => ({
143
+ name: `t${i}`,
144
+ description: "",
145
+ schemaBytes: perTool,
146
+ }));
147
+ const messages: NormMessage[] = [
148
+ { role: "user", text: "", images: f.hasImages ? 1 : 0, textBytes: f.promptTokens * 4, toolCalls: [] },
149
+ ];
150
+ return {
151
+ protocol: "openai-chat",
152
+ conversationKey: row.conversation_key,
153
+ harnessId: row.harness_id,
154
+ ompSessionId: "",
155
+ agentdoxScope: "",
156
+ requestedModel: row.requested_model,
157
+ messages,
158
+ tools,
159
+ forcedToolChoice: false,
160
+ stream: true,
161
+ hasImages: f.hasImages,
162
+ promptBytes: f.promptTokens * 4,
163
+ renderUpstreamBody: () => ({}),
164
+ };
165
+ }
166
+
167
+ /** Neutral state: no sticky tier, no warm cache, no prior spend. See header. */
168
+ function stateOf(row: Row): ConversationState {
169
+ return {
170
+ key: row.conversation_key,
171
+ sessionId: `omp-${row.conversation_key}`,
172
+ turn: row.turn,
173
+ currentSlug: null,
174
+ currentTier: null,
175
+ stickyUntilTurn: 0,
176
+ escalations: 0,
177
+ spentUsd: 0,
178
+ lastPromptTokens: 0,
179
+ cacheWarmSlug: null,
180
+ cacheWarmAtMs: 0,
181
+ contextVersion: null,
182
+ contextFetchedAtMs: 0,
183
+ updatedAtMs: 0,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Re-prices a decision against the tokens the turn ACTUALLY used, via the real
189
+ * `computeCost` so price tiers, the cache split and reasoning/request fees are
190
+ * handled exactly as they are live.
191
+ *
192
+ * Deliberately NOT the router's own forecast: `candidates.ts` hardcodes
193
+ * `cacheHitRate: 0`, so forecasts overstate absolute cost ~2.8x. Pricing both
194
+ * variants off recorded usage keeps the delta apples-to-apples and grounded.
195
+ */
196
+ function repriceUsd(model: CatalogModel | undefined, usage: UsageCounts): number {
197
+ if (model === undefined) return 0;
198
+ return computeCost(model, usage).total;
199
+ }
200
+
201
+ const DEAD_UPSTREAM: UpstreamClient = {
202
+ dispatch: () => Promise.reject(new Error("replay is offline")),
203
+ complete: () => Promise.reject(new Error("replay is offline")),
204
+ fetchModels: () => Promise.reject(new Error("replay is offline")),
205
+ fetchModelsForUser: () => Promise.reject(new Error("replay is offline")),
206
+ };
207
+
208
+ const args = parseArgs(process.argv.slice(2));
209
+ const baseCfg = await loadConfig();
210
+ // Compaction cannot be re-planned without messages; see the header.
211
+ const forced = ["compaction.enabled=false"];
212
+ const cfgA = withOverrides(baseCfg, [...forced, ...args.setA]);
213
+ const cfgB = withOverrides(baseCfg, [...forced, ...args.setB]);
214
+
215
+ const dbPath = args.db !== "" ? args.db : baseCfg.ledger.path;
216
+ const db = new Database(dbPath, { readonly: true });
217
+ const catalog = createCatalog(cfgA, DEAD_UPSTREAM, db);
218
+ const snapshot = catalog.peek();
219
+ if (snapshot === null) {
220
+ console.error(`no cached catalog in ${dbPath}; run the router once so it populates catalog_cache`);
221
+ process.exit(2);
222
+ }
223
+ const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
224
+ const ledger = createLedger(db, cfgA);
225
+
226
+ const predicate = args.where === "" ? "" : ` AND (${args.where})`;
227
+ const rows = db
228
+ .query(
229
+ `SELECT id, conversation_key, turn, requested_model, harness_id, served_slug, tier, features, usage, reported_usd, predicted_usd
230
+ FROM ledger
231
+ WHERE features IS NOT NULL AND wasted = 0${predicate}
232
+ ORDER BY created_at_ms DESC LIMIT ?`,
233
+ )
234
+ .all(args.limit) as Row[];
235
+
236
+ if (rows.length === 0) {
237
+ console.error("no rows matched; widen --where or --limit");
238
+ process.exit(2);
239
+ }
240
+
241
+ /** Profile resolution mirrors router/index.ts, which does not export it. */
242
+ function profileOf(cfg: RouterConfig, requested: string) {
243
+ const exact = cfg.profiles.find((p) => p.id === requested);
244
+ if (exact !== undefined) return exact;
245
+ const first = cfg.profiles[0];
246
+ if (first === undefined) throw new Error("no router profiles configured");
247
+ return first;
248
+ }
249
+
250
+ interface Outcome {
251
+ tier: Tier;
252
+ slug: string;
253
+ usd: number;
254
+ }
255
+
256
+ function run(cfg: RouterConfig, row: Row, usage: UsageCounts): Outcome {
257
+ const f = featuresOf(row, usage.promptTokens);
258
+ const req = requestOf(row, f);
259
+ const decision: Decision = select({
260
+ req,
261
+ features: f,
262
+ classification: scoreHeuristic(f, cfg),
263
+ profile: profileOf(cfg, row.requested_model),
264
+ state: stateOf(row),
265
+ snapshot,
266
+ ledger,
267
+ cfg,
268
+ nowMs: Date.now(),
269
+ });
270
+ return { tier: decision.tier, slug: decision.slug, usd: repriceUsd(bySlug.get(decision.slug), usage) };
271
+ }
272
+
273
+ const tallyA = new Map<string, number>();
274
+ const tallyB = new Map<string, number>();
275
+ const tallyRec = new Map<string, number>();
276
+ const tierA = new Map<string, number>();
277
+ const tierB = new Map<string, number>();
278
+ const tierRec = new Map<string, number>();
279
+ let usdA = 0;
280
+ let usdB = 0;
281
+ let usdRec = 0;
282
+ let fidelitySlug = 0;
283
+ let fidelityTier = 0;
284
+ let comparable = 0;
285
+ const flips: { id: string; tier: string; from: string; to: string; delta: number }[] = [];
286
+ const bump = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
287
+
288
+ for (const row of rows) {
289
+ const u = JSON.parse(row.usage) as UsageCounts;
290
+ if (!(u.promptTokens > 0)) continue;
291
+ const a = run(cfgA, row, u);
292
+ const b = run(cfgB, row, u);
293
+ bump(tallyA, a.slug);
294
+ bump(tallyB, b.slug);
295
+ bump(tierA, a.tier);
296
+ bump(tierB, b.tier);
297
+ // The recorded outcome: what the router ACTUALLY did, under whatever code and
298
+ // config were live then. This is the yardstick for fidelity, and it is also
299
+ // how a shipped classifier change shows up — replay runs current code.
300
+ if (row.served_slug !== null) bump(tallyRec, row.served_slug);
301
+ bump(tierRec, row.tier);
302
+ usdA += a.usd;
303
+ usdB += b.usd;
304
+ usdRec += row.reported_usd ?? row.predicted_usd;
305
+ comparable++;
306
+ if (row.served_slug !== null && row.served_slug === a.slug) fidelitySlug++;
307
+ if (row.tier === a.tier) fidelityTier++;
308
+ if (a.slug !== b.slug || a.tier !== b.tier) {
309
+ flips.push({ id: row.id.slice(0, 8), tier: `${a.tier}->${b.tier}`, from: a.slug, to: b.slug, delta: b.usd - a.usd });
310
+ }
311
+ }
312
+
313
+ const pct = (n: number, d: number) => (d === 0 ? "0.0" : ((100 * n) / d).toFixed(1));
314
+ console.log(`\nreplayed ${comparable} dispatches from ${dbPath}`);
315
+ console.log(`variant A overrides: ${args.setA.length ? args.setA.join(" ") : "(config as-is)"}`);
316
+ console.log(`variant B overrides: ${args.setB.length ? args.setB.join(" ") : "(none — A and B identical)"}`);
317
+ console.log(`\nFIDELITY vs what actually ran:`);
318
+ console.log(` same model ${fidelitySlug}/${comparable} (${pct(fidelitySlug, comparable)}%) same tier ${fidelityTier}/${comparable} (${pct(fidelityTier, comparable)}%)`);
319
+ console.log(" Divergence is expected where code has changed since those rows were served");
320
+ console.log(" (replay runs CURRENT code); the rest is the unmodelled neutral state.");
321
+ console.log(" Low fidelity => treat the A/B delta below as weak evidence.");
322
+
323
+ function table(label: string, rec: Map<string, number>, A: Map<string, number>, B: Map<string, number>) {
324
+ const keys = [...new Set([...rec.keys(), ...A.keys(), ...B.keys()])].sort((x, y) => (B.get(y) ?? 0) - (B.get(x) ?? 0));
325
+ console.log(`\n${label.padEnd(32)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
326
+ for (const k of keys) {
327
+ const r = rec.get(k) ?? 0;
328
+ const a = A.get(k) ?? 0;
329
+ const b = B.get(k) ?? 0;
330
+ const d = b - a;
331
+ console.log(` ${k.padEnd(30)}${String(r).padStart(8)}${String(a).padStart(7)}${String(b).padStart(7)}${(d > 0 ? `+${d}` : String(d)).padStart(7)}`);
332
+ }
333
+ }
334
+ table("tier", tierRec, tierA, tierB);
335
+ table("model", tallyRec, tallyA, tallyB);
336
+
337
+ console.log(`\nspend, re-priced on RECORDED usage via the real computeCost:`);
338
+ console.log(` actual (billed) $${usdRec.toFixed(4)} per dispatch $${(usdRec / comparable).toFixed(5)}`);
339
+ console.log(` A $${usdA.toFixed(4)} per dispatch $${(usdA / comparable).toFixed(5)}`);
340
+ console.log(` B $${usdB.toFixed(4)} per dispatch $${(usdB / comparable).toFixed(5)}`);
341
+ const delta = usdB - usdA;
342
+ console.log(` B vs A $${delta.toFixed(4)} (${delta === 0 ? "no change" : `${((100 * delta) / (usdA || 1)).toFixed(1)}%`})`);
343
+ console.log(`\ndecisions changed: ${flips.length}/${comparable} (${pct(flips.length, comparable)}%)`);
344
+ if (args.verbose) {
345
+ for (const f of flips.slice(0, 40)) {
346
+ console.log(` ${f.id} ${f.tier.padEnd(22)} ${f.from} -> ${f.to} ${f.delta >= 0 ? "+" : ""}$${f.delta.toFixed(5)}`);
347
+ }
348
+ if (flips.length > 40) console.log(` … ${flips.length - 40} more`);
349
+ }
350
+ db.close();