@clawchatsai/connector 0.0.82 → 0.0.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.js +509 -2565
package/server.js CHANGED
@@ -485,6 +485,7 @@ function migrate(db) {
485
485
  if (!hasFts) {
486
486
  try {
487
487
  _createFtsTables(db);
488
+ db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
488
489
  } catch (ftsCreateErr) {
489
490
  console.error('[DB] messages_fts creation failed, search disabled:', ftsCreateErr.message);
490
491
  }
@@ -792,728 +793,6 @@ function cleanGatewaySessionsByPrefix(prefix) {
792
793
  }
793
794
  }
794
795
 
795
- // ─── Route Handlers ─────────────────────────────────────────────────────────
796
-
797
- // --- User settings (synced across devices) ---
798
- const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
799
-
800
- function handleGetSettings(req, res) {
801
- try {
802
- const data = fs.existsSync(SETTINGS_FILE)
803
- ? JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'))
804
- : {};
805
- return send(res, 200, data);
806
- } catch {
807
- return send(res, 200, {});
808
- }
809
- }
810
-
811
- async function handleSaveSettings(req, res) {
812
- const body = await parseBody(req);
813
- try {
814
- // Merge with existing settings
815
- const existing = fs.existsSync(SETTINGS_FILE)
816
- ? JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'))
817
- : {};
818
- const merged = { ...existing, ...body };
819
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2));
820
- return send(res, 200, merged);
821
- } catch (err) {
822
- return send(res, 500, { error: err.message });
823
- }
824
- }
825
-
826
- // --- Workspaces ---
827
-
828
- function handleGetWorkspaces(req, res) {
829
- const ws = getWorkspaces();
830
- const sorted = Object.values(ws.workspaces).sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
831
-
832
- // Add unread_count for each workspace (from thread unread_count column, kept in sync by mark-read + WS handler)
833
- for (const workspace of sorted) {
834
- try {
835
- const db = getDb(workspace.name);
836
- const result = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get();
837
- workspace.unread_count = result.total;
838
- } catch (e) {
839
- workspace.unread_count = 0;
840
- }
841
- }
842
-
843
- send(res, 200, {
844
- active: ws.active,
845
- workspaces: sorted,
846
- });
847
- }
848
-
849
- async function handleCreateWorkspace(req, res) {
850
- const body = await parseBody(req);
851
- const { name, label } = body;
852
- if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) {
853
- return sendError(res, 400, 'Name must be [a-z0-9-], 1-32 chars');
854
- }
855
- const ws = getWorkspaces();
856
- if (ws.workspaces[name]) {
857
- return sendError(res, 409, 'Workspace already exists');
858
- }
859
- let agent = 'main';
860
- try { agent = validateAgent(body.agent || 'main'); } catch { agent = 'main'; }
861
- const workspace = { name, label: label || name, color: body.color || null, icon: body.icon || null, agent, createdAt: Date.now() };
862
- ws.workspaces[name] = workspace;
863
- setWorkspaces(ws);
864
- // Initialize DB
865
- getDb(name);
866
- send(res, 201, { workspace });
867
- }
868
-
869
- async function handleUpdateWorkspace(req, res, params) {
870
- const body = await parseBody(req);
871
- const ws = getWorkspaces();
872
- if (!ws.workspaces[params.name]) {
873
- return sendError(res, 404, 'Workspace not found');
874
- }
875
- if (body.label !== undefined) {
876
- ws.workspaces[params.name].label = body.label;
877
- }
878
- if (body.color !== undefined) {
879
- ws.workspaces[params.name].color = body.color;
880
- }
881
- if (body.icon !== undefined) {
882
- ws.workspaces[params.name].icon = body.icon;
883
- }
884
- if (body.lastThread !== undefined) {
885
- ws.workspaces[params.name].lastThread = body.lastThread;
886
- }
887
- let migratedThreads = 0;
888
- if (body.agent !== undefined) {
889
- let newAgent;
890
- try { newAgent = validateAgent(body.agent); } catch (e) { return sendError(res, 400, e.message); }
891
- const oldAgent = ws.workspaces[params.name].agent || 'main';
892
- if (newAgent !== oldAgent) {
893
- const db = getDb(params.name);
894
- const threads = db.prepare(
895
- `SELECT id, session_key FROM threads WHERE session_key LIKE ?`
896
- ).all(`agent:${oldAgent}:${params.name}:chat:%`);
897
- db.prepare(`
898
- UPDATE threads
899
- SET session_key = replace(
900
- session_key,
901
- 'agent:' || ? || ':' || ? || ':chat:',
902
- 'agent:' || ? || ':' || ? || ':chat:'
903
- )
904
- WHERE session_key LIKE 'agent:' || ? || ':' || ? || ':chat:%'
905
- `).run(oldAgent, params.name, newAgent, params.name, oldAgent, params.name);
906
- for (const t of threads) cleanGatewaySession(t.session_key);
907
- ws.workspaces[params.name].agent = newAgent;
908
- migratedThreads = threads.length;
909
- gatewayClient.broadcastToBrowsers(JSON.stringify({
910
- type: 'clawchats',
911
- event: 'workspace-agent-changed',
912
- workspace: params.name,
913
- agent: newAgent
914
- }));
915
- }
916
- }
917
- setWorkspaces(ws);
918
- send(res, 200, { workspace: ws.workspaces[params.name], migratedThreads });
919
- }
920
-
921
- function handleDeleteWorkspace(req, res, params) {
922
- const ws = getWorkspaces();
923
- console.log('DELETE workspace:', params.name, 'workspaces:', Object.keys(ws.workspaces), 'active:', ws.active);
924
- if (!ws.workspaces[params.name]) {
925
- return sendError(res, 404, 'Workspace not found');
926
- }
927
- if (Object.keys(ws.workspaces).length <= 1) {
928
- return sendError(res, 400, 'Cannot delete the only workspace');
929
- }
930
- // Close and remove DB
931
- closeDb(params.name);
932
- const dbPath = path.join(DATA_DIR, `${params.name}.db`);
933
- try { fs.unlinkSync(dbPath); } catch { /* ok */ }
934
- try { fs.unlinkSync(dbPath + '-wal'); } catch { /* ok */ }
935
- try { fs.unlinkSync(dbPath + '-shm'); } catch { /* ok */ }
936
-
937
- // Clean all gateway sessions for this workspace
938
- const wsAgentForDelete = ws.workspaces[params.name]?.agent || 'main';
939
- const cleaned = cleanGatewaySessionsByPrefix(`agent:${wsAgentForDelete}:${params.name}:chat:`);
940
- if (cleaned > 0) {
941
- console.log(`Cleaned ${cleaned} gateway sessions for workspace: ${params.name}`);
942
- }
943
-
944
- delete ws.workspaces[params.name];
945
-
946
- // If this was the active workspace, switch active to the first remaining one
947
- if (ws.active === params.name) {
948
- ws.active = Object.keys(ws.workspaces)[0] || null;
949
- }
950
-
951
- setWorkspaces(ws);
952
- send(res, 200, { ok: true });
953
- }
954
-
955
- async function handleReorderWorkspaces(req, res) {
956
- const body = await parseBody(req);
957
- const { order } = body;
958
- if (!Array.isArray(order)) {
959
- return sendError(res, 400, 'order must be an array of workspace names');
960
- }
961
- const ws = getWorkspaces();
962
- // Assign order index to each workspace
963
- order.forEach((name, i) => {
964
- if (ws.workspaces[name]) {
965
- ws.workspaces[name].order = i;
966
- }
967
- });
968
- setWorkspaces(ws);
969
- send(res, 200, { ok: true, workspaces: Object.values(ws.workspaces) });
970
- }
971
-
972
- function handleActivateWorkspace(req, res, params) {
973
- const ws = getWorkspaces();
974
- if (!ws.workspaces[params.name]) {
975
- return sendError(res, 404, 'Workspace not found');
976
- }
977
- ws.active = params.name;
978
- setWorkspaces(ws);
979
- // Pre-open the DB
980
- getDb(params.name);
981
- send(res, 200, { ok: true, workspace: ws.workspaces[params.name] });
982
- }
983
-
984
- // --- Threads ---
985
-
986
- function handleGetThreads(req, res, params, query) {
987
- const db = getActiveDb();
988
- const page = parseInt(query.page || '1', 10);
989
- const limit = Math.min(parseInt(query.limit || '50', 10), 200);
990
- const offset = (page - 1) * limit;
991
- const search = query.search || '';
992
-
993
- let threads, total;
994
- if (search) {
995
- // FTS5 search across messages, return matching thread IDs
996
- try {
997
- const ftsQuery = `
998
- SELECT DISTINCT m.thread_id
999
- FROM messages m
1000
- JOIN messages_fts ON messages_fts.rowid = m.rowid
1001
- WHERE messages_fts MATCH ?
1002
- `;
1003
- const matchingIds = db.prepare(ftsQuery).all(search).map(r => r.thread_id);
1004
- if (matchingIds.length === 0) {
1005
- return send(res, 200, { threads: [], total: 0, page });
1006
- }
1007
- const placeholders = matchingIds.map(() => '?').join(',');
1008
- total = db.prepare(
1009
- `SELECT COUNT(*) as c FROM threads WHERE id IN (${placeholders})`
1010
- ).get(...matchingIds).c;
1011
- threads = db.prepare(
1012
- `SELECT t.*, (SELECT MAX(m.timestamp) FROM messages m WHERE m.thread_id = t.id) as last_message_at
1013
- FROM threads t WHERE t.id IN (${placeholders}) ORDER BY t.pinned DESC, t.sort_order DESC, t.updated_at DESC LIMIT ? OFFSET ?`
1014
- ).all(...matchingIds, limit, offset);
1015
- } catch (ftsErr) {
1016
- console.warn('[DB] FTS thread search failed, returning empty results:', ftsErr.message);
1017
- return send(res, 200, { threads: [], total: 0, page });
1018
- }
1019
- } else {
1020
- total = db.prepare('SELECT COUNT(*) as c FROM threads').get().c;
1021
- threads = db.prepare(
1022
- `SELECT t.*, (SELECT MAX(m.timestamp) FROM messages m WHERE m.thread_id = t.id) as last_message_at
1023
- FROM threads t ORDER BY t.pinned DESC, t.sort_order DESC, t.updated_at DESC LIMIT ? OFFSET ?`
1024
- ).all(limit, offset);
1025
- }
1026
-
1027
- send(res, 200, { threads, total, page });
1028
- }
1029
-
1030
- function handleGetUnreadThreads(req, res) {
1031
- const db = getActiveDb();
1032
- const threads = db.prepare(`
1033
- SELECT t.id, t.title, t.unread_count, m.content as lastMessage
1034
- FROM threads t
1035
- LEFT JOIN messages m ON m.thread_id = t.id
1036
- WHERE t.unread_count > 0
1037
- AND m.timestamp = (SELECT MAX(timestamp) FROM messages WHERE thread_id = t.id)
1038
- ORDER BY t.updated_at DESC
1039
- `).all();
1040
-
1041
- // Attach unread message IDs to each thread
1042
- for (const thread of threads) {
1043
- const rows = db.prepare('SELECT message_id FROM unread_messages WHERE thread_id = ?').all(thread.id);
1044
- thread.unreadMessageIds = rows.map(r => r.message_id);
1045
- }
1046
-
1047
- send(res, 200, { threads });
1048
- }
1049
-
1050
- async function handleMarkMessagesRead(req, res, params) {
1051
- const body = await parseBody(req);
1052
- const db = getActiveDb();
1053
- const threadId = params.id;
1054
- const messageIds = body.messageIds;
1055
-
1056
- if (!Array.isArray(messageIds) || messageIds.length === 0) {
1057
- return send(res, 400, { error: 'messageIds array required' });
1058
- }
1059
-
1060
- const placeholders = messageIds.map(() => '?').join(',');
1061
- db.prepare(`DELETE FROM unread_messages WHERE thread_id = ? AND message_id IN (${placeholders})`).run(threadId, ...messageIds);
1062
-
1063
- // Sync unread_count from actual unread_messages table
1064
- const remaining = syncThreadUnreadCount(db, threadId);
1065
-
1066
- // Broadcast unread-update to ALL browser clients (so other tabs/devices sync)
1067
- const workspace = getWorkspaces().active;
1068
- gatewayClient.broadcastToBrowsers(JSON.stringify({
1069
- type: 'clawchats',
1070
- event: 'unread-update',
1071
- workspace,
1072
- threadId,
1073
- action: 'read',
1074
- messageIds,
1075
- unreadCount: remaining,
1076
- timestamp: Date.now()
1077
- }));
1078
-
1079
- send(res, 200, { unread_count: remaining });
1080
- }
1081
-
1082
- async function handleCreateThread(req, res) {
1083
- const body = await parseBody(req);
1084
- const db = getActiveDb();
1085
- const ws = getWorkspaces();
1086
- const id = body.id || uuid();
1087
- const now = Date.now();
1088
- const workspaceAgent = ws.workspaces[ws.active]?.agent || 'main';
1089
- const sessionKey = `agent:${workspaceAgent}:${ws.active}:chat:${id}`;
1090
-
1091
- try {
1092
- db.prepare(
1093
- 'INSERT INTO threads (id, session_key, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
1094
- ).run(id, sessionKey, 'New chat', now, now);
1095
- } catch (e) {
1096
- if (e.message.includes('UNIQUE constraint')) {
1097
- return sendError(res, 409, 'Thread already exists');
1098
- }
1099
- throw e;
1100
- }
1101
-
1102
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(id);
1103
- send(res, 201, { thread });
1104
- }
1105
-
1106
- function handleGetThread(req, res, params) {
1107
- const db = getActiveDb();
1108
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1109
- if (!thread) return sendError(res, 404, 'Thread not found');
1110
- send(res, 200, { thread });
1111
- }
1112
-
1113
- async function handleUpdateThread(req, res, params) {
1114
- const body = await parseBody(req);
1115
- const db = getActiveDb();
1116
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1117
- if (!thread) return sendError(res, 404, 'Thread not found');
1118
-
1119
- const fields = [];
1120
- const values = [];
1121
- if (body.title !== undefined) { fields.push('title = ?'); values.push(body.title); }
1122
- if (body.pinned !== undefined) { fields.push('pinned = ?'); values.push(body.pinned ? 1 : 0); }
1123
- if (body.pin_order !== undefined) { fields.push('pin_order = ?'); values.push(body.pin_order); }
1124
- if (body.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(body.sort_order); }
1125
- if (body.model !== undefined) { fields.push('model = ?'); values.push(body.model); }
1126
- if (body.last_session_id !== undefined) { fields.push('last_session_id = ?'); values.push(body.last_session_id); }
1127
- if (body.unread_count !== undefined) { fields.push('unread_count = ?'); values.push(body.unread_count); }
1128
-
1129
- if (fields.length > 0) {
1130
- fields.push('updated_at = ?');
1131
- values.push(Date.now());
1132
- values.push(params.id);
1133
- db.prepare(`UPDATE threads SET ${fields.join(', ')} WHERE id = ?`).run(...values);
1134
- }
1135
-
1136
- const updated = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1137
- send(res, 200, { thread: updated });
1138
- }
1139
-
1140
- function handleDeleteThread(req, res, params) {
1141
- const db = getActiveDb();
1142
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1143
- if (!thread) return sendError(res, 404, 'Thread not found');
1144
-
1145
- // Delete from SQLite (CASCADE deletes messages)
1146
- db.prepare('DELETE FROM threads WHERE id = ?').run(params.id);
1147
-
1148
- // Look up sessionId from SQLite or sessions.json as fallback
1149
- let sessionIdToDelete = thread.last_session_id;
1150
- const threadAgentMatch = (thread.session_key || '').match(/^agent:([^:]+):/);
1151
- const threadSessionsDir = getSessionsDirForAgent(threadAgentMatch?.[1]);
1152
- if (!sessionIdToDelete) {
1153
- try {
1154
- const raw = fs.readFileSync(path.join(threadSessionsDir, 'sessions.json'), 'utf8');
1155
- const store = JSON.parse(raw);
1156
- const entry = store[thread.session_key];
1157
- if (entry?.sessionId) sessionIdToDelete = entry.sessionId;
1158
- } catch { /* ok */ }
1159
- }
1160
-
1161
- // Clean gateway session (deletes .jsonl + sessions.json entry)
1162
- cleanGatewaySession(thread.session_key);
1163
-
1164
- // If cleanGatewaySession didn't find it but we have a sessionId, delete transcript directly
1165
- if (sessionIdToDelete) {
1166
- const jsonlPath = path.join(threadSessionsDir, `${sessionIdToDelete}.jsonl`);
1167
- try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
1168
- }
1169
-
1170
- // Delete uploaded files
1171
- const uploadDir = path.join(UPLOADS_DIR, params.id);
1172
- try { fs.rmSync(uploadDir, { recursive: true }); } catch { /* ok */ }
1173
-
1174
- send(res, 200, { ok: true });
1175
- }
1176
-
1177
- // --- Messages ---
1178
-
1179
- function handleGetMessages(req, res, params, query) {
1180
- const db = getActiveDb();
1181
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
1182
- if (!thread) return sendError(res, 404, 'Thread not found');
1183
-
1184
- const limit = Math.min(parseInt(query.limit || '100', 10), 500);
1185
- const before = query.before ? parseInt(query.before, 10) : null;
1186
- const after = query.after ? parseInt(query.after, 10) : null;
1187
-
1188
- let sql = 'SELECT * FROM messages WHERE thread_id = ?';
1189
- const sqlParams = [params.id];
1190
-
1191
- if (before) {
1192
- sql += ' AND timestamp < ?';
1193
- sqlParams.push(before);
1194
- }
1195
- if (after) {
1196
- sql += ' AND timestamp > ?';
1197
- sqlParams.push(after);
1198
- }
1199
-
1200
- // Count total matching for hasMore
1201
- const countSql = sql.replace('SELECT *', 'SELECT COUNT(*) as c');
1202
- const total = db.prepare(countSql).get(...sqlParams).c;
1203
-
1204
- sql += ' ORDER BY timestamp DESC LIMIT ?';
1205
- sqlParams.push(limit + 1);
1206
-
1207
- const rows = db.prepare(sql).all(...sqlParams);
1208
- const hasMore = rows.length > limit;
1209
- const messages = rows.slice(0, limit).reverse(); // Return chronological order
1210
-
1211
- // Parse metadata JSON
1212
- for (const m of messages) {
1213
- if (m.metadata) {
1214
- try { m.metadata = JSON.parse(m.metadata); } catch { /* leave as string */ }
1215
- }
1216
- }
1217
-
1218
- send(res, 200, { messages, hasMore });
1219
- }
1220
-
1221
- async function handleCreateMessage(req, res, params) {
1222
- const body = await parseBody(req);
1223
- const db = getActiveDb();
1224
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
1225
- if (!thread) return sendError(res, 404, 'Thread not found');
1226
-
1227
- if (!body.id || !body.role || body.content === undefined || !body.timestamp) {
1228
- return sendError(res, 400, 'Required: id, role, content, timestamp');
1229
- }
1230
-
1231
- const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
1232
-
1233
- // Idempotent upsert: insert or update status if it changed
1234
- const existing = db.prepare('SELECT id, status FROM messages WHERE id = ?').get(body.id);
1235
- if (existing) {
1236
- // Only update if status changes
1237
- if (body.status && body.status !== existing.status) {
1238
- db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?')
1239
- .run(body.status, body.content, metadata, body.id);
1240
- }
1241
- } else {
1242
- db.prepare(
1243
- 'INSERT INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
1244
- ).run(body.id, params.id, body.role, body.content, body.status || 'sent', metadata, body.seq || null, body.timestamp, Date.now());
1245
-
1246
- // Bump thread updated_at
1247
- db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
1248
-
1249
- // Heuristic title on first user message (mimics Claude/ChatGPT — title appears immediately on send)
1250
- if (body.role === 'user' && body.content) {
1251
- const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
1252
- if (threadInfo?.title === 'New chat') {
1253
- const heuristic = body.content.replace(/\n.*/s, '').slice(0, 40).trim()
1254
- + (body.content.length > 40 ? '...' : '');
1255
- if (heuristic) {
1256
- db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, params.id);
1257
- const activeWs = getWorkspaces().active;
1258
- gatewayClient.broadcastToBrowsers(JSON.stringify({
1259
- type: 'clawchats',
1260
- event: 'thread-title-updated',
1261
- threadId: params.id,
1262
- workspace: activeWs,
1263
- title: heuristic
1264
- }));
1265
- }
1266
- }
1267
- }
1268
- }
1269
-
1270
- const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
1271
- if (message && message.metadata) {
1272
- try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ }
1273
- }
1274
- send(res, existing ? 200 : 201, { message });
1275
- }
1276
-
1277
- function handleDeleteMessage(req, res, params) {
1278
- const db = getActiveDb();
1279
- const msg = db.prepare('SELECT id FROM messages WHERE id = ? AND thread_id = ?').get(params.messageId, params.id);
1280
- if (!msg) return sendError(res, 404, 'Message not found');
1281
- db.prepare('DELETE FROM messages WHERE id = ?').run(params.messageId);
1282
- send(res, 200, { ok: true });
1283
- }
1284
-
1285
- // --- Context Fill ---
1286
-
1287
- function handleContextFill(req, res, params) {
1288
- const db = getActiveDb();
1289
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1290
- if (!thread) return sendError(res, 404, 'Thread not found');
1291
-
1292
- const { preamble, method } = buildContextPreamble(db, params.id, thread.last_session_id, thread.session_key);
1293
- send(res, 200, { preamble, method });
1294
- }
1295
-
1296
- // --- Search ---
1297
-
1298
- function handleSearch(req, res, params, query) {
1299
- const db = getActiveDb();
1300
- const q = query.q || '';
1301
- if (!q) return send(res, 200, { results: [], total: 0 });
1302
-
1303
- const page = parseInt(query.page || '1', 10);
1304
- const limit = Math.min(parseInt(query.limit || '20', 10), 100);
1305
- const offset = (page - 1) * limit;
1306
-
1307
- // FTS5 search with snippet
1308
- try {
1309
- const results = db.prepare(`
1310
- SELECT
1311
- m.id as messageId,
1312
- m.thread_id as threadId,
1313
- t.title as threadTitle,
1314
- m.role,
1315
- snippet(messages_fts, 0, '<mark>', '</mark>', '...', 40) as content,
1316
- m.timestamp
1317
- FROM messages_fts
1318
- JOIN messages m ON messages_fts.rowid = m.rowid
1319
- JOIN threads t ON m.thread_id = t.id
1320
- WHERE messages_fts MATCH ?
1321
- ORDER BY rank
1322
- LIMIT ? OFFSET ?
1323
- `).all(q, limit, offset);
1324
-
1325
- const totalRow = db.prepare(`
1326
- SELECT COUNT(*) as c
1327
- FROM messages_fts
1328
- WHERE messages_fts MATCH ?
1329
- `).get(q);
1330
-
1331
- send(res, 200, { results, total: totalRow.c });
1332
- } catch (ftsErr) {
1333
- console.warn('[DB] FTS message search failed, returning empty results:', ftsErr.message);
1334
- send(res, 200, { results: [], total: 0 });
1335
- }
1336
- }
1337
-
1338
- // --- Export ---
1339
-
1340
- function handleExport(req, res) {
1341
- const db = getActiveDb();
1342
- const ws = getWorkspaces();
1343
- const threads = db.prepare('SELECT * FROM threads ORDER BY updated_at DESC').all();
1344
- const data = {
1345
- workspace: ws.active,
1346
- exportedAt: Date.now(),
1347
- threads: threads.map(t => {
1348
- const messages = db.prepare(
1349
- 'SELECT * FROM messages WHERE thread_id = ? ORDER BY timestamp ASC'
1350
- ).all(t.id);
1351
- // Parse metadata
1352
- for (const m of messages) {
1353
- if (m.metadata) {
1354
- try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ }
1355
- }
1356
- }
1357
- return { ...t, messages };
1358
- }),
1359
- };
1360
- send(res, 200, data);
1361
- }
1362
-
1363
- // --- Import ---
1364
-
1365
- async function handleImport(req, res) {
1366
- const body = await parseBody(req);
1367
- const db = getActiveDb();
1368
- const ws = getWorkspaces();
1369
-
1370
- if (!body.threads || !Array.isArray(body.threads)) {
1371
- return sendError(res, 400, 'Expected { threads: [...] }');
1372
- }
1373
-
1374
- let threadsImported = 0;
1375
- let messagesImported = 0;
1376
-
1377
- const insertThread = db.prepare(
1378
- 'INSERT OR IGNORE INTO threads (id, session_key, title, pinned, pin_order, model, last_session_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
1379
- );
1380
- const insertMsg = db.prepare(
1381
- 'INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
1382
- );
1383
-
1384
- const importAll = db.transaction(() => {
1385
- for (const t of body.threads) {
1386
- if (!t.id) continue;
1387
- // TODO: per-project agent — import uses agent:main for now
1388
- const sessionKey = t.session_key || `agent:main:${ws.active}:chat:${t.id}`;
1389
- const result = insertThread.run(
1390
- t.id, sessionKey, t.title || 'Imported chat',
1391
- t.pinned || 0, t.pin_order || 0, t.model || null,
1392
- t.last_session_id || null, t.created_at || Date.now(), t.updated_at || Date.now()
1393
- );
1394
- if (result.changes > 0) threadsImported++;
1395
-
1396
- const messages = t.messages || [];
1397
- for (const m of messages) {
1398
- if (!m.id || !m.role) continue;
1399
- const metadata = m.metadata ? (typeof m.metadata === 'string' ? m.metadata : JSON.stringify(m.metadata)) : null;
1400
- const r = insertMsg.run(
1401
- m.id, t.id, m.role, m.content || '', m.status || 'sent',
1402
- metadata, m.seq || null, m.timestamp || Date.now(), m.created_at || Date.now()
1403
- );
1404
- if (r.changes > 0) messagesImported++;
1405
- }
1406
- }
1407
- });
1408
-
1409
- importAll();
1410
- send(res, 200, { ok: true, threadsImported, messagesImported });
1411
- }
1412
-
1413
- // --- File Upload ---
1414
-
1415
- async function handleUpload(req, res, params) {
1416
- const db = getActiveDb();
1417
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
1418
- if (!thread) return sendError(res, 404, 'Thread not found');
1419
-
1420
- const files = await parseMultipart(req);
1421
- const threadUploadDir = path.join(UPLOADS_DIR, params.id);
1422
- fs.mkdirSync(threadUploadDir, { recursive: true });
1423
-
1424
- const savedFiles = [];
1425
- for (const file of files) {
1426
- const fileId = uuid();
1427
- const ext = path.extname(file.filename) || '';
1428
- const savedName = fileId + ext;
1429
- const filePath = path.join(threadUploadDir, savedName);
1430
- fs.writeFileSync(filePath, file.data);
1431
- savedFiles.push({
1432
- id: fileId,
1433
- filename: file.filename,
1434
- path: `/api/uploads/${params.id}/${fileId}${ext}`,
1435
- mimeType: file.mimeType,
1436
- size: file.data.length,
1437
- });
1438
- }
1439
-
1440
- send(res, 200, { files: savedFiles });
1441
- }
1442
-
1443
- function handleServeUpload(req, res, params) {
1444
- const filePath = path.join(UPLOADS_DIR, params.threadId, params.fileId);
1445
- // Also try with common extensions
1446
- let resolved = filePath;
1447
- if (!fs.existsSync(resolved)) {
1448
- // Scan directory for file starting with fileId
1449
- const dir = path.join(UPLOADS_DIR, params.threadId);
1450
- try {
1451
- const entries = fs.readdirSync(dir);
1452
- const match = entries.find(e => e.startsWith(params.fileId.replace(/\.[^.]+$/, '')));
1453
- if (match) resolved = path.join(dir, match);
1454
- } catch { /* dir not found */ }
1455
- }
1456
-
1457
- if (!fs.existsSync(resolved)) {
1458
- return sendError(res, 404, 'File not found');
1459
- }
1460
-
1461
- const ext = path.extname(resolved).toLowerCase();
1462
- const mimeTypes = {
1463
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1464
- '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
1465
- '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
1466
- };
1467
- const contentType = mimeTypes[ext] || 'application/octet-stream';
1468
-
1469
- const stat = fs.statSync(resolved);
1470
- res.writeHead(200, {
1471
- 'Content-Type': contentType,
1472
- 'Content-Length': stat.size,
1473
- 'Cache-Control': 'public, max-age=86400',
1474
- 'Access-Control-Allow-Origin': '*',
1475
- });
1476
- fs.createReadStream(resolved).pipe(res);
1477
- }
1478
-
1479
- // ─── Intelligence (per-thread, per-workspace) ──────────────────────────────
1480
-
1481
- const INTELLIGENCE_DIR = path.join(DATA_DIR, 'intelligence');
1482
-
1483
- function getIntelligencePath(threadId) {
1484
- const workspace = getWorkspaces().active;
1485
- return path.join(INTELLIGENCE_DIR, workspace, `${threadId}.json`);
1486
- }
1487
-
1488
- function handleGetIntelligence(req, res, params) {
1489
- const filePath = getIntelligencePath(params.id);
1490
- if (!fs.existsSync(filePath)) {
1491
- return send(res, 200, { versions: [], currentVersion: -1 });
1492
- }
1493
- try {
1494
- const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
1495
- return send(res, 200, data);
1496
- } catch (err) {
1497
- return send(res, 200, { versions: [], currentVersion: -1 });
1498
- }
1499
- }
1500
-
1501
- async function handleSaveIntelligence(req, res, params) {
1502
- const body = await parseBody(req);
1503
- const filePath = getIntelligencePath(params.id);
1504
- const dir = path.dirname(filePath);
1505
- fs.mkdirSync(dir, { recursive: true });
1506
-
1507
- // body should have { versions, currentVersion }
1508
- const data = {
1509
- versions: body.versions || [],
1510
- currentVersion: body.currentVersion ?? -1,
1511
- updatedAt: Date.now()
1512
- };
1513
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
1514
- return send(res, 200, data);
1515
- }
1516
-
1517
796
  // ─── File Serving (restricted to ~/.openclaw/media/) ────────────────────────
1518
797
 
1519
798
  const ALLOWED_FILE_DIRS = [
@@ -2040,169 +1319,6 @@ function createMemoryProvider(config) {
2040
1319
  return createQdrantProvider(config);
2041
1320
  }
2042
1321
 
2043
- const memoryProvider = createMemoryProvider(MEMORY_CONFIG);
2044
- // Initialize provider (auto-detect collection etc.) — runs async at startup
2045
- memoryProvider.init().catch(err => console.error('Memory provider init error:', err.message));
2046
-
2047
- console.log(`Memory: provider=${MEMORY_CONFIG.provider} host=${MEMORY_CONFIG.host}:${MEMORY_CONFIG.port} collection=${MEMORY_CONFIG.collection || '(auto-detect)'}`);
2048
-
2049
- // ─── Memory Handlers ────────────────────────────────────────────────────────
2050
-
2051
- async function handleMemoryList(req, res, query) {
2052
- const limit = Math.min(parseInt(query.limit) || 20, 100);
2053
- try {
2054
- const result = await memoryProvider.list(limit, query.offset || null);
2055
- send(res, 200, result);
2056
- } catch (err) {
2057
- send(res, 502, { error: `Failed to reach ${memoryProvider.name}`, detail: err.message });
2058
- }
2059
- }
2060
-
2061
- async function handleMemorySearch(req, res, query) {
2062
- const q = (query.query || '').toLowerCase().trim();
2063
- if (!q) return send(res, 400, { error: 'Missing query parameter' });
2064
- try {
2065
- const result = await memoryProvider.search(q);
2066
- send(res, 200, result);
2067
- } catch (err) {
2068
- send(res, 502, { error: `Failed to reach ${memoryProvider.name}`, detail: err.message });
2069
- }
2070
- }
2071
-
2072
- const MEMORY_FILES_DIR = path.join(MEMORY_CONFIG.workspaceDir, 'memory');
2073
-
2074
- function parseMemoryFiles() {
2075
- const memories = [];
2076
- let files;
2077
- try {
2078
- files = fs.readdirSync(MEMORY_FILES_DIR);
2079
- } catch { return memories; }
2080
-
2081
- for (const file of files) {
2082
- if (!file.endsWith('.md')) continue;
2083
- const filePath = path.join(MEMORY_FILES_DIR, file);
2084
- let stat;
2085
- try { stat = fs.statSync(filePath); } catch { continue; }
2086
- if (!stat.isFile()) continue;
2087
-
2088
- const content = fs.readFileSync(filePath, 'utf8');
2089
- const basename = file.replace(/\.md$/, '');
2090
-
2091
- // Extract date from filename (e.g. 2026-02-14 or 2026-02-09-topic-name)
2092
- const dateMatch = basename.match(/^(\d{4}-\d{2}-\d{2})/);
2093
- const fileDate = dateMatch ? dateMatch[1] : null;
2094
-
2095
- // Split by ## headings into sections
2096
- const sections = content.split(/^(?=## )/m);
2097
- for (const section of sections) {
2098
- const trimmed = section.trim();
2099
- if (!trimmed) continue;
2100
-
2101
- // Extract heading
2102
- const headingMatch = trimmed.match(/^##\s+(.+)/);
2103
- const heading = headingMatch ? headingMatch[1].trim() : null;
2104
- // Body is everything after heading line (or whole section if top-level)
2105
- const body = headingMatch ? trimmed.slice(trimmed.indexOf('\n') + 1).trim() : trimmed;
2106
-
2107
- // Skip if it's just the top-level # heading with no real content
2108
- if (!heading && body.match(/^#\s+/) && body.split('\n').length <= 2) continue;
2109
-
2110
- const title = heading || basename;
2111
- const id = `file:${basename}:${title}`;
2112
- memories.push({
2113
- id,
2114
- source: 'file',
2115
- file: basename,
2116
- title,
2117
- data: heading ? `**${title}**\n${body}` : body,
2118
- createdAt: fileDate ? `${fileDate}T00:00:00Z` : stat.mtime.toISOString(),
2119
- });
2120
- }
2121
- }
2122
-
2123
- // Also scan subdirectories one level deep
2124
- try {
2125
- for (const entry of fs.readdirSync(MEMORY_FILES_DIR)) {
2126
- const subdir = path.join(MEMORY_FILES_DIR, entry);
2127
- if (!fs.statSync(subdir).isDirectory()) continue;
2128
- for (const file of fs.readdirSync(subdir)) {
2129
- if (!file.endsWith('.md')) continue;
2130
- const filePath = path.join(subdir, file);
2131
- const content = fs.readFileSync(filePath, 'utf8');
2132
- const basename = file.replace(/\.md$/, '');
2133
- const relPath = `${entry}/${basename}`;
2134
- const stat = fs.statSync(filePath);
2135
-
2136
- memories.push({
2137
- id: `file:${relPath}`,
2138
- source: 'file',
2139
- file: relPath,
2140
- title: basename,
2141
- data: content.trim(),
2142
- createdAt: stat.mtime.toISOString(),
2143
- });
2144
- }
2145
- }
2146
- } catch { /* ignore */ }
2147
-
2148
- return memories;
2149
- }
2150
-
2151
- function handleMemoryFiles(req, res, query) {
2152
- const q = (query.query || '').toLowerCase().trim();
2153
- const memories = parseMemoryFiles();
2154
- const filtered = q
2155
- ? memories.filter(m => m.data.toLowerCase().includes(q) || m.title.toLowerCase().includes(q))
2156
- : memories;
2157
- // Sort newest first
2158
- filtered.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
2159
- send(res, 200, { memories: filtered });
2160
- }
2161
-
2162
- async function handleMemoryUpdate(req, res, params) {
2163
- const id = params.id;
2164
- try {
2165
- const chunks = [];
2166
- for await (const chunk of req) chunks.push(chunk);
2167
- const body = JSON.parse(Buffer.concat(chunks).toString());
2168
- const newData = (body.data || '').trim();
2169
- if (!newData) return send(res, 400, { error: 'Missing data field' });
2170
-
2171
- const result = await memoryProvider.update(id, newData);
2172
- send(res, 200, { ok: true, result });
2173
- } catch (err) {
2174
- send(res, 502, { error: 'Failed to update memory', detail: err.message });
2175
- }
2176
- }
2177
-
2178
- async function handleMemoryDelete(req, res, params) {
2179
- const id = params.id;
2180
- try {
2181
- const result = await memoryProvider.delete(id);
2182
- send(res, 200, { ok: true, result });
2183
- } catch (err) {
2184
- send(res, 502, { error: `Failed to reach ${memoryProvider.name}`, detail: err.message });
2185
- }
2186
- }
2187
-
2188
- async function handleMemoryStatus(req, res) {
2189
- const status = await memoryProvider.status();
2190
- const filesExist = fs.existsSync(MEMORY_FILES_DIR);
2191
- send(res, 200, {
2192
- provider: memoryProvider.name,
2193
- host: MEMORY_CONFIG.host,
2194
- port: MEMORY_CONFIG.port,
2195
- collection: MEMORY_CONFIG.collection,
2196
- backend: status,
2197
- memoryFilesDir: MEMORY_FILES_DIR,
2198
- memoryFilesDirExists: filesExist,
2199
- });
2200
- }
2201
-
2202
- // ─── Router ─────────────────────────────────────────────────────────────────
2203
-
2204
- // ─── Speech-to-text (Whisper API proxy) ─────────────────────────────────────
2205
-
2206
1322
  async function handleTranscribe(req, res) {
2207
1323
  try {
2208
1324
  const chunks = [];
@@ -2260,1399 +1376,49 @@ async function handleTranscribe(req, res) {
2260
1376
  }
2261
1377
  }
2262
1378
 
2263
- async function handleRequest(req, res) {
2264
- const _wsName = req.headers?.['x-workspace'];
2265
- const _requestDb = _wsName ? getDb(_wsName) : getActiveDb();
2266
- return _requestDbStore.run(_requestDb, () => _handleRequestImpl(req, res));
2267
- }
2268
1379
 
2269
- async function _handleRequestImpl(req, res) {
2270
- // Parse URL and query string
2271
- const [urlPath, queryString] = (req.url || '/').split('?');
2272
- const query = {};
2273
- if (queryString) {
2274
- for (const pair of queryString.split('&')) {
2275
- const [k, v] = pair.split('=');
2276
- if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '');
2277
- }
2278
- }
2279
- const method = req.method;
1380
+ // ─── Controllers ─────────────────────────────────────────────────────────────
2280
1381
 
2281
- // CORS preflight
2282
- if (method === 'OPTIONS') {
2283
- setCors(res);
2284
- res.writeHead(204);
2285
- return res.end();
1382
+ class WorkspaceController {
1383
+ constructor({ getDb, closeDb, getWorkspaces, setWorkspaces, dataDir, broadcast }) {
1384
+ this.getDb = getDb;
1385
+ this.closeDb = closeDb;
1386
+ this.getWorkspaces = getWorkspaces;
1387
+ this.setWorkspaces = setWorkspaces;
1388
+ this.dataDir = dataDir;
1389
+ this.broadcast = broadcast;
2286
1390
  }
2287
1391
 
2288
- // Serve static assets (no auth — browser authenticates via WS/API headers)
2289
- if (method === 'GET' && !urlPath.startsWith('/api/')) {
2290
- const STATIC_FILES = {
2291
- '/': 'index.html',
2292
- '/index.html': 'index.html',
2293
- '/app.js': 'app.js',
2294
- '/style.css': 'style.css',
2295
- '/error-handler.js': 'error-handler.js',
2296
- '/manifest.json': 'manifest.json',
2297
- '/favicon.ico': 'favicon.ico',
2298
- };
2299
- const fileName = STATIC_FILES[urlPath];
2300
- // Also serve /icons/*, /lib/*, /frontend/*, and /config.js
2301
- const isIcon = urlPath.startsWith('/icons/');
2302
- const isLib = urlPath.startsWith('/lib/');
2303
- const isFrontend = urlPath.startsWith('/frontend/');
2304
- const isEmoji = urlPath.startsWith('/emoji/');
2305
- const isConfig = urlPath === '/config.js';
2306
- const staticPath = fileName ? path.join(__dirname, fileName)
2307
- : (isIcon || isLib || isFrontend || isEmoji || isConfig) ? path.join(__dirname, urlPath.slice(1))
2308
- : null;
2309
- if (staticPath && fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
2310
- const ext = path.extname(staticPath).toLowerCase();
2311
- const mimeMap = {
2312
- '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
2313
- '.json': 'application/json', '.ico': 'image/x-icon',
2314
- '.png': 'image/png', '.svg': 'image/svg+xml', '.gif': 'image/gif', '.webp': 'image/webp',
2315
- };
2316
- const ct = mimeMap[ext] || 'application/octet-stream';
2317
- const stat = fs.statSync(staticPath);
2318
- res.writeHead(200, {
2319
- 'Content-Type': ct,
2320
- 'Content-Length': stat.size,
2321
- 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600',
2322
- });
2323
- return fs.createReadStream(staticPath).pipe(res);
2324
- }
2325
- }
2326
-
2327
- // Serve uploaded files (no auth required — files are accessed by URL)
2328
- let p;
2329
- if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) {
2330
- return handleServeUpload(req, res, p);
2331
- }
2332
-
2333
- // Custom emoji listing (no auth — public like static assets)
2334
- if (method === 'GET' && urlPath === '/api/emoji') {
2335
- try {
2336
- const db = getGlobalDb();
2337
- const rows = db.prepare('SELECT name, pack, url, mime_type FROM custom_emojis ORDER BY created_at DESC').all();
2338
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
2339
- return res.end(JSON.stringify(rows));
2340
- } catch (e) {
2341
- res.writeHead(500, { 'Content-Type': 'application/json' });
2342
- return res.end(JSON.stringify({ error: e.message }));
2343
- }
2344
- }
2345
-
2346
- // Search slackmojis.com (scrapes HTML search since JSON API has no search)
2347
- if (method === 'GET' && urlPath === '/api/emoji/search') {
2348
- const q = query.q || '';
2349
- if (!q) {
2350
- res.writeHead(400, { 'Content-Type': 'application/json' });
2351
- return res.end(JSON.stringify({ error: 'Missing ?q= parameter' }));
2352
- }
2353
- try {
2354
- const https = await import('https');
2355
- const fetchUrl = `https://slackmojis.com/emojis/search?query=${encodeURIComponent(q)}`;
2356
- const html = await new Promise((resolve, reject) => {
2357
- https.default.get(fetchUrl, (resp) => {
2358
- let body = '';
2359
- resp.on('data', chunk => body += chunk);
2360
- resp.on('end', () => resolve(body));
2361
- }).on('error', reject);
2362
- });
2363
- // Parse emoji entries from HTML:
2364
- // <li class='emoji name' title='name'>
2365
- // <a ... data-emoji-id="123" data-emoji-id-name="123-name" href="/emojis/123-name/download">
2366
- // <img ... src="https://emojis.slackmojis.com/emojis/images/.../name.png?..." />
2367
- const results = [];
2368
- const regex = /data-emoji-id-name="([^"]+)"[^>]*href="([^"]+)"[\s\S]*?<img[^>]*src="([^"]+)"/g;
2369
- let match;
2370
- while ((match = regex.exec(html)) !== null && results.length < 50) {
2371
- const idName = match[1]; // e.g. "57350-sextant"
2372
- const downloadPath = match[2];
2373
- const imageUrl = match[3];
2374
- const name = idName.replace(/^\d+-/, '');
2375
- results.push({
2376
- name,
2377
- image_url: imageUrl,
2378
- download_url: `https://slackmojis.com${downloadPath}`,
2379
- });
2380
- }
2381
- res.writeHead(200, { 'Content-Type': 'application/json' });
2382
- return res.end(JSON.stringify(results));
2383
- } catch (e) {
2384
- res.writeHead(500, { 'Content-Type': 'application/json' });
2385
- return res.end(JSON.stringify({ error: e.message }));
2386
- }
2387
- }
2388
-
2389
- // Add custom emoji (store URL in global.db)
2390
- if (method === 'POST' && urlPath === '/api/emoji/add') {
2391
- if (!checkAuth(req, res)) return;
2392
- try {
2393
- const { url, name, pack } = await parseBody(req);
2394
- if (!url || !name) {
2395
- res.writeHead(400, { 'Content-Type': 'application/json' });
2396
- return res.end(JSON.stringify({ error: 'Missing url or name' }));
2397
- }
2398
- const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2399
- const targetPack = pack || 'slackmojis';
2400
- // Determine mime type from URL extension
2401
- const urlLower = url.split('?')[0].toLowerCase();
2402
- let mimeType = 'image/png';
2403
- if (urlLower.endsWith('.gif')) mimeType = 'image/gif';
2404
- else if (urlLower.endsWith('.webp')) mimeType = 'image/webp';
2405
- else if (urlLower.endsWith('.jpg') || urlLower.endsWith('.jpeg')) mimeType = 'image/jpeg';
2406
-
2407
- const db = getGlobalDb();
2408
- db.prepare('INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)')
2409
- .run(safeName, targetPack, url, mimeType);
2410
-
2411
- res.writeHead(200, { 'Content-Type': 'application/json' });
2412
- return res.end(JSON.stringify({ name: safeName, pack: targetPack, url, mime_type: mimeType }));
2413
- } catch (e) {
2414
- res.writeHead(500, { 'Content-Type': 'application/json' });
2415
- return res.end(JSON.stringify({ error: e.message }));
2416
- }
2417
- }
2418
-
2419
- // Delete custom emoji
2420
- if (method === 'DELETE' && urlPath === '/api/emoji') {
2421
- if (!checkAuth(req, res)) return;
2422
- try {
2423
- const { name, pack } = await parseBody(req);
2424
- if (!name || !pack) {
2425
- res.writeHead(400, { 'Content-Type': 'application/json' });
2426
- return res.end(JSON.stringify({ error: 'Missing name or pack' }));
2427
- }
2428
- const db = getGlobalDb();
2429
- db.prepare('DELETE FROM custom_emojis WHERE name = ? AND pack = ?').run(name, pack);
2430
- res.writeHead(200, { 'Content-Type': 'application/json' });
2431
- return res.end(JSON.stringify({ ok: true }));
2432
- } catch (e) {
2433
- res.writeHead(500, { 'Content-Type': 'application/json' });
2434
- return res.end(JSON.stringify({ error: e.message }));
2435
- }
2436
- }
2437
-
2438
- // Auth check for all other routes
2439
- if (!checkAuth(req, res)) return;
2440
-
2441
- try {
2442
- // --- File serving (absolute paths from gateway) ---
2443
- if (method === 'GET' && urlPath === '/api/file') {
2444
- return handleServeFile(req, res, query);
2445
- }
2446
-
2447
- // --- Workspace file browser ---
2448
- if (method === 'GET' && urlPath === '/api/workspace') {
2449
- return handleWorkspaceList(req, res, query);
2450
- }
2451
- if (method === 'GET' && urlPath === '/api/workspace/file') {
2452
- return handleWorkspaceFileRead(req, res, query);
2453
- }
2454
- if (method === 'PUT' && urlPath === '/api/workspace/file') {
2455
- return await handleWorkspaceFileWrite(req, res, query);
2456
- }
2457
- if (method === 'DELETE' && urlPath === '/api/workspace/file') {
2458
- return handleWorkspaceFileDelete(req, res, query);
2459
- }
2460
- if (method === 'POST' && urlPath === '/api/workspace/upload') {
2461
- return await handleWorkspaceUpload(req, res, query);
2462
- }
2463
-
2464
- // --- Memory (configurable backend) ---
2465
- if (method === 'GET' && urlPath === '/api/memory/status') {
2466
- return await handleMemoryStatus(req, res);
2467
- }
2468
- if (method === 'GET' && urlPath === '/api/memory/list') {
2469
- return await handleMemoryList(req, res, query);
2470
- }
2471
- if (method === 'GET' && urlPath === '/api/memory/search') {
2472
- return await handleMemorySearch(req, res, query);
2473
- }
2474
- if (method === 'GET' && urlPath === '/api/memory/files') {
2475
- return handleMemoryFiles(req, res, query);
2476
- }
2477
- if ((p = matchRoute(method, urlPath, 'PUT /api/memory/:id'))) {
2478
- return await handleMemoryUpdate(req, res, p);
2479
- }
2480
- if ((p = matchRoute(method, urlPath, 'DELETE /api/memory/:id'))) {
2481
- return await handleMemoryDelete(req, res, p);
2482
- }
2483
-
2484
- // --- User settings ---
2485
- if (method === 'GET' && urlPath === '/api/settings') {
2486
- return handleGetSettings(req, res);
2487
- }
2488
- if (method === 'PUT' && urlPath === '/api/settings') {
2489
- return await handleSaveSettings(req, res);
2490
- }
2491
-
2492
- // --- Speech-to-text ---
2493
- if (method === 'POST' && urlPath === '/api/transcribe') {
2494
- return await handleTranscribe(req, res);
2495
- }
2496
-
2497
- // --- Agents ---
2498
- if (method === 'GET' && urlPath === '/api/agents') {
2499
- try {
2500
- const agentsDir = path.join(HOME, '.openclaw', 'agents');
2501
- const agents = fs.readdirSync(agentsDir, { withFileTypes: true })
2502
- .filter(e => e.isDirectory())
2503
- .map(e => e.name);
2504
- return send(res, 200, { agents });
2505
- } catch {
2506
- return send(res, 200, { agents: ['main'] });
2507
- }
2508
- }
2509
-
2510
- // --- Workspaces ---
2511
- if (method === 'GET' && urlPath === '/api/workspaces') {
2512
- return handleGetWorkspaces(req, res);
2513
- }
2514
- if (method === 'POST' && urlPath === '/api/workspaces') {
2515
- return await handleCreateWorkspace(req, res);
2516
- }
2517
- if ((p = matchRoute(method, urlPath, 'PATCH /api/workspaces/:name'))) {
2518
- return await handleUpdateWorkspace(req, res, p);
2519
- }
2520
- if ((p = matchRoute(method, urlPath, 'DELETE /api/workspaces/:name'))) {
2521
- return handleDeleteWorkspace(req, res, p);
2522
- }
2523
- if (method === 'POST' && urlPath === '/api/workspaces/reorder') {
2524
- return await handleReorderWorkspaces(req, res);
2525
- }
2526
- if ((p = matchRoute(method, urlPath, 'POST /api/workspaces/:name/activate'))) {
2527
- return handleActivateWorkspace(req, res, p);
2528
- }
2529
-
2530
- // --- Threads ---
2531
- if (method === 'GET' && urlPath === '/api/threads') {
2532
- return handleGetThreads(req, res, {}, query);
2533
- }
2534
- if (method === 'GET' && urlPath === '/api/threads/unread') {
2535
- return handleGetUnreadThreads(req, res);
2536
- }
2537
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/mark-read'))) {
2538
- return await handleMarkMessagesRead(req, res, p);
2539
- }
2540
- if (method === 'POST' && urlPath === '/api/threads') {
2541
- return await handleCreateThread(req, res);
2542
- }
2543
- // Thread-specific routes (must check more specific paths first)
2544
- if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/messages'))) {
2545
- return handleGetMessages(req, res, p, query);
2546
- }
2547
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) {
2548
- return await handleCreateMessage(req, res, p);
2549
- }
2550
- if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) {
2551
- return handleDeleteMessage(req, res, p);
2552
- }
2553
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) {
2554
- return handleContextFill(req, res, p);
2555
- }
2556
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
2557
- const db = getActiveDb();
2558
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
2559
- if (!thread) return sendError(res, 404, 'Thread not found');
2560
- // Regenerate: sets heuristic immediately (safe fallback), then fires AI upgrade
2561
- const activeWs = getWorkspaces().active;
2562
- gatewayClient.generateThreadTitle(db, p.id, activeWs);
2563
- return send(res, 200, { ok: true });
2564
- }
2565
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) {
2566
- return await handleUpload(req, res, p);
2567
- }
2568
- if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) {
2569
- return handleGetIntelligence(req, res, p);
2570
- }
2571
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) {
2572
- return await handleSaveIntelligence(req, res, p);
2573
- }
2574
- if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id'))) {
2575
- return handleGetThread(req, res, p);
2576
- }
2577
- if ((p = matchRoute(method, urlPath, 'PATCH /api/threads/:id'))) {
2578
- return await handleUpdateThread(req, res, p);
2579
- }
2580
- if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id'))) {
2581
- return handleDeleteThread(req, res, p);
2582
- }
2583
-
2584
- // --- Search ---
2585
- if (method === 'GET' && urlPath === '/api/search') {
2586
- return handleSearch(req, res, {}, query);
2587
- }
2588
-
2589
- // --- Export / Import ---
2590
- if (method === 'GET' && urlPath === '/api/export') {
2591
- return handleExport(req, res);
2592
- }
2593
- if (method === 'POST' && urlPath === '/api/import') {
2594
- return await handleImport(req, res);
2595
- }
2596
-
2597
- // --- Health check ---
2598
- if (method === 'GET' && urlPath === '/api/health') {
2599
- return send(res, 200, { ok: true, workspace: getWorkspaces().active, uptime: process.uptime() });
2600
- }
2601
-
2602
- // Not found
2603
- sendError(res, 404, `Not found: ${method} ${urlPath}`);
2604
- } catch (err) {
2605
- console.error(`Error handling ${method} ${urlPath}:`, err);
2606
- if (err.message && err.message.includes('UNIQUE constraint')) {
2607
- sendError(res, 409, 'Conflict: ' + err.message);
2608
- } else {
2609
- sendError(res, 500, err.message || 'Internal server error');
2610
- }
2611
- }
2612
- }
2613
-
2614
- // ─── Gateway WebSocket Client ───────────────────────────────────────────────
2615
-
2616
- class GatewayClient {
2617
- constructor() {
2618
- this.ws = null;
2619
- this.connected = false;
2620
- this.reconnectAttempts = 0;
2621
- this.maxReconnectDelay = 30000;
2622
- this.browserClients = new Map(); // Map<WebSocket, { activeWorkspace, activeThreadId }>
2623
- this.streamState = new Map(); // Map<sessionKey, { state, buffer, threadId }>
2624
- this.activityLogs = new Map(); // Map<runId, { sessionKey, steps, startTime }>
2625
-
2626
- // Clean up stale activity logs every 5 minutes (runs that never completed)
2627
- setInterval(() => {
2628
- const cutoff = Date.now() - 10 * 60 * 1000;
2629
- for (const [runId, log] of this.activityLogs) {
2630
- if (log.startTime < cutoff) {
2631
- if (log._messageId) {
2632
- const db = getDb(log._parsed?.workspace);
2633
- if (db) {
2634
- db.prepare(`
2635
- UPDATE messages SET content = '[Response interrupted]',
2636
- metadata = json_remove(metadata, '$.pending')
2637
- WHERE id = ? AND content = ''
2638
- `).run(log._messageId);
2639
- }
2640
- }
2641
- this.activityLogs.delete(runId);
2642
- }
2643
- }
2644
- }, 5 * 60 * 1000);
2645
- }
2646
-
2647
- connect() {
2648
- if (this.ws && (this.ws.readyState === WS.CONNECTING || this.ws.readyState === WS.OPEN)) {
2649
- return; // Already connecting or connected
2650
- }
2651
-
2652
- console.log(`Connecting to gateway at ${GATEWAY_WS_URL}...`);
2653
- this.ws = new WS(GATEWAY_WS_URL);
2654
-
2655
- this.ws.on('open', () => {
2656
- console.log('Gateway WebSocket connected');
2657
- this.reconnectAttempts = 0;
2658
- });
2659
-
2660
- this.ws.on('message', (data) => {
2661
- this.handleGatewayMessage(data.toString());
2662
- });
2663
-
2664
- this.ws.on('close', () => {
2665
- console.log('Gateway WebSocket closed');
2666
- this.connected = false;
2667
- this.broadcastGatewayStatus(false);
2668
- this.scheduleReconnect();
2669
- });
2670
-
2671
- this.ws.on('error', (err) => {
2672
- console.error('Gateway WebSocket error:', err.message);
2673
- });
2674
- }
2675
-
2676
- handleGatewayMessage(data) {
2677
- debugLogger.logFrame('GW→SRV', data);
2678
- let msg;
2679
- try {
2680
- msg = JSON.parse(data);
2681
- } catch (e) {
2682
- console.error('Invalid JSON from gateway:', data);
2683
- return;
2684
- }
2685
-
2686
- // Handle connect.challenge (handshake)
2687
- if (msg.type === 'event' && msg.event === 'connect.challenge') {
2688
- console.log('Received connect.challenge, sending auth...');
2689
- const nonce = msg.payload?.nonce || '';
2690
- const identityPath = path.join(DATA_DIR, 'device-identity.json');
2691
- const identity = _loadOrCreateDeviceIdentity(identityPath);
2692
- const device = _buildDeviceAuth(identity, {
2693
- clientId: 'gateway-client', clientMode: 'backend', role: 'operator',
2694
- scopes: ['operator.read', 'operator.write', 'operator.admin'], token: AUTH_TOKEN, nonce
2695
- });
2696
- this.ws.send(JSON.stringify({
2697
- type: 'req',
2698
- id: 'gw-connect-1',
2699
- method: 'connect',
2700
- params: {
2701
- minProtocol: 3,
2702
- maxProtocol: 3,
2703
- client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' },
2704
- role: 'operator',
2705
- scopes: ['operator.read', 'operator.write', 'operator.admin'],
2706
- device,
2707
- auth: { token: AUTH_TOKEN },
2708
- caps: ['tool-events']
2709
- }
2710
- }));
2711
- return;
2712
- }
2713
-
2714
- // Handle connect response (hello-ok)
2715
- if (msg.type === 'res' && msg.payload?.type === 'hello-ok') {
2716
- console.log('Gateway handshake complete');
2717
- this.connected = true;
2718
- this.broadcastGatewayStatus(true);
2719
- }
2720
-
2721
- // Forward all messages to browser clients
2722
- this.broadcastToBrowsers(data);
2723
-
2724
- // Process chat events for persistence
2725
- if (msg.type === 'event' && msg.event === 'chat' && msg.payload) {
2726
- this.handleChatEvent(msg.payload);
2727
- }
2728
-
2729
- // Process agent events for activity log
2730
- if (msg.type === 'event' && msg.event === 'agent' && msg.payload) {
2731
- this.handleAgentEvent(msg.payload);
2732
- }
2733
- }
2734
-
2735
- handleChatEvent(params) {
2736
- const { sessionKey, state, message, seq } = params;
2737
-
2738
- // Update streaming state
2739
- if (state === 'delta') {
2740
- const parsed = parseSessionKey(sessionKey);
2741
- if (parsed) {
2742
- const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming' };
2743
- const deltaText = extractContent(message);
2744
- existing.buffer += deltaText;
2745
- this.streamState.set(sessionKey, existing);
2746
- }
2747
- return;
2748
- }
2749
-
2750
- // Clear stream state on final/aborted/error
2751
- if (state === 'final' || state === 'aborted' || state === 'error') {
2752
- this.streamState.delete(sessionKey);
2753
- }
2754
-
2755
- // Intercept title generation responses (final, error, or aborted)
2756
- if (sessionKey && sessionKey.includes('__clawchats_title_')) {
2757
- if (state === 'final') {
2758
- const content = extractContent(message);
2759
- if (content && this.handleTitleResponse(sessionKey, content)) return;
2760
- } else if (state === 'error' || state === 'aborted') {
2761
- // Clean up pending entry by substring match — heuristic title stays
2762
- if (this._pendingTitleGens) {
2763
- for (const key of this._pendingTitleGens.keys()) {
2764
- if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; }
2765
- }
2766
- }
2767
- return;
2768
- }
2769
- }
2770
-
2771
- // Save assistant messages on final
2772
- if (state === 'final') {
2773
- this.saveAssistantMessage(sessionKey, message, seq);
2774
- }
2775
-
2776
- // Save error markers
2777
- if (state === 'error') {
2778
- this.saveErrorMarker(sessionKey, message);
2779
- }
2780
- }
2781
-
2782
- saveAssistantMessage(sessionKey, message, seq) {
2783
- const parsed = parseSessionKey(sessionKey);
2784
- if (!parsed) return;
2785
-
2786
- const ws = getWorkspaces();
2787
- if (!ws.workspaces[parsed.workspace]) {
2788
- console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`);
2789
- return;
2790
- }
2791
-
2792
- const db = getDb(parsed.workspace);
2793
-
2794
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2795
- if (!thread) {
2796
- console.log(`Ignoring response for deleted thread: ${parsed.threadId}`);
2797
- return;
2798
- }
2799
-
2800
- const content = extractContent(message);
2801
- if (!content || !content.trim()) {
2802
- console.log(`Skipping empty assistant response for thread ${parsed.threadId}`);
2803
- return;
2804
- }
2805
-
2806
- const now = Date.now();
2807
-
2808
- // Check for pending activity message
2809
- const pendingMsg = db.prepare(`
2810
- SELECT id, metadata FROM messages
2811
- WHERE thread_id = ? AND role = 'assistant'
2812
- AND json_extract(metadata, '$.pending') = 1
2813
- ORDER BY timestamp DESC LIMIT 1
2814
- `).get(parsed.threadId);
2815
-
2816
- let messageId;
2817
-
2818
- if (pendingMsg) {
2819
- // Merge final content into existing activity row
2820
- const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
2821
- delete metadata.pending;
2822
-
2823
- // Clean up: remove last assistant narration (it's the final reply text)
2824
- if (metadata.activityLog) {
2825
- const lastAssistantIdx = metadata.activityLog.findLastIndex(s => s.type === 'assistant');
2826
- if (lastAssistantIdx >= 0) metadata.activityLog.splice(lastAssistantIdx, 1);
2827
- metadata.activitySummary = this.generateActivitySummary(metadata.activityLog);
2828
- }
2829
-
2830
- db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?')
2831
- .run(content, JSON.stringify(metadata), now, pendingMsg.id);
2832
-
2833
- messageId = pendingMsg.id;
2834
- } else {
2835
- // No pending activity — normal INSERT (simple responses, no tools)
2836
- messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
2837
- db.prepare(`
2838
- INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at)
2839
- VALUES (?, ?, 'assistant', ?, 'sent', ?, ?)
2840
- ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp
2841
- `).run(messageId, parsed.threadId, content, now, now);
2842
- }
2843
-
2844
- // Thread timestamp + unreads + broadcast (same for both paths)
2845
- try {
2846
- db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
2847
- db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
2848
- syncThreadUnreadCount(db, parsed.threadId);
2849
-
2850
- const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
2851
- const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
2852
- const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
2853
-
2854
- this.broadcastToBrowsers(JSON.stringify({
2855
- type: 'clawchats',
2856
- event: 'message-saved',
2857
- threadId: parsed.threadId,
2858
- workspace: parsed.workspace,
2859
- messageId,
2860
- timestamp: now,
2861
- title: threadInfo?.title || 'Chat',
2862
- preview,
2863
- unreadCount
2864
- }));
2865
-
2866
- const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2867
- this.broadcastToBrowsers(JSON.stringify({
2868
- type: 'clawchats',
2869
- event: 'unread-update',
2870
- workspace: parsed.workspace,
2871
- threadId: parsed.threadId,
2872
- messageId,
2873
- action: 'new',
2874
- unreadCount,
2875
- workspaceUnreadTotal,
2876
- title: threadInfo?.title || 'Chat',
2877
- preview,
2878
- timestamp: now
2879
- }));
2880
-
2881
- console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
2882
-
2883
- // Auto-generate AI title upgrade after first assistant response
2884
- // Heuristic was already set on user message save; this fires the AI upgrade
2885
- const currentTitle = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId)?.title;
2886
- const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
2887
- if (msgCount === 2 || currentTitle === 'New chat') {
2888
- this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
2889
- }
2890
- } catch (e) {
2891
- console.error(`Failed to save assistant message:`, e.message);
2892
- }
2893
- }
2894
-
2895
- saveErrorMarker(sessionKey, message) {
2896
- const parsed = parseSessionKey(sessionKey);
2897
- if (!parsed) return;
2898
-
2899
- const ws = getWorkspaces();
2900
- if (!ws.workspaces[parsed.workspace]) return;
2901
-
2902
- const db = getDb(parsed.workspace);
2903
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2904
- if (!thread) return;
2905
-
2906
- const errorText = message?.error || message?.content || 'Unknown error';
2907
- const content = `[error] ${errorText}`;
2908
- const now = Date.now();
2909
- const messageId = `gw-error-${parsed.threadId}-${now}`;
2910
-
2911
- try {
2912
- db.prepare(
2913
- 'INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
2914
- ).run(messageId, parsed.threadId, 'system', content, 'sent', '{"transient":true}', now, now);
2915
-
2916
- console.log(`Saved error marker for ${parsed.workspace}/${parsed.threadId}`);
2917
- } catch (e) {
2918
- console.error(`Failed to save error marker:`, e.message);
2919
- }
2920
- }
2921
-
2922
- /**
2923
- * Generate a title for a thread. Optionally sets a heuristic title immediately,
2924
- * then fires an async AI title upgrade via the gateway.
2925
- * @param {boolean} skipHeuristic - If true, skip heuristic step (already set on user message save)
2926
- */
2927
- generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
2928
- const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId);
2929
- if (!thread) return;
2930
-
2931
- // Skip if AI title gen already in progress for this thread
2932
- const titleKey = `__clawchats_title_${threadId}`;
2933
- if (this._pendingTitleGens?.has(titleKey)) return;
2934
-
2935
- // Get first user message for heuristic title
2936
- const firstUserMsg = db.prepare(
2937
- "SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1"
2938
- ).get(threadId);
2939
- if (!firstUserMsg?.content) return;
2940
-
2941
- // Step 1: Heuristic title (immediate) — skip if already set on user message save
2942
- if (!skipHeuristic) {
2943
- const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim()
2944
- + (firstUserMsg.content.length > 40 ? '...' : '');
2945
- db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
2946
-
2947
- this.broadcastToBrowsers(JSON.stringify({
2948
- type: 'clawchats',
2949
- event: 'thread-title-updated',
2950
- threadId,
2951
- workspace,
2952
- title: heuristic
2953
- }));
2954
- }
2955
-
2956
- // Step 2: AI title upgrade (async, best-effort)
2957
- const messages = db.prepare(
2958
- 'SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6'
2959
- ).all(threadId);
2960
-
2961
- // Need at least 2 messages (user + assistant) for meaningful AI title
2962
- if (messages.length < 2) return;
2963
-
2964
- const conversation = messages.map(m => {
2965
- const role = m.role === 'user' ? 'User' : 'Assistant';
2966
- const content = m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content;
2967
- return `${role}: ${content}`;
2968
- }).join('\n\n');
2969
-
2970
- 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:`;
2971
-
2972
- const reqId = `title-${threadId}-${Date.now()}`;
2973
- if (!this._pendingTitleGens) this._pendingTitleGens = new Map();
2974
- this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
2975
-
2976
- // Timeout cleanup — prevent unbounded map growth if gateway never responds
2977
- setTimeout(() => {
2978
- if (this._pendingTitleGens?.has(titleKey)) {
2979
- this._pendingTitleGens.delete(titleKey);
2980
- console.log(`Title gen timeout for ${threadId} — keeping heuristic title`);
2981
- }
2982
- }, 30000);
2983
-
2984
- this.sendToGateway(JSON.stringify({
2985
- type: 'req',
2986
- id: reqId,
2987
- method: 'chat.send',
2988
- params: {
2989
- sessionKey: titleKey,
2990
- message: prompt,
2991
- deliver: false,
2992
- idempotencyKey: reqId
2993
- }
2994
- }));
2995
- }
2996
-
2997
- /**
2998
- * Handle AI title response from gateway.
2999
- * Returns true if the event was consumed (was a title gen response).
3000
- */
3001
- handleTitleResponse(sessionKey, content) {
3002
- if (!this._pendingTitleGens) return false;
3003
- // Gateway may prefix sessionKey (e.g. agent:main:__clawchats_title_xxx)
3004
- // Find the matching pending entry by substring
3005
- let matchKey = null, pending = null;
3006
- for (const [key, val] of this._pendingTitleGens) {
3007
- if (sessionKey === key || sessionKey.includes(key)) {
3008
- matchKey = key;
3009
- pending = val;
3010
- break;
3011
- }
3012
- }
3013
- if (!pending) return false;
3014
-
3015
- this._pendingTitleGens.delete(matchKey);
3016
-
3017
- let title = content.trim()
3018
- .replace(/^["']|["']$/g, '')
3019
- .replace(/^Title:\s*/i, '')
3020
- .replace(/\n.*/s, '')
3021
- .trim();
3022
-
3023
- if (title.length > 50) title = title.substring(0, 47) + '...';
3024
- if (title.length === 0 || title.length >= 100) return true; // bad response, keep heuristic
3025
-
3026
- const db = getDb(pending.workspace);
3027
- db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, pending.threadId);
3028
-
3029
- this.broadcastToBrowsers(JSON.stringify({
3030
- type: 'clawchats',
3031
- event: 'thread-title-updated',
3032
- threadId: pending.threadId,
3033
- workspace: pending.workspace,
3034
- title
3035
- }));
3036
-
3037
- console.log(`AI title generated for ${pending.threadId}: "${title}"`);
3038
- return true;
3039
- }
3040
-
3041
- handleAgentEvent(payload) {
3042
- const { runId, stream, data, sessionKey } = payload;
3043
- if (!runId) return;
3044
-
3045
- // Initialize log if needed
3046
- if (!this.activityLogs.has(runId)) {
3047
- this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
3048
- }
3049
- const log = this.activityLogs.get(runId);
3050
-
3051
- if (stream === 'assistant') {
3052
- // Capture intermediate text turns (narration between tool calls)
3053
- const text = data?.text || '';
3054
- if (text) {
3055
- let currentSegment = log._currentAssistantSegment;
3056
- if (!currentSegment || currentSegment._sealed) {
3057
- currentSegment = {
3058
- type: 'assistant',
3059
- timestamp: Date.now(),
3060
- text: text,
3061
- _sealed: false
3062
- };
3063
- log._currentAssistantSegment = currentSegment;
3064
- log.steps.push(currentSegment);
3065
- } else {
3066
- currentSegment.text = text;
3067
- }
3068
- }
3069
- // Don't broadcast on every assistant delta — too noisy
3070
- return;
3071
- }
3072
-
3073
- if (stream === 'thinking') {
3074
- const thinkingText = data?.text || '';
3075
- let thinkingStep = log.steps.find(s => s.type === 'thinking');
3076
- if (thinkingStep) {
3077
- thinkingStep.text = thinkingText;
3078
- } else {
3079
- log.steps.push({
3080
- type: 'thinking',
3081
- timestamp: Date.now(),
3082
- text: thinkingText
3083
- });
3084
- }
3085
- // Always write to DB; throttle broadcasts to every 300ms
3086
- this._writeActivityToDb(runId, log);
3087
- const now = Date.now();
3088
- if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
3089
- log._lastThinkingBroadcast = now;
3090
- this._broadcastActivityUpdate(runId, log);
3091
- }
3092
- }
3093
-
3094
- if (stream === 'tool') {
3095
- // Seal any current assistant text segment (narration before this tool call)
3096
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
3097
- log._currentAssistantSegment._sealed = true;
3098
- }
3099
-
3100
- const args = data?.args;
3101
- const argsMeta = args ? (args.command || args.path || args.query || args.url || Object.values(args).find(v => typeof v === 'string') || '') : '';
3102
- const step = {
3103
- type: 'tool',
3104
- timestamp: Date.now(),
3105
- name: data?.name || 'unknown',
3106
- phase: data?.phase || 'start',
3107
- toolCallId: data?.toolCallId,
3108
- meta: data?.meta || (argsMeta ? String(argsMeta) : undefined),
3109
- isError: data?.isError || false
3110
- };
3111
-
3112
- // On result phase, update the existing start step or add new
3113
- if (data?.phase === 'result') {
3114
- const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
3115
- if (existing) {
3116
- existing.phase = 'done';
3117
- existing.resultMeta = data?.meta;
3118
- existing.isError = data?.isError || false;
3119
- existing.durationMs = Date.now() - existing.timestamp;
3120
- } else {
3121
- step.phase = 'done';
3122
- log.steps.push(step);
3123
- }
3124
- } else if (data?.phase === 'update') {
3125
- const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
3126
- if (existing) {
3127
- if (data?.meta) existing.resultMeta = data.meta;
3128
- if (data?.isError) existing.isError = true;
3129
- existing.phase = 'running';
3130
- }
3131
- } else {
3132
- log.steps.push(step);
3133
- }
3134
-
3135
- this._writeActivityToDb(runId, log);
3136
- this._broadcastActivityUpdate(runId, log);
3137
- }
3138
-
3139
- if (stream === 'lifecycle') {
3140
- if (data?.phase === 'end' || data?.phase === 'error') {
3141
- // Seal any remaining assistant segment
3142
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
3143
- log._currentAssistantSegment._sealed = true;
3144
- }
3145
- // Remove the final assistant segment — that's the actual response, not narration
3146
- const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
3147
- if (lastAssistantIdx >= 0) {
3148
- log.steps.splice(lastAssistantIdx, 1);
3149
- }
3150
-
3151
- // Write final state to DB, then clean up
3152
- this._writeActivityToDb(runId, log);
3153
- this.activityLogs.delete(runId);
3154
- return;
3155
- }
3156
- }
3157
- }
3158
-
3159
- _writeActivityToDb(runId, log) {
3160
- writeActivityToDb(getDb, this.broadcastToBrowsers.bind(this), runId, log);
3161
- }
3162
-
3163
- _broadcastActivityUpdate(runId, log) {
3164
- const parsed = log._parsed;
3165
- if (!parsed || !log._messageId) return;
3166
-
3167
- const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
3168
-
3169
- this.broadcastToBrowsers(JSON.stringify({
3170
- type: 'clawchats',
3171
- event: 'activity-updated',
3172
- workspace: parsed.workspace,
3173
- threadId: parsed.threadId,
3174
- messageId: log._messageId,
3175
- activityLog: cleanSteps,
3176
- activitySummary: this.generateActivitySummary(log.steps)
3177
- }));
3178
- }
3179
-
3180
- generateActivitySummary(steps) {
3181
- return generateActivitySummary(steps);
3182
- }
3183
-
3184
- broadcastToBrowsers(data) {
3185
- debugLogger.logFrame('SRV→BR', data);
3186
- for (const client of this.browserClients.keys()) {
3187
- if (client.readyState === WS.OPEN) {
3188
- client.send(data);
3189
- }
3190
- }
3191
- }
3192
-
3193
- broadcastGatewayStatus(connected) {
3194
- const msg = JSON.stringify({
3195
- type: 'clawchats',
3196
- event: 'gateway-status',
3197
- connected
3198
- });
3199
- this.broadcastToBrowsers(msg);
3200
- }
3201
-
3202
- sendToGateway(data) {
3203
- debugLogger.logFrame('SRV→GW', data);
3204
- if (this.ws && this.ws.readyState === WS.OPEN) {
3205
- this.ws.send(data);
3206
- } else {
3207
- console.error('Cannot send to gateway: not connected');
3208
- }
3209
- }
3210
-
3211
- scheduleReconnect() {
3212
- const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
3213
- this.reconnectAttempts++;
3214
- console.log(`Reconnecting to gateway in ${delay}ms (attempt ${this.reconnectAttempts})...`);
3215
- setTimeout(() => this.connect(), delay);
3216
- }
3217
-
3218
- addBrowserClient(ws) {
3219
- this.browserClients.set(ws, { activeWorkspace: null, activeThreadId: null });
3220
-
3221
- // Send current gateway status
3222
- if (ws.readyState === WS.OPEN) {
3223
- ws.send(JSON.stringify({
3224
- type: 'clawchats',
3225
- event: 'gateway-status',
3226
- connected: this.connected
3227
- }));
3228
-
3229
- // Send stream-sync with current streaming states
3230
- const streams = [];
3231
- for (const [sessionKey, state] of this.streamState.entries()) {
3232
- if (state.state === 'streaming') {
3233
- streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
3234
- }
3235
- }
3236
- if (streams.length > 0) {
3237
- ws.send(JSON.stringify({
3238
- type: 'clawchats',
3239
- event: 'stream-sync',
3240
- streams
3241
- }));
3242
- }
3243
- }
3244
- }
3245
-
3246
- removeBrowserClient(ws) {
3247
- this.browserClients.delete(ws);
3248
- }
3249
-
3250
- setActiveThread(ws, workspace, threadId) {
3251
- const client = ws ? this.browserClients.get(ws) : null;
3252
- if (client) {
3253
- client.activeWorkspace = workspace;
3254
- client.activeThreadId = threadId;
3255
- }
3256
-
3257
- // Auto-clear unreads: opening a thread = reading it (read receipt)
3258
- if (workspace && threadId) {
3259
- try {
3260
- const wsData = getWorkspaces();
3261
- if (!wsData.workspaces[workspace]) return;
3262
-
3263
- const db = getDb(workspace);
3264
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(threadId);
3265
- if (!thread) return;
3266
-
3267
- // Delete all unread entries for this thread
3268
- const deleted = db.prepare('DELETE FROM unread_messages WHERE thread_id = ?').run(threadId);
3269
- if (deleted.changes > 0) {
3270
- syncThreadUnreadCount(db, threadId);
3271
-
3272
- // Broadcast unread-update clear to ALL browser clients
3273
- const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3274
- this.broadcastToBrowsers(JSON.stringify({
3275
- type: 'clawchats',
3276
- event: 'unread-update',
3277
- workspace,
3278
- threadId,
3279
- action: 'clear',
3280
- unreadCount: 0,
3281
- workspaceUnreadTotal,
3282
- timestamp: Date.now()
3283
- }));
3284
- }
3285
- } catch (e) {
3286
- console.error('Failed to auto-clear unreads on active-thread:', e.message);
3287
- }
3288
- }
3289
- }
3290
- }
3291
-
3292
- // Helper: Recount unread_messages and sync threads.unread_count
3293
- function syncThreadUnreadCount(db, threadId) {
3294
- const count = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(threadId).c;
3295
- db.prepare('UPDATE threads SET unread_count = ? WHERE id = ?').run(count, threadId);
3296
- return count;
3297
- }
3298
-
3299
- // Helper: Parse session key
3300
- function parseSessionKey(sessionKey) {
3301
- if (!sessionKey) return null;
3302
- const match = sessionKey.match(/^agent:([^:]+):([^:]+):chat:([^:]+)$/);
3303
- if (!match) return null; // Non-ClawChats keys — silently ignore
3304
- return { agent: match[1], workspace: match[2], threadId: match[3] };
3305
- }
3306
-
3307
- // Helper: Extract content from message
3308
- function extractContent(message) {
3309
- if (!message) return '';
3310
- if (typeof message.content === 'string') return message.content;
3311
- if (Array.isArray(message.content)) {
3312
- return message.content
3313
- .filter(part => part.type === 'text')
3314
- .map(part => part.text)
3315
- .join('');
3316
- }
3317
- return '';
3318
- }
3319
-
3320
- // ─── Sentinel filtering helpers ───────────────────────────────────────────────
3321
- // Mirrors logic from OpenClaw's tokens-C27XM9Ox.js so we can filter NO_REPLY /
3322
- // HEARTBEAT_OK tokens before they reach the browser — including partial leaks
3323
- // during streaming (e.g. "NO_" appearing mid-stream before the full token is
3324
- // assembled). See GitHub issue #96.
3325
-
3326
- // Returns true if text is exactly the sentinel (with optional surrounding whitespace).
3327
- function isSilentReplyExact(text, token = 'NO_REPLY') {
3328
- if (!text) return false;
3329
- const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3330
- return new RegExp(`^\\s*${escaped}\\s*$`).test(text);
3331
- }
3332
-
3333
- // Returns true if text could be the beginning of the sentinel token —
3334
- // i.e. it is a strict uppercase-only prefix of the token.
3335
- // Example: "NO" and "NO_" and "NO_REPLY" all return true for token="NO_REPLY".
3336
- function isSilentReplyPrefix(text, token = 'NO_REPLY') {
3337
- if (!text) return false;
3338
- const trimmed = text.trimStart();
3339
- if (!trimmed) return false;
3340
- if (trimmed !== trimmed.toUpperCase()) return false; // must be ALL CAPS
3341
- const normalized = trimmed.toUpperCase();
3342
- if (normalized.length < 2) return false;
3343
- if (/[^A-Z_]/.test(normalized)) return false; // only letters + underscore
3344
- const tokenUpper = token.toUpperCase();
3345
- if (!tokenUpper.startsWith(normalized)) return false; // must be a prefix
3346
- if (normalized.includes('_')) return true; // past the first word — unambiguous
3347
- // Single word without underscore: only allow "NO" for NO_REPLY (avoids false-positives
3348
- // on other short all-caps words like "HI", "OK", etc.)
3349
- return tokenUpper === 'NO_REPLY' && normalized === 'NO';
3350
- }
3351
-
3352
- // Strip a trailing sentinel token from mixed-content text (e.g. "answer NO_REPLY" → "answer").
3353
- function stripTrailingSentinel(text, token = 'NO_REPLY') {
3354
- if (!text) return text;
3355
- const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3356
- return text.replace(new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`), '').trim();
3357
- }
3358
-
3359
- // Strip internal <final> / </final> tags that occasionally leak from the model.
3360
- function stripFinalTags(text) {
3361
- if (!text) return text;
3362
- return text.replace(/<\s*\/?\s*final\s*>/gi, '');
3363
- }
3364
-
3365
- // Apply all content sanitization steps before saving to DB.
3366
- function sanitizeAssistantContent(text) {
3367
- if (!text) return text;
3368
- let out = stripFinalTags(text);
3369
- out = out.replace(/^(?:[ \t]*\r?\n)+/, ''); // strip leading blank lines
3370
- if (out.includes('NO_REPLY')) out = stripTrailingSentinel(out, 'NO_REPLY');
3371
- if (out.includes('HEARTBEAT_OK')) out = stripTrailingSentinel(out, 'HEARTBEAT_OK');
3372
- return out;
3373
- }
3374
-
3375
- // ─── Shared activity log helpers ─────────────────────────────────────────────
3376
- // Pure functions extracted from GatewayClient / _GatewayClient so both classes
3377
- // share a single implementation. Pass the workspace-scoped DB getter and a
3378
- // bound broadcastToBrowsers fn as arguments.
3379
-
3380
- function generateActivitySummary(steps) {
3381
- const toolSteps = steps.filter(s => s.type === 'tool' && s.phase !== 'result' && s.phase !== 'update');
3382
- const hasThinking = steps.some(s => s.type === 'thinking' && s.text);
3383
- const hasNarration = steps.some(s => s.type === 'assistant' && s.text?.trim());
3384
- if (toolSteps.length === 0 && !hasThinking && !hasNarration) return null;
3385
- if (toolSteps.length === 0 && hasThinking) return 'Reasoned through the problem';
3386
- if (toolSteps.length === 0 && hasNarration) return 'Processed in multiple steps';
3387
-
3388
- const counts = {};
3389
- for (const s of toolSteps) {
3390
- const name = s.name || 'unknown';
3391
- counts[name] = (counts[name] || 0) + 1;
3392
- }
3393
-
3394
- const parts = [];
3395
- const toolNames = {
3396
- 'web_search': 'searched the web',
3397
- 'web_fetch': 'fetched web pages',
3398
- 'Read': 'read files',
3399
- 'read': 'read files',
3400
- 'Write': 'wrote files',
3401
- 'write': 'wrote files',
3402
- 'Edit': 'edited files',
3403
- 'edit': 'edited files',
3404
- 'exec': 'ran commands',
3405
- 'Bash': 'ran commands',
3406
- 'browser': 'browsed the web',
3407
- 'memory_search': 'searched memory',
3408
- 'memory_store': 'saved to memory',
3409
- 'image': 'analyzed images',
3410
- 'message': 'sent messages',
3411
- 'sessions_spawn': 'spawned sub-agents',
3412
- 'cron': 'managed cron jobs',
3413
- 'Grep': 'searched code',
3414
- 'grep': 'searched code',
3415
- 'Glob': 'found files',
3416
- 'glob': 'found files'
3417
- };
3418
-
3419
- for (const [name, count] of Object.entries(counts)) {
3420
- const friendly = toolNames[name];
3421
- if (friendly) {
3422
- parts.push(count > 1 ? `${friendly} (${count}×)` : friendly);
3423
- } else {
3424
- parts.push(count > 1 ? `used ${name} (${count}×)` : `used ${name}`);
3425
- }
3426
- }
3427
-
3428
- if (parts.length === 0) return null;
3429
- if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
3430
- const last = parts.pop();
3431
- return (parts.join(', ') + ' and ' + last).replace(/^./, c => c.toUpperCase());
3432
- }
3433
-
3434
- function writeActivityToDb(getDbFn, broadcastFn, runId, log) {
3435
- if (!log._parsed) {
3436
- log._parsed = parseSessionKey(log.sessionKey);
3437
- }
3438
- const parsed = log._parsed;
3439
- if (!parsed) return;
3440
-
3441
- const db = getDbFn(parsed.workspace);
3442
- if (!db) return;
3443
-
3444
- const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
3445
- const summary = generateActivitySummary(log.steps);
3446
- const now = Date.now();
3447
-
3448
- if (!log._messageId) {
3449
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
3450
- if (!thread) return;
3451
-
3452
- const messageId = `gw-activity-${runId}`;
3453
- const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
3454
-
3455
- try {
3456
- db.prepare(`
3457
- INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
3458
- VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
3459
- `).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
3460
-
3461
- log._messageId = messageId;
3462
-
3463
- broadcastFn(JSON.stringify({
3464
- type: 'clawchats',
3465
- event: 'message-saved',
3466
- threadId: parsed.threadId,
3467
- workspace: parsed.workspace,
3468
- messageId,
3469
- timestamp: now
3470
- }));
3471
- } catch (err) {
3472
- console.error(`[activity] Failed to write activity ${messageId}:`, err.message);
3473
- }
3474
- } else {
3475
- const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
3476
- const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
3477
- metadata.activityLog = cleanSteps;
3478
- metadata.activitySummary = summary;
3479
- metadata.pending = true;
3480
-
3481
- db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
3482
- .run(JSON.stringify(metadata), log._messageId);
3483
- }
3484
- }
3485
-
3486
- const gatewayClient = new GatewayClient();
3487
-
3488
- // ─── createApp Factory ───────────────────────────────────────────────────────
3489
- // Returns an isolated instance of the app state + handlers.
3490
- // Used by the plugin (signaling/index.js) to embed ClawChats logic without
3491
- // spinning up a standalone HTTP server.
3492
-
3493
- export function createApp(config = {}) {
3494
- // ── Config-dependent constants ─────────────────────────────────────────────
3495
- const _DATA_DIR = config.dataDir || path.join(__dirname, 'data');
3496
- const _UPLOADS_DIR = config.uploadsDir || path.join(__dirname, 'uploads');
3497
- const _WORKSPACES_FILE = path.join(_DATA_DIR, 'workspaces.json');
3498
- const _SETTINGS_FILE = path.join(_DATA_DIR, 'settings.json');
3499
- const _INTELLIGENCE_DIR = path.join(_DATA_DIR, 'intelligence');
3500
-
3501
- let _AUTH_TOKEN = config.authToken !== undefined
3502
- ? config.authToken
3503
- : (process.env.CLAWCHATS_AUTH_TOKEN || process.env.SHELLCHAT_AUTH_TOKEN || parseConfigField('authToken') || '');
3504
-
3505
- // Separate token for gateway WS auth (falls back to _AUTH_TOKEN for direct mode)
3506
- const _GATEWAY_TOKEN = config.gatewayToken !== undefined
3507
- ? config.gatewayToken
3508
- : _AUTH_TOKEN;
3509
-
3510
- const _GATEWAY_WS_URL = config.gatewayUrl || discoverGatewayWsUrl();
3511
-
3512
- // ── Mutable singleton state ────────────────────────────────────────────────
3513
- const _dbCache = new Map();
3514
- let _workspacesConfig = null;
3515
- const _debugLogger = new DebugLogger(_DATA_DIR);
3516
-
3517
- const _MEMORY_CONFIG = discoverMemoryConfig();
3518
- const _memoryProvider = createMemoryProvider(_MEMORY_CONFIG);
3519
- _memoryProvider.init().catch(err => console.error('[createApp] Memory provider init error:', err.message));
3520
-
3521
- const _MEMORY_FILES_DIR = path.join(_MEMORY_CONFIG.workspaceDir, 'memory');
3522
-
3523
- // ── Workspace helpers ──────────────────────────────────────────────────────
3524
- function _loadWorkspaces() {
3525
- try {
3526
- return JSON.parse(fs.readFileSync(_WORKSPACES_FILE, 'utf8'));
3527
- } catch {
3528
- const initial = {
3529
- active: 'default',
3530
- workspaces: {
3531
- default: { name: 'default', label: 'Default', createdAt: Date.now() }
3532
- }
3533
- };
3534
- fs.writeFileSync(_WORKSPACES_FILE, JSON.stringify(initial, null, 2));
3535
- return initial;
3536
- }
3537
- }
3538
-
3539
- function _saveWorkspaces(data) {
3540
- fs.writeFileSync(_WORKSPACES_FILE, JSON.stringify(data, null, 2));
3541
- }
3542
-
3543
- function _getWorkspaces() {
3544
- if (!_workspacesConfig) _workspacesConfig = _loadWorkspaces();
3545
- return _workspacesConfig;
3546
- }
3547
-
3548
- function _setWorkspaces(data) {
3549
- _workspacesConfig = data;
3550
- _saveWorkspaces(data);
3551
- }
3552
-
3553
- // ── Database helpers ───────────────────────────────────────────────────────
3554
- function _getDb(workspaceName) {
3555
- if (_dbCache.has(workspaceName)) return _dbCache.get(workspaceName);
3556
- const dbPath = path.join(_DATA_DIR, `${workspaceName}.db`);
3557
- const db = new Database(dbPath);
3558
- db.pragma('journal_mode = WAL');
3559
- db.pragma('foreign_keys = ON');
3560
- migrate(db);
3561
- _dbCache.set(workspaceName, db);
3562
- return db;
3563
- }
3564
-
3565
- function _getActiveDb() {
3566
- return _requestDbStore.getStore() || _getDb(_getWorkspaces().active);
3567
- }
3568
-
3569
- function _closeDb(workspaceName) {
3570
- const db = _dbCache.get(workspaceName);
3571
- if (db) { db.close(); _dbCache.delete(workspaceName); }
3572
- }
3573
-
3574
- function _closeAllDbs() {
3575
- for (const [, db] of _dbCache) db.close();
3576
- _dbCache.clear();
3577
- }
3578
-
3579
- function _ensureDirs() {
3580
- fs.mkdirSync(_DATA_DIR, { recursive: true });
3581
- fs.mkdirSync(_UPLOADS_DIR, { recursive: true });
3582
- }
3583
-
3584
- // ── Auth (closes over _AUTH_TOKEN) ─────────────────────────────────────────
3585
- function _checkAuth(req, res) {
3586
- if (!_AUTH_TOKEN) return true;
3587
- const auth = req.headers.authorization;
3588
- if (!auth || !auth.startsWith('Bearer ')) {
3589
- sendError(res, 401, 'Missing or invalid Authorization header');
3590
- return false;
3591
- }
3592
- const token = auth.slice(7);
3593
- if (token !== _AUTH_TOKEN) {
3594
- sendError(res, 401, 'Invalid auth token');
3595
- return false;
3596
- }
3597
- return true;
3598
- }
3599
-
3600
- // ── Route handlers (all close over _getDb, _getActiveDb, _getWorkspaces, etc.) ──
3601
-
3602
- function _handleGetSettings(req, res) {
3603
- try {
3604
- const data = fs.existsSync(_SETTINGS_FILE)
3605
- ? JSON.parse(fs.readFileSync(_SETTINGS_FILE, 'utf8'))
3606
- : {};
3607
- return send(res, 200, data);
3608
- } catch { return send(res, 200, {}); }
3609
- }
3610
-
3611
- async function _handleSaveSettings(req, res) {
3612
- const body = await parseBody(req);
3613
- try {
3614
- const existing = fs.existsSync(_SETTINGS_FILE)
3615
- ? JSON.parse(fs.readFileSync(_SETTINGS_FILE, 'utf8'))
3616
- : {};
3617
- const merged = { ...existing, ...body };
3618
- fs.writeFileSync(_SETTINGS_FILE, JSON.stringify(merged, null, 2));
3619
- return send(res, 200, merged);
3620
- } catch (err) { return send(res, 500, { error: err.message }); }
3621
- }
3622
-
3623
- function _handleGetWorkspaces(req, res) {
3624
- const ws = _getWorkspaces();
1392
+ getAll(req, res) {
1393
+ const ws = this.getWorkspaces();
3625
1394
  const sorted = Object.values(ws.workspaces).sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
3626
1395
  for (const workspace of sorted) {
3627
1396
  try {
3628
- const db = _getDb(workspace.name);
3629
- const result = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get();
3630
- workspace.unread_count = result.total;
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;
3631
1399
  } catch { workspace.unread_count = 0; }
3632
1400
  }
3633
1401
  send(res, 200, { active: ws.active, workspaces: sorted });
3634
1402
  }
3635
1403
 
3636
- async function _handleCreateWorkspace(req, res) {
1404
+ async create(req, res) {
3637
1405
  const body = await parseBody(req);
3638
1406
  const { name, label } = body;
3639
- if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) {
3640
- return sendError(res, 400, 'Name must be [a-z0-9-], 1-32 chars');
3641
- }
3642
- const ws = _getWorkspaces();
1407
+ if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) return sendError(res, 400, 'Name must be [a-z0-9-], 1-32 chars');
1408
+ const ws = this.getWorkspaces();
3643
1409
  if (ws.workspaces[name]) return sendError(res, 409, 'Workspace already exists');
3644
1410
  let agent = 'main';
3645
1411
  try { agent = validateAgent(body.agent || 'main'); } catch { agent = 'main'; }
3646
1412
  const workspace = { name, label: label || name, color: body.color || null, icon: body.icon || null, agent, createdAt: Date.now() };
3647
1413
  ws.workspaces[name] = workspace;
3648
- _setWorkspaces(ws);
3649
- _getDb(name);
1414
+ this.setWorkspaces(ws);
1415
+ this.getDb(name);
3650
1416
  send(res, 201, { workspace });
3651
1417
  }
3652
1418
 
3653
- async function _handleUpdateWorkspace(req, res, params) {
1419
+ async update(req, res, params) {
3654
1420
  const body = await parseBody(req);
3655
- const ws = _getWorkspaces();
1421
+ const ws = this.getWorkspaces();
3656
1422
  if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
3657
1423
  if (body.label !== undefined) ws.workspaces[params.name].label = body.label;
3658
1424
  if (body.color !== undefined) ws.workspaces[params.name].color = body.color;
@@ -3664,73 +1430,67 @@ export function createApp(config = {}) {
3664
1430
  try { newAgent = validateAgent(body.agent); } catch (e) { return sendError(res, 400, e.message); }
3665
1431
  const oldAgent = ws.workspaces[params.name].agent || 'main';
3666
1432
  if (newAgent !== oldAgent) {
3667
- const db = _getDb(params.name);
3668
- const threads = db.prepare(
3669
- `SELECT id, session_key FROM threads WHERE session_key LIKE ?`
3670
- ).all(`agent:${oldAgent}:${params.name}:chat:%`);
3671
- db.prepare(`
3672
- UPDATE threads
3673
- SET session_key = replace(
3674
- session_key,
3675
- 'agent:' || ? || ':' || ? || ':chat:',
3676
- 'agent:' || ? || ':' || ? || ':chat:'
3677
- )
3678
- WHERE session_key LIKE 'agent:' || ? || ':' || ? || ':chat:%'
3679
- `).run(oldAgent, params.name, newAgent, params.name, oldAgent, params.name);
1433
+ const db = this.getDb(params.name);
1434
+ const threads = db.prepare(`SELECT id, session_key FROM threads WHERE session_key LIKE ?`).all(`agent:${oldAgent}:${params.name}:chat:%`);
1435
+ db.prepare(`UPDATE threads SET session_key = replace(session_key, 'agent:' || ? || ':' || ? || ':chat:', 'agent:' || ? || ':' || ? || ':chat:') WHERE session_key LIKE 'agent:' || ? || ':' || ? || ':chat:%'`).run(oldAgent, params.name, newAgent, params.name, oldAgent, params.name);
3680
1436
  for (const t of threads) cleanGatewaySession(t.session_key);
3681
1437
  ws.workspaces[params.name].agent = newAgent;
3682
1438
  migratedThreads = threads.length;
3683
- _gatewayClient.broadcastToBrowsers(JSON.stringify({
3684
- type: 'clawchats',
3685
- event: 'workspace-agent-changed',
3686
- workspace: params.name,
3687
- agent: newAgent
3688
- }));
1439
+ this.broadcast(JSON.stringify({ type: 'clawchats', event: 'workspace-agent-changed', workspace: params.name, agent: newAgent }));
3689
1440
  }
3690
1441
  }
3691
- _setWorkspaces(ws);
1442
+ this.setWorkspaces(ws);
3692
1443
  send(res, 200, { workspace: ws.workspaces[params.name], migratedThreads });
3693
1444
  }
3694
1445
 
3695
- function _handleDeleteWorkspace(req, res, params) {
3696
- const ws = _getWorkspaces();
1446
+ delete(req, res, params) {
1447
+ const ws = this.getWorkspaces();
3697
1448
  if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
3698
1449
  if (Object.keys(ws.workspaces).length <= 1) return sendError(res, 400, 'Cannot delete the only workspace');
3699
- _closeDb(params.name);
3700
- const dbPath = path.join(_DATA_DIR, `${params.name}.db`);
1450
+ this.closeDb(params.name);
1451
+ const dbPath = path.join(this.dataDir, `${params.name}.db`);
3701
1452
  try { fs.unlinkSync(dbPath); } catch { /* ok */ }
3702
1453
  try { fs.unlinkSync(dbPath + '-wal'); } catch { /* ok */ }
3703
1454
  try { fs.unlinkSync(dbPath + '-shm'); } catch { /* ok */ }
3704
- const wsAgentForDelete = ws.workspaces[params.name]?.agent || 'main';
3705
- const cleaned = cleanGatewaySessionsByPrefix(`agent:${wsAgentForDelete}:${params.name}:chat:`);
1455
+ const wsAgent = ws.workspaces[params.name]?.agent || 'main';
1456
+ const cleaned = cleanGatewaySessionsByPrefix(`agent:${wsAgent}:${params.name}:chat:`);
3706
1457
  if (cleaned > 0) console.log(`Cleaned ${cleaned} gateway sessions for workspace: ${params.name}`);
3707
1458
  delete ws.workspaces[params.name];
3708
1459
  if (ws.active === params.name) ws.active = Object.keys(ws.workspaces)[0] || null;
3709
- _setWorkspaces(ws);
1460
+ this.setWorkspaces(ws);
3710
1461
  send(res, 200, { ok: true });
3711
1462
  }
3712
1463
 
3713
- async function _handleReorderWorkspaces(req, res) {
1464
+ async reorder(req, res) {
3714
1465
  const body = await parseBody(req);
3715
1466
  const { order } = body;
3716
1467
  if (!Array.isArray(order)) return sendError(res, 400, 'order must be an array of workspace names');
3717
- const ws = _getWorkspaces();
1468
+ const ws = this.getWorkspaces();
3718
1469
  order.forEach((name, i) => { if (ws.workspaces[name]) ws.workspaces[name].order = i; });
3719
- _setWorkspaces(ws);
1470
+ this.setWorkspaces(ws);
3720
1471
  send(res, 200, { ok: true, workspaces: Object.values(ws.workspaces) });
3721
1472
  }
3722
1473
 
3723
- function _handleActivateWorkspace(req, res, params) {
3724
- const ws = _getWorkspaces();
1474
+ activate(req, res, params) {
1475
+ const ws = this.getWorkspaces();
3725
1476
  if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
3726
1477
  ws.active = params.name;
3727
- _setWorkspaces(ws);
3728
- _getDb(params.name);
1478
+ this.setWorkspaces(ws);
1479
+ this.getDb(params.name);
3729
1480
  send(res, 200, { ok: true, workspace: ws.workspaces[params.name] });
3730
1481
  }
1482
+ }
1483
+
1484
+ class ThreadController {
1485
+ constructor({ getActiveDb, getWorkspaces, uploadsDir, broadcast }) {
1486
+ this.getActiveDb = getActiveDb;
1487
+ this.getWorkspaces = getWorkspaces;
1488
+ this.uploadsDir = uploadsDir;
1489
+ this.broadcast = broadcast;
1490
+ }
3731
1491
 
3732
- function _handleGetThreads(req, res, params, query) {
3733
- const db = _getActiveDb();
1492
+ getAll(req, res, params, query) {
1493
+ const db = this.getActiveDb();
3734
1494
  const page = parseInt(query.page || '1', 10);
3735
1495
  const limit = Math.min(parseInt(query.limit || '50', 10), 200);
3736
1496
  const offset = (page - 1) * limit;
@@ -3738,16 +1498,12 @@ export function createApp(config = {}) {
3738
1498
  let threads, total;
3739
1499
  if (search) {
3740
1500
  try {
3741
- const ftsQuery = `SELECT DISTINCT m.thread_id FROM messages m JOIN messages_fts ON messages_fts.rowid = m.rowid WHERE messages_fts MATCH ?`;
3742
- const matchingIds = db.prepare(ftsQuery).all(search).map(r => r.thread_id);
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);
3743
1502
  if (matchingIds.length === 0) return send(res, 200, { threads: [], total: 0, page });
3744
- const placeholders = matchingIds.map(() => '?').join(',');
3745
- total = db.prepare(`SELECT COUNT(*) as c FROM threads WHERE id IN (${placeholders})`).get(...matchingIds).c;
3746
- threads = db.prepare(`SELECT * FROM threads WHERE id IN (${placeholders}) ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?`).all(...matchingIds, limit, offset);
3747
- } catch (ftsErr) {
3748
- console.warn('[DB] FTS thread search failed, returning empty results:', ftsErr.message);
3749
- return send(res, 200, { threads: [], total: 0, page });
3750
- }
1503
+ const ph = matchingIds.map(() => '?').join(',');
1504
+ total = db.prepare(`SELECT COUNT(*) as c FROM threads WHERE id IN (${ph})`).get(...matchingIds).c;
1505
+ 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 }); }
3751
1507
  } else {
3752
1508
  total = db.prepare('SELECT COUNT(*) as c FROM threads').get().c;
3753
1509
  threads = db.prepare('SELECT * FROM threads ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?').all(limit, offset);
@@ -3755,118 +1511,102 @@ export function createApp(config = {}) {
3755
1511
  send(res, 200, { threads, total, page });
3756
1512
  }
3757
1513
 
3758
- function _handleGetUnreadThreads(req, res) {
3759
- const db = _getActiveDb();
3760
- const threads = db.prepare(`
3761
- SELECT t.id, t.title, t.unread_count, m.content as lastMessage
3762
- FROM threads t
3763
- LEFT JOIN messages m ON m.thread_id = t.id
3764
- WHERE t.unread_count > 0
3765
- AND m.timestamp = (SELECT MAX(timestamp) FROM messages WHERE thread_id = t.id)
3766
- ORDER BY t.updated_at DESC
3767
- `).all();
1514
+ getUnread(req, res) {
1515
+ const db = this.getActiveDb();
1516
+ 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();
3768
1517
  for (const thread of threads) {
3769
- const rows = db.prepare('SELECT message_id FROM unread_messages WHERE thread_id = ?').all(thread.id);
3770
- thread.unreadMessageIds = rows.map(r => r.message_id);
1518
+ thread.unreadMessageIds = db.prepare('SELECT message_id FROM unread_messages WHERE thread_id = ?').all(thread.id).map(r => r.message_id);
3771
1519
  }
3772
1520
  send(res, 200, { threads });
3773
1521
  }
3774
1522
 
3775
- async function _handleMarkMessagesRead(req, res, params) {
1523
+ async markRead(req, res, params) {
3776
1524
  const body = await parseBody(req);
3777
- const db = _getActiveDb();
3778
- const threadId = params.id;
3779
- const messageIds = body.messageIds;
3780
- if (!Array.isArray(messageIds) || messageIds.length === 0) {
3781
- return send(res, 400, { error: 'messageIds array required' });
3782
- }
3783
- const placeholders = messageIds.map(() => '?').join(',');
3784
- db.prepare(`DELETE FROM unread_messages WHERE thread_id = ? AND message_id IN (${placeholders})`).run(threadId, ...messageIds);
3785
- const remaining = syncThreadUnreadCount(db, threadId);
3786
- const workspace = _getWorkspaces().active;
3787
- _gatewayClient.broadcastToBrowsers(JSON.stringify({
3788
- type: 'clawchats', event: 'unread-update', workspace, threadId,
3789
- action: 'read', messageIds, unreadCount: remaining, timestamp: Date.now()
3790
- }));
1525
+ 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(',');
1529
+ db.prepare(`DELETE FROM unread_messages WHERE thread_id = ? AND message_id IN (${ph})`).run(params.id, ...messageIds);
1530
+ 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() }));
3791
1533
  send(res, 200, { unread_count: remaining });
3792
1534
  }
3793
1535
 
3794
- async function _handleCreateThread(req, res) {
1536
+ async create(req, res) {
3795
1537
  const body = await parseBody(req);
3796
- const db = _getActiveDb();
3797
- const ws = _getWorkspaces();
1538
+ const db = this.getActiveDb();
1539
+ const ws = this.getWorkspaces();
3798
1540
  const id = body.id || uuid();
3799
1541
  const now = Date.now();
3800
- const workspaceAgent = ws.workspaces[ws.active]?.agent || 'main';
3801
- const sessionKey = `agent:${workspaceAgent}:${ws.active}:chat:${id}`;
1542
+ const agent = ws.workspaces[ws.active]?.agent || 'main';
1543
+ const sessionKey = `agent:${agent}:${ws.active}:chat:${id}`;
3802
1544
  try {
3803
1545
  db.prepare('INSERT INTO threads (id, session_key, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(id, sessionKey, 'New chat', now, now);
3804
1546
  } catch (e) {
3805
1547
  if (e.message.includes('UNIQUE constraint')) return sendError(res, 409, 'Thread already exists');
3806
1548
  throw e;
3807
1549
  }
3808
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(id);
3809
- send(res, 201, { thread });
1550
+ send(res, 201, { thread: db.prepare('SELECT * FROM threads WHERE id = ?').get(id) });
3810
1551
  }
3811
1552
 
3812
- function _handleGetThread(req, res, params) {
3813
- const db = _getActiveDb();
3814
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
1553
+ get(req, res, params) {
1554
+ const thread = this.getActiveDb().prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3815
1555
  if (!thread) return sendError(res, 404, 'Thread not found');
3816
1556
  send(res, 200, { thread });
3817
1557
  }
3818
1558
 
3819
- async function _handleUpdateThread(req, res, params) {
1559
+ async update(req, res, params) {
3820
1560
  const body = await parseBody(req);
3821
- const db = _getActiveDb();
3822
- const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3823
- if (!thread) return sendError(res, 404, 'Thread not found');
1561
+ const db = this.getActiveDb();
1562
+ if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
3824
1563
  const fields = [], values = [];
3825
- if (body.title !== undefined) { fields.push('title = ?'); values.push(body.title); }
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); }
1566
+ }
3826
1567
  if (body.pinned !== undefined) { fields.push('pinned = ?'); values.push(body.pinned ? 1 : 0); }
3827
1568
  if (body.pin_order !== undefined) { fields.push('pin_order = ?'); values.push(body.pin_order); }
3828
1569
  if (body.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(body.sort_order); }
3829
- if (body.model !== undefined) { fields.push('model = ?'); values.push(body.model); }
3830
- if (body.last_session_id !== undefined) { fields.push('last_session_id = ?'); values.push(body.last_session_id); }
3831
- if (body.unread_count !== undefined) { fields.push('unread_count = ?'); values.push(body.unread_count); }
3832
1570
  if (fields.length > 0) {
3833
- fields.push('updated_at = ?'); values.push(Date.now()); values.push(params.id);
1571
+ fields.push('updated_at = ?'); values.push(Date.now(), params.id);
3834
1572
  db.prepare(`UPDATE threads SET ${fields.join(', ')} WHERE id = ?`).run(...values);
3835
1573
  }
3836
- const updated = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3837
- send(res, 200, { thread: updated });
1574
+ send(res, 200, { thread: db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id) });
3838
1575
  }
3839
1576
 
3840
- function _handleDeleteThread(req, res, params) {
3841
- const db = _getActiveDb();
1577
+ delete(req, res, params) {
1578
+ const db = this.getActiveDb();
3842
1579
  const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3843
1580
  if (!thread) return sendError(res, 404, 'Thread not found');
3844
1581
  db.prepare('DELETE FROM threads WHERE id = ?').run(params.id);
3845
1582
  let sessionIdToDelete = thread.last_session_id;
3846
- const tAgentMatch = (thread.session_key || '').match(/^agent:([^:]+):/);
3847
- const tSessionsDir = getSessionsDirForAgent(tAgentMatch?.[1]);
1583
+ const agentMatch = (thread.session_key || '').match(/^agent:([^:]+):/);
1584
+ const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
3848
1585
  if (!sessionIdToDelete) {
3849
1586
  try {
3850
- const raw = fs.readFileSync(path.join(tSessionsDir, 'sessions.json'), 'utf8');
3851
- const store = JSON.parse(raw);
3852
- const entry = store[thread.session_key];
3853
- if (entry?.sessionId) sessionIdToDelete = entry.sessionId;
1587
+ const store = JSON.parse(fs.readFileSync(path.join(sessionsDir, 'sessions.json'), 'utf8'));
1588
+ sessionIdToDelete = store[thread.session_key]?.sessionId;
3854
1589
  } catch { /* ok */ }
3855
1590
  }
3856
1591
  cleanGatewaySession(thread.session_key);
3857
1592
  if (sessionIdToDelete) {
3858
- const jsonlPath = path.join(tSessionsDir, `${sessionIdToDelete}.jsonl`);
3859
- try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
1593
+ try { fs.unlinkSync(path.join(sessionsDir, `${sessionIdToDelete}.jsonl`)); } catch { /* ok */ }
3860
1594
  }
3861
- const uploadDir = path.join(_UPLOADS_DIR, params.id);
3862
- try { fs.rmSync(uploadDir, { recursive: true }); } catch { /* ok */ }
1595
+ try { fs.rmSync(path.join(this.uploadsDir, params.id), { recursive: true }); } catch { /* ok */ }
3863
1596
  send(res, 200, { ok: true });
3864
1597
  }
1598
+ }
3865
1599
 
3866
- function _handleGetMessages(req, res, params, query) {
3867
- const db = _getActiveDb();
3868
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
3869
- if (!thread) return sendError(res, 404, 'Thread not found');
1600
+ class MessageController {
1601
+ constructor({ getActiveDb, getWorkspaces, broadcast }) {
1602
+ this.getActiveDb = getActiveDb;
1603
+ this.getWorkspaces = getWorkspaces;
1604
+ this.broadcast = broadcast;
1605
+ }
1606
+
1607
+ getAll(req, res, params, query) {
1608
+ const db = this.getActiveDb();
1609
+ if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
3870
1610
  const limit = Math.min(parseInt(query.limit || '100', 10), 500);
3871
1611
  const before = query.before ? parseInt(query.before, 10) : null;
3872
1612
  const after = query.after ? parseInt(query.after, 10) : null;
@@ -3874,302 +1614,484 @@ export function createApp(config = {}) {
3874
1614
  const sqlParams = [params.id];
3875
1615
  if (before) { sql += ' AND timestamp < ?'; sqlParams.push(before); }
3876
1616
  if (after) { sql += ' AND timestamp > ?'; sqlParams.push(after); }
3877
- const countSql = sql.replace('SELECT *', 'SELECT COUNT(*) as c');
3878
- const total = db.prepare(countSql).get(...sqlParams).c;
3879
- sql += ' ORDER BY timestamp DESC LIMIT ?';
3880
- sqlParams.push(limit + 1);
3881
- const rows = db.prepare(sql).all(...sqlParams);
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);
3882
1619
  const hasMore = rows.length > limit;
3883
1620
  const messages = rows.slice(0, limit).reverse();
3884
- for (const m of messages) {
3885
- if (m.metadata) { try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ } }
3886
- }
1621
+ for (const m of messages) { if (m.metadata) { try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ } } }
3887
1622
  send(res, 200, { messages, hasMore });
3888
1623
  }
3889
1624
 
3890
- async function _handleCreateMessage(req, res, params) {
1625
+ async create(req, res, params) {
3891
1626
  const body = await parseBody(req);
3892
- const db = _getActiveDb();
3893
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
3894
- if (!thread) return sendError(res, 404, 'Thread not found');
3895
- if (!body.id || !body.role || body.content === undefined || !body.timestamp) {
3896
- return sendError(res, 400, 'Required: id, role, content, timestamp');
3897
- }
1627
+ 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');
3898
1630
  const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
3899
1631
  const existing = db.prepare('SELECT id, status, metadata FROM messages WHERE id = ?').get(body.id);
3900
1632
  if (existing) {
3901
- const newStatus = body.status || existing.status;
3902
- const statusChanged = body.status && body.status !== existing.status;
3903
- if (statusChanged || metadata) {
3904
- // Only overwrite metadata if new metadata is provided; otherwise preserve existing
3905
- const finalMetadata = metadata || existing.metadata || null;
3906
- db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?').run(newStatus, body.content, finalMetadata, body.id);
1633
+ 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);
3907
1635
  }
3908
1636
  } else {
3909
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());
3910
1638
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
3911
-
3912
- // Heuristic title on first user message (mimics Claude/ChatGPT — title appears immediately on send)
3913
1639
  if (body.role === 'user' && body.content) {
3914
1640
  const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
3915
1641
  if (threadInfo?.title === 'New chat') {
3916
- const heuristic = body.content.replace(/\n.*/s, '').slice(0, 40).trim()
3917
- + (body.content.length > 40 ? '...' : '');
3918
- if (heuristic) {
3919
- db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, params.id);
3920
- const activeWs = _getWorkspaces().active;
3921
- _gatewayClient.broadcastToBrowsers(JSON.stringify({
3922
- type: 'clawchats',
3923
- event: 'thread-title-updated',
3924
- threadId: params.id,
3925
- workspace: activeWs,
3926
- title: heuristic
3927
- }));
1642
+ const title = body.content.replace(/\n.*/s, '').slice(0, 40).trim() + (body.content.length > 40 ? '...' : '');
1643
+ 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 }));
3928
1646
  }
3929
1647
  }
3930
1648
  }
3931
1649
  }
3932
1650
  const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
3933
- if (message && message.metadata) { try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ } }
1651
+ if (message?.metadata) { try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ } }
3934
1652
  send(res, existing ? 200 : 201, { message });
3935
1653
  }
3936
1654
 
3937
- function _handleDeleteMessage(req, res, params) {
3938
- const db = _getActiveDb();
3939
- const msg = db.prepare('SELECT id FROM messages WHERE id = ? AND thread_id = ?').get(params.messageId, params.id);
3940
- if (!msg) return sendError(res, 404, 'Message not found');
1655
+ delete(req, res, params) {
1656
+ 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');
3941
1658
  db.prepare('DELETE FROM messages WHERE id = ?').run(params.messageId);
3942
1659
  send(res, 200, { ok: true });
3943
1660
  }
3944
1661
 
3945
- function _handleContextFill(req, res, params) {
3946
- const db = _getActiveDb();
1662
+ contextFill(req, res, params) {
1663
+ const db = this.getActiveDb();
3947
1664
  const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
3948
1665
  if (!thread) return sendError(res, 404, 'Thread not found');
3949
1666
  const { preamble, method } = buildContextPreamble(db, params.id, thread.last_session_id, thread.session_key);
3950
1667
  send(res, 200, { preamble, method });
3951
1668
  }
3952
1669
 
3953
- function _handleSearch(req, res, params, query) {
3954
- const db = _getActiveDb();
1670
+ search(req, res, params, query) {
1671
+ const db = this.getActiveDb();
3955
1672
  const q = query.q || '';
3956
1673
  if (!q) return send(res, 200, { results: [], total: 0 });
3957
1674
  const page = parseInt(query.page || '1', 10);
3958
1675
  const limit = Math.min(parseInt(query.limit || '20', 10), 100);
3959
1676
  const offset = (page - 1) * limit;
3960
1677
  try {
3961
- const results = db.prepare(`
3962
- SELECT m.id as messageId, m.thread_id as threadId, t.title as threadTitle, m.role,
3963
- snippet(messages_fts, 0, '<mark>', '</mark>', '...', 40) as content, m.timestamp
3964
- FROM messages_fts JOIN messages m ON messages_fts.rowid = m.rowid JOIN threads t ON m.thread_id = t.id
3965
- WHERE messages_fts MATCH ? ORDER BY rank LIMIT ? OFFSET ?
3966
- `).all(q, limit, offset);
3967
- const totalRow = db.prepare(`SELECT COUNT(*) as c FROM messages_fts WHERE messages_fts MATCH ?`).get(q);
3968
- send(res, 200, { results, total: totalRow.c });
3969
- } catch (ftsErr) {
3970
- console.warn('[DB] FTS message search failed, returning empty results:', ftsErr.message);
3971
- send(res, 200, { results: [], total: 0 });
3972
- }
1678
+ 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
+ const total = db.prepare(`SELECT COUNT(*) as c FROM messages_fts WHERE messages_fts MATCH ?`).get(q).c;
1680
+ send(res, 200, { results, total });
1681
+ } catch { send(res, 200, { results: [], total: 0 }); }
3973
1682
  }
3974
1683
 
3975
- function _handleExport(req, res) {
3976
- const db = _getActiveDb();
3977
- const ws = _getWorkspaces();
1684
+ export(req, res) {
1685
+ const db = this.getActiveDb();
1686
+ const ws = this.getWorkspaces();
3978
1687
  const threads = db.prepare('SELECT * FROM threads ORDER BY updated_at DESC').all();
3979
- const data = {
1688
+ send(res, 200, {
3980
1689
  workspace: ws.active, exportedAt: Date.now(),
3981
1690
  threads: threads.map(t => {
3982
1691
  const messages = db.prepare('SELECT * FROM messages WHERE thread_id = ? ORDER BY timestamp ASC').all(t.id);
3983
1692
  for (const m of messages) { if (m.metadata) { try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ } } }
3984
1693
  return { ...t, messages };
3985
1694
  }),
3986
- };
3987
- send(res, 200, data);
1695
+ });
3988
1696
  }
3989
1697
 
3990
- async function _handleImport(req, res) {
1698
+ async import(req, res) {
3991
1699
  const body = await parseBody(req);
3992
- const db = _getActiveDb();
3993
- const ws = _getWorkspaces();
1700
+ const db = this.getActiveDb();
1701
+ const ws = this.getWorkspaces();
3994
1702
  if (!body.threads || !Array.isArray(body.threads)) return sendError(res, 400, 'Expected { threads: [...] }');
3995
1703
  let threadsImported = 0, messagesImported = 0;
3996
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)');
3997
1705
  const insertMsg = db.prepare('INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
3998
- const importAll = db.transaction(() => {
1706
+ db.transaction(() => {
3999
1707
  for (const t of body.threads) {
4000
1708
  if (!t.id) continue;
4001
- // TODO: per-project agent — import uses agent:main for now
4002
1709
  const sessionKey = t.session_key || `agent:main:${ws.active}:chat:${t.id}`;
4003
- const result = insertThread.run(t.id, sessionKey, t.title || 'Imported chat', t.pinned || 0, t.pin_order || 0, t.model || null, t.last_session_id || null, t.created_at || Date.now(), t.updated_at || Date.now());
4004
- if (result.changes > 0) threadsImported++;
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++;
4005
1711
  for (const m of (t.messages || [])) {
4006
1712
  if (!m.id || !m.role) continue;
4007
- const metadata = m.metadata ? (typeof m.metadata === 'string' ? m.metadata : JSON.stringify(m.metadata)) : null;
4008
- const r = insertMsg.run(m.id, t.id, m.role, m.content || '', m.status || 'sent', metadata, m.seq || null, m.timestamp || Date.now(), m.created_at || Date.now());
4009
- if (r.changes > 0) messagesImported++;
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++;
4010
1715
  }
4011
1716
  }
4012
- });
4013
- importAll();
1717
+ })();
4014
1718
  send(res, 200, { ok: true, threadsImported, messagesImported });
4015
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
+ }
4016
1729
 
4017
- async function _handleUpload(req, res, params) {
4018
- const db = _getActiveDb();
4019
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id);
4020
- if (!thread) return sendError(res, 404, 'Thread not found');
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');
4021
1733
  const files = await parseMultipart(req);
4022
- const threadUploadDir = path.join(_UPLOADS_DIR, params.id);
4023
- fs.mkdirSync(threadUploadDir, { recursive: true });
1734
+ const dir = path.join(this.uploadsDir, params.id);
1735
+ fs.mkdirSync(dir, { recursive: true });
4024
1736
  const savedFiles = [];
4025
1737
  for (const file of files) {
4026
1738
  const fileId = uuid();
4027
1739
  const ext = path.extname(file.filename) || '';
4028
- const savedName = fileId + ext;
4029
- const filePath = path.join(threadUploadDir, savedName);
4030
- fs.writeFileSync(filePath, file.data);
1740
+ const savedPath = path.join(dir, fileId + ext);
1741
+ fs.writeFileSync(savedPath, file.data);
4031
1742
  savedFiles.push({ id: fileId, filename: file.filename, path: `/api/uploads/${params.id}/${fileId}${ext}`, mimeType: file.mimeType, size: file.data.length });
4032
1743
  }
4033
1744
  send(res, 200, { files: savedFiles });
4034
1745
  }
4035
1746
 
4036
- function _handleServeUpload(req, res, params) {
4037
- const filePath = path.join(_UPLOADS_DIR, params.threadId, params.fileId);
4038
- let resolved = filePath;
1747
+ serveUpload(req, res, params) {
1748
+ const base = path.join(this.uploadsDir, params.threadId, params.fileId);
1749
+ let resolved = base;
4039
1750
  if (!fs.existsSync(resolved)) {
4040
- const dir = path.join(_UPLOADS_DIR, params.threadId);
4041
1751
  try {
4042
- const entries = fs.readdirSync(dir);
4043
- const match = entries.find(e => e.startsWith(params.fileId.replace(/\.[^.]+$/, '')));
4044
- if (match) resolved = path.join(dir, match);
4045
- } catch { /* dir not found */ }
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 */ }
4046
1755
  }
4047
1756
  if (!fs.existsSync(resolved)) return sendError(res, 404, 'File not found');
4048
- const ext = path.extname(resolved).toLowerCase();
4049
- const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json' };
4050
- const contentType = mimeTypes[ext] || 'application/octet-stream';
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' };
4051
1758
  const stat = fs.statSync(resolved);
4052
- res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' });
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': '*' });
4053
1760
  fs.createReadStream(resolved).pipe(res);
4054
1761
  }
4055
1762
 
4056
- function _getIntelligencePath(threadId) {
4057
- const workspace = _getWorkspaces().active;
4058
- return path.join(_INTELLIGENCE_DIR, workspace, `${threadId}.json`);
1763
+ _intelligencePath(threadId) {
1764
+ return path.join(this.intelligenceDir, this.getWorkspaces().active, `${threadId}.json`);
4059
1765
  }
4060
1766
 
4061
- function _handleGetIntelligence(req, res, params) {
4062
- const filePath = _getIntelligencePath(params.id);
1767
+ getIntelligence(req, res, params) {
1768
+ const filePath = this._intelligencePath(params.id);
4063
1769
  if (!fs.existsSync(filePath)) return send(res, 200, { versions: [], currentVersion: -1 });
4064
1770
  try { return send(res, 200, JSON.parse(fs.readFileSync(filePath, 'utf8'))); }
4065
1771
  catch { return send(res, 200, { versions: [], currentVersion: -1 }); }
4066
1772
  }
4067
1773
 
4068
- async function _handleSaveIntelligence(req, res, params) {
1774
+ async saveIntelligence(req, res, params) {
4069
1775
  const body = await parseBody(req);
4070
- const filePath = _getIntelligencePath(params.id);
1776
+ const filePath = this._intelligencePath(params.id);
4071
1777
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
4072
1778
  const data = { versions: body.versions || [], currentVersion: body.currentVersion ?? -1, updatedAt: Date.now() };
4073
1779
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
4074
- return send(res, 200, data);
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;
4075
1789
  }
4076
1790
 
4077
- async function _handleMemoryList(req, res, query) {
1791
+ async list(req, res, query) {
4078
1792
  const limit = Math.min(parseInt(query.limit) || 20, 100);
4079
- try {
4080
- const result = await _memoryProvider.list(limit, query.offset || null);
4081
- send(res, 200, result);
4082
- } catch (err) { send(res, 502, { error: `Failed to reach ${_memoryProvider.name}`, detail: err.message }); }
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 }); }
4083
1795
  }
4084
1796
 
4085
- async function _handleMemorySearch(req, res, query) {
1797
+ async search(req, res, query) {
4086
1798
  const q = (query.query || '').toLowerCase().trim();
4087
1799
  if (!q) return send(res, 400, { error: 'Missing query parameter' });
4088
- try {
4089
- const result = await _memoryProvider.search(q);
4090
- send(res, 200, result);
4091
- } catch (err) { send(res, 502, { error: `Failed to reach ${_memoryProvider.name}`, detail: err.message }); }
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 }); }
4092
1802
  }
4093
1803
 
4094
- function _handleMemoryFiles(req, res, query) {
1804
+ files(req, res, query) {
4095
1805
  const q = (query.query || '').toLowerCase().trim();
4096
- // Use _MEMORY_FILES_DIR scoped to this factory instance
4097
- const memories = _parseMemoryFiles();
1806
+ const memories = this._parseFiles();
4098
1807
  const filtered = q ? memories.filter(m => m.data.toLowerCase().includes(q) || m.title.toLowerCase().includes(q)) : memories;
4099
1808
  filtered.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
4100
1809
  send(res, 200, { memories: filtered });
4101
1810
  }
4102
1811
 
4103
- function _parseMemoryFiles() {
1812
+ _parseFiles() {
4104
1813
  const memories = [];
4105
- let files;
4106
- try { files = fs.readdirSync(_MEMORY_FILES_DIR); } catch { return memories; }
4107
- for (const file of files) {
4108
- if (!file.endsWith('.md')) continue;
4109
- const filePath = path.join(_MEMORY_FILES_DIR, file);
4110
- let stat;
4111
- try { stat = fs.statSync(filePath); } catch { continue; }
4112
- if (!stat.isFile()) continue;
4113
- const content = fs.readFileSync(filePath, 'utf8');
4114
- const basename = file.replace(/\.md$/, '');
4115
- const dateMatch = basename.match(/^(\d{4}-\d{2}-\d{2})/);
4116
- const fileDate = dateMatch ? dateMatch[1] : null;
4117
- const sections = content.split(/^(?=## )/m);
4118
- for (const section of sections) {
4119
- const trimmed = section.trim();
4120
- if (!trimmed) continue;
4121
- const headingMatch = trimmed.match(/^##\s+(.+)/);
4122
- const heading = headingMatch ? headingMatch[1].trim() : null;
4123
- const body = headingMatch ? trimmed.slice(trimmed.indexOf('\n') + 1).trim() : trimmed;
4124
- if (!heading && body.match(/^#\s+/) && body.split('\n').length <= 2) continue;
4125
- const title = heading || basename;
4126
- const id = `file:${basename}:${title}`;
4127
- memories.push({ id, source: 'file', file: basename, title, data: heading ? `**${title}**\n${body}` : body, createdAt: fileDate ? `${fileDate}T00:00:00Z` : stat.mtime.toISOString() });
4128
- }
4129
- }
4130
- try {
4131
- for (const entry of fs.readdirSync(_MEMORY_FILES_DIR)) {
4132
- const subdir = path.join(_MEMORY_FILES_DIR, entry);
4133
- if (!fs.statSync(subdir).isDirectory()) continue;
4134
- for (const file of fs.readdirSync(subdir)) {
4135
- if (!file.endsWith('.md')) continue;
4136
- const filePath = path.join(subdir, file);
4137
- const content = fs.readFileSync(filePath, 'utf8');
4138
- const basename = file.replace(/\.md$/, '');
4139
- const relPath = `${entry}/${basename}`;
4140
- const stat = fs.statSync(filePath);
4141
- memories.push({ id: `file:${relPath}`, source: 'file', file: relPath, title: basename, data: content.trim(), createdAt: stat.mtime.toISOString() });
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
+ }
4142
1843
  }
4143
1844
  }
4144
- } catch { /* ignore */ }
1845
+ };
1846
+ scanDir(this.filesDir);
4145
1847
  return memories;
4146
1848
  }
4147
1849
 
4148
- async function _handleMemoryUpdate(req, res, params) {
4149
- const id = params.id;
1850
+ async update(req, res, params) {
4150
1851
  try {
4151
1852
  const chunks = [];
4152
1853
  for await (const chunk of req) chunks.push(chunk);
4153
- const body = JSON.parse(Buffer.concat(chunks).toString());
4154
- const newData = (body.data || '').trim();
4155
- if (!newData) return send(res, 400, { error: 'Missing data field' });
4156
- const result = await _memoryProvider.update(id, newData);
4157
- send(res, 200, { ok: true, result });
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()) });
4158
1857
  } catch (err) { send(res, 502, { error: 'Failed to update memory', detail: err.message }); }
4159
1858
  }
4160
1859
 
4161
- async function _handleMemoryDelete(req, res, params) {
4162
- const id = params.id;
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
+ }
1983
+ }
1984
+
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() {
4163
2022
  try {
4164
- const result = await _memoryProvider.delete(id);
4165
- send(res, 200, { ok: true, result });
4166
- } catch (err) { send(res, 502, { error: `Failed to reach ${_memoryProvider.name}`, detail: err.message }); }
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();
2074
+ }
2075
+
2076
+ function _ensureDirs() {
2077
+ fs.mkdirSync(_DATA_DIR, { recursive: true });
2078
+ fs.mkdirSync(_UPLOADS_DIR, { recursive: true });
4167
2079
  }
4168
2080
 
4169
- async function _handleMemoryStatus(req, res) {
4170
- const status = await _memoryProvider.status();
4171
- const filesExist = fs.existsSync(_MEMORY_FILES_DIR);
4172
- send(res, 200, { provider: _memoryProvider.name, host: _MEMORY_CONFIG.host, port: _MEMORY_CONFIG.port, collection: _MEMORY_CONFIG.collection, backend: status, memoryFilesDir: _MEMORY_FILES_DIR, memoryFilesDirExists: filesExist });
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;
2093
+ }
2094
+ return true;
4173
2095
  }
4174
2096
 
4175
2097
  // ── GatewayClient (scoped to this factory instance) ───────────────────────
@@ -4627,6 +2549,28 @@ export function createApp(config = {}) {
4627
2549
 
4628
2550
  const _gatewayClient = new _GatewayClient();
4629
2551
 
2552
+ const _broadcast = (msg) => _gatewayClient.broadcastToBrowsers(msg);
2553
+
2554
+ function _handleGetSettings(req, res) {
2555
+ 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, {}); }
2559
+ }
2560
+
2561
+ async function _handleSaveSettings(req, res) {
2562
+ 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 });
2566
+ }
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
+
4630
2574
  // ── handleRequest (scoped to factory state) ────────────────────────────────
4631
2575
  async function _handleRequest(req, res) {
4632
2576
  const _wsName = req.headers?.['x-workspace'];
@@ -4667,7 +2611,7 @@ export function createApp(config = {}) {
4667
2611
  }
4668
2612
 
4669
2613
  let p;
4670
- if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) return _handleServeUpload(req, res, p);
2614
+ if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) return files.serveUpload(req, res, p);
4671
2615
 
4672
2616
  // Custom emoji listing (no auth)
4673
2617
  if (method === 'GET' && urlPath === '/api/emoji') {
@@ -4742,12 +2686,12 @@ export function createApp(config = {}) {
4742
2686
  if (method === 'PUT' && urlPath === '/api/workspace/file') return await handleWorkspaceFileWrite(req, res, query);
4743
2687
  if (method === 'DELETE' && urlPath === '/api/workspace/file') return handleWorkspaceFileDelete(req, res, query);
4744
2688
  if (method === 'POST' && urlPath === '/api/workspace/upload') return await handleWorkspaceUpload(req, res, query);
4745
- if (method === 'GET' && urlPath === '/api/memory/status') return await _handleMemoryStatus(req, res);
4746
- if (method === 'GET' && urlPath === '/api/memory/list') return await _handleMemoryList(req, res, query);
4747
- if (method === 'GET' && urlPath === '/api/memory/search') return await _handleMemorySearch(req, res, query);
4748
- if (method === 'GET' && urlPath === '/api/memory/files') return _handleMemoryFiles(req, res, query);
4749
- if ((p = matchRoute(method, urlPath, 'PUT /api/memory/:id'))) return await _handleMemoryUpdate(req, res, p);
4750
- if ((p = matchRoute(method, urlPath, 'DELETE /api/memory/:id'))) return await _handleMemoryDelete(req, res, p);
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);
4751
2695
  if (method === 'GET' && urlPath === '/api/settings') return _handleGetSettings(req, res);
4752
2696
  if (method === 'PUT' && urlPath === '/api/settings') return await _handleSaveSettings(req, res);
4753
2697
  if (method === 'POST' && urlPath === '/api/transcribe') return await handleTranscribe(req, res);
@@ -4759,20 +2703,20 @@ export function createApp(config = {}) {
4759
2703
  return send(res, 200, { agents });
4760
2704
  } catch { return send(res, 200, { agents: ['main'] }); }
4761
2705
  }
4762
- if (method === 'GET' && urlPath === '/api/workspaces') return _handleGetWorkspaces(req, res);
4763
- if (method === 'POST' && urlPath === '/api/workspaces') return await _handleCreateWorkspace(req, res);
4764
- if ((p = matchRoute(method, urlPath, 'PATCH /api/workspaces/:name'))) return await _handleUpdateWorkspace(req, res, p);
4765
- if ((p = matchRoute(method, urlPath, 'DELETE /api/workspaces/:name'))) return _handleDeleteWorkspace(req, res, p);
4766
- if (method === 'POST' && urlPath === '/api/workspaces/reorder') return await _handleReorderWorkspaces(req, res);
4767
- if ((p = matchRoute(method, urlPath, 'POST /api/workspaces/:name/activate'))) return _handleActivateWorkspace(req, res, p);
4768
- if (method === 'GET' && urlPath === '/api/threads') return _handleGetThreads(req, res, {}, query);
4769
- if (method === 'GET' && urlPath === '/api/threads/unread') return _handleGetUnreadThreads(req, res);
4770
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/mark-read'))) return await _handleMarkMessagesRead(req, res, p);
4771
- if (method === 'POST' && urlPath === '/api/threads') return await _handleCreateThread(req, res);
4772
- if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/messages'))) return _handleGetMessages(req, res, p, query);
4773
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) return await _handleCreateMessage(req, res, p);
4774
- if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) return _handleDeleteMessage(req, res, p);
4775
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) return _handleContextFill(req, res, p);
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);
4776
2720
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
4777
2721
  const db = _getActiveDb();
4778
2722
  const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
@@ -4782,15 +2726,15 @@ export function createApp(config = {}) {
4782
2726
  _gatewayClient.generateThreadTitle(db, p.id, activeWs);
4783
2727
  return send(res, 200, { ok: true });
4784
2728
  }
4785
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) return await _handleUpload(req, res, p);
4786
- if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) return _handleGetIntelligence(req, res, p);
4787
- if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) return await _handleSaveIntelligence(req, res, p);
4788
- if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id'))) return _handleGetThread(req, res, p);
4789
- if ((p = matchRoute(method, urlPath, 'PATCH /api/threads/:id'))) return await _handleUpdateThread(req, res, p);
4790
- if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id'))) return _handleDeleteThread(req, res, p);
4791
- if (method === 'GET' && urlPath === '/api/search') return _handleSearch(req, res, {}, query);
4792
- if (method === 'GET' && urlPath === '/api/export') return _handleExport(req, res);
4793
- if (method === 'POST' && urlPath === '/api/import') return await _handleImport(req, res);
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);
4794
2738
  if (method === 'POST' && urlPath === '/api/active-thread') {
4795
2739
  const body = await parseBody(req);
4796
2740
  const { threadId, workspace } = body;