auto-model-router 0.4.2 → 0.4.4

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 (48) hide show
  1. package/.gitattributes +2 -0
  2. package/.omp-plugin/marketplace.json +2 -2
  3. package/README.md +31 -3
  4. package/bunfig.toml +2 -0
  5. package/omp-extension/report-logic.ts +46 -0
  6. package/omp-extension/router-configure.ts +55 -3
  7. package/package.json +1 -1
  8. package/src/catalog/composite.ts +4 -1
  9. package/src/cli/config-wizard.ts +8 -1
  10. package/src/config/defaults.ts +8 -0
  11. package/src/config/hot-reload.ts +58 -9
  12. package/src/config/schema.ts +5 -1
  13. package/src/config/types.ts +34 -0
  14. package/src/cost/ledger.ts +84 -8
  15. package/src/cost/report.ts +34 -4
  16. package/src/cost/summary.ts +231 -0
  17. package/src/cost/types.ts +35 -3
  18. package/src/router/candidates.ts +1 -1
  19. package/src/router/classify.ts +2 -2
  20. package/src/router/compaction.ts +2 -1
  21. package/src/router/learned.ts +11 -1
  22. package/src/router/select.ts +27 -3
  23. package/src/server/compaction-digest.ts +129 -0
  24. package/src/server/digest.ts +68 -4
  25. package/src/server/http.ts +50 -7
  26. package/src/server/providers.ts +1 -0
  27. package/src/server/turn.ts +38 -1
  28. package/src/util/sqlite.ts +7 -0
  29. package/src/wire/openai/request.ts +4 -0
  30. package/src/wire/types.ts +7 -0
  31. package/test/cache-control.test.ts +1 -1
  32. package/test/compaction.test.ts +40 -3
  33. package/test/digest.test.ts +44 -0
  34. package/test/failover.test.ts +4 -4
  35. package/test/hot-reload.test.ts +37 -1
  36. package/test/learned.test.ts +21 -1
  37. package/test/migrations.test.ts +84 -0
  38. package/test/report-hub.test.ts +1 -1
  39. package/test/report-logic.test.ts +11 -1
  40. package/test/report.test.ts +29 -1
  41. package/test/select.test.ts +26 -2
  42. package/test/summary.test.ts +171 -0
  43. package/test/support/preload.ts +19 -0
  44. package/test/tokens.test.ts +68 -0
  45. package/test/trust-attribution.test.ts +37 -0
  46. package/test/turn.test.ts +69 -4
  47. package/tools/gen-migration-fixtures.ts +69 -0
  48. package/tools/train-classifier.ts +75 -20
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { CatalogSnapshot } from "../catalog/types.ts";
10
10
  import type { ProfileConfig, RouterConfig } from "../config/types.ts";
11
- import { priceAt } from "../cost/forecast.ts";
11
+ import { forecast, priceAt } from "../cost/forecast.ts";
12
12
  import type { Ledger } from "../cost/types.ts";
13
13
  import { explorationDraw } from "./explore.ts";
14
14
  import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
@@ -361,7 +361,11 @@ export function select(args: SelectArgs): Decision {
361
361
  // query per signal kind, instead of per-model individual lookups.
362
362
  const candidateSignals =
363
363
  ledger !== null && snapshot.models.length > 0
364
- ? ledger.signals?.(snapshot.models.map((m) => m.slug), cfg.filters.trustScopedByHarness ? req.harnessId : undefined)
364
+ ? ledger.signals?.(
365
+ snapshot.models.map((m) => m.slug),
366
+ cfg.filters.trustScopedByHarness ? req.harnessId : undefined,
367
+ cfg.filters.feedbackByTask ? classification.task : undefined,
368
+ )
365
369
  : undefined;
366
370
  // What an escalated retry has actually been billing per prompt token, for
367
371
  // the escalation-cost term in candidate scoring. Read once per turn; null
@@ -606,13 +610,33 @@ export function select(args: SelectArgs): Decision {
606
610
  }
607
611
  const stripAssistantReasoning = !(chosen.model.supportsReasoning && REASONING_REPLAY_AUTHORS[chosen.model.author] === true);
608
612
 
613
+ // The recorded forecast is the EXPECTED price of this dispatch, not the
614
+ // cold worst case candidates are ranked on. When the chosen model's cache
615
+ // is warm, the previous prompt's tokens are priced as cache reads at the
616
+ // model's measured hit rate; coldUsd stays the cold figure the budget
617
+ // guards used. Before this every recorded forecast was cold while nine
618
+ // turns in ten were warm: 89% over-predicted, median error 220%.
619
+ let expectedForecast = chosen.forecast;
620
+ if (warmSlug !== null && chosen.model.slug === warmSlug && effFeatures.promptTokens > 0 && state.lastPromptTokens > 0) {
621
+ const cachedShare = Math.min(1, state.lastPromptTokens / effFeatures.promptTokens);
622
+ let images = 0;
623
+ if (req.hasImages) for (const m of req.messages) images += m.images;
624
+ const warm = forecast(chosen.model, {
625
+ promptTokens: effFeatures.promptTokens,
626
+ completionTokens: EXPECTED_COMPLETION_TOKENS,
627
+ cacheHitRate: cacheHitExpectation(chosen.model.slug).rate * cachedShare,
628
+ images,
629
+ });
630
+ expectedForecast = { ...warm, coldUsd: chosen.forecast.coldUsd };
631
+ }
632
+
609
633
  return {
610
634
  slug: chosen.model.slug,
611
635
  fallbacks,
612
636
  tier: chosenTier,
613
637
  classification: cls,
614
638
  features,
615
- forecast: chosen.forecast,
639
+ forecast: expectedForecast,
616
640
  sessionId: state.sessionId,
617
641
  sticky,
618
642
  cacheBreakpointMessageIndices,
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Summarising compaction: when the compaction plan gains new edits, a cheap
3
+ * model digests the tool results those edits would otherwise truncate or stub,
4
+ * and the digest rides in the persisted plan in place of the breadcrumb.
5
+ *
6
+ * Plain compaction keeps a head and a tail of a stale tool result; a digest
7
+ * keeps what the task needs from all of it (paths, identifiers, errors, the
8
+ * code the agent will edit) in a few hundred chars. The digest is stored on
9
+ * the edit, so the bytes sent stay identical on every later turn until the
10
+ * plan changes — the same byte-stability plain edits have, which is what keeps
11
+ * the prompt cache warm.
12
+ *
13
+ * Bounded: at most `compaction.digestMaxPerTurn` digests per turn, each under
14
+ * the digest's own cost guard and timeout, largest results first. A digest
15
+ * that fails or declines leaves the plain edit in place; nothing is lost.
16
+ */
17
+
18
+ import type { RouterConfig } from "../config/types.ts";
19
+ import { compactedBytes } from "../router/compaction.ts";
20
+ import type { Logger } from "../util/log.ts";
21
+ import type { CompactionEdit, NormRequest } from "../wire/types.ts";
22
+ import type { DigestRequest, DigestResult } from "./digest.ts";
23
+
24
+ export interface CompactionDigester {
25
+ digest(req: DigestRequest): Promise<DigestResult>;
26
+ /** See Digester.noteToolCalls; optional so a fake need not implement it. */
27
+ noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
28
+ }
29
+
30
+ export interface DigestCompactionArgs {
31
+ req: NormRequest;
32
+ /** The plan this turn dispatches with; edits gain `digest` in place. */
33
+ plan: CompactionEdit[];
34
+ /** The tier this turn routed to: the digest pays off only above `digest.fromTier`. */
35
+ tier: string;
36
+ cfg: RouterConfig;
37
+ digester: CompactionDigester;
38
+ /** Per-turn memo (index:bytes → digest) so a retry does not pay twice. */
39
+ memo: Map<string, string>;
40
+ /** The user's current ask, steering what the digest keeps. */
41
+ query: string;
42
+ log: Logger;
43
+ }
44
+
45
+ /** Tool name and parsed arguments for a tool-result message, via its call id. */
46
+ function toolOf(req: NormRequest, index: number): { name: string; input: Record<string, unknown> } | null {
47
+ const m = req.messages[index];
48
+ if (m === undefined || m.role !== "tool") return null;
49
+ let name = m.toolName ?? "";
50
+ let input: Record<string, unknown> = {};
51
+ if (m.toolCallId !== undefined) {
52
+ for (const a of req.messages) {
53
+ if (a.role !== "assistant") continue;
54
+ const tc = a.toolCalls.find((c) => c.id === m.toolCallId);
55
+ if (tc === undefined) continue;
56
+ if (name === "") name = tc.name;
57
+ try {
58
+ const parsed: unknown = JSON.parse(tc.argsJson);
59
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) input = parsed as Record<string, unknown>;
60
+ } catch {
61
+ // Unparseable args: the marker just names the tool.
62
+ }
63
+ break;
64
+ }
65
+ }
66
+ return name === "" ? null : { name, input };
67
+ }
68
+
69
+ /**
70
+ * Digests the plan's new (undigested) edits, largest first, up to the per-turn
71
+ * cap. Returns the change in bytes saved versus the plain edits: positive when
72
+ * the digests are smaller than what truncation would have kept, negative when
73
+ * a digest keeps more than head+tail did (it usually does, and that is the point).
74
+ */
75
+ export async function digestCompactionEdits(args: DigestCompactionArgs): Promise<number> {
76
+ const { req, plan, tier, cfg, digester, memo, query, log } = args;
77
+ const max = cfg.compaction.digestMaxPerTurn;
78
+ if (max <= 0) return 0;
79
+ const pending = plan.filter((e) => e.digest === undefined).sort((a, b) => b.bytes - a.bytes);
80
+ let delta = 0;
81
+ const work: CompactionEdit[] = [];
82
+ for (const e of pending) {
83
+ const key = `${e.index}:${e.bytes}`;
84
+ const remembered = memo.get(key);
85
+ if (remembered !== undefined) {
86
+ delta += compactedBytes(e.bytes, e) - Buffer.byteLength(remembered);
87
+ e.digest = remembered;
88
+ continue;
89
+ }
90
+ if (work.length >= max) break;
91
+ work.push(e);
92
+ }
93
+ if (work.length === 0) return delta;
94
+
95
+ const results = await Promise.all(
96
+ work.map(async (e): Promise<{ edit: CompactionEdit; result: DigestResult }> => {
97
+ const tool = toolOf(req, e.index);
98
+ const m = req.messages[e.index];
99
+ if (tool === null || m === undefined) return { edit: e, result: { digested: false, reason: "tool result has no tool name" } };
100
+ try {
101
+ const result = await digester.digest({
102
+ ompSessionId: req.ompSessionId,
103
+ harnessId: req.harnessId,
104
+ toolName: tool.name,
105
+ input: tool.input,
106
+ content: m.text,
107
+ query,
108
+ tier,
109
+ source: "compaction",
110
+ });
111
+ return { edit: e, result };
112
+ } catch (err) {
113
+ return { edit: e, result: { digested: false, reason: err instanceof Error ? err.message : String(err) } };
114
+ }
115
+ }),
116
+ );
117
+ for (const { edit, result } of results) {
118
+ if (!result.digested) {
119
+ log.debug("compaction digest declined", { index: edit.index, bytes: edit.bytes, reason: result.reason });
120
+ continue;
121
+ }
122
+ const plain = compactedBytes(edit.bytes, edit);
123
+ edit.digest = result.text;
124
+ memo.set(`${edit.index}:${edit.bytes}`, result.text);
125
+ delta += plain - Buffer.byteLength(result.text);
126
+ log.info("compaction digest", { index: edit.index, bytes: edit.bytes, chars: result.outputChars, model: result.model, usd: result.usd });
127
+ }
128
+ return delta;
129
+ }
@@ -23,6 +23,7 @@ import type { DigestConfig, RouterConfig } from "../config/types.ts";
23
23
  import { computeCost, forecast } from "../cost/forecast.ts";
24
24
  import type { Ledger, LedgerEntry } from "../cost/types.ts";
25
25
  import { buildCandidates } from "../router/candidates.ts";
26
+ import { primaryArg } from "../router/compaction.ts";
26
27
  import { extractFeatures } from "../router/features.ts";
27
28
  import { TIER_ORDER, type Tier } from "../router/types.ts";
28
29
  import { estimateTokens } from "../tokens/estimate.ts";
@@ -39,6 +40,14 @@ export interface DigestRequest {
39
40
  content: string;
40
41
  /** The user's current ask, so the digest keeps what matters for it. */
41
42
  query: string;
43
+ /** The tier to judge `digest.fromTier` against; default: the session's last routed tier. */
44
+ tier?: string;
45
+ /**
46
+ * Who asked. `tool_result` (default) is the omp extension and is gated on
47
+ * `digest.enabled`; `compaction` is summarising compaction inside a turn
48
+ * and is gated on `compaction.digestToolResults` instead.
49
+ */
50
+ source?: "tool_result" | "compaction";
42
51
  }
43
52
 
44
53
  export type DigestResult =
@@ -99,8 +108,31 @@ function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
99
108
  };
100
109
  }
101
110
 
102
- export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest): Promise<DigestResult> } {
111
+ /** A digest the agent may still go back on: same tool, same primary argument, within RERUN_WINDOW_MS. */
112
+ interface RecentDigest {
113
+ tool: string;
114
+ arg: string | null;
115
+ atMs: number;
116
+ ledgerId: string;
117
+ rerun: boolean;
118
+ }
119
+ const RERUN_WINDOW_MS = 2 * 3_600_000;
120
+ const RECENT_PER_SESSION = 50;
121
+
122
+ export interface Digester {
123
+ digest(req: DigestRequest): Promise<DigestResult>;
124
+ /**
125
+ * Quality signal: the tool calls a session just made. One that repeats a
126
+ * recent digest (same tool, same primary argument) means the agent went
127
+ * back for the full output; that digest's ledger row is marked wasted and
128
+ * the report shows the re-run rate. Returns how many were marked.
129
+ */
130
+ noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
131
+ }
132
+
133
+ export function createDigester(deps: DigesterDeps): Digester {
103
134
  const { cfg, catalog, ledger, upstream, log } = deps;
135
+ const recent = new Map<string, RecentDigest[]>();
104
136
 
105
137
  /** Cheapest simple-tier model that fits the prompt, or the configured one. */
106
138
  async function pickModel(req: NormRequest, promptTokens: number): Promise<CatalogModel | null> {
@@ -129,8 +161,10 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
129
161
  return {
130
162
  async digest(req) {
131
163
  const inputBytes = Buffer.byteLength(req.content);
132
- const currentTier = ledger.latestForSession?.(req.ompSessionId)?.tier ?? null;
133
- const applies = digestApplies(cfg.digest, req.toolName, inputBytes, false, currentTier);
164
+ const source = req.source ?? "tool_result";
165
+ const currentTier = req.tier ?? ledger.latestForSession?.(req.ompSessionId)?.tier ?? null;
166
+ const gate = source === "compaction" ? { ...cfg.digest, enabled: cfg.compaction.digestToolResults } : cfg.digest;
167
+ const applies = digestApplies(gate, req.toolName, inputBytes, false, currentTier);
134
168
  if (!applies.ok) return { digested: false, reason: applies.reason };
135
169
 
136
170
  const promptText = `Task: ${req.query === "" ? "(unknown)" : req.query}\nTool: ${req.toolName} ${JSON.stringify(req.input)}\n--- output ---\n${req.content}`;
@@ -190,7 +224,7 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
190
224
  servedSlug: model.slug,
191
225
  tier: cfg.digest.tier,
192
226
  classificationSource: "forced",
193
- reasons: [`digest: ${req.toolName} ${inputBytes} bytes → ${text.length} chars for a ${currentTier} session`],
227
+ reasons: [`digest (${source}): ${req.toolName} ${inputBytes} bytes → ${text.length} chars for a ${currentTier} ${source === "compaction" ? "turn" : "session"}`],
194
228
  features: null,
195
229
  score: null,
196
230
  confidence: null,
@@ -219,6 +253,11 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
219
253
  }
220
254
  if (error !== null) return { digested: false, reason: `digest model failed: ${error}` };
221
255
  if (text === "" || text.length >= inputBytes * 0.9) return { digested: false, reason: "digest did not shrink the output" };
256
+ if (req.ompSessionId !== "") {
257
+ const list = recent.get(req.ompSessionId) ?? [];
258
+ list.push({ tool: req.toolName.toLowerCase(), arg: primaryArg(JSON.stringify(req.input)), atMs: startedAt, ledgerId: entry.id, rerun: false });
259
+ recent.set(req.ompSessionId, list.slice(-RECENT_PER_SESSION));
260
+ }
222
261
  return {
223
262
  digested: true,
224
263
  text: `${digestMarker(req.toolName, req.input, model.slug, inputBytes, text.length)}\n${text}`,
@@ -229,5 +268,30 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
229
268
  ms,
230
269
  };
231
270
  },
271
+ noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
272
+ const list = recent.get(ompSessionId);
273
+ if (list === undefined || list.length === 0) return 0;
274
+ let marked = 0;
275
+ for (const c of calls) {
276
+ const tool = c.name.toLowerCase();
277
+ const arg = primaryArg(c.argsJson);
278
+ if (arg === null) continue;
279
+ for (const d of list) {
280
+ if (d.rerun || d.tool !== tool || d.arg !== arg || nowMs - d.atMs > RERUN_WINDOW_MS) continue;
281
+ d.rerun = true;
282
+ marked++;
283
+ try {
284
+ ledger.markWasted?.(d.ledgerId);
285
+ } catch (err) {
286
+ log.debug("digest re-run mark failed", { error: err instanceof Error ? err.message : String(err) });
287
+ }
288
+ log.info("digest re-run: the agent fetched the full output after all", { tool, arg: arg.slice(0, 80) });
289
+ }
290
+ }
291
+ const kept = list.filter((d) => nowMs - d.atMs <= RERUN_WINDOW_MS);
292
+ if (kept.length === 0) recent.delete(ompSessionId);
293
+ else recent.set(ompSessionId, kept);
294
+ return marked;
295
+ },
232
296
  };
233
297
  }
@@ -9,6 +9,7 @@ import { createSessionOverrides } from "./overrides.ts";
9
9
  import { createDigester } from "./digest.ts";
10
10
  import { TIER_ORDER, type Tier } from "../router/types.ts";
11
11
  import { baselinePrices, buildUsageReport } from "../cost/report.ts";
12
+ import { buildDailySummary, createKv, markSummaryShown, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
12
13
  import type { Ledger, ModelTrust } from "../cost/types.ts";
13
14
  import { createRouter } from "../router/index.ts";
14
15
  import { createConversationStore } from "../router/state.ts";
@@ -16,7 +17,7 @@ import { UpstreamError } from "../upstream/types.ts";
16
17
  import { apiKeySource, ollamaKeySource } from "../config/load.ts";
17
18
  import { ollamaMeter } from "../upstream/ollama-usage.ts";
18
19
  import { routerConfigPath } from "../cli/config-cmd.ts";
19
- import { watchConfig } from "../config/hot-reload.ts";
20
+ import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
20
21
  import type { RouterConfig } from "../config/types.ts";
21
22
  import { createLogger } from "../util/log.ts";
22
23
  import { openDb } from "../util/sqlite.ts";
@@ -195,20 +196,22 @@ export function startServer(cfg: RouterConfig): StartedServer {
195
196
  const context = createBridgeFromConfig(cfg, db);
196
197
  const overrides = createSessionOverrides();
197
198
  const feedback = createFeedbackStore(db);
199
+ const kv = createKv(db);
198
200
  const digester = createDigester({ cfg, catalog, ledger, upstream, log });
199
- const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale };
201
+ const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale, digester };
200
202
 
201
203
  // Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
202
204
  // effect on the next turn without a restart, because every consumer reads
203
- // the shared config object at call time. Construction-captured blocks
204
- // (server socket, OpenRouter client, agentdox bridge) are pinned — editing
205
- // those still requires a restart, and the watcher says so explicitly.
206
- const pinned = { ...cfg };
205
+ // the shared config object at call time. Construction-captured settings
206
+ // (server socket, upstream clients, agentdox bridge, ledger file) are
207
+ // pinned by path (PINNED_CONFIG_PATHS); editing those still requires a
208
+ // restart. The blocks are deep-copied so a reload cannot mutate the pin.
209
+ const pinned = structuredClone(cfg);
207
210
  const configWatcher = watchConfig(
208
211
  routerConfigPath(),
209
212
  cfg,
210
213
  pinned,
211
- ["server", "openrouter", "ollama", "context", "ledger"],
214
+ PINNED_CONFIG_PATHS,
212
215
  {
213
216
  onReload: ({ changed }) => {
214
217
  log.info("config reloaded", { changed: changed.join(", ") });
@@ -267,6 +270,20 @@ export function startServer(cfg: RouterConfig): StartedServer {
267
270
  }, 60_000);
268
271
  pruneTimer.unref();
269
272
 
273
+ // Ledger retention: hourly, and once at boot so a lowered setting takes
274
+ // effect without waiting. Reads the live config, so it hot-reloads.
275
+ const retain = (): void => {
276
+ try {
277
+ const dropped = ledger.prune?.(cfg.ledger.retentionDays) ?? 0;
278
+ if (dropped > 0) log.info("pruned ledger rows past retention", { dropped, retentionDays: cfg.ledger.retentionDays });
279
+ } catch (err) {
280
+ log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
281
+ }
282
+ };
283
+ const retentionTimer = setInterval(retain, 3_600_000);
284
+ retentionTimer.unref();
285
+ setTimeout(retain, 5_000).unref();
286
+
270
287
  // Periodically refetch the (key-scoped) catalog in the background so
271
288
  // guardrail/preference changes are picked up without needing traffic and a
272
289
  // TTL expiry. catalogRefreshMs === 0 disables this.
@@ -398,6 +415,29 @@ export function startServer(cfg: RouterConfig): StartedServer {
398
415
  const harnessId = url.searchParams.get("harness") ?? "";
399
416
  return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
400
417
  }
418
+ if (req.method === "GET" && url.pathname === "/v1/router/summary") {
419
+ // The last 24 hours in a few lines. `auto=1` is the session-start
420
+ // caller: it gets `due: false` unless report.dailySummary is on, no
421
+ // summary was posted for this harness in the last 20h, and there is
422
+ // something to say; posting is then marked so other windows skip it.
423
+ const harnessId = url.searchParams.get("harness") ?? "";
424
+ const auto = url.searchParams.get("auto") === "1";
425
+ if (auto && !cfg.report.dailySummary) return json({ due: false, reason: "report.dailySummary is off", summary: null });
426
+ if (auto && !summaryDue(kv, harnessId)) return json({ due: false, reason: "posted in the last 20h", summary: null });
427
+ const meter = ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd);
428
+ const runway = ollamaRunway(meter, ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1);
429
+ const ollamaSummary: SummaryOllama | null =
430
+ ollama === null || meter === null ? null : { plan: meter.plan ?? null, usedUsd: meter.usedUsd, creditsUsd: meter.creditsUsd, runwayDays: runway?.days ?? null };
431
+ const summary = buildDailySummary(db, {
432
+ harnessId,
433
+ baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)),
434
+ spikes: ledger.softFailureSpikes?.() ?? [],
435
+ ollama: ollamaSummary,
436
+ });
437
+ if (auto && !summaryHasNews(summary)) return json({ due: false, reason: "nothing to report", summary: null });
438
+ if (auto) markSummaryShown(kv, harnessId);
439
+ return json({ due: true, summary });
440
+ }
401
441
  if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
402
442
  const rawLimit = url.searchParams.get("limit");
403
443
  const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
@@ -514,6 +554,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
514
554
  runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1),
515
555
  costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
516
556
  },
557
+ // Models failing well above their own baseline in the last hour.
558
+ // Visibility only: nothing routes around a spike.
559
+ softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: ledger.softFailureSpikes?.() ?? [] },
517
560
  catalog: snap === null
518
561
  ? null
519
562
  : {
@@ -54,6 +54,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
54
54
  costBias: cfg.ollama.costBias,
55
55
  biasUntilUsage: cfg.ollama.biasUntilUsage,
56
56
  usage: ollamaUsage,
57
+ live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
57
58
  }),
58
59
  ollama,
59
60
  ollamaUsage,
@@ -28,6 +28,7 @@ import {
28
28
  } from "../router/types.ts";
29
29
  import { UpstreamError, type Dispatch, type UpstreamClient } from "../upstream/types.ts";
30
30
  import type { SessionOverrides } from "./overrides.ts";
31
+ import { digestCompactionEdits, type CompactionDigester } from "./compaction-digest.ts";
31
32
  import { createLogger } from "../util/log.ts";
32
33
  import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../wire/types.ts";
33
34
 
@@ -71,6 +72,8 @@ export interface TurnDeps {
71
72
  overrides?: SessionOverrides;
72
73
  /** Ledger-vs-meter calibration for Ollama's estimated costs; absent ⇒ 1. */
73
74
  ollamaCostScale?: () => number;
75
+ /** Cheap-model digester for summarising compaction (`compaction.digestToolResults`). Absent ⇒ plain edits. */
76
+ digester?: CompactionDigester;
74
77
  }
75
78
 
76
79
  /** A dead client connection surfaces as the sink throwing mid-stream. */
@@ -111,6 +114,17 @@ export async function runTurn(
111
114
  const log = createLogger(config.logLevel);
112
115
  const state = conversations.load(req.conversationKey);
113
116
  const turnNumber = state.turn + 1;
117
+ // Digest quality signal: the calls the agent just made, matched against
118
+ // recent digests of this session (a re-run of a digested read means the
119
+ // digest was not enough). The last assistant message holds this turn's calls.
120
+ if (deps.digester?.noteToolCalls !== undefined && req.ompSessionId !== "") {
121
+ for (let i = req.messages.length - 1; i >= 0; i--) {
122
+ const m = req.messages[i];
123
+ if (m === undefined || m.role !== "assistant") continue;
124
+ if (m.toolCalls.length > 0) deps.digester.noteToolCalls(req.ompSessionId, m.toolCalls.map((c) => ({ name: c.name, argsJson: c.argsJson })));
125
+ break;
126
+ }
127
+ }
114
128
  // Request header wins; the configured default covers harnesses that send none.
115
129
  const doxScope = req.agentdoxScope !== "" ? req.agentdoxScope : config.context.defaultScope;
116
130
  const doxActive = bridge.enabled && doxScope !== "";
@@ -135,6 +149,8 @@ export async function runTurn(
135
149
  // A failover decision already routed inside onUpstreamError; the next
136
150
  // loop iteration dispatches it instead of routing again.
137
151
  let pendingDecision: Decision | null = null;
152
+ // Digests made this turn, by edit; a retry re-plans and must not pay twice.
153
+ const digestMemo = new Map<string, string>();
138
154
 
139
155
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
140
156
  // Client disconnected before anything was dispatched: spend nothing.
@@ -160,6 +176,27 @@ export async function runTurn(
160
176
  }
161
177
  }
162
178
 
179
+ // Summarising compaction: new edits get a cheap-model digest before the
180
+ // plan is applied and persisted. Bounded per turn; a decline leaves the
181
+ // plain edit. Accounted in the estimate adjustment below.
182
+ let compactionSavedBytes = decision.compactionSavedBytes;
183
+ if (deps.digester !== undefined && config.compaction.digestToolResults && decision.compactionPlan.length > 0) {
184
+ try {
185
+ compactionSavedBytes += await digestCompactionEdits({
186
+ req,
187
+ plan: decision.compactionPlan,
188
+ tier: decision.tier,
189
+ cfg: config,
190
+ digester: deps.digester,
191
+ memo: digestMemo,
192
+ query: relevanceQuery(req),
193
+ log,
194
+ });
195
+ } catch (err) {
196
+ log.warn("compaction digest failed; dispatching plain edits", { error: err instanceof Error ? err.message : String(err) });
197
+ }
198
+ }
199
+
163
200
  // Resolve the shared context block. The bridge refreshes only when this
164
201
  // turn's prefix is already cold — a model switch or a retry — so the
165
202
  // injected bytes stay identical while the cache is worth keeping.
@@ -209,7 +246,7 @@ export async function runTurn(
209
246
  // — not the raw request the estimate was taken from.
210
247
  adjustPendingEstimate(
211
248
  req.conversationKey,
212
- req.promptBytes - decision.compactionSavedBytes + (contextBlock === undefined ? 0 : Buffer.byteLength(contextBlock)),
249
+ req.promptBytes - compactionSavedBytes + (contextBlock === undefined ? 0 : Buffer.byteLength(contextBlock)),
213
250
  );
214
251
 
215
252
  // Our own abort composes with the client's: escalation teardown and
@@ -141,6 +141,13 @@ CREATE TABLE IF NOT EXISTS feedback (
141
141
  CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback (created_at_ms);
142
142
  CREATE INDEX IF NOT EXISTS idx_feedback_ledger ON feedback (ledger_id);
143
143
 
144
+ -- Small durable markers (e.g. when the daily summary was last posted, per harness).
145
+ CREATE TABLE IF NOT EXISTS router_kv (
146
+ key TEXT PRIMARY KEY,
147
+ value TEXT NOT NULL,
148
+ updated_at_ms INTEGER NOT NULL
149
+ );
150
+
144
151
  CREATE TABLE IF NOT EXISTS agentdox_sessions (
145
152
  conversation_key TEXT PRIMARY KEY,
146
153
  scope TEXT NOT NULL,
@@ -199,6 +199,10 @@ function applyCompaction(messages: Record<string, unknown>[], edits: readonly Co
199
199
  if (msg === undefined) continue;
200
200
  const content = msg.content;
201
201
  if (typeof content !== "string") continue;
202
+ if (edit.digest !== undefined) {
203
+ msg.content = edit.digest;
204
+ continue;
205
+ }
202
206
  if (edit.mode === "stub") {
203
207
  msg.content = `[omp-router: ${edit.note} elided to save context; re-run the tool to restore]`;
204
208
  continue;
package/src/wire/types.ts CHANGED
@@ -158,6 +158,13 @@ export interface CompactionEdit {
158
158
  * the edit instead of corrupting the prompt.
159
159
  */
160
160
  bytes: number;
161
+ /**
162
+ * A cheap-model digest of the original content (marker line first), set
163
+ * by summarising compaction. When present it replaces the content outright
164
+ * instead of the head/tail or stub breadcrumb, and persists with the plan
165
+ * so the dispatched bytes stay identical turn to turn.
166
+ */
167
+ digest?: string;
161
168
  }
162
169
 
163
170
  export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter" | "error";
@@ -93,7 +93,7 @@ describe("planCacheBreakpoints", () => {
93
93
  test("milestones follow post-compaction sizes", () => {
94
94
  const req = loop(30);
95
95
  const tail = req.messages.length - 1;
96
- const plan = planCompaction(req.messages, BASE.compaction, req.promptBytes * 0.3, req.promptBytes);
96
+ const plan = planCompaction(req.messages, { ...BASE.compaction, enabled: true }, req.promptBytes * 0.3, req.promptBytes);
97
97
  expect(plan.edits.length).toBeGreaterThan(0);
98
98
  const options = cfg({ maxBreakpoints: 64, milestoneTokens: 4_000 });
99
99
  const raw = planCacheBreakpoints(req, MODEL, options).filter((i) => i !== 0 && i !== tail);
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import type { CompactionConfig } from "../src/config/types.ts";
4
- import { planCompaction, validatePlan } from "../src/router/compaction.ts";
4
+ import { compactedBytes, planCompaction, validatePlan } from "../src/router/compaction.ts";
5
5
  import { parseChatRequest } from "../src/wire/openai/request.ts";
6
6
  import type { NormMessage } from "../src/wire/types.ts";
7
7
 
@@ -16,7 +16,7 @@ const CFG: CompactionConfig = {
16
16
  keepHeadBytes: 10,
17
17
  keepTailBytes: 10,
18
18
  elideSupersededReads: true,
19
- collapseDuplicateResults: true,
19
+ collapseDuplicateResults: true, digestToolResults: false, digestMaxPerTurn: 2,
20
20
  };
21
21
 
22
22
  function user(text: string): NormMessage {
@@ -78,7 +78,7 @@ describe("planCompaction", () => {
78
78
  toolMsg("c2", "read", big("V2")), // different content, same path → supersedes c1
79
79
  ...PAD,
80
80
  ];
81
- const { edits } = planCompaction(msgs, { ...CFG, collapseDuplicateResults: false }, 10_000, 10_000);
81
+ const { edits } = planCompaction(msgs, { ...CFG, collapseDuplicateResults: false, digestToolResults: false, digestMaxPerTurn: 2, }, 10_000, 10_000);
82
82
  expect(edits.map((e) => e.index)).toEqual([2]);
83
83
  expect(edits[0]?.mode).toBe("stub");
84
84
  });
@@ -270,3 +270,40 @@ describe("plan byte-stability across turns", () => {
270
270
  expect(next.edits.filter((e) => e.index === 2)).toEqual(carried);
271
271
  });
272
272
  });
273
+
274
+ describe("summarising compaction (edit.digest)", () => {
275
+ const bodyWith = (messages: unknown[]): Record<string, unknown> => ({ model: "auto", messages });
276
+ const MUT = { slug: "x/y", fallbacks: [], sessionId: "s", cacheBreakpointMessageIndices: [], reasoning: undefined, maxTokens: undefined, stripAssistantReasoning: false };
277
+ const DIGEST = "[digest: read output 208 bytes → 40 chars by cheap/model. Full output: re-run read {}]\nA: two hundred x's.";
278
+
279
+ test("a digested edit replaces the content with the digest, whatever its mode", () => {
280
+ const raw = [
281
+ { role: "user", content: "go" },
282
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }] },
283
+ { role: "tool", tool_call_id: "c1", content: "HEAD" + "x".repeat(500) + "TAIL" },
284
+ ];
285
+ const req = parseChatRequest(bodyWith(raw), new Headers());
286
+ for (const mode of ["truncate", "stub"] as const) {
287
+ const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode, keepHead: 4, keepTail: 4, note: "large read result", bytes: 508, digest: DIGEST }] });
288
+ expect((out.messages as { content: string }[])[2]?.content).toBe(DIGEST);
289
+ }
290
+ });
291
+
292
+ test("compactedBytes sizes a digested edit by its digest, never above the original", () => {
293
+ const plain = { index: 2, mode: "truncate" as const, keepHead: 10, keepTail: 10, note: "n", bytes: 5_000 };
294
+ expect(compactedBytes(5_000, { ...plain, digest: DIGEST })).toBe(Buffer.byteLength(DIGEST));
295
+ expect(compactedBytes(5_000, { ...plain, digest: "y".repeat(9_000) })).toBe(5_000);
296
+ });
297
+
298
+ test("the digest survives validation and a re-plan, so the bytes stay stable", () => {
299
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
300
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
301
+ const digested = edits.map((e) => ({ ...e, digest: DIGEST }));
302
+ expect(validatePlan(digested, msgs)[0]?.digest).toBe(DIGEST);
303
+ const replanned = planCompaction(msgs, CFG, 1, 10_000, validatePlan(digested, msgs));
304
+ expect(replanned.edits).toHaveLength(1);
305
+ expect(replanned.edits[0]?.digest).toBe(DIGEST);
306
+ // Savings count the digest's size, not the head+tail the plain edit would have kept.
307
+ expect(replanned.savedBytes).toBe(Buffer.byteLength(big("A")) - Buffer.byteLength(DIGEST));
308
+ });
309
+ });