@clawchatsai/connector 0.0.86 → 0.0.88
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/LICENSE +661 -0
- package/README.md +67 -13
- package/dist/index.js +80 -89
- package/openclaw.plugin.json +1 -1
- package/package.json +11 -8
- package/prebuilds/darwin-arm64/node_datachannel.node +0 -0
- package/prebuilds/darwin-x64/node_datachannel.node +0 -0
- package/prebuilds/linux-arm/node_datachannel.node +0 -0
- package/prebuilds/linux-arm64/node_datachannel.node +0 -0
- package/prebuilds/linux-x64/node_datachannel.node +0 -0
- package/prebuilds/linuxmusl-arm64/node_datachannel.node +0 -0
- package/prebuilds/linuxmusl-x64/node_datachannel.node +0 -0
- package/prebuilds/win32-arm64/node_datachannel.node +0 -0
- package/prebuilds/win32-x64/node_datachannel.node +0 -0
- package/server/bootstrap/identity.js +47 -0
- package/server/bootstrap/native.js +9 -0
- package/server/config.js +63 -0
- package/server/controllers/agents.js +20 -0
- package/server/controllers/files.js +64 -0
- package/server/controllers/filesystem.js +139 -0
- package/server/controllers/memory.js +86 -0
- package/server/controllers/messages.js +128 -0
- package/server/controllers/settings.js +28 -0
- package/server/controllers/static.js +56 -0
- package/server/controllers/threads.js +113 -0
- package/server/controllers/transcribe.js +44 -0
- package/server/controllers/workspaces.js +102 -0
- package/server/debug.js +56 -0
- package/server/gateway-cleanup.js +47 -0
- package/server/gateway.js +331 -0
- package/server/index.js +397 -0
- package/server/providers/memory-config.js +52 -0
- package/server/providers/memory.js +108 -0
- package/server/store/workspace-store.js +31 -0
- package/server/util/context.js +49 -0
- package/server/util/helpers.js +111 -0
- package/server/util/http.js +57 -0
- package/server/util/multipart.js +46 -0
- package/dist/migrate.d.ts +0 -16
- package/dist/migrate.js +0 -114
- package/dist/updater.d.ts +0 -21
- package/dist/updater.js +0 -64
- package/server.js +0 -2459
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { send, sendError, parseBody } from '../util/http.js';
|
|
4
|
+
import { validateAgent } from '../config.js';
|
|
5
|
+
import { cleanGatewaySession, cleanGatewaySessionsByPrefix } from '../gateway-cleanup.js';
|
|
6
|
+
|
|
7
|
+
export class WorkspaceController {
|
|
8
|
+
constructor({ getDb, closeDb, getWorkspaces, setWorkspaces, dataDir, broadcast }) {
|
|
9
|
+
this.getDb = getDb;
|
|
10
|
+
this.closeDb = closeDb;
|
|
11
|
+
this.getWorkspaces = getWorkspaces;
|
|
12
|
+
this.setWorkspaces = setWorkspaces;
|
|
13
|
+
this.dataDir = dataDir;
|
|
14
|
+
this.broadcast = broadcast;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getAll(req, res) {
|
|
18
|
+
const ws = this.getWorkspaces();
|
|
19
|
+
const sorted = Object.values(ws.workspaces).sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
|
|
20
|
+
for (const workspace of sorted) {
|
|
21
|
+
try {
|
|
22
|
+
workspace.unread_count = this.getDb(workspace.name).prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
|
|
23
|
+
} catch { workspace.unread_count = 0; }
|
|
24
|
+
}
|
|
25
|
+
send(res, 200, { active: ws.active, workspaces: sorted });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async create(req, res) {
|
|
29
|
+
const body = await parseBody(req);
|
|
30
|
+
const { name, label } = body;
|
|
31
|
+
if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) return sendError(res, 400, 'Name must be [a-z0-9-], 1-32 chars');
|
|
32
|
+
const ws = this.getWorkspaces();
|
|
33
|
+
if (ws.workspaces[name]) return sendError(res, 409, 'Workspace already exists');
|
|
34
|
+
let agent = 'main';
|
|
35
|
+
try { agent = validateAgent(body.agent || 'main'); } catch { agent = 'main'; }
|
|
36
|
+
ws.workspaces[name] = { name, label: label || name, color: body.color || null, icon: body.icon || null, agent, createdAt: Date.now() };
|
|
37
|
+
this.setWorkspaces(ws);
|
|
38
|
+
this.getDb(name);
|
|
39
|
+
send(res, 201, { workspace: ws.workspaces[name] });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async update(req, res, params) {
|
|
43
|
+
const body = await parseBody(req);
|
|
44
|
+
const ws = this.getWorkspaces();
|
|
45
|
+
if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
|
|
46
|
+
if (body.label !== undefined) ws.workspaces[params.name].label = body.label;
|
|
47
|
+
if (body.color !== undefined) ws.workspaces[params.name].color = body.color;
|
|
48
|
+
if (body.icon !== undefined) ws.workspaces[params.name].icon = body.icon;
|
|
49
|
+
if (body.lastThread !== undefined) ws.workspaces[params.name].lastThread = body.lastThread;
|
|
50
|
+
let migratedThreads = 0;
|
|
51
|
+
if (body.agent !== undefined) {
|
|
52
|
+
let newAgent;
|
|
53
|
+
try { newAgent = validateAgent(body.agent); } catch (e) { return sendError(res, 400, e.message); }
|
|
54
|
+
const oldAgent = ws.workspaces[params.name].agent || 'main';
|
|
55
|
+
if (newAgent !== oldAgent) {
|
|
56
|
+
const db = this.getDb(params.name);
|
|
57
|
+
const threads = db.prepare(`SELECT id, session_key FROM threads WHERE session_key LIKE ?`).all(`agent:${oldAgent}:${params.name}:chat:%`);
|
|
58
|
+
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);
|
|
59
|
+
for (const t of threads) cleanGatewaySession(t.session_key);
|
|
60
|
+
ws.workspaces[params.name].agent = newAgent;
|
|
61
|
+
migratedThreads = threads.length;
|
|
62
|
+
this.broadcast(JSON.stringify({ type: 'clawchats', event: 'workspace-agent-changed', workspace: params.name, agent: newAgent }));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.setWorkspaces(ws);
|
|
66
|
+
send(res, 200, { workspace: ws.workspaces[params.name], migratedThreads });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
delete(req, res, params) {
|
|
70
|
+
const ws = this.getWorkspaces();
|
|
71
|
+
if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
|
|
72
|
+
if (Object.keys(ws.workspaces).length <= 1) return sendError(res, 400, 'Cannot delete the only workspace');
|
|
73
|
+
this.closeDb(params.name);
|
|
74
|
+
const dbPath = path.join(this.dataDir, `${params.name}.db`);
|
|
75
|
+
for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch { /* ok */ } }
|
|
76
|
+
const wsAgent = ws.workspaces[params.name]?.agent || 'main';
|
|
77
|
+
const cleaned = cleanGatewaySessionsByPrefix(`agent:${wsAgent}:${params.name}:chat:`);
|
|
78
|
+
if (cleaned > 0) console.log(`Cleaned ${cleaned} gateway sessions for workspace: ${params.name}`);
|
|
79
|
+
delete ws.workspaces[params.name];
|
|
80
|
+
if (ws.active === params.name) ws.active = Object.keys(ws.workspaces)[0] || null;
|
|
81
|
+
this.setWorkspaces(ws);
|
|
82
|
+
send(res, 200, { ok: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async reorder(req, res) {
|
|
86
|
+
const body = await parseBody(req);
|
|
87
|
+
if (!Array.isArray(body.order)) return sendError(res, 400, 'order must be an array of workspace names');
|
|
88
|
+
const ws = this.getWorkspaces();
|
|
89
|
+
body.order.forEach((name, i) => { if (ws.workspaces[name]) ws.workspaces[name].order = i; });
|
|
90
|
+
this.setWorkspaces(ws);
|
|
91
|
+
send(res, 200, { ok: true, workspaces: Object.values(ws.workspaces) });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
activate(req, res, params) {
|
|
95
|
+
const ws = this.getWorkspaces();
|
|
96
|
+
if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
|
|
97
|
+
ws.active = params.name;
|
|
98
|
+
this.setWorkspaces(ws);
|
|
99
|
+
this.getDb(params.name);
|
|
100
|
+
send(res, 200, { ok: true, workspace: ws.workspaces[params.name] });
|
|
101
|
+
}
|
|
102
|
+
}
|
package/server/debug.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class DebugLogger {
|
|
5
|
+
constructor(baseDir) {
|
|
6
|
+
this.baseDir = path.join(baseDir, '..', 'debug');
|
|
7
|
+
this.active = false;
|
|
8
|
+
this.sessionId = null;
|
|
9
|
+
this.wsStream = null;
|
|
10
|
+
this.originatingClient = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
start(ts, originatingClient) {
|
|
14
|
+
if (this.active) return { error: 'already-active', sessionId: this.sessionId };
|
|
15
|
+
this.sessionId = ts.replace(/[:.]/g, '-');
|
|
16
|
+
this.originatingClient = originatingClient;
|
|
17
|
+
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
18
|
+
this.wsStream = fs.createWriteStream(path.join(this.baseDir, `session-${this.sessionId}-ws.log`), { flags: 'a' });
|
|
19
|
+
this.active = true;
|
|
20
|
+
console.log(`Debug recording started: ${this.sessionId}`);
|
|
21
|
+
return { sessionId: this.sessionId };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
logFrame(direction, data) {
|
|
25
|
+
if (this.active && this.wsStream) this.wsStream.write(`${new Date().toISOString()} ${direction} ${data}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
saveDump(payload) {
|
|
29
|
+
if (!this.sessionId) return { sessionId: null, files: [] };
|
|
30
|
+
const files = [];
|
|
31
|
+
const id = this.sessionId;
|
|
32
|
+
if (this.wsStream) { this.wsStream.end(); this.wsStream = null; files.push(`session-${id}-ws.log`); }
|
|
33
|
+
|
|
34
|
+
let logContent = '';
|
|
35
|
+
for (const entry of (payload.console || [])) {
|
|
36
|
+
logContent += `${entry.ts} [${entry.level.toUpperCase()}] ${entry.args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}\n`;
|
|
37
|
+
}
|
|
38
|
+
for (const err of (payload.errors || [])) logContent += `${err.ts} [UNHANDLED] ${err.message}\n${err.stack || ''}\n`;
|
|
39
|
+
if (logContent) { fs.writeFileSync(path.join(this.baseDir, `session-${id}-client.log`), logContent); files.push(`session-${id}-client.log`); }
|
|
40
|
+
if (payload.state) { fs.writeFileSync(path.join(this.baseDir, `session-${id}-state.json`), JSON.stringify(payload.state, null, 2)); files.push(`session-${id}-state.json`); }
|
|
41
|
+
if (payload.screenshot) { fs.writeFileSync(path.join(this.baseDir, `session-${id}-screenshot.jpg`), Buffer.from(payload.screenshot, 'base64')); files.push(`session-${id}-screenshot.jpg`); }
|
|
42
|
+
|
|
43
|
+
const savedId = id;
|
|
44
|
+
this.active = false; this.sessionId = null; this.originatingClient = null;
|
|
45
|
+
console.log(`Debug session saved: ${files.join(', ')}`);
|
|
46
|
+
return { sessionId: savedId, files };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
handleClientDisconnect(ws) {
|
|
50
|
+
if (this.active && this.originatingClient === ws) {
|
|
51
|
+
console.log(`Debug session ${this.sessionId} auto-closed: client disconnected`);
|
|
52
|
+
if (this.wsStream) { this.wsStream.write(`${new Date().toISOString()} SYSTEM Client disconnected — session auto-closed\n`); this.wsStream.end(); this.wsStream = null; }
|
|
53
|
+
this.active = false; this.sessionId = null; this.originatingClient = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getSessionsDirForAgent } from './config.js';
|
|
4
|
+
|
|
5
|
+
export function cleanGatewaySession(sessionKey) {
|
|
6
|
+
try {
|
|
7
|
+
const agentMatch = (sessionKey || '').match(/^agent:([^:]+):/);
|
|
8
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
9
|
+
const sessionsPath = path.join(sessionsDir, 'sessions.json');
|
|
10
|
+
const store = JSON.parse(fs.readFileSync(sessionsPath, 'utf8'));
|
|
11
|
+
const entry = store[sessionKey];
|
|
12
|
+
if (!entry) return null;
|
|
13
|
+
if (entry.sessionId) {
|
|
14
|
+
try { fs.unlinkSync(path.join(sessionsDir, `${entry.sessionId}.jsonl`)); } catch { /* ok */ }
|
|
15
|
+
}
|
|
16
|
+
const sessionId = entry.sessionId || null;
|
|
17
|
+
delete store[sessionKey];
|
|
18
|
+
fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
|
|
19
|
+
return sessionId;
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.warn(`cleanGatewaySession(${sessionKey}):`, err.message);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function cleanGatewaySessionsByPrefix(prefix) {
|
|
27
|
+
try {
|
|
28
|
+
const agentMatch = (prefix || '').match(/^agent:([^:]+):/);
|
|
29
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
30
|
+
const sessionsPath = path.join(sessionsDir, 'sessions.json');
|
|
31
|
+
const store = JSON.parse(fs.readFileSync(sessionsPath, 'utf8'));
|
|
32
|
+
let cleaned = 0;
|
|
33
|
+
for (const key of Object.keys(store)) {
|
|
34
|
+
if (!key.startsWith(prefix)) continue;
|
|
35
|
+
if (store[key]?.sessionId) {
|
|
36
|
+
try { fs.unlinkSync(path.join(sessionsDir, `${store[key].sessionId}.jsonl`)); } catch { /* ok */ }
|
|
37
|
+
}
|
|
38
|
+
delete store[key];
|
|
39
|
+
cleaned++;
|
|
40
|
+
}
|
|
41
|
+
if (cleaned > 0) fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
|
|
42
|
+
return cleaned;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.warn(`cleanGatewaySessionsByPrefix(${prefix}):`, err.message);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { WebSocket as WS } from 'ws';
|
|
3
|
+
import { loadOrCreateDeviceIdentity, buildDeviceAuth } from './bootstrap/identity.js';
|
|
4
|
+
import { parseSessionKey, extractContent, isSilentReplyExact, isSilentReplyPrefix, sanitizeAssistantContent, syncThreadUnreadCount, generateActivitySummary, writeActivityToDb } from './util/helpers.js';
|
|
5
|
+
|
|
6
|
+
export class GatewayClient {
|
|
7
|
+
constructor({ getDb, getWorkspaces, dataDir, debugLogger, gatewayWsUrl, authToken, mediaStash }) {
|
|
8
|
+
this.getDb = getDb;
|
|
9
|
+
this.getWorkspaces = getWorkspaces;
|
|
10
|
+
this.dataDir = dataDir;
|
|
11
|
+
this.debugLogger = debugLogger;
|
|
12
|
+
this.gatewayWsUrl = gatewayWsUrl;
|
|
13
|
+
this.authToken = authToken;
|
|
14
|
+
this.mediaStash = mediaStash;
|
|
15
|
+
|
|
16
|
+
this.ws = null;
|
|
17
|
+
this.connected = false;
|
|
18
|
+
this.reconnectAttempts = 0;
|
|
19
|
+
this.maxReconnectDelay = 30000;
|
|
20
|
+
this.browserClients = new Map();
|
|
21
|
+
this._externalBroadcastTargets = [];
|
|
22
|
+
this.streamState = new Map();
|
|
23
|
+
this.activityLogs = new Map();
|
|
24
|
+
this._pendingTitleGens = new Map();
|
|
25
|
+
|
|
26
|
+
// Periodically clean up stale activity logs (>10 min old)
|
|
27
|
+
setInterval(() => {
|
|
28
|
+
const cutoff = Date.now() - 10 * 60 * 1000;
|
|
29
|
+
for (const [runId, log] of this.activityLogs) {
|
|
30
|
+
if (log.startTime < cutoff) {
|
|
31
|
+
if (log._messageId) {
|
|
32
|
+
const db = this.getDb(log._parsed?.workspace);
|
|
33
|
+
if (db) db.prepare(`UPDATE messages SET content = '[Response interrupted]', metadata = json_remove(metadata, '$.pending') WHERE id = ? AND content = ''`).run(log._messageId);
|
|
34
|
+
}
|
|
35
|
+
this.activityLogs.delete(runId);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}, 5 * 60 * 1000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
connect() {
|
|
42
|
+
if (this.ws && (this.ws.readyState === WS.CONNECTING || this.ws.readyState === WS.OPEN)) return;
|
|
43
|
+
console.log(`Connecting to gateway at ${this.gatewayWsUrl}...`);
|
|
44
|
+
this.ws = new WS(this.gatewayWsUrl);
|
|
45
|
+
this.ws.on('open', () => { console.log('Gateway WebSocket connected'); this.reconnectAttempts = 0; });
|
|
46
|
+
this.ws.on('message', data => this.handleGatewayMessage(data.toString()));
|
|
47
|
+
this.ws.on('close', () => { console.log('Gateway WebSocket closed'); this.connected = false; this.broadcastGatewayStatus(false); this.scheduleReconnect(); });
|
|
48
|
+
this.ws.on('error', err => console.error('Gateway WebSocket error:', err.message));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
handleGatewayMessage(data) {
|
|
52
|
+
this.debugLogger.logFrame('GW→SRV', data);
|
|
53
|
+
let msg;
|
|
54
|
+
try { msg = JSON.parse(data); } catch { console.error('Invalid JSON from gateway:', data); return; }
|
|
55
|
+
|
|
56
|
+
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
|
57
|
+
const identity = loadOrCreateDeviceIdentity(path.join(this.dataDir, 'device-identity.json'));
|
|
58
|
+
const device = buildDeviceAuth(identity, { clientId: 'gateway-client', clientMode: 'backend', role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.admin'], token: this.authToken, nonce: msg.payload?.nonce || '' });
|
|
59
|
+
this.ws.send(JSON.stringify({ type: 'req', id: 'gw-connect-1', method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' }, role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.admin'], device, auth: { token: this.authToken }, caps: ['tool-events'] } }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (msg.type === 'res' && msg.payload?.type === 'hello-ok') { console.log('Gateway handshake complete'); this.connected = true; this.broadcastGatewayStatus(true); }
|
|
63
|
+
if (msg.type === 'event' && msg.event === 'chat' && msg.payload) {
|
|
64
|
+
this.handleChatEvent(msg.payload, data);
|
|
65
|
+
} else {
|
|
66
|
+
this.broadcastToBrowsers(data);
|
|
67
|
+
}
|
|
68
|
+
if (msg.type === 'event' && msg.event === 'agent' && msg.payload) this.handleAgentEvent(msg.payload);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
handleChatEvent(params, rawData) {
|
|
72
|
+
const { sessionKey, state, message, seq } = params;
|
|
73
|
+
|
|
74
|
+
if (state === 'delta') {
|
|
75
|
+
const parsed = parseSessionKey(sessionKey);
|
|
76
|
+
if (parsed) {
|
|
77
|
+
const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming', held: [] };
|
|
78
|
+
existing.buffer += extractContent(message);
|
|
79
|
+
if (isSilentReplyPrefix(existing.buffer, 'NO_REPLY') || isSilentReplyPrefix(existing.buffer, 'HEARTBEAT_OK')) {
|
|
80
|
+
existing.held = existing.held || [];
|
|
81
|
+
existing.held.push(rawData);
|
|
82
|
+
this.streamState.set(sessionKey, existing);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (existing.held?.length > 0) {
|
|
86
|
+
for (const h of existing.held) this.broadcastToBrowsers(h);
|
|
87
|
+
existing.held = [];
|
|
88
|
+
}
|
|
89
|
+
this.streamState.set(sessionKey, existing);
|
|
90
|
+
}
|
|
91
|
+
this.broadcastToBrowsers(rawData);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const streamEntry = this.streamState.get(sessionKey);
|
|
96
|
+
if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
|
|
97
|
+
|
|
98
|
+
if (sessionKey?.includes('__clawchats_title_')) {
|
|
99
|
+
if (state === 'final') { const content = extractContent(message); if (content && this.handleTitleResponse(sessionKey, content)) return; }
|
|
100
|
+
else if (state === 'error' || state === 'aborted') { for (const key of this._pendingTitleGens.keys()) { if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; } } return; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (state === 'final') {
|
|
104
|
+
const rawContent = extractContent(message);
|
|
105
|
+
if (isSilentReplyExact(rawContent, 'NO_REPLY') || isSilentReplyExact(rawContent, 'HEARTBEAT_OK')) return;
|
|
106
|
+
if (streamEntry?.held?.length > 0) for (const h of streamEntry.held) this.broadcastToBrowsers(h);
|
|
107
|
+
this.broadcastToBrowsers(rawData);
|
|
108
|
+
this.saveAssistantMessage(sessionKey, message, seq);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (state === 'aborted' || state === 'error') this.broadcastToBrowsers(rawData);
|
|
112
|
+
if (state === 'error') this.saveErrorMarker(sessionKey, message);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
saveAssistantMessage(sessionKey, message, seq) {
|
|
116
|
+
const parsed = parseSessionKey(sessionKey);
|
|
117
|
+
if (!parsed) return;
|
|
118
|
+
const ws = this.getWorkspaces();
|
|
119
|
+
if (!ws.workspaces[parsed.workspace]) { console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`); return; }
|
|
120
|
+
const db = this.getDb(parsed.workspace);
|
|
121
|
+
if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId)) { console.log(`Ignoring response for deleted thread: ${parsed.threadId}`); return; }
|
|
122
|
+
|
|
123
|
+
let content = sanitizeAssistantContent(extractContent(message));
|
|
124
|
+
if (!content?.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
|
|
125
|
+
|
|
126
|
+
// Attach media (MEDIA: lines from exec stdout captured by after_tool_call hook)
|
|
127
|
+
const pendingPaths = this.mediaStash?.get(sessionKey) ?? [];
|
|
128
|
+
this.mediaStash?.delete(sessionKey);
|
|
129
|
+
const IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','webp','bmp','svg','ico','avif','tiff']);
|
|
130
|
+
const AUDIO_EXTS = new Set(['mp3','wav','ogg','m4a','flac','aac','opus','wma']);
|
|
131
|
+
const imagePaths = [], pendingAttachments = [];
|
|
132
|
+
for (const p of pendingPaths) {
|
|
133
|
+
const ext = (p.split('.').pop() || '').toLowerCase();
|
|
134
|
+
if (IMAGE_EXTS.has(ext)) imagePaths.push(p);
|
|
135
|
+
else pendingAttachments.push({ path: p, name: p.split('/').pop(), type: AUDIO_EXTS.has(ext) ? 'audio' : 'file' });
|
|
136
|
+
}
|
|
137
|
+
if (imagePaths.length > 0) content = content.trimEnd() + '\n\n' + imagePaths.map(p => ``).join('\n');
|
|
138
|
+
if (pendingPaths.length > 0) console.log(`[clawchats] media-attach: ${imagePaths.length} image(s), ${pendingAttachments.length} attachment(s) for ${sessionKey}`);
|
|
139
|
+
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const pendingMsg = db.prepare(`SELECT id, metadata FROM messages WHERE thread_id = ? AND role = 'assistant' AND json_extract(metadata, '$.pending') = 1 ORDER BY timestamp DESC LIMIT 1`).get(parsed.threadId);
|
|
142
|
+
let messageId;
|
|
143
|
+
|
|
144
|
+
if (pendingMsg) {
|
|
145
|
+
const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
|
|
146
|
+
delete metadata.pending;
|
|
147
|
+
if (metadata.activityLog) { const idx = metadata.activityLog.findLastIndex(s => s.type === 'assistant'); if (idx >= 0) metadata.activityLog.splice(idx, 1); metadata.activitySummary = generateActivitySummary(metadata.activityLog); }
|
|
148
|
+
if (pendingAttachments.length > 0) metadata.attachments = [...(metadata.attachments || []), ...pendingAttachments];
|
|
149
|
+
db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?').run(content, JSON.stringify(metadata), now, pendingMsg.id);
|
|
150
|
+
messageId = pendingMsg.id;
|
|
151
|
+
} else {
|
|
152
|
+
messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
|
|
153
|
+
const newMeta = pendingAttachments.length > 0 ? JSON.stringify({ attachments: pendingAttachments }) : null;
|
|
154
|
+
db.prepare(`INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, 'assistant', ?, 'sent', ?, ?, ?) ON CONFLICT(id) DO UPDATE SET content = excluded.content, metadata = COALESCE(excluded.metadata, metadata), timestamp = excluded.timestamp`).run(messageId, parsed.threadId, content, newMeta, now, now);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
|
|
159
|
+
db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
|
|
160
|
+
syncThreadUnreadCount(db, parsed.threadId);
|
|
161
|
+
const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
|
|
162
|
+
const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
|
|
163
|
+
const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
|
|
164
|
+
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title, preview, unreadCount, updatedContent: imagePaths.length > 0 ? content : undefined, updatedAttachments: pendingAttachments.length > 0 ? pendingAttachments : undefined }));
|
|
165
|
+
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace: parsed.workspace, threadId: parsed.threadId, messageId, action: 'new', unreadCount, workspaceUnreadTotal: db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total, title: threadInfo?.title, preview, timestamp: now }));
|
|
166
|
+
console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
|
|
167
|
+
const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
|
|
168
|
+
if (msgCount === 2 || threadInfo?.title === 'New chat') this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
|
|
169
|
+
} catch (e) { console.error('Failed to save assistant message:', e.message); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
saveErrorMarker(sessionKey, message) {
|
|
173
|
+
const parsed = parseSessionKey(sessionKey);
|
|
174
|
+
if (!parsed) return;
|
|
175
|
+
const ws = this.getWorkspaces();
|
|
176
|
+
if (!ws.workspaces[parsed.workspace]) return;
|
|
177
|
+
const db = this.getDb(parsed.workspace);
|
|
178
|
+
if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId)) return;
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
try {
|
|
181
|
+
db.prepare('INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(`gw-error-${parsed.threadId}-${now}`, parsed.threadId, 'system', `[error] ${message?.error || message?.content || 'Unknown error'}`, 'sent', '{"transient":true}', now, now);
|
|
182
|
+
} catch (e) { console.error('Failed to save error marker:', e.message); }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
|
|
186
|
+
if (!db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId)) return;
|
|
187
|
+
const titleKey = `__clawchats_title_${threadId}`;
|
|
188
|
+
if (this._pendingTitleGens.has(titleKey)) return;
|
|
189
|
+
const firstUserMsg = db.prepare("SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1").get(threadId);
|
|
190
|
+
if (!firstUserMsg?.content) return;
|
|
191
|
+
if (!skipHeuristic) {
|
|
192
|
+
const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim() + (firstUserMsg.content.length > 40 ? '...' : '');
|
|
193
|
+
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
|
|
194
|
+
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId, workspace, title: heuristic }));
|
|
195
|
+
}
|
|
196
|
+
const messages = db.prepare('SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6').all(threadId);
|
|
197
|
+
if (messages.length < 2) return;
|
|
198
|
+
const conversation = messages.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content}`).join('\n\n');
|
|
199
|
+
const reqId = `title-${threadId}-${Date.now()}`;
|
|
200
|
+
this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
|
|
201
|
+
setTimeout(() => { if (this._pendingTitleGens.has(titleKey)) { this._pendingTitleGens.delete(titleKey); console.log(`Title gen timeout for ${threadId}`); } }, 30000);
|
|
202
|
+
this.sendToGateway(JSON.stringify({ type: 'req', id: reqId, method: 'chat.send', params: { sessionKey: titleKey, message: `Based on this conversation, generate a concise 3-5 word title. Return ONLY the title text, no quotes, no explanation:\n\n${conversation}\n\nTitle:`, deliver: false, idempotencyKey: reqId } }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
handleTitleResponse(sessionKey, content) {
|
|
206
|
+
let matchKey = null, pending = null;
|
|
207
|
+
for (const [key, val] of this._pendingTitleGens) {
|
|
208
|
+
if (sessionKey === key || sessionKey.includes(key)) { matchKey = key; pending = val; break; }
|
|
209
|
+
}
|
|
210
|
+
if (!pending) return false;
|
|
211
|
+
this._pendingTitleGens.delete(matchKey);
|
|
212
|
+
let title = content.trim().replace(/^["']|["']$/g, '').replace(/^Title:\s*/i, '').replace(/\n.*/s, '').trim();
|
|
213
|
+
if (title.length > 50) title = title.substring(0, 47) + '...';
|
|
214
|
+
if (!title || title.length >= 100) return true;
|
|
215
|
+
const db = this.getDb(pending.workspace);
|
|
216
|
+
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, pending.threadId);
|
|
217
|
+
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId: pending.threadId, workspace: pending.workspace, title }));
|
|
218
|
+
console.log(`AI title generated for ${pending.threadId}: "${title}"`);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
handleAgentEvent(payload) {
|
|
223
|
+
const { runId, stream, data, sessionKey } = payload;
|
|
224
|
+
if (!runId) return;
|
|
225
|
+
if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
|
|
226
|
+
const log = this.activityLogs.get(runId);
|
|
227
|
+
|
|
228
|
+
if (stream === 'assistant') {
|
|
229
|
+
const text = data?.text || '';
|
|
230
|
+
if (text) {
|
|
231
|
+
let seg = log._currentAssistantSegment;
|
|
232
|
+
if (!seg || seg._sealed) { seg = { type: 'assistant', timestamp: Date.now(), text, _sealed: false }; log._currentAssistantSegment = seg; log.steps.push(seg); }
|
|
233
|
+
else seg.text = text;
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (stream === 'thinking') {
|
|
238
|
+
let step = log.steps.find(s => s.type === 'thinking');
|
|
239
|
+
if (step) step.text = data?.text || '';
|
|
240
|
+
else log.steps.push({ type: 'thinking', timestamp: Date.now(), text: data?.text || '' });
|
|
241
|
+
writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) { log._lastThinkingBroadcast = now; this._broadcastActivityUpdate(runId, log); }
|
|
244
|
+
}
|
|
245
|
+
if (stream === 'tool') {
|
|
246
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
|
|
247
|
+
const argsMeta = data?.args ? (data.args.command || data.args.path || data.args.query || data.args.url || Object.values(data.args).find(v => typeof v === 'string') || '') : '';
|
|
248
|
+
const step = { type: 'tool', timestamp: Date.now(), name: data?.name || 'unknown', phase: data?.phase || 'start', toolCallId: data?.toolCallId, meta: data?.meta || (argsMeta ? String(argsMeta) : undefined), isError: data?.isError || false };
|
|
249
|
+
if (data?.phase === 'result') {
|
|
250
|
+
const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
|
|
251
|
+
if (existing) { existing.phase = 'done'; existing.resultMeta = data?.meta; existing.isError = data?.isError || false; existing.durationMs = Date.now() - existing.timestamp; }
|
|
252
|
+
else { step.phase = 'done'; log.steps.push(step); }
|
|
253
|
+
} else if (data?.phase === 'update') {
|
|
254
|
+
const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
|
|
255
|
+
if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
|
|
256
|
+
} else log.steps.push(step);
|
|
257
|
+
writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
|
|
258
|
+
this._broadcastActivityUpdate(runId, log);
|
|
259
|
+
}
|
|
260
|
+
if (stream === 'lifecycle' && (data?.phase === 'end' || data?.phase === 'error')) {
|
|
261
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
|
|
262
|
+
const idx = log.steps.findLastIndex(s => s.type === 'assistant');
|
|
263
|
+
if (idx >= 0) log.steps.splice(idx, 1);
|
|
264
|
+
writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
|
|
265
|
+
this.activityLogs.delete(runId);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_broadcastActivityUpdate(runId, log) {
|
|
270
|
+
if (!log._parsed || !log._messageId) return;
|
|
271
|
+
const cleanSteps = log.steps.map(s => { const c = { ...s }; delete c._sealed; return c; });
|
|
272
|
+
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'activity-updated', workspace: log._parsed.workspace, threadId: log._parsed.threadId, messageId: log._messageId, activityLog: cleanSteps, activitySummary: generateActivitySummary(log.steps) }));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
broadcastToBrowsers(data) {
|
|
276
|
+
this.debugLogger.logFrame('SRV→BR', data);
|
|
277
|
+
for (const client of this.browserClients.keys()) { if (client.readyState === WS.OPEN) client.send(data); }
|
|
278
|
+
for (const fn of this._externalBroadcastTargets) { try { fn(data); } catch { /* target disconnected */ } }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
broadcastGatewayStatus(connected) {
|
|
282
|
+
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected }));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
sendToGateway(data) {
|
|
286
|
+
this.debugLogger.logFrame('SRV→GW', data);
|
|
287
|
+
if (this.ws?.readyState === WS.OPEN) this.ws.send(data);
|
|
288
|
+
else console.error('Cannot send to gateway: not connected');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
scheduleReconnect() {
|
|
292
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
|
|
293
|
+
this.reconnectAttempts++;
|
|
294
|
+
console.log(`Reconnecting to gateway in ${delay}ms (attempt ${this.reconnectAttempts})...`);
|
|
295
|
+
setTimeout(() => this.connect(), delay);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
addBrowserClient(ws) {
|
|
299
|
+
this.browserClients.set(ws, { activeWorkspace: null, activeThreadId: null });
|
|
300
|
+
if (ws.readyState === WS.OPEN) {
|
|
301
|
+
ws.send(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected: this.connected }));
|
|
302
|
+
const streams = [];
|
|
303
|
+
for (const [sessionKey, state] of this.streamState.entries()) {
|
|
304
|
+
if (state.state === 'streaming' && !(state.held?.length > 0)) streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
|
|
305
|
+
}
|
|
306
|
+
if (streams.length > 0) ws.send(JSON.stringify({ type: 'clawchats', event: 'stream-sync', streams }));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
removeBrowserClient(ws) { this.browserClients.delete(ws); }
|
|
311
|
+
|
|
312
|
+
setActiveThread(ws, workspace, threadId) {
|
|
313
|
+
const client = ws ? this.browserClients.get(ws) : null;
|
|
314
|
+
if (client) { client.activeWorkspace = workspace; client.activeThreadId = threadId; }
|
|
315
|
+
if (!workspace || !threadId) return;
|
|
316
|
+
try {
|
|
317
|
+
const wsData = this.getWorkspaces();
|
|
318
|
+
if (!wsData.workspaces[workspace]) return;
|
|
319
|
+
const db = this.getDb(workspace);
|
|
320
|
+
if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(threadId)) return;
|
|
321
|
+
const deleted = db.prepare('DELETE FROM unread_messages WHERE thread_id = ?').run(threadId);
|
|
322
|
+
if (deleted.changes > 0) {
|
|
323
|
+
syncThreadUnreadCount(db, threadId);
|
|
324
|
+
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace, threadId, action: 'clear', unreadCount: 0, workspaceUnreadTotal: db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total, timestamp: Date.now() }));
|
|
325
|
+
}
|
|
326
|
+
} catch (e) { console.error('Failed to auto-clear unreads on active-thread:', e.message); }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
addBroadcastTarget(fn) { this._externalBroadcastTargets.push(fn); }
|
|
330
|
+
removeBroadcastTarget(fn) { this._externalBroadcastTargets = this._externalBroadcastTargets.filter(f => f !== fn); }
|
|
331
|
+
}
|