@maintainer-pro/ai-server 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -23,7 +23,8 @@ Requires **Node.js 22+**, [`@maintainer-pro/ai-ui`](https://www.npmjs.com/packag
23
23
  | Path | Purpose |
24
24
  |------|---------|
25
25
  | `GET` / `POST /api/chat` | Chat (agent + Maintainer Pro store) |
26
- | `GET /embed-config.js` | Client keys for the widget (`window.__MAINTAINER_PRO__`) |
26
+ | `WS /api/ws` (also `/ws`) | Live AI events (`subscribe`, `chat.run`, `message.created`) |
27
+ | `GET /embed-config.js` | `window.__MAINTAINER_PRO__` — `aiServerUrl`, `apiUrl`, `aiServerWsUrl`, client keys |
27
28
  | `GET /ai-ui.iife.js` | Embed widget from `node_modules/@maintainer-pro/ai-ui` (unless a file exists in `AI_SERVER_UI`) |
28
29
  | other paths | Static files from `AI_SERVER_UI` |
29
30
 
@@ -41,6 +42,7 @@ A copied `ai-ui.iife.js` in the UI folder is served first and will not update wh
41
42
  | `MAINTAINER_PRO_URL` | Admin base URL |
42
43
  | `MAINTAINER_PRO_API_KEY` | Server API key |
43
44
  | `MAINTAINER_PRO_CLIENT_API_KEY` | Client key (served at `/embed-config.js`) |
45
+ | `AI_SERVER_URL` | Public chat origin (e.g. `http://localhost:3100`). Included in `/embed-config.js` as `aiServerUrl` / `apiUrl`. If unset, derived from the request Host. |
44
46
  | `AI_CLI_PROVIDER` | `auto` \| `claude` \| `cursor` \| `antigravity` |
45
47
  | `AI_SERVER_PRODUCT_DESCRIPTION` | Optional product text for the agent prompt |
46
48
 
@@ -54,4 +56,3 @@ npx --yes @maintainer-pro/ai-server --port 3100 --ui . --workspace .
54
56
  |---------|------|
55
57
  | [`@maintainer-pro/ai-cli`](https://www.npmjs.com/package/@maintainer-pro/ai-cli) | Talks to coding agents |
56
58
  | [`@maintainer-pro/ai-ui`](https://www.npmjs.com/package/@maintainer-pro/ai-ui) | Embed widget (`/ai-ui.iife.js`) |
57
- | [`@maintainer-pro/setup`](https://www.npmjs.com/package/@maintainer-pro/setup) | Download the sandbox setup guide |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maintainer-pro/ai-server",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Node HTTP server for Maintainer Pro chat. Uses @maintainer-pro/ai-cli to talk to coding agents.",
5
5
  "keywords": [
6
6
  "maintainer-pro",
@@ -26,7 +26,8 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@maintainer-pro/ai-cli": "^0.1.2"
29
+ "@maintainer-pro/ai-cli": "^0.1.5",
30
+ "ws": "^8.21.3"
30
31
  },
31
32
  "engines": {
32
33
  "node": ">=22"
package/src/server.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Config from .env (or --env-file). CLI flags override env.
7
7
  */
8
+ import { randomUUID } from "node:crypto";
8
9
  import fs from "node:fs";
9
10
  import http from "node:http";
10
11
  import path from "node:path";
@@ -12,14 +13,12 @@ import { fileURLToPath } from "node:url";
12
13
  import {
13
14
  createChatHandler,
14
15
  createDefaultSystemPrompt,
16
+ createLogger,
15
17
  createMaintainerProStoreFromEnv,
18
+ parseIgnorePathsEnv,
19
+ resolveIgnorePaths,
16
20
  } from "@maintainer-pro/ai-cli";
17
-
18
- const log = (msg) => console.log(`[ai-server] ${msg}`);
19
- const fail = (msg) => {
20
- console.error(`[ai-server] ${msg}`);
21
- process.exit(1);
22
- };
21
+ import { attachChatWebSocket } from "./ws.mjs";
23
22
 
24
23
  function parseArgs(argv) {
25
24
  /** @type {Record<string, string | boolean>} */
@@ -59,7 +58,8 @@ function loadEnvFile(file) {
59
58
  }
60
59
  }
61
60
 
62
- function printHelp() {
61
+ const args = parseArgs(process.argv.slice(2));
62
+ if (args.help) {
63
63
  console.log(`Maintainer Pro AI server
64
64
 
65
65
  Usage:
@@ -73,9 +73,32 @@ Flags override .env. Preferred config is env (from the admin setup guide).
73
73
  --workspace <dir> Agent edit root (AI_CLI_WORKSPACE)
74
74
  --env-file <path> Env file (default: ./.env)
75
75
  --cors-origin <url> CORS origin (CORS_ORIGIN)
76
+
77
+ Logging (env):
78
+ LOG_LEVEL=info|debug|warn|error|silent
79
+ AI_LOG_LEVEL=… alias for LOG_LEVEL
80
+ NODE_ENV=development defaults to debug logs (auto if unset)
81
+ LOG_PRETTY=0 disable pretty TTY output
76
82
  `);
83
+ process.exit(0);
77
84
  }
78
85
 
86
+ const envFile = path.resolve(
87
+ typeof args.envFile === "string" ? args.envFile : path.join(process.cwd(), ".env")
88
+ );
89
+ loadEnvFile(envFile);
90
+
91
+ // Local sidecar defaults to development (debug logs) unless explicitly set.
92
+ if (!process.env.NODE_ENV?.trim()) {
93
+ process.env.NODE_ENV = "development";
94
+ }
95
+
96
+ const logger = createLogger("ai-server");
97
+ const fail = (msg) => {
98
+ logger.fatal(msg);
99
+ process.exit(1);
100
+ };
101
+
79
102
  function contentType(file) {
80
103
  if (file.endsWith(".html")) return "text/html; charset=utf-8";
81
104
  if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
@@ -138,17 +161,12 @@ async function send(res, response) {
138
161
  res.end(Buffer.from(await response.arrayBuffer()));
139
162
  }
140
163
 
141
- const args = parseArgs(process.argv.slice(2));
142
- if (args.help) {
143
- printHelp();
144
- process.exit(0);
164
+ function toHttpWsUrl(httpUrl) {
165
+ const u = new URL(httpUrl);
166
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
167
+ return u.toString().replace(/\/$/, "");
145
168
  }
146
169
 
147
- const envFile = path.resolve(
148
- typeof args.envFile === "string" ? args.envFile : path.join(process.cwd(), ".env")
149
- );
150
- loadEnvFile(envFile);
151
-
152
170
  const port = Number(args.port || process.env.PORT || 3000);
153
171
  const workspaceDir = path.resolve(
154
172
  args.workspace || process.env.AI_CLI_WORKSPACE || process.cwd()
@@ -162,7 +180,22 @@ const corsOrigin =
162
180
 
163
181
  process.env.AI_CLI_WORKSPACE = workspaceDir;
164
182
 
165
- const db = createMaintainerProStoreFromEnv();
183
+ const runningTurns = new Set();
184
+ /** One active AI turn per conversation — prevents raced double promotes. */
185
+ const runningByConversation = new Map();
186
+ /** Turns that arrived while the conversation was busy — run after unlock. */
187
+ const deferredTurns = new Map();
188
+ /** @type {{ publish: (conversationId: string, payload: Record<string, unknown>) => void; close: () => void }} */
189
+ let hub = { publish() {}, close() {} };
190
+ /** @type {(event: { conversationId: string; message: { id?: string; content?: string; senderType?: string | null; senderName?: string | null } }) => Promise<void>} */
191
+ let runWorkingTurn = async () => {};
192
+
193
+ const db = createMaintainerProStoreFromEnv({
194
+ logger,
195
+ onWorkingTurn: (event) => {
196
+ void runWorkingTurn(event);
197
+ },
198
+ });
166
199
  if (!db) {
167
200
  fail(
168
201
  `Set MAINTAINER_PRO_URL and MAINTAINER_PRO_API_KEY (env file: ${envFile}).`
@@ -173,14 +206,255 @@ const productDescription =
173
206
  process.env.AI_SERVER_PRODUCT_DESCRIPTION?.trim() ||
174
207
  "This product uses Maintainer Pro. When the user asks for a change, edit source files in the workspace using the coding agent CLI. Do not invent JSON tool calls.";
175
208
 
209
+ const ignorePaths = resolveIgnorePaths(
210
+ parseIgnorePathsEnv(process.env.AI_CLI_IGNORE_PATHS)
211
+ );
212
+
176
213
  const handlers = createChatHandler({
177
- systemPrompt: createDefaultSystemPrompt({ productDescription }),
214
+ systemPrompt: createDefaultSystemPrompt({ productDescription, ignorePaths }),
178
215
  workspaceDir,
179
216
  db,
217
+ logger,
180
218
  });
181
219
 
220
+ async function historyForTurn(conversationId, userMessage, userMessageId) {
221
+ const history = db.listMessages ? await db.listMessages(conversationId) : [];
222
+ let rows = history.filter(
223
+ (row) =>
224
+ (row.role === "user" || row.role === "assistant") &&
225
+ row.provider !== "working" &&
226
+ Boolean(row.content?.trim())
227
+ );
228
+ // Exclude later queued user turns so the model answers the current working
229
+ // message, not messages still waiting behind it.
230
+ if (userMessageId) {
231
+ const end = rows.findIndex((row) => row.id === userMessageId);
232
+ if (end >= 0) {
233
+ rows = rows.slice(0, end + 1);
234
+ } else {
235
+ rows = rows.filter(
236
+ (row) =>
237
+ row.queueStatus !== "queued" ||
238
+ row.content === userMessage
239
+ );
240
+ }
241
+ } else {
242
+ rows = rows.filter((row) => row.queueStatus !== "queued");
243
+ }
244
+ const messages = rows.map((row) => ({ role: row.role, content: row.content }));
245
+ if (
246
+ userMessage &&
247
+ !messages.some(
248
+ (row) => row.role === "user" && row.content === userMessage
249
+ )
250
+ ) {
251
+ messages.push({ role: "user", content: userMessage });
252
+ }
253
+ return messages;
254
+ }
255
+
256
+ async function runChatPost(body) {
257
+ logger.debug(
258
+ {
259
+ conversationId: body.conversationId,
260
+ skipPersistUser: body.skipPersistUser,
261
+ userMessageId: body.userMessageId,
262
+ messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
263
+ },
264
+ "runChatPost"
265
+ );
266
+ const response = await handlers.POST(
267
+ new Request("http://127.0.0.1/api/chat", {
268
+ method: "POST",
269
+ headers: { "content-type": "application/json" },
270
+ body: JSON.stringify(body),
271
+ })
272
+ );
273
+ const text = await response.text();
274
+ let data = {};
275
+ try {
276
+ data = text ? JSON.parse(text) : {};
277
+ } catch {
278
+ data = { error: text || "invalid chat response" };
279
+ }
280
+ logger.debug(
281
+ { conversationId: body.conversationId, ok: response.ok, status: response.status },
282
+ "runChatPost done"
283
+ );
284
+ return { ok: response.ok, status: response.status, data };
285
+ }
286
+
287
+ function publishChatResult(conversationId, result, fallbackMessageId, parentMessageId) {
288
+ if (!conversationId) return;
289
+ if (!result.ok) {
290
+ logger.debug({ conversationId, status: result.status }, "publish chat.run.failed");
291
+ hub.publish(conversationId, {
292
+ type: "chat.run.failed",
293
+ conversationId,
294
+ error: result.data?.error ?? `chat failed (${result.status})`,
295
+ userMessageId: parentMessageId,
296
+ });
297
+ return;
298
+ }
299
+ const reply = typeof result.data?.text === "string" ? result.data.text : "";
300
+ if (!reply) {
301
+ logger.debug({ conversationId }, "skip publish (empty reply)");
302
+ return;
303
+ }
304
+ const messageId =
305
+ typeof result.data?.messageId === "string"
306
+ ? result.data.messageId
307
+ : fallbackMessageId;
308
+ logger.debug(
309
+ { conversationId, messageId, provider: result.data?.provider, chars: reply.length },
310
+ "publish message.created"
311
+ );
312
+ hub.publish(conversationId, {
313
+ type: "message.created",
314
+ conversationId,
315
+ message: {
316
+ id: messageId,
317
+ role: "assistant",
318
+ content: reply,
319
+ senderType: "ai",
320
+ senderName: "AI",
321
+ provider: result.data?.provider ?? null,
322
+ parentMessageId: parentMessageId ?? null,
323
+ },
324
+ });
325
+ }
326
+
327
+ runWorkingTurn = async (event) => {
328
+ const userMessageId = event.message.id;
329
+ const content = event.message.content ?? "";
330
+ const conversationId = event.conversationId;
331
+ if (!userMessageId || !content.trim()) {
332
+ logger.debug({ event }, "skip working turn (empty)");
333
+ return;
334
+ }
335
+ if (runningTurns.has(userMessageId)) {
336
+ logger.debug({ userMessageId }, "skip working turn (already running)");
337
+ return;
338
+ }
339
+ const busy = runningByConversation.get(conversationId);
340
+ if (busy && busy !== userMessageId) {
341
+ deferredTurns.set(conversationId, event);
342
+ logger.info(
343
+ { conversationId, busy, userMessageId },
344
+ "defer working turn (conversation busy)"
345
+ );
346
+ return;
347
+ }
348
+ runningTurns.add(userMessageId);
349
+ runningByConversation.set(conversationId, userMessageId);
350
+ let nextQueued = null;
351
+ try {
352
+ const messages = await historyForTurn(conversationId, content, userMessageId);
353
+ if (!messages.length) {
354
+ messages.push({ role: "user", content });
355
+ }
356
+ logger.info(
357
+ {
358
+ conversationId,
359
+ userMessageId,
360
+ historyLength: messages.length,
361
+ },
362
+ "ai turn"
363
+ );
364
+ hub.publish(conversationId, {
365
+ type: "chat.turn",
366
+ conversationId,
367
+ userMessageId,
368
+ });
369
+ const assistantMessageId = randomUUID();
370
+ const result = await runChatPost({
371
+ conversationId,
372
+ messages,
373
+ userMessage: content,
374
+ skipPersistUser: true,
375
+ userMessageId,
376
+ assistantMessageId,
377
+ senderType: event.message.senderType === "client" ? "client" : undefined,
378
+ senderName: event.message.senderName ?? undefined,
379
+ });
380
+ publishChatResult(conversationId, result, assistantMessageId, userMessageId);
381
+ if (!result.ok) {
382
+ logger.warn({ status: result.status }, "ai turn failed");
383
+ } else if (
384
+ result.data?.nextQueued &&
385
+ typeof result.data.nextQueued.id === "string"
386
+ ) {
387
+ nextQueued = result.data.nextQueued;
388
+ }
389
+ } catch (err) {
390
+ logger.error({ err }, "ai turn failed");
391
+ hub.publish(conversationId, {
392
+ type: "chat.run.failed",
393
+ conversationId,
394
+ error: err instanceof Error ? err.message : String(err),
395
+ userMessageId,
396
+ });
397
+ } finally {
398
+ runningTurns.delete(userMessageId);
399
+ if (runningByConversation.get(conversationId) === userMessageId) {
400
+ runningByConversation.delete(conversationId);
401
+ }
402
+ // Prefer explicit next from complete-turn; else a deferred becameWorking event.
403
+ const deferred = deferredTurns.get(conversationId);
404
+ if (deferred) deferredTurns.delete(conversationId);
405
+ const followUp =
406
+ nextQueued?.id && nextQueued.content?.trim()
407
+ ? {
408
+ conversationId,
409
+ message: {
410
+ id: nextQueued.id,
411
+ content: nextQueued.content,
412
+ senderType: "client",
413
+ },
414
+ }
415
+ : deferred && deferred.message?.id !== userMessageId
416
+ ? deferred
417
+ : null;
418
+ if (followUp) {
419
+ logger.info(
420
+ {
421
+ conversationId,
422
+ from: userMessageId,
423
+ next: followUp.message.id,
424
+ via: nextQueued?.id ? "nextQueued" : "deferred",
425
+ },
426
+ "chain next queued turn"
427
+ );
428
+ queueMicrotask(() => {
429
+ void runWorkingTurn(followUp);
430
+ });
431
+ }
432
+ }
433
+ };
434
+
435
+ function resolveAiServerUrl(req) {
436
+ const fromEnv = (
437
+ process.env.AI_SERVER_URL ||
438
+ process.env.AI_SERVER_BASE_URL ||
439
+ ""
440
+ )
441
+ .trim()
442
+ .replace(/\/$/, "");
443
+ if (fromEnv) return fromEnv;
444
+ const host = req.headers.host?.trim();
445
+ if (host) {
446
+ const xfProto = String(req.headers["x-forwarded-proto"] ?? "")
447
+ .split(",")[0]
448
+ ?.trim();
449
+ const proto = xfProto || "http";
450
+ return `${proto}://${host}`;
451
+ }
452
+ return `http://localhost:${port}`;
453
+ }
454
+
182
455
  const server = http.createServer((req, res) => {
183
456
  const pathname = (req.url ?? "/").split("?")[0];
457
+ logger.debug({ method: req.method, pathname }, "http");
184
458
  res.setHeader("Access-Control-Allow-Origin", corsOrigin);
185
459
  res.setHeader("Access-Control-Allow-Headers", "content-type, authorization");
186
460
  res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
@@ -190,24 +464,61 @@ const server = http.createServer((req, res) => {
190
464
  return;
191
465
  }
192
466
  if (pathname === "/embed-config.js") {
193
- const payload = {
467
+ const aiServerUrl = resolveAiServerUrl(req);
468
+ const payload = {
469
+ aiServerUrl,
470
+ apiUrl: `${aiServerUrl}/api/chat`,
471
+ aiServerWsUrl: `${toHttpWsUrl(aiServerUrl)}/api/ws`,
472
+ debug: true,
473
+ logLevel: "debug",
194
474
  maintainerProUrl: process.env.MAINTAINER_PRO_URL ?? "",
195
475
  maintainerProApiKey:
196
476
  process.env.MAINTAINER_PRO_CLIENT_API_KEY ??
197
477
  process.env.NEXT_PUBLIC_MAINTAINER_PRO_CLIENT_API_KEY ??
198
478
  "",
199
479
  };
480
+ logger.debug({ aiServerUrl }, "embed-config");
200
481
  res.setHeader("Content-Type", "text/javascript; charset=utf-8");
201
482
  res.end(`window.__MAINTAINER_PRO__=${JSON.stringify(payload)};`);
202
483
  return;
203
484
  }
204
485
  if (pathname === "/api/chat") {
486
+ logger.info({ method: req.method }, "http /api/chat");
205
487
  const handler = req.method === "GET" ? handlers.GET : handlers.POST;
206
488
  void toFetchRequest(req)
207
- .then((request) => handler(request))
489
+ .then(async (request) => {
490
+ const response = await handler(request);
491
+ if (req.method === "POST") {
492
+ const clone = response.clone();
493
+ const text = await clone.text();
494
+ let data = {};
495
+ try {
496
+ data = text ? JSON.parse(text) : {};
497
+ } catch {
498
+ data = {};
499
+ }
500
+ const conversationId =
501
+ typeof data.conversationId === "string" ? data.conversationId : "";
502
+ const parentMessageId =
503
+ typeof data.parentMessageId === "string"
504
+ ? data.parentMessageId
505
+ : undefined;
506
+ publishChatResult(
507
+ conversationId,
508
+ {
509
+ ok: response.ok,
510
+ status: response.status,
511
+ data,
512
+ },
513
+ undefined,
514
+ parentMessageId
515
+ );
516
+ }
517
+ return response;
518
+ })
208
519
  .then((response) => send(res, response))
209
520
  .catch((err) => {
210
- console.error(err);
521
+ logger.error({ err }, "chat handler failed");
211
522
  res.statusCode = 500;
212
523
  res.end("Chat handler failed");
213
524
  });
@@ -223,8 +534,256 @@ const server = http.createServer((req, res) => {
223
534
  fs.createReadStream(file).pipe(res);
224
535
  });
225
536
 
226
- server.listen(port, () => {
227
- log(`chat → http://localhost:${port}/api/chat`);
228
- log(`ui → http://localhost:${port}/`);
229
- log(`workspace ${workspaceDir}`);
537
+ hub = attachChatWebSocket(server, {
538
+ logger,
539
+ onChatRun: async (msg) => {
540
+ const conversationId =
541
+ typeof msg.conversationId === "string" ? msg.conversationId : "";
542
+ const userMessage =
543
+ typeof msg.userMessage === "string"
544
+ ? msg.userMessage
545
+ : typeof msg.content === "string"
546
+ ? msg.content
547
+ : "";
548
+ const userMessageId =
549
+ typeof msg.userMessageId === "string" ? msg.userMessageId : undefined;
550
+ logger.info(
551
+ {
552
+ conversationId,
553
+ userMessageId,
554
+ chars: userMessage.length,
555
+ hasMessages: Array.isArray(msg.messages),
556
+ messageCount: Array.isArray(msg.messages) ? msg.messages.length : 0,
557
+ skipPersistUser: msg.skipPersistUser,
558
+ },
559
+ "ws chat.run"
560
+ );
561
+ if (userMessageId && runningTurns.has(userMessageId)) {
562
+ logger.debug({ userMessageId }, "ws chat.run duplicate");
563
+ return { conversationId, accepted: true, duplicate: true };
564
+ }
565
+ if (conversationId) {
566
+ const busy = runningByConversation.get(conversationId);
567
+ if (busy && busy !== userMessageId) {
568
+ if (userMessageId && userMessage.trim()) {
569
+ deferredTurns.set(conversationId, {
570
+ conversationId,
571
+ message: {
572
+ id: userMessageId,
573
+ content: userMessage,
574
+ senderType:
575
+ typeof msg.senderType === "string" ? msg.senderType : "client",
576
+ senderName:
577
+ typeof msg.senderName === "string" ? msg.senderName : null,
578
+ },
579
+ });
580
+ logger.info(
581
+ { conversationId, busy, userMessageId },
582
+ "ws chat.run deferred (conversation busy)"
583
+ );
584
+ return { conversationId, accepted: true, deferred: true };
585
+ }
586
+ logger.warn(
587
+ { conversationId, busy, userMessageId },
588
+ "ws chat.run skipped (conversation busy)"
589
+ );
590
+ return { conversationId, accepted: false, busy: true };
591
+ }
592
+ }
593
+ let messages = Array.isArray(msg.messages)
594
+ ? msg.messages.filter(
595
+ (row) =>
596
+ row &&
597
+ (row.role === "user" || row.role === "assistant") &&
598
+ typeof row.content === "string"
599
+ )
600
+ : [];
601
+ if (!messages.length && conversationId) {
602
+ messages = await historyForTurn(conversationId, userMessage, userMessageId);
603
+ logger.info(
604
+ { conversationId, history: messages.length },
605
+ "ws chat.run loaded history"
606
+ );
607
+ } else if (
608
+ userMessage &&
609
+ !messages.some((row) => row.role === "user" && row.content === userMessage)
610
+ ) {
611
+ messages.push({ role: "user", content: userMessage });
612
+ }
613
+ const assistantMessageId =
614
+ typeof msg.assistantMessageId === "string" && msg.assistantMessageId
615
+ ? msg.assistantMessageId
616
+ : randomUUID();
617
+ if (userMessageId) runningTurns.add(userMessageId);
618
+ if (conversationId && userMessageId) {
619
+ runningByConversation.set(conversationId, userMessageId);
620
+ }
621
+ let nextQueued = null;
622
+ try {
623
+ hub.publish(conversationId, {
624
+ type: "chat.turn",
625
+ conversationId,
626
+ userMessageId,
627
+ });
628
+ const result = await runChatPost({
629
+ conversationId,
630
+ messages,
631
+ userMessage,
632
+ skipPersistUser: msg.skipPersistUser !== false,
633
+ userMessageId,
634
+ assistantMessageId,
635
+ senderType: typeof msg.senderType === "string" ? msg.senderType : undefined,
636
+ senderName: typeof msg.senderName === "string" ? msg.senderName : undefined,
637
+ });
638
+ publishChatResult(conversationId, result, assistantMessageId, userMessageId);
639
+ if (!result.ok) {
640
+ throw new Error(result.data?.error ?? `chat failed (${result.status})`);
641
+ }
642
+ if (
643
+ result.data?.nextQueued &&
644
+ typeof result.data.nextQueued.id === "string"
645
+ ) {
646
+ nextQueued = result.data.nextQueued;
647
+ }
648
+ return { conversationId, ...result.data };
649
+ } finally {
650
+ if (userMessageId) runningTurns.delete(userMessageId);
651
+ if (
652
+ conversationId &&
653
+ userMessageId &&
654
+ runningByConversation.get(conversationId) === userMessageId
655
+ ) {
656
+ runningByConversation.delete(conversationId);
657
+ }
658
+ const deferred = deferredTurns.get(conversationId);
659
+ if (deferred) deferredTurns.delete(conversationId);
660
+ const followUp =
661
+ nextQueued?.id && String(nextQueued.content ?? "").trim()
662
+ ? {
663
+ conversationId,
664
+ message: {
665
+ id: nextQueued.id,
666
+ content: nextQueued.content,
667
+ senderType: "client",
668
+ },
669
+ }
670
+ : deferred && deferred.message?.id !== userMessageId
671
+ ? deferred
672
+ : null;
673
+ if (followUp) {
674
+ logger.info(
675
+ {
676
+ conversationId,
677
+ from: userMessageId,
678
+ next: followUp.message.id,
679
+ via: nextQueued?.id ? "nextQueued" : "deferred",
680
+ },
681
+ "chain next queued turn"
682
+ );
683
+ queueMicrotask(() => {
684
+ void runWorkingTurn(followUp);
685
+ });
686
+ }
687
+ }
688
+ },
230
689
  });
690
+
691
+ async function start() {
692
+ if (db.ready) {
693
+ try {
694
+ const info = await db.ready;
695
+ logger.info(
696
+ {
697
+ conversations: info.conversations,
698
+ messages: info.messages,
699
+ },
700
+ "cache ready"
701
+ );
702
+ } catch (err) {
703
+ logger.warn({ err }, "cache hydrate failed");
704
+ }
705
+ }
706
+
707
+ const recoverMs = Math.max(
708
+ 5_000,
709
+ Number(process.env.AI_QUEUE_RECOVER_MS || 20_000) || 20_000
710
+ );
711
+ let recoverInFlight = false;
712
+ const sweepStuckQueues = async (reason) => {
713
+ if (recoverInFlight || !db.recoverQueue || !db.listConversations) return;
714
+ recoverInFlight = true;
715
+ try {
716
+ const convs = await db.listConversations();
717
+ for (const conv of convs) {
718
+ if (runningByConversation.has(conv.id)) continue;
719
+ const msgs = conv.messages ?? [];
720
+ const hasWorking = msgs.some(
721
+ (m) =>
722
+ m.role === "user" &&
723
+ m.queueStatus === "working" &&
724
+ m.senderType !== "developer"
725
+ );
726
+ const hasQueued = msgs.some(
727
+ (m) =>
728
+ m.role === "user" &&
729
+ m.queueStatus === "queued" &&
730
+ m.senderType !== "developer"
731
+ );
732
+ if (!hasWorking && !hasQueued) continue;
733
+ const result = await db.recoverQueue(conv.id);
734
+ if (result && result.action && result.action !== "noop") {
735
+ logger.info(
736
+ {
737
+ reason,
738
+ conversationId: conv.id,
739
+ action: result.action,
740
+ workingId: result.workingId,
741
+ promotedId: result.promotedId,
742
+ },
743
+ "queue recover"
744
+ );
745
+ }
746
+ }
747
+ } catch (err) {
748
+ logger.warn({ err, reason }, "queue recover sweep failed");
749
+ } finally {
750
+ recoverInFlight = false;
751
+ }
752
+ };
753
+
754
+ void sweepStuckQueues("startup");
755
+ const recoverTimer = setInterval(() => {
756
+ void sweepStuckQueues("interval");
757
+ }, recoverMs);
758
+ if (typeof recoverTimer.unref === "function") recoverTimer.unref();
759
+
760
+ server.listen(port, () => {
761
+ const base =
762
+ (process.env.AI_SERVER_URL || process.env.AI_SERVER_BASE_URL || "")
763
+ .trim()
764
+ .replace(/\/$/, "") || `http://localhost:${port}`;
765
+ logger.info(`chat → ${base}/api/chat`);
766
+ logger.info(`ws → ${toHttpWsUrl(base)}/api/ws`);
767
+ logger.info(`ui → ${base}/`);
768
+ logger.info(`embed-config → ${base}/embed-config.js`);
769
+ logger.info(`workspace ${workspaceDir}`);
770
+ logger.info(
771
+ {
772
+ logLevel: logger.level,
773
+ nodeEnv: process.env.NODE_ENV,
774
+ queueRecoverMs: recoverMs,
775
+ },
776
+ "logger ready"
777
+ );
778
+ });
779
+ }
780
+
781
+ void start();
782
+
783
+ for (const signal of ["SIGINT", "SIGTERM"]) {
784
+ process.on(signal, () => {
785
+ db.stop?.();
786
+ hub.close();
787
+ process.exit(0);
788
+ });
789
+ }
package/src/ws.mjs ADDED
@@ -0,0 +1,243 @@
1
+ import { WebSocketServer } from "ws";
2
+
3
+ function rawToText(raw) {
4
+ if (typeof raw === "string") return raw;
5
+ if (Buffer.isBuffer(raw)) return raw.toString("utf8");
6
+ if (ArrayBuffer.isView(raw)) {
7
+ return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString(
8
+ "utf8"
9
+ );
10
+ }
11
+ return String(raw ?? "");
12
+ }
13
+
14
+ function parseJson(raw) {
15
+ try {
16
+ const data = JSON.parse(rawToText(raw).trim());
17
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
18
+ return data;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function sendJson(socket, payload, debug) {
25
+ if (socket.readyState !== socket.OPEN) {
26
+ debug?.(
27
+ { type: payload?.type, readyState: socket.readyState },
28
+ "ws outbound skipped (not open)"
29
+ );
30
+ return;
31
+ }
32
+ try {
33
+ socket.send(JSON.stringify(payload));
34
+ } catch (err) {
35
+ debug?.(
36
+ { type: payload?.type, err: err instanceof Error ? err.message : String(err) },
37
+ "ws outbound failed"
38
+ );
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Accepts browser / bridge WebSockets on /api/ws (and /ws).
44
+ * Clients subscribe to a conversation and receive AI turn events.
45
+ */
46
+ export function attachChatWebSocket(server, opts) {
47
+ const logger = opts.logger;
48
+ const log =
49
+ opts.log ??
50
+ ((msg) => {
51
+ if (logger) logger.info(msg);
52
+ else console.log(`[ai-server] ${msg}`);
53
+ });
54
+ const debug = (obj, msg) => {
55
+ if (logger?.debug) logger.debug(obj, msg);
56
+ };
57
+ const info = (obj, msg) => {
58
+ if (logger?.info) logger.info(obj, msg);
59
+ else debug(obj, msg);
60
+ };
61
+ const onChatRun = opts.onChatRun;
62
+ const wss = new WebSocketServer({ noServer: true });
63
+ /** @type {Map<string, Set<import("ws").WebSocket>>} */
64
+ const channels = new Map();
65
+ /** @type {WeakMap<import("ws").WebSocket, Set<string>>} */
66
+ const socketChannels = new WeakMap();
67
+
68
+ const subscribe = (socket, conversationId) => {
69
+ const id = String(conversationId ?? "").trim();
70
+ if (!id) return;
71
+ let set = channels.get(id);
72
+ if (!set) {
73
+ set = new Set();
74
+ channels.set(id, set);
75
+ }
76
+ set.add(socket);
77
+ const owned = socketChannels.get(socket) ?? new Set();
78
+ owned.add(id);
79
+ socketChannels.set(socket, owned);
80
+ debug({ conversationId: id, subscribers: set.size }, "ws subscribe");
81
+ };
82
+
83
+ const unsubscribeAll = (socket) => {
84
+ const owned = socketChannels.get(socket);
85
+ if (!owned) return;
86
+ for (const id of owned) {
87
+ const set = channels.get(id);
88
+ if (!set) continue;
89
+ set.delete(socket);
90
+ if (set.size === 0) channels.delete(id);
91
+ }
92
+ socketChannels.delete(socket);
93
+ };
94
+
95
+ const publish = (conversationId, payload) => {
96
+ const id = String(conversationId ?? "").trim();
97
+ if (!id) return;
98
+ const set = channels.get(id);
99
+ if (!set?.size) {
100
+ debug({ conversationId: id, type: payload?.type }, "ws publish (no subscribers)");
101
+ return;
102
+ }
103
+ debug(
104
+ { conversationId: id, type: payload?.type, subscribers: set.size },
105
+ "ws publish"
106
+ );
107
+ for (const socket of [...set]) {
108
+ sendJson(socket, payload, debug);
109
+ }
110
+ };
111
+
112
+ wss.on("connection", (socket) => {
113
+ debug({ clients: wss.clients.size }, "ws connection open");
114
+ sendJson(socket, { type: "hello", role: "ai_server" }, debug);
115
+ socket.on("message", (raw) => {
116
+ void (async () => {
117
+ const text = rawToText(raw);
118
+ const msg = parseJson(raw);
119
+ if (!msg) {
120
+ log(`ws inbound unparsed (${text.slice(0, 200)})`);
121
+ return;
122
+ }
123
+ const type = typeof msg.type === "string" ? msg.type : "";
124
+ if (type && type !== "ping") {
125
+ info(
126
+ {
127
+ type,
128
+ conversationId: msg.conversationId,
129
+ userMessageId: msg.userMessageId,
130
+ requestId: msg.requestId,
131
+ bytes: text.length,
132
+ },
133
+ "ws inbound"
134
+ );
135
+ }
136
+ if (type === "ping") {
137
+ sendJson(socket, { type: "pong" }, debug);
138
+ return;
139
+ }
140
+ if (type === "subscribe") {
141
+ const ids = [];
142
+ if (typeof msg.conversationId === "string") {
143
+ ids.push(msg.conversationId);
144
+ }
145
+ if (Array.isArray(msg.channels)) {
146
+ for (const channel of msg.channels) {
147
+ if (typeof channel !== "string") continue;
148
+ ids.push(
149
+ channel.startsWith("conversation:")
150
+ ? channel.slice("conversation:".length)
151
+ : channel
152
+ );
153
+ }
154
+ }
155
+ debug({ channels: ids }, "ws subscribe request");
156
+ for (const id of ids.slice(0, 50)) subscribe(socket, id);
157
+ return;
158
+ }
159
+ if (type === "chat.run" && onChatRun) {
160
+ const requestId =
161
+ typeof msg.requestId === "string" ? msg.requestId : undefined;
162
+ debug(
163
+ {
164
+ requestId,
165
+ conversationId: msg.conversationId,
166
+ userMessageId: msg.userMessageId,
167
+ },
168
+ "ws chat.run"
169
+ );
170
+ try {
171
+ const result = await onChatRun(msg);
172
+ info(
173
+ {
174
+ requestId,
175
+ conversationId: msg.conversationId,
176
+ duplicate: result?.duplicate === true,
177
+ ok: result?.ok !== false,
178
+ },
179
+ "ws chat.run done"
180
+ );
181
+ sendJson(
182
+ socket,
183
+ {
184
+ type: "chat.accepted",
185
+ requestId,
186
+ ok: true,
187
+ ...result,
188
+ },
189
+ debug
190
+ );
191
+ } catch (err) {
192
+ log(
193
+ `ws chat.run failed: ${err instanceof Error ? err.message : err}`
194
+ );
195
+ sendJson(
196
+ socket,
197
+ {
198
+ type: "chat.error",
199
+ requestId,
200
+ error: err instanceof Error ? err.message : "chat.run failed",
201
+ },
202
+ debug
203
+ );
204
+ }
205
+ return;
206
+ }
207
+ info({ type: type || "(missing)" }, "ws inbound ignored (unknown type)");
208
+ })().catch((err) => {
209
+ log(`ws handler failed: ${err instanceof Error ? err.message : err}`);
210
+ });
211
+ });
212
+ socket.on("close", () => {
213
+ debug({}, "ws connection close");
214
+ unsubscribeAll(socket);
215
+ });
216
+ });
217
+
218
+ server.on("upgrade", (req, socket, head) => {
219
+ const pathname = (req.url ?? "/").split("?")[0];
220
+ if (pathname !== "/api/ws" && pathname !== "/ws") {
221
+ socket.destroy();
222
+ return;
223
+ }
224
+ debug({ pathname }, "ws upgrade");
225
+ wss.handleUpgrade(req, socket, head, (ws) => {
226
+ wss.emit("connection", ws, req);
227
+ });
228
+ });
229
+
230
+ return {
231
+ publish,
232
+ close() {
233
+ for (const socket of wss.clients) {
234
+ try {
235
+ socket.close();
236
+ } catch {
237
+ /* ignore */
238
+ }
239
+ }
240
+ wss.close();
241
+ },
242
+ };
243
+ }