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
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Onboarding View — First-time setup wizard shown when no API key or no forge.config.json.
3
+ *
4
+ * Checklist-style screen with 3 steps:
5
+ * 1. Add API Key (required)
6
+ * 2. Set tools directory (optional)
7
+ * 3. Choose model (optional, has default)
8
+ * → Launch Forge
9
+ */
10
+
11
+ import blessed from 'blessed';
12
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
13
+ import { resolve, dirname } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const PROJECT_ROOT = resolve(__dirname, '../..');
18
+ const CONFIG_FILE = resolve(PROJECT_ROOT, 'forge.config.json');
19
+ const ENV_FILE = resolve(PROJECT_ROOT, '.env');
20
+
21
+ const DEFAULT_TOOLS_DIR = 'example/tools';
22
+ const DEFAULT_MODEL = 'claude-sonnet-4-6';
23
+
24
+ const ONBOARDING_MODELS = [
25
+ 'claude-sonnet-4-6',
26
+ 'claude-opus-4-6',
27
+ 'gpt-4o-mini'
28
+ ];
29
+
30
+ const DEFAULT_CONFIG = {
31
+ project: { toolsDir: DEFAULT_TOOLS_DIR },
32
+ models: {
33
+ generation: DEFAULT_MODEL,
34
+ eval: DEFAULT_MODEL,
35
+ verifier: DEFAULT_MODEL,
36
+ secondary: null
37
+ },
38
+ multiModel: {
39
+ enabled: false,
40
+ compareOnGenerate: false
41
+ }
42
+ };
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Helpers (mirrored from settings.js)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function loadConfig() {
49
+ try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch (_) { return {}; }
50
+ }
51
+
52
+ function saveConfig(cfg) {
53
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf-8');
54
+ }
55
+
56
+ function loadEnv() {
57
+ if (!existsSync(ENV_FILE)) return {};
58
+ const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
59
+ const out = {};
60
+ for (const line of lines) {
61
+ const trimmed = line.trim();
62
+ if (!trimmed || trimmed.startsWith('#')) continue;
63
+ const eqIdx = trimmed.indexOf('=');
64
+ if (eqIdx === -1) continue;
65
+ const key = trimmed.slice(0, eqIdx).trim();
66
+ const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
67
+ out[key] = val;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function saveEnv(envMap) {
73
+ const lines = Object.entries(envMap).map(([k, v]) => `${k}=${v}`);
74
+ writeFileSync(ENV_FILE, lines.join('\n') + '\n', 'utf-8');
75
+ }
76
+
77
+ /**
78
+ * Detect which provider key is present in envMap.
79
+ * Returns a short label, or null if none found.
80
+ */
81
+ function detectApiKeyProvider(envMap) {
82
+ const keys = Object.keys(envMap);
83
+ if (keys.some((k) => /ANTHROPIC/i.test(k))) return 'anthropic';
84
+ if (keys.some((k) => /OPENAI/i.test(k))) return 'openai';
85
+ if (keys.some((k) => /GOOGLE|GEMINI/i.test(k))) return 'google';
86
+ return null;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // View
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export function createView({ screen, content, config, navigate, setFooter, screenKey, openPopup, closePopup, startService }) {
94
+ // Track which steps are done: [apiKey, toolsDir, model]
95
+ const completed = [false, false, false];
96
+
97
+ // Step values chosen during this session
98
+ const chosen = {
99
+ provider: null, // e.g. 'anthropic'
100
+ toolsDir: null, // e.g. 'example/tools'
101
+ model: null // e.g. 'claude-sonnet-4-6'
102
+ };
103
+
104
+ // Pre-fill from existing state
105
+ const initialEnv = loadEnv();
106
+ const initialProvider = detectApiKeyProvider(initialEnv);
107
+ if (initialProvider) {
108
+ completed[0] = true;
109
+ chosen.provider = initialProvider;
110
+ }
111
+
112
+ const initialCfg = loadConfig();
113
+ if (initialCfg.project?.toolsDir) {
114
+ completed[1] = true;
115
+ chosen.toolsDir = initialCfg.project.toolsDir;
116
+ }
117
+ if (initialCfg.models?.generation) {
118
+ completed[2] = true;
119
+ chosen.model = initialCfg.models.generation;
120
+ }
121
+
122
+ // -------------------------------------------------------------------------
123
+ // Layout: title box + list
124
+ // -------------------------------------------------------------------------
125
+
126
+ const titleBox = blessed.box({
127
+ top: 0,
128
+ left: 0,
129
+ width: '100%',
130
+ height: 3,
131
+ tags: true,
132
+ content: '\n {bold}{cyan-fg}Tool Forge{/cyan-fg} — First Time Setup{/bold} {#555555-fg}Let\'s get you set up in 3 steps.{/#555555-fg}',
133
+ style: { fg: 'white' }
134
+ });
135
+
136
+ const list = blessed.list({
137
+ top: 3,
138
+ left: 0,
139
+ width: '100%',
140
+ height: '100%-3',
141
+ tags: true,
142
+ keys: true,
143
+ vi: true,
144
+ mouse: true,
145
+ style: {
146
+ selected: { bg: 'blue', fg: 'white', bold: true },
147
+ item: { fg: 'white' }
148
+ },
149
+ padding: { top: 0, left: 2 }
150
+ });
151
+
152
+ const statusBar = blessed.box({
153
+ top: '100%-1',
154
+ left: 0,
155
+ width: '100%',
156
+ height: 1,
157
+ tags: true,
158
+ style: { fg: '#888888' }
159
+ });
160
+
161
+ content.append(titleBox);
162
+ content.append(list);
163
+ content.append(statusBar);
164
+
165
+ setFooter(' {bold}↑↓{/bold} navigate {bold}Enter{/bold} select {bold}b{/bold} skip setup');
166
+
167
+ // -------------------------------------------------------------------------
168
+ // Render helpers
169
+ // -------------------------------------------------------------------------
170
+
171
+ function checkMark(idx) {
172
+ return completed[idx] ? '{green-fg}✓{/green-fg}' : ' ';
173
+ }
174
+
175
+ function stepLabel(idx, label, detail) {
176
+ const mark = checkMark(idx);
177
+ const num = idx + 1;
178
+ const detailStr = detail
179
+ ? ` {#888888-fg}${detail}{/#888888-fg}`
180
+ : '';
181
+ return ` [${mark}] {bold}${num}.{/bold} ${label}${detailStr}`;
182
+ }
183
+
184
+ function step1Detail() {
185
+ if (!completed[0]) return '(required)';
186
+ return `{green-fg}${chosen.provider} ✓{/green-fg}`;
187
+ }
188
+
189
+ function step2Detail() {
190
+ if (!completed[1]) return '(optional)';
191
+ return `{green-fg}${chosen.toolsDir}{/green-fg}`;
192
+ }
193
+
194
+ function step3Detail() {
195
+ if (!completed[2]) return '(optional)';
196
+ return `{green-fg}${chosen.model}{/green-fg}`;
197
+ }
198
+
199
+ function renderList() {
200
+ const divider = ` {#444444-fg}${'─'.repeat(46)}{/#444444-fg}`;
201
+ const launchStyle = completed[0]
202
+ ? '{bold}{green-fg} → Launch Forge{/green-fg}{/bold}'
203
+ : '{#888888-fg} → Launch Forge {yellow-fg}(add API key first){/yellow-fg}{/#888888-fg}';
204
+
205
+ list.setItems([
206
+ stepLabel(0, 'Add API Key ', step1Detail()),
207
+ stepLabel(1, 'Set tools directory', step2Detail()),
208
+ stepLabel(2, 'Choose model ', step3Detail()),
209
+ divider,
210
+ launchStyle
211
+ ]);
212
+ screen.render();
213
+ }
214
+
215
+ renderList();
216
+
217
+ // -------------------------------------------------------------------------
218
+ // Step handlers
219
+ // -------------------------------------------------------------------------
220
+
221
+ function handleStep1() {
222
+ const prompt = blessed.prompt({
223
+ parent: screen,
224
+ border: 'line',
225
+ height: 'shrink',
226
+ width: '70%',
227
+ top: 'center',
228
+ left: 'center',
229
+ label: ' Add API Key ',
230
+ tags: true,
231
+ keys: true
232
+ });
233
+
234
+ openPopup?.();
235
+ prompt.input(
236
+ 'Enter ANTHROPIC_API_KEY (or KEY_NAME=value for other providers):',
237
+ '',
238
+ (err, val) => {
239
+ closePopup?.();
240
+ prompt.destroy();
241
+
242
+ if (!err && val && val.trim()) {
243
+ const input = val.trim();
244
+ const envMap = loadEnv();
245
+ let keyName, keyValue;
246
+
247
+ if (input.includes('=')) {
248
+ // KEY=value format
249
+ const eqIdx = input.indexOf('=');
250
+ keyName = input.slice(0, eqIdx).trim().toUpperCase();
251
+ keyValue = input.slice(eqIdx + 1).trim();
252
+ } else {
253
+ // Bare value — assume ANTHROPIC_API_KEY
254
+ keyName = 'ANTHROPIC_API_KEY';
255
+ keyValue = input;
256
+ }
257
+
258
+ envMap[keyName] = keyValue;
259
+ try {
260
+ saveEnv(envMap);
261
+ chosen.provider = detectApiKeyProvider(envMap) || keyName.toLowerCase().split('_')[0];
262
+ completed[0] = true;
263
+ } catch (err) {
264
+ statusBar.setContent(`{red-fg}⚠ Could not save .env: ${err.message}{/red-fg}`);
265
+ screen.render();
266
+ }
267
+ }
268
+
269
+ renderList();
270
+ list.focus();
271
+ screen.render();
272
+ }
273
+ );
274
+ }
275
+
276
+ function handleStep2() {
277
+ const current = chosen.toolsDir || DEFAULT_TOOLS_DIR;
278
+ const prompt = blessed.prompt({
279
+ parent: screen,
280
+ border: 'line',
281
+ height: 'shrink',
282
+ width: '70%',
283
+ top: 'center',
284
+ left: 'center',
285
+ label: ' Set Tools Directory ',
286
+ tags: true,
287
+ keys: true
288
+ });
289
+
290
+ openPopup?.();
291
+ prompt.input(
292
+ 'Path to your tools directory (relative to project root):',
293
+ current,
294
+ (err, val) => {
295
+ closePopup?.();
296
+ prompt.destroy();
297
+
298
+ if (!err && val !== null && val !== undefined) {
299
+ const trimmed = val.trim() || DEFAULT_TOOLS_DIR;
300
+ const cfg = loadConfig();
301
+ cfg.project = cfg.project || {};
302
+ cfg.project.toolsDir = trimmed;
303
+ try {
304
+ saveConfig(cfg);
305
+ config.project = config.project || {};
306
+ config.project.toolsDir = trimmed;
307
+ chosen.toolsDir = trimmed; // only set if save succeeds
308
+ completed[1] = true; // only set if save succeeds
309
+ } catch (err) {
310
+ statusBar?.setContent?.(`{red-fg}⚠ Could not save config: ${err.message}{/red-fg}`);
311
+ screen.render();
312
+ }
313
+ }
314
+
315
+ renderList();
316
+ list.focus();
317
+ screen.render();
318
+ }
319
+ );
320
+ }
321
+
322
+ function handleStep3() {
323
+ const current = chosen.model || DEFAULT_MODEL;
324
+
325
+ // Mark current selection in the displayed list
326
+ const markedItems = ONBOARDING_MODELS.map((m) =>
327
+ m === current
328
+ ? ` {green-fg}● ${m}{/green-fg}`
329
+ : ` ${m}`
330
+ );
331
+
332
+ const popup = blessed.box({
333
+ parent: screen,
334
+ border: 'line',
335
+ height: ONBOARDING_MODELS.length + 4,
336
+ width: 44,
337
+ top: 'center',
338
+ left: 'center',
339
+ label: ' Choose Model ',
340
+ tags: true,
341
+ style: { border: { fg: 'blue' } }
342
+ });
343
+
344
+ const modelList = blessed.list({
345
+ parent: popup,
346
+ top: 0,
347
+ left: 0,
348
+ width: '100%',
349
+ height: '100%-2',
350
+ tags: true,
351
+ keys: true,
352
+ vi: true,
353
+ style: { selected: { bg: '#1a3a5c', fg: 'white' } },
354
+ items: markedItems
355
+ });
356
+
357
+ openPopup?.();
358
+
359
+ function applyModel(idx) {
360
+ const selected = ONBOARDING_MODELS[idx];
361
+ if (!selected) return;
362
+
363
+ const cfg = loadConfig();
364
+ cfg.models = cfg.models || {};
365
+ cfg.models.generation = selected;
366
+ try {
367
+ saveConfig(cfg);
368
+ config.models = config.models || {};
369
+ config.models.generation = selected;
370
+ chosen.model = selected; // only set if save succeeds
371
+ completed[2] = true; // only set if save succeeds
372
+ } catch (err) {
373
+ statusBar?.setContent?.(`{red-fg}⚠ Could not save config: ${err.message}{/red-fg}`);
374
+ screen.render();
375
+ }
376
+
377
+ closePopup?.();
378
+ popup.destroy();
379
+ renderList();
380
+ list.focus();
381
+ screen.render();
382
+ }
383
+
384
+ modelList.on('select', (item, idx) => applyModel(idx));
385
+ modelList.key(['escape', 'b'], () => { // CORRECT - both on focused widget
386
+ closePopup?.();
387
+ popup.destroy();
388
+ renderList();
389
+ list.focus();
390
+ screen.render();
391
+ });
392
+ modelList.focus();
393
+ screen.render();
394
+ }
395
+
396
+ function handleLaunch() {
397
+ if (!completed[0]) {
398
+ // Show error — API key is required
399
+ const errorBox = blessed.message({
400
+ parent: screen,
401
+ border: 'line',
402
+ height: 'shrink',
403
+ width: 'half',
404
+ top: 'center',
405
+ left: 'center',
406
+ label: ' Setup Required ',
407
+ tags: true,
408
+ keys: true,
409
+ style: { border: { fg: 'red' } }
410
+ });
411
+ openPopup?.();
412
+ errorBox.display('Please add an API key first. (Step 1)', 0, () => {
413
+ closePopup?.();
414
+ errorBox.destroy();
415
+ list.focus();
416
+ screen.render();
417
+ });
418
+ return;
419
+ }
420
+
421
+ // Write forge.config.json if it doesn't exist yet, or merge defaults
422
+ const existingCfg = loadConfig();
423
+ const merged = Object.assign({}, DEFAULT_CONFIG, existingCfg);
424
+
425
+ // Ensure all required model fields exist
426
+ merged.models = Object.assign({}, DEFAULT_CONFIG.models, existingCfg.models || {});
427
+ merged.project = Object.assign({}, DEFAULT_CONFIG.project, existingCfg.project || {});
428
+ merged.multiModel = Object.assign({}, DEFAULT_CONFIG.multiModel, existingCfg.multiModel || {});
429
+
430
+ // Apply session choices
431
+ if (chosen.toolsDir) merged.project.toolsDir = chosen.toolsDir;
432
+ if (chosen.model) merged.models.generation = chosen.model;
433
+
434
+ try {
435
+ saveConfig(merged);
436
+ } catch (err) {
437
+ statusBar?.setContent?.(`{red-fg}⚠ Could not save config: ${err.message}{/red-fg}`);
438
+ screen.render();
439
+ return;
440
+ }
441
+
442
+ // Reload config in-place
443
+ Object.assign(config, merged);
444
+
445
+ navigate('main-menu');
446
+ }
447
+
448
+ // -------------------------------------------------------------------------
449
+ // Event wiring
450
+ // -------------------------------------------------------------------------
451
+
452
+ // Divider at index 3 is not selectable — skip over it
453
+ list.on('select', (item, index) => {
454
+ switch (index) {
455
+ case 0: handleStep1(); break;
456
+ case 1: handleStep2(); break;
457
+ case 2: handleStep3(); break;
458
+ case 3: break; // divider — ignore
459
+ case 4: handleLaunch(); break;
460
+ }
461
+ });
462
+
463
+ // 'b' skips setup and goes straight to main-menu (without writing config)
464
+ screenKey('b', () => navigate('main-menu'));
465
+
466
+ list.focus();
467
+ screen.render();
468
+
469
+ return list;
470
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Performance View — Eval run history from SQLite, with sparklines and drift alerts.
3
+ */
4
+
5
+ import blessed from 'blessed';
6
+ import { existsSync } from 'fs';
7
+ import { resolve } from 'path';
8
+
9
+ // ── ASCII sparkline ────────────────────────────────────────────────────────
10
+
11
+ function sparkline(values) {
12
+ const blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
13
+ if (!values || values.length === 0) return '—';
14
+ return values.map((v) => blocks[Math.min(7, Math.floor((v || 0) * 8))]).join('');
15
+ }
16
+
17
+ // ── Data loader ────────────────────────────────────────────────────────────
18
+
19
+ async function loadData(config) {
20
+ const dbPath = resolve(process.cwd(), config?.dbPath || 'forge.db');
21
+ if (!existsSync(dbPath)) return { rows: [], driftMap: {}, historyMap: {} };
22
+
23
+ try {
24
+ const { getDb, getEvalSummary, getDriftAlerts, getPerToolRunHistory } = await import('../db.js');
25
+ const db = getDb(dbPath);
26
+ const rows = getEvalSummary(db);
27
+ const alerts = getDriftAlerts(db, null);
28
+ const driftMap = {};
29
+ for (const a of alerts) driftMap[a.tool_name] = a;
30
+
31
+ // Load per-tool history for sparklines
32
+ const historyMap = {};
33
+ for (const r of rows) {
34
+ const history = getPerToolRunHistory(db, r.tool_name, 10);
35
+ // history is DESC order — reverse for sparkline (oldest first)
36
+ historyMap[r.tool_name] = history.reverse().map((h) => h.pass_rate || 0);
37
+ }
38
+
39
+ return { rows, driftMap, historyMap, db };
40
+ } catch (_) {
41
+ return { rows: [], driftMap: {}, historyMap: {} };
42
+ }
43
+ }
44
+
45
+ // ── View ───────────────────────────────────────────────────────────────────
46
+
47
+ export function createView({ screen, content, config, navigate, setFooter, screenKey, openPopup, closePopup, startService }) {
48
+ const container = blessed.box({
49
+ top: 0,
50
+ left: 0,
51
+ width: '100%',
52
+ height: '100%',
53
+ tags: true
54
+ });
55
+
56
+ const table = blessed.listtable({
57
+ parent: container,
58
+ top: 0,
59
+ left: 0,
60
+ width: '100%',
61
+ height: '100%-2',
62
+ tags: true,
63
+ keys: true,
64
+ vi: true,
65
+ mouse: true,
66
+ border: { type: 'line' },
67
+ align: 'left',
68
+ style: {
69
+ header: { bold: true, fg: 'cyan' },
70
+ cell: { selected: { bg: 'blue', fg: 'white' } }
71
+ },
72
+ pad: 1
73
+ });
74
+
75
+ const emptyMsg = blessed.box({
76
+ parent: container,
77
+ top: 'center',
78
+ left: 'center',
79
+ width: '80%',
80
+ height: 3,
81
+ tags: true,
82
+ align: 'center',
83
+ content: '{gray-fg}No eval history yet.\nEval results will appear here when forge-eval runs are logged.{/gray-fg}',
84
+ hidden: true
85
+ });
86
+
87
+ setFooter(' {bold}r{/bold} refresh {bold}c{/bold} clear history {bold}d{/bold} drift suspects {bold}b{/bold} back');
88
+
89
+ let cachedData = { rows: [], driftMap: {}, historyMap: {}, db: null };
90
+ let suspectsOpen = false;
91
+
92
+ table.key('c', () => {
93
+ if (!existsSync(resolve(process.cwd(), config?.dbPath || 'forge.db'))) return;
94
+ showClearConfirm(screen, config, openPopup, closePopup, container.refresh);
95
+ });
96
+
97
+ screenKey('d', () => {
98
+ if (suspectsOpen) return; // prevent double-open from rapid keypresses
99
+ if (openPopup && cachedData.rows.length > 0) {
100
+ const idx = table.selected;
101
+ if (idx >= 1 && cachedData.rows[idx - 1]) {
102
+ suspectsOpen = true;
103
+ showSuspectsPopup(screen, cachedData.rows[idx - 1], cachedData, config, openPopup, closePopup)
104
+ .finally(() => { suspectsOpen = false; });
105
+ }
106
+ }
107
+ });
108
+
109
+ container.refresh = async () => {
110
+ try {
111
+ cachedData = await loadData(config);
112
+ const { rows, driftMap, historyMap } = cachedData;
113
+
114
+ if (rows.length === 0) {
115
+ table.hide();
116
+ emptyMsg.show();
117
+ screen.render();
118
+ return;
119
+ }
120
+
121
+ table.show();
122
+ emptyMsg.hide();
123
+
124
+ const data = rows.map((r) => {
125
+ const history = historyMap[r.tool_name] || [];
126
+ const trend = sparkline(history);
127
+ const driftCell = driftMap[r.tool_name]
128
+ ? '{red-fg}⚠ drift{/red-fg}'
129
+ : '{#555555-fg}—{/#555555-fg}';
130
+ return [
131
+ r.tool_name,
132
+ trend,
133
+ r.last_run ? r.last_run.slice(0, 19).replace('T', ' ') : '—',
134
+ r.pass_rate,
135
+ driftCell
136
+ ];
137
+ });
138
+
139
+ table.setData([
140
+ ['Tool', 'Trend', 'Last Run', 'Pass Rate', 'Alert'],
141
+ ...data
142
+ ]);
143
+ } catch (err) {
144
+ table.show();
145
+ emptyMsg.hide();
146
+ table.setData([['Tool', 'Trend', 'Last Run', 'Pass Rate', 'Alert'], ['Error: ' + err.message, '', '', '', '']]);
147
+ }
148
+ screen.render();
149
+ };
150
+
151
+ container.refresh();
152
+ table.focus();
153
+ return container;
154
+ }
155
+
156
+ // ── Suspects popup ─────────────────────────────────────────────────────────
157
+
158
+ async function showSuspectsPopup(screen, toolRow, cachedData, config, openPopup, closePopup) {
159
+ let content = '';
160
+ try {
161
+ const dbPath = resolve(process.cwd(), config?.dbPath || 'forge.db');
162
+ if (existsSync(dbPath)) {
163
+ const { computeSuspects } = await import('../drift-monitor.js');
164
+ const db = cachedData.db;
165
+ if (db) {
166
+ const suspects = computeSuspects(db, toolRow.tool_name);
167
+ const alert = cachedData.driftMap[toolRow.tool_name];
168
+ if (!alert) {
169
+ content = '\n {green-fg}No drift detected for this tool.{/green-fg}';
170
+ } else {
171
+ content = `\n {yellow-fg}Drift suspects for: ${toolRow.tool_name}{/yellow-fg}\n\n` +
172
+ (suspects.length > 0
173
+ ? suspects.map((s) => ` • ${s}`).join('\n')
174
+ : ' {#888888-fg}(no suspects identified){/#888888-fg}') +
175
+ `\n\n {#888888-fg}Delta: -${Math.round((alert.delta || 0) * 100)}pp` +
176
+ ` Baseline: ${alert.baseline_rate != null ? Math.round(alert.baseline_rate * 100) + '%' : '?'}{/#888888-fg}`;
177
+ }
178
+ } else {
179
+ content = '\n {#888888-fg}DB not available.{/#888888-fg}';
180
+ }
181
+ }
182
+ } catch (err) {
183
+ content = `\n {red-fg}Error: ${err.message}{/red-fg}`;
184
+ }
185
+
186
+ const popup = blessed.box({
187
+ parent: screen,
188
+ border: 'line',
189
+ top: 'center',
190
+ left: 'center',
191
+ width: 60,
192
+ height: 14,
193
+ label: ` Drift Suspects `,
194
+ tags: true,
195
+ content
196
+ });
197
+ openPopup?.();
198
+ popup.key(['escape', 'q', 'enter', 'd'], () => {
199
+ closePopup?.();
200
+ popup.destroy();
201
+ screen.render();
202
+ });
203
+ popup.focus();
204
+ screen.render();
205
+ }
206
+
207
+ // ── Clear confirm ──────────────────────────────────────────────────────────
208
+
209
+ function showClearConfirm(screen, config, openPopup, closePopup, onClear) {
210
+ const confirm = blessed.question({
211
+ parent: screen,
212
+ border: 'line',
213
+ height: 'shrink',
214
+ width: 'half',
215
+ top: 'center',
216
+ left: 'center',
217
+ label: ' Clear Eval History ',
218
+ tags: true,
219
+ keys: true
220
+ });
221
+
222
+ openPopup?.();
223
+ confirm.ask('Clear all eval history? This cannot be undone. (y/n)', async (err, answer) => {
224
+ closePopup?.();
225
+ confirm.destroy();
226
+ if (!err && /^y/i.test(answer)) {
227
+ try {
228
+ const dbPath = resolve(process.cwd(), config?.dbPath || 'forge.db');
229
+ const { getDb } = await import('../db.js');
230
+ const db = getDb(dbPath);
231
+ db.prepare('DELETE FROM eval_runs').run();
232
+ db.prepare('DELETE FROM eval_run_cases').run();
233
+ } catch (_) { /* ignore */ }
234
+ onClear();
235
+ }
236
+ });
237
+ }