@zenithfoundry/slm-gate 1.2.1

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 (106) hide show
  1. package/.env.example +669 -0
  2. package/LICENSE +21 -0
  3. package/README.md +317 -0
  4. package/configs/antigravity/.env.16gb.example +674 -0
  5. package/configs/antigravity/.env.24gb.example +674 -0
  6. package/configs/antigravity/.env.32gb.example +674 -0
  7. package/configs/antigravity/README.md +109 -0
  8. package/configs/claude-code/.env.16gb.example +674 -0
  9. package/configs/claude-code/.env.24gb.example +674 -0
  10. package/configs/claude-code/.env.32gb.example +674 -0
  11. package/configs/claude-code/README.md +52 -0
  12. package/configs/claude-desktop/.env.16gb.example +674 -0
  13. package/configs/claude-desktop/.env.24gb.example +674 -0
  14. package/configs/claude-desktop/.env.32gb.example +674 -0
  15. package/configs/claude-desktop/README.md +37 -0
  16. package/configs/cline-continue-opencode/.env.16gb.example +674 -0
  17. package/configs/cline-continue-opencode/.env.24gb.example +674 -0
  18. package/configs/cline-continue-opencode/.env.32gb.example +674 -0
  19. package/configs/cline-continue-opencode/README.md +34 -0
  20. package/configs/cursor/.env.16gb.example +674 -0
  21. package/configs/cursor/.env.24gb.example +674 -0
  22. package/configs/cursor/.env.32gb.example +674 -0
  23. package/configs/cursor/README.md +26 -0
  24. package/configs/generic-http/.env.16gb.example +674 -0
  25. package/configs/generic-http/.env.24gb.example +674 -0
  26. package/configs/generic-http/.env.32gb.example +674 -0
  27. package/configs/generic-http/README.md +20 -0
  28. package/configs/generic-stdio/.env.16gb.example +674 -0
  29. package/configs/generic-stdio/.env.24gb.example +674 -0
  30. package/configs/generic-stdio/.env.32gb.example +674 -0
  31. package/configs/generic-stdio/README.md +24 -0
  32. package/configs/preserve/README.md +26 -0
  33. package/configs/preserve/tls.json +61 -0
  34. package/dist/adapters/tech-lead-stack.js +38 -0
  35. package/dist/cache/index.js +173 -0
  36. package/dist/cli.js +256 -0
  37. package/dist/config.js +255 -0
  38. package/dist/dashboard/data.js +149 -0
  39. package/dist/dashboard/export.js +42 -0
  40. package/dist/dashboard/serve.js +63 -0
  41. package/dist/doctor.js +338 -0
  42. package/dist/hardware.js +126 -0
  43. package/dist/home-dir.js +39 -0
  44. package/dist/ledger/flush-lifecycle.js +50 -0
  45. package/dist/ledger/index.js +946 -0
  46. package/dist/ledger/report.js +69 -0
  47. package/dist/ledger/setup-dashboard.js +456 -0
  48. package/dist/ledger/smoke.js +37 -0
  49. package/dist/ledger/sync-config.js +177 -0
  50. package/dist/ledger/sync.js +307 -0
  51. package/dist/ledger/verify.js +185 -0
  52. package/dist/ledger/wipe-langfuse.js +130 -0
  53. package/dist/llm-gate/distill.js +239 -0
  54. package/dist/llm-gate/formats/anthropic.js +185 -0
  55. package/dist/llm-gate/formats/chat-completions.js +103 -0
  56. package/dist/llm-gate/formats/contract.js +29 -0
  57. package/dist/llm-gate/formats/gemini.js +84 -0
  58. package/dist/llm-gate/formats/internal.js +1 -0
  59. package/dist/llm-gate/formats/openai.js +77 -0
  60. package/dist/llm-gate/formats/responses.js +146 -0
  61. package/dist/llm-gate/forward.js +150 -0
  62. package/dist/llm-gate/index.js +40 -0
  63. package/dist/llm-gate/local-first.js +217 -0
  64. package/dist/llm-gate/pipeline.js +267 -0
  65. package/dist/llm-gate/server.js +289 -0
  66. package/dist/mcp-gate/ground.js +64 -0
  67. package/dist/mcp-gate/index.js +57 -0
  68. package/dist/mcp-gate/pipeline.js +252 -0
  69. package/dist/mcp-gate/server.js +302 -0
  70. package/dist/mcp-gate/tool-names.js +57 -0
  71. package/dist/models/check.js +26 -0
  72. package/dist/models/footprint.js +137 -0
  73. package/dist/models/helpers.js +91 -0
  74. package/dist/models/index.js +5 -0
  75. package/dist/models/reasoning.js +91 -0
  76. package/dist/models/roles.js +9 -0
  77. package/dist/models/slm.js +243 -0
  78. package/dist/models/types.js +1 -0
  79. package/dist/pricing/index.js +115 -0
  80. package/dist/pricing/plans.js +54 -0
  81. package/dist/pricing/providers.js +172 -0
  82. package/dist/resolver/index.js +277 -0
  83. package/dist/resolver/types.js +1 -0
  84. package/dist/setup/claim.js +41 -0
  85. package/dist/setup/gate-command.js +41 -0
  86. package/dist/setup/init.js +92 -0
  87. package/dist/setup/local-models.js +123 -0
  88. package/dist/setup/model-gate.js +220 -0
  89. package/dist/setup/notify.js +45 -0
  90. package/dist/setup/ollama-install.js +53 -0
  91. package/dist/setup/parent-watch.js +84 -0
  92. package/dist/setup/required-models.js +20 -0
  93. package/dist/setup/startup.js +132 -0
  94. package/dist/setup/tool-settings.js +101 -0
  95. package/dist/utils/backoff.js +47 -0
  96. package/dist/utils/compression.js +145 -0
  97. package/dist/utils/constants.js +22 -0
  98. package/dist/utils/duration.js +43 -0
  99. package/dist/utils/elision.js +556 -0
  100. package/dist/utils/embedding.js +32 -0
  101. package/dist/utils/entry-point.js +23 -0
  102. package/dist/utils/local-only.js +82 -0
  103. package/dist/utils/preserve-patterns.js +115 -0
  104. package/dist/utils/safety.js +30 -0
  105. package/dist/verifier/index.js +67 -0
  106. package/package.json +121 -0
@@ -0,0 +1,946 @@
1
+ import Database from 'better-sqlite3';
2
+ import crypto from 'node:crypto';
3
+ import { CONFIG } from '../config.js';
4
+ import { waitWithBackoff } from '../utils/backoff.js';
5
+ import { getProviderRegistry, minutesFreed, providerFromAgentName, providerFromModelId, } from '../pricing/providers.js';
6
+ let db = null;
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ export function getDb() {
10
+ if (!db) {
11
+ // CONFIG.LEDGER_PATH always resolves (a blank env value is treated as unset and takes the
12
+ // absolute default under OUTPUT_DIR), so there is deliberately no cwd-relative fallback:
13
+ // MCP hosts spawn this process with a working directory that may not exist.
14
+ const ledgerPath = CONFIG.LEDGER_PATH;
15
+ const dir = path.dirname(ledgerPath);
16
+ if (!fs.existsSync(dir)) {
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ }
19
+ db = new Database(ledgerPath);
20
+ db.pragma('journal_mode = WAL');
21
+ db.exec(`
22
+ CREATE TABLE IF NOT EXISTS distill_policy (
23
+ tool_pattern TEXT PRIMARY KEY,
24
+ fidelity TEXT CHECK (fidelity IN ('verbatim','structural','summarize')),
25
+ priority INTEGER,
26
+ updated_at TEXT
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS distill_feedback (
30
+ id TEXT PRIMARY KEY,
31
+ tool_name TEXT,
32
+ skill TEXT,
33
+ content_hash TEXT,
34
+ region_text TEXT,
35
+ embedding_blob BLOB,
36
+ signal INTEGER,
37
+ created_at TEXT
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS idx_distill_feedback_tool ON distill_feedback(tool_name);
41
+ CREATE INDEX IF NOT EXISTS idx_distill_feedback_created ON distill_feedback(created_at);
42
+
43
+ CREATE TABLE IF NOT EXISTS events (
44
+ ts TEXT,
45
+ layer TEXT,
46
+ request_id TEXT UNIQUE PRIMARY KEY,
47
+ session_id TEXT,
48
+ skill TEXT,
49
+ route TEXT,
50
+ is_local_call INTEGER,
51
+ slm_model TEXT,
52
+ api_model TEXT,
53
+ in_tok INTEGER,
54
+ out_tok INTEGER,
55
+ api_in_tok INTEGER,
56
+ api_out_tok INTEGER,
57
+ cost_usd REAL,
58
+ slm_latency_s REAL,
59
+ api_latency_s REAL,
60
+ verifier_flags TEXT,
61
+ quality_score REAL,
62
+ slm_gate TEXT,
63
+ meta TEXT,
64
+ provider TEXT,
65
+ agent TEXT,
66
+ environment TEXT
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS cache (
70
+ key TEXT PRIMARY KEY,
71
+ value TEXT,
72
+ ts TEXT
73
+ );
74
+
75
+ -- The model gate's distillation decisions: for each original tool result (by key), the exact
76
+ -- text the gate sent. The first decision wins and rows are never pruned automatically: every
77
+ -- later request must resend the same bytes, or provider caches and Claude's thinking break.
78
+ CREATE TABLE IF NOT EXISTS llm_distilled (
79
+ key TEXT PRIMARY KEY,
80
+ text TEXT NOT NULL,
81
+ outcome TEXT NOT NULL,
82
+ created_at TEXT NOT NULL
83
+ );
84
+
85
+ CREATE TABLE IF NOT EXISTS langfuse_queue (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ payload TEXT,
88
+ synced INTEGER DEFAULT 0,
89
+ attempts INTEGER DEFAULT 0
90
+ );
91
+
92
+ -- Queue rows Langfuse kept rejecting item by item. Parked here, never deleted, so a
93
+ -- malformed row cannot block the queue and its data is still there to inspect or resend.
94
+ CREATE TABLE IF NOT EXISTS langfuse_dead_letter (
95
+ id INTEGER PRIMARY KEY,
96
+ payload TEXT,
97
+ attempts INTEGER,
98
+ error TEXT,
99
+ dead_at TEXT
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS elision_cache (
103
+ id TEXT PRIMARY KEY,
104
+ tool_name TEXT,
105
+ args TEXT,
106
+ original_text TEXT,
107
+ ranges TEXT,
108
+ content_hash TEXT,
109
+ created_at TEXT,
110
+ last_accessed_at TEXT,
111
+ size_bytes INTEGER
112
+ );
113
+ `);
114
+ // Additive, idempotent column migrations. CREATE TABLE IF NOT EXISTS above only covers
115
+ // fresh databases, and this process runs headless inside MCP hosts where nobody will
116
+ // remember to run scripts/migrations/*. Every column here must be nullable.
117
+ for (const [table, column, ddl] of [
118
+ ['events', 'provider', 'ALTER TABLE events ADD COLUMN provider TEXT'],
119
+ ['events', 'agent', 'ALTER TABLE events ADD COLUMN agent TEXT'],
120
+ ['events', 'environment', 'ALTER TABLE events ADD COLUMN environment TEXT'],
121
+ ['langfuse_queue', 'attempts', 'ALTER TABLE langfuse_queue ADD COLUMN attempts INTEGER DEFAULT 0'],
122
+ ]) {
123
+ const tableInfo = db.prepare(`PRAGMA table_info(${table})`).all();
124
+ if (!Array.isArray(tableInfo) || tableInfo.some(c => c.name === column))
125
+ continue;
126
+ try {
127
+ db.exec(ddl);
128
+ console.error(`[ledger] Migrated: added ${table}.${column}`);
129
+ }
130
+ catch (err) {
131
+ // "duplicate column name" means another process won the race — benign.
132
+ const message = err instanceof Error ? err.message : String(err);
133
+ if (!/duplicate column name/i.test(message))
134
+ throw err;
135
+ }
136
+ }
137
+ const policyCount = db.prepare('SELECT count(*) as c FROM distill_policy').get();
138
+ if (!policyCount || policyCount.c === 0) {
139
+ const stmt = db.prepare('INSERT INTO distill_policy (tool_pattern, fidelity, priority, updated_at) VALUES (?, ?, ?, ?)');
140
+ const now = new Date().toISOString();
141
+ stmt.run('%skill%', 'verbatim', 10, now);
142
+ stmt.run('get_skill', 'verbatim', 10, now);
143
+ stmt.run('read_file', 'structural', 5, now);
144
+ stmt.run('view_file', 'structural', 5, now);
145
+ stmt.run('run_command', 'summarize', 0, now);
146
+ stmt.run('get_logs', 'summarize', 0, now);
147
+ stmt.run('grep_search', 'summarize', 0, now);
148
+ stmt.run('list_dir', 'summarize', 0, now);
149
+ stmt.run('*', 'summarize', -1, now);
150
+ }
151
+ // Automatic cleanup (startup sweep)
152
+ const retentionDays = CONFIG.ELISION_RETENTION_DAYS ?? 180;
153
+ const cutoffDate = new Date();
154
+ cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
155
+ db.prepare(`DELETE FROM elision_cache WHERE created_at < ?`).run(cutoffDate.toISOString());
156
+ }
157
+ return db;
158
+ }
159
+ /**
160
+ * Log the resolved ledger database path and event count to stderr.
161
+ * Call at startup from each layer entry point so divergent paths are immediately obvious.
162
+ *
163
+ * @param layer - Identifier for the calling layer (e.g. 'mcp-gate', 'llm-gate', 'sync')
164
+ */
165
+ export function logLedgerInfo(layer) {
166
+ const database = getDb();
167
+ const count = database.prepare('SELECT count(*) as c FROM events').get()?.c ?? 0;
168
+ const resolved = path.resolve(CONFIG.LEDGER_PATH);
169
+ console.error(`[${layer}] Using database: ${resolved} (${count} events)`);
170
+ }
171
+ export function writeElision(record) {
172
+ const ts = new Date().toISOString();
173
+ // Size cap constraint
174
+ const maxMb = CONFIG.ELISION_MAX_MB ?? 500;
175
+ const maxBytes = maxMb * 1024 * 1024;
176
+ const db = getDb();
177
+ // Start a transaction for the write + eviction
178
+ const transaction = db.transaction(() => {
179
+ const insertStmt = db.prepare(`
180
+ INSERT OR REPLACE INTO elision_cache (
181
+ id, tool_name, args, original_text, ranges, content_hash, created_at, last_accessed_at, size_bytes
182
+ ) VALUES (
183
+ @id, @tool_name, @args, @original_text, @ranges, @content_hash, @created_at, @last_accessed_at, @size_bytes
184
+ )
185
+ `);
186
+ insertStmt.run({
187
+ ...record,
188
+ created_at: ts,
189
+ last_accessed_at: ts
190
+ });
191
+ // Evict oldest by last_accessed_at if we exceed max size
192
+ db.prepare(`
193
+ DELETE FROM elision_cache
194
+ WHERE id IN (
195
+ SELECT id FROM (
196
+ SELECT id, sum(size_bytes) OVER (ORDER BY last_accessed_at DESC) as running_total
197
+ FROM elision_cache
198
+ ) WHERE running_total > ?
199
+ )
200
+ `).run(maxBytes);
201
+ });
202
+ transaction();
203
+ }
204
+ export function getElision(id) {
205
+ const db = getDb();
206
+ const row = db.prepare(`SELECT * FROM elision_cache WHERE id = ?`).get(id);
207
+ if (row) {
208
+ // Lazy expiry check
209
+ const retentionDays = CONFIG.ELISION_RETENTION_DAYS ?? 180;
210
+ const cutoffDate = new Date();
211
+ cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
212
+ if (new Date(row.created_at) < cutoffDate) {
213
+ db.prepare(`DELETE FROM elision_cache WHERE id = ?`).run(id);
214
+ return null;
215
+ }
216
+ db.prepare(`UPDATE elision_cache SET last_accessed_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
217
+ return row;
218
+ }
219
+ return null;
220
+ }
221
+ export function isLocalEvent(e) {
222
+ return e.route === 'defer_local' || (!!e.verifier_flags && !e.verifier_flags.includes('escalate'));
223
+ }
224
+ /**
225
+ * Classifies what actually happened to a request.
226
+ *
227
+ * The previous binary (isLocalEvent) had no slot for `condition`, so every distilled MCP
228
+ * tool result was labelled "Escalated (Cloud)" in the `verified` pie while the same trace
229
+ * carried a `call:local` tag — the two widgets flatly contradicted each other. Distillation
230
+ * is a third outcome: real local work was done, AND the payload still went to the cloud.
231
+ *
232
+ * @param e The ledger event to classify
233
+ * @returns Which of the three routing outcomes occurred
234
+ */
235
+ export function routingOutcome(e) {
236
+ if (isLocalEvent(e))
237
+ return 'resolved_local';
238
+ if (e.route === 'condition' || e.route === 'forward_compressed')
239
+ return 'distilled_forwarded';
240
+ return 'escalated_cloud';
241
+ }
242
+ // Detection is delegated to the data-driven provider registry so a new host or vendor is
243
+ // configuration (PROVIDER_REGISTRY_PATH), not a code change and a release.
244
+ export function providerFromModel(model) {
245
+ return providerFromModelId(model);
246
+ }
247
+ export function providerFromAgent(agent) {
248
+ return providerFromAgentName(agent);
249
+ }
250
+ /**
251
+ * Resolves the provider for an event using every available signal, in priority order.
252
+ *
253
+ * @param e The ledger event
254
+ * @returns Provider id, or null when the event cannot be attributed.
255
+ */
256
+ export function resolveProvider(e) {
257
+ return e.provider ?? providerFromModelId(e.api_model) ?? providerFromAgentName(e.agent) ?? CONFIG.PROVIDER ?? null;
258
+ }
259
+ /**
260
+ * Units of a provider's window that this single event frees.
261
+ *
262
+ * The unit depends on the metering model: requests never sent for 'message' providers,
263
+ * tokens never sent for 'compute' providers. Returning units (not minutes) keeps this
264
+ * honest — conversion to minutes requires a window budget the gate cannot observe.
265
+ *
266
+ * @param e The ledger event
267
+ * @param metering How the provider meters its window
268
+ * @returns Units saved, never negative.
269
+ */
270
+ export function perEventUnitsSaved(e, metering) {
271
+ if (metering === 'message') {
272
+ // Only a prompt answered entirely locally avoids a request. A distilled-but-forwarded
273
+ // payload still costs one message, no matter how much it was compressed.
274
+ return e.route === 'defer_local' ? 1 : 0;
275
+ }
276
+ return perEventTokensSaved(e);
277
+ }
278
+ /**
279
+ * Minutes of a provider's rolling window freed by a single event.
280
+ *
281
+ * @param e The ledger event
282
+ * @param providerId Provider id, as resolved by resolveProvider()
283
+ * @returns Minutes freed, clamped to [0, windowMinutes], or null when the provider is
284
+ * unknown or its window budget has not been configured.
285
+ */
286
+ export function perEventCycleMinutes(e, providerId) {
287
+ const profile = getProviderRegistry()[providerId];
288
+ if (!profile)
289
+ return null;
290
+ // No budget means no denominator. Emitting a number here is what produced the old
291
+ // nonsense figures, so minutesFreed returns null instead.
292
+ return minutesFreed(profile, perEventUnitsSaved(e, profile.metering));
293
+ }
294
+ export function perEventTokensSaved(e) {
295
+ // 'feedback' rows record a user action (an elision expansion), not model work.
296
+ if (e.route === 'feedback')
297
+ return 0;
298
+ const parsedMeta = e.meta ? (() => { try {
299
+ return JSON.parse(e.meta);
300
+ }
301
+ catch {
302
+ return {};
303
+ } })() : {};
304
+ if (e.route === 'defer_local') {
305
+ return (e.in_tok || 0) + (e.out_tok || 0);
306
+ }
307
+ else if (e.route === 'forward_compressed') {
308
+ const rawInTok = typeof parsedMeta.raw_in_tok === 'number' ? parsedMeta.raw_in_tok : (e.api_in_tok > 0 ? Math.round(e.api_in_tok * 1.5) : e.in_tok);
309
+ const baselineTokens = rawInTok + (e.api_out_tok || e.out_tok || 0);
310
+ const actualTokens = (e.api_in_tok || 0) + (e.api_out_tok || 0);
311
+ return Math.max(0, baselineTokens - actualTokens);
312
+ }
313
+ else if (e.route === 'condition') {
314
+ const baselineTokens = e.in_tok || 0;
315
+ const actualTokens = e.out_tok || 0;
316
+ return Math.max(0, baselineTokens - actualTokens);
317
+ }
318
+ return 0;
319
+ }
320
+ export function perEventBaselineTokens(e) {
321
+ if (e.route === 'feedback')
322
+ return 0;
323
+ const parsedMeta = e.meta ? (() => { try {
324
+ return JSON.parse(e.meta);
325
+ }
326
+ catch {
327
+ return {};
328
+ } })() : {};
329
+ if (e.route === 'defer_local') {
330
+ return (e.in_tok || 0) + (e.out_tok || 0);
331
+ }
332
+ else if (e.route === 'forward_compressed') {
333
+ const rawInTok = typeof parsedMeta.raw_in_tok === 'number' ? parsedMeta.raw_in_tok : (e.api_in_tok > 0 ? Math.round(e.api_in_tok * 1.5) : e.in_tok);
334
+ return rawInTok + (e.api_out_tok || e.out_tok || 0);
335
+ }
336
+ else if (e.route === 'condition') {
337
+ return e.in_tok || 0;
338
+ }
339
+ return (e.api_in_tok || e.in_tok || 0) + (e.api_out_tok || e.out_tok || 0);
340
+ }
341
+ export function computeTotals(rows) {
342
+ let tokensSaved = 0;
343
+ let baselineTokens = 0;
344
+ for (const r of rows) {
345
+ baselineTokens += perEventBaselineTokens(r);
346
+ tokensSaved += perEventTokensSaved(r);
347
+ }
348
+ return { tokensSaved, baselineTokens };
349
+ }
350
+ export function computeTotalsByProvider(rows, fallbackProvider = CONFIG.PROVIDER ?? null) {
351
+ const stats = {
352
+ claude: { tokensSaved: 0, baselineTokens: 0 },
353
+ chatgpt: { tokensSaved: 0, baselineTokens: 0 },
354
+ gemini: { tokensSaved: 0, baselineTokens: 0 },
355
+ };
356
+ for (const r of rows) {
357
+ const p = r.provider || providerFromModel(r.api_model) || providerFromAgent(r.agent) || fallbackProvider || null;
358
+ if (p && stats[p]) {
359
+ stats[p].baselineTokens += perEventBaselineTokens(r);
360
+ stats[p].tokensSaved += perEventTokensSaved(r);
361
+ }
362
+ }
363
+ return stats;
364
+ }
365
+ export function computeCycleRates(rows,
366
+ // Same default as computeCycleRateAvg. Without it, an event carrying no provider was
367
+ // attributed by the average but not by the total, so the two disagreed on the same data.
368
+ fallbackProvider = CONFIG.PROVIDER ?? null) {
369
+ // Aggregate minutes freed across all of a provider's traffic. Uses the same rate-based
370
+ // definition as the per-event score, so the two can never disagree.
371
+ const out = { claude: 0, chatgpt: 0, gemini: 0 };
372
+ for (const r of rows) {
373
+ const p = (r.provider || providerFromModel(r.api_model) || providerFromAgent(r.agent) || fallbackProvider || null);
374
+ if (p && p in out) {
375
+ out[p] += perEventCycleMinutes(r, p) ?? 0;
376
+ }
377
+ }
378
+ for (const p of Object.keys(out))
379
+ out[p] = Number(out[p].toFixed(2));
380
+ return out;
381
+ }
382
+ export function computeCycleRateAvg(rows, fallbackProvider = CONFIG.PROVIDER ?? null) {
383
+ const counts = { claude: 0, chatgpt: 0, gemini: 0 };
384
+ const sums = { claude: 0, chatgpt: 0, gemini: 0 };
385
+ for (const r of rows) {
386
+ const p = (r.provider || providerFromModel(r.api_model) || providerFromAgent(r.agent) || fallbackProvider || null);
387
+ if (p && (p === 'claude' || p === 'chatgpt' || p === 'gemini')) {
388
+ // null means the provider has no configured window budget, so this event contributes
389
+ // nothing and is excluded from the denominator too — an unmeasurable event must not
390
+ // be averaged in as a zero.
391
+ const minutes = perEventCycleMinutes(r, p);
392
+ if (minutes !== null) {
393
+ sums[p] += minutes;
394
+ counts[p] += 1;
395
+ }
396
+ }
397
+ }
398
+ return {
399
+ claude: counts.claude > 0 ? sums.claude / counts.claude : null,
400
+ chatgpt: counts.chatgpt > 0 ? sums.chatgpt / counts.chatgpt : null,
401
+ gemini: counts.gemini > 0 ? sums.gemini / counts.gemini : null,
402
+ };
403
+ }
404
+ export function writeEvent(e) {
405
+ // Resolve derived fields ONCE and carry them on a single enriched object, so SQLite and
406
+ // Langfuse can never disagree. Previously `provider` was computed into a local, written
407
+ // to SQLite, and then the *un-enriched* `e` was mirrored — leaving Langfuse to re-derive
408
+ // it through a chain ending in CONFIG.PROVIDER, which is unset when .env fails to load.
409
+ const event = {
410
+ ...e,
411
+ provider: e.provider ?? providerFromModel(e.api_model) ?? providerFromAgent(e.agent) ?? CONFIG.PROVIDER ?? null,
412
+ environment: e.environment ?? CONFIG.LANGFUSE_ENVIRONMENT,
413
+ };
414
+ const statement = getDb().prepare(`
415
+ INSERT OR REPLACE INTO events (
416
+ ts, layer, request_id, session_id, skill, route, is_local_call, slm_model, api_model,
417
+ in_tok, out_tok, api_in_tok, api_out_tok, cost_usd, slm_latency_s, api_latency_s,
418
+ verifier_flags, quality_score, slm_gate, meta, provider, agent, environment
419
+ ) VALUES (
420
+ @ts, @layer, @request_id, @session_id, @skill, @route, @is_local_call, @slm_model, @api_model,
421
+ @in_tok, @out_tok, @api_in_tok, @api_out_tok, @cost_usd, @slm_latency_s, @api_latency_s,
422
+ @verifier_flags, @quality_score, @slm_gate, @meta, @provider, @agent, @environment
423
+ )
424
+ `);
425
+ // better-sqlite3 strictly requires all named parameters to exist on the object,
426
+ // so we must coalesce any undefined optional properties to null.
427
+ statement.run({
428
+ ts: event.ts,
429
+ layer: event.layer,
430
+ request_id: event.request_id,
431
+ session_id: event.session_id ?? null,
432
+ skill: event.skill ?? null,
433
+ route: event.route,
434
+ is_local_call: event.is_local_call,
435
+ slm_model: event.slm_model ?? null,
436
+ api_model: event.api_model ?? null,
437
+ in_tok: event.in_tok,
438
+ out_tok: event.out_tok,
439
+ api_in_tok: event.api_in_tok,
440
+ api_out_tok: event.api_out_tok,
441
+ cost_usd: event.cost_usd,
442
+ slm_latency_s: event.slm_latency_s,
443
+ api_latency_s: event.api_latency_s,
444
+ verifier_flags: event.verifier_flags ?? null,
445
+ quality_score: event.quality_score ?? null,
446
+ slm_gate: event.slm_gate,
447
+ meta: event.meta ?? null,
448
+ provider: event.provider,
449
+ agent: event.agent ?? null,
450
+ environment: event.environment ?? null
451
+ });
452
+ // Mirror to Langfuse if enabled
453
+ LangfuseSink.mirrorEvent(event);
454
+ }
455
+ export function cacheGet(key) {
456
+ const statement = getDb().prepare('SELECT value FROM cache WHERE key = ?');
457
+ const row = statement.get(key);
458
+ return row ? row.value : null;
459
+ }
460
+ export function cacheSet(key, value) {
461
+ const statement = getDb().prepare('INSERT OR REPLACE INTO cache (key, value, ts) VALUES (?, ?, ?)');
462
+ statement.run(key, value, new Date().toISOString());
463
+ }
464
+ /** The model gate's stored decision for a tool result, or null when it has none. Throws on database errors. */
465
+ export function getDistillDecision(key) {
466
+ const row = getDb().prepare('SELECT text, outcome FROM llm_distilled WHERE key = ?').get(key);
467
+ return row ?? null;
468
+ }
469
+ /**
470
+ * Stores a decision unless one already exists and returns the one that stands; the caller must send
471
+ * exactly that text. Insert and read-back share one transaction, so a decision is never on disk
472
+ * without the caller knowing it. Throws on database errors (e.g. the gate's short busy timeout), in
473
+ * which case nothing was stored.
474
+ */
475
+ export function recordDistillDecision(params) {
476
+ const db = getDb();
477
+ return db.transaction(() => {
478
+ db.prepare('INSERT OR IGNORE INTO llm_distilled (key, text, outcome, created_at) VALUES (?, ?, ?, ?)')
479
+ .run(params.key, params.text, params.outcome, new Date().toISOString());
480
+ return db.prepare('SELECT text, outcome FROM llm_distilled WHERE key = ?').get(params.key);
481
+ })();
482
+ }
483
+ import { safeCalculateCostUsd } from '../pricing/index.js';
484
+ /**
485
+ * Carried by every trace the gate writes. Other programs may write to the same Langfuse
486
+ * project, so ledger:verify counts and langfuse:wipe deletes only traces with this tag. The
487
+ * dashboard cards do not use it (see setup-dashboard.ts): they filter on the gate's score names.
488
+ */
489
+ export const SLM_GATE_SOURCE_TAG = 'source:slm-gate';
490
+ export function formatEventForLangfuse(e) {
491
+ // Price against the model that actually served (or would have served) this event.
492
+ // Previously every defer_local/condition event was priced at CONFIG.CLOUD_MODEL, so
493
+ // Claude traffic was costed at Gemini rates — and because CLOUD_MODEL comes from .env,
494
+ // the figure silently changed depending on which directory the gate was spawned in.
495
+ const referenceCloudModel = e.api_model || CONFIG.CLOUD_MODEL || 'gemini-2.5-flash';
496
+ const baselineTokens = perEventBaselineTokens(e);
497
+ const tokensSaved = perEventTokensSaved(e);
498
+ let baselineCostUsd = 0;
499
+ let costSavedUsd = 0;
500
+ const parsedMeta = e.meta ? (() => { try {
501
+ return JSON.parse(e.meta);
502
+ }
503
+ catch {
504
+ return {};
505
+ } })() : {};
506
+ if (e.route === 'defer_local') {
507
+ baselineCostUsd = safeCalculateCostUsd(referenceCloudModel, e.in_tok, e.out_tok);
508
+ costSavedUsd = baselineCostUsd;
509
+ }
510
+ else if (e.route === 'forward_compressed') {
511
+ const rawInTok = typeof parsedMeta.raw_in_tok === 'number' ? parsedMeta.raw_in_tok : (e.api_in_tok > 0 ? Math.round(e.api_in_tok * 1.5) : e.in_tok);
512
+ baselineCostUsd = safeCalculateCostUsd(referenceCloudModel, rawInTok, e.api_out_tok || e.out_tok || 0);
513
+ costSavedUsd = Math.max(0, baselineCostUsd - (e.cost_usd || 0));
514
+ }
515
+ else if (e.route === 'condition') {
516
+ baselineCostUsd = safeCalculateCostUsd(referenceCloudModel, baselineTokens, 0);
517
+ const conditionedCostUsd = safeCalculateCostUsd(referenceCloudModel, e.out_tok || 0, 0);
518
+ costSavedUsd = Math.max(0, baselineCostUsd - conditionedCostUsd);
519
+ }
520
+ else {
521
+ // forward_raw or escalate
522
+ baselineCostUsd = e.cost_usd || 0;
523
+ costSavedUsd = 0;
524
+ }
525
+ const traceName = e.skill
526
+ ? e.skill
527
+ : (e.layer === 'mcp' ? `[mcp] ${e.route}` : `[llm] ${e.route}`);
528
+ const tags = [
529
+ SLM_GATE_SOURCE_TAG,
530
+ e.slm_gate === 'on' ? 'slm_gate=on' : 'slm_gate=off',
531
+ `route:${e.route}`,
532
+ `layer:${e.layer}`,
533
+ `call:${routingOutcome(e) === 'resolved_local' ? 'local' : 'cloud'}`,
534
+ `outcome:${routingOutcome(e)}`,
535
+ `model:${e.api_model || e.slm_model || 'unknown'}`
536
+ ];
537
+ const metadata = {
538
+ ...parsedMeta,
539
+ route: e.route,
540
+ layer: e.layer,
541
+ is_local_call: Boolean(e.is_local_call),
542
+ slm_model: e.slm_model ?? undefined,
543
+ api_model: e.api_model ?? undefined,
544
+ slm_latency_s: e.slm_latency_s,
545
+ api_latency_s: e.api_latency_s,
546
+ cost_usd: e.cost_usd,
547
+ baseline_tokens: baselineTokens,
548
+ baseline_cost_usd: Number(baselineCostUsd.toFixed(6)),
549
+ tokens_saved: tokensSaved,
550
+ cost_saved_usd: Number(costSavedUsd.toFixed(6)),
551
+ verifier_flags: e.verifier_flags ? (() => { try {
552
+ return JSON.parse(e.verifier_flags);
553
+ }
554
+ catch {
555
+ return e.verifier_flags;
556
+ } })() : undefined,
557
+ };
558
+ const generations = [];
559
+ // Cloud generation
560
+ if (e.api_model && (e.api_in_tok > 0 || e.api_out_tok > 0)) {
561
+ const apiLatency = e.api_latency_s > 0 ? e.api_latency_s : 0.05;
562
+ generations.push({
563
+ id: `${e.request_id}_gen_cloud`,
564
+ name: 'cloud_api_call',
565
+ model: e.api_model,
566
+ usageDetails: {
567
+ input: e.api_in_tok,
568
+ output: e.api_out_tok,
569
+ total: e.api_in_tok + e.api_out_tok,
570
+ },
571
+ costDetails: {
572
+ total: e.cost_usd,
573
+ },
574
+ startTime: new Date(new Date(e.ts).getTime() - apiLatency * 1000).toISOString(),
575
+ endTime: new Date(e.ts).toISOString(),
576
+ metadata: {
577
+ cost_usd: e.cost_usd,
578
+ route: e.route,
579
+ }
580
+ });
581
+ }
582
+ // Local SLM generation (logged as generation so Langfuse aggregates local token throughput at $0)
583
+ if (e.slm_model && (e.in_tok > 0 || e.out_tok > 0)) {
584
+ const slmLatency = e.slm_latency_s > 0 ? e.slm_latency_s : 0.05;
585
+ const apiLatency = e.api_latency_s || 0;
586
+ generations.push({
587
+ id: `${e.request_id}_gen_local`,
588
+ name: 'local_slm_generation',
589
+ model: e.slm_model,
590
+ usageDetails: {
591
+ input: e.in_tok,
592
+ output: e.out_tok,
593
+ total: e.in_tok + e.out_tok,
594
+ },
595
+ costDetails: {
596
+ total: 0,
597
+ },
598
+ startTime: new Date(new Date(e.ts).getTime() - (apiLatency + slmLatency) * 1000).toISOString(),
599
+ endTime: new Date(new Date(e.ts).getTime() - apiLatency * 1000).toISOString(),
600
+ metadata: {
601
+ cost_usd: 0,
602
+ route: e.route,
603
+ }
604
+ });
605
+ }
606
+ const scores = [
607
+ { id: `${e.request_id}_score_cost_saved`, name: 'cost_saved_cents', value: Number((costSavedUsd * 100).toFixed(6)), dataType: 'NUMERIC' },
608
+ { id: `${e.request_id}_score_tokens_saved`, name: 'tokens_saved', value: tokensSaved, dataType: 'NUMERIC' },
609
+ ];
610
+ // Accuracy rule
611
+ if (typeof e.quality_score === 'number') {
612
+ scores.push({ id: `${e.request_id}_score_accuracy_rate_pct`, name: 'accuracy_rate_pct', value: Number((e.quality_score * 100).toFixed(2)), dataType: 'NUMERIC' });
613
+ }
614
+ else {
615
+ // Only score accuracy when a verifier ACTUALLY RAN. Previously `route === 'condition'`
616
+ // and `is_local_call === 1` both forced isAccepted true, so every MCP event scored a
617
+ // free 100 and the "SLM Accuracy Rate" widget measured nothing at all. The MCP path
618
+ // never invokes the verifier (src/verifier is only called from llm-gate/pipeline.ts),
619
+ // so those events must now produce NO accuracy score rather than a fake perfect one.
620
+ const localAttempted = parsedMeta.local_attempted === 1;
621
+ if (localAttempted) {
622
+ let hasFailureFlag = false;
623
+ if (e.verifier_flags) {
624
+ try {
625
+ const flags = JSON.parse(e.verifier_flags);
626
+ hasFailureFlag = Array.isArray(flags) && flags.length > 0;
627
+ }
628
+ catch {
629
+ hasFailureFlag = Boolean(e.verifier_flags);
630
+ }
631
+ }
632
+ const isAccepted = parsedMeta.local_accepted === 1 || !hasFailureFlag;
633
+ scores.push({ id: `${e.request_id}_score_accuracy_rate_pct`, name: 'accuracy_rate_pct', value: isAccepted ? 100 : 0, dataType: 'NUMERIC' });
634
+ }
635
+ }
636
+ const outcome = routingOutcome(e);
637
+ const verifiedLabels = {
638
+ resolved_local: {
639
+ label: 'Passed (Local SLM)',
640
+ comment: 'Handled 100% locally by Small Language Model ($0 cloud cost)'
641
+ },
642
+ distilled_forwarded: {
643
+ label: 'Distilled (Forwarded)',
644
+ comment: 'Compressed locally by the SLM, then forwarded to the cloud model'
645
+ },
646
+ escalated_cloud: {
647
+ label: 'Escalated (Cloud)',
648
+ comment: 'Sent to the cloud model without local resolution'
649
+ }
650
+ };
651
+ scores.push({
652
+ id: `${e.request_id}_score_verified`,
653
+ name: 'verified',
654
+ value: verifiedLabels[outcome].label,
655
+ dataType: 'CATEGORICAL',
656
+ comment: verifiedLabels[outcome].comment
657
+ });
658
+ const resolvedProvider = resolveProvider(e);
659
+ if (resolvedProvider) {
660
+ const cycleMinutes = perEventCycleMinutes(e, resolvedProvider);
661
+ // Emitted only when the provider's window budget is configured. Without a denominator
662
+ // there is no honest way to express savings as minutes, so we stay silent rather than
663
+ // publishing the old ratio-times-window figure.
664
+ if (cycleMinutes !== null) {
665
+ // The same quantity in two units. A Langfuse widget picks a measure and an
666
+ // aggregation and cannot convert, so a card that reads in minutes needs a score in
667
+ // minutes. Minutes carry the raw value and are summed into a per-range total;
668
+ // seconds are averaged into a per-prompt figure, where minutes render as an
669
+ // unreadable 0.18934.
670
+ scores.push({
671
+ id: `${e.request_id}_score_cycle_min_${resolvedProvider}`,
672
+ name: `cycle_extended_minutes_${resolvedProvider}`,
673
+ value: cycleMinutes,
674
+ dataType: 'NUMERIC'
675
+ });
676
+ scores.push({
677
+ id: `${e.request_id}_score_cycle_sec_${resolvedProvider}`,
678
+ name: `cycle_extended_seconds_${resolvedProvider}`,
679
+ value: Number((cycleMinutes * 60).toFixed(1)),
680
+ dataType: 'NUMERIC'
681
+ });
682
+ }
683
+ }
684
+ const environment = e.environment ?? CONFIG.LANGFUSE_ENVIRONMENT;
685
+ const eventTs = new Date(e.ts).toISOString();
686
+ return {
687
+ eventTs,
688
+ environment,
689
+ trace: {
690
+ id: e.request_id,
691
+ // TraceBody supports `timestamp` directly; ScoreBody does not, so scores rely on the
692
+ // ingestion envelope instead (see flushQueue / sync.ts).
693
+ timestamp: eventTs,
694
+ environment,
695
+ sessionId: e.session_id,
696
+ tags,
697
+ metadata,
698
+ name: traceName,
699
+ },
700
+ generations: generations.map(g => ({ ...g, environment })),
701
+ scores: scores.map(s => ({ ...s, environment })),
702
+ };
703
+ }
704
+ /** Flushes a queue row may be rejected before it is parked in langfuse_dead_letter. */
705
+ const MAX_ROW_ATTEMPTS = 5;
706
+ function describeItemError(e) {
707
+ const detail = e.message ?? (typeof e.error === 'string' ? e.error : e.error === undefined ? '' : JSON.stringify(e.error));
708
+ return `status=${e.status ?? 'unknown'} ${detail}`.trim();
709
+ }
710
+ export class LangfuseSink {
711
+ static _warnedMissingKeys = false;
712
+ static hasValidConfig() {
713
+ const hasKeys = CONFIG.LANGFUSE_PUBLIC_KEY || CONFIG.LANGFUSE_SECRET_KEY || CONFIG.LANGFUSE_HOST;
714
+ const hasAllKeys = CONFIG.LANGFUSE_PUBLIC_KEY && CONFIG.LANGFUSE_SECRET_KEY && CONFIG.LANGFUSE_HOST;
715
+ if (hasAllKeys) {
716
+ return true;
717
+ }
718
+ else if (hasKeys && !this._warnedMissingKeys) {
719
+ console.error('Langfuse needs LANGFUSE_PUBLIC_KEY + SECRET_KEY + HOST — running ledger-only');
720
+ this._warnedMissingKeys = true;
721
+ }
722
+ return false;
723
+ }
724
+ static mirrorEvent(e) {
725
+ try {
726
+ const payload = formatEventForLangfuse(e);
727
+ const statement = getDb().prepare('INSERT INTO langfuse_queue (payload) VALUES (?)');
728
+ statement.run(JSON.stringify(payload));
729
+ }
730
+ catch (err) {
731
+ console.error('Failed to queue langfuse event:', err);
732
+ }
733
+ }
734
+ /**
735
+ * Ship queued Langfuse payloads.
736
+ *
737
+ * Drains to empty rather than a single fixed-size page: the previous `LIMIT 50` with no
738
+ * loop meant throughput was capped at 50 events per invocation, so a backlog could never
739
+ * catch up. Rows are only deleted after the server accepts them, so a failure leaves the
740
+ * queue intact and the offline contract holds.
741
+ *
742
+ * Langfuse answers 207 when it takes the batch but rejects single items. A row is deleted
743
+ * only when every one of its items was accepted; a rejected row stays queued with its
744
+ * attempt count raised, and after MAX_ROW_ATTEMPTS it moves to langfuse_dead_letter so it
745
+ * cannot block the queue. Each row is sent at most once per call, so a rejection costs one
746
+ * attempt per flush, not one per batch.
747
+ *
748
+ * @param options.maxBatches Safety valve so a pathological queue cannot spin forever.
749
+ * @param options.deadlineMs Wall-clock budget; used by the shutdown drain, where MCP hosts
750
+ * force-kill after a short grace period.
751
+ * @returns Number of queue rows successfully shipped.
752
+ */
753
+ static async flushQueue(options = {}) {
754
+ if (!this.hasValidConfig())
755
+ return 0;
756
+ const { maxBatches = 100, deadlineMs } = options;
757
+ const startedAt = Date.now();
758
+ const db = getDb();
759
+ const MAX_ATTEMPTS = 3;
760
+ let shipped = 0;
761
+ // Rows at or below this id were already sent during this call.
762
+ let lastId = 0;
763
+ for (let batchNo = 0; batchNo < maxBatches; batchNo++) {
764
+ if (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs) {
765
+ console.error('[ledger] Langfuse flush hit its time budget; remaining rows stay queued.');
766
+ break;
767
+ }
768
+ // ORDER BY id so the oldest events drain first and ordering is deterministic.
769
+ const rows = db
770
+ .prepare('SELECT id, payload, attempts FROM langfuse_queue WHERE synced = 0 AND id > ? ORDER BY id ASC LIMIT 50')
771
+ .all(lastId);
772
+ if (rows.length === 0)
773
+ break;
774
+ lastId = rows[rows.length - 1].id;
775
+ const batch = [];
776
+ const rowIds = [];
777
+ // Each batch item's id → the queue row it came from, so a per-item error in a 207
778
+ // response can be traced back to the one row that must stay queued.
779
+ const rowIdByItemId = new Map();
780
+ for (const row of rows) {
781
+ rowIds.push(row.id);
782
+ const payload = JSON.parse(row.payload);
783
+ // Langfuse stamps the ingestion ENVELOPE, not the body. ScoreBody has no timestamp
784
+ // field at all, so without this every score lands at flush time and the whole time
785
+ // series collapses onto a few instants — which is what broke the date filters.
786
+ const envelopeTs = payload.eventTs ?? payload.trace?.timestamp ?? new Date().toISOString();
787
+ const add = (item) => {
788
+ const id = crypto.randomUUID();
789
+ rowIdByItemId.set(id, row.id);
790
+ batch.push({ id, type: item.type, timestamp: envelopeTs, body: item.body });
791
+ };
792
+ add({ type: 'trace-create', body: payload.trace });
793
+ const generations = payload.generations || (payload.generation ? [payload.generation] : []);
794
+ for (const gen of generations) {
795
+ add({ type: 'generation-create', body: { ...gen, traceId: payload.trace.id } });
796
+ }
797
+ if (payload.scores && Array.isArray(payload.scores)) {
798
+ for (const score of payload.scores) {
799
+ add({ type: 'score-create', body: { ...score, traceId: payload.trace.id } });
800
+ }
801
+ }
802
+ if (payload.span) {
803
+ add({ type: 'span-create', body: { ...payload.span, traceId: payload.trace.id } });
804
+ }
805
+ }
806
+ let accepted = false;
807
+ let itemErrors = [];
808
+ for (let attempt = 0; attempt < MAX_ATTEMPTS && !accepted; attempt++) {
809
+ try {
810
+ const auth = Buffer.from(`${CONFIG.LANGFUSE_PUBLIC_KEY}:${CONFIG.LANGFUSE_SECRET_KEY}`).toString('base64');
811
+ const res = await fetch(`${CONFIG.LANGFUSE_HOST}/api/public/ingestion`, {
812
+ method: 'POST',
813
+ headers: {
814
+ 'Authorization': `Basic ${auth}`,
815
+ 'Content-Type': 'application/json'
816
+ },
817
+ body: JSON.stringify({ batch })
818
+ });
819
+ let body = null;
820
+ try {
821
+ body = await res.json();
822
+ }
823
+ catch { /* not JSON */ }
824
+ if (body && Array.isArray(body.errors) && body.errors.length > 0) {
825
+ for (const e of body.errors) {
826
+ console.error(`[ledger] Langfuse per-item error: id=${e.id ?? 'unknown'} ${describeItemError(e)}`);
827
+ }
828
+ }
829
+ if (res.ok) {
830
+ accepted = true;
831
+ itemErrors = body && Array.isArray(body.errors) ? body.errors : [];
832
+ break;
833
+ }
834
+ // 429 and 5xx are transient: back off and retry the same batch. Anything else is
835
+ // a permanent rejection (bad payload, bad auth) that retrying cannot fix.
836
+ const isTransient = res.status === 429 || res.status >= 500;
837
+ const errText = body ? JSON.stringify(body) : await res.text().catch(() => '(no body)');
838
+ if (!isTransient) {
839
+ console.warn(`[ledger] Warning: Langfuse ingestion rejected (${res.status}): ${errText}`);
840
+ break;
841
+ }
842
+ if (attempt < MAX_ATTEMPTS - 1) {
843
+ await waitWithBackoff(attempt, MAX_ATTEMPTS, `Langfuse ingestion ${res.status}`, res.headers.get('retry-after'), 'ledger');
844
+ }
845
+ else {
846
+ console.warn(`[ledger] Warning: Langfuse ingestion failed after ${MAX_ATTEMPTS} attempts (${res.status}): ${errText}`);
847
+ }
848
+ }
849
+ catch (err) {
850
+ const message = err instanceof Error ? err.message : String(err);
851
+ if (attempt < MAX_ATTEMPTS - 1) {
852
+ await waitWithBackoff(attempt, MAX_ATTEMPTS, `Langfuse network error (${message})`, null, 'ledger');
853
+ }
854
+ else {
855
+ console.warn(`[ledger] Warning: Langfuse network flush failed: ${message}`);
856
+ }
857
+ }
858
+ }
859
+ if (!accepted)
860
+ break; // leave rows queued for the next attempt
861
+ // An error that names no item of this batch cannot be pinned to a row, so every row
862
+ // in the batch is treated as rejected: resending is harmless (every id is an upsert),
863
+ // deleting an unaccepted row is silent data loss.
864
+ const rejections = new Map();
865
+ for (const e of itemErrors) {
866
+ const rowId = e.id === undefined ? undefined : rowIdByItemId.get(e.id);
867
+ for (const id of rowId === undefined ? rowIds : [rowId]) {
868
+ rejections.set(id, [...(rejections.get(id) ?? []), describeItemError(e)]);
869
+ }
870
+ }
871
+ const acceptedIds = rowIds.filter(id => !rejections.has(id));
872
+ db.transaction(() => {
873
+ if (acceptedIds.length > 0) {
874
+ const placeholders = acceptedIds.map(() => '?').join(',');
875
+ db.prepare(`DELETE FROM langfuse_queue WHERE id IN (${placeholders})`).run(...acceptedIds);
876
+ }
877
+ for (const row of rows) {
878
+ const errors = rejections.get(row.id);
879
+ if (!errors)
880
+ continue;
881
+ const attempts = (row.attempts ?? 0) + 1;
882
+ if (attempts < MAX_ROW_ATTEMPTS) {
883
+ db.prepare('UPDATE langfuse_queue SET attempts = ? WHERE id = ?').run(attempts, row.id);
884
+ continue;
885
+ }
886
+ db.prepare('INSERT OR REPLACE INTO langfuse_dead_letter (id, payload, attempts, error, dead_at) VALUES (?, ?, ?, ?, ?)')
887
+ .run(row.id, row.payload, attempts, errors.join('\n'), new Date().toISOString());
888
+ db.prepare('DELETE FROM langfuse_queue WHERE id = ?').run(row.id);
889
+ console.warn(`[ledger] Warning: Langfuse rejected queue row ${row.id} ${attempts} times; moved it to langfuse_dead_letter. Last error: ${errors[0]}`);
890
+ }
891
+ })();
892
+ shipped += acceptedIds.length;
893
+ }
894
+ return shipped;
895
+ }
896
+ /**
897
+ * Test-only utility to reset the internal client state.
898
+ */
899
+ static __resetForTests() {
900
+ db = null;
901
+ }
902
+ }
903
+ export function writeDistillFeedback(row) {
904
+ const db = getDb();
905
+ if (!db)
906
+ return;
907
+ try {
908
+ const stmt = db.prepare(`
909
+ INSERT INTO distill_feedback (id, tool_name, skill, content_hash, region_text, embedding_blob, signal, created_at)
910
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
911
+ `);
912
+ stmt.run(row.id, row.tool_name, row.skill, row.content_hash, row.region_text, row.embedding_blob, row.signal, new Date().toISOString());
913
+ }
914
+ catch (e) {
915
+ console.error('Failed to write distill feedback', e);
916
+ }
917
+ }
918
+ export function getDistillFeedback(toolName, limit = 50) {
919
+ const db = getDb();
920
+ if (!db)
921
+ return [];
922
+ try {
923
+ return db.prepare('SELECT * FROM distill_feedback WHERE tool_name = ? ORDER BY created_at DESC LIMIT ?')
924
+ .all(toolName, limit);
925
+ }
926
+ catch (e) {
927
+ return [];
928
+ }
929
+ }
930
+ export function getDistillPolicy(toolName) {
931
+ const db = getDb();
932
+ if (!db)
933
+ return 'summarize';
934
+ try {
935
+ const rows = db.prepare('SELECT tool_pattern, fidelity FROM distill_policy ORDER BY priority DESC').all();
936
+ for (const r of rows) {
937
+ if (r.tool_pattern === '*')
938
+ return r.fidelity;
939
+ const regexStr = r.tool_pattern.replace(/%/g, '.*');
940
+ if (new RegExp('^' + regexStr + '$', 'i').test(toolName))
941
+ return r.fidelity;
942
+ }
943
+ }
944
+ catch (e) { }
945
+ return 'summarize';
946
+ }