@clawchatsai/connector 0.0.1

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/server.js ADDED
@@ -0,0 +1,4058 @@
1
+ // ShellChat Backend Server
2
+ // Single-file Node.js HTTP server with SQLite storage
3
+ // See specs/backend-session-architecture.md for full spec
4
+
5
+ import http from 'node:http';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { execSync } from 'node:child_process';
11
+ import os from 'node:os';
12
+ import { fileURLToPath } from 'node:url';
13
+ import Database from 'better-sqlite3';
14
+ import { WebSocket as WS, WebSocketServer } from 'ws';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+
19
+ // ─── Configuration ──────────────────────────────────────────────────────────
20
+
21
+ const PORT = parseInt(process.env.SHELLCHAT_PORT || '3001', 10);
22
+ const DATA_DIR = path.join(__dirname, 'data');
23
+ const UPLOADS_DIR = path.join(__dirname, 'uploads');
24
+ const WORKSPACES_FILE = path.join(DATA_DIR, 'workspaces.json');
25
+
26
+ // --- Debug Recording (isolated module) ---
27
+ class DebugLogger {
28
+ constructor(baseDir) {
29
+ this.baseDir = path.join(baseDir, '..', 'debug');
30
+ this.active = false;
31
+ this.sessionId = null;
32
+ this.wsStream = null;
33
+ this.originatingClient = null;
34
+ }
35
+
36
+ start(ts, originatingClient) {
37
+ if (this.active) {
38
+ return { error: 'already-active', sessionId: this.sessionId };
39
+ }
40
+ this.sessionId = ts.replace(/[:.]/g, '-');
41
+ this.originatingClient = originatingClient;
42
+ fs.mkdirSync(this.baseDir, { recursive: true });
43
+ const wsLogPath = path.join(this.baseDir, `session-${this.sessionId}-ws.log`);
44
+ this.wsStream = fs.createWriteStream(wsLogPath, { flags: 'a' });
45
+ this.active = true;
46
+ console.log(`Debug recording started: ${this.sessionId}`);
47
+ return { sessionId: this.sessionId };
48
+ }
49
+
50
+ logFrame(direction, data) {
51
+ if (!this.active || !this.wsStream) return;
52
+ this.wsStream.write(`${new Date().toISOString()} ${direction} ${data}\n`);
53
+ }
54
+
55
+ saveDump(payload) {
56
+ if (!this.sessionId) return { sessionId: null, files: [] };
57
+ const files = [];
58
+ const id = this.sessionId;
59
+
60
+ if (this.wsStream) {
61
+ this.wsStream.end();
62
+ this.wsStream = null;
63
+ files.push(`session-${id}-ws.log`);
64
+ }
65
+
66
+ const clientLogPath = path.join(this.baseDir, `session-${id}-client.log`);
67
+ let logContent = '';
68
+ for (const entry of (payload.console || [])) {
69
+ const args = entry.args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
70
+ logContent += `${entry.ts} [${entry.level.toUpperCase()}] ${args}\n`;
71
+ }
72
+ for (const err of (payload.errors || [])) {
73
+ logContent += `${err.ts} [UNHANDLED] ${err.message}\n${err.stack || ''}\n`;
74
+ }
75
+ if (logContent) {
76
+ fs.writeFileSync(clientLogPath, logContent);
77
+ files.push(`session-${id}-client.log`);
78
+ }
79
+
80
+ if (payload.state) {
81
+ const statePath = path.join(this.baseDir, `session-${id}-state.json`);
82
+ fs.writeFileSync(statePath, JSON.stringify(payload.state, null, 2));
83
+ files.push(`session-${id}-state.json`);
84
+ }
85
+
86
+ if (payload.screenshot) {
87
+ const ssPath = path.join(this.baseDir, `session-${id}-screenshot.jpg`);
88
+ fs.writeFileSync(ssPath, Buffer.from(payload.screenshot, 'base64'));
89
+ files.push(`session-${id}-screenshot.jpg`);
90
+ }
91
+
92
+ const savedId = id;
93
+ this.active = false;
94
+ this.sessionId = null;
95
+ this.originatingClient = null;
96
+ console.log(`Debug session saved: ${files.join(', ')}`);
97
+ return { sessionId: savedId, files };
98
+ }
99
+
100
+ handleClientDisconnect(ws) {
101
+ if (this.active && this.originatingClient === ws) {
102
+ console.log(`Debug session ${this.sessionId} auto-closed: client disconnected`);
103
+ if (this.wsStream) {
104
+ this.wsStream.write(`${new Date().toISOString()} SYSTEM Client disconnected — session auto-closed\n`);
105
+ this.wsStream.end();
106
+ this.wsStream = null;
107
+ }
108
+ this.active = false;
109
+ this.sessionId = null;
110
+ this.originatingClient = null;
111
+ }
112
+ }
113
+ }
114
+
115
+ const debugLogger = new DebugLogger(DATA_DIR);
116
+ const HOME = os.homedir();
117
+ const MAX_PREAMBLE_CHARS = 50000;
118
+
119
+ // ─── Config.js Parser ────────────────────────────────────────────────────────
120
+
121
+ function parseConfigField(field) {
122
+ try {
123
+ const configPath = path.join(__dirname, 'config.js');
124
+ const configText = fs.readFileSync(configPath, 'utf8');
125
+ const match = configText.match(new RegExp(`${field}:\\s*['"]([^'"]+)['"]`));
126
+ return match ? match[1] : null;
127
+ } catch { return null; }
128
+ }
129
+
130
+ // Load auth token from config.js or env var
131
+ let AUTH_TOKEN = process.env.SHELLCHAT_AUTH_TOKEN || parseConfigField('authToken') || '';
132
+ if (!AUTH_TOKEN) {
133
+ console.error('WARNING: No auth token configured. Set SHELLCHAT_AUTH_TOKEN or create config.js');
134
+ }
135
+
136
+ // Load gateway WebSocket URL
137
+ // Priority: env var → OpenClaw config (local) → conventional default
138
+ // NOTE: Do NOT use config.js gatewayUrl — that's the browser's external-facing URL.
139
+ // server.js needs the internal/local gateway address.
140
+ function discoverGatewayWsUrl() {
141
+ if (process.env.GATEWAY_WS_URL) return process.env.GATEWAY_WS_URL;
142
+ // Read OpenClaw's own config for the local gateway port
143
+ const candidates = [
144
+ path.join(os.homedir(), '.openclaw', 'openclaw.json'),
145
+ '/etc/openclaw/openclaw.json'
146
+ ];
147
+ for (const cfgPath of candidates) {
148
+ try {
149
+ const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
150
+ const port = raw.gateway?.port || raw.port;
151
+ const host = raw.gateway?.host || raw.host || 'localhost';
152
+ if (port) return `ws://${host}:${port}`;
153
+ } catch {}
154
+ }
155
+ return 'ws://localhost:18789';
156
+ }
157
+ const GATEWAY_WS_URL = discoverGatewayWsUrl();
158
+
159
+ // ─── Sessions Directory Discovery ────────────────────────────────────────────
160
+
161
+ function discoverViaCliSync() {
162
+ try {
163
+ const output = execSync('openclaw status --json', { encoding: 'utf8', timeout: 5000 });
164
+ const status = JSON.parse(output);
165
+ if (status.sessions?.paths?.[0]) {
166
+ return path.dirname(status.sessions.paths[0]);
167
+ }
168
+ } catch { /* cli not available or failed */ }
169
+ return null;
170
+ }
171
+
172
+ const OPENCLAW_SESSIONS_DIR = (() => {
173
+ if (process.env.OPENCLAW_SESSIONS_DIR) {
174
+ console.log(`Sessions dir: ${process.env.OPENCLAW_SESSIONS_DIR} (source: env)`);
175
+ return process.env.OPENCLAW_SESSIONS_DIR;
176
+ }
177
+ const configDir = parseConfigField('sessionsDir');
178
+ if (configDir) {
179
+ console.log(`Sessions dir: ${configDir} (source: config)`);
180
+ return configDir;
181
+ }
182
+ const cliDir = discoverViaCliSync();
183
+ if (cliDir) {
184
+ console.log(`Sessions dir: ${cliDir} (source: cli)`);
185
+ return cliDir;
186
+ }
187
+ const fallback = path.join(HOME, '.openclaw', 'agents', 'main', 'sessions');
188
+ console.log(`Sessions dir: ${fallback} (source: fallback)`);
189
+ return fallback;
190
+ })();
191
+
192
+ // ─── Workspace Management ───────────────────────────────────────────────────
193
+
194
+ function ensureDirs() {
195
+ fs.mkdirSync(DATA_DIR, { recursive: true });
196
+ fs.mkdirSync(UPLOADS_DIR, { recursive: true });
197
+ }
198
+
199
+ function loadWorkspaces() {
200
+ try {
201
+ return JSON.parse(fs.readFileSync(WORKSPACES_FILE, 'utf8'));
202
+ } catch {
203
+ const initial = {
204
+ active: 'default',
205
+ workspaces: {
206
+ default: {
207
+ name: 'default',
208
+ label: 'Default',
209
+ createdAt: Date.now()
210
+ }
211
+ }
212
+ };
213
+ fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(initial, null, 2));
214
+ return initial;
215
+ }
216
+ }
217
+
218
+ function saveWorkspaces(data) {
219
+ fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(data, null, 2));
220
+ }
221
+
222
+ let workspacesConfig = null;
223
+
224
+ function getWorkspaces() {
225
+ if (!workspacesConfig) workspacesConfig = loadWorkspaces();
226
+ return workspacesConfig;
227
+ }
228
+
229
+ function setWorkspaces(data) {
230
+ workspacesConfig = data;
231
+ saveWorkspaces(data);
232
+ }
233
+
234
+ // ─── Database Management ────────────────────────────────────────────────────
235
+
236
+ const dbCache = new Map(); // name → Database instance
237
+
238
+ function getDb(workspaceName) {
239
+ if (dbCache.has(workspaceName)) return dbCache.get(workspaceName);
240
+ const dbPath = path.join(DATA_DIR, `${workspaceName}.db`);
241
+ const db = new Database(dbPath);
242
+ db.pragma('journal_mode = WAL');
243
+ db.pragma('foreign_keys = ON');
244
+ migrate(db);
245
+ dbCache.set(workspaceName, db);
246
+ return db;
247
+ }
248
+
249
+ function getActiveDb() {
250
+ return getDb(getWorkspaces().active);
251
+ }
252
+
253
+ function closeDb(workspaceName) {
254
+ const db = dbCache.get(workspaceName);
255
+ if (db) {
256
+ db.close();
257
+ dbCache.delete(workspaceName);
258
+ }
259
+ }
260
+
261
+ function closeAllDbs() {
262
+ for (const [name, db] of dbCache) {
263
+ db.close();
264
+ }
265
+ dbCache.clear();
266
+ }
267
+
268
+ function migrate(db) {
269
+ db.exec(`
270
+ CREATE TABLE IF NOT EXISTS threads (
271
+ id TEXT PRIMARY KEY,
272
+ session_key TEXT UNIQUE NOT NULL,
273
+ title TEXT DEFAULT 'New chat',
274
+ pinned INTEGER DEFAULT 0,
275
+ pin_order INTEGER DEFAULT 0,
276
+ model TEXT,
277
+ last_session_id TEXT,
278
+ created_at INTEGER NOT NULL,
279
+ updated_at INTEGER NOT NULL
280
+ );
281
+
282
+ CREATE TABLE IF NOT EXISTS messages (
283
+ id TEXT PRIMARY KEY,
284
+ thread_id TEXT NOT NULL,
285
+ role TEXT NOT NULL,
286
+ content TEXT NOT NULL,
287
+ status TEXT DEFAULT 'sent',
288
+ metadata TEXT,
289
+ seq INTEGER,
290
+ timestamp INTEGER NOT NULL,
291
+ created_at INTEGER NOT NULL,
292
+ FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
293
+ );
294
+
295
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, timestamp);
296
+ CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages(thread_id, role, timestamp);
297
+ `);
298
+
299
+ // Migration: add sort_order column if missing
300
+ try {
301
+ db.exec('ALTER TABLE threads ADD COLUMN sort_order INTEGER DEFAULT 0');
302
+ } catch (e) {
303
+ // Column already exists — ignore
304
+ }
305
+
306
+ // Migration: add unread_count column if missing
307
+ try {
308
+ db.exec('ALTER TABLE threads ADD COLUMN unread_count INTEGER DEFAULT 0');
309
+ } catch (e) {
310
+ // Column already exists — ignore
311
+ }
312
+
313
+ // Migration: unread_messages table for per-message read tracking
314
+ db.exec(`
315
+ CREATE TABLE IF NOT EXISTS unread_messages (
316
+ thread_id TEXT NOT NULL,
317
+ message_id TEXT NOT NULL,
318
+ created_at INTEGER NOT NULL,
319
+ PRIMARY KEY (thread_id, message_id),
320
+ FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
321
+ )
322
+ `);
323
+ db.exec('CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id)');
324
+
325
+ // FTS5 table — CREATE VIRTUAL TABLE doesn't support IF NOT EXISTS in all versions,
326
+ // so check if it exists first
327
+ const hasFts = db.prepare(
328
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'"
329
+ ).get();
330
+ if (!hasFts) {
331
+ db.exec(`
332
+ CREATE VIRTUAL TABLE messages_fts USING fts5(
333
+ content,
334
+ content=messages,
335
+ content_rowid=rowid,
336
+ tokenize='porter unicode61'
337
+ );
338
+
339
+ CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
340
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
341
+ END;
342
+ CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
343
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content);
344
+ END;
345
+ CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
346
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content);
347
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
348
+ END;
349
+ `);
350
+ }
351
+ }
352
+
353
+ // ─── HTTP Helpers ───────────────────────────────────────────────────────────
354
+
355
+ function parseBody(req) {
356
+ return new Promise((resolve, reject) => {
357
+ const chunks = [];
358
+ req.on('data', c => chunks.push(c));
359
+ req.on('end', () => {
360
+ const raw = Buffer.concat(chunks).toString();
361
+ if (!raw) return resolve({});
362
+ try { resolve(JSON.parse(raw)); }
363
+ catch { reject(new Error('Invalid JSON')); }
364
+ });
365
+ req.on('error', reject);
366
+ });
367
+ }
368
+
369
+ function send(res, status, data) {
370
+ const body = JSON.stringify(data);
371
+ res.writeHead(status, {
372
+ 'Content-Type': 'application/json',
373
+ 'Access-Control-Allow-Origin': '*',
374
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
375
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
376
+ });
377
+ res.end(body);
378
+ }
379
+
380
+ function sendError(res, status, message) {
381
+ send(res, status, { error: message });
382
+ }
383
+
384
+ function setCors(res) {
385
+ res.setHeader('Access-Control-Allow-Origin', '*');
386
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
387
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
388
+ }
389
+
390
+ function uuid() {
391
+ return crypto.randomUUID();
392
+ }
393
+
394
+ // ─── Route matching ─────────────────────────────────────────────────────────
395
+
396
+ function matchRoute(method, url, pattern) {
397
+ // pattern like "GET /api/threads/:id/messages/:messageId"
398
+ const [pMethod, pPath] = pattern.split(' ');
399
+ if (method !== pMethod) return null;
400
+ const pParts = pPath.split('/').filter(Boolean);
401
+ const uParts = url.split('/').filter(Boolean);
402
+ if (pParts.length !== uParts.length) return null;
403
+ const params = {};
404
+ for (let i = 0; i < pParts.length; i++) {
405
+ if (pParts[i].startsWith(':')) {
406
+ params[pParts[i].slice(1)] = decodeURIComponent(uParts[i]);
407
+ } else if (pParts[i] !== uParts[i]) {
408
+ return null;
409
+ }
410
+ }
411
+ return params;
412
+ }
413
+
414
+ // ─── Auth Middleware ─────────────────────────────────────────────────────────
415
+
416
+ function checkAuth(req, res) {
417
+ if (!AUTH_TOKEN) return true; // no token configured = open
418
+ const auth = req.headers.authorization;
419
+ if (!auth || !auth.startsWith('Bearer ')) {
420
+ sendError(res, 401, 'Missing or invalid Authorization header');
421
+ return false;
422
+ }
423
+ const token = auth.slice(7);
424
+ if (token !== AUTH_TOKEN) {
425
+ sendError(res, 401, 'Invalid auth token');
426
+ return false;
427
+ }
428
+ return true;
429
+ }
430
+
431
+ // ─── Multipart Parser (minimal, for file uploads) ───────────────────────────
432
+
433
+ function parseMultipart(req) {
434
+ return new Promise((resolve, reject) => {
435
+ const contentType = req.headers['content-type'] || '';
436
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
437
+ if (!boundaryMatch) return reject(new Error('No boundary in content-type'));
438
+ const boundary = boundaryMatch[1] || boundaryMatch[2];
439
+ const chunks = [];
440
+ req.on('data', c => chunks.push(c));
441
+ req.on('end', () => {
442
+ const buf = Buffer.concat(chunks);
443
+ const files = [];
444
+ const delimiter = Buffer.from(`--${boundary}`);
445
+ const end = Buffer.from(`--${boundary}--`);
446
+
447
+ let pos = 0;
448
+ while (pos < buf.length) {
449
+ const start = buf.indexOf(delimiter, pos);
450
+ if (start === -1) break;
451
+ const nextStart = buf.indexOf(delimiter, start + delimiter.length);
452
+ if (nextStart === -1) break;
453
+
454
+ const part = buf.subarray(start + delimiter.length, nextStart);
455
+ const headerEnd = part.indexOf('\r\n\r\n');
456
+ if (headerEnd === -1) { pos = nextStart; continue; }
457
+
458
+ const headerStr = part.subarray(0, headerEnd).toString();
459
+ let body = part.subarray(headerEnd + 4);
460
+ // Trim trailing \r\n
461
+ if (body.length >= 2 && body[body.length - 2] === 0x0d && body[body.length - 1] === 0x0a) {
462
+ body = body.subarray(0, body.length - 2);
463
+ }
464
+
465
+ const filenameMatch = headerStr.match(/filename="([^"]+)"/);
466
+ const ctMatch = headerStr.match(/Content-Type:\s*(\S+)/i);
467
+ if (filenameMatch) {
468
+ files.push({
469
+ filename: filenameMatch[1],
470
+ mimeType: ctMatch ? ctMatch[1] : 'application/octet-stream',
471
+ data: body,
472
+ });
473
+ }
474
+ pos = nextStart;
475
+ }
476
+ resolve(files);
477
+ });
478
+ req.on('error', reject);
479
+ });
480
+ }
481
+
482
+ // ─── Context Fill Helper ────────────────────────────────────────────────────
483
+
484
+ function buildContextPreamble(db, threadId, lastSessionId) {
485
+ let summary = null;
486
+ let method = 'raw';
487
+
488
+ // Try to read old JSONL transcript
489
+ if (lastSessionId) {
490
+ const jsonlPath = path.join(OPENCLAW_SESSIONS_DIR, `${lastSessionId}.jsonl`);
491
+ try {
492
+ const content = fs.readFileSync(jsonlPath, 'utf8');
493
+ const lines = content.split('\n').filter(Boolean);
494
+ // Find last compaction entry
495
+ for (let i = lines.length - 1; i >= 0; i--) {
496
+ try {
497
+ const entry = JSON.parse(lines[i]);
498
+ if (entry.type === 'compaction' && entry.summary) {
499
+ summary = entry.summary;
500
+ method = 'compaction';
501
+ break;
502
+ }
503
+ } catch { /* skip malformed lines */ }
504
+ }
505
+ } catch { /* file not found, fall back to raw */ }
506
+ }
507
+
508
+ // Build preamble
509
+ let preamble = '';
510
+
511
+ if (method === 'compaction' && summary) {
512
+ preamble += '[CONTEXT RECOVERY — This thread\'s agent session was reset. Below is a summary of the previous conversation followed by recent messages to restore context.]\n\n';
513
+ preamble += '[CONVERSATION SUMMARY]\n';
514
+ preamble += summary + '\n\n';
515
+
516
+ // Last 10 messages from SQLite
517
+ const msgs = db.prepare(
518
+ 'SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 10'
519
+ ).all(threadId).reverse();
520
+
521
+ if (msgs.length) {
522
+ preamble += '[RECENT MESSAGES]\n';
523
+ for (const m of msgs) {
524
+ const d = new Date(m.timestamp);
525
+ const ts = d.toISOString().replace('T', ' ').slice(0, 16);
526
+ const role = m.role.charAt(0).toUpperCase() + m.role.slice(1);
527
+ preamble += `${role} (${ts}): ${m.content}\n`;
528
+ }
529
+ }
530
+ } else {
531
+ preamble += '[CONTEXT RECOVERY — This thread\'s agent session was reset. Below are recent messages from the previous conversation to restore context.]\n\n';
532
+
533
+ // Last 25 messages from SQLite
534
+ const msgs = db.prepare(
535
+ 'SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 25'
536
+ ).all(threadId).reverse();
537
+
538
+ if (msgs.length) {
539
+ preamble += '[PREVIOUS MESSAGES]\n';
540
+ for (const m of msgs) {
541
+ const d = new Date(m.timestamp);
542
+ const ts = d.toISOString().replace('T', ' ').slice(0, 16);
543
+ const role = m.role.charAt(0).toUpperCase() + m.role.slice(1);
544
+ preamble += `${role} (${ts}): ${m.content}\n`;
545
+ }
546
+ }
547
+ }
548
+
549
+ // Enforce token budget
550
+ if (preamble.length > MAX_PREAMBLE_CHARS) {
551
+ preamble = preamble.slice(preamble.length - MAX_PREAMBLE_CHARS);
552
+ }
553
+
554
+ return { preamble, method };
555
+ }
556
+
557
+ // ─── Gateway Session Cleanup ─────────────────────────────────────────────────
558
+
559
+ function cleanGatewaySession(sessionKey) {
560
+ try {
561
+ const sessionsPath = path.join(OPENCLAW_SESSIONS_DIR, 'sessions.json');
562
+ const raw = fs.readFileSync(sessionsPath, 'utf8');
563
+ const store = JSON.parse(raw);
564
+ const entry = store[sessionKey];
565
+ if (!entry) return null;
566
+
567
+ // Delete .jsonl transcript
568
+ if (entry.sessionId) {
569
+ const jsonlPath = path.join(OPENCLAW_SESSIONS_DIR, `${entry.sessionId}.jsonl`);
570
+ try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
571
+ }
572
+
573
+ // Remove entry from store and write back
574
+ const sessionId = entry.sessionId || null;
575
+ delete store[sessionKey];
576
+ fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
577
+ return sessionId;
578
+ } catch (err) {
579
+ console.warn(`cleanGatewaySession(${sessionKey}):`, err.message);
580
+ return null;
581
+ }
582
+ }
583
+
584
+ function cleanGatewaySessionsByPrefix(prefix) {
585
+ try {
586
+ const sessionsPath = path.join(OPENCLAW_SESSIONS_DIR, 'sessions.json');
587
+ const raw = fs.readFileSync(sessionsPath, 'utf8');
588
+ const store = JSON.parse(raw);
589
+ let cleaned = 0;
590
+
591
+ for (const key of Object.keys(store)) {
592
+ if (!key.startsWith(prefix)) continue;
593
+ const entry = store[key];
594
+ if (entry?.sessionId) {
595
+ const jsonlPath = path.join(OPENCLAW_SESSIONS_DIR, `${entry.sessionId}.jsonl`);
596
+ try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
597
+ }
598
+ delete store[key];
599
+ cleaned++;
600
+ }
601
+
602
+ if (cleaned > 0) {
603
+ fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
604
+ }
605
+ return cleaned;
606
+ } catch (err) {
607
+ console.warn(`cleanGatewaySessionsByPrefix(${prefix}):`, err.message);
608
+ return 0;
609
+ }
610
+ }
611
+
612
+ // ─── Route Handlers ─────────────────────────────────────────────────────────
613
+
614
+ // --- User settings (synced across devices) ---
615
+ const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
616
+
617
+ function handleGetSettings(req, res) {
618
+ try {
619
+ const data = fs.existsSync(SETTINGS_FILE)
620
+ ? JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'))
621
+ : {};
622
+ return send(res, 200, data);
623
+ } catch {
624
+ return send(res, 200, {});
625
+ }
626
+ }
627
+
628
+ async function handleSaveSettings(req, res) {
629
+ const body = await parseBody(req);
630
+ try {
631
+ // Merge with existing settings
632
+ const existing = fs.existsSync(SETTINGS_FILE)
633
+ ? JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'))
634
+ : {};
635
+ const merged = { ...existing, ...body };
636
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2));
637
+ return send(res, 200, merged);
638
+ } catch (err) {
639
+ return send(res, 500, { error: err.message });
640
+ }
641
+ }
642
+
643
+ // --- Workspaces ---
644
+
645
+ function handleGetWorkspaces(req, res) {
646
+ const ws = getWorkspaces();
647
+ const sorted = Object.values(ws.workspaces).sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
648
+
649
+ // Add unread_count for each workspace (from thread unread_count column, kept in sync by mark-read + WS handler)
650
+ for (const workspace of sorted) {
651
+ try {
652
+ const db = getDb(workspace.name);
653
+ const result = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get();
654
+ workspace.unread_count = result.total;
655
+ } catch (e) {
656
+ workspace.unread_count = 0;
657
+ }
658
+ }
659
+
660
+ send(res, 200, {
661
+ active: ws.active,
662
+ workspaces: sorted,
663
+ });
664
+ }
665
+
666
+ async function handleCreateWorkspace(req, res) {
667
+ const body = await parseBody(req);
668
+ const { name, label } = body;
669
+ if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) {
670
+ return sendError(res, 400, 'Name must be [a-z0-9-], 1-32 chars');
671
+ }
672
+ const ws = getWorkspaces();
673
+ if (ws.workspaces[name]) {
674
+ return sendError(res, 409, 'Workspace already exists');
675
+ }
676
+ const workspace = { name, label: label || name, color: body.color || null, icon: body.icon || null, createdAt: Date.now() };
677
+ ws.workspaces[name] = workspace;
678
+ setWorkspaces(ws);
679
+ // Initialize DB
680
+ getDb(name);
681
+ send(res, 201, { workspace });
682
+ }
683
+
684
+ async function handleUpdateWorkspace(req, res, params) {
685
+ const body = await parseBody(req);
686
+ const ws = getWorkspaces();
687
+ if (!ws.workspaces[params.name]) {
688
+ return sendError(res, 404, 'Workspace not found');
689
+ }
690
+ if (body.label !== undefined) {
691
+ ws.workspaces[params.name].label = body.label;
692
+ }
693
+ if (body.color !== undefined) {
694
+ ws.workspaces[params.name].color = body.color;
695
+ }
696
+ if (body.icon !== undefined) {
697
+ ws.workspaces[params.name].icon = body.icon;
698
+ }
699
+ setWorkspaces(ws);
700
+ send(res, 200, { workspace: ws.workspaces[params.name] });
701
+ }
702
+
703
+ function handleDeleteWorkspace(req, res, params) {
704
+ const ws = getWorkspaces();
705
+ console.log('DELETE workspace:', params.name, 'workspaces:', Object.keys(ws.workspaces), 'active:', ws.active);
706
+ if (!ws.workspaces[params.name]) {
707
+ return sendError(res, 404, 'Workspace not found');
708
+ }
709
+ if (Object.keys(ws.workspaces).length <= 1) {
710
+ return sendError(res, 400, 'Cannot delete the only workspace');
711
+ }
712
+ if (ws.active === params.name) {
713
+ return sendError(res, 400, 'Cannot delete the active workspace');
714
+ }
715
+ // Close and remove DB
716
+ closeDb(params.name);
717
+ const dbPath = path.join(DATA_DIR, `${params.name}.db`);
718
+ try { fs.unlinkSync(dbPath); } catch { /* ok */ }
719
+ try { fs.unlinkSync(dbPath + '-wal'); } catch { /* ok */ }
720
+ try { fs.unlinkSync(dbPath + '-shm'); } catch { /* ok */ }
721
+
722
+ // Clean all gateway sessions for this workspace
723
+ const cleaned = cleanGatewaySessionsByPrefix(`agent:main:${params.name}:chat:`);
724
+ if (cleaned > 0) {
725
+ console.log(`Cleaned ${cleaned} gateway sessions for workspace: ${params.name}`);
726
+ }
727
+
728
+ delete ws.workspaces[params.name];
729
+ setWorkspaces(ws);
730
+ send(res, 200, { ok: true });
731
+ }
732
+
733
+ async function handleReorderWorkspaces(req, res) {
734
+ const body = await parseBody(req);
735
+ const { order } = body;
736
+ if (!Array.isArray(order)) {
737
+ return sendError(res, 400, 'order must be an array of workspace names');
738
+ }
739
+ const ws = getWorkspaces();
740
+ // Assign order index to each workspace
741
+ order.forEach((name, i) => {
742
+ if (ws.workspaces[name]) {
743
+ ws.workspaces[name].order = i;
744
+ }
745
+ });
746
+ setWorkspaces(ws);
747
+ send(res, 200, { ok: true, workspaces: Object.values(ws.workspaces) });
748
+ }
749
+
750
+ function handleActivateWorkspace(req, res, params) {
751
+ const ws = getWorkspaces();
752
+ if (!ws.workspaces[params.name]) {
753
+ return sendError(res, 404, 'Workspace not found');
754
+ }
755
+ ws.active = params.name;
756
+ setWorkspaces(ws);
757
+ // Pre-open the DB
758
+ getDb(params.name);
759
+ send(res, 200, { ok: true, workspace: ws.workspaces[params.name] });
760
+ }
761
+
762
+ // --- Threads ---
763
+
764
+ function handleGetThreads(req, res, params, query) {
765
+ const db = getActiveDb();
766
+ const page = parseInt(query.page || '1', 10);
767
+ const limit = Math.min(parseInt(query.limit || '50', 10), 200);
768
+ const offset = (page - 1) * limit;
769
+ const search = query.search || '';
770
+
771
+ let threads, total;
772
+ if (search) {
773
+ // FTS5 search across messages, return matching thread IDs
774
+ const ftsQuery = `
775
+ SELECT DISTINCT m.thread_id
776
+ FROM messages m
777
+ JOIN messages_fts ON messages_fts.rowid = m.rowid
778
+ WHERE messages_fts MATCH ?
779
+ `;
780
+ const matchingIds = db.prepare(ftsQuery).all(search).map(r => r.thread_id);
781
+ if (matchingIds.length === 0) {
782
+ return send(res, 200, { threads: [], total: 0, page });
783
+ }
784
+ const placeholders = matchingIds.map(() => '?').join(',');
785
+ total = db.prepare(
786
+ `SELECT COUNT(*) as c FROM threads WHERE id IN (${placeholders})`
787
+ ).get(...matchingIds).c;
788
+ threads = db.prepare(
789
+ `SELECT * FROM threads WHERE id IN (${placeholders}) ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?`
790
+ ).all(...matchingIds, limit, offset);
791
+ } else {
792
+ total = db.prepare('SELECT COUNT(*) as c FROM threads').get().c;
793
+ threads = db.prepare(
794
+ 'SELECT * FROM threads ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?'
795
+ ).all(limit, offset);
796
+ }
797
+
798
+ send(res, 200, { threads, total, page });
799
+ }
800
+
801
+ function handleGetUnreadThreads(req, res) {
802
+ const db = getActiveDb();
803
+ const threads = db.prepare(`
804
+ SELECT t.id, t.title, t.unread_count, m.content as lastMessage
805
+ FROM threads t
806
+ LEFT JOIN messages m ON m.thread_id = t.id
807
+ WHERE t.unread_count > 0
808
+ AND m.timestamp = (SELECT MAX(timestamp) FROM messages WHERE thread_id = t.id)
809
+ ORDER BY t.updated_at DESC
810
+ `).all();
811
+
812
+ // Attach unread message IDs to each thread
813
+ for (const thread of threads) {
814
+ const rows = db.prepare('SELECT message_id FROM unread_messages WHERE thread_id = ?').all(thread.id);
815
+ thread.unreadMessageIds = rows.map(r => r.message_id);
816
+ }
817
+
818
+ send(res, 200, { threads });
819
+ }
820
+
821
+ async function handleMarkMessagesRead(req, res, params) {
822
+ const body = await parseBody(req);
823
+ const db = getActiveDb();
824
+ const threadId = params.id;
825
+ const messageIds = body.messageIds;
826
+
827
+ if (!Array.isArray(messageIds) || messageIds.length === 0) {
828
+ return send(res, 400, { error: 'messageIds array required' });
829
+ }
830
+
831
+ const placeholders = messageIds.map(() => '?').join(',');
832
+ db.prepare(`DELETE FROM unread_messages WHERE thread_id = ? AND message_id IN (${placeholders})`).run(threadId, ...messageIds);
833
+
834
+ // Sync unread_count from actual unread_messages table
835
+ const remaining = syncThreadUnreadCount(db, threadId);
836
+
837
+ // Broadcast unread-update to ALL browser clients (so other tabs/devices sync)
838
+ const workspace = getWorkspaces().active;
839
+ gatewayClient.broadcastToBrowsers(JSON.stringify({
840
+ type: 'shellchat',
841
+ event: 'unread-update',
842
+ workspace,
843
+ threadId,
844
+ action: 'read',
845
+ messageIds,
846
+ unreadCount: remaining,
847
+ timestamp: Date.now()
848
+ }));
849
+
850
+ send(res, 200, { unread_count: remaining });
851
+ }
852
+
853
+ async function handleCreateThread(req, res) {
854
+ const body = await parseBody(req);
855
+ const db = getActiveDb();
856
+ const ws = getWorkspaces();
857
+ const id = body.id || uuid();
858
+ const now = Date.now();
859
+ const sessionKey = `agent:main:${ws.active}:chat:${id}`;
860
+
861
+ try {
862
+ db.prepare(
863
+ 'INSERT INTO threads (id, session_key, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
864
+ ).run(id, sessionKey, 'New chat', now, now);
865
+ } catch (e) {
866
+ if (e.message.includes('UNIQUE constraint')) {
867
+ return sendError(res, 409, 'Thread already exists');
868
+ }
869
+ throw e;
870
+ }
871
+
872
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(id);
873
+ send(res, 201, { thread });
874
+ }
875
+
876
+ function handleGetThread(req, res, params) {
877
+ const db = getActiveDb();
878
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
879
+ if (!thread) return sendError(res, 404, 'Thread not found');
880
+ send(res, 200, { thread });
881
+ }
882
+
883
+ async function handleUpdateThread(req, res, params) {
884
+ const body = await parseBody(req);
885
+ const db = getActiveDb();
886
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
887
+ if (!thread) return sendError(res, 404, 'Thread not found');
888
+
889
+ const fields = [];
890
+ const values = [];
891
+ if (body.title !== undefined) { fields.push('title = ?'); values.push(body.title); }
892
+ if (body.pinned !== undefined) { fields.push('pinned = ?'); values.push(body.pinned ? 1 : 0); }
893
+ if (body.pin_order !== undefined) { fields.push('pin_order = ?'); values.push(body.pin_order); }
894
+ if (body.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(body.sort_order); }
895
+ if (body.model !== undefined) { fields.push('model = ?'); values.push(body.model); }
896
+ if (body.last_session_id !== undefined) { fields.push('last_session_id = ?'); values.push(body.last_session_id); }
897
+ if (body.unread_count !== undefined) { fields.push('unread_count = ?'); values.push(body.unread_count); }
898
+
899
+ if (fields.length > 0) {
900
+ fields.push('updated_at = ?');
901
+ values.push(Date.now());
902
+ values.push(params.id);
903
+ db.prepare(`UPDATE threads SET ${fields.join(', ')} WHERE id = ?`).run(...values);
904
+ }
905
+
906
+ const updated = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
907
+ send(res, 200, { thread: updated });
908
+ }
909
+
910
+ function handleDeleteThread(req, res, params) {
911
+ const db = getActiveDb();
912
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
913
+ if (!thread) return sendError(res, 404, 'Thread not found');
914
+
915
+ // Delete from SQLite (CASCADE deletes messages)
916
+ db.prepare('DELETE FROM threads WHERE id = ?').run(params.id);
917
+
918
+ // Look up sessionId from SQLite or sessions.json as fallback
919
+ let sessionIdToDelete = thread.last_session_id;
920
+ if (!sessionIdToDelete) {
921
+ try {
922
+ const raw = fs.readFileSync(path.join(OPENCLAW_SESSIONS_DIR, 'sessions.json'), 'utf8');
923
+ const store = JSON.parse(raw);
924
+ const entry = store[thread.session_key];
925
+ if (entry?.sessionId) sessionIdToDelete = entry.sessionId;
926
+ } catch { /* ok */ }
927
+ }
928
+
929
+ // Clean gateway session (deletes .jsonl + sessions.json entry)
930
+ cleanGatewaySession(thread.session_key);
931
+
932
+ // If cleanGatewaySession didn't find it but we have a sessionId, delete transcript directly
933
+ if (sessionIdToDelete) {
934
+ const jsonlPath = path.join(OPENCLAW_SESSIONS_DIR, `${sessionIdToDelete}.jsonl`);
935
+ try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
936
+ }
937
+
938
+ // Delete uploaded files
939
+ const uploadDir = path.join(UPLOADS_DIR, params.id);
940
+ try { fs.rmSync(uploadDir, { recursive: true }); } catch { /* ok */ }
941
+
942
+ send(res, 200, { ok: true });
943
+ }
944
+
945
+ // --- Messages ---
946
+
947
+ function handleGetMessages(req, res, params, query) {
948
+ const db = getActiveDb();
949
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
950
+ if (!thread) return sendError(res, 404, 'Thread not found');
951
+
952
+ const limit = Math.min(parseInt(query.limit || '100', 10), 500);
953
+ const before = query.before ? parseInt(query.before, 10) : null;
954
+ const after = query.after ? parseInt(query.after, 10) : null;
955
+
956
+ let sql = 'SELECT * FROM messages WHERE thread_id = ?';
957
+ const sqlParams = [params.id];
958
+
959
+ if (before) {
960
+ sql += ' AND timestamp < ?';
961
+ sqlParams.push(before);
962
+ }
963
+ if (after) {
964
+ sql += ' AND timestamp > ?';
965
+ sqlParams.push(after);
966
+ }
967
+
968
+ // Count total matching for hasMore
969
+ const countSql = sql.replace('SELECT *', 'SELECT COUNT(*) as c');
970
+ const total = db.prepare(countSql).get(...sqlParams).c;
971
+
972
+ sql += ' ORDER BY timestamp DESC LIMIT ?';
973
+ sqlParams.push(limit + 1);
974
+
975
+ const rows = db.prepare(sql).all(...sqlParams);
976
+ const hasMore = rows.length > limit;
977
+ const messages = rows.slice(0, limit).reverse(); // Return chronological order
978
+
979
+ // Parse metadata JSON
980
+ for (const m of messages) {
981
+ if (m.metadata) {
982
+ try { m.metadata = JSON.parse(m.metadata); } catch { /* leave as string */ }
983
+ }
984
+ }
985
+
986
+ send(res, 200, { messages, hasMore });
987
+ }
988
+
989
+ async function handleCreateMessage(req, res, params) {
990
+ const body = await parseBody(req);
991
+ const db = getActiveDb();
992
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
993
+ if (!thread) return sendError(res, 404, 'Thread not found');
994
+
995
+ if (!body.id || !body.role || body.content === undefined || !body.timestamp) {
996
+ return sendError(res, 400, 'Required: id, role, content, timestamp');
997
+ }
998
+
999
+ const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
1000
+
1001
+ // Idempotent upsert: insert or update status if it changed
1002
+ const existing = db.prepare('SELECT id, status FROM messages WHERE id = ?').get(body.id);
1003
+ if (existing) {
1004
+ // Only update if status changes
1005
+ if (body.status && body.status !== existing.status) {
1006
+ db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?')
1007
+ .run(body.status, body.content, metadata, body.id);
1008
+ }
1009
+ } else {
1010
+ db.prepare(
1011
+ 'INSERT INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
1012
+ ).run(body.id, params.id, body.role, body.content, body.status || 'sent', metadata, body.seq || null, body.timestamp, Date.now());
1013
+
1014
+ // Bump thread updated_at
1015
+ db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
1016
+ }
1017
+
1018
+ const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
1019
+ if (message && message.metadata) {
1020
+ try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ }
1021
+ }
1022
+ send(res, existing ? 200 : 201, { message });
1023
+ }
1024
+
1025
+ function handleDeleteMessage(req, res, params) {
1026
+ const db = getActiveDb();
1027
+ const msg = db.prepare('SELECT id FROM messages WHERE id = ? AND thread_id = ?').get(params.messageId, params.id);
1028
+ if (!msg) return sendError(res, 404, 'Message not found');
1029
+ db.prepare('DELETE FROM messages WHERE id = ?').run(params.messageId);
1030
+ send(res, 200, { ok: true });
1031
+ }
1032
+
1033
+ // --- Context Fill ---
1034
+
1035
+ function handleContextFill(req, res, params) {
1036
+ const db = getActiveDb();
1037
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1038
+ if (!thread) return sendError(res, 404, 'Thread not found');
1039
+
1040
+ const { preamble, method } = buildContextPreamble(db, params.id, thread.last_session_id);
1041
+ send(res, 200, { preamble, method });
1042
+ }
1043
+
1044
+ // --- Search ---
1045
+
1046
+ function handleSearch(req, res, params, query) {
1047
+ const db = getActiveDb();
1048
+ const q = query.q || '';
1049
+ if (!q) return send(res, 200, { results: [], total: 0 });
1050
+
1051
+ const page = parseInt(query.page || '1', 10);
1052
+ const limit = Math.min(parseInt(query.limit || '20', 10), 100);
1053
+ const offset = (page - 1) * limit;
1054
+
1055
+ // FTS5 search with snippet
1056
+ const results = db.prepare(`
1057
+ SELECT
1058
+ m.id as messageId,
1059
+ m.thread_id as threadId,
1060
+ t.title as threadTitle,
1061
+ m.role,
1062
+ snippet(messages_fts, 0, '<mark>', '</mark>', '...', 40) as content,
1063
+ m.timestamp
1064
+ FROM messages_fts
1065
+ JOIN messages m ON messages_fts.rowid = m.rowid
1066
+ JOIN threads t ON m.thread_id = t.id
1067
+ WHERE messages_fts MATCH ?
1068
+ ORDER BY rank
1069
+ LIMIT ? OFFSET ?
1070
+ `).all(q, limit, offset);
1071
+
1072
+ const totalRow = db.prepare(`
1073
+ SELECT COUNT(*) as c
1074
+ FROM messages_fts
1075
+ WHERE messages_fts MATCH ?
1076
+ `).get(q);
1077
+
1078
+ send(res, 200, { results, total: totalRow.c });
1079
+ }
1080
+
1081
+ // --- Export ---
1082
+
1083
+ function handleExport(req, res) {
1084
+ const db = getActiveDb();
1085
+ const ws = getWorkspaces();
1086
+ const threads = db.prepare('SELECT * FROM threads ORDER BY updated_at DESC').all();
1087
+ const data = {
1088
+ workspace: ws.active,
1089
+ exportedAt: Date.now(),
1090
+ threads: threads.map(t => {
1091
+ const messages = db.prepare(
1092
+ 'SELECT * FROM messages WHERE thread_id = ? ORDER BY timestamp ASC'
1093
+ ).all(t.id);
1094
+ // Parse metadata
1095
+ for (const m of messages) {
1096
+ if (m.metadata) {
1097
+ try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ }
1098
+ }
1099
+ }
1100
+ return { ...t, messages };
1101
+ }),
1102
+ };
1103
+ send(res, 200, data);
1104
+ }
1105
+
1106
+ // --- Import ---
1107
+
1108
+ async function handleImport(req, res) {
1109
+ const body = await parseBody(req);
1110
+ const db = getActiveDb();
1111
+ const ws = getWorkspaces();
1112
+
1113
+ if (!body.threads || !Array.isArray(body.threads)) {
1114
+ return sendError(res, 400, 'Expected { threads: [...] }');
1115
+ }
1116
+
1117
+ let threadsImported = 0;
1118
+ let messagesImported = 0;
1119
+
1120
+ const insertThread = db.prepare(
1121
+ 'INSERT OR IGNORE INTO threads (id, session_key, title, pinned, pin_order, model, last_session_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
1122
+ );
1123
+ const insertMsg = db.prepare(
1124
+ 'INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
1125
+ );
1126
+
1127
+ const importAll = db.transaction(() => {
1128
+ for (const t of body.threads) {
1129
+ if (!t.id) continue;
1130
+ const sessionKey = t.session_key || `agent:main:${ws.active}:chat:${t.id}`;
1131
+ const result = insertThread.run(
1132
+ t.id, sessionKey, t.title || 'Imported chat',
1133
+ t.pinned || 0, t.pin_order || 0, t.model || null,
1134
+ t.last_session_id || null, t.created_at || Date.now(), t.updated_at || Date.now()
1135
+ );
1136
+ if (result.changes > 0) threadsImported++;
1137
+
1138
+ const messages = t.messages || [];
1139
+ for (const m of messages) {
1140
+ if (!m.id || !m.role) continue;
1141
+ const metadata = m.metadata ? (typeof m.metadata === 'string' ? m.metadata : JSON.stringify(m.metadata)) : null;
1142
+ const r = insertMsg.run(
1143
+ m.id, t.id, m.role, m.content || '', m.status || 'sent',
1144
+ metadata, m.seq || null, m.timestamp || Date.now(), m.created_at || Date.now()
1145
+ );
1146
+ if (r.changes > 0) messagesImported++;
1147
+ }
1148
+ }
1149
+ });
1150
+
1151
+ importAll();
1152
+ send(res, 200, { ok: true, threadsImported, messagesImported });
1153
+ }
1154
+
1155
+ // --- File Upload ---
1156
+
1157
+ async function handleUpload(req, res, params) {
1158
+ const db = getActiveDb();
1159
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
1160
+ if (!thread) return sendError(res, 404, 'Thread not found');
1161
+
1162
+ const files = await parseMultipart(req);
1163
+ const threadUploadDir = path.join(UPLOADS_DIR, params.id);
1164
+ fs.mkdirSync(threadUploadDir, { recursive: true });
1165
+
1166
+ const savedFiles = [];
1167
+ for (const file of files) {
1168
+ const fileId = uuid();
1169
+ const ext = path.extname(file.filename) || '';
1170
+ const savedName = fileId + ext;
1171
+ const filePath = path.join(threadUploadDir, savedName);
1172
+ fs.writeFileSync(filePath, file.data);
1173
+ savedFiles.push({
1174
+ id: fileId,
1175
+ filename: file.filename,
1176
+ path: `/api/uploads/${params.id}/${fileId}${ext}`,
1177
+ mimeType: file.mimeType,
1178
+ size: file.data.length,
1179
+ });
1180
+ }
1181
+
1182
+ send(res, 200, { files: savedFiles });
1183
+ }
1184
+
1185
+ function handleServeUpload(req, res, params) {
1186
+ const filePath = path.join(UPLOADS_DIR, params.threadId, params.fileId);
1187
+ // Also try with common extensions
1188
+ let resolved = filePath;
1189
+ if (!fs.existsSync(resolved)) {
1190
+ // Scan directory for file starting with fileId
1191
+ const dir = path.join(UPLOADS_DIR, params.threadId);
1192
+ try {
1193
+ const entries = fs.readdirSync(dir);
1194
+ const match = entries.find(e => e.startsWith(params.fileId.replace(/\.[^.]+$/, '')));
1195
+ if (match) resolved = path.join(dir, match);
1196
+ } catch { /* dir not found */ }
1197
+ }
1198
+
1199
+ if (!fs.existsSync(resolved)) {
1200
+ return sendError(res, 404, 'File not found');
1201
+ }
1202
+
1203
+ const ext = path.extname(resolved).toLowerCase();
1204
+ const mimeTypes = {
1205
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1206
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
1207
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
1208
+ };
1209
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
1210
+
1211
+ const stat = fs.statSync(resolved);
1212
+ res.writeHead(200, {
1213
+ 'Content-Type': contentType,
1214
+ 'Content-Length': stat.size,
1215
+ 'Cache-Control': 'public, max-age=86400',
1216
+ 'Access-Control-Allow-Origin': '*',
1217
+ });
1218
+ fs.createReadStream(resolved).pipe(res);
1219
+ }
1220
+
1221
+ // ─── Intelligence (per-thread, per-workspace) ──────────────────────────────
1222
+
1223
+ const INTELLIGENCE_DIR = path.join(DATA_DIR, 'intelligence');
1224
+
1225
+ function getIntelligencePath(threadId) {
1226
+ const workspace = getWorkspaces().active;
1227
+ return path.join(INTELLIGENCE_DIR, workspace, `${threadId}.json`);
1228
+ }
1229
+
1230
+ function handleGetIntelligence(req, res, params) {
1231
+ const filePath = getIntelligencePath(params.id);
1232
+ if (!fs.existsSync(filePath)) {
1233
+ return send(res, 200, { versions: [], currentVersion: -1 });
1234
+ }
1235
+ try {
1236
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
1237
+ return send(res, 200, data);
1238
+ } catch (err) {
1239
+ return send(res, 200, { versions: [], currentVersion: -1 });
1240
+ }
1241
+ }
1242
+
1243
+ async function handleSaveIntelligence(req, res, params) {
1244
+ const body = await parseBody(req);
1245
+ const filePath = getIntelligencePath(params.id);
1246
+ const dir = path.dirname(filePath);
1247
+ fs.mkdirSync(dir, { recursive: true });
1248
+
1249
+ // body should have { versions, currentVersion }
1250
+ const data = {
1251
+ versions: body.versions || [],
1252
+ currentVersion: body.currentVersion ?? -1,
1253
+ updatedAt: Date.now()
1254
+ };
1255
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
1256
+ return send(res, 200, data);
1257
+ }
1258
+
1259
+ // ─── File Serving (restricted to ~/.openclaw/media/) ────────────────────────
1260
+
1261
+ const ALLOWED_FILE_DIRS = [
1262
+ path.join(HOME, '.openclaw', 'media'),
1263
+ path.join(HOME, '.openclaw', 'workspace'),
1264
+ '/tmp',
1265
+ ];
1266
+
1267
+ function handleServeFile(req, res, query) {
1268
+ const filePath = query.path;
1269
+ if (!filePath) return sendError(res, 400, 'Missing path parameter');
1270
+
1271
+ // Resolve to prevent traversal attacks
1272
+ const resolved = path.resolve(filePath);
1273
+
1274
+ // Security: only serve files from allowed directories
1275
+ const allowed = ALLOWED_FILE_DIRS.some(dir => resolved.startsWith(dir + '/') || resolved === dir);
1276
+ if (!allowed) {
1277
+ return sendError(res, 403, 'Access denied: path not in allowed directories');
1278
+ }
1279
+
1280
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
1281
+ return sendError(res, 404, 'File not found');
1282
+ }
1283
+
1284
+ const ext = path.extname(resolved).toLowerCase();
1285
+ const mimeTypes = {
1286
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1287
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
1288
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
1289
+ '.md': 'text/markdown', '.csv': 'text/csv', '.xml': 'text/xml',
1290
+ '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
1291
+ '.py': 'text/x-python', '.sh': 'text/x-shellscript',
1292
+ '.yaml': 'text/yaml', '.yml': 'text/yaml', '.toml': 'text/toml',
1293
+ '.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar',
1294
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1295
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1296
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
1297
+ '.mp4': 'video/mp4', '.webm': 'video/webm',
1298
+ };
1299
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
1300
+
1301
+ const stat = fs.statSync(resolved);
1302
+ res.writeHead(200, {
1303
+ 'Content-Type': contentType,
1304
+ 'Content-Length': stat.size,
1305
+ 'Cache-Control': 'public, max-age=86400',
1306
+ 'Access-Control-Allow-Origin': '*',
1307
+ });
1308
+ fs.createReadStream(resolved).pipe(res);
1309
+ }
1310
+
1311
+ // ─── Workspace File Browser ─────────────────────────────────────────────────
1312
+
1313
+ const WORKSPACE_ROOT = HOME;
1314
+
1315
+ function handleWorkspaceList(req, res, query) {
1316
+ const reqPath = query.path || '~/.openclaw/workspace';
1317
+ const depth = parseInt(query.depth || '2', 10);
1318
+ const showHidden = query.hidden === '1' || query.hidden === 'true';
1319
+
1320
+ // Resolve ~ to HOME
1321
+ const resolved = path.resolve(reqPath.replace(/^~/, HOME));
1322
+
1323
+ // Security: only allow listing under home directory
1324
+ if (!resolved.startsWith(HOME)) {
1325
+ return sendError(res, 403, 'Access denied');
1326
+ }
1327
+
1328
+ if (!fs.existsSync(resolved)) {
1329
+ return sendError(res, 404, 'Path not found');
1330
+ }
1331
+
1332
+ const files = [];
1333
+
1334
+ function walk(dir, currentDepth) {
1335
+ if (currentDepth > depth) return;
1336
+ try {
1337
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1338
+ for (const entry of entries) {
1339
+ // Skip hidden files unless showHidden (always show .openclaw)
1340
+ if (entry.name.startsWith('.') && entry.name !== '.openclaw' && !showHidden) continue;
1341
+ if (entry.name === 'node_modules') continue;
1342
+
1343
+ const fullPath = path.join(dir, entry.name);
1344
+ const isDir = entry.isDirectory();
1345
+ files.push({
1346
+ path: fullPath + (isDir ? '/' : ''),
1347
+ type: isDir ? 'dir' : 'file',
1348
+ name: entry.name,
1349
+ size: isDir ? 0 : (() => { try { return fs.statSync(fullPath).size; } catch { return 0; } })(),
1350
+ });
1351
+ if (isDir) walk(fullPath, currentDepth + 1);
1352
+ }
1353
+ } catch { /* permission denied, etc. */ }
1354
+ }
1355
+
1356
+ // Add root entry
1357
+ files.unshift({ path: resolved + '/', type: 'dir', name: path.basename(resolved), size: 0 });
1358
+ walk(resolved, 1);
1359
+
1360
+ send(res, 200, { files, cwd: resolved });
1361
+ }
1362
+
1363
+ function handleWorkspaceFileRead(req, res, query) {
1364
+ const filePath = query.path;
1365
+ if (!filePath) return sendError(res, 400, 'Missing path parameter');
1366
+
1367
+ const resolved = path.resolve(filePath.replace(/^~/, HOME));
1368
+
1369
+ // Security: only allow reading under home directory
1370
+ if (!resolved.startsWith(HOME)) {
1371
+ return sendError(res, 403, 'Access denied');
1372
+ }
1373
+
1374
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
1375
+ return sendError(res, 404, 'File not found');
1376
+ }
1377
+
1378
+ // Limit file size to 1MB
1379
+ const stat = fs.statSync(resolved);
1380
+ if (stat.size > 1024 * 1024) {
1381
+ return sendError(res, 413, 'File too large (max 1MB)');
1382
+ }
1383
+
1384
+ const content = fs.readFileSync(resolved, 'utf8');
1385
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1386
+ res.end(content);
1387
+ }
1388
+
1389
+ async function handleWorkspaceFileWrite(req, res, query) {
1390
+ const filePath = query.path;
1391
+ if (!filePath) return sendError(res, 400, 'Missing path parameter');
1392
+
1393
+ const resolved = path.resolve(filePath.replace(/^~/, HOME));
1394
+
1395
+ // Security: only allow writing under workspace
1396
+ if (!resolved.startsWith(WORKSPACE_ROOT)) {
1397
+ return sendError(res, 403, 'Can only write to workspace directory');
1398
+ }
1399
+
1400
+ // Read body as text
1401
+ const chunks = [];
1402
+ for await (const chunk of req) chunks.push(chunk);
1403
+ const content = Buffer.concat(chunks).toString('utf8');
1404
+
1405
+ // Ensure parent directory exists
1406
+ const dir = path.dirname(resolved);
1407
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1408
+
1409
+ fs.writeFileSync(resolved, content, 'utf8');
1410
+ send(res, 200, { ok: true });
1411
+ }
1412
+
1413
+ async function handleWorkspaceUpload(req, res, query) {
1414
+ const targetDir = query.path;
1415
+ if (!targetDir) return sendError(res, 400, 'Missing path parameter');
1416
+
1417
+ const resolved = path.resolve(targetDir.replace(/^~/, HOME));
1418
+
1419
+ // Security: only allow uploading under home directory
1420
+ if (!resolved.startsWith(HOME)) {
1421
+ return sendError(res, 403, 'Access denied');
1422
+ }
1423
+
1424
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
1425
+ return sendError(res, 404, 'Target directory not found');
1426
+ }
1427
+
1428
+ // Parse multipart form data manually (no dependency needed for simple case)
1429
+ const contentType = req.headers['content-type'] || '';
1430
+ if (!contentType.includes('multipart/form-data')) {
1431
+ return sendError(res, 400, 'Expected multipart/form-data');
1432
+ }
1433
+
1434
+ const boundary = contentType.split('boundary=')[1];
1435
+ if (!boundary) return sendError(res, 400, 'Missing boundary');
1436
+
1437
+ const chunks = [];
1438
+ for await (const chunk of req) chunks.push(chunk);
1439
+ const body = Buffer.concat(chunks);
1440
+
1441
+ // Parse multipart parts
1442
+ const boundaryBuf = Buffer.from('--' + boundary);
1443
+ const uploaded = [];
1444
+ let start = 0;
1445
+
1446
+ while (true) {
1447
+ const idx = body.indexOf(boundaryBuf, start);
1448
+ if (idx === -1) break;
1449
+
1450
+ if (start > 0) {
1451
+ // Extract the part between previous boundary and this one
1452
+ const partData = body.slice(start, idx - 2); // -2 for \r\n before boundary
1453
+ const headerEnd = partData.indexOf('\r\n\r\n');
1454
+ if (headerEnd !== -1) {
1455
+ const headerStr = partData.slice(0, headerEnd).toString('utf8');
1456
+ const fileContent = partData.slice(headerEnd + 4);
1457
+
1458
+ const filenameMatch = headerStr.match(/filename="([^"]+)"/);
1459
+ if (filenameMatch && fileContent.length > 0) {
1460
+ const filename = path.basename(filenameMatch[1]); // sanitize
1461
+ const filePath = path.join(resolved, filename);
1462
+
1463
+ // Don't overwrite — add suffix if exists
1464
+ let finalPath = filePath;
1465
+ let counter = 1;
1466
+ while (fs.existsSync(finalPath)) {
1467
+ const ext = path.extname(filename);
1468
+ const base = path.basename(filename, ext);
1469
+ finalPath = path.join(resolved, `${base} (${counter})${ext}`);
1470
+ counter++;
1471
+ }
1472
+
1473
+ fs.writeFileSync(finalPath, fileContent);
1474
+ uploaded.push({ name: path.basename(finalPath), size: fileContent.length });
1475
+ }
1476
+ }
1477
+ }
1478
+
1479
+ start = idx + boundaryBuf.length + 2; // +2 for \r\n after boundary
1480
+ }
1481
+
1482
+ send(res, 200, { ok: true, uploaded });
1483
+ }
1484
+
1485
+ // ─── Memory (configurable backend) ──────────────────────────────────────────
1486
+
1487
+ function discoverMemoryConfig() {
1488
+ // Priority: env vars → openclaw.json → defaults
1489
+ const defaults = { provider: 'qdrant', host: 'localhost', port: 6333, collection: null };
1490
+
1491
+ // Try reading openclaw.json
1492
+ let oc = null;
1493
+ const candidates = [
1494
+ path.join(os.homedir(), '.openclaw', 'openclaw.json'),
1495
+ '/etc/openclaw/openclaw.json'
1496
+ ];
1497
+ for (const cfgPath of candidates) {
1498
+ try {
1499
+ oc = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
1500
+ break;
1501
+ } catch {}
1502
+ }
1503
+
1504
+ let cfg = { ...defaults };
1505
+
1506
+ if (oc) {
1507
+ const memSlot = oc.plugins?.slots?.memory;
1508
+ const vs = memSlot ? oc.plugins?.entries?.[memSlot]?.config?.oss?.vectorStore : null;
1509
+ if (vs) {
1510
+ if (vs.provider) cfg.provider = vs.provider;
1511
+ if (vs.config?.host) cfg.host = vs.config.host;
1512
+ if (vs.config?.port) cfg.port = vs.config.port;
1513
+ if (vs.config?.collectionName) cfg.collection = vs.config.collectionName;
1514
+ // Postgres-specific fields
1515
+ if (vs.config?.user) cfg.pgUser = vs.config.user;
1516
+ if (vs.config?.password) cfg.pgPassword = vs.config.password;
1517
+ if (vs.config?.dbname) cfg.pgDbName = vs.config.dbname;
1518
+ }
1519
+
1520
+ // Derive workspace dir from openclaw.json
1521
+ const wsDir = oc.agents?.defaults?.workspace;
1522
+ if (wsDir) cfg.workspaceDir = wsDir;
1523
+ }
1524
+
1525
+ // Env var overrides (MEMORY_* take priority, then Qdrant-specific fallbacks)
1526
+ if (process.env.MEMORY_PROVIDER) cfg.provider = process.env.MEMORY_PROVIDER;
1527
+ if (process.env.MEMORY_HOST || process.env.QDRANT_HOST) cfg.host = process.env.MEMORY_HOST || process.env.QDRANT_HOST;
1528
+ if (process.env.MEMORY_PORT || process.env.QDRANT_PORT) cfg.port = parseInt(process.env.MEMORY_PORT || process.env.QDRANT_PORT, 10);
1529
+ if (process.env.MEMORY_COLLECTION || process.env.QDRANT_COLLECTION) cfg.collection = process.env.MEMORY_COLLECTION || process.env.QDRANT_COLLECTION;
1530
+ if (process.env.MEMORY_PG_URL) cfg.pgUrl = process.env.MEMORY_PG_URL;
1531
+ // QDRANT_URL (e.g. "http://myhost:6333") — parse host and port from it
1532
+ if (process.env.QDRANT_URL && !process.env.MEMORY_HOST) {
1533
+ try {
1534
+ const u = new URL(process.env.QDRANT_URL);
1535
+ cfg.host = u.hostname;
1536
+ if (u.port) cfg.port = parseInt(u.port, 10);
1537
+ } catch {}
1538
+ }
1539
+
1540
+ if (!cfg.workspaceDir) {
1541
+ cfg.workspaceDir = path.join(os.homedir(), '.openclaw', 'workspace');
1542
+ }
1543
+
1544
+ return cfg;
1545
+ }
1546
+
1547
+ const MEMORY_CONFIG = discoverMemoryConfig();
1548
+
1549
+ // Auto-detect Qdrant collection if not explicitly configured
1550
+ async function autoDetectQdrantCollection(config) {
1551
+ if (config.collection) return config.collection;
1552
+ const baseUrl = `http://${config.host}:${config.port}`;
1553
+ try {
1554
+ const r = await fetch(`${baseUrl}/collections`, { signal: AbortSignal.timeout(3000) });
1555
+ const data = await r.json();
1556
+ const collections = (data.result?.collections || []).map(c => c.name);
1557
+ const found = collections.find(name => !name.includes('migration'));
1558
+ if (found) {
1559
+ console.log(`Memory: auto-detected Qdrant collection "${found}"`);
1560
+ return found;
1561
+ }
1562
+ } catch {}
1563
+ console.log('Memory: Qdrant unreachable or no collections, falling back to "memories"');
1564
+ return 'memories';
1565
+ }
1566
+
1567
+ // ─── Memory Providers ─────────────────────────────────────────────────────────
1568
+
1569
+ function createQdrantProvider(config) {
1570
+ const baseUrl = `http://${config.host}:${config.port}`;
1571
+ let collection = config.collection; // may be null until init()
1572
+
1573
+ return {
1574
+ name: 'qdrant',
1575
+ config,
1576
+ async init() {
1577
+ collection = await autoDetectQdrantCollection(config);
1578
+ config.collection = collection;
1579
+ },
1580
+ async list(limit, offset) {
1581
+ const body = { limit, with_payload: true, with_vector: false };
1582
+ if (offset) body.offset = offset;
1583
+ const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, {
1584
+ method: 'POST',
1585
+ headers: { 'Content-Type': 'application/json' },
1586
+ body: JSON.stringify(body),
1587
+ });
1588
+ const data = await r.json();
1589
+ const points = data.result?.points || [];
1590
+ return {
1591
+ memories: points.map(p => ({ id: p.id, ...p.payload })),
1592
+ next_offset: data.result?.next_page_offset || null,
1593
+ };
1594
+ },
1595
+ async search(query) {
1596
+ const q = query.toLowerCase();
1597
+ const matches = [];
1598
+ let offset = null;
1599
+ do {
1600
+ const body = { limit: 100, with_payload: true, with_vector: false };
1601
+ if (offset) body.offset = offset;
1602
+ const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, {
1603
+ method: 'POST',
1604
+ headers: { 'Content-Type': 'application/json' },
1605
+ body: JSON.stringify(body),
1606
+ });
1607
+ const data = await r.json();
1608
+ const points = data.result?.points || [];
1609
+ for (const p of points) {
1610
+ if ((p.payload?.data || '').toLowerCase().includes(q)) {
1611
+ matches.push({ id: p.id, ...p.payload });
1612
+ }
1613
+ }
1614
+ offset = data.result?.next_page_offset || null;
1615
+ } while (offset);
1616
+ return { memories: matches, next_offset: null };
1617
+ },
1618
+ async update(id, newData) {
1619
+ const r = await fetch(`${baseUrl}/collections/${collection}/points/payload`, {
1620
+ method: 'POST',
1621
+ headers: { 'Content-Type': 'application/json' },
1622
+ body: JSON.stringify({ points: [id], payload: { data: newData } }),
1623
+ });
1624
+ const data = await r.json();
1625
+ if (data.status?.error) throw new Error(data.status.error);
1626
+ return data.result;
1627
+ },
1628
+ async delete(id) {
1629
+ const r = await fetch(`${baseUrl}/collections/${collection}/points/delete`, {
1630
+ method: 'POST',
1631
+ headers: { 'Content-Type': 'application/json' },
1632
+ body: JSON.stringify({ points: [id] }),
1633
+ });
1634
+ const data = await r.json();
1635
+ return data.result;
1636
+ },
1637
+ async status() {
1638
+ try {
1639
+ const r = await fetch(`${baseUrl}/collections/${collection}`, { signal: AbortSignal.timeout(3000) });
1640
+ const data = await r.json();
1641
+ return { reachable: true, pointsCount: data.result?.points_count ?? null };
1642
+ } catch {
1643
+ return { reachable: false };
1644
+ }
1645
+ },
1646
+ };
1647
+ }
1648
+
1649
+ function createPgProvider(config) {
1650
+ // Lazy-load pg — no crash if not installed and user runs Qdrant
1651
+ // NOTE: Postgres schema based on mem0 SDK pgvector patterns; needs verification against a real setup.
1652
+ let _pool = null;
1653
+ const table = config.collection || 'memories';
1654
+
1655
+ async function getPool() {
1656
+ if (_pool) return _pool;
1657
+ let pg;
1658
+ try {
1659
+ pg = await import('pg');
1660
+ } catch {
1661
+ throw new Error('pg package not installed. Run: npm install pg');
1662
+ }
1663
+ const Pool = pg.default?.Pool || pg.Pool;
1664
+ if (config.pgUrl) {
1665
+ _pool = new Pool({ connectionString: config.pgUrl });
1666
+ } else {
1667
+ _pool = new Pool({
1668
+ host: config.host,
1669
+ port: config.port || 5432,
1670
+ user: config.pgUser || 'mem0',
1671
+ password: config.pgPassword || '',
1672
+ database: config.pgDbName || 'mem0',
1673
+ });
1674
+ }
1675
+ return _pool;
1676
+ }
1677
+
1678
+ return {
1679
+ name: 'postgres',
1680
+ config,
1681
+ async init() { /* pool created lazily on first query */ },
1682
+ async list(limit, offset) {
1683
+ const pool = await getPool();
1684
+ const offsetVal = offset ? parseInt(offset, 10) : 0;
1685
+ const { rows } = await pool.query(
1686
+ `SELECT id, payload FROM ${table} ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
1687
+ [limit, offsetVal]
1688
+ );
1689
+ return {
1690
+ memories: rows.map(r => ({ id: r.id, ...(typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) })),
1691
+ next_offset: rows.length === limit ? offsetVal + limit : null,
1692
+ };
1693
+ },
1694
+ async search(query) {
1695
+ const pool = await getPool();
1696
+ const { rows } = await pool.query(
1697
+ `SELECT id, payload FROM ${table} WHERE payload->>'data' ILIKE $1 LIMIT 100`,
1698
+ [`%${query}%`]
1699
+ );
1700
+ return {
1701
+ memories: rows.map(r => ({ id: r.id, ...(typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) })),
1702
+ next_offset: null,
1703
+ };
1704
+ },
1705
+ async update(id, newData) {
1706
+ const pool = await getPool();
1707
+ const { rowCount } = await pool.query(
1708
+ `UPDATE ${table} SET payload = jsonb_set(payload, '{data}', $1::jsonb) WHERE id = $2`,
1709
+ [JSON.stringify(newData), id]
1710
+ );
1711
+ if (rowCount === 0) throw new Error('Memory not found');
1712
+ return { updated: true };
1713
+ },
1714
+ async delete(id) {
1715
+ const pool = await getPool();
1716
+ await pool.query(`DELETE FROM ${table} WHERE id = $1`, [id]);
1717
+ return { deleted: true };
1718
+ },
1719
+ async status() {
1720
+ try {
1721
+ const pool = await getPool();
1722
+ const { rows } = await pool.query(`SELECT COUNT(*) as count FROM ${table}`);
1723
+ return { reachable: true, pointsCount: parseInt(rows[0].count, 10) };
1724
+ } catch (err) {
1725
+ return { reachable: false, error: err.message };
1726
+ }
1727
+ },
1728
+ };
1729
+ }
1730
+
1731
+ function createMemoryProvider(config) {
1732
+ if (config.provider === 'postgres' || config.provider === 'pgvector')
1733
+ return createPgProvider(config);
1734
+ return createQdrantProvider(config);
1735
+ }
1736
+
1737
+ const memoryProvider = createMemoryProvider(MEMORY_CONFIG);
1738
+ // Initialize provider (auto-detect collection etc.) — runs async at startup
1739
+ memoryProvider.init().catch(err => console.error('Memory provider init error:', err.message));
1740
+
1741
+ console.log(`Memory: provider=${MEMORY_CONFIG.provider} host=${MEMORY_CONFIG.host}:${MEMORY_CONFIG.port} collection=${MEMORY_CONFIG.collection || '(auto-detect)'}`);
1742
+
1743
+ // ─── Memory Handlers ────────────────────────────────────────────────────────
1744
+
1745
+ async function handleMemoryList(req, res, query) {
1746
+ const limit = Math.min(parseInt(query.limit) || 20, 100);
1747
+ try {
1748
+ const result = await memoryProvider.list(limit, query.offset || null);
1749
+ send(res, 200, result);
1750
+ } catch (err) {
1751
+ send(res, 502, { error: `Failed to reach ${memoryProvider.name}`, detail: err.message });
1752
+ }
1753
+ }
1754
+
1755
+ async function handleMemorySearch(req, res, query) {
1756
+ const q = (query.query || '').toLowerCase().trim();
1757
+ if (!q) return send(res, 400, { error: 'Missing query parameter' });
1758
+ try {
1759
+ const result = await memoryProvider.search(q);
1760
+ send(res, 200, result);
1761
+ } catch (err) {
1762
+ send(res, 502, { error: `Failed to reach ${memoryProvider.name}`, detail: err.message });
1763
+ }
1764
+ }
1765
+
1766
+ const MEMORY_FILES_DIR = path.join(MEMORY_CONFIG.workspaceDir, 'memory');
1767
+
1768
+ function parseMemoryFiles() {
1769
+ const memories = [];
1770
+ let files;
1771
+ try {
1772
+ files = fs.readdirSync(MEMORY_FILES_DIR);
1773
+ } catch { return memories; }
1774
+
1775
+ for (const file of files) {
1776
+ if (!file.endsWith('.md')) continue;
1777
+ const filePath = path.join(MEMORY_FILES_DIR, file);
1778
+ let stat;
1779
+ try { stat = fs.statSync(filePath); } catch { continue; }
1780
+ if (!stat.isFile()) continue;
1781
+
1782
+ const content = fs.readFileSync(filePath, 'utf8');
1783
+ const basename = file.replace(/\.md$/, '');
1784
+
1785
+ // Extract date from filename (e.g. 2026-02-14 or 2026-02-09-topic-name)
1786
+ const dateMatch = basename.match(/^(\d{4}-\d{2}-\d{2})/);
1787
+ const fileDate = dateMatch ? dateMatch[1] : null;
1788
+
1789
+ // Split by ## headings into sections
1790
+ const sections = content.split(/^(?=## )/m);
1791
+ for (const section of sections) {
1792
+ const trimmed = section.trim();
1793
+ if (!trimmed) continue;
1794
+
1795
+ // Extract heading
1796
+ const headingMatch = trimmed.match(/^##\s+(.+)/);
1797
+ const heading = headingMatch ? headingMatch[1].trim() : null;
1798
+ // Body is everything after heading line (or whole section if top-level)
1799
+ const body = headingMatch ? trimmed.slice(trimmed.indexOf('\n') + 1).trim() : trimmed;
1800
+
1801
+ // Skip if it's just the top-level # heading with no real content
1802
+ if (!heading && body.match(/^#\s+/) && body.split('\n').length <= 2) continue;
1803
+
1804
+ const title = heading || basename;
1805
+ const id = `file:${basename}:${title}`;
1806
+ memories.push({
1807
+ id,
1808
+ source: 'file',
1809
+ file: basename,
1810
+ title,
1811
+ data: heading ? `**${title}**\n${body}` : body,
1812
+ createdAt: fileDate ? `${fileDate}T00:00:00Z` : stat.mtime.toISOString(),
1813
+ });
1814
+ }
1815
+ }
1816
+
1817
+ // Also scan subdirectories one level deep
1818
+ try {
1819
+ for (const entry of fs.readdirSync(MEMORY_FILES_DIR)) {
1820
+ const subdir = path.join(MEMORY_FILES_DIR, entry);
1821
+ if (!fs.statSync(subdir).isDirectory()) continue;
1822
+ for (const file of fs.readdirSync(subdir)) {
1823
+ if (!file.endsWith('.md')) continue;
1824
+ const filePath = path.join(subdir, file);
1825
+ const content = fs.readFileSync(filePath, 'utf8');
1826
+ const basename = file.replace(/\.md$/, '');
1827
+ const relPath = `${entry}/${basename}`;
1828
+ const stat = fs.statSync(filePath);
1829
+
1830
+ memories.push({
1831
+ id: `file:${relPath}`,
1832
+ source: 'file',
1833
+ file: relPath,
1834
+ title: basename,
1835
+ data: content.trim(),
1836
+ createdAt: stat.mtime.toISOString(),
1837
+ });
1838
+ }
1839
+ }
1840
+ } catch { /* ignore */ }
1841
+
1842
+ return memories;
1843
+ }
1844
+
1845
+ function handleMemoryFiles(req, res, query) {
1846
+ const q = (query.query || '').toLowerCase().trim();
1847
+ const memories = parseMemoryFiles();
1848
+ const filtered = q
1849
+ ? memories.filter(m => m.data.toLowerCase().includes(q) || m.title.toLowerCase().includes(q))
1850
+ : memories;
1851
+ // Sort newest first
1852
+ filtered.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
1853
+ send(res, 200, { memories: filtered });
1854
+ }
1855
+
1856
+ async function handleMemoryUpdate(req, res, params) {
1857
+ const id = params.id;
1858
+ try {
1859
+ const chunks = [];
1860
+ for await (const chunk of req) chunks.push(chunk);
1861
+ const body = JSON.parse(Buffer.concat(chunks).toString());
1862
+ const newData = (body.data || '').trim();
1863
+ if (!newData) return send(res, 400, { error: 'Missing data field' });
1864
+
1865
+ const result = await memoryProvider.update(id, newData);
1866
+ send(res, 200, { ok: true, result });
1867
+ } catch (err) {
1868
+ send(res, 502, { error: 'Failed to update memory', detail: err.message });
1869
+ }
1870
+ }
1871
+
1872
+ async function handleMemoryDelete(req, res, params) {
1873
+ const id = params.id;
1874
+ try {
1875
+ const result = await memoryProvider.delete(id);
1876
+ send(res, 200, { ok: true, result });
1877
+ } catch (err) {
1878
+ send(res, 502, { error: `Failed to reach ${memoryProvider.name}`, detail: err.message });
1879
+ }
1880
+ }
1881
+
1882
+ async function handleMemoryStatus(req, res) {
1883
+ const status = await memoryProvider.status();
1884
+ const filesExist = fs.existsSync(MEMORY_FILES_DIR);
1885
+ send(res, 200, {
1886
+ provider: memoryProvider.name,
1887
+ host: MEMORY_CONFIG.host,
1888
+ port: MEMORY_CONFIG.port,
1889
+ collection: MEMORY_CONFIG.collection,
1890
+ backend: status,
1891
+ memoryFilesDir: MEMORY_FILES_DIR,
1892
+ memoryFilesDirExists: filesExist,
1893
+ });
1894
+ }
1895
+
1896
+ // ─── Router ─────────────────────────────────────────────────────────────────
1897
+
1898
+ // ─── Speech-to-text (Whisper API proxy) ─────────────────────────────────────
1899
+
1900
+ async function handleTranscribe(req, res) {
1901
+ try {
1902
+ const chunks = [];
1903
+ for await (const chunk of req) chunks.push(chunk);
1904
+ const audioBuffer = Buffer.concat(chunks);
1905
+
1906
+ if (audioBuffer.length === 0) return send(res, 400, { error: 'No audio data' });
1907
+ if (audioBuffer.length > 25 * 1024 * 1024) return send(res, 400, { error: 'Audio too large (max 25MB)' });
1908
+
1909
+ // Read OpenAI API key from OpenClaw config
1910
+ let apiKey;
1911
+ try {
1912
+ const ocConfig = JSON.parse(fs.readFileSync(
1913
+ path.join(os.homedir(), '.openclaw', 'openclaw.json'), 'utf8'));
1914
+ apiKey = ocConfig?.skills?.entries?.['openai-whisper-api']?.apiKey;
1915
+ } catch {}
1916
+ if (!apiKey) apiKey = process.env.OPENAI_API_KEY;
1917
+ if (!apiKey) return send(res, 500, { error: 'No OpenAI API key configured' });
1918
+
1919
+ const contentType = req.headers['content-type'] || 'audio/webm';
1920
+ const ext = contentType.includes('wav') ? 'wav'
1921
+ : contentType.includes('mp4') || contentType.includes('m4a') ? 'm4a'
1922
+ : contentType.includes('ogg') ? 'ogg' : 'webm';
1923
+
1924
+ const boundary = '----WhisperBoundary' + Date.now();
1925
+ const parts = [
1926
+ `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.${ext}"\r\nContent-Type: ${contentType}\r\n\r\n`,
1927
+ audioBuffer,
1928
+ `\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1\r\n`,
1929
+ `--${boundary}\r\nContent-Disposition: form-data; name="response_format"\r\n\r\njson\r\n`,
1930
+ `--${boundary}--\r\n`
1931
+ ];
1932
+ const body = Buffer.concat(parts.map(p => typeof p === 'string' ? Buffer.from(p) : p));
1933
+
1934
+ const resp = await fetch('https://api.openai.com/v1/audio/transcriptions', {
1935
+ method: 'POST',
1936
+ headers: {
1937
+ 'Authorization': `Bearer ${apiKey}`,
1938
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
1939
+ },
1940
+ body
1941
+ });
1942
+
1943
+ if (!resp.ok) {
1944
+ const errText = await resp.text();
1945
+ console.error('Whisper API error:', resp.status, errText);
1946
+ return send(res, 502, { error: `Whisper API error: ${resp.status}` });
1947
+ }
1948
+
1949
+ const result = await resp.json();
1950
+ return send(res, 200, { text: result.text || '' });
1951
+ } catch (err) {
1952
+ console.error('Transcribe error:', err);
1953
+ return send(res, 500, { error: err.message });
1954
+ }
1955
+ }
1956
+
1957
+ async function handleRequest(req, res) {
1958
+ // Parse URL and query string
1959
+ const [urlPath, queryString] = (req.url || '/').split('?');
1960
+ const query = {};
1961
+ if (queryString) {
1962
+ for (const pair of queryString.split('&')) {
1963
+ const [k, v] = pair.split('=');
1964
+ if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '');
1965
+ }
1966
+ }
1967
+ const method = req.method;
1968
+
1969
+ // CORS preflight
1970
+ if (method === 'OPTIONS') {
1971
+ setCors(res);
1972
+ res.writeHead(204);
1973
+ return res.end();
1974
+ }
1975
+
1976
+ // Serve static assets (no auth — browser authenticates via WS/API headers)
1977
+ if (method === 'GET' && !urlPath.startsWith('/api/')) {
1978
+ const STATIC_FILES = {
1979
+ '/': 'index.html',
1980
+ '/index.html': 'index.html',
1981
+ '/app.js': 'app.js',
1982
+ '/style.css': 'style.css',
1983
+ '/error-handler.js': 'error-handler.js',
1984
+ '/manifest.json': 'manifest.json',
1985
+ '/favicon.ico': 'favicon.ico',
1986
+ };
1987
+ const fileName = STATIC_FILES[urlPath];
1988
+ // Also serve /icons/*, /lib/*, /frontend/*, and /config.js
1989
+ const isIcon = urlPath.startsWith('/icons/');
1990
+ const isLib = urlPath.startsWith('/lib/');
1991
+ const isFrontend = urlPath.startsWith('/frontend/');
1992
+ const isConfig = urlPath === '/config.js';
1993
+ const staticPath = fileName ? path.join(__dirname, fileName)
1994
+ : (isIcon || isLib || isFrontend || isConfig) ? path.join(__dirname, urlPath.slice(1))
1995
+ : null;
1996
+ if (staticPath && fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
1997
+ const ext = path.extname(staticPath).toLowerCase();
1998
+ const mimeMap = {
1999
+ '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
2000
+ '.json': 'application/json', '.ico': 'image/x-icon',
2001
+ '.png': 'image/png', '.svg': 'image/svg+xml',
2002
+ };
2003
+ const ct = mimeMap[ext] || 'application/octet-stream';
2004
+ const stat = fs.statSync(staticPath);
2005
+ res.writeHead(200, {
2006
+ 'Content-Type': ct,
2007
+ 'Content-Length': stat.size,
2008
+ 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600',
2009
+ });
2010
+ return fs.createReadStream(staticPath).pipe(res);
2011
+ }
2012
+ }
2013
+
2014
+ // Serve uploaded files (no auth required — files are accessed by URL)
2015
+ let p;
2016
+ if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) {
2017
+ return handleServeUpload(req, res, p);
2018
+ }
2019
+
2020
+ // Auth check for all other routes
2021
+ if (!checkAuth(req, res)) return;
2022
+
2023
+ try {
2024
+ // --- File serving (absolute paths from gateway) ---
2025
+ if (method === 'GET' && urlPath === '/api/file') {
2026
+ return handleServeFile(req, res, query);
2027
+ }
2028
+
2029
+ // --- Workspace file browser ---
2030
+ if (method === 'GET' && urlPath === '/api/workspace') {
2031
+ return handleWorkspaceList(req, res, query);
2032
+ }
2033
+ if (method === 'GET' && urlPath === '/api/workspace/file') {
2034
+ return handleWorkspaceFileRead(req, res, query);
2035
+ }
2036
+ if (method === 'PUT' && urlPath === '/api/workspace/file') {
2037
+ return await handleWorkspaceFileWrite(req, res, query);
2038
+ }
2039
+ if (method === 'POST' && urlPath === '/api/workspace/upload') {
2040
+ return await handleWorkspaceUpload(req, res, query);
2041
+ }
2042
+
2043
+ // --- Memory (configurable backend) ---
2044
+ if (method === 'GET' && urlPath === '/api/memory/status') {
2045
+ return await handleMemoryStatus(req, res);
2046
+ }
2047
+ if (method === 'GET' && urlPath === '/api/memory/list') {
2048
+ return await handleMemoryList(req, res, query);
2049
+ }
2050
+ if (method === 'GET' && urlPath === '/api/memory/search') {
2051
+ return await handleMemorySearch(req, res, query);
2052
+ }
2053
+ if (method === 'GET' && urlPath === '/api/memory/files') {
2054
+ return handleMemoryFiles(req, res, query);
2055
+ }
2056
+ if ((p = matchRoute(method, urlPath, 'PUT /api/memory/:id'))) {
2057
+ return await handleMemoryUpdate(req, res, p);
2058
+ }
2059
+ if ((p = matchRoute(method, urlPath, 'DELETE /api/memory/:id'))) {
2060
+ return await handleMemoryDelete(req, res, p);
2061
+ }
2062
+
2063
+ // --- User settings ---
2064
+ if (method === 'GET' && urlPath === '/api/settings') {
2065
+ return handleGetSettings(req, res);
2066
+ }
2067
+ if (method === 'PUT' && urlPath === '/api/settings') {
2068
+ return await handleSaveSettings(req, res);
2069
+ }
2070
+
2071
+ // --- Speech-to-text ---
2072
+ if (method === 'POST' && urlPath === '/api/transcribe') {
2073
+ return await handleTranscribe(req, res);
2074
+ }
2075
+
2076
+ // --- Workspaces ---
2077
+ if (method === 'GET' && urlPath === '/api/workspaces') {
2078
+ return handleGetWorkspaces(req, res);
2079
+ }
2080
+ if (method === 'POST' && urlPath === '/api/workspaces') {
2081
+ return await handleCreateWorkspace(req, res);
2082
+ }
2083
+ if ((p = matchRoute(method, urlPath, 'PATCH /api/workspaces/:name'))) {
2084
+ return await handleUpdateWorkspace(req, res, p);
2085
+ }
2086
+ if ((p = matchRoute(method, urlPath, 'DELETE /api/workspaces/:name'))) {
2087
+ return handleDeleteWorkspace(req, res, p);
2088
+ }
2089
+ if (method === 'POST' && urlPath === '/api/workspaces/reorder') {
2090
+ return await handleReorderWorkspaces(req, res);
2091
+ }
2092
+ if ((p = matchRoute(method, urlPath, 'POST /api/workspaces/:name/activate'))) {
2093
+ return handleActivateWorkspace(req, res, p);
2094
+ }
2095
+
2096
+ // --- Threads ---
2097
+ if (method === 'GET' && urlPath === '/api/threads') {
2098
+ return handleGetThreads(req, res, {}, query);
2099
+ }
2100
+ if (method === 'GET' && urlPath === '/api/threads/unread') {
2101
+ return handleGetUnreadThreads(req, res);
2102
+ }
2103
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/mark-read'))) {
2104
+ return await handleMarkMessagesRead(req, res, p);
2105
+ }
2106
+ if (method === 'POST' && urlPath === '/api/threads') {
2107
+ return await handleCreateThread(req, res);
2108
+ }
2109
+ // Thread-specific routes (must check more specific paths first)
2110
+ if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/messages'))) {
2111
+ return handleGetMessages(req, res, p, query);
2112
+ }
2113
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) {
2114
+ return await handleCreateMessage(req, res, p);
2115
+ }
2116
+ if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) {
2117
+ return handleDeleteMessage(req, res, p);
2118
+ }
2119
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) {
2120
+ return handleContextFill(req, res, p);
2121
+ }
2122
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) {
2123
+ return await handleUpload(req, res, p);
2124
+ }
2125
+ if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) {
2126
+ return handleGetIntelligence(req, res, p);
2127
+ }
2128
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) {
2129
+ return await handleSaveIntelligence(req, res, p);
2130
+ }
2131
+ if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id'))) {
2132
+ return handleGetThread(req, res, p);
2133
+ }
2134
+ if ((p = matchRoute(method, urlPath, 'PATCH /api/threads/:id'))) {
2135
+ return await handleUpdateThread(req, res, p);
2136
+ }
2137
+ if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id'))) {
2138
+ return handleDeleteThread(req, res, p);
2139
+ }
2140
+
2141
+ // --- Search ---
2142
+ if (method === 'GET' && urlPath === '/api/search') {
2143
+ return handleSearch(req, res, {}, query);
2144
+ }
2145
+
2146
+ // --- Export / Import ---
2147
+ if (method === 'GET' && urlPath === '/api/export') {
2148
+ return handleExport(req, res);
2149
+ }
2150
+ if (method === 'POST' && urlPath === '/api/import') {
2151
+ return await handleImport(req, res);
2152
+ }
2153
+
2154
+ // --- Health check ---
2155
+ if (method === 'GET' && urlPath === '/api/health') {
2156
+ return send(res, 200, { ok: true, workspace: getWorkspaces().active, uptime: process.uptime() });
2157
+ }
2158
+
2159
+ // Not found
2160
+ sendError(res, 404, `Not found: ${method} ${urlPath}`);
2161
+ } catch (err) {
2162
+ console.error(`Error handling ${method} ${urlPath}:`, err);
2163
+ if (err.message && err.message.includes('UNIQUE constraint')) {
2164
+ sendError(res, 409, 'Conflict: ' + err.message);
2165
+ } else {
2166
+ sendError(res, 500, err.message || 'Internal server error');
2167
+ }
2168
+ }
2169
+ }
2170
+
2171
+ // ─── Gateway WebSocket Client ───────────────────────────────────────────────
2172
+
2173
+ class GatewayClient {
2174
+ constructor() {
2175
+ this.ws = null;
2176
+ this.connected = false;
2177
+ this.reconnectAttempts = 0;
2178
+ this.maxReconnectDelay = 30000;
2179
+ this.browserClients = new Map(); // Map<WebSocket, { activeWorkspace, activeThreadId }>
2180
+ this.streamState = new Map(); // Map<sessionKey, { state, buffer, threadId }>
2181
+ this.activityLogs = new Map(); // Map<runId, { sessionKey, steps, startTime }>
2182
+
2183
+ // Clean up stale activity logs every 5 minutes (runs that never completed)
2184
+ setInterval(() => {
2185
+ const cutoff = Date.now() - 10 * 60 * 1000; // 10 minutes
2186
+ for (const [runId, log] of this.activityLogs) {
2187
+ if (log.startTime < cutoff) {
2188
+ this.activityLogs.delete(runId);
2189
+ }
2190
+ }
2191
+ }, 5 * 60 * 1000);
2192
+ }
2193
+
2194
+ connect() {
2195
+ if (this.ws && (this.ws.readyState === WS.CONNECTING || this.ws.readyState === WS.OPEN)) {
2196
+ return; // Already connecting or connected
2197
+ }
2198
+
2199
+ console.log(`Connecting to gateway at ${GATEWAY_WS_URL}...`);
2200
+ this.ws = new WS(GATEWAY_WS_URL);
2201
+
2202
+ this.ws.on('open', () => {
2203
+ console.log('Gateway WebSocket connected');
2204
+ this.reconnectAttempts = 0;
2205
+ });
2206
+
2207
+ this.ws.on('message', (data) => {
2208
+ this.handleGatewayMessage(data.toString());
2209
+ });
2210
+
2211
+ this.ws.on('close', () => {
2212
+ console.log('Gateway WebSocket closed');
2213
+ this.connected = false;
2214
+ this.broadcastGatewayStatus(false);
2215
+ this.scheduleReconnect();
2216
+ });
2217
+
2218
+ this.ws.on('error', (err) => {
2219
+ console.error('Gateway WebSocket error:', err.message);
2220
+ });
2221
+ }
2222
+
2223
+ handleGatewayMessage(data) {
2224
+ debugLogger.logFrame('GW→SRV', data);
2225
+ let msg;
2226
+ try {
2227
+ msg = JSON.parse(data);
2228
+ } catch (e) {
2229
+ console.error('Invalid JSON from gateway:', data);
2230
+ return;
2231
+ }
2232
+
2233
+ // Handle connect.challenge (handshake)
2234
+ if (msg.type === 'event' && msg.event === 'connect.challenge') {
2235
+ console.log('Received connect.challenge, sending auth...');
2236
+ this.ws.send(JSON.stringify({
2237
+ type: 'req',
2238
+ id: 'gw-connect-1',
2239
+ method: 'connect',
2240
+ params: {
2241
+ minProtocol: 3,
2242
+ maxProtocol: 3,
2243
+ client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' },
2244
+ role: 'operator',
2245
+ scopes: ['operator.read', 'operator.write', 'operator.admin'],
2246
+ auth: { token: AUTH_TOKEN },
2247
+ caps: ['tool-events']
2248
+ }
2249
+ }));
2250
+ return;
2251
+ }
2252
+
2253
+ // Handle connect response (hello-ok)
2254
+ if (msg.type === 'res' && msg.payload?.type === 'hello-ok') {
2255
+ console.log('Gateway handshake complete');
2256
+ this.connected = true;
2257
+ this.broadcastGatewayStatus(true);
2258
+ }
2259
+
2260
+ // Handle activity log history responses
2261
+ if (msg.type === 'res' && msg.id && this._pendingActivityCallbacks?.has(msg.id)) {
2262
+ const callback = this._pendingActivityCallbacks.get(msg.id);
2263
+ this._pendingActivityCallbacks.delete(msg.id);
2264
+ if (msg.ok) {
2265
+ callback(msg.payload);
2266
+ } else {
2267
+ callback(null);
2268
+ }
2269
+ }
2270
+
2271
+ // Forward all messages to browser clients
2272
+ this.broadcastToBrowsers(data);
2273
+
2274
+ // Process chat events for persistence
2275
+ if (msg.type === 'event' && msg.event === 'chat' && msg.payload) {
2276
+ this.handleChatEvent(msg.payload);
2277
+ }
2278
+
2279
+ // Process agent events for activity log
2280
+ if (msg.type === 'event' && msg.event === 'agent' && msg.payload) {
2281
+ this.handleAgentEvent(msg.payload);
2282
+ }
2283
+ }
2284
+
2285
+ handleChatEvent(params) {
2286
+ const { sessionKey, state, message, seq } = params;
2287
+
2288
+ // Update streaming state
2289
+ if (state === 'delta') {
2290
+ const parsed = parseSessionKey(sessionKey);
2291
+ if (parsed) {
2292
+ const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming' };
2293
+ const deltaText = extractContent(message);
2294
+ existing.buffer += deltaText;
2295
+ this.streamState.set(sessionKey, existing);
2296
+ }
2297
+ return;
2298
+ }
2299
+
2300
+ // Clear stream state on final/aborted/error
2301
+ if (state === 'final' || state === 'aborted' || state === 'error') {
2302
+ this.streamState.delete(sessionKey);
2303
+ }
2304
+
2305
+ // Save assistant messages on final
2306
+ if (state === 'final') {
2307
+ this.saveAssistantMessage(sessionKey, message, seq);
2308
+ }
2309
+
2310
+ // Save error markers
2311
+ if (state === 'error') {
2312
+ this.saveErrorMarker(sessionKey, message);
2313
+ }
2314
+ }
2315
+
2316
+ saveAssistantMessage(sessionKey, message, seq) {
2317
+ const parsed = parseSessionKey(sessionKey);
2318
+ if (!parsed) return; // Non-ShellChat session key, silently ignore
2319
+
2320
+ // Guard: verify workspace still exists
2321
+ const ws = getWorkspaces();
2322
+ if (!ws.workspaces[parsed.workspace]) {
2323
+ console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`);
2324
+ return;
2325
+ }
2326
+
2327
+ const db = getDb(parsed.workspace);
2328
+
2329
+ // Guard: verify thread still exists
2330
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2331
+ if (!thread) {
2332
+ console.log(`Ignoring response for deleted thread: ${parsed.threadId}`);
2333
+ return;
2334
+ }
2335
+
2336
+ // Extract content
2337
+ const content = extractContent(message);
2338
+
2339
+ // Guard: skip empty content
2340
+ if (!content || !content.trim()) {
2341
+ console.log(`Skipping empty assistant response for thread ${parsed.threadId}`);
2342
+ return;
2343
+ }
2344
+
2345
+ // Deterministic message ID from seq (deduplicates tool-call loops)
2346
+ const now = Date.now();
2347
+ const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
2348
+
2349
+ // Upsert message: INSERT OR REPLACE (same seq → same messageId → update content)
2350
+ try {
2351
+ db.prepare(`
2352
+ INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at)
2353
+ VALUES (?, ?, 'assistant', ?, 'sent', ?, ?)
2354
+ ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp
2355
+ `).run(messageId, parsed.threadId, content, now, now);
2356
+
2357
+ // Update thread updated_at
2358
+ db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
2359
+
2360
+ // Check if thread is active in any browser client
2361
+ const isActive = [...this.browserClients.values()].some(
2362
+ c => c.activeWorkspace === parsed.workspace && c.activeThreadId === parsed.threadId
2363
+ );
2364
+
2365
+ // Track unread message if thread not active in any browser
2366
+ if (!isActive) {
2367
+ db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
2368
+ // Recount from table (always accurate)
2369
+ syncThreadUnreadCount(db, parsed.threadId);
2370
+ }
2371
+
2372
+ // Get thread title and unread info for notification
2373
+ const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
2374
+ const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
2375
+ const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
2376
+
2377
+ // Broadcast message-saved for active thread reload
2378
+ this.broadcastToBrowsers(JSON.stringify({
2379
+ type: 'shellchat',
2380
+ event: 'message-saved',
2381
+ threadId: parsed.threadId,
2382
+ workspace: parsed.workspace,
2383
+ messageId,
2384
+ timestamp: now,
2385
+ title: threadInfo?.title || 'Chat',
2386
+ preview,
2387
+ unreadCount
2388
+ }));
2389
+
2390
+ // Broadcast unread-update for badge/notification sync (all clients)
2391
+ if (!isActive) {
2392
+ const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2393
+ this.broadcastToBrowsers(JSON.stringify({
2394
+ type: 'shellchat',
2395
+ event: 'unread-update',
2396
+ workspace: parsed.workspace,
2397
+ threadId: parsed.threadId,
2398
+ messageId,
2399
+ action: 'new',
2400
+ unreadCount,
2401
+ workspaceUnreadTotal,
2402
+ title: threadInfo?.title || 'Chat',
2403
+ preview,
2404
+ timestamp: now
2405
+ }));
2406
+ }
2407
+
2408
+ console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (seq: ${seq})`);
2409
+ } catch (e) {
2410
+ console.error(`Failed to save assistant message:`, e.message);
2411
+ }
2412
+ }
2413
+
2414
+ saveErrorMarker(sessionKey, message) {
2415
+ const parsed = parseSessionKey(sessionKey);
2416
+ if (!parsed) return;
2417
+
2418
+ const ws = getWorkspaces();
2419
+ if (!ws.workspaces[parsed.workspace]) return;
2420
+
2421
+ const db = getDb(parsed.workspace);
2422
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2423
+ if (!thread) return;
2424
+
2425
+ const errorText = message?.error || message?.content || 'Unknown error';
2426
+ const content = `[error] ${errorText}`;
2427
+ const now = Date.now();
2428
+ const messageId = `gw-error-${parsed.threadId}-${now}`;
2429
+
2430
+ try {
2431
+ db.prepare(
2432
+ 'INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
2433
+ ).run(messageId, parsed.threadId, 'system', content, 'sent', '{"transient":true}', now, now);
2434
+
2435
+ console.log(`Saved error marker for ${parsed.workspace}/${parsed.threadId}`);
2436
+ } catch (e) {
2437
+ console.error(`Failed to save error marker:`, e.message);
2438
+ }
2439
+ }
2440
+
2441
+ handleAgentEvent(payload) {
2442
+ const { runId, stream, data, sessionKey } = payload;
2443
+ if (!runId) return;
2444
+
2445
+ // Initialize log if needed
2446
+ if (!this.activityLogs.has(runId)) {
2447
+ this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
2448
+ }
2449
+ const log = this.activityLogs.get(runId);
2450
+
2451
+ if (stream === 'assistant') {
2452
+ // Capture intermediate text turns (narration between tool calls)
2453
+ const text = data?.text || '';
2454
+ if (text) {
2455
+ // Update or create the current assistant text segment
2456
+ // Each new assistant segment starts after a tool call
2457
+ let currentSegment = log._currentAssistantSegment;
2458
+ if (!currentSegment || currentSegment._sealed) {
2459
+ currentSegment = {
2460
+ type: 'assistant',
2461
+ timestamp: Date.now(),
2462
+ text: text,
2463
+ _sealed: false
2464
+ };
2465
+ log._currentAssistantSegment = currentSegment;
2466
+ log.steps.push(currentSegment);
2467
+ } else {
2468
+ currentSegment.text = text;
2469
+ }
2470
+ }
2471
+ // Don't broadcast on every assistant delta — too noisy
2472
+ // We'll broadcast when a tool starts (which seals the current segment)
2473
+ return;
2474
+ }
2475
+
2476
+ if (stream === 'thinking') {
2477
+ // Reasoning/thinking text from the model (requires reasoningLevel: "stream")
2478
+ const thinkingText = data?.text || '';
2479
+ const delta = data?.delta || '';
2480
+ // Update or create thinking step — we keep a single thinking step that accumulates
2481
+ let thinkingStep = log.steps.find(s => s.type === 'thinking');
2482
+ if (thinkingStep) {
2483
+ thinkingStep.text = thinkingText;
2484
+ } else {
2485
+ log.steps.push({
2486
+ type: 'thinking',
2487
+ timestamp: Date.now(),
2488
+ text: thinkingText
2489
+ });
2490
+ }
2491
+ // Broadcast to browser for live display (throttled — thinking events come fast)
2492
+ const now = Date.now();
2493
+ if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
2494
+ log._lastThinkingBroadcast = now;
2495
+ this.broadcastActivityUpdate(runId, log);
2496
+ }
2497
+ }
2498
+
2499
+ if (stream === 'tool') {
2500
+ // Seal any current assistant text segment (narration before this tool call)
2501
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
2502
+ log._currentAssistantSegment._sealed = true;
2503
+ // Broadcast the narration + upcoming tool
2504
+ this.broadcastActivityUpdate(runId, log);
2505
+ }
2506
+
2507
+ const step = {
2508
+ type: 'tool',
2509
+ timestamp: Date.now(),
2510
+ name: data?.name || 'unknown',
2511
+ phase: data?.phase || 'start',
2512
+ toolCallId: data?.toolCallId,
2513
+ meta: data?.meta,
2514
+ isError: data?.isError || false
2515
+ };
2516
+
2517
+ // On result phase, update the existing start step or add new
2518
+ if (data?.phase === 'result') {
2519
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
2520
+ if (existing) {
2521
+ existing.phase = 'done';
2522
+ existing.resultMeta = data?.meta;
2523
+ existing.isError = data?.isError || false;
2524
+ existing.durationMs = Date.now() - existing.timestamp;
2525
+ } else {
2526
+ step.phase = 'done';
2527
+ log.steps.push(step);
2528
+ }
2529
+ } else if (data?.phase === 'update') {
2530
+ // Merge update events into the existing step — don't create new entries
2531
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
2532
+ if (existing) {
2533
+ // Update meta if the update carries new info
2534
+ if (data?.meta) existing.resultMeta = data.meta;
2535
+ if (data?.isError) existing.isError = true;
2536
+ existing.phase = 'running';
2537
+ }
2538
+ // If no existing step found, silently ignore the orphaned update
2539
+ } else {
2540
+ log.steps.push(step);
2541
+ }
2542
+
2543
+ // Forward to browser for real-time display
2544
+ this.broadcastActivityUpdate(runId, log);
2545
+ }
2546
+
2547
+ if (stream === 'lifecycle') {
2548
+ if (data?.phase === 'end' || data?.phase === 'error') {
2549
+ // Seal any remaining assistant segment
2550
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
2551
+ log._currentAssistantSegment._sealed = true;
2552
+ }
2553
+ // Remove the final assistant segment — that's the actual response, not narration
2554
+ // The last assistant segment is the final reply text, which is displayed as the message itself
2555
+ const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
2556
+ if (lastAssistantIdx >= 0) {
2557
+ log.steps.splice(lastAssistantIdx, 1);
2558
+ }
2559
+
2560
+ log.steps.push({ type: 'lifecycle', timestamp: Date.now(), phase: data.phase });
2561
+
2562
+ // Finalize: save to message metadata
2563
+ this.finalizeActivityLog(runId, log);
2564
+ }
2565
+ }
2566
+ }
2567
+
2568
+ broadcastActivityUpdate(runId, log) {
2569
+ const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
2570
+ if (!parsed) return;
2571
+
2572
+ this.broadcastToBrowsers(JSON.stringify({
2573
+ type: 'shellchat',
2574
+ event: 'agent-activity',
2575
+ workspace: parsed.workspace,
2576
+ threadId: parsed.threadId,
2577
+ runId,
2578
+ steps: log.steps,
2579
+ summary: this.generateActivitySummary(log.steps)
2580
+ }));
2581
+ }
2582
+
2583
+ finalizeActivityLog(runId, log) {
2584
+ const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
2585
+ if (!parsed) return;
2586
+
2587
+ const db = getDb(parsed.workspace);
2588
+ if (!db) return;
2589
+
2590
+ const hasToolSteps = log.steps.some(s => s.type === 'tool');
2591
+
2592
+ // If we got no tool events (e.g., webchat session where we're not a registered recipient),
2593
+ // try to extract tool calls from the session history via Gateway API
2594
+ if (!hasToolSteps && this.ws && this.ws.readyState === 1 /* OPEN */) {
2595
+ this.fetchToolCallsFromHistory(log.sessionKey, parsed, db, log, runId);
2596
+ return;
2597
+ }
2598
+
2599
+ // Save activity log to message metadata (delay to ensure final message is saved first)
2600
+ this.saveActivityToMessage(parsed, db, log, runId);
2601
+ }
2602
+
2603
+ fetchToolCallsFromHistory(sessionKey, parsed, db, log, runId) {
2604
+ const reqId = `activity-hist-${runId}`;
2605
+
2606
+ const timeout = setTimeout(() => {
2607
+ // Timeout: save what we have (even if empty)
2608
+ this._pendingActivityCallbacks?.delete(reqId);
2609
+ this.saveActivityToMessage(parsed, db, log, runId);
2610
+ }, 5000);
2611
+
2612
+ // Register callback for when the gateway responds
2613
+ if (!this._pendingActivityCallbacks) this._pendingActivityCallbacks = new Map();
2614
+ this._pendingActivityCallbacks.set(reqId, (payload) => {
2615
+ clearTimeout(timeout);
2616
+
2617
+ // Extract tool calls and narration from the last assistant message in history
2618
+ if (payload?.messages) {
2619
+ for (let i = payload.messages.length - 1; i >= 0; i--) {
2620
+ const m = payload.messages[i];
2621
+ if (m.role === 'assistant' && Array.isArray(m.content)) {
2622
+ let hasToolUse = false;
2623
+ for (const block of m.content) {
2624
+ if (block.type === 'text' && block.text?.trim() && hasToolUse) {
2625
+ // Text after a tool_use = intermediate narration (not the first text which is part of the response)
2626
+ // Actually, text BEFORE a tool_use is narration too
2627
+ }
2628
+ if (block.type === 'text' && block.text?.trim()) {
2629
+ // We'll collect all text blocks, then trim the last one (that's the actual response)
2630
+ log.steps.push({
2631
+ type: 'assistant',
2632
+ timestamp: Date.now(),
2633
+ text: block.text.trim()
2634
+ });
2635
+ }
2636
+ if (block.type === 'tool_use') {
2637
+ hasToolUse = true;
2638
+ log.steps.push({
2639
+ type: 'tool',
2640
+ timestamp: Date.now(),
2641
+ name: block.name || 'unknown',
2642
+ phase: 'done',
2643
+ toolCallId: block.id,
2644
+ meta: this.extractToolMeta(block),
2645
+ isError: false
2646
+ });
2647
+ }
2648
+ }
2649
+ // Remove the last assistant text — that's the final response, not narration
2650
+ if (hasToolUse) {
2651
+ const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
2652
+ if (lastAssistantIdx >= 0) {
2653
+ log.steps.splice(lastAssistantIdx, 1);
2654
+ }
2655
+ } else {
2656
+ // No tool calls — remove all assistant narration (nothing interesting to show)
2657
+ log.steps = log.steps.filter(s => s.type !== 'assistant');
2658
+ }
2659
+ break; // Only process the last assistant message
2660
+ }
2661
+ }
2662
+ }
2663
+
2664
+ this.saveActivityToMessage(parsed, db, log, runId);
2665
+ this.broadcastActivityUpdate(runId, log);
2666
+ });
2667
+
2668
+ // Request session history from gateway
2669
+ this.ws.send(JSON.stringify({
2670
+ type: 'req',
2671
+ id: reqId,
2672
+ method: 'sessions.history',
2673
+ params: { key: sessionKey, limit: 3, includeTools: true }
2674
+ }));
2675
+ }
2676
+
2677
+ extractToolMeta(toolUseBlock) {
2678
+ if (!toolUseBlock.input) return '';
2679
+ const input = toolUseBlock.input;
2680
+ // Generate a brief description from the tool input
2681
+ if (input.query) return input.query;
2682
+ if (input.command) return input.command.substring(0, 80);
2683
+ if (input.file_path || input.path) return input.file_path || input.path;
2684
+ if (input.url) return input.url;
2685
+ if (input.text) return input.text.substring(0, 60);
2686
+ return '';
2687
+ }
2688
+
2689
+ saveActivityToMessage(parsed, db, log, runId) {
2690
+ // Delay to ensure the assistant message is saved first (from handleChatEvent final)
2691
+ setTimeout(() => {
2692
+ try {
2693
+ const msg = db.prepare(
2694
+ 'SELECT id, metadata FROM messages WHERE thread_id = ? AND role = ? ORDER BY timestamp DESC LIMIT 1'
2695
+ ).get(parsed.threadId, 'assistant');
2696
+
2697
+ if (msg) {
2698
+ const toolSteps = log.steps.filter(s => s.type === 'tool' && s.phase !== 'update');
2699
+ const thinkingSteps = log.steps.filter(s => s.type === 'thinking');
2700
+ const narrativeSteps = log.steps.filter(s => s.type === 'assistant' && s.text?.trim());
2701
+ // Only save if we have meaningful activity
2702
+ if (toolSteps.length > 0 || thinkingSteps.length > 0 || narrativeSteps.length > 0) {
2703
+ const metadata = msg.metadata ? JSON.parse(msg.metadata) : {};
2704
+ // Clean internal fields before persisting
2705
+ metadata.activityLog = log.steps.map(s => {
2706
+ const clean = { ...s };
2707
+ delete clean._sealed;
2708
+ return clean;
2709
+ });
2710
+ metadata.activitySummary = this.generateActivitySummary(log.steps);
2711
+ db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
2712
+ .run(JSON.stringify(metadata), msg.id);
2713
+
2714
+ console.log(`[ActivityLog] Saved ${toolSteps.length} tool steps for message ${msg.id}`);
2715
+
2716
+ // Notify browsers to re-render this message with activity data
2717
+ this.broadcastToBrowsers(JSON.stringify({
2718
+ type: 'shellchat',
2719
+ event: 'activity-saved',
2720
+ workspace: parsed.workspace,
2721
+ threadId: parsed.threadId,
2722
+ messageId: msg.id
2723
+ }));
2724
+ }
2725
+ }
2726
+ } catch (e) {
2727
+ console.error('Failed to save activity log:', e.message);
2728
+ }
2729
+
2730
+ this.activityLogs.delete(runId);
2731
+ }, 1000); // 1s delay — must happen after message-saved (which fires on chat:final)
2732
+ }
2733
+
2734
+ generateActivitySummary(steps) {
2735
+ const toolSteps = steps.filter(s => s.type === 'tool' && s.phase !== 'result' && s.phase !== 'update');
2736
+ const hasThinking = steps.some(s => s.type === 'thinking' && s.text);
2737
+ const hasNarration = steps.some(s => s.type === 'assistant' && s.text?.trim());
2738
+ if (toolSteps.length === 0 && !hasThinking && !hasNarration) return null;
2739
+ if (toolSteps.length === 0 && hasThinking) return 'Reasoned through the problem';
2740
+ if (toolSteps.length === 0 && hasNarration) return 'Processed in multiple steps';
2741
+
2742
+ // Count by tool name
2743
+ const counts = {};
2744
+ for (const s of toolSteps) {
2745
+ const name = s.name || 'unknown';
2746
+ counts[name] = (counts[name] || 0) + 1;
2747
+ }
2748
+
2749
+ // Build description
2750
+ const parts = [];
2751
+ const toolNames = {
2752
+ 'web_search': 'searched the web',
2753
+ 'web_fetch': 'fetched web pages',
2754
+ 'Read': 'read files',
2755
+ 'read': 'read files',
2756
+ 'Write': 'wrote files',
2757
+ 'write': 'wrote files',
2758
+ 'Edit': 'edited files',
2759
+ 'edit': 'edited files',
2760
+ 'exec': 'ran commands',
2761
+ 'Bash': 'ran commands',
2762
+ 'browser': 'browsed the web',
2763
+ 'memory_search': 'searched memory',
2764
+ 'memory_store': 'saved to memory',
2765
+ 'image': 'analyzed images',
2766
+ 'message': 'sent messages',
2767
+ 'sessions_spawn': 'spawned sub-agents',
2768
+ 'cron': 'managed cron jobs',
2769
+ 'Grep': 'searched code',
2770
+ 'grep': 'searched code',
2771
+ 'Glob': 'found files',
2772
+ 'glob': 'found files'
2773
+ };
2774
+
2775
+ for (const [name, count] of Object.entries(counts)) {
2776
+ const friendly = toolNames[name];
2777
+ if (friendly) {
2778
+ parts.push(count > 1 ? `${friendly} (${count}×)` : friendly);
2779
+ } else {
2780
+ parts.push(count > 1 ? `used ${name} (${count}×)` : `used ${name}`);
2781
+ }
2782
+ }
2783
+
2784
+ if (parts.length === 0) return null;
2785
+ if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
2786
+ const last = parts.pop();
2787
+ return (parts.join(', ') + ' and ' + last).replace(/^./, c => c.toUpperCase());
2788
+ }
2789
+
2790
+ broadcastToBrowsers(data) {
2791
+ debugLogger.logFrame('SRV→BR', data);
2792
+ for (const client of this.browserClients.keys()) {
2793
+ if (client.readyState === WS.OPEN) {
2794
+ client.send(data);
2795
+ }
2796
+ }
2797
+ }
2798
+
2799
+ broadcastGatewayStatus(connected) {
2800
+ const msg = JSON.stringify({
2801
+ type: 'shellchat',
2802
+ event: 'gateway-status',
2803
+ connected
2804
+ });
2805
+ this.broadcastToBrowsers(msg);
2806
+ }
2807
+
2808
+ sendToGateway(data) {
2809
+ debugLogger.logFrame('SRV→GW', data);
2810
+ if (this.ws && this.ws.readyState === WS.OPEN) {
2811
+ this.ws.send(data);
2812
+ } else {
2813
+ console.error('Cannot send to gateway: not connected');
2814
+ }
2815
+ }
2816
+
2817
+ scheduleReconnect() {
2818
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
2819
+ this.reconnectAttempts++;
2820
+ console.log(`Reconnecting to gateway in ${delay}ms (attempt ${this.reconnectAttempts})...`);
2821
+ setTimeout(() => this.connect(), delay);
2822
+ }
2823
+
2824
+ addBrowserClient(ws) {
2825
+ this.browserClients.set(ws, { activeWorkspace: null, activeThreadId: null });
2826
+
2827
+ // Send current gateway status
2828
+ if (ws.readyState === WS.OPEN) {
2829
+ ws.send(JSON.stringify({
2830
+ type: 'shellchat',
2831
+ event: 'gateway-status',
2832
+ connected: this.connected
2833
+ }));
2834
+
2835
+ // Send stream-sync with current streaming states
2836
+ const streams = [];
2837
+ for (const [sessionKey, state] of this.streamState.entries()) {
2838
+ if (state.state === 'streaming') {
2839
+ streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
2840
+ }
2841
+ }
2842
+ if (streams.length > 0) {
2843
+ ws.send(JSON.stringify({
2844
+ type: 'shellchat',
2845
+ event: 'stream-sync',
2846
+ streams
2847
+ }));
2848
+ }
2849
+ }
2850
+ }
2851
+
2852
+ removeBrowserClient(ws) {
2853
+ this.browserClients.delete(ws);
2854
+ }
2855
+
2856
+ setActiveThread(ws, workspace, threadId) {
2857
+ const client = this.browserClients.get(ws);
2858
+ if (client) {
2859
+ client.activeWorkspace = workspace;
2860
+ client.activeThreadId = threadId;
2861
+ }
2862
+
2863
+ // Auto-clear unreads: opening a thread = reading it
2864
+ if (workspace && threadId) {
2865
+ try {
2866
+ const wsData = getWorkspaces();
2867
+ if (!wsData.workspaces[workspace]) return;
2868
+
2869
+ const db = getDb(workspace);
2870
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(threadId);
2871
+ if (!thread) return;
2872
+
2873
+ // Delete all unread entries for this thread
2874
+ const deleted = db.prepare('DELETE FROM unread_messages WHERE thread_id = ?').run(threadId);
2875
+ if (deleted.changes > 0) {
2876
+ syncThreadUnreadCount(db, threadId);
2877
+
2878
+ // Broadcast unread-update clear to ALL browser clients
2879
+ const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2880
+ this.broadcastToBrowsers(JSON.stringify({
2881
+ type: 'shellchat',
2882
+ event: 'unread-update',
2883
+ workspace,
2884
+ threadId,
2885
+ action: 'clear',
2886
+ unreadCount: 0,
2887
+ workspaceUnreadTotal,
2888
+ timestamp: Date.now()
2889
+ }));
2890
+ }
2891
+ } catch (e) {
2892
+ console.error('Failed to auto-clear unreads on active-thread:', e.message);
2893
+ }
2894
+ }
2895
+ }
2896
+ }
2897
+
2898
+ // Helper: Recount unread_messages and sync threads.unread_count
2899
+ function syncThreadUnreadCount(db, threadId) {
2900
+ const count = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(threadId).c;
2901
+ db.prepare('UPDATE threads SET unread_count = ? WHERE id = ?').run(count, threadId);
2902
+ return count;
2903
+ }
2904
+
2905
+ // Helper: Parse session key
2906
+ function parseSessionKey(sessionKey) {
2907
+ if (!sessionKey) return null;
2908
+ const match = sessionKey.match(/^agent:main:([^:]+):chat:([^:]+)$/);
2909
+ if (!match) return null; // Non-ShellChat keys — silently ignore
2910
+ return { workspace: match[1], threadId: match[2] };
2911
+ }
2912
+
2913
+ // Helper: Extract content from message
2914
+ function extractContent(message) {
2915
+ if (!message) return '';
2916
+ if (typeof message.content === 'string') return message.content;
2917
+ if (Array.isArray(message.content)) {
2918
+ return message.content
2919
+ .filter(part => part.type === 'text')
2920
+ .map(part => part.text)
2921
+ .join('');
2922
+ }
2923
+ return '';
2924
+ }
2925
+
2926
+ const gatewayClient = new GatewayClient();
2927
+
2928
+ // ─── createApp Factory ───────────────────────────────────────────────────────
2929
+ // Returns an isolated instance of the app state + handlers.
2930
+ // Used by the plugin (signaling/index.js) to embed ShellChat logic without
2931
+ // spinning up a standalone HTTP server.
2932
+
2933
+ export function createApp(config = {}) {
2934
+ // ── Config-dependent constants ─────────────────────────────────────────────
2935
+ const _DATA_DIR = config.dataDir || path.join(__dirname, 'data');
2936
+ const _UPLOADS_DIR = config.uploadsDir || path.join(__dirname, 'uploads');
2937
+ const _WORKSPACES_FILE = path.join(_DATA_DIR, 'workspaces.json');
2938
+ const _SETTINGS_FILE = path.join(_DATA_DIR, 'settings.json');
2939
+ const _INTELLIGENCE_DIR = path.join(_DATA_DIR, 'intelligence');
2940
+
2941
+ let _AUTH_TOKEN = config.authToken !== undefined
2942
+ ? config.authToken
2943
+ : (process.env.SHELLCHAT_AUTH_TOKEN || parseConfigField('authToken') || '');
2944
+
2945
+ // Separate token for gateway WS auth (falls back to _AUTH_TOKEN for direct mode)
2946
+ const _GATEWAY_TOKEN = config.gatewayToken !== undefined
2947
+ ? config.gatewayToken
2948
+ : _AUTH_TOKEN;
2949
+
2950
+ const _GATEWAY_WS_URL = config.gatewayUrl || discoverGatewayWsUrl();
2951
+
2952
+ // ── Mutable singleton state ────────────────────────────────────────────────
2953
+ const _dbCache = new Map();
2954
+ let _workspacesConfig = null;
2955
+ const _debugLogger = new DebugLogger(_DATA_DIR);
2956
+
2957
+ const _MEMORY_CONFIG = discoverMemoryConfig();
2958
+ const _memoryProvider = createMemoryProvider(_MEMORY_CONFIG);
2959
+ _memoryProvider.init().catch(err => console.error('[createApp] Memory provider init error:', err.message));
2960
+
2961
+ const _MEMORY_FILES_DIR = path.join(_MEMORY_CONFIG.workspaceDir, 'memory');
2962
+
2963
+ // ── Workspace helpers ──────────────────────────────────────────────────────
2964
+ function _loadWorkspaces() {
2965
+ try {
2966
+ return JSON.parse(fs.readFileSync(_WORKSPACES_FILE, 'utf8'));
2967
+ } catch {
2968
+ const initial = {
2969
+ active: 'default',
2970
+ workspaces: {
2971
+ default: { name: 'default', label: 'Default', createdAt: Date.now() }
2972
+ }
2973
+ };
2974
+ fs.writeFileSync(_WORKSPACES_FILE, JSON.stringify(initial, null, 2));
2975
+ return initial;
2976
+ }
2977
+ }
2978
+
2979
+ function _saveWorkspaces(data) {
2980
+ fs.writeFileSync(_WORKSPACES_FILE, JSON.stringify(data, null, 2));
2981
+ }
2982
+
2983
+ function _getWorkspaces() {
2984
+ if (!_workspacesConfig) _workspacesConfig = _loadWorkspaces();
2985
+ return _workspacesConfig;
2986
+ }
2987
+
2988
+ function _setWorkspaces(data) {
2989
+ _workspacesConfig = data;
2990
+ _saveWorkspaces(data);
2991
+ }
2992
+
2993
+ // ── Database helpers ───────────────────────────────────────────────────────
2994
+ function _getDb(workspaceName) {
2995
+ if (_dbCache.has(workspaceName)) return _dbCache.get(workspaceName);
2996
+ const dbPath = path.join(_DATA_DIR, `${workspaceName}.db`);
2997
+ const db = new Database(dbPath);
2998
+ db.pragma('journal_mode = WAL');
2999
+ db.pragma('foreign_keys = ON');
3000
+ migrate(db);
3001
+ _dbCache.set(workspaceName, db);
3002
+ return db;
3003
+ }
3004
+
3005
+ function _getActiveDb() {
3006
+ return _getDb(_getWorkspaces().active);
3007
+ }
3008
+
3009
+ function _closeDb(workspaceName) {
3010
+ const db = _dbCache.get(workspaceName);
3011
+ if (db) { db.close(); _dbCache.delete(workspaceName); }
3012
+ }
3013
+
3014
+ function _closeAllDbs() {
3015
+ for (const [, db] of _dbCache) db.close();
3016
+ _dbCache.clear();
3017
+ }
3018
+
3019
+ function _ensureDirs() {
3020
+ fs.mkdirSync(_DATA_DIR, { recursive: true });
3021
+ fs.mkdirSync(_UPLOADS_DIR, { recursive: true });
3022
+ }
3023
+
3024
+ // ── Auth (closes over _AUTH_TOKEN) ─────────────────────────────────────────
3025
+ function _checkAuth(req, res) {
3026
+ if (!_AUTH_TOKEN) return true;
3027
+ const auth = req.headers.authorization;
3028
+ if (!auth || !auth.startsWith('Bearer ')) {
3029
+ sendError(res, 401, 'Missing or invalid Authorization header');
3030
+ return false;
3031
+ }
3032
+ const token = auth.slice(7);
3033
+ if (token !== _AUTH_TOKEN) {
3034
+ sendError(res, 401, 'Invalid auth token');
3035
+ return false;
3036
+ }
3037
+ return true;
3038
+ }
3039
+
3040
+ // ── Route handlers (all close over _getDb, _getActiveDb, _getWorkspaces, etc.) ──
3041
+
3042
+ function _handleGetSettings(req, res) {
3043
+ try {
3044
+ const data = fs.existsSync(_SETTINGS_FILE)
3045
+ ? JSON.parse(fs.readFileSync(_SETTINGS_FILE, 'utf8'))
3046
+ : {};
3047
+ return send(res, 200, data);
3048
+ } catch { return send(res, 200, {}); }
3049
+ }
3050
+
3051
+ async function _handleSaveSettings(req, res) {
3052
+ const body = await parseBody(req);
3053
+ try {
3054
+ const existing = fs.existsSync(_SETTINGS_FILE)
3055
+ ? JSON.parse(fs.readFileSync(_SETTINGS_FILE, 'utf8'))
3056
+ : {};
3057
+ const merged = { ...existing, ...body };
3058
+ fs.writeFileSync(_SETTINGS_FILE, JSON.stringify(merged, null, 2));
3059
+ return send(res, 200, merged);
3060
+ } catch (err) { return send(res, 500, { error: err.message }); }
3061
+ }
3062
+
3063
+ function _handleGetWorkspaces(req, res) {
3064
+ const ws = _getWorkspaces();
3065
+ const sorted = Object.values(ws.workspaces).sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
3066
+ for (const workspace of sorted) {
3067
+ try {
3068
+ const db = _getDb(workspace.name);
3069
+ const result = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get();
3070
+ workspace.unread_count = result.total;
3071
+ } catch { workspace.unread_count = 0; }
3072
+ }
3073
+ send(res, 200, { active: ws.active, workspaces: sorted });
3074
+ }
3075
+
3076
+ async function _handleCreateWorkspace(req, res) {
3077
+ const body = await parseBody(req);
3078
+ const { name, label } = body;
3079
+ if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) {
3080
+ return sendError(res, 400, 'Name must be [a-z0-9-], 1-32 chars');
3081
+ }
3082
+ const ws = _getWorkspaces();
3083
+ if (ws.workspaces[name]) return sendError(res, 409, 'Workspace already exists');
3084
+ const workspace = { name, label: label || name, color: body.color || null, icon: body.icon || null, createdAt: Date.now() };
3085
+ ws.workspaces[name] = workspace;
3086
+ _setWorkspaces(ws);
3087
+ _getDb(name);
3088
+ send(res, 201, { workspace });
3089
+ }
3090
+
3091
+ async function _handleUpdateWorkspace(req, res, params) {
3092
+ const body = await parseBody(req);
3093
+ const ws = _getWorkspaces();
3094
+ if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
3095
+ if (body.label !== undefined) ws.workspaces[params.name].label = body.label;
3096
+ if (body.color !== undefined) ws.workspaces[params.name].color = body.color;
3097
+ if (body.icon !== undefined) ws.workspaces[params.name].icon = body.icon;
3098
+ _setWorkspaces(ws);
3099
+ send(res, 200, { workspace: ws.workspaces[params.name] });
3100
+ }
3101
+
3102
+ function _handleDeleteWorkspace(req, res, params) {
3103
+ const ws = _getWorkspaces();
3104
+ if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
3105
+ if (Object.keys(ws.workspaces).length <= 1) return sendError(res, 400, 'Cannot delete the only workspace');
3106
+ if (ws.active === params.name) return sendError(res, 400, 'Cannot delete the active workspace');
3107
+ _closeDb(params.name);
3108
+ const dbPath = path.join(_DATA_DIR, `${params.name}.db`);
3109
+ try { fs.unlinkSync(dbPath); } catch { /* ok */ }
3110
+ try { fs.unlinkSync(dbPath + '-wal'); } catch { /* ok */ }
3111
+ try { fs.unlinkSync(dbPath + '-shm'); } catch { /* ok */ }
3112
+ const cleaned = cleanGatewaySessionsByPrefix(`agent:main:${params.name}:chat:`);
3113
+ if (cleaned > 0) console.log(`Cleaned ${cleaned} gateway sessions for workspace: ${params.name}`);
3114
+ delete ws.workspaces[params.name];
3115
+ _setWorkspaces(ws);
3116
+ send(res, 200, { ok: true });
3117
+ }
3118
+
3119
+ async function _handleReorderWorkspaces(req, res) {
3120
+ const body = await parseBody(req);
3121
+ const { order } = body;
3122
+ if (!Array.isArray(order)) return sendError(res, 400, 'order must be an array of workspace names');
3123
+ const ws = _getWorkspaces();
3124
+ order.forEach((name, i) => { if (ws.workspaces[name]) ws.workspaces[name].order = i; });
3125
+ _setWorkspaces(ws);
3126
+ send(res, 200, { ok: true, workspaces: Object.values(ws.workspaces) });
3127
+ }
3128
+
3129
+ function _handleActivateWorkspace(req, res, params) {
3130
+ const ws = _getWorkspaces();
3131
+ if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
3132
+ ws.active = params.name;
3133
+ _setWorkspaces(ws);
3134
+ _getDb(params.name);
3135
+ send(res, 200, { ok: true, workspace: ws.workspaces[params.name] });
3136
+ }
3137
+
3138
+ function _handleGetThreads(req, res, params, query) {
3139
+ const db = _getActiveDb();
3140
+ const page = parseInt(query.page || '1', 10);
3141
+ const limit = Math.min(parseInt(query.limit || '50', 10), 200);
3142
+ const offset = (page - 1) * limit;
3143
+ const search = query.search || '';
3144
+ let threads, total;
3145
+ if (search) {
3146
+ const ftsQuery = `SELECT DISTINCT m.thread_id FROM messages m JOIN messages_fts ON messages_fts.rowid = m.rowid WHERE messages_fts MATCH ?`;
3147
+ const matchingIds = db.prepare(ftsQuery).all(search).map(r => r.thread_id);
3148
+ if (matchingIds.length === 0) return send(res, 200, { threads: [], total: 0, page });
3149
+ const placeholders = matchingIds.map(() => '?').join(',');
3150
+ total = db.prepare(`SELECT COUNT(*) as c FROM threads WHERE id IN (${placeholders})`).get(...matchingIds).c;
3151
+ threads = db.prepare(`SELECT * FROM threads WHERE id IN (${placeholders}) ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?`).all(...matchingIds, limit, offset);
3152
+ } else {
3153
+ total = db.prepare('SELECT COUNT(*) as c FROM threads').get().c;
3154
+ threads = db.prepare('SELECT * FROM threads ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?').all(limit, offset);
3155
+ }
3156
+ send(res, 200, { threads, total, page });
3157
+ }
3158
+
3159
+ function _handleGetUnreadThreads(req, res) {
3160
+ const db = _getActiveDb();
3161
+ const threads = db.prepare(`
3162
+ SELECT t.id, t.title, t.unread_count, m.content as lastMessage
3163
+ FROM threads t
3164
+ LEFT JOIN messages m ON m.thread_id = t.id
3165
+ WHERE t.unread_count > 0
3166
+ AND m.timestamp = (SELECT MAX(timestamp) FROM messages WHERE thread_id = t.id)
3167
+ ORDER BY t.updated_at DESC
3168
+ `).all();
3169
+ for (const thread of threads) {
3170
+ const rows = db.prepare('SELECT message_id FROM unread_messages WHERE thread_id = ?').all(thread.id);
3171
+ thread.unreadMessageIds = rows.map(r => r.message_id);
3172
+ }
3173
+ send(res, 200, { threads });
3174
+ }
3175
+
3176
+ async function _handleMarkMessagesRead(req, res, params) {
3177
+ const body = await parseBody(req);
3178
+ const db = _getActiveDb();
3179
+ const threadId = params.id;
3180
+ const messageIds = body.messageIds;
3181
+ if (!Array.isArray(messageIds) || messageIds.length === 0) {
3182
+ return send(res, 400, { error: 'messageIds array required' });
3183
+ }
3184
+ const placeholders = messageIds.map(() => '?').join(',');
3185
+ db.prepare(`DELETE FROM unread_messages WHERE thread_id = ? AND message_id IN (${placeholders})`).run(threadId, ...messageIds);
3186
+ const remaining = syncThreadUnreadCount(db, threadId);
3187
+ const workspace = _getWorkspaces().active;
3188
+ _gatewayClient.broadcastToBrowsers(JSON.stringify({
3189
+ type: 'shellchat', event: 'unread-update', workspace, threadId,
3190
+ action: 'read', messageIds, unreadCount: remaining, timestamp: Date.now()
3191
+ }));
3192
+ send(res, 200, { unread_count: remaining });
3193
+ }
3194
+
3195
+ async function _handleCreateThread(req, res) {
3196
+ const body = await parseBody(req);
3197
+ const db = _getActiveDb();
3198
+ const ws = _getWorkspaces();
3199
+ const id = body.id || uuid();
3200
+ const now = Date.now();
3201
+ const sessionKey = `agent:main:${ws.active}:chat:${id}`;
3202
+ try {
3203
+ db.prepare('INSERT INTO threads (id, session_key, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(id, sessionKey, 'New chat', now, now);
3204
+ } catch (e) {
3205
+ if (e.message.includes('UNIQUE constraint')) return sendError(res, 409, 'Thread already exists');
3206
+ throw e;
3207
+ }
3208
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(id);
3209
+ send(res, 201, { thread });
3210
+ }
3211
+
3212
+ function _handleGetThread(req, res, params) {
3213
+ const db = _getActiveDb();
3214
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3215
+ if (!thread) return sendError(res, 404, 'Thread not found');
3216
+ send(res, 200, { thread });
3217
+ }
3218
+
3219
+ async function _handleUpdateThread(req, res, params) {
3220
+ const body = await parseBody(req);
3221
+ const db = _getActiveDb();
3222
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3223
+ if (!thread) return sendError(res, 404, 'Thread not found');
3224
+ const fields = [], values = [];
3225
+ if (body.title !== undefined) { fields.push('title = ?'); values.push(body.title); }
3226
+ if (body.pinned !== undefined) { fields.push('pinned = ?'); values.push(body.pinned ? 1 : 0); }
3227
+ if (body.pin_order !== undefined) { fields.push('pin_order = ?'); values.push(body.pin_order); }
3228
+ if (body.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(body.sort_order); }
3229
+ if (body.model !== undefined) { fields.push('model = ?'); values.push(body.model); }
3230
+ if (body.last_session_id !== undefined) { fields.push('last_session_id = ?'); values.push(body.last_session_id); }
3231
+ if (body.unread_count !== undefined) { fields.push('unread_count = ?'); values.push(body.unread_count); }
3232
+ if (fields.length > 0) {
3233
+ fields.push('updated_at = ?'); values.push(Date.now()); values.push(params.id);
3234
+ db.prepare(`UPDATE threads SET ${fields.join(', ')} WHERE id = ?`).run(...values);
3235
+ }
3236
+ const updated = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3237
+ send(res, 200, { thread: updated });
3238
+ }
3239
+
3240
+ function _handleDeleteThread(req, res, params) {
3241
+ const db = _getActiveDb();
3242
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3243
+ if (!thread) return sendError(res, 404, 'Thread not found');
3244
+ db.prepare('DELETE FROM threads WHERE id = ?').run(params.id);
3245
+ let sessionIdToDelete = thread.last_session_id;
3246
+ if (!sessionIdToDelete) {
3247
+ try {
3248
+ const raw = fs.readFileSync(path.join(OPENCLAW_SESSIONS_DIR, 'sessions.json'), 'utf8');
3249
+ const store = JSON.parse(raw);
3250
+ const entry = store[thread.session_key];
3251
+ if (entry?.sessionId) sessionIdToDelete = entry.sessionId;
3252
+ } catch { /* ok */ }
3253
+ }
3254
+ cleanGatewaySession(thread.session_key);
3255
+ if (sessionIdToDelete) {
3256
+ const jsonlPath = path.join(OPENCLAW_SESSIONS_DIR, `${sessionIdToDelete}.jsonl`);
3257
+ try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
3258
+ }
3259
+ const uploadDir = path.join(_UPLOADS_DIR, params.id);
3260
+ try { fs.rmSync(uploadDir, { recursive: true }); } catch { /* ok */ }
3261
+ send(res, 200, { ok: true });
3262
+ }
3263
+
3264
+ function _handleGetMessages(req, res, params, query) {
3265
+ const db = _getActiveDb();
3266
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
3267
+ if (!thread) return sendError(res, 404, 'Thread not found');
3268
+ const limit = Math.min(parseInt(query.limit || '100', 10), 500);
3269
+ const before = query.before ? parseInt(query.before, 10) : null;
3270
+ const after = query.after ? parseInt(query.after, 10) : null;
3271
+ let sql = 'SELECT * FROM messages WHERE thread_id = ?';
3272
+ const sqlParams = [params.id];
3273
+ if (before) { sql += ' AND timestamp < ?'; sqlParams.push(before); }
3274
+ if (after) { sql += ' AND timestamp > ?'; sqlParams.push(after); }
3275
+ const countSql = sql.replace('SELECT *', 'SELECT COUNT(*) as c');
3276
+ const total = db.prepare(countSql).get(...sqlParams).c;
3277
+ sql += ' ORDER BY timestamp DESC LIMIT ?';
3278
+ sqlParams.push(limit + 1);
3279
+ const rows = db.prepare(sql).all(...sqlParams);
3280
+ const hasMore = rows.length > limit;
3281
+ const messages = rows.slice(0, limit).reverse();
3282
+ for (const m of messages) {
3283
+ if (m.metadata) { try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ } }
3284
+ }
3285
+ send(res, 200, { messages, hasMore });
3286
+ }
3287
+
3288
+ async function _handleCreateMessage(req, res, params) {
3289
+ const body = await parseBody(req);
3290
+ const db = _getActiveDb();
3291
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
3292
+ if (!thread) return sendError(res, 404, 'Thread not found');
3293
+ if (!body.id || !body.role || body.content === undefined || !body.timestamp) {
3294
+ return sendError(res, 400, 'Required: id, role, content, timestamp');
3295
+ }
3296
+ const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
3297
+ const existing = db.prepare('SELECT id, status FROM messages WHERE id = ?').get(body.id);
3298
+ if (existing) {
3299
+ if (body.status && body.status !== existing.status) {
3300
+ db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?').run(body.status, body.content, metadata, body.id);
3301
+ }
3302
+ } else {
3303
+ db.prepare('INSERT INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run(body.id, params.id, body.role, body.content, body.status || 'sent', metadata, body.seq || null, body.timestamp, Date.now());
3304
+ db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
3305
+ }
3306
+ const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
3307
+ if (message && message.metadata) { try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ } }
3308
+ send(res, existing ? 200 : 201, { message });
3309
+ }
3310
+
3311
+ function _handleDeleteMessage(req, res, params) {
3312
+ const db = _getActiveDb();
3313
+ const msg = db.prepare('SELECT id FROM messages WHERE id = ? AND thread_id = ?').get(params.messageId, params.id);
3314
+ if (!msg) return sendError(res, 404, 'Message not found');
3315
+ db.prepare('DELETE FROM messages WHERE id = ?').run(params.messageId);
3316
+ send(res, 200, { ok: true });
3317
+ }
3318
+
3319
+ function _handleContextFill(req, res, params) {
3320
+ const db = _getActiveDb();
3321
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3322
+ if (!thread) return sendError(res, 404, 'Thread not found');
3323
+ const { preamble, method } = buildContextPreamble(db, params.id, thread.last_session_id);
3324
+ send(res, 200, { preamble, method });
3325
+ }
3326
+
3327
+ function _handleSearch(req, res, params, query) {
3328
+ const db = _getActiveDb();
3329
+ const q = query.q || '';
3330
+ if (!q) return send(res, 200, { results: [], total: 0 });
3331
+ const page = parseInt(query.page || '1', 10);
3332
+ const limit = Math.min(parseInt(query.limit || '20', 10), 100);
3333
+ const offset = (page - 1) * limit;
3334
+ const results = db.prepare(`
3335
+ SELECT m.id as messageId, m.thread_id as threadId, t.title as threadTitle, m.role,
3336
+ snippet(messages_fts, 0, '<mark>', '</mark>', '...', 40) as content, m.timestamp
3337
+ FROM messages_fts JOIN messages m ON messages_fts.rowid = m.rowid JOIN threads t ON m.thread_id = t.id
3338
+ WHERE messages_fts MATCH ? ORDER BY rank LIMIT ? OFFSET ?
3339
+ `).all(q, limit, offset);
3340
+ const totalRow = db.prepare(`SELECT COUNT(*) as c FROM messages_fts WHERE messages_fts MATCH ?`).get(q);
3341
+ send(res, 200, { results, total: totalRow.c });
3342
+ }
3343
+
3344
+ function _handleExport(req, res) {
3345
+ const db = _getActiveDb();
3346
+ const ws = _getWorkspaces();
3347
+ const threads = db.prepare('SELECT * FROM threads ORDER BY updated_at DESC').all();
3348
+ const data = {
3349
+ workspace: ws.active, exportedAt: Date.now(),
3350
+ threads: threads.map(t => {
3351
+ const messages = db.prepare('SELECT * FROM messages WHERE thread_id = ? ORDER BY timestamp ASC').all(t.id);
3352
+ for (const m of messages) { if (m.metadata) { try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ } } }
3353
+ return { ...t, messages };
3354
+ }),
3355
+ };
3356
+ send(res, 200, data);
3357
+ }
3358
+
3359
+ async function _handleImport(req, res) {
3360
+ const body = await parseBody(req);
3361
+ const db = _getActiveDb();
3362
+ const ws = _getWorkspaces();
3363
+ if (!body.threads || !Array.isArray(body.threads)) return sendError(res, 400, 'Expected { threads: [...] }');
3364
+ let threadsImported = 0, messagesImported = 0;
3365
+ const insertThread = db.prepare('INSERT OR IGNORE INTO threads (id, session_key, title, pinned, pin_order, model, last_session_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
3366
+ const insertMsg = db.prepare('INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
3367
+ const importAll = db.transaction(() => {
3368
+ for (const t of body.threads) {
3369
+ if (!t.id) continue;
3370
+ const sessionKey = t.session_key || `agent:main:${ws.active}:chat:${t.id}`;
3371
+ const result = insertThread.run(t.id, sessionKey, t.title || 'Imported chat', t.pinned || 0, t.pin_order || 0, t.model || null, t.last_session_id || null, t.created_at || Date.now(), t.updated_at || Date.now());
3372
+ if (result.changes > 0) threadsImported++;
3373
+ for (const m of (t.messages || [])) {
3374
+ if (!m.id || !m.role) continue;
3375
+ const metadata = m.metadata ? (typeof m.metadata === 'string' ? m.metadata : JSON.stringify(m.metadata)) : null;
3376
+ const r = insertMsg.run(m.id, t.id, m.role, m.content || '', m.status || 'sent', metadata, m.seq || null, m.timestamp || Date.now(), m.created_at || Date.now());
3377
+ if (r.changes > 0) messagesImported++;
3378
+ }
3379
+ }
3380
+ });
3381
+ importAll();
3382
+ send(res, 200, { ok: true, threadsImported, messagesImported });
3383
+ }
3384
+
3385
+ async function _handleUpload(req, res, params) {
3386
+ const db = _getActiveDb();
3387
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
3388
+ if (!thread) return sendError(res, 404, 'Thread not found');
3389
+ const files = await parseMultipart(req);
3390
+ const threadUploadDir = path.join(_UPLOADS_DIR, params.id);
3391
+ fs.mkdirSync(threadUploadDir, { recursive: true });
3392
+ const savedFiles = [];
3393
+ for (const file of files) {
3394
+ const fileId = uuid();
3395
+ const ext = path.extname(file.filename) || '';
3396
+ const savedName = fileId + ext;
3397
+ const filePath = path.join(threadUploadDir, savedName);
3398
+ fs.writeFileSync(filePath, file.data);
3399
+ savedFiles.push({ id: fileId, filename: file.filename, path: `/api/uploads/${params.id}/${fileId}${ext}`, mimeType: file.mimeType, size: file.data.length });
3400
+ }
3401
+ send(res, 200, { files: savedFiles });
3402
+ }
3403
+
3404
+ function _handleServeUpload(req, res, params) {
3405
+ const filePath = path.join(_UPLOADS_DIR, params.threadId, params.fileId);
3406
+ let resolved = filePath;
3407
+ if (!fs.existsSync(resolved)) {
3408
+ const dir = path.join(_UPLOADS_DIR, params.threadId);
3409
+ try {
3410
+ const entries = fs.readdirSync(dir);
3411
+ const match = entries.find(e => e.startsWith(params.fileId.replace(/\.[^.]+$/, '')));
3412
+ if (match) resolved = path.join(dir, match);
3413
+ } catch { /* dir not found */ }
3414
+ }
3415
+ if (!fs.existsSync(resolved)) return sendError(res, 404, 'File not found');
3416
+ const ext = path.extname(resolved).toLowerCase();
3417
+ const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json' };
3418
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
3419
+ const stat = fs.statSync(resolved);
3420
+ res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' });
3421
+ fs.createReadStream(resolved).pipe(res);
3422
+ }
3423
+
3424
+ function _getIntelligencePath(threadId) {
3425
+ const workspace = _getWorkspaces().active;
3426
+ return path.join(_INTELLIGENCE_DIR, workspace, `${threadId}.json`);
3427
+ }
3428
+
3429
+ function _handleGetIntelligence(req, res, params) {
3430
+ const filePath = _getIntelligencePath(params.id);
3431
+ if (!fs.existsSync(filePath)) return send(res, 200, { versions: [], currentVersion: -1 });
3432
+ try { return send(res, 200, JSON.parse(fs.readFileSync(filePath, 'utf8'))); }
3433
+ catch { return send(res, 200, { versions: [], currentVersion: -1 }); }
3434
+ }
3435
+
3436
+ async function _handleSaveIntelligence(req, res, params) {
3437
+ const body = await parseBody(req);
3438
+ const filePath = _getIntelligencePath(params.id);
3439
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
3440
+ const data = { versions: body.versions || [], currentVersion: body.currentVersion ?? -1, updatedAt: Date.now() };
3441
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
3442
+ return send(res, 200, data);
3443
+ }
3444
+
3445
+ async function _handleMemoryList(req, res, query) {
3446
+ const limit = Math.min(parseInt(query.limit) || 20, 100);
3447
+ try {
3448
+ const result = await _memoryProvider.list(limit, query.offset || null);
3449
+ send(res, 200, result);
3450
+ } catch (err) { send(res, 502, { error: `Failed to reach ${_memoryProvider.name}`, detail: err.message }); }
3451
+ }
3452
+
3453
+ async function _handleMemorySearch(req, res, query) {
3454
+ const q = (query.query || '').toLowerCase().trim();
3455
+ if (!q) return send(res, 400, { error: 'Missing query parameter' });
3456
+ try {
3457
+ const result = await _memoryProvider.search(q);
3458
+ send(res, 200, result);
3459
+ } catch (err) { send(res, 502, { error: `Failed to reach ${_memoryProvider.name}`, detail: err.message }); }
3460
+ }
3461
+
3462
+ function _handleMemoryFiles(req, res, query) {
3463
+ const q = (query.query || '').toLowerCase().trim();
3464
+ // Use _MEMORY_FILES_DIR scoped to this factory instance
3465
+ const memories = _parseMemoryFiles();
3466
+ const filtered = q ? memories.filter(m => m.data.toLowerCase().includes(q) || m.title.toLowerCase().includes(q)) : memories;
3467
+ filtered.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
3468
+ send(res, 200, { memories: filtered });
3469
+ }
3470
+
3471
+ function _parseMemoryFiles() {
3472
+ const memories = [];
3473
+ let files;
3474
+ try { files = fs.readdirSync(_MEMORY_FILES_DIR); } catch { return memories; }
3475
+ for (const file of files) {
3476
+ if (!file.endsWith('.md')) continue;
3477
+ const filePath = path.join(_MEMORY_FILES_DIR, file);
3478
+ let stat;
3479
+ try { stat = fs.statSync(filePath); } catch { continue; }
3480
+ if (!stat.isFile()) continue;
3481
+ const content = fs.readFileSync(filePath, 'utf8');
3482
+ const basename = file.replace(/\.md$/, '');
3483
+ const dateMatch = basename.match(/^(\d{4}-\d{2}-\d{2})/);
3484
+ const fileDate = dateMatch ? dateMatch[1] : null;
3485
+ const sections = content.split(/^(?=## )/m);
3486
+ for (const section of sections) {
3487
+ const trimmed = section.trim();
3488
+ if (!trimmed) continue;
3489
+ const headingMatch = trimmed.match(/^##\s+(.+)/);
3490
+ const heading = headingMatch ? headingMatch[1].trim() : null;
3491
+ const body = headingMatch ? trimmed.slice(trimmed.indexOf('\n') + 1).trim() : trimmed;
3492
+ if (!heading && body.match(/^#\s+/) && body.split('\n').length <= 2) continue;
3493
+ const title = heading || basename;
3494
+ const id = `file:${basename}:${title}`;
3495
+ memories.push({ id, source: 'file', file: basename, title, data: heading ? `**${title}**\n${body}` : body, createdAt: fileDate ? `${fileDate}T00:00:00Z` : stat.mtime.toISOString() });
3496
+ }
3497
+ }
3498
+ try {
3499
+ for (const entry of fs.readdirSync(_MEMORY_FILES_DIR)) {
3500
+ const subdir = path.join(_MEMORY_FILES_DIR, entry);
3501
+ if (!fs.statSync(subdir).isDirectory()) continue;
3502
+ for (const file of fs.readdirSync(subdir)) {
3503
+ if (!file.endsWith('.md')) continue;
3504
+ const filePath = path.join(subdir, file);
3505
+ const content = fs.readFileSync(filePath, 'utf8');
3506
+ const basename = file.replace(/\.md$/, '');
3507
+ const relPath = `${entry}/${basename}`;
3508
+ const stat = fs.statSync(filePath);
3509
+ memories.push({ id: `file:${relPath}`, source: 'file', file: relPath, title: basename, data: content.trim(), createdAt: stat.mtime.toISOString() });
3510
+ }
3511
+ }
3512
+ } catch { /* ignore */ }
3513
+ return memories;
3514
+ }
3515
+
3516
+ async function _handleMemoryUpdate(req, res, params) {
3517
+ const id = params.id;
3518
+ try {
3519
+ const chunks = [];
3520
+ for await (const chunk of req) chunks.push(chunk);
3521
+ const body = JSON.parse(Buffer.concat(chunks).toString());
3522
+ const newData = (body.data || '').trim();
3523
+ if (!newData) return send(res, 400, { error: 'Missing data field' });
3524
+ const result = await _memoryProvider.update(id, newData);
3525
+ send(res, 200, { ok: true, result });
3526
+ } catch (err) { send(res, 502, { error: 'Failed to update memory', detail: err.message }); }
3527
+ }
3528
+
3529
+ async function _handleMemoryDelete(req, res, params) {
3530
+ const id = params.id;
3531
+ try {
3532
+ const result = await _memoryProvider.delete(id);
3533
+ send(res, 200, { ok: true, result });
3534
+ } catch (err) { send(res, 502, { error: `Failed to reach ${_memoryProvider.name}`, detail: err.message }); }
3535
+ }
3536
+
3537
+ async function _handleMemoryStatus(req, res) {
3538
+ const status = await _memoryProvider.status();
3539
+ const filesExist = fs.existsSync(_MEMORY_FILES_DIR);
3540
+ send(res, 200, { provider: _memoryProvider.name, host: _MEMORY_CONFIG.host, port: _MEMORY_CONFIG.port, collection: _MEMORY_CONFIG.collection, backend: status, memoryFilesDir: _MEMORY_FILES_DIR, memoryFilesDirExists: filesExist });
3541
+ }
3542
+
3543
+ // ── GatewayClient (scoped to this factory instance) ───────────────────────
3544
+ class _GatewayClient {
3545
+ constructor() {
3546
+ this.ws = null;
3547
+ this.connected = false;
3548
+ this.reconnectAttempts = 0;
3549
+ this.maxReconnectDelay = 30000;
3550
+ this.browserClients = new Map();
3551
+ this._externalBroadcastTargets = [];
3552
+ this.streamState = new Map();
3553
+ this.activityLogs = new Map();
3554
+ setInterval(() => {
3555
+ const cutoff = Date.now() - 10 * 60 * 1000;
3556
+ for (const [runId, log] of this.activityLogs) {
3557
+ if (log.startTime < cutoff) this.activityLogs.delete(runId);
3558
+ }
3559
+ }, 5 * 60 * 1000);
3560
+ }
3561
+
3562
+ connect() {
3563
+ if (this.ws && (this.ws.readyState === WS.CONNECTING || this.ws.readyState === WS.OPEN)) return;
3564
+ console.log(`Connecting to gateway at ${_GATEWAY_WS_URL}...`);
3565
+ this.ws = new WS(_GATEWAY_WS_URL);
3566
+ this.ws.on('open', () => { console.log('Gateway WebSocket connected'); this.reconnectAttempts = 0; });
3567
+ this.ws.on('message', (data) => { this.handleGatewayMessage(data.toString()); });
3568
+ this.ws.on('close', () => { console.log('Gateway WebSocket closed'); this.connected = false; this.broadcastGatewayStatus(false); this.scheduleReconnect(); });
3569
+ this.ws.on('error', (err) => { console.error('Gateway WebSocket error:', err.message); });
3570
+ }
3571
+
3572
+ handleGatewayMessage(data) {
3573
+ _debugLogger.logFrame('GW→SRV', data);
3574
+ let msg;
3575
+ try { msg = JSON.parse(data); } catch { console.error('Invalid JSON from gateway:', data); return; }
3576
+ if (msg.type === 'event' && msg.event === 'connect.challenge') {
3577
+ console.log('Received connect.challenge, sending auth...');
3578
+ this.ws.send(JSON.stringify({ type: 'req', id: 'gw-connect-1', method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' }, role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.admin'], auth: { token: _GATEWAY_TOKEN }, caps: ['tool-events'] } }));
3579
+ return;
3580
+ }
3581
+ if (msg.type === 'res' && msg.payload?.type === 'hello-ok') { console.log('Gateway handshake complete'); this.connected = true; this.broadcastGatewayStatus(true); }
3582
+ if (msg.type === 'res' && msg.id && this._pendingActivityCallbacks?.has(msg.id)) {
3583
+ const callback = this._pendingActivityCallbacks.get(msg.id);
3584
+ this._pendingActivityCallbacks.delete(msg.id);
3585
+ if (msg.ok) callback(msg.payload); else callback(null);
3586
+ }
3587
+ this.broadcastToBrowsers(data);
3588
+ if (msg.type === 'event' && msg.event === 'chat' && msg.payload) this.handleChatEvent(msg.payload);
3589
+ if (msg.type === 'event' && msg.event === 'agent' && msg.payload) this.handleAgentEvent(msg.payload);
3590
+ }
3591
+
3592
+ handleChatEvent(params) {
3593
+ const { sessionKey, state, message, seq } = params;
3594
+ if (state === 'delta') {
3595
+ const parsed = parseSessionKey(sessionKey);
3596
+ if (parsed) {
3597
+ const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming' };
3598
+ existing.buffer += extractContent(message);
3599
+ this.streamState.set(sessionKey, existing);
3600
+ }
3601
+ return;
3602
+ }
3603
+ if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
3604
+ if (state === 'final') this.saveAssistantMessage(sessionKey, message, seq);
3605
+ if (state === 'error') this.saveErrorMarker(sessionKey, message);
3606
+ }
3607
+
3608
+ saveAssistantMessage(sessionKey, message, seq) {
3609
+ const parsed = parseSessionKey(sessionKey);
3610
+ if (!parsed) return;
3611
+ const ws = _getWorkspaces();
3612
+ if (!ws.workspaces[parsed.workspace]) { console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`); return; }
3613
+ const db = _getDb(parsed.workspace);
3614
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
3615
+ if (!thread) { console.log(`Ignoring response for deleted thread: ${parsed.threadId}`); return; }
3616
+ const content = extractContent(message);
3617
+ if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
3618
+ const now = Date.now();
3619
+ const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
3620
+ try {
3621
+ db.prepare(`INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at) VALUES (?, ?, 'assistant', ?, 'sent', ?, ?) ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp`).run(messageId, parsed.threadId, content, now, now);
3622
+ db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
3623
+ const isActive = [...this.browserClients.values()].some(c => c.activeWorkspace === parsed.workspace && c.activeThreadId === parsed.threadId);
3624
+ if (!isActive) {
3625
+ db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
3626
+ syncThreadUnreadCount(db, parsed.threadId);
3627
+ }
3628
+ const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
3629
+ const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
3630
+ const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
3631
+ this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title || 'Chat', preview, unreadCount }));
3632
+ if (!isActive) {
3633
+ const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3634
+ this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'unread-update', workspace: parsed.workspace, threadId: parsed.threadId, messageId, action: 'new', unreadCount, workspaceUnreadTotal, title: threadInfo?.title || 'Chat', preview, timestamp: now }));
3635
+ }
3636
+ console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (seq: ${seq})`);
3637
+ } catch (e) { console.error(`Failed to save assistant message:`, e.message); }
3638
+ }
3639
+
3640
+ saveErrorMarker(sessionKey, message) {
3641
+ const parsed = parseSessionKey(sessionKey);
3642
+ if (!parsed) return;
3643
+ const ws = _getWorkspaces();
3644
+ if (!ws.workspaces[parsed.workspace]) return;
3645
+ const db = _getDb(parsed.workspace);
3646
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
3647
+ if (!thread) return;
3648
+ const errorText = message?.error || message?.content || 'Unknown error';
3649
+ const content = `[error] ${errorText}`;
3650
+ const now = Date.now();
3651
+ const messageId = `gw-error-${parsed.threadId}-${now}`;
3652
+ try {
3653
+ db.prepare('INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(messageId, parsed.threadId, 'system', content, 'sent', '{"transient":true}', now, now);
3654
+ console.log(`Saved error marker for ${parsed.workspace}/${parsed.threadId}`);
3655
+ } catch (e) { console.error(`Failed to save error marker:`, e.message); }
3656
+ }
3657
+
3658
+ handleAgentEvent(payload) {
3659
+ const { runId, stream, data, sessionKey } = payload;
3660
+ if (!runId) return;
3661
+ if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
3662
+ const log = this.activityLogs.get(runId);
3663
+ if (stream === 'assistant') {
3664
+ const text = data?.text || '';
3665
+ if (text) {
3666
+ let currentSegment = log._currentAssistantSegment;
3667
+ if (!currentSegment || currentSegment._sealed) {
3668
+ currentSegment = { type: 'assistant', timestamp: Date.now(), text, _sealed: false };
3669
+ log._currentAssistantSegment = currentSegment;
3670
+ log.steps.push(currentSegment);
3671
+ } else { currentSegment.text = text; }
3672
+ }
3673
+ return;
3674
+ }
3675
+ if (stream === 'thinking') {
3676
+ const thinkingText = data?.text || '';
3677
+ let thinkingStep = log.steps.find(s => s.type === 'thinking');
3678
+ if (thinkingStep) { thinkingStep.text = thinkingText; }
3679
+ else { log.steps.push({ type: 'thinking', timestamp: Date.now(), text: thinkingText }); }
3680
+ const now = Date.now();
3681
+ if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
3682
+ log._lastThinkingBroadcast = now;
3683
+ this.broadcastActivityUpdate(runId, log);
3684
+ }
3685
+ }
3686
+ if (stream === 'tool') {
3687
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) { log._currentAssistantSegment._sealed = true; this.broadcastActivityUpdate(runId, log); }
3688
+ const step = { type: 'tool', timestamp: Date.now(), name: data?.name || 'unknown', phase: data?.phase || 'start', toolCallId: data?.toolCallId, meta: data?.meta, isError: data?.isError || false };
3689
+ if (data?.phase === 'result') {
3690
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
3691
+ if (existing) { existing.phase = 'done'; existing.resultMeta = data?.meta; existing.isError = data?.isError || false; existing.durationMs = Date.now() - existing.timestamp; }
3692
+ else { step.phase = 'done'; log.steps.push(step); }
3693
+ } else if (data?.phase === 'update') {
3694
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
3695
+ if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
3696
+ } else { log.steps.push(step); }
3697
+ this.broadcastActivityUpdate(runId, log);
3698
+ }
3699
+ if (stream === 'lifecycle') {
3700
+ if (data?.phase === 'end' || data?.phase === 'error') {
3701
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
3702
+ const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
3703
+ if (lastAssistantIdx >= 0) log.steps.splice(lastAssistantIdx, 1);
3704
+ log.steps.push({ type: 'lifecycle', timestamp: Date.now(), phase: data.phase });
3705
+ this.finalizeActivityLog(runId, log);
3706
+ }
3707
+ }
3708
+ }
3709
+
3710
+ broadcastActivityUpdate(runId, log) {
3711
+ const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
3712
+ if (!parsed) return;
3713
+ this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'agent-activity', workspace: parsed.workspace, threadId: parsed.threadId, runId, steps: log.steps, summary: this.generateActivitySummary(log.steps) }));
3714
+ }
3715
+
3716
+ finalizeActivityLog(runId, log) {
3717
+ const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
3718
+ if (!parsed) return;
3719
+ const db = _getDb(parsed.workspace);
3720
+ if (!db) return;
3721
+ const hasToolSteps = log.steps.some(s => s.type === 'tool');
3722
+ if (!hasToolSteps && this.ws && this.ws.readyState === 1) { this.fetchToolCallsFromHistory(log.sessionKey, parsed, db, log, runId); return; }
3723
+ this.saveActivityToMessage(parsed, db, log, runId);
3724
+ }
3725
+
3726
+ fetchToolCallsFromHistory(sessionKey, parsed, db, log, runId) {
3727
+ const reqId = `activity-hist-${runId}`;
3728
+ const timeout = setTimeout(() => { this._pendingActivityCallbacks?.delete(reqId); this.saveActivityToMessage(parsed, db, log, runId); }, 5000);
3729
+ if (!this._pendingActivityCallbacks) this._pendingActivityCallbacks = new Map();
3730
+ this._pendingActivityCallbacks.set(reqId, (payload) => {
3731
+ clearTimeout(timeout);
3732
+ if (payload?.messages) {
3733
+ for (let i = payload.messages.length - 1; i >= 0; i--) {
3734
+ const m = payload.messages[i];
3735
+ if (m.role === 'assistant' && Array.isArray(m.content)) {
3736
+ let hasToolUse = false;
3737
+ for (const block of m.content) {
3738
+ if (block.type === 'text' && block.text?.trim()) log.steps.push({ type: 'assistant', timestamp: Date.now(), text: block.text.trim() });
3739
+ if (block.type === 'tool_use') { hasToolUse = true; log.steps.push({ type: 'tool', timestamp: Date.now(), name: block.name || 'unknown', phase: 'done', toolCallId: block.id, meta: this.extractToolMeta(block), isError: false }); }
3740
+ }
3741
+ if (hasToolUse) { const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant'); if (lastAssistantIdx >= 0) log.steps.splice(lastAssistantIdx, 1); }
3742
+ else log.steps = log.steps.filter(s => s.type !== 'assistant');
3743
+ break;
3744
+ }
3745
+ }
3746
+ }
3747
+ this.saveActivityToMessage(parsed, db, log, runId);
3748
+ this.broadcastActivityUpdate(runId, log);
3749
+ });
3750
+ this.ws.send(JSON.stringify({ type: 'req', id: reqId, method: 'sessions.history', params: { key: sessionKey, limit: 3, includeTools: true } }));
3751
+ }
3752
+
3753
+ extractToolMeta(toolUseBlock) {
3754
+ if (!toolUseBlock.input) return '';
3755
+ const input = toolUseBlock.input;
3756
+ if (input.query) return input.query;
3757
+ if (input.command) return input.command.substring(0, 80);
3758
+ if (input.file_path || input.path) return input.file_path || input.path;
3759
+ if (input.url) return input.url;
3760
+ if (input.text) return input.text.substring(0, 60);
3761
+ return '';
3762
+ }
3763
+
3764
+ saveActivityToMessage(parsed, db, log, runId) {
3765
+ setTimeout(() => {
3766
+ try {
3767
+ const msg = db.prepare('SELECT id, metadata FROM messages WHERE thread_id = ? AND role = ? ORDER BY timestamp DESC LIMIT 1').get(parsed.threadId, 'assistant');
3768
+ if (msg) {
3769
+ const toolSteps = log.steps.filter(s => s.type === 'tool' && s.phase !== 'update');
3770
+ const thinkingSteps = log.steps.filter(s => s.type === 'thinking');
3771
+ const narrativeSteps = log.steps.filter(s => s.type === 'assistant' && s.text?.trim());
3772
+ if (toolSteps.length > 0 || thinkingSteps.length > 0 || narrativeSteps.length > 0) {
3773
+ const metadata = msg.metadata ? JSON.parse(msg.metadata) : {};
3774
+ metadata.activityLog = log.steps.map(s => { const clean = { ...s }; delete clean._sealed; return clean; });
3775
+ metadata.activitySummary = this.generateActivitySummary(log.steps);
3776
+ db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), msg.id);
3777
+ console.log(`[ActivityLog] Saved ${toolSteps.length} tool steps for message ${msg.id}`);
3778
+ this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'activity-saved', workspace: parsed.workspace, threadId: parsed.threadId, messageId: msg.id }));
3779
+ }
3780
+ }
3781
+ } catch (e) { console.error('Failed to save activity log:', e.message); }
3782
+ this.activityLogs.delete(runId);
3783
+ }, 1000);
3784
+ }
3785
+
3786
+ generateActivitySummary(steps) {
3787
+ const toolSteps = steps.filter(s => s.type === 'tool' && s.phase !== 'result' && s.phase !== 'update');
3788
+ const hasThinking = steps.some(s => s.type === 'thinking' && s.text);
3789
+ const hasNarration = steps.some(s => s.type === 'assistant' && s.text?.trim());
3790
+ if (toolSteps.length === 0 && !hasThinking && !hasNarration) return null;
3791
+ if (toolSteps.length === 0 && hasThinking) return 'Reasoned through the problem';
3792
+ if (toolSteps.length === 0 && hasNarration) return 'Processed in multiple steps';
3793
+ const counts = {};
3794
+ for (const s of toolSteps) { const name = s.name || 'unknown'; counts[name] = (counts[name] || 0) + 1; }
3795
+ const parts = [];
3796
+ const toolNames = { 'web_search': 'searched the web', 'web_fetch': 'fetched web pages', 'Read': 'read files', 'read': 'read files', 'Write': 'wrote files', 'write': 'wrote files', 'Edit': 'edited files', 'edit': 'edited files', 'exec': 'ran commands', 'Bash': 'ran commands', 'browser': 'browsed the web', 'memory_search': 'searched memory', 'memory_store': 'saved to memory', 'image': 'analyzed images', 'message': 'sent messages', 'sessions_spawn': 'spawned sub-agents', 'cron': 'managed cron jobs', 'Grep': 'searched code', 'grep': 'searched code', 'Glob': 'found files', 'glob': 'found files' };
3797
+ for (const [name, count] of Object.entries(counts)) { const friendly = toolNames[name]; if (friendly) parts.push(count > 1 ? `${friendly} (${count}x)` : friendly); else parts.push(count > 1 ? `used ${name} (${count}x)` : `used ${name}`); }
3798
+ if (parts.length === 0) return null;
3799
+ if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
3800
+ const last = parts.pop();
3801
+ return (parts.join(', ') + ' and ' + last).replace(/^./, c => c.toUpperCase());
3802
+ }
3803
+
3804
+ addBroadcastTarget(fn) { this._externalBroadcastTargets.push(fn); }
3805
+ removeBroadcastTarget(fn) { this._externalBroadcastTargets = this._externalBroadcastTargets.filter(f => f !== fn); }
3806
+
3807
+ broadcastToBrowsers(data) {
3808
+ _debugLogger.logFrame('SRV→BR', data);
3809
+ for (const client of this.browserClients.keys()) {
3810
+ if (client.readyState === WS.OPEN) client.send(data);
3811
+ }
3812
+ for (const fn of this._externalBroadcastTargets) {
3813
+ try { fn(data); } catch { /* target disconnected */ }
3814
+ }
3815
+ }
3816
+
3817
+ broadcastGatewayStatus(connected) {
3818
+ this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'gateway-status', connected }));
3819
+ }
3820
+
3821
+ sendToGateway(data) {
3822
+ _debugLogger.logFrame('SRV→GW', data);
3823
+ if (this.ws && this.ws.readyState === WS.OPEN) this.ws.send(data);
3824
+ else console.error('Cannot send to gateway: not connected');
3825
+ }
3826
+
3827
+ scheduleReconnect() {
3828
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
3829
+ this.reconnectAttempts++;
3830
+ console.log(`Reconnecting to gateway in ${delay}ms (attempt ${this.reconnectAttempts})...`);
3831
+ setTimeout(() => this.connect(), delay);
3832
+ }
3833
+
3834
+ addBrowserClient(ws) {
3835
+ this.browserClients.set(ws, { activeWorkspace: null, activeThreadId: null });
3836
+ if (ws.readyState === WS.OPEN) {
3837
+ ws.send(JSON.stringify({ type: 'shellchat', event: 'gateway-status', connected: this.connected }));
3838
+ const streams = [];
3839
+ for (const [sessionKey, state] of this.streamState.entries()) {
3840
+ if (state.state === 'streaming') streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
3841
+ }
3842
+ if (streams.length > 0) ws.send(JSON.stringify({ type: 'shellchat', event: 'stream-sync', streams }));
3843
+ }
3844
+ }
3845
+
3846
+ removeBrowserClient(ws) { this.browserClients.delete(ws); }
3847
+
3848
+ setActiveThread(ws, workspace, threadId) {
3849
+ const client = this.browserClients.get(ws);
3850
+ if (client) { client.activeWorkspace = workspace; client.activeThreadId = threadId; }
3851
+ if (workspace && threadId) {
3852
+ try {
3853
+ const wsData = _getWorkspaces();
3854
+ if (!wsData.workspaces[workspace]) return;
3855
+ const db = _getDb(workspace);
3856
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(threadId);
3857
+ if (!thread) return;
3858
+ const deleted = db.prepare('DELETE FROM unread_messages WHERE thread_id = ?').run(threadId);
3859
+ if (deleted.changes > 0) {
3860
+ syncThreadUnreadCount(db, threadId);
3861
+ const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3862
+ this.broadcastToBrowsers(JSON.stringify({ type: 'shellchat', event: 'unread-update', workspace, threadId, action: 'clear', unreadCount: 0, workspaceUnreadTotal, timestamp: Date.now() }));
3863
+ }
3864
+ } catch (e) { console.error('Failed to auto-clear unreads on active-thread:', e.message); }
3865
+ }
3866
+ }
3867
+ }
3868
+
3869
+ const _gatewayClient = new _GatewayClient();
3870
+
3871
+ // ── handleRequest (scoped to factory state) ────────────────────────────────
3872
+ async function _handleRequest(req, res) {
3873
+ const [urlPath, queryString] = (req.url || '/').split('?');
3874
+ const query = {};
3875
+ if (queryString) {
3876
+ for (const pair of queryString.split('&')) {
3877
+ const [k, v] = pair.split('=');
3878
+ if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '');
3879
+ }
3880
+ }
3881
+ const method = req.method;
3882
+
3883
+ if (method === 'OPTIONS') { setCors(res); res.writeHead(204); return res.end(); }
3884
+
3885
+ if (method === 'GET' && !urlPath.startsWith('/api/')) {
3886
+ const STATIC_FILES = { '/': 'index.html', '/index.html': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css', '/error-handler.js': 'error-handler.js', '/manifest.json': 'manifest.json', '/favicon.ico': 'favicon.ico' };
3887
+ const fileName = STATIC_FILES[urlPath];
3888
+ const isIcon = urlPath.startsWith('/icons/');
3889
+ const isLib = urlPath.startsWith('/lib/');
3890
+ const isFrontend = urlPath.startsWith('/frontend/');
3891
+ const isConfig = urlPath === '/config.js';
3892
+ const staticPath = fileName ? path.join(__dirname, fileName) : (isIcon || isLib || isFrontend || isConfig) ? path.join(__dirname, urlPath.slice(1)) : null;
3893
+ if (staticPath && fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
3894
+ const ext = path.extname(staticPath).toLowerCase();
3895
+ const mimeMap = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml' };
3896
+ const ct = mimeMap[ext] || 'application/octet-stream';
3897
+ const stat = fs.statSync(staticPath);
3898
+ res.writeHead(200, { 'Content-Type': ct, 'Content-Length': stat.size, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600' });
3899
+ return fs.createReadStream(staticPath).pipe(res);
3900
+ }
3901
+ }
3902
+
3903
+ let p;
3904
+ if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) return _handleServeUpload(req, res, p);
3905
+
3906
+ if (!_checkAuth(req, res)) return;
3907
+
3908
+ try {
3909
+ if (method === 'GET' && urlPath === '/api/file') return handleServeFile(req, res, query);
3910
+ if (method === 'GET' && urlPath === '/api/workspace') return handleWorkspaceList(req, res, query);
3911
+ if (method === 'GET' && urlPath === '/api/workspace/file') return handleWorkspaceFileRead(req, res, query);
3912
+ if (method === 'PUT' && urlPath === '/api/workspace/file') return await handleWorkspaceFileWrite(req, res, query);
3913
+ if (method === 'POST' && urlPath === '/api/workspace/upload') return await handleWorkspaceUpload(req, res, query);
3914
+ if (method === 'GET' && urlPath === '/api/memory/status') return await _handleMemoryStatus(req, res);
3915
+ if (method === 'GET' && urlPath === '/api/memory/list') return await _handleMemoryList(req, res, query);
3916
+ if (method === 'GET' && urlPath === '/api/memory/search') return await _handleMemorySearch(req, res, query);
3917
+ if (method === 'GET' && urlPath === '/api/memory/files') return _handleMemoryFiles(req, res, query);
3918
+ if ((p = matchRoute(method, urlPath, 'PUT /api/memory/:id'))) return await _handleMemoryUpdate(req, res, p);
3919
+ if ((p = matchRoute(method, urlPath, 'DELETE /api/memory/:id'))) return await _handleMemoryDelete(req, res, p);
3920
+ if (method === 'GET' && urlPath === '/api/settings') return _handleGetSettings(req, res);
3921
+ if (method === 'PUT' && urlPath === '/api/settings') return await _handleSaveSettings(req, res);
3922
+ if (method === 'POST' && urlPath === '/api/transcribe') return await handleTranscribe(req, res);
3923
+ if (method === 'GET' && urlPath === '/api/workspaces') return _handleGetWorkspaces(req, res);
3924
+ if (method === 'POST' && urlPath === '/api/workspaces') return await _handleCreateWorkspace(req, res);
3925
+ if ((p = matchRoute(method, urlPath, 'PATCH /api/workspaces/:name'))) return await _handleUpdateWorkspace(req, res, p);
3926
+ if ((p = matchRoute(method, urlPath, 'DELETE /api/workspaces/:name'))) return _handleDeleteWorkspace(req, res, p);
3927
+ if (method === 'POST' && urlPath === '/api/workspaces/reorder') return await _handleReorderWorkspaces(req, res);
3928
+ if ((p = matchRoute(method, urlPath, 'POST /api/workspaces/:name/activate'))) return _handleActivateWorkspace(req, res, p);
3929
+ if (method === 'GET' && urlPath === '/api/threads') return _handleGetThreads(req, res, {}, query);
3930
+ if (method === 'GET' && urlPath === '/api/threads/unread') return _handleGetUnreadThreads(req, res);
3931
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/mark-read'))) return await _handleMarkMessagesRead(req, res, p);
3932
+ if (method === 'POST' && urlPath === '/api/threads') return await _handleCreateThread(req, res);
3933
+ if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/messages'))) return _handleGetMessages(req, res, p, query);
3934
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) return await _handleCreateMessage(req, res, p);
3935
+ if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) return _handleDeleteMessage(req, res, p);
3936
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) return _handleContextFill(req, res, p);
3937
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) return await _handleUpload(req, res, p);
3938
+ if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) return _handleGetIntelligence(req, res, p);
3939
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) return await _handleSaveIntelligence(req, res, p);
3940
+ if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id'))) return _handleGetThread(req, res, p);
3941
+ if ((p = matchRoute(method, urlPath, 'PATCH /api/threads/:id'))) return await _handleUpdateThread(req, res, p);
3942
+ if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id'))) return _handleDeleteThread(req, res, p);
3943
+ if (method === 'GET' && urlPath === '/api/search') return _handleSearch(req, res, {}, query);
3944
+ if (method === 'GET' && urlPath === '/api/export') return _handleExport(req, res);
3945
+ if (method === 'POST' && urlPath === '/api/import') return await _handleImport(req, res);
3946
+ if (method === 'POST' && urlPath === '/api/active-thread') {
3947
+ const body = JSON.parse(await parseBody(req));
3948
+ const { threadId, workspace } = body;
3949
+ if (threadId && workspace) _gatewayClient.setActiveThread(null, workspace, threadId);
3950
+ return send(res, 200, { ok: true });
3951
+ }
3952
+ if (method === 'GET' && urlPath === '/api/health') return send(res, 200, { ok: true, workspace: _getWorkspaces().active, uptime: process.uptime() });
3953
+ sendError(res, 404, `Not found: ${method} ${urlPath}`);
3954
+ } catch (err) {
3955
+ console.error(`Error handling ${method} ${urlPath}:`, err);
3956
+ if (err.message && err.message.includes('UNIQUE constraint')) sendError(res, 409, 'Conflict: ' + err.message);
3957
+ else sendError(res, 500, err.message || 'Internal server error');
3958
+ }
3959
+ }
3960
+
3961
+ // ── Browser WebSocket setup (shared logic for standalone and plugin) ────────
3962
+ function _setupBrowserWs(wssInstance) {
3963
+ wssInstance.on('connection', (ws) => {
3964
+ console.log('Browser client connected');
3965
+ _gatewayClient.addBrowserClient(ws);
3966
+ const nonce = crypto.randomUUID();
3967
+ ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: { nonce, ts: Date.now() } }));
3968
+
3969
+ ws.on('message', (data) => {
3970
+ const msgStr = data.toString();
3971
+ _debugLogger.logFrame('BR→SRV', msgStr);
3972
+ try {
3973
+ const msg = JSON.parse(msgStr);
3974
+ if (msg.type === 'req' && msg.method === 'connect') {
3975
+ const token = msg.params?.auth?.token;
3976
+ if (token === _AUTH_TOKEN || !_AUTH_TOKEN) {
3977
+ console.log('Browser client authenticated');
3978
+ ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: true, payload: { type: 'hello-ok', protocol: 3, server: { version: '0.1.0', host: 'shellchat-backend' } } }));
3979
+ } else {
3980
+ console.log('Browser client auth failed');
3981
+ ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: false, error: { code: 'AUTH_FAILED', message: 'Invalid auth token' } }));
3982
+ ws.close();
3983
+ }
3984
+ return;
3985
+ }
3986
+ if (msg.type === 'shellchat') {
3987
+ if (msg.action === 'active-thread') { _gatewayClient.setActiveThread(ws, msg.workspace, msg.threadId); console.log(`Browser client set active thread: ${msg.workspace}/${msg.threadId}`); return; }
3988
+ if (msg.action === 'debug-start') { const result = _debugLogger.start(msg.ts, ws); if (result.error === 'already-active') ws.send(JSON.stringify({ type: 'shellchat', event: 'debug-error', error: 'Recording already active in another tab', sessionId: result.sessionId })); else ws.send(JSON.stringify({ type: 'shellchat', event: 'debug-started', sessionId: result.sessionId })); return; }
3989
+ if (msg.action === 'debug-dump') { const { sessionId, files } = _debugLogger.saveDump(msg); ws.send(JSON.stringify({ type: 'shellchat', event: 'debug-saved', sessionId, files })); return; }
3990
+ }
3991
+ } catch { /* Not JSON or not a ShellChat message, forward to gateway */ }
3992
+ _gatewayClient.sendToGateway(msgStr);
3993
+ });
3994
+
3995
+ ws.on('close', () => { console.log('Browser client disconnected'); _debugLogger.handleClientDisconnect(ws); _gatewayClient.removeBrowserClient(ws); });
3996
+ ws.on('error', (err) => { console.error('Browser WebSocket error:', err.message); });
3997
+ });
3998
+ }
3999
+
4000
+ // ── Public API ─────────────────────────────────────────────────────────────
4001
+ _ensureDirs();
4002
+
4003
+ return {
4004
+ handleRequest: _handleRequest,
4005
+ getDb: _getDb,
4006
+ getActiveDb: _getActiveDb,
4007
+ migrate,
4008
+ getWorkspaces: _getWorkspaces,
4009
+ setWorkspaces: _setWorkspaces,
4010
+ shutdown: _closeAllDbs,
4011
+ closeAllDbs: _closeAllDbs,
4012
+ gatewayClient: _gatewayClient,
4013
+ setupBrowserWs: _setupBrowserWs,
4014
+ dataDir: _DATA_DIR,
4015
+ };
4016
+ }
4017
+
4018
+ // ─── Server Startup (standalone mode only) ───────────────────────────────────
4019
+
4020
+ const isDirectRun = import.meta.url === `file://${process.argv[1]}`;
4021
+ if (isDirectRun) {
4022
+ const app = createApp();
4023
+ app.getActiveDb(); // Eagerly open active workspace DB
4024
+
4025
+ const server = http.createServer(app.handleRequest);
4026
+
4027
+ // ─── Browser WebSocket Server ───────────────────────────────────────────────
4028
+
4029
+ const wss = new WebSocketServer({ noServer: true });
4030
+ app.setupBrowserWs(wss);
4031
+
4032
+ // Handle WebSocket upgrade requests
4033
+ server.on('upgrade', (req, socket, head) => {
4034
+ wss.handleUpgrade(req, socket, head, (ws) => {
4035
+ wss.emit('connection', ws, req);
4036
+ });
4037
+ });
4038
+
4039
+ server.listen(PORT, () => {
4040
+ console.log(`ShellChat backend listening on port ${PORT}`);
4041
+ console.log(`Active workspace: ${app.getWorkspaces().active}`);
4042
+ console.log(`Data dir: ${app.dataDir}`);
4043
+
4044
+ // Connect to gateway
4045
+ app.gatewayClient.connect();
4046
+ });
4047
+
4048
+ // Graceful shutdown
4049
+ function shutdown() {
4050
+ console.log('Shutting down...');
4051
+ app.shutdown();
4052
+ server.close(() => process.exit(0));
4053
+ // Force exit after 5s
4054
+ setTimeout(() => process.exit(1), 5000);
4055
+ }
4056
+ process.on('SIGTERM', shutdown);
4057
+ process.on('SIGINT', shutdown);
4058
+ }