create-ironclaws 1.0.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 (80) hide show
  1. package/README.md +101 -0
  2. package/bin/create.js +394 -0
  3. package/package.json +33 -0
  4. package/template/.env.example +38 -0
  5. package/template/CLAUDE.md +104 -0
  6. package/template/agent-credentials.yaml +33 -0
  7. package/template/agents.yaml +22 -0
  8. package/template/container/Dockerfile +70 -0
  9. package/template/container/Dockerfile.argus +34 -0
  10. package/template/container/agent-runner/package-lock.json +1524 -0
  11. package/template/container/agent-runner/package.json +23 -0
  12. package/template/container/agent-runner/src/index.ts +630 -0
  13. package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
  14. package/template/container/agent-runner/tsconfig.json +15 -0
  15. package/template/container/build-argus.sh +25 -0
  16. package/template/container/build.sh +23 -0
  17. package/template/container/skills/agent-browser/SKILL.md +159 -0
  18. package/template/container/skills/agent-status/SKILL.md +69 -0
  19. package/template/container/skills/capabilities/SKILL.md +100 -0
  20. package/template/container/skills/edit-agent/SKILL.md +93 -0
  21. package/template/container/skills/slack-formatting/SKILL.md +92 -0
  22. package/template/container/skills/status/SKILL.md +104 -0
  23. package/template/container/tools/elastic_query.py +161 -0
  24. package/template/container/tools/gdrive_tool.py +185 -0
  25. package/template/container/tools/jira_tool.py +433 -0
  26. package/template/container/tools/slack_history_tool.py +144 -0
  27. package/template/container/tools/youtube_tool.py +174 -0
  28. package/template/docker-compose.yml +54 -0
  29. package/template/docs/how-it-works.md +496 -0
  30. package/template/eslint.config.js +32 -0
  31. package/template/groups/forge/CLAUDE.md +107 -0
  32. package/template/package-lock.json +5278 -0
  33. package/template/package.json +52 -0
  34. package/template/scripts/github-app-token.py +58 -0
  35. package/template/scripts/register-expense-agent.sh +121 -0
  36. package/template/scripts/run-migrations.ts +105 -0
  37. package/template/scripts/setup-onecli-secrets.sh +252 -0
  38. package/template/setup-agents.sh +142 -0
  39. package/template/src/channels/index.ts +13 -0
  40. package/template/src/channels/registry.test.ts +42 -0
  41. package/template/src/channels/registry.ts +28 -0
  42. package/template/src/channels/slack.test.ts +859 -0
  43. package/template/src/channels/slack.ts +373 -0
  44. package/template/src/claw-skill.test.ts +45 -0
  45. package/template/src/config.ts +94 -0
  46. package/template/src/container-runner.test.ts +221 -0
  47. package/template/src/container-runner.ts +1029 -0
  48. package/template/src/container-runtime.test.ts +149 -0
  49. package/template/src/container-runtime.ts +124 -0
  50. package/template/src/db-migration.test.ts +67 -0
  51. package/template/src/db.test.ts +484 -0
  52. package/template/src/db.ts +837 -0
  53. package/template/src/env.ts +42 -0
  54. package/template/src/formatting.test.ts +294 -0
  55. package/template/src/github-token.ts +48 -0
  56. package/template/src/google-token.ts +75 -0
  57. package/template/src/group-folder.test.ts +43 -0
  58. package/template/src/group-folder.ts +44 -0
  59. package/template/src/group-queue.test.ts +484 -0
  60. package/template/src/group-queue.ts +363 -0
  61. package/template/src/http-server.ts +343 -0
  62. package/template/src/index.ts +960 -0
  63. package/template/src/ipc-auth.test.ts +679 -0
  64. package/template/src/ipc.ts +548 -0
  65. package/template/src/logger.ts +16 -0
  66. package/template/src/mount-security.ts +421 -0
  67. package/template/src/network-policy.ts +119 -0
  68. package/template/src/remote-control.test.ts +397 -0
  69. package/template/src/remote-control.ts +224 -0
  70. package/template/src/router.ts +52 -0
  71. package/template/src/routing.test.ts +170 -0
  72. package/template/src/sender-allowlist.test.ts +216 -0
  73. package/template/src/sender-allowlist.ts +128 -0
  74. package/template/src/task-scheduler.test.ts +129 -0
  75. package/template/src/task-scheduler.ts +290 -0
  76. package/template/src/timezone.test.ts +73 -0
  77. package/template/src/timezone.ts +37 -0
  78. package/template/src/types.ts +114 -0
  79. package/template/src/worktree.ts +206 -0
  80. package/template/tsconfig.json +20 -0
@@ -0,0 +1,363 @@
1
+ import { ChildProcess } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ import { BASE_RETRY_MS, DATA_DIR, MAX_CONCURRENT_CONTAINERS, MAX_RETRIES } from './config.js';
6
+ import { logger } from './logger.js';
7
+
8
+ interface QueuedTask {
9
+ id: string;
10
+ groupJid: string;
11
+ fn: () => Promise<void>;
12
+ }
13
+
14
+
15
+ interface GroupState {
16
+ active: boolean;
17
+ idleWaiting: boolean;
18
+ isTaskContainer: boolean;
19
+ runningTaskId: string | null;
20
+ pendingMessages: boolean;
21
+ pendingTasks: QueuedTask[];
22
+ process: ChildProcess | null;
23
+ containerName: string | null;
24
+ groupFolder: string | null;
25
+ retryCount: number;
26
+ }
27
+
28
+ export class GroupQueue {
29
+ private groups = new Map<string, GroupState>();
30
+ private activeCount = 0;
31
+ private waitingGroups: string[] = [];
32
+ private processMessagesFn: ((groupJid: string) => Promise<boolean>) | null =
33
+ null;
34
+ private shuttingDown = false;
35
+
36
+ private getGroup(groupJid: string): GroupState {
37
+ let state = this.groups.get(groupJid);
38
+ if (!state) {
39
+ state = {
40
+ active: false,
41
+ idleWaiting: false,
42
+ isTaskContainer: false,
43
+ runningTaskId: null,
44
+ pendingMessages: false,
45
+ pendingTasks: [],
46
+ process: null,
47
+ containerName: null,
48
+ groupFolder: null,
49
+ retryCount: 0,
50
+ };
51
+ this.groups.set(groupJid, state);
52
+ }
53
+ return state;
54
+ }
55
+
56
+ setProcessMessagesFn(fn: (groupJid: string) => Promise<boolean>): void {
57
+ this.processMessagesFn = fn;
58
+ }
59
+
60
+ enqueueMessageCheck(groupJid: string): void {
61
+ if (this.shuttingDown) return;
62
+
63
+ const state = this.getGroup(groupJid);
64
+
65
+ if (state.active) {
66
+ state.pendingMessages = true;
67
+ logger.debug({ groupJid }, 'Container active, message queued');
68
+ return;
69
+ }
70
+
71
+ if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
72
+ state.pendingMessages = true;
73
+ if (!this.waitingGroups.includes(groupJid)) {
74
+ this.waitingGroups.push(groupJid);
75
+ }
76
+ logger.debug(
77
+ { groupJid, activeCount: this.activeCount },
78
+ 'At concurrency limit, message queued',
79
+ );
80
+ return;
81
+ }
82
+
83
+ this.runForGroup(groupJid, 'messages').catch((err) =>
84
+ logger.error({ groupJid, err }, 'Unhandled error in runForGroup'),
85
+ );
86
+ }
87
+
88
+ enqueueTask(groupJid: string, taskId: string, fn: () => Promise<void>): void {
89
+ if (this.shuttingDown) return;
90
+
91
+ const state = this.getGroup(groupJid);
92
+
93
+ // Prevent double-queuing: check both pending and currently-running task
94
+ if (state.runningTaskId === taskId) {
95
+ logger.debug({ groupJid, taskId }, 'Task already running, skipping');
96
+ return;
97
+ }
98
+ if (state.pendingTasks.some((t) => t.id === taskId)) {
99
+ logger.debug({ groupJid, taskId }, 'Task already queued, skipping');
100
+ return;
101
+ }
102
+
103
+ if (state.active) {
104
+ state.pendingTasks.push({ id: taskId, groupJid, fn });
105
+ if (state.idleWaiting) {
106
+ this.closeStdin(groupJid);
107
+ }
108
+ logger.debug({ groupJid, taskId }, 'Container active, task queued');
109
+ return;
110
+ }
111
+
112
+ if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
113
+ state.pendingTasks.push({ id: taskId, groupJid, fn });
114
+ if (!this.waitingGroups.includes(groupJid)) {
115
+ this.waitingGroups.push(groupJid);
116
+ }
117
+ logger.debug(
118
+ { groupJid, taskId, activeCount: this.activeCount },
119
+ 'At concurrency limit, task queued',
120
+ );
121
+ return;
122
+ }
123
+
124
+ // Run immediately
125
+ this.runTask(groupJid, { id: taskId, groupJid, fn }).catch((err) =>
126
+ logger.error({ groupJid, taskId, err }, 'Unhandled error in runTask'),
127
+ );
128
+ }
129
+
130
+ registerProcess(
131
+ groupJid: string,
132
+ proc: ChildProcess,
133
+ containerName: string,
134
+ groupFolder?: string,
135
+ ): void {
136
+ const state = this.getGroup(groupJid);
137
+ state.process = proc;
138
+ state.containerName = containerName;
139
+ if (groupFolder) state.groupFolder = groupFolder;
140
+ }
141
+
142
+ /**
143
+ * Mark the container as idle-waiting (finished work, waiting for IPC input).
144
+ * If tasks are pending, preempt the idle container immediately.
145
+ */
146
+ notifyIdle(groupJid: string): void {
147
+ const state = this.getGroup(groupJid);
148
+ state.idleWaiting = true;
149
+ if (state.pendingTasks.length > 0) {
150
+ this.closeStdin(groupJid);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Send a follow-up message to the active container via IPC file.
156
+ * Returns true if the message was written, false if no active container.
157
+ */
158
+ sendMessage(groupJid: string, text: string): boolean {
159
+ const state = this.getGroup(groupJid);
160
+ if (!state.active || !state.groupFolder || state.isTaskContainer)
161
+ return false;
162
+ state.idleWaiting = false; // Agent is about to receive work, no longer idle
163
+
164
+ const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
165
+ try {
166
+ fs.mkdirSync(inputDir, { recursive: true });
167
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
168
+ const filepath = path.join(inputDir, filename);
169
+ const tempPath = `${filepath}.tmp`;
170
+ fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
171
+ fs.renameSync(tempPath, filepath);
172
+ return true;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Signal the active container to wind down by writing a close sentinel.
180
+ */
181
+ closeStdin(groupJid: string): void {
182
+ const state = this.getGroup(groupJid);
183
+ if (!state.active || !state.groupFolder) return;
184
+
185
+ const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
186
+ try {
187
+ fs.mkdirSync(inputDir, { recursive: true });
188
+ fs.writeFileSync(path.join(inputDir, '_close'), '');
189
+ } catch {
190
+ // ignore
191
+ }
192
+ }
193
+
194
+ private async runForGroup(
195
+ groupJid: string,
196
+ reason: 'messages' | 'drain',
197
+ ): Promise<void> {
198
+ const state = this.getGroup(groupJid);
199
+ state.active = true;
200
+ state.idleWaiting = false;
201
+ state.isTaskContainer = false;
202
+ state.pendingMessages = false;
203
+ this.activeCount++;
204
+
205
+ logger.debug(
206
+ { groupJid, reason, activeCount: this.activeCount },
207
+ 'Starting container for group',
208
+ );
209
+
210
+ try {
211
+ if (this.processMessagesFn) {
212
+ const success = await this.processMessagesFn(groupJid);
213
+ if (success) {
214
+ state.retryCount = 0;
215
+ } else {
216
+ this.scheduleRetry(groupJid, state);
217
+ }
218
+ }
219
+ } catch (err) {
220
+ logger.error({ groupJid, err }, 'Error processing messages for group');
221
+ this.scheduleRetry(groupJid, state);
222
+ } finally {
223
+ state.active = false;
224
+ state.process = null;
225
+ state.containerName = null;
226
+ state.groupFolder = null;
227
+ this.activeCount--;
228
+ this.drainGroup(groupJid);
229
+ }
230
+ }
231
+
232
+ private async runTask(groupJid: string, task: QueuedTask): Promise<void> {
233
+ const state = this.getGroup(groupJid);
234
+ state.active = true;
235
+ state.idleWaiting = false;
236
+ state.isTaskContainer = true;
237
+ state.runningTaskId = task.id;
238
+ this.activeCount++;
239
+
240
+ logger.debug(
241
+ { groupJid, taskId: task.id, activeCount: this.activeCount },
242
+ 'Running queued task',
243
+ );
244
+
245
+ try {
246
+ await task.fn();
247
+ } catch (err) {
248
+ logger.error({ groupJid, taskId: task.id, err }, 'Error running task');
249
+ } finally {
250
+ state.active = false;
251
+ state.isTaskContainer = false;
252
+ state.runningTaskId = null;
253
+ state.process = null;
254
+ state.containerName = null;
255
+ state.groupFolder = null;
256
+ this.activeCount--;
257
+ this.drainGroup(groupJid);
258
+ }
259
+ }
260
+
261
+ private scheduleRetry(groupJid: string, state: GroupState): void {
262
+ state.retryCount++;
263
+ if (state.retryCount > MAX_RETRIES) {
264
+ logger.error(
265
+ { groupJid, retryCount: state.retryCount },
266
+ 'Max retries exceeded, dropping messages (will retry on next incoming message)',
267
+ );
268
+ state.retryCount = 0;
269
+ return;
270
+ }
271
+
272
+ const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
273
+ logger.info(
274
+ { groupJid, retryCount: state.retryCount, delayMs },
275
+ 'Scheduling retry with backoff',
276
+ );
277
+ setTimeout(() => {
278
+ if (!this.shuttingDown) {
279
+ this.enqueueMessageCheck(groupJid);
280
+ }
281
+ }, delayMs);
282
+ }
283
+
284
+ private drainGroup(groupJid: string): void {
285
+ if (this.shuttingDown) return;
286
+
287
+ const state = this.getGroup(groupJid);
288
+
289
+ // Tasks first (they won't be re-discovered from SQLite like messages)
290
+ if (state.pendingTasks.length > 0) {
291
+ const task = state.pendingTasks.shift()!;
292
+ this.runTask(groupJid, task).catch((err) =>
293
+ logger.error(
294
+ { groupJid, taskId: task.id, err },
295
+ 'Unhandled error in runTask (drain)',
296
+ ),
297
+ );
298
+ return;
299
+ }
300
+
301
+ // Then pending messages
302
+ if (state.pendingMessages) {
303
+ this.runForGroup(groupJid, 'drain').catch((err) =>
304
+ logger.error(
305
+ { groupJid, err },
306
+ 'Unhandled error in runForGroup (drain)',
307
+ ),
308
+ );
309
+ return;
310
+ }
311
+
312
+ // Nothing pending for this group; check if other groups are waiting for a slot
313
+ this.drainWaiting();
314
+ }
315
+
316
+ private drainWaiting(): void {
317
+ while (
318
+ this.waitingGroups.length > 0 &&
319
+ this.activeCount < MAX_CONCURRENT_CONTAINERS
320
+ ) {
321
+ const nextJid = this.waitingGroups.shift()!;
322
+ const state = this.getGroup(nextJid);
323
+
324
+ // Prioritize tasks over messages
325
+ if (state.pendingTasks.length > 0) {
326
+ const task = state.pendingTasks.shift()!;
327
+ this.runTask(nextJid, task).catch((err) =>
328
+ logger.error(
329
+ { groupJid: nextJid, taskId: task.id, err },
330
+ 'Unhandled error in runTask (waiting)',
331
+ ),
332
+ );
333
+ } else if (state.pendingMessages) {
334
+ this.runForGroup(nextJid, 'drain').catch((err) =>
335
+ logger.error(
336
+ { groupJid: nextJid, err },
337
+ 'Unhandled error in runForGroup (waiting)',
338
+ ),
339
+ );
340
+ }
341
+ // If neither pending, skip this group
342
+ }
343
+ }
344
+
345
+ async shutdown(_gracePeriodMs: number): Promise<void> {
346
+ this.shuttingDown = true;
347
+
348
+ // Count active containers but don't kill them — they'll finish on their own
349
+ // via idle timeout or container timeout. The --rm flag cleans them up on exit.
350
+ // This prevents WhatsApp reconnection restarts from killing working agents.
351
+ const activeContainers: string[] = [];
352
+ for (const [_jid, state] of this.groups) {
353
+ if (state.process && !state.process.killed && state.containerName) {
354
+ activeContainers.push(state.containerName);
355
+ }
356
+ }
357
+
358
+ logger.info(
359
+ { activeCount: this.activeCount, detachedContainers: activeContainers },
360
+ 'GroupQueue shutting down (containers detached, not killed)',
361
+ );
362
+ }
363
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * HTTP Server for NanoClaw
3
+ *
4
+ * Receives questionnaire questions via POST and routes them
5
+ * to the questionnaire-assistant OpenShell sandbox agent.
6
+ * Runs alongside the existing Slack connection.
7
+ */
8
+ import http from 'http';
9
+
10
+ import { findExactMatch, saveQuestionnaireMetric } from './db.js';
11
+ import { readEnvFile } from './env.js';
12
+ import { logger } from './logger.js';
13
+ import { runContainerAgent, ContainerOutput } from './container-runner.js';
14
+ import type { RegisteredGroup } from './types.js';
15
+
16
+ // ─── Concurrency limiter for sandbox execs ───────────────────────────────────
17
+ const MAX_CONCURRENT_EXECS = 10;
18
+ let activeExecs = 0;
19
+ const execQueue: Array<() => void> = [];
20
+
21
+ function acquireExecSlot(): Promise<void> {
22
+ if (activeExecs < MAX_CONCURRENT_EXECS) {
23
+ activeExecs++;
24
+ return Promise.resolve();
25
+ }
26
+ return new Promise((resolve) => {
27
+ execQueue.push(() => {
28
+ activeExecs++;
29
+ resolve();
30
+ });
31
+ });
32
+ }
33
+
34
+ function releaseExecSlot(): void {
35
+ activeExecs--;
36
+ const next = execQueue.shift();
37
+ if (next) next();
38
+ }
39
+
40
+ // ─── Types ──────────────────────────────────────────────────────────────────
41
+
42
+ interface QuestionnaireRequest {
43
+ question: string;
44
+ systemPrompt: string | null;
45
+ userEmail: string;
46
+ documentUrl: string;
47
+ documentId: string;
48
+ documentName: string;
49
+ }
50
+
51
+ interface Citation {
52
+ source: string;
53
+ title: string;
54
+ url: string;
55
+ answer_source: string;
56
+ }
57
+
58
+ interface QuestionnaireResponse {
59
+ answer: string;
60
+ citations: Citation[];
61
+ answer_source: string;
62
+ confidence: string;
63
+ }
64
+
65
+ // ─── Helpers ────────────────────────────────────────────────────────────────
66
+
67
+ function jsonResponse(
68
+ res: http.ServerResponse,
69
+ statusCode: number,
70
+ body: unknown,
71
+ ): void {
72
+ const json = JSON.stringify(body);
73
+ res.writeHead(statusCode, {
74
+ 'Content-Type': 'application/json',
75
+ 'Access-Control-Allow-Origin': '*',
76
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
77
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
78
+ });
79
+ res.end(json);
80
+ }
81
+
82
+ function readBody(req: http.IncomingMessage): Promise<string> {
83
+ return new Promise((resolve, reject) => {
84
+ const chunks: Buffer[] = [];
85
+ let size = 0;
86
+ const MAX_BODY = 1_048_576; // 1 MB
87
+
88
+ req.on('data', (chunk: Buffer) => {
89
+ size += chunk.length;
90
+ if (size > MAX_BODY) {
91
+ req.destroy();
92
+ reject(new Error('Request body too large'));
93
+ return;
94
+ }
95
+ chunks.push(chunk);
96
+ });
97
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
98
+ req.on('error', reject);
99
+ });
100
+ }
101
+
102
+ // ─── Route handlers ─────────────────────────────────────────────────────────
103
+
104
+ function handleStatus(
105
+ _req: http.IncomingMessage,
106
+ res: http.ServerResponse,
107
+ ): void {
108
+ jsonResponse(res, 200, { status: 'ok', uptime: process.uptime() });
109
+ }
110
+
111
+ async function handleQuestionnaireAnswer(
112
+ req: http.IncomingMessage,
113
+ res: http.ServerResponse,
114
+ authToken: string,
115
+ registeredGroups: () => Record<string, RegisteredGroup>,
116
+ ): Promise<void> {
117
+ // ── Auth ────────────────────────────────────────────────────────────────
118
+ const authHeader = req.headers.authorization;
119
+ if (!authHeader || authHeader !== `Bearer ${authToken}`) {
120
+ jsonResponse(res, 401, { error: 'Unauthorized' });
121
+ return;
122
+ }
123
+
124
+ // ── Parse body ─────────────────────────────────────────────────────────
125
+ let body: QuestionnaireRequest;
126
+ try {
127
+ const raw = await readBody(req);
128
+ body = JSON.parse(raw) as QuestionnaireRequest;
129
+ } catch (err) {
130
+ jsonResponse(res, 400, {
131
+ error: 'Invalid JSON body',
132
+ detail: err instanceof Error ? err.message : String(err),
133
+ });
134
+ return;
135
+ }
136
+
137
+ const { question, systemPrompt, userEmail, documentUrl, documentId, documentName } =
138
+ body;
139
+
140
+ if (!question || typeof question !== 'string') {
141
+ jsonResponse(res, 400, { error: 'Missing required field: question' });
142
+ return;
143
+ }
144
+
145
+ const startTime = Date.now();
146
+
147
+ // ── Exact-match cache ──────────────────────────────────────────────────
148
+ const cached = findExactMatch(question);
149
+ if (cached) {
150
+ logger.info({ question: question.slice(0, 80) }, 'Questionnaire cache hit');
151
+ jsonResponse(res, 200, cached);
152
+ return;
153
+ }
154
+
155
+ // ── Find the questionnaire-assistant group ─────────────────────────────
156
+ const groups = registeredGroups();
157
+ let group: RegisteredGroup | undefined;
158
+ for (const g of Object.values(groups)) {
159
+ if (g.folder === 'questionnaire-assistant') {
160
+ group = g;
161
+ break;
162
+ }
163
+ }
164
+
165
+ if (!group) {
166
+ jsonResponse(res, 503, {
167
+ error: 'questionnaire-assistant agent is not registered',
168
+ });
169
+ return;
170
+ }
171
+
172
+ // ── Run the agent (with concurrency limit) ─────────────────────────────
173
+ const prompt = `<question>${question}</question>\n<system_prompt>${systemPrompt || ''}</system_prompt>\n<user_email>${userEmail}</user_email>`;
174
+
175
+ // Wait for an exec slot
176
+ await acquireExecSlot();
177
+ logger.info(
178
+ { question: question.slice(0, 60), activeExecs, queued: execQueue.length },
179
+ 'Exec slot acquired',
180
+ );
181
+
182
+ // Use a Promise that resolves when onOutput fires — don't wait for container exit.
183
+ // Same pattern as Slack: respond immediately when the agent produces output.
184
+ try {
185
+ const resultText = await new Promise<string>((resolve, reject) => {
186
+ const timeout = setTimeout(() => {
187
+ reject(new Error('Agent timed out after 5 minutes'));
188
+ }, 300_000);
189
+
190
+ runContainerAgent(
191
+ group,
192
+ {
193
+ prompt,
194
+ groupFolder: 'questionnaire-assistant',
195
+ chatJid: 'http:questionnaire',
196
+ isMain: false,
197
+ assistantName: 'Questionnaire Assistant',
198
+ },
199
+ () => {}, // onProcess — not needed for HTTP
200
+ async (streamedOutput) => {
201
+ // First non-empty result resolves the promise immediately
202
+ if (streamedOutput.result) {
203
+ clearTimeout(timeout);
204
+ resolve(streamedOutput.result);
205
+ }
206
+ },
207
+ ).catch((err) => {
208
+ clearTimeout(timeout);
209
+ reject(err);
210
+ });
211
+ });
212
+
213
+ // ── Parse agent response ───────────────────────────────────────────────
214
+ let parsed: QuestionnaireResponse;
215
+ try {
216
+ parsed = JSON.parse(resultText.trim());
217
+ } catch {
218
+ try {
219
+ const jsonMatch = resultText.match(/\{[\s\S]*"answer"\s*:\s*"[\s\S]*\}/);
220
+ if (jsonMatch) {
221
+ parsed = JSON.parse(jsonMatch[0]);
222
+ } else {
223
+ throw new Error('No JSON found');
224
+ }
225
+ } catch {
226
+ parsed = {
227
+ answer: resultText,
228
+ citations: [],
229
+ answer_source: 'ai',
230
+ confidence: 'low',
231
+ };
232
+ }
233
+ }
234
+
235
+ const responseTimeMs = Date.now() - startTime;
236
+
237
+ saveQuestionnaireMetric({
238
+ user_email: userEmail || null,
239
+ document_url: documentUrl || null,
240
+ document_id: documentId || null,
241
+ question,
242
+ answer: parsed.answer,
243
+ response_time_ms: responseTimeMs,
244
+ was_answered: parsed.answer ? 1 : 0,
245
+ answer_reference: parsed.answer_source || null,
246
+ category: ((parsed as unknown as Record<string, unknown>).category as string) || null,
247
+ confidence: parsed.confidence || null,
248
+ });
249
+
250
+ logger.info(
251
+ {
252
+ question: question.slice(0, 80),
253
+ responseTimeMs,
254
+ confidence: parsed.confidence,
255
+ },
256
+ 'Questionnaire answered',
257
+ );
258
+
259
+ jsonResponse(res, 200, {
260
+ answer: parsed.answer,
261
+ citations: parsed.citations || [],
262
+ answer_source: parsed.answer_source || 'ai',
263
+ confidence: parsed.confidence || 'low',
264
+ });
265
+ } catch (err) {
266
+ const responseTimeMs = Date.now() - startTime;
267
+ logger.error(
268
+ { err, question: question.slice(0, 80), responseTimeMs },
269
+ 'Questionnaire request failed',
270
+ );
271
+ jsonResponse(res, 500, {
272
+ error: 'Internal server error',
273
+ detail: err instanceof Error ? err.message : String(err),
274
+ });
275
+ } finally {
276
+ releaseExecSlot();
277
+ }
278
+ }
279
+
280
+ // ─── Server ─────────────────────────────────────────────────────────────────
281
+
282
+ export function startHttpServer(opts: {
283
+ registeredGroups: () => Record<string, RegisteredGroup>;
284
+ }): void {
285
+ const envConfig = readEnvFile(['API_AUTH_TOKEN', 'HTTP_PORT']);
286
+ const authToken = envConfig.API_AUTH_TOKEN;
287
+ const port = parseInt(envConfig.HTTP_PORT || '3000', 10);
288
+
289
+ if (!authToken) {
290
+ logger.warn(
291
+ 'API_AUTH_TOKEN not set — HTTP server will reject all authenticated requests',
292
+ );
293
+ }
294
+
295
+ const server = http.createServer((req, res) => {
296
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
297
+ const method = req.method?.toUpperCase() || 'GET';
298
+
299
+ // CORS preflight
300
+ if (method === 'OPTIONS') {
301
+ res.writeHead(204, {
302
+ 'Access-Control-Allow-Origin': '*',
303
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
304
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
305
+ 'Access-Control-Max-Age': '86400',
306
+ });
307
+ res.end();
308
+ return;
309
+ }
310
+
311
+ // Route dispatch
312
+ if (url.pathname === '/status' && method === 'GET') {
313
+ handleStatus(req, res);
314
+ return;
315
+ }
316
+
317
+ if (url.pathname === '/api/questionnaire/answer' && method === 'POST') {
318
+ handleQuestionnaireAnswer(
319
+ req,
320
+ res,
321
+ authToken || '',
322
+ opts.registeredGroups,
323
+ ).catch((err) => {
324
+ logger.error({ err }, 'Unhandled error in questionnaire handler');
325
+ if (!res.headersSent) {
326
+ jsonResponse(res, 500, { error: 'Internal server error' });
327
+ }
328
+ });
329
+ return;
330
+ }
331
+
332
+ // 404 for everything else
333
+ jsonResponse(res, 404, { error: 'Not found' });
334
+ });
335
+
336
+ server.listen(port, () => {
337
+ logger.info({ port }, 'HTTP server listening');
338
+ });
339
+
340
+ server.on('error', (err) => {
341
+ logger.error({ err, port }, 'HTTP server error');
342
+ });
343
+ }