auto-model-router 0.3.2 → 0.3.3

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.
@@ -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.3.2",
10
+ "version": "0.3.3",
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.3.2",
17
+ "version": "0.3.3",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/CLAUDE.md CHANGED
@@ -11,108 +11,28 @@ It presents one keyless OpenAI-compatible provider and picks a concrete OpenRout
11
11
 
12
12
  ## agentdox — shared context/memory (**MANDATORY to keep updated**)
13
13
 
14
- agentdox is this repo's memory + docs + live-conversation system. The project slug is
15
- **`omp-router`** — ALWAYS scope agentdox writes to it. The HTTP MCP server in `.mcp.json`
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.
14
+ agentdox is this repo's memory + docs + live-conversation store. The project slug is
15
+ **`omp-router`** — ALWAYS scope agentdox writes to it. The bearer token (`AGENTDOX_TOKEN`)
16
+ is one global PAT that grants every scope, so a wrong slug is **not** rejected: it silently
17
+ files this project's data under another project. Getting `omp-router` right is on you.
20
18
 
21
- **Where the credentials live:**
19
+ **Full protocol: `.claude/skills/agentdox/SKILL.md`** (on connect, before asking the user,
20
+ batching writes to the end of the session, the REST fallback with request shapes, search
21
+ tips). Read it before using agentdox; do not improvise from this summary.
22
22
 
23
23
  | What | Where |
24
24
  | --- | --- |
25
- | Token + URL + scope | `.env.agentdox` in this repo root (**gitignored** via `.env.*` — never commit) |
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')`) |
28
- | Server | `http://localhost:3003` Docker container `agentdox-server` |
29
- | Admin token (to re-mint the global PAT) | `E:/projects/agentdox/deploy/.env` |
30
-
31
- **Searching agentdox.** Retrieval is hybrid BM25 keyword matching fused with embeddings and
32
- runs over *passages* of docs, not whole files. So ask in your own words; exact identifiers work
33
- too. Two habits worth having:
34
-
35
- - **Prefer `docs_passages` over `docs_search`.** It returns the section that answers the
36
- question. `docs_search` returns whole documents, which then get truncated, and the truncation
37
- is rarely the relevant part.
38
- - **If results look thin, run `index_stats {scope}` before concluding the store is empty.** It
39
- reports how much of the scope is indexed and whether the embedding provider is reachable;
40
- `embedded` far below `total`, or an unreachable provider, means you are getting keyword-only
41
- results.
42
-
43
- `.env.agentdox` is the durable record; the environment variable is what Claude Code actually
44
- substitutes into `.mcp.json` at MCP-server startup. If agentdox MCP returns **401**, the
45
- variable is missing from the environment — re-set it from `.env.agentdox` and restart Claude
46
- Code (substitution happens once, at startup). Re-mint instructions are in `.env.agentdox`.
47
-
48
- **Requirement: keeping agentdox current is part of completing any task, not optional.**
49
- Do NOT close out a task while memory, docs, or conversation history for the area you touched
50
- is stale or incomplete.
51
-
52
- Concrete duties (all scoped to `omp-router`):
53
-
54
- - **On connect:** run `project_ensure` with `slug: "omp-router"` before any memory/docs work.
55
- - **On startup:** read `context_brief` to onboard on decisions, conventions, and gotchas
56
- before rediscovering them.
57
- - **Memory** (`memory_add` / `memory_update` / `memory_search`): record user-stated
58
- preferences and corrections. When a fact changes, UPDATE the existing entry — never leave
59
- contradictory facts. Keep entries compact and high-signal.
60
- - **Docs** (`docs_write` / `docs_update`): keep architecture and decisions current as reality
61
- changes; writing once is not enough.
62
- - **Sessions** (`session_start` / `session_append`): append messages in real time, not as an
63
- end-of-task summary.
64
- - **Context** (`context_assemble`): consult it (with a query) before re-asking the user about
65
- anything already captured.
66
- - **Decisions** (`context_brief_record`): record decisions and conventions as they are made.
67
-
68
- ### How to actually call it (MCP tools vs REST)
69
-
70
- The duties above name the **MCP tools** (`memory_add`, `docs_write`, `context_brief_record`, …),
71
- served from this repo's `.mcp.json`.
72
-
73
- **omp gets these tools too** — verified 2026-08-28. omp reads `.mcp.json` (repo root),
74
- `.omp/mcp.json`, `.claude/mcp.json`, and `~/.omp/agent/mcp.json`, and it expands `${VAR}` in
75
- headers. It mounts them **prefixed**: `agentdox_memory_add`, `agentdox_context_assemble`, …
76
- (fully qualified `mcp__agentdox_*`). All 17 tools load.
77
-
78
- The one prerequisite is `AGENTDOX_TOKEN` being present in the **launching shell's**
79
- environment. It is persisted at Windows *User* scope, so only shells started afterwards
80
- inherit it — an already-open terminal will show no agentdox tools until restarted.
81
-
82
- **A harness genuinely without those tools (e.g. Hermes, or omp before the env var is
83
- inherited) MUST use the REST API directly** — same live store, same RBAC. Don't skip recording
84
- just because the MCP tools are absent.
85
-
86
- REST basics: base `http://localhost:3003`, header `Authorization: Bearer <token>` where the token
87
- is `AGENTDOX_TOKEN` from `.env.agentdox` (global; grants every scope). **memory uses
88
- `category`, everything else uses `scope`; both are always `"omp-router"`.** MCP-tool → REST map:
89
-
90
- | Duty / MCP tool | REST |
91
- | --- | --- |
92
- | `project_ensure` | `POST /projects` `{slug,name}` (idempotent; re-ensure returns the project) |
93
- | `memory_search` | `GET /memory?category=omp-router&limit=N` · `GET /memory/search?q=…&category=omp-router` |
94
- | `memory_add` | `POST /memory` `{content, category:"omp-router", importance:0..1}` |
95
- | `memory_update` | `PATCH /memory/:id` `{content?, importance?, …}` (edit in place — never pile on dupes) |
96
- | `docs_write` | `POST /docs` `{slug, title, content, scope:"omp-router", tags?}` |
97
- | `docs_update` | `PATCH /docs/:id` `{title?, content?, tags?}` · list `GET /docs?scope=omp-router` |
98
- | read `context_brief` | `GET /context/brief?scope=omp-router` (404 `no_brief` until seeded) |
99
- | seed the brief | `PUT /context/brief` `{scope, overview?, repoLayout?, codeStyle?, buildTest?, assetConventions?, gotchas?}` |
100
- | `context_brief_record` | `POST /context/brief/decision` `{scope, title, decision, rationale}` |
101
- | `context_assemble` | `POST /context/assemble` `{scope, query}` · baseline `GET /context/snapshot?scope=` · `POST /context/refresh` `{scope}` |
102
- | `session_start` / `session_append` | `POST /sessions` `{scope, title}` → `POST /sessions/:id/messages` `{role, content, refs?}` → `POST /sessions/:id/end` |
103
-
104
- Cleanest call path (avoids Windows PowerShell mangling `$` in inline JSON): a throwaway
105
- `bun` script that reads the token and `fetch`es —
106
-
107
- ```ts
108
- const tok = /AGENTDOX_TOKEN=(.+)/.exec(await Bun.file(".env.agentdox").text())?.[1]?.trim() ?? "";
109
- const H = { Authorization: `Bearer ${tok}`, "content-type": "application/json" };
110
- await fetch("http://localhost:3003/memory", { method: "POST", headers: H,
111
- body: JSON.stringify({ content: "…", category: "omp-router", importance: 0.9 }) });
112
- ```
113
-
114
- Endpoints are defined in `E:/projects/agentdox/packages/server/src/index.ts`; the router's own
115
- read/write client is `src/context/agentdox.ts` (assemble / createSession / append only).
25
+ | Token, URL, scope | `.env.agentdox` in this repo root (gitignored — never commit) |
26
+ | What `.mcp.json` reads | the `AGENTDOX_TOKEN` **environment variable** (Windows *User* scope; shells opened before it was set lack it) |
27
+ | Server | `http://localhost:3003` Docker container `agentdox-server`; endpoints in `E:/projects/agentdox/packages/server/src/index.ts` |
28
+ | Admin token (re-mint) | `E:/projects/agentdox/deploy/.env` |
29
+
30
+ Two rules that cause silent mistakes: **memory calls take `category`, everything else takes
31
+ `scope`** (both always `"omp-router"`); and a **401 means the env var is missing** from the
32
+ launching shell re-set it from `.env.agentdox` and restart the harness. omp mounts the
33
+ tools prefixed (`agentdox_memory_add`, …); a harness without them MUST use REST (the skill
34
+ has the map) rather than skip recording. Cleanest REST call path is a throwaway `bun`
35
+ script reading the token from `.env.agentdox`.
116
36
 
117
37
  ## This repo also *implements* an agentdox client
118
38
 
@@ -175,8 +175,14 @@ would fix that, at the cost of one credential reaching every project.
175
175
  so router test turns feed back into the next block. The omp-router scope had accumulated 19
176
176
  sessions of which 18 were noise (`hi`, `say hello`, `Reply with exactly the word: PONG`,
177
177
  injection probes, `bridge e2e …`); they were deleted, and `tools/agentdox-e2e.ts` writes two
178
- more every run. Consider a `sessionLimit` override for the bridge, or excluding
179
- router-authored sessions.
178
+ more every run. **Done 2026-09-07:** recent sessions ride only on a conversation's FIRST
179
+ block (`ContextResolveInput.firstFetch`); refreshes ask for `sessionLimit: 0`, since by then
180
+ the section was this conversation's own turns (1.4-1.9k chars per block, changing every
181
+ refresh). In the same change the relevance query became `context/query.ts`
182
+ `relevanceQuery`: the last user message with real content after omp's wrapper elements
183
+ (`<system-reminder>`, `<recap>`, `<chat>`, image stubs) are stripped, capped at 400 chars —
184
+ live blocks had been queried with those wrappers verbatim, one header running to 2k chars.
185
+ Recorded user turns use the same cleaner.
180
186
  - **`context.timeoutMs` is 3000ms** and failures degrade silently at `debug` level by design.
181
187
  If agentdox is cold this can no-op invisibly. Consider logging the first failure at `warn`.
182
188
  - **Four copies of this project exist** on this machine: this repo, the research checkout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -121,7 +121,15 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
121
121
  return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
122
122
  }
123
123
 
124
- const raw = await client.assemble(input.scope, input.query, { memoryLimit, docsLimit, sessionLimit, briefChars });
124
+ // Recent sessions only on a conversation's first block: after that they
125
+ // are this conversation's own recorded turns, duplicating the prompt
126
+ // and changing on every refresh (measured 1.4-1.9k chars per block).
127
+ const raw = await client.assemble(input.scope, input.query, {
128
+ memoryLimit,
129
+ docsLimit,
130
+ sessionLimit: input.firstFetch ? sessionLimit : 0,
131
+ briefChars,
132
+ });
125
133
  if (raw === null) {
126
134
  // agentdox unreachable or empty. Keep serving the pinned block if we
127
135
  // have one: stale shared context beats none, and re-using it also
@@ -0,0 +1,58 @@
1
+ /**
2
+ * What to tell agentdox this turn is about.
3
+ *
4
+ * The relevance query biases which memories and docs the assembled block
5
+ * carries, and agentdox echoes it verbatim into the block's header. Taking
6
+ * the last user-role message literally sent omp's own machinery instead of
7
+ * the user's ask: `<system-reminder>` nudges, `<system-notice>` job results,
8
+ * `<recap>` prompts, "Attached image(s) from tool result:" continuations.
9
+ * Measured on the live ledger: the three most recent blocks for one scope
10
+ * were queried with `<chat>`, `<system-reminder>` and an image notice, one
11
+ * header alone ran to 2k chars, and every refresh re-ranked memory against
12
+ * noise — different bytes each time, at the head of the cached prefix.
13
+ *
14
+ * The query is the last user message with real content once wrapper blocks
15
+ * are stripped, falling back to the conversation's opening ask, capped so the
16
+ * header stays a line rather than a message.
17
+ */
18
+
19
+ import type { NormRequest } from "../wire/types.ts";
20
+
21
+ /** Longest query worth sending: enough to rank on, short enough to stay stable. */
22
+ export const MAX_QUERY_CHARS = 400;
23
+
24
+ /**
25
+ * Harness wrapper tags whose whole element is machinery, not the user's ask.
26
+ * Stripped before judging whether a message has content.
27
+ */
28
+ const WRAPPER_TAGS = ["system-reminder", "system-notice", "system-directive", "recap", "chat", "checkpoint-active-reminder", "interrupted-thinking"];
29
+
30
+ const WRAPPER_RE = new RegExp(`<(${WRAPPER_TAGS.join("|")})(\\s[^>]*)?>[\\s\\S]*?</\\1>`, "gi");
31
+ /** An unclosed wrapper at the start swallows the rest: treat the message as machinery. */
32
+ const OPEN_WRAPPER_RE = new RegExp(`^\\s*<(${WRAPPER_TAGS.join("|")})(\\s[^>]*)?>`, "i");
33
+ /** omp's tool-result image continuation and similar auto-generated stubs. */
34
+ const STUB_RE = /^(attached image\(s\)(\s.*)?|\[image\]|\(no output\)|continue[.!]?)$/i;
35
+
36
+ /** The user's own words in a message, or "" when it is all harness machinery. */
37
+ export function userContent(text: string): string {
38
+ let t = text.replace(WRAPPER_RE, " ");
39
+ if (OPEN_WRAPPER_RE.test(t)) return "";
40
+ t = t.replace(/\s+/g, " ").trim();
41
+ if (t === "" || STUB_RE.test(t)) return "";
42
+ return t;
43
+ }
44
+
45
+ function cap(text: string): string {
46
+ return text.length > MAX_QUERY_CHARS ? `${text.slice(0, MAX_QUERY_CHARS - 1)}…` : text;
47
+ }
48
+
49
+ /** The relevance query for this turn: last real user message, else the opening ask, else "". */
50
+ export function relevanceQuery(req: Pick<NormRequest, "messages">): string {
51
+ for (let i = req.messages.length - 1; i >= 0; i--) {
52
+ const m = req.messages[i];
53
+ if (m === undefined || m.role !== "user") continue;
54
+ const content = userContent(m.text);
55
+ if (content !== "") return cap(content);
56
+ }
57
+ return "";
58
+ }
@@ -38,8 +38,15 @@ export interface ContextResolveInput {
38
38
  modelSwitching: boolean;
39
39
  /** An escalation or failover retry — the prefix is cold either way. */
40
40
  retrying: boolean;
41
- /** Latest user text, used to bias agentdox relevance ranking. */
41
+ /** The user's ask this turn (see `context/query.ts`), used to bias agentdox relevance ranking. */
42
42
  query: string;
43
+ /**
44
+ * This conversation has not been given a block before. Recent sessions
45
+ * are included only then: they carry a previous conversation forward, but
46
+ * on a refresh they would mostly be this conversation's own turns, which
47
+ * the prompt already holds.
48
+ */
49
+ firstFetch: boolean;
43
50
  }
44
51
 
45
52
  /** One settled turn, recorded to agentdox with the model that served it. */
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { CatalogSource } from "../catalog/types.ts";
12
+ import { relevanceQuery, userContent } from "../context/query.ts";
12
13
  import type { ContextBridge } from "../context/types.ts";
13
14
  import type { RouterConfig } from "../config/types.ts";
14
15
  import { estimateUnreportedCache } from "../cost/cache-estimate.ts";
@@ -159,7 +160,8 @@ export async function runTurn(
159
160
  pinnedFetchedAtMs: state.contextFetchedAtMs,
160
161
  modelSwitching: state.currentSlug !== null && state.currentSlug !== decision.slug,
161
162
  retrying: attempt > 0,
162
- query: lastUserText(req),
163
+ query: relevanceQuery(req),
164
+ firstFetch: state.contextVersion === null,
163
165
  });
164
166
  if (pin !== null) {
165
167
  contextBlock = pin.block;
@@ -589,7 +591,8 @@ export async function runTurn(
589
591
  // tool-less session is therefore not transcribed: silence beats garbage,
590
592
  // because every junk record is re-injected into every later turn.
591
593
  if (doxActive && req.tools.length > 0) {
592
- const userText = lastUserText(req);
594
+ // Record the user's words, not omp's wrappers (recap prompts, reminders).
595
+ const userText = userContent(lastUserText(req));
593
596
  const turnEnded = finishReason !== "tool_calls";
594
597
  log.debug("agentdox record turn", {
595
598
  conversationKey: req.conversationKey.slice(0, 8),
@@ -72,11 +72,23 @@ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
72
72
  modelSwitching: false,
73
73
  retrying: false,
74
74
  query: "movement rules",
75
+ firstFetch: true,
75
76
  ...over,
76
77
  };
77
78
  }
78
79
 
79
80
  describe("context bridge refresh policy", () => {
81
+ test("recent sessions ride only on a conversation's first block; refreshes ask for none", async () => {
82
+ const client = mkClient();
83
+ const { bridge } = mkBridge(client, { sessionLimit: 6 });
84
+ await bridge.resolve(input({ firstFetch: true }));
85
+ expect(client.lastLimits?.sessionLimit).toBe(6);
86
+ // A refresh (model switch) on a conversation that already had a block.
87
+ await bridge.resolve(input({ firstFetch: false, modelSwitching: true, pinnedVersion: "stale", pinnedFetchedAtMs: 1 }));
88
+ expect(client.lastLimits?.sessionLimit).toBe(0);
89
+ expect(client.assembleCalls).toBe(2);
90
+ });
91
+
80
92
  test("fetches on the first turn, then pins without re-fetching", async () => {
81
93
  const client = mkClient();
82
94
  const { bridge, db } = mkBridge(client);
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { MAX_QUERY_CHARS, relevanceQuery, userContent } from "../src/context/query.ts";
4
+
5
+ /**
6
+ * The agentdox relevance query must be the user's ask, not omp's wrappers:
7
+ * live blocks were queried with `<chat>`, `<system-reminder>` and an image
8
+ * notice, re-ranking memory against noise on every refresh.
9
+ */
10
+
11
+ const msg = (role: "user" | "assistant" | "system" | "tool", text: string) => ({ role, text }) as never;
12
+
13
+ describe("userContent", () => {
14
+ test("strips wrapper elements and keeps the user's words", () => {
15
+ expect(userContent("<system-reminder>\n5 todo items still open.\n</system-reminder>\nplease fix the water tests")).toBe("please fix the water tests");
16
+ expect(userContent("<recap>User stepped away; returning.</recap>")).toBe("");
17
+ expect(userContent("<system-notice>Background job bg_43 completed.</system-notice>")).toBe("");
18
+ });
19
+
20
+ test("an unclosed wrapper at the start is all machinery", () => {
21
+ expect(userContent("<chat>\nassistant said things\nuser said things")).toBe("");
22
+ expect(userContent("<system-reminder>Today: 2026-09-07")).toBe("");
23
+ });
24
+
25
+ test("auto-generated stubs are not content", () => {
26
+ expect(userContent("Attached image(s) from tool result:")).toBe("");
27
+ expect(userContent("(no output)")).toBe("");
28
+ expect(userContent("continue")).toBe("");
29
+ });
30
+
31
+ test("ordinary text passes through with whitespace collapsed", () => {
32
+ expect(userContent(" why does the\n\nrouter pick glm? ")).toBe("why does the router pick glm?");
33
+ });
34
+ });
35
+
36
+ describe("relevanceQuery", () => {
37
+ test("takes the last user message with real content, skipping wrapper-only turns", () => {
38
+ const req = {
39
+ messages: [
40
+ msg("system", "You are omp."),
41
+ msg("user", "add a /router command with reports"),
42
+ msg("assistant", "done"),
43
+ msg("user", "<system-reminder>5 todo items still open.</system-reminder>"),
44
+ msg("tool", "Attached image(s) from tool result:"),
45
+ msg("user", "Attached image(s) from tool result:"),
46
+ ],
47
+ };
48
+ expect(relevanceQuery(req)).toBe("add a /router command with reports");
49
+ });
50
+
51
+ test("prefers the newest real ask over the opening one", () => {
52
+ const req = { messages: [msg("user", "first ask"), msg("assistant", "ok"), msg("user", "<recap>x</recap> now fix the report window")] };
53
+ expect(relevanceQuery(req)).toBe("now fix the report window");
54
+ });
55
+
56
+ test("caps long asks so the block header stays a line", () => {
57
+ const long = "x".repeat(5_000);
58
+ const q = relevanceQuery({ messages: [msg("user", long)] });
59
+ expect(q.length).toBe(MAX_QUERY_CHARS);
60
+ expect(q.endsWith("…")).toBe(true);
61
+ });
62
+
63
+ test("nothing usable yields an empty query", () => {
64
+ expect(relevanceQuery({ messages: [msg("user", "<chat>\nlog"), msg("assistant", "hi")] })).toBe("");
65
+ expect(relevanceQuery({ messages: [] })).toBe("");
66
+ });
67
+ });
@@ -58,6 +58,7 @@ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
58
58
  modelSwitching: false,
59
59
  retrying: false,
60
60
  query: "cache prompt context injection",
61
+ firstFetch: true,
61
62
  ...over,
62
63
  };
63
64
  }