agent-tool-forge 0.3.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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/lib/agent-registry.js +170 -0
  4. package/lib/api-client.js +792 -0
  5. package/lib/api-loader.js +260 -0
  6. package/lib/auth.d.ts +25 -0
  7. package/lib/auth.js +158 -0
  8. package/lib/checks/check-adapter.js +172 -0
  9. package/lib/checks/compose.js +42 -0
  10. package/lib/checks/content-match.js +14 -0
  11. package/lib/checks/cost-budget.js +11 -0
  12. package/lib/checks/index.js +18 -0
  13. package/lib/checks/json-valid.js +15 -0
  14. package/lib/checks/latency.js +11 -0
  15. package/lib/checks/length-bounds.js +17 -0
  16. package/lib/checks/negative-match.js +14 -0
  17. package/lib/checks/no-hallucinated-numbers.js +63 -0
  18. package/lib/checks/non-empty.js +34 -0
  19. package/lib/checks/regex-match.js +12 -0
  20. package/lib/checks/run-checks.js +84 -0
  21. package/lib/checks/schema-match.js +26 -0
  22. package/lib/checks/tool-call-count.js +16 -0
  23. package/lib/checks/tool-selection.js +34 -0
  24. package/lib/checks/types.js +45 -0
  25. package/lib/comparison/compare.js +86 -0
  26. package/lib/comparison/format.js +104 -0
  27. package/lib/comparison/index.js +6 -0
  28. package/lib/comparison/statistics.js +59 -0
  29. package/lib/comparison/types.js +41 -0
  30. package/lib/config-schema.js +200 -0
  31. package/lib/config.d.ts +66 -0
  32. package/lib/conversation-store.d.ts +77 -0
  33. package/lib/conversation-store.js +443 -0
  34. package/lib/db.d.ts +6 -0
  35. package/lib/db.js +1112 -0
  36. package/lib/dep-check.js +99 -0
  37. package/lib/drift-background.js +61 -0
  38. package/lib/drift-monitor.js +187 -0
  39. package/lib/eval-runner.js +566 -0
  40. package/lib/fixtures/fixture-store.js +161 -0
  41. package/lib/fixtures/index.js +11 -0
  42. package/lib/forge-engine.js +982 -0
  43. package/lib/forge-eval-generator.js +417 -0
  44. package/lib/forge-file-writer.js +386 -0
  45. package/lib/forge-service-client.js +190 -0
  46. package/lib/forge-service.d.ts +4 -0
  47. package/lib/forge-service.js +655 -0
  48. package/lib/forge-verifier-generator.js +271 -0
  49. package/lib/handlers/admin.js +151 -0
  50. package/lib/handlers/agents.js +229 -0
  51. package/lib/handlers/chat-resume.js +334 -0
  52. package/lib/handlers/chat-sync.js +320 -0
  53. package/lib/handlers/chat.js +320 -0
  54. package/lib/handlers/conversations.js +92 -0
  55. package/lib/handlers/preferences.js +88 -0
  56. package/lib/handlers/tools-list.js +58 -0
  57. package/lib/hitl-engine.d.ts +60 -0
  58. package/lib/hitl-engine.js +261 -0
  59. package/lib/http-utils.js +92 -0
  60. package/lib/index.d.ts +20 -0
  61. package/lib/index.js +141 -0
  62. package/lib/init.js +636 -0
  63. package/lib/manual-entry.js +59 -0
  64. package/lib/mcp-server.js +252 -0
  65. package/lib/output-groups.js +54 -0
  66. package/lib/postgres-store.d.ts +31 -0
  67. package/lib/postgres-store.js +465 -0
  68. package/lib/preference-store.d.ts +47 -0
  69. package/lib/preference-store.js +79 -0
  70. package/lib/prompt-store.d.ts +42 -0
  71. package/lib/prompt-store.js +60 -0
  72. package/lib/rate-limiter.d.ts +30 -0
  73. package/lib/rate-limiter.js +104 -0
  74. package/lib/react-engine.d.ts +110 -0
  75. package/lib/react-engine.js +337 -0
  76. package/lib/runner/cli.js +156 -0
  77. package/lib/runner/cost-estimator.js +71 -0
  78. package/lib/runner/gate.js +46 -0
  79. package/lib/runner/index.js +165 -0
  80. package/lib/sidecar.d.ts +83 -0
  81. package/lib/sidecar.js +161 -0
  82. package/lib/sse.d.ts +15 -0
  83. package/lib/sse.js +30 -0
  84. package/lib/tools-scanner.js +91 -0
  85. package/lib/tui.js +253 -0
  86. package/lib/verifier-report.js +78 -0
  87. package/lib/verifier-runner.js +338 -0
  88. package/lib/verifier-scanner.js +70 -0
  89. package/lib/verifier-worker-pool.js +196 -0
  90. package/lib/views/chat.js +340 -0
  91. package/lib/views/endpoints.js +203 -0
  92. package/lib/views/eval-run.js +206 -0
  93. package/lib/views/forge-agent.js +538 -0
  94. package/lib/views/forge.js +410 -0
  95. package/lib/views/main-menu.js +275 -0
  96. package/lib/views/mediation.js +381 -0
  97. package/lib/views/model-compare.js +430 -0
  98. package/lib/views/model-comparison.js +333 -0
  99. package/lib/views/onboarding.js +470 -0
  100. package/lib/views/performance.js +237 -0
  101. package/lib/views/run-evals.js +205 -0
  102. package/lib/views/settings.js +829 -0
  103. package/lib/views/tools-evals.js +514 -0
  104. package/lib/views/verifier-coverage.js +617 -0
  105. package/lib/workers/verifier-worker.js +52 -0
  106. package/package.json +123 -0
  107. package/widget/forge-chat.js +789 -0
package/lib/db.js ADDED
@@ -0,0 +1,1112 @@
1
+ /**
2
+ * SQLite helper for forge eval history.
3
+ * Uses better-sqlite3 (synchronous API).
4
+ *
5
+ * Schema:
6
+ * eval_runs(id, tool_name, run_at, eval_type, total_cases, passed, failed, notes)
7
+ */
8
+
9
+ import { randomUUID } from 'crypto';
10
+ import Database from 'better-sqlite3';
11
+
12
+ const SCHEMA = `
13
+ CREATE TABLE IF NOT EXISTS eval_runs (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ tool_name TEXT NOT NULL,
16
+ run_at TEXT NOT NULL,
17
+ eval_type TEXT DEFAULT 'unknown',
18
+ total_cases INTEGER DEFAULT 0,
19
+ passed INTEGER DEFAULT 0,
20
+ failed INTEGER DEFAULT 0,
21
+ skipped INTEGER DEFAULT 0,
22
+ notes TEXT
23
+ );
24
+
25
+ CREATE TABLE IF NOT EXISTS tool_registry (
26
+ tool_name TEXT PRIMARY KEY,
27
+ spec_json TEXT,
28
+ lifecycle_state TEXT NOT NULL DEFAULT 'candidate',
29
+ promoted_at TEXT,
30
+ flagged_at TEXT,
31
+ retired_at TEXT,
32
+ version TEXT DEFAULT '1.0.0',
33
+ replaced_by TEXT,
34
+ baseline_pass_rate REAL
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS eval_run_cases (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ eval_run_id INTEGER NOT NULL,
40
+ case_id TEXT,
41
+ tool_name TEXT NOT NULL,
42
+ status TEXT NOT NULL,
43
+ reason TEXT,
44
+ tools_called TEXT,
45
+ latency_ms INTEGER,
46
+ model TEXT,
47
+ input_tokens INTEGER,
48
+ output_tokens INTEGER,
49
+ run_at TEXT NOT NULL,
50
+ FOREIGN KEY (eval_run_id) REFERENCES eval_runs(id)
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS drift_alerts (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ tool_name TEXT NOT NULL,
56
+ detected_at TEXT NOT NULL,
57
+ trigger_tools TEXT,
58
+ baseline_rate REAL,
59
+ current_rate REAL,
60
+ delta REAL,
61
+ status TEXT NOT NULL DEFAULT 'open',
62
+ resolved_at TEXT
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS tool_generations (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ tool_name TEXT NOT NULL,
68
+ started_at TEXT NOT NULL,
69
+ completed_at TEXT,
70
+ generation_model TEXT,
71
+ eval_model TEXT,
72
+ phases_completed INTEGER DEFAULT 0,
73
+ spec_json TEXT,
74
+ generated_files TEXT,
75
+ status TEXT DEFAULT 'in_progress',
76
+ notes TEXT
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS model_comparisons (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ compared_at TEXT NOT NULL,
82
+ tool_name TEXT,
83
+ model_a TEXT NOT NULL,
84
+ model_b TEXT NOT NULL,
85
+ spec_a_json TEXT,
86
+ spec_b_json TEXT,
87
+ chosen_model TEXT,
88
+ phase TEXT
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS conversations (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ session_id TEXT NOT NULL,
94
+ stage TEXT NOT NULL,
95
+ role TEXT NOT NULL CHECK(role IN ('user','assistant','system')),
96
+ content TEXT NOT NULL,
97
+ created_at TEXT NOT NULL
98
+ );
99
+ CREATE INDEX IF NOT EXISTS idx_conversations_session
100
+ ON conversations(session_id, created_at);
101
+
102
+ CREATE TABLE IF NOT EXISTS mcp_call_log (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ tool_name TEXT NOT NULL,
105
+ called_at TEXT NOT NULL,
106
+ input_json TEXT,
107
+ output_json TEXT,
108
+ status_code INTEGER,
109
+ latency_ms INTEGER,
110
+ error TEXT
111
+ );
112
+
113
+ CREATE TABLE IF NOT EXISTS prompt_versions (
114
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
115
+ version TEXT NOT NULL,
116
+ content TEXT NOT NULL,
117
+ is_active INTEGER NOT NULL DEFAULT 0,
118
+ created_at TEXT NOT NULL,
119
+ activated_at TEXT,
120
+ notes TEXT
121
+ );
122
+
123
+ CREATE TABLE IF NOT EXISTS user_preferences (
124
+ user_id TEXT PRIMARY KEY,
125
+ model TEXT,
126
+ hitl_level TEXT CHECK(hitl_level IN ('autonomous','cautious','standard','paranoid')),
127
+ updated_at TEXT NOT NULL
128
+ );
129
+
130
+ CREATE TABLE IF NOT EXISTS verifier_results (
131
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
132
+ session_id TEXT,
133
+ tool_name TEXT NOT NULL,
134
+ verifier_name TEXT NOT NULL,
135
+ outcome TEXT NOT NULL CHECK(outcome IN ('pass','warn','block')),
136
+ message TEXT,
137
+ tool_call_input TEXT,
138
+ tool_call_output TEXT,
139
+ created_at TEXT NOT NULL
140
+ );
141
+ CREATE INDEX IF NOT EXISTS idx_verifier_results_tool
142
+ ON verifier_results(tool_name, created_at);
143
+
144
+ CREATE TABLE IF NOT EXISTS verifier_registry (
145
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
146
+ verifier_name TEXT NOT NULL UNIQUE,
147
+ display_name TEXT,
148
+ type TEXT NOT NULL CHECK(type IN ('schema','pattern','custom')),
149
+ aciru_category TEXT NOT NULL DEFAULT 'U',
150
+ aciru_order TEXT NOT NULL DEFAULT 'U-9999',
151
+ spec_json TEXT NOT NULL,
152
+ description TEXT,
153
+ enabled INTEGER NOT NULL DEFAULT 1,
154
+ role TEXT DEFAULT 'any',
155
+ created_at TEXT NOT NULL,
156
+ updated_at TEXT NOT NULL
157
+ );
158
+
159
+ CREATE TABLE IF NOT EXISTS verifier_tool_bindings (
160
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
161
+ verifier_name TEXT NOT NULL,
162
+ tool_name TEXT NOT NULL,
163
+ enabled INTEGER NOT NULL DEFAULT 1,
164
+ created_at TEXT NOT NULL,
165
+ UNIQUE(verifier_name, tool_name),
166
+ FOREIGN KEY (verifier_name) REFERENCES verifier_registry(verifier_name)
167
+ );
168
+ CREATE INDEX IF NOT EXISTS idx_vtb_tool_name
169
+ ON verifier_tool_bindings(tool_name, verifier_name);
170
+
171
+ CREATE TABLE IF NOT EXISTS agent_registry (
172
+ agent_id TEXT PRIMARY KEY,
173
+ display_name TEXT NOT NULL,
174
+ description TEXT,
175
+ system_prompt TEXT,
176
+ default_model TEXT,
177
+ default_hitl_level TEXT CHECK(default_hitl_level IN ('autonomous','cautious','standard','paranoid')),
178
+ allow_user_model_select INTEGER NOT NULL DEFAULT 0,
179
+ allow_user_hitl_config INTEGER NOT NULL DEFAULT 0,
180
+ tool_allowlist TEXT NOT NULL DEFAULT '*',
181
+ max_turns INTEGER,
182
+ max_tokens INTEGER,
183
+ is_default INTEGER NOT NULL DEFAULT 0,
184
+ enabled INTEGER NOT NULL DEFAULT 1,
185
+ seeded_from_config INTEGER NOT NULL DEFAULT 0,
186
+ created_at TEXT NOT NULL,
187
+ updated_at TEXT NOT NULL
188
+ );
189
+
190
+ CREATE TABLE IF NOT EXISTS chat_audit (
191
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
192
+ session_id TEXT NOT NULL,
193
+ user_id TEXT NOT NULL,
194
+ agent_id TEXT,
195
+ route TEXT NOT NULL,
196
+ status_code INTEGER NOT NULL,
197
+ duration_ms INTEGER NOT NULL,
198
+ model TEXT,
199
+ message_text TEXT,
200
+ tool_count INTEGER DEFAULT 0,
201
+ hitl_triggered INTEGER DEFAULT 0,
202
+ warnings_count INTEGER DEFAULT 0,
203
+ error_message TEXT,
204
+ created_at TEXT NOT NULL
205
+ );
206
+ CREATE INDEX IF NOT EXISTS idx_chat_audit_user ON chat_audit(user_id, created_at);
207
+ CREATE INDEX IF NOT EXISTS idx_chat_audit_session ON chat_audit(session_id);
208
+ CREATE INDEX IF NOT EXISTS idx_chat_audit_status ON chat_audit(status_code, created_at);
209
+ `;
210
+
211
+ /**
212
+ * Get (or create) a better-sqlite3 Database instance at the given path.
213
+ * @param {string} dbPath - Absolute or relative path to forge.db
214
+ * @returns {import('better-sqlite3').Database}
215
+ */
216
+ export function getDb(dbPath) {
217
+ const db = new Database(dbPath);
218
+ db.exec(SCHEMA);
219
+ // Migrate: add skipped column if it doesn't exist yet
220
+ try {
221
+ db.exec('ALTER TABLE eval_runs ADD COLUMN skipped INTEGER DEFAULT 0');
222
+ } catch (err) {
223
+ if (!err.message.includes('duplicate column name')) throw err;
224
+ }
225
+ // Migrate: add model column
226
+ try {
227
+ db.exec('ALTER TABLE eval_runs ADD COLUMN model TEXT');
228
+ } catch (err) {
229
+ if (!err.message.includes('duplicate column name')) throw err;
230
+ }
231
+ // Migrate: add pass_rate column
232
+ try {
233
+ db.exec('ALTER TABLE eval_runs ADD COLUMN pass_rate REAL');
234
+ } catch (err) {
235
+ if (!err.message.includes('duplicate column name')) throw err;
236
+ }
237
+ // Migrate: add sample_type column
238
+ try {
239
+ db.exec('ALTER TABLE eval_runs ADD COLUMN sample_type TEXT');
240
+ } catch (err) {
241
+ if (!err.message.includes('duplicate column name')) throw err;
242
+ }
243
+ // Migrate: add agent_id to conversations
244
+ try {
245
+ db.exec('ALTER TABLE conversations ADD COLUMN agent_id TEXT');
246
+ } catch (err) {
247
+ if (!err.message.includes('duplicate column name')) throw err;
248
+ }
249
+ // Migrate: add user_id to conversations
250
+ try {
251
+ db.exec('ALTER TABLE conversations ADD COLUMN user_id TEXT');
252
+ } catch (err) {
253
+ if (!err.message.includes('duplicate column name')) throw err;
254
+ }
255
+ // Add index for user_id lookups
256
+ try {
257
+ db.exec('CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id, created_at)');
258
+ } catch { /* may already exist */ }
259
+ // Migrate: add input_tokens to eval_run_cases
260
+ try {
261
+ db.exec('ALTER TABLE eval_run_cases ADD COLUMN input_tokens INTEGER');
262
+ } catch (err) {
263
+ if (!err.message.includes('duplicate column name')) throw err;
264
+ }
265
+ // Migrate: add output_tokens to eval_run_cases
266
+ try {
267
+ db.exec('ALTER TABLE eval_run_cases ADD COLUMN output_tokens INTEGER');
268
+ } catch (err) {
269
+ if (!err.message.includes('duplicate column name')) throw err;
270
+ }
271
+ // Migrate: add role column to verifier_registry (Phase 6 — sandbox)
272
+ try {
273
+ db.exec("ALTER TABLE verifier_registry ADD COLUMN role TEXT DEFAULT 'any'");
274
+ } catch (err) {
275
+ if (!err.message.includes('duplicate column name')) throw err;
276
+ }
277
+ return db;
278
+ }
279
+
280
+ /**
281
+ * Return eval summary grouped by tool_name, including last run time and pass rate.
282
+ * @param {import('better-sqlite3').Database} db
283
+ * @returns {{ tool_name: string; last_run: string; total_cases: number; passed: number; failed: number; pass_rate: string }[]}
284
+ */
285
+ export function getEvalSummary(db) {
286
+ const rows = db.prepare(`
287
+ SELECT
288
+ tool_name,
289
+ MAX(run_at) AS last_run,
290
+ SUM(total_cases) AS total_cases,
291
+ SUM(passed) AS passed,
292
+ SUM(failed) AS failed
293
+ FROM eval_runs
294
+ GROUP BY tool_name
295
+ ORDER BY last_run DESC
296
+ `).all();
297
+
298
+ return rows.map((r) => ({
299
+ ...r,
300
+ pass_rate: r.total_cases > 0
301
+ ? `${Math.round((r.passed / r.total_cases) * 100)}%`
302
+ : 'N/A'
303
+ }));
304
+ }
305
+
306
+ /**
307
+ * Insert one eval run record.
308
+ * @param {import('better-sqlite3').Database} db
309
+ * @param {{ tool_name: string; eval_type?: string; total_cases?: number; passed?: number; failed?: number; skipped?: number; notes?: string; model?: string; pass_rate?: number; sample_type?: string }} row
310
+ * @returns {number} lastInsertRowid
311
+ */
312
+ export function insertEvalRun(db, row) {
313
+ const result = db.prepare(`
314
+ INSERT INTO eval_runs (tool_name, run_at, eval_type, total_cases, passed, failed, skipped, notes, model, pass_rate, sample_type)
315
+ VALUES (@tool_name, @run_at, @eval_type, @total_cases, @passed, @failed, @skipped, @notes, @model, @pass_rate, @sample_type)
316
+ `).run({
317
+ tool_name: row.tool_name,
318
+ run_at: row.run_at ?? new Date().toISOString(),
319
+ eval_type: row.eval_type ?? 'unknown',
320
+ total_cases: row.total_cases ?? 0,
321
+ passed: row.passed ?? 0,
322
+ failed: row.failed ?? 0,
323
+ skipped: row.skipped ?? 0,
324
+ notes: row.notes ?? null,
325
+ model: row.model ?? null,
326
+ pass_rate: row.pass_rate ?? null,
327
+ sample_type: row.sample_type ?? null
328
+ });
329
+ return Number(result.lastInsertRowid);
330
+ }
331
+
332
+ /**
333
+ * Insert a new tool generation record. Returns the new row id.
334
+ * @param {import('better-sqlite3').Database} db
335
+ * @param {{ tool_name: string; started_at?: string; generation_model?: string; eval_model?: string; spec_json?: string; generated_files?: string; status?: string; notes?: string }} row
336
+ * @returns {number} lastInsertRowid
337
+ */
338
+ export function insertToolGeneration(db, row) {
339
+ const result = db.prepare(`
340
+ INSERT INTO tool_generations
341
+ (tool_name, started_at, generation_model, eval_model, spec_json, generated_files, status, notes)
342
+ VALUES
343
+ (@tool_name, @started_at, @generation_model, @eval_model, @spec_json, @generated_files, @status, @notes)
344
+ `).run({
345
+ tool_name: row.tool_name,
346
+ started_at: row.started_at ?? new Date().toISOString(),
347
+ generation_model: row.generation_model ?? null,
348
+ eval_model: row.eval_model ?? null,
349
+ spec_json: row.spec_json ?? null,
350
+ generated_files: row.generated_files ?? null,
351
+ status: row.status ?? 'in_progress',
352
+ notes: row.notes ?? null
353
+ });
354
+ return Number(result.lastInsertRowid);
355
+ }
356
+
357
+ /**
358
+ * Update fields on an existing tool generation record.
359
+ * @param {import('better-sqlite3').Database} db
360
+ * @param {number} id
361
+ * @param {Partial<{ completed_at: string; phases_completed: number; spec_json: string; generated_files: string; status: string; notes: string; generation_model: string; eval_model: string }>} updates
362
+ */
363
+ export function updateToolGeneration(db, id, updates) {
364
+ const allowed = [
365
+ 'completed_at', 'phases_completed', 'spec_json', 'generated_files',
366
+ 'status', 'notes', 'generation_model', 'eval_model'
367
+ ];
368
+ const fields = Object.keys(updates).filter((k) => allowed.includes(k));
369
+ if (fields.length === 0) return;
370
+ const setClauses = fields.map((f) => `${f} = @${f}`).join(', ');
371
+ const params = { id };
372
+ for (const f of fields) params[f] = updates[f];
373
+ db.prepare(`UPDATE tool_generations SET ${setClauses} WHERE id = @id`).run(params);
374
+ }
375
+
376
+ /**
377
+ * Get all tool generation records ordered by started_at DESC.
378
+ * @param {import('better-sqlite3').Database} db
379
+ * @returns {object[]}
380
+ */
381
+ export function getToolGenerations(db) {
382
+ return db.prepare(`
383
+ SELECT * FROM tool_generations ORDER BY started_at DESC
384
+ `).all();
385
+ }
386
+
387
+ /**
388
+ * Insert a model comparison record. Returns the new row id.
389
+ * @param {import('better-sqlite3').Database} db
390
+ * @param {{ compared_at?: string; tool_name?: string; model_a: string; model_b: string; spec_a_json?: string; spec_b_json?: string; chosen_model?: string; phase?: string }} row
391
+ * @returns {number} lastInsertRowid
392
+ */
393
+ export function insertModelComparison(db, row) {
394
+ const result = db.prepare(`
395
+ INSERT INTO model_comparisons
396
+ (compared_at, tool_name, model_a, model_b, spec_a_json, spec_b_json, chosen_model, phase)
397
+ VALUES
398
+ (@compared_at, @tool_name, @model_a, @model_b, @spec_a_json, @spec_b_json, @chosen_model, @phase)
399
+ `).run({
400
+ compared_at: row.compared_at ?? new Date().toISOString(),
401
+ tool_name: row.tool_name ?? null,
402
+ model_a: row.model_a,
403
+ model_b: row.model_b,
404
+ spec_a_json: row.spec_a_json ?? null,
405
+ spec_b_json: row.spec_b_json ?? null,
406
+ chosen_model: row.chosen_model ?? null,
407
+ phase: row.phase ?? null
408
+ });
409
+ return Number(result.lastInsertRowid);
410
+ }
411
+
412
+ /**
413
+ * Get model comparisons for a specific tool, or all rows if toolName is null.
414
+ * @param {import('better-sqlite3').Database} db
415
+ * @param {string | null} toolName
416
+ * @returns {object[]}
417
+ */
418
+ export function getModelComparisons(db, toolName) {
419
+ if (toolName == null) {
420
+ return db.prepare(`
421
+ SELECT * FROM model_comparisons ORDER BY compared_at DESC
422
+ `).all();
423
+ }
424
+ return db.prepare(`
425
+ SELECT * FROM model_comparisons WHERE tool_name = ? ORDER BY compared_at DESC
426
+ `).all(toolName);
427
+ }
428
+
429
+ // ── tool_registry ──────────────────────────────────────────────────────────
430
+
431
+ /**
432
+ * Upsert a row in tool_registry.
433
+ * @param {import('better-sqlite3').Database} db
434
+ * @param {{ tool_name: string; spec_json?: string; lifecycle_state?: string; promoted_at?: string; flagged_at?: string; retired_at?: string; version?: string; replaced_by?: string; baseline_pass_rate?: number }} row
435
+ */
436
+ export function upsertToolRegistry(db, row) {
437
+ db.prepare(`
438
+ INSERT INTO tool_registry (tool_name, spec_json, lifecycle_state, promoted_at, flagged_at, retired_at, version, replaced_by, baseline_pass_rate)
439
+ VALUES (@tool_name, @spec_json, @lifecycle_state, @promoted_at, @flagged_at, @retired_at, @version, @replaced_by, @baseline_pass_rate)
440
+ ON CONFLICT(tool_name) DO UPDATE SET
441
+ spec_json = excluded.spec_json,
442
+ lifecycle_state = excluded.lifecycle_state,
443
+ promoted_at = excluded.promoted_at,
444
+ flagged_at = excluded.flagged_at,
445
+ retired_at = excluded.retired_at,
446
+ version = excluded.version,
447
+ replaced_by = excluded.replaced_by,
448
+ baseline_pass_rate = excluded.baseline_pass_rate
449
+ `).run({
450
+ tool_name: row.tool_name,
451
+ spec_json: row.spec_json ?? null,
452
+ lifecycle_state: row.lifecycle_state ?? 'candidate',
453
+ promoted_at: row.promoted_at ?? null,
454
+ flagged_at: row.flagged_at ?? null,
455
+ retired_at: row.retired_at ?? null,
456
+ version: row.version ?? '1.0.0',
457
+ replaced_by: row.replaced_by ?? null,
458
+ baseline_pass_rate: row.baseline_pass_rate ?? null
459
+ });
460
+ }
461
+
462
+ /**
463
+ * Get a single tool_registry row by tool name.
464
+ * @param {import('better-sqlite3').Database} db
465
+ * @param {string} toolName
466
+ * @returns {object|null}
467
+ */
468
+ export function getToolRegistry(db, toolName) {
469
+ return db.prepare(`SELECT * FROM tool_registry WHERE tool_name = ?`).get(toolName) ?? null;
470
+ }
471
+
472
+ /**
473
+ * Get all tool_registry rows.
474
+ * @param {import('better-sqlite3').Database} db
475
+ * @returns {object[]}
476
+ */
477
+ export function getAllToolRegistry(db) {
478
+ return db.prepare(`SELECT * FROM tool_registry ORDER BY tool_name`).all();
479
+ }
480
+
481
+ /**
482
+ * Partially update lifecycle fields on a tool_registry row.
483
+ * @param {import('better-sqlite3').Database} db
484
+ * @param {string} toolName
485
+ * @param {Partial<{ lifecycle_state: string; promoted_at: string; flagged_at: string; retired_at: string; replaced_by: string; baseline_pass_rate: number }>} updates
486
+ */
487
+ export function updateToolLifecycle(db, toolName, updates) {
488
+ const allowed = ['lifecycle_state', 'promoted_at', 'flagged_at', 'retired_at', 'replaced_by', 'baseline_pass_rate'];
489
+ const fields = Object.keys(updates).filter((k) => allowed.includes(k));
490
+ if (fields.length === 0) return;
491
+ const setClauses = fields.map((f) => `${f} = @${f}`).join(', ');
492
+ const params = { tool_name: toolName };
493
+ for (const f of fields) params[f] = updates[f];
494
+ db.prepare(`UPDATE tool_registry SET ${setClauses} WHERE tool_name = @tool_name`).run(params);
495
+ }
496
+
497
+ // ── eval_run_cases ─────────────────────────────────────────────────────────
498
+
499
+ /**
500
+ * Batch insert eval run case rows in a transaction.
501
+ * @param {import('better-sqlite3').Database} db
502
+ * @param {Array<{ eval_run_id: number; case_id?: string; tool_name: string; status: string; reason?: string; tools_called?: string; latency_ms?: number; model?: string }>} rows
503
+ */
504
+ export function insertEvalRunCases(db, rows) {
505
+ const stmt = db.prepare(`
506
+ INSERT INTO eval_run_cases (eval_run_id, case_id, tool_name, status, reason, tools_called, latency_ms, model, input_tokens, output_tokens, run_at)
507
+ VALUES (@eval_run_id, @case_id, @tool_name, @status, @reason, @tools_called, @latency_ms, @model, @input_tokens, @output_tokens, @run_at)
508
+ `);
509
+ const now = new Date().toISOString();
510
+ db.transaction(() => {
511
+ for (const row of rows) {
512
+ stmt.run({
513
+ eval_run_id: row.eval_run_id,
514
+ case_id: row.case_id ?? null,
515
+ tool_name: row.tool_name,
516
+ status: row.status,
517
+ reason: row.reason ?? null,
518
+ tools_called: row.tools_called ?? null,
519
+ latency_ms: row.latency_ms ?? null,
520
+ model: row.model ?? null,
521
+ input_tokens: row.input_tokens ?? null,
522
+ output_tokens: row.output_tokens ?? null,
523
+ run_at: now
524
+ });
525
+ }
526
+ })();
527
+ }
528
+
529
+ /**
530
+ * Get eval run cases for a specific tool (or any tool if toolName is null), with LIMIT.
531
+ * @param {import('better-sqlite3').Database} db
532
+ * @param {string|null} toolName - null = any tool
533
+ * @param {number} [limit=50]
534
+ * @returns {object[]}
535
+ */
536
+ export function getEvalRunCasesByTool(db, toolName, limit = 50) {
537
+ if (toolName == null) {
538
+ return db.prepare(`SELECT * FROM eval_run_cases ORDER BY run_at DESC LIMIT ?`).all(limit);
539
+ }
540
+ return db.prepare(`SELECT * FROM eval_run_cases WHERE tool_name = ? ORDER BY run_at DESC LIMIT ?`).all(toolName, limit);
541
+ }
542
+
543
+ // ── drift_alerts ───────────────────────────────────────────────────────────
544
+
545
+ /**
546
+ * Insert a drift alert record.
547
+ * @param {import('better-sqlite3').Database} db
548
+ * @param {{ tool_name: string; trigger_tools?: string; baseline_rate?: number; current_rate?: number; delta?: number }} row
549
+ * @returns {number} lastInsertRowid
550
+ */
551
+ export function insertDriftAlert(db, row) {
552
+ const result = db.prepare(`
553
+ INSERT INTO drift_alerts (tool_name, detected_at, trigger_tools, baseline_rate, current_rate, delta, status)
554
+ VALUES (@tool_name, @detected_at, @trigger_tools, @baseline_rate, @current_rate, @delta, 'open')
555
+ `).run({
556
+ tool_name: row.tool_name,
557
+ detected_at: new Date().toISOString(),
558
+ trigger_tools: row.trigger_tools ?? null,
559
+ baseline_rate: row.baseline_rate ?? null,
560
+ current_rate: row.current_rate ?? null,
561
+ delta: row.delta ?? null
562
+ });
563
+ return Number(result.lastInsertRowid);
564
+ }
565
+
566
+ /**
567
+ * Get drift alerts — all open alerts if toolName is null, or filtered by tool.
568
+ * @param {import('better-sqlite3').Database} db
569
+ * @param {string|null} toolName - null = all open alerts
570
+ * @returns {object[]}
571
+ */
572
+ export function getDriftAlerts(db, toolName) {
573
+ if (toolName == null) {
574
+ return db.prepare(`SELECT * FROM drift_alerts WHERE status = 'open' ORDER BY detected_at DESC`).all();
575
+ }
576
+ return db.prepare(`SELECT * FROM drift_alerts WHERE tool_name = ? AND status = 'open' ORDER BY detected_at DESC`).all(toolName);
577
+ }
578
+
579
+ /**
580
+ * Mark a drift alert as resolved.
581
+ * @param {import('better-sqlite3').Database} db
582
+ * @param {number} alertId
583
+ */
584
+ export function resolveDriftAlert(db, alertId) {
585
+ db.prepare(`UPDATE drift_alerts SET status = 'resolved', resolved_at = ? WHERE id = ?`)
586
+ .run(new Date().toISOString(), alertId);
587
+ }
588
+
589
+ /**
590
+ * Mark a drift alert as dismissed (acknowledged, not fixed).
591
+ * @param {import('better-sqlite3').Database} db
592
+ * @param {number} alertId
593
+ */
594
+ export function dismissDriftAlert(db, alertId) {
595
+ db.prepare(`UPDATE drift_alerts SET status = 'dismissed', resolved_at = ? WHERE id = ?`)
596
+ .run(new Date().toISOString(), alertId);
597
+ }
598
+
599
+ // ── performance trending ───────────────────────────────────────────────────
600
+
601
+ /**
602
+ * Get per-tool eval run history for trending (pass_rate over time).
603
+ * @param {import('better-sqlite3').Database} db
604
+ * @param {string} toolName
605
+ * @param {number} [windowSize=10]
606
+ * @returns {object[]} rows with run_at, pass_rate, passed, total_cases
607
+ */
608
+ export function getPerToolRunHistory(db, toolName, windowSize = 10) {
609
+ return db.prepare(`
610
+ SELECT run_at, pass_rate, passed, total_cases, model
611
+ FROM eval_runs
612
+ WHERE tool_name = ? AND total_cases > 0
613
+ ORDER BY run_at DESC
614
+ LIMIT ?
615
+ `).all(toolName, windowSize);
616
+ }
617
+
618
+ /**
619
+ * Get per-model aggregate stats for a tool's eval cases.
620
+ * Used by the model comparison view.
621
+ *
622
+ * @param {import('better-sqlite3').Database} db
623
+ * @param {string} toolName
624
+ * @returns {Array<{
625
+ * model: string,
626
+ * case_count: number,
627
+ * passed: number,
628
+ * avg_latency_ms: number,
629
+ * total_input_tokens: number,
630
+ * total_output_tokens: number
631
+ * }>}
632
+ */
633
+ export function getModelComparisonData(db, toolName) {
634
+ return db.prepare(`
635
+ SELECT
636
+ model,
637
+ COUNT(*) AS case_count,
638
+ SUM(CASE WHEN status = 'passed' THEN 1 ELSE 0 END) AS passed,
639
+ ROUND(AVG(latency_ms)) AS avg_latency_ms,
640
+ SUM(COALESCE(input_tokens, 0)) AS total_input_tokens,
641
+ SUM(COALESCE(output_tokens, 0)) AS total_output_tokens
642
+ FROM eval_run_cases
643
+ WHERE tool_name = ? AND model IS NOT NULL AND status != 'skipped'
644
+ GROUP BY model
645
+ ORDER BY (passed * 1.0 / COUNT(*)) DESC
646
+ `).all(toolName);
647
+ }
648
+
649
+ // ── conversations ───────────────────────────────────────────────────────────
650
+
651
+ export function createSession() {
652
+ return randomUUID();
653
+ }
654
+
655
+ export function insertConversationMessage(db, { session_id, stage, role, content }) {
656
+ const result = db.prepare(`
657
+ INSERT INTO conversations (session_id, stage, role, content, created_at)
658
+ VALUES (@session_id, @stage, @role, @content, @created_at)
659
+ `).run({
660
+ session_id,
661
+ stage,
662
+ role,
663
+ content,
664
+ created_at: new Date().toISOString()
665
+ });
666
+ return Number(result.lastInsertRowid);
667
+ }
668
+
669
+ export function getConversationHistory(db, session_id) {
670
+ return db.prepare(`
671
+ SELECT * FROM conversations
672
+ WHERE session_id = ?
673
+ ORDER BY created_at ASC
674
+ `).all(session_id);
675
+ }
676
+
677
+ export function getIncompleteSessions(db) {
678
+ return db.prepare(`
679
+ SELECT
680
+ c.session_id,
681
+ c.stage,
682
+ MAX(c.created_at) AS last_updated
683
+ FROM conversations c
684
+ WHERE c.session_id NOT IN (
685
+ SELECT DISTINCT session_id
686
+ FROM conversations
687
+ WHERE role = 'system' AND content = '[COMPLETE]'
688
+ )
689
+ GROUP BY c.session_id
690
+ ORDER BY last_updated DESC
691
+ `).all();
692
+ }
693
+
694
+ // ── mcp_call_log ───────────────────────────────────────────────────────────
695
+
696
+ export function insertMcpCallLog(db, row) {
697
+ const result = db.prepare(`
698
+ INSERT INTO mcp_call_log (tool_name, called_at, input_json, output_json, status_code, latency_ms, error)
699
+ VALUES (@tool_name, @called_at, @input_json, @output_json, @status_code, @latency_ms, @error)
700
+ `).run({
701
+ tool_name: row.tool_name,
702
+ called_at: new Date().toISOString(),
703
+ input_json: row.input_json ?? null,
704
+ output_json: row.output_json ?? null,
705
+ status_code: row.status_code ?? null,
706
+ latency_ms: row.latency_ms ?? null,
707
+ error: row.error ?? null
708
+ });
709
+ return Number(result.lastInsertRowid);
710
+ }
711
+
712
+ export function getMcpCallLog(db, toolName = null, limit = 50) {
713
+ if (toolName == null) {
714
+ return db.prepare(`SELECT * FROM mcp_call_log ORDER BY id DESC LIMIT ?`).all(limit);
715
+ }
716
+ return db.prepare(`SELECT * FROM mcp_call_log WHERE tool_name = ? ORDER BY id DESC LIMIT ?`).all(toolName, limit);
717
+ }
718
+
719
+ // ── prompt_versions ─────────────────────────────────────────────────────────
720
+
721
+ /**
722
+ * Get the currently active prompt version.
723
+ * @param {import('better-sqlite3').Database} db
724
+ * @returns {object|null}
725
+ */
726
+ export function getActivePrompt(db) {
727
+ return db.prepare(`SELECT * FROM prompt_versions WHERE is_active = 1`).get() ?? null;
728
+ }
729
+
730
+ /**
731
+ * Insert a new prompt version (inactive by default).
732
+ * @param {import('better-sqlite3').Database} db
733
+ * @param {{ version: string; content: string; notes?: string }} row
734
+ * @returns {number} lastInsertRowid
735
+ */
736
+ export function insertPromptVersion(db, row) {
737
+ const result = db.prepare(`
738
+ INSERT INTO prompt_versions (version, content, is_active, created_at, notes)
739
+ VALUES (@version, @content, 0, @created_at, @notes)
740
+ `).run({
741
+ version: row.version,
742
+ content: row.content,
743
+ created_at: new Date().toISOString(),
744
+ notes: row.notes ?? null
745
+ });
746
+ return Number(result.lastInsertRowid);
747
+ }
748
+
749
+ /**
750
+ * Activate a prompt version (deactivates all others in a transaction).
751
+ * @param {import('better-sqlite3').Database} db
752
+ * @param {number} id
753
+ */
754
+ export function activatePromptVersion(db, id) {
755
+ db.transaction(() => {
756
+ db.prepare(`UPDATE prompt_versions SET is_active = 0, activated_at = NULL WHERE is_active = 1`).run();
757
+ db.prepare(`UPDATE prompt_versions SET is_active = 1, activated_at = ? WHERE id = ?`)
758
+ .run(new Date().toISOString(), id);
759
+ })();
760
+ }
761
+
762
+ /**
763
+ * Get all prompt versions ordered by created_at DESC.
764
+ * @param {import('better-sqlite3').Database} db
765
+ * @returns {object[]}
766
+ */
767
+ export function getAllPromptVersions(db) {
768
+ return db.prepare(`SELECT * FROM prompt_versions ORDER BY id DESC`).all();
769
+ }
770
+
771
+ // ── user_preferences ────────────────────────────────────────────────────────
772
+
773
+ /**
774
+ * Get user preferences by userId.
775
+ * @param {import('better-sqlite3').Database} db
776
+ * @param {string} userId
777
+ * @returns {object|null}
778
+ */
779
+ export function getUserPreferences(db, userId) {
780
+ return db.prepare(`SELECT * FROM user_preferences WHERE user_id = ?`).get(userId) ?? null;
781
+ }
782
+
783
+ /**
784
+ * Upsert user preferences.
785
+ * @param {import('better-sqlite3').Database} db
786
+ * @param {string} userId
787
+ * @param {{ model?: string; hitlLevel?: string }} prefs
788
+ */
789
+ export function upsertUserPreferences(db, userId, prefs) {
790
+ db.prepare(`
791
+ INSERT INTO user_preferences (user_id, model, hitl_level, updated_at)
792
+ VALUES (@user_id, @model, @hitl_level, @updated_at)
793
+ ON CONFLICT(user_id) DO UPDATE SET
794
+ model = @model,
795
+ hitl_level = @hitl_level,
796
+ updated_at = @updated_at
797
+ `).run({
798
+ user_id: userId,
799
+ model: prefs.model ?? null,
800
+ hitl_level: prefs.hitlLevel ?? null,
801
+ updated_at: new Date().toISOString()
802
+ });
803
+ }
804
+
805
+ // ── verifier_results ────────────────────────────────────────────────────────
806
+
807
+ /**
808
+ * Insert a verifier result record.
809
+ * @param {import('better-sqlite3').Database} db
810
+ * @param {{ session_id?: string; tool_name: string; verifier_name: string; outcome: string; message?: string; tool_call_input?: string; tool_call_output?: string }} row
811
+ * @returns {number} lastInsertRowid
812
+ */
813
+ export function insertVerifierResult(db, row) {
814
+ const result = db.prepare(`
815
+ INSERT INTO verifier_results (session_id, tool_name, verifier_name, outcome, message, tool_call_input, tool_call_output, created_at)
816
+ VALUES (@session_id, @tool_name, @verifier_name, @outcome, @message, @tool_call_input, @tool_call_output, @created_at)
817
+ `).run({
818
+ session_id: row.session_id ?? null,
819
+ tool_name: row.tool_name,
820
+ verifier_name: row.verifier_name,
821
+ outcome: row.outcome,
822
+ message: row.message ?? null,
823
+ tool_call_input: row.tool_call_input ?? null,
824
+ tool_call_output: row.tool_call_output ?? null,
825
+ created_at: new Date().toISOString()
826
+ });
827
+ return Number(result.lastInsertRowid);
828
+ }
829
+
830
+ /**
831
+ * Get verifier results for a tool, ordered by most recent.
832
+ * @param {import('better-sqlite3').Database} db
833
+ * @param {string} toolName
834
+ * @param {number} [limit=50]
835
+ * @returns {object[]}
836
+ */
837
+ export function getVerifierResultsByTool(db, toolName, limit = 50) {
838
+ return db.prepare(`
839
+ SELECT * FROM verifier_results
840
+ WHERE tool_name = ?
841
+ ORDER BY created_at DESC
842
+ LIMIT ?
843
+ `).all(toolName, limit);
844
+ }
845
+
846
+ // ── verifier_registry ───────────────────────────────────────────────────────
847
+
848
+ /**
849
+ * Upsert a verifier into the registry. Sets enabled=1 on insert/update.
850
+ * @param {import('better-sqlite3').Database} db
851
+ * @param {{ verifier_name: string; display_name?: string; type: string; aciru_category?: string; aciru_order?: string; spec_json: string; description?: string }} row
852
+ */
853
+ export function upsertVerifier(db, row) {
854
+ const now = new Date().toISOString();
855
+ db.prepare(`
856
+ INSERT INTO verifier_registry (verifier_name, display_name, type, aciru_category, aciru_order, spec_json, description, enabled, created_at, updated_at)
857
+ VALUES (@verifier_name, @display_name, @type, @aciru_category, @aciru_order, @spec_json, @description, 1, @now, @now)
858
+ ON CONFLICT(verifier_name) DO UPDATE SET
859
+ display_name = excluded.display_name,
860
+ type = excluded.type,
861
+ aciru_category = excluded.aciru_category,
862
+ aciru_order = excluded.aciru_order,
863
+ spec_json = excluded.spec_json,
864
+ description = excluded.description,
865
+ enabled = 1,
866
+ updated_at = excluded.updated_at
867
+ `).run({
868
+ verifier_name: row.verifier_name,
869
+ display_name: row.display_name ?? null,
870
+ type: row.type,
871
+ aciru_category: row.aciru_category ?? 'U',
872
+ aciru_order: row.aciru_order ?? 'U-9999',
873
+ spec_json: row.spec_json,
874
+ description: row.description ?? null,
875
+ now
876
+ });
877
+ }
878
+
879
+ /**
880
+ * Get a single verifier by name.
881
+ * @param {import('better-sqlite3').Database} db
882
+ * @param {string} verifierName
883
+ * @returns {object|null}
884
+ */
885
+ export function getVerifier(db, verifierName) {
886
+ return db.prepare('SELECT * FROM verifier_registry WHERE verifier_name = ?').get(verifierName) ?? null;
887
+ }
888
+
889
+ /**
890
+ * Get all verifiers ordered by aciru_order.
891
+ * @param {import('better-sqlite3').Database} db
892
+ * @returns {object[]}
893
+ */
894
+ export function getAllVerifiers(db) {
895
+ return db.prepare('SELECT * FROM verifier_registry ORDER BY aciru_order ASC').all();
896
+ }
897
+
898
+ /**
899
+ * Delete a verifier and its bindings in a transaction.
900
+ * @param {import('better-sqlite3').Database} db
901
+ * @param {string} verifierName
902
+ */
903
+ export function deleteVerifier(db, verifierName) {
904
+ db.transaction(() => {
905
+ db.prepare('DELETE FROM verifier_tool_bindings WHERE verifier_name = ?').run(verifierName);
906
+ db.prepare('DELETE FROM verifier_registry WHERE verifier_name = ?').run(verifierName);
907
+ })();
908
+ }
909
+
910
+ /**
911
+ * Bind a verifier to a tool. Use tool_name='*' for wildcard.
912
+ * @param {import('better-sqlite3').Database} db
913
+ * @param {{ verifier_name: string; tool_name: string }} binding
914
+ */
915
+ export function upsertVerifierBinding(db, binding) {
916
+ db.prepare(`
917
+ INSERT OR IGNORE INTO verifier_tool_bindings (verifier_name, tool_name, enabled, created_at)
918
+ VALUES (@verifier_name, @tool_name, 1, @created_at)
919
+ `).run({
920
+ verifier_name: binding.verifier_name,
921
+ tool_name: binding.tool_name,
922
+ created_at: new Date().toISOString()
923
+ });
924
+ }
925
+
926
+ /**
927
+ * Remove a verifier-tool binding.
928
+ * @param {import('better-sqlite3').Database} db
929
+ * @param {string} verifierName
930
+ * @param {string} toolName
931
+ */
932
+ export function removeVerifierBinding(db, verifierName, toolName) {
933
+ db.prepare('DELETE FROM verifier_tool_bindings WHERE verifier_name = ? AND tool_name = ?')
934
+ .run(verifierName, toolName);
935
+ }
936
+
937
+ /**
938
+ * Get all enabled verifiers bound to a tool (includes wildcard '*' bindings).
939
+ * Hot-path query — called per sidecar request.
940
+ * @param {import('better-sqlite3').Database} db
941
+ * @param {string} toolName
942
+ * @returns {object[]}
943
+ */
944
+ export function getVerifiersForTool(db, toolName) {
945
+ return db.prepare(`
946
+ SELECT vr.* FROM verifier_registry vr
947
+ INNER JOIN verifier_tool_bindings vtb ON vr.verifier_name = vtb.verifier_name
948
+ WHERE (vtb.tool_name = ? OR vtb.tool_name = '*')
949
+ AND vtb.enabled = 1 AND vr.enabled = 1
950
+ ORDER BY vr.aciru_order ASC
951
+ `).all(toolName);
952
+ }
953
+
954
+ /**
955
+ * Get all tool bindings for a verifier.
956
+ * @param {import('better-sqlite3').Database} db
957
+ * @param {string} verifierName
958
+ * @returns {object[]}
959
+ */
960
+ export function getBindingsForVerifier(db, verifierName) {
961
+ return db.prepare('SELECT * FROM verifier_tool_bindings WHERE verifier_name = ?').all(verifierName);
962
+ }
963
+
964
+ // ── agent_registry ──────────────────────────────────────────────────────────
965
+
966
+ /**
967
+ * Upsert an agent into the registry.
968
+ * @param {import('better-sqlite3').Database} db
969
+ * @param {object} row
970
+ */
971
+ export function upsertAgent(db, row) {
972
+ const now = new Date().toISOString();
973
+ db.prepare(`
974
+ INSERT INTO agent_registry (agent_id, display_name, description, system_prompt, default_model,
975
+ default_hitl_level, allow_user_model_select, allow_user_hitl_config, tool_allowlist,
976
+ max_turns, max_tokens, is_default, enabled, seeded_from_config, created_at, updated_at)
977
+ VALUES (@agent_id, @display_name, @description, @system_prompt, @default_model,
978
+ @default_hitl_level, @allow_user_model_select, @allow_user_hitl_config, @tool_allowlist,
979
+ @max_turns, @max_tokens, @is_default, @enabled, @seeded_from_config, @now, @now)
980
+ ON CONFLICT(agent_id) DO UPDATE SET
981
+ display_name = excluded.display_name,
982
+ description = excluded.description,
983
+ system_prompt = excluded.system_prompt,
984
+ default_model = excluded.default_model,
985
+ default_hitl_level = excluded.default_hitl_level,
986
+ allow_user_model_select = excluded.allow_user_model_select,
987
+ allow_user_hitl_config = excluded.allow_user_hitl_config,
988
+ tool_allowlist = excluded.tool_allowlist,
989
+ max_turns = excluded.max_turns,
990
+ max_tokens = excluded.max_tokens,
991
+ is_default = excluded.is_default,
992
+ enabled = excluded.enabled,
993
+ seeded_from_config = excluded.seeded_from_config,
994
+ updated_at = excluded.updated_at
995
+ `).run({
996
+ agent_id: row.agent_id,
997
+ display_name: row.display_name,
998
+ description: row.description ?? null,
999
+ system_prompt: row.system_prompt ?? null,
1000
+ default_model: row.default_model ?? null,
1001
+ default_hitl_level: row.default_hitl_level ?? null,
1002
+ allow_user_model_select: row.allow_user_model_select ?? 0,
1003
+ allow_user_hitl_config: row.allow_user_hitl_config ?? 0,
1004
+ tool_allowlist: row.tool_allowlist ?? '*',
1005
+ max_turns: row.max_turns ?? null,
1006
+ max_tokens: row.max_tokens ?? null,
1007
+ is_default: row.is_default ?? 0,
1008
+ enabled: row.enabled ?? 1,
1009
+ seeded_from_config: row.seeded_from_config ?? 0,
1010
+ now
1011
+ });
1012
+ }
1013
+
1014
+ /**
1015
+ * Get a single agent by ID.
1016
+ * @param {import('better-sqlite3').Database} db
1017
+ * @param {string} agentId
1018
+ * @returns {object|null}
1019
+ */
1020
+ export function getAgent(db, agentId) {
1021
+ return db.prepare('SELECT * FROM agent_registry WHERE agent_id = ?').get(agentId) ?? null;
1022
+ }
1023
+
1024
+ /**
1025
+ * Get all agents ordered by display_name.
1026
+ * @param {import('better-sqlite3').Database} db
1027
+ * @returns {object[]}
1028
+ */
1029
+ export function getAllAgents(db) {
1030
+ return db.prepare('SELECT * FROM agent_registry ORDER BY display_name').all();
1031
+ }
1032
+
1033
+ /**
1034
+ * Get the default agent (is_default = 1 and enabled = 1).
1035
+ * @param {import('better-sqlite3').Database} db
1036
+ * @returns {object|null}
1037
+ */
1038
+ export function getDefaultAgent(db) {
1039
+ return db.prepare('SELECT * FROM agent_registry WHERE is_default = 1 AND enabled = 1').get() ?? null;
1040
+ }
1041
+
1042
+ /**
1043
+ * Set a single agent as default (clears others in a transaction).
1044
+ * @param {import('better-sqlite3').Database} db
1045
+ * @param {string} agentId
1046
+ */
1047
+ export function setDefaultAgent(db, agentId) {
1048
+ db.transaction(() => {
1049
+ // Verify agent exists and is enabled before clearing defaults
1050
+ const exists = db.prepare('SELECT 1 FROM agent_registry WHERE agent_id = ? AND enabled = 1').get(agentId);
1051
+ if (!exists) return;
1052
+ db.prepare('UPDATE agent_registry SET is_default = 0 WHERE is_default = 1').run();
1053
+ db.prepare('UPDATE agent_registry SET is_default = 1, updated_at = ? WHERE agent_id = ?')
1054
+ .run(new Date().toISOString(), agentId);
1055
+ })();
1056
+ }
1057
+
1058
+ /**
1059
+ * Delete an agent by ID.
1060
+ * @param {import('better-sqlite3').Database} db
1061
+ * @param {string} agentId
1062
+ */
1063
+ export function deleteAgent(db, agentId) {
1064
+ db.prepare('DELETE FROM agent_registry WHERE agent_id = ?').run(agentId);
1065
+ }
1066
+
1067
+ // ── chat_audit ───────────────────────────────────────────────────────────────
1068
+
1069
+ /**
1070
+ * Insert a chat audit record.
1071
+ * @param {import('better-sqlite3').Database} db
1072
+ * @param {{
1073
+ * session_id: string,
1074
+ * user_id: string,
1075
+ * agent_id?: string,
1076
+ * route: string,
1077
+ * status_code: number,
1078
+ * duration_ms: number,
1079
+ * model?: string,
1080
+ * message_text?: string,
1081
+ * tool_count?: number,
1082
+ * hitl_triggered?: number,
1083
+ * warnings_count?: number,
1084
+ * error_message?: string
1085
+ * }} row
1086
+ * @returns {number} lastInsertRowid
1087
+ */
1088
+ export function insertChatAudit(db, row) {
1089
+ const result = db.prepare(`
1090
+ INSERT INTO chat_audit
1091
+ (session_id, user_id, agent_id, route, status_code, duration_ms,
1092
+ model, message_text, tool_count, hitl_triggered, warnings_count, error_message, created_at)
1093
+ VALUES
1094
+ (@session_id, @user_id, @agent_id, @route, @status_code, @duration_ms,
1095
+ @model, @message_text, @tool_count, @hitl_triggered, @warnings_count, @error_message, @created_at)
1096
+ `).run({
1097
+ session_id: row.session_id ?? '',
1098
+ user_id: row.user_id ?? 'anon',
1099
+ agent_id: row.agent_id ?? null,
1100
+ route: row.route,
1101
+ status_code: row.status_code,
1102
+ duration_ms: row.duration_ms,
1103
+ model: row.model ?? null,
1104
+ message_text: row.message_text ?? null,
1105
+ tool_count: row.tool_count ?? 0,
1106
+ hitl_triggered: row.hitl_triggered ?? 0,
1107
+ warnings_count: row.warnings_count ?? 0,
1108
+ error_message: row.error_message ?? null,
1109
+ created_at: new Date().toISOString()
1110
+ });
1111
+ return Number(result.lastInsertRowid);
1112
+ }