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,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge Agent View — Stage-aware LLM chat panel for building MCP tool definitions.
|
|
3
|
+
*
|
|
4
|
+
* Layout:
|
|
5
|
+
* phaseBar (1 row, top) — current stage indicator
|
|
6
|
+
* log (fills middle) — chat history, auto-scroll
|
|
7
|
+
* inputBox (3 rows, bottom) — user input
|
|
8
|
+
*
|
|
9
|
+
* Stages: orient → report → name-describe → skeptic →
|
|
10
|
+
* tool-writing → eval-writing → verifier-creation → promote
|
|
11
|
+
*
|
|
12
|
+
* Stage skill files are loaded from context/forge-agent/stages/{name}.md.
|
|
13
|
+
* Base system prompt from context/forge-agent/system-prompt.md.
|
|
14
|
+
* Conversation history is persisted via cli/conversation-store.js (SQLite by default, Redis optional).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import blessed from 'blessed';
|
|
18
|
+
import { existsSync, readFileSync } from 'fs';
|
|
19
|
+
import { resolve, dirname } from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const PROJECT_ROOT = resolve(__dirname, '../..');
|
|
24
|
+
const STAGES_DIR = resolve(PROJECT_ROOT, 'context/forge-agent/stages');
|
|
25
|
+
const BASE_PROMPT_PATH = resolve(PROJECT_ROOT, 'context/forge-agent/system-prompt.md');
|
|
26
|
+
|
|
27
|
+
// ── Stage registry ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export const STAGES = [
|
|
30
|
+
'orient',
|
|
31
|
+
'report',
|
|
32
|
+
'name-describe',
|
|
33
|
+
'skeptic',
|
|
34
|
+
'tool-writing',
|
|
35
|
+
'eval-writing',
|
|
36
|
+
'verifier-creation',
|
|
37
|
+
'promote'
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Load the base system prompt. Returns '' if file is missing.
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function loadBasePrompt() {
|
|
47
|
+
try {
|
|
48
|
+
if (!existsSync(BASE_PROMPT_PATH)) return '';
|
|
49
|
+
return readFileSync(BASE_PROMPT_PATH, 'utf-8');
|
|
50
|
+
} catch (_) {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load a stage skill file by stage name. Returns '' if missing or unreadable.
|
|
57
|
+
* @param {string} stageName
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function loadStageSkill(stageName) {
|
|
61
|
+
try {
|
|
62
|
+
const filePath = resolve(STAGES_DIR, `${stageName}.md`);
|
|
63
|
+
if (!existsSync(STAGES_DIR)) return '';
|
|
64
|
+
if (!existsSync(filePath)) return '';
|
|
65
|
+
return readFileSync(filePath, 'utf-8');
|
|
66
|
+
} catch (_) {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the stage label string for the phase bar.
|
|
73
|
+
* @param {string} stageName
|
|
74
|
+
* @param {number} totalStages
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function computeStageLabel(stageName, totalStages) {
|
|
78
|
+
if (!totalStages || totalStages === 0) {
|
|
79
|
+
return `Stage ?/${totalStages || 0}: ${stageName}`;
|
|
80
|
+
}
|
|
81
|
+
const idx = STAGES.indexOf(stageName);
|
|
82
|
+
const n = idx === -1 ? '?' : idx + 1;
|
|
83
|
+
return `Stage ${n}/${totalStages}: ${stageName}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the combined system prompt for a turn.
|
|
88
|
+
* @param {string} baseContent
|
|
89
|
+
* @param {string} stageContent
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function buildSystemPrompt(baseContent, stageContent) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
if (baseContent && baseContent.trim()) parts.push(baseContent.trim());
|
|
95
|
+
if (stageContent && stageContent.trim()) parts.push(stageContent.trim());
|
|
96
|
+
return parts.join('\n\n---\n\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Load and parse .env file from project root.
|
|
101
|
+
* @returns {object}
|
|
102
|
+
*/
|
|
103
|
+
function loadEnv() {
|
|
104
|
+
const envPath = resolve(PROJECT_ROOT, '.env');
|
|
105
|
+
if (!existsSync(envPath)) return {};
|
|
106
|
+
const out = {};
|
|
107
|
+
try {
|
|
108
|
+
for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
|
|
109
|
+
const t = line.trim();
|
|
110
|
+
if (!t || t.startsWith('#')) continue;
|
|
111
|
+
const eq = t.indexOf('=');
|
|
112
|
+
if (eq === -1) continue;
|
|
113
|
+
out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
|
|
114
|
+
}
|
|
115
|
+
} catch (_) { /* ignore */ }
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── View ────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export function createView({
|
|
122
|
+
screen, content, config, navigate, setFooter,
|
|
123
|
+
screenKey, openPopup, closePopup
|
|
124
|
+
}) {
|
|
125
|
+
const container = blessed.box({
|
|
126
|
+
top: 0, left: 0, width: '100%', height: '100%', tags: true
|
|
127
|
+
});
|
|
128
|
+
// Escape/b navigates back immediately — session is auto-persisted.
|
|
129
|
+
|
|
130
|
+
// ── Layout ──────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
const phaseBar = blessed.box({
|
|
133
|
+
parent: container, top: 0, left: 0, width: '100%', height: 1,
|
|
134
|
+
tags: true, style: { fg: '#888888' }
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const log = blessed.log({
|
|
138
|
+
parent: container, top: 1, left: 0, width: '100%', height: '100%-4',
|
|
139
|
+
tags: true, scrollable: true, alwaysScroll: true, keys: true, mouse: true,
|
|
140
|
+
border: { type: 'line' }, label: ' Forge Agent ',
|
|
141
|
+
style: {
|
|
142
|
+
border: { fg: '#333333' },
|
|
143
|
+
focus: { border: { fg: 'cyan' } }
|
|
144
|
+
},
|
|
145
|
+
scrollbar: { ch: '│', style: { fg: '#555555' } }
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const inputBox = blessed.textbox({
|
|
149
|
+
parent: container, bottom: 0, left: 0, width: '100%', height: 3,
|
|
150
|
+
border: { type: 'line' },
|
|
151
|
+
label: ' Message (Enter send, Esc shortcuts, ] next, [ prev) ',
|
|
152
|
+
style: {
|
|
153
|
+
border: { fg: '#333333' },
|
|
154
|
+
focus: { border: { fg: 'cyan' } }
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
setFooter(
|
|
159
|
+
' {cyan-fg}Enter{/cyan-fg} send {cyan-fg}Esc{/cyan-fg} shortcuts ' +
|
|
160
|
+
'{cyan-fg}e{/cyan-fg} edit {cyan-fg}]{/cyan-fg} next {cyan-fg}[{/cyan-fg} prev {cyan-fg}b{/cyan-fg} back'
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// ── Explicit input mode management ──────────────────────────────────────
|
|
164
|
+
// blessed.textbox with inputOnFocus captures ALL keystrokes at the program
|
|
165
|
+
// level, causing conflicts with screen-level hotkeys. Instead we manage
|
|
166
|
+
// readInput() explicitly so hotkeys only work outside of input mode.
|
|
167
|
+
|
|
168
|
+
let inputActive = false;
|
|
169
|
+
|
|
170
|
+
function startInput() {
|
|
171
|
+
inputActive = true;
|
|
172
|
+
inputBox.focus();
|
|
173
|
+
inputBox.style.border = { fg: 'cyan' };
|
|
174
|
+
log.style.border = { fg: '#333333' };
|
|
175
|
+
screen.render();
|
|
176
|
+
inputBox.readInput((err, value) => {
|
|
177
|
+
inputActive = false;
|
|
178
|
+
if (err || value === undefined || value === null) {
|
|
179
|
+
// Escape pressed — exit to command mode
|
|
180
|
+
log.focus();
|
|
181
|
+
log.style.border = { fg: 'cyan' };
|
|
182
|
+
inputBox.style.border = { fg: '#333333' };
|
|
183
|
+
screen.render();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Enter pressed — submit
|
|
187
|
+
const text = (value || '').trim();
|
|
188
|
+
inputBox.clearValue();
|
|
189
|
+
screen.render();
|
|
190
|
+
if (text) {
|
|
191
|
+
doStep(text);
|
|
192
|
+
} else {
|
|
193
|
+
startInput();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── State ────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
let currentStageIdx = 0;
|
|
201
|
+
let busy = false;
|
|
202
|
+
let modelConfig = null;
|
|
203
|
+
let db = null;
|
|
204
|
+
let sessionId = null;
|
|
205
|
+
let conversationStore = null;
|
|
206
|
+
|
|
207
|
+
// apiMessages is the LLM conversation history (role/content pairs)
|
|
208
|
+
let apiMessages = [];
|
|
209
|
+
|
|
210
|
+
// ── Log helpers ──────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
const appendSystem = (t) => {
|
|
213
|
+
log.log(`{#555555-fg}── ${t} ──{/#555555-fg}`);
|
|
214
|
+
screen.render();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const appendUser = (t) => {
|
|
218
|
+
log.log('');
|
|
219
|
+
log.log(`{cyan-fg}{bold}You:{/bold}{/cyan-fg} ${t}`);
|
|
220
|
+
screen.render();
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const appendAssistant = (t) => {
|
|
224
|
+
if (!t || !t.trim()) return;
|
|
225
|
+
log.log(`{green-fg}{bold}Agent:{/bold}{/green-fg} ${t.replace(/\n/g, '\n ')}`);
|
|
226
|
+
screen.render();
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ── Phase bar ────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
function updatePhaseBar() {
|
|
232
|
+
const stageName = STAGES[currentStageIdx] || 'unknown';
|
|
233
|
+
const label = computeStageLabel(stageName, STAGES.length);
|
|
234
|
+
phaseBar.setContent(
|
|
235
|
+
` {cyan-fg}${label}{/cyan-fg}` +
|
|
236
|
+
` {#888888-fg}Model: ${modelConfig?.model || 'n/a'}{/#888888-fg}`
|
|
237
|
+
);
|
|
238
|
+
screen.render();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Stage navigation ─────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
function advanceStage() {
|
|
244
|
+
if (busy) return;
|
|
245
|
+
if (currentStageIdx < STAGES.length - 1) {
|
|
246
|
+
currentStageIdx++;
|
|
247
|
+
appendSystem(`Advanced to stage: ${STAGES[currentStageIdx]}`);
|
|
248
|
+
updatePhaseBar();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function rewindStage() {
|
|
253
|
+
if (busy) return;
|
|
254
|
+
if (currentStageIdx > 0) {
|
|
255
|
+
currentStageIdx--;
|
|
256
|
+
appendSystem(`Rewound to stage: ${STAGES[currentStageIdx]}`);
|
|
257
|
+
updatePhaseBar();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Session persistence ──────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
function persistMessage(role, content) {
|
|
264
|
+
if (!conversationStore || !sessionId) return;
|
|
265
|
+
conversationStore
|
|
266
|
+
.persistMessage(sessionId, STAGES[currentStageIdx] || 'unknown', role, content)
|
|
267
|
+
.catch((err) => process.stderr.write(`[forge-agent] store write failed: ${err.message}\n`));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Core LLM step ────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
async function doStep(userText) {
|
|
273
|
+
if (busy) return;
|
|
274
|
+
busy = true;
|
|
275
|
+
|
|
276
|
+
if (!modelConfig || !modelConfig.apiKey) {
|
|
277
|
+
appendSystem('No API key found. Add ANTHROPIC_API_KEY or OPENAI_API_KEY in Settings → API Keys.');
|
|
278
|
+
busy = false;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (userText) {
|
|
283
|
+
appendUser(userText);
|
|
284
|
+
apiMessages.push({ role: 'user', content: userText });
|
|
285
|
+
persistMessage('user', userText);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Build system prompt: base + current stage
|
|
289
|
+
const baseContent = loadBasePrompt();
|
|
290
|
+
const stageName = STAGES[currentStageIdx] || 'unknown';
|
|
291
|
+
const stageContent = loadStageSkill(stageName);
|
|
292
|
+
const systemPrompt = buildSystemPrompt(baseContent, stageContent);
|
|
293
|
+
|
|
294
|
+
if (!stageContent && stageName !== 'unknown') {
|
|
295
|
+
appendSystem(`Warning: stage file missing for '${stageName}' — using base prompt only.`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
// Ensure messages array is non-empty and ends with a user turn
|
|
300
|
+
let callMessages = [...apiMessages];
|
|
301
|
+
if (callMessages.length === 0) {
|
|
302
|
+
// Seed the first turn so Anthropic never receives an empty messages array.
|
|
303
|
+
// Also push to apiMessages so the history starts with a user turn — without this
|
|
304
|
+
// the first assistant reply lands at index 0, making subsequent turns invalid.
|
|
305
|
+
const seed = { role: 'user', content: 'Begin.' };
|
|
306
|
+
callMessages = [seed];
|
|
307
|
+
apiMessages.push(seed);
|
|
308
|
+
} else if (
|
|
309
|
+
callMessages[callMessages.length - 1].role === 'assistant' &&
|
|
310
|
+
!userText
|
|
311
|
+
) {
|
|
312
|
+
callMessages = [...callMessages, { role: 'user', content: '[continue]' }];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const { llmTurn } = await import('../api-client.js');
|
|
316
|
+
const result = await llmTurn({
|
|
317
|
+
provider: modelConfig.provider,
|
|
318
|
+
apiKey: modelConfig.apiKey,
|
|
319
|
+
model: modelConfig.model,
|
|
320
|
+
system: systemPrompt,
|
|
321
|
+
messages: callMessages,
|
|
322
|
+
maxTokens: 4096
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
let text = result.text || '';
|
|
326
|
+
|
|
327
|
+
// Check for [STAGE_COMPLETE] marker — handle both inline and line-anchored
|
|
328
|
+
const hasComplete = /\[STAGE_COMPLETE\]/.test(text);
|
|
329
|
+
if (hasComplete) {
|
|
330
|
+
text = text.replace(/\[STAGE_COMPLETE\]\s*/g, '').trim();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (text) {
|
|
334
|
+
appendAssistant(text);
|
|
335
|
+
apiMessages.push({ role: 'assistant', content: text });
|
|
336
|
+
persistMessage('assistant', text);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Advance stage after displaying text
|
|
340
|
+
if (hasComplete) {
|
|
341
|
+
if (currentStageIdx < STAGES.length - 1) {
|
|
342
|
+
currentStageIdx++;
|
|
343
|
+
appendSystem(`Stage complete. Moving to: ${STAGES[currentStageIdx]}`);
|
|
344
|
+
updatePhaseBar();
|
|
345
|
+
} else {
|
|
346
|
+
appendSystem('All stages complete. Session finished.');
|
|
347
|
+
persistMessage('system', '[COMPLETE]');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
} catch (err) {
|
|
352
|
+
appendSystem(`Error: ${err.message}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
busy = false;
|
|
356
|
+
startInput();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Session resumption ───────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
async function promptResume(sessions, dbMod) {
|
|
362
|
+
return new Promise((resolve) => {
|
|
363
|
+
openPopup();
|
|
364
|
+
|
|
365
|
+
if (sessions.length === 1) {
|
|
366
|
+
const s = sessions[0];
|
|
367
|
+
const label = `Resume session at stage '${s.stage}'? (last: ${s.last_updated?.slice(0, 16) || '?'})`;
|
|
368
|
+
const q = blessed.question({
|
|
369
|
+
parent: screen, border: 'line', height: 'shrink', width: '60%',
|
|
370
|
+
top: 'center', left: 'center',
|
|
371
|
+
label: ' Resume Session? ', tags: true, keys: true
|
|
372
|
+
});
|
|
373
|
+
q.ask(`${label}\n[R]esume / [N]ew session (y=resume, n=new)`, (err, answer) => {
|
|
374
|
+
q.destroy();
|
|
375
|
+
closePopup();
|
|
376
|
+
if (!err && /^y/i.test(answer)) {
|
|
377
|
+
resolve(s.session_id);
|
|
378
|
+
} else {
|
|
379
|
+
resolve(null);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
} else {
|
|
383
|
+
// Multiple sessions — show list
|
|
384
|
+
const listLines = sessions.slice(0, 5).map((s, i) =>
|
|
385
|
+
`${i + 1}. stage=${s.stage} last=${s.last_updated?.slice(0, 16) || '?'}`
|
|
386
|
+
).join('\n');
|
|
387
|
+
const q = blessed.question({
|
|
388
|
+
parent: screen, border: 'line', height: 'shrink', width: '70%',
|
|
389
|
+
top: 'center', left: 'center',
|
|
390
|
+
label: ' Resume a Session? ', tags: true, keys: true
|
|
391
|
+
});
|
|
392
|
+
q.ask(
|
|
393
|
+
`Incomplete sessions:\n${listLines}\n\nEnter session number to resume, or 0 for new:`,
|
|
394
|
+
(err, answer) => {
|
|
395
|
+
q.destroy();
|
|
396
|
+
closePopup();
|
|
397
|
+
if (err) { resolve(null); return; }
|
|
398
|
+
const n = parseInt(answer, 10);
|
|
399
|
+
if (n >= 1 && n <= sessions.length) {
|
|
400
|
+
resolve(sessions[n - 1].session_id);
|
|
401
|
+
} else {
|
|
402
|
+
resolve(null);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Init ──────────────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
setImmediate(async () => {
|
|
413
|
+
try {
|
|
414
|
+
const env = loadEnv();
|
|
415
|
+
const { resolveModelConfig } = await import('../api-client.js');
|
|
416
|
+
modelConfig = resolveModelConfig(config, env, 'generation');
|
|
417
|
+
|
|
418
|
+
if (!modelConfig.apiKey) {
|
|
419
|
+
appendSystem('No API key found. Add ANTHROPIC_API_KEY or OPENAI_API_KEY in Settings → API Keys.');
|
|
420
|
+
inputBox.style.border = { fg: 'red' };
|
|
421
|
+
updatePhaseBar();
|
|
422
|
+
screen.render();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Load DB + conversation store
|
|
427
|
+
let dbMod;
|
|
428
|
+
try {
|
|
429
|
+
dbMod = await import('../db.js');
|
|
430
|
+
const dbPath = resolve(process.cwd(), config?.dbPath || 'forge.db');
|
|
431
|
+
db = dbMod.getDb(dbPath);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
appendSystem(`DB init failed (non-fatal): ${err.message}`);
|
|
434
|
+
dbMod = null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const { makeConversationStore } = await import('../conversation-store.js');
|
|
439
|
+
conversationStore = makeConversationStore(config, db);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
appendSystem(`Conversation store init failed (non-fatal): ${err.message}`);
|
|
442
|
+
conversationStore = null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Check for incomplete sessions
|
|
446
|
+
if (conversationStore) {
|
|
447
|
+
let incompleteSessions = [];
|
|
448
|
+
try {
|
|
449
|
+
incompleteSessions = await conversationStore.getIncompleteSessions();
|
|
450
|
+
} catch (_) { /* ignore */ }
|
|
451
|
+
|
|
452
|
+
if (incompleteSessions.length > 0) {
|
|
453
|
+
const resumeId = await promptResume(incompleteSessions, dbMod);
|
|
454
|
+
if (resumeId) {
|
|
455
|
+
// Restore session
|
|
456
|
+
sessionId = resumeId;
|
|
457
|
+
const history = await conversationStore.getHistory(sessionId);
|
|
458
|
+
|
|
459
|
+
// Find the last stage used
|
|
460
|
+
const lastRow = [...history].reverse().find((r) => r.stage);
|
|
461
|
+
if (lastRow) {
|
|
462
|
+
const stageIdx = STAGES.indexOf(lastRow.stage);
|
|
463
|
+
if (stageIdx !== -1) currentStageIdx = stageIdx;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Restore apiMessages (user + assistant only)
|
|
467
|
+
apiMessages = history
|
|
468
|
+
.filter((r) => r.role === 'user' || r.role === 'assistant')
|
|
469
|
+
.map((r) => ({ role: r.role, content: r.content }));
|
|
470
|
+
|
|
471
|
+
// Display history in log
|
|
472
|
+
appendSystem(`Resumed session. Stage: ${STAGES[currentStageIdx]}`);
|
|
473
|
+
for (const row of history.filter((r) => r.role !== 'system')) {
|
|
474
|
+
if (row.role === 'user') appendUser(row.content);
|
|
475
|
+
else if (row.role === 'assistant') appendAssistant(row.content);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
updatePhaseBar();
|
|
479
|
+
startInput();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Start fresh session
|
|
486
|
+
if (conversationStore) {
|
|
487
|
+
sessionId = conversationStore.createSession();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
updatePhaseBar();
|
|
491
|
+
|
|
492
|
+
// Kick off first LLM turn to greet the user
|
|
493
|
+
await doStep(null);
|
|
494
|
+
|
|
495
|
+
} catch (err) {
|
|
496
|
+
appendSystem(`Init error: ${err.message}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
startInput();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// ── Input handling (managed by startInput / readInput) ───────────────────
|
|
503
|
+
|
|
504
|
+
// ── Key bindings ──────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
// e/i = enter input mode (vim-style)
|
|
507
|
+
screenKey(['e', 'i'], () => {
|
|
508
|
+
if (inputActive) return;
|
|
509
|
+
startInput();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
screenKey('tab', () => {
|
|
513
|
+
if (inputActive) {
|
|
514
|
+
// Cancel textbox input → readInput callback moves focus to log
|
|
515
|
+
inputBox.cancel();
|
|
516
|
+
} else {
|
|
517
|
+
startInput();
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
screenKey(']', () => {
|
|
522
|
+
if (inputActive || busy) return;
|
|
523
|
+
advanceStage();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
screenKey('[', () => {
|
|
527
|
+
if (inputActive || busy) return;
|
|
528
|
+
rewindStage();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
screenKey('b', () => {
|
|
532
|
+
if (inputActive) return;
|
|
533
|
+
navigate('main-menu');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
container.refresh = () => { /* live view — no-op */ };
|
|
537
|
+
return container;
|
|
538
|
+
}
|