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