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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/lib/agent-registry.js +170 -0
- package/lib/api-client.js +792 -0
- package/lib/api-loader.js +260 -0
- package/lib/auth.d.ts +25 -0
- package/lib/auth.js +158 -0
- package/lib/checks/check-adapter.js +172 -0
- package/lib/checks/compose.js +42 -0
- package/lib/checks/content-match.js +14 -0
- package/lib/checks/cost-budget.js +11 -0
- package/lib/checks/index.js +18 -0
- package/lib/checks/json-valid.js +15 -0
- package/lib/checks/latency.js +11 -0
- package/lib/checks/length-bounds.js +17 -0
- package/lib/checks/negative-match.js +14 -0
- package/lib/checks/no-hallucinated-numbers.js +63 -0
- package/lib/checks/non-empty.js +34 -0
- package/lib/checks/regex-match.js +12 -0
- package/lib/checks/run-checks.js +84 -0
- package/lib/checks/schema-match.js +26 -0
- package/lib/checks/tool-call-count.js +16 -0
- package/lib/checks/tool-selection.js +34 -0
- package/lib/checks/types.js +45 -0
- package/lib/comparison/compare.js +86 -0
- package/lib/comparison/format.js +104 -0
- package/lib/comparison/index.js +6 -0
- package/lib/comparison/statistics.js +59 -0
- package/lib/comparison/types.js +41 -0
- package/lib/config-schema.js +200 -0
- package/lib/config.d.ts +66 -0
- package/lib/conversation-store.d.ts +77 -0
- package/lib/conversation-store.js +443 -0
- package/lib/db.d.ts +6 -0
- package/lib/db.js +1112 -0
- package/lib/dep-check.js +99 -0
- package/lib/drift-background.js +61 -0
- package/lib/drift-monitor.js +187 -0
- package/lib/eval-runner.js +566 -0
- package/lib/fixtures/fixture-store.js +161 -0
- package/lib/fixtures/index.js +11 -0
- package/lib/forge-engine.js +982 -0
- package/lib/forge-eval-generator.js +417 -0
- package/lib/forge-file-writer.js +386 -0
- package/lib/forge-service-client.js +190 -0
- package/lib/forge-service.d.ts +4 -0
- package/lib/forge-service.js +655 -0
- package/lib/forge-verifier-generator.js +271 -0
- package/lib/handlers/admin.js +151 -0
- package/lib/handlers/agents.js +229 -0
- package/lib/handlers/chat-resume.js +334 -0
- package/lib/handlers/chat-sync.js +320 -0
- package/lib/handlers/chat.js +320 -0
- package/lib/handlers/conversations.js +92 -0
- package/lib/handlers/preferences.js +88 -0
- package/lib/handlers/tools-list.js +58 -0
- package/lib/hitl-engine.d.ts +60 -0
- package/lib/hitl-engine.js +261 -0
- package/lib/http-utils.js +92 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +141 -0
- package/lib/init.js +636 -0
- package/lib/manual-entry.js +59 -0
- package/lib/mcp-server.js +252 -0
- package/lib/output-groups.js +54 -0
- package/lib/postgres-store.d.ts +31 -0
- package/lib/postgres-store.js +465 -0
- package/lib/preference-store.d.ts +47 -0
- package/lib/preference-store.js +79 -0
- package/lib/prompt-store.d.ts +42 -0
- package/lib/prompt-store.js +60 -0
- package/lib/rate-limiter.d.ts +30 -0
- package/lib/rate-limiter.js +104 -0
- package/lib/react-engine.d.ts +110 -0
- package/lib/react-engine.js +337 -0
- package/lib/runner/cli.js +156 -0
- package/lib/runner/cost-estimator.js +71 -0
- package/lib/runner/gate.js +46 -0
- package/lib/runner/index.js +165 -0
- package/lib/sidecar.d.ts +83 -0
- package/lib/sidecar.js +161 -0
- package/lib/sse.d.ts +15 -0
- package/lib/sse.js +30 -0
- package/lib/tools-scanner.js +91 -0
- package/lib/tui.js +253 -0
- package/lib/verifier-report.js +78 -0
- package/lib/verifier-runner.js +338 -0
- package/lib/verifier-scanner.js +70 -0
- package/lib/verifier-worker-pool.js +196 -0
- package/lib/views/chat.js +340 -0
- package/lib/views/endpoints.js +203 -0
- package/lib/views/eval-run.js +206 -0
- package/lib/views/forge-agent.js +538 -0
- package/lib/views/forge.js +410 -0
- package/lib/views/main-menu.js +275 -0
- package/lib/views/mediation.js +381 -0
- package/lib/views/model-compare.js +430 -0
- package/lib/views/model-comparison.js +333 -0
- package/lib/views/onboarding.js +470 -0
- package/lib/views/performance.js +237 -0
- package/lib/views/run-evals.js +205 -0
- package/lib/views/settings.js +829 -0
- package/lib/views/tools-evals.js +514 -0
- package/lib/views/verifier-coverage.js +617 -0
- package/lib/workers/verifier-worker.js +52 -0
- package/package.json +123 -0
- 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
|
+
}
|