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
@@ -139,14 +139,21 @@ export interface FilterConfig {
139
139
  */
140
140
  contextHeadroom: number;
141
141
  /**
142
- * How hard to penalise slow models in candidate scoring. A model's mean
143
- * time-to-first-token above `latencyReferenceMs` inflates its effective
144
- * cost — the same lever trust uses for flakiness so a faster model of
145
- * equal quality and price wins. 0 disables latency scoring entirely.
142
+ * How hard to penalise slow models in candidate scoring. A model's expected
143
+ * total wait TTFT plus streaming the expected completion at its measured
144
+ * throughputabove the reference inflates its effective cost, the same lever
145
+ * trust uses for flakiness, so a faster model of equal quality and price wins.
146
+ * 0 disables latency scoring entirely.
146
147
  */
147
148
  latencyWeight: number;
148
- /** TTFT (ms) below which a model is considered fully responsive (no penalty). */
149
+ /** Reference TTFT (ms): the start-latency component that accrues no penalty. */
149
150
  latencyReferenceMs: number;
151
+ /**
152
+ * Reference throughput (tokens/second): the streaming speed that accrues no
153
+ * penalty. Below it, a slow-streaming model's expected wait exceeds the
154
+ * reference and its effective cost is inflated.
155
+ */
156
+ latencyReferenceTokensPerSec: number;
150
157
  /** Streamed samples required before latency is scored against a model. */
151
158
  latencyMinSamples: number;
152
159
  }
@@ -326,6 +333,71 @@ export interface LedgerConfig {
326
333
  conversationTtlMs: number;
327
334
  }
328
335
 
336
+ /**
337
+ * agentdox bridge: one shared project context that follows a conversation
338
+ * across model switches, plus a model-attributed transcript written back.
339
+ *
340
+ * Off by default. Enabling it injects a project-context block into the system
341
+ * prefix; the block is PINNED per conversation and refreshed only when the
342
+ * prompt cache is already cold (see `src/context/bridge.ts`), so sharing
343
+ * context does not cost a cache miss every turn.
344
+ */
345
+ export interface ContextConfig {
346
+ enabled: boolean;
347
+ /** agentdox REST base URL, e.g. `http://localhost:3003`. */
348
+ baseUrl: string;
349
+ /** Bearer token with read+write on the project scope. From `AGENTDOX_TOKEN`. */
350
+ token: string;
351
+ /**
352
+ * Fallback project scope when a request carries no `X-Agentdox-Scope`
353
+ * header. Empty ⇒ the bridge is inert for unlabelled requests rather than
354
+ * guessing, so one harness cannot leak context into another's project.
355
+ */
356
+ defaultScope: string;
357
+ /** Per-request timeout against agentdox, ms. */
358
+ timeoutMs: number;
359
+ /**
360
+ * Upper bound on how stale a pinned context block may get, ms. Reaching it
361
+ * forces a refresh on the next turn even if the model did not change —
362
+ * the one case where the bridge knowingly spends a cache miss. 0 disables
363
+ * the TTL, refreshing only on turns whose cache is already forfeit.
364
+ */
365
+ maxStalenessMs: number;
366
+ /** Hard cap on injected block size, characters. */
367
+ maxBlockChars: number;
368
+ /** Write settled turns back to agentdox sessions, tagged with the served model. */
369
+ recordTurns: boolean;
370
+ /** Bound on queued write-backs; excess turns are dropped, never buffered unbounded. */
371
+ maxQueue: number;
372
+ }
373
+
374
+ /**
375
+ * Context optimization (compaction): before dispatch, shrink stale, low-value
376
+ * bulk — chiefly old tool output — so long agentic conversations cost less and
377
+ * keep fitting narrower-window models. Deterministic and reversible-by-reference:
378
+ * every elision leaves an in-band breadcrumb so the model can re-run the tool.
379
+ * Off by default. See docs/context-optimization.md.
380
+ */
381
+ export interface CompactionConfig {
382
+ enabled: boolean;
383
+ /** Compact when the estimated prompt exceeds this many tokens. */
384
+ budgetTokens: number;
385
+ /** Also compact when the prompt would overflow the profile's context window. */
386
+ fitToWindow: boolean;
387
+ /** Never touch the last N user/assistant turns or the volatile tail. */
388
+ protectRecentTurns: number;
389
+ /** Tool results larger than this (outside the protected window) are truncated. */
390
+ maxToolResultBytes: number;
391
+ /** Bytes of a truncated tool result's head to keep. */
392
+ keepHeadBytes: number;
393
+ /** Bytes of a truncated tool result's tail to keep. */
394
+ keepTailBytes: number;
395
+ /** Elide an older tool result when a newer call to the same resource supersedes it. */
396
+ elideSupersededReads: boolean;
397
+ /** Collapse byte-identical repeated tool results to a single copy. */
398
+ collapseDuplicateResults: boolean;
399
+ }
400
+
329
401
  export interface RouterConfig {
330
402
  server: ServerConfig;
331
403
  openrouter: OpenRouterConfig;
@@ -338,6 +410,8 @@ export interface RouterConfig {
338
410
  hysteresis: HysteresisConfig;
339
411
  exploration: ExplorationConfig;
340
412
  cache: CacheConfig;
413
+ context: ContextConfig;
414
+ compaction: CompactionConfig;
341
415
  budget: BudgetConfig;
342
416
  profiles: ProfileConfig[];
343
417
  ledger: LedgerConfig;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * agentdox REST client.
3
+ *
4
+ * Deliberately tiny and total: every method resolves to a value or null and
5
+ * never throws. agentdox is an ENRICHMENT, not a dependency — if it is down,
6
+ * slow, or unauthorized, the turn must still route and dispatch normally.
7
+ */
8
+
9
+ import type { Logger } from "../util/log.ts";
10
+
11
+ export interface AgentDoxClientOptions {
12
+ baseUrl: string;
13
+ token: string;
14
+ timeoutMs: number;
15
+ log: Logger;
16
+ }
17
+
18
+ export interface AgentDoxClient {
19
+ /**
20
+ * Assembles a context slice for `scope`, biased by `query`. Falls back to
21
+ * the server's pre-assembled baseline when assembly is unavailable (older
22
+ * server, or no query-relevant content).
23
+ */
24
+ assemble(scope: string, query: string): Promise<string | null>;
25
+ createSession(scope: string, title: string): Promise<string | null>;
26
+ append(sessionId: string, role: "user" | "assistant", content: string, refs: string[]): Promise<boolean>;
27
+ }
28
+
29
+ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClient {
30
+ const { baseUrl, token, timeoutMs, log } = opts;
31
+ const root = baseUrl.replace(/\/+$/, "");
32
+
33
+ const request = async (
34
+ method: string,
35
+ path: string,
36
+ body?: unknown,
37
+ ): Promise<{ status: number; json: unknown } | null> => {
38
+ const ctl = new AbortController();
39
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
40
+ try {
41
+ const headers: Record<string, string> = { authorization: `Bearer ${token}` };
42
+ if (body !== undefined) headers["content-type"] = "application/json";
43
+ const res = await fetch(`${root}${path}`, {
44
+ method,
45
+ headers,
46
+ signal: ctl.signal,
47
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
48
+ });
49
+ // A 404 is meaningful data (no snapshot/brief yet), not a failure.
50
+ const text = await res.text();
51
+ let parsed: unknown = null;
52
+ try {
53
+ parsed = text === "" ? null : JSON.parse(text);
54
+ } catch {
55
+ parsed = null;
56
+ }
57
+ return { status: res.status, json: parsed };
58
+ } catch (err) {
59
+ // Timeouts, connection refused, DNS — all the same to the caller.
60
+ log.debug("agentdox request failed", {
61
+ method,
62
+ path,
63
+ error: err instanceof Error ? err.message : String(err),
64
+ });
65
+ return null;
66
+ } finally {
67
+ clearTimeout(timer);
68
+ }
69
+ };
70
+
71
+ const promptOf = (json: unknown): string | null => {
72
+ if (typeof json !== "object" || json === null) return null;
73
+ const p = (json as { prompt?: unknown }).prompt;
74
+ return typeof p === "string" && p.trim() !== "" ? p : null;
75
+ };
76
+
77
+ return {
78
+ async assemble(scope, query) {
79
+ const res = await request("POST", "/context/assemble", { scope, query });
80
+ if (res !== null && res.status === 200) {
81
+ const prompt = promptOf(res.json);
82
+ if (prompt !== null) return prompt;
83
+ }
84
+ if (res !== null && (res.status === 401 || res.status === 403)) {
85
+ log.warn("agentdox rejected the router token; context injection is off for this scope", {
86
+ scope,
87
+ status: res.status,
88
+ });
89
+ return null;
90
+ }
91
+ // Baseline fallback: the server-side auto-context job keeps this fresh.
92
+ const snap = await request("GET", `/context/snapshot?scope=${encodeURIComponent(scope)}`);
93
+ if (snap === null || snap.status !== 200) return null;
94
+ return promptOf(snap.json);
95
+ },
96
+
97
+ async createSession(scope, title) {
98
+ const res = await request("POST", "/sessions", { scope, title });
99
+ if (res === null || (res.status !== 200 && res.status !== 201)) return null;
100
+ const id = (res.json as { id?: unknown } | null)?.id;
101
+ return typeof id === "string" ? id : null;
102
+ },
103
+
104
+ async append(sessionId, role, content, refs) {
105
+ const res = await request("POST", `/sessions/${encodeURIComponent(sessionId)}/messages`, {
106
+ role,
107
+ content,
108
+ refs,
109
+ });
110
+ return res !== null && (res.status === 200 || res.status === 201);
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The refresh policy, which is the whole design.
3
+ *
4
+ * Injecting fresh context every turn would defeat the router's single largest
5
+ * cost lever: the prompt cache. The block sits at the front of the prefix, so
6
+ * changing it invalidates everything after it. The rule is therefore to
7
+ * re-fetch ONLY when the prefix is already being paid for:
8
+ *
9
+ * - no pin yet first turn; nothing is warm
10
+ * - model switching the router already decided to forfeit the cache
11
+ * - retrying an escalation/failover dispatch is cold by definition
12
+ * - staleness TTL a bounded upper limit on how old context may get
13
+ *
14
+ * Between those moments the same bytes are re-injected verbatim and the cache
15
+ * survives. This is what makes "context refreshes as I switch models" cheap
16
+ * rather than ruinous: the refresh rides on a cache miss that was happening
17
+ * anyway.
18
+ */
19
+
20
+ import { sha256Hex } from "../util/hash.ts";
21
+ import type { Logger } from "../util/log.ts";
22
+ import type { AgentDoxClient } from "./agentdox.ts";
23
+ import type { ContextBlockStore, ContextBridge, ContextPin, ContextResolveInput, TurnRecord } from "./types.ts";
24
+
25
+ export interface BridgeOptions {
26
+ client: AgentDoxClient;
27
+ store: ContextBlockStore;
28
+ log: Logger;
29
+ /** Upper bound on pinned-context age, ms. 0 ⇒ refresh only on cache-cold turns. */
30
+ maxStalenessMs: number;
31
+ /** Hard cap on injected block size; a runaway context must not dominate the prompt. */
32
+ maxBlockChars: number;
33
+ /** Record settled turns back into agentdox sessions. */
34
+ recordTurns: boolean;
35
+ /** Bound on queued write-backs; excess is dropped rather than grown unbounded. */
36
+ maxQueue: number;
37
+ }
38
+
39
+ /** Wraps the raw agentdox slice in a delimiter the model can reason about. */
40
+ function renderBlock(raw: string, maxChars: number): string {
41
+ const body = raw.length > maxChars ? `${raw.slice(0, maxChars)}\n[...truncated]` : raw;
42
+ return [
43
+ "<project-context source=\"agentdox\">",
44
+ "Durable project memory, documentation, and history shared across every model that",
45
+ "serves this conversation. Treat it as established fact; prefer it over re-deriving.",
46
+ "",
47
+ body,
48
+ "</project-context>",
49
+ ].join("\n");
50
+ }
51
+
52
+ export function createContextBridge(opts: BridgeOptions): ContextBridge {
53
+ const { client, store, log, maxStalenessMs, maxBlockChars, recordTurns, maxQueue } = opts;
54
+
55
+ // Serialized write-back queue. Session appends for one conversation must
56
+ // stay ordered, and agentdox is a local service — one worker is plenty.
57
+ let queue: Promise<void> = Promise.resolve();
58
+ let queued = 0;
59
+ let closed = false;
60
+
61
+ const shouldRefresh = (input: ContextResolveInput, pin: ContextPin | null): boolean => {
62
+ if (pin === null) return true;
63
+ if (input.modelSwitching || input.retrying) return true;
64
+ // Staleness is measured from when THIS conversation last refreshed, not
65
+ // from the shared block row: another conversation confirming the same
66
+ // content must not silently extend our TTL. The re-check is cheap
67
+ // anyway — identical content hashes to the same version, so a
68
+ // confirming refresh re-sends identical bytes and the cache survives.
69
+ if (maxStalenessMs > 0 && Date.now() - input.pinnedFetchedAtMs > maxStalenessMs) return true;
70
+ return false;
71
+ };
72
+
73
+ return {
74
+ enabled: true,
75
+
76
+ async resolve(input) {
77
+ if (input.scope === "") return null;
78
+
79
+ const pinned = input.pinnedVersion === null ? null : store.get(input.pinnedVersion);
80
+ if (!shouldRefresh(input, pinned) && pinned !== null) {
81
+ // Carry the conversation's own pin time forward, so the TTL keeps
82
+ // counting from its last real refresh rather than resetting to
83
+ // whenever some other conversation last touched this block.
84
+ return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
85
+ }
86
+
87
+ const raw = await client.assemble(input.scope, input.query);
88
+ if (raw === null) {
89
+ // agentdox unreachable or empty. Keep serving the pinned block if we
90
+ // have one: stale shared context beats none, and re-using it also
91
+ // keeps the prefix stable.
92
+ return pinned;
93
+ }
94
+
95
+ const block = renderBlock(raw, maxBlockChars);
96
+ // Content hash, not a timestamp: agentdox re-assembles on a timer, and
97
+ // an unchanged assembly MUST keep its version so the cache survives.
98
+ const version = sha256Hex(block).slice(0, 32);
99
+ const pin: ContextPin = { version, block, fetchedAtMs: Date.now() };
100
+ try {
101
+ store.put(input.scope, pin);
102
+ } catch (err) {
103
+ log.debug("context block persist failed", { error: err instanceof Error ? err.message : String(err) });
104
+ }
105
+ if (pinned !== null && pinned.version !== version) {
106
+ log.debug("context refreshed", {
107
+ scope: input.scope,
108
+ from: pinned.version.slice(0, 8),
109
+ to: version.slice(0, 8),
110
+ reason: input.retrying ? "retry" : input.modelSwitching ? "model-switch" : "stale",
111
+ });
112
+ }
113
+ return pin;
114
+ },
115
+
116
+ recordTurn(rec: TurnRecord) {
117
+ if (!recordTurns || closed || rec.scope === "") return;
118
+ if (rec.userText === "" && rec.assistantText === "") return;
119
+ if (queued >= maxQueue) {
120
+ log.debug("agentdox write-back queue full; dropping turn record", { queued });
121
+ return;
122
+ }
123
+ queued++;
124
+ queue = queue
125
+ .then(async () => {
126
+ let sessionId = store.sessionFor(rec.conversationKey);
127
+ if (sessionId === null) {
128
+ sessionId = await client.createSession(rec.scope, rec.title);
129
+ if (sessionId === null) return;
130
+ store.bindSession(rec.conversationKey, rec.scope, sessionId);
131
+ }
132
+ // Model attribution rides on refs, which agentdox already carries
133
+ // per message. This is what makes the transcript newly useful:
134
+ // every turn shows WHICH model produced it.
135
+ const refs = [`model:${rec.slug}`, `tier:${rec.tier}`];
136
+ if (rec.userText !== "") await client.append(sessionId, "user", rec.userText, []);
137
+ if (rec.assistantText !== "") await client.append(sessionId, "assistant", rec.assistantText, refs);
138
+ })
139
+ .catch((err: unknown) => {
140
+ log.debug("agentdox write-back failed", { error: err instanceof Error ? err.message : String(err) });
141
+ })
142
+ .finally(() => {
143
+ queued--;
144
+ });
145
+ },
146
+
147
+ async flush() {
148
+ await queue;
149
+ },
150
+
151
+ close() {
152
+ closed = true;
153
+ },
154
+ };
155
+ }
156
+
157
+ /** The inert bridge used when agentdox is not configured. Every call is free. */
158
+ export function createDisabledBridge(): ContextBridge {
159
+ return {
160
+ enabled: false,
161
+ resolve: async () => null,
162
+ recordTurn: () => {},
163
+ flush: async () => {},
164
+ close: () => {},
165
+ };
166
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Bridge factory. Returns the inert bridge unless agentdox is fully
3
+ * configured, so every call site can stay unconditional.
4
+ */
5
+
6
+ import type { Database } from "bun:sqlite";
7
+
8
+ import type { RouterConfig } from "../config/types.ts";
9
+ import { createLogger } from "../util/log.ts";
10
+ import { createAgentDoxClient } from "./agentdox.ts";
11
+ import { createContextBridge, createDisabledBridge } from "./bridge.ts";
12
+ import { createContextStore } from "./store.ts";
13
+ import type { ContextBridge } from "./types.ts";
14
+
15
+ export type { ContextBridge, ContextPin, ContextResolveInput, TurnRecord } from "./types.ts";
16
+ export { createContextBridge, createDisabledBridge } from "./bridge.ts";
17
+ export { createContextStore } from "./store.ts";
18
+ export { createAgentDoxClient } from "./agentdox.ts";
19
+
20
+ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): ContextBridge {
21
+ const c = cfg.context;
22
+ if (!c.enabled || c.baseUrl === "" || c.token === "") return createDisabledBridge();
23
+ const log = createLogger(cfg.logLevel);
24
+ return createContextBridge({
25
+ client: createAgentDoxClient({ baseUrl: c.baseUrl, token: c.token, timeoutMs: c.timeoutMs, log }),
26
+ store: createContextStore(db),
27
+ log,
28
+ maxStalenessMs: c.maxStalenessMs,
29
+ maxBlockChars: c.maxBlockChars,
30
+ recordTurns: c.recordTurns,
31
+ maxQueue: c.maxQueue,
32
+ });
33
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Persistence for the agentdox bridge.
3
+ *
4
+ * Blocks are content-addressed by version and shared across conversations, so
5
+ * ten conversations on one project store one copy. Persisting them (rather
6
+ * than caching in memory) means a router restart can re-inject the SAME bytes
7
+ * a conversation was already using — OpenRouter's prompt cache outlives our
8
+ * process, and re-fetching would needlessly change the prefix.
9
+ *
10
+ * Tables are created by `util/sqlite.ts`, the single migration path.
11
+ */
12
+
13
+ import type { Database, Statement } from "bun:sqlite";
14
+
15
+ import type { ContextBlockStore, ContextPin } from "./types.ts";
16
+
17
+ interface BlockRow {
18
+ version: string;
19
+ block: string;
20
+ fetched_at_ms: number;
21
+ }
22
+
23
+ interface SessionRow {
24
+ session_id: string;
25
+ }
26
+
27
+ export function createContextStore(db: Database): ContextBlockStore {
28
+ // Hoisted: these run on the turn hot path.
29
+ const selectBlock: Statement<BlockRow, [string]> = db.query(
30
+ "SELECT version, block, fetched_at_ms FROM context_blocks WHERE version = ?",
31
+ );
32
+ const insertBlock = db.query(`
33
+ INSERT INTO context_blocks (version, scope, block, fetched_at_ms)
34
+ VALUES ($version, $scope, $block, $fetchedAtMs)
35
+ ON CONFLICT(version) DO UPDATE SET fetched_at_ms = excluded.fetched_at_ms
36
+ `);
37
+ const selectSession: Statement<SessionRow, [string]> = db.query(
38
+ "SELECT session_id FROM agentdox_sessions WHERE conversation_key = ?",
39
+ );
40
+ const insertSession = db.query(`
41
+ INSERT INTO agentdox_sessions (conversation_key, scope, session_id, created_at_ms)
42
+ VALUES ($key, $scope, $sessionId, $createdAtMs)
43
+ ON CONFLICT(conversation_key) DO UPDATE SET session_id = excluded.session_id
44
+ `);
45
+ const deleteStale: Statement<unknown, [number]> = db.query("DELETE FROM context_blocks WHERE fetched_at_ms < ?");
46
+
47
+ return {
48
+ get(version) {
49
+ const row = selectBlock.get(version);
50
+ if (row === null) return null;
51
+ return { version: row.version, block: row.block, fetchedAtMs: row.fetched_at_ms };
52
+ },
53
+
54
+ put(scope, pin: ContextPin) {
55
+ insertBlock.run({
56
+ $version: pin.version,
57
+ $scope: scope,
58
+ $block: pin.block,
59
+ $fetchedAtMs: pin.fetchedAtMs,
60
+ });
61
+ },
62
+
63
+ sessionFor(conversationKey) {
64
+ const row = selectSession.get(conversationKey);
65
+ return row === null ? null : row.session_id;
66
+ },
67
+
68
+ bindSession(conversationKey, scope, sessionId) {
69
+ insertSession.run({
70
+ $key: conversationKey,
71
+ $scope: scope,
72
+ $sessionId: sessionId,
73
+ $createdAtMs: Date.now(),
74
+ });
75
+ },
76
+
77
+ prune(maxAgeMs) {
78
+ const res = deleteStale.run(Date.now() - maxAgeMs) as unknown as { changes?: number };
79
+ return res.changes ?? 0;
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * agentdox bridge — one shared project context that survives model switches.
3
+ *
4
+ * The router is the only choke point that sees every model in every harness,
5
+ * so injecting project memory/docs/brief HERE gives every candidate model the
6
+ * same context without per-harness MCP wiring.
7
+ *
8
+ * The load-bearing constraint is the prompt cache. A context block that
9
+ * changes per turn sits at the front of the prefix and turns every turn into a
10
+ * full cache miss — which would cost far more than routing saves. So a block
11
+ * is PINNED per conversation and only re-fetched at moments the prefix is
12
+ * already forfeit (a model switch, an escalation retry) or after a staleness
13
+ * TTL. See `bridge.ts` for the policy.
14
+ */
15
+
16
+ /** A pinned, prompt-ready context block. */
17
+ export interface ContextPin {
18
+ /**
19
+ * Content hash of `block`. Deliberately NOT agentdox's `assembledAt`: the
20
+ * server re-assembles on a timer, so a timestamp changes even when nothing
21
+ * about the content did, and that would break a warm cache for no reason.
22
+ */
23
+ version: string;
24
+ /** Text appended to the system prefix by the wire. */
25
+ block: string;
26
+ fetchedAtMs: number;
27
+ }
28
+
29
+ export interface ContextResolveInput {
30
+ /** agentdox project slug. Empty ⇒ the bridge is inert for this turn. */
31
+ scope: string;
32
+ conversationKey: string;
33
+ /** Version pinned by the previous turn; null on a fresh conversation. */
34
+ pinnedVersion: string | null;
35
+ /** When that pin was fetched, for the staleness TTL. */
36
+ pinnedFetchedAtMs: number;
37
+ /** This turn dispatches to a different slug than the last committed turn. */
38
+ modelSwitching: boolean;
39
+ /** An escalation or failover retry — the prefix is cold either way. */
40
+ retrying: boolean;
41
+ /** Latest user text, used to bias agentdox relevance ranking. */
42
+ query: string;
43
+ }
44
+
45
+ /** One settled turn, recorded to agentdox with the model that served it. */
46
+ export interface TurnRecord {
47
+ scope: string;
48
+ conversationKey: string;
49
+ /** Title used if this is the first turn and a session must be created. */
50
+ title: string;
51
+ userText: string;
52
+ assistantText: string;
53
+ /** The slug that actually served the turn — the model attribution. */
54
+ slug: string;
55
+ tier: string;
56
+ }
57
+
58
+ export interface ContextBridge {
59
+ /** False when no agentdox URL/token is configured; every call is then a no-op. */
60
+ readonly enabled: boolean;
61
+ /** Resolves the block to inject. Never throws: agentdox being down degrades to null. */
62
+ resolve(input: ContextResolveInput): Promise<ContextPin | null>;
63
+ /** Queues a turn for write-back. Returns immediately; never blocks the turn. */
64
+ recordTurn(rec: TurnRecord): void;
65
+ /** Drains the write queue. For tests and shutdown. */
66
+ flush(): Promise<void>;
67
+ close(): void;
68
+ }
69
+
70
+ /** Content-addressed store of fetched blocks, so a restart keeps a warm prefix. */
71
+ export interface ContextBlockStore {
72
+ get(version: string): ContextPin | null;
73
+ put(scope: string, pin: ContextPin): void;
74
+ /** agentdox session id previously opened for a conversation. */
75
+ sessionFor(conversationKey: string): string | null;
76
+ bindSession(conversationKey: string, scope: string, sessionId: string): void;
77
+ prune(maxAgeMs: number): number;
78
+ }