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,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge View — 12-phase tool generation dialogue (phases 0–11, split-panel TUI).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import blessed from 'blessed';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
7
|
+
import { resolve, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { resolveModelConfig } from '../api-client.js';
|
|
10
|
+
import { forgeStep, createInitialState, getPhaseIndex, PHASES } from '../forge-engine.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const PROJECT_ROOT = resolve(__dirname, '../..');
|
|
14
|
+
|
|
15
|
+
function loadEnv() {
|
|
16
|
+
const envPath = resolve(PROJECT_ROOT, '.env');
|
|
17
|
+
if (!existsSync(envPath)) return {};
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
|
|
20
|
+
const t = line.trim();
|
|
21
|
+
if (!t || t.startsWith('#')) continue;
|
|
22
|
+
const eq = t.indexOf('=');
|
|
23
|
+
if (eq === -1) continue;
|
|
24
|
+
out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createView({
|
|
30
|
+
screen, content, config, navigate, setFooter,
|
|
31
|
+
screenKey, openPopup, closePopup, startService
|
|
32
|
+
}) {
|
|
33
|
+
const container = blessed.box({ top: 0, left: 0, width: '100%', height: '100%', tags: true });
|
|
34
|
+
// Escape/b navigates back immediately — session state is auto-persisted.
|
|
35
|
+
|
|
36
|
+
const phaseBar = blessed.box({
|
|
37
|
+
parent: container, top: 0, left: 0, width: '100%', height: 1,
|
|
38
|
+
tags: true, style: { fg: '#888888' }
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const log = blessed.log({
|
|
42
|
+
parent: container, top: 1, left: 0, width: '55%', height: '100%-5',
|
|
43
|
+
tags: true, scrollable: true, alwaysScroll: true, keys: true, mouse: true,
|
|
44
|
+
border: { type: 'line' }, label: ' Dialogue ',
|
|
45
|
+
style: { border: { fg: '#333333' }, focus: { border: { fg: 'cyan' } } },
|
|
46
|
+
scrollbar: { ch: '│', style: { fg: '#555555' } }
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const specPanel = blessed.scrollablebox({
|
|
50
|
+
parent: container, top: 1, left: '55%', width: '45%', height: '100%-5',
|
|
51
|
+
tags: true, scrollable: true, alwaysScroll: true, keys: true, vi: true, mouse: true,
|
|
52
|
+
border: { type: 'line' }, label: ' Live Spec ',
|
|
53
|
+
style: { border: { fg: '#333333' }, focus: { border: { fg: 'cyan' } } },
|
|
54
|
+
scrollbar: { ch: '│', style: { fg: '#555555' } }
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const statusBar = blessed.box({
|
|
58
|
+
parent: container, bottom: 3, left: 0, width: '100%', height: 1, tags: true,
|
|
59
|
+
style: { fg: '#888888' }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const inputBox = blessed.textbox({
|
|
63
|
+
parent: container, bottom: 0, left: 0, width: '100%', height: 3,
|
|
64
|
+
border: { type: 'line' },
|
|
65
|
+
label: ' Message (Enter send, Esc shortcuts, Tab panel) ',
|
|
66
|
+
style: { border: { fg: '#333333' }, focus: { border: { fg: 'cyan' } } }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
setFooter(
|
|
70
|
+
' {cyan-fg}Enter{/cyan-fg} send {cyan-fg}Esc{/cyan-fg} shortcuts ' +
|
|
71
|
+
'{cyan-fg}e{/cyan-fg} edit {cyan-fg}s{/cyan-fg} skip {cyan-fg}m{/cyan-fg} compare {cyan-fg}b{/cyan-fg} back'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// ── Explicit input mode management ──────────────────────────────────────
|
|
75
|
+
let inputActive = false;
|
|
76
|
+
|
|
77
|
+
function startInput() {
|
|
78
|
+
inputActive = true;
|
|
79
|
+
inputBox.focus();
|
|
80
|
+
inputBox.style.border = { fg: 'cyan' };
|
|
81
|
+
log.style.border = { fg: '#333333' };
|
|
82
|
+
specPanel.style.border = { fg: '#333333' };
|
|
83
|
+
screen.render();
|
|
84
|
+
inputBox.readInput((err, value) => {
|
|
85
|
+
inputActive = false;
|
|
86
|
+
if (err || value === undefined || value === null) {
|
|
87
|
+
// Escape — exit to command mode
|
|
88
|
+
log.focus();
|
|
89
|
+
log.style.border = { fg: 'cyan' };
|
|
90
|
+
inputBox.style.border = { fg: '#333333' };
|
|
91
|
+
screen.render();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Enter — submit
|
|
95
|
+
const text = (value || '').trim();
|
|
96
|
+
inputBox.clearValue();
|
|
97
|
+
screen.render();
|
|
98
|
+
if (text) {
|
|
99
|
+
doStep(text);
|
|
100
|
+
} else {
|
|
101
|
+
startInput();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let forgeState = createInitialState();
|
|
107
|
+
let busy = false;
|
|
108
|
+
let previewPending = false;
|
|
109
|
+
let currentModelConfig = null;
|
|
110
|
+
let db = null;
|
|
111
|
+
let updateToolGenerationFn = null;
|
|
112
|
+
|
|
113
|
+
// ── Log helpers ────────────────────────────────────────────────────────
|
|
114
|
+
const appendSystem = (t) => log.log(`{#555555-fg}── ${t} ──{/#555555-fg}`);
|
|
115
|
+
const appendUser = (t) => { log.log(''); log.log(`{cyan-fg}{bold}You:{/bold}{/cyan-fg} ${t}`); };
|
|
116
|
+
const appendAssistant = (t) => {
|
|
117
|
+
if (!t?.trim()) return;
|
|
118
|
+
log.log(`{green-fg}{bold}Forge:{/bold}{/green-fg} ${t.replace(/\n/g, '\n ')}`);
|
|
119
|
+
};
|
|
120
|
+
const setStatus = (t) => {
|
|
121
|
+
statusBar.setContent(t ? ` {#888888-fg}${t}{/#888888-fg}` : '');
|
|
122
|
+
screen.render();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function updateSpecPanel() {
|
|
126
|
+
const raw = JSON.stringify(forgeState.spec, null, 2);
|
|
127
|
+
specPanel.setContent(raw.replace(/: null/g, ': {#444444-fg}null{/#444444-fg}'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function updatePhaseBar() {
|
|
131
|
+
const idx = getPhaseIndex(forgeState.phase);
|
|
132
|
+
const n = idx === -1 ? '?' : idx + 1;
|
|
133
|
+
phaseBar.setContent(
|
|
134
|
+
` {cyan-fg}Phase ${n}/${PHASES.length}: ${forgeState.phase}{/cyan-fg}` +
|
|
135
|
+
` {#888888-fg}Model: ${currentModelConfig?.model || 'n/a'}{/#888888-fg}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function saveToDb() {
|
|
140
|
+
if (!db || !forgeState.generationId || !updateToolGenerationFn) return;
|
|
141
|
+
try {
|
|
142
|
+
updateToolGenerationFn(db, forgeState.generationId, {
|
|
143
|
+
phases_completed: getPhaseIndex(forgeState.phase),
|
|
144
|
+
spec_json: JSON.stringify(forgeState.spec),
|
|
145
|
+
status: forgeState.phase === 'done' ? 'complete' : 'in_progress'
|
|
146
|
+
});
|
|
147
|
+
} catch (_) { /* non-fatal */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── File preview popup ─────────────────────────────────────────────────
|
|
151
|
+
function showFilePreview(files) {
|
|
152
|
+
previewPending = true;
|
|
153
|
+
const lines = [];
|
|
154
|
+
for (const key of ['toolFile', 'testFile']) {
|
|
155
|
+
const f = files[key];
|
|
156
|
+
if (!f) continue;
|
|
157
|
+
lines.push(`{bold}{cyan-fg}${key === 'toolFile' ? 'Tool' : 'Test'}: ${f.path}{/cyan-fg}{/bold}`);
|
|
158
|
+
lines.push('{#333333-fg}' + '─'.repeat(60) + '{/#333333-fg}');
|
|
159
|
+
lines.push(f.content);
|
|
160
|
+
lines.push('');
|
|
161
|
+
}
|
|
162
|
+
if (files.barrelDiff) {
|
|
163
|
+
lines.push(`{bold}{cyan-fg}Barrel: ${files.barrelDiff.path}{/cyan-fg}{/bold}`);
|
|
164
|
+
lines.push(files.barrelDiff.lineToAdd);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const popup = blessed.scrollablebox({
|
|
168
|
+
parent: screen, border: 'line',
|
|
169
|
+
top: 1, left: 2, right: 2, bottom: 3,
|
|
170
|
+
label: ' Generated Files — [y] write [e] editor [n] abort ',
|
|
171
|
+
tags: true, scrollable: true, alwaysScroll: true, keys: true, vi: true, mouse: true,
|
|
172
|
+
content: lines.join('\n'),
|
|
173
|
+
scrollbar: { ch: '│', style: { fg: '#555555' } },
|
|
174
|
+
style: { border: { fg: 'cyan' } }
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
openPopup?.();
|
|
178
|
+
popup.focus();
|
|
179
|
+
screen.render();
|
|
180
|
+
|
|
181
|
+
function closePreview() {
|
|
182
|
+
closePopup?.();
|
|
183
|
+
popup.destroy();
|
|
184
|
+
screen.render();
|
|
185
|
+
startInput();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
popup.key('y', async () => {
|
|
189
|
+
previewPending = false;
|
|
190
|
+
closePreview();
|
|
191
|
+
try {
|
|
192
|
+
if (files.toolFile) {
|
|
193
|
+
mkdirSync(dirname(files.toolFile.path), { recursive: true });
|
|
194
|
+
writeFileSync(files.toolFile.path, files.toolFile.content, 'utf-8');
|
|
195
|
+
}
|
|
196
|
+
if (files.testFile) {
|
|
197
|
+
mkdirSync(dirname(files.testFile.path), { recursive: true });
|
|
198
|
+
writeFileSync(files.testFile.path, files.testFile.content, 'utf-8');
|
|
199
|
+
}
|
|
200
|
+
if (files.barrelDiff) {
|
|
201
|
+
let barrel = existsSync(files.barrelDiff.path)
|
|
202
|
+
? readFileSync(files.barrelDiff.path, 'utf-8') : '';
|
|
203
|
+
if (!barrel.includes(files.barrelDiff.lineToAdd)) {
|
|
204
|
+
writeFileSync(
|
|
205
|
+
files.barrelDiff.path,
|
|
206
|
+
barrel + (barrel.endsWith('\n') ? '' : '\n') + files.barrelDiff.lineToAdd + '\n',
|
|
207
|
+
'utf-8'
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
appendSystem('Files written successfully.');
|
|
212
|
+
} catch (err) {
|
|
213
|
+
appendSystem(`Write error: ${err.message}`);
|
|
214
|
+
}
|
|
215
|
+
await doStep(null);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
popup.key('n', () => {
|
|
219
|
+
previewPending = false;
|
|
220
|
+
closePreview();
|
|
221
|
+
appendSystem("File write aborted. Describe changes and I'll regenerate.");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
popup.key('e', async () => {
|
|
225
|
+
if (!files.toolFile?.path) {
|
|
226
|
+
previewPending = false;
|
|
227
|
+
closePreview();
|
|
228
|
+
appendSystem('No tool file path to open in editor.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const { spawn } = await import('child_process');
|
|
232
|
+
screen.program.disableMouse();
|
|
233
|
+
screen.program.normalBuffer();
|
|
234
|
+
const child = spawn(process.env.EDITOR || 'vi', [files.toolFile.path], { stdio: 'inherit' });
|
|
235
|
+
child.on('exit', () => {
|
|
236
|
+
screen.program.alternateBuffer();
|
|
237
|
+
screen.program.enableMouse();
|
|
238
|
+
previewPending = false;
|
|
239
|
+
closePreview();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
popup.key(['escape', 'b'], () => { previewPending = false; closePreview(); appendSystem('File preview closed.'); });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Action handler ─────────────────────────────────────────────────────
|
|
247
|
+
async function handleAction(action) {
|
|
248
|
+
if (action.type === 'write_file') {
|
|
249
|
+
setStatus('Generating files…');
|
|
250
|
+
try {
|
|
251
|
+
const { generateToolFiles } = await import('../forge-file-writer.js');
|
|
252
|
+
const files = await generateToolFiles({
|
|
253
|
+
spec: forgeState.spec, projectConfig: config,
|
|
254
|
+
projectRoot: process.cwd(), modelConfig: currentModelConfig, existingTools: []
|
|
255
|
+
});
|
|
256
|
+
showFilePreview(files);
|
|
257
|
+
} catch (err) { appendSystem(`File generation failed: ${err.message}`); }
|
|
258
|
+
|
|
259
|
+
} else if (action.type === 'run_tests') {
|
|
260
|
+
appendSystem('Run tests manually: ' + (action.payload?.command || 'npm test'));
|
|
261
|
+
|
|
262
|
+
} else if (action.type === 'write_evals') {
|
|
263
|
+
appendSystem('Eval generation starting…');
|
|
264
|
+
try {
|
|
265
|
+
const { generateEvals } = await import('../forge-eval-generator.js');
|
|
266
|
+
const r = await generateEvals({
|
|
267
|
+
spec: forgeState.spec, allTools: [], projectConfig: config,
|
|
268
|
+
projectRoot: process.cwd(), modelConfig: currentModelConfig
|
|
269
|
+
});
|
|
270
|
+
mkdirSync(dirname(r.goldenPath), { recursive: true });
|
|
271
|
+
writeFileSync(r.goldenPath, JSON.stringify(r.goldenCases, null, 2), 'utf-8');
|
|
272
|
+
writeFileSync(r.labeledPath, JSON.stringify(r.labeledCases, null, 2), 'utf-8');
|
|
273
|
+
appendSystem(`Evals written: ${r.goldenPath}, ${r.labeledPath}`);
|
|
274
|
+
} catch (err) { appendSystem(`Eval generation failed: ${err.message}`); }
|
|
275
|
+
|
|
276
|
+
} else if (action.type === 'write_verifiers') {
|
|
277
|
+
appendSystem('Verifier generation starting…');
|
|
278
|
+
try {
|
|
279
|
+
const { generateVerifiers } = await import('../forge-verifier-generator.js');
|
|
280
|
+
const r = await generateVerifiers({
|
|
281
|
+
spec: forgeState.spec, projectConfig: config,
|
|
282
|
+
projectRoot: process.cwd(), modelConfig: currentModelConfig
|
|
283
|
+
});
|
|
284
|
+
for (const vf of r.verifierFiles) {
|
|
285
|
+
mkdirSync(dirname(vf.path), { recursive: true });
|
|
286
|
+
writeFileSync(vf.path, vf.content, 'utf-8');
|
|
287
|
+
}
|
|
288
|
+
appendSystem(`${r.verifierFiles.length} verifier(s) written.`);
|
|
289
|
+
} catch (err) { appendSystem(`Verifier generation failed: ${err.message}`); }
|
|
290
|
+
|
|
291
|
+
} else if (action.type === 'compare_models') {
|
|
292
|
+
navigate('model-compare');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Core step ──────────────────────────────────────────────────────────
|
|
297
|
+
async function doStep(userInput) {
|
|
298
|
+
if (busy) return;
|
|
299
|
+
busy = true;
|
|
300
|
+
if (userInput) appendUser(userInput);
|
|
301
|
+
setStatus('Thinking…');
|
|
302
|
+
try {
|
|
303
|
+
const result = await forgeStep({
|
|
304
|
+
state: forgeState, userInput, modelConfig: currentModelConfig,
|
|
305
|
+
existingTools: [], projectConfig: config, projectRoot: process.cwd()
|
|
306
|
+
});
|
|
307
|
+
forgeState = result.nextState;
|
|
308
|
+
if (result.assistantText) appendAssistant(result.assistantText);
|
|
309
|
+
updateSpecPanel();
|
|
310
|
+
updatePhaseBar();
|
|
311
|
+
for (const action of result.actions || []) await handleAction(action);
|
|
312
|
+
saveToDb();
|
|
313
|
+
} catch (err) {
|
|
314
|
+
appendSystem(`Error: ${err.message}`);
|
|
315
|
+
}
|
|
316
|
+
setStatus('');
|
|
317
|
+
busy = false;
|
|
318
|
+
screen.render();
|
|
319
|
+
if (!previewPending) {
|
|
320
|
+
startInput();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Init ───────────────────────────────────────────────────────────────
|
|
325
|
+
setImmediate(async () => {
|
|
326
|
+
const env = loadEnv();
|
|
327
|
+
currentModelConfig = resolveModelConfig(config, env, 'generation');
|
|
328
|
+
|
|
329
|
+
if (!currentModelConfig.apiKey) {
|
|
330
|
+
appendSystem('No API key found. Add ANTHROPIC_API_KEY or OPENAI_API_KEY in Settings → API Keys.');
|
|
331
|
+
inputBox.style.border = { fg: 'red' };
|
|
332
|
+
updatePhaseBar();
|
|
333
|
+
updateSpecPanel();
|
|
334
|
+
screen.render();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const dbMod = await import('../db.js');
|
|
340
|
+
db = dbMod.getDb(resolve(process.cwd(), config?.dbPath || 'forge.db'));
|
|
341
|
+
updateToolGenerationFn = dbMod.updateToolGeneration;
|
|
342
|
+
forgeState.generationId = dbMod.insertToolGeneration(db, {
|
|
343
|
+
tool_name: 'new_tool',
|
|
344
|
+
started_at: new Date().toISOString(),
|
|
345
|
+
generation_model: currentModelConfig.model
|
|
346
|
+
});
|
|
347
|
+
} catch (err) { appendSystem(`DB init failed (non-fatal): ${err.message}`); }
|
|
348
|
+
|
|
349
|
+
if (config._forgeTarget) {
|
|
350
|
+
const t = config._forgeTarget;
|
|
351
|
+
forgeState.spec = { ...forgeState.spec, ...t.spec };
|
|
352
|
+
forgeState.phase = 'confirm';
|
|
353
|
+
appendSystem(`Re-forging: ${t.spec?.name || t.toolName || '(unknown)'}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// If returning from model-compare with a chosen spec, apply it
|
|
357
|
+
if (config._chosenSpec) {
|
|
358
|
+
forgeState = { ...forgeState, spec: { ...forgeState.spec, ...config._chosenSpec } };
|
|
359
|
+
config._chosenSpec = null; // consume it
|
|
360
|
+
appendSystem('Applied spec from model comparison.');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
updatePhaseBar();
|
|
364
|
+
updateSpecPanel();
|
|
365
|
+
await doStep(null);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ── Input (managed by startInput / readInput) ────────────────────────
|
|
369
|
+
|
|
370
|
+
// ── Key bindings ───────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
// e/i = enter input mode (vim-style)
|
|
373
|
+
screenKey(['e', 'i'], () => {
|
|
374
|
+
if (inputActive) return;
|
|
375
|
+
startInput();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
screenKey('tab', () => {
|
|
379
|
+
if (inputActive) {
|
|
380
|
+
inputBox.cancel();
|
|
381
|
+
} else if (screen.focused === log) {
|
|
382
|
+
specPanel.focus();
|
|
383
|
+
log.style.border = { fg: '#333333' };
|
|
384
|
+
specPanel.style.border = { fg: 'cyan' };
|
|
385
|
+
screen.render();
|
|
386
|
+
} else {
|
|
387
|
+
startInput();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
screenKey('s', () => {
|
|
392
|
+
if (inputActive || busy || previewPending) return;
|
|
393
|
+
doStep('skip');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
screenKey('m', () => {
|
|
397
|
+
if (inputActive || busy || previewPending) return;
|
|
398
|
+
config._forgeState = forgeState;
|
|
399
|
+
config._forgeInput = null;
|
|
400
|
+
navigate('model-compare');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
screenKey('b', () => {
|
|
404
|
+
if (inputActive) return;
|
|
405
|
+
navigate('main-menu');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
container.refresh = () => { /* live view — no-op */ };
|
|
409
|
+
return container;
|
|
410
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main Menu View — Title banner + navigable list with color-coded inline stats.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import blessed from 'blessed';
|
|
6
|
+
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
|
7
|
+
import { resolve, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const PROJECT_ROOT = resolve(__dirname, '../..');
|
|
12
|
+
const LOCK_FILE = resolve(PROJECT_ROOT, '.forge-service.lock');
|
|
13
|
+
|
|
14
|
+
function readLock() {
|
|
15
|
+
if (!existsSync(LOCK_FILE)) return null;
|
|
16
|
+
try { return JSON.parse(readFileSync(LOCK_FILE, 'utf-8')); } catch (_) { return null; }
|
|
17
|
+
}
|
|
18
|
+
import { loadApis } from '../api-loader.js';
|
|
19
|
+
import { getExistingTools, getToolsWithMetadata } from '../tools-scanner.js';
|
|
20
|
+
import { getExistingVerifiers } from '../verifier-scanner.js';
|
|
21
|
+
import { inferOutputGroups, getVerifiersForGroups } from '../output-groups.js';
|
|
22
|
+
|
|
23
|
+
const MENU_ITEMS = [
|
|
24
|
+
{ key: 'tools-evals', label: 'Tools & Evals', icon: '⚙' },
|
|
25
|
+
{ key: 'forge', label: 'Forge Tool', icon: '⚒' },
|
|
26
|
+
{ key: 'forge-agent', label: 'Forge Agent', icon: '◈' },
|
|
27
|
+
{ key: 'endpoints', label: 'Endpoints', icon: '⇄' },
|
|
28
|
+
{ key: 'verifier-coverage', label: 'Verifier Coverage', icon: '✔' },
|
|
29
|
+
{ key: 'performance', label: 'Performance', icon: '▲' },
|
|
30
|
+
{ key: 'chat', label: 'Chat / Test Model', icon: '◎' },
|
|
31
|
+
{ key: 'settings', label: 'Settings', icon: '⊙' },
|
|
32
|
+
{ key: 'run-evals', label: 'Run Evals', icon: '▶' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const BANNER_HEIGHT = 5;
|
|
36
|
+
|
|
37
|
+
async function buildStats(config) {
|
|
38
|
+
const project = config?.project || {};
|
|
39
|
+
const api = config?.api || {};
|
|
40
|
+
const verification = config?.verification || {};
|
|
41
|
+
|
|
42
|
+
const tools = getExistingTools(project);
|
|
43
|
+
|
|
44
|
+
let endpoints = [];
|
|
45
|
+
let uncovered = 0;
|
|
46
|
+
try {
|
|
47
|
+
endpoints = await loadApis(api);
|
|
48
|
+
const toolSet = new Set(tools.map((t) => t.toLowerCase().replace(/-/g, '_')));
|
|
49
|
+
uncovered = endpoints.filter((e) => {
|
|
50
|
+
const name = (e.name || '').toLowerCase().replace(/-/g, '_');
|
|
51
|
+
return !toolSet.has(name);
|
|
52
|
+
}).length;
|
|
53
|
+
} catch (_) { /* no api config */ }
|
|
54
|
+
|
|
55
|
+
let allCovered = false;
|
|
56
|
+
try {
|
|
57
|
+
const toolMeta = getToolsWithMetadata(project);
|
|
58
|
+
const verifiers = getExistingVerifiers(verification);
|
|
59
|
+
allCovered = toolMeta.length > 0 && toolMeta.every((t) => {
|
|
60
|
+
const groups = inferOutputGroups(t);
|
|
61
|
+
return getVerifiersForGroups(groups).some((v) => verifiers.includes(v));
|
|
62
|
+
});
|
|
63
|
+
} catch (_) { /* skip */ }
|
|
64
|
+
|
|
65
|
+
let hasEvalHistory = false;
|
|
66
|
+
try {
|
|
67
|
+
const dbPath = resolve(process.cwd(), config?.dbPath || 'forge.db');
|
|
68
|
+
if (existsSync(dbPath)) {
|
|
69
|
+
const { getDb, getEvalSummary } = await import('../db.js');
|
|
70
|
+
hasEvalHistory = getEvalSummary(getDb(dbPath)).length > 0;
|
|
71
|
+
}
|
|
72
|
+
} catch (_) { /* sqlite not available */ }
|
|
73
|
+
|
|
74
|
+
let generationStats = { complete: 0, inProgress: 0 };
|
|
75
|
+
try {
|
|
76
|
+
const dbPath = resolve(process.cwd(), config?.dbPath || 'forge.db');
|
|
77
|
+
if (existsSync(dbPath)) {
|
|
78
|
+
const { getDb, getToolGenerations } = await import('../db.js');
|
|
79
|
+
const generations = getToolGenerations(getDb(dbPath));
|
|
80
|
+
generationStats.complete = generations.filter((g) => g.status === 'complete').length;
|
|
81
|
+
generationStats.inProgress = generations.filter((g) => g.status === 'in_progress').length;
|
|
82
|
+
}
|
|
83
|
+
} catch (_) { /* sqlite not available */ }
|
|
84
|
+
|
|
85
|
+
const model = config?.model || 'not set';
|
|
86
|
+
|
|
87
|
+
let chatProvider = 'no key';
|
|
88
|
+
try {
|
|
89
|
+
const envPath = resolve(process.cwd(), '.env');
|
|
90
|
+
if (existsSync(envPath)) {
|
|
91
|
+
const envText = readFileSync(envPath, 'utf-8');
|
|
92
|
+
if (/ANTHROPIC_API_KEY\s*=\s*\S/.test(envText)) chatProvider = 'anthropic';
|
|
93
|
+
else if (/OPENAI_API_KEY\s*=\s*\S/.test(envText)) chatProvider = 'openai';
|
|
94
|
+
}
|
|
95
|
+
} catch (_) { /* skip */ }
|
|
96
|
+
|
|
97
|
+
const serviceRunning = readLock() !== null;
|
|
98
|
+
return { toolsCount: tools.length, uncovered, endpointsTotal: endpoints.length, allCovered, hasEvalHistory, model, chatProvider, serviceRunning, generationStats };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function colorStat(text, level) {
|
|
102
|
+
// level: 'good' | 'warn' | 'neutral' | 'dim'
|
|
103
|
+
if (level === 'good') return `{green-fg}${text}{/green-fg}`;
|
|
104
|
+
if (level === 'warn') return `{yellow-fg}${text}{/yellow-fg}`;
|
|
105
|
+
if (level === 'neutral') return `{white-fg}${text}{/white-fg}`;
|
|
106
|
+
return `{#888888-fg}${text}{/#888888-fg}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildItems(stats) {
|
|
110
|
+
const toolsStat = colorStat(`${stats.toolsCount} tool${stats.toolsCount !== 1 ? 's' : ''}`, stats.toolsCount > 0 ? 'good' : 'dim');
|
|
111
|
+
const forgeStat = stats.generationStats.complete > 0 || stats.generationStats.inProgress > 0
|
|
112
|
+
? colorStat(`${stats.generationStats.complete} complete`, 'good') +
|
|
113
|
+
(stats.generationStats.inProgress > 0 ? ' ' + colorStat(`${stats.generationStats.inProgress} in progress`, 'warn') : '')
|
|
114
|
+
: colorStat('no sessions yet', 'dim');
|
|
115
|
+
const epStat = colorStat(`${stats.endpointsTotal} total`, 'neutral') +
|
|
116
|
+
(stats.uncovered > 0 ? ', ' + colorStat(`${stats.uncovered} uncovered`, 'warn') : '');
|
|
117
|
+
const verifStat = stats.allCovered ? colorStat('✓ all covered', 'good') : colorStat('⚠ gaps detected', 'warn');
|
|
118
|
+
const perfStat = stats.hasEvalHistory ? colorStat('has history', 'good') : colorStat('no history', 'dim');
|
|
119
|
+
const chatStat = stats.chatProvider !== 'no key'
|
|
120
|
+
? colorStat(`via ${stats.chatProvider}`, 'good')
|
|
121
|
+
: colorStat('no api key', 'dim');
|
|
122
|
+
const modelStat = colorStat(stats.model, 'neutral');
|
|
123
|
+
|
|
124
|
+
// Column widths: icon (2) + num (2) + label (22) + stat
|
|
125
|
+
const row = (num, icon, label, stat) =>
|
|
126
|
+
` {bold}${num}{/bold} ${icon} {white-fg}${label.padEnd(22)}{/white-fg}${stat}`;
|
|
127
|
+
|
|
128
|
+
const agentStat = colorStat('stage-aware chat', 'dim');
|
|
129
|
+
|
|
130
|
+
return [
|
|
131
|
+
row('1', MENU_ITEMS[0].icon, MENU_ITEMS[0].label, toolsStat),
|
|
132
|
+
row('2', MENU_ITEMS[1].icon, MENU_ITEMS[1].label, forgeStat),
|
|
133
|
+
row('3', MENU_ITEMS[2].icon, MENU_ITEMS[2].label, agentStat),
|
|
134
|
+
row('4', MENU_ITEMS[3].icon, MENU_ITEMS[3].label, epStat),
|
|
135
|
+
row('5', MENU_ITEMS[4].icon, MENU_ITEMS[4].label, verifStat),
|
|
136
|
+
row('6', MENU_ITEMS[5].icon, MENU_ITEMS[5].label, perfStat),
|
|
137
|
+
row('7', MENU_ITEMS[6].icon, MENU_ITEMS[6].label, chatStat),
|
|
138
|
+
row('8', MENU_ITEMS[7].icon, MENU_ITEMS[7].label, modelStat),
|
|
139
|
+
row('9', MENU_ITEMS[8].icon, MENU_ITEMS[8].label, colorStat('eval runner', 'dim')),
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createView({ screen, content, config, navigate, setFooter, screenKey, startService }) {
|
|
144
|
+
const container = blessed.box({
|
|
145
|
+
top: 0,
|
|
146
|
+
left: 0,
|
|
147
|
+
width: '100%',
|
|
148
|
+
height: '100%',
|
|
149
|
+
tags: true
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
let serviceRunning = false;
|
|
153
|
+
|
|
154
|
+
// ── Title banner ──────────────────────────────────────────────────────────
|
|
155
|
+
const banner = blessed.box({
|
|
156
|
+
parent: container,
|
|
157
|
+
top: 0,
|
|
158
|
+
left: 0,
|
|
159
|
+
width: '100%',
|
|
160
|
+
height: BANNER_HEIGHT,
|
|
161
|
+
tags: true,
|
|
162
|
+
border: { type: 'line' },
|
|
163
|
+
style: { border: { fg: 'blue' } },
|
|
164
|
+
align: 'center',
|
|
165
|
+
valign: 'middle',
|
|
166
|
+
content: [
|
|
167
|
+
'',
|
|
168
|
+
' {bold}{cyan-fg}⚒ T O O L F O R G E{/cyan-fg}{/bold} ',
|
|
169
|
+
' {#555555-fg}Build · Test · Verify{/#555555-fg} ',
|
|
170
|
+
].join('\n')
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── Divider between banner and list ───────────────────────────────────────
|
|
174
|
+
const divider = blessed.box({
|
|
175
|
+
parent: container,
|
|
176
|
+
top: BANNER_HEIGHT,
|
|
177
|
+
left: 0,
|
|
178
|
+
width: '100%',
|
|
179
|
+
height: 1,
|
|
180
|
+
tags: true,
|
|
181
|
+
content: '{#333333-fg}' + '─'.repeat(screen.width || 200) + '{/#333333-fg}'
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── Service notice bar (1 row, shown when service is not running) ─────────
|
|
185
|
+
const noticeBar = blessed.box({
|
|
186
|
+
parent: container,
|
|
187
|
+
top: BANNER_HEIGHT + 1,
|
|
188
|
+
left: 0,
|
|
189
|
+
width: '100%',
|
|
190
|
+
height: 1,
|
|
191
|
+
tags: true
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── Menu list ─────────────────────────────────────────────────────────────
|
|
195
|
+
const list = blessed.list({
|
|
196
|
+
parent: container,
|
|
197
|
+
top: BANNER_HEIGHT + 2,
|
|
198
|
+
left: 2,
|
|
199
|
+
width: '100%-4',
|
|
200
|
+
height: `100%-${BANNER_HEIGHT + 4}`,
|
|
201
|
+
tags: true,
|
|
202
|
+
keys: true,
|
|
203
|
+
vi: true,
|
|
204
|
+
mouse: true,
|
|
205
|
+
style: {
|
|
206
|
+
selected: { bg: '#1a3a5c', bold: true },
|
|
207
|
+
item: { fg: 'white' }
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
list.on('select', (item, index) => {
|
|
212
|
+
const target = MENU_ITEMS[index];
|
|
213
|
+
if (target) navigate(target.key);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
list.key(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (ch) => {
|
|
217
|
+
const idx = parseInt(ch, 10) - 1;
|
|
218
|
+
if (MENU_ITEMS[idx]) navigate(MENU_ITEMS[idx].key);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── s = toggle service (start / stop) ───────────────────────────────────
|
|
222
|
+
let lastStatsJson = '';
|
|
223
|
+
|
|
224
|
+
screenKey('s', () => {
|
|
225
|
+
if (serviceRunning) {
|
|
226
|
+
const lock = readLock();
|
|
227
|
+
if (lock?.pid && Number.isInteger(lock.pid) && lock.pid > 0) {
|
|
228
|
+
try { process.kill(lock.pid); } catch (_) { /* already dead */ }
|
|
229
|
+
}
|
|
230
|
+
try { unlinkSync(LOCK_FILE); } catch (_) { /* ignore */ }
|
|
231
|
+
serviceRunning = false;
|
|
232
|
+
lastStatsJson = ''; // force next refresh to re-render
|
|
233
|
+
} else {
|
|
234
|
+
startService?.();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
setFooter(
|
|
239
|
+
' {cyan-fg}↑↓{/cyan-fg} navigate {cyan-fg}Enter{/cyan-fg} select' +
|
|
240
|
+
' {cyan-fg}1-9{/cyan-fg} jump {cyan-fg}s{/cyan-fg} service {cyan-fg}q{/cyan-fg} quit'
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
container.refresh = async () => {
|
|
244
|
+
try {
|
|
245
|
+
const stats = await buildStats(config);
|
|
246
|
+
const statsJson = JSON.stringify(stats);
|
|
247
|
+
if (statsJson === lastStatsJson) return; // no change — skip render
|
|
248
|
+
lastStatsJson = statsJson;
|
|
249
|
+
|
|
250
|
+
serviceRunning = stats.serviceRunning;
|
|
251
|
+
list.setItems(buildItems(stats));
|
|
252
|
+
|
|
253
|
+
if (!serviceRunning) {
|
|
254
|
+
noticeBar.setContent(
|
|
255
|
+
' {yellow-fg}⚡ Service stopped{/yellow-fg}' +
|
|
256
|
+
' {cyan-fg}s{/cyan-fg} {#888888-fg}to start{/#888888-fg}'
|
|
257
|
+
);
|
|
258
|
+
} else {
|
|
259
|
+
noticeBar.setContent(
|
|
260
|
+
' {green-fg}⚡ Service running{/green-fg}' +
|
|
261
|
+
' {cyan-fg}s{/cyan-fg} {#888888-fg}to stop{/#888888-fg}'
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
} catch (_) {
|
|
265
|
+
list.setItems(MENU_ITEMS.map((m, i) => ` ${i + 1} ${m.icon} ${m.label}`));
|
|
266
|
+
}
|
|
267
|
+
screen.render();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
container.refresh();
|
|
271
|
+
list.focus();
|
|
272
|
+
|
|
273
|
+
return container;
|
|
274
|
+
}
|
|
275
|
+
|