auto-model-router 0.2.13 → 0.2.14

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.2.13",
10
+ "version": "0.2.14",
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.13",
17
+ "version": "0.2.14",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -161,6 +161,15 @@ export const DEFAULT_CONFIG: RouterConfig = {
161
161
  // faster than the server reassembles buys nothing but cache misses.
162
162
  maxStalenessMs: 900_000,
163
163
  maxBlockChars: 24_000,
164
+ // Bound what agentdox SELECTS, rather than letting the block grow and then
165
+ // slicing it at `maxBlockChars`. Byte truncation cuts an entry mid-sentence
166
+ // and is blind to relevance; a limit lets the server rank first. Left
167
+ // unbounded, this scope reached 15 memory entries = 23.5k chars (~5.9k
168
+ // tokens) injected into every turn, against a 24k cap it was about to hit.
169
+ memoryLimit: 8,
170
+ // Session messages are cheap today but grow once recordTurns is on, and
171
+ // they feed straight back into the next assembly.
172
+ sessionLimit: 6,
164
173
  recordTurns: true,
165
174
  maxQueue: 64,
166
175
  },
@@ -138,6 +138,8 @@ const context = z.strictObject({
138
138
  timeoutMs: z.number().int().positive().optional(),
139
139
  maxStalenessMs: z.number().int().nonnegative().optional(),
140
140
  maxBlockChars: z.number().int().positive().optional(),
141
+ memoryLimit: z.number().int().positive().optional(),
142
+ sessionLimit: z.number().int().nonnegative().optional(),
141
143
  recordTurns: z.boolean().optional(),
142
144
  maxQueue: z.number().int().positive().optional(),
143
145
  });
@@ -372,6 +372,10 @@ export interface ContextConfig {
372
372
  maxStalenessMs: number;
373
373
  /** Hard cap on injected block size, characters. */
374
374
  maxBlockChars: number;
375
+ /** Max memory entries agentdox may select for the block. */
376
+ memoryLimit: number;
377
+ /** Max recent session messages agentdox may select for the block. */
378
+ sessionLimit: number;
375
379
  /** Write settled turns back to agentdox sessions, tagged with the served model. */
376
380
  recordTurns: boolean;
377
381
  /** Bound on queued write-backs; excess turns are dropped, never buffered unbounded. */
@@ -15,13 +15,19 @@ export interface AgentDoxClientOptions {
15
15
  log: Logger;
16
16
  }
17
17
 
18
+ /** Bounds on what agentdox may select for one block. */
19
+ export interface AssembleLimits {
20
+ memoryLimit: number;
21
+ sessionLimit: number;
22
+ }
23
+
18
24
  export interface AgentDoxClient {
19
25
  /**
20
26
  * Assembles a context slice for `scope`, biased by `query`. Falls back to
21
27
  * the server's pre-assembled baseline when assembly is unavailable (older
22
28
  * server, or no query-relevant content).
23
29
  */
24
- assemble(scope: string, query: string): Promise<string | null>;
30
+ assemble(scope: string, query: string, limits: AssembleLimits): Promise<string | null>;
25
31
  createSession(scope: string, title: string): Promise<string | null>;
26
32
  append(sessionId: string, role: "user" | "assistant", content: string, refs: string[]): Promise<boolean>;
27
33
  }
@@ -75,8 +81,15 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
75
81
  };
76
82
 
77
83
  return {
78
- async assemble(scope, query) {
79
- const res = await request("POST", "/context/assemble", { scope, query });
84
+ async assemble(scope, query, limits) {
85
+ // camelCase: the REST endpoint ignores snake_case limit keys entirely,
86
+ // which silently reads as "unbounded".
87
+ const res = await request("POST", "/context/assemble", {
88
+ scope,
89
+ query,
90
+ memoryLimit: limits.memoryLimit,
91
+ sessionLimit: limits.sessionLimit,
92
+ });
80
93
  if (res !== null && res.status === 200) {
81
94
  const prompt = promptOf(res.json);
82
95
  if (prompt !== null) return prompt;
@@ -30,6 +30,13 @@ export interface BridgeOptions {
30
30
  maxStalenessMs: number;
31
31
  /** Hard cap on injected block size; a runaway context must not dominate the prompt. */
32
32
  maxBlockChars: number;
33
+ /**
34
+ * Bounds on what agentdox SELECTS. Preferred over `maxBlockChars`, which can
35
+ * only slice bytes: the server ranks by relevance, so a limit drops the least
36
+ * useful entry instead of severing whatever straddles the cap.
37
+ */
38
+ memoryLimit: number;
39
+ sessionLimit: number;
33
40
  /** Record settled turns back into agentdox sessions. */
34
41
  recordTurns: boolean;
35
42
  /** Bound on queued write-backs; excess is dropped rather than grown unbounded. */
@@ -73,7 +80,7 @@ function appendFragment(prior: string, next: string): string {
73
80
  }
74
81
 
75
82
  export function createContextBridge(opts: BridgeOptions): ContextBridge {
76
- const { client, store, log, maxStalenessMs, maxBlockChars, recordTurns, maxQueue } = opts;
83
+ const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, sessionLimit, recordTurns, maxQueue } = opts;
77
84
 
78
85
  // Serialized write-back queue. Session appends for one conversation must
79
86
  // stay ordered, and agentdox is a local service — one worker is plenty.
@@ -111,7 +118,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
111
118
  return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
112
119
  }
113
120
 
114
- const raw = await client.assemble(input.scope, input.query);
121
+ const raw = await client.assemble(input.scope, input.query, { memoryLimit, sessionLimit });
115
122
  if (raw === null) {
116
123
  // agentdox unreachable or empty. Keep serving the pinned block if we
117
124
  // have one: stale shared context beats none, and re-using it also
@@ -27,6 +27,8 @@ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): Context
27
27
  log,
28
28
  maxStalenessMs: c.maxStalenessMs,
29
29
  maxBlockChars: c.maxBlockChars,
30
+ memoryLimit: c.memoryLimit,
31
+ sessionLimit: c.sessionLimit,
30
32
  recordTurns: c.recordTurns,
31
33
  maxQueue: c.maxQueue,
32
34
  });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import type { AgentDoxClient } from "../src/context/agentdox.ts";
3
+ import type { AgentDoxClient, AssembleLimits } from "../src/context/agentdox.ts";
4
4
  import { createContextBridge } from "../src/context/bridge.ts";
5
5
  import { createContextStore } from "../src/context/store.ts";
6
6
  import type { ContextResolveInput, TurnRecord } from "../src/context/types.ts";
@@ -15,16 +15,19 @@ interface FakeClient extends AgentDoxClient {
15
15
  appended: { sessionId: string; role: string; content: string; refs: string[] }[];
16
16
  sessionsCreated: number;
17
17
  prompt: string;
18
+ lastLimits: AssembleLimits | null;
18
19
  }
19
20
 
20
21
  function mkClient(prompt = "MEMORY: player digs in 3/4 top-down"): FakeClient {
21
22
  const c: FakeClient = {
22
23
  assembleCalls: 0,
24
+ lastLimits: null,
23
25
  appended: [],
24
26
  sessionsCreated: 0,
25
27
  prompt,
26
- async assemble() {
28
+ async assemble(_scope, _query, limits) {
27
29
  c.assembleCalls++;
30
+ c.lastLimits = limits;
28
31
  return c.prompt;
29
32
  },
30
33
  async createSession() {
@@ -49,6 +52,8 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
49
52
  log,
50
53
  maxStalenessMs: 900_000,
51
54
  maxBlockChars: 24_000,
55
+ memoryLimit: 8,
56
+ sessionLimit: 6,
52
57
  recordTurns: true,
53
58
  maxQueue: 64,
54
59
  ...over,
@@ -89,6 +94,22 @@ describe("context bridge refresh policy", () => {
89
94
  }
90
95
  });
91
96
 
97
+ 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 unbounded — hence pinning that the limits are passed.
103
+ const client = mkClient();
104
+ const { bridge, db } = mkBridge(client, { memoryLimit: 5, sessionLimit: 2 });
105
+ try {
106
+ await bridge.resolve(input());
107
+ expect(client.lastLimits).toEqual({ memoryLimit: 5, sessionLimit: 2 });
108
+ } finally {
109
+ db.close();
110
+ }
111
+ });
112
+
92
113
  test("refreshes when the model switches, because the cache is already forfeit", async () => {
93
114
  const client = mkClient();
94
115
  const { bridge, db } = mkBridge(client);
@@ -217,6 +238,8 @@ describe("context bridge refresh policy", () => {
217
238
  log,
218
239
  maxStalenessMs: 900_000,
219
240
  maxBlockChars: 24_000,
241
+ memoryLimit: 8,
242
+ sessionLimit: 6,
220
243
  recordTurns: true,
221
244
  maxQueue: 64,
222
245
  };
@@ -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, recordTurns: false, maxQueue: 64 },
72
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, 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, recordTurns: false, maxQueue: 64 },
73
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, 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: [],