@clawchatsai/connector 0.0.83 → 0.0.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -24
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
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
|
-
|
|
2290
|
-
|
|
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 =
|
|
3630
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3650
|
-
|
|
1414
|
+
this.setWorkspaces(ws);
|
|
1415
|
+
this.getDb(name);
|
|
3651
1416
|
send(res, 201, { workspace });
|
|
3652
1417
|
}
|
|
3653
1418
|
|
|
3654
|
-
async
|
|
1419
|
+
async update(req, res, params) {
|
|
3655
1420
|
const body = await parseBody(req);
|
|
3656
|
-
const ws =
|
|
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 =
|
|
3669
|
-
const threads = db.prepare(
|
|
3670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1442
|
+
this.setWorkspaces(ws);
|
|
3693
1443
|
send(res, 200, { workspace: ws.workspaces[params.name], migratedThreads });
|
|
3694
1444
|
}
|
|
3695
1445
|
|
|
3696
|
-
|
|
3697
|
-
const ws =
|
|
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
|
-
|
|
3701
|
-
const dbPath = path.join(
|
|
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
|
|
3706
|
-
const cleaned = cleanGatewaySessionsByPrefix(`agent:${
|
|
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
|
-
|
|
1460
|
+
this.setWorkspaces(ws);
|
|
3711
1461
|
send(res, 200, { ok: true });
|
|
3712
1462
|
}
|
|
3713
1463
|
|
|
3714
|
-
async
|
|
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 =
|
|
1468
|
+
const ws = this.getWorkspaces();
|
|
3719
1469
|
order.forEach((name, i) => { if (ws.workspaces[name]) ws.workspaces[name].order = i; });
|
|
3720
|
-
|
|
1470
|
+
this.setWorkspaces(ws);
|
|
3721
1471
|
send(res, 200, { ok: true, workspaces: Object.values(ws.workspaces) });
|
|
3722
1472
|
}
|
|
3723
1473
|
|
|
3724
|
-
|
|
3725
|
-
const ws =
|
|
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
|
-
|
|
3729
|
-
|
|
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
|
-
|
|
3734
|
-
const db =
|
|
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
|
|
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
|
|
3746
|
-
total = db.prepare(`SELECT COUNT(*) as c FROM threads WHERE id IN (${
|
|
3747
|
-
threads = db.prepare(`SELECT * FROM threads WHERE id IN (${
|
|
3748
|
-
} catch (
|
|
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
|
-
|
|
3760
|
-
const db =
|
|
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
|
-
|
|
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
|
|
1523
|
+
async markRead(req, res, params) {
|
|
3777
1524
|
const body = await parseBody(req);
|
|
3778
|
-
const db =
|
|
3779
|
-
const
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
const
|
|
3785
|
-
|
|
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
|
|
1536
|
+
async create(req, res) {
|
|
3796
1537
|
const body = await parseBody(req);
|
|
3797
|
-
const db =
|
|
3798
|
-
const ws =
|
|
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
|
|
3802
|
-
const sessionKey = `agent:${
|
|
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
|
-
|
|
3810
|
-
send(res, 201, { thread });
|
|
1550
|
+
send(res, 201, { thread: db.prepare('SELECT * FROM threads WHERE id = ?').get(id) });
|
|
3811
1551
|
}
|
|
3812
1552
|
|
|
3813
|
-
|
|
3814
|
-
const
|
|
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
|
|
1559
|
+
async update(req, res, params) {
|
|
3821
1560
|
const body = await parseBody(req);
|
|
3822
|
-
const db =
|
|
3823
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
3842
|
-
const db =
|
|
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
|
|
3848
|
-
const
|
|
1583
|
+
const agentMatch = (thread.session_key || '').match(/^agent:([^:]+):/);
|
|
1584
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
3849
1585
|
if (!sessionIdToDelete) {
|
|
3850
1586
|
try {
|
|
3851
|
-
const
|
|
3852
|
-
|
|
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
|
-
|
|
3860
|
-
try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
|
|
1593
|
+
try { fs.unlinkSync(path.join(sessionsDir, `${sessionIdToDelete}.jsonl`)); } catch { /* ok */ }
|
|
3861
1594
|
}
|
|
3862
|
-
|
|
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
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
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
|
|
3879
|
-
const
|
|
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
|
|
1625
|
+
async create(req, res, params) {
|
|
3892
1626
|
const body = await parseBody(req);
|
|
3893
|
-
const db =
|
|
3894
|
-
|
|
3895
|
-
if (!
|
|
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
|
-
|
|
3903
|
-
|
|
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
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
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
|
|
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
|
-
|
|
3939
|
-
const db =
|
|
3940
|
-
|
|
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
|
-
|
|
3947
|
-
const db =
|
|
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
|
-
|
|
3955
|
-
const db =
|
|
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
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
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
|
-
|
|
3977
|
-
const db =
|
|
3978
|
-
const ws =
|
|
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
|
-
|
|
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
|
|
1698
|
+
async import(req, res) {
|
|
3992
1699
|
const body = await parseBody(req);
|
|
3993
|
-
const db =
|
|
3994
|
-
const ws =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4009
|
-
|
|
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
|
|
4019
|
-
const db =
|
|
4020
|
-
|
|
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
|
|
4024
|
-
fs.mkdirSync(
|
|
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
|
|
4030
|
-
|
|
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
|
-
|
|
4038
|
-
const
|
|
4039
|
-
let resolved =
|
|
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
|
|
4044
|
-
|
|
4045
|
-
|
|
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
|
|
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':
|
|
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
|
-
|
|
4058
|
-
|
|
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
|
-
|
|
4063
|
-
const filePath =
|
|
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
|
|
1774
|
+
async saveIntelligence(req, res, params) {
|
|
4070
1775
|
const body = await parseBody(req);
|
|
4071
|
-
const filePath =
|
|
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
|
-
|
|
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
|
|
1791
|
+
async list(req, res, query) {
|
|
4079
1792
|
const limit = Math.min(parseInt(query.limit) || 20, 100);
|
|
4080
|
-
try {
|
|
4081
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1804
|
+
files(req, res, query) {
|
|
4096
1805
|
const q = (query.query || '').toLowerCase().trim();
|
|
4097
|
-
|
|
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
|
-
|
|
1812
|
+
_parseFiles() {
|
|
4105
1813
|
const memories = [];
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
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
|
-
}
|
|
1845
|
+
};
|
|
1846
|
+
scanDir(this.filesDir);
|
|
4146
1847
|
return memories;
|
|
4147
1848
|
}
|
|
4148
1849
|
|
|
4149
|
-
async
|
|
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
|
|
4155
|
-
|
|
4156
|
-
|
|
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
|
|
4163
|
-
|
|
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
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
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
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
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
|
|
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
|
|
4747
|
-
if (method === 'GET' && urlPath === '/api/memory/list') return await
|
|
4748
|
-
if (method === 'GET' && urlPath === '/api/memory/search') return await
|
|
4749
|
-
if (method === 'GET' && urlPath === '/api/memory/files') return
|
|
4750
|
-
if ((p = matchRoute(method, urlPath, 'PUT /api/memory/:id'))) return await
|
|
4751
|
-
if ((p = matchRoute(method, urlPath, 'DELETE /api/memory/:id'))) return await
|
|
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
|
|
4764
|
-
if (method === 'POST' && urlPath === '/api/workspaces') return await
|
|
4765
|
-
if ((p = matchRoute(method, urlPath, 'PATCH /api/workspaces/:name'))) return await
|
|
4766
|
-
if ((p = matchRoute(method, urlPath, 'DELETE /api/workspaces/:name'))) return
|
|
4767
|
-
if (method === 'POST' && urlPath === '/api/workspaces/reorder') return await
|
|
4768
|
-
if ((p = matchRoute(method, urlPath, 'POST /api/workspaces/:name/activate'))) return
|
|
4769
|
-
if (method === 'GET' && urlPath === '/api/threads') return
|
|
4770
|
-
if (method === 'GET' && urlPath === '/api/threads/unread') return
|
|
4771
|
-
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/mark-read'))) return await
|
|
4772
|
-
if (method === 'POST' && urlPath === '/api/threads') return await
|
|
4773
|
-
if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/messages'))) return
|
|
4774
|
-
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) return await
|
|
4775
|
-
if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) return
|
|
4776
|
-
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) return
|
|
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
|
|
4787
|
-
if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) return
|
|
4788
|
-
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) return await
|
|
4789
|
-
if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id'))) return
|
|
4790
|
-
if ((p = matchRoute(method, urlPath, 'PATCH /api/threads/:id'))) return await
|
|
4791
|
-
if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id'))) return
|
|
4792
|
-
if (method === 'GET' && urlPath === '/api/search') return
|
|
4793
|
-
if (method === 'GET' && urlPath === '/api/export') return
|
|
4794
|
-
if (method === 'POST' && urlPath === '/api/import') return await
|
|
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;
|