auto-model-router 0.31.0 → 0.32.0

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 (110) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +32 -2
  3. package/omp-extension/router-configure.ts +9 -7
  4. package/package.json +1 -1
  5. package/src/cli/config-cmd.ts +8 -7
  6. package/src/cli/explain.ts +10 -5
  7. package/src/cli/export.ts +6 -5
  8. package/src/cli/models.ts +10 -7
  9. package/src/cli/report.ts +6 -1
  10. package/src/cli/stats.ts +7 -7
  11. package/src/config/load.ts +10 -1
  12. package/src/config/types.ts +10 -1
  13. package/src/context/bridge.ts +7 -7
  14. package/src/context/index.ts +3 -3
  15. package/src/context/store.ts +39 -56
  16. package/src/context/types.ts +7 -6
  17. package/src/cost/blended.ts +28 -7
  18. package/src/cost/feedback.ts +33 -37
  19. package/src/cost/ledger-sql.ts +547 -0
  20. package/src/cost/ledger.ts +30 -459
  21. package/src/cost/report.ts +171 -129
  22. package/src/cost/retention.ts +10 -10
  23. package/src/cost/summary.ts +15 -10
  24. package/src/cost/types.ts +43 -62
  25. package/src/cost/views.ts +79 -49
  26. package/src/lib.ts +6 -2
  27. package/src/router/candidates.ts +7 -15
  28. package/src/router/classify.ts +6 -4
  29. package/src/router/index.ts +95 -9
  30. package/src/router/select.ts +38 -21
  31. package/src/router/state.ts +90 -102
  32. package/src/router/types.ts +11 -5
  33. package/src/server/advise.ts +6 -4
  34. package/src/server/compaction-digest.ts +1 -1
  35. package/src/server/digest.ts +9 -10
  36. package/src/server/http.ts +101 -41
  37. package/src/server/providers.ts +18 -4
  38. package/src/server/turn.ts +32 -9
  39. package/src/tokens/estimate.ts +16 -6
  40. package/src/upstream/ollama-usage.ts +21 -11
  41. package/src/util/schema.ts +201 -0
  42. package/src/util/sql.ts +246 -0
  43. package/src/wire/anthropic/messages.ts +3 -4
  44. package/src/wire/openai/request.ts +1 -0
  45. package/src/wire/types.ts +7 -0
  46. package/test/anthropic-wire.test.ts +9 -9
  47. package/test/benchmark-feeds.test.ts +7 -7
  48. package/test/cache-control.test.ts +7 -7
  49. package/test/cache-estimate.test.ts +5 -5
  50. package/test/catalog-view.test.ts +4 -4
  51. package/test/catalog.test.ts +11 -11
  52. package/test/classify.test.ts +24 -24
  53. package/test/compaction.test.ts +20 -20
  54. package/test/config-wizard.test.ts +32 -32
  55. package/test/config.test.ts +10 -10
  56. package/test/connect-harnesses.test.ts +11 -11
  57. package/test/context-bridge.test.ts +40 -30
  58. package/test/context-prune.test.ts +43 -36
  59. package/test/context-query.test.ts +8 -8
  60. package/test/controls.test.ts +54 -27
  61. package/test/cost.test.ts +12 -12
  62. package/test/digest.test.ts +55 -44
  63. package/test/embed-lifecycle.test.ts +5 -5
  64. package/test/embed-logic.test.ts +26 -26
  65. package/test/escalate.test.ts +17 -17
  66. package/test/eval.test.ts +13 -13
  67. package/test/executable.test.ts +6 -6
  68. package/test/exploration.test.ts +19 -20
  69. package/test/failover.test.ts +22 -21
  70. package/test/fakes.ts +105 -0
  71. package/test/features.test.ts +21 -21
  72. package/test/harness-requests.test.ts +3 -3
  73. package/test/harness-switch.test.ts +5 -5
  74. package/test/hold-exploration.test.ts +13 -13
  75. package/test/hot-reload.test.ts +5 -5
  76. package/test/learned.test.ts +5 -5
  77. package/test/ledger-sql.test.ts +342 -0
  78. package/test/mcp-entry.test.ts +5 -5
  79. package/test/migrations.test.ts +28 -22
  80. package/test/models-yml.test.ts +18 -18
  81. package/test/ollama.test.ts +40 -34
  82. package/test/omp-credentials.test.ts +16 -16
  83. package/test/policy.test.ts +3 -3
  84. package/test/reconfigure.test.ts +4 -4
  85. package/test/redaction.test.ts +41 -35
  86. package/test/remote.test.ts +12 -12
  87. package/test/report-logic.test.ts +8 -8
  88. package/test/report.test.ts +95 -87
  89. package/test/retention.test.ts +79 -66
  90. package/test/schema.test.ts +123 -0
  91. package/test/scope.test.ts +8 -8
  92. package/test/select.test.ts +216 -257
  93. package/test/skills.test.ts +3 -3
  94. package/test/sql-shim.test.ts +154 -0
  95. package/test/state.test.ts +43 -36
  96. package/test/summary.test.ts +38 -27
  97. package/test/tier-plan.test.ts +45 -62
  98. package/test/toast-logic.test.ts +31 -31
  99. package/test/tokens.test.ts +95 -80
  100. package/test/trust-attribution.test.ts +217 -187
  101. package/test/trust-window.test.ts +37 -32
  102. package/test/turn.test.ts +55 -23
  103. package/test/upstreams.test.ts +13 -13
  104. package/test/views.test.ts +81 -59
  105. package/test/wire-request.test.ts +17 -17
  106. package/test/wire-responses.test.ts +4 -4
  107. package/tools/agentdox-e2e.ts +5 -2
  108. package/tools/export-benchmarks.ts +5 -5
  109. package/tools/ledger-parity.ts +266 -0
  110. package/tools/replay.ts +16 -8
@@ -5,11 +5,11 @@
5
5
  * survive a restart: an omp session outlives this process, and forgetting
6
6
  * which model is warm would cold-start a paid prompt cache for no reason.
7
7
  *
8
- * The `conversations` table is created by `util/sqlite.ts`, the single
9
- * migration path.
8
+ * The `conversations` table is created by the store's migration
9
+ * (`util/schema.ts`, or `util/sqlite.ts` for a file that predates it).
10
10
  */
11
11
 
12
- import type { Database, Statement } from "bun:sqlite";
12
+ import { jsonParam, jsonValue, num, type SqlDb } from "../util/sql.ts";
13
13
 
14
14
  import type { CompactionEdit } from "../wire/types.ts";
15
15
  import type { ConversationState, ConversationStore, Tier } from "./types.ts";
@@ -19,147 +19,135 @@ import type { ConversationState, ConversationStore, Tier } from "./types.ts";
19
19
  interface Row {
20
20
  key: string;
21
21
  session_id: string;
22
- turn: number;
22
+ turn: unknown;
23
23
  current_slug: string | null;
24
24
  current_tier: string | null;
25
- sticky_until_turn: number;
26
- escalations: number;
27
- spent_usd: number;
28
- last_prompt_tokens: number;
25
+ sticky_until_turn: unknown;
26
+ escalations: unknown;
27
+ spent_usd: unknown;
28
+ last_prompt_tokens: unknown;
29
29
  cache_warm_slug: string | null;
30
- cache_warm_at_ms: number;
30
+ cache_warm_at_ms: unknown;
31
31
  context_version: string | null;
32
- context_fetched_at_ms: number;
33
- compaction_plan: string | null;
34
- compaction_plan_tokens: number;
32
+ context_fetched_at_ms: unknown;
33
+ /** JSON: text on sqlite, already parsed on postgres. */
34
+ compaction_plan: unknown;
35
+ compaction_plan_tokens: unknown;
35
36
  upgrade_deferred_tier: string | null;
36
- updated_at_ms: number;
37
+ updated_at_ms: unknown;
37
38
  }
38
39
 
39
40
  function toState(row: Row): ConversationState {
40
41
  return {
41
42
  key: row.key,
42
43
  sessionId: row.session_id,
43
- turn: row.turn,
44
+ // Counts and sums arrive as strings from Postgres, and every one of these
45
+ // feeds arithmetic — the sticky window, the budget guard, cache warmth.
46
+ turn: num(row.turn),
44
47
  currentSlug: row.current_slug,
45
48
  // Stored as free text; the column is only ever written from a Tier.
46
49
  currentTier: row.current_tier as Tier | null,
47
- stickyUntilTurn: row.sticky_until_turn,
48
- escalations: row.escalations,
49
- spentUsd: row.spent_usd,
50
- lastPromptTokens: row.last_prompt_tokens,
50
+ stickyUntilTurn: num(row.sticky_until_turn),
51
+ escalations: num(row.escalations),
52
+ spentUsd: num(row.spent_usd),
53
+ lastPromptTokens: num(row.last_prompt_tokens),
51
54
  cacheWarmSlug: row.cache_warm_slug,
52
- cacheWarmAtMs: row.cache_warm_at_ms,
55
+ cacheWarmAtMs: num(row.cache_warm_at_ms),
53
56
  contextVersion: row.context_version,
54
- contextFetchedAtMs: row.context_fetched_at_ms,
55
- compactionPlan: row.compaction_plan === null ? null : (JSON.parse(row.compaction_plan) as CompactionEdit[]),
56
- compactionPlanTokens: row.compaction_plan_tokens,
57
+ contextFetchedAtMs: num(row.context_fetched_at_ms),
58
+ compactionPlan: jsonValue<CompactionEdit[]>(row.compaction_plan),
59
+ compactionPlanTokens: num(row.compaction_plan_tokens),
57
60
  upgradeDeferredTier: row.upgrade_deferred_tier as Tier | null,
58
- updatedAtMs: row.updated_at_ms,
61
+ updatedAtMs: num(row.updated_at_ms),
59
62
  };
60
63
  }
61
64
 
62
- export function createConversationStore(db: Database): ConversationStore {
63
- // Hoisted: this runs on every turn, twice when an escalation retries.
64
- const selectOne: Statement<Row, [string]> = db.query("SELECT * FROM conversations WHERE key = ?");
65
- const insertOne: Statement<unknown, [string, string, number]> = db.query(
66
- "INSERT INTO conversations (key, session_id, updated_at_ms) VALUES (?, ?, ?)",
67
- );
68
- // `spent_usd` and `escalations` are ABSENT from this statement on purpose.
69
- // They accumulate through `accrueOne` below, so writing a turn-start snapshot
70
- // back here would erase whatever a billed-but-uncommitted dispatch added.
71
- // The schema defaults both to 0, so the INSERT arm still works.
72
- const upsert = db.query(`
73
- INSERT INTO conversations (
74
- key, session_id, turn, current_slug, current_tier, sticky_until_turn,
75
- last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
76
- context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, upgrade_deferred_tier, updated_at_ms
77
- ) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
78
- $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
79
- $contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $upgradeDeferredTier, $updatedAtMs)
80
- ON CONFLICT(key) DO UPDATE SET
81
- session_id = excluded.session_id,
82
- turn = excluded.turn,
83
- current_slug = excluded.current_slug,
84
- current_tier = excluded.current_tier,
85
- sticky_until_turn = excluded.sticky_until_turn,
86
- last_prompt_tokens = excluded.last_prompt_tokens,
87
- cache_warm_slug = excluded.cache_warm_slug,
88
- cache_warm_at_ms = excluded.cache_warm_at_ms,
89
- context_version = excluded.context_version,
90
- context_fetched_at_ms = excluded.context_fetched_at_ms,
91
- compaction_plan = excluded.compaction_plan,
92
- compaction_plan_tokens = excluded.compaction_plan_tokens,
93
- upgrade_deferred_tier = excluded.upgrade_deferred_tier,
94
- updated_at_ms = excluded.updated_at_ms
95
- `);
96
- // Read-modify-write in JS lost money: an aborted or failed dispatch is still
97
- // billed by the upstream, but it returns before the commit path, so the next
98
- // dispatch loaded a stale total and overwrote it. Measured on live data:
99
- // 152 aborted dispatches billing $0.9985 — 30% of all spend — never reached
100
- // `spent_usd`, leaving the per-conversation budget guard blind to it.
101
- // Accumulating in SQL is correct regardless of who raced whom.
102
- const accrueOne = db.query(`
103
- UPDATE conversations
104
- SET spent_usd = spent_usd + $spentUsd,
105
- escalations = escalations + $escalations,
106
- updated_at_ms = $updatedAtMs
107
- WHERE key = $key
108
- `);
109
- const deleteStale: Statement<unknown, [number]> = db.query("DELETE FROM conversations WHERE updated_at_ms < ?");
65
+ export function createConversationStore(db: SqlDb): ConversationStore {
66
+ const { sql } = db;
67
+ const selectOne = async (key: string): Promise<Row | null> =>
68
+ await db.one<Row>("SELECT * FROM conversations WHERE key = $key", { key });
110
69
 
111
70
  return {
112
- get(key) {
113
- const row = selectOne.get(key);
71
+ async get(key) {
72
+ const row = await selectOne(key);
114
73
  return row === null ? null : toState(row);
115
74
  },
116
75
 
117
- load(key) {
118
- const existing = selectOne.get(key);
76
+ async load(key) {
77
+ const existing = await selectOne(key);
119
78
  if (existing !== null) return toState(existing);
120
79
  // Session id is derived, not random, so it stays stable if this row is
121
80
  // ever pruned and the same conversation continues afterwards.
122
81
  const sessionId = `omp-${key}`;
123
- insertOne.run(key, sessionId, Date.now());
124
- const inserted = selectOne.get(key);
82
+ // Two replicas can reach this at once for the same conversation; the
83
+ // loser must read the winner's row rather than fail the turn.
84
+ await sql`INSERT INTO conversations (key, session_id, updated_at_ms) VALUES (${key}, ${sessionId}, ${Date.now()})
85
+ ON CONFLICT (key) DO NOTHING`;
86
+ const inserted = await selectOne(key);
125
87
  if (inserted === null) throw new Error(`conversation row vanished immediately after insert: ${key}`);
126
88
  return toState(inserted);
127
89
  },
128
90
 
129
- save(state) {
130
- // bun:sqlite matches named parameters by their literal `$name` key;
131
- // bare keys bind nothing at all and every column silently lands NULL.
132
- // No $spentUsd / $escalations here — see the statement above.
133
- upsert.run({
134
- $key: state.key,
135
- $sessionId: state.sessionId,
136
- $turn: state.turn,
137
- $currentSlug: state.currentSlug,
138
- $currentTier: state.currentTier,
139
- $stickyUntilTurn: state.stickyUntilTurn,
140
- $lastPromptTokens: state.lastPromptTokens,
141
- $compactionPlan: state.compactionPlan === null ? null : JSON.stringify(state.compactionPlan),
142
- $compactionPlanTokens: state.compactionPlanTokens ?? 0,
143
- $upgradeDeferredTier: state.upgradeDeferredTier ?? null,
144
- $cacheWarmSlug: state.cacheWarmSlug,
145
- $cacheWarmAtMs: state.cacheWarmAtMs,
146
- $contextVersion: state.contextVersion,
147
- $contextFetchedAtMs: state.contextFetchedAtMs,
148
- $updatedAtMs: Date.now(),
149
- });
91
+ async save(state) {
92
+ // `spent_usd` and `escalations` are ABSENT on purpose: they accumulate
93
+ // through `accrue`, so writing a turn-start snapshot back here would
94
+ // erase whatever a billed-but-uncommitted dispatch added. The schema
95
+ // defaults both to 0, so the INSERT arm still works.
96
+ await sql`
97
+ INSERT INTO conversations (
98
+ key, session_id, turn, current_slug, current_tier, sticky_until_turn,
99
+ last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
100
+ context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens,
101
+ upgrade_deferred_tier, updated_at_ms
102
+ ) VALUES (
103
+ ${state.key}, ${state.sessionId}, ${state.turn}, ${state.currentSlug}, ${state.currentTier},
104
+ ${state.stickyUntilTurn}, ${state.lastPromptTokens}, ${state.cacheWarmSlug}, ${state.cacheWarmAtMs},
105
+ ${state.contextVersion}, ${state.contextFetchedAtMs}, ${jsonParam(db, state.compactionPlan)},
106
+ ${state.compactionPlanTokens ?? 0}, ${state.upgradeDeferredTier ?? null}, ${Date.now()}
107
+ )
108
+ ON CONFLICT (key) DO UPDATE SET
109
+ session_id = excluded.session_id,
110
+ turn = excluded.turn,
111
+ current_slug = excluded.current_slug,
112
+ current_tier = excluded.current_tier,
113
+ sticky_until_turn = excluded.sticky_until_turn,
114
+ last_prompt_tokens = excluded.last_prompt_tokens,
115
+ cache_warm_slug = excluded.cache_warm_slug,
116
+ cache_warm_at_ms = excluded.cache_warm_at_ms,
117
+ context_version = excluded.context_version,
118
+ context_fetched_at_ms = excluded.context_fetched_at_ms,
119
+ compaction_plan = excluded.compaction_plan,
120
+ compaction_plan_tokens = excluded.compaction_plan_tokens,
121
+ upgrade_deferred_tier = excluded.upgrade_deferred_tier,
122
+ updated_at_ms = excluded.updated_at_ms`;
150
123
  },
151
124
 
152
- accrue(key, delta) {
125
+ async accrue(key, delta) {
153
126
  const spentUsd = delta.spentUsd ?? 0;
154
127
  const escalations = delta.escalations ?? 0;
155
128
  // Nothing to add: skip the write rather than bump updated_at_ms and
156
129
  // keep a dead conversation alive against `prune`.
157
130
  if (spentUsd === 0 && escalations === 0) return;
158
- accrueOne.run({ $key: key, $spentUsd: spentUsd, $escalations: escalations, $updatedAtMs: Date.now() });
131
+ // Read-modify-write in JS lost money: an aborted or failed dispatch is
132
+ // still billed by the upstream, but it returns before the commit path,
133
+ // so the next dispatch loaded a stale total and overwrote it. Measured
134
+ // on live data: 152 aborted dispatches billing $0.9985 — 30% of all
135
+ // spend — never reached `spent_usd`. Accumulating in SQL is correct
136
+ // regardless of who raced whom, and with a shared store the racers can
137
+ // now be different processes.
138
+ await sql`
139
+ UPDATE conversations
140
+ SET spent_usd = spent_usd + ${spentUsd},
141
+ escalations = escalations + ${escalations},
142
+ updated_at_ms = ${Date.now()}
143
+ WHERE key = ${key}`;
159
144
  },
160
145
 
161
- prune(maxAgeMs) {
162
- return deleteStale.run(Date.now() - maxAgeMs).changes;
146
+ async prune(maxAgeMs) {
147
+ const deleted = (await sql`DELETE FROM conversations WHERE updated_at_ms < ${Date.now() - maxAgeMs} RETURNING key`) as {
148
+ key: string;
149
+ }[];
150
+ return deleted.length;
163
151
  },
164
152
  };
165
153
  }
@@ -226,16 +226,22 @@ export interface ConversationState {
226
226
  updatedAtMs: number;
227
227
  }
228
228
 
229
+ /**
230
+ * Every method is asynchronous because the store may be a shared database
231
+ * rather than a local file: replicas that cannot see each other's conversation
232
+ * state re-cold-start prompt caches for turns they did not serve, which at an
233
+ * observed 93% cache hit rate is a cost regression, not a latency one.
234
+ */
229
235
  export interface ConversationStore {
230
- get(key: string): ConversationState | null;
236
+ get(key: string): Promise<ConversationState | null>;
231
237
  /** Loads existing state or creates a fresh record. */
232
- load(key: string): ConversationState;
238
+ load(key: string): Promise<ConversationState>;
233
239
  /**
234
240
  * Persists the latest-wins fields. Deliberately does NOT write `spentUsd` or
235
241
  * `escalations` — those accumulate via `accrue`, and writing back a snapshot
236
242
  * here would clobber what a concurrent or already-billed dispatch added.
237
243
  */
238
- save(state: ConversationState): void;
244
+ save(state: ConversationState): Promise<void>;
239
245
  /**
240
246
  * Adds to the persisted counters, atomically in SQL.
241
247
  *
@@ -244,9 +250,9 @@ export interface ConversationStore {
244
250
  * reach the per-conversation budget guard anyway. Requires `load` to have
245
251
  * created the row.
246
252
  */
247
- accrue(key: string, delta: { spentUsd?: number; escalations?: number }): void;
253
+ accrue(key: string, delta: { spentUsd?: number; escalations?: number }): Promise<void>;
248
254
  /** Drops records untouched for longer than `maxAgeMs`. */
249
- prune(maxAgeMs: number): number;
255
+ prune(maxAgeMs: number): Promise<number>;
250
256
  }
251
257
 
252
258
  /** Guarded-probe configuration for one dispatch. */
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { RouterConfig } from "../config/types.ts";
19
- import type { Ledger } from "../cost/types.ts";
19
+ import type { AsyncLedger } from "../cost/types.ts";
20
20
  import { scoreHeuristic } from "../router/classify.ts";
21
21
  import { extractFeatures } from "../router/features.ts";
22
22
  import { estimateTokens } from "../tokens/estimate.ts";
@@ -69,11 +69,13 @@ function requestOf(req: AdviseRequest): NormRequest {
69
69
  }
70
70
 
71
71
  /** Classifies a prompt the way the first turn of a conversation would be, without dispatching anything. */
72
- export function advise(cfg: RouterConfig, ledger: Ledger | null, req: AdviseRequest): Advice {
72
+ export async function advise(cfg: RouterConfig, ledger: AsyncLedger | null, req: AdviseRequest): Promise<Advice> {
73
73
  const norm = requestOf(req);
74
- const features = extractFeatures(norm, estimateTokens(norm.promptBytes, "unknown", ledger));
74
+ // Advice is a hint for a client that has not dispatched yet: the default
75
+ // family ratio is enough, and it keeps this off the store entirely.
76
+ const features = extractFeatures(norm, estimateTokens(norm.promptBytes, "unknown", null));
75
77
  const cls = scoreHeuristic(features, cfg);
76
- const last = req.ompSessionId === "" ? null : (ledger?.latestForSession?.(req.ompSessionId)?.tier ?? null);
78
+ const last = req.ompSessionId === "" ? null : ((await ledger?.latestForSession(req.ompSessionId))?.tier ?? null);
77
79
  return {
78
80
  tier: cls.tier,
79
81
  task: cls.task,
@@ -24,7 +24,7 @@ import type { DigestRequest, DigestResult } from "./digest.ts";
24
24
  export interface CompactionDigester {
25
25
  digest(req: DigestRequest): Promise<DigestResult>;
26
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;
27
+ noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): Promise<number>;
28
28
  }
29
29
 
30
30
  export interface DigestCompactionArgs {
@@ -21,7 +21,7 @@
21
21
  import type { CatalogModel, CatalogSource } from "../catalog/types.ts";
22
22
  import type { DigestConfig, RouterConfig } from "../config/types.ts";
23
23
  import { computeCost, forecast } from "../cost/forecast.ts";
24
- import type { Ledger, LedgerEntry } from "../cost/types.ts";
24
+ import type { AsyncLedger, LedgerEntry } from "../cost/types.ts";
25
25
  import { buildCandidates } from "../router/candidates.ts";
26
26
  import { primaryArg } from "../router/compaction.ts";
27
27
  import { extractFeatures } from "../router/features.ts";
@@ -57,7 +57,7 @@ export type DigestResult =
57
57
  export interface DigesterDeps {
58
58
  cfg: RouterConfig;
59
59
  catalog: CatalogSource;
60
- ledger: Ledger;
60
+ ledger: AsyncLedger;
61
61
  upstream: UpstreamClient;
62
62
  log: Logger;
63
63
  }
@@ -144,7 +144,7 @@ export interface Digester {
144
144
  * back for the full output; that digest's ledger row is marked wasted and
145
145
  * the report shows the re-run rate. Returns how many were marked.
146
146
  */
147
- noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
147
+ noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): Promise<number>;
148
148
  }
149
149
 
150
150
  export function createDigester(deps: DigesterDeps): Digester {
@@ -163,7 +163,6 @@ export function createDigester(deps: DigesterDeps): Digester {
163
163
  tier: cfg.digest.tier,
164
164
  task: "documentation",
165
165
  snapshot,
166
- ledger,
167
166
  cfg,
168
167
  expectedCompletionTokens: cfg.digest.maxOutputTokens,
169
168
  warmSlug: null,
@@ -179,14 +178,14 @@ export function createDigester(deps: DigesterDeps): Digester {
179
178
  async digest(req) {
180
179
  const inputBytes = Buffer.byteLength(req.content);
181
180
  const source = req.source ?? "tool_result";
182
- const currentTier = req.tier ?? ledger.latestForSession?.(req.ompSessionId)?.tier ?? null;
181
+ const currentTier = req.tier ?? (await ledger.latestForSession(req.ompSessionId))?.tier ?? null;
183
182
  const gate = source === "compaction" ? { ...cfg.digest, enabled: cfg.compaction.digestToolResults } : cfg.digest;
184
183
  const applies = digestApplies(gate, req.toolName, inputBytes, false, currentTier);
185
184
  if (!applies.ok) return { digested: false, reason: applies.reason };
186
185
 
187
186
  const promptText = `Task: ${req.query === "" ? "(unknown)" : req.query}\nTool: ${req.toolName} ${JSON.stringify(req.input)}\n--- output ---\n${req.content}`;
188
187
  const synthetic = syntheticRequest(req, promptText);
189
- const promptTokens = estimateTokens(synthetic.promptBytes, "unknown", ledger);
188
+ const promptTokens = estimateTokens(synthetic.promptBytes, "unknown", null);
190
189
  const model = await pickModel(synthetic, promptTokens);
191
190
  if (model === null) return { digested: false, reason: "no digest model available" };
192
191
  const est = forecast(model, { promptTokens, completionTokens: cfg.digest.maxOutputTokens, cacheHitRate: 0, images: 0 });
@@ -222,7 +221,7 @@ export function createDigester(deps: DigesterDeps): Digester {
222
221
  clearTimeout(timer);
223
222
  }
224
223
  const ms = Date.now() - startedAt;
225
- const completionTokens = estimateTokens(Buffer.byteLength(text), model.tokenizer, ledger);
224
+ const completionTokens = estimateTokens(Buffer.byteLength(text), model.tokenizer, null);
226
225
  const usage = { promptTokens, cachedTokens: 0, cacheWriteTokens: 0, completionTokens, reasoningTokens: 0, images: 0 };
227
226
  const usd = costUsd ?? computeCost(model, usage).total;
228
227
 
@@ -264,7 +263,7 @@ export function createDigester(deps: DigesterDeps): Digester {
264
263
  priceModel: model,
265
264
  };
266
265
  try {
267
- ledger.record(entry);
266
+ await ledger.record(entry);
268
267
  } catch (err) {
269
268
  log.debug("digest ledger record failed", { error: err instanceof Error ? err.message : String(err) });
270
269
  }
@@ -285,7 +284,7 @@ export function createDigester(deps: DigesterDeps): Digester {
285
284
  ms,
286
285
  };
287
286
  },
288
- noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
287
+ async noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
289
288
  const list = recent.get(ompSessionId);
290
289
  if (list === undefined || list.length === 0) return 0;
291
290
  let marked = 0;
@@ -303,7 +302,7 @@ export function createDigester(deps: DigesterDeps): Digester {
303
302
  d.rerun = true;
304
303
  marked++;
305
304
  try {
306
- ledger.markWasted?.(d.ledgerId);
305
+ await ledger.markWasted(d.ledgerId);
307
306
  } catch (err) {
308
307
  log.debug("digest re-run mark failed", { error: err instanceof Error ? err.message : String(err) });
309
308
  }