@clawchatsai/connector 0.0.85 → 0.0.87
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 +0 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +11 -6
- package/server/bootstrap/identity.js +47 -0
- package/server/bootstrap/native.js +9 -0
- package/server/config.js +62 -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/threads.js +113 -0
- package/server/controllers/transcribe.js +51 -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 +422 -0
- package/server/providers/memory.js +144 -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/server.js +1840 -2330
- package/dist/migrate.d.ts +0 -16
- package/dist/migrate.js +0 -114
package/server.js
CHANGED
|
@@ -1,195 +1,144 @@
|
|
|
1
|
-
// ClawChats
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
);
|
|
37
|
-
try {
|
|
38
|
-
Database = _require('better-sqlite3');
|
|
39
|
-
} catch (e) {
|
|
40
|
-
if (_nativeErr(e)) {
|
|
41
|
-
console.error('[ClawChats] better-sqlite3 binary is incompatible with your Node.js version. Attempting auto-rebuild...');
|
|
42
|
-
try {
|
|
43
|
-
execSync('npm rebuild better-sqlite3', { cwd: __dirname, stdio: 'inherit' });
|
|
44
|
-
Database = _require('better-sqlite3');
|
|
45
|
-
console.log('[ClawChats] Auto-rebuild succeeded — continuing startup.');
|
|
46
|
-
} catch (rebuildErr) {
|
|
47
|
-
console.error('[ClawChats] Auto-rebuild failed. Build tools may be missing.');
|
|
48
|
-
console.error('[ClawChats] To fix, run the following, then restart the gateway:');
|
|
49
|
-
console.error(`[ClawChats] cd ${__dirname} && npm rebuild better-sqlite3`);
|
|
50
|
-
console.error('[ClawChats] If that fails, install build tools first:');
|
|
51
|
-
console.error('[ClawChats] Linux: sudo apt install build-essential python3');
|
|
52
|
-
console.error('[ClawChats] macOS: xcode-select --install');
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
} else {
|
|
56
|
-
throw e;
|
|
1
|
+
// ClawChats backend — built by esbuild, source: github.com/clawchatsai/connector
|
|
2
|
+
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
5
|
+
|
|
6
|
+
// server/index.js
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import fs13 from "node:fs";
|
|
9
|
+
import path14 from "node:path";
|
|
10
|
+
import os5 from "node:os";
|
|
11
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
12
|
+
import { WebSocket as WS2, WebSocketServer } from "ws";
|
|
13
|
+
|
|
14
|
+
// server/bootstrap/native.js
|
|
15
|
+
import { DatabaseSync as Database } from "node:sqlite";
|
|
16
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
17
|
+
var requestDbStore = new AsyncLocalStorage();
|
|
18
|
+
|
|
19
|
+
// server/config.js
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
var HOME = os.homedir();
|
|
25
|
+
var MAX_PREAMBLE_CHARS = 5e4;
|
|
26
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
27
|
+
var __dirname = path.dirname(__filename);
|
|
28
|
+
function parseConfigField(field) {
|
|
29
|
+
const candidates = [path.join(__dirname, "config.js"), path.join(__dirname, "..", "config.js")];
|
|
30
|
+
for (const configPath of candidates) {
|
|
31
|
+
try {
|
|
32
|
+
const configText = fs.readFileSync(configPath, "utf8");
|
|
33
|
+
const match = configText.match(new RegExp(`${field}:\\s*['"]([^'"]+)['"]`));
|
|
34
|
+
if (match) return match[1];
|
|
35
|
+
} catch {
|
|
57
36
|
}
|
|
58
37
|
}
|
|
38
|
+
return null;
|
|
59
39
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
40
|
+
__name(parseConfigField, "parseConfigField");
|
|
41
|
+
var AUTH_TOKEN = process.env.CLAWCHATS_AUTH_TOKEN || parseConfigField("authToken") || "";
|
|
42
|
+
function discoverGatewayWsUrl() {
|
|
43
|
+
if (process.env.GATEWAY_WS_URL) return process.env.GATEWAY_WS_URL;
|
|
44
|
+
for (const cfgPath of [path.join(HOME, ".openclaw", "openclaw.json"), "/etc/openclaw/openclaw.json"]) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
47
|
+
const port = raw.gateway?.port || raw.port;
|
|
48
|
+
const host = raw.gateway?.host || raw.host || "localhost";
|
|
49
|
+
if (port) return `ws://${host}:${port}`;
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
70
52
|
}
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function _fingerprintPublicKey(publicKeyPem) {
|
|
75
|
-
return crypto.createHash('sha256').update(_derivePublicKeyRaw(publicKeyPem)).digest('hex');
|
|
53
|
+
return "ws://localhost:18789";
|
|
76
54
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
if (fs.existsSync(identityPath)) {
|
|
85
|
-
const parsed = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
|
|
86
|
-
if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) return parsed;
|
|
87
|
-
}
|
|
88
|
-
} catch { /* regenerate */ }
|
|
89
|
-
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
90
|
-
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
91
|
-
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
92
|
-
const identity = { version: 1, deviceId: _fingerprintPublicKey(publicKeyPem), publicKeyPem, privateKeyPem, createdAtMs: Date.now() };
|
|
93
|
-
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
|
|
94
|
-
fs.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
|
|
95
|
-
return identity;
|
|
55
|
+
__name(discoverGatewayWsUrl, "discoverGatewayWsUrl");
|
|
56
|
+
var GATEWAY_WS_URL = discoverGatewayWsUrl();
|
|
57
|
+
var OPENCLAW_SESSIONS_DIR = process.env.OPENCLAW_SESSIONS_DIR || parseConfigField("sessionsDir") || path.join(HOME, ".openclaw", "agents", "main", "sessions");
|
|
58
|
+
function getSessionsDirForAgent(agentId) {
|
|
59
|
+
if (!agentId || agentId === "main") return OPENCLAW_SESSIONS_DIR;
|
|
60
|
+
return path.join(HOME, ".openclaw", "agents", agentId, "sessions");
|
|
96
61
|
}
|
|
97
|
-
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce };
|
|
62
|
+
__name(getSessionsDirForAgent, "getSessionsDirForAgent");
|
|
63
|
+
function validateAgent(agentId) {
|
|
64
|
+
if (!agentId) return "main";
|
|
65
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) throw new Error("Invalid agent ID");
|
|
66
|
+
const agentDir = path.join(HOME, ".openclaw", "agents", agentId);
|
|
67
|
+
if (!fs.existsSync(agentDir)) throw new Error(`Agent not found: ${agentId}`);
|
|
68
|
+
return agentId;
|
|
105
69
|
}
|
|
70
|
+
__name(validateAgent, "validateAgent");
|
|
106
71
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// --- Debug Recording (isolated module) ---
|
|
115
|
-
class DebugLogger {
|
|
72
|
+
// server/debug.js
|
|
73
|
+
import fs2 from "node:fs";
|
|
74
|
+
import path2 from "node:path";
|
|
75
|
+
var DebugLogger = class {
|
|
76
|
+
static {
|
|
77
|
+
__name(this, "DebugLogger");
|
|
78
|
+
}
|
|
116
79
|
constructor(baseDir) {
|
|
117
|
-
this.baseDir =
|
|
80
|
+
this.baseDir = path2.join(baseDir, "..", "debug");
|
|
118
81
|
this.active = false;
|
|
119
82
|
this.sessionId = null;
|
|
120
83
|
this.wsStream = null;
|
|
121
84
|
this.originatingClient = null;
|
|
122
85
|
}
|
|
123
|
-
|
|
124
86
|
start(ts, originatingClient) {
|
|
125
|
-
if (this.active) {
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
this.sessionId = ts.replace(/[:.]/g, '-');
|
|
87
|
+
if (this.active) return { error: "already-active", sessionId: this.sessionId };
|
|
88
|
+
this.sessionId = ts.replace(/[:.]/g, "-");
|
|
129
89
|
this.originatingClient = originatingClient;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
this.wsStream = fs.createWriteStream(wsLogPath, { flags: 'a' });
|
|
90
|
+
fs2.mkdirSync(this.baseDir, { recursive: true });
|
|
91
|
+
this.wsStream = fs2.createWriteStream(path2.join(this.baseDir, `session-${this.sessionId}-ws.log`), { flags: "a" });
|
|
133
92
|
this.active = true;
|
|
134
93
|
console.log(`Debug recording started: ${this.sessionId}`);
|
|
135
94
|
return { sessionId: this.sessionId };
|
|
136
95
|
}
|
|
137
|
-
|
|
138
96
|
logFrame(direction, data) {
|
|
139
|
-
if (
|
|
140
|
-
|
|
97
|
+
if (this.active && this.wsStream) this.wsStream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${direction} ${data}
|
|
98
|
+
`);
|
|
141
99
|
}
|
|
142
|
-
|
|
143
100
|
saveDump(payload) {
|
|
144
101
|
if (!this.sessionId) return { sessionId: null, files: [] };
|
|
145
102
|
const files = [];
|
|
146
103
|
const id = this.sessionId;
|
|
147
|
-
|
|
148
104
|
if (this.wsStream) {
|
|
149
105
|
this.wsStream.end();
|
|
150
106
|
this.wsStream = null;
|
|
151
107
|
files.push(`session-${id}-ws.log`);
|
|
152
108
|
}
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const args = entry.args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
|
158
|
-
logContent += `${entry.ts} [${entry.level.toUpperCase()}] ${args}\n`;
|
|
159
|
-
}
|
|
160
|
-
for (const err of (payload.errors || [])) {
|
|
161
|
-
logContent += `${err.ts} [UNHANDLED] ${err.message}\n${err.stack || ''}\n`;
|
|
109
|
+
let logContent = "";
|
|
110
|
+
for (const entry of payload.console || []) {
|
|
111
|
+
logContent += `${entry.ts} [${entry.level.toUpperCase()}] ${entry.args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
112
|
+
`;
|
|
162
113
|
}
|
|
114
|
+
for (const err of payload.errors || []) logContent += `${err.ts} [UNHANDLED] ${err.message}
|
|
115
|
+
${err.stack || ""}
|
|
116
|
+
`;
|
|
163
117
|
if (logContent) {
|
|
164
|
-
|
|
118
|
+
fs2.writeFileSync(path2.join(this.baseDir, `session-${id}-client.log`), logContent);
|
|
165
119
|
files.push(`session-${id}-client.log`);
|
|
166
120
|
}
|
|
167
|
-
|
|
168
121
|
if (payload.state) {
|
|
169
|
-
|
|
170
|
-
fs.writeFileSync(statePath, JSON.stringify(payload.state, null, 2));
|
|
122
|
+
fs2.writeFileSync(path2.join(this.baseDir, `session-${id}-state.json`), JSON.stringify(payload.state, null, 2));
|
|
171
123
|
files.push(`session-${id}-state.json`);
|
|
172
124
|
}
|
|
173
|
-
|
|
174
125
|
if (payload.screenshot) {
|
|
175
|
-
|
|
176
|
-
fs.writeFileSync(ssPath, Buffer.from(payload.screenshot, 'base64'));
|
|
126
|
+
fs2.writeFileSync(path2.join(this.baseDir, `session-${id}-screenshot.jpg`), Buffer.from(payload.screenshot, "base64"));
|
|
177
127
|
files.push(`session-${id}-screenshot.jpg`);
|
|
178
128
|
}
|
|
179
|
-
|
|
180
129
|
const savedId = id;
|
|
181
130
|
this.active = false;
|
|
182
131
|
this.sessionId = null;
|
|
183
132
|
this.originatingClient = null;
|
|
184
|
-
console.log(`Debug session saved: ${files.join(
|
|
133
|
+
console.log(`Debug session saved: ${files.join(", ")}`);
|
|
185
134
|
return { sessionId: savedId, files };
|
|
186
135
|
}
|
|
187
|
-
|
|
188
136
|
handleClientDisconnect(ws) {
|
|
189
137
|
if (this.active && this.originatingClient === ws) {
|
|
190
138
|
console.log(`Debug session ${this.sessionId} auto-closed: client disconnected`);
|
|
191
139
|
if (this.wsStream) {
|
|
192
|
-
this.wsStream.write(`${new Date().toISOString()} SYSTEM Client disconnected
|
|
140
|
+
this.wsStream.write(`${(/* @__PURE__ */ new Date()).toISOString()} SYSTEM Client disconnected \u2014 session auto-closed
|
|
141
|
+
`);
|
|
193
142
|
this.wsStream.end();
|
|
194
143
|
this.wsStream = null;
|
|
195
144
|
}
|
|
@@ -198,965 +147,643 @@ class DebugLogger {
|
|
|
198
147
|
this.originatingClient = null;
|
|
199
148
|
}
|
|
200
149
|
}
|
|
201
|
-
}
|
|
150
|
+
};
|
|
202
151
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
152
|
+
// server/gateway.js
|
|
153
|
+
import path4 from "node:path";
|
|
154
|
+
import { WebSocket as WS } from "ws";
|
|
206
155
|
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
156
|
+
// server/bootstrap/identity.js
|
|
157
|
+
import crypto from "node:crypto";
|
|
158
|
+
import fs3 from "node:fs";
|
|
159
|
+
import path3 from "node:path";
|
|
160
|
+
var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
161
|
+
function derivePublicKeyRaw(publicKeyPem) {
|
|
162
|
+
const spki = crypto.createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
|
|
163
|
+
if (spki.length === ED25519_SPKI_PREFIX.length + 32 && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
|
|
164
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
165
|
+
}
|
|
166
|
+
return spki;
|
|
216
167
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (!AUTH_TOKEN) {
|
|
221
|
-
console.error('WARNING: No auth token configured. Set CLAWCHATS_AUTH_TOKEN or create config.js');
|
|
168
|
+
__name(derivePublicKeyRaw, "derivePublicKeyRaw");
|
|
169
|
+
function fingerprintPublicKey(publicKeyPem) {
|
|
170
|
+
return crypto.createHash("sha256").update(derivePublicKeyRaw(publicKeyPem)).digest("hex");
|
|
222
171
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
// NOTE: Do NOT use config.js gatewayUrl — that's the browser's external-facing URL.
|
|
227
|
-
// server.js needs the internal/local gateway address.
|
|
228
|
-
function discoverGatewayWsUrl() {
|
|
229
|
-
if (process.env.GATEWAY_WS_URL) return process.env.GATEWAY_WS_URL;
|
|
230
|
-
// Read OpenClaw's own config for the local gateway port
|
|
231
|
-
const candidates = [
|
|
232
|
-
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
|
|
233
|
-
'/etc/openclaw/openclaw.json'
|
|
234
|
-
];
|
|
235
|
-
for (const cfgPath of candidates) {
|
|
236
|
-
try {
|
|
237
|
-
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
238
|
-
const port = raw.gateway?.port || raw.port;
|
|
239
|
-
const host = raw.gateway?.host || raw.host || 'localhost';
|
|
240
|
-
if (port) return `ws://${host}:${port}`;
|
|
241
|
-
} catch {}
|
|
242
|
-
}
|
|
243
|
-
return 'ws://localhost:18789';
|
|
172
|
+
__name(fingerprintPublicKey, "fingerprintPublicKey");
|
|
173
|
+
function base64UrlEncode(buf) {
|
|
174
|
+
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
244
175
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
// ─── Sessions Directory Discovery ────────────────────────────────────────────
|
|
248
|
-
|
|
249
|
-
function discoverViaCliSync() {
|
|
176
|
+
__name(base64UrlEncode, "base64UrlEncode");
|
|
177
|
+
function loadOrCreateDeviceIdentity(identityPath) {
|
|
250
178
|
try {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
return path.dirname(status.sessions.paths[0]);
|
|
179
|
+
if (fs3.existsSync(identityPath)) {
|
|
180
|
+
const parsed = JSON.parse(fs3.readFileSync(identityPath, "utf8"));
|
|
181
|
+
if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) return parsed;
|
|
255
182
|
}
|
|
256
|
-
} catch {
|
|
257
|
-
|
|
183
|
+
} catch {
|
|
184
|
+
}
|
|
185
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
186
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
187
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
188
|
+
const identity = { version: 1, deviceId: fingerprintPublicKey(publicKeyPem), publicKeyPem, privateKeyPem, createdAtMs: Date.now() };
|
|
189
|
+
fs3.mkdirSync(path3.dirname(identityPath), { recursive: true });
|
|
190
|
+
fs3.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + "\n", { mode: 384 });
|
|
191
|
+
return identity;
|
|
258
192
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
console.log(`Sessions dir: ${configDir} (source: config)`);
|
|
268
|
-
return configDir;
|
|
269
|
-
}
|
|
270
|
-
const cliDir = discoverViaCliSync();
|
|
271
|
-
if (cliDir) {
|
|
272
|
-
console.log(`Sessions dir: ${cliDir} (source: cli)`);
|
|
273
|
-
return cliDir;
|
|
274
|
-
}
|
|
275
|
-
const fallback = path.join(HOME, '.openclaw', 'agents', 'main', 'sessions');
|
|
276
|
-
console.log(`Sessions dir: ${fallback} (source: fallback)`);
|
|
277
|
-
return fallback;
|
|
278
|
-
})();
|
|
279
|
-
|
|
280
|
-
// ─── Agent Helpers ───────────────────────────────────────────────────────────
|
|
281
|
-
|
|
282
|
-
function getSessionsDirForAgent(agentId) {
|
|
283
|
-
if (!agentId || agentId === 'main') return OPENCLAW_SESSIONS_DIR;
|
|
284
|
-
return path.join(HOME, '.openclaw', 'agents', agentId, 'sessions');
|
|
193
|
+
__name(loadOrCreateDeviceIdentity, "loadOrCreateDeviceIdentity");
|
|
194
|
+
function buildDeviceAuth(identity, { clientId, clientMode, role, scopes, token, nonce }) {
|
|
195
|
+
const signedAt = Date.now();
|
|
196
|
+
const payload = ["v2", identity.deviceId, clientId, clientMode, role, scopes.join(","), String(signedAt), token || "", nonce].join("|");
|
|
197
|
+
const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
|
|
198
|
+
const signature = base64UrlEncode(crypto.sign(null, Buffer.from(payload, "utf8"), privateKey));
|
|
199
|
+
const publicKeyB64Url = base64UrlEncode(derivePublicKeyRaw(identity.publicKeyPem));
|
|
200
|
+
return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce };
|
|
285
201
|
}
|
|
202
|
+
__name(buildDeviceAuth, "buildDeviceAuth");
|
|
286
203
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return agentId;
|
|
204
|
+
// server/util/helpers.js
|
|
205
|
+
function syncThreadUnreadCount(db, threadId) {
|
|
206
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?").get(threadId).c;
|
|
207
|
+
db.prepare("UPDATE threads SET unread_count = ? WHERE id = ?").run(count, threadId);
|
|
208
|
+
return count;
|
|
293
209
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
210
|
+
__name(syncThreadUnreadCount, "syncThreadUnreadCount");
|
|
211
|
+
function parseSessionKey(sessionKey) {
|
|
212
|
+
if (!sessionKey) return null;
|
|
213
|
+
const match = sessionKey.match(/^agent:([^:]+):([^:]+):chat:([^:]+)$/);
|
|
214
|
+
if (!match) return null;
|
|
215
|
+
return { agent: match[1], workspace: match[2], threadId: match[3] };
|
|
300
216
|
}
|
|
301
|
-
|
|
302
|
-
function
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
active: 'default',
|
|
308
|
-
workspaces: {
|
|
309
|
-
default: {
|
|
310
|
-
name: 'default',
|
|
311
|
-
label: 'Default',
|
|
312
|
-
createdAt: Date.now()
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
};
|
|
316
|
-
fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(initial, null, 2));
|
|
317
|
-
return initial;
|
|
217
|
+
__name(parseSessionKey, "parseSessionKey");
|
|
218
|
+
function extractContent(message) {
|
|
219
|
+
if (!message) return "";
|
|
220
|
+
if (typeof message.content === "string") return message.content;
|
|
221
|
+
if (Array.isArray(message.content)) {
|
|
222
|
+
return message.content.filter((p) => p.type === "text").map((p) => p.text).join("");
|
|
318
223
|
}
|
|
224
|
+
return "";
|
|
319
225
|
}
|
|
320
|
-
|
|
321
|
-
function
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
let workspacesConfig = null;
|
|
326
|
-
|
|
327
|
-
function getWorkspaces() {
|
|
328
|
-
if (!workspacesConfig) workspacesConfig = loadWorkspaces();
|
|
329
|
-
return workspacesConfig;
|
|
226
|
+
__name(extractContent, "extractContent");
|
|
227
|
+
function isSilentReplyExact(text, token = "NO_REPLY") {
|
|
228
|
+
if (!text) return false;
|
|
229
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
230
|
+
return new RegExp(`^\\s*${escaped}\\s*$`).test(text);
|
|
330
231
|
}
|
|
331
|
-
|
|
332
|
-
function
|
|
333
|
-
|
|
334
|
-
|
|
232
|
+
__name(isSilentReplyExact, "isSilentReplyExact");
|
|
233
|
+
function isSilentReplyPrefix(text, token = "NO_REPLY") {
|
|
234
|
+
if (!text) return false;
|
|
235
|
+
const trimmed = text.trimStart();
|
|
236
|
+
if (!trimmed) return false;
|
|
237
|
+
if (trimmed !== trimmed.toUpperCase()) return false;
|
|
238
|
+
const normalized = trimmed.toUpperCase();
|
|
239
|
+
if (normalized.length < 2 || /[^A-Z_]/.test(normalized)) return false;
|
|
240
|
+
const tokenUpper = token.toUpperCase();
|
|
241
|
+
if (!tokenUpper.startsWith(normalized)) return false;
|
|
242
|
+
if (normalized.includes("_")) return true;
|
|
243
|
+
return tokenUpper === "NO_REPLY" && normalized === "NO";
|
|
335
244
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
function getDb(workspaceName) {
|
|
342
|
-
if (dbCache.has(workspaceName)) return dbCache.get(workspaceName);
|
|
343
|
-
const dbPath = path.join(DATA_DIR, `${workspaceName}.db`);
|
|
344
|
-
const db = new Database(dbPath);
|
|
345
|
-
db.pragma('journal_mode = WAL');
|
|
346
|
-
db.pragma('foreign_keys = ON');
|
|
347
|
-
migrate(db);
|
|
348
|
-
dbCache.set(workspaceName, db);
|
|
349
|
-
return db;
|
|
245
|
+
__name(isSilentReplyPrefix, "isSilentReplyPrefix");
|
|
246
|
+
function stripTrailingSentinel(text, token = "NO_REPLY") {
|
|
247
|
+
if (!text) return text;
|
|
248
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
249
|
+
return text.replace(new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`), "").trim();
|
|
350
250
|
}
|
|
351
|
-
|
|
352
|
-
function
|
|
353
|
-
return
|
|
251
|
+
__name(stripTrailingSentinel, "stripTrailingSentinel");
|
|
252
|
+
function stripFinalTags(text) {
|
|
253
|
+
return text ? text.replace(/<\s*\/?\s*final\s*>/gi, "") : text;
|
|
354
254
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
db.exec(`
|
|
364
|
-
CREATE TABLE IF NOT EXISTS custom_emojis (
|
|
365
|
-
name TEXT NOT NULL,
|
|
366
|
-
pack TEXT NOT NULL DEFAULT 'slackmojis',
|
|
367
|
-
url TEXT NOT NULL,
|
|
368
|
-
mime_type TEXT,
|
|
369
|
-
created_at INTEGER DEFAULT (strftime('%s','now')),
|
|
370
|
-
PRIMARY KEY (name, pack)
|
|
371
|
-
)
|
|
372
|
-
`);
|
|
373
|
-
_globalDbCache.set(dbPath, db);
|
|
374
|
-
return db;
|
|
255
|
+
__name(stripFinalTags, "stripFinalTags");
|
|
256
|
+
function sanitizeAssistantContent(text) {
|
|
257
|
+
if (!text) return text;
|
|
258
|
+
let out = stripFinalTags(text);
|
|
259
|
+
out = out.replace(/^(?:[ \t]*\r?\n)+/, "");
|
|
260
|
+
if (out.includes("NO_REPLY")) out = stripTrailingSentinel(out, "NO_REPLY");
|
|
261
|
+
if (out.includes("HEARTBEAT_OK")) out = stripTrailingSentinel(out, "HEARTBEAT_OK");
|
|
262
|
+
return out;
|
|
375
263
|
}
|
|
376
|
-
|
|
377
|
-
function
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
264
|
+
__name(sanitizeAssistantContent, "sanitizeAssistantContent");
|
|
265
|
+
function generateActivitySummary(steps) {
|
|
266
|
+
const toolSteps = steps.filter((s) => s.type === "tool" && s.phase !== "result" && s.phase !== "update");
|
|
267
|
+
const hasThinking = steps.some((s) => s.type === "thinking" && s.text);
|
|
268
|
+
const hasNarration = steps.some((s) => s.type === "assistant" && s.text?.trim());
|
|
269
|
+
if (toolSteps.length === 0 && !hasThinking && !hasNarration) return null;
|
|
270
|
+
if (toolSteps.length === 0 && hasThinking) return "Reasoned through the problem";
|
|
271
|
+
if (toolSteps.length === 0 && hasNarration) return "Processed in multiple steps";
|
|
272
|
+
const counts = {};
|
|
273
|
+
for (const s of toolSteps) {
|
|
274
|
+
const name = s.name || "unknown";
|
|
275
|
+
counts[name] = (counts[name] || 0) + 1;
|
|
382
276
|
}
|
|
277
|
+
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" };
|
|
278
|
+
const parts = [];
|
|
279
|
+
for (const [name, count] of Object.entries(counts)) {
|
|
280
|
+
const friendly = toolNames[name];
|
|
281
|
+
parts.push(friendly ? count > 1 ? `${friendly} (${count}\xD7)` : friendly : count > 1 ? `used ${name} (${count}\xD7)` : `used ${name}`);
|
|
282
|
+
}
|
|
283
|
+
if (parts.length === 0) return null;
|
|
284
|
+
if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
|
285
|
+
const last = parts.pop();
|
|
286
|
+
return (parts.join(", ") + " and " + last).replace(/^./, (c) => c.toUpperCase());
|
|
383
287
|
}
|
|
384
|
-
|
|
385
|
-
function
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
DROP TRIGGER IF EXISTS messages_ad;
|
|
419
|
-
DROP TRIGGER IF EXISTS messages_au;
|
|
420
|
-
`);
|
|
288
|
+
__name(generateActivitySummary, "generateActivitySummary");
|
|
289
|
+
function writeActivityToDb(getDbFn, broadcastFn, runId, log) {
|
|
290
|
+
if (!log._parsed) log._parsed = parseSessionKey(log.sessionKey);
|
|
291
|
+
const parsed = log._parsed;
|
|
292
|
+
if (!parsed) return;
|
|
293
|
+
const db = getDbFn(parsed.workspace);
|
|
294
|
+
if (!db) return;
|
|
295
|
+
const cleanSteps = log.steps.map((s) => {
|
|
296
|
+
const c = { ...s };
|
|
297
|
+
delete c._sealed;
|
|
298
|
+
return c;
|
|
299
|
+
});
|
|
300
|
+
const summary = generateActivitySummary(log.steps);
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
if (!log._messageId) {
|
|
303
|
+
const thread = db.prepare("SELECT id FROM threads WHERE id = ?").get(parsed.threadId);
|
|
304
|
+
if (!thread) return;
|
|
305
|
+
const messageId = `gw-activity-${runId}`;
|
|
306
|
+
const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
|
|
307
|
+
try {
|
|
308
|
+
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);
|
|
309
|
+
log._messageId = messageId;
|
|
310
|
+
broadcastFn(JSON.stringify({ type: "clawchats", event: "message-saved", threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now }));
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(`[activity] Failed to write activity ${messageId}:`, err.message);
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
const existing = db.prepare("SELECT metadata FROM messages WHERE id = ?").get(log._messageId);
|
|
316
|
+
const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
|
|
317
|
+
metadata.activityLog = cleanSteps;
|
|
318
|
+
metadata.activitySummary = summary;
|
|
319
|
+
metadata.pending = true;
|
|
320
|
+
db.prepare("UPDATE messages SET metadata = ? WHERE id = ?").run(JSON.stringify(metadata), log._messageId);
|
|
321
|
+
}
|
|
421
322
|
}
|
|
323
|
+
__name(writeActivityToDb, "writeActivityToDb");
|
|
422
324
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
session_key TEXT UNIQUE NOT NULL,
|
|
428
|
-
title TEXT DEFAULT 'New chat',
|
|
429
|
-
pinned INTEGER DEFAULT 0,
|
|
430
|
-
pin_order INTEGER DEFAULT 0,
|
|
431
|
-
model TEXT,
|
|
432
|
-
last_session_id TEXT,
|
|
433
|
-
created_at INTEGER NOT NULL,
|
|
434
|
-
updated_at INTEGER NOT NULL
|
|
435
|
-
);
|
|
436
|
-
|
|
437
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
438
|
-
id TEXT PRIMARY KEY,
|
|
439
|
-
thread_id TEXT NOT NULL,
|
|
440
|
-
role TEXT NOT NULL,
|
|
441
|
-
content TEXT NOT NULL,
|
|
442
|
-
status TEXT DEFAULT 'sent',
|
|
443
|
-
metadata TEXT,
|
|
444
|
-
seq INTEGER,
|
|
445
|
-
timestamp INTEGER NOT NULL,
|
|
446
|
-
created_at INTEGER NOT NULL,
|
|
447
|
-
FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
|
448
|
-
);
|
|
449
|
-
|
|
450
|
-
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, timestamp);
|
|
451
|
-
CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages(thread_id, role, timestamp);
|
|
452
|
-
`);
|
|
453
|
-
|
|
454
|
-
// Migration: add sort_order column if missing
|
|
455
|
-
try {
|
|
456
|
-
db.exec('ALTER TABLE threads ADD COLUMN sort_order INTEGER DEFAULT 0');
|
|
457
|
-
} catch (e) {
|
|
458
|
-
// Column already exists — ignore
|
|
325
|
+
// server/gateway.js
|
|
326
|
+
var GatewayClient = class {
|
|
327
|
+
static {
|
|
328
|
+
__name(this, "GatewayClient");
|
|
459
329
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
330
|
+
constructor({ getDb, getWorkspaces, dataDir, debugLogger, gatewayWsUrl, authToken, mediaStash }) {
|
|
331
|
+
this.getDb = getDb;
|
|
332
|
+
this.getWorkspaces = getWorkspaces;
|
|
333
|
+
this.dataDir = dataDir;
|
|
334
|
+
this.debugLogger = debugLogger;
|
|
335
|
+
this.gatewayWsUrl = gatewayWsUrl;
|
|
336
|
+
this.authToken = authToken;
|
|
337
|
+
this.mediaStash = mediaStash;
|
|
338
|
+
this.ws = null;
|
|
339
|
+
this.connected = false;
|
|
340
|
+
this.reconnectAttempts = 0;
|
|
341
|
+
this.maxReconnectDelay = 3e4;
|
|
342
|
+
this.browserClients = /* @__PURE__ */ new Map();
|
|
343
|
+
this._externalBroadcastTargets = [];
|
|
344
|
+
this.streamState = /* @__PURE__ */ new Map();
|
|
345
|
+
this.activityLogs = /* @__PURE__ */ new Map();
|
|
346
|
+
this._pendingTitleGens = /* @__PURE__ */ new Map();
|
|
347
|
+
setInterval(() => {
|
|
348
|
+
const cutoff = Date.now() - 10 * 60 * 1e3;
|
|
349
|
+
for (const [runId, log] of this.activityLogs) {
|
|
350
|
+
if (log.startTime < cutoff) {
|
|
351
|
+
if (log._messageId) {
|
|
352
|
+
const db = this.getDb(log._parsed?.workspace);
|
|
353
|
+
if (db) db.prepare(`UPDATE messages SET content = '[Response interrupted]', metadata = json_remove(metadata, '$.pending') WHERE id = ? AND content = ''`).run(log._messageId);
|
|
354
|
+
}
|
|
355
|
+
this.activityLogs.delete(runId);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}, 5 * 60 * 1e3);
|
|
359
|
+
}
|
|
360
|
+
connect() {
|
|
361
|
+
if (this.ws && (this.ws.readyState === WS.CONNECTING || this.ws.readyState === WS.OPEN)) return;
|
|
362
|
+
console.log(`Connecting to gateway at ${this.gatewayWsUrl}...`);
|
|
363
|
+
this.ws = new WS(this.gatewayWsUrl);
|
|
364
|
+
this.ws.on("open", () => {
|
|
365
|
+
console.log("Gateway WebSocket connected");
|
|
366
|
+
this.reconnectAttempts = 0;
|
|
367
|
+
});
|
|
368
|
+
this.ws.on("message", (data) => this.handleGatewayMessage(data.toString()));
|
|
369
|
+
this.ws.on("close", () => {
|
|
370
|
+
console.log("Gateway WebSocket closed");
|
|
371
|
+
this.connected = false;
|
|
372
|
+
this.broadcastGatewayStatus(false);
|
|
373
|
+
this.scheduleReconnect();
|
|
374
|
+
});
|
|
375
|
+
this.ws.on("error", (err) => console.error("Gateway WebSocket error:", err.message));
|
|
466
376
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
CREATE TABLE IF NOT EXISTS unread_messages (
|
|
471
|
-
thread_id TEXT NOT NULL,
|
|
472
|
-
message_id TEXT NOT NULL,
|
|
473
|
-
created_at INTEGER NOT NULL,
|
|
474
|
-
PRIMARY KEY (thread_id, message_id),
|
|
475
|
-
FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
|
476
|
-
)
|
|
477
|
-
`);
|
|
478
|
-
db.exec('CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id)');
|
|
479
|
-
|
|
480
|
-
// FTS5 table — CREATE VIRTUAL TABLE doesn't support IF NOT EXISTS in all versions,
|
|
481
|
-
// so check existence first, then verify integrity and auto-repair if corrupted.
|
|
482
|
-
const hasFts = db.prepare(
|
|
483
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'"
|
|
484
|
-
).get();
|
|
485
|
-
if (!hasFts) {
|
|
377
|
+
handleGatewayMessage(data) {
|
|
378
|
+
this.debugLogger.logFrame("GW\u2192SRV", data);
|
|
379
|
+
let msg;
|
|
486
380
|
try {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
381
|
+
msg = JSON.parse(data);
|
|
382
|
+
} catch {
|
|
383
|
+
console.error("Invalid JSON from gateway:", data);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (msg.type === "event" && msg.event === "connect.challenge") {
|
|
387
|
+
const identity = loadOrCreateDeviceIdentity(path4.join(this.dataDir, "device-identity.json"));
|
|
388
|
+
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 || "" });
|
|
389
|
+
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"] } }));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (msg.type === "res" && msg.payload?.type === "hello-ok") {
|
|
393
|
+
console.log("Gateway handshake complete");
|
|
394
|
+
this.connected = true;
|
|
395
|
+
this.broadcastGatewayStatus(true);
|
|
396
|
+
}
|
|
397
|
+
if (msg.type === "event" && msg.event === "chat" && msg.payload) {
|
|
398
|
+
this.handleChatEvent(msg.payload, data);
|
|
399
|
+
} else {
|
|
400
|
+
this.broadcastToBrowsers(data);
|
|
491
401
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
402
|
+
if (msg.type === "event" && msg.event === "agent" && msg.payload) this.handleAgentEvent(msg.payload);
|
|
403
|
+
}
|
|
404
|
+
handleChatEvent(params, rawData) {
|
|
405
|
+
const { sessionKey, state, message, seq } = params;
|
|
406
|
+
if (state === "delta") {
|
|
407
|
+
const parsed = parseSessionKey(sessionKey);
|
|
408
|
+
if (parsed) {
|
|
409
|
+
const existing = this.streamState.get(sessionKey) || { buffer: "", threadId: parsed.threadId, state: "streaming", held: [] };
|
|
410
|
+
existing.buffer += extractContent(message);
|
|
411
|
+
if (isSilentReplyPrefix(existing.buffer, "NO_REPLY") || isSilentReplyPrefix(existing.buffer, "HEARTBEAT_OK")) {
|
|
412
|
+
existing.held = existing.held || [];
|
|
413
|
+
existing.held.push(rawData);
|
|
414
|
+
this.streamState.set(sessionKey, existing);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (existing.held?.length > 0) {
|
|
418
|
+
for (const h of existing.held) this.broadcastToBrowsers(h);
|
|
419
|
+
existing.held = [];
|
|
420
|
+
}
|
|
421
|
+
this.streamState.set(sessionKey, existing);
|
|
509
422
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
423
|
+
this.broadcastToBrowsers(rawData);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const streamEntry = this.streamState.get(sessionKey);
|
|
427
|
+
if (state === "final" || state === "aborted" || state === "error") this.streamState.delete(sessionKey);
|
|
428
|
+
if (sessionKey?.includes("__clawchats_title_")) {
|
|
429
|
+
if (state === "final") {
|
|
430
|
+
const content = extractContent(message);
|
|
431
|
+
if (content && this.handleTitleResponse(sessionKey, content)) return;
|
|
432
|
+
} else if (state === "error" || state === "aborted") {
|
|
433
|
+
for (const key of this._pendingTitleGens.keys()) {
|
|
434
|
+
if (sessionKey === key || sessionKey.includes(key)) {
|
|
435
|
+
this._pendingTitleGens.delete(key);
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
525
438
|
}
|
|
439
|
+
return;
|
|
526
440
|
}
|
|
527
441
|
}
|
|
442
|
+
if (state === "final") {
|
|
443
|
+
const rawContent = extractContent(message);
|
|
444
|
+
if (isSilentReplyExact(rawContent, "NO_REPLY") || isSilentReplyExact(rawContent, "HEARTBEAT_OK")) return;
|
|
445
|
+
if (streamEntry?.held?.length > 0) for (const h of streamEntry.held) this.broadcastToBrowsers(h);
|
|
446
|
+
this.broadcastToBrowsers(rawData);
|
|
447
|
+
this.saveAssistantMessage(sessionKey, message, seq);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (state === "aborted" || state === "error") this.broadcastToBrowsers(rawData);
|
|
451
|
+
if (state === "error") this.saveErrorMarker(sessionKey, message);
|
|
528
452
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
453
|
+
saveAssistantMessage(sessionKey, message, seq) {
|
|
454
|
+
const parsed = parseSessionKey(sessionKey);
|
|
455
|
+
if (!parsed) return;
|
|
456
|
+
const ws = this.getWorkspaces();
|
|
457
|
+
if (!ws.workspaces[parsed.workspace]) {
|
|
458
|
+
console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const db = this.getDb(parsed.workspace);
|
|
462
|
+
if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(parsed.threadId)) {
|
|
463
|
+
console.log(`Ignoring response for deleted thread: ${parsed.threadId}`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
let content = sanitizeAssistantContent(extractContent(message));
|
|
467
|
+
if (!content?.trim()) {
|
|
468
|
+
console.log(`Skipping empty assistant response for thread ${parsed.threadId}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const pendingPaths = this.mediaStash?.get(sessionKey) ?? [];
|
|
472
|
+
this.mediaStash?.delete(sessionKey);
|
|
473
|
+
const IMAGE_EXTS = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "avif", "tiff"]);
|
|
474
|
+
const AUDIO_EXTS = /* @__PURE__ */ new Set(["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus", "wma"]);
|
|
475
|
+
const imagePaths = [], pendingAttachments = [];
|
|
476
|
+
for (const p of pendingPaths) {
|
|
477
|
+
const ext = (p.split(".").pop() || "").toLowerCase();
|
|
478
|
+
if (IMAGE_EXTS.has(ext)) imagePaths.push(p);
|
|
479
|
+
else pendingAttachments.push({ path: p, name: p.split("/").pop(), type: AUDIO_EXTS.has(ext) ? "audio" : "file" });
|
|
480
|
+
}
|
|
481
|
+
if (imagePaths.length > 0) content = content.trimEnd() + "\n\n" + imagePaths.map((p) => ``).join("\n");
|
|
482
|
+
if (pendingPaths.length > 0) console.log(`[clawchats] media-attach: ${imagePaths.length} image(s), ${pendingAttachments.length} attachment(s) for ${sessionKey}`);
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
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);
|
|
485
|
+
let messageId;
|
|
486
|
+
if (pendingMsg) {
|
|
487
|
+
const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
|
|
488
|
+
delete metadata.pending;
|
|
489
|
+
if (metadata.activityLog) {
|
|
490
|
+
const idx = metadata.activityLog.findLastIndex((s) => s.type === "assistant");
|
|
491
|
+
if (idx >= 0) metadata.activityLog.splice(idx, 1);
|
|
492
|
+
metadata.activitySummary = generateActivitySummary(metadata.activityLog);
|
|
493
|
+
}
|
|
494
|
+
if (pendingAttachments.length > 0) metadata.attachments = [...metadata.attachments || [], ...pendingAttachments];
|
|
495
|
+
db.prepare("UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?").run(content, JSON.stringify(metadata), now, pendingMsg.id);
|
|
496
|
+
messageId = pendingMsg.id;
|
|
497
|
+
} else {
|
|
498
|
+
messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
|
|
499
|
+
const newMeta = pendingAttachments.length > 0 ? JSON.stringify({ attachments: pendingAttachments }) : null;
|
|
500
|
+
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);
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
db.prepare("UPDATE threads SET updated_at = ? WHERE id = ?").run(now, parsed.threadId);
|
|
504
|
+
db.prepare("INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)").run(parsed.threadId, messageId, now);
|
|
505
|
+
syncThreadUnreadCount(db, parsed.threadId);
|
|
506
|
+
const threadInfo = db.prepare("SELECT title FROM threads WHERE id = ?").get(parsed.threadId);
|
|
507
|
+
const unreadCount = db.prepare("SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?").get(parsed.threadId).c;
|
|
508
|
+
const preview = content.length > 120 ? content.substring(0, 120) + "..." : content;
|
|
509
|
+
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 : void 0, updatedAttachments: pendingAttachments.length > 0 ? pendingAttachments : void 0 }));
|
|
510
|
+
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 }));
|
|
511
|
+
console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? "merged into pending" : "seq: " + seq})`);
|
|
512
|
+
const msgCount = db.prepare("SELECT COUNT(*) as c FROM messages WHERE thread_id = ?").get(parsed.threadId).c;
|
|
513
|
+
if (msgCount === 2 || threadInfo?.title === "New chat") this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
|
|
514
|
+
} catch (e) {
|
|
515
|
+
console.error("Failed to save assistant message:", e.message);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
saveErrorMarker(sessionKey, message) {
|
|
519
|
+
const parsed = parseSessionKey(sessionKey);
|
|
520
|
+
if (!parsed) return;
|
|
521
|
+
const ws = this.getWorkspaces();
|
|
522
|
+
if (!ws.workspaces[parsed.workspace]) return;
|
|
523
|
+
const db = this.getDb(parsed.workspace);
|
|
524
|
+
if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(parsed.threadId)) return;
|
|
525
|
+
const now = Date.now();
|
|
526
|
+
try {
|
|
527
|
+
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);
|
|
528
|
+
} catch (e) {
|
|
529
|
+
console.error("Failed to save error marker:", e.message);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
|
|
533
|
+
if (!db.prepare("SELECT title FROM threads WHERE id = ?").get(threadId)) return;
|
|
534
|
+
const titleKey = `__clawchats_title_${threadId}`;
|
|
535
|
+
if (this._pendingTitleGens.has(titleKey)) return;
|
|
536
|
+
const firstUserMsg = db.prepare("SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1").get(threadId);
|
|
537
|
+
if (!firstUserMsg?.content) return;
|
|
538
|
+
if (!skipHeuristic) {
|
|
539
|
+
const heuristic = firstUserMsg.content.replace(/\n.*/s, "").slice(0, 40).trim() + (firstUserMsg.content.length > 40 ? "..." : "");
|
|
540
|
+
db.prepare("UPDATE threads SET title = ? WHERE id = ?").run(heuristic, threadId);
|
|
541
|
+
this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "thread-title-updated", threadId, workspace, title: heuristic }));
|
|
542
|
+
}
|
|
543
|
+
const messages = db.prepare("SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6").all(threadId);
|
|
544
|
+
if (messages.length < 2) return;
|
|
545
|
+
const conversation = messages.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content.length > 300 ? m.content.slice(0, 300) + "..." : m.content}`).join("\n\n");
|
|
546
|
+
const reqId = `title-${threadId}-${Date.now()}`;
|
|
547
|
+
this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
|
|
548
|
+
setTimeout(() => {
|
|
549
|
+
if (this._pendingTitleGens.has(titleKey)) {
|
|
550
|
+
this._pendingTitleGens.delete(titleKey);
|
|
551
|
+
console.log(`Title gen timeout for ${threadId}`);
|
|
552
|
+
}
|
|
553
|
+
}, 3e4);
|
|
554
|
+
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:
|
|
532
555
|
|
|
533
|
-
|
|
534
|
-
return new Promise((resolve, reject) => {
|
|
535
|
-
const chunks = [];
|
|
536
|
-
req.on('data', c => chunks.push(c));
|
|
537
|
-
req.on('end', () => {
|
|
538
|
-
const raw = Buffer.concat(chunks).toString();
|
|
539
|
-
if (!raw) return resolve({});
|
|
540
|
-
try { resolve(JSON.parse(raw)); }
|
|
541
|
-
catch { reject(new Error('Invalid JSON')); }
|
|
542
|
-
});
|
|
543
|
-
req.on('error', reject);
|
|
544
|
-
});
|
|
545
|
-
}
|
|
556
|
+
${conversation}
|
|
546
557
|
|
|
547
|
-
|
|
548
|
-
const body = JSON.stringify(data);
|
|
549
|
-
res.writeHead(status, {
|
|
550
|
-
'Content-Type': 'application/json',
|
|
551
|
-
'Access-Control-Allow-Origin': '*',
|
|
552
|
-
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
|
553
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
554
|
-
});
|
|
555
|
-
res.end(body);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function sendError(res, status, message) {
|
|
559
|
-
send(res, status, { error: message });
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function setCors(res) {
|
|
563
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
564
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
|
|
565
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function uuid() {
|
|
569
|
-
return crypto.randomUUID();
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// ─── Route matching ─────────────────────────────────────────────────────────
|
|
573
|
-
|
|
574
|
-
function matchRoute(method, url, pattern) {
|
|
575
|
-
// pattern like "GET /api/threads/:id/messages/:messageId"
|
|
576
|
-
const [pMethod, pPath] = pattern.split(' ');
|
|
577
|
-
if (method !== pMethod) return null;
|
|
578
|
-
const pParts = pPath.split('/').filter(Boolean);
|
|
579
|
-
const uParts = url.split('/').filter(Boolean);
|
|
580
|
-
if (pParts.length !== uParts.length) return null;
|
|
581
|
-
const params = {};
|
|
582
|
-
for (let i = 0; i < pParts.length; i++) {
|
|
583
|
-
if (pParts[i].startsWith(':')) {
|
|
584
|
-
params[pParts[i].slice(1)] = decodeURIComponent(uParts[i]);
|
|
585
|
-
} else if (pParts[i] !== uParts[i]) {
|
|
586
|
-
return null;
|
|
587
|
-
}
|
|
558
|
+
Title:`, deliver: false, idempotencyKey: reqId } }));
|
|
588
559
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const auth = req.headers.authorization;
|
|
597
|
-
if (!auth || !auth.startsWith('Bearer ')) {
|
|
598
|
-
sendError(res, 401, 'Missing or invalid Authorization header');
|
|
599
|
-
return false;
|
|
600
|
-
}
|
|
601
|
-
const token = auth.slice(7);
|
|
602
|
-
if (token !== AUTH_TOKEN) {
|
|
603
|
-
sendError(res, 401, 'Invalid auth token');
|
|
604
|
-
return false;
|
|
605
|
-
}
|
|
606
|
-
return true;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// ─── Multipart Parser (minimal, for file uploads) ───────────────────────────
|
|
610
|
-
|
|
611
|
-
function parseMultipart(req) {
|
|
612
|
-
return new Promise((resolve, reject) => {
|
|
613
|
-
const contentType = req.headers['content-type'] || '';
|
|
614
|
-
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
|
|
615
|
-
if (!boundaryMatch) return reject(new Error('No boundary in content-type'));
|
|
616
|
-
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
|
617
|
-
const chunks = [];
|
|
618
|
-
req.on('data', c => chunks.push(c));
|
|
619
|
-
req.on('end', () => {
|
|
620
|
-
const buf = Buffer.concat(chunks);
|
|
621
|
-
const files = [];
|
|
622
|
-
const delimiter = Buffer.from(`--${boundary}`);
|
|
623
|
-
const end = Buffer.from(`--${boundary}--`);
|
|
624
|
-
|
|
625
|
-
let pos = 0;
|
|
626
|
-
while (pos < buf.length) {
|
|
627
|
-
const start = buf.indexOf(delimiter, pos);
|
|
628
|
-
if (start === -1) break;
|
|
629
|
-
const nextStart = buf.indexOf(delimiter, start + delimiter.length);
|
|
630
|
-
if (nextStart === -1) break;
|
|
631
|
-
|
|
632
|
-
const part = buf.subarray(start + delimiter.length, nextStart);
|
|
633
|
-
const headerEnd = part.indexOf('\r\n\r\n');
|
|
634
|
-
if (headerEnd === -1) { pos = nextStart; continue; }
|
|
635
|
-
|
|
636
|
-
const headerStr = part.subarray(0, headerEnd).toString();
|
|
637
|
-
let body = part.subarray(headerEnd + 4);
|
|
638
|
-
// Trim trailing \r\n
|
|
639
|
-
if (body.length >= 2 && body[body.length - 2] === 0x0d && body[body.length - 1] === 0x0a) {
|
|
640
|
-
body = body.subarray(0, body.length - 2);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const filenameMatch = headerStr.match(/filename="([^"]+)"/);
|
|
644
|
-
const ctMatch = headerStr.match(/Content-Type:\s*(\S+)/i);
|
|
645
|
-
if (filenameMatch) {
|
|
646
|
-
files.push({
|
|
647
|
-
filename: filenameMatch[1],
|
|
648
|
-
mimeType: ctMatch ? ctMatch[1] : 'application/octet-stream',
|
|
649
|
-
data: body,
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
pos = nextStart;
|
|
653
|
-
}
|
|
654
|
-
resolve(files);
|
|
655
|
-
});
|
|
656
|
-
req.on('error', reject);
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// ─── Context Fill Helper ────────────────────────────────────────────────────
|
|
661
|
-
|
|
662
|
-
function buildContextPreamble(db, threadId, lastSessionId, sessionKey) {
|
|
663
|
-
let summary = null;
|
|
664
|
-
let method = 'raw';
|
|
665
|
-
|
|
666
|
-
// Try to read old JSONL transcript
|
|
667
|
-
if (lastSessionId) {
|
|
668
|
-
const agentMatch = (sessionKey || '').match(/^agent:([^:]+):/);
|
|
669
|
-
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
670
|
-
const jsonlPath = path.join(sessionsDir, `${lastSessionId}.jsonl`);
|
|
671
|
-
try {
|
|
672
|
-
const content = fs.readFileSync(jsonlPath, 'utf8');
|
|
673
|
-
const lines = content.split('\n').filter(Boolean);
|
|
674
|
-
// Find last compaction entry
|
|
675
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
676
|
-
try {
|
|
677
|
-
const entry = JSON.parse(lines[i]);
|
|
678
|
-
if (entry.type === 'compaction' && entry.summary) {
|
|
679
|
-
summary = entry.summary;
|
|
680
|
-
method = 'compaction';
|
|
681
|
-
break;
|
|
682
|
-
}
|
|
683
|
-
} catch { /* skip malformed lines */ }
|
|
560
|
+
handleTitleResponse(sessionKey, content) {
|
|
561
|
+
let matchKey = null, pending = null;
|
|
562
|
+
for (const [key, val] of this._pendingTitleGens) {
|
|
563
|
+
if (sessionKey === key || sessionKey.includes(key)) {
|
|
564
|
+
matchKey = key;
|
|
565
|
+
pending = val;
|
|
566
|
+
break;
|
|
684
567
|
}
|
|
685
|
-
}
|
|
568
|
+
}
|
|
569
|
+
if (!pending) return false;
|
|
570
|
+
this._pendingTitleGens.delete(matchKey);
|
|
571
|
+
let title = content.trim().replace(/^["']|["']$/g, "").replace(/^Title:\s*/i, "").replace(/\n.*/s, "").trim();
|
|
572
|
+
if (title.length > 50) title = title.substring(0, 47) + "...";
|
|
573
|
+
if (!title || title.length >= 100) return true;
|
|
574
|
+
const db = this.getDb(pending.workspace);
|
|
575
|
+
db.prepare("UPDATE threads SET title = ? WHERE id = ?").run(title, pending.threadId);
|
|
576
|
+
this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "thread-title-updated", threadId: pending.threadId, workspace: pending.workspace, title }));
|
|
577
|
+
console.log(`AI title generated for ${pending.threadId}: "${title}"`);
|
|
578
|
+
return true;
|
|
686
579
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (msgs.length) {
|
|
702
|
-
preamble += '[RECENT MESSAGES]\n';
|
|
703
|
-
for (const m of msgs) {
|
|
704
|
-
const d = new Date(m.timestamp);
|
|
705
|
-
const ts = d.toISOString().replace('T', ' ').slice(0, 16);
|
|
706
|
-
const role = m.role.charAt(0).toUpperCase() + m.role.slice(1);
|
|
707
|
-
preamble += `${role} (${ts}): ${m.content}\n`;
|
|
580
|
+
handleAgentEvent(payload) {
|
|
581
|
+
const { runId, stream, data, sessionKey } = payload;
|
|
582
|
+
if (!runId) return;
|
|
583
|
+
if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
|
|
584
|
+
const log = this.activityLogs.get(runId);
|
|
585
|
+
if (stream === "assistant") {
|
|
586
|
+
const text = data?.text || "";
|
|
587
|
+
if (text) {
|
|
588
|
+
let seg = log._currentAssistantSegment;
|
|
589
|
+
if (!seg || seg._sealed) {
|
|
590
|
+
seg = { type: "assistant", timestamp: Date.now(), text, _sealed: false };
|
|
591
|
+
log._currentAssistantSegment = seg;
|
|
592
|
+
log.steps.push(seg);
|
|
593
|
+
} else seg.text = text;
|
|
708
594
|
}
|
|
595
|
+
return;
|
|
709
596
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
preamble += '[PREVIOUS MESSAGES]\n';
|
|
720
|
-
for (const m of msgs) {
|
|
721
|
-
const d = new Date(m.timestamp);
|
|
722
|
-
const ts = d.toISOString().replace('T', ' ').slice(0, 16);
|
|
723
|
-
const role = m.role.charAt(0).toUpperCase() + m.role.slice(1);
|
|
724
|
-
preamble += `${role} (${ts}): ${m.content}\n`;
|
|
597
|
+
if (stream === "thinking") {
|
|
598
|
+
let step = log.steps.find((s) => s.type === "thinking");
|
|
599
|
+
if (step) step.text = data?.text || "";
|
|
600
|
+
else log.steps.push({ type: "thinking", timestamp: Date.now(), text: data?.text || "" });
|
|
601
|
+
writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
|
|
602
|
+
const now = Date.now();
|
|
603
|
+
if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
|
|
604
|
+
log._lastThinkingBroadcast = now;
|
|
605
|
+
this._broadcastActivityUpdate(runId, log);
|
|
725
606
|
}
|
|
726
607
|
}
|
|
608
|
+
if (stream === "tool") {
|
|
609
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
|
|
610
|
+
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") || "" : "";
|
|
611
|
+
const step = { type: "tool", timestamp: Date.now(), name: data?.name || "unknown", phase: data?.phase || "start", toolCallId: data?.toolCallId, meta: data?.meta || (argsMeta ? String(argsMeta) : void 0), isError: data?.isError || false };
|
|
612
|
+
if (data?.phase === "result") {
|
|
613
|
+
const existing = log.steps.findLast((s) => s.toolCallId === data.toolCallId && (s.phase === "start" || s.phase === "running"));
|
|
614
|
+
if (existing) {
|
|
615
|
+
existing.phase = "done";
|
|
616
|
+
existing.resultMeta = data?.meta;
|
|
617
|
+
existing.isError = data?.isError || false;
|
|
618
|
+
existing.durationMs = Date.now() - existing.timestamp;
|
|
619
|
+
} else {
|
|
620
|
+
step.phase = "done";
|
|
621
|
+
log.steps.push(step);
|
|
622
|
+
}
|
|
623
|
+
} else if (data?.phase === "update") {
|
|
624
|
+
const existing = log.steps.findLast((s) => s.toolCallId === data.toolCallId);
|
|
625
|
+
if (existing) {
|
|
626
|
+
if (data?.meta) existing.resultMeta = data.meta;
|
|
627
|
+
if (data?.isError) existing.isError = true;
|
|
628
|
+
existing.phase = "running";
|
|
629
|
+
}
|
|
630
|
+
} else log.steps.push(step);
|
|
631
|
+
writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
|
|
632
|
+
this._broadcastActivityUpdate(runId, log);
|
|
633
|
+
}
|
|
634
|
+
if (stream === "lifecycle" && (data?.phase === "end" || data?.phase === "error")) {
|
|
635
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
|
|
636
|
+
const idx = log.steps.findLastIndex((s) => s.type === "assistant");
|
|
637
|
+
if (idx >= 0) log.steps.splice(idx, 1);
|
|
638
|
+
writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
|
|
639
|
+
this.activityLogs.delete(runId);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
_broadcastActivityUpdate(runId, log) {
|
|
643
|
+
if (!log._parsed || !log._messageId) return;
|
|
644
|
+
const cleanSteps = log.steps.map((s) => {
|
|
645
|
+
const c = { ...s };
|
|
646
|
+
delete c._sealed;
|
|
647
|
+
return c;
|
|
648
|
+
});
|
|
649
|
+
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) }));
|
|
727
650
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
return { preamble, method };
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// ─── Gateway Session Cleanup ─────────────────────────────────────────────────
|
|
738
|
-
|
|
739
|
-
function cleanGatewaySession(sessionKey) {
|
|
740
|
-
try {
|
|
741
|
-
const agentMatch = (sessionKey || '').match(/^agent:([^:]+):/);
|
|
742
|
-
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
743
|
-
const sessionsPath = path.join(sessionsDir, 'sessions.json');
|
|
744
|
-
const raw = fs.readFileSync(sessionsPath, 'utf8');
|
|
745
|
-
const store = JSON.parse(raw);
|
|
746
|
-
const entry = store[sessionKey];
|
|
747
|
-
if (!entry) return null;
|
|
748
|
-
|
|
749
|
-
// Delete .jsonl transcript
|
|
750
|
-
if (entry.sessionId) {
|
|
751
|
-
const jsonlPath = path.join(sessionsDir, `${entry.sessionId}.jsonl`);
|
|
752
|
-
try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
|
|
651
|
+
broadcastToBrowsers(data) {
|
|
652
|
+
this.debugLogger.logFrame("SRV\u2192BR", data);
|
|
653
|
+
for (const client of this.browserClients.keys()) {
|
|
654
|
+
if (client.readyState === WS.OPEN) client.send(data);
|
|
753
655
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
|
|
759
|
-
return sessionId;
|
|
760
|
-
} catch (err) {
|
|
761
|
-
console.warn(`cleanGatewaySession(${sessionKey}):`, err.message);
|
|
762
|
-
return null;
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
function cleanGatewaySessionsByPrefix(prefix) {
|
|
767
|
-
try {
|
|
768
|
-
const agentMatch = (prefix || '').match(/^agent:([^:]+):/);
|
|
769
|
-
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
770
|
-
const sessionsPath = path.join(sessionsDir, 'sessions.json');
|
|
771
|
-
const raw = fs.readFileSync(sessionsPath, 'utf8');
|
|
772
|
-
const store = JSON.parse(raw);
|
|
773
|
-
let cleaned = 0;
|
|
774
|
-
|
|
775
|
-
for (const key of Object.keys(store)) {
|
|
776
|
-
if (!key.startsWith(prefix)) continue;
|
|
777
|
-
const entry = store[key];
|
|
778
|
-
if (entry?.sessionId) {
|
|
779
|
-
const jsonlPath = path.join(sessionsDir, `${entry.sessionId}.jsonl`);
|
|
780
|
-
try { fs.unlinkSync(jsonlPath); } catch { /* ok */ }
|
|
656
|
+
for (const fn of this._externalBroadcastTargets) {
|
|
657
|
+
try {
|
|
658
|
+
fn(data);
|
|
659
|
+
} catch {
|
|
781
660
|
}
|
|
782
|
-
delete store[key];
|
|
783
|
-
cleaned++;
|
|
784
661
|
}
|
|
785
|
-
|
|
786
|
-
if (cleaned > 0) {
|
|
787
|
-
fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
|
|
788
|
-
}
|
|
789
|
-
return cleaned;
|
|
790
|
-
} catch (err) {
|
|
791
|
-
console.warn(`cleanGatewaySessionsByPrefix(${prefix}):`, err.message);
|
|
792
|
-
return 0;
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
// ─── File Serving (restricted to ~/.openclaw/media/) ────────────────────────
|
|
797
|
-
|
|
798
|
-
const ALLOWED_FILE_DIRS = [
|
|
799
|
-
HOME,
|
|
800
|
-
'/tmp',
|
|
801
|
-
];
|
|
802
|
-
|
|
803
|
-
function handleServeFile(req, res, query) {
|
|
804
|
-
const filePath = query.path;
|
|
805
|
-
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
806
|
-
|
|
807
|
-
// Resolve to prevent traversal attacks
|
|
808
|
-
// Relative paths (./filename) resolve against the workspace directory
|
|
809
|
-
const resolved = (filePath.startsWith('./') || filePath.startsWith('../'))
|
|
810
|
-
? path.resolve(MEMORY_CONFIG.workspaceDir, filePath)
|
|
811
|
-
: path.resolve(filePath);
|
|
812
|
-
|
|
813
|
-
// Security: only serve files from allowed directories
|
|
814
|
-
const allowed = ALLOWED_FILE_DIRS.some(dir => resolved.startsWith(dir + '/') || resolved === dir);
|
|
815
|
-
if (!allowed) {
|
|
816
|
-
return sendError(res, 403, 'Access denied: path not in allowed directories');
|
|
817
662
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
return sendError(res, 404, 'File not found');
|
|
663
|
+
broadcastGatewayStatus(connected) {
|
|
664
|
+
this.broadcastToBrowsers(JSON.stringify({ type: "clawchats", event: "gateway-status", connected }));
|
|
821
665
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
827
|
-
'.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
|
|
828
|
-
'.md': 'text/markdown', '.csv': 'text/csv', '.xml': 'text/xml',
|
|
829
|
-
'.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
|
|
830
|
-
'.py': 'text/x-python', '.sh': 'text/x-shellscript',
|
|
831
|
-
'.yaml': 'text/yaml', '.yml': 'text/yaml', '.toml': 'text/toml',
|
|
832
|
-
'.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar',
|
|
833
|
-
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
834
|
-
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
835
|
-
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
|
836
|
-
'.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
837
|
-
};
|
|
838
|
-
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
839
|
-
|
|
840
|
-
const stat = fs.statSync(resolved);
|
|
841
|
-
res.writeHead(200, {
|
|
842
|
-
'Content-Type': contentType,
|
|
843
|
-
'Content-Length': stat.size,
|
|
844
|
-
'Cache-Control': 'public, max-age=86400',
|
|
845
|
-
'Access-Control-Allow-Origin': '*',
|
|
846
|
-
});
|
|
847
|
-
fs.createReadStream(resolved).pipe(res);
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// ─── Workspace File Browser ─────────────────────────────────────────────────
|
|
851
|
-
|
|
852
|
-
const WORKSPACE_ROOT = HOME;
|
|
853
|
-
|
|
854
|
-
function handleWorkspaceList(req, res, query) {
|
|
855
|
-
const reqPath = query.path || '~/.openclaw/workspace';
|
|
856
|
-
const depth = parseInt(query.depth || '2', 10);
|
|
857
|
-
const showHidden = query.hidden === '1' || query.hidden === 'true';
|
|
858
|
-
|
|
859
|
-
// Resolve ~ to HOME
|
|
860
|
-
const resolved = path.resolve(reqPath.replace(/^~/, HOME));
|
|
861
|
-
|
|
862
|
-
// Security: only allow listing under home directory
|
|
863
|
-
if (!resolved.startsWith(HOME)) {
|
|
864
|
-
return sendError(res, 403, 'Access denied');
|
|
666
|
+
sendToGateway(data) {
|
|
667
|
+
this.debugLogger.logFrame("SRV\u2192GW", data);
|
|
668
|
+
if (this.ws?.readyState === WS.OPEN) this.ws.send(data);
|
|
669
|
+
else console.error("Cannot send to gateway: not connected");
|
|
865
670
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
671
|
+
scheduleReconnect() {
|
|
672
|
+
const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
|
|
673
|
+
this.reconnectAttempts++;
|
|
674
|
+
console.log(`Reconnecting to gateway in ${delay}ms (attempt ${this.reconnectAttempts})...`);
|
|
675
|
+
setTimeout(() => this.connect(), delay);
|
|
869
676
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
for (const entry of entries) {
|
|
878
|
-
// Skip hidden files unless showHidden (always show .openclaw)
|
|
879
|
-
if (entry.name.startsWith('.') && entry.name !== '.openclaw' && !showHidden) continue;
|
|
880
|
-
if (entry.name === 'node_modules') continue;
|
|
881
|
-
|
|
882
|
-
const fullPath = path.join(dir, entry.name);
|
|
883
|
-
const isDir = entry.isDirectory();
|
|
884
|
-
files.push({
|
|
885
|
-
path: fullPath + (isDir ? '/' : ''),
|
|
886
|
-
type: isDir ? 'dir' : 'file',
|
|
887
|
-
name: entry.name,
|
|
888
|
-
size: isDir ? 0 : (() => { try { return fs.statSync(fullPath).size; } catch { return 0; } })(),
|
|
889
|
-
});
|
|
890
|
-
if (isDir) walk(fullPath, currentDepth + 1);
|
|
677
|
+
addBrowserClient(ws) {
|
|
678
|
+
this.browserClients.set(ws, { activeWorkspace: null, activeThreadId: null });
|
|
679
|
+
if (ws.readyState === WS.OPEN) {
|
|
680
|
+
ws.send(JSON.stringify({ type: "clawchats", event: "gateway-status", connected: this.connected }));
|
|
681
|
+
const streams = [];
|
|
682
|
+
for (const [sessionKey, state] of this.streamState.entries()) {
|
|
683
|
+
if (state.state === "streaming" && !(state.held?.length > 0)) streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
|
|
891
684
|
}
|
|
892
|
-
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
// Add root entry
|
|
896
|
-
files.unshift({ path: resolved + '/', type: 'dir', name: path.basename(resolved), size: 0 });
|
|
897
|
-
walk(resolved, 1);
|
|
898
|
-
|
|
899
|
-
send(res, 200, { files, cwd: resolved });
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
function handleWorkspaceFileRead(req, res, query) {
|
|
903
|
-
const filePath = query.path;
|
|
904
|
-
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
905
|
-
|
|
906
|
-
const resolved = path.resolve(filePath.replace(/^~/, HOME));
|
|
907
|
-
|
|
908
|
-
// Security: only allow reading under home directory
|
|
909
|
-
if (!resolved.startsWith(HOME)) {
|
|
910
|
-
return sendError(res, 403, 'Access denied');
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
914
|
-
return sendError(res, 404, 'File not found');
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
const stat = fs.statSync(resolved);
|
|
918
|
-
const ext = path.extname(resolved).toLowerCase().slice(1);
|
|
919
|
-
|
|
920
|
-
// MIME type map — binary types get served as binary, rest as text
|
|
921
|
-
const mimeMap = {
|
|
922
|
-
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
923
|
-
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
924
|
-
bmp: 'image/bmp', ico: 'image/x-icon',
|
|
925
|
-
pdf: 'application/pdf',
|
|
926
|
-
mp3: 'audio/mpeg', mp4: 'video/mp4', wav: 'audio/wav',
|
|
927
|
-
ogg: 'audio/ogg', webm: 'video/webm',
|
|
928
|
-
};
|
|
929
|
-
const mime = mimeMap[ext];
|
|
930
|
-
const isBinary = !!mime;
|
|
931
|
-
|
|
932
|
-
if (isBinary) {
|
|
933
|
-
if (stat.size > 20 * 1024 * 1024) return sendError(res, 413, 'File too large (max 20MB)');
|
|
934
|
-
const content = fs.readFileSync(resolved);
|
|
935
|
-
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'private, max-age=60' });
|
|
936
|
-
res.end(content);
|
|
937
|
-
} else {
|
|
938
|
-
if (stat.size > 1024 * 1024) return sendError(res, 413, 'File too large (max 1MB)');
|
|
939
|
-
const content = fs.readFileSync(resolved, 'utf8');
|
|
940
|
-
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
941
|
-
res.end(content);
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
async function handleWorkspaceFileWrite(req, res, query) {
|
|
946
|
-
const filePath = query.path;
|
|
947
|
-
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
948
|
-
|
|
949
|
-
const resolved = path.resolve(filePath.replace(/^~/, HOME));
|
|
950
|
-
|
|
951
|
-
// Security: only allow writing under workspace
|
|
952
|
-
if (!resolved.startsWith(WORKSPACE_ROOT)) {
|
|
953
|
-
return sendError(res, 403, 'Can only write to workspace directory');
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// Read body as text
|
|
957
|
-
const chunks = [];
|
|
958
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
959
|
-
const content = Buffer.concat(chunks).toString('utf8');
|
|
960
|
-
|
|
961
|
-
// Ensure parent directory exists
|
|
962
|
-
const dir = path.dirname(resolved);
|
|
963
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
964
|
-
|
|
965
|
-
fs.writeFileSync(resolved, content, 'utf8');
|
|
966
|
-
send(res, 200, { ok: true });
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
function handleWorkspaceFileDelete(req, res, query) {
|
|
970
|
-
const filePath = query.path;
|
|
971
|
-
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
972
|
-
|
|
973
|
-
const resolved = path.resolve(filePath.replace(/^~/, HOME));
|
|
974
|
-
|
|
975
|
-
// Security: only allow paths under home directory
|
|
976
|
-
if (!resolved.startsWith(HOME)) {
|
|
977
|
-
return sendError(res, 403, 'Access denied');
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
if (!fs.existsSync(resolved)) {
|
|
981
|
-
return sendError(res, 404, 'Path not found');
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
try {
|
|
985
|
-
const stat = fs.statSync(resolved);
|
|
986
|
-
if (stat.isDirectory()) {
|
|
987
|
-
fs.rmSync(resolved, { recursive: true, force: true });
|
|
988
|
-
send(res, 200, { ok: true, type: 'dir' });
|
|
989
|
-
} else {
|
|
990
|
-
fs.unlinkSync(resolved);
|
|
991
|
-
send(res, 200, { ok: true, type: 'file' });
|
|
685
|
+
if (streams.length > 0) ws.send(JSON.stringify({ type: "clawchats", event: "stream-sync", streams }));
|
|
992
686
|
}
|
|
993
|
-
} catch (err) {
|
|
994
|
-
sendError(res, 500, 'Delete failed: ' + err.message);
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
async function handleWorkspaceUpload(req, res, query) {
|
|
999
|
-
const targetDir = query.path;
|
|
1000
|
-
if (!targetDir) return sendError(res, 400, 'Missing path parameter');
|
|
1001
|
-
|
|
1002
|
-
const resolved = path.resolve(targetDir.replace(/^~/, HOME));
|
|
1003
|
-
|
|
1004
|
-
// Security: only allow uploading under home directory
|
|
1005
|
-
if (!resolved.startsWith(HOME)) {
|
|
1006
|
-
return sendError(res, 403, 'Access denied');
|
|
1007
687
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
return sendError(res, 404, 'Target directory not found');
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// Parse multipart form data manually (no dependency needed for simple case)
|
|
1014
|
-
const contentType = req.headers['content-type'] || '';
|
|
1015
|
-
if (!contentType.includes('multipart/form-data')) {
|
|
1016
|
-
return sendError(res, 400, 'Expected multipart/form-data');
|
|
688
|
+
removeBrowserClient(ws) {
|
|
689
|
+
this.browserClients.delete(ws);
|
|
1017
690
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
if (start > 0) {
|
|
1036
|
-
// Extract the part between previous boundary and this one
|
|
1037
|
-
const partData = body.slice(start, idx - 2); // -2 for \r\n before boundary
|
|
1038
|
-
const headerEnd = partData.indexOf('\r\n\r\n');
|
|
1039
|
-
if (headerEnd !== -1) {
|
|
1040
|
-
const headerStr = partData.slice(0, headerEnd).toString('utf8');
|
|
1041
|
-
const fileContent = partData.slice(headerEnd + 4);
|
|
1042
|
-
|
|
1043
|
-
const filenameMatch = headerStr.match(/filename="([^"]+)"/);
|
|
1044
|
-
if (filenameMatch && fileContent.length > 0) {
|
|
1045
|
-
const filename = path.basename(filenameMatch[1]); // sanitize
|
|
1046
|
-
const filePath = path.join(resolved, filename);
|
|
1047
|
-
|
|
1048
|
-
// Don't overwrite — add suffix if exists
|
|
1049
|
-
let finalPath = filePath;
|
|
1050
|
-
let counter = 1;
|
|
1051
|
-
while (fs.existsSync(finalPath)) {
|
|
1052
|
-
const ext = path.extname(filename);
|
|
1053
|
-
const base = path.basename(filename, ext);
|
|
1054
|
-
finalPath = path.join(resolved, `${base} (${counter})${ext}`);
|
|
1055
|
-
counter++;
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
fs.writeFileSync(finalPath, fileContent);
|
|
1059
|
-
uploaded.push({ name: path.basename(finalPath), size: fileContent.length });
|
|
1060
|
-
}
|
|
691
|
+
setActiveThread(ws, workspace, threadId) {
|
|
692
|
+
const client = ws ? this.browserClients.get(ws) : null;
|
|
693
|
+
if (client) {
|
|
694
|
+
client.activeWorkspace = workspace;
|
|
695
|
+
client.activeThreadId = threadId;
|
|
696
|
+
}
|
|
697
|
+
if (!workspace || !threadId) return;
|
|
698
|
+
try {
|
|
699
|
+
const wsData = this.getWorkspaces();
|
|
700
|
+
if (!wsData.workspaces[workspace]) return;
|
|
701
|
+
const db = this.getDb(workspace);
|
|
702
|
+
if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(threadId)) return;
|
|
703
|
+
const deleted = db.prepare("DELETE FROM unread_messages WHERE thread_id = ?").run(threadId);
|
|
704
|
+
if (deleted.changes > 0) {
|
|
705
|
+
syncThreadUnreadCount(db, threadId);
|
|
706
|
+
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() }));
|
|
1061
707
|
}
|
|
708
|
+
} catch (e) {
|
|
709
|
+
console.error("Failed to auto-clear unreads on active-thread:", e.message);
|
|
1062
710
|
}
|
|
1063
|
-
|
|
1064
|
-
start = idx + boundaryBuf.length + 2; // +2 for \r\n after boundary
|
|
1065
711
|
}
|
|
712
|
+
addBroadcastTarget(fn) {
|
|
713
|
+
this._externalBroadcastTargets.push(fn);
|
|
714
|
+
}
|
|
715
|
+
removeBroadcastTarget(fn) {
|
|
716
|
+
this._externalBroadcastTargets = this._externalBroadcastTargets.filter((f) => f !== fn);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
1066
719
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
720
|
+
// server/providers/memory.js
|
|
721
|
+
import fs4 from "node:fs";
|
|
722
|
+
import path5 from "node:path";
|
|
723
|
+
import os2 from "node:os";
|
|
1072
724
|
function discoverMemoryConfig() {
|
|
1073
|
-
|
|
1074
|
-
const defaults = { provider: 'qdrant', host: 'localhost', port: 6333, collection: null };
|
|
1075
|
-
|
|
1076
|
-
// Try reading openclaw.json
|
|
725
|
+
const defaults = { provider: "qdrant", host: "localhost", port: 6333, collection: null };
|
|
1077
726
|
let oc = null;
|
|
1078
|
-
const
|
|
1079
|
-
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
|
|
1080
|
-
'/etc/openclaw/openclaw.json'
|
|
1081
|
-
];
|
|
1082
|
-
for (const cfgPath of candidates) {
|
|
727
|
+
for (const cfgPath of [path5.join(os2.homedir(), ".openclaw", "openclaw.json"), "/etc/openclaw/openclaw.json"]) {
|
|
1083
728
|
try {
|
|
1084
|
-
oc = JSON.parse(
|
|
729
|
+
oc = JSON.parse(fs4.readFileSync(cfgPath, "utf8"));
|
|
1085
730
|
break;
|
|
1086
|
-
} catch {
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
1087
733
|
}
|
|
1088
|
-
|
|
1089
734
|
let cfg = { ...defaults };
|
|
1090
|
-
|
|
1091
735
|
if (oc) {
|
|
1092
|
-
const
|
|
1093
|
-
const vs = memSlot ? oc.plugins?.entries?.[memSlot]?.config?.oss?.vectorStore : null;
|
|
736
|
+
const vs = oc.plugins?.slots?.memory ? oc.plugins?.entries?.[oc.plugins.slots.memory]?.config?.oss?.vectorStore : null;
|
|
1094
737
|
if (vs) {
|
|
1095
738
|
if (vs.provider) cfg.provider = vs.provider;
|
|
1096
739
|
if (vs.config?.host) cfg.host = vs.config.host;
|
|
1097
740
|
if (vs.config?.port) cfg.port = vs.config.port;
|
|
1098
741
|
if (vs.config?.collectionName) cfg.collection = vs.config.collectionName;
|
|
1099
|
-
// Postgres-specific fields
|
|
1100
742
|
if (vs.config?.user) cfg.pgUser = vs.config.user;
|
|
1101
743
|
if (vs.config?.password) cfg.pgPassword = vs.config.password;
|
|
1102
744
|
if (vs.config?.dbname) cfg.pgDbName = vs.config.dbname;
|
|
1103
745
|
}
|
|
1104
|
-
|
|
1105
|
-
// Derive workspace dir from openclaw.json
|
|
1106
746
|
const wsDir = oc.agents?.defaults?.workspace;
|
|
1107
747
|
if (wsDir) cfg.workspaceDir = wsDir;
|
|
1108
748
|
}
|
|
1109
|
-
|
|
1110
|
-
// Env var overrides (MEMORY_* take priority, then Qdrant-specific fallbacks)
|
|
1111
749
|
if (process.env.MEMORY_PROVIDER) cfg.provider = process.env.MEMORY_PROVIDER;
|
|
1112
750
|
if (process.env.MEMORY_HOST || process.env.QDRANT_HOST) cfg.host = process.env.MEMORY_HOST || process.env.QDRANT_HOST;
|
|
1113
751
|
if (process.env.MEMORY_PORT || process.env.QDRANT_PORT) cfg.port = parseInt(process.env.MEMORY_PORT || process.env.QDRANT_PORT, 10);
|
|
1114
752
|
if (process.env.MEMORY_COLLECTION || process.env.QDRANT_COLLECTION) cfg.collection = process.env.MEMORY_COLLECTION || process.env.QDRANT_COLLECTION;
|
|
1115
753
|
if (process.env.MEMORY_PG_URL) cfg.pgUrl = process.env.MEMORY_PG_URL;
|
|
1116
|
-
// QDRANT_URL (e.g. "http://myhost:6333") — parse host and port from it
|
|
1117
754
|
if (process.env.QDRANT_URL && !process.env.MEMORY_HOST) {
|
|
1118
755
|
try {
|
|
1119
756
|
const u = new URL(process.env.QDRANT_URL);
|
|
1120
757
|
cfg.host = u.hostname;
|
|
1121
758
|
if (u.port) cfg.port = parseInt(u.port, 10);
|
|
1122
|
-
} catch {
|
|
759
|
+
} catch {
|
|
760
|
+
}
|
|
1123
761
|
}
|
|
1124
|
-
|
|
1125
|
-
if (!cfg.workspaceDir) {
|
|
1126
|
-
cfg.workspaceDir = path.join(os.homedir(), '.openclaw', 'workspace');
|
|
1127
|
-
}
|
|
1128
|
-
|
|
762
|
+
if (!cfg.workspaceDir) cfg.workspaceDir = path5.join(os2.homedir(), ".openclaw", "workspace");
|
|
1129
763
|
return cfg;
|
|
1130
764
|
}
|
|
1131
|
-
|
|
1132
|
-
const MEMORY_CONFIG = discoverMemoryConfig();
|
|
1133
|
-
|
|
1134
|
-
// Auto-detect Qdrant collection if not explicitly configured
|
|
765
|
+
__name(discoverMemoryConfig, "discoverMemoryConfig");
|
|
1135
766
|
async function autoDetectQdrantCollection(config) {
|
|
1136
767
|
if (config.collection) return config.collection;
|
|
1137
|
-
const baseUrl = `http://${config.host}:${config.port}`;
|
|
1138
768
|
try {
|
|
1139
|
-
const r = await fetch(
|
|
769
|
+
const r = await fetch(`http://${config.host}:${config.port}/collections`, { signal: AbortSignal.timeout(3e3) });
|
|
1140
770
|
const data = await r.json();
|
|
1141
|
-
const
|
|
1142
|
-
const found = collections.find(name => !name.includes('migration'));
|
|
771
|
+
const found = (data.result?.collections || []).map((c) => c.name).find((n) => !n.includes("migration"));
|
|
1143
772
|
if (found) {
|
|
1144
773
|
console.log(`Memory: auto-detected Qdrant collection "${found}"`);
|
|
1145
774
|
return found;
|
|
1146
775
|
}
|
|
1147
|
-
} catch {
|
|
776
|
+
} catch {
|
|
777
|
+
}
|
|
1148
778
|
console.log('Memory: Qdrant unreachable or no collections, falling back to "memories"');
|
|
1149
|
-
return
|
|
779
|
+
return "memories";
|
|
1150
780
|
}
|
|
1151
|
-
|
|
1152
|
-
// ─── Memory Providers ─────────────────────────────────────────────────────────
|
|
1153
|
-
|
|
781
|
+
__name(autoDetectQdrantCollection, "autoDetectQdrantCollection");
|
|
1154
782
|
function createQdrantProvider(config) {
|
|
1155
783
|
const baseUrl = `http://${config.host}:${config.port}`;
|
|
1156
|
-
let collection = config.collection;
|
|
1157
|
-
|
|
784
|
+
let collection = config.collection;
|
|
1158
785
|
return {
|
|
1159
|
-
name:
|
|
786
|
+
name: "qdrant",
|
|
1160
787
|
config,
|
|
1161
788
|
async init() {
|
|
1162
789
|
collection = await autoDetectQdrantCollection(config);
|
|
@@ -1165,17 +792,9 @@ function createQdrantProvider(config) {
|
|
|
1165
792
|
async list(limit, offset) {
|
|
1166
793
|
const body = { limit, with_payload: true, with_vector: false };
|
|
1167
794
|
if (offset) body.offset = offset;
|
|
1168
|
-
const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, {
|
|
1169
|
-
method: 'POST',
|
|
1170
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1171
|
-
body: JSON.stringify(body),
|
|
1172
|
-
});
|
|
795
|
+
const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
1173
796
|
const data = await r.json();
|
|
1174
|
-
|
|
1175
|
-
return {
|
|
1176
|
-
memories: points.map(p => ({ id: p.id, ...p.payload })),
|
|
1177
|
-
next_offset: data.result?.next_page_offset || null,
|
|
1178
|
-
};
|
|
797
|
+
return { memories: (data.result?.points || []).map((p) => ({ id: p.id, ...p.payload })), next_offset: data.result?.next_page_offset || null };
|
|
1179
798
|
},
|
|
1180
799
|
async search(query) {
|
|
1181
800
|
const q = query.toLowerCase();
|
|
@@ -1184,116 +803,73 @@ function createQdrantProvider(config) {
|
|
|
1184
803
|
do {
|
|
1185
804
|
const body = { limit: 100, with_payload: true, with_vector: false };
|
|
1186
805
|
if (offset) body.offset = offset;
|
|
1187
|
-
const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, {
|
|
1188
|
-
method: 'POST',
|
|
1189
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1190
|
-
body: JSON.stringify(body),
|
|
1191
|
-
});
|
|
806
|
+
const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
1192
807
|
const data = await r.json();
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
if ((p.payload?.data || '').toLowerCase().includes(q)) {
|
|
1196
|
-
matches.push({ id: p.id, ...p.payload });
|
|
1197
|
-
}
|
|
808
|
+
for (const p of data.result?.points || []) {
|
|
809
|
+
if ((p.payload?.data || "").toLowerCase().includes(q)) matches.push({ id: p.id, ...p.payload });
|
|
1198
810
|
}
|
|
1199
811
|
offset = data.result?.next_page_offset || null;
|
|
1200
812
|
} while (offset);
|
|
1201
813
|
return { memories: matches, next_offset: null };
|
|
1202
814
|
},
|
|
1203
815
|
async update(id, newData) {
|
|
1204
|
-
const r = await fetch(`${baseUrl}/collections/${collection}/points/payload`, {
|
|
1205
|
-
method: 'POST',
|
|
1206
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1207
|
-
body: JSON.stringify({ points: [id], payload: { data: newData } }),
|
|
1208
|
-
});
|
|
816
|
+
const r = await fetch(`${baseUrl}/collections/${collection}/points/payload`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ points: [id], payload: { data: newData } }) });
|
|
1209
817
|
const data = await r.json();
|
|
1210
818
|
if (data.status?.error) throw new Error(data.status.error);
|
|
1211
819
|
return data.result;
|
|
1212
820
|
},
|
|
1213
821
|
async delete(id) {
|
|
1214
|
-
const r = await fetch(`${baseUrl}/collections/${collection}/points/delete`, {
|
|
1215
|
-
|
|
1216
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1217
|
-
body: JSON.stringify({ points: [id] }),
|
|
1218
|
-
});
|
|
1219
|
-
const data = await r.json();
|
|
1220
|
-
return data.result;
|
|
822
|
+
const r = await fetch(`${baseUrl}/collections/${collection}/points/delete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ points: [id] }) });
|
|
823
|
+
return (await r.json()).result;
|
|
1221
824
|
},
|
|
1222
825
|
async status() {
|
|
1223
826
|
try {
|
|
1224
|
-
const r = await fetch(`${baseUrl}/collections/${collection}`, { signal: AbortSignal.timeout(
|
|
827
|
+
const r = await fetch(`${baseUrl}/collections/${collection}`, { signal: AbortSignal.timeout(3e3) });
|
|
1225
828
|
const data = await r.json();
|
|
1226
829
|
return { reachable: true, pointsCount: data.result?.points_count ?? null };
|
|
1227
830
|
} catch {
|
|
1228
831
|
return { reachable: false };
|
|
1229
832
|
}
|
|
1230
|
-
}
|
|
833
|
+
}
|
|
1231
834
|
};
|
|
1232
835
|
}
|
|
1233
|
-
|
|
836
|
+
__name(createQdrantProvider, "createQdrantProvider");
|
|
1234
837
|
function createPgProvider(config) {
|
|
1235
|
-
// Lazy-load pg — no crash if not installed and user runs Qdrant
|
|
1236
|
-
// NOTE: Postgres schema based on mem0 SDK pgvector patterns; needs verification against a real setup.
|
|
1237
838
|
let _pool = null;
|
|
1238
|
-
const table = config.collection ||
|
|
1239
|
-
|
|
839
|
+
const table = config.collection || "memories";
|
|
1240
840
|
async function getPool() {
|
|
1241
841
|
if (_pool) return _pool;
|
|
1242
842
|
let pg;
|
|
1243
843
|
try {
|
|
1244
|
-
pg = await import(
|
|
844
|
+
pg = await import("pg");
|
|
1245
845
|
} catch {
|
|
1246
|
-
throw new Error(
|
|
846
|
+
throw new Error("pg package not installed. Run: npm install pg");
|
|
1247
847
|
}
|
|
1248
848
|
const Pool = pg.default?.Pool || pg.Pool;
|
|
1249
|
-
|
|
1250
|
-
_pool = new Pool({ connectionString: config.pgUrl });
|
|
1251
|
-
} else {
|
|
1252
|
-
_pool = new Pool({
|
|
1253
|
-
host: config.host,
|
|
1254
|
-
port: config.port || 5432,
|
|
1255
|
-
user: config.pgUser || 'mem0',
|
|
1256
|
-
password: config.pgPassword || '',
|
|
1257
|
-
database: config.pgDbName || 'mem0',
|
|
1258
|
-
});
|
|
1259
|
-
}
|
|
849
|
+
_pool = config.pgUrl ? new Pool({ connectionString: config.pgUrl }) : new Pool({ host: config.host, port: config.port || 5432, user: config.pgUser || "mem0", password: config.pgPassword || "", database: config.pgDbName || "mem0" });
|
|
1260
850
|
return _pool;
|
|
1261
851
|
}
|
|
1262
|
-
|
|
852
|
+
__name(getPool, "getPool");
|
|
1263
853
|
return {
|
|
1264
|
-
name:
|
|
854
|
+
name: "postgres",
|
|
1265
855
|
config,
|
|
1266
|
-
async init() {
|
|
856
|
+
async init() {
|
|
857
|
+
},
|
|
1267
858
|
async list(limit, offset) {
|
|
1268
859
|
const pool = await getPool();
|
|
1269
|
-
const
|
|
1270
|
-
const { rows } = await pool.query(
|
|
1271
|
-
|
|
1272
|
-
[limit, offsetVal]
|
|
1273
|
-
);
|
|
1274
|
-
return {
|
|
1275
|
-
memories: rows.map(r => ({ id: r.id, ...(typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) })),
|
|
1276
|
-
next_offset: rows.length === limit ? offsetVal + limit : null,
|
|
1277
|
-
};
|
|
860
|
+
const off = offset ? parseInt(offset, 10) : 0;
|
|
861
|
+
const { rows } = await pool.query(`SELECT id, payload FROM ${table} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [limit, off]);
|
|
862
|
+
return { memories: rows.map((r) => ({ id: r.id, ...typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload })), next_offset: rows.length === limit ? off + limit : null };
|
|
1278
863
|
},
|
|
1279
864
|
async search(query) {
|
|
1280
865
|
const pool = await getPool();
|
|
1281
|
-
const { rows } = await pool.query(
|
|
1282
|
-
|
|
1283
|
-
[`%${query}%`]
|
|
1284
|
-
);
|
|
1285
|
-
return {
|
|
1286
|
-
memories: rows.map(r => ({ id: r.id, ...(typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) })),
|
|
1287
|
-
next_offset: null,
|
|
1288
|
-
};
|
|
866
|
+
const { rows } = await pool.query(`SELECT id, payload FROM ${table} WHERE payload->>'data' ILIKE $1 LIMIT 100`, [`%${query}%`]);
|
|
867
|
+
return { memories: rows.map((r) => ({ id: r.id, ...typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload })), next_offset: null };
|
|
1289
868
|
},
|
|
1290
869
|
async update(id, newData) {
|
|
1291
870
|
const pool = await getPool();
|
|
1292
|
-
const { rowCount } = await pool.query(
|
|
1293
|
-
|
|
1294
|
-
[JSON.stringify(newData), id]
|
|
1295
|
-
);
|
|
1296
|
-
if (rowCount === 0) throw new Error('Memory not found');
|
|
871
|
+
const { rowCount } = await pool.query(`UPDATE ${table} SET payload = jsonb_set(payload, '{data}', $1::jsonb) WHERE id = $2`, [JSON.stringify(newData), id]);
|
|
872
|
+
if (rowCount === 0) throw new Error("Memory not found");
|
|
1297
873
|
return { updated: true };
|
|
1298
874
|
},
|
|
1299
875
|
async delete(id) {
|
|
@@ -1309,77 +885,141 @@ function createPgProvider(config) {
|
|
|
1309
885
|
} catch (err) {
|
|
1310
886
|
return { reachable: false, error: err.message };
|
|
1311
887
|
}
|
|
1312
|
-
}
|
|
888
|
+
}
|
|
1313
889
|
};
|
|
1314
890
|
}
|
|
1315
|
-
|
|
891
|
+
__name(createPgProvider, "createPgProvider");
|
|
1316
892
|
function createMemoryProvider(config) {
|
|
1317
|
-
if (config.provider ===
|
|
1318
|
-
return createPgProvider(config);
|
|
893
|
+
if (config.provider === "postgres" || config.provider === "pgvector") return createPgProvider(config);
|
|
1319
894
|
return createQdrantProvider(config);
|
|
1320
895
|
}
|
|
896
|
+
__name(createMemoryProvider, "createMemoryProvider");
|
|
1321
897
|
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
1326
|
-
const audioBuffer = Buffer.concat(chunks);
|
|
1327
|
-
|
|
1328
|
-
if (audioBuffer.length === 0) return send(res, 400, { error: 'No audio data' });
|
|
1329
|
-
if (audioBuffer.length > 25 * 1024 * 1024) return send(res, 400, { error: 'Audio too large (max 25MB)' });
|
|
1330
|
-
|
|
1331
|
-
// Read OpenAI API key from OpenClaw config
|
|
1332
|
-
let apiKey;
|
|
1333
|
-
try {
|
|
1334
|
-
const ocConfig = JSON.parse(fs.readFileSync(
|
|
1335
|
-
path.join(os.homedir(), '.openclaw', 'openclaw.json'), 'utf8'));
|
|
1336
|
-
apiKey = ocConfig?.skills?.entries?.['openai-whisper-api']?.apiKey;
|
|
1337
|
-
} catch {}
|
|
1338
|
-
if (!apiKey) apiKey = process.env.OPENAI_API_KEY;
|
|
1339
|
-
if (!apiKey) return send(res, 500, { error: 'No OpenAI API key configured' });
|
|
1340
|
-
|
|
1341
|
-
const contentType = req.headers['content-type'] || 'audio/webm';
|
|
1342
|
-
const ext = contentType.includes('wav') ? 'wav'
|
|
1343
|
-
: contentType.includes('mp4') || contentType.includes('m4a') ? 'm4a'
|
|
1344
|
-
: contentType.includes('ogg') ? 'ogg' : 'webm';
|
|
1345
|
-
|
|
1346
|
-
const boundary = '----WhisperBoundary' + Date.now();
|
|
1347
|
-
const parts = [
|
|
1348
|
-
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.${ext}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
|
1349
|
-
audioBuffer,
|
|
1350
|
-
`\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1\r\n`,
|
|
1351
|
-
`--${boundary}\r\nContent-Disposition: form-data; name="response_format"\r\n\r\njson\r\n`,
|
|
1352
|
-
`--${boundary}--\r\n`
|
|
1353
|
-
];
|
|
1354
|
-
const body = Buffer.concat(parts.map(p => typeof p === 'string' ? Buffer.from(p) : p));
|
|
898
|
+
// server/controllers/workspaces.js
|
|
899
|
+
import fs6 from "node:fs";
|
|
900
|
+
import path7 from "node:path";
|
|
1355
901
|
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
902
|
+
// server/util/http.js
|
|
903
|
+
import crypto2 from "node:crypto";
|
|
904
|
+
function parseBody(req) {
|
|
905
|
+
return new Promise((resolve, reject) => {
|
|
906
|
+
const chunks = [];
|
|
907
|
+
req.on("data", (c) => chunks.push(c));
|
|
908
|
+
req.on("end", () => {
|
|
909
|
+
const raw = Buffer.concat(chunks).toString();
|
|
910
|
+
if (!raw) return resolve({});
|
|
911
|
+
try {
|
|
912
|
+
resolve(JSON.parse(raw));
|
|
913
|
+
} catch {
|
|
914
|
+
reject(new Error("Invalid JSON"));
|
|
915
|
+
}
|
|
1363
916
|
});
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
917
|
+
req.on("error", reject);
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
__name(parseBody, "parseBody");
|
|
921
|
+
function send(res, status, data) {
|
|
922
|
+
const body = JSON.stringify(data);
|
|
923
|
+
res.writeHead(status, {
|
|
924
|
+
"Content-Type": "application/json",
|
|
925
|
+
"Access-Control-Allow-Origin": "*",
|
|
926
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
927
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
928
|
+
});
|
|
929
|
+
res.end(body);
|
|
930
|
+
}
|
|
931
|
+
__name(send, "send");
|
|
932
|
+
function sendError(res, status, message) {
|
|
933
|
+
send(res, status, { error: message });
|
|
934
|
+
}
|
|
935
|
+
__name(sendError, "sendError");
|
|
936
|
+
function setCors(res) {
|
|
937
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
938
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
|
|
939
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
940
|
+
}
|
|
941
|
+
__name(setCors, "setCors");
|
|
942
|
+
function uuid() {
|
|
943
|
+
return crypto2.randomUUID();
|
|
944
|
+
}
|
|
945
|
+
__name(uuid, "uuid");
|
|
946
|
+
function matchRoute(method, url, pattern) {
|
|
947
|
+
const [pMethod, pPath] = pattern.split(" ");
|
|
948
|
+
if (method !== pMethod) return null;
|
|
949
|
+
const pParts = pPath.split("/").filter(Boolean);
|
|
950
|
+
const uParts = url.split("/").filter(Boolean);
|
|
951
|
+
if (pParts.length !== uParts.length) return null;
|
|
952
|
+
const params = {};
|
|
953
|
+
for (let i = 0; i < pParts.length; i++) {
|
|
954
|
+
if (pParts[i].startsWith(":")) {
|
|
955
|
+
params[pParts[i].slice(1)] = decodeURIComponent(uParts[i]);
|
|
956
|
+
} else if (pParts[i] !== uParts[i]) {
|
|
957
|
+
return null;
|
|
1369
958
|
}
|
|
959
|
+
}
|
|
960
|
+
return params;
|
|
961
|
+
}
|
|
962
|
+
__name(matchRoute, "matchRoute");
|
|
1370
963
|
|
|
1371
|
-
|
|
1372
|
-
|
|
964
|
+
// server/gateway-cleanup.js
|
|
965
|
+
import fs5 from "node:fs";
|
|
966
|
+
import path6 from "node:path";
|
|
967
|
+
function cleanGatewaySession(sessionKey) {
|
|
968
|
+
try {
|
|
969
|
+
const agentMatch = (sessionKey || "").match(/^agent:([^:]+):/);
|
|
970
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
971
|
+
const sessionsPath = path6.join(sessionsDir, "sessions.json");
|
|
972
|
+
const store = JSON.parse(fs5.readFileSync(sessionsPath, "utf8"));
|
|
973
|
+
const entry = store[sessionKey];
|
|
974
|
+
if (!entry) return null;
|
|
975
|
+
if (entry.sessionId) {
|
|
976
|
+
try {
|
|
977
|
+
fs5.unlinkSync(path6.join(sessionsDir, `${entry.sessionId}.jsonl`));
|
|
978
|
+
} catch {
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const sessionId = entry.sessionId || null;
|
|
982
|
+
delete store[sessionKey];
|
|
983
|
+
fs5.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
|
|
984
|
+
return sessionId;
|
|
1373
985
|
} catch (err) {
|
|
1374
|
-
console.
|
|
1375
|
-
return
|
|
986
|
+
console.warn(`cleanGatewaySession(${sessionKey}):`, err.message);
|
|
987
|
+
return null;
|
|
1376
988
|
}
|
|
1377
989
|
}
|
|
990
|
+
__name(cleanGatewaySession, "cleanGatewaySession");
|
|
991
|
+
function cleanGatewaySessionsByPrefix(prefix) {
|
|
992
|
+
try {
|
|
993
|
+
const agentMatch = (prefix || "").match(/^agent:([^:]+):/);
|
|
994
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
995
|
+
const sessionsPath = path6.join(sessionsDir, "sessions.json");
|
|
996
|
+
const store = JSON.parse(fs5.readFileSync(sessionsPath, "utf8"));
|
|
997
|
+
let cleaned = 0;
|
|
998
|
+
for (const key of Object.keys(store)) {
|
|
999
|
+
if (!key.startsWith(prefix)) continue;
|
|
1000
|
+
if (store[key]?.sessionId) {
|
|
1001
|
+
try {
|
|
1002
|
+
fs5.unlinkSync(path6.join(sessionsDir, `${store[key].sessionId}.jsonl`));
|
|
1003
|
+
} catch {
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
delete store[key];
|
|
1007
|
+
cleaned++;
|
|
1008
|
+
}
|
|
1009
|
+
if (cleaned > 0) fs5.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
|
|
1010
|
+
return cleaned;
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
console.warn(`cleanGatewaySessionsByPrefix(${prefix}):`, err.message);
|
|
1013
|
+
return 0;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
__name(cleanGatewaySessionsByPrefix, "cleanGatewaySessionsByPrefix");
|
|
1378
1017
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1018
|
+
// server/controllers/workspaces.js
|
|
1019
|
+
var WorkspaceController = class {
|
|
1020
|
+
static {
|
|
1021
|
+
__name(this, "WorkspaceController");
|
|
1022
|
+
}
|
|
1383
1023
|
constructor({ getDb, closeDb, getWorkspaces, setWorkspaces, dataDir, broadcast }) {
|
|
1384
1024
|
this.getDb = getDb;
|
|
1385
1025
|
this.closeDb = closeDb;
|
|
@@ -1388,47 +1028,52 @@ class WorkspaceController {
|
|
|
1388
1028
|
this.dataDir = dataDir;
|
|
1389
1029
|
this.broadcast = broadcast;
|
|
1390
1030
|
}
|
|
1391
|
-
|
|
1392
1031
|
getAll(req, res) {
|
|
1393
1032
|
const ws = this.getWorkspaces();
|
|
1394
1033
|
const sorted = Object.values(ws.workspaces).sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
|
|
1395
1034
|
for (const workspace of sorted) {
|
|
1396
1035
|
try {
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1036
|
+
workspace.unread_count = this.getDb(workspace.name).prepare("SELECT COALESCE(SUM(unread_count), 0) as total FROM threads").get().total;
|
|
1037
|
+
} catch {
|
|
1038
|
+
workspace.unread_count = 0;
|
|
1039
|
+
}
|
|
1400
1040
|
}
|
|
1401
1041
|
send(res, 200, { active: ws.active, workspaces: sorted });
|
|
1402
1042
|
}
|
|
1403
|
-
|
|
1404
1043
|
async create(req, res) {
|
|
1405
1044
|
const body = await parseBody(req);
|
|
1406
1045
|
const { name, label } = body;
|
|
1407
|
-
if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) return sendError(res, 400,
|
|
1046
|
+
if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) return sendError(res, 400, "Name must be [a-z0-9-], 1-32 chars");
|
|
1408
1047
|
const ws = this.getWorkspaces();
|
|
1409
|
-
if (ws.workspaces[name]) return sendError(res, 409,
|
|
1410
|
-
let agent =
|
|
1411
|
-
try {
|
|
1412
|
-
|
|
1413
|
-
|
|
1048
|
+
if (ws.workspaces[name]) return sendError(res, 409, "Workspace already exists");
|
|
1049
|
+
let agent = "main";
|
|
1050
|
+
try {
|
|
1051
|
+
agent = validateAgent(body.agent || "main");
|
|
1052
|
+
} catch {
|
|
1053
|
+
agent = "main";
|
|
1054
|
+
}
|
|
1055
|
+
ws.workspaces[name] = { name, label: label || name, color: body.color || null, icon: body.icon || null, agent, createdAt: Date.now() };
|
|
1414
1056
|
this.setWorkspaces(ws);
|
|
1415
1057
|
this.getDb(name);
|
|
1416
|
-
send(res, 201, { workspace });
|
|
1058
|
+
send(res, 201, { workspace: ws.workspaces[name] });
|
|
1417
1059
|
}
|
|
1418
|
-
|
|
1419
1060
|
async update(req, res, params) {
|
|
1420
1061
|
const body = await parseBody(req);
|
|
1421
1062
|
const ws = this.getWorkspaces();
|
|
1422
|
-
if (!ws.workspaces[params.name]) return sendError(res, 404,
|
|
1423
|
-
if (body.label !==
|
|
1424
|
-
if (body.color !==
|
|
1425
|
-
if (body.icon !==
|
|
1426
|
-
if (body.lastThread !==
|
|
1063
|
+
if (!ws.workspaces[params.name]) return sendError(res, 404, "Workspace not found");
|
|
1064
|
+
if (body.label !== void 0) ws.workspaces[params.name].label = body.label;
|
|
1065
|
+
if (body.color !== void 0) ws.workspaces[params.name].color = body.color;
|
|
1066
|
+
if (body.icon !== void 0) ws.workspaces[params.name].icon = body.icon;
|
|
1067
|
+
if (body.lastThread !== void 0) ws.workspaces[params.name].lastThread = body.lastThread;
|
|
1427
1068
|
let migratedThreads = 0;
|
|
1428
|
-
if (body.agent !==
|
|
1069
|
+
if (body.agent !== void 0) {
|
|
1429
1070
|
let newAgent;
|
|
1430
|
-
try {
|
|
1431
|
-
|
|
1071
|
+
try {
|
|
1072
|
+
newAgent = validateAgent(body.agent);
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
return sendError(res, 400, e.message);
|
|
1075
|
+
}
|
|
1076
|
+
const oldAgent = ws.workspaces[params.name].agent || "main";
|
|
1432
1077
|
if (newAgent !== oldAgent) {
|
|
1433
1078
|
const db = this.getDb(params.name);
|
|
1434
1079
|
const threads = db.prepare(`SELECT id, session_key FROM threads WHERE session_key LIKE ?`).all(`agent:${oldAgent}:${params.name}:chat:%`);
|
|
@@ -1436,23 +1081,25 @@ class WorkspaceController {
|
|
|
1436
1081
|
for (const t of threads) cleanGatewaySession(t.session_key);
|
|
1437
1082
|
ws.workspaces[params.name].agent = newAgent;
|
|
1438
1083
|
migratedThreads = threads.length;
|
|
1439
|
-
this.broadcast(JSON.stringify({ type:
|
|
1084
|
+
this.broadcast(JSON.stringify({ type: "clawchats", event: "workspace-agent-changed", workspace: params.name, agent: newAgent }));
|
|
1440
1085
|
}
|
|
1441
1086
|
}
|
|
1442
1087
|
this.setWorkspaces(ws);
|
|
1443
1088
|
send(res, 200, { workspace: ws.workspaces[params.name], migratedThreads });
|
|
1444
1089
|
}
|
|
1445
|
-
|
|
1446
1090
|
delete(req, res, params) {
|
|
1447
1091
|
const ws = this.getWorkspaces();
|
|
1448
|
-
if (!ws.workspaces[params.name]) return sendError(res, 404,
|
|
1449
|
-
if (Object.keys(ws.workspaces).length <= 1) return sendError(res, 400,
|
|
1092
|
+
if (!ws.workspaces[params.name]) return sendError(res, 404, "Workspace not found");
|
|
1093
|
+
if (Object.keys(ws.workspaces).length <= 1) return sendError(res, 400, "Cannot delete the only workspace");
|
|
1450
1094
|
this.closeDb(params.name);
|
|
1451
|
-
const dbPath =
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1095
|
+
const dbPath = path7.join(this.dataDir, `${params.name}.db`);
|
|
1096
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
1097
|
+
try {
|
|
1098
|
+
fs6.unlinkSync(dbPath + suffix);
|
|
1099
|
+
} catch {
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const wsAgent = ws.workspaces[params.name]?.agent || "main";
|
|
1456
1103
|
const cleaned = cleanGatewaySessionsByPrefix(`agent:${wsAgent}:${params.name}:chat:`);
|
|
1457
1104
|
if (cleaned > 0) console.log(`Cleaned ${cleaned} gateway sessions for workspace: ${params.name}`);
|
|
1458
1105
|
delete ws.workspaces[params.name];
|
|
@@ -1460,1423 +1107,1286 @@ class WorkspaceController {
|
|
|
1460
1107
|
this.setWorkspaces(ws);
|
|
1461
1108
|
send(res, 200, { ok: true });
|
|
1462
1109
|
}
|
|
1463
|
-
|
|
1464
1110
|
async reorder(req, res) {
|
|
1465
1111
|
const body = await parseBody(req);
|
|
1466
|
-
|
|
1467
|
-
if (!Array.isArray(order)) return sendError(res, 400, 'order must be an array of workspace names');
|
|
1112
|
+
if (!Array.isArray(body.order)) return sendError(res, 400, "order must be an array of workspace names");
|
|
1468
1113
|
const ws = this.getWorkspaces();
|
|
1469
|
-
order.forEach((name, i) => {
|
|
1114
|
+
body.order.forEach((name, i) => {
|
|
1115
|
+
if (ws.workspaces[name]) ws.workspaces[name].order = i;
|
|
1116
|
+
});
|
|
1470
1117
|
this.setWorkspaces(ws);
|
|
1471
1118
|
send(res, 200, { ok: true, workspaces: Object.values(ws.workspaces) });
|
|
1472
1119
|
}
|
|
1473
|
-
|
|
1474
1120
|
activate(req, res, params) {
|
|
1475
1121
|
const ws = this.getWorkspaces();
|
|
1476
|
-
if (!ws.workspaces[params.name]) return sendError(res, 404,
|
|
1122
|
+
if (!ws.workspaces[params.name]) return sendError(res, 404, "Workspace not found");
|
|
1477
1123
|
ws.active = params.name;
|
|
1478
1124
|
this.setWorkspaces(ws);
|
|
1479
1125
|
this.getDb(params.name);
|
|
1480
1126
|
send(res, 200, { ok: true, workspace: ws.workspaces[params.name] });
|
|
1481
1127
|
}
|
|
1482
|
-
}
|
|
1128
|
+
};
|
|
1483
1129
|
|
|
1484
|
-
|
|
1130
|
+
// server/controllers/threads.js
|
|
1131
|
+
import fs7 from "node:fs";
|
|
1132
|
+
import path8 from "node:path";
|
|
1133
|
+
var ThreadController = class {
|
|
1134
|
+
static {
|
|
1135
|
+
__name(this, "ThreadController");
|
|
1136
|
+
}
|
|
1485
1137
|
constructor({ getActiveDb, getWorkspaces, uploadsDir, broadcast }) {
|
|
1486
1138
|
this.getActiveDb = getActiveDb;
|
|
1487
1139
|
this.getWorkspaces = getWorkspaces;
|
|
1488
1140
|
this.uploadsDir = uploadsDir;
|
|
1489
1141
|
this.broadcast = broadcast;
|
|
1490
1142
|
}
|
|
1491
|
-
|
|
1492
1143
|
getAll(req, res, params, query) {
|
|
1493
1144
|
const db = this.getActiveDb();
|
|
1494
|
-
const page = parseInt(query.page ||
|
|
1495
|
-
const limit = Math.min(parseInt(query.limit ||
|
|
1145
|
+
const page = parseInt(query.page || "1", 10);
|
|
1146
|
+
const limit = Math.min(parseInt(query.limit || "50", 10), 200);
|
|
1496
1147
|
const offset = (page - 1) * limit;
|
|
1497
|
-
const search = query.search ||
|
|
1148
|
+
const search = query.search || "";
|
|
1498
1149
|
let threads, total;
|
|
1499
1150
|
if (search) {
|
|
1500
1151
|
try {
|
|
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);
|
|
1502
|
-
if (matchingIds.length
|
|
1503
|
-
const ph = matchingIds.map(() =>
|
|
1152
|
+
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);
|
|
1153
|
+
if (!matchingIds.length) return send(res, 200, { threads: [], total: 0, page });
|
|
1154
|
+
const ph = matchingIds.map(() => "?").join(",");
|
|
1504
1155
|
total = db.prepare(`SELECT COUNT(*) as c FROM threads WHERE id IN (${ph})`).get(...matchingIds).c;
|
|
1505
1156
|
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 {
|
|
1157
|
+
} catch {
|
|
1158
|
+
return send(res, 200, { threads: [], total: 0, page });
|
|
1159
|
+
}
|
|
1507
1160
|
} else {
|
|
1508
|
-
total = db.prepare(
|
|
1509
|
-
threads = db.prepare(
|
|
1161
|
+
total = db.prepare("SELECT COUNT(*) as c FROM threads").get().c;
|
|
1162
|
+
threads = db.prepare("SELECT * FROM threads ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?").all(limit, offset);
|
|
1510
1163
|
}
|
|
1511
1164
|
send(res, 200, { threads, total, page });
|
|
1512
1165
|
}
|
|
1513
|
-
|
|
1514
1166
|
getUnread(req, res) {
|
|
1515
1167
|
const db = this.getActiveDb();
|
|
1516
1168
|
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();
|
|
1517
|
-
for (const
|
|
1518
|
-
thread.unreadMessageIds = db.prepare('SELECT message_id FROM unread_messages WHERE thread_id = ?').all(thread.id).map(r => r.message_id);
|
|
1519
|
-
}
|
|
1169
|
+
for (const t of threads) t.unreadMessageIds = db.prepare("SELECT message_id FROM unread_messages WHERE thread_id = ?").all(t.id).map((r) => r.message_id);
|
|
1520
1170
|
send(res, 200, { threads });
|
|
1521
1171
|
}
|
|
1522
|
-
|
|
1523
1172
|
async markRead(req, res, params) {
|
|
1524
|
-
const
|
|
1173
|
+
const { messageIds } = await parseBody(req);
|
|
1174
|
+
if (!Array.isArray(messageIds) || !messageIds.length) return send(res, 400, { error: "messageIds array required" });
|
|
1525
1175
|
const db = this.getActiveDb();
|
|
1526
|
-
const
|
|
1527
|
-
if (!Array.isArray(messageIds) || messageIds.length === 0) return send(res, 400, { error: 'messageIds array required' });
|
|
1528
|
-
const ph = messageIds.map(() => '?').join(',');
|
|
1176
|
+
const ph = messageIds.map(() => "?").join(",");
|
|
1529
1177
|
db.prepare(`DELETE FROM unread_messages WHERE thread_id = ? AND message_id IN (${ph})`).run(params.id, ...messageIds);
|
|
1530
1178
|
const remaining = syncThreadUnreadCount(db, params.id);
|
|
1531
|
-
|
|
1532
|
-
this.broadcast(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace, threadId: params.id, action: 'read', messageIds, unreadCount: remaining, timestamp: Date.now() }));
|
|
1179
|
+
this.broadcast(JSON.stringify({ type: "clawchats", event: "unread-update", workspace: this.getWorkspaces().active, threadId: params.id, action: "read", messageIds, unreadCount: remaining, timestamp: Date.now() }));
|
|
1533
1180
|
send(res, 200, { unread_count: remaining });
|
|
1534
1181
|
}
|
|
1535
|
-
|
|
1536
1182
|
async create(req, res) {
|
|
1537
1183
|
const body = await parseBody(req);
|
|
1538
1184
|
const db = this.getActiveDb();
|
|
1539
1185
|
const ws = this.getWorkspaces();
|
|
1540
1186
|
const id = body.id || uuid();
|
|
1541
1187
|
const now = Date.now();
|
|
1542
|
-
const agent = ws.workspaces[ws.active]?.agent ||
|
|
1543
|
-
const sessionKey = `agent:${agent}:${ws.active}:chat:${id}`;
|
|
1188
|
+
const agent = ws.workspaces[ws.active]?.agent || "main";
|
|
1544
1189
|
try {
|
|
1545
|
-
db.prepare(
|
|
1190
|
+
db.prepare("INSERT INTO threads (id, session_key, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(id, `agent:${agent}:${ws.active}:chat:${id}`, "New chat", now, now);
|
|
1546
1191
|
} catch (e) {
|
|
1547
|
-
if (e.message.includes(
|
|
1192
|
+
if (e.message.includes("UNIQUE constraint")) return sendError(res, 409, "Thread already exists");
|
|
1548
1193
|
throw e;
|
|
1549
1194
|
}
|
|
1550
|
-
send(res, 201, { thread: db.prepare(
|
|
1195
|
+
send(res, 201, { thread: db.prepare("SELECT * FROM threads WHERE id = ?").get(id) });
|
|
1551
1196
|
}
|
|
1552
|
-
|
|
1553
1197
|
get(req, res, params) {
|
|
1554
|
-
const thread = this.getActiveDb().prepare(
|
|
1555
|
-
if (!thread) return sendError(res, 404,
|
|
1198
|
+
const thread = this.getActiveDb().prepare("SELECT * FROM threads WHERE id = ?").get(params.id);
|
|
1199
|
+
if (!thread) return sendError(res, 404, "Thread not found");
|
|
1556
1200
|
send(res, 200, { thread });
|
|
1557
1201
|
}
|
|
1558
|
-
|
|
1559
1202
|
async update(req, res, params) {
|
|
1560
1203
|
const body = await parseBody(req);
|
|
1561
1204
|
const db = this.getActiveDb();
|
|
1562
|
-
if (!db.prepare(
|
|
1205
|
+
if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(params.id)) return sendError(res, 404, "Thread not found");
|
|
1563
1206
|
const fields = [], values = [];
|
|
1564
|
-
for (const [col, val] of [[
|
|
1565
|
-
if (val !==
|
|
1207
|
+
for (const [col, val] of [["title", body.title], ["model", body.model], ["last_session_id", body.last_session_id], ["unread_count", body.unread_count]]) {
|
|
1208
|
+
if (val !== void 0) {
|
|
1209
|
+
fields.push(`${col} = ?`);
|
|
1210
|
+
values.push(val);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (body.pinned !== void 0) {
|
|
1214
|
+
fields.push("pinned = ?");
|
|
1215
|
+
values.push(body.pinned ? 1 : 0);
|
|
1566
1216
|
}
|
|
1567
|
-
if (body.
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
if (fields.length > 0) {
|
|
1571
|
-
fields.push('updated_at = ?'); values.push(Date.now(), params.id);
|
|
1572
|
-
db.prepare(`UPDATE threads SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
|
1217
|
+
if (body.pin_order !== void 0) {
|
|
1218
|
+
fields.push("pin_order = ?");
|
|
1219
|
+
values.push(body.pin_order);
|
|
1573
1220
|
}
|
|
1574
|
-
|
|
1221
|
+
if (body.sort_order !== void 0) {
|
|
1222
|
+
fields.push("sort_order = ?");
|
|
1223
|
+
values.push(body.sort_order);
|
|
1224
|
+
}
|
|
1225
|
+
if (fields.length) {
|
|
1226
|
+
fields.push("updated_at = ?");
|
|
1227
|
+
values.push(Date.now(), params.id);
|
|
1228
|
+
db.prepare(`UPDATE threads SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
|
1229
|
+
}
|
|
1230
|
+
send(res, 200, { thread: db.prepare("SELECT * FROM threads WHERE id = ?").get(params.id) });
|
|
1575
1231
|
}
|
|
1576
|
-
|
|
1577
1232
|
delete(req, res, params) {
|
|
1578
1233
|
const db = this.getActiveDb();
|
|
1579
|
-
const thread = db.prepare(
|
|
1580
|
-
if (!thread) return sendError(res, 404,
|
|
1581
|
-
db.prepare(
|
|
1582
|
-
|
|
1583
|
-
const agentMatch = (thread.session_key || '').match(/^agent:([^:]+):/);
|
|
1234
|
+
const thread = db.prepare("SELECT * FROM threads WHERE id = ?").get(params.id);
|
|
1235
|
+
if (!thread) return sendError(res, 404, "Thread not found");
|
|
1236
|
+
db.prepare("DELETE FROM threads WHERE id = ?").run(params.id);
|
|
1237
|
+
const agentMatch = (thread.session_key || "").match(/^agent:([^:]+):/);
|
|
1584
1238
|
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
1239
|
+
let sessionIdToDelete = thread.last_session_id;
|
|
1585
1240
|
if (!sessionIdToDelete) {
|
|
1586
1241
|
try {
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
}
|
|
1242
|
+
sessionIdToDelete = JSON.parse(fs7.readFileSync(path8.join(sessionsDir, "sessions.json"), "utf8"))[thread.session_key]?.sessionId;
|
|
1243
|
+
} catch {
|
|
1244
|
+
}
|
|
1590
1245
|
}
|
|
1591
1246
|
cleanGatewaySession(thread.session_key);
|
|
1592
1247
|
if (sessionIdToDelete) {
|
|
1593
|
-
try {
|
|
1248
|
+
try {
|
|
1249
|
+
fs7.unlinkSync(path8.join(sessionsDir, `${sessionIdToDelete}.jsonl`));
|
|
1250
|
+
} catch {
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
try {
|
|
1254
|
+
fs7.rmSync(path8.join(this.uploadsDir, params.id), { recursive: true });
|
|
1255
|
+
} catch {
|
|
1594
1256
|
}
|
|
1595
|
-
try { fs.rmSync(path.join(this.uploadsDir, params.id), { recursive: true }); } catch { /* ok */ }
|
|
1596
1257
|
send(res, 200, { ok: true });
|
|
1597
1258
|
}
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
// server/util/context.js
|
|
1262
|
+
import fs8 from "node:fs";
|
|
1263
|
+
import path9 from "node:path";
|
|
1264
|
+
function buildContextPreamble(db, threadId, lastSessionId, sessionKey) {
|
|
1265
|
+
let summary = null;
|
|
1266
|
+
let method = "raw";
|
|
1267
|
+
if (lastSessionId) {
|
|
1268
|
+
const agentMatch = (sessionKey || "").match(/^agent:([^:]+):/);
|
|
1269
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
1270
|
+
try {
|
|
1271
|
+
const lines = fs8.readFileSync(path9.join(sessionsDir, `${lastSessionId}.jsonl`), "utf8").split("\n").filter(Boolean);
|
|
1272
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1273
|
+
try {
|
|
1274
|
+
const entry = JSON.parse(lines[i]);
|
|
1275
|
+
if (entry.type === "compaction" && entry.summary) {
|
|
1276
|
+
summary = entry.summary;
|
|
1277
|
+
method = "compaction";
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
} catch {
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
} catch {
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
let preamble = "";
|
|
1287
|
+
if (method === "compaction" && summary) {
|
|
1288
|
+
preamble += "[CONTEXT RECOVERY \u2014 This thread's agent session was reset. Below is a summary of the previous conversation followed by recent messages to restore context.]\n\n";
|
|
1289
|
+
preamble += "[CONVERSATION SUMMARY]\n" + summary + "\n\n";
|
|
1290
|
+
const msgs = db.prepare("SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 10").all(threadId).reverse();
|
|
1291
|
+
if (msgs.length) {
|
|
1292
|
+
preamble += "[RECENT MESSAGES]\n";
|
|
1293
|
+
for (const m of msgs) {
|
|
1294
|
+
const ts = new Date(m.timestamp).toISOString().replace("T", " ").slice(0, 16);
|
|
1295
|
+
preamble += `${m.role.charAt(0).toUpperCase() + m.role.slice(1)} (${ts}): ${m.content}
|
|
1296
|
+
`;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
} else {
|
|
1300
|
+
preamble += "[CONTEXT RECOVERY \u2014 This thread's agent session was reset. Below are recent messages from the previous conversation to restore context.]\n\n";
|
|
1301
|
+
const msgs = db.prepare("SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 25").all(threadId).reverse();
|
|
1302
|
+
if (msgs.length) {
|
|
1303
|
+
preamble += "[PREVIOUS MESSAGES]\n";
|
|
1304
|
+
for (const m of msgs) {
|
|
1305
|
+
const ts = new Date(m.timestamp).toISOString().replace("T", " ").slice(0, 16);
|
|
1306
|
+
preamble += `${m.role.charAt(0).toUpperCase() + m.role.slice(1)} (${ts}): ${m.content}
|
|
1307
|
+
`;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (preamble.length > MAX_PREAMBLE_CHARS) preamble = preamble.slice(preamble.length - MAX_PREAMBLE_CHARS);
|
|
1312
|
+
return { preamble, method };
|
|
1598
1313
|
}
|
|
1314
|
+
__name(buildContextPreamble, "buildContextPreamble");
|
|
1599
1315
|
|
|
1600
|
-
|
|
1316
|
+
// server/controllers/messages.js
|
|
1317
|
+
var MessageController = class {
|
|
1318
|
+
static {
|
|
1319
|
+
__name(this, "MessageController");
|
|
1320
|
+
}
|
|
1601
1321
|
constructor({ getActiveDb, getWorkspaces, broadcast }) {
|
|
1602
1322
|
this.getActiveDb = getActiveDb;
|
|
1603
1323
|
this.getWorkspaces = getWorkspaces;
|
|
1604
1324
|
this.broadcast = broadcast;
|
|
1605
1325
|
}
|
|
1606
|
-
|
|
1607
1326
|
getAll(req, res, params, query) {
|
|
1608
1327
|
const db = this.getActiveDb();
|
|
1609
|
-
if (!db.prepare(
|
|
1610
|
-
const limit = Math.min(parseInt(query.limit ||
|
|
1328
|
+
if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(params.id)) return sendError(res, 404, "Thread not found");
|
|
1329
|
+
const limit = Math.min(parseInt(query.limit || "100", 10), 500);
|
|
1611
1330
|
const before = query.before ? parseInt(query.before, 10) : null;
|
|
1612
1331
|
const after = query.after ? parseInt(query.after, 10) : null;
|
|
1613
|
-
let sql =
|
|
1332
|
+
let sql = "SELECT * FROM messages WHERE thread_id = ?";
|
|
1614
1333
|
const sqlParams = [params.id];
|
|
1615
|
-
if (before) {
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1334
|
+
if (before) {
|
|
1335
|
+
sql += " AND timestamp < ?";
|
|
1336
|
+
sqlParams.push(before);
|
|
1337
|
+
}
|
|
1338
|
+
if (after) {
|
|
1339
|
+
sql += " AND timestamp > ?";
|
|
1340
|
+
sqlParams.push(after);
|
|
1341
|
+
}
|
|
1342
|
+
const total = db.prepare(sql.replace("SELECT *", "SELECT COUNT(*) as c")).get(...sqlParams).c;
|
|
1343
|
+
const rows = db.prepare(sql + " ORDER BY timestamp DESC LIMIT ?").all(...sqlParams, limit + 1);
|
|
1620
1344
|
const messages = rows.slice(0, limit).reverse();
|
|
1621
|
-
for (const m of messages) {
|
|
1622
|
-
|
|
1345
|
+
for (const m of messages) {
|
|
1346
|
+
if (m.metadata) {
|
|
1347
|
+
try {
|
|
1348
|
+
m.metadata = JSON.parse(m.metadata);
|
|
1349
|
+
} catch {
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
send(res, 200, { messages, hasMore: rows.length > limit });
|
|
1623
1354
|
}
|
|
1624
|
-
|
|
1625
1355
|
async create(req, res, params) {
|
|
1626
1356
|
const body = await parseBody(req);
|
|
1627
1357
|
const db = this.getActiveDb();
|
|
1628
|
-
if (!db.prepare(
|
|
1629
|
-
if (!body.id || !body.role || body.content ===
|
|
1358
|
+
if (!db.prepare("SELECT id FROM threads WHERE id = ?").get(params.id)) return sendError(res, 404, "Thread not found");
|
|
1359
|
+
if (!body.id || !body.role || body.content === void 0 || !body.timestamp) return sendError(res, 400, "Required: id, role, content, timestamp");
|
|
1630
1360
|
const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
|
|
1631
|
-
const existing = db.prepare(
|
|
1361
|
+
const existing = db.prepare("SELECT id, status, metadata FROM messages WHERE id = ?").get(body.id);
|
|
1632
1362
|
if (existing) {
|
|
1633
1363
|
if (body.status && body.status !== existing.status) {
|
|
1634
|
-
db.prepare(
|
|
1364
|
+
db.prepare("UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?").run(body.status, body.content, metadata || existing.metadata, body.id);
|
|
1635
1365
|
}
|
|
1636
1366
|
} else {
|
|
1637
|
-
db.prepare(
|
|
1638
|
-
db.prepare(
|
|
1639
|
-
if (body.role ===
|
|
1640
|
-
const
|
|
1641
|
-
if (
|
|
1642
|
-
const title = body.content.replace(/\n.*/s,
|
|
1367
|
+
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());
|
|
1368
|
+
db.prepare("UPDATE threads SET updated_at = ? WHERE id = ?").run(Date.now(), params.id);
|
|
1369
|
+
if (body.role === "user" && body.content) {
|
|
1370
|
+
const thread = db.prepare("SELECT title FROM threads WHERE id = ?").get(params.id);
|
|
1371
|
+
if (thread?.title === "New chat") {
|
|
1372
|
+
const title = body.content.replace(/\n.*/s, "").slice(0, 40).trim() + (body.content.length > 40 ? "..." : "");
|
|
1643
1373
|
if (title) {
|
|
1644
|
-
db.prepare(
|
|
1645
|
-
this.broadcast(JSON.stringify({ type:
|
|
1374
|
+
db.prepare("UPDATE threads SET title = ? WHERE id = ?").run(title, params.id);
|
|
1375
|
+
this.broadcast(JSON.stringify({ type: "clawchats", event: "thread-title-updated", threadId: params.id, workspace: this.getWorkspaces().active, title }));
|
|
1646
1376
|
}
|
|
1647
1377
|
}
|
|
1648
1378
|
}
|
|
1649
1379
|
}
|
|
1650
|
-
const message = db.prepare(
|
|
1651
|
-
if (message?.metadata) {
|
|
1380
|
+
const message = db.prepare("SELECT * FROM messages WHERE id = ?").get(body.id);
|
|
1381
|
+
if (message?.metadata) {
|
|
1382
|
+
try {
|
|
1383
|
+
message.metadata = JSON.parse(message.metadata);
|
|
1384
|
+
} catch {
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1652
1387
|
send(res, existing ? 200 : 201, { message });
|
|
1653
1388
|
}
|
|
1654
|
-
|
|
1655
1389
|
delete(req, res, params) {
|
|
1656
1390
|
const db = this.getActiveDb();
|
|
1657
|
-
if (!db.prepare(
|
|
1658
|
-
db.prepare(
|
|
1391
|
+
if (!db.prepare("SELECT id FROM messages WHERE id = ? AND thread_id = ?").get(params.messageId, params.id)) return sendError(res, 404, "Message not found");
|
|
1392
|
+
db.prepare("DELETE FROM messages WHERE id = ?").run(params.messageId);
|
|
1659
1393
|
send(res, 200, { ok: true });
|
|
1660
1394
|
}
|
|
1661
|
-
|
|
1662
1395
|
contextFill(req, res, params) {
|
|
1663
1396
|
const db = this.getActiveDb();
|
|
1664
|
-
const thread = db.prepare(
|
|
1665
|
-
if (!thread) return sendError(res, 404,
|
|
1666
|
-
|
|
1667
|
-
send(res, 200, { preamble, method });
|
|
1397
|
+
const thread = db.prepare("SELECT * FROM threads WHERE id = ?").get(params.id);
|
|
1398
|
+
if (!thread) return sendError(res, 404, "Thread not found");
|
|
1399
|
+
send(res, 200, buildContextPreamble(db, params.id, thread.last_session_id, thread.session_key));
|
|
1668
1400
|
}
|
|
1669
|
-
|
|
1670
1401
|
search(req, res, params, query) {
|
|
1671
|
-
const
|
|
1672
|
-
const q = query.q || '';
|
|
1402
|
+
const q = query.q || "";
|
|
1673
1403
|
if (!q) return send(res, 200, { results: [], total: 0 });
|
|
1674
|
-
const
|
|
1675
|
-
const
|
|
1404
|
+
const db = this.getActiveDb();
|
|
1405
|
+
const page = parseInt(query.page || "1", 10);
|
|
1406
|
+
const limit = Math.min(parseInt(query.limit || "20", 10), 100);
|
|
1676
1407
|
const offset = (page - 1) * limit;
|
|
1677
1408
|
try {
|
|
1678
1409
|
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
1410
|
const total = db.prepare(`SELECT COUNT(*) as c FROM messages_fts WHERE messages_fts MATCH ?`).get(q).c;
|
|
1680
1411
|
send(res, 200, { results, total });
|
|
1681
|
-
} catch {
|
|
1412
|
+
} catch {
|
|
1413
|
+
send(res, 200, { results: [], total: 0 });
|
|
1414
|
+
}
|
|
1682
1415
|
}
|
|
1683
|
-
|
|
1684
1416
|
export(req, res) {
|
|
1685
1417
|
const db = this.getActiveDb();
|
|
1686
1418
|
const ws = this.getWorkspaces();
|
|
1687
|
-
const threads = db.prepare(
|
|
1419
|
+
const threads = db.prepare("SELECT * FROM threads ORDER BY updated_at DESC").all();
|
|
1688
1420
|
send(res, 200, {
|
|
1689
|
-
workspace: ws.active,
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1421
|
+
workspace: ws.active,
|
|
1422
|
+
exportedAt: Date.now(),
|
|
1423
|
+
threads: threads.map((t) => {
|
|
1424
|
+
const messages = db.prepare("SELECT * FROM messages WHERE thread_id = ? ORDER BY timestamp ASC").all(t.id);
|
|
1425
|
+
for (const m of messages) {
|
|
1426
|
+
if (m.metadata) {
|
|
1427
|
+
try {
|
|
1428
|
+
m.metadata = JSON.parse(m.metadata);
|
|
1429
|
+
} catch {
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1693
1433
|
return { ...t, messages };
|
|
1694
|
-
})
|
|
1434
|
+
})
|
|
1695
1435
|
});
|
|
1696
1436
|
}
|
|
1697
|
-
|
|
1698
1437
|
async import(req, res) {
|
|
1699
1438
|
const body = await parseBody(req);
|
|
1700
1439
|
const db = this.getActiveDb();
|
|
1701
1440
|
const ws = this.getWorkspaces();
|
|
1702
|
-
if (!body.threads || !Array.isArray(body.threads)) return sendError(res, 400,
|
|
1441
|
+
if (!body.threads || !Array.isArray(body.threads)) return sendError(res, 400, "Expected { threads: [...] }");
|
|
1703
1442
|
let threadsImported = 0, messagesImported = 0;
|
|
1704
|
-
const insertThread = db.prepare(
|
|
1705
|
-
const insertMsg = db.prepare(
|
|
1706
|
-
db.
|
|
1443
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
1444
|
+
const insertMsg = db.prepare("INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
1445
|
+
db.exec("BEGIN");
|
|
1446
|
+
try {
|
|
1707
1447
|
for (const t of body.threads) {
|
|
1708
1448
|
if (!t.id) continue;
|
|
1709
1449
|
const sessionKey = t.session_key || `agent:main:${ws.active}:chat:${t.id}`;
|
|
1710
|
-
if (insertThread.run(t.id, sessionKey, t.title ||
|
|
1711
|
-
for (const m of
|
|
1450
|
+
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++;
|
|
1451
|
+
for (const m of t.messages || []) {
|
|
1712
1452
|
if (!m.id || !m.role) continue;
|
|
1713
|
-
const meta = m.metadata ?
|
|
1714
|
-
if (insertMsg.run(m.id, t.id, m.role, m.content ||
|
|
1453
|
+
const meta = m.metadata ? typeof m.metadata === "string" ? m.metadata : JSON.stringify(m.metadata) : null;
|
|
1454
|
+
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++;
|
|
1715
1455
|
}
|
|
1716
1456
|
}
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
}
|
|
1729
|
-
|
|
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');
|
|
1733
|
-
const files = await parseMultipart(req);
|
|
1734
|
-
const dir = path.join(this.uploadsDir, params.id);
|
|
1735
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1736
|
-
const savedFiles = [];
|
|
1737
|
-
for (const file of files) {
|
|
1738
|
-
const fileId = uuid();
|
|
1739
|
-
const ext = path.extname(file.filename) || '';
|
|
1740
|
-
const savedPath = path.join(dir, fileId + ext);
|
|
1741
|
-
fs.writeFileSync(savedPath, file.data);
|
|
1742
|
-
savedFiles.push({ id: fileId, filename: file.filename, path: `/api/uploads/${params.id}/${fileId}${ext}`, mimeType: file.mimeType, size: file.data.length });
|
|
1743
|
-
}
|
|
1744
|
-
send(res, 200, { files: savedFiles });
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
serveUpload(req, res, params) {
|
|
1748
|
-
const base = path.join(this.uploadsDir, params.threadId, params.fileId);
|
|
1749
|
-
let resolved = base;
|
|
1750
|
-
if (!fs.existsSync(resolved)) {
|
|
1751
|
-
try {
|
|
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 */ }
|
|
1457
|
+
db.exec("COMMIT");
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
db.exec("ROLLBACK");
|
|
1460
|
+
throw e;
|
|
1755
1461
|
}
|
|
1756
|
-
|
|
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' };
|
|
1758
|
-
const stat = fs.statSync(resolved);
|
|
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': '*' });
|
|
1760
|
-
fs.createReadStream(resolved).pipe(res);
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
_intelligencePath(threadId) {
|
|
1764
|
-
return path.join(this.intelligenceDir, this.getWorkspaces().active, `${threadId}.json`);
|
|
1462
|
+
send(res, 200, { ok: true, threadsImported, messagesImported });
|
|
1765
1463
|
}
|
|
1464
|
+
};
|
|
1766
1465
|
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
try { return send(res, 200, JSON.parse(fs.readFileSync(filePath, 'utf8'))); }
|
|
1771
|
-
catch { return send(res, 200, { versions: [], currentVersion: -1 }); }
|
|
1772
|
-
}
|
|
1466
|
+
// server/controllers/files.js
|
|
1467
|
+
import fs9 from "node:fs";
|
|
1468
|
+
import path10 from "node:path";
|
|
1773
1469
|
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
const
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
_parseFiles() {
|
|
1813
|
-
const memories = [];
|
|
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
|
-
}
|
|
1470
|
+
// server/util/multipart.js
|
|
1471
|
+
function parseMultipart(req) {
|
|
1472
|
+
return new Promise((resolve, reject) => {
|
|
1473
|
+
const contentType = req.headers["content-type"] || "";
|
|
1474
|
+
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
|
|
1475
|
+
if (!boundaryMatch) return reject(new Error("No boundary in content-type"));
|
|
1476
|
+
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
|
1477
|
+
const chunks = [];
|
|
1478
|
+
req.on("data", (c) => chunks.push(c));
|
|
1479
|
+
req.on("end", () => {
|
|
1480
|
+
const buf = Buffer.concat(chunks);
|
|
1481
|
+
const files = [];
|
|
1482
|
+
const delimiter = Buffer.from(`--${boundary}`);
|
|
1483
|
+
let pos = 0;
|
|
1484
|
+
while (pos < buf.length) {
|
|
1485
|
+
const start = buf.indexOf(delimiter, pos);
|
|
1486
|
+
if (start === -1) break;
|
|
1487
|
+
const nextStart = buf.indexOf(delimiter, start + delimiter.length);
|
|
1488
|
+
if (nextStart === -1) break;
|
|
1489
|
+
const part = buf.subarray(start + delimiter.length, nextStart);
|
|
1490
|
+
const headerEnd = part.indexOf("\r\n\r\n");
|
|
1491
|
+
if (headerEnd === -1) {
|
|
1492
|
+
pos = nextStart;
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
const headerStr = part.subarray(0, headerEnd).toString();
|
|
1496
|
+
let body = part.subarray(headerEnd + 4);
|
|
1497
|
+
if (body.length >= 2 && body[body.length - 2] === 13 && body[body.length - 1] === 10) {
|
|
1498
|
+
body = body.subarray(0, body.length - 2);
|
|
1499
|
+
}
|
|
1500
|
+
const filenameMatch = headerStr.match(/filename="([^"]+)"/);
|
|
1501
|
+
const ctMatch = headerStr.match(/Content-Type:\s*(\S+)/i);
|
|
1502
|
+
if (filenameMatch) {
|
|
1503
|
+
files.push({
|
|
1504
|
+
filename: filenameMatch[1],
|
|
1505
|
+
mimeType: ctMatch ? ctMatch[1] : "application/octet-stream",
|
|
1506
|
+
data: body
|
|
1507
|
+
});
|
|
1843
1508
|
}
|
|
1509
|
+
pos = nextStart;
|
|
1844
1510
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
async update(req, res, params) {
|
|
1851
|
-
try {
|
|
1852
|
-
const chunks = [];
|
|
1853
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
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()) });
|
|
1857
|
-
} catch (err) { send(res, 502, { error: 'Failed to update memory', detail: err.message }); }
|
|
1858
|
-
}
|
|
1859
|
-
|
|
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
|
-
}
|
|
1511
|
+
resolve(files);
|
|
1512
|
+
});
|
|
1513
|
+
req.on("error", reject);
|
|
1514
|
+
});
|
|
1983
1515
|
}
|
|
1516
|
+
__name(parseMultipart, "parseMultipart");
|
|
1984
1517
|
|
|
1985
|
-
//
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
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() {
|
|
2022
|
-
try {
|
|
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();
|
|
1518
|
+
// server/controllers/files.js
|
|
1519
|
+
var FileController = class {
|
|
1520
|
+
static {
|
|
1521
|
+
__name(this, "FileController");
|
|
2074
1522
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
1523
|
+
constructor({ getActiveDb, getWorkspaces, uploadsDir, intelligenceDir }) {
|
|
1524
|
+
this.getActiveDb = getActiveDb;
|
|
1525
|
+
this.getWorkspaces = getWorkspaces;
|
|
1526
|
+
this.uploadsDir = uploadsDir;
|
|
1527
|
+
this.intelligenceDir = intelligenceDir;
|
|
2079
1528
|
}
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
sendError(res, 401, 'Invalid auth token');
|
|
2092
|
-
return false;
|
|
1529
|
+
async upload(req, res, params) {
|
|
1530
|
+
if (!this.getActiveDb().prepare("SELECT id FROM threads WHERE id = ?").get(params.id)) return sendError(res, 404, "Thread not found");
|
|
1531
|
+
const files = await parseMultipart(req);
|
|
1532
|
+
const dir = path10.join(this.uploadsDir, params.id);
|
|
1533
|
+
fs9.mkdirSync(dir, { recursive: true });
|
|
1534
|
+
const savedFiles = [];
|
|
1535
|
+
for (const file of files) {
|
|
1536
|
+
const fileId = uuid();
|
|
1537
|
+
const ext = path10.extname(file.filename) || "";
|
|
1538
|
+
fs9.writeFileSync(path10.join(dir, fileId + ext), file.data);
|
|
1539
|
+
savedFiles.push({ id: fileId, filename: file.filename, path: `/api/uploads/${params.id}/${fileId}${ext}`, mimeType: file.mimeType, size: file.data.length });
|
|
2093
1540
|
}
|
|
2094
|
-
|
|
1541
|
+
send(res, 200, { files: savedFiles });
|
|
2095
1542
|
}
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
this.ws = null;
|
|
2101
|
-
this.connected = false;
|
|
2102
|
-
this.reconnectAttempts = 0;
|
|
2103
|
-
this.maxReconnectDelay = 30000;
|
|
2104
|
-
this.browserClients = new Map();
|
|
2105
|
-
this._externalBroadcastTargets = [];
|
|
2106
|
-
this.streamState = new Map();
|
|
2107
|
-
this.activityLogs = new Map();
|
|
2108
|
-
setInterval(() => {
|
|
2109
|
-
const cutoff = Date.now() - 10 * 60 * 1000;
|
|
2110
|
-
for (const [runId, log] of this.activityLogs) {
|
|
2111
|
-
if (log.startTime < cutoff) {
|
|
2112
|
-
if (log._messageId) {
|
|
2113
|
-
const db = _getDb(log._parsed?.workspace);
|
|
2114
|
-
if (db) {
|
|
2115
|
-
db.prepare(`
|
|
2116
|
-
UPDATE messages SET content = '[Response interrupted]',
|
|
2117
|
-
metadata = json_remove(metadata, '$.pending')
|
|
2118
|
-
WHERE id = ? AND content = ''
|
|
2119
|
-
`).run(log._messageId);
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
this.activityLogs.delete(runId);
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
|
-
}, 5 * 60 * 1000);
|
|
2126
|
-
}
|
|
2127
|
-
|
|
2128
|
-
connect() {
|
|
2129
|
-
if (this.ws && (this.ws.readyState === WS.CONNECTING || this.ws.readyState === WS.OPEN)) return;
|
|
2130
|
-
console.log(`Connecting to gateway at ${_GATEWAY_WS_URL}...`);
|
|
2131
|
-
this.ws = new WS(_GATEWAY_WS_URL);
|
|
2132
|
-
this.ws.on('open', () => { console.log('Gateway WebSocket connected'); this.reconnectAttempts = 0; });
|
|
2133
|
-
this.ws.on('message', (data) => { this.handleGatewayMessage(data.toString()); });
|
|
2134
|
-
this.ws.on('close', () => { console.log('Gateway WebSocket closed'); this.connected = false; this.broadcastGatewayStatus(false); this.scheduleReconnect(); });
|
|
2135
|
-
this.ws.on('error', (err) => { console.error('Gateway WebSocket error:', err.message); });
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
handleGatewayMessage(data) {
|
|
2139
|
-
_debugLogger.logFrame('GW→SRV', data);
|
|
2140
|
-
let msg;
|
|
2141
|
-
try { msg = JSON.parse(data); } catch { console.error('Invalid JSON from gateway:', data); return; }
|
|
2142
|
-
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
|
2143
|
-
console.log('Received connect.challenge, sending auth...');
|
|
2144
|
-
const _nonce = msg.payload?.nonce || '';
|
|
2145
|
-
const _identityPath = path.join(_DATA_DIR, 'device-identity.json');
|
|
2146
|
-
const _identity = _loadOrCreateDeviceIdentity(_identityPath);
|
|
2147
|
-
const _device = _buildDeviceAuth(_identity, {
|
|
2148
|
-
clientId: 'gateway-client', clientMode: 'backend', role: 'operator',
|
|
2149
|
-
scopes: ['operator.read', 'operator.write', 'operator.admin'], token: _GATEWAY_TOKEN, nonce: _nonce
|
|
2150
|
-
});
|
|
2151
|
-
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: _device, auth: { token: _GATEWAY_TOKEN }, caps: ['tool-events'] } }));
|
|
2152
|
-
return;
|
|
2153
|
-
}
|
|
2154
|
-
if (msg.type === 'res' && msg.payload?.type === 'hello-ok') { console.log('Gateway handshake complete'); this.connected = true; this.broadcastGatewayStatus(true); }
|
|
2155
|
-
// Chat events are routed through handleChatEvent which decides when/if to
|
|
2156
|
-
// broadcast (sentinel hold, flush ordering, suppression). All other events
|
|
2157
|
-
// broadcast unconditionally.
|
|
2158
|
-
if (msg.type === 'event' && msg.event === 'chat' && msg.payload) {
|
|
2159
|
-
this.handleChatEvent(msg.payload, data);
|
|
2160
|
-
} else {
|
|
2161
|
-
this.broadcastToBrowsers(data);
|
|
2162
|
-
}
|
|
2163
|
-
if (msg.type === 'event' && msg.event === 'agent' && msg.payload) this.handleAgentEvent(msg.payload);
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
handleChatEvent(params, rawData) {
|
|
2167
|
-
const { sessionKey, state, message, seq } = params;
|
|
2168
|
-
|
|
2169
|
-
if (state === 'delta') {
|
|
2170
|
-
const parsed = parseSessionKey(sessionKey);
|
|
2171
|
-
if (parsed) {
|
|
2172
|
-
const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming', held: [] };
|
|
2173
|
-
existing.buffer += extractContent(message);
|
|
2174
|
-
|
|
2175
|
-
// If the accumulated buffer looks like a NO_REPLY or HEARTBEAT_OK prefix,
|
|
2176
|
-
// hold this raw delta — don't forward to browsers yet.
|
|
2177
|
-
if (isSilentReplyPrefix(existing.buffer, 'NO_REPLY') || isSilentReplyPrefix(existing.buffer, 'HEARTBEAT_OK')) {
|
|
2178
|
-
existing.held = existing.held || [];
|
|
2179
|
-
existing.held.push(rawData);
|
|
2180
|
-
this.streamState.set(sessionKey, existing);
|
|
2181
|
-
return; // suppressed — wait for more chunks
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
// Buffer diverged from any sentinel — flush held deltas first, then current.
|
|
2185
|
-
if (existing.held?.length > 0) {
|
|
2186
|
-
for (const h of existing.held) this.broadcastToBrowsers(h);
|
|
2187
|
-
existing.held = [];
|
|
2188
|
-
}
|
|
2189
|
-
this.streamState.set(sessionKey, existing);
|
|
2190
|
-
}
|
|
2191
|
-
this.broadcastToBrowsers(rawData);
|
|
2192
|
-
return;
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
// Capture stream entry before deletion so we can flush held deltas if needed.
|
|
2196
|
-
const streamEntry = this.streamState.get(sessionKey);
|
|
2197
|
-
if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
|
|
2198
|
-
|
|
2199
|
-
// Title generation sessions: pass through without sentinel filtering.
|
|
2200
|
-
if (sessionKey && sessionKey.includes('__clawchats_title_')) {
|
|
2201
|
-
if (state === 'final') {
|
|
2202
|
-
const content = extractContent(message);
|
|
2203
|
-
if (content && this.handleTitleResponse(sessionKey, content)) return;
|
|
2204
|
-
} else if (state === 'error' || state === 'aborted') {
|
|
2205
|
-
if (this._pendingTitleGens) {
|
|
2206
|
-
for (const key of this._pendingTitleGens.keys()) {
|
|
2207
|
-
if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; }
|
|
2208
|
-
}
|
|
2209
|
-
}
|
|
2210
|
-
return;
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
|
|
2214
|
-
if (state === 'final') {
|
|
2215
|
-
const rawContent = extractContent(message);
|
|
2216
|
-
|
|
2217
|
-
// Exact NO_REPLY or HEARTBEAT_OK — suppress entirely. Discard any held deltas,
|
|
2218
|
-
// don't broadcast the final event, don't save to DB.
|
|
2219
|
-
if (isSilentReplyExact(rawContent, 'NO_REPLY') || isSilentReplyExact(rawContent, 'HEARTBEAT_OK')) {
|
|
2220
|
-
return;
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
// If we held deltas (buffer was a sentinel prefix), flush them before the final
|
|
2224
|
-
// so the browser has a response element ready for finalization.
|
|
2225
|
-
if (streamEntry?.held?.length > 0) {
|
|
2226
|
-
for (const h of streamEntry.held) this.broadcastToBrowsers(h);
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
// Broadcast the final event, then persist.
|
|
2230
|
-
this.broadcastToBrowsers(rawData);
|
|
2231
|
-
this.saveAssistantMessage(sessionKey, message, seq);
|
|
2232
|
-
return;
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
|
-
if (state === 'aborted' || state === 'error') {
|
|
2236
|
-
// Discard any held deltas silently — if stream was aborted while holding a
|
|
2237
|
-
// NO_REPLY prefix, there's nothing to show. Broadcast the terminal event normally.
|
|
2238
|
-
this.broadcastToBrowsers(rawData);
|
|
2239
|
-
}
|
|
2240
|
-
|
|
2241
|
-
if (state === 'error') this.saveErrorMarker(sessionKey, message);
|
|
2242
|
-
}
|
|
2243
|
-
|
|
2244
|
-
saveAssistantMessage(sessionKey, message, seq) {
|
|
2245
|
-
const parsed = parseSessionKey(sessionKey);
|
|
2246
|
-
if (!parsed) return;
|
|
2247
|
-
const ws = _getWorkspaces();
|
|
2248
|
-
if (!ws.workspaces[parsed.workspace]) { console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`); return; }
|
|
2249
|
-
const db = _getDb(parsed.workspace);
|
|
2250
|
-
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
2251
|
-
if (!thread) { console.log(`Ignoring response for deleted thread: ${parsed.threadId}`); return; }
|
|
2252
|
-
let content = sanitizeAssistantContent(extractContent(message));
|
|
2253
|
-
if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
|
|
2254
|
-
|
|
2255
|
-
// Attach media captured by the after_tool_call hook (MEDIA: lines from exec stdout).
|
|
2256
|
-
// Classify by extension: images → inline markdown, audio/docs → metadata.attachments.
|
|
2257
|
-
// Always clear the stash regardless of whether paths were found (prevents cross-turn leaks).
|
|
2258
|
-
const _mediaStash = config.mediaStash;
|
|
2259
|
-
const _pendingPaths = _mediaStash?.get(sessionKey) ?? [];
|
|
2260
|
-
_mediaStash?.delete(sessionKey);
|
|
2261
|
-
const _IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','webp','bmp','svg','ico','avif','tiff']);
|
|
2262
|
-
const _AUDIO_EXTS = new Set(['mp3','wav','ogg','m4a','flac','aac','opus','wma']);
|
|
2263
|
-
const _pendingAttachments = []; // non-image files → metadata.attachments
|
|
2264
|
-
const imagePaths = []; // images → inline markdown (declared outside if for broadcast scope)
|
|
2265
|
-
if (_pendingPaths.length > 0) {
|
|
2266
|
-
for (const p of _pendingPaths) {
|
|
2267
|
-
const ext = (p.split('.').pop() || '').toLowerCase();
|
|
2268
|
-
if (_IMAGE_EXTS.has(ext)) {
|
|
2269
|
-
imagePaths.push(p);
|
|
2270
|
-
} else {
|
|
2271
|
-
const name = p.split('/').pop();
|
|
2272
|
-
const type = _AUDIO_EXTS.has(ext) ? 'audio' : 'file';
|
|
2273
|
-
_pendingAttachments.push({ path: p, name, type });
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
if (imagePaths.length > 0) {
|
|
2277
|
-
content = content.trimEnd() + '\n\n' + imagePaths.map(p => ``).join('\n');
|
|
2278
|
-
}
|
|
2279
|
-
console.log(`[clawchats] media-attach: ${imagePaths.length} image(s) inline, ${_pendingAttachments.length} attachment(s) for ${sessionKey}`);
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
const now = Date.now();
|
|
2283
|
-
|
|
2284
|
-
// Check for pending activity message
|
|
2285
|
-
const pendingMsg = db.prepare(`
|
|
2286
|
-
SELECT id, metadata FROM messages
|
|
2287
|
-
WHERE thread_id = ? AND role = 'assistant'
|
|
2288
|
-
AND json_extract(metadata, '$.pending') = 1
|
|
2289
|
-
ORDER BY timestamp DESC LIMIT 1
|
|
2290
|
-
`).get(parsed.threadId);
|
|
2291
|
-
|
|
2292
|
-
let messageId;
|
|
2293
|
-
|
|
2294
|
-
if (pendingMsg) {
|
|
2295
|
-
// Merge final content into existing activity row
|
|
2296
|
-
const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
|
|
2297
|
-
delete metadata.pending;
|
|
2298
|
-
if (metadata.activityLog) {
|
|
2299
|
-
const lastAssistantIdx = metadata.activityLog.findLastIndex(s => s.type === 'assistant');
|
|
2300
|
-
if (lastAssistantIdx >= 0) metadata.activityLog.splice(lastAssistantIdx, 1);
|
|
2301
|
-
metadata.activitySummary = this.generateActivitySummary(metadata.activityLog);
|
|
2302
|
-
}
|
|
2303
|
-
if (_pendingAttachments.length > 0) {
|
|
2304
|
-
metadata.attachments = [...(metadata.attachments || []), ..._pendingAttachments];
|
|
2305
|
-
}
|
|
2306
|
-
db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?')
|
|
2307
|
-
.run(content, JSON.stringify(metadata), now, pendingMsg.id);
|
|
2308
|
-
messageId = pendingMsg.id;
|
|
2309
|
-
} else {
|
|
2310
|
-
// No pending activity — normal INSERT (simple responses, no tools)
|
|
2311
|
-
messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
|
|
2312
|
-
const newMeta = _pendingAttachments.length > 0 ? JSON.stringify({ attachments: _pendingAttachments }) : null;
|
|
2313
|
-
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);
|
|
2314
|
-
}
|
|
2315
|
-
|
|
2316
|
-
try {
|
|
2317
|
-
db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
|
|
2318
|
-
db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
|
|
2319
|
-
syncThreadUnreadCount(db, parsed.threadId);
|
|
2320
|
-
const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
|
|
2321
|
-
const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
|
|
2322
|
-
const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
|
|
2323
|
-
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title || 'Chat', preview, unreadCount, updatedContent: imagePaths.length > 0 ? content : undefined, updatedAttachments: _pendingAttachments.length > 0 ? _pendingAttachments : undefined }));
|
|
2324
|
-
const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
|
|
2325
|
-
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace: parsed.workspace, threadId: parsed.threadId, messageId, action: 'new', unreadCount, workspaceUnreadTotal, title: threadInfo?.title || 'Chat', preview, timestamp: now }));
|
|
2326
|
-
console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
|
|
2327
|
-
|
|
2328
|
-
// Auto-generate AI title upgrade after first assistant response
|
|
2329
|
-
const currentTitle = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId)?.title;
|
|
2330
|
-
const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
|
|
2331
|
-
if (msgCount === 2 || currentTitle === 'New chat') {
|
|
2332
|
-
this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
|
|
2333
|
-
}
|
|
2334
|
-
} catch (e) { console.error(`Failed to save assistant message:`, e.message); }
|
|
2335
|
-
}
|
|
2336
|
-
|
|
2337
|
-
saveErrorMarker(sessionKey, message) {
|
|
2338
|
-
const parsed = parseSessionKey(sessionKey);
|
|
2339
|
-
if (!parsed) return;
|
|
2340
|
-
const ws = _getWorkspaces();
|
|
2341
|
-
if (!ws.workspaces[parsed.workspace]) return;
|
|
2342
|
-
const db = _getDb(parsed.workspace);
|
|
2343
|
-
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
2344
|
-
if (!thread) return;
|
|
2345
|
-
const errorText = message?.error || message?.content || 'Unknown error';
|
|
2346
|
-
const content = `[error] ${errorText}`;
|
|
2347
|
-
const now = Date.now();
|
|
2348
|
-
const messageId = `gw-error-${parsed.threadId}-${now}`;
|
|
1543
|
+
serveUpload(req, res, params) {
|
|
1544
|
+
const base = path10.join(this.uploadsDir, params.threadId, params.fileId);
|
|
1545
|
+
let resolved = base;
|
|
1546
|
+
if (!fs9.existsSync(resolved)) {
|
|
2349
1547
|
try {
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
} catch
|
|
2353
|
-
}
|
|
2354
|
-
|
|
2355
|
-
generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
|
|
2356
|
-
const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId);
|
|
2357
|
-
if (!thread) return;
|
|
2358
|
-
const titleKey = `__clawchats_title_${threadId}`;
|
|
2359
|
-
if (this._pendingTitleGens?.has(titleKey)) return;
|
|
2360
|
-
const firstUserMsg = db.prepare("SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1").get(threadId);
|
|
2361
|
-
if (!firstUserMsg?.content) return;
|
|
2362
|
-
|
|
2363
|
-
if (!skipHeuristic) {
|
|
2364
|
-
const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim() + (firstUserMsg.content.length > 40 ? '...' : '');
|
|
2365
|
-
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
|
|
2366
|
-
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId, workspace, title: heuristic }));
|
|
1548
|
+
const match = fs9.readdirSync(path10.join(this.uploadsDir, params.threadId)).find((e) => e.startsWith(params.fileId.replace(/\.[^.]+$/, "")));
|
|
1549
|
+
if (match) resolved = path10.join(this.uploadsDir, params.threadId, match);
|
|
1550
|
+
} catch {
|
|
2367
1551
|
}
|
|
2368
|
-
|
|
2369
|
-
const messages = db.prepare('SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6').all(threadId);
|
|
2370
|
-
if (messages.length < 2) return;
|
|
2371
|
-
const conversation = messages.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content}`).join('\n\n');
|
|
2372
|
-
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:`;
|
|
2373
|
-
const reqId = `title-${threadId}-${Date.now()}`;
|
|
2374
|
-
if (!this._pendingTitleGens) this._pendingTitleGens = new Map();
|
|
2375
|
-
this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
|
|
2376
|
-
setTimeout(() => { if (this._pendingTitleGens?.has(titleKey)) { this._pendingTitleGens.delete(titleKey); console.log(`Title gen timeout for ${threadId} — keeping heuristic title`); } }, 30000);
|
|
2377
|
-
this.sendToGateway(JSON.stringify({ type: 'req', id: reqId, method: 'chat.send', params: { sessionKey: titleKey, message: prompt, deliver: false, idempotencyKey: reqId } }));
|
|
2378
1552
|
}
|
|
1553
|
+
if (!fs9.existsSync(resolved)) return sendError(res, 404, "File not found");
|
|
1554
|
+
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" };
|
|
1555
|
+
const stat = fs9.statSync(resolved);
|
|
1556
|
+
res.writeHead(200, { "Content-Type": MIME[path10.extname(resolved).toLowerCase()] || "application/octet-stream", "Content-Length": stat.size, "Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*" });
|
|
1557
|
+
fs9.createReadStream(resolved).pipe(res);
|
|
1558
|
+
}
|
|
1559
|
+
_intelligencePath(threadId) {
|
|
1560
|
+
return path10.join(this.intelligenceDir, this.getWorkspaces().active, `${threadId}.json`);
|
|
1561
|
+
}
|
|
1562
|
+
getIntelligence(req, res, params) {
|
|
1563
|
+
const filePath = this._intelligencePath(params.id);
|
|
1564
|
+
if (!fs9.existsSync(filePath)) return send(res, 200, { versions: [], currentVersion: -1 });
|
|
1565
|
+
try {
|
|
1566
|
+
return send(res, 200, JSON.parse(fs9.readFileSync(filePath, "utf8")));
|
|
1567
|
+
} catch {
|
|
1568
|
+
return send(res, 200, { versions: [], currentVersion: -1 });
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
async saveIntelligence(req, res, params) {
|
|
1572
|
+
const body = await parseBody(req);
|
|
1573
|
+
const filePath = this._intelligencePath(params.id);
|
|
1574
|
+
fs9.mkdirSync(path10.dirname(filePath), { recursive: true });
|
|
1575
|
+
const data = { versions: body.versions || [], currentVersion: body.currentVersion ?? -1, updatedAt: Date.now() };
|
|
1576
|
+
fs9.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
1577
|
+
send(res, 200, data);
|
|
1578
|
+
}
|
|
1579
|
+
};
|
|
2379
1580
|
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
1581
|
+
// server/controllers/memory.js
|
|
1582
|
+
import fs10 from "node:fs";
|
|
1583
|
+
import path11 from "node:path";
|
|
1584
|
+
var MemoryController = class {
|
|
1585
|
+
static {
|
|
1586
|
+
__name(this, "MemoryController");
|
|
1587
|
+
}
|
|
1588
|
+
constructor({ memoryProvider, memoryFilesDir, memoryConfig }) {
|
|
1589
|
+
this.provider = memoryProvider;
|
|
1590
|
+
this.filesDir = memoryFilesDir;
|
|
1591
|
+
this.config = memoryConfig;
|
|
1592
|
+
}
|
|
1593
|
+
async list(req, res, query) {
|
|
1594
|
+
const limit = Math.min(parseInt(query.limit) || 20, 100);
|
|
1595
|
+
try {
|
|
1596
|
+
send(res, 200, await this.provider.list(limit, query.offset || null));
|
|
1597
|
+
} catch (err) {
|
|
1598
|
+
send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message });
|
|
2397
1599
|
}
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
1600
|
+
}
|
|
1601
|
+
async search(req, res, query) {
|
|
1602
|
+
const q = (query.query || "").toLowerCase().trim();
|
|
1603
|
+
if (!q) return send(res, 400, { error: "Missing query parameter" });
|
|
1604
|
+
try {
|
|
1605
|
+
send(res, 200, await this.provider.search(q));
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message });
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
files(req, res, query) {
|
|
1611
|
+
const q = (query.query || "").toLowerCase().trim();
|
|
1612
|
+
const memories = this._parseFiles();
|
|
1613
|
+
const filtered = q ? memories.filter((m) => m.data.toLowerCase().includes(q) || m.title.toLowerCase().includes(q)) : memories;
|
|
1614
|
+
filtered.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
1615
|
+
send(res, 200, { memories: filtered });
|
|
1616
|
+
}
|
|
1617
|
+
_parseFiles() {
|
|
1618
|
+
const memories = [];
|
|
1619
|
+
const scanDir = /* @__PURE__ */ __name((dir, prefix = "") => {
|
|
1620
|
+
let entries;
|
|
1621
|
+
try {
|
|
1622
|
+
entries = fs10.readdirSync(dir);
|
|
1623
|
+
} catch {
|
|
2414
1624
|
return;
|
|
2415
1625
|
}
|
|
2416
|
-
|
|
2417
|
-
const
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
1626
|
+
for (const entry of entries) {
|
|
1627
|
+
const fullPath = path11.join(dir, entry);
|
|
1628
|
+
const stat = (() => {
|
|
1629
|
+
try {
|
|
1630
|
+
return fs10.statSync(fullPath);
|
|
1631
|
+
} catch {
|
|
1632
|
+
return null;
|
|
1633
|
+
}
|
|
1634
|
+
})();
|
|
1635
|
+
if (!stat) continue;
|
|
1636
|
+
if (stat.isDirectory() && !prefix) {
|
|
1637
|
+
scanDir(fullPath, entry + "/");
|
|
1638
|
+
continue;
|
|
2426
1639
|
}
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
const
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
if (data?.phase === 'end' || data?.phase === 'error') {
|
|
2446
|
-
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
|
|
2447
|
-
const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
|
|
2448
|
-
if (lastAssistantIdx >= 0) log.steps.splice(lastAssistantIdx, 1);
|
|
2449
|
-
this._writeActivityToDb(runId, log);
|
|
2450
|
-
this.activityLogs.delete(runId);
|
|
2451
|
-
return;
|
|
1640
|
+
if (!entry.endsWith(".md") || !stat.isFile()) continue;
|
|
1641
|
+
const content = fs10.readFileSync(fullPath, "utf8");
|
|
1642
|
+
const basename = entry.replace(/\.md$/, "");
|
|
1643
|
+
const dateMatch = basename.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
1644
|
+
if (prefix) {
|
|
1645
|
+
memories.push({ id: `file:${prefix + basename}`, source: "file", file: prefix + basename, title: basename, data: content.trim(), createdAt: stat.mtime.toISOString() });
|
|
1646
|
+
} else {
|
|
1647
|
+
for (const section of content.split(/^(?=## )/m)) {
|
|
1648
|
+
const trimmed = section.trim();
|
|
1649
|
+
if (!trimmed) continue;
|
|
1650
|
+
const headingMatch = trimmed.match(/^##\s+(.+)/);
|
|
1651
|
+
const heading = headingMatch ? headingMatch[1].trim() : null;
|
|
1652
|
+
const body = headingMatch ? trimmed.slice(trimmed.indexOf("\n") + 1).trim() : trimmed;
|
|
1653
|
+
if (!heading && body.match(/^#\s+/) && body.split("\n").length <= 2) continue;
|
|
1654
|
+
const title = heading || basename;
|
|
1655
|
+
memories.push({ id: `file:${basename}:${title}`, source: "file", file: basename, title, data: heading ? `**${title}**
|
|
1656
|
+
${body}` : body, createdAt: dateMatch ? `${dateMatch[1]}T00:00:00Z` : stat.mtime.toISOString() });
|
|
1657
|
+
}
|
|
2452
1658
|
}
|
|
2453
1659
|
}
|
|
1660
|
+
}, "scanDir");
|
|
1661
|
+
scanDir(this.filesDir);
|
|
1662
|
+
return memories;
|
|
1663
|
+
}
|
|
1664
|
+
async update(req, res, params) {
|
|
1665
|
+
try {
|
|
1666
|
+
const chunks = [];
|
|
1667
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
1668
|
+
const { data } = JSON.parse(Buffer.concat(chunks).toString());
|
|
1669
|
+
if (!(data || "").trim()) return send(res, 400, { error: "Missing data field" });
|
|
1670
|
+
send(res, 200, { ok: true, result: await this.provider.update(params.id, data.trim()) });
|
|
1671
|
+
} catch (err) {
|
|
1672
|
+
send(res, 502, { error: "Failed to update memory", detail: err.message });
|
|
2454
1673
|
}
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
const parsed = log._parsed;
|
|
2462
|
-
if (!parsed || !log._messageId) return;
|
|
2463
|
-
|
|
2464
|
-
const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
|
|
2465
|
-
|
|
2466
|
-
this.broadcastToBrowsers(JSON.stringify({
|
|
2467
|
-
type: 'clawchats',
|
|
2468
|
-
event: 'activity-updated',
|
|
2469
|
-
workspace: parsed.workspace,
|
|
2470
|
-
threadId: parsed.threadId,
|
|
2471
|
-
messageId: log._messageId,
|
|
2472
|
-
activityLog: cleanSteps,
|
|
2473
|
-
activitySummary: this.generateActivitySummary(log.steps)
|
|
2474
|
-
}));
|
|
2475
|
-
}
|
|
2476
|
-
|
|
2477
|
-
generateActivitySummary(steps) {
|
|
2478
|
-
return generateActivitySummary(steps);
|
|
1674
|
+
}
|
|
1675
|
+
async delete(req, res, params) {
|
|
1676
|
+
try {
|
|
1677
|
+
send(res, 200, { ok: true, result: await this.provider.delete(params.id) });
|
|
1678
|
+
} catch (err) {
|
|
1679
|
+
send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message });
|
|
2479
1680
|
}
|
|
1681
|
+
}
|
|
1682
|
+
async status(req, res) {
|
|
1683
|
+
const status = await this.provider.status();
|
|
1684
|
+
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: fs10.existsSync(this.filesDir) });
|
|
1685
|
+
}
|
|
1686
|
+
};
|
|
2480
1687
|
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
1688
|
+
// server/controllers/filesystem.js
|
|
1689
|
+
import fs11 from "node:fs";
|
|
1690
|
+
import path12 from "node:path";
|
|
1691
|
+
import os3 from "node:os";
|
|
1692
|
+
var HOME2 = os3.homedir();
|
|
1693
|
+
var ALLOWED_FILE_DIRS = [HOME2, "/tmp"];
|
|
1694
|
+
function handleServeFile(req, res, query, memoryConfig) {
|
|
1695
|
+
const filePath = query.path;
|
|
1696
|
+
if (!filePath) return sendError(res, 400, "Missing path parameter");
|
|
1697
|
+
const resolved = filePath.startsWith("./") || filePath.startsWith("../") ? path12.resolve(memoryConfig.workspaceDir, filePath) : path12.resolve(filePath);
|
|
1698
|
+
if (!ALLOWED_FILE_DIRS.some((dir) => resolved.startsWith(dir + "/") || resolved === dir)) return sendError(res, 403, "Access denied: path not in allowed directories");
|
|
1699
|
+
if (!fs11.existsSync(resolved) || !fs11.statSync(resolved).isFile()) return sendError(res, 404, "File not found");
|
|
1700
|
+
const MIME = {
|
|
1701
|
+
".png": "image/png",
|
|
1702
|
+
".jpg": "image/jpeg",
|
|
1703
|
+
".jpeg": "image/jpeg",
|
|
1704
|
+
".gif": "image/gif",
|
|
1705
|
+
".webp": "image/webp",
|
|
1706
|
+
".svg": "image/svg+xml",
|
|
1707
|
+
".pdf": "application/pdf",
|
|
1708
|
+
".txt": "text/plain",
|
|
1709
|
+
".json": "application/json",
|
|
1710
|
+
".md": "text/markdown",
|
|
1711
|
+
".csv": "text/csv",
|
|
1712
|
+
".xml": "text/xml",
|
|
1713
|
+
".html": "text/html",
|
|
1714
|
+
".css": "text/css",
|
|
1715
|
+
".js": "text/javascript",
|
|
1716
|
+
".py": "text/x-python",
|
|
1717
|
+
".sh": "text/x-shellscript",
|
|
1718
|
+
".yaml": "text/yaml",
|
|
1719
|
+
".yml": "text/yaml",
|
|
1720
|
+
".toml": "text/toml",
|
|
1721
|
+
".zip": "application/zip",
|
|
1722
|
+
".gz": "application/gzip",
|
|
1723
|
+
".tar": "application/x-tar",
|
|
1724
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1725
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1726
|
+
".mp3": "audio/mpeg",
|
|
1727
|
+
".wav": "audio/wav",
|
|
1728
|
+
".ogg": "audio/ogg",
|
|
1729
|
+
".mp4": "video/mp4",
|
|
1730
|
+
".webm": "video/webm"
|
|
1731
|
+
};
|
|
1732
|
+
const stat = fs11.statSync(resolved);
|
|
1733
|
+
res.writeHead(200, { "Content-Type": MIME[path12.extname(resolved).toLowerCase()] || "application/octet-stream", "Content-Length": stat.size, "Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*" });
|
|
1734
|
+
fs11.createReadStream(resolved).pipe(res);
|
|
1735
|
+
}
|
|
1736
|
+
__name(handleServeFile, "handleServeFile");
|
|
1737
|
+
function handleWorkspaceList(req, res, query) {
|
|
1738
|
+
const reqPath = query.path || "~/.openclaw/workspace";
|
|
1739
|
+
const depth = parseInt(query.depth || "2", 10);
|
|
1740
|
+
const showHidden = query.hidden === "1" || query.hidden === "true";
|
|
1741
|
+
const resolved = path12.resolve(reqPath.replace(/^~/, HOME2));
|
|
1742
|
+
if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Access denied");
|
|
1743
|
+
if (!fs11.existsSync(resolved)) return sendError(res, 404, "Path not found");
|
|
1744
|
+
const files = [{ path: resolved + "/", type: "dir", name: path12.basename(resolved), size: 0 }];
|
|
1745
|
+
const walk = /* @__PURE__ */ __name((dir, d) => {
|
|
1746
|
+
if (d > depth) return;
|
|
1747
|
+
try {
|
|
1748
|
+
for (const entry of fs11.readdirSync(dir, { withFileTypes: true })) {
|
|
1749
|
+
if (entry.name.startsWith(".") && entry.name !== ".openclaw" && !showHidden) continue;
|
|
1750
|
+
if (entry.name === "node_modules") continue;
|
|
1751
|
+
const fullPath = path12.join(dir, entry.name);
|
|
1752
|
+
const isDir = entry.isDirectory();
|
|
1753
|
+
files.push({ path: fullPath + (isDir ? "/" : ""), type: isDir ? "dir" : "file", name: entry.name, size: isDir ? 0 : (() => {
|
|
1754
|
+
try {
|
|
1755
|
+
return fs11.statSync(fullPath).size;
|
|
1756
|
+
} catch {
|
|
1757
|
+
return 0;
|
|
1758
|
+
}
|
|
1759
|
+
})() });
|
|
1760
|
+
if (isDir) walk(fullPath, d + 1);
|
|
2491
1761
|
}
|
|
1762
|
+
} catch {
|
|
2492
1763
|
}
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
1764
|
+
}, "walk");
|
|
1765
|
+
walk(resolved, 1);
|
|
1766
|
+
send(res, 200, { files, cwd: resolved });
|
|
1767
|
+
}
|
|
1768
|
+
__name(handleWorkspaceList, "handleWorkspaceList");
|
|
1769
|
+
function handleWorkspaceFileRead(req, res, query) {
|
|
1770
|
+
const filePath = query.path;
|
|
1771
|
+
if (!filePath) return sendError(res, 400, "Missing path parameter");
|
|
1772
|
+
const resolved = path12.resolve(filePath.replace(/^~/, HOME2));
|
|
1773
|
+
if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Access denied");
|
|
1774
|
+
if (!fs11.existsSync(resolved) || !fs11.statSync(resolved).isFile()) return sendError(res, 404, "File not found");
|
|
1775
|
+
const stat = fs11.statSync(resolved);
|
|
1776
|
+
const ext = path12.extname(resolved).toLowerCase().slice(1);
|
|
1777
|
+
const binaryMime = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", pdf: "application/pdf", mp3: "audio/mpeg", mp4: "video/mp4", wav: "audio/wav", ogg: "audio/ogg", webm: "video/webm" };
|
|
1778
|
+
const mime = binaryMime[ext];
|
|
1779
|
+
if (mime) {
|
|
1780
|
+
if (stat.size > 20 * 1024 * 1024) return sendError(res, 413, "File too large (max 20MB)");
|
|
1781
|
+
res.writeHead(200, { "Content-Type": mime, "Cache-Control": "private, max-age=60" });
|
|
1782
|
+
res.end(fs11.readFileSync(resolved));
|
|
1783
|
+
} else {
|
|
1784
|
+
if (stat.size > 1024 * 1024) return sendError(res, 413, "File too large (max 1MB)");
|
|
1785
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1786
|
+
res.end(fs11.readFileSync(resolved, "utf8"));
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
__name(handleWorkspaceFileRead, "handleWorkspaceFileRead");
|
|
1790
|
+
async function handleWorkspaceFileWrite(req, res, query) {
|
|
1791
|
+
const filePath = query.path;
|
|
1792
|
+
if (!filePath) return sendError(res, 400, "Missing path parameter");
|
|
1793
|
+
const resolved = path12.resolve(filePath.replace(/^~/, HOME2));
|
|
1794
|
+
if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Can only write to workspace directory");
|
|
1795
|
+
const chunks = [];
|
|
1796
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
1797
|
+
const dir = path12.dirname(resolved);
|
|
1798
|
+
if (!fs11.existsSync(dir)) fs11.mkdirSync(dir, { recursive: true });
|
|
1799
|
+
fs11.writeFileSync(resolved, Buffer.concat(chunks).toString("utf8"), "utf8");
|
|
1800
|
+
send(res, 200, { ok: true });
|
|
1801
|
+
}
|
|
1802
|
+
__name(handleWorkspaceFileWrite, "handleWorkspaceFileWrite");
|
|
1803
|
+
function handleWorkspaceFileDelete(req, res, query) {
|
|
1804
|
+
const filePath = query.path;
|
|
1805
|
+
if (!filePath) return sendError(res, 400, "Missing path parameter");
|
|
1806
|
+
const resolved = path12.resolve(filePath.replace(/^~/, HOME2));
|
|
1807
|
+
if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Access denied");
|
|
1808
|
+
if (!fs11.existsSync(resolved)) return sendError(res, 404, "Path not found");
|
|
1809
|
+
try {
|
|
1810
|
+
const stat = fs11.statSync(resolved);
|
|
1811
|
+
if (stat.isDirectory()) {
|
|
1812
|
+
fs11.rmSync(resolved, { recursive: true, force: true });
|
|
1813
|
+
send(res, 200, { ok: true, type: "dir" });
|
|
1814
|
+
} else {
|
|
1815
|
+
fs11.unlinkSync(resolved);
|
|
1816
|
+
send(res, 200, { ok: true, type: "file" });
|
|
2496
1817
|
}
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
sendError(res, 500, "Delete failed: " + err.message);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
__name(handleWorkspaceFileDelete, "handleWorkspaceFileDelete");
|
|
1823
|
+
async function handleWorkspaceUpload(req, res, query) {
|
|
1824
|
+
const targetDir = query.path;
|
|
1825
|
+
if (!targetDir) return sendError(res, 400, "Missing path parameter");
|
|
1826
|
+
const resolved = path12.resolve(targetDir.replace(/^~/, HOME2));
|
|
1827
|
+
if (!resolved.startsWith(HOME2)) return sendError(res, 403, "Access denied");
|
|
1828
|
+
if (!fs11.existsSync(resolved) || !fs11.statSync(resolved).isDirectory()) return sendError(res, 404, "Target directory not found");
|
|
1829
|
+
if (!(req.headers["content-type"] || "").includes("multipart/form-data")) return sendError(res, 400, "Expected multipart/form-data");
|
|
1830
|
+
let files;
|
|
1831
|
+
try {
|
|
1832
|
+
files = await parseMultipart(req);
|
|
1833
|
+
} catch (err) {
|
|
1834
|
+
return sendError(res, 400, "Invalid multipart data: " + err.message);
|
|
1835
|
+
}
|
|
1836
|
+
const uploaded = [];
|
|
1837
|
+
for (const { filename, data } of files) {
|
|
1838
|
+
if (!filename || !data.length) continue;
|
|
1839
|
+
const safeName = path12.basename(filename);
|
|
1840
|
+
let finalPath = path12.join(resolved, safeName);
|
|
1841
|
+
let counter = 1;
|
|
1842
|
+
while (fs11.existsSync(finalPath)) {
|
|
1843
|
+
const ext = path12.extname(safeName);
|
|
1844
|
+
finalPath = path12.join(resolved, `${path12.basename(safeName, ext)} (${counter++})${ext}`);
|
|
1845
|
+
}
|
|
1846
|
+
fs11.writeFileSync(finalPath, data);
|
|
1847
|
+
uploaded.push({ name: path12.basename(finalPath), size: data.length });
|
|
1848
|
+
}
|
|
1849
|
+
send(res, 200, { ok: true, uploaded });
|
|
1850
|
+
}
|
|
1851
|
+
__name(handleWorkspaceUpload, "handleWorkspaceUpload");
|
|
2497
1852
|
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
1853
|
+
// server/controllers/transcribe.js
|
|
1854
|
+
import fs12 from "node:fs";
|
|
1855
|
+
import path13 from "node:path";
|
|
1856
|
+
import os4 from "node:os";
|
|
1857
|
+
async function handleTranscribe(req, res) {
|
|
1858
|
+
try {
|
|
1859
|
+
const chunks = [];
|
|
1860
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
1861
|
+
const audioBuffer = Buffer.concat(chunks);
|
|
1862
|
+
if (audioBuffer.length === 0) return send(res, 400, { error: "No audio data" });
|
|
1863
|
+
if (audioBuffer.length > 25 * 1024 * 1024) return send(res, 400, { error: "Audio too large (max 25MB)" });
|
|
1864
|
+
let apiKey;
|
|
1865
|
+
try {
|
|
1866
|
+
const ocConfig = JSON.parse(fs12.readFileSync(path13.join(os4.homedir(), ".openclaw", "openclaw.json"), "utf8"));
|
|
1867
|
+
apiKey = ocConfig?.skills?.entries?.["openai-whisper-api"]?.apiKey;
|
|
1868
|
+
} catch {
|
|
2502
1869
|
}
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
1870
|
+
if (!apiKey) apiKey = process.env.OPENAI_API_KEY;
|
|
1871
|
+
if (!apiKey) return send(res, 500, { error: "No OpenAI API key configured" });
|
|
1872
|
+
const contentType = req.headers["content-type"] || "audio/webm";
|
|
1873
|
+
const ext = contentType.includes("wav") ? "wav" : contentType.includes("mp4") || contentType.includes("m4a") ? "m4a" : contentType.includes("ogg") ? "ogg" : "webm";
|
|
1874
|
+
const boundary = "----WhisperBoundary" + Date.now();
|
|
1875
|
+
const body = Buffer.concat([
|
|
1876
|
+
`--${boundary}\r
|
|
1877
|
+
Content-Disposition: form-data; name="file"; filename="audio.${ext}"\r
|
|
1878
|
+
Content-Type: ${contentType}\r
|
|
1879
|
+
\r
|
|
1880
|
+
`,
|
|
1881
|
+
audioBuffer,
|
|
1882
|
+
`\r
|
|
1883
|
+
--${boundary}\r
|
|
1884
|
+
Content-Disposition: form-data; name="model"\r
|
|
1885
|
+
\r
|
|
1886
|
+
whisper-1\r
|
|
1887
|
+
`,
|
|
1888
|
+
`--${boundary}\r
|
|
1889
|
+
Content-Disposition: form-data; name="response_format"\r
|
|
1890
|
+
\r
|
|
1891
|
+
json\r
|
|
1892
|
+
`,
|
|
1893
|
+
`--${boundary}--\r
|
|
1894
|
+
`
|
|
1895
|
+
].map((p) => typeof p === "string" ? Buffer.from(p) : p));
|
|
1896
|
+
const resp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
1897
|
+
method: "POST",
|
|
1898
|
+
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": `multipart/form-data; boundary=${boundary}` },
|
|
1899
|
+
body
|
|
1900
|
+
});
|
|
1901
|
+
if (!resp.ok) {
|
|
1902
|
+
console.error("Whisper API error:", resp.status, await resp.text());
|
|
1903
|
+
return send(res, 502, { error: `Whisper API error: ${resp.status}` });
|
|
2509
1904
|
}
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
1905
|
+
const result = await resp.json();
|
|
1906
|
+
return send(res, 200, { text: result.text || "" });
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
console.error("Transcribe error:", err);
|
|
1909
|
+
return send(res, 500, { error: err.message });
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
__name(handleTranscribe, "handleTranscribe");
|
|
1913
|
+
|
|
1914
|
+
// server/index.js
|
|
1915
|
+
var HOME3 = os5.homedir();
|
|
1916
|
+
var PORT = parseInt(process.env.PORT || "3001", 10);
|
|
1917
|
+
var __dirname2 = path14.dirname(fileURLToPath2(import.meta.url));
|
|
1918
|
+
var PLUGIN_DIR = path14.resolve(__dirname2, "..");
|
|
1919
|
+
function createApp(config = {}) {
|
|
1920
|
+
const DATA_DIR = config.dataDir || path14.join(PLUGIN_DIR, "data");
|
|
1921
|
+
const UPLOADS_DIR = config.uploadsDir || path14.join(PLUGIN_DIR, "uploads");
|
|
1922
|
+
const WORKSPACES_FILE = path14.join(DATA_DIR, "workspaces.json");
|
|
1923
|
+
const SETTINGS_FILE = path14.join(DATA_DIR, "settings.json");
|
|
1924
|
+
const INTELLIGENCE_DIR = path14.join(DATA_DIR, "intelligence");
|
|
1925
|
+
const authToken = config.authToken !== void 0 ? config.authToken : AUTH_TOKEN;
|
|
1926
|
+
const gatewayToken = config.gatewayToken !== void 0 ? config.gatewayToken : authToken;
|
|
1927
|
+
const gatewayUrl = config.gatewayUrl || GATEWAY_WS_URL;
|
|
1928
|
+
fs13.mkdirSync(DATA_DIR, { recursive: true });
|
|
1929
|
+
fs13.mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
1930
|
+
const dbCache = /* @__PURE__ */ new Map();
|
|
1931
|
+
function getDb(workspaceName) {
|
|
1932
|
+
if (dbCache.has(workspaceName)) return dbCache.get(workspaceName);
|
|
1933
|
+
const db = new Database(path14.join(DATA_DIR, `${workspaceName}.db`));
|
|
1934
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
1935
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
1936
|
+
migrate(db);
|
|
1937
|
+
dbCache.set(workspaceName, db);
|
|
1938
|
+
return db;
|
|
1939
|
+
}
|
|
1940
|
+
__name(getDb, "getDb");
|
|
1941
|
+
function getActiveDb() {
|
|
1942
|
+
return requestDbStore.getStore() || getDb(getWorkspaces().active);
|
|
1943
|
+
}
|
|
1944
|
+
__name(getActiveDb, "getActiveDb");
|
|
1945
|
+
function closeDb(name) {
|
|
1946
|
+
const db = dbCache.get(name);
|
|
1947
|
+
if (db) {
|
|
1948
|
+
db.close();
|
|
1949
|
+
dbCache.delete(name);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
__name(closeDb, "closeDb");
|
|
1953
|
+
function closeAll() {
|
|
1954
|
+
for (const db of dbCache.values()) db.close();
|
|
1955
|
+
dbCache.clear();
|
|
1956
|
+
globalDbCache.close?.();
|
|
1957
|
+
}
|
|
1958
|
+
__name(closeAll, "closeAll");
|
|
1959
|
+
let _globalDb = null;
|
|
1960
|
+
const globalDbCache = {
|
|
1961
|
+
get() {
|
|
1962
|
+
if (_globalDb) return _globalDb;
|
|
1963
|
+
_globalDb = new Database(path14.join(DATA_DIR, "global.db"));
|
|
1964
|
+
_globalDb.exec("PRAGMA journal_mode = WAL");
|
|
1965
|
+
_globalDb.exec(`CREATE TABLE IF NOT EXISTS custom_emojis (name TEXT NOT NULL, pack TEXT NOT NULL DEFAULT 'slackmojis', url TEXT NOT NULL, mime_type TEXT, created_at INTEGER DEFAULT (strftime('%s','now')), PRIMARY KEY (name, pack))`);
|
|
1966
|
+
return _globalDb;
|
|
1967
|
+
},
|
|
1968
|
+
close() {
|
|
1969
|
+
if (_globalDb) {
|
|
1970
|
+
_globalDb.close();
|
|
1971
|
+
_globalDb = null;
|
|
2524
1972
|
}
|
|
2525
1973
|
}
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
if (!wsData.workspaces[workspace]) return;
|
|
2536
|
-
const db = _getDb(workspace);
|
|
2537
|
-
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(threadId);
|
|
2538
|
-
if (!thread) return;
|
|
2539
|
-
const deleted = db.prepare('DELETE FROM unread_messages WHERE thread_id = ?').run(threadId);
|
|
2540
|
-
if (deleted.changes > 0) {
|
|
2541
|
-
syncThreadUnreadCount(db, threadId);
|
|
2542
|
-
const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
|
|
2543
|
-
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace, threadId, action: 'clear', unreadCount: 0, workspaceUnreadTotal, timestamp: Date.now() }));
|
|
2544
|
-
}
|
|
2545
|
-
} catch (e) { console.error('Failed to auto-clear unreads on active-thread:', e.message); }
|
|
1974
|
+
};
|
|
1975
|
+
let workspacesConfig = null;
|
|
1976
|
+
function getWorkspaces() {
|
|
1977
|
+
if (!workspacesConfig) {
|
|
1978
|
+
try {
|
|
1979
|
+
workspacesConfig = JSON.parse(fs13.readFileSync(WORKSPACES_FILE, "utf8"));
|
|
1980
|
+
} catch {
|
|
1981
|
+
workspacesConfig = { active: "default", workspaces: { default: { name: "default", label: "Default", createdAt: Date.now() } } };
|
|
1982
|
+
fs13.writeFileSync(WORKSPACES_FILE, JSON.stringify(workspacesConfig, null, 2));
|
|
2546
1983
|
}
|
|
2547
1984
|
}
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
1985
|
+
return workspacesConfig;
|
|
1986
|
+
}
|
|
1987
|
+
__name(getWorkspaces, "getWorkspaces");
|
|
1988
|
+
function setWorkspaces(data) {
|
|
1989
|
+
workspacesConfig = data;
|
|
1990
|
+
fs13.writeFileSync(WORKSPACES_FILE, JSON.stringify(data, null, 2));
|
|
1991
|
+
}
|
|
1992
|
+
__name(setWorkspaces, "setWorkspaces");
|
|
1993
|
+
const debugLogger = new DebugLogger(DATA_DIR);
|
|
1994
|
+
const mediaStash = /* @__PURE__ */ new Map();
|
|
1995
|
+
const memoryConfig = discoverMemoryConfig();
|
|
1996
|
+
const memoryProvider = createMemoryProvider(memoryConfig);
|
|
1997
|
+
memoryProvider.init().catch((err) => console.error("[createApp] Memory provider init error:", err.message));
|
|
1998
|
+
const MEMORY_FILES_DIR = path14.join(memoryConfig.workspaceDir, "memory");
|
|
1999
|
+
const gatewayClient = new GatewayClient({ getDb, getWorkspaces, dataDir: DATA_DIR, debugLogger, gatewayWsUrl: gatewayUrl, authToken: gatewayToken, mediaStash });
|
|
2000
|
+
const broadcast = /* @__PURE__ */ __name((msg) => gatewayClient.broadcastToBrowsers(msg), "broadcast");
|
|
2001
|
+
const workspaces = new WorkspaceController({ getDb, closeDb, getWorkspaces, setWorkspaces, dataDir: DATA_DIR, broadcast });
|
|
2002
|
+
const threads = new ThreadController({ getActiveDb, getWorkspaces, uploadsDir: UPLOADS_DIR, broadcast });
|
|
2003
|
+
const messages = new MessageController({ getActiveDb, getWorkspaces, broadcast });
|
|
2004
|
+
const files = new FileController({ getActiveDb, getWorkspaces, uploadsDir: UPLOADS_DIR, intelligenceDir: INTELLIGENCE_DIR });
|
|
2005
|
+
const memory = new MemoryController({ memoryProvider, memoryFilesDir: MEMORY_FILES_DIR, memoryConfig });
|
|
2006
|
+
function handleGetSettings(req, res) {
|
|
2555
2007
|
try {
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2008
|
+
send(res, 200, fs13.existsSync(SETTINGS_FILE) ? JSON.parse(fs13.readFileSync(SETTINGS_FILE, "utf8")) : {});
|
|
2009
|
+
} catch {
|
|
2010
|
+
send(res, 200, {});
|
|
2011
|
+
}
|
|
2559
2012
|
}
|
|
2560
|
-
|
|
2561
|
-
async function
|
|
2013
|
+
__name(handleGetSettings, "handleGetSettings");
|
|
2014
|
+
async function handleSaveSettings(req, res) {
|
|
2562
2015
|
const body = await parseBody(req);
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2016
|
+
fs13.mkdirSync(path14.dirname(SETTINGS_FILE), { recursive: true });
|
|
2017
|
+
fs13.writeFileSync(SETTINGS_FILE, JSON.stringify(body, null, 2));
|
|
2018
|
+
send(res, 200, { ok: true });
|
|
2566
2019
|
}
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2020
|
+
__name(handleSaveSettings, "handleSaveSettings");
|
|
2021
|
+
function checkAuth(req, res) {
|
|
2022
|
+
if (!authToken) return true;
|
|
2023
|
+
const auth = req.headers.authorization;
|
|
2024
|
+
if (!auth?.startsWith("Bearer ")) {
|
|
2025
|
+
sendError(res, 401, "Missing or invalid Authorization header");
|
|
2026
|
+
return false;
|
|
2027
|
+
}
|
|
2028
|
+
if (auth.slice(7) !== authToken) {
|
|
2029
|
+
sendError(res, 401, "Invalid auth token");
|
|
2030
|
+
return false;
|
|
2031
|
+
}
|
|
2032
|
+
return true;
|
|
2579
2033
|
}
|
|
2580
|
-
|
|
2581
|
-
async function
|
|
2582
|
-
const
|
|
2034
|
+
__name(checkAuth, "checkAuth");
|
|
2035
|
+
async function handleRequest(req, res) {
|
|
2036
|
+
const wsName = req.headers?.["x-workspace"];
|
|
2037
|
+
const db = wsName ? getDb(wsName) : getActiveDb();
|
|
2038
|
+
return requestDbStore.run(db, () => route(req, res));
|
|
2039
|
+
}
|
|
2040
|
+
__name(handleRequest, "handleRequest");
|
|
2041
|
+
async function route(req, res) {
|
|
2042
|
+
const [urlPath, queryString] = (req.url || "/").split("?");
|
|
2583
2043
|
const query = {};
|
|
2584
|
-
if (queryString) {
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '');
|
|
2588
|
-
}
|
|
2044
|
+
if (queryString) for (const pair of queryString.split("&")) {
|
|
2045
|
+
const [k, v] = pair.split("=");
|
|
2046
|
+
if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || "");
|
|
2589
2047
|
}
|
|
2590
2048
|
const method = req.method;
|
|
2591
|
-
|
|
2592
|
-
if (method ===
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
const
|
|
2599
|
-
const
|
|
2600
|
-
const
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2049
|
+
let p;
|
|
2050
|
+
if (method === "OPTIONS") {
|
|
2051
|
+
setCors(res);
|
|
2052
|
+
res.writeHead(204);
|
|
2053
|
+
return res.end();
|
|
2054
|
+
}
|
|
2055
|
+
if (method === "GET" && !urlPath.startsWith("/api/")) {
|
|
2056
|
+
const STATIC = { "/": "index.html", "/index.html": "index.html", "/app.js": "app.js", "/style.css": "style.css", "/error-handler.js": "error-handler.js", "/manifest.json": "manifest.json", "/favicon.ico": "favicon.ico" };
|
|
2057
|
+
const fileName = STATIC[urlPath];
|
|
2058
|
+
const isAllowed = fileName || urlPath.startsWith("/icons/") || urlPath.startsWith("/lib/") || urlPath.startsWith("/frontend/") || urlPath.startsWith("/emoji/") || urlPath === "/config.js";
|
|
2059
|
+
if (isAllowed) {
|
|
2060
|
+
const staticPath = path14.join(PLUGIN_DIR, fileName || urlPath.slice(1));
|
|
2061
|
+
if (fs13.existsSync(staticPath) && fs13.statSync(staticPath).isFile()) {
|
|
2062
|
+
const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".ico": "image/x-icon", ".png": "image/png", ".svg": "image/svg+xml", ".gif": "image/gif", ".webp": "image/webp" };
|
|
2063
|
+
const ext = path14.extname(staticPath).toLowerCase();
|
|
2064
|
+
const stat = fs13.statSync(staticPath);
|
|
2065
|
+
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Content-Length": stat.size, "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=3600" });
|
|
2066
|
+
return fs13.createReadStream(staticPath).pipe(res);
|
|
2067
|
+
}
|
|
2610
2068
|
}
|
|
2611
2069
|
}
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) return files.serveUpload(req, res, p);
|
|
2615
|
-
|
|
2616
|
-
// Custom emoji listing (no auth)
|
|
2617
|
-
if (method === 'GET' && urlPath === '/api/emoji') {
|
|
2070
|
+
if (p = matchRoute(method, urlPath, "GET /api/uploads/:threadId/:fileId")) return files.serveUpload(req, res, p);
|
|
2071
|
+
if (method === "GET" && urlPath === "/api/emoji") {
|
|
2618
2072
|
try {
|
|
2619
|
-
const
|
|
2620
|
-
|
|
2621
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
|
|
2073
|
+
const rows = globalDbCache.get().prepare("SELECT name, pack, url, mime_type FROM custom_emojis ORDER BY created_at DESC").all();
|
|
2074
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=300" });
|
|
2622
2075
|
return res.end(JSON.stringify(rows));
|
|
2623
|
-
} catch (e) {
|
|
2076
|
+
} catch (e) {
|
|
2077
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2078
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
2079
|
+
}
|
|
2624
2080
|
}
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2081
|
+
if (method === "GET" && urlPath === "/api/emoji/search") {
|
|
2082
|
+
const q = query.q || "";
|
|
2083
|
+
if (!q) {
|
|
2084
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2085
|
+
return res.end(JSON.stringify({ error: "Missing ?q=" }));
|
|
2086
|
+
}
|
|
2630
2087
|
try {
|
|
2631
|
-
const https = await import(
|
|
2088
|
+
const https = await import("https");
|
|
2632
2089
|
const html = await new Promise((resolve, reject) => {
|
|
2633
2090
|
https.default.get(`https://slackmojis.com/emojis/search?query=${encodeURIComponent(q)}`, (resp) => {
|
|
2634
|
-
let body =
|
|
2635
|
-
|
|
2091
|
+
let body = "";
|
|
2092
|
+
resp.on("data", (c) => body += c);
|
|
2093
|
+
resp.on("end", () => resolve(body));
|
|
2094
|
+
}).on("error", reject);
|
|
2636
2095
|
});
|
|
2637
2096
|
const results = [];
|
|
2638
2097
|
const regex = /data-emoji-id-name="([^"]+)"[^>]*href="([^"]+)"[\s\S]*?<img[^>]*src="([^"]+)"/g;
|
|
2639
2098
|
let match;
|
|
2640
|
-
while ((match = regex.exec(html)) !== null && results.length < 50) {
|
|
2641
|
-
|
|
2642
|
-
results.push({ name, image_url: match[3], download_url: `https://slackmojis.com${match[2]}` });
|
|
2643
|
-
}
|
|
2644
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2099
|
+
while ((match = regex.exec(html)) !== null && results.length < 50) results.push({ name: match[1].replace(/^\d+-/, ""), image_url: match[3], download_url: `https://slackmojis.com${match[2]}` });
|
|
2100
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2645
2101
|
return res.end(JSON.stringify(results));
|
|
2646
|
-
} catch (e) {
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2104
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
2105
|
+
}
|
|
2647
2106
|
}
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
// Add custom emoji (auth required)
|
|
2652
|
-
if (method === 'POST' && urlPath === '/api/emoji/add') {
|
|
2653
|
-
try {
|
|
2107
|
+
if (!checkAuth(req, res)) return;
|
|
2108
|
+
try {
|
|
2109
|
+
if (method === "POST" && urlPath === "/api/emoji/add") {
|
|
2654
2110
|
const { url, name, pack } = await parseBody(req);
|
|
2655
|
-
if (!url || !name) {
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
db.prepare('INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)').run(safeName, targetPack, url, mimeType);
|
|
2665
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2111
|
+
if (!url || !name) {
|
|
2112
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2113
|
+
return res.end(JSON.stringify({ error: "Missing url or name" }));
|
|
2114
|
+
}
|
|
2115
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2116
|
+
const targetPack = pack || "slackmojis";
|
|
2117
|
+
const mimeType = url.toLowerCase().endsWith(".gif") ? "image/gif" : url.toLowerCase().endsWith(".webp") ? "image/webp" : url.toLowerCase().match(/\.jpe?g/) ? "image/jpeg" : "image/png";
|
|
2118
|
+
globalDbCache.get().prepare("INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)").run(safeName, targetPack, url, mimeType);
|
|
2119
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2666
2120
|
return res.end(JSON.stringify({ name: safeName, pack: targetPack, url, mime_type: mimeType }));
|
|
2667
|
-
}
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
// Delete custom emoji (auth required)
|
|
2671
|
-
if (method === 'DELETE' && urlPath === '/api/emoji') {
|
|
2672
|
-
try {
|
|
2121
|
+
}
|
|
2122
|
+
if (method === "DELETE" && urlPath === "/api/emoji") {
|
|
2673
2123
|
const { name, pack } = await parseBody(req);
|
|
2674
|
-
if (!name || !pack) {
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2124
|
+
if (!name || !pack) {
|
|
2125
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2126
|
+
return res.end(JSON.stringify({ error: "Missing name or pack" }));
|
|
2127
|
+
}
|
|
2128
|
+
globalDbCache.get().prepare("DELETE FROM custom_emojis WHERE name = ? AND pack = ?").run(name, pack);
|
|
2129
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2678
2130
|
return res.end(JSON.stringify({ ok: true }));
|
|
2679
|
-
}
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
if (method ===
|
|
2684
|
-
if (method ===
|
|
2685
|
-
if (method ===
|
|
2686
|
-
if (method ===
|
|
2687
|
-
if (method ===
|
|
2688
|
-
if (method ===
|
|
2689
|
-
if (method ===
|
|
2690
|
-
if (
|
|
2691
|
-
if (
|
|
2692
|
-
if (method ===
|
|
2693
|
-
if (
|
|
2694
|
-
if (
|
|
2695
|
-
if (method ===
|
|
2696
|
-
if (method ===
|
|
2697
|
-
if (method === 'POST' && urlPath === '/api/transcribe') return await handleTranscribe(req, res);
|
|
2698
|
-
if (method === 'GET' && urlPath === '/api/agents') {
|
|
2131
|
+
}
|
|
2132
|
+
if (method === "GET" && urlPath === "/api/file") return handleServeFile(req, res, query, memoryConfig);
|
|
2133
|
+
if (method === "GET" && urlPath === "/api/workspace") return handleWorkspaceList(req, res, query);
|
|
2134
|
+
if (method === "GET" && urlPath === "/api/workspace/file") return handleWorkspaceFileRead(req, res, query);
|
|
2135
|
+
if (method === "PUT" && urlPath === "/api/workspace/file") return await handleWorkspaceFileWrite(req, res, query);
|
|
2136
|
+
if (method === "DELETE" && urlPath === "/api/workspace/file") return handleWorkspaceFileDelete(req, res, query);
|
|
2137
|
+
if (method === "POST" && urlPath === "/api/workspace/upload") return await handleWorkspaceUpload(req, res, query);
|
|
2138
|
+
if (method === "GET" && urlPath === "/api/memory/status") return await memory.status(req, res);
|
|
2139
|
+
if (method === "GET" && urlPath === "/api/memory/list") return await memory.list(req, res, query);
|
|
2140
|
+
if (method === "GET" && urlPath === "/api/memory/search") return await memory.search(req, res, query);
|
|
2141
|
+
if (method === "GET" && urlPath === "/api/memory/files") return memory.files(req, res, query);
|
|
2142
|
+
if (p = matchRoute(method, urlPath, "PUT /api/memory/:id")) return await memory.update(req, res, p);
|
|
2143
|
+
if (p = matchRoute(method, urlPath, "DELETE /api/memory/:id")) return await memory.delete(req, res, p);
|
|
2144
|
+
if (method === "GET" && urlPath === "/api/settings") return handleGetSettings(req, res);
|
|
2145
|
+
if (method === "PUT" && urlPath === "/api/settings") return await handleSaveSettings(req, res);
|
|
2146
|
+
if (method === "POST" && urlPath === "/api/transcribe") return await handleTranscribe(req, res);
|
|
2147
|
+
if (method === "GET" && urlPath === "/api/health") return send(res, 200, { ok: true, workspace: getWorkspaces().active, uptime: process.uptime() });
|
|
2148
|
+
if (method === "GET" && urlPath === "/api/agents") {
|
|
2699
2149
|
try {
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2150
|
+
send(res, 200, { agents: fs13.readdirSync(path14.join(HOME3, ".openclaw", "agents"), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) });
|
|
2151
|
+
} catch {
|
|
2152
|
+
send(res, 200, { agents: ["main"] });
|
|
2153
|
+
}
|
|
2154
|
+
return;
|
|
2705
2155
|
}
|
|
2706
|
-
if (method ===
|
|
2707
|
-
if (method ===
|
|
2708
|
-
if (
|
|
2709
|
-
if (
|
|
2710
|
-
if (method ===
|
|
2711
|
-
if (
|
|
2712
|
-
if (method ===
|
|
2713
|
-
if (method ===
|
|
2714
|
-
if (
|
|
2715
|
-
if (
|
|
2716
|
-
if (
|
|
2717
|
-
if (
|
|
2718
|
-
if (
|
|
2719
|
-
if (
|
|
2720
|
-
if (
|
|
2721
|
-
const db =
|
|
2722
|
-
const thread = db.prepare(
|
|
2723
|
-
if (!thread) return sendError(res, 404,
|
|
2724
|
-
|
|
2725
|
-
const activeWs = _getWorkspaces().active;
|
|
2726
|
-
_gatewayClient.generateThreadTitle(db, p.id, activeWs);
|
|
2156
|
+
if (method === "GET" && urlPath === "/api/workspaces") return workspaces.getAll(req, res);
|
|
2157
|
+
if (method === "POST" && urlPath === "/api/workspaces") return await workspaces.create(req, res);
|
|
2158
|
+
if (p = matchRoute(method, urlPath, "PATCH /api/workspaces/:name")) return await workspaces.update(req, res, p);
|
|
2159
|
+
if (p = matchRoute(method, urlPath, "DELETE /api/workspaces/:name")) return workspaces.delete(req, res, p);
|
|
2160
|
+
if (method === "POST" && urlPath === "/api/workspaces/reorder") return await workspaces.reorder(req, res);
|
|
2161
|
+
if (p = matchRoute(method, urlPath, "POST /api/workspaces/:name/activate")) return workspaces.activate(req, res, p);
|
|
2162
|
+
if (method === "GET" && urlPath === "/api/threads") return threads.getAll(req, res, {}, query);
|
|
2163
|
+
if (method === "GET" && urlPath === "/api/threads/unread") return threads.getUnread(req, res);
|
|
2164
|
+
if (method === "POST" && urlPath === "/api/threads") return await threads.create(req, res);
|
|
2165
|
+
if (p = matchRoute(method, urlPath, "POST /api/threads/:id/mark-read")) return await threads.markRead(req, res, p);
|
|
2166
|
+
if (p = matchRoute(method, urlPath, "GET /api/threads/:id/messages")) return messages.getAll(req, res, p, query);
|
|
2167
|
+
if (p = matchRoute(method, urlPath, "POST /api/threads/:id/messages")) return await messages.create(req, res, p);
|
|
2168
|
+
if (p = matchRoute(method, urlPath, "DELETE /api/threads/:id/messages/:messageId")) return messages.delete(req, res, p);
|
|
2169
|
+
if (p = matchRoute(method, urlPath, "POST /api/threads/:id/context-fill")) return messages.contextFill(req, res, p);
|
|
2170
|
+
if (p = matchRoute(method, urlPath, "POST /api/threads/:id/generate-title")) {
|
|
2171
|
+
const db = getActiveDb();
|
|
2172
|
+
const thread = db.prepare("SELECT * FROM threads WHERE id = ?").get(p.id);
|
|
2173
|
+
if (!thread) return sendError(res, 404, "Thread not found");
|
|
2174
|
+
gatewayClient.generateThreadTitle(db, p.id, getWorkspaces().active);
|
|
2727
2175
|
return send(res, 200, { ok: true });
|
|
2728
2176
|
}
|
|
2729
|
-
if (
|
|
2730
|
-
if (
|
|
2731
|
-
if (
|
|
2732
|
-
if (
|
|
2733
|
-
if (
|
|
2734
|
-
if (
|
|
2735
|
-
if (method ===
|
|
2736
|
-
if (method ===
|
|
2737
|
-
if (method ===
|
|
2738
|
-
if (method ===
|
|
2177
|
+
if (p = matchRoute(method, urlPath, "POST /api/threads/:id/upload")) return await files.upload(req, res, p);
|
|
2178
|
+
if (p = matchRoute(method, urlPath, "GET /api/threads/:id/intelligence")) return files.getIntelligence(req, res, p);
|
|
2179
|
+
if (p = matchRoute(method, urlPath, "POST /api/threads/:id/intelligence")) return await files.saveIntelligence(req, res, p);
|
|
2180
|
+
if (p = matchRoute(method, urlPath, "GET /api/threads/:id")) return threads.get(req, res, p);
|
|
2181
|
+
if (p = matchRoute(method, urlPath, "PATCH /api/threads/:id")) return await threads.update(req, res, p);
|
|
2182
|
+
if (p = matchRoute(method, urlPath, "DELETE /api/threads/:id")) return threads.delete(req, res, p);
|
|
2183
|
+
if (method === "GET" && urlPath === "/api/search") return messages.search(req, res, {}, query);
|
|
2184
|
+
if (method === "GET" && urlPath === "/api/export") return messages.export(req, res);
|
|
2185
|
+
if (method === "POST" && urlPath === "/api/import") return await messages.import(req, res);
|
|
2186
|
+
if (method === "POST" && urlPath === "/api/active-thread") {
|
|
2739
2187
|
const body = await parseBody(req);
|
|
2740
|
-
|
|
2741
|
-
if (threadId && workspace) _gatewayClient.setActiveThread(null, workspace, threadId);
|
|
2188
|
+
if (body.threadId && body.workspace) gatewayClient.setActiveThread(null, body.workspace, body.threadId);
|
|
2742
2189
|
return send(res, 200, { ok: true });
|
|
2743
2190
|
}
|
|
2744
|
-
if (method === 'GET' && urlPath === '/api/health') return send(res, 200, { ok: true, workspace: _getWorkspaces().active, uptime: process.uptime() });
|
|
2745
2191
|
sendError(res, 404, `Not found: ${method} ${urlPath}`);
|
|
2746
2192
|
} catch (err) {
|
|
2747
2193
|
console.error(`Error handling ${method} ${urlPath}:`, err);
|
|
2748
|
-
if (err.message
|
|
2749
|
-
else sendError(res, 500, err.message ||
|
|
2194
|
+
if (err.message?.includes("UNIQUE constraint")) sendError(res, 409, "Conflict: " + err.message);
|
|
2195
|
+
else sendError(res, 500, err.message || "Internal server error");
|
|
2750
2196
|
}
|
|
2751
2197
|
}
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: { nonce, ts: Date.now() } }));
|
|
2760
|
-
|
|
2761
|
-
ws.on('message', (data) => {
|
|
2198
|
+
__name(route, "route");
|
|
2199
|
+
function setupBrowserWs(wss) {
|
|
2200
|
+
wss.on("connection", (ws) => {
|
|
2201
|
+
console.log("Browser client connected");
|
|
2202
|
+
gatewayClient.addBrowserClient(ws);
|
|
2203
|
+
ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: uuid(), ts: Date.now() } }));
|
|
2204
|
+
ws.on("message", async (data) => {
|
|
2762
2205
|
const msgStr = data.toString();
|
|
2763
|
-
|
|
2206
|
+
debugLogger.logFrame("BR\u2192SRV", msgStr);
|
|
2764
2207
|
let msgToForward = msgStr;
|
|
2765
2208
|
try {
|
|
2766
2209
|
const msg = JSON.parse(msgStr);
|
|
2767
|
-
if (msg.type ===
|
|
2210
|
+
if (msg.type === "req" && msg.method === "connect") {
|
|
2768
2211
|
const token = msg.params?.auth?.token;
|
|
2769
|
-
if (token ===
|
|
2770
|
-
|
|
2771
|
-
ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: true, payload: { type: 'hello-ok', protocol: 3, server: { version: '0.1.0', host: 'clawchats-backend' } } }));
|
|
2212
|
+
if (token === authToken || !authToken) {
|
|
2213
|
+
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: { type: "hello-ok", protocol: 3, server: { version: "0.1.0", host: "clawchats-backend" } } }));
|
|
2772
2214
|
} else {
|
|
2773
|
-
|
|
2774
|
-
ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: false, error: { code: 'AUTH_FAILED', message: 'Invalid auth token' } }));
|
|
2215
|
+
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: false, error: { code: "AUTH_FAILED", message: "Invalid auth token" } }));
|
|
2775
2216
|
ws.close();
|
|
2776
2217
|
}
|
|
2777
2218
|
return;
|
|
2778
2219
|
}
|
|
2779
|
-
if (msg.type ===
|
|
2780
|
-
if (msg.action ===
|
|
2781
|
-
|
|
2782
|
-
|
|
2220
|
+
if (msg.type === "clawchats" || msg.type === "shellchat") {
|
|
2221
|
+
if (msg.action === "active-thread") {
|
|
2222
|
+
gatewayClient.setActiveThread(ws, msg.workspace, msg.threadId);
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
if (msg.action === "debug-start") {
|
|
2226
|
+
const r = debugLogger.start(msg.ts, ws);
|
|
2227
|
+
ws.send(JSON.stringify(r.error === "already-active" ? { type: "clawchats", event: "debug-error", error: "Recording already active in another tab", sessionId: r.sessionId } : { type: "clawchats", event: "debug-started", sessionId: r.sessionId }));
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
if (msg.action === "debug-dump") {
|
|
2231
|
+
const r = debugLogger.saveDump(msg);
|
|
2232
|
+
ws.send(JSON.stringify({ type: "clawchats", event: "debug-saved", sessionId: r.sessionId, files: r.files }));
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2783
2235
|
}
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
const
|
|
2787
|
-
const
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
const extMap = { jpeg: 'jpg', jpg: 'jpg', png: 'png', gif: 'gif', webp: 'webp', pdf: 'pdf', 'svg+xml': 'svg', mp3: 'mp3', mp4: 'mp4', wav: 'wav', webm: 'webm' };
|
|
2236
|
+
if (msg.type === "req" && msg.method === "chat.send" && msg.params?.attachments?.length > 0) {
|
|
2237
|
+
const parsed = parseSessionKey(msg.params.sessionKey || "");
|
|
2238
|
+
const threadId = parsed?.threadId || "misc";
|
|
2239
|
+
const uploadDir = path14.join(UPLOADS_DIR, threadId);
|
|
2240
|
+
fs13.mkdirSync(uploadDir, { recursive: true });
|
|
2241
|
+
const extMap = { jpeg: "jpg", jpg: "jpg", png: "png", gif: "gif", webp: "webp", pdf: "pdf", "svg+xml": "svg", mp3: "mp3", mp4: "mp4", wav: "wav", webm: "webm" };
|
|
2791
2242
|
const savedPaths = [];
|
|
2792
2243
|
for (const att of msg.params.attachments) {
|
|
2793
2244
|
if (!att.content || !att.mimeType) continue;
|
|
2794
2245
|
try {
|
|
2795
|
-
const rawExt = att.mimeType.split(
|
|
2796
|
-
const
|
|
2797
|
-
|
|
2798
|
-
const filePath = path.join(uploadDir, `${fileId}.${ext}`);
|
|
2799
|
-
fs.writeFileSync(filePath, Buffer.from(att.content, 'base64'));
|
|
2246
|
+
const rawExt = att.mimeType.split("/")[1]?.split(";")[0] || "bin";
|
|
2247
|
+
const filePath = path14.join(uploadDir, `${Date.now()}_${Math.random().toString(36).slice(2, 6)}.${extMap[rawExt] || rawExt}`);
|
|
2248
|
+
fs13.writeFileSync(filePath, Buffer.from(att.content, "base64"));
|
|
2800
2249
|
savedPaths.push(filePath);
|
|
2801
|
-
console.log(`[upload] Saved attachment to ${filePath}`);
|
|
2802
2250
|
} catch (err) {
|
|
2803
|
-
console.error(
|
|
2251
|
+
console.error("[upload] Failed to save attachment:", err.message);
|
|
2804
2252
|
}
|
|
2805
2253
|
}
|
|
2806
2254
|
if (savedPaths.length > 0) {
|
|
2807
|
-
const
|
|
2808
|
-
|
|
2809
|
-
|
|
2255
|
+
const note = `
|
|
2256
|
+
|
|
2257
|
+
[${savedPaths.length === 1 ? "Attached file saved on disk" : "Attached files saved on disk"}:
|
|
2258
|
+
${savedPaths.map((p) => `- ${p}`).join("\n")}]`;
|
|
2259
|
+
msgToForward = JSON.stringify({ ...msg, params: { ...msg.params, message: (msg.params.message || "") + note } });
|
|
2810
2260
|
}
|
|
2811
2261
|
}
|
|
2812
|
-
} catch {
|
|
2813
|
-
|
|
2262
|
+
} catch {
|
|
2263
|
+
}
|
|
2264
|
+
gatewayClient.sendToGateway(msgToForward);
|
|
2814
2265
|
});
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2266
|
+
ws.on("close", () => {
|
|
2267
|
+
console.log("Browser client disconnected");
|
|
2268
|
+
debugLogger.handleClientDisconnect(ws);
|
|
2269
|
+
gatewayClient.removeBrowserClient(ws);
|
|
2270
|
+
});
|
|
2271
|
+
ws.on("error", (err) => console.error("Browser WebSocket error:", err.message));
|
|
2818
2272
|
});
|
|
2819
2273
|
}
|
|
2820
|
-
|
|
2821
|
-
// ── Public API ─────────────────────────────────────────────────────────────
|
|
2822
|
-
_ensureDirs();
|
|
2823
|
-
|
|
2274
|
+
__name(setupBrowserWs, "setupBrowserWs");
|
|
2824
2275
|
return {
|
|
2825
|
-
handleRequest
|
|
2826
|
-
getDb
|
|
2827
|
-
getActiveDb
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
dataDir: _DATA_DIR,
|
|
2276
|
+
handleRequest,
|
|
2277
|
+
getDb,
|
|
2278
|
+
getActiveDb,
|
|
2279
|
+
getWorkspaces,
|
|
2280
|
+
setWorkspaces,
|
|
2281
|
+
shutdown: closeAll,
|
|
2282
|
+
closeAllDbs: closeAll,
|
|
2283
|
+
gatewayClient,
|
|
2284
|
+
setupBrowserWs,
|
|
2285
|
+
dataDir: DATA_DIR
|
|
2836
2286
|
};
|
|
2837
2287
|
}
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2288
|
+
__name(createApp, "createApp");
|
|
2289
|
+
function migrate(db) {
|
|
2290
|
+
db.exec(`
|
|
2291
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
2292
|
+
id TEXT PRIMARY KEY, session_key TEXT UNIQUE NOT NULL, title TEXT DEFAULT 'New chat',
|
|
2293
|
+
pinned INTEGER DEFAULT 0, pin_order INTEGER DEFAULT 0, model TEXT,
|
|
2294
|
+
last_session_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
2295
|
+
);
|
|
2296
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
2297
|
+
id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL,
|
|
2298
|
+
status TEXT DEFAULT 'sent', metadata TEXT, seq INTEGER, timestamp INTEGER NOT NULL, created_at INTEGER NOT NULL,
|
|
2299
|
+
FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
|
2300
|
+
);
|
|
2301
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, timestamp);
|
|
2302
|
+
CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages(thread_id, role, timestamp);
|
|
2303
|
+
`);
|
|
2304
|
+
try {
|
|
2305
|
+
db.exec("ALTER TABLE threads ADD COLUMN sort_order INTEGER DEFAULT 0");
|
|
2306
|
+
} catch {
|
|
2307
|
+
}
|
|
2308
|
+
try {
|
|
2309
|
+
db.exec("ALTER TABLE threads ADD COLUMN unread_count INTEGER DEFAULT 0");
|
|
2310
|
+
} catch {
|
|
2311
|
+
}
|
|
2312
|
+
db.exec(`CREATE TABLE IF NOT EXISTS unread_messages (thread_id TEXT NOT NULL, message_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (thread_id, message_id), FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE)`);
|
|
2313
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id)");
|
|
2314
|
+
ensureFts(db);
|
|
2315
|
+
}
|
|
2316
|
+
__name(migrate, "migrate");
|
|
2317
|
+
function createFts(db) {
|
|
2318
|
+
db.exec(`CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='porter unicode61 tokenchars x27')`);
|
|
2319
|
+
db.exec(`CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content); END`);
|
|
2320
|
+
db.exec(`CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content); END`);
|
|
2321
|
+
db.exec(`CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.rowid, old.content); INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content); END`);
|
|
2322
|
+
}
|
|
2323
|
+
__name(createFts, "createFts");
|
|
2324
|
+
function dropFts(db) {
|
|
2325
|
+
db.exec("DROP TABLE IF EXISTS messages_fts; DROP TRIGGER IF EXISTS messages_ai; DROP TRIGGER IF EXISTS messages_ad; DROP TRIGGER IF EXISTS messages_au;");
|
|
2326
|
+
}
|
|
2327
|
+
__name(dropFts, "dropFts");
|
|
2328
|
+
function ensureFts(db) {
|
|
2329
|
+
const hasFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
|
|
2330
|
+
if (!hasFts) {
|
|
2331
|
+
try {
|
|
2332
|
+
createFts(db);
|
|
2333
|
+
db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
|
|
2334
|
+
} catch (e) {
|
|
2335
|
+
console.error("[DB] messages_fts creation failed:", e.message);
|
|
2336
|
+
}
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
|
|
2340
|
+
if (schema && !schema.sql.includes("tokenchars")) {
|
|
2341
|
+
console.log("[DB] Upgrading messages_fts tokenizer...");
|
|
2342
|
+
try {
|
|
2343
|
+
dropFts(db);
|
|
2344
|
+
createFts(db);
|
|
2345
|
+
db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
|
|
2346
|
+
console.log("[DB] Upgrade complete");
|
|
2347
|
+
} catch (e) {
|
|
2348
|
+
console.error("[DB] Upgrade failed:", e.message);
|
|
2349
|
+
dropFts(db);
|
|
2350
|
+
}
|
|
2351
|
+
} else {
|
|
2352
|
+
try {
|
|
2353
|
+
db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('integrity-check')").run();
|
|
2354
|
+
} catch {
|
|
2355
|
+
try {
|
|
2356
|
+
db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run();
|
|
2357
|
+
} catch (e) {
|
|
2358
|
+
console.error("[DB] FTS rebuild failed:", e.message);
|
|
2359
|
+
dropFts(db);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
__name(ensureFts, "ensureFts");
|
|
2365
|
+
var isDirectRun = import.meta.url === `file://${process.argv[1]}`;
|
|
2842
2366
|
if (isDirectRun) {
|
|
2843
2367
|
const app = createApp();
|
|
2844
|
-
app.getActiveDb();
|
|
2845
|
-
|
|
2368
|
+
app.getActiveDb();
|
|
2846
2369
|
const server = http.createServer(app.handleRequest);
|
|
2847
|
-
|
|
2848
|
-
// ─── Browser WebSocket Server ───────────────────────────────────────────────
|
|
2849
|
-
|
|
2850
2370
|
const wss = new WebSocketServer({ noServer: true });
|
|
2851
2371
|
app.setupBrowserWs(wss);
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
server.on('upgrade', (req, socket, head) => {
|
|
2855
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2856
|
-
wss.emit('connection', ws, req);
|
|
2857
|
-
});
|
|
2372
|
+
server.on("upgrade", (req, socket, head) => {
|
|
2373
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
|
|
2858
2374
|
});
|
|
2859
|
-
|
|
2860
2375
|
server.listen(PORT, () => {
|
|
2861
2376
|
console.log(`ClawChats backend listening on port ${PORT}`);
|
|
2862
2377
|
console.log(`Active workspace: ${app.getWorkspaces().active}`);
|
|
2863
2378
|
console.log(`Data dir: ${app.dataDir}`);
|
|
2864
|
-
|
|
2865
|
-
// Connect to gateway
|
|
2866
2379
|
app.gatewayClient.connect();
|
|
2867
|
-
|
|
2868
|
-
// Initialize global DB (custom emojis, etc.)
|
|
2869
|
-
getGlobalDb(DATA_DIR);
|
|
2870
2380
|
});
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
function shutdown() {
|
|
2874
|
-
console.log('Shutting down...');
|
|
2381
|
+
const shutdown = /* @__PURE__ */ __name(() => {
|
|
2382
|
+
console.log("Shutting down...");
|
|
2875
2383
|
app.shutdown();
|
|
2876
2384
|
server.close(() => process.exit(0));
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
process.on(
|
|
2881
|
-
|
|
2882
|
-
|
|
2385
|
+
setTimeout(() => process.exit(1), 5e3);
|
|
2386
|
+
}, "shutdown");
|
|
2387
|
+
process.on("SIGTERM", shutdown);
|
|
2388
|
+
process.on("SIGINT", shutdown);
|
|
2389
|
+
}
|
|
2390
|
+
export {
|
|
2391
|
+
createApp
|
|
2392
|
+
};
|