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