@clawchatsai/connector 0.0.85 → 0.0.87

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 CHANGED
@@ -1,195 +1,144 @@
1
- // ClawChats 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 { createRequire } from 'node:module';
14
- import { AsyncLocalStorage } from 'node:async_hooks';
15
- import { WebSocket as WS, WebSocketServer } from 'ws';
16
-
17
- const __filename = fileURLToPath(import.meta.url);
18
- const __dirname = path.dirname(__filename);
19
-
20
- // ─── Native Module Bootstrap (better-sqlite3) ───────────────────────────────
21
- // better-sqlite3 is a native (.node) binary compiled for a specific Node.js
22
- // ABI. If the installed binary doesn't match the running Node version, we
23
- // auto-rebuild in-place before proceeding. Falls back to a clear error message
24
- // if the user is missing build tools.
25
- const _require = createRequire(import.meta.url);
26
-
27
- // Per-request workspace DB — isolates concurrent clients on different workspaces.
28
- const _requestDbStore = new AsyncLocalStorage();
29
- let Database;
30
- {
31
- const _nativeErr = (e) =>
32
- e.message && (
33
- e.message.includes('did not self-register') ||
34
- e.message.includes('NODE_MODULE_VERSION') ||
35
- e.message.includes('was compiled against a different Node.js version')
36
- );
37
- try {
38
- Database = _require('better-sqlite3');
39
- } catch (e) {
40
- if (_nativeErr(e)) {
41
- console.error('[ClawChats] better-sqlite3 binary is incompatible with your Node.js version. Attempting auto-rebuild...');
42
- try {
43
- execSync('npm rebuild better-sqlite3', { cwd: __dirname, stdio: 'inherit' });
44
- Database = _require('better-sqlite3');
45
- console.log('[ClawChats] Auto-rebuild succeeded — continuing startup.');
46
- } catch (rebuildErr) {
47
- console.error('[ClawChats] Auto-rebuild failed. Build tools may be missing.');
48
- console.error('[ClawChats] To fix, run the following, then restart the gateway:');
49
- console.error(`[ClawChats] cd ${__dirname} && npm rebuild better-sqlite3`);
50
- console.error('[ClawChats] If that fails, install build tools first:');
51
- console.error('[ClawChats] Linux: sudo apt install build-essential python3');
52
- console.error('[ClawChats] macOS: xcode-select --install');
53
- process.exit(1);
54
- }
55
- } else {
56
- throw e;
1
+ // ClawChats backend — built by esbuild, source: github.com/clawchatsai/connector
2
+
3
+ var __defProp = Object.defineProperty;
4
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
5
+
6
+ // server/index.js
7
+ import http from "node:http";
8
+ import fs13 from "node:fs";
9
+ import path14 from "node:path";
10
+ import os5 from "node:os";
11
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
12
+ import { WebSocket as WS2, WebSocketServer } from "ws";
13
+
14
+ // server/bootstrap/native.js
15
+ import { DatabaseSync as Database } from "node:sqlite";
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+ var requestDbStore = new AsyncLocalStorage();
18
+
19
+ // server/config.js
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import os from "node:os";
23
+ import { fileURLToPath } from "node:url";
24
+ var HOME = os.homedir();
25
+ var MAX_PREAMBLE_CHARS = 5e4;
26
+ var __filename = fileURLToPath(import.meta.url);
27
+ var __dirname = path.dirname(__filename);
28
+ function parseConfigField(field) {
29
+ const candidates = [path.join(__dirname, "config.js"), path.join(__dirname, "..", "config.js")];
30
+ for (const configPath of candidates) {
31
+ try {
32
+ const configText = fs.readFileSync(configPath, "utf8");
33
+ const match = configText.match(new RegExp(`${field}:\\s*['"]([^'"]+)['"]`));
34
+ if (match) return match[1];
35
+ } catch {
57
36
  }
58
37
  }
38
+ return null;
59
39
  }
60
-
61
- // ─── Device Identity (ed25519 signing for OpenClaw ≥2.15 scope preservation)
62
-
63
- const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
64
-
65
- function _derivePublicKeyRaw(publicKeyPem) {
66
- const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
67
- if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
68
- spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
69
- return spki.subarray(ED25519_SPKI_PREFIX.length);
40
+ __name(parseConfigField, "parseConfigField");
41
+ var AUTH_TOKEN = process.env.CLAWCHATS_AUTH_TOKEN || parseConfigField("authToken") || "";
42
+ function discoverGatewayWsUrl() {
43
+ if (process.env.GATEWAY_WS_URL) return process.env.GATEWAY_WS_URL;
44
+ for (const cfgPath of [path.join(HOME, ".openclaw", "openclaw.json"), "/etc/openclaw/openclaw.json"]) {
45
+ try {
46
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
47
+ const port = raw.gateway?.port || raw.port;
48
+ const host = raw.gateway?.host || raw.host || "localhost";
49
+ if (port) return `ws://${host}:${port}`;
50
+ } catch {
51
+ }
70
52
  }
71
- return spki;
72
- }
73
-
74
- function _fingerprintPublicKey(publicKeyPem) {
75
- return crypto.createHash('sha256').update(_derivePublicKeyRaw(publicKeyPem)).digest('hex');
53
+ return "ws://localhost:18789";
76
54
  }
77
-
78
- function _base64UrlEncode(buf) {
79
- return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
80
- }
81
-
82
- function _loadOrCreateDeviceIdentity(identityPath) {
83
- try {
84
- if (fs.existsSync(identityPath)) {
85
- const parsed = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
86
- if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) return parsed;
87
- }
88
- } catch { /* regenerate */ }
89
- const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
90
- const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
91
- const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
92
- const identity = { version: 1, deviceId: _fingerprintPublicKey(publicKeyPem), publicKeyPem, privateKeyPem, createdAtMs: Date.now() };
93
- fs.mkdirSync(path.dirname(identityPath), { recursive: true });
94
- fs.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
95
- return identity;
55
+ __name(discoverGatewayWsUrl, "discoverGatewayWsUrl");
56
+ var GATEWAY_WS_URL = discoverGatewayWsUrl();
57
+ var OPENCLAW_SESSIONS_DIR = process.env.OPENCLAW_SESSIONS_DIR || parseConfigField("sessionsDir") || path.join(HOME, ".openclaw", "agents", "main", "sessions");
58
+ function getSessionsDirForAgent(agentId) {
59
+ if (!agentId || agentId === "main") return OPENCLAW_SESSIONS_DIR;
60
+ return path.join(HOME, ".openclaw", "agents", agentId, "sessions");
96
61
  }
97
-
98
- function _buildDeviceAuth(identity, { clientId, clientMode, role, scopes, token, nonce }) {
99
- const signedAt = Date.now();
100
- const payload = ['v2', identity.deviceId, clientId, clientMode, role, scopes.join(','), String(signedAt), token || '', nonce].join('|');
101
- const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
102
- const signature = _base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), privateKey));
103
- const publicKeyB64Url = _base64UrlEncode(_derivePublicKeyRaw(identity.publicKeyPem));
104
- return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce };
62
+ __name(getSessionsDirForAgent, "getSessionsDirForAgent");
63
+ function validateAgent(agentId) {
64
+ if (!agentId) return "main";
65
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) throw new Error("Invalid agent ID");
66
+ const agentDir = path.join(HOME, ".openclaw", "agents", agentId);
67
+ if (!fs.existsSync(agentDir)) throw new Error(`Agent not found: ${agentId}`);
68
+ return agentId;
105
69
  }
70
+ __name(validateAgent, "validateAgent");
106
71
 
107
- // ─── Configuration ──────────────────────────────────────────────────────────
108
-
109
- const PORT = parseInt(process.env.CLAWCHATS_PORT || process.env.SHELLCHAT_PORT || '3001', 10);
110
- const DATA_DIR = path.join(__dirname, 'data');
111
- const UPLOADS_DIR = path.join(__dirname, 'uploads');
112
- const WORKSPACES_FILE = path.join(DATA_DIR, 'workspaces.json');
113
-
114
- // --- Debug Recording (isolated module) ---
115
- class DebugLogger {
72
+ // server/debug.js
73
+ import fs2 from "node:fs";
74
+ import path2 from "node:path";
75
+ var DebugLogger = class {
76
+ static {
77
+ __name(this, "DebugLogger");
78
+ }
116
79
  constructor(baseDir) {
117
- this.baseDir = path.join(baseDir, '..', 'debug');
80
+ this.baseDir = path2.join(baseDir, "..", "debug");
118
81
  this.active = false;
119
82
  this.sessionId = null;
120
83
  this.wsStream = null;
121
84
  this.originatingClient = null;
122
85
  }
123
-
124
86
  start(ts, originatingClient) {
125
- if (this.active) {
126
- return { error: 'already-active', sessionId: this.sessionId };
127
- }
128
- this.sessionId = ts.replace(/[:.]/g, '-');
87
+ if (this.active) return { error: "already-active", sessionId: this.sessionId };
88
+ this.sessionId = ts.replace(/[:.]/g, "-");
129
89
  this.originatingClient = originatingClient;
130
- fs.mkdirSync(this.baseDir, { recursive: true });
131
- const wsLogPath = path.join(this.baseDir, `session-${this.sessionId}-ws.log`);
132
- this.wsStream = fs.createWriteStream(wsLogPath, { flags: 'a' });
90
+ fs2.mkdirSync(this.baseDir, { recursive: true });
91
+ this.wsStream = fs2.createWriteStream(path2.join(this.baseDir, `session-${this.sessionId}-ws.log`), { flags: "a" });
133
92
  this.active = true;
134
93
  console.log(`Debug recording started: ${this.sessionId}`);
135
94
  return { sessionId: this.sessionId };
136
95
  }
137
-
138
96
  logFrame(direction, data) {
139
- if (!this.active || !this.wsStream) return;
140
- this.wsStream.write(`${new Date().toISOString()} ${direction} ${data}\n`);
97
+ if (this.active && this.wsStream) this.wsStream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${direction} ${data}
98
+ `);
141
99
  }
142
-
143
100
  saveDump(payload) {
144
101
  if (!this.sessionId) return { sessionId: null, files: [] };
145
102
  const files = [];
146
103
  const id = this.sessionId;
147
-
148
104
  if (this.wsStream) {
149
105
  this.wsStream.end();
150
106
  this.wsStream = null;
151
107
  files.push(`session-${id}-ws.log`);
152
108
  }
153
-
154
- const clientLogPath = path.join(this.baseDir, `session-${id}-client.log`);
155
- let logContent = '';
156
- for (const entry of (payload.console || [])) {
157
- const args = entry.args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
158
- logContent += `${entry.ts} [${entry.level.toUpperCase()}] ${args}\n`;
159
- }
160
- for (const err of (payload.errors || [])) {
161
- logContent += `${err.ts} [UNHANDLED] ${err.message}\n${err.stack || ''}\n`;
109
+ let logContent = "";
110
+ for (const entry of payload.console || []) {
111
+ logContent += `${entry.ts} [${entry.level.toUpperCase()}] ${entry.args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
112
+ `;
162
113
  }
114
+ for (const err of payload.errors || []) logContent += `${err.ts} [UNHANDLED] ${err.message}
115
+ ${err.stack || ""}
116
+ `;
163
117
  if (logContent) {
164
- fs.writeFileSync(clientLogPath, logContent);
118
+ fs2.writeFileSync(path2.join(this.baseDir, `session-${id}-client.log`), logContent);
165
119
  files.push(`session-${id}-client.log`);
166
120
  }
167
-
168
121
  if (payload.state) {
169
- const statePath = path.join(this.baseDir, `session-${id}-state.json`);
170
- fs.writeFileSync(statePath, JSON.stringify(payload.state, null, 2));
122
+ fs2.writeFileSync(path2.join(this.baseDir, `session-${id}-state.json`), JSON.stringify(payload.state, null, 2));
171
123
  files.push(`session-${id}-state.json`);
172
124
  }
173
-
174
125
  if (payload.screenshot) {
175
- const ssPath = path.join(this.baseDir, `session-${id}-screenshot.jpg`);
176
- fs.writeFileSync(ssPath, Buffer.from(payload.screenshot, 'base64'));
126
+ fs2.writeFileSync(path2.join(this.baseDir, `session-${id}-screenshot.jpg`), Buffer.from(payload.screenshot, "base64"));
177
127
  files.push(`session-${id}-screenshot.jpg`);
178
128
  }
179
-
180
129
  const savedId = id;
181
130
  this.active = false;
182
131
  this.sessionId = null;
183
132
  this.originatingClient = null;
184
- console.log(`Debug session saved: ${files.join(', ')}`);
133
+ console.log(`Debug session saved: ${files.join(", ")}`);
185
134
  return { sessionId: savedId, files };
186
135
  }
187
-
188
136
  handleClientDisconnect(ws) {
189
137
  if (this.active && this.originatingClient === ws) {
190
138
  console.log(`Debug session ${this.sessionId} auto-closed: client disconnected`);
191
139
  if (this.wsStream) {
192
- this.wsStream.write(`${new Date().toISOString()} SYSTEM Client disconnected session auto-closed\n`);
140
+ this.wsStream.write(`${(/* @__PURE__ */ new Date()).toISOString()} SYSTEM Client disconnected \u2014 session auto-closed
141
+ `);
193
142
  this.wsStream.end();
194
143
  this.wsStream = null;
195
144
  }
@@ -198,965 +147,643 @@ class DebugLogger {
198
147
  this.originatingClient = null;
199
148
  }
200
149
  }
201
- }
150
+ };
202
151
 
203
- const debugLogger = new DebugLogger(DATA_DIR);
204
- const HOME = os.homedir();
205
- const MAX_PREAMBLE_CHARS = 50000;
152
+ // server/gateway.js
153
+ import path4 from "node:path";
154
+ import { WebSocket as WS } from "ws";
206
155
 
207
- // ─── Config.js Parser ────────────────────────────────────────────────────────
208
-
209
- function parseConfigField(field) {
210
- try {
211
- const configPath = path.join(__dirname, 'config.js');
212
- const configText = fs.readFileSync(configPath, 'utf8');
213
- const match = configText.match(new RegExp(`${field}:\\s*['"]([^'"]+)['"]`));
214
- return match ? match[1] : null;
215
- } catch { return null; }
156
+ // server/bootstrap/identity.js
157
+ import crypto from "node:crypto";
158
+ import fs3 from "node:fs";
159
+ import path3 from "node:path";
160
+ var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
161
+ function derivePublicKeyRaw(publicKeyPem) {
162
+ const spki = crypto.createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
163
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
164
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
165
+ }
166
+ return spki;
216
167
  }
217
-
218
- // Load auth token from config.js or env var
219
- let AUTH_TOKEN = process.env.CLAWCHATS_AUTH_TOKEN || process.env.SHELLCHAT_AUTH_TOKEN || parseConfigField('authToken') || '';
220
- if (!AUTH_TOKEN) {
221
- console.error('WARNING: No auth token configured. Set CLAWCHATS_AUTH_TOKEN or create config.js');
168
+ __name(derivePublicKeyRaw, "derivePublicKeyRaw");
169
+ function fingerprintPublicKey(publicKeyPem) {
170
+ return crypto.createHash("sha256").update(derivePublicKeyRaw(publicKeyPem)).digest("hex");
222
171
  }
223
-
224
- // Load gateway WebSocket URL
225
- // Priority: env var → OpenClaw config (local) conventional default
226
- // NOTE: Do NOT use config.js gatewayUrl — that's the browser's external-facing URL.
227
- // server.js needs the internal/local gateway address.
228
- function discoverGatewayWsUrl() {
229
- if (process.env.GATEWAY_WS_URL) return process.env.GATEWAY_WS_URL;
230
- // Read OpenClaw's own config for the local gateway port
231
- const candidates = [
232
- path.join(os.homedir(), '.openclaw', 'openclaw.json'),
233
- '/etc/openclaw/openclaw.json'
234
- ];
235
- for (const cfgPath of candidates) {
236
- try {
237
- const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
238
- const port = raw.gateway?.port || raw.port;
239
- const host = raw.gateway?.host || raw.host || 'localhost';
240
- if (port) return `ws://${host}:${port}`;
241
- } catch {}
242
- }
243
- return 'ws://localhost:18789';
172
+ __name(fingerprintPublicKey, "fingerprintPublicKey");
173
+ function base64UrlEncode(buf) {
174
+ return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
244
175
  }
245
- const GATEWAY_WS_URL = discoverGatewayWsUrl();
246
-
247
- // ─── Sessions Directory Discovery ────────────────────────────────────────────
248
-
249
- function discoverViaCliSync() {
176
+ __name(base64UrlEncode, "base64UrlEncode");
177
+ function loadOrCreateDeviceIdentity(identityPath) {
250
178
  try {
251
- const output = execSync('openclaw status --json', { encoding: 'utf8', timeout: 5000 });
252
- const status = JSON.parse(output);
253
- if (status.sessions?.paths?.[0]) {
254
- return path.dirname(status.sessions.paths[0]);
179
+ if (fs3.existsSync(identityPath)) {
180
+ const parsed = JSON.parse(fs3.readFileSync(identityPath, "utf8"));
181
+ if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) return parsed;
255
182
  }
256
- } catch { /* cli not available or failed */ }
257
- return null;
183
+ } catch {
184
+ }
185
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
186
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
187
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
188
+ const identity = { version: 1, deviceId: fingerprintPublicKey(publicKeyPem), publicKeyPem, privateKeyPem, createdAtMs: Date.now() };
189
+ fs3.mkdirSync(path3.dirname(identityPath), { recursive: true });
190
+ fs3.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + "\n", { mode: 384 });
191
+ return identity;
258
192
  }
259
-
260
- const OPENCLAW_SESSIONS_DIR = (() => {
261
- if (process.env.OPENCLAW_SESSIONS_DIR) {
262
- console.log(`Sessions dir: ${process.env.OPENCLAW_SESSIONS_DIR} (source: env)`);
263
- return process.env.OPENCLAW_SESSIONS_DIR;
264
- }
265
- const configDir = parseConfigField('sessionsDir');
266
- if (configDir) {
267
- console.log(`Sessions dir: ${configDir} (source: config)`);
268
- return configDir;
269
- }
270
- const cliDir = discoverViaCliSync();
271
- if (cliDir) {
272
- console.log(`Sessions dir: ${cliDir} (source: cli)`);
273
- return cliDir;
274
- }
275
- const fallback = path.join(HOME, '.openclaw', 'agents', 'main', 'sessions');
276
- console.log(`Sessions dir: ${fallback} (source: fallback)`);
277
- return fallback;
278
- })();
279
-
280
- // ─── Agent Helpers ───────────────────────────────────────────────────────────
281
-
282
- function getSessionsDirForAgent(agentId) {
283
- if (!agentId || agentId === 'main') return OPENCLAW_SESSIONS_DIR;
284
- return path.join(HOME, '.openclaw', 'agents', agentId, 'sessions');
193
+ __name(loadOrCreateDeviceIdentity, "loadOrCreateDeviceIdentity");
194
+ function buildDeviceAuth(identity, { clientId, clientMode, role, scopes, token, nonce }) {
195
+ const signedAt = Date.now();
196
+ const payload = ["v2", identity.deviceId, clientId, clientMode, role, scopes.join(","), String(signedAt), token || "", nonce].join("|");
197
+ const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
198
+ const signature = base64UrlEncode(crypto.sign(null, Buffer.from(payload, "utf8"), privateKey));
199
+ const publicKeyB64Url = base64UrlEncode(derivePublicKeyRaw(identity.publicKeyPem));
200
+ return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce };
285
201
  }
202
+ __name(buildDeviceAuth, "buildDeviceAuth");
286
203
 
287
- function validateAgent(agentId) {
288
- if (!agentId) return 'main';
289
- if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) throw new Error('Invalid agent ID');
290
- const agentDir = path.join(HOME, '.openclaw', 'agents', agentId);
291
- if (!fs.existsSync(agentDir)) throw new Error(`Agent not found: ${agentId}`);
292
- return agentId;
204
+ // server/util/helpers.js
205
+ function syncThreadUnreadCount(db, threadId) {
206
+ const count = db.prepare("SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?").get(threadId).c;
207
+ db.prepare("UPDATE threads SET unread_count = ? WHERE id = ?").run(count, threadId);
208
+ return count;
293
209
  }
294
-
295
- // ─── Workspace Management ───────────────────────────────────────────────────
296
-
297
- function ensureDirs() {
298
- fs.mkdirSync(DATA_DIR, { recursive: true });
299
- fs.mkdirSync(UPLOADS_DIR, { recursive: true });
210
+ __name(syncThreadUnreadCount, "syncThreadUnreadCount");
211
+ function parseSessionKey(sessionKey) {
212
+ if (!sessionKey) return null;
213
+ const match = sessionKey.match(/^agent:([^:]+):([^:]+):chat:([^:]+)$/);
214
+ if (!match) return null;
215
+ return { agent: match[1], workspace: match[2], threadId: match[3] };
300
216
  }
301
-
302
- function loadWorkspaces() {
303
- try {
304
- return JSON.parse(fs.readFileSync(WORKSPACES_FILE, 'utf8'));
305
- } catch {
306
- const initial = {
307
- active: 'default',
308
- workspaces: {
309
- default: {
310
- name: 'default',
311
- label: 'Default',
312
- createdAt: Date.now()
313
- }
314
- }
315
- };
316
- fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(initial, null, 2));
317
- return initial;
217
+ __name(parseSessionKey, "parseSessionKey");
218
+ function extractContent(message) {
219
+ if (!message) return "";
220
+ if (typeof message.content === "string") return message.content;
221
+ if (Array.isArray(message.content)) {
222
+ return message.content.filter((p) => p.type === "text").map((p) => p.text).join("");
318
223
  }
224
+ return "";
319
225
  }
320
-
321
- function saveWorkspaces(data) {
322
- fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(data, null, 2));
323
- }
324
-
325
- let workspacesConfig = null;
326
-
327
- function getWorkspaces() {
328
- if (!workspacesConfig) workspacesConfig = loadWorkspaces();
329
- return workspacesConfig;
226
+ __name(extractContent, "extractContent");
227
+ function isSilentReplyExact(text, token = "NO_REPLY") {
228
+ if (!text) return false;
229
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
230
+ return new RegExp(`^\\s*${escaped}\\s*$`).test(text);
330
231
  }
331
-
332
- function setWorkspaces(data) {
333
- workspacesConfig = data;
334
- saveWorkspaces(data);
232
+ __name(isSilentReplyExact, "isSilentReplyExact");
233
+ function isSilentReplyPrefix(text, token = "NO_REPLY") {
234
+ if (!text) return false;
235
+ const trimmed = text.trimStart();
236
+ if (!trimmed) return false;
237
+ if (trimmed !== trimmed.toUpperCase()) return false;
238
+ const normalized = trimmed.toUpperCase();
239
+ if (normalized.length < 2 || /[^A-Z_]/.test(normalized)) return false;
240
+ const tokenUpper = token.toUpperCase();
241
+ if (!tokenUpper.startsWith(normalized)) return false;
242
+ if (normalized.includes("_")) return true;
243
+ return tokenUpper === "NO_REPLY" && normalized === "NO";
335
244
  }
336
-
337
- // ─── Database Management ────────────────────────────────────────────────────
338
-
339
- const dbCache = new Map(); // name → Database instance
340
-
341
- function getDb(workspaceName) {
342
- if (dbCache.has(workspaceName)) return dbCache.get(workspaceName);
343
- const dbPath = path.join(DATA_DIR, `${workspaceName}.db`);
344
- const db = new Database(dbPath);
345
- db.pragma('journal_mode = WAL');
346
- db.pragma('foreign_keys = ON');
347
- migrate(db);
348
- dbCache.set(workspaceName, db);
349
- return db;
245
+ __name(isSilentReplyPrefix, "isSilentReplyPrefix");
246
+ function stripTrailingSentinel(text, token = "NO_REPLY") {
247
+ if (!text) return text;
248
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
249
+ return text.replace(new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`), "").trim();
350
250
  }
351
-
352
- function getActiveDb() {
353
- return _requestDbStore.getStore() || getDb(getWorkspaces().active);
251
+ __name(stripTrailingSentinel, "stripTrailingSentinel");
252
+ function stripFinalTags(text) {
253
+ return text ? text.replace(/<\s*\/?\s*final\s*>/gi, "") : text;
354
254
  }
355
-
356
- const _globalDbCache = new Map(); // keyed by resolved dbPath
357
- function getGlobalDb(dataDir = DATA_DIR) {
358
- const dbPath = path.join(dataDir, 'global.db');
359
- if (_globalDbCache.has(dbPath)) return _globalDbCache.get(dbPath);
360
- fs.mkdirSync(dataDir, { recursive: true });
361
- const db = new Database(dbPath);
362
- db.pragma('journal_mode = WAL');
363
- db.exec(`
364
- CREATE TABLE IF NOT EXISTS custom_emojis (
365
- name TEXT NOT NULL,
366
- pack TEXT NOT NULL DEFAULT 'slackmojis',
367
- url TEXT NOT NULL,
368
- mime_type TEXT,
369
- created_at INTEGER DEFAULT (strftime('%s','now')),
370
- PRIMARY KEY (name, pack)
371
- )
372
- `);
373
- _globalDbCache.set(dbPath, db);
374
- return db;
255
+ __name(stripFinalTags, "stripFinalTags");
256
+ function sanitizeAssistantContent(text) {
257
+ if (!text) return text;
258
+ let out = stripFinalTags(text);
259
+ out = out.replace(/^(?:[ \t]*\r?\n)+/, "");
260
+ if (out.includes("NO_REPLY")) out = stripTrailingSentinel(out, "NO_REPLY");
261
+ if (out.includes("HEARTBEAT_OK")) out = stripTrailingSentinel(out, "HEARTBEAT_OK");
262
+ return out;
375
263
  }
376
-
377
- function closeDb(workspaceName) {
378
- const db = dbCache.get(workspaceName);
379
- if (db) {
380
- db.close();
381
- dbCache.delete(workspaceName);
264
+ __name(sanitizeAssistantContent, "sanitizeAssistantContent");
265
+ function generateActivitySummary(steps) {
266
+ const toolSteps = steps.filter((s) => s.type === "tool" && s.phase !== "result" && s.phase !== "update");
267
+ const hasThinking = steps.some((s) => s.type === "thinking" && s.text);
268
+ const hasNarration = steps.some((s) => s.type === "assistant" && s.text?.trim());
269
+ if (toolSteps.length === 0 && !hasThinking && !hasNarration) return null;
270
+ if (toolSteps.length === 0 && hasThinking) return "Reasoned through the problem";
271
+ if (toolSteps.length === 0 && hasNarration) return "Processed in multiple steps";
272
+ const counts = {};
273
+ for (const s of toolSteps) {
274
+ const name = s.name || "unknown";
275
+ counts[name] = (counts[name] || 0) + 1;
382
276
  }
277
+ 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" };
278
+ const parts = [];
279
+ for (const [name, count] of Object.entries(counts)) {
280
+ const friendly = toolNames[name];
281
+ parts.push(friendly ? count > 1 ? `${friendly} (${count}\xD7)` : friendly : count > 1 ? `used ${name} (${count}\xD7)` : `used ${name}`);
282
+ }
283
+ if (parts.length === 0) return null;
284
+ if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
285
+ const last = parts.pop();
286
+ return (parts.join(", ") + " and " + last).replace(/^./, (c) => c.toUpperCase());
383
287
  }
384
-
385
- function closeAllDbs() {
386
- for (const [, db] of dbCache) db.close();
387
- dbCache.clear();
388
- for (const [, db] of _globalDbCache) db.close();
389
- _globalDbCache.clear();
390
- }
391
-
392
- function _createFtsTables(db) {
393
- // Each statement must be a separate db.exec() call — SQLite's multi-statement exec
394
- // splits on every semicolon including those inside BEGIN...END trigger bodies,
395
- // which breaks trigger creation with an "unrecognized token" error.
396
- db.exec(`CREATE VIRTUAL TABLE messages_fts USING fts5(
397
- content,
398
- content=messages,
399
- content_rowid=rowid,
400
- tokenize='porter unicode61 tokenchars x27'
401
- )`);
402
- db.exec(`CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
403
- INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
404
- END`);
405
- db.exec(`CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
406
- INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content);
407
- END`);
408
- db.exec(`CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
409
- INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content);
410
- INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
411
- END`);
412
- }
413
-
414
- function _dropFtsTables(db) {
415
- db.exec(`
416
- DROP TABLE IF EXISTS messages_fts;
417
- DROP TRIGGER IF EXISTS messages_ai;
418
- DROP TRIGGER IF EXISTS messages_ad;
419
- DROP TRIGGER IF EXISTS messages_au;
420
- `);
288
+ __name(generateActivitySummary, "generateActivitySummary");
289
+ function writeActivityToDb(getDbFn, broadcastFn, runId, log) {
290
+ if (!log._parsed) log._parsed = parseSessionKey(log.sessionKey);
291
+ const parsed = log._parsed;
292
+ if (!parsed) return;
293
+ const db = getDbFn(parsed.workspace);
294
+ if (!db) return;
295
+ const cleanSteps = log.steps.map((s) => {
296
+ const c = { ...s };
297
+ delete c._sealed;
298
+ return c;
299
+ });
300
+ const summary = generateActivitySummary(log.steps);
301
+ const now = Date.now();
302
+ if (!log._messageId) {
303
+ const thread = db.prepare("SELECT id FROM threads WHERE id = ?").get(parsed.threadId);
304
+ if (!thread) return;
305
+ const messageId = `gw-activity-${runId}`;
306
+ const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
307
+ try {
308
+ db.prepare(`INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)`).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
309
+ log._messageId = messageId;
310
+ broadcastFn(JSON.stringify({ type: "clawchats", event: "message-saved", threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now }));
311
+ } catch (err) {
312
+ console.error(`[activity] Failed to write activity ${messageId}:`, err.message);
313
+ }
314
+ } else {
315
+ const existing = db.prepare("SELECT metadata FROM messages WHERE id = ?").get(log._messageId);
316
+ const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
317
+ metadata.activityLog = cleanSteps;
318
+ metadata.activitySummary = summary;
319
+ metadata.pending = true;
320
+ db.prepare("UPDATE messages SET metadata = ? WHERE id = ?").run(JSON.stringify(metadata), log._messageId);
321
+ }
421
322
  }
323
+ __name(writeActivityToDb, "writeActivityToDb");
422
324
 
423
- function migrate(db) {
424
- db.exec(`
425
- CREATE TABLE IF NOT EXISTS threads (
426
- id TEXT PRIMARY KEY,
427
- session_key TEXT UNIQUE NOT NULL,
428
- title TEXT DEFAULT 'New chat',
429
- pinned INTEGER DEFAULT 0,
430
- pin_order INTEGER DEFAULT 0,
431
- model TEXT,
432
- last_session_id TEXT,
433
- created_at INTEGER NOT NULL,
434
- updated_at INTEGER NOT NULL
435
- );
436
-
437
- CREATE TABLE IF NOT EXISTS messages (
438
- id TEXT PRIMARY KEY,
439
- thread_id TEXT NOT NULL,
440
- role TEXT NOT NULL,
441
- content TEXT NOT NULL,
442
- status TEXT DEFAULT 'sent',
443
- metadata TEXT,
444
- seq INTEGER,
445
- timestamp INTEGER NOT NULL,
446
- created_at INTEGER NOT NULL,
447
- FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
448
- );
449
-
450
- CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, timestamp);
451
- CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages(thread_id, role, timestamp);
452
- `);
453
-
454
- // Migration: add sort_order column if missing
455
- try {
456
- db.exec('ALTER TABLE threads ADD COLUMN sort_order INTEGER DEFAULT 0');
457
- } catch (e) {
458
- // Column already exists — ignore
325
+ // server/gateway.js
326
+ var GatewayClient = class {
327
+ static {
328
+ __name(this, "GatewayClient");
459
329
  }
460
-
461
- // Migration: add unread_count column if missing
462
- try {
463
- db.exec('ALTER TABLE threads ADD COLUMN unread_count INTEGER DEFAULT 0');
464
- } catch (e) {
465
- // Column already exists — ignore
330
+ constructor({ getDb, getWorkspaces, dataDir, debugLogger, gatewayWsUrl, authToken, mediaStash }) {
331
+ this.getDb = getDb;
332
+ this.getWorkspaces = getWorkspaces;
333
+ this.dataDir = dataDir;
334
+ this.debugLogger = debugLogger;
335
+ this.gatewayWsUrl = gatewayWsUrl;
336
+ this.authToken = authToken;
337
+ this.mediaStash = mediaStash;
338
+ this.ws = null;
339
+ this.connected = false;
340
+ this.reconnectAttempts = 0;
341
+ this.maxReconnectDelay = 3e4;
342
+ this.browserClients = /* @__PURE__ */ new Map();
343
+ this._externalBroadcastTargets = [];
344
+ this.streamState = /* @__PURE__ */ new Map();
345
+ this.activityLogs = /* @__PURE__ */ new Map();
346
+ this._pendingTitleGens = /* @__PURE__ */ new Map();
347
+ setInterval(() => {
348
+ const cutoff = Date.now() - 10 * 60 * 1e3;
349
+ for (const [runId, log] of this.activityLogs) {
350
+ if (log.startTime < cutoff) {
351
+ if (log._messageId) {
352
+ const db = this.getDb(log._parsed?.workspace);
353
+ if (db) db.prepare(`UPDATE messages SET content = '[Response interrupted]', metadata = json_remove(metadata, '$.pending') WHERE id = ? AND content = ''`).run(log._messageId);
354
+ }
355
+ this.activityLogs.delete(runId);
356
+ }
357
+ }
358
+ }, 5 * 60 * 1e3);
359
+ }
360
+ connect() {
361
+ if (this.ws && (this.ws.readyState === WS.CONNECTING || this.ws.readyState === WS.OPEN)) return;
362
+ console.log(`Connecting to gateway at ${this.gatewayWsUrl}...`);
363
+ this.ws = new WS(this.gatewayWsUrl);
364
+ this.ws.on("open", () => {
365
+ console.log("Gateway WebSocket connected");
366
+ this.reconnectAttempts = 0;
367
+ });
368
+ this.ws.on("message", (data) => this.handleGatewayMessage(data.toString()));
369
+ this.ws.on("close", () => {
370
+ console.log("Gateway WebSocket closed");
371
+ this.connected = false;
372
+ this.broadcastGatewayStatus(false);
373
+ this.scheduleReconnect();
374
+ });
375
+ this.ws.on("error", (err) => console.error("Gateway WebSocket error:", err.message));
466
376
  }
467
-
468
- // Migration: unread_messages table for per-message read tracking
469
- db.exec(`
470
- CREATE TABLE IF NOT EXISTS unread_messages (
471
- thread_id TEXT NOT NULL,
472
- message_id TEXT NOT NULL,
473
- created_at INTEGER NOT NULL,
474
- PRIMARY KEY (thread_id, message_id),
475
- FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
476
- )
477
- `);
478
- db.exec('CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id)');
479
-
480
- // FTS5 table — CREATE VIRTUAL TABLE doesn't support IF NOT EXISTS in all versions,
481
- // so check existence first, then verify integrity and auto-repair if corrupted.
482
- const hasFts = db.prepare(
483
- "SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'"
484
- ).get();
485
- if (!hasFts) {
377
+ handleGatewayMessage(data) {
378
+ this.debugLogger.logFrame("GW\u2192SRV", data);
379
+ let msg;
486
380
  try {
487
- _createFtsTables(db);
488
- db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
489
- } catch (ftsCreateErr) {
490
- console.error('[DB] messages_fts creation failed, search disabled:', ftsCreateErr.message);
381
+ msg = JSON.parse(data);
382
+ } catch {
383
+ console.error("Invalid JSON from gateway:", data);
384
+ return;
385
+ }
386
+ if (msg.type === "event" && msg.event === "connect.challenge") {
387
+ const identity = loadOrCreateDeviceIdentity(path4.join(this.dataDir, "device-identity.json"));
388
+ const device = buildDeviceAuth(identity, { clientId: "gateway-client", clientMode: "backend", role: "operator", scopes: ["operator.read", "operator.write", "operator.admin"], token: this.authToken, nonce: msg.payload?.nonce || "" });
389
+ 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"], device, auth: { token: this.authToken }, caps: ["tool-events"] } }));
390
+ return;
391
+ }
392
+ if (msg.type === "res" && msg.payload?.type === "hello-ok") {
393
+ console.log("Gateway handshake complete");
394
+ this.connected = true;
395
+ this.broadcastGatewayStatus(true);
396
+ }
397
+ if (msg.type === "event" && msg.event === "chat" && msg.payload) {
398
+ this.handleChatEvent(msg.payload, data);
399
+ } else {
400
+ this.broadcastToBrowsers(data);
491
401
  }
492
- } else {
493
- // Migration: rebuild FTS table if tokenizer is outdated (missing tokenchars for apostrophe support).
494
- // Old tokenizer split "there's" into ["there", "s"] causing contraction searches to return 0 results.
495
- const ftsSchema = db.prepare(
496
- "SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'"
497
- ).get();
498
- const needsTokenizerUpgrade = ftsSchema && !ftsSchema.sql.includes('tokenchars');
499
- if (needsTokenizerUpgrade) {
500
- console.log('[DB] messages_fts tokenizer upgrade required (apostrophe support) — rebuilding index...');
501
- try {
502
- _dropFtsTables(db);
503
- _createFtsTables(db);
504
- db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
505
- console.log('[DB] messages_fts tokenizer upgrade complete — apostrophes in contractions now searchable');
506
- } catch (upgradeErr) {
507
- console.error('[DB] messages_fts tokenizer upgrade failed, dropping for graceful degradation:', upgradeErr.message);
508
- _dropFtsTables(db);
402
+ if (msg.type === "event" && msg.event === "agent" && msg.payload) this.handleAgentEvent(msg.payload);
403
+ }
404
+ handleChatEvent(params, rawData) {
405
+ const { sessionKey, state, message, seq } = params;
406
+ if (state === "delta") {
407
+ const parsed = parseSessionKey(sessionKey);
408
+ if (parsed) {
409
+ const existing = this.streamState.get(sessionKey) || { buffer: "", threadId: parsed.threadId, state: "streaming", held: [] };
410
+ existing.buffer += extractContent(message);
411
+ if (isSilentReplyPrefix(existing.buffer, "NO_REPLY") || isSilentReplyPrefix(existing.buffer, "HEARTBEAT_OK")) {
412
+ existing.held = existing.held || [];
413
+ existing.held.push(rawData);
414
+ this.streamState.set(sessionKey, existing);
415
+ return;
416
+ }
417
+ if (existing.held?.length > 0) {
418
+ for (const h of existing.held) this.broadcastToBrowsers(h);
419
+ existing.held = [];
420
+ }
421
+ this.streamState.set(sessionKey, existing);
509
422
  }
510
- } else {
511
- // Integrity check: corruption causes all message writes to 500.
512
- // Attempt rebuild first; if that fails, drop entirely for graceful degradation
513
- // (messages still save, search returns empty until next restart recreates the table).
514
- try {
515
- db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('integrity-check')").run();
516
- } catch (err) {
517
- console.warn('[DB] messages_fts integrity check failed, attempting rebuild:', err.message);
518
- try {
519
- db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
520
- console.log('[DB] messages_fts rebuilt successfully — search index restored');
521
- } catch (rebuildErr) {
522
- console.error('[DB] messages_fts rebuild failed, dropping FTS for graceful degradation:', rebuildErr.message);
523
- _dropFtsTables(db);
524
- // On the next gateway restart the table will be recreated fresh via the !hasFts path
423
+ this.broadcastToBrowsers(rawData);
424
+ return;
425
+ }
426
+ const streamEntry = this.streamState.get(sessionKey);
427
+ if (state === "final" || state === "aborted" || state === "error") this.streamState.delete(sessionKey);
428
+ if (sessionKey?.includes("__clawchats_title_")) {
429
+ if (state === "final") {
430
+ const content = extractContent(message);
431
+ if (content && this.handleTitleResponse(sessionKey, content)) return;
432
+ } else if (state === "error" || state === "aborted") {
433
+ for (const key of this._pendingTitleGens.keys()) {
434
+ if (sessionKey === key || sessionKey.includes(key)) {
435
+ this._pendingTitleGens.delete(key);
436
+ break;
437
+ }
525
438
  }
439
+ return;
526
440
  }
527
441
  }
442
+ if (state === "final") {
443
+ const rawContent = extractContent(message);
444
+ if (isSilentReplyExact(rawContent, "NO_REPLY") || isSilentReplyExact(rawContent, "HEARTBEAT_OK")) return;
445
+ if (streamEntry?.held?.length > 0) for (const h of streamEntry.held) this.broadcastToBrowsers(h);
446
+ this.broadcastToBrowsers(rawData);
447
+ this.saveAssistantMessage(sessionKey, message, seq);
448
+ return;
449
+ }
450
+ if (state === "aborted" || state === "error") this.broadcastToBrowsers(rawData);
451
+ if (state === "error") this.saveErrorMarker(sessionKey, message);
528
452
  }
529
- }
530
-
531
- // ─── HTTP Helpers ───────────────────────────────────────────────────────────
453
+ saveAssistantMessage(sessionKey, message, seq) {
454
+ const parsed = parseSessionKey(sessionKey);
455
+ if (!parsed) return;
456
+ const ws = this.getWorkspaces();
457
+ if (!ws.workspaces[parsed.workspace]) {
458
+ console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`);
459
+ return;
460
+ }
461
+ const db = this.getDb(parsed.workspace);
462
+ if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(parsed.threadId)) {
463
+ console.log(`Ignoring response for deleted thread: ${parsed.threadId}`);
464
+ return;
465
+ }
466
+ let content = sanitizeAssistantContent(extractContent(message));
467
+ if (!content?.trim()) {
468
+ console.log(`Skipping empty assistant response for thread ${parsed.threadId}`);
469
+ return;
470
+ }
471
+ const pendingPaths = this.mediaStash?.get(sessionKey) ?? [];
472
+ this.mediaStash?.delete(sessionKey);
473
+ const IMAGE_EXTS = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "avif", "tiff"]);
474
+ const AUDIO_EXTS = /* @__PURE__ */ new Set(["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus", "wma"]);
475
+ const imagePaths = [], pendingAttachments = [];
476
+ for (const p of pendingPaths) {
477
+ const ext = (p.split(".").pop() || "").toLowerCase();
478
+ if (IMAGE_EXTS.has(ext)) imagePaths.push(p);
479
+ else pendingAttachments.push({ path: p, name: p.split("/").pop(), type: AUDIO_EXTS.has(ext) ? "audio" : "file" });
480
+ }
481
+ if (imagePaths.length > 0) content = content.trimEnd() + "\n\n" + imagePaths.map((p) => `![image](${p})`).join("\n");
482
+ if (pendingPaths.length > 0) console.log(`[clawchats] media-attach: ${imagePaths.length} image(s), ${pendingAttachments.length} attachment(s) for ${sessionKey}`);
483
+ const now = Date.now();
484
+ const pendingMsg = db.prepare(`SELECT id, metadata FROM messages WHERE thread_id = ? AND role = 'assistant' AND json_extract(metadata, '$.pending') = 1 ORDER BY timestamp DESC LIMIT 1`).get(parsed.threadId);
485
+ let messageId;
486
+ if (pendingMsg) {
487
+ const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
488
+ delete metadata.pending;
489
+ if (metadata.activityLog) {
490
+ const idx = metadata.activityLog.findLastIndex((s) => s.type === "assistant");
491
+ if (idx >= 0) metadata.activityLog.splice(idx, 1);
492
+ metadata.activitySummary = generateActivitySummary(metadata.activityLog);
493
+ }
494
+ if (pendingAttachments.length > 0) metadata.attachments = [...metadata.attachments || [], ...pendingAttachments];
495
+ db.prepare("UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?").run(content, JSON.stringify(metadata), now, pendingMsg.id);
496
+ messageId = pendingMsg.id;
497
+ } else {
498
+ messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
499
+ const newMeta = pendingAttachments.length > 0 ? JSON.stringify({ attachments: pendingAttachments }) : null;
500
+ db.prepare(`INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, 'assistant', ?, 'sent', ?, ?, ?) ON CONFLICT(id) DO UPDATE SET content = excluded.content, metadata = COALESCE(excluded.metadata, metadata), timestamp = excluded.timestamp`).run(messageId, parsed.threadId, content, newMeta, now, now);
501
+ }
502
+ try {
503
+ db.prepare("UPDATE threads SET updated_at = ? WHERE id = ?").run(now, parsed.threadId);
504
+ db.prepare("INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)").run(parsed.threadId, messageId, now);
505
+ syncThreadUnreadCount(db, parsed.threadId);
506
+ const threadInfo = db.prepare("SELECT title FROM threads WHERE id = ?").get(parsed.threadId);
507
+ const unreadCount = db.prepare("SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?").get(parsed.threadId).c;
508
+ const preview = content.length > 120 ? content.substring(0, 120) + "..." : content;
509
+ this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "message-saved", threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title, preview, unreadCount, updatedContent: imagePaths.length > 0 ? content : void 0, updatedAttachments: pendingAttachments.length > 0 ? pendingAttachments : void 0 }));
510
+ this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "unread-update", workspace: parsed.workspace, threadId: parsed.threadId, messageId, action: "new", unreadCount, workspaceUnreadTotal: db.prepare("SELECT COALESCE(SUM(unread_count), 0) as total FROM threads").get().total, title: threadInfo?.title, preview, timestamp: now }));
511
+ console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? "merged into pending" : "seq: " + seq})`);
512
+ const msgCount = db.prepare("SELECT COUNT(*) as c FROM messages WHERE thread_id = ?").get(parsed.threadId).c;
513
+ if (msgCount === 2 || threadInfo?.title === "New chat") this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
514
+ } catch (e) {
515
+ console.error("Failed to save assistant message:", e.message);
516
+ }
517
+ }
518
+ saveErrorMarker(sessionKey, message) {
519
+ const parsed = parseSessionKey(sessionKey);
520
+ if (!parsed) return;
521
+ const ws = this.getWorkspaces();
522
+ if (!ws.workspaces[parsed.workspace]) return;
523
+ const db = this.getDb(parsed.workspace);
524
+ if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(parsed.threadId)) return;
525
+ const now = Date.now();
526
+ try {
527
+ db.prepare("INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(`gw-error-${parsed.threadId}-${now}`, parsed.threadId, "system", `[error] ${message?.error || message?.content || "Unknown error"}`, "sent", '{"transient":true}', now, now);
528
+ } catch (e) {
529
+ console.error("Failed to save error marker:", e.message);
530
+ }
531
+ }
532
+ generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
533
+ if (!db.prepare("SELECT title FROM threads WHERE id = ?").get(threadId)) return;
534
+ const titleKey = `__clawchats_title_${threadId}`;
535
+ if (this._pendingTitleGens.has(titleKey)) return;
536
+ const firstUserMsg = db.prepare("SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1").get(threadId);
537
+ if (!firstUserMsg?.content) return;
538
+ if (!skipHeuristic) {
539
+ const heuristic = firstUserMsg.content.replace(/\n.*/s, "").slice(0, 40).trim() + (firstUserMsg.content.length > 40 ? "..." : "");
540
+ db.prepare("UPDATE threads SET title = ? WHERE id = ?").run(heuristic, threadId);
541
+ this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "thread-title-updated", threadId, workspace, title: heuristic }));
542
+ }
543
+ const messages = db.prepare("SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6").all(threadId);
544
+ if (messages.length < 2) return;
545
+ const conversation = messages.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content.length > 300 ? m.content.slice(0, 300) + "..." : m.content}`).join("\n\n");
546
+ const reqId = `title-${threadId}-${Date.now()}`;
547
+ this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
548
+ setTimeout(() => {
549
+ if (this._pendingTitleGens.has(titleKey)) {
550
+ this._pendingTitleGens.delete(titleKey);
551
+ console.log(`Title gen timeout for ${threadId}`);
552
+ }
553
+ }, 3e4);
554
+ this.sendToGateway(JSON.stringify({ type: "req", id: reqId, method: "chat.send", params: { sessionKey: titleKey, message: `Based on this conversation, generate a concise 3-5 word title. Return ONLY the title text, no quotes, no explanation:
532
555
 
533
- function parseBody(req) {
534
- return new Promise((resolve, reject) => {
535
- const chunks = [];
536
- req.on('data', c => chunks.push(c));
537
- req.on('end', () => {
538
- const raw = Buffer.concat(chunks).toString();
539
- if (!raw) return resolve({});
540
- try { resolve(JSON.parse(raw)); }
541
- catch { reject(new Error('Invalid JSON')); }
542
- });
543
- req.on('error', reject);
544
- });
545
- }
556
+ ${conversation}
546
557
 
547
- function send(res, status, data) {
548
- const body = JSON.stringify(data);
549
- res.writeHead(status, {
550
- 'Content-Type': 'application/json',
551
- 'Access-Control-Allow-Origin': '*',
552
- 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
553
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
554
- });
555
- res.end(body);
556
- }
557
-
558
- function sendError(res, status, message) {
559
- send(res, status, { error: message });
560
- }
561
-
562
- function setCors(res) {
563
- res.setHeader('Access-Control-Allow-Origin', '*');
564
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
565
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
566
- }
567
-
568
- function uuid() {
569
- return crypto.randomUUID();
570
- }
571
-
572
- // ─── Route matching ─────────────────────────────────────────────────────────
573
-
574
- function matchRoute(method, url, pattern) {
575
- // pattern like "GET /api/threads/:id/messages/:messageId"
576
- const [pMethod, pPath] = pattern.split(' ');
577
- if (method !== pMethod) return null;
578
- const pParts = pPath.split('/').filter(Boolean);
579
- const uParts = url.split('/').filter(Boolean);
580
- if (pParts.length !== uParts.length) return null;
581
- const params = {};
582
- for (let i = 0; i < pParts.length; i++) {
583
- if (pParts[i].startsWith(':')) {
584
- params[pParts[i].slice(1)] = decodeURIComponent(uParts[i]);
585
- } else if (pParts[i] !== uParts[i]) {
586
- return null;
587
- }
558
+ Title:`, deliver: false, idempotencyKey: reqId } }));
588
559
  }
589
- return params;
590
- }
591
-
592
- // ─── Auth Middleware ─────────────────────────────────────────────────────────
593
-
594
- function checkAuth(req, res) {
595
- if (!AUTH_TOKEN) return true; // no token configured = open
596
- const auth = req.headers.authorization;
597
- if (!auth || !auth.startsWith('Bearer ')) {
598
- sendError(res, 401, 'Missing or invalid Authorization header');
599
- return false;
600
- }
601
- const token = auth.slice(7);
602
- if (token !== AUTH_TOKEN) {
603
- sendError(res, 401, 'Invalid auth token');
604
- return false;
605
- }
606
- return true;
607
- }
608
-
609
- // ─── Multipart Parser (minimal, for file uploads) ───────────────────────────
610
-
611
- function parseMultipart(req) {
612
- return new Promise((resolve, reject) => {
613
- const contentType = req.headers['content-type'] || '';
614
- const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
615
- if (!boundaryMatch) return reject(new Error('No boundary in content-type'));
616
- const boundary = boundaryMatch[1] || boundaryMatch[2];
617
- const chunks = [];
618
- req.on('data', c => chunks.push(c));
619
- req.on('end', () => {
620
- const buf = Buffer.concat(chunks);
621
- const files = [];
622
- const delimiter = Buffer.from(`--${boundary}`);
623
- const end = Buffer.from(`--${boundary}--`);
624
-
625
- let pos = 0;
626
- while (pos < buf.length) {
627
- const start = buf.indexOf(delimiter, pos);
628
- if (start === -1) break;
629
- const nextStart = buf.indexOf(delimiter, start + delimiter.length);
630
- if (nextStart === -1) break;
631
-
632
- const part = buf.subarray(start + delimiter.length, nextStart);
633
- const headerEnd = part.indexOf('\r\n\r\n');
634
- if (headerEnd === -1) { pos = nextStart; continue; }
635
-
636
- const headerStr = part.subarray(0, headerEnd).toString();
637
- let body = part.subarray(headerEnd + 4);
638
- // Trim trailing \r\n
639
- if (body.length >= 2 && body[body.length - 2] === 0x0d && body[body.length - 1] === 0x0a) {
640
- body = body.subarray(0, body.length - 2);
641
- }
642
-
643
- const filenameMatch = headerStr.match(/filename="([^"]+)"/);
644
- const ctMatch = headerStr.match(/Content-Type:\s*(\S+)/i);
645
- if (filenameMatch) {
646
- files.push({
647
- filename: filenameMatch[1],
648
- mimeType: ctMatch ? ctMatch[1] : 'application/octet-stream',
649
- data: body,
650
- });
651
- }
652
- pos = nextStart;
653
- }
654
- resolve(files);
655
- });
656
- req.on('error', reject);
657
- });
658
- }
659
-
660
- // ─── Context Fill Helper ────────────────────────────────────────────────────
661
-
662
- function buildContextPreamble(db, threadId, lastSessionId, sessionKey) {
663
- let summary = null;
664
- let method = 'raw';
665
-
666
- // Try to read old JSONL transcript
667
- if (lastSessionId) {
668
- const agentMatch = (sessionKey || '').match(/^agent:([^:]+):/);
669
- const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
670
- const jsonlPath = path.join(sessionsDir, `${lastSessionId}.jsonl`);
671
- try {
672
- const content = fs.readFileSync(jsonlPath, 'utf8');
673
- const lines = content.split('\n').filter(Boolean);
674
- // Find last compaction entry
675
- for (let i = lines.length - 1; i >= 0; i--) {
676
- try {
677
- const entry = JSON.parse(lines[i]);
678
- if (entry.type === 'compaction' && entry.summary) {
679
- summary = entry.summary;
680
- method = 'compaction';
681
- break;
682
- }
683
- } catch { /* skip malformed lines */ }
560
+ handleTitleResponse(sessionKey, content) {
561
+ let matchKey = null, pending = null;
562
+ for (const [key, val] of this._pendingTitleGens) {
563
+ if (sessionKey === key || sessionKey.includes(key)) {
564
+ matchKey = key;
565
+ pending = val;
566
+ break;
684
567
  }
685
- } catch { /* file not found, fall back to raw */ }
568
+ }
569
+ if (!pending) return false;
570
+ this._pendingTitleGens.delete(matchKey);
571
+ let title = content.trim().replace(/^["']|["']$/g, "").replace(/^Title:\s*/i, "").replace(/\n.*/s, "").trim();
572
+ if (title.length > 50) title = title.substring(0, 47) + "...";
573
+ if (!title || title.length >= 100) return true;
574
+ const db = this.getDb(pending.workspace);
575
+ db.prepare("UPDATE threads SET title = ? WHERE id = ?").run(title, pending.threadId);
576
+ this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "thread-title-updated", threadId: pending.threadId, workspace: pending.workspace, title }));
577
+ console.log(`AI title generated for ${pending.threadId}: "${title}"`);
578
+ return true;
686
579
  }
687
-
688
- // Build preamble
689
- let preamble = '';
690
-
691
- if (method === 'compaction' && summary) {
692
- 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';
693
- preamble += '[CONVERSATION SUMMARY]\n';
694
- preamble += summary + '\n\n';
695
-
696
- // Last 10 messages from SQLite
697
- const msgs = db.prepare(
698
- 'SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 10'
699
- ).all(threadId).reverse();
700
-
701
- if (msgs.length) {
702
- preamble += '[RECENT MESSAGES]\n';
703
- for (const m of msgs) {
704
- const d = new Date(m.timestamp);
705
- const ts = d.toISOString().replace('T', ' ').slice(0, 16);
706
- const role = m.role.charAt(0).toUpperCase() + m.role.slice(1);
707
- preamble += `${role} (${ts}): ${m.content}\n`;
580
+ handleAgentEvent(payload) {
581
+ const { runId, stream, data, sessionKey } = payload;
582
+ if (!runId) return;
583
+ if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
584
+ const log = this.activityLogs.get(runId);
585
+ if (stream === "assistant") {
586
+ const text = data?.text || "";
587
+ if (text) {
588
+ let seg = log._currentAssistantSegment;
589
+ if (!seg || seg._sealed) {
590
+ seg = { type: "assistant", timestamp: Date.now(), text, _sealed: false };
591
+ log._currentAssistantSegment = seg;
592
+ log.steps.push(seg);
593
+ } else seg.text = text;
708
594
  }
595
+ return;
709
596
  }
710
- } else {
711
- preamble += '[CONTEXT RECOVERY This thread\'s agent session was reset. Below are recent messages from the previous conversation to restore context.]\n\n';
712
-
713
- // Last 25 messages from SQLite
714
- const msgs = db.prepare(
715
- 'SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 25'
716
- ).all(threadId).reverse();
717
-
718
- if (msgs.length) {
719
- preamble += '[PREVIOUS MESSAGES]\n';
720
- for (const m of msgs) {
721
- const d = new Date(m.timestamp);
722
- const ts = d.toISOString().replace('T', ' ').slice(0, 16);
723
- const role = m.role.charAt(0).toUpperCase() + m.role.slice(1);
724
- preamble += `${role} (${ts}): ${m.content}\n`;
597
+ if (stream === "thinking") {
598
+ let step = log.steps.find((s) => s.type === "thinking");
599
+ if (step) step.text = data?.text || "";
600
+ else log.steps.push({ type: "thinking", timestamp: Date.now(), text: data?.text || "" });
601
+ writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
602
+ const now = Date.now();
603
+ if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
604
+ log._lastThinkingBroadcast = now;
605
+ this._broadcastActivityUpdate(runId, log);
725
606
  }
726
607
  }
608
+ if (stream === "tool") {
609
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
610
+ const argsMeta = data?.args ? data.args.command || data.args.path || data.args.query || data.args.url || Object.values(data.args).find((v) => typeof v === "string") || "" : "";
611
+ const step = { type: "tool", timestamp: Date.now(), name: data?.name || "unknown", phase: data?.phase || "start", toolCallId: data?.toolCallId, meta: data?.meta || (argsMeta ? String(argsMeta) : void 0), isError: data?.isError || false };
612
+ if (data?.phase === "result") {
613
+ const existing = log.steps.findLast((s) => s.toolCallId === data.toolCallId && (s.phase === "start" || s.phase === "running"));
614
+ if (existing) {
615
+ existing.phase = "done";
616
+ existing.resultMeta = data?.meta;
617
+ existing.isError = data?.isError || false;
618
+ existing.durationMs = Date.now() - existing.timestamp;
619
+ } else {
620
+ step.phase = "done";
621
+ log.steps.push(step);
622
+ }
623
+ } else if (data?.phase === "update") {
624
+ const existing = log.steps.findLast((s) => s.toolCallId === data.toolCallId);
625
+ if (existing) {
626
+ if (data?.meta) existing.resultMeta = data.meta;
627
+ if (data?.isError) existing.isError = true;
628
+ existing.phase = "running";
629
+ }
630
+ } else log.steps.push(step);
631
+ writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
632
+ this._broadcastActivityUpdate(runId, log);
633
+ }
634
+ if (stream === "lifecycle" && (data?.phase === "end" || data?.phase === "error")) {
635
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
636
+ const idx = log.steps.findLastIndex((s) => s.type === "assistant");
637
+ if (idx >= 0) log.steps.splice(idx, 1);
638
+ writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
639
+ this.activityLogs.delete(runId);
640
+ }
641
+ }
642
+ _broadcastActivityUpdate(runId, log) {
643
+ if (!log._parsed || !log._messageId) return;
644
+ const cleanSteps = log.steps.map((s) => {
645
+ const c = { ...s };
646
+ delete c._sealed;
647
+ return c;
648
+ });
649
+ this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "activity-updated", workspace: log._parsed.workspace, threadId: log._parsed.threadId, messageId: log._messageId, activityLog: cleanSteps, activitySummary: generateActivitySummary(log.steps) }));
727
650
  }
728
-
729
- // Enforce token budget
730
- if (preamble.length > MAX_PREAMBLE_CHARS) {
731
- preamble = preamble.slice(preamble.length - MAX_PREAMBLE_CHARS);
732
- }
733
-
734
- return { preamble, method };
735
- }
736
-
737
- // ─── Gateway Session Cleanup ─────────────────────────────────────────────────
738
-
739
- function cleanGatewaySession(sessionKey) {
740
- try {
741
- const agentMatch = (sessionKey || '').match(/^agent:([^:]+):/);
742
- const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
743
- const sessionsPath = path.join(sessionsDir, 'sessions.json');
744
- const raw = fs.readFileSync(sessionsPath, 'utf8');
745
- const store = JSON.parse(raw);
746
- const entry = store[sessionKey];
747
- if (!entry) return null;
748
-
749
- // Delete .jsonl transcript
750
- if (entry.sessionId) {
751
- const jsonlPath = path.join(sessionsDir, `${entry.sessionId}.jsonl`);
752
- try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
651
+ broadcastToBrowsers(data) {
652
+ this.debugLogger.logFrame("SRV\u2192BR", data);
653
+ for (const client of this.browserClients.keys()) {
654
+ if (client.readyState === WS.OPEN) client.send(data);
753
655
  }
754
-
755
- // Remove entry from store and write back
756
- const sessionId = entry.sessionId || null;
757
- delete store[sessionKey];
758
- fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
759
- return sessionId;
760
- } catch (err) {
761
- console.warn(`cleanGatewaySession(${sessionKey}):`, err.message);
762
- return null;
763
- }
764
- }
765
-
766
- function cleanGatewaySessionsByPrefix(prefix) {
767
- try {
768
- const agentMatch = (prefix || '').match(/^agent:([^:]+):/);
769
- const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
770
- const sessionsPath = path.join(sessionsDir, 'sessions.json');
771
- const raw = fs.readFileSync(sessionsPath, 'utf8');
772
- const store = JSON.parse(raw);
773
- let cleaned = 0;
774
-
775
- for (const key of Object.keys(store)) {
776
- if (!key.startsWith(prefix)) continue;
777
- const entry = store[key];
778
- if (entry?.sessionId) {
779
- const jsonlPath = path.join(sessionsDir, `${entry.sessionId}.jsonl`);
780
- try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
656
+ for (const fn of this._externalBroadcastTargets) {
657
+ try {
658
+ fn(data);
659
+ } catch {
781
660
  }
782
- delete store[key];
783
- cleaned++;
784
661
  }
785
-
786
- if (cleaned > 0) {
787
- fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
788
- }
789
- return cleaned;
790
- } catch (err) {
791
- console.warn(`cleanGatewaySessionsByPrefix(${prefix}):`, err.message);
792
- return 0;
793
- }
794
- }
795
-
796
- // ─── File Serving (restricted to ~/.openclaw/media/) ────────────────────────
797
-
798
- const ALLOWED_FILE_DIRS = [
799
- HOME,
800
- '/tmp',
801
- ];
802
-
803
- function handleServeFile(req, res, query) {
804
- const filePath = query.path;
805
- if (!filePath) return sendError(res, 400, 'Missing path parameter');
806
-
807
- // Resolve to prevent traversal attacks
808
- // Relative paths (./filename) resolve against the workspace directory
809
- const resolved = (filePath.startsWith('./') || filePath.startsWith('../'))
810
- ? path.resolve(MEMORY_CONFIG.workspaceDir, filePath)
811
- : path.resolve(filePath);
812
-
813
- // Security: only serve files from allowed directories
814
- const allowed = ALLOWED_FILE_DIRS.some(dir => resolved.startsWith(dir + '/') || resolved === dir);
815
- if (!allowed) {
816
- return sendError(res, 403, 'Access denied: path not in allowed directories');
817
662
  }
818
-
819
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
820
- return sendError(res, 404, 'File not found');
663
+ broadcastGatewayStatus(connected) {
664
+ this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "gateway-status", connected }));
821
665
  }
822
-
823
- const ext = path.extname(resolved).toLowerCase();
824
- const mimeTypes = {
825
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
826
- '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
827
- '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
828
- '.md': 'text/markdown', '.csv': 'text/csv', '.xml': 'text/xml',
829
- '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
830
- '.py': 'text/x-python', '.sh': 'text/x-shellscript',
831
- '.yaml': 'text/yaml', '.yml': 'text/yaml', '.toml': 'text/toml',
832
- '.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar',
833
- '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
834
- '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
835
- '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
836
- '.mp4': 'video/mp4', '.webm': 'video/webm',
837
- };
838
- const contentType = mimeTypes[ext] || 'application/octet-stream';
839
-
840
- const stat = fs.statSync(resolved);
841
- res.writeHead(200, {
842
- 'Content-Type': contentType,
843
- 'Content-Length': stat.size,
844
- 'Cache-Control': 'public, max-age=86400',
845
- 'Access-Control-Allow-Origin': '*',
846
- });
847
- fs.createReadStream(resolved).pipe(res);
848
- }
849
-
850
- // ─── Workspace File Browser ─────────────────────────────────────────────────
851
-
852
- const WORKSPACE_ROOT = HOME;
853
-
854
- function handleWorkspaceList(req, res, query) {
855
- const reqPath = query.path || '~/.openclaw/workspace';
856
- const depth = parseInt(query.depth || '2', 10);
857
- const showHidden = query.hidden === '1' || query.hidden === 'true';
858
-
859
- // Resolve ~ to HOME
860
- const resolved = path.resolve(reqPath.replace(/^~/, HOME));
861
-
862
- // Security: only allow listing under home directory
863
- if (!resolved.startsWith(HOME)) {
864
- return sendError(res, 403, 'Access denied');
666
+ sendToGateway(data) {
667
+ this.debugLogger.logFrame("SRV\u2192GW", data);
668
+ if (this.ws?.readyState === WS.OPEN) this.ws.send(data);
669
+ else console.error("Cannot send to gateway: not connected");
865
670
  }
866
-
867
- if (!fs.existsSync(resolved)) {
868
- return sendError(res, 404, 'Path not found');
671
+ scheduleReconnect() {
672
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
673
+ this.reconnectAttempts++;
674
+ console.log(`Reconnecting to gateway in ${delay}ms (attempt ${this.reconnectAttempts})...`);
675
+ setTimeout(() => this.connect(), delay);
869
676
  }
870
-
871
- const files = [];
872
-
873
- function walk(dir, currentDepth) {
874
- if (currentDepth > depth) return;
875
- try {
876
- const entries = fs.readdirSync(dir, { withFileTypes: true });
877
- for (const entry of entries) {
878
- // Skip hidden files unless showHidden (always show .openclaw)
879
- if (entry.name.startsWith('.') && entry.name !== '.openclaw' && !showHidden) continue;
880
- if (entry.name === 'node_modules') continue;
881
-
882
- const fullPath = path.join(dir, entry.name);
883
- const isDir = entry.isDirectory();
884
- files.push({
885
- path: fullPath + (isDir ? '/' : ''),
886
- type: isDir ? 'dir' : 'file',
887
- name: entry.name,
888
- size: isDir ? 0 : (() => { try { return fs.statSync(fullPath).size; } catch { return 0; } })(),
889
- });
890
- if (isDir) walk(fullPath, currentDepth + 1);
677
+ addBrowserClient(ws) {
678
+ this.browserClients.set(ws, { activeWorkspace: null, activeThreadId: null });
679
+ if (ws.readyState === WS.OPEN) {
680
+ ws.send(JSON.stringify({ type: "clawchats", event: "gateway-status", connected: this.connected }));
681
+ const streams = [];
682
+ for (const [sessionKey, state] of this.streamState.entries()) {
683
+ if (state.state === "streaming" && !(state.held?.length > 0)) streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
891
684
  }
892
- } catch { /* permission denied, etc. */ }
893
- }
894
-
895
- // Add root entry
896
- files.unshift({ path: resolved + '/', type: 'dir', name: path.basename(resolved), size: 0 });
897
- walk(resolved, 1);
898
-
899
- send(res, 200, { files, cwd: resolved });
900
- }
901
-
902
- function handleWorkspaceFileRead(req, res, query) {
903
- const filePath = query.path;
904
- if (!filePath) return sendError(res, 400, 'Missing path parameter');
905
-
906
- const resolved = path.resolve(filePath.replace(/^~/, HOME));
907
-
908
- // Security: only allow reading under home directory
909
- if (!resolved.startsWith(HOME)) {
910
- return sendError(res, 403, 'Access denied');
911
- }
912
-
913
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
914
- return sendError(res, 404, 'File not found');
915
- }
916
-
917
- const stat = fs.statSync(resolved);
918
- const ext = path.extname(resolved).toLowerCase().slice(1);
919
-
920
- // MIME type map — binary types get served as binary, rest as text
921
- const mimeMap = {
922
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
923
- gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
924
- bmp: 'image/bmp', ico: 'image/x-icon',
925
- pdf: 'application/pdf',
926
- mp3: 'audio/mpeg', mp4: 'video/mp4', wav: 'audio/wav',
927
- ogg: 'audio/ogg', webm: 'video/webm',
928
- };
929
- const mime = mimeMap[ext];
930
- const isBinary = !!mime;
931
-
932
- if (isBinary) {
933
- if (stat.size > 20 * 1024 * 1024) return sendError(res, 413, 'File too large (max 20MB)');
934
- const content = fs.readFileSync(resolved);
935
- res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'private, max-age=60' });
936
- res.end(content);
937
- } else {
938
- if (stat.size > 1024 * 1024) return sendError(res, 413, 'File too large (max 1MB)');
939
- const content = fs.readFileSync(resolved, 'utf8');
940
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
941
- res.end(content);
942
- }
943
- }
944
-
945
- async function handleWorkspaceFileWrite(req, res, query) {
946
- const filePath = query.path;
947
- if (!filePath) return sendError(res, 400, 'Missing path parameter');
948
-
949
- const resolved = path.resolve(filePath.replace(/^~/, HOME));
950
-
951
- // Security: only allow writing under workspace
952
- if (!resolved.startsWith(WORKSPACE_ROOT)) {
953
- return sendError(res, 403, 'Can only write to workspace directory');
954
- }
955
-
956
- // Read body as text
957
- const chunks = [];
958
- for await (const chunk of req) chunks.push(chunk);
959
- const content = Buffer.concat(chunks).toString('utf8');
960
-
961
- // Ensure parent directory exists
962
- const dir = path.dirname(resolved);
963
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
964
-
965
- fs.writeFileSync(resolved, content, 'utf8');
966
- send(res, 200, { ok: true });
967
- }
968
-
969
- function handleWorkspaceFileDelete(req, res, query) {
970
- const filePath = query.path;
971
- if (!filePath) return sendError(res, 400, 'Missing path parameter');
972
-
973
- const resolved = path.resolve(filePath.replace(/^~/, HOME));
974
-
975
- // Security: only allow paths under home directory
976
- if (!resolved.startsWith(HOME)) {
977
- return sendError(res, 403, 'Access denied');
978
- }
979
-
980
- if (!fs.existsSync(resolved)) {
981
- return sendError(res, 404, 'Path not found');
982
- }
983
-
984
- try {
985
- const stat = fs.statSync(resolved);
986
- if (stat.isDirectory()) {
987
- fs.rmSync(resolved, { recursive: true, force: true });
988
- send(res, 200, { ok: true, type: 'dir' });
989
- } else {
990
- fs.unlinkSync(resolved);
991
- send(res, 200, { ok: true, type: 'file' });
685
+ if (streams.length > 0) ws.send(JSON.stringify({ type: "clawchats", event: "stream-sync", streams }));
992
686
  }
993
- } catch (err) {
994
- sendError(res, 500, 'Delete failed: ' + err.message);
995
- }
996
- }
997
-
998
- async function handleWorkspaceUpload(req, res, query) {
999
- const targetDir = query.path;
1000
- if (!targetDir) return sendError(res, 400, 'Missing path parameter');
1001
-
1002
- const resolved = path.resolve(targetDir.replace(/^~/, HOME));
1003
-
1004
- // Security: only allow uploading under home directory
1005
- if (!resolved.startsWith(HOME)) {
1006
- return sendError(res, 403, 'Access denied');
1007
687
  }
1008
-
1009
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
1010
- return sendError(res, 404, 'Target directory not found');
1011
- }
1012
-
1013
- // Parse multipart form data manually (no dependency needed for simple case)
1014
- const contentType = req.headers['content-type'] || '';
1015
- if (!contentType.includes('multipart/form-data')) {
1016
- return sendError(res, 400, 'Expected multipart/form-data');
688
+ removeBrowserClient(ws) {
689
+ this.browserClients.delete(ws);
1017
690
  }
1018
-
1019
- const boundary = contentType.split('boundary=')[1];
1020
- if (!boundary) return sendError(res, 400, 'Missing boundary');
1021
-
1022
- const chunks = [];
1023
- for await (const chunk of req) chunks.push(chunk);
1024
- const body = Buffer.concat(chunks);
1025
-
1026
- // Parse multipart parts
1027
- const boundaryBuf = Buffer.from('--' + boundary);
1028
- const uploaded = [];
1029
- let start = 0;
1030
-
1031
- while (true) {
1032
- const idx = body.indexOf(boundaryBuf, start);
1033
- if (idx === -1) break;
1034
-
1035
- if (start > 0) {
1036
- // Extract the part between previous boundary and this one
1037
- const partData = body.slice(start, idx - 2); // -2 for \r\n before boundary
1038
- const headerEnd = partData.indexOf('\r\n\r\n');
1039
- if (headerEnd !== -1) {
1040
- const headerStr = partData.slice(0, headerEnd).toString('utf8');
1041
- const fileContent = partData.slice(headerEnd + 4);
1042
-
1043
- const filenameMatch = headerStr.match(/filename="([^"]+)"/);
1044
- if (filenameMatch && fileContent.length > 0) {
1045
- const filename = path.basename(filenameMatch[1]); // sanitize
1046
- const filePath = path.join(resolved, filename);
1047
-
1048
- // Don't overwrite — add suffix if exists
1049
- let finalPath = filePath;
1050
- let counter = 1;
1051
- while (fs.existsSync(finalPath)) {
1052
- const ext = path.extname(filename);
1053
- const base = path.basename(filename, ext);
1054
- finalPath = path.join(resolved, `${base} (${counter})${ext}`);
1055
- counter++;
1056
- }
1057
-
1058
- fs.writeFileSync(finalPath, fileContent);
1059
- uploaded.push({ name: path.basename(finalPath), size: fileContent.length });
1060
- }
691
+ setActiveThread(ws, workspace, threadId) {
692
+ const client = ws ? this.browserClients.get(ws) : null;
693
+ if (client) {
694
+ client.activeWorkspace = workspace;
695
+ client.activeThreadId = threadId;
696
+ }
697
+ if (!workspace || !threadId) return;
698
+ try {
699
+ const wsData = this.getWorkspaces();
700
+ if (!wsData.workspaces[workspace]) return;
701
+ const db = this.getDb(workspace);
702
+ if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(threadId)) return;
703
+ const deleted = db.prepare("DELETE FROM unread_messages WHERE thread_id = ?").run(threadId);
704
+ if (deleted.changes > 0) {
705
+ syncThreadUnreadCount(db, threadId);
706
+ this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "unread-update", workspace, threadId, action: "clear", unreadCount: 0, workspaceUnreadTotal: db.prepare("SELECT COALESCE(SUM(unread_count), 0) as total FROM threads").get().total, timestamp: Date.now() }));
1061
707
  }
708
+ } catch (e) {
709
+ console.error("Failed to auto-clear unreads on active-thread:", e.message);
1062
710
  }
1063
-
1064
- start = idx + boundaryBuf.length + 2; // +2 for \r\n after boundary
1065
711
  }
712
+ addBroadcastTarget(fn) {
713
+ this._externalBroadcastTargets.push(fn);
714
+ }
715
+ removeBroadcastTarget(fn) {
716
+ this._externalBroadcastTargets = this._externalBroadcastTargets.filter((f) => f !== fn);
717
+ }
718
+ };
1066
719
 
1067
- send(res, 200, { ok: true, uploaded });
1068
- }
1069
-
1070
- // ─── Memory (configurable backend) ──────────────────────────────────────────
1071
-
720
+ // server/providers/memory.js
721
+ import fs4 from "node:fs";
722
+ import path5 from "node:path";
723
+ import os2 from "node:os";
1072
724
  function discoverMemoryConfig() {
1073
- // Priority: env vars openclaw.json defaults
1074
- const defaults = { provider: 'qdrant', host: 'localhost', port: 6333, collection: null };
1075
-
1076
- // Try reading openclaw.json
725
+ const defaults = { provider: "qdrant", host: "localhost", port: 6333, collection: null };
1077
726
  let oc = null;
1078
- const candidates = [
1079
- path.join(os.homedir(), '.openclaw', 'openclaw.json'),
1080
- '/etc/openclaw/openclaw.json'
1081
- ];
1082
- for (const cfgPath of candidates) {
727
+ for (const cfgPath of [path5.join(os2.homedir(), ".openclaw", "openclaw.json"), "/etc/openclaw/openclaw.json"]) {
1083
728
  try {
1084
- oc = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
729
+ oc = JSON.parse(fs4.readFileSync(cfgPath, "utf8"));
1085
730
  break;
1086
- } catch {}
731
+ } catch {
732
+ }
1087
733
  }
1088
-
1089
734
  let cfg = { ...defaults };
1090
-
1091
735
  if (oc) {
1092
- const memSlot = oc.plugins?.slots?.memory;
1093
- const vs = memSlot ? oc.plugins?.entries?.[memSlot]?.config?.oss?.vectorStore : null;
736
+ const vs = oc.plugins?.slots?.memory ? oc.plugins?.entries?.[oc.plugins.slots.memory]?.config?.oss?.vectorStore : null;
1094
737
  if (vs) {
1095
738
  if (vs.provider) cfg.provider = vs.provider;
1096
739
  if (vs.config?.host) cfg.host = vs.config.host;
1097
740
  if (vs.config?.port) cfg.port = vs.config.port;
1098
741
  if (vs.config?.collectionName) cfg.collection = vs.config.collectionName;
1099
- // Postgres-specific fields
1100
742
  if (vs.config?.user) cfg.pgUser = vs.config.user;
1101
743
  if (vs.config?.password) cfg.pgPassword = vs.config.password;
1102
744
  if (vs.config?.dbname) cfg.pgDbName = vs.config.dbname;
1103
745
  }
1104
-
1105
- // Derive workspace dir from openclaw.json
1106
746
  const wsDir = oc.agents?.defaults?.workspace;
1107
747
  if (wsDir) cfg.workspaceDir = wsDir;
1108
748
  }
1109
-
1110
- // Env var overrides (MEMORY_* take priority, then Qdrant-specific fallbacks)
1111
749
  if (process.env.MEMORY_PROVIDER) cfg.provider = process.env.MEMORY_PROVIDER;
1112
750
  if (process.env.MEMORY_HOST || process.env.QDRANT_HOST) cfg.host = process.env.MEMORY_HOST || process.env.QDRANT_HOST;
1113
751
  if (process.env.MEMORY_PORT || process.env.QDRANT_PORT) cfg.port = parseInt(process.env.MEMORY_PORT || process.env.QDRANT_PORT, 10);
1114
752
  if (process.env.MEMORY_COLLECTION || process.env.QDRANT_COLLECTION) cfg.collection = process.env.MEMORY_COLLECTION || process.env.QDRANT_COLLECTION;
1115
753
  if (process.env.MEMORY_PG_URL) cfg.pgUrl = process.env.MEMORY_PG_URL;
1116
- // QDRANT_URL (e.g. "http://myhost:6333") — parse host and port from it
1117
754
  if (process.env.QDRANT_URL && !process.env.MEMORY_HOST) {
1118
755
  try {
1119
756
  const u = new URL(process.env.QDRANT_URL);
1120
757
  cfg.host = u.hostname;
1121
758
  if (u.port) cfg.port = parseInt(u.port, 10);
1122
- } catch {}
759
+ } catch {
760
+ }
1123
761
  }
1124
-
1125
- if (!cfg.workspaceDir) {
1126
- cfg.workspaceDir = path.join(os.homedir(), '.openclaw', 'workspace');
1127
- }
1128
-
762
+ if (!cfg.workspaceDir) cfg.workspaceDir = path5.join(os2.homedir(), ".openclaw", "workspace");
1129
763
  return cfg;
1130
764
  }
1131
-
1132
- const MEMORY_CONFIG = discoverMemoryConfig();
1133
-
1134
- // Auto-detect Qdrant collection if not explicitly configured
765
+ __name(discoverMemoryConfig, "discoverMemoryConfig");
1135
766
  async function autoDetectQdrantCollection(config) {
1136
767
  if (config.collection) return config.collection;
1137
- const baseUrl = `http://${config.host}:${config.port}`;
1138
768
  try {
1139
- const r = await fetch(`${baseUrl}/collections`, { signal: AbortSignal.timeout(3000) });
769
+ const r = await fetch(`http://${config.host}:${config.port}/collections`, { signal: AbortSignal.timeout(3e3) });
1140
770
  const data = await r.json();
1141
- const collections = (data.result?.collections || []).map(c => c.name);
1142
- const found = collections.find(name => !name.includes('migration'));
771
+ const found = (data.result?.collections || []).map((c) => c.name).find((n) => !n.includes("migration"));
1143
772
  if (found) {
1144
773
  console.log(`Memory: auto-detected Qdrant collection "${found}"`);
1145
774
  return found;
1146
775
  }
1147
- } catch {}
776
+ } catch {
777
+ }
1148
778
  console.log('Memory: Qdrant unreachable or no collections, falling back to "memories"');
1149
- return 'memories';
779
+ return "memories";
1150
780
  }
1151
-
1152
- // ─── Memory Providers ─────────────────────────────────────────────────────────
1153
-
781
+ __name(autoDetectQdrantCollection, "autoDetectQdrantCollection");
1154
782
  function createQdrantProvider(config) {
1155
783
  const baseUrl = `http://${config.host}:${config.port}`;
1156
- let collection = config.collection; // may be null until init()
1157
-
784
+ let collection = config.collection;
1158
785
  return {
1159
- name: 'qdrant',
786
+ name: "qdrant",
1160
787
  config,
1161
788
  async init() {
1162
789
  collection = await autoDetectQdrantCollection(config);
@@ -1165,17 +792,9 @@ function createQdrantProvider(config) {
1165
792
  async list(limit, offset) {
1166
793
  const body = { limit, with_payload: true, with_vector: false };
1167
794
  if (offset) body.offset = offset;
1168
- const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, {
1169
- method: 'POST',
1170
- headers: { 'Content-Type': 'application/json' },
1171
- body: JSON.stringify(body),
1172
- });
795
+ const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
1173
796
  const data = await r.json();
1174
- const points = data.result?.points || [];
1175
- return {
1176
- memories: points.map(p => ({ id: p.id, ...p.payload })),
1177
- next_offset: data.result?.next_page_offset || null,
1178
- };
797
+ return { memories: (data.result?.points || []).map((p) => ({ id: p.id, ...p.payload })), next_offset: data.result?.next_page_offset || null };
1179
798
  },
1180
799
  async search(query) {
1181
800
  const q = query.toLowerCase();
@@ -1184,116 +803,73 @@ function createQdrantProvider(config) {
1184
803
  do {
1185
804
  const body = { limit: 100, with_payload: true, with_vector: false };
1186
805
  if (offset) body.offset = offset;
1187
- const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, {
1188
- method: 'POST',
1189
- headers: { 'Content-Type': 'application/json' },
1190
- body: JSON.stringify(body),
1191
- });
806
+ const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
1192
807
  const data = await r.json();
1193
- const points = data.result?.points || [];
1194
- for (const p of points) {
1195
- if ((p.payload?.data || '').toLowerCase().includes(q)) {
1196
- matches.push({ id: p.id, ...p.payload });
1197
- }
808
+ for (const p of data.result?.points || []) {
809
+ if ((p.payload?.data || "").toLowerCase().includes(q)) matches.push({ id: p.id, ...p.payload });
1198
810
  }
1199
811
  offset = data.result?.next_page_offset || null;
1200
812
  } while (offset);
1201
813
  return { memories: matches, next_offset: null };
1202
814
  },
1203
815
  async update(id, newData) {
1204
- const r = await fetch(`${baseUrl}/collections/${collection}/points/payload`, {
1205
- method: 'POST',
1206
- headers: { 'Content-Type': 'application/json' },
1207
- body: JSON.stringify({ points: [id], payload: { data: newData } }),
1208
- });
816
+ const r = await fetch(`${baseUrl}/collections/${collection}/points/payload`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ points: [id], payload: { data: newData } }) });
1209
817
  const data = await r.json();
1210
818
  if (data.status?.error) throw new Error(data.status.error);
1211
819
  return data.result;
1212
820
  },
1213
821
  async delete(id) {
1214
- const r = await fetch(`${baseUrl}/collections/${collection}/points/delete`, {
1215
- method: 'POST',
1216
- headers: { 'Content-Type': 'application/json' },
1217
- body: JSON.stringify({ points: [id] }),
1218
- });
1219
- const data = await r.json();
1220
- return data.result;
822
+ const r = await fetch(`${baseUrl}/collections/${collection}/points/delete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ points: [id] }) });
823
+ return (await r.json()).result;
1221
824
  },
1222
825
  async status() {
1223
826
  try {
1224
- const r = await fetch(`${baseUrl}/collections/${collection}`, { signal: AbortSignal.timeout(3000) });
827
+ const r = await fetch(`${baseUrl}/collections/${collection}`, { signal: AbortSignal.timeout(3e3) });
1225
828
  const data = await r.json();
1226
829
  return { reachable: true, pointsCount: data.result?.points_count ?? null };
1227
830
  } catch {
1228
831
  return { reachable: false };
1229
832
  }
1230
- },
833
+ }
1231
834
  };
1232
835
  }
1233
-
836
+ __name(createQdrantProvider, "createQdrantProvider");
1234
837
  function createPgProvider(config) {
1235
- // Lazy-load pg — no crash if not installed and user runs Qdrant
1236
- // NOTE: Postgres schema based on mem0 SDK pgvector patterns; needs verification against a real setup.
1237
838
  let _pool = null;
1238
- const table = config.collection || 'memories';
1239
-
839
+ const table = config.collection || "memories";
1240
840
  async function getPool() {
1241
841
  if (_pool) return _pool;
1242
842
  let pg;
1243
843
  try {
1244
- pg = await import('pg');
844
+ pg = await import("pg");
1245
845
  } catch {
1246
- throw new Error('pg package not installed. Run: npm install pg');
846
+ throw new Error("pg package not installed. Run: npm install pg");
1247
847
  }
1248
848
  const Pool = pg.default?.Pool || pg.Pool;
1249
- if (config.pgUrl) {
1250
- _pool = new Pool({ connectionString: config.pgUrl });
1251
- } else {
1252
- _pool = new Pool({
1253
- host: config.host,
1254
- port: config.port || 5432,
1255
- user: config.pgUser || 'mem0',
1256
- password: config.pgPassword || '',
1257
- database: config.pgDbName || 'mem0',
1258
- });
1259
- }
849
+ _pool = config.pgUrl ? new Pool({ connectionString: config.pgUrl }) : new Pool({ host: config.host, port: config.port || 5432, user: config.pgUser || "mem0", password: config.pgPassword || "", database: config.pgDbName || "mem0" });
1260
850
  return _pool;
1261
851
  }
1262
-
852
+ __name(getPool, "getPool");
1263
853
  return {
1264
- name: 'postgres',
854
+ name: "postgres",
1265
855
  config,
1266
- async init() { /* pool created lazily on first query */ },
856
+ async init() {
857
+ },
1267
858
  async list(limit, offset) {
1268
859
  const pool = await getPool();
1269
- const offsetVal = offset ? parseInt(offset, 10) : 0;
1270
- const { rows } = await pool.query(
1271
- `SELECT id, payload FROM ${table} ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
1272
- [limit, offsetVal]
1273
- );
1274
- return {
1275
- memories: rows.map(r => ({ id: r.id, ...(typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) })),
1276
- next_offset: rows.length === limit ? offsetVal + limit : null,
1277
- };
860
+ const off = offset ? parseInt(offset, 10) : 0;
861
+ const { rows } = await pool.query(`SELECT id, payload FROM ${table} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [limit, off]);
862
+ return { memories: rows.map((r) => ({ id: r.id, ...typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload })), next_offset: rows.length === limit ? off + limit : null };
1278
863
  },
1279
864
  async search(query) {
1280
865
  const pool = await getPool();
1281
- const { rows } = await pool.query(
1282
- `SELECT id, payload FROM ${table} WHERE payload->>'data' ILIKE $1 LIMIT 100`,
1283
- [`%${query}%`]
1284
- );
1285
- return {
1286
- memories: rows.map(r => ({ id: r.id, ...(typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) })),
1287
- next_offset: null,
1288
- };
866
+ const { rows } = await pool.query(`SELECT id, payload FROM ${table} WHERE payload->>'data' ILIKE $1 LIMIT 100`, [`%${query}%`]);
867
+ return { memories: rows.map((r) => ({ id: r.id, ...typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload })), next_offset: null };
1289
868
  },
1290
869
  async update(id, newData) {
1291
870
  const pool = await getPool();
1292
- const { rowCount } = await pool.query(
1293
- `UPDATE ${table} SET payload = jsonb_set(payload, '{data}', $1::jsonb) WHERE id = $2`,
1294
- [JSON.stringify(newData), id]
1295
- );
1296
- if (rowCount === 0) throw new Error('Memory not found');
871
+ const { rowCount } = await pool.query(`UPDATE ${table} SET payload = jsonb_set(payload, '{data}', $1::jsonb) WHERE id = $2`, [JSON.stringify(newData), id]);
872
+ if (rowCount === 0) throw new Error("Memory not found");
1297
873
  return { updated: true };
1298
874
  },
1299
875
  async delete(id) {
@@ -1309,77 +885,141 @@ function createPgProvider(config) {
1309
885
  } catch (err) {
1310
886
  return { reachable: false, error: err.message };
1311
887
  }
1312
- },
888
+ }
1313
889
  };
1314
890
  }
1315
-
891
+ __name(createPgProvider, "createPgProvider");
1316
892
  function createMemoryProvider(config) {
1317
- if (config.provider === 'postgres' || config.provider === 'pgvector')
1318
- return createPgProvider(config);
893
+ if (config.provider === "postgres" || config.provider === "pgvector") return createPgProvider(config);
1319
894
  return createQdrantProvider(config);
1320
895
  }
896
+ __name(createMemoryProvider, "createMemoryProvider");
1321
897
 
1322
- async function handleTranscribe(req, res) {
1323
- try {
1324
- const chunks = [];
1325
- for await (const chunk of req) chunks.push(chunk);
1326
- const audioBuffer = Buffer.concat(chunks);
1327
-
1328
- if (audioBuffer.length === 0) return send(res, 400, { error: 'No audio data' });
1329
- if (audioBuffer.length > 25 * 1024 * 1024) return send(res, 400, { error: 'Audio too large (max 25MB)' });
1330
-
1331
- // Read OpenAI API key from OpenClaw config
1332
- let apiKey;
1333
- try {
1334
- const ocConfig = JSON.parse(fs.readFileSync(
1335
- path.join(os.homedir(), '.openclaw', 'openclaw.json'), 'utf8'));
1336
- apiKey = ocConfig?.skills?.entries?.['openai-whisper-api']?.apiKey;
1337
- } catch {}
1338
- if (!apiKey) apiKey = process.env.OPENAI_API_KEY;
1339
- if (!apiKey) return send(res, 500, { error: 'No OpenAI API key configured' });
1340
-
1341
- const contentType = req.headers['content-type'] || 'audio/webm';
1342
- const ext = contentType.includes('wav') ? 'wav'
1343
- : contentType.includes('mp4') || contentType.includes('m4a') ? 'm4a'
1344
- : contentType.includes('ogg') ? 'ogg' : 'webm';
1345
-
1346
- const boundary = '----WhisperBoundary' + Date.now();
1347
- const parts = [
1348
- `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.${ext}"\r\nContent-Type: ${contentType}\r\n\r\n`,
1349
- audioBuffer,
1350
- `\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1\r\n`,
1351
- `--${boundary}\r\nContent-Disposition: form-data; name="response_format"\r\n\r\njson\r\n`,
1352
- `--${boundary}--\r\n`
1353
- ];
1354
- const body = Buffer.concat(parts.map(p => typeof p === 'string' ? Buffer.from(p) : p));
898
+ // server/controllers/workspaces.js
899
+ import fs6 from "node:fs";
900
+ import path7 from "node:path";
1355
901
 
1356
- const resp = await fetch('https://api.openai.com/v1/audio/transcriptions', {
1357
- method: 'POST',
1358
- headers: {
1359
- 'Authorization': `Bearer ${apiKey}`,
1360
- 'Content-Type': `multipart/form-data; boundary=${boundary}`,
1361
- },
1362
- body
902
+ // server/util/http.js
903
+ import crypto2 from "node:crypto";
904
+ function parseBody(req) {
905
+ return new Promise((resolve, reject) => {
906
+ const chunks = [];
907
+ req.on("data", (c) => chunks.push(c));
908
+ req.on("end", () => {
909
+ const raw = Buffer.concat(chunks).toString();
910
+ if (!raw) return resolve({});
911
+ try {
912
+ resolve(JSON.parse(raw));
913
+ } catch {
914
+ reject(new Error("Invalid JSON"));
915
+ }
1363
916
  });
1364
-
1365
- if (!resp.ok) {
1366
- const errText = await resp.text();
1367
- console.error('Whisper API error:', resp.status, errText);
1368
- return send(res, 502, { error: `Whisper API error: ${resp.status}` });
917
+ req.on("error", reject);
918
+ });
919
+ }
920
+ __name(parseBody, "parseBody");
921
+ function send(res, status, data) {
922
+ const body = JSON.stringify(data);
923
+ res.writeHead(status, {
924
+ "Content-Type": "application/json",
925
+ "Access-Control-Allow-Origin": "*",
926
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
927
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
928
+ });
929
+ res.end(body);
930
+ }
931
+ __name(send, "send");
932
+ function sendError(res, status, message) {
933
+ send(res, status, { error: message });
934
+ }
935
+ __name(sendError, "sendError");
936
+ function setCors(res) {
937
+ res.setHeader("Access-Control-Allow-Origin", "*");
938
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
939
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
940
+ }
941
+ __name(setCors, "setCors");
942
+ function uuid() {
943
+ return crypto2.randomUUID();
944
+ }
945
+ __name(uuid, "uuid");
946
+ function matchRoute(method, url, pattern) {
947
+ const [pMethod, pPath] = pattern.split(" ");
948
+ if (method !== pMethod) return null;
949
+ const pParts = pPath.split("/").filter(Boolean);
950
+ const uParts = url.split("/").filter(Boolean);
951
+ if (pParts.length !== uParts.length) return null;
952
+ const params = {};
953
+ for (let i = 0; i < pParts.length; i++) {
954
+ if (pParts[i].startsWith(":")) {
955
+ params[pParts[i].slice(1)] = decodeURIComponent(uParts[i]);
956
+ } else if (pParts[i] !== uParts[i]) {
957
+ return null;
1369
958
  }
959
+ }
960
+ return params;
961
+ }
962
+ __name(matchRoute, "matchRoute");
1370
963
 
1371
- const result = await resp.json();
1372
- return send(res, 200, { text: result.text || '' });
964
+ // server/gateway-cleanup.js
965
+ import fs5 from "node:fs";
966
+ import path6 from "node:path";
967
+ function cleanGatewaySession(sessionKey) {
968
+ try {
969
+ const agentMatch = (sessionKey || "").match(/^agent:([^:]+):/);
970
+ const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
971
+ const sessionsPath = path6.join(sessionsDir, "sessions.json");
972
+ const store = JSON.parse(fs5.readFileSync(sessionsPath, "utf8"));
973
+ const entry = store[sessionKey];
974
+ if (!entry) return null;
975
+ if (entry.sessionId) {
976
+ try {
977
+ fs5.unlinkSync(path6.join(sessionsDir, `${entry.sessionId}.jsonl`));
978
+ } catch {
979
+ }
980
+ }
981
+ const sessionId = entry.sessionId || null;
982
+ delete store[sessionKey];
983
+ fs5.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
984
+ return sessionId;
1373
985
  } catch (err) {
1374
- console.error('Transcribe error:', err);
1375
- return send(res, 500, { error: err.message });
986
+ console.warn(`cleanGatewaySession(${sessionKey}):`, err.message);
987
+ return null;
1376
988
  }
1377
989
  }
990
+ __name(cleanGatewaySession, "cleanGatewaySession");
991
+ function cleanGatewaySessionsByPrefix(prefix) {
992
+ try {
993
+ const agentMatch = (prefix || "").match(/^agent:([^:]+):/);
994
+ const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
995
+ const sessionsPath = path6.join(sessionsDir, "sessions.json");
996
+ const store = JSON.parse(fs5.readFileSync(sessionsPath, "utf8"));
997
+ let cleaned = 0;
998
+ for (const key of Object.keys(store)) {
999
+ if (!key.startsWith(prefix)) continue;
1000
+ if (store[key]?.sessionId) {
1001
+ try {
1002
+ fs5.unlinkSync(path6.join(sessionsDir, `${store[key].sessionId}.jsonl`));
1003
+ } catch {
1004
+ }
1005
+ }
1006
+ delete store[key];
1007
+ cleaned++;
1008
+ }
1009
+ if (cleaned > 0) fs5.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
1010
+ return cleaned;
1011
+ } catch (err) {
1012
+ console.warn(`cleanGatewaySessionsByPrefix(${prefix}):`, err.message);
1013
+ return 0;
1014
+ }
1015
+ }
1016
+ __name(cleanGatewaySessionsByPrefix, "cleanGatewaySessionsByPrefix");
1378
1017
 
1379
-
1380
- // ─── Controllers ─────────────────────────────────────────────────────────────
1381
-
1382
- class WorkspaceController {
1018
+ // server/controllers/workspaces.js
1019
+ var WorkspaceController = class {
1020
+ static {
1021
+ __name(this, "WorkspaceController");
1022
+ }
1383
1023
  constructor({ getDb, closeDb, getWorkspaces, setWorkspaces, dataDir, broadcast }) {
1384
1024
  this.getDb = getDb;
1385
1025
  this.closeDb = closeDb;
@@ -1388,47 +1028,52 @@ class WorkspaceController {
1388
1028
  this.dataDir = dataDir;
1389
1029
  this.broadcast = broadcast;
1390
1030
  }
1391
-
1392
1031
  getAll(req, res) {
1393
1032
  const ws = this.getWorkspaces();
1394
1033
  const sorted = Object.values(ws.workspaces).sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
1395
1034
  for (const workspace of sorted) {
1396
1035
  try {
1397
- const db = this.getDb(workspace.name);
1398
- workspace.unread_count = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
1399
- } catch { workspace.unread_count = 0; }
1036
+ workspace.unread_count = this.getDb(workspace.name).prepare("SELECT COALESCE(SUM(unread_count), 0) as total FROM threads").get().total;
1037
+ } catch {
1038
+ workspace.unread_count = 0;
1039
+ }
1400
1040
  }
1401
1041
  send(res, 200, { active: ws.active, workspaces: sorted });
1402
1042
  }
1403
-
1404
1043
  async create(req, res) {
1405
1044
  const body = await parseBody(req);
1406
1045
  const { name, label } = body;
1407
- if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) return sendError(res, 400, 'Name must be [a-z0-9-], 1-32 chars');
1046
+ if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) return sendError(res, 400, "Name must be [a-z0-9-], 1-32 chars");
1408
1047
  const ws = this.getWorkspaces();
1409
- if (ws.workspaces[name]) return sendError(res, 409, 'Workspace already exists');
1410
- let agent = 'main';
1411
- try { agent = validateAgent(body.agent || 'main'); } catch { agent = 'main'; }
1412
- const workspace = { name, label: label || name, color: body.color || null, icon: body.icon || null, agent, createdAt: Date.now() };
1413
- ws.workspaces[name] = workspace;
1048
+ if (ws.workspaces[name]) return sendError(res, 409, "Workspace already exists");
1049
+ let agent = "main";
1050
+ try {
1051
+ agent = validateAgent(body.agent || "main");
1052
+ } catch {
1053
+ agent = "main";
1054
+ }
1055
+ ws.workspaces[name] = { name, label: label || name, color: body.color || null, icon: body.icon || null, agent, createdAt: Date.now() };
1414
1056
  this.setWorkspaces(ws);
1415
1057
  this.getDb(name);
1416
- send(res, 201, { workspace });
1058
+ send(res, 201, { workspace: ws.workspaces[name] });
1417
1059
  }
1418
-
1419
1060
  async update(req, res, params) {
1420
1061
  const body = await parseBody(req);
1421
1062
  const ws = this.getWorkspaces();
1422
- if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
1423
- if (body.label !== undefined) ws.workspaces[params.name].label = body.label;
1424
- if (body.color !== undefined) ws.workspaces[params.name].color = body.color;
1425
- if (body.icon !== undefined) ws.workspaces[params.name].icon = body.icon;
1426
- if (body.lastThread !== undefined) ws.workspaces[params.name].lastThread = body.lastThread;
1063
+ if (!ws.workspaces[params.name]) return sendError(res, 404, "Workspace not found");
1064
+ if (body.label !== void 0) ws.workspaces[params.name].label = body.label;
1065
+ if (body.color !== void 0) ws.workspaces[params.name].color = body.color;
1066
+ if (body.icon !== void 0) ws.workspaces[params.name].icon = body.icon;
1067
+ if (body.lastThread !== void 0) ws.workspaces[params.name].lastThread = body.lastThread;
1427
1068
  let migratedThreads = 0;
1428
- if (body.agent !== undefined) {
1069
+ if (body.agent !== void 0) {
1429
1070
  let newAgent;
1430
- try { newAgent = validateAgent(body.agent); } catch (e) { return sendError(res, 400, e.message); }
1431
- const oldAgent = ws.workspaces[params.name].agent || 'main';
1071
+ try {
1072
+ newAgent = validateAgent(body.agent);
1073
+ } catch (e) {
1074
+ return sendError(res, 400, e.message);
1075
+ }
1076
+ const oldAgent = ws.workspaces[params.name].agent || "main";
1432
1077
  if (newAgent !== oldAgent) {
1433
1078
  const db = this.getDb(params.name);
1434
1079
  const threads = db.prepare(`SELECT id, session_key FROM threads WHERE session_key LIKE ?`).all(`agent:${oldAgent}:${params.name}:chat:%`);
@@ -1436,23 +1081,25 @@ class WorkspaceController {
1436
1081
  for (const t of threads) cleanGatewaySession(t.session_key);
1437
1082
  ws.workspaces[params.name].agent = newAgent;
1438
1083
  migratedThreads = threads.length;
1439
- this.broadcast(JSON.stringify({ type: 'clawchats', event: 'workspace-agent-changed', workspace: params.name, agent: newAgent }));
1084
+ this.broadcast(JSON.stringify({ type: "clawchats", event: "workspace-agent-changed", workspace: params.name, agent: newAgent }));
1440
1085
  }
1441
1086
  }
1442
1087
  this.setWorkspaces(ws);
1443
1088
  send(res, 200, { workspace: ws.workspaces[params.name], migratedThreads });
1444
1089
  }
1445
-
1446
1090
  delete(req, res, params) {
1447
1091
  const ws = this.getWorkspaces();
1448
- if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
1449
- if (Object.keys(ws.workspaces).length <= 1) return sendError(res, 400, 'Cannot delete the only workspace');
1092
+ if (!ws.workspaces[params.name]) return sendError(res, 404, "Workspace not found");
1093
+ if (Object.keys(ws.workspaces).length <= 1) return sendError(res, 400, "Cannot delete the only workspace");
1450
1094
  this.closeDb(params.name);
1451
- const dbPath = path.join(this.dataDir, `${params.name}.db`);
1452
- try { fs.unlinkSync(dbPath); } catch { /* ok */ }
1453
- try { fs.unlinkSync(dbPath + '-wal'); } catch { /* ok */ }
1454
- try { fs.unlinkSync(dbPath + '-shm'); } catch { /* ok */ }
1455
- const wsAgent = ws.workspaces[params.name]?.agent || 'main';
1095
+ const dbPath = path7.join(this.dataDir, `${params.name}.db`);
1096
+ for (const suffix of ["", "-wal", "-shm"]) {
1097
+ try {
1098
+ fs6.unlinkSync(dbPath + suffix);
1099
+ } catch {
1100
+ }
1101
+ }
1102
+ const wsAgent = ws.workspaces[params.name]?.agent || "main";
1456
1103
  const cleaned = cleanGatewaySessionsByPrefix(`agent:${wsAgent}:${params.name}:chat:`);
1457
1104
  if (cleaned > 0) console.log(`Cleaned ${cleaned} gateway sessions for workspace: ${params.name}`);
1458
1105
  delete ws.workspaces[params.name];
@@ -1460,1423 +1107,1286 @@ class WorkspaceController {
1460
1107
  this.setWorkspaces(ws);
1461
1108
  send(res, 200, { ok: true });
1462
1109
  }
1463
-
1464
1110
  async reorder(req, res) {
1465
1111
  const body = await parseBody(req);
1466
- const { order } = body;
1467
- if (!Array.isArray(order)) return sendError(res, 400, 'order must be an array of workspace names');
1112
+ if (!Array.isArray(body.order)) return sendError(res, 400, "order must be an array of workspace names");
1468
1113
  const ws = this.getWorkspaces();
1469
- order.forEach((name, i) => { if (ws.workspaces[name]) ws.workspaces[name].order = i; });
1114
+ body.order.forEach((name, i) => {
1115
+ if (ws.workspaces[name]) ws.workspaces[name].order = i;
1116
+ });
1470
1117
  this.setWorkspaces(ws);
1471
1118
  send(res, 200, { ok: true, workspaces: Object.values(ws.workspaces) });
1472
1119
  }
1473
-
1474
1120
  activate(req, res, params) {
1475
1121
  const ws = this.getWorkspaces();
1476
- if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
1122
+ if (!ws.workspaces[params.name]) return sendError(res, 404, "Workspace not found");
1477
1123
  ws.active = params.name;
1478
1124
  this.setWorkspaces(ws);
1479
1125
  this.getDb(params.name);
1480
1126
  send(res, 200, { ok: true, workspace: ws.workspaces[params.name] });
1481
1127
  }
1482
- }
1128
+ };
1483
1129
 
1484
- class ThreadController {
1130
+ // server/controllers/threads.js
1131
+ import fs7 from "node:fs";
1132
+ import path8 from "node:path";
1133
+ var ThreadController = class {
1134
+ static {
1135
+ __name(this, "ThreadController");
1136
+ }
1485
1137
  constructor({ getActiveDb, getWorkspaces, uploadsDir, broadcast }) {
1486
1138
  this.getActiveDb = getActiveDb;
1487
1139
  this.getWorkspaces = getWorkspaces;
1488
1140
  this.uploadsDir = uploadsDir;
1489
1141
  this.broadcast = broadcast;
1490
1142
  }
1491
-
1492
1143
  getAll(req, res, params, query) {
1493
1144
  const db = this.getActiveDb();
1494
- const page = parseInt(query.page || '1', 10);
1495
- const limit = Math.min(parseInt(query.limit || '50', 10), 200);
1145
+ const page = parseInt(query.page || "1", 10);
1146
+ const limit = Math.min(parseInt(query.limit || "50", 10), 200);
1496
1147
  const offset = (page - 1) * limit;
1497
- const search = query.search || '';
1148
+ const search = query.search || "";
1498
1149
  let threads, total;
1499
1150
  if (search) {
1500
1151
  try {
1501
- const matchingIds = db.prepare(`SELECT DISTINCT m.thread_id FROM messages m JOIN messages_fts ON messages_fts.rowid = m.rowid WHERE messages_fts MATCH ?`).all(search).map(r => r.thread_id);
1502
- if (matchingIds.length === 0) return send(res, 200, { threads: [], total: 0, page });
1503
- const ph = matchingIds.map(() => '?').join(',');
1152
+ const matchingIds = db.prepare(`SELECT DISTINCT m.thread_id FROM messages m JOIN messages_fts ON messages_fts.rowid = m.rowid WHERE messages_fts MATCH ?`).all(search).map((r) => r.thread_id);
1153
+ if (!matchingIds.length) return send(res, 200, { threads: [], total: 0, page });
1154
+ const ph = matchingIds.map(() => "?").join(",");
1504
1155
  total = db.prepare(`SELECT COUNT(*) as c FROM threads WHERE id IN (${ph})`).get(...matchingIds).c;
1505
1156
  threads = db.prepare(`SELECT * FROM threads WHERE id IN (${ph}) ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?`).all(...matchingIds, limit, offset);
1506
- } catch { return send(res, 200, { threads: [], total: 0, page }); }
1157
+ } catch {
1158
+ return send(res, 200, { threads: [], total: 0, page });
1159
+ }
1507
1160
  } else {
1508
- total = db.prepare('SELECT COUNT(*) as c FROM threads').get().c;
1509
- threads = db.prepare('SELECT * FROM threads ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?').all(limit, offset);
1161
+ total = db.prepare("SELECT COUNT(*) as c FROM threads").get().c;
1162
+ threads = db.prepare("SELECT * FROM threads ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?").all(limit, offset);
1510
1163
  }
1511
1164
  send(res, 200, { threads, total, page });
1512
1165
  }
1513
-
1514
1166
  getUnread(req, res) {
1515
1167
  const db = this.getActiveDb();
1516
1168
  const threads = db.prepare(`SELECT t.id, t.title, t.unread_count, m.content as lastMessage FROM threads t LEFT JOIN messages m ON m.thread_id = t.id WHERE t.unread_count > 0 AND m.timestamp = (SELECT MAX(timestamp) FROM messages WHERE thread_id = t.id) ORDER BY t.updated_at DESC`).all();
1517
- for (const thread of threads) {
1518
- thread.unreadMessageIds = db.prepare('SELECT message_id FROM unread_messages WHERE thread_id = ?').all(thread.id).map(r => r.message_id);
1519
- }
1169
+ for (const t of threads) t.unreadMessageIds = db.prepare("SELECT message_id FROM unread_messages WHERE thread_id = ?").all(t.id).map((r) => r.message_id);
1520
1170
  send(res, 200, { threads });
1521
1171
  }
1522
-
1523
1172
  async markRead(req, res, params) {
1524
- const body = await parseBody(req);
1173
+ const { messageIds } = await parseBody(req);
1174
+ if (!Array.isArray(messageIds) || !messageIds.length) return send(res, 400, { error: "messageIds array required" });
1525
1175
  const db = this.getActiveDb();
1526
- const { messageIds } = body;
1527
- if (!Array.isArray(messageIds) || messageIds.length === 0) return send(res, 400, { error: 'messageIds array required' });
1528
- const ph = messageIds.map(() => '?').join(',');
1176
+ const ph = messageIds.map(() => "?").join(",");
1529
1177
  db.prepare(`DELETE FROM unread_messages WHERE thread_id = ? AND message_id IN (${ph})`).run(params.id, ...messageIds);
1530
1178
  const remaining = syncThreadUnreadCount(db, params.id);
1531
- const workspace = this.getWorkspaces().active;
1532
- this.broadcast(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace, threadId: params.id, action: 'read', messageIds, unreadCount: remaining, timestamp: Date.now() }));
1179
+ this.broadcast(JSON.stringify({ type: "clawchats", event: "unread-update", workspace: this.getWorkspaces().active, threadId: params.id, action: "read", messageIds, unreadCount: remaining, timestamp: Date.now() }));
1533
1180
  send(res, 200, { unread_count: remaining });
1534
1181
  }
1535
-
1536
1182
  async create(req, res) {
1537
1183
  const body = await parseBody(req);
1538
1184
  const db = this.getActiveDb();
1539
1185
  const ws = this.getWorkspaces();
1540
1186
  const id = body.id || uuid();
1541
1187
  const now = Date.now();
1542
- const agent = ws.workspaces[ws.active]?.agent || 'main';
1543
- const sessionKey = `agent:${agent}:${ws.active}:chat:${id}`;
1188
+ const agent = ws.workspaces[ws.active]?.agent || "main";
1544
1189
  try {
1545
- db.prepare('INSERT INTO threads (id, session_key, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(id, sessionKey, 'New chat', now, now);
1190
+ db.prepare("INSERT INTO threads (id, session_key, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(id, `agent:${agent}:${ws.active}:chat:${id}`, "New chat", now, now);
1546
1191
  } catch (e) {
1547
- if (e.message.includes('UNIQUE constraint')) return sendError(res, 409, 'Thread already exists');
1192
+ if (e.message.includes("UNIQUE constraint")) return sendError(res, 409, "Thread already exists");
1548
1193
  throw e;
1549
1194
  }
1550
- send(res, 201, { thread: db.prepare('SELECT * FROM threads WHERE id = ?').get(id) });
1195
+ send(res, 201, { thread: db.prepare("SELECT * FROM threads WHERE id = ?").get(id) });
1551
1196
  }
1552
-
1553
1197
  get(req, res, params) {
1554
- const thread = this.getActiveDb().prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1555
- if (!thread) return sendError(res, 404, 'Thread not found');
1198
+ const thread = this.getActiveDb().prepare("SELECT * FROM threads WHERE id = ?").get(params.id);
1199
+ if (!thread) return sendError(res, 404, "Thread not found");
1556
1200
  send(res, 200, { thread });
1557
1201
  }
1558
-
1559
1202
  async update(req, res, params) {
1560
1203
  const body = await parseBody(req);
1561
1204
  const db = this.getActiveDb();
1562
- if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
1205
+ if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(params.id)) return sendError(res, 404, "Thread not found");
1563
1206
  const fields = [], values = [];
1564
- for (const [col, val] of [['title', body.title], ['model', body.model], ['last_session_id', body.last_session_id], ['unread_count', body.unread_count]]) {
1565
- if (val !== undefined) { fields.push(`${col} = ?`); values.push(val); }
1207
+ for (const [col, val] of [["title", body.title], ["model", body.model], ["last_session_id", body.last_session_id], ["unread_count", body.unread_count]]) {
1208
+ if (val !== void 0) {
1209
+ fields.push(`${col} = ?`);
1210
+ values.push(val);
1211
+ }
1212
+ }
1213
+ if (body.pinned !== void 0) {
1214
+ fields.push("pinned = ?");
1215
+ values.push(body.pinned ? 1 : 0);
1566
1216
  }
1567
- if (body.pinned !== undefined) { fields.push('pinned = ?'); values.push(body.pinned ? 1 : 0); }
1568
- if (body.pin_order !== undefined) { fields.push('pin_order = ?'); values.push(body.pin_order); }
1569
- if (body.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(body.sort_order); }
1570
- if (fields.length > 0) {
1571
- fields.push('updated_at = ?'); values.push(Date.now(), params.id);
1572
- db.prepare(`UPDATE threads SET ${fields.join(', ')} WHERE id = ?`).run(...values);
1217
+ if (body.pin_order !== void 0) {
1218
+ fields.push("pin_order = ?");
1219
+ values.push(body.pin_order);
1573
1220
  }
1574
- send(res, 200, { thread: db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id) });
1221
+ if (body.sort_order !== void 0) {
1222
+ fields.push("sort_order = ?");
1223
+ values.push(body.sort_order);
1224
+ }
1225
+ if (fields.length) {
1226
+ fields.push("updated_at = ?");
1227
+ values.push(Date.now(), params.id);
1228
+ db.prepare(`UPDATE threads SET ${fields.join(", ")} WHERE id = ?`).run(...values);
1229
+ }
1230
+ send(res, 200, { thread: db.prepare("SELECT * FROM threads WHERE id = ?").get(params.id) });
1575
1231
  }
1576
-
1577
1232
  delete(req, res, params) {
1578
1233
  const db = this.getActiveDb();
1579
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1580
- if (!thread) return sendError(res, 404, 'Thread not found');
1581
- db.prepare('DELETE FROM threads WHERE id = ?').run(params.id);
1582
- let sessionIdToDelete = thread.last_session_id;
1583
- const agentMatch = (thread.session_key || '').match(/^agent:([^:]+):/);
1234
+ const thread = db.prepare("SELECT * FROM threads WHERE id = ?").get(params.id);
1235
+ if (!thread) return sendError(res, 404, "Thread not found");
1236
+ db.prepare("DELETE FROM threads WHERE id = ?").run(params.id);
1237
+ const agentMatch = (thread.session_key || "").match(/^agent:([^:]+):/);
1584
1238
  const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
1239
+ let sessionIdToDelete = thread.last_session_id;
1585
1240
  if (!sessionIdToDelete) {
1586
1241
  try {
1587
- const store = JSON.parse(fs.readFileSync(path.join(sessionsDir, 'sessions.json'), 'utf8'));
1588
- sessionIdToDelete = store[thread.session_key]?.sessionId;
1589
- } catch { /* ok */ }
1242
+ sessionIdToDelete = JSON.parse(fs7.readFileSync(path8.join(sessionsDir, "sessions.json"), "utf8"))[thread.session_key]?.sessionId;
1243
+ } catch {
1244
+ }
1590
1245
  }
1591
1246
  cleanGatewaySession(thread.session_key);
1592
1247
  if (sessionIdToDelete) {
1593
- try { fs.unlinkSync(path.join(sessionsDir, `${sessionIdToDelete}.jsonl`)); } catch { /* ok */ }
1248
+ try {
1249
+ fs7.unlinkSync(path8.join(sessionsDir, `${sessionIdToDelete}.jsonl`));
1250
+ } catch {
1251
+ }
1252
+ }
1253
+ try {
1254
+ fs7.rmSync(path8.join(this.uploadsDir, params.id), { recursive: true });
1255
+ } catch {
1594
1256
  }
1595
- try { fs.rmSync(path.join(this.uploadsDir, params.id), { recursive: true }); } catch { /* ok */ }
1596
1257
  send(res, 200, { ok: true });
1597
1258
  }
1259
+ };
1260
+
1261
+ // server/util/context.js
1262
+ import fs8 from "node:fs";
1263
+ import path9 from "node:path";
1264
+ function buildContextPreamble(db, threadId, lastSessionId, sessionKey) {
1265
+ let summary = null;
1266
+ let method = "raw";
1267
+ if (lastSessionId) {
1268
+ const agentMatch = (sessionKey || "").match(/^agent:([^:]+):/);
1269
+ const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
1270
+ try {
1271
+ const lines = fs8.readFileSync(path9.join(sessionsDir, `${lastSessionId}.jsonl`), "utf8").split("\n").filter(Boolean);
1272
+ for (let i = lines.length - 1; i >= 0; i--) {
1273
+ try {
1274
+ const entry = JSON.parse(lines[i]);
1275
+ if (entry.type === "compaction" && entry.summary) {
1276
+ summary = entry.summary;
1277
+ method = "compaction";
1278
+ break;
1279
+ }
1280
+ } catch {
1281
+ }
1282
+ }
1283
+ } catch {
1284
+ }
1285
+ }
1286
+ let preamble = "";
1287
+ if (method === "compaction" && summary) {
1288
+ preamble += "[CONTEXT RECOVERY \u2014 This thread's agent session was reset. Below is a summary of the previous conversation followed by recent messages to restore context.]\n\n";
1289
+ preamble += "[CONVERSATION SUMMARY]\n" + summary + "\n\n";
1290
+ const msgs = db.prepare("SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 10").all(threadId).reverse();
1291
+ if (msgs.length) {
1292
+ preamble += "[RECENT MESSAGES]\n";
1293
+ for (const m of msgs) {
1294
+ const ts = new Date(m.timestamp).toISOString().replace("T", " ").slice(0, 16);
1295
+ preamble += `${m.role.charAt(0).toUpperCase() + m.role.slice(1)} (${ts}): ${m.content}
1296
+ `;
1297
+ }
1298
+ }
1299
+ } else {
1300
+ preamble += "[CONTEXT RECOVERY \u2014 This thread's agent session was reset. Below are recent messages from the previous conversation to restore context.]\n\n";
1301
+ const msgs = db.prepare("SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 25").all(threadId).reverse();
1302
+ if (msgs.length) {
1303
+ preamble += "[PREVIOUS MESSAGES]\n";
1304
+ for (const m of msgs) {
1305
+ const ts = new Date(m.timestamp).toISOString().replace("T", " ").slice(0, 16);
1306
+ preamble += `${m.role.charAt(0).toUpperCase() + m.role.slice(1)} (${ts}): ${m.content}
1307
+ `;
1308
+ }
1309
+ }
1310
+ }
1311
+ if (preamble.length > MAX_PREAMBLE_CHARS) preamble = preamble.slice(preamble.length - MAX_PREAMBLE_CHARS);
1312
+ return { preamble, method };
1598
1313
  }
1314
+ __name(buildContextPreamble, "buildContextPreamble");
1599
1315
 
1600
- class MessageController {
1316
+ // server/controllers/messages.js
1317
+ var MessageController = class {
1318
+ static {
1319
+ __name(this, "MessageController");
1320
+ }
1601
1321
  constructor({ getActiveDb, getWorkspaces, broadcast }) {
1602
1322
  this.getActiveDb = getActiveDb;
1603
1323
  this.getWorkspaces = getWorkspaces;
1604
1324
  this.broadcast = broadcast;
1605
1325
  }
1606
-
1607
1326
  getAll(req, res, params, query) {
1608
1327
  const db = this.getActiveDb();
1609
- if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
1610
- const limit = Math.min(parseInt(query.limit || '100', 10), 500);
1328
+ if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(params.id)) return sendError(res, 404, "Thread not found");
1329
+ const limit = Math.min(parseInt(query.limit || "100", 10), 500);
1611
1330
  const before = query.before ? parseInt(query.before, 10) : null;
1612
1331
  const after = query.after ? parseInt(query.after, 10) : null;
1613
- let sql = 'SELECT * FROM messages WHERE thread_id = ?';
1332
+ let sql = "SELECT * FROM messages WHERE thread_id = ?";
1614
1333
  const sqlParams = [params.id];
1615
- if (before) { sql += ' AND timestamp < ?'; sqlParams.push(before); }
1616
- if (after) { sql += ' AND timestamp > ?'; sqlParams.push(after); }
1617
- const total = db.prepare(sql.replace('SELECT *', 'SELECT COUNT(*) as c')).get(...sqlParams).c;
1618
- const rows = db.prepare(sql + ' ORDER BY timestamp DESC LIMIT ?').all(...sqlParams, limit + 1);
1619
- const hasMore = rows.length > limit;
1334
+ if (before) {
1335
+ sql += " AND timestamp < ?";
1336
+ sqlParams.push(before);
1337
+ }
1338
+ if (after) {
1339
+ sql += " AND timestamp > ?";
1340
+ sqlParams.push(after);
1341
+ }
1342
+ const total = db.prepare(sql.replace("SELECT *", "SELECT COUNT(*) as c")).get(...sqlParams).c;
1343
+ const rows = db.prepare(sql + " ORDER BY timestamp DESC LIMIT ?").all(...sqlParams, limit + 1);
1620
1344
  const messages = rows.slice(0, limit).reverse();
1621
- for (const m of messages) { if (m.metadata) { try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ } } }
1622
- send(res, 200, { messages, hasMore });
1345
+ for (const m of messages) {
1346
+ if (m.metadata) {
1347
+ try {
1348
+ m.metadata = JSON.parse(m.metadata);
1349
+ } catch {
1350
+ }
1351
+ }
1352
+ }
1353
+ send(res, 200, { messages, hasMore: rows.length > limit });
1623
1354
  }
1624
-
1625
1355
  async create(req, res, params) {
1626
1356
  const body = await parseBody(req);
1627
1357
  const db = this.getActiveDb();
1628
- if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
1629
- if (!body.id || !body.role || body.content === undefined || !body.timestamp) return sendError(res, 400, 'Required: id, role, content, timestamp');
1358
+ if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(params.id)) return sendError(res, 404, "Thread not found");
1359
+ if (!body.id || !body.role || body.content === void 0 || !body.timestamp) return sendError(res, 400, "Required: id, role, content, timestamp");
1630
1360
  const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
1631
- const existing = db.prepare('SELECT id, status, metadata FROM messages WHERE id = ?').get(body.id);
1361
+ const existing = db.prepare("SELECT id, status, metadata FROM messages WHERE id = ?").get(body.id);
1632
1362
  if (existing) {
1633
1363
  if (body.status && body.status !== existing.status) {
1634
- db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?').run(body.status, body.content, metadata || existing.metadata, body.id);
1364
+ db.prepare("UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?").run(body.status, body.content, metadata || existing.metadata, body.id);
1635
1365
  }
1636
1366
  } else {
1637
- 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());
1638
- db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
1639
- if (body.role === 'user' && body.content) {
1640
- const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
1641
- if (threadInfo?.title === 'New chat') {
1642
- const title = body.content.replace(/\n.*/s, '').slice(0, 40).trim() + (body.content.length > 40 ? '...' : '');
1367
+ 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());
1368
+ db.prepare("UPDATE threads SET updated_at = ? WHERE id = ?").run(Date.now(), params.id);
1369
+ if (body.role === "user" && body.content) {
1370
+ const thread = db.prepare("SELECT title FROM threads WHERE id = ?").get(params.id);
1371
+ if (thread?.title === "New chat") {
1372
+ const title = body.content.replace(/\n.*/s, "").slice(0, 40).trim() + (body.content.length > 40 ? "..." : "");
1643
1373
  if (title) {
1644
- db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, params.id);
1645
- this.broadcast(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId: params.id, workspace: this.getWorkspaces().active, title }));
1374
+ db.prepare("UPDATE threads SET title = ? WHERE id = ?").run(title, params.id);
1375
+ this.broadcast(JSON.stringify({ type: "clawchats", event: "thread-title-updated", threadId: params.id, workspace: this.getWorkspaces().active, title }));
1646
1376
  }
1647
1377
  }
1648
1378
  }
1649
1379
  }
1650
- const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
1651
- if (message?.metadata) { try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ } }
1380
+ const message = db.prepare("SELECT * FROM messages WHERE id = ?").get(body.id);
1381
+ if (message?.metadata) {
1382
+ try {
1383
+ message.metadata = JSON.parse(message.metadata);
1384
+ } catch {
1385
+ }
1386
+ }
1652
1387
  send(res, existing ? 200 : 201, { message });
1653
1388
  }
1654
-
1655
1389
  delete(req, res, params) {
1656
1390
  const db = this.getActiveDb();
1657
- if (!db.prepare('SELECT id FROM messages WHERE id = ? AND thread_id = ?').get(params.messageId, params.id)) return sendError(res, 404, 'Message not found');
1658
- db.prepare('DELETE FROM messages WHERE id = ?').run(params.messageId);
1391
+ if (!db.prepare("SELECT id FROM messages WHERE id = ? AND thread_id = ?").get(params.messageId, params.id)) return sendError(res, 404, "Message not found");
1392
+ db.prepare("DELETE FROM messages WHERE id = ?").run(params.messageId);
1659
1393
  send(res, 200, { ok: true });
1660
1394
  }
1661
-
1662
1395
  contextFill(req, res, params) {
1663
1396
  const db = this.getActiveDb();
1664
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1665
- if (!thread) return sendError(res, 404, 'Thread not found');
1666
- const { preamble, method } = buildContextPreamble(db, params.id, thread.last_session_id, thread.session_key);
1667
- send(res, 200, { preamble, method });
1397
+ const thread = db.prepare("SELECT * FROM threads WHERE id = ?").get(params.id);
1398
+ if (!thread) return sendError(res, 404, "Thread not found");
1399
+ send(res, 200, buildContextPreamble(db, params.id, thread.last_session_id, thread.session_key));
1668
1400
  }
1669
-
1670
1401
  search(req, res, params, query) {
1671
- const db = this.getActiveDb();
1672
- const q = query.q || '';
1402
+ const q = query.q || "";
1673
1403
  if (!q) return send(res, 200, { results: [], total: 0 });
1674
- const page = parseInt(query.page || '1', 10);
1675
- const limit = Math.min(parseInt(query.limit || '20', 10), 100);
1404
+ const db = this.getActiveDb();
1405
+ const page = parseInt(query.page || "1", 10);
1406
+ const limit = Math.min(parseInt(query.limit || "20", 10), 100);
1676
1407
  const offset = (page - 1) * limit;
1677
1408
  try {
1678
1409
  const results = db.prepare(`SELECT m.id as messageId, m.thread_id as threadId, t.title as threadTitle, m.role, snippet(messages_fts, 0, '<mark>', '</mark>', '...', 40) as content, m.timestamp FROM messages_fts JOIN messages m ON messages_fts.rowid = m.rowid JOIN threads t ON m.thread_id = t.id WHERE messages_fts MATCH ? ORDER BY rank LIMIT ? OFFSET ?`).all(q, limit, offset);
1679
1410
  const total = db.prepare(`SELECT COUNT(*) as c FROM messages_fts WHERE messages_fts MATCH ?`).get(q).c;
1680
1411
  send(res, 200, { results, total });
1681
- } catch { send(res, 200, { results: [], total: 0 }); }
1412
+ } catch {
1413
+ send(res, 200, { results: [], total: 0 });
1414
+ }
1682
1415
  }
1683
-
1684
1416
  export(req, res) {
1685
1417
  const db = this.getActiveDb();
1686
1418
  const ws = this.getWorkspaces();
1687
- const threads = db.prepare('SELECT * FROM threads ORDER BY updated_at DESC').all();
1419
+ const threads = db.prepare("SELECT * FROM threads ORDER BY updated_at DESC").all();
1688
1420
  send(res, 200, {
1689
- workspace: ws.active, exportedAt: Date.now(),
1690
- threads: threads.map(t => {
1691
- const messages = db.prepare('SELECT * FROM messages WHERE thread_id = ? ORDER BY timestamp ASC').all(t.id);
1692
- for (const m of messages) { if (m.metadata) { try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ } } }
1421
+ workspace: ws.active,
1422
+ exportedAt: Date.now(),
1423
+ threads: threads.map((t) => {
1424
+ const messages = db.prepare("SELECT * FROM messages WHERE thread_id = ? ORDER BY timestamp ASC").all(t.id);
1425
+ for (const m of messages) {
1426
+ if (m.metadata) {
1427
+ try {
1428
+ m.metadata = JSON.parse(m.metadata);
1429
+ } catch {
1430
+ }
1431
+ }
1432
+ }
1693
1433
  return { ...t, messages };
1694
- }),
1434
+ })
1695
1435
  });
1696
1436
  }
1697
-
1698
1437
  async import(req, res) {
1699
1438
  const body = await parseBody(req);
1700
1439
  const db = this.getActiveDb();
1701
1440
  const ws = this.getWorkspaces();
1702
- if (!body.threads || !Array.isArray(body.threads)) return sendError(res, 400, 'Expected { threads: [...] }');
1441
+ if (!body.threads || !Array.isArray(body.threads)) return sendError(res, 400, "Expected { threads: [...] }");
1703
1442
  let threadsImported = 0, messagesImported = 0;
1704
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)');
1705
- const insertMsg = db.prepare('INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
1706
- db.transaction(() => {
1443
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)");
1444
+ const insertMsg = db.prepare("INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
1445
+ db.exec("BEGIN");
1446
+ try {
1707
1447
  for (const t of body.threads) {
1708
1448
  if (!t.id) continue;
1709
1449
  const sessionKey = t.session_key || `agent:main:${ws.active}:chat:${t.id}`;
1710
- if (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()).changes > 0) threadsImported++;
1711
- for (const m of (t.messages || [])) {
1450
+ if (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()).changes > 0) threadsImported++;
1451
+ for (const m of t.messages || []) {
1712
1452
  if (!m.id || !m.role) continue;
1713
- const meta = m.metadata ? (typeof m.metadata === 'string' ? m.metadata : JSON.stringify(m.metadata)) : null;
1714
- if (insertMsg.run(m.id, t.id, m.role, m.content || '', m.status || 'sent', meta, m.seq || null, m.timestamp || Date.now(), m.created_at || Date.now()).changes > 0) messagesImported++;
1453
+ const meta = m.metadata ? typeof m.metadata === "string" ? m.metadata : JSON.stringify(m.metadata) : null;
1454
+ if (insertMsg.run(m.id, t.id, m.role, m.content || "", m.status || "sent", meta, m.seq || null, m.timestamp || Date.now(), m.created_at || Date.now()).changes > 0) messagesImported++;
1715
1455
  }
1716
1456
  }
1717
- })();
1718
- send(res, 200, { ok: true, threadsImported, messagesImported });
1719
- }
1720
- }
1721
-
1722
- class FileController {
1723
- constructor({ getActiveDb, getWorkspaces, uploadsDir, intelligenceDir }) {
1724
- this.getActiveDb = getActiveDb;
1725
- this.getWorkspaces = getWorkspaces;
1726
- this.uploadsDir = uploadsDir;
1727
- this.intelligenceDir = intelligenceDir;
1728
- }
1729
-
1730
- async upload(req, res, params) {
1731
- const db = this.getActiveDb();
1732
- if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
1733
- const files = await parseMultipart(req);
1734
- const dir = path.join(this.uploadsDir, params.id);
1735
- fs.mkdirSync(dir, { recursive: true });
1736
- const savedFiles = [];
1737
- for (const file of files) {
1738
- const fileId = uuid();
1739
- const ext = path.extname(file.filename) || '';
1740
- const savedPath = path.join(dir, fileId + ext);
1741
- fs.writeFileSync(savedPath, file.data);
1742
- savedFiles.push({ id: fileId, filename: file.filename, path: `/api/uploads/${params.id}/${fileId}${ext}`, mimeType: file.mimeType, size: file.data.length });
1743
- }
1744
- send(res, 200, { files: savedFiles });
1745
- }
1746
-
1747
- serveUpload(req, res, params) {
1748
- const base = path.join(this.uploadsDir, params.threadId, params.fileId);
1749
- let resolved = base;
1750
- if (!fs.existsSync(resolved)) {
1751
- try {
1752
- const match = fs.readdirSync(path.join(this.uploadsDir, params.threadId)).find(e => e.startsWith(params.fileId.replace(/\.[^.]+$/, '')));
1753
- if (match) resolved = path.join(this.uploadsDir, params.threadId, match);
1754
- } catch { /* ok */ }
1457
+ db.exec("COMMIT");
1458
+ } catch (e) {
1459
+ db.exec("ROLLBACK");
1460
+ throw e;
1755
1461
  }
1756
- if (!fs.existsSync(resolved)) return sendError(res, 404, 'File not found');
1757
- const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json' };
1758
- const stat = fs.statSync(resolved);
1759
- res.writeHead(200, { 'Content-Type': MIME[path.extname(resolved).toLowerCase()] || 'application/octet-stream', 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' });
1760
- fs.createReadStream(resolved).pipe(res);
1761
- }
1762
-
1763
- _intelligencePath(threadId) {
1764
- return path.join(this.intelligenceDir, this.getWorkspaces().active, `${threadId}.json`);
1462
+ send(res, 200, { ok: true, threadsImported, messagesImported });
1765
1463
  }
1464
+ };
1766
1465
 
1767
- getIntelligence(req, res, params) {
1768
- const filePath = this._intelligencePath(params.id);
1769
- if (!fs.existsSync(filePath)) return send(res, 200, { versions: [], currentVersion: -1 });
1770
- try { return send(res, 200, JSON.parse(fs.readFileSync(filePath, 'utf8'))); }
1771
- catch { return send(res, 200, { versions: [], currentVersion: -1 }); }
1772
- }
1466
+ // server/controllers/files.js
1467
+ import fs9 from "node:fs";
1468
+ import path10 from "node:path";
1773
1469
 
1774
- async saveIntelligence(req, res, params) {
1775
- const body = await parseBody(req);
1776
- const filePath = this._intelligencePath(params.id);
1777
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
1778
- const data = { versions: body.versions || [], currentVersion: body.currentVersion ?? -1, updatedAt: Date.now() };
1779
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
1780
- send(res, 200, data);
1781
- }
1782
- }
1783
-
1784
- class MemoryController {
1785
- constructor({ memoryProvider, memoryFilesDir, memoryConfig }) {
1786
- this.provider = memoryProvider;
1787
- this.filesDir = memoryFilesDir;
1788
- this.config = memoryConfig;
1789
- }
1790
-
1791
- async list(req, res, query) {
1792
- const limit = Math.min(parseInt(query.limit) || 20, 100);
1793
- try { send(res, 200, await this.provider.list(limit, query.offset || null)); }
1794
- catch (err) { send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message }); }
1795
- }
1796
-
1797
- async search(req, res, query) {
1798
- const q = (query.query || '').toLowerCase().trim();
1799
- if (!q) return send(res, 400, { error: 'Missing query parameter' });
1800
- try { send(res, 200, await this.provider.search(q)); }
1801
- catch (err) { send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message }); }
1802
- }
1803
-
1804
- files(req, res, query) {
1805
- const q = (query.query || '').toLowerCase().trim();
1806
- const memories = this._parseFiles();
1807
- const filtered = q ? memories.filter(m => m.data.toLowerCase().includes(q) || m.title.toLowerCase().includes(q)) : memories;
1808
- filtered.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
1809
- send(res, 200, { memories: filtered });
1810
- }
1811
-
1812
- _parseFiles() {
1813
- const memories = [];
1814
- const scanDir = (dir, prefix = '') => {
1815
- let entries;
1816
- try { entries = fs.readdirSync(dir); } catch { return; }
1817
- for (const entry of entries) {
1818
- const fullPath = path.join(dir, entry);
1819
- const stat = (() => { try { return fs.statSync(fullPath); } catch { return null; } })();
1820
- if (!stat) continue;
1821
- if (stat.isDirectory() && !prefix) {
1822
- scanDir(fullPath, entry + '/');
1823
- } else if (entry.endsWith('.md') && stat.isFile()) {
1824
- const content = fs.readFileSync(fullPath, 'utf8');
1825
- const basename = entry.replace(/\.md$/, '');
1826
- const relName = prefix + basename;
1827
- const dateMatch = basename.match(/^(\d{4}-\d{2}-\d{2})/);
1828
- if (prefix) {
1829
- memories.push({ id: `file:${relName}`, source: 'file', file: relName, title: basename, data: content.trim(), createdAt: stat.mtime.toISOString() });
1830
- } else {
1831
- const sections = content.split(/^(?=## )/m);
1832
- for (const section of sections) {
1833
- const trimmed = section.trim();
1834
- if (!trimmed) continue;
1835
- const headingMatch = trimmed.match(/^##\s+(.+)/);
1836
- const heading = headingMatch ? headingMatch[1].trim() : null;
1837
- const body = headingMatch ? trimmed.slice(trimmed.indexOf('\n') + 1).trim() : trimmed;
1838
- if (!heading && body.match(/^#\s+/) && body.split('\n').length <= 2) continue;
1839
- const title = heading || basename;
1840
- memories.push({ id: `file:${basename}:${title}`, source: 'file', file: basename, title, data: heading ? `**${title}**\n${body}` : body, createdAt: dateMatch ? `${dateMatch[1]}T00:00:00Z` : stat.mtime.toISOString() });
1841
- }
1842
- }
1470
+ // server/util/multipart.js
1471
+ function parseMultipart(req) {
1472
+ return new Promise((resolve, reject) => {
1473
+ const contentType = req.headers["content-type"] || "";
1474
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
1475
+ if (!boundaryMatch) return reject(new Error("No boundary in content-type"));
1476
+ const boundary = boundaryMatch[1] || boundaryMatch[2];
1477
+ const chunks = [];
1478
+ req.on("data", (c) => chunks.push(c));
1479
+ req.on("end", () => {
1480
+ const buf = Buffer.concat(chunks);
1481
+ const files = [];
1482
+ const delimiter = Buffer.from(`--${boundary}`);
1483
+ let pos = 0;
1484
+ while (pos < buf.length) {
1485
+ const start = buf.indexOf(delimiter, pos);
1486
+ if (start === -1) break;
1487
+ const nextStart = buf.indexOf(delimiter, start + delimiter.length);
1488
+ if (nextStart === -1) break;
1489
+ const part = buf.subarray(start + delimiter.length, nextStart);
1490
+ const headerEnd = part.indexOf("\r\n\r\n");
1491
+ if (headerEnd === -1) {
1492
+ pos = nextStart;
1493
+ continue;
1494
+ }
1495
+ const headerStr = part.subarray(0, headerEnd).toString();
1496
+ let body = part.subarray(headerEnd + 4);
1497
+ if (body.length >= 2 && body[body.length - 2] === 13 && body[body.length - 1] === 10) {
1498
+ body = body.subarray(0, body.length - 2);
1499
+ }
1500
+ const filenameMatch = headerStr.match(/filename="([^"]+)"/);
1501
+ const ctMatch = headerStr.match(/Content-Type:\s*(\S+)/i);
1502
+ if (filenameMatch) {
1503
+ files.push({
1504
+ filename: filenameMatch[1],
1505
+ mimeType: ctMatch ? ctMatch[1] : "application/octet-stream",
1506
+ data: body
1507
+ });
1843
1508
  }
1509
+ pos = nextStart;
1844
1510
  }
1845
- };
1846
- scanDir(this.filesDir);
1847
- return memories;
1848
- }
1849
-
1850
- async update(req, res, params) {
1851
- try {
1852
- const chunks = [];
1853
- for await (const chunk of req) chunks.push(chunk);
1854
- const { data } = JSON.parse(Buffer.concat(chunks).toString());
1855
- if (!(data || '').trim()) return send(res, 400, { error: 'Missing data field' });
1856
- send(res, 200, { ok: true, result: await this.provider.update(params.id, data.trim()) });
1857
- } catch (err) { send(res, 502, { error: 'Failed to update memory', detail: err.message }); }
1858
- }
1859
-
1860
- async delete(req, res, params) {
1861
- try { send(res, 200, { ok: true, result: await this.provider.delete(params.id) }); }
1862
- catch (err) { send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message }); }
1863
- }
1864
-
1865
- async status(req, res) {
1866
- const status = await this.provider.status();
1867
- send(res, 200, { provider: this.provider.name, host: this.config.host, port: this.config.port, collection: this.config.collection, backend: status, memoryFilesDir: this.filesDir, memoryFilesDirExists: fs.existsSync(this.filesDir) });
1868
- }
1869
- }
1870
-
1871
- // ─── Shared helpers (used by _GatewayClient inside createApp) ────────────────
1872
-
1873
- function syncThreadUnreadCount(db, threadId) {
1874
- const count = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(threadId).c;
1875
- db.prepare('UPDATE threads SET unread_count = ? WHERE id = ?').run(count, threadId);
1876
- return count;
1877
- }
1878
-
1879
- function parseSessionKey(sessionKey) {
1880
- if (!sessionKey) return null;
1881
- const match = sessionKey.match(/^agent:([^:]+):([^:]+):chat:([^:]+)$/);
1882
- if (!match) return null;
1883
- return { agent: match[1], workspace: match[2], threadId: match[3] };
1884
- }
1885
-
1886
- function extractContent(message) {
1887
- if (!message) return '';
1888
- if (typeof message.content === 'string') return message.content;
1889
- if (Array.isArray(message.content)) {
1890
- return message.content.filter(p => p.type === 'text').map(p => p.text).join('');
1891
- }
1892
- return '';
1893
- }
1894
-
1895
- function isSilentReplyExact(text, token = 'NO_REPLY') {
1896
- if (!text) return false;
1897
- const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1898
- return new RegExp(`^\\s*${escaped}\\s*$`).test(text);
1899
- }
1900
-
1901
- function isSilentReplyPrefix(text, token = 'NO_REPLY') {
1902
- if (!text) return false;
1903
- const trimmed = text.trimStart();
1904
- if (!trimmed) return false;
1905
- if (trimmed !== trimmed.toUpperCase()) return false;
1906
- const normalized = trimmed.toUpperCase();
1907
- if (normalized.length < 2) return false;
1908
- if (/[^A-Z_]/.test(normalized)) return false;
1909
- const tokenUpper = token.toUpperCase();
1910
- if (!tokenUpper.startsWith(normalized)) return false;
1911
- if (normalized.includes('_')) return true;
1912
- return tokenUpper === 'NO_REPLY' && normalized === 'NO';
1913
- }
1914
-
1915
- function stripTrailingSentinel(text, token = 'NO_REPLY') {
1916
- if (!text) return text;
1917
- const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1918
- return text.replace(new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`), '').trim();
1919
- }
1920
-
1921
- function stripFinalTags(text) {
1922
- if (!text) return text;
1923
- return text.replace(/<\s*\/?\s*final\s*>/gi, '');
1924
- }
1925
-
1926
- function sanitizeAssistantContent(text) {
1927
- if (!text) return text;
1928
- let out = stripFinalTags(text);
1929
- out = out.replace(/^(?:[ \t]*\r?\n)+/, '');
1930
- if (out.includes('NO_REPLY')) out = stripTrailingSentinel(out, 'NO_REPLY');
1931
- if (out.includes('HEARTBEAT_OK')) out = stripTrailingSentinel(out, 'HEARTBEAT_OK');
1932
- return out;
1933
- }
1934
-
1935
- function generateActivitySummary(steps) {
1936
- const toolSteps = steps.filter(s => s.type === 'tool' && s.phase !== 'result' && s.phase !== 'update');
1937
- const hasThinking = steps.some(s => s.type === 'thinking' && s.text);
1938
- const hasNarration = steps.some(s => s.type === 'assistant' && s.text?.trim());
1939
- if (toolSteps.length === 0 && !hasThinking && !hasNarration) return null;
1940
- if (toolSteps.length === 0 && hasThinking) return 'Reasoned through the problem';
1941
- if (toolSteps.length === 0 && hasNarration) return 'Processed in multiple steps';
1942
- const counts = {};
1943
- for (const s of toolSteps) { const name = s.name || 'unknown'; counts[name] = (counts[name] || 0) + 1; }
1944
- 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' };
1945
- const parts = [];
1946
- for (const [name, count] of Object.entries(counts)) {
1947
- const friendly = toolNames[name];
1948
- parts.push(friendly ? (count > 1 ? `${friendly} (${count}×)` : friendly) : (count > 1 ? `used ${name} (${count}×)` : `used ${name}`));
1949
- }
1950
- if (parts.length === 0) return null;
1951
- if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
1952
- const last = parts.pop();
1953
- return (parts.join(', ') + ' and ' + last).replace(/^./, c => c.toUpperCase());
1954
- }
1955
-
1956
- function writeActivityToDb(getDbFn, broadcastFn, runId, log) {
1957
- if (!log._parsed) log._parsed = parseSessionKey(log.sessionKey);
1958
- const parsed = log._parsed;
1959
- if (!parsed) return;
1960
- const db = getDbFn(parsed.workspace);
1961
- if (!db) return;
1962
- const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
1963
- const summary = generateActivitySummary(log.steps);
1964
- const now = Date.now();
1965
- if (!log._messageId) {
1966
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
1967
- if (!thread) return;
1968
- const messageId = `gw-activity-${runId}`;
1969
- const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
1970
- try {
1971
- db.prepare(`INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)`).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
1972
- log._messageId = messageId;
1973
- broadcastFn(JSON.stringify({ type: 'clawchats', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now }));
1974
- } catch (err) { console.error(`[activity] Failed to write activity ${messageId}:`, err.message); }
1975
- } else {
1976
- const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
1977
- const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
1978
- metadata.activityLog = cleanSteps;
1979
- metadata.activitySummary = summary;
1980
- metadata.pending = true;
1981
- db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), log._messageId);
1982
- }
1511
+ resolve(files);
1512
+ });
1513
+ req.on("error", reject);
1514
+ });
1983
1515
  }
1516
+ __name(parseMultipart, "parseMultipart");
1984
1517
 
1985
- // ─── createApp Factory ───────────────────────────────────────────────────────
1986
- // Returns an isolated instance of the app state + handlers.
1987
- // Used by the plugin (signaling/index.js) to embed ClawChats logic without
1988
- // spinning up a standalone HTTP server.
1989
-
1990
- export function createApp(config = {}) {
1991
- // ── Config-dependent constants ─────────────────────────────────────────────
1992
- const _DATA_DIR = config.dataDir || path.join(__dirname, 'data');
1993
- const _UPLOADS_DIR = config.uploadsDir || path.join(__dirname, 'uploads');
1994
- const _WORKSPACES_FILE = path.join(_DATA_DIR, 'workspaces.json');
1995
- const _SETTINGS_FILE = path.join(_DATA_DIR, 'settings.json');
1996
- const _INTELLIGENCE_DIR = path.join(_DATA_DIR, 'intelligence');
1997
-
1998
- let _AUTH_TOKEN = config.authToken !== undefined
1999
- ? config.authToken
2000
- : (process.env.CLAWCHATS_AUTH_TOKEN || process.env.SHELLCHAT_AUTH_TOKEN || parseConfigField('authToken') || '');
2001
-
2002
- // Separate token for gateway WS auth (falls back to _AUTH_TOKEN for direct mode)
2003
- const _GATEWAY_TOKEN = config.gatewayToken !== undefined
2004
- ? config.gatewayToken
2005
- : _AUTH_TOKEN;
2006
-
2007
- const _GATEWAY_WS_URL = config.gatewayUrl || discoverGatewayWsUrl();
2008
-
2009
- // ── Mutable singleton state ────────────────────────────────────────────────
2010
- const _dbCache = new Map();
2011
- let _workspacesConfig = null;
2012
- const _debugLogger = new DebugLogger(_DATA_DIR);
2013
-
2014
- const _MEMORY_CONFIG = discoverMemoryConfig();
2015
- const _memoryProvider = createMemoryProvider(_MEMORY_CONFIG);
2016
- _memoryProvider.init().catch(err => console.error('[createApp] Memory provider init error:', err.message));
2017
-
2018
- const _MEMORY_FILES_DIR = path.join(_MEMORY_CONFIG.workspaceDir, 'memory');
2019
-
2020
- // ── Workspace helpers ──────────────────────────────────────────────────────
2021
- function _loadWorkspaces() {
2022
- try {
2023
- return JSON.parse(fs.readFileSync(_WORKSPACES_FILE, 'utf8'));
2024
- } catch {
2025
- const initial = {
2026
- active: 'default',
2027
- workspaces: {
2028
- default: { name: 'default', label: 'Default', createdAt: Date.now() }
2029
- }
2030
- };
2031
- fs.writeFileSync(_WORKSPACES_FILE, JSON.stringify(initial, null, 2));
2032
- return initial;
2033
- }
2034
- }
2035
-
2036
- function _saveWorkspaces(data) {
2037
- fs.writeFileSync(_WORKSPACES_FILE, JSON.stringify(data, null, 2));
2038
- }
2039
-
2040
- function _getWorkspaces() {
2041
- if (!_workspacesConfig) _workspacesConfig = _loadWorkspaces();
2042
- return _workspacesConfig;
2043
- }
2044
-
2045
- function _setWorkspaces(data) {
2046
- _workspacesConfig = data;
2047
- _saveWorkspaces(data);
2048
- }
2049
-
2050
- // ── Database helpers ───────────────────────────────────────────────────────
2051
- function _getDb(workspaceName) {
2052
- if (_dbCache.has(workspaceName)) return _dbCache.get(workspaceName);
2053
- const dbPath = path.join(_DATA_DIR, `${workspaceName}.db`);
2054
- const db = new Database(dbPath);
2055
- db.pragma('journal_mode = WAL');
2056
- db.pragma('foreign_keys = ON');
2057
- migrate(db);
2058
- _dbCache.set(workspaceName, db);
2059
- return db;
2060
- }
2061
-
2062
- function _getActiveDb() {
2063
- return _requestDbStore.getStore() || _getDb(_getWorkspaces().active);
2064
- }
2065
-
2066
- function _closeDb(workspaceName) {
2067
- const db = _dbCache.get(workspaceName);
2068
- if (db) { db.close(); _dbCache.delete(workspaceName); }
2069
- }
2070
-
2071
- function _closeAllDbs() {
2072
- for (const [, db] of _dbCache) db.close();
2073
- _dbCache.clear();
1518
+ // server/controllers/files.js
1519
+ var FileController = class {
1520
+ static {
1521
+ __name(this, "FileController");
2074
1522
  }
2075
-
2076
- function _ensureDirs() {
2077
- fs.mkdirSync(_DATA_DIR, { recursive: true });
2078
- fs.mkdirSync(_UPLOADS_DIR, { recursive: true });
1523
+ constructor({ getActiveDb, getWorkspaces, uploadsDir, intelligenceDir }) {
1524
+ this.getActiveDb = getActiveDb;
1525
+ this.getWorkspaces = getWorkspaces;
1526
+ this.uploadsDir = uploadsDir;
1527
+ this.intelligenceDir = intelligenceDir;
2079
1528
  }
2080
-
2081
- // ── Auth (closes over _AUTH_TOKEN) ─────────────────────────────────────────
2082
- function _checkAuth(req, res) {
2083
- if (!_AUTH_TOKEN) return true;
2084
- const auth = req.headers.authorization;
2085
- if (!auth || !auth.startsWith('Bearer ')) {
2086
- sendError(res, 401, 'Missing or invalid Authorization header');
2087
- return false;
2088
- }
2089
- const token = auth.slice(7);
2090
- if (token !== _AUTH_TOKEN) {
2091
- sendError(res, 401, 'Invalid auth token');
2092
- return false;
1529
+ async upload(req, res, params) {
1530
+ if (!this.getActiveDb().prepare("SELECT id FROM threads WHERE id = ?").get(params.id)) return sendError(res, 404, "Thread not found");
1531
+ const files = await parseMultipart(req);
1532
+ const dir = path10.join(this.uploadsDir, params.id);
1533
+ fs9.mkdirSync(dir, { recursive: true });
1534
+ const savedFiles = [];
1535
+ for (const file of files) {
1536
+ const fileId = uuid();
1537
+ const ext = path10.extname(file.filename) || "";
1538
+ fs9.writeFileSync(path10.join(dir, fileId + ext), file.data);
1539
+ savedFiles.push({ id: fileId, filename: file.filename, path: `/api/uploads/${params.id}/${fileId}${ext}`, mimeType: file.mimeType, size: file.data.length });
2093
1540
  }
2094
- return true;
1541
+ send(res, 200, { files: savedFiles });
2095
1542
  }
2096
-
2097
- // ── GatewayClient (scoped to this factory instance) ───────────────────────
2098
- class _GatewayClient {
2099
- constructor() {
2100
- this.ws = null;
2101
- this.connected = false;
2102
- this.reconnectAttempts = 0;
2103
- this.maxReconnectDelay = 30000;
2104
- this.browserClients = new Map();
2105
- this._externalBroadcastTargets = [];
2106
- this.streamState = new Map();
2107
- this.activityLogs = new Map();
2108
- setInterval(() => {
2109
- const cutoff = Date.now() - 10 * 60 * 1000;
2110
- for (const [runId, log] of this.activityLogs) {
2111
- if (log.startTime < cutoff) {
2112
- if (log._messageId) {
2113
- const db = _getDb(log._parsed?.workspace);
2114
- if (db) {
2115
- db.prepare(`
2116
- UPDATE messages SET content = '[Response interrupted]',
2117
- metadata = json_remove(metadata, '$.pending')
2118
- WHERE id = ? AND content = ''
2119
- `).run(log._messageId);
2120
- }
2121
- }
2122
- this.activityLogs.delete(runId);
2123
- }
2124
- }
2125
- }, 5 * 60 * 1000);
2126
- }
2127
-
2128
- connect() {
2129
- if (this.ws && (this.ws.readyState === WS.CONNECTING || this.ws.readyState === WS.OPEN)) return;
2130
- console.log(`Connecting to gateway at ${_GATEWAY_WS_URL}...`);
2131
- this.ws = new WS(_GATEWAY_WS_URL);
2132
- this.ws.on('open', () => { console.log('Gateway WebSocket connected'); this.reconnectAttempts = 0; });
2133
- this.ws.on('message', (data) => { this.handleGatewayMessage(data.toString()); });
2134
- this.ws.on('close', () => { console.log('Gateway WebSocket closed'); this.connected = false; this.broadcastGatewayStatus(false); this.scheduleReconnect(); });
2135
- this.ws.on('error', (err) => { console.error('Gateway WebSocket error:', err.message); });
2136
- }
2137
-
2138
- handleGatewayMessage(data) {
2139
- _debugLogger.logFrame('GW→SRV', data);
2140
- let msg;
2141
- try { msg = JSON.parse(data); } catch { console.error('Invalid JSON from gateway:', data); return; }
2142
- if (msg.type === 'event' && msg.event === 'connect.challenge') {
2143
- console.log('Received connect.challenge, sending auth...');
2144
- const _nonce = msg.payload?.nonce || '';
2145
- const _identityPath = path.join(_DATA_DIR, 'device-identity.json');
2146
- const _identity = _loadOrCreateDeviceIdentity(_identityPath);
2147
- const _device = _buildDeviceAuth(_identity, {
2148
- clientId: 'gateway-client', clientMode: 'backend', role: 'operator',
2149
- scopes: ['operator.read', 'operator.write', 'operator.admin'], token: _GATEWAY_TOKEN, nonce: _nonce
2150
- });
2151
- 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'], device: _device, auth: { token: _GATEWAY_TOKEN }, caps: ['tool-events'] } }));
2152
- return;
2153
- }
2154
- if (msg.type === 'res' && msg.payload?.type === 'hello-ok') { console.log('Gateway handshake complete'); this.connected = true; this.broadcastGatewayStatus(true); }
2155
- // Chat events are routed through handleChatEvent which decides when/if to
2156
- // broadcast (sentinel hold, flush ordering, suppression). All other events
2157
- // broadcast unconditionally.
2158
- if (msg.type === 'event' && msg.event === 'chat' && msg.payload) {
2159
- this.handleChatEvent(msg.payload, data);
2160
- } else {
2161
- this.broadcastToBrowsers(data);
2162
- }
2163
- if (msg.type === 'event' && msg.event === 'agent' && msg.payload) this.handleAgentEvent(msg.payload);
2164
- }
2165
-
2166
- handleChatEvent(params, rawData) {
2167
- const { sessionKey, state, message, seq } = params;
2168
-
2169
- if (state === 'delta') {
2170
- const parsed = parseSessionKey(sessionKey);
2171
- if (parsed) {
2172
- const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming', held: [] };
2173
- existing.buffer += extractContent(message);
2174
-
2175
- // If the accumulated buffer looks like a NO_REPLY or HEARTBEAT_OK prefix,
2176
- // hold this raw delta — don't forward to browsers yet.
2177
- if (isSilentReplyPrefix(existing.buffer, 'NO_REPLY') || isSilentReplyPrefix(existing.buffer, 'HEARTBEAT_OK')) {
2178
- existing.held = existing.held || [];
2179
- existing.held.push(rawData);
2180
- this.streamState.set(sessionKey, existing);
2181
- return; // suppressed — wait for more chunks
2182
- }
2183
-
2184
- // Buffer diverged from any sentinel — flush held deltas first, then current.
2185
- if (existing.held?.length > 0) {
2186
- for (const h of existing.held) this.broadcastToBrowsers(h);
2187
- existing.held = [];
2188
- }
2189
- this.streamState.set(sessionKey, existing);
2190
- }
2191
- this.broadcastToBrowsers(rawData);
2192
- return;
2193
- }
2194
-
2195
- // Capture stream entry before deletion so we can flush held deltas if needed.
2196
- const streamEntry = this.streamState.get(sessionKey);
2197
- if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
2198
-
2199
- // Title generation sessions: pass through without sentinel filtering.
2200
- if (sessionKey && sessionKey.includes('__clawchats_title_')) {
2201
- if (state === 'final') {
2202
- const content = extractContent(message);
2203
- if (content && this.handleTitleResponse(sessionKey, content)) return;
2204
- } else if (state === 'error' || state === 'aborted') {
2205
- if (this._pendingTitleGens) {
2206
- for (const key of this._pendingTitleGens.keys()) {
2207
- if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; }
2208
- }
2209
- }
2210
- return;
2211
- }
2212
- }
2213
-
2214
- if (state === 'final') {
2215
- const rawContent = extractContent(message);
2216
-
2217
- // Exact NO_REPLY or HEARTBEAT_OK — suppress entirely. Discard any held deltas,
2218
- // don't broadcast the final event, don't save to DB.
2219
- if (isSilentReplyExact(rawContent, 'NO_REPLY') || isSilentReplyExact(rawContent, 'HEARTBEAT_OK')) {
2220
- return;
2221
- }
2222
-
2223
- // If we held deltas (buffer was a sentinel prefix), flush them before the final
2224
- // so the browser has a response element ready for finalization.
2225
- if (streamEntry?.held?.length > 0) {
2226
- for (const h of streamEntry.held) this.broadcastToBrowsers(h);
2227
- }
2228
-
2229
- // Broadcast the final event, then persist.
2230
- this.broadcastToBrowsers(rawData);
2231
- this.saveAssistantMessage(sessionKey, message, seq);
2232
- return;
2233
- }
2234
-
2235
- if (state === 'aborted' || state === 'error') {
2236
- // Discard any held deltas silently — if stream was aborted while holding a
2237
- // NO_REPLY prefix, there's nothing to show. Broadcast the terminal event normally.
2238
- this.broadcastToBrowsers(rawData);
2239
- }
2240
-
2241
- if (state === 'error') this.saveErrorMarker(sessionKey, message);
2242
- }
2243
-
2244
- saveAssistantMessage(sessionKey, message, seq) {
2245
- const parsed = parseSessionKey(sessionKey);
2246
- if (!parsed) return;
2247
- const ws = _getWorkspaces();
2248
- if (!ws.workspaces[parsed.workspace]) { console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`); return; }
2249
- const db = _getDb(parsed.workspace);
2250
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2251
- if (!thread) { console.log(`Ignoring response for deleted thread: ${parsed.threadId}`); return; }
2252
- let content = sanitizeAssistantContent(extractContent(message));
2253
- if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
2254
-
2255
- // Attach media captured by the after_tool_call hook (MEDIA: lines from exec stdout).
2256
- // Classify by extension: images → inline markdown, audio/docs → metadata.attachments.
2257
- // Always clear the stash regardless of whether paths were found (prevents cross-turn leaks).
2258
- const _mediaStash = config.mediaStash;
2259
- const _pendingPaths = _mediaStash?.get(sessionKey) ?? [];
2260
- _mediaStash?.delete(sessionKey);
2261
- const _IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','webp','bmp','svg','ico','avif','tiff']);
2262
- const _AUDIO_EXTS = new Set(['mp3','wav','ogg','m4a','flac','aac','opus','wma']);
2263
- const _pendingAttachments = []; // non-image files → metadata.attachments
2264
- const imagePaths = []; // images → inline markdown (declared outside if for broadcast scope)
2265
- if (_pendingPaths.length > 0) {
2266
- for (const p of _pendingPaths) {
2267
- const ext = (p.split('.').pop() || '').toLowerCase();
2268
- if (_IMAGE_EXTS.has(ext)) {
2269
- imagePaths.push(p);
2270
- } else {
2271
- const name = p.split('/').pop();
2272
- const type = _AUDIO_EXTS.has(ext) ? 'audio' : 'file';
2273
- _pendingAttachments.push({ path: p, name, type });
2274
- }
2275
- }
2276
- if (imagePaths.length > 0) {
2277
- content = content.trimEnd() + '\n\n' + imagePaths.map(p => `![image](${p})`).join('\n');
2278
- }
2279
- console.log(`[clawchats] media-attach: ${imagePaths.length} image(s) inline, ${_pendingAttachments.length} attachment(s) for ${sessionKey}`);
2280
- }
2281
-
2282
- const now = Date.now();
2283
-
2284
- // Check for pending activity message
2285
- const pendingMsg = db.prepare(`
2286
- SELECT id, metadata FROM messages
2287
- WHERE thread_id = ? AND role = 'assistant'
2288
- AND json_extract(metadata, '$.pending') = 1
2289
- ORDER BY timestamp DESC LIMIT 1
2290
- `).get(parsed.threadId);
2291
-
2292
- let messageId;
2293
-
2294
- if (pendingMsg) {
2295
- // Merge final content into existing activity row
2296
- const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
2297
- delete metadata.pending;
2298
- if (metadata.activityLog) {
2299
- const lastAssistantIdx = metadata.activityLog.findLastIndex(s => s.type === 'assistant');
2300
- if (lastAssistantIdx >= 0) metadata.activityLog.splice(lastAssistantIdx, 1);
2301
- metadata.activitySummary = this.generateActivitySummary(metadata.activityLog);
2302
- }
2303
- if (_pendingAttachments.length > 0) {
2304
- metadata.attachments = [...(metadata.attachments || []), ..._pendingAttachments];
2305
- }
2306
- db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?')
2307
- .run(content, JSON.stringify(metadata), now, pendingMsg.id);
2308
- messageId = pendingMsg.id;
2309
- } else {
2310
- // No pending activity — normal INSERT (simple responses, no tools)
2311
- messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
2312
- const newMeta = _pendingAttachments.length > 0 ? JSON.stringify({ attachments: _pendingAttachments }) : null;
2313
- db.prepare(`INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, 'assistant', ?, 'sent', ?, ?, ?) ON CONFLICT(id) DO UPDATE SET content = excluded.content, metadata = COALESCE(excluded.metadata, metadata), timestamp = excluded.timestamp`).run(messageId, parsed.threadId, content, newMeta, now, now);
2314
- }
2315
-
2316
- try {
2317
- db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
2318
- db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
2319
- syncThreadUnreadCount(db, parsed.threadId);
2320
- const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
2321
- const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
2322
- const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
2323
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title || 'Chat', preview, unreadCount, updatedContent: imagePaths.length > 0 ? content : undefined, updatedAttachments: _pendingAttachments.length > 0 ? _pendingAttachments : undefined }));
2324
- const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2325
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace: parsed.workspace, threadId: parsed.threadId, messageId, action: 'new', unreadCount, workspaceUnreadTotal, title: threadInfo?.title || 'Chat', preview, timestamp: now }));
2326
- console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
2327
-
2328
- // Auto-generate AI title upgrade after first assistant response
2329
- const currentTitle = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId)?.title;
2330
- const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
2331
- if (msgCount === 2 || currentTitle === 'New chat') {
2332
- this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
2333
- }
2334
- } catch (e) { console.error(`Failed to save assistant message:`, e.message); }
2335
- }
2336
-
2337
- saveErrorMarker(sessionKey, message) {
2338
- const parsed = parseSessionKey(sessionKey);
2339
- if (!parsed) return;
2340
- const ws = _getWorkspaces();
2341
- if (!ws.workspaces[parsed.workspace]) return;
2342
- const db = _getDb(parsed.workspace);
2343
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2344
- if (!thread) return;
2345
- const errorText = message?.error || message?.content || 'Unknown error';
2346
- const content = `[error] ${errorText}`;
2347
- const now = Date.now();
2348
- const messageId = `gw-error-${parsed.threadId}-${now}`;
1543
+ serveUpload(req, res, params) {
1544
+ const base = path10.join(this.uploadsDir, params.threadId, params.fileId);
1545
+ let resolved = base;
1546
+ if (!fs9.existsSync(resolved)) {
2349
1547
  try {
2350
- 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);
2351
- console.log(`Saved error marker for ${parsed.workspace}/${parsed.threadId}`);
2352
- } catch (e) { console.error(`Failed to save error marker:`, e.message); }
2353
- }
2354
-
2355
- generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
2356
- const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId);
2357
- if (!thread) return;
2358
- const titleKey = `__clawchats_title_${threadId}`;
2359
- if (this._pendingTitleGens?.has(titleKey)) return;
2360
- const firstUserMsg = db.prepare("SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1").get(threadId);
2361
- if (!firstUserMsg?.content) return;
2362
-
2363
- if (!skipHeuristic) {
2364
- const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim() + (firstUserMsg.content.length > 40 ? '...' : '');
2365
- db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
2366
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId, workspace, title: heuristic }));
1548
+ const match = fs9.readdirSync(path10.join(this.uploadsDir, params.threadId)).find((e) => e.startsWith(params.fileId.replace(/\.[^.]+$/, "")));
1549
+ if (match) resolved = path10.join(this.uploadsDir, params.threadId, match);
1550
+ } catch {
2367
1551
  }
2368
-
2369
- const messages = db.prepare('SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6').all(threadId);
2370
- if (messages.length < 2) return;
2371
- const conversation = messages.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content}`).join('\n\n');
2372
- const prompt = `Based on this conversation, generate a concise 3-5 word title. Return ONLY the title text, no quotes, no explanation:\n\n${conversation}\n\nTitle:`;
2373
- const reqId = `title-${threadId}-${Date.now()}`;
2374
- if (!this._pendingTitleGens) this._pendingTitleGens = new Map();
2375
- this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
2376
- setTimeout(() => { if (this._pendingTitleGens?.has(titleKey)) { this._pendingTitleGens.delete(titleKey); console.log(`Title gen timeout for ${threadId} — keeping heuristic title`); } }, 30000);
2377
- this.sendToGateway(JSON.stringify({ type: 'req', id: reqId, method: 'chat.send', params: { sessionKey: titleKey, message: prompt, deliver: false, idempotencyKey: reqId } }));
2378
1552
  }
1553
+ if (!fs9.existsSync(resolved)) return sendError(res, 404, "File not found");
1554
+ const MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".pdf": "application/pdf", ".txt": "text/plain", ".json": "application/json" };
1555
+ const stat = fs9.statSync(resolved);
1556
+ res.writeHead(200, { "Content-Type": MIME[path10.extname(resolved).toLowerCase()] || "application/octet-stream", "Content-Length": stat.size, "Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*" });
1557
+ fs9.createReadStream(resolved).pipe(res);
1558
+ }
1559
+ _intelligencePath(threadId) {
1560
+ return path10.join(this.intelligenceDir, this.getWorkspaces().active, `${threadId}.json`);
1561
+ }
1562
+ getIntelligence(req, res, params) {
1563
+ const filePath = this._intelligencePath(params.id);
1564
+ if (!fs9.existsSync(filePath)) return send(res, 200, { versions: [], currentVersion: -1 });
1565
+ try {
1566
+ return send(res, 200, JSON.parse(fs9.readFileSync(filePath, "utf8")));
1567
+ } catch {
1568
+ return send(res, 200, { versions: [], currentVersion: -1 });
1569
+ }
1570
+ }
1571
+ async saveIntelligence(req, res, params) {
1572
+ const body = await parseBody(req);
1573
+ const filePath = this._intelligencePath(params.id);
1574
+ fs9.mkdirSync(path10.dirname(filePath), { recursive: true });
1575
+ const data = { versions: body.versions || [], currentVersion: body.currentVersion ?? -1, updatedAt: Date.now() };
1576
+ fs9.writeFileSync(filePath, JSON.stringify(data, null, 2));
1577
+ send(res, 200, data);
1578
+ }
1579
+ };
2379
1580
 
2380
- handleTitleResponse(sessionKey, content) {
2381
- if (!this._pendingTitleGens) return false;
2382
- // Gateway may prefix sessionKey (e.g. agent:main:__clawchats_title_xxx)
2383
- let matchKey = null, pending = null;
2384
- for (const [key, val] of this._pendingTitleGens) {
2385
- if (sessionKey === key || sessionKey.includes(key)) { matchKey = key; pending = val; break; }
2386
- }
2387
- if (!pending) return false;
2388
- this._pendingTitleGens.delete(matchKey);
2389
- let title = content.trim().replace(/^["']|["']$/g, '').replace(/^Title:\s*/i, '').replace(/\n.*/s, '').trim();
2390
- if (title.length > 50) title = title.substring(0, 47) + '...';
2391
- if (title.length === 0 || title.length >= 100) return true;
2392
- const db = _getDb(pending.workspace);
2393
- db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, pending.threadId);
2394
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId: pending.threadId, workspace: pending.workspace, title }));
2395
- console.log(`AI title generated for ${pending.threadId}: "${title}"`);
2396
- return true;
1581
+ // server/controllers/memory.js
1582
+ import fs10 from "node:fs";
1583
+ import path11 from "node:path";
1584
+ var MemoryController = class {
1585
+ static {
1586
+ __name(this, "MemoryController");
1587
+ }
1588
+ constructor({ memoryProvider, memoryFilesDir, memoryConfig }) {
1589
+ this.provider = memoryProvider;
1590
+ this.filesDir = memoryFilesDir;
1591
+ this.config = memoryConfig;
1592
+ }
1593
+ async list(req, res, query) {
1594
+ const limit = Math.min(parseInt(query.limit) || 20, 100);
1595
+ try {
1596
+ send(res, 200, await this.provider.list(limit, query.offset || null));
1597
+ } catch (err) {
1598
+ send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message });
2397
1599
  }
2398
-
2399
- handleAgentEvent(payload) {
2400
- const { runId, stream, data, sessionKey } = payload;
2401
- if (!runId) return;
2402
- if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
2403
- const log = this.activityLogs.get(runId);
2404
- if (stream === 'assistant') {
2405
- const text = data?.text || '';
2406
- if (text) {
2407
- let currentSegment = log._currentAssistantSegment;
2408
- if (!currentSegment || currentSegment._sealed) {
2409
- currentSegment = { type: 'assistant', timestamp: Date.now(), text, _sealed: false };
2410
- log._currentAssistantSegment = currentSegment;
2411
- log.steps.push(currentSegment);
2412
- } else { currentSegment.text = text; }
2413
- }
1600
+ }
1601
+ async search(req, res, query) {
1602
+ const q = (query.query || "").toLowerCase().trim();
1603
+ if (!q) return send(res, 400, { error: "Missing query parameter" });
1604
+ try {
1605
+ send(res, 200, await this.provider.search(q));
1606
+ } catch (err) {
1607
+ send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message });
1608
+ }
1609
+ }
1610
+ files(req, res, query) {
1611
+ const q = (query.query || "").toLowerCase().trim();
1612
+ const memories = this._parseFiles();
1613
+ const filtered = q ? memories.filter((m) => m.data.toLowerCase().includes(q) || m.title.toLowerCase().includes(q)) : memories;
1614
+ filtered.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
1615
+ send(res, 200, { memories: filtered });
1616
+ }
1617
+ _parseFiles() {
1618
+ const memories = [];
1619
+ const scanDir = /* @__PURE__ */ __name((dir, prefix = "") => {
1620
+ let entries;
1621
+ try {
1622
+ entries = fs10.readdirSync(dir);
1623
+ } catch {
2414
1624
  return;
2415
1625
  }
2416
- if (stream === 'thinking') {
2417
- const thinkingText = data?.text || '';
2418
- let thinkingStep = log.steps.find(s => s.type === 'thinking');
2419
- if (thinkingStep) { thinkingStep.text = thinkingText; }
2420
- else { log.steps.push({ type: 'thinking', timestamp: Date.now(), text: thinkingText }); }
2421
- this._writeActivityToDb(runId, log);
2422
- const now = Date.now();
2423
- if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
2424
- log._lastThinkingBroadcast = now;
2425
- this._broadcastActivityUpdate(runId, log);
1626
+ for (const entry of entries) {
1627
+ const fullPath = path11.join(dir, entry);
1628
+ const stat = (() => {
1629
+ try {
1630
+ return fs10.statSync(fullPath);
1631
+ } catch {
1632
+ return null;
1633
+ }
1634
+ })();
1635
+ if (!stat) continue;
1636
+ if (stat.isDirectory() && !prefix) {
1637
+ scanDir(fullPath, entry + "/");
1638
+ continue;
2426
1639
  }
2427
- }
2428
- if (stream === 'tool') {
2429
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) { log._currentAssistantSegment._sealed = true; }
2430
- const _args = data?.args;
2431
- const _argsMeta = _args ? (_args.command || _args.path || _args.query || _args.url || Object.values(_args).find(v => typeof v === 'string') || '') : '';
2432
- const step = { type: 'tool', timestamp: Date.now(), name: data?.name || 'unknown', phase: data?.phase || 'start', toolCallId: data?.toolCallId, meta: data?.meta || (_argsMeta ? String(_argsMeta) : undefined), isError: data?.isError || false };
2433
- if (data?.phase === 'result') {
2434
- const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
2435
- if (existing) { existing.phase = 'done'; existing.resultMeta = data?.meta; existing.isError = data?.isError || false; existing.durationMs = Date.now() - existing.timestamp; }
2436
- else { step.phase = 'done'; log.steps.push(step); }
2437
- } else if (data?.phase === 'update') {
2438
- const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
2439
- if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
2440
- } else { log.steps.push(step); }
2441
- this._writeActivityToDb(runId, log);
2442
- this._broadcastActivityUpdate(runId, log);
2443
- }
2444
- if (stream === 'lifecycle') {
2445
- if (data?.phase === 'end' || data?.phase === 'error') {
2446
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
2447
- const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
2448
- if (lastAssistantIdx >= 0) log.steps.splice(lastAssistantIdx, 1);
2449
- this._writeActivityToDb(runId, log);
2450
- this.activityLogs.delete(runId);
2451
- return;
1640
+ if (!entry.endsWith(".md") || !stat.isFile()) continue;
1641
+ const content = fs10.readFileSync(fullPath, "utf8");
1642
+ const basename = entry.replace(/\.md$/, "");
1643
+ const dateMatch = basename.match(/^(\d{4}-\d{2}-\d{2})/);
1644
+ if (prefix) {
1645
+ memories.push({ id: `file:${prefix + basename}`, source: "file", file: prefix + basename, title: basename, data: content.trim(), createdAt: stat.mtime.toISOString() });
1646
+ } else {
1647
+ for (const section of content.split(/^(?=## )/m)) {
1648
+ const trimmed = section.trim();
1649
+ if (!trimmed) continue;
1650
+ const headingMatch = trimmed.match(/^##\s+(.+)/);
1651
+ const heading = headingMatch ? headingMatch[1].trim() : null;
1652
+ const body = headingMatch ? trimmed.slice(trimmed.indexOf("\n") + 1).trim() : trimmed;
1653
+ if (!heading && body.match(/^#\s+/) && body.split("\n").length <= 2) continue;
1654
+ const title = heading || basename;
1655
+ memories.push({ id: `file:${basename}:${title}`, source: "file", file: basename, title, data: heading ? `**${title}**
1656
+ ${body}` : body, createdAt: dateMatch ? `${dateMatch[1]}T00:00:00Z` : stat.mtime.toISOString() });
1657
+ }
2452
1658
  }
2453
1659
  }
1660
+ }, "scanDir");
1661
+ scanDir(this.filesDir);
1662
+ return memories;
1663
+ }
1664
+ async update(req, res, params) {
1665
+ try {
1666
+ const chunks = [];
1667
+ for await (const chunk of req) chunks.push(chunk);
1668
+ const { data } = JSON.parse(Buffer.concat(chunks).toString());
1669
+ if (!(data || "").trim()) return send(res, 400, { error: "Missing data field" });
1670
+ send(res, 200, { ok: true, result: await this.provider.update(params.id, data.trim()) });
1671
+ } catch (err) {
1672
+ send(res, 502, { error: "Failed to update memory", detail: err.message });
2454
1673
  }
2455
-
2456
- _writeActivityToDb(runId, log) {
2457
- writeActivityToDb(_getDb, this.broadcastToBrowsers.bind(this), runId, log);
2458
- }
2459
-
2460
- _broadcastActivityUpdate(runId, log) {
2461
- const parsed = log._parsed;
2462
- if (!parsed || !log._messageId) return;
2463
-
2464
- const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
2465
-
2466
- this.broadcastToBrowsers(JSON.stringify({
2467
- type: 'clawchats',
2468
- event: 'activity-updated',
2469
- workspace: parsed.workspace,
2470
- threadId: parsed.threadId,
2471
- messageId: log._messageId,
2472
- activityLog: cleanSteps,
2473
- activitySummary: this.generateActivitySummary(log.steps)
2474
- }));
2475
- }
2476
-
2477
- generateActivitySummary(steps) {
2478
- return generateActivitySummary(steps);
1674
+ }
1675
+ async delete(req, res, params) {
1676
+ try {
1677
+ send(res, 200, { ok: true, result: await this.provider.delete(params.id) });
1678
+ } catch (err) {
1679
+ send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message });
2479
1680
  }
1681
+ }
1682
+ async status(req, res) {
1683
+ const status = await this.provider.status();
1684
+ send(res, 200, { provider: this.provider.name, host: this.config.host, port: this.config.port, collection: this.config.collection, backend: status, memoryFilesDir: this.filesDir, memoryFilesDirExists: fs10.existsSync(this.filesDir) });
1685
+ }
1686
+ };
2480
1687
 
2481
- addBroadcastTarget(fn) { this._externalBroadcastTargets.push(fn); }
2482
- removeBroadcastTarget(fn) { this._externalBroadcastTargets = this._externalBroadcastTargets.filter(f => f !== fn); }
2483
-
2484
- broadcastToBrowsers(data) {
2485
- _debugLogger.logFrame('SRV→BR', data);
2486
- for (const client of this.browserClients.keys()) {
2487
- if (client.readyState === WS.OPEN) client.send(data);
2488
- }
2489
- for (const fn of this._externalBroadcastTargets) {
2490
- try { fn(data); } catch { /* target disconnected */ }
1688
+ // server/controllers/filesystem.js
1689
+ import fs11 from "node:fs";
1690
+ import path12 from "node:path";
1691
+ import os3 from "node:os";
1692
+ var HOME2 = os3.homedir();
1693
+ var ALLOWED_FILE_DIRS = [HOME2, "/tmp"];
1694
+ function handleServeFile(req, res, query, memoryConfig) {
1695
+ const filePath = query.path;
1696
+ if (!filePath) return sendError(res, 400, "Missing path parameter");
1697
+ const resolved = filePath.startsWith("./") || filePath.startsWith("../") ? path12.resolve(memoryConfig.workspaceDir, filePath) : path12.resolve(filePath);
1698
+ if (!ALLOWED_FILE_DIRS.some((dir) => resolved.startsWith(dir + "/") || resolved === dir)) return sendError(res, 403, "Access denied: path not in allowed directories");
1699
+ if (!fs11.existsSync(resolved) || !fs11.statSync(resolved).isFile()) return sendError(res, 404, "File not found");
1700
+ const MIME = {
1701
+ ".png": "image/png",
1702
+ ".jpg": "image/jpeg",
1703
+ ".jpeg": "image/jpeg",
1704
+ ".gif": "image/gif",
1705
+ ".webp": "image/webp",
1706
+ ".svg": "image/svg+xml",
1707
+ ".pdf": "application/pdf",
1708
+ ".txt": "text/plain",
1709
+ ".json": "application/json",
1710
+ ".md": "text/markdown",
1711
+ ".csv": "text/csv",
1712
+ ".xml": "text/xml",
1713
+ ".html": "text/html",
1714
+ ".css": "text/css",
1715
+ ".js": "text/javascript",
1716
+ ".py": "text/x-python",
1717
+ ".sh": "text/x-shellscript",
1718
+ ".yaml": "text/yaml",
1719
+ ".yml": "text/yaml",
1720
+ ".toml": "text/toml",
1721
+ ".zip": "application/zip",
1722
+ ".gz": "application/gzip",
1723
+ ".tar": "application/x-tar",
1724
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1725
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1726
+ ".mp3": "audio/mpeg",
1727
+ ".wav": "audio/wav",
1728
+ ".ogg": "audio/ogg",
1729
+ ".mp4": "video/mp4",
1730
+ ".webm": "video/webm"
1731
+ };
1732
+ const stat = fs11.statSync(resolved);
1733
+ res.writeHead(200, { "Content-Type": MIME[path12.extname(resolved).toLowerCase()] || "application/octet-stream", "Content-Length": stat.size, "Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*" });
1734
+ fs11.createReadStream(resolved).pipe(res);
1735
+ }
1736
+ __name(handleServeFile, "handleServeFile");
1737
+ function handleWorkspaceList(req, res, query) {
1738
+ const reqPath = query.path || "~/.openclaw/workspace";
1739
+ const depth = parseInt(query.depth || "2", 10);
1740
+ const showHidden = query.hidden === "1" || query.hidden === "true";
1741
+ const resolved = path12.resolve(reqPath.replace(/^~/, HOME2));
1742
+ if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Access denied");
1743
+ if (!fs11.existsSync(resolved)) return sendError(res, 404, "Path not found");
1744
+ const files = [{ path: resolved + "/", type: "dir", name: path12.basename(resolved), size: 0 }];
1745
+ const walk = /* @__PURE__ */ __name((dir, d) => {
1746
+ if (d > depth) return;
1747
+ try {
1748
+ for (const entry of fs11.readdirSync(dir, { withFileTypes: true })) {
1749
+ if (entry.name.startsWith(".") && entry.name !== ".openclaw" && !showHidden) continue;
1750
+ if (entry.name === "node_modules") continue;
1751
+ const fullPath = path12.join(dir, entry.name);
1752
+ const isDir = entry.isDirectory();
1753
+ files.push({ path: fullPath + (isDir ? "/" : ""), type: isDir ? "dir" : "file", name: entry.name, size: isDir ? 0 : (() => {
1754
+ try {
1755
+ return fs11.statSync(fullPath).size;
1756
+ } catch {
1757
+ return 0;
1758
+ }
1759
+ })() });
1760
+ if (isDir) walk(fullPath, d + 1);
2491
1761
  }
1762
+ } catch {
2492
1763
  }
2493
-
2494
- broadcastGatewayStatus(connected) {
2495
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected }));
1764
+ }, "walk");
1765
+ walk(resolved, 1);
1766
+ send(res, 200, { files, cwd: resolved });
1767
+ }
1768
+ __name(handleWorkspaceList, "handleWorkspaceList");
1769
+ function handleWorkspaceFileRead(req, res, query) {
1770
+ const filePath = query.path;
1771
+ if (!filePath) return sendError(res, 400, "Missing path parameter");
1772
+ const resolved = path12.resolve(filePath.replace(/^~/, HOME2));
1773
+ if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Access denied");
1774
+ if (!fs11.existsSync(resolved) || !fs11.statSync(resolved).isFile()) return sendError(res, 404, "File not found");
1775
+ const stat = fs11.statSync(resolved);
1776
+ const ext = path12.extname(resolved).toLowerCase().slice(1);
1777
+ const binaryMime = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", pdf: "application/pdf", mp3: "audio/mpeg", mp4: "video/mp4", wav: "audio/wav", ogg: "audio/ogg", webm: "video/webm" };
1778
+ const mime = binaryMime[ext];
1779
+ if (mime) {
1780
+ if (stat.size > 20 * 1024 * 1024) return sendError(res, 413, "File too large (max 20MB)");
1781
+ res.writeHead(200, { "Content-Type": mime, "Cache-Control": "private, max-age=60" });
1782
+ res.end(fs11.readFileSync(resolved));
1783
+ } else {
1784
+ if (stat.size > 1024 * 1024) return sendError(res, 413, "File too large (max 1MB)");
1785
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
1786
+ res.end(fs11.readFileSync(resolved, "utf8"));
1787
+ }
1788
+ }
1789
+ __name(handleWorkspaceFileRead, "handleWorkspaceFileRead");
1790
+ async function handleWorkspaceFileWrite(req, res, query) {
1791
+ const filePath = query.path;
1792
+ if (!filePath) return sendError(res, 400, "Missing path parameter");
1793
+ const resolved = path12.resolve(filePath.replace(/^~/, HOME2));
1794
+ if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Can only write to workspace directory");
1795
+ const chunks = [];
1796
+ for await (const chunk of req) chunks.push(chunk);
1797
+ const dir = path12.dirname(resolved);
1798
+ if (!fs11.existsSync(dir)) fs11.mkdirSync(dir, { recursive: true });
1799
+ fs11.writeFileSync(resolved, Buffer.concat(chunks).toString("utf8"), "utf8");
1800
+ send(res, 200, { ok: true });
1801
+ }
1802
+ __name(handleWorkspaceFileWrite, "handleWorkspaceFileWrite");
1803
+ function handleWorkspaceFileDelete(req, res, query) {
1804
+ const filePath = query.path;
1805
+ if (!filePath) return sendError(res, 400, "Missing path parameter");
1806
+ const resolved = path12.resolve(filePath.replace(/^~/, HOME2));
1807
+ if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Access denied");
1808
+ if (!fs11.existsSync(resolved)) return sendError(res, 404, "Path not found");
1809
+ try {
1810
+ const stat = fs11.statSync(resolved);
1811
+ if (stat.isDirectory()) {
1812
+ fs11.rmSync(resolved, { recursive: true, force: true });
1813
+ send(res, 200, { ok: true, type: "dir" });
1814
+ } else {
1815
+ fs11.unlinkSync(resolved);
1816
+ send(res, 200, { ok: true, type: "file" });
2496
1817
  }
1818
+ } catch (err) {
1819
+ sendError(res, 500, "Delete failed: " + err.message);
1820
+ }
1821
+ }
1822
+ __name(handleWorkspaceFileDelete, "handleWorkspaceFileDelete");
1823
+ async function handleWorkspaceUpload(req, res, query) {
1824
+ const targetDir = query.path;
1825
+ if (!targetDir) return sendError(res, 400, "Missing path parameter");
1826
+ const resolved = path12.resolve(targetDir.replace(/^~/, HOME2));
1827
+ if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Access denied");
1828
+ if (!fs11.existsSync(resolved) || !fs11.statSync(resolved).isDirectory()) return sendError(res, 404, "Target directory not found");
1829
+ if (!(req.headers["content-type"] || "").includes("multipart/form-data")) return sendError(res, 400, "Expected multipart/form-data");
1830
+ let files;
1831
+ try {
1832
+ files = await parseMultipart(req);
1833
+ } catch (err) {
1834
+ return sendError(res, 400, "Invalid multipart data: " + err.message);
1835
+ }
1836
+ const uploaded = [];
1837
+ for (const { filename, data } of files) {
1838
+ if (!filename || !data.length) continue;
1839
+ const safeName = path12.basename(filename);
1840
+ let finalPath = path12.join(resolved, safeName);
1841
+ let counter = 1;
1842
+ while (fs11.existsSync(finalPath)) {
1843
+ const ext = path12.extname(safeName);
1844
+ finalPath = path12.join(resolved, `${path12.basename(safeName, ext)} (${counter++})${ext}`);
1845
+ }
1846
+ fs11.writeFileSync(finalPath, data);
1847
+ uploaded.push({ name: path12.basename(finalPath), size: data.length });
1848
+ }
1849
+ send(res, 200, { ok: true, uploaded });
1850
+ }
1851
+ __name(handleWorkspaceUpload, "handleWorkspaceUpload");
2497
1852
 
2498
- sendToGateway(data) {
2499
- _debugLogger.logFrame('SRV→GW', data);
2500
- if (this.ws && this.ws.readyState === WS.OPEN) this.ws.send(data);
2501
- else console.error('Cannot send to gateway: not connected');
1853
+ // server/controllers/transcribe.js
1854
+ import fs12 from "node:fs";
1855
+ import path13 from "node:path";
1856
+ import os4 from "node:os";
1857
+ async function handleTranscribe(req, res) {
1858
+ try {
1859
+ const chunks = [];
1860
+ for await (const chunk of req) chunks.push(chunk);
1861
+ const audioBuffer = Buffer.concat(chunks);
1862
+ if (audioBuffer.length === 0) return send(res, 400, { error: "No audio data" });
1863
+ if (audioBuffer.length > 25 * 1024 * 1024) return send(res, 400, { error: "Audio too large (max 25MB)" });
1864
+ let apiKey;
1865
+ try {
1866
+ const ocConfig = JSON.parse(fs12.readFileSync(path13.join(os4.homedir(), ".openclaw", "openclaw.json"), "utf8"));
1867
+ apiKey = ocConfig?.skills?.entries?.["openai-whisper-api"]?.apiKey;
1868
+ } catch {
2502
1869
  }
2503
-
2504
- scheduleReconnect() {
2505
- const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
2506
- this.reconnectAttempts++;
2507
- console.log(`Reconnecting to gateway in ${delay}ms (attempt ${this.reconnectAttempts})...`);
2508
- setTimeout(() => this.connect(), delay);
1870
+ if (!apiKey) apiKey = process.env.OPENAI_API_KEY;
1871
+ if (!apiKey) return send(res, 500, { error: "No OpenAI API key configured" });
1872
+ const contentType = req.headers["content-type"] || "audio/webm";
1873
+ const ext = contentType.includes("wav") ? "wav" : contentType.includes("mp4") || contentType.includes("m4a") ? "m4a" : contentType.includes("ogg") ? "ogg" : "webm";
1874
+ const boundary = "----WhisperBoundary" + Date.now();
1875
+ const body = Buffer.concat([
1876
+ `--${boundary}\r
1877
+ Content-Disposition: form-data; name="file"; filename="audio.${ext}"\r
1878
+ Content-Type: ${contentType}\r
1879
+ \r
1880
+ `,
1881
+ audioBuffer,
1882
+ `\r
1883
+ --${boundary}\r
1884
+ Content-Disposition: form-data; name="model"\r
1885
+ \r
1886
+ whisper-1\r
1887
+ `,
1888
+ `--${boundary}\r
1889
+ Content-Disposition: form-data; name="response_format"\r
1890
+ \r
1891
+ json\r
1892
+ `,
1893
+ `--${boundary}--\r
1894
+ `
1895
+ ].map((p) => typeof p === "string" ? Buffer.from(p) : p));
1896
+ const resp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
1897
+ method: "POST",
1898
+ headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": `multipart/form-data; boundary=${boundary}` },
1899
+ body
1900
+ });
1901
+ if (!resp.ok) {
1902
+ console.error("Whisper API error:", resp.status, await resp.text());
1903
+ return send(res, 502, { error: `Whisper API error: ${resp.status}` });
2509
1904
  }
2510
-
2511
- addBrowserClient(ws) {
2512
- this.browserClients.set(ws, { activeWorkspace: null, activeThreadId: null });
2513
- if (ws.readyState === WS.OPEN) {
2514
- ws.send(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected: this.connected }));
2515
- const streams = [];
2516
- for (const [sessionKey, state] of this.streamState.entries()) {
2517
- // Skip sessions in sentinel-hold state — their buffer contains a NO_REPLY/HEARTBEAT_OK
2518
- // prefix that should not be forwarded to reconnecting browsers.
2519
- if (state.state === 'streaming' && !(state.held?.length > 0)) {
2520
- streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
2521
- }
2522
- }
2523
- if (streams.length > 0) ws.send(JSON.stringify({ type: 'clawchats', event: 'stream-sync', streams }));
1905
+ const result = await resp.json();
1906
+ return send(res, 200, { text: result.text || "" });
1907
+ } catch (err) {
1908
+ console.error("Transcribe error:", err);
1909
+ return send(res, 500, { error: err.message });
1910
+ }
1911
+ }
1912
+ __name(handleTranscribe, "handleTranscribe");
1913
+
1914
+ // server/index.js
1915
+ var HOME3 = os5.homedir();
1916
+ var PORT = parseInt(process.env.PORT || "3001", 10);
1917
+ var __dirname2 = path14.dirname(fileURLToPath2(import.meta.url));
1918
+ var PLUGIN_DIR = path14.resolve(__dirname2, "..");
1919
+ function createApp(config = {}) {
1920
+ const DATA_DIR = config.dataDir || path14.join(PLUGIN_DIR, "data");
1921
+ const UPLOADS_DIR = config.uploadsDir || path14.join(PLUGIN_DIR, "uploads");
1922
+ const WORKSPACES_FILE = path14.join(DATA_DIR, "workspaces.json");
1923
+ const SETTINGS_FILE = path14.join(DATA_DIR, "settings.json");
1924
+ const INTELLIGENCE_DIR = path14.join(DATA_DIR, "intelligence");
1925
+ const authToken = config.authToken !== void 0 ? config.authToken : AUTH_TOKEN;
1926
+ const gatewayToken = config.gatewayToken !== void 0 ? config.gatewayToken : authToken;
1927
+ const gatewayUrl = config.gatewayUrl || GATEWAY_WS_URL;
1928
+ fs13.mkdirSync(DATA_DIR, { recursive: true });
1929
+ fs13.mkdirSync(UPLOADS_DIR, { recursive: true });
1930
+ const dbCache = /* @__PURE__ */ new Map();
1931
+ function getDb(workspaceName) {
1932
+ if (dbCache.has(workspaceName)) return dbCache.get(workspaceName);
1933
+ const db = new Database(path14.join(DATA_DIR, `${workspaceName}.db`));
1934
+ db.exec("PRAGMA journal_mode = WAL");
1935
+ db.exec("PRAGMA foreign_keys = ON");
1936
+ migrate(db);
1937
+ dbCache.set(workspaceName, db);
1938
+ return db;
1939
+ }
1940
+ __name(getDb, "getDb");
1941
+ function getActiveDb() {
1942
+ return requestDbStore.getStore() || getDb(getWorkspaces().active);
1943
+ }
1944
+ __name(getActiveDb, "getActiveDb");
1945
+ function closeDb(name) {
1946
+ const db = dbCache.get(name);
1947
+ if (db) {
1948
+ db.close();
1949
+ dbCache.delete(name);
1950
+ }
1951
+ }
1952
+ __name(closeDb, "closeDb");
1953
+ function closeAll() {
1954
+ for (const db of dbCache.values()) db.close();
1955
+ dbCache.clear();
1956
+ globalDbCache.close?.();
1957
+ }
1958
+ __name(closeAll, "closeAll");
1959
+ let _globalDb = null;
1960
+ const globalDbCache = {
1961
+ get() {
1962
+ if (_globalDb) return _globalDb;
1963
+ _globalDb = new Database(path14.join(DATA_DIR, "global.db"));
1964
+ _globalDb.exec("PRAGMA journal_mode = WAL");
1965
+ _globalDb.exec(`CREATE TABLE IF NOT EXISTS custom_emojis (name TEXT NOT NULL, pack TEXT NOT NULL DEFAULT 'slackmojis', url TEXT NOT NULL, mime_type TEXT, created_at INTEGER DEFAULT (strftime('%s','now')), PRIMARY KEY (name, pack))`);
1966
+ return _globalDb;
1967
+ },
1968
+ close() {
1969
+ if (_globalDb) {
1970
+ _globalDb.close();
1971
+ _globalDb = null;
2524
1972
  }
2525
1973
  }
2526
-
2527
- removeBrowserClient(ws) { this.browserClients.delete(ws); }
2528
-
2529
- setActiveThread(ws, workspace, threadId) {
2530
- const client = ws ? this.browserClients.get(ws) : null;
2531
- if (client) { client.activeWorkspace = workspace; client.activeThreadId = threadId; }
2532
- if (workspace && threadId) {
2533
- try {
2534
- const wsData = _getWorkspaces();
2535
- if (!wsData.workspaces[workspace]) return;
2536
- const db = _getDb(workspace);
2537
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(threadId);
2538
- if (!thread) return;
2539
- const deleted = db.prepare('DELETE FROM unread_messages WHERE thread_id = ?').run(threadId);
2540
- if (deleted.changes > 0) {
2541
- syncThreadUnreadCount(db, threadId);
2542
- const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2543
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace, threadId, action: 'clear', unreadCount: 0, workspaceUnreadTotal, timestamp: Date.now() }));
2544
- }
2545
- } catch (e) { console.error('Failed to auto-clear unreads on active-thread:', e.message); }
1974
+ };
1975
+ let workspacesConfig = null;
1976
+ function getWorkspaces() {
1977
+ if (!workspacesConfig) {
1978
+ try {
1979
+ workspacesConfig = JSON.parse(fs13.readFileSync(WORKSPACES_FILE, "utf8"));
1980
+ } catch {
1981
+ workspacesConfig = { active: "default", workspaces: { default: { name: "default", label: "Default", createdAt: Date.now() } } };
1982
+ fs13.writeFileSync(WORKSPACES_FILE, JSON.stringify(workspacesConfig, null, 2));
2546
1983
  }
2547
1984
  }
2548
- }
2549
-
2550
- const _gatewayClient = new _GatewayClient();
2551
-
2552
- const _broadcast = (msg) => _gatewayClient.broadcastToBrowsers(msg);
2553
-
2554
- function _handleGetSettings(req, res) {
1985
+ return workspacesConfig;
1986
+ }
1987
+ __name(getWorkspaces, "getWorkspaces");
1988
+ function setWorkspaces(data) {
1989
+ workspacesConfig = data;
1990
+ fs13.writeFileSync(WORKSPACES_FILE, JSON.stringify(data, null, 2));
1991
+ }
1992
+ __name(setWorkspaces, "setWorkspaces");
1993
+ const debugLogger = new DebugLogger(DATA_DIR);
1994
+ const mediaStash = /* @__PURE__ */ new Map();
1995
+ const memoryConfig = discoverMemoryConfig();
1996
+ const memoryProvider = createMemoryProvider(memoryConfig);
1997
+ memoryProvider.init().catch((err) => console.error("[createApp] Memory provider init error:", err.message));
1998
+ const MEMORY_FILES_DIR = path14.join(memoryConfig.workspaceDir, "memory");
1999
+ const gatewayClient = new GatewayClient({ getDb, getWorkspaces, dataDir: DATA_DIR, debugLogger, gatewayWsUrl: gatewayUrl, authToken: gatewayToken, mediaStash });
2000
+ const broadcast = /* @__PURE__ */ __name((msg) => gatewayClient.broadcastToBrowsers(msg), "broadcast");
2001
+ const workspaces = new WorkspaceController({ getDb, closeDb, getWorkspaces, setWorkspaces, dataDir: DATA_DIR, broadcast });
2002
+ const threads = new ThreadController({ getActiveDb, getWorkspaces, uploadsDir: UPLOADS_DIR, broadcast });
2003
+ const messages = new MessageController({ getActiveDb, getWorkspaces, broadcast });
2004
+ const files = new FileController({ getActiveDb, getWorkspaces, uploadsDir: UPLOADS_DIR, intelligenceDir: INTELLIGENCE_DIR });
2005
+ const memory = new MemoryController({ memoryProvider, memoryFilesDir: MEMORY_FILES_DIR, memoryConfig });
2006
+ function handleGetSettings(req, res) {
2555
2007
  try {
2556
- const data = fs.existsSync(_SETTINGS_FILE) ? JSON.parse(fs.readFileSync(_SETTINGS_FILE, 'utf8')) : {};
2557
- return send(res, 200, data);
2558
- } catch { return send(res, 200, {}); }
2008
+ send(res, 200, fs13.existsSync(SETTINGS_FILE) ? JSON.parse(fs13.readFileSync(SETTINGS_FILE, "utf8")) : {});
2009
+ } catch {
2010
+ send(res, 200, {});
2011
+ }
2559
2012
  }
2560
-
2561
- async function _handleSaveSettings(req, res) {
2013
+ __name(handleGetSettings, "handleGetSettings");
2014
+ async function handleSaveSettings(req, res) {
2562
2015
  const body = await parseBody(req);
2563
- fs.mkdirSync(path.dirname(_SETTINGS_FILE), { recursive: true });
2564
- fs.writeFileSync(_SETTINGS_FILE, JSON.stringify(body, null, 2));
2565
- return send(res, 200, { ok: true });
2016
+ fs13.mkdirSync(path14.dirname(SETTINGS_FILE), { recursive: true });
2017
+ fs13.writeFileSync(SETTINGS_FILE, JSON.stringify(body, null, 2));
2018
+ send(res, 200, { ok: true });
2566
2019
  }
2567
-
2568
- const workspaces = new WorkspaceController({ getDb: _getDb, closeDb: _closeDb, getWorkspaces: _getWorkspaces, setWorkspaces: _setWorkspaces, dataDir: _DATA_DIR, broadcast: _broadcast });
2569
- const threads = new ThreadController({ getActiveDb: _getActiveDb, getWorkspaces: _getWorkspaces, uploadsDir: _UPLOADS_DIR, broadcast: _broadcast });
2570
- const messages = new MessageController({ getActiveDb: _getActiveDb, getWorkspaces: _getWorkspaces, broadcast: _broadcast });
2571
- const files = new FileController({ getActiveDb: _getActiveDb, getWorkspaces: _getWorkspaces, uploadsDir: _UPLOADS_DIR, intelligenceDir: _INTELLIGENCE_DIR });
2572
- const memory = new MemoryController({ memoryProvider: _memoryProvider, memoryFilesDir: _MEMORY_FILES_DIR, memoryConfig: _MEMORY_CONFIG });
2573
-
2574
- // ── handleRequest (scoped to factory state) ────────────────────────────────
2575
- async function _handleRequest(req, res) {
2576
- const _wsName = req.headers?.['x-workspace'];
2577
- const _requestDb = _wsName ? _getDb(_wsName) : _getActiveDb();
2578
- return _requestDbStore.run(_requestDb, () => _handleRequestImplFactory(req, res));
2020
+ __name(handleSaveSettings, "handleSaveSettings");
2021
+ function checkAuth(req, res) {
2022
+ if (!authToken) return true;
2023
+ const auth = req.headers.authorization;
2024
+ if (!auth?.startsWith("Bearer ")) {
2025
+ sendError(res, 401, "Missing or invalid Authorization header");
2026
+ return false;
2027
+ }
2028
+ if (auth.slice(7) !== authToken) {
2029
+ sendError(res, 401, "Invalid auth token");
2030
+ return false;
2031
+ }
2032
+ return true;
2579
2033
  }
2580
-
2581
- async function _handleRequestImplFactory(req, res) {
2582
- const [urlPath, queryString] = (req.url || '/').split('?');
2034
+ __name(checkAuth, "checkAuth");
2035
+ async function handleRequest(req, res) {
2036
+ const wsName = req.headers?.["x-workspace"];
2037
+ const db = wsName ? getDb(wsName) : getActiveDb();
2038
+ return requestDbStore.run(db, () => route(req, res));
2039
+ }
2040
+ __name(handleRequest, "handleRequest");
2041
+ async function route(req, res) {
2042
+ const [urlPath, queryString] = (req.url || "/").split("?");
2583
2043
  const query = {};
2584
- if (queryString) {
2585
- for (const pair of queryString.split('&')) {
2586
- const [k, v] = pair.split('=');
2587
- if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '');
2588
- }
2044
+ if (queryString) for (const pair of queryString.split("&")) {
2045
+ const [k, v] = pair.split("=");
2046
+ if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || "");
2589
2047
  }
2590
2048
  const method = req.method;
2591
-
2592
- if (method === 'OPTIONS') { setCors(res); res.writeHead(204); return res.end(); }
2593
-
2594
- if (method === 'GET' && !urlPath.startsWith('/api/')) {
2595
- 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' };
2596
- const fileName = STATIC_FILES[urlPath];
2597
- const isIcon = urlPath.startsWith('/icons/');
2598
- const isLib = urlPath.startsWith('/lib/');
2599
- const isFrontend = urlPath.startsWith('/frontend/');
2600
- const isEmoji = urlPath.startsWith('/emoji/');
2601
- const isConfig = urlPath === '/config.js';
2602
- const staticPath = fileName ? path.join(__dirname, fileName) : (isIcon || isLib || isFrontend || isEmoji || isConfig) ? path.join(__dirname, urlPath.slice(1)) : null;
2603
- if (staticPath && fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
2604
- const ext = path.extname(staticPath).toLowerCase();
2605
- 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', '.gif': 'image/gif', '.webp': 'image/webp' };
2606
- const ct = mimeMap[ext] || 'application/octet-stream';
2607
- const stat = fs.statSync(staticPath);
2608
- res.writeHead(200, { 'Content-Type': ct, 'Content-Length': stat.size, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600' });
2609
- return fs.createReadStream(staticPath).pipe(res);
2049
+ let p;
2050
+ if (method === "OPTIONS") {
2051
+ setCors(res);
2052
+ res.writeHead(204);
2053
+ return res.end();
2054
+ }
2055
+ if (method === "GET" && !urlPath.startsWith("/api/")) {
2056
+ const STATIC = { "/": "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" };
2057
+ const fileName = STATIC[urlPath];
2058
+ const isAllowed = fileName || urlPath.startsWith("/icons/") || urlPath.startsWith("/lib/") || urlPath.startsWith("/frontend/") || urlPath.startsWith("/emoji/") || urlPath === "/config.js";
2059
+ if (isAllowed) {
2060
+ const staticPath = path14.join(PLUGIN_DIR, fileName || urlPath.slice(1));
2061
+ if (fs13.existsSync(staticPath) && fs13.statSync(staticPath).isFile()) {
2062
+ const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".ico": "image/x-icon", ".png": "image/png", ".svg": "image/svg+xml", ".gif": "image/gif", ".webp": "image/webp" };
2063
+ const ext = path14.extname(staticPath).toLowerCase();
2064
+ const stat = fs13.statSync(staticPath);
2065
+ res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Content-Length": stat.size, "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=3600" });
2066
+ return fs13.createReadStream(staticPath).pipe(res);
2067
+ }
2610
2068
  }
2611
2069
  }
2612
-
2613
- let p;
2614
- if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) return files.serveUpload(req, res, p);
2615
-
2616
- // Custom emoji listing (no auth)
2617
- if (method === 'GET' && urlPath === '/api/emoji') {
2070
+ if (p = matchRoute(method, urlPath, "GET /api/uploads/:threadId/:fileId")) return files.serveUpload(req, res, p);
2071
+ if (method === "GET" && urlPath === "/api/emoji") {
2618
2072
  try {
2619
- const db = getGlobalDb(_DATA_DIR);
2620
- const rows = db.prepare('SELECT name, pack, url, mime_type FROM custom_emojis ORDER BY created_at DESC').all();
2621
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
2073
+ const rows = globalDbCache.get().prepare("SELECT name, pack, url, mime_type FROM custom_emojis ORDER BY created_at DESC").all();
2074
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=300" });
2622
2075
  return res.end(JSON.stringify(rows));
2623
- } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
2076
+ } catch (e) {
2077
+ res.writeHead(500, { "Content-Type": "application/json" });
2078
+ return res.end(JSON.stringify({ error: e.message }));
2079
+ }
2624
2080
  }
2625
-
2626
- // Search slackmojis.com (no auth, proxied)
2627
- if (method === 'GET' && urlPath === '/api/emoji/search') {
2628
- const q = query.q || '';
2629
- if (!q) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing ?q=' })); }
2081
+ if (method === "GET" && urlPath === "/api/emoji/search") {
2082
+ const q = query.q || "";
2083
+ if (!q) {
2084
+ res.writeHead(400, { "Content-Type": "application/json" });
2085
+ return res.end(JSON.stringify({ error: "Missing ?q=" }));
2086
+ }
2630
2087
  try {
2631
- const https = await import('https');
2088
+ const https = await import("https");
2632
2089
  const html = await new Promise((resolve, reject) => {
2633
2090
  https.default.get(`https://slackmojis.com/emojis/search?query=${encodeURIComponent(q)}`, (resp) => {
2634
- let body = ''; resp.on('data', c => body += c); resp.on('end', () => resolve(body));
2635
- }).on('error', reject);
2091
+ let body = "";
2092
+ resp.on("data", (c) => body += c);
2093
+ resp.on("end", () => resolve(body));
2094
+ }).on("error", reject);
2636
2095
  });
2637
2096
  const results = [];
2638
2097
  const regex = /data-emoji-id-name="([^"]+)"[^>]*href="([^"]+)"[\s\S]*?<img[^>]*src="([^"]+)"/g;
2639
2098
  let match;
2640
- while ((match = regex.exec(html)) !== null && results.length < 50) {
2641
- const name = match[1].replace(/^\d+-/, '');
2642
- results.push({ name, image_url: match[3], download_url: `https://slackmojis.com${match[2]}` });
2643
- }
2644
- res.writeHead(200, { 'Content-Type': 'application/json' });
2099
+ while ((match = regex.exec(html)) !== null && results.length < 50) results.push({ name: match[1].replace(/^\d+-/, ""), image_url: match[3], download_url: `https://slackmojis.com${match[2]}` });
2100
+ res.writeHead(200, { "Content-Type": "application/json" });
2645
2101
  return res.end(JSON.stringify(results));
2646
- } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
2102
+ } catch (e) {
2103
+ res.writeHead(500, { "Content-Type": "application/json" });
2104
+ return res.end(JSON.stringify({ error: e.message }));
2105
+ }
2647
2106
  }
2648
-
2649
- if (!_checkAuth(req, res)) return;
2650
-
2651
- // Add custom emoji (auth required)
2652
- if (method === 'POST' && urlPath === '/api/emoji/add') {
2653
- try {
2107
+ if (!checkAuth(req, res)) return;
2108
+ try {
2109
+ if (method === "POST" && urlPath === "/api/emoji/add") {
2654
2110
  const { url, name, pack } = await parseBody(req);
2655
- if (!url || !name) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing url or name' })); }
2656
- const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2657
- const targetPack = pack || 'slackmojis';
2658
- const urlLower = url.split('?')[0].toLowerCase();
2659
- let mimeType = 'image/png';
2660
- if (urlLower.endsWith('.gif')) mimeType = 'image/gif';
2661
- else if (urlLower.endsWith('.webp')) mimeType = 'image/webp';
2662
- else if (urlLower.endsWith('.jpg') || urlLower.endsWith('.jpeg')) mimeType = 'image/jpeg';
2663
- const db = getGlobalDb(_DATA_DIR);
2664
- db.prepare('INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)').run(safeName, targetPack, url, mimeType);
2665
- res.writeHead(200, { 'Content-Type': 'application/json' });
2111
+ if (!url || !name) {
2112
+ res.writeHead(400, { "Content-Type": "application/json" });
2113
+ return res.end(JSON.stringify({ error: "Missing url or name" }));
2114
+ }
2115
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
2116
+ const targetPack = pack || "slackmojis";
2117
+ const mimeType = url.toLowerCase().endsWith(".gif") ? "image/gif" : url.toLowerCase().endsWith(".webp") ? "image/webp" : url.toLowerCase().match(/\.jpe?g/) ? "image/jpeg" : "image/png";
2118
+ globalDbCache.get().prepare("INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)").run(safeName, targetPack, url, mimeType);
2119
+ res.writeHead(200, { "Content-Type": "application/json" });
2666
2120
  return res.end(JSON.stringify({ name: safeName, pack: targetPack, url, mime_type: mimeType }));
2667
- } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
2668
- }
2669
-
2670
- // Delete custom emoji (auth required)
2671
- if (method === 'DELETE' && urlPath === '/api/emoji') {
2672
- try {
2121
+ }
2122
+ if (method === "DELETE" && urlPath === "/api/emoji") {
2673
2123
  const { name, pack } = await parseBody(req);
2674
- if (!name || !pack) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing name or pack' })); }
2675
- const db = getGlobalDb(_DATA_DIR);
2676
- db.prepare('DELETE FROM custom_emojis WHERE name = ? AND pack = ?').run(name, pack);
2677
- res.writeHead(200, { 'Content-Type': 'application/json' });
2124
+ if (!name || !pack) {
2125
+ res.writeHead(400, { "Content-Type": "application/json" });
2126
+ return res.end(JSON.stringify({ error: "Missing name or pack" }));
2127
+ }
2128
+ globalDbCache.get().prepare("DELETE FROM custom_emojis WHERE name = ? AND pack = ?").run(name, pack);
2129
+ res.writeHead(200, { "Content-Type": "application/json" });
2678
2130
  return res.end(JSON.stringify({ ok: true }));
2679
- } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
2680
- }
2681
-
2682
- try {
2683
- if (method === 'GET' && urlPath === '/api/file') return handleServeFile(req, res, query);
2684
- if (method === 'GET' && urlPath === '/api/workspace') return handleWorkspaceList(req, res, query);
2685
- if (method === 'GET' && urlPath === '/api/workspace/file') return handleWorkspaceFileRead(req, res, query);
2686
- if (method === 'PUT' && urlPath === '/api/workspace/file') return await handleWorkspaceFileWrite(req, res, query);
2687
- if (method === 'DELETE' && urlPath === '/api/workspace/file') return handleWorkspaceFileDelete(req, res, query);
2688
- if (method === 'POST' && urlPath === '/api/workspace/upload') return await handleWorkspaceUpload(req, res, query);
2689
- if (method === 'GET' && urlPath === '/api/memory/status') return await memory.status(req, res);
2690
- if (method === 'GET' && urlPath === '/api/memory/list') return await memory.list(req, res, query);
2691
- if (method === 'GET' && urlPath === '/api/memory/search') return await memory.search(req, res, query);
2692
- if (method === 'GET' && urlPath === '/api/memory/files') return memory.files(req, res, query);
2693
- if ((p = matchRoute(method, urlPath, 'PUT /api/memory/:id'))) return await memory.update(req, res, p);
2694
- if ((p = matchRoute(method, urlPath, 'DELETE /api/memory/:id'))) return await memory.delete(req, res, p);
2695
- if (method === 'GET' && urlPath === '/api/settings') return _handleGetSettings(req, res);
2696
- if (method === 'PUT' && urlPath === '/api/settings') return await _handleSaveSettings(req, res);
2697
- if (method === 'POST' && urlPath === '/api/transcribe') return await handleTranscribe(req, res);
2698
- if (method === 'GET' && urlPath === '/api/agents') {
2131
+ }
2132
+ if (method === "GET" && urlPath === "/api/file") return handleServeFile(req, res, query, memoryConfig);
2133
+ if (method === "GET" && urlPath === "/api/workspace") return handleWorkspaceList(req, res, query);
2134
+ if (method === "GET" && urlPath === "/api/workspace/file") return handleWorkspaceFileRead(req, res, query);
2135
+ if (method === "PUT" && urlPath === "/api/workspace/file") return await handleWorkspaceFileWrite(req, res, query);
2136
+ if (method === "DELETE" && urlPath === "/api/workspace/file") return handleWorkspaceFileDelete(req, res, query);
2137
+ if (method === "POST" && urlPath === "/api/workspace/upload") return await handleWorkspaceUpload(req, res, query);
2138
+ if (method === "GET" && urlPath === "/api/memory/status") return await memory.status(req, res);
2139
+ if (method === "GET" && urlPath === "/api/memory/list") return await memory.list(req, res, query);
2140
+ if (method === "GET" && urlPath === "/api/memory/search") return await memory.search(req, res, query);
2141
+ if (method === "GET" && urlPath === "/api/memory/files") return memory.files(req, res, query);
2142
+ if (p = matchRoute(method, urlPath, "PUT /api/memory/:id")) return await memory.update(req, res, p);
2143
+ if (p = matchRoute(method, urlPath, "DELETE /api/memory/:id")) return await memory.delete(req, res, p);
2144
+ if (method === "GET" && urlPath === "/api/settings") return handleGetSettings(req, res);
2145
+ if (method === "PUT" && urlPath === "/api/settings") return await handleSaveSettings(req, res);
2146
+ if (method === "POST" && urlPath === "/api/transcribe") return await handleTranscribe(req, res);
2147
+ if (method === "GET" && urlPath === "/api/health") return send(res, 200, { ok: true, workspace: getWorkspaces().active, uptime: process.uptime() });
2148
+ if (method === "GET" && urlPath === "/api/agents") {
2699
2149
  try {
2700
- const agentsDir = path.join(HOME, '.openclaw', 'agents');
2701
- const agents = fs.readdirSync(agentsDir, { withFileTypes: true })
2702
- .filter(e => e.isDirectory()).map(e => e.name);
2703
- return send(res, 200, { agents });
2704
- } catch { return send(res, 200, { agents: ['main'] }); }
2150
+ send(res, 200, { agents: fs13.readdirSync(path14.join(HOME3, ".openclaw", "agents"), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) });
2151
+ } catch {
2152
+ send(res, 200, { agents: ["main"] });
2153
+ }
2154
+ return;
2705
2155
  }
2706
- if (method === 'GET' && urlPath === '/api/workspaces') return workspaces.getAll(req, res);
2707
- if (method === 'POST' && urlPath === '/api/workspaces') return await workspaces.create(req, res);
2708
- if ((p = matchRoute(method, urlPath, 'PATCH /api/workspaces/:name'))) return await workspaces.update(req, res, p);
2709
- if ((p = matchRoute(method, urlPath, 'DELETE /api/workspaces/:name'))) return workspaces.delete(req, res, p);
2710
- if (method === 'POST' && urlPath === '/api/workspaces/reorder') return await workspaces.reorder(req, res);
2711
- if ((p = matchRoute(method, urlPath, 'POST /api/workspaces/:name/activate'))) return workspaces.activate(req, res, p);
2712
- if (method === 'GET' && urlPath === '/api/threads') return threads.getAll(req, res, {}, query);
2713
- if (method === 'GET' && urlPath === '/api/threads/unread') return threads.getUnread(req, res);
2714
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/mark-read'))) return await threads.markRead(req, res, p);
2715
- if (method === 'POST' && urlPath === '/api/threads') return await threads.create(req, res);
2716
- if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/messages'))) return messages.getAll(req, res, p, query);
2717
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) return await messages.create(req, res, p);
2718
- if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) return messages.delete(req, res, p);
2719
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) return messages.contextFill(req, res, p);
2720
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
2721
- const db = _getActiveDb();
2722
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
2723
- if (!thread) return sendError(res, 404, 'Thread not found');
2724
- // Regenerate: sets heuristic immediately (safe fallback), then fires AI upgrade
2725
- const activeWs = _getWorkspaces().active;
2726
- _gatewayClient.generateThreadTitle(db, p.id, activeWs);
2156
+ if (method === "GET" && urlPath === "/api/workspaces") return workspaces.getAll(req, res);
2157
+ if (method === "POST" && urlPath === "/api/workspaces") return await workspaces.create(req, res);
2158
+ if (p = matchRoute(method, urlPath, "PATCH /api/workspaces/:name")) return await workspaces.update(req, res, p);
2159
+ if (p = matchRoute(method, urlPath, "DELETE /api/workspaces/:name")) return workspaces.delete(req, res, p);
2160
+ if (method === "POST" && urlPath === "/api/workspaces/reorder") return await workspaces.reorder(req, res);
2161
+ if (p = matchRoute(method, urlPath, "POST /api/workspaces/:name/activate")) return workspaces.activate(req, res, p);
2162
+ if (method === "GET" && urlPath === "/api/threads") return threads.getAll(req, res, {}, query);
2163
+ if (method === "GET" && urlPath === "/api/threads/unread") return threads.getUnread(req, res);
2164
+ if (method === "POST" && urlPath === "/api/threads") return await threads.create(req, res);
2165
+ if (p = matchRoute(method, urlPath, "POST /api/threads/:id/mark-read")) return await threads.markRead(req, res, p);
2166
+ if (p = matchRoute(method, urlPath, "GET /api/threads/:id/messages")) return messages.getAll(req, res, p, query);
2167
+ if (p = matchRoute(method, urlPath, "POST /api/threads/:id/messages")) return await messages.create(req, res, p);
2168
+ if (p = matchRoute(method, urlPath, "DELETE /api/threads/:id/messages/:messageId")) return messages.delete(req, res, p);
2169
+ if (p = matchRoute(method, urlPath, "POST /api/threads/:id/context-fill")) return messages.contextFill(req, res, p);
2170
+ if (p = matchRoute(method, urlPath, "POST /api/threads/:id/generate-title")) {
2171
+ const db = getActiveDb();
2172
+ const thread = db.prepare("SELECT * FROM threads WHERE id = ?").get(p.id);
2173
+ if (!thread) return sendError(res, 404, "Thread not found");
2174
+ gatewayClient.generateThreadTitle(db, p.id, getWorkspaces().active);
2727
2175
  return send(res, 200, { ok: true });
2728
2176
  }
2729
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) return await files.upload(req, res, p);
2730
- if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) return files.getIntelligence(req, res, p);
2731
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) return await files.saveIntelligence(req, res, p);
2732
- if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id'))) return threads.get(req, res, p);
2733
- if ((p = matchRoute(method, urlPath, 'PATCH /api/threads/:id'))) return await threads.update(req, res, p);
2734
- if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id'))) return threads.delete(req, res, p);
2735
- if (method === 'GET' && urlPath === '/api/search') return messages.search(req, res, {}, query);
2736
- if (method === 'GET' && urlPath === '/api/export') return messages.export(req, res);
2737
- if (method === 'POST' && urlPath === '/api/import') return await messages.import(req, res);
2738
- if (method === 'POST' && urlPath === '/api/active-thread') {
2177
+ if (p = matchRoute(method, urlPath, "POST /api/threads/:id/upload")) return await files.upload(req, res, p);
2178
+ if (p = matchRoute(method, urlPath, "GET /api/threads/:id/intelligence")) return files.getIntelligence(req, res, p);
2179
+ if (p = matchRoute(method, urlPath, "POST /api/threads/:id/intelligence")) return await files.saveIntelligence(req, res, p);
2180
+ if (p = matchRoute(method, urlPath, "GET /api/threads/:id")) return threads.get(req, res, p);
2181
+ if (p = matchRoute(method, urlPath, "PATCH /api/threads/:id")) return await threads.update(req, res, p);
2182
+ if (p = matchRoute(method, urlPath, "DELETE /api/threads/:id")) return threads.delete(req, res, p);
2183
+ if (method === "GET" && urlPath === "/api/search") return messages.search(req, res, {}, query);
2184
+ if (method === "GET" && urlPath === "/api/export") return messages.export(req, res);
2185
+ if (method === "POST" && urlPath === "/api/import") return await messages.import(req, res);
2186
+ if (method === "POST" && urlPath === "/api/active-thread") {
2739
2187
  const body = await parseBody(req);
2740
- const { threadId, workspace } = body;
2741
- if (threadId && workspace) _gatewayClient.setActiveThread(null, workspace, threadId);
2188
+ if (body.threadId && body.workspace) gatewayClient.setActiveThread(null, body.workspace, body.threadId);
2742
2189
  return send(res, 200, { ok: true });
2743
2190
  }
2744
- if (method === 'GET' && urlPath === '/api/health') return send(res, 200, { ok: true, workspace: _getWorkspaces().active, uptime: process.uptime() });
2745
2191
  sendError(res, 404, `Not found: ${method} ${urlPath}`);
2746
2192
  } catch (err) {
2747
2193
  console.error(`Error handling ${method} ${urlPath}:`, err);
2748
- if (err.message && err.message.includes('UNIQUE constraint')) sendError(res, 409, 'Conflict: ' + err.message);
2749
- else sendError(res, 500, err.message || 'Internal server error');
2194
+ if (err.message?.includes("UNIQUE constraint")) sendError(res, 409, "Conflict: " + err.message);
2195
+ else sendError(res, 500, err.message || "Internal server error");
2750
2196
  }
2751
2197
  }
2752
-
2753
- // ── Browser WebSocket setup (shared logic for standalone and plugin) ────────
2754
- function _setupBrowserWs(wssInstance) {
2755
- wssInstance.on('connection', (ws) => {
2756
- console.log('Browser client connected');
2757
- _gatewayClient.addBrowserClient(ws);
2758
- const nonce = crypto.randomUUID();
2759
- ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: { nonce, ts: Date.now() } }));
2760
-
2761
- ws.on('message', (data) => {
2198
+ __name(route, "route");
2199
+ function setupBrowserWs(wss) {
2200
+ wss.on("connection", (ws) => {
2201
+ console.log("Browser client connected");
2202
+ gatewayClient.addBrowserClient(ws);
2203
+ ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: uuid(), ts: Date.now() } }));
2204
+ ws.on("message", async (data) => {
2762
2205
  const msgStr = data.toString();
2763
- _debugLogger.logFrame('BR→SRV', msgStr);
2206
+ debugLogger.logFrame("BR\u2192SRV", msgStr);
2764
2207
  let msgToForward = msgStr;
2765
2208
  try {
2766
2209
  const msg = JSON.parse(msgStr);
2767
- if (msg.type === 'req' && msg.method === 'connect') {
2210
+ if (msg.type === "req" && msg.method === "connect") {
2768
2211
  const token = msg.params?.auth?.token;
2769
- if (token === _AUTH_TOKEN || !_AUTH_TOKEN) {
2770
- console.log('Browser client authenticated');
2771
- ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: true, payload: { type: 'hello-ok', protocol: 3, server: { version: '0.1.0', host: 'clawchats-backend' } } }));
2212
+ if (token === authToken || !authToken) {
2213
+ ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: { type: "hello-ok", protocol: 3, server: { version: "0.1.0", host: "clawchats-backend" } } }));
2772
2214
  } else {
2773
- console.log('Browser client auth failed');
2774
- ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: false, error: { code: 'AUTH_FAILED', message: 'Invalid auth token' } }));
2215
+ ws.send(JSON.stringify({ type: "res", id: msg.id, ok: false, error: { code: "AUTH_FAILED", message: "Invalid auth token" } }));
2775
2216
  ws.close();
2776
2217
  }
2777
2218
  return;
2778
2219
  }
2779
- if (msg.type === 'clawchats' || msg.type === 'shellchat') { // backward compat: accept legacy 'shellchat' type
2780
- if (msg.action === 'active-thread') { _gatewayClient.setActiveThread(ws, msg.workspace, msg.threadId); console.log(`Browser client set active thread: ${msg.workspace}/${msg.threadId}`); return; }
2781
- if (msg.action === 'debug-start') { const result = _debugLogger.start(msg.ts, ws); if (result.error === 'already-active') ws.send(JSON.stringify({ type: 'clawchats', event: 'debug-error', error: 'Recording already active in another tab', sessionId: result.sessionId })); else ws.send(JSON.stringify({ type: 'clawchats', event: 'debug-started', sessionId: result.sessionId })); return; }
2782
- if (msg.action === 'debug-dump') { const { sessionId, files } = _debugLogger.saveDump(msg); ws.send(JSON.stringify({ type: 'clawchats', event: 'debug-saved', sessionId, files })); return; }
2220
+ if (msg.type === "clawchats" || msg.type === "shellchat") {
2221
+ if (msg.action === "active-thread") {
2222
+ gatewayClient.setActiveThread(ws, msg.workspace, msg.threadId);
2223
+ return;
2224
+ }
2225
+ if (msg.action === "debug-start") {
2226
+ const r = debugLogger.start(msg.ts, ws);
2227
+ ws.send(JSON.stringify(r.error === "already-active" ? { type: "clawchats", event: "debug-error", error: "Recording already active in another tab", sessionId: r.sessionId } : { type: "clawchats", event: "debug-started", sessionId: r.sessionId }));
2228
+ return;
2229
+ }
2230
+ if (msg.action === "debug-dump") {
2231
+ const r = debugLogger.saveDump(msg);
2232
+ ws.send(JSON.stringify({ type: "clawchats", event: "debug-saved", sessionId: r.sessionId, files: r.files }));
2233
+ return;
2234
+ }
2783
2235
  }
2784
- // Save inline attachments to disk so the agent can access them as file paths
2785
- if (msg.type === 'req' && msg.method === 'chat.send' && msg.params?.attachments?.length > 0) {
2786
- const parsed = parseSessionKey(msg.params.sessionKey || '');
2787
- const threadId = parsed?.threadId || 'misc';
2788
- const uploadDir = path.join(_UPLOADS_DIR, threadId);
2789
- fs.mkdirSync(uploadDir, { recursive: true });
2790
- const extMap = { jpeg: 'jpg', jpg: 'jpg', png: 'png', gif: 'gif', webp: 'webp', pdf: 'pdf', 'svg+xml': 'svg', mp3: 'mp3', mp4: 'mp4', wav: 'wav', webm: 'webm' };
2236
+ if (msg.type === "req" && msg.method === "chat.send" && msg.params?.attachments?.length > 0) {
2237
+ const parsed = parseSessionKey(msg.params.sessionKey || "");
2238
+ const threadId = parsed?.threadId || "misc";
2239
+ const uploadDir = path14.join(UPLOADS_DIR, threadId);
2240
+ fs13.mkdirSync(uploadDir, { recursive: true });
2241
+ const extMap = { jpeg: "jpg", jpg: "jpg", png: "png", gif: "gif", webp: "webp", pdf: "pdf", "svg+xml": "svg", mp3: "mp3", mp4: "mp4", wav: "wav", webm: "webm" };
2791
2242
  const savedPaths = [];
2792
2243
  for (const att of msg.params.attachments) {
2793
2244
  if (!att.content || !att.mimeType) continue;
2794
2245
  try {
2795
- const rawExt = att.mimeType.split('/')[1]?.split(';')[0] || 'bin';
2796
- const ext = extMap[rawExt] || rawExt;
2797
- const fileId = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
2798
- const filePath = path.join(uploadDir, `${fileId}.${ext}`);
2799
- fs.writeFileSync(filePath, Buffer.from(att.content, 'base64'));
2246
+ const rawExt = att.mimeType.split("/")[1]?.split(";")[0] || "bin";
2247
+ const filePath = path14.join(uploadDir, `${Date.now()}_${Math.random().toString(36).slice(2, 6)}.${extMap[rawExt] || rawExt}`);
2248
+ fs13.writeFileSync(filePath, Buffer.from(att.content, "base64"));
2800
2249
  savedPaths.push(filePath);
2801
- console.log(`[upload] Saved attachment to ${filePath}`);
2802
2250
  } catch (err) {
2803
- console.error('[upload] Failed to save attachment:', err.message);
2251
+ console.error("[upload] Failed to save attachment:", err.message);
2804
2252
  }
2805
2253
  }
2806
2254
  if (savedPaths.length > 0) {
2807
- const label = savedPaths.length === 1 ? 'Attached file saved on disk' : 'Attached files saved on disk';
2808
- const note = `\n\n[${label}:\n${savedPaths.map(p => `- ${p}`).join('\n')}]`;
2809
- msgToForward = JSON.stringify({ ...msg, params: { ...msg.params, message: (msg.params.message || '') + note } });
2255
+ const note = `
2256
+
2257
+ [${savedPaths.length === 1 ? "Attached file saved on disk" : "Attached files saved on disk"}:
2258
+ ${savedPaths.map((p) => `- ${p}`).join("\n")}]`;
2259
+ msgToForward = JSON.stringify({ ...msg, params: { ...msg.params, message: (msg.params.message || "") + note } });
2810
2260
  }
2811
2261
  }
2812
- } catch { /* Not JSON or not a ClawChats message, forward as-is */ }
2813
- _gatewayClient.sendToGateway(msgToForward);
2262
+ } catch {
2263
+ }
2264
+ gatewayClient.sendToGateway(msgToForward);
2814
2265
  });
2815
-
2816
- ws.on('close', () => { console.log('Browser client disconnected'); _debugLogger.handleClientDisconnect(ws); _gatewayClient.removeBrowserClient(ws); });
2817
- ws.on('error', (err) => { console.error('Browser WebSocket error:', err.message); });
2266
+ ws.on("close", () => {
2267
+ console.log("Browser client disconnected");
2268
+ debugLogger.handleClientDisconnect(ws);
2269
+ gatewayClient.removeBrowserClient(ws);
2270
+ });
2271
+ ws.on("error", (err) => console.error("Browser WebSocket error:", err.message));
2818
2272
  });
2819
2273
  }
2820
-
2821
- // ── Public API ─────────────────────────────────────────────────────────────
2822
- _ensureDirs();
2823
-
2274
+ __name(setupBrowserWs, "setupBrowserWs");
2824
2275
  return {
2825
- handleRequest: _handleRequest,
2826
- getDb: _getDb,
2827
- getActiveDb: _getActiveDb,
2828
- migrate,
2829
- getWorkspaces: _getWorkspaces,
2830
- setWorkspaces: _setWorkspaces,
2831
- shutdown: _closeAllDbs,
2832
- closeAllDbs: _closeAllDbs,
2833
- gatewayClient: _gatewayClient,
2834
- setupBrowserWs: _setupBrowserWs,
2835
- dataDir: _DATA_DIR,
2276
+ handleRequest,
2277
+ getDb,
2278
+ getActiveDb,
2279
+ getWorkspaces,
2280
+ setWorkspaces,
2281
+ shutdown: closeAll,
2282
+ closeAllDbs: closeAll,
2283
+ gatewayClient,
2284
+ setupBrowserWs,
2285
+ dataDir: DATA_DIR
2836
2286
  };
2837
2287
  }
2838
-
2839
- // ─── Server Startup (standalone mode only) ───────────────────────────────────
2840
-
2841
- const isDirectRun = import.meta.url === `file://${process.argv[1]}`;
2288
+ __name(createApp, "createApp");
2289
+ function migrate(db) {
2290
+ db.exec(`
2291
+ CREATE TABLE IF NOT EXISTS threads (
2292
+ id TEXT PRIMARY KEY, session_key TEXT UNIQUE NOT NULL, title TEXT DEFAULT 'New chat',
2293
+ pinned INTEGER DEFAULT 0, pin_order INTEGER DEFAULT 0, model TEXT,
2294
+ last_session_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2295
+ );
2296
+ CREATE TABLE IF NOT EXISTS messages (
2297
+ id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL,
2298
+ status TEXT DEFAULT 'sent', metadata TEXT, seq INTEGER, timestamp INTEGER NOT NULL, created_at INTEGER NOT NULL,
2299
+ FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
2300
+ );
2301
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, timestamp);
2302
+ CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages(thread_id, role, timestamp);
2303
+ `);
2304
+ try {
2305
+ db.exec("ALTER TABLE threads ADD COLUMN sort_order INTEGER DEFAULT 0");
2306
+ } catch {
2307
+ }
2308
+ try {
2309
+ db.exec("ALTER TABLE threads ADD COLUMN unread_count INTEGER DEFAULT 0");
2310
+ } catch {
2311
+ }
2312
+ db.exec(`CREATE TABLE IF NOT EXISTS unread_messages (thread_id TEXT NOT NULL, message_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (thread_id, message_id), FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE)`);
2313
+ db.exec("CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id)");
2314
+ ensureFts(db);
2315
+ }
2316
+ __name(migrate, "migrate");
2317
+ function createFts(db) {
2318
+ db.exec(`CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='porter unicode61 tokenchars x27')`);
2319
+ db.exec(`CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content); END`);
2320
+ db.exec(`CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content); END`);
2321
+ db.exec(`CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content); INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content); END`);
2322
+ }
2323
+ __name(createFts, "createFts");
2324
+ function dropFts(db) {
2325
+ db.exec("DROP TABLE IF EXISTS messages_fts; DROP TRIGGER IF EXISTS messages_ai; DROP TRIGGER IF EXISTS messages_ad; DROP TRIGGER IF EXISTS messages_au;");
2326
+ }
2327
+ __name(dropFts, "dropFts");
2328
+ function ensureFts(db) {
2329
+ const hasFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
2330
+ if (!hasFts) {
2331
+ try {
2332
+ createFts(db);
2333
+ db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
2334
+ } catch (e) {
2335
+ console.error("[DB] messages_fts creation failed:", e.message);
2336
+ }
2337
+ return;
2338
+ }
2339
+ const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
2340
+ if (schema && !schema.sql.includes("tokenchars")) {
2341
+ console.log("[DB] Upgrading messages_fts tokenizer...");
2342
+ try {
2343
+ dropFts(db);
2344
+ createFts(db);
2345
+ db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
2346
+ console.log("[DB] Upgrade complete");
2347
+ } catch (e) {
2348
+ console.error("[DB] Upgrade failed:", e.message);
2349
+ dropFts(db);
2350
+ }
2351
+ } else {
2352
+ try {
2353
+ db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('integrity-check')").run();
2354
+ } catch {
2355
+ try {
2356
+ db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
2357
+ } catch (e) {
2358
+ console.error("[DB] FTS rebuild failed:", e.message);
2359
+ dropFts(db);
2360
+ }
2361
+ }
2362
+ }
2363
+ }
2364
+ __name(ensureFts, "ensureFts");
2365
+ var isDirectRun = import.meta.url === `file://${process.argv[1]}`;
2842
2366
  if (isDirectRun) {
2843
2367
  const app = createApp();
2844
- app.getActiveDb(); // Eagerly open active workspace DB
2845
-
2368
+ app.getActiveDb();
2846
2369
  const server = http.createServer(app.handleRequest);
2847
-
2848
- // ─── Browser WebSocket Server ───────────────────────────────────────────────
2849
-
2850
2370
  const wss = new WebSocketServer({ noServer: true });
2851
2371
  app.setupBrowserWs(wss);
2852
-
2853
- // Handle WebSocket upgrade requests
2854
- server.on('upgrade', (req, socket, head) => {
2855
- wss.handleUpgrade(req, socket, head, (ws) => {
2856
- wss.emit('connection', ws, req);
2857
- });
2372
+ server.on("upgrade", (req, socket, head) => {
2373
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
2858
2374
  });
2859
-
2860
2375
  server.listen(PORT, () => {
2861
2376
  console.log(`ClawChats backend listening on port ${PORT}`);
2862
2377
  console.log(`Active workspace: ${app.getWorkspaces().active}`);
2863
2378
  console.log(`Data dir: ${app.dataDir}`);
2864
-
2865
- // Connect to gateway
2866
2379
  app.gatewayClient.connect();
2867
-
2868
- // Initialize global DB (custom emojis, etc.)
2869
- getGlobalDb(DATA_DIR);
2870
2380
  });
2871
-
2872
- // Graceful shutdown
2873
- function shutdown() {
2874
- console.log('Shutting down...');
2381
+ const shutdown = /* @__PURE__ */ __name(() => {
2382
+ console.log("Shutting down...");
2875
2383
  app.shutdown();
2876
2384
  server.close(() => process.exit(0));
2877
- // Force exit after 5s
2878
- setTimeout(() => process.exit(1), 5000);
2879
- }
2880
- process.on('SIGTERM', shutdown);
2881
- process.on('SIGINT', shutdown);
2882
- }
2385
+ setTimeout(() => process.exit(1), 5e3);
2386
+ }, "shutdown");
2387
+ process.on("SIGTERM", shutdown);
2388
+ process.on("SIGINT", shutdown);
2389
+ }
2390
+ export {
2391
+ createApp
2392
+ };