@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/index.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { WebSocket as WS, WebSocketServer } from 'ws';
|
|
7
|
+
|
|
8
|
+
import { Database, requestDbStore } from './bootstrap/native.js';
|
|
9
|
+
import { GATEWAY_WS_URL, AUTH_TOKEN, getSessionsDirForAgent } from './config.js';
|
|
10
|
+
import { DebugLogger } from './debug.js';
|
|
11
|
+
import { GatewayClient } from './gateway.js';
|
|
12
|
+
import { discoverMemoryConfig, createMemoryProvider } from './providers/memory.js';
|
|
13
|
+
import { WorkspaceController } from './controllers/workspaces.js';
|
|
14
|
+
import { ThreadController } from './controllers/threads.js';
|
|
15
|
+
import { MessageController } from './controllers/messages.js';
|
|
16
|
+
import { FileController } from './controllers/files.js';
|
|
17
|
+
import { MemoryController } from './controllers/memory.js';
|
|
18
|
+
import { handleServeFile, handleWorkspaceList, handleWorkspaceFileRead, handleWorkspaceFileWrite, handleWorkspaceFileDelete, handleWorkspaceUpload } from './controllers/filesystem.js';
|
|
19
|
+
import { handleTranscribe } from './controllers/transcribe.js';
|
|
20
|
+
import { parseSessionKey } from './util/helpers.js';
|
|
21
|
+
import { send, sendError, parseBody, uuid, matchRoute, setCors } from './util/http.js';
|
|
22
|
+
|
|
23
|
+
const HOME = os.homedir();
|
|
24
|
+
const PORT = parseInt(process.env.PORT || '3001', 10);
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
// Resolve the plugin directory (parent of server/) for static file serving
|
|
28
|
+
const PLUGIN_DIR = path.resolve(__dirname, '..');
|
|
29
|
+
|
|
30
|
+
export function createApp(config = {}) {
|
|
31
|
+
const DATA_DIR = config.dataDir || path.join(PLUGIN_DIR, 'data');
|
|
32
|
+
const UPLOADS_DIR = config.uploadsDir || path.join(PLUGIN_DIR, 'uploads');
|
|
33
|
+
const WORKSPACES_FILE = path.join(DATA_DIR, 'workspaces.json');
|
|
34
|
+
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
|
|
35
|
+
const INTELLIGENCE_DIR = path.join(DATA_DIR, 'intelligence');
|
|
36
|
+
|
|
37
|
+
const authToken = config.authToken !== undefined ? config.authToken : AUTH_TOKEN;
|
|
38
|
+
const gatewayToken = config.gatewayToken !== undefined ? config.gatewayToken : authToken;
|
|
39
|
+
const gatewayUrl = config.gatewayUrl || GATEWAY_WS_URL;
|
|
40
|
+
|
|
41
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
42
|
+
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
43
|
+
|
|
44
|
+
// Per-workspace SQLite databases
|
|
45
|
+
const dbCache = new Map();
|
|
46
|
+
function getDb(workspaceName) {
|
|
47
|
+
if (dbCache.has(workspaceName)) return dbCache.get(workspaceName);
|
|
48
|
+
const db = new Database(path.join(DATA_DIR, `${workspaceName}.db`));
|
|
49
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
50
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
51
|
+
migrate(db);
|
|
52
|
+
dbCache.set(workspaceName, db);
|
|
53
|
+
return db;
|
|
54
|
+
}
|
|
55
|
+
function getActiveDb() { return requestDbStore.getStore() || getDb(getWorkspaces().active); }
|
|
56
|
+
function closeDb(name) { const db = dbCache.get(name); if (db) { db.close(); dbCache.delete(name); } }
|
|
57
|
+
function closeAll() { for (const db of dbCache.values()) db.close(); dbCache.clear(); globalDbCache.close?.(); }
|
|
58
|
+
|
|
59
|
+
// Global DB (custom emojis, cross-workspace data)
|
|
60
|
+
let _globalDb = null;
|
|
61
|
+
const globalDbCache = {
|
|
62
|
+
get() {
|
|
63
|
+
if (_globalDb) return _globalDb;
|
|
64
|
+
_globalDb = new Database(path.join(DATA_DIR, 'global.db'));
|
|
65
|
+
_globalDb.exec('PRAGMA journal_mode = WAL');
|
|
66
|
+
_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))`);
|
|
67
|
+
return _globalDb;
|
|
68
|
+
},
|
|
69
|
+
close() { if (_globalDb) { _globalDb.close(); _globalDb = null; } }
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Workspace config (JSON sidecar)
|
|
73
|
+
let workspacesConfig = null;
|
|
74
|
+
function getWorkspaces() {
|
|
75
|
+
if (!workspacesConfig) {
|
|
76
|
+
try { workspacesConfig = JSON.parse(fs.readFileSync(WORKSPACES_FILE, 'utf8')); }
|
|
77
|
+
catch { workspacesConfig = { active: 'default', workspaces: { default: { name: 'default', label: 'Default', createdAt: Date.now() } } }; fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(workspacesConfig, null, 2)); }
|
|
78
|
+
}
|
|
79
|
+
return workspacesConfig;
|
|
80
|
+
}
|
|
81
|
+
function setWorkspaces(data) { workspacesConfig = data; fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(data, null, 2)); }
|
|
82
|
+
|
|
83
|
+
const debugLogger = new DebugLogger(DATA_DIR);
|
|
84
|
+
const mediaStash = new Map();
|
|
85
|
+
|
|
86
|
+
const memoryConfig = discoverMemoryConfig();
|
|
87
|
+
const memoryProvider = createMemoryProvider(memoryConfig);
|
|
88
|
+
memoryProvider.init().catch(err => console.error('[createApp] Memory provider init error:', err.message));
|
|
89
|
+
const MEMORY_FILES_DIR = path.join(memoryConfig.workspaceDir, 'memory');
|
|
90
|
+
|
|
91
|
+
// Instantiate the gateway client with all dependencies injected
|
|
92
|
+
const gatewayClient = new GatewayClient({ getDb, getWorkspaces, dataDir: DATA_DIR, debugLogger, gatewayWsUrl: gatewayUrl, authToken: gatewayToken, mediaStash });
|
|
93
|
+
const broadcast = msg => gatewayClient.broadcastToBrowsers(msg);
|
|
94
|
+
|
|
95
|
+
// Instantiate controllers
|
|
96
|
+
const workspaces = new WorkspaceController({ getDb, closeDb, getWorkspaces, setWorkspaces, dataDir: DATA_DIR, broadcast });
|
|
97
|
+
const threads = new ThreadController({ getActiveDb, getWorkspaces, uploadsDir: UPLOADS_DIR, broadcast });
|
|
98
|
+
const messages = new MessageController({ getActiveDb, getWorkspaces, broadcast });
|
|
99
|
+
const files = new FileController({ getActiveDb, getWorkspaces, uploadsDir: UPLOADS_DIR, intelligenceDir: INTELLIGENCE_DIR });
|
|
100
|
+
const memory = new MemoryController({ memoryProvider, memoryFilesDir: MEMORY_FILES_DIR, memoryConfig });
|
|
101
|
+
|
|
102
|
+
// Settings (simple key-value file store, no class needed)
|
|
103
|
+
function handleGetSettings(req, res) {
|
|
104
|
+
try { send(res, 200, fs.existsSync(SETTINGS_FILE) ? JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) : {}); }
|
|
105
|
+
catch { send(res, 200, {}); }
|
|
106
|
+
}
|
|
107
|
+
async function handleSaveSettings(req, res) {
|
|
108
|
+
const body = await parseBody(req);
|
|
109
|
+
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
|
|
110
|
+
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(body, null, 2));
|
|
111
|
+
send(res, 200, { ok: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Auth middleware
|
|
115
|
+
function checkAuth(req, res) {
|
|
116
|
+
if (!authToken) return true;
|
|
117
|
+
const auth = req.headers.authorization;
|
|
118
|
+
if (!auth?.startsWith('Bearer ')) { sendError(res, 401, 'Missing or invalid Authorization header'); return false; }
|
|
119
|
+
if (auth.slice(7) !== authToken) { sendError(res, 401, 'Invalid auth token'); return false; }
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Request handler
|
|
124
|
+
async function handleRequest(req, res) {
|
|
125
|
+
const wsName = req.headers?.['x-workspace'];
|
|
126
|
+
const db = wsName ? getDb(wsName) : getActiveDb();
|
|
127
|
+
return requestDbStore.run(db, () => route(req, res));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function route(req, res) {
|
|
131
|
+
const [urlPath, queryString] = (req.url || '/').split('?');
|
|
132
|
+
const query = {};
|
|
133
|
+
if (queryString) for (const pair of queryString.split('&')) { const [k, v] = pair.split('='); if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || ''); }
|
|
134
|
+
const method = req.method;
|
|
135
|
+
let p;
|
|
136
|
+
|
|
137
|
+
if (method === 'OPTIONS') { setCors(res); res.writeHead(204); return res.end(); }
|
|
138
|
+
|
|
139
|
+
// Static file serving
|
|
140
|
+
if (method === 'GET' && !urlPath.startsWith('/api/')) {
|
|
141
|
+
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' };
|
|
142
|
+
const fileName = STATIC[urlPath];
|
|
143
|
+
const isAllowed = fileName || urlPath.startsWith('/icons/') || urlPath.startsWith('/lib/') || urlPath.startsWith('/frontend/') || urlPath.startsWith('/emoji/') || urlPath === '/config.js';
|
|
144
|
+
if (isAllowed) {
|
|
145
|
+
const staticPath = path.join(PLUGIN_DIR, fileName || urlPath.slice(1));
|
|
146
|
+
if (fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
|
|
147
|
+
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' };
|
|
148
|
+
const ext = path.extname(staticPath).toLowerCase();
|
|
149
|
+
const stat = fs.statSync(staticPath);
|
|
150
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', 'Content-Length': stat.size, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600' });
|
|
151
|
+
return fs.createReadStream(staticPath).pipe(res);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Unauthenticated routes
|
|
157
|
+
if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) return files.serveUpload(req, res, p);
|
|
158
|
+
|
|
159
|
+
if (method === 'GET' && urlPath === '/api/emoji') {
|
|
160
|
+
try { const rows = globalDbCache.get().prepare('SELECT name, pack, url, mime_type FROM custom_emojis ORDER BY created_at DESC').all(); res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' }); return res.end(JSON.stringify(rows)); }
|
|
161
|
+
catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (method === 'GET' && urlPath === '/api/emoji/search') {
|
|
165
|
+
const q = query.q || '';
|
|
166
|
+
if (!q) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing ?q=' })); }
|
|
167
|
+
try {
|
|
168
|
+
const https = await import('https');
|
|
169
|
+
const html = await new Promise((resolve, reject) => {
|
|
170
|
+
https.default.get(`https://slackmojis.com/emojis/search?query=${encodeURIComponent(q)}`, resp => { let body = ''; resp.on('data', c => body += c); resp.on('end', () => resolve(body)); }).on('error', reject);
|
|
171
|
+
});
|
|
172
|
+
const results = [];
|
|
173
|
+
const regex = /data-emoji-id-name="([^"]+)"[^>]*href="([^"]+)"[\s\S]*?<img[^>]*src="([^"]+)"/g;
|
|
174
|
+
let match;
|
|
175
|
+
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]}` });
|
|
176
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(results));
|
|
177
|
+
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!checkAuth(req, res)) return;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
// Emoji management
|
|
184
|
+
if (method === 'POST' && urlPath === '/api/emoji/add') {
|
|
185
|
+
const { url, name, pack } = await parseBody(req);
|
|
186
|
+
if (!url || !name) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing url or name' })); }
|
|
187
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
188
|
+
const targetPack = pack || 'slackmojis';
|
|
189
|
+
const mimeType = url.toLowerCase().endsWith('.gif') ? 'image/gif' : url.toLowerCase().endsWith('.webp') ? 'image/webp' : url.toLowerCase().match(/\.jpe?g/) ? 'image/jpeg' : 'image/png';
|
|
190
|
+
globalDbCache.get().prepare('INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)').run(safeName, targetPack, url, mimeType);
|
|
191
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ name: safeName, pack: targetPack, url, mime_type: mimeType }));
|
|
192
|
+
}
|
|
193
|
+
if (method === 'DELETE' && urlPath === '/api/emoji') {
|
|
194
|
+
const { name, pack } = await parseBody(req);
|
|
195
|
+
if (!name || !pack) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing name or pack' })); }
|
|
196
|
+
globalDbCache.get().prepare('DELETE FROM custom_emojis WHERE name = ? AND pack = ?').run(name, pack);
|
|
197
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true }));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// File serving & workspace browser
|
|
201
|
+
if (method === 'GET' && urlPath === '/api/file') return handleServeFile(req, res, query, memoryConfig);
|
|
202
|
+
if (method === 'GET' && urlPath === '/api/workspace') return handleWorkspaceList(req, res, query);
|
|
203
|
+
if (method === 'GET' && urlPath === '/api/workspace/file') return handleWorkspaceFileRead(req, res, query);
|
|
204
|
+
if (method === 'PUT' && urlPath === '/api/workspace/file') return await handleWorkspaceFileWrite(req, res, query);
|
|
205
|
+
if (method === 'DELETE' && urlPath === '/api/workspace/file') return handleWorkspaceFileDelete(req, res, query);
|
|
206
|
+
if (method === 'POST' && urlPath === '/api/workspace/upload') return await handleWorkspaceUpload(req, res, query);
|
|
207
|
+
|
|
208
|
+
// Memory
|
|
209
|
+
if (method === 'GET' && urlPath === '/api/memory/status') return await memory.status(req, res);
|
|
210
|
+
if (method === 'GET' && urlPath === '/api/memory/list') return await memory.list(req, res, query);
|
|
211
|
+
if (method === 'GET' && urlPath === '/api/memory/search') return await memory.search(req, res, query);
|
|
212
|
+
if (method === 'GET' && urlPath === '/api/memory/files') return memory.files(req, res, query);
|
|
213
|
+
if ((p = matchRoute(method, urlPath, 'PUT /api/memory/:id'))) return await memory.update(req, res, p);
|
|
214
|
+
if ((p = matchRoute(method, urlPath, 'DELETE /api/memory/:id'))) return await memory.delete(req, res, p);
|
|
215
|
+
|
|
216
|
+
// Settings & misc
|
|
217
|
+
if (method === 'GET' && urlPath === '/api/settings') return handleGetSettings(req, res);
|
|
218
|
+
if (method === 'PUT' && urlPath === '/api/settings') return await handleSaveSettings(req, res);
|
|
219
|
+
if (method === 'POST' && urlPath === '/api/transcribe') return await handleTranscribe(req, res);
|
|
220
|
+
if (method === 'GET' && urlPath === '/api/health') return send(res, 200, { ok: true, workspace: getWorkspaces().active, uptime: process.uptime() });
|
|
221
|
+
if (method === 'GET' && urlPath === '/api/agents') {
|
|
222
|
+
try { send(res, 200, { agents: fs.readdirSync(path.join(HOME, '.openclaw', 'agents'), { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name) }); }
|
|
223
|
+
catch { send(res, 200, { agents: ['main'] }); }
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Workspaces
|
|
228
|
+
if (method === 'GET' && urlPath === '/api/workspaces') return workspaces.getAll(req, res);
|
|
229
|
+
if (method === 'POST' && urlPath === '/api/workspaces') return await workspaces.create(req, res);
|
|
230
|
+
if ((p = matchRoute(method, urlPath, 'PATCH /api/workspaces/:name'))) return await workspaces.update(req, res, p);
|
|
231
|
+
if ((p = matchRoute(method, urlPath, 'DELETE /api/workspaces/:name'))) return workspaces.delete(req, res, p);
|
|
232
|
+
if (method === 'POST' && urlPath === '/api/workspaces/reorder') return await workspaces.reorder(req, res);
|
|
233
|
+
if ((p = matchRoute(method, urlPath, 'POST /api/workspaces/:name/activate'))) return workspaces.activate(req, res, p);
|
|
234
|
+
|
|
235
|
+
// Threads
|
|
236
|
+
if (method === 'GET' && urlPath === '/api/threads') return threads.getAll(req, res, {}, query);
|
|
237
|
+
if (method === 'GET' && urlPath === '/api/threads/unread') return threads.getUnread(req, res);
|
|
238
|
+
if (method === 'POST' && urlPath === '/api/threads') return await threads.create(req, res);
|
|
239
|
+
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/mark-read'))) return await threads.markRead(req, res, p);
|
|
240
|
+
if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/messages'))) return messages.getAll(req, res, p, query);
|
|
241
|
+
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) return await messages.create(req, res, p);
|
|
242
|
+
if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) return messages.delete(req, res, p);
|
|
243
|
+
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) return messages.contextFill(req, res, p);
|
|
244
|
+
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
|
|
245
|
+
const db = getActiveDb();
|
|
246
|
+
const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
|
|
247
|
+
if (!thread) return sendError(res, 404, 'Thread not found');
|
|
248
|
+
gatewayClient.generateThreadTitle(db, p.id, getWorkspaces().active);
|
|
249
|
+
return send(res, 200, { ok: true });
|
|
250
|
+
}
|
|
251
|
+
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) return await files.upload(req, res, p);
|
|
252
|
+
if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) return files.getIntelligence(req, res, p);
|
|
253
|
+
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) return await files.saveIntelligence(req, res, p);
|
|
254
|
+
if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id'))) return threads.get(req, res, p);
|
|
255
|
+
if ((p = matchRoute(method, urlPath, 'PATCH /api/threads/:id'))) return await threads.update(req, res, p);
|
|
256
|
+
if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id'))) return threads.delete(req, res, p);
|
|
257
|
+
|
|
258
|
+
// Search / export / import
|
|
259
|
+
if (method === 'GET' && urlPath === '/api/search') return messages.search(req, res, {}, query);
|
|
260
|
+
if (method === 'GET' && urlPath === '/api/export') return messages.export(req, res);
|
|
261
|
+
if (method === 'POST' && urlPath === '/api/import') return await messages.import(req, res);
|
|
262
|
+
|
|
263
|
+
if (method === 'POST' && urlPath === '/api/active-thread') {
|
|
264
|
+
const body = await parseBody(req);
|
|
265
|
+
if (body.threadId && body.workspace) gatewayClient.setActiveThread(null, body.workspace, body.threadId);
|
|
266
|
+
return send(res, 200, { ok: true });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
sendError(res, 404, `Not found: ${method} ${urlPath}`);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error(`Error handling ${method} ${urlPath}:`, err);
|
|
272
|
+
if (err.message?.includes('UNIQUE constraint')) sendError(res, 409, 'Conflict: ' + err.message);
|
|
273
|
+
else sendError(res, 500, err.message || 'Internal server error');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Browser WebSocket setup (shared by standalone and plugin modes)
|
|
278
|
+
function setupBrowserWs(wss) {
|
|
279
|
+
wss.on('connection', ws => {
|
|
280
|
+
console.log('Browser client connected');
|
|
281
|
+
gatewayClient.addBrowserClient(ws);
|
|
282
|
+
ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: { nonce: uuid(), ts: Date.now() } }));
|
|
283
|
+
|
|
284
|
+
ws.on('message', async data => {
|
|
285
|
+
const msgStr = data.toString();
|
|
286
|
+
debugLogger.logFrame('BR→SRV', msgStr);
|
|
287
|
+
let msgToForward = msgStr;
|
|
288
|
+
try {
|
|
289
|
+
const msg = JSON.parse(msgStr);
|
|
290
|
+
if (msg.type === 'req' && msg.method === 'connect') {
|
|
291
|
+
const token = msg.params?.auth?.token;
|
|
292
|
+
if (token === authToken || !authToken) {
|
|
293
|
+
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' } } }));
|
|
294
|
+
} else {
|
|
295
|
+
ws.send(JSON.stringify({ type: 'res', id: msg.id, ok: false, error: { code: 'AUTH_FAILED', message: 'Invalid auth token' } }));
|
|
296
|
+
ws.close();
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (msg.type === 'clawchats' || msg.type === 'shellchat') {
|
|
301
|
+
if (msg.action === 'active-thread') { gatewayClient.setActiveThread(ws, msg.workspace, msg.threadId); return; }
|
|
302
|
+
if (msg.action === 'debug-start') { const r = debugLogger.start(msg.ts, ws); 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 })); return; }
|
|
303
|
+
if (msg.action === 'debug-dump') { const r = debugLogger.saveDump(msg); ws.send(JSON.stringify({ type: 'clawchats', event: 'debug-saved', sessionId: r.sessionId, files: r.files })); return; }
|
|
304
|
+
}
|
|
305
|
+
// Save inline attachments to disk before forwarding to gateway
|
|
306
|
+
if (msg.type === 'req' && msg.method === 'chat.send' && msg.params?.attachments?.length > 0) {
|
|
307
|
+
const parsed = parseSessionKey(msg.params.sessionKey || '');
|
|
308
|
+
const threadId = parsed?.threadId || 'misc';
|
|
309
|
+
const uploadDir = path.join(UPLOADS_DIR, threadId);
|
|
310
|
+
fs.mkdirSync(uploadDir, { recursive: true });
|
|
311
|
+
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' };
|
|
312
|
+
const savedPaths = [];
|
|
313
|
+
for (const att of msg.params.attachments) {
|
|
314
|
+
if (!att.content || !att.mimeType) continue;
|
|
315
|
+
try {
|
|
316
|
+
const rawExt = att.mimeType.split('/')[1]?.split(';')[0] || 'bin';
|
|
317
|
+
const filePath = path.join(uploadDir, `${Date.now()}_${Math.random().toString(36).slice(2, 6)}.${extMap[rawExt] || rawExt}`);
|
|
318
|
+
fs.writeFileSync(filePath, Buffer.from(att.content, 'base64'));
|
|
319
|
+
savedPaths.push(filePath);
|
|
320
|
+
} catch (err) { console.error('[upload] Failed to save attachment:', err.message); }
|
|
321
|
+
}
|
|
322
|
+
if (savedPaths.length > 0) {
|
|
323
|
+
const note = `\n\n[${savedPaths.length === 1 ? 'Attached file saved on disk' : 'Attached files saved on disk'}:\n${savedPaths.map(p => `- ${p}`).join('\n')}]`;
|
|
324
|
+
msgToForward = JSON.stringify({ ...msg, params: { ...msg.params, message: (msg.params.message || '') + note } });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch { /* not JSON or not a ClawChats message, forward as-is */ }
|
|
328
|
+
gatewayClient.sendToGateway(msgToForward);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
ws.on('close', () => { console.log('Browser client disconnected'); debugLogger.handleClientDisconnect(ws); gatewayClient.removeBrowserClient(ws); });
|
|
332
|
+
ws.on('error', err => console.error('Browser WebSocket error:', err.message));
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
handleRequest,
|
|
338
|
+
getDb,
|
|
339
|
+
getActiveDb,
|
|
340
|
+
getWorkspaces,
|
|
341
|
+
setWorkspaces,
|
|
342
|
+
shutdown: closeAll,
|
|
343
|
+
closeAllDbs: closeAll,
|
|
344
|
+
gatewayClient,
|
|
345
|
+
setupBrowserWs,
|
|
346
|
+
dataDir: DATA_DIR,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Migration — kept here as it references Database-level constructs shared across modules
|
|
351
|
+
function migrate(db) {
|
|
352
|
+
db.exec(`
|
|
353
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
354
|
+
id TEXT PRIMARY KEY, session_key TEXT UNIQUE NOT NULL, title TEXT DEFAULT 'New chat',
|
|
355
|
+
pinned INTEGER DEFAULT 0, pin_order INTEGER DEFAULT 0, model TEXT,
|
|
356
|
+
last_session_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
357
|
+
);
|
|
358
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
359
|
+
id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL,
|
|
360
|
+
status TEXT DEFAULT 'sent', metadata TEXT, seq INTEGER, timestamp INTEGER NOT NULL, created_at INTEGER NOT NULL,
|
|
361
|
+
FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
|
362
|
+
);
|
|
363
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, timestamp);
|
|
364
|
+
CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages(thread_id, role, timestamp);
|
|
365
|
+
`);
|
|
366
|
+
try { db.exec('ALTER TABLE threads ADD COLUMN sort_order INTEGER DEFAULT 0'); } catch { /* exists */ }
|
|
367
|
+
try { db.exec('ALTER TABLE threads ADD COLUMN unread_count INTEGER DEFAULT 0'); } catch { /* exists */ }
|
|
368
|
+
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)`);
|
|
369
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id)');
|
|
370
|
+
ensureFts(db);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function createFts(db) {
|
|
374
|
+
db.exec(`CREATE VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='porter unicode61 tokenchars x27')`);
|
|
375
|
+
db.exec(`CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content); END`);
|
|
376
|
+
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`);
|
|
377
|
+
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`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function dropFts(db) {
|
|
381
|
+
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;');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function ensureFts(db) {
|
|
385
|
+
const hasFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
|
|
386
|
+
if (!hasFts) {
|
|
387
|
+
try { createFts(db); db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run(); }
|
|
388
|
+
catch (e) { console.error('[DB] messages_fts creation failed:', e.message); }
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
|
|
392
|
+
if (schema && !schema.sql.includes('tokenchars')) {
|
|
393
|
+
console.log('[DB] Upgrading messages_fts tokenizer...');
|
|
394
|
+
try { dropFts(db); createFts(db); db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run(); console.log('[DB] Upgrade complete'); }
|
|
395
|
+
catch (e) { console.error('[DB] Upgrade failed:', e.message); dropFts(db); }
|
|
396
|
+
} else {
|
|
397
|
+
try { db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('integrity-check')").run(); }
|
|
398
|
+
catch { try { db.prepare("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')").run(); } catch (e) { console.error('[DB] FTS rebuild failed:', e.message); dropFts(db); } }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Standalone mode (node server/index.js or via isDirectRun check in bundle)
|
|
403
|
+
const isDirectRun = import.meta.url === `file://${process.argv[1]}`;
|
|
404
|
+
if (isDirectRun) {
|
|
405
|
+
const app = createApp();
|
|
406
|
+
app.getActiveDb();
|
|
407
|
+
|
|
408
|
+
const server = http.createServer(app.handleRequest);
|
|
409
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
410
|
+
app.setupBrowserWs(wss);
|
|
411
|
+
server.on('upgrade', (req, socket, head) => { wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req)); });
|
|
412
|
+
server.listen(PORT, () => {
|
|
413
|
+
console.log(`ClawChats backend listening on port ${PORT}`);
|
|
414
|
+
console.log(`Active workspace: ${app.getWorkspaces().active}`);
|
|
415
|
+
console.log(`Data dir: ${app.dataDir}`);
|
|
416
|
+
app.gatewayClient.connect();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const shutdown = () => { console.log('Shutting down...'); app.shutdown(); server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 5000); };
|
|
420
|
+
process.on('SIGTERM', shutdown);
|
|
421
|
+
process.on('SIGINT', shutdown);
|
|
422
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
// Discover memory backend configuration from env vars and OpenClaw config
|
|
6
|
+
export function discoverMemoryConfig() {
|
|
7
|
+
const defaults = { provider: 'qdrant', host: 'localhost', port: 6333, collection: null };
|
|
8
|
+
let oc = null;
|
|
9
|
+
for (const cfgPath of [path.join(os.homedir(), '.openclaw', 'openclaw.json'), '/etc/openclaw/openclaw.json']) {
|
|
10
|
+
try { oc = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); break; } catch { /* try next */ }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let cfg = { ...defaults };
|
|
14
|
+
if (oc) {
|
|
15
|
+
const vs = oc.plugins?.slots?.memory ? oc.plugins?.entries?.[oc.plugins.slots.memory]?.config?.oss?.vectorStore : null;
|
|
16
|
+
if (vs) {
|
|
17
|
+
if (vs.provider) cfg.provider = vs.provider;
|
|
18
|
+
if (vs.config?.host) cfg.host = vs.config.host;
|
|
19
|
+
if (vs.config?.port) cfg.port = vs.config.port;
|
|
20
|
+
if (vs.config?.collectionName) cfg.collection = vs.config.collectionName;
|
|
21
|
+
if (vs.config?.user) cfg.pgUser = vs.config.user;
|
|
22
|
+
if (vs.config?.password) cfg.pgPassword = vs.config.password;
|
|
23
|
+
if (vs.config?.dbname) cfg.pgDbName = vs.config.dbname;
|
|
24
|
+
}
|
|
25
|
+
const wsDir = oc.agents?.defaults?.workspace;
|
|
26
|
+
if (wsDir) cfg.workspaceDir = wsDir;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (process.env.MEMORY_PROVIDER) cfg.provider = process.env.MEMORY_PROVIDER;
|
|
30
|
+
if (process.env.MEMORY_HOST || process.env.QDRANT_HOST) cfg.host = process.env.MEMORY_HOST || process.env.QDRANT_HOST;
|
|
31
|
+
if (process.env.MEMORY_PORT || process.env.QDRANT_PORT) cfg.port = parseInt(process.env.MEMORY_PORT || process.env.QDRANT_PORT, 10);
|
|
32
|
+
if (process.env.MEMORY_COLLECTION || process.env.QDRANT_COLLECTION) cfg.collection = process.env.MEMORY_COLLECTION || process.env.QDRANT_COLLECTION;
|
|
33
|
+
if (process.env.MEMORY_PG_URL) cfg.pgUrl = process.env.MEMORY_PG_URL;
|
|
34
|
+
if (process.env.QDRANT_URL && !process.env.MEMORY_HOST) {
|
|
35
|
+
try { const u = new URL(process.env.QDRANT_URL); cfg.host = u.hostname; if (u.port) cfg.port = parseInt(u.port, 10); } catch { /* ignore */ }
|
|
36
|
+
}
|
|
37
|
+
if (!cfg.workspaceDir) cfg.workspaceDir = path.join(os.homedir(), '.openclaw', 'workspace');
|
|
38
|
+
return cfg;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function autoDetectQdrantCollection(config) {
|
|
42
|
+
if (config.collection) return config.collection;
|
|
43
|
+
try {
|
|
44
|
+
const r = await fetch(`http://${config.host}:${config.port}/collections`, { signal: AbortSignal.timeout(3000) });
|
|
45
|
+
const data = await r.json();
|
|
46
|
+
const found = (data.result?.collections || []).map(c => c.name).find(n => !n.includes('migration'));
|
|
47
|
+
if (found) { console.log(`Memory: auto-detected Qdrant collection "${found}"`); return found; }
|
|
48
|
+
} catch { /* fall through */ }
|
|
49
|
+
console.log('Memory: Qdrant unreachable or no collections, falling back to "memories"');
|
|
50
|
+
return 'memories';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createQdrantProvider(config) {
|
|
54
|
+
const baseUrl = `http://${config.host}:${config.port}`;
|
|
55
|
+
let collection = config.collection;
|
|
56
|
+
return {
|
|
57
|
+
name: 'qdrant',
|
|
58
|
+
config,
|
|
59
|
+
async init() { collection = await autoDetectQdrantCollection(config); config.collection = collection; },
|
|
60
|
+
async list(limit, offset) {
|
|
61
|
+
const body = { limit, with_payload: true, with_vector: false };
|
|
62
|
+
if (offset) body.offset = offset;
|
|
63
|
+
const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
64
|
+
const data = await r.json();
|
|
65
|
+
return { memories: (data.result?.points || []).map(p => ({ id: p.id, ...p.payload })), next_offset: data.result?.next_page_offset || null };
|
|
66
|
+
},
|
|
67
|
+
async search(query) {
|
|
68
|
+
const q = query.toLowerCase();
|
|
69
|
+
const matches = [];
|
|
70
|
+
let offset = null;
|
|
71
|
+
do {
|
|
72
|
+
const body = { limit: 100, with_payload: true, with_vector: false };
|
|
73
|
+
if (offset) body.offset = offset;
|
|
74
|
+
const r = await fetch(`${baseUrl}/collections/${collection}/points/scroll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
75
|
+
const data = await r.json();
|
|
76
|
+
for (const p of (data.result?.points || [])) { if ((p.payload?.data || '').toLowerCase().includes(q)) matches.push({ id: p.id, ...p.payload }); }
|
|
77
|
+
offset = data.result?.next_page_offset || null;
|
|
78
|
+
} while (offset);
|
|
79
|
+
return { memories: matches, next_offset: null };
|
|
80
|
+
},
|
|
81
|
+
async update(id, newData) {
|
|
82
|
+
const r = await fetch(`${baseUrl}/collections/${collection}/points/payload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ points: [id], payload: { data: newData } }) });
|
|
83
|
+
const data = await r.json();
|
|
84
|
+
if (data.status?.error) throw new Error(data.status.error);
|
|
85
|
+
return data.result;
|
|
86
|
+
},
|
|
87
|
+
async delete(id) {
|
|
88
|
+
const r = await fetch(`${baseUrl}/collections/${collection}/points/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ points: [id] }) });
|
|
89
|
+
return (await r.json()).result;
|
|
90
|
+
},
|
|
91
|
+
async status() {
|
|
92
|
+
try {
|
|
93
|
+
const r = await fetch(`${baseUrl}/collections/${collection}`, { signal: AbortSignal.timeout(3000) });
|
|
94
|
+
const data = await r.json();
|
|
95
|
+
return { reachable: true, pointsCount: data.result?.points_count ?? null };
|
|
96
|
+
} catch { return { reachable: false }; }
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createPgProvider(config) {
|
|
102
|
+
let _pool = null;
|
|
103
|
+
const table = config.collection || 'memories';
|
|
104
|
+
async function getPool() {
|
|
105
|
+
if (_pool) return _pool;
|
|
106
|
+
let pg;
|
|
107
|
+
try { pg = await import('pg'); } catch { throw new Error('pg package not installed. Run: npm install pg'); }
|
|
108
|
+
const Pool = pg.default?.Pool || pg.Pool;
|
|
109
|
+
_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' });
|
|
110
|
+
return _pool;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
name: 'postgres',
|
|
114
|
+
config,
|
|
115
|
+
async init() { /* pool created lazily */ },
|
|
116
|
+
async list(limit, offset) {
|
|
117
|
+
const pool = await getPool();
|
|
118
|
+
const off = offset ? parseInt(offset, 10) : 0;
|
|
119
|
+
const { rows } = await pool.query(`SELECT id, payload FROM ${table} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [limit, off]);
|
|
120
|
+
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 };
|
|
121
|
+
},
|
|
122
|
+
async search(query) {
|
|
123
|
+
const pool = await getPool();
|
|
124
|
+
const { rows } = await pool.query(`SELECT id, payload FROM ${table} WHERE payload->>'data' ILIKE $1 LIMIT 100`, [`%${query}%`]);
|
|
125
|
+
return { memories: rows.map(r => ({ id: r.id, ...(typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload) })), next_offset: null };
|
|
126
|
+
},
|
|
127
|
+
async update(id, newData) {
|
|
128
|
+
const pool = await getPool();
|
|
129
|
+
const { rowCount } = await pool.query(`UPDATE ${table} SET payload = jsonb_set(payload, '{data}', $1::jsonb) WHERE id = $2`, [JSON.stringify(newData), id]);
|
|
130
|
+
if (rowCount === 0) throw new Error('Memory not found');
|
|
131
|
+
return { updated: true };
|
|
132
|
+
},
|
|
133
|
+
async delete(id) { const pool = await getPool(); await pool.query(`DELETE FROM ${table} WHERE id = $1`, [id]); return { deleted: true }; },
|
|
134
|
+
async status() {
|
|
135
|
+
try { const pool = await getPool(); const { rows } = await pool.query(`SELECT COUNT(*) as count FROM ${table}`); return { reachable: true, pointsCount: parseInt(rows[0].count, 10) }; }
|
|
136
|
+
catch (err) { return { reachable: false, error: err.message }; }
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createMemoryProvider(config) {
|
|
142
|
+
if (config.provider === 'postgres' || config.provider === 'pgvector') return createPgProvider(config);
|
|
143
|
+
return createQdrantProvider(config);
|
|
144
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { MAX_PREAMBLE_CHARS, getSessionsDirForAgent } from '../config.js';
|
|
4
|
+
|
|
5
|
+
export function buildContextPreamble(db, threadId, lastSessionId, sessionKey) {
|
|
6
|
+
let summary = null;
|
|
7
|
+
let method = 'raw';
|
|
8
|
+
|
|
9
|
+
if (lastSessionId) {
|
|
10
|
+
const agentMatch = (sessionKey || '').match(/^agent:([^:]+):/);
|
|
11
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
12
|
+
try {
|
|
13
|
+
const lines = fs.readFileSync(path.join(sessionsDir, `${lastSessionId}.jsonl`), 'utf8').split('\n').filter(Boolean);
|
|
14
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
15
|
+
try {
|
|
16
|
+
const entry = JSON.parse(lines[i]);
|
|
17
|
+
if (entry.type === 'compaction' && entry.summary) { summary = entry.summary; method = 'compaction'; break; }
|
|
18
|
+
} catch { /* skip malformed */ }
|
|
19
|
+
}
|
|
20
|
+
} catch { /* file not found */ }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let preamble = '';
|
|
24
|
+
if (method === 'compaction' && summary) {
|
|
25
|
+
preamble += "[CONTEXT RECOVERY — This thread's agent session was reset. Below is a summary of the previous conversation followed by recent messages to restore context.]\n\n";
|
|
26
|
+
preamble += '[CONVERSATION SUMMARY]\n' + summary + '\n\n';
|
|
27
|
+
const msgs = db.prepare('SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 10').all(threadId).reverse();
|
|
28
|
+
if (msgs.length) {
|
|
29
|
+
preamble += '[RECENT MESSAGES]\n';
|
|
30
|
+
for (const m of msgs) {
|
|
31
|
+
const ts = new Date(m.timestamp).toISOString().replace('T', ' ').slice(0, 16);
|
|
32
|
+
preamble += `${m.role.charAt(0).toUpperCase() + m.role.slice(1)} (${ts}): ${m.content}\n`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
preamble += "[CONTEXT RECOVERY — This thread's agent session was reset. Below are recent messages from the previous conversation to restore context.]\n\n";
|
|
37
|
+
const msgs = db.prepare('SELECT role, content, timestamp FROM messages WHERE thread_id = ? ORDER BY timestamp DESC LIMIT 25').all(threadId).reverse();
|
|
38
|
+
if (msgs.length) {
|
|
39
|
+
preamble += '[PREVIOUS MESSAGES]\n';
|
|
40
|
+
for (const m of msgs) {
|
|
41
|
+
const ts = new Date(m.timestamp).toISOString().replace('T', ' ').slice(0, 16);
|
|
42
|
+
preamble += `${m.role.charAt(0).toUpperCase() + m.role.slice(1)} (${ts}): ${m.content}\n`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (preamble.length > MAX_PREAMBLE_CHARS) preamble = preamble.slice(preamble.length - MAX_PREAMBLE_CHARS);
|
|
48
|
+
return { preamble, method };
|
|
49
|
+
}
|