@maintainer-pro/ai-server 0.1.1 → 0.1.3

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