@clawchatsai/connector 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,544 @@
1
+ /**
2
+ * @shellchat/tunnel — OpenClaw plugin entry point
3
+ *
4
+ * Registers ShellChat as a gateway plugin, providing:
5
+ * - Local HTTP API bridge via createApp()
6
+ * - WebRTC DataChannel for browser connections
7
+ * - Signaling client for NAT traversal
8
+ * - Gateway bridge for local OpenClaw communication
9
+ *
10
+ * Spec: specs/multitenant-p2p.md sections 6.1-6.2
11
+ */
12
+ import * as fs from 'node:fs';
13
+ import * as http from 'node:http';
14
+ import * as path from 'node:path';
15
+ import { SignalingClient } from './signaling-client.js';
16
+ import { WebRTCPeerManager } from './webrtc-peer.js';
17
+ import { dispatchRpc } from './shim.js';
18
+ import { checkForUpdates, performUpdate } from './updater.js';
19
+ // Inline from shared/api-version.ts to avoid rootDir conflict
20
+ const CURRENT_API_VERSION = 1;
21
+ export const PLUGIN_ID = 'tunnel';
22
+ export const PLUGIN_VERSION = '0.0.1';
23
+ /** Max DataChannel message size (~256KB, leave room for envelope) */
24
+ const MAX_DC_MESSAGE_SIZE = 256 * 1024;
25
+ /** Active DataChannel connections: connectionId → send function */
26
+ const connectedClients = new Map();
27
+ let app = null;
28
+ let signaling = null;
29
+ let webrtcPeer = null;
30
+ let healthServer = null;
31
+ // ---------------------------------------------------------------------------
32
+ // Config helpers
33
+ // ---------------------------------------------------------------------------
34
+ const CONFIG_DIR = path.join(process.env.HOME || '/root', '.openclaw', 'shellchat');
35
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
36
+ const RUNTIME_FILE = path.join(CONFIG_DIR, 'runtime.json');
37
+ function loadConfig() {
38
+ try {
39
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
40
+ const cfg = JSON.parse(raw);
41
+ if (!cfg.userId || !cfg.apiKey || !cfg.serverUrl) {
42
+ return null;
43
+ }
44
+ return cfg;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Service lifecycle
52
+ // ---------------------------------------------------------------------------
53
+ async function startShellChat(ctx, api) {
54
+ const config = loadConfig();
55
+ if (!config) {
56
+ ctx.logger.info('ShellChat not configured. Run: openclaw shellchat setup <token>');
57
+ return;
58
+ }
59
+ // 1. Check for updates
60
+ const update = await checkForUpdates();
61
+ if (update) {
62
+ ctx.logger.info(`Update available: ${update.current} → ${update.latest}`);
63
+ if (ctx._forceUpdate) {
64
+ try {
65
+ await performUpdate();
66
+ ctx.logger.info(`Updated to ${update.latest}. Requesting graceful restart...`);
67
+ api.runtime.requestRestart?.('shellchat update');
68
+ return; // will restart with new version
69
+ }
70
+ catch (e) {
71
+ ctx.logger.error(`Auto-update failed: ${e.message}`);
72
+ }
73
+ }
74
+ }
75
+ // 2. Resolve gateway token: runtime API → config file → error
76
+ const gwCfg = api.config;
77
+ const gwAuth = gwCfg?.['gateway']?.['auth'];
78
+ const gatewayToken = gwAuth?.['token'] || config.gatewayToken || '';
79
+ if (!gatewayToken) {
80
+ ctx.logger.error('No gateway token available. Re-run: openclaw shellchat setup <token>');
81
+ return;
82
+ }
83
+ // 3. Import server.js and create app instance with plugin paths
84
+ const dataDir = path.join(ctx.stateDir, 'shellchat', 'data');
85
+ const uploadsDir = path.join(ctx.stateDir, 'shellchat', 'uploads');
86
+ // Dynamic import of server.js (plain JS, no type declarations)
87
+ // @ts-expect-error — server.js is plain JS with no .d.ts
88
+ const serverModule = await import('../server.js');
89
+ app = serverModule.createApp({
90
+ dataDir,
91
+ uploadsDir,
92
+ gatewayUrl: 'ws://localhost:18789',
93
+ authToken: '', // P2P: DataChannel is the auth boundary (signaling authenticates both sides)
94
+ gatewayToken, // For WS auth to local OpenClaw gateway
95
+ });
96
+ // 4. Connect createApp's gateway client (handles persistence + event relay)
97
+ app.gatewayClient.connect();
98
+ // Wire DataChannel clients as broadcast targets so they receive gateway events
99
+ app.gatewayClient.addBroadcastTarget((data) => {
100
+ for (const [id, client] of connectedClients) {
101
+ try {
102
+ client.send(JSON.stringify({ type: 'gateway-event', payload: data }));
103
+ }
104
+ catch {
105
+ connectedClients.delete(id);
106
+ }
107
+ }
108
+ });
109
+ // 5. Connect to signaling server
110
+ signaling = new SignalingClient(config.serverUrl, config.userId, config.apiKey);
111
+ signaling.on('connected', () => {
112
+ ctx.logger.info('Connected to signaling server');
113
+ });
114
+ signaling.on('auth-rejected', (reason) => {
115
+ ctx.logger.error(`Signaling auth rejected: ${reason}`);
116
+ });
117
+ signaling.on('version-rejected', (current, minimum) => {
118
+ ctx.logger.error(`Plugin version ${current} rejected, minimum: ${minimum}`);
119
+ });
120
+ signaling.on('force-update', async (targetVersion) => {
121
+ ctx.logger.info(`Force update to ${targetVersion} requested`);
122
+ try {
123
+ await performUpdate();
124
+ ctx.logger.info('Update complete, requesting restart');
125
+ api.runtime.requestRestart?.('forced update');
126
+ }
127
+ catch (e) {
128
+ ctx.logger.error(`Force update failed: ${e.message}`);
129
+ }
130
+ });
131
+ signaling.on('account-suspended', (reason) => {
132
+ ctx.logger.error(`Account suspended: ${reason}`);
133
+ broadcastToClients({ type: 'account-suspended', reason });
134
+ });
135
+ // 6. Initialize WebRTC peer manager
136
+ webrtcPeer = new WebRTCPeerManager();
137
+ webrtcPeer.on('datachannel', (dc, connectionId) => {
138
+ ctx.logger.info(`Browser connected via DataChannel: ${connectionId}`);
139
+ setupDataChannelHandler(dc, connectionId, ctx);
140
+ signaling?.reportConnectionCount(webrtcPeer?.activeCount ?? 0);
141
+ });
142
+ webrtcPeer.on('datachannel-closed', (connectionId) => {
143
+ ctx.logger.info(`Browser disconnected: ${connectionId}`);
144
+ connectedClients.delete(connectionId);
145
+ signaling?.reportConnectionCount(webrtcPeer?.activeCount ?? 0);
146
+ });
147
+ // Wire signaling ICE events to peer manager
148
+ signaling.on('ice-offer', async (offer) => {
149
+ if (!webrtcPeer)
150
+ return;
151
+ try {
152
+ const answer = await webrtcPeer.handleOffer(offer);
153
+ signaling?.sendIceAnswer(answer.connectionId, answer.sdp, answer.candidates);
154
+ }
155
+ catch (e) {
156
+ ctx.logger.error(`ICE offer handling failed: ${e.message}`);
157
+ }
158
+ });
159
+ // ICE servers arrive before offers — buffer them
160
+ signaling.on('ice-servers', (data) => {
161
+ webrtcPeer?.setIceServers(data);
162
+ });
163
+ // Trickle ICE candidates from browser → plugin
164
+ signaling.on('ice-candidate', (data) => {
165
+ webrtcPeer?.handleIceCandidate(data.connectionId, data.candidate);
166
+ });
167
+ // Trickle ICE candidates from plugin → browser
168
+ webrtcPeer.on('ice-candidate-local', (data) => {
169
+ signaling?.sendIceCandidate(data.connectionId, data.candidate);
170
+ });
171
+ await signaling.connect();
172
+ // 7. Start health endpoint for CLI status queries
173
+ healthServer = http.createServer((req, res) => {
174
+ if (req.url === '/status' && req.method === 'GET') {
175
+ res.writeHead(200, { 'Content-Type': 'application/json' });
176
+ res.end(JSON.stringify({
177
+ version: PLUGIN_VERSION,
178
+ pid: process.pid,
179
+ uptime: process.uptime(),
180
+ gateway: { connected: app?.gatewayClient?.connected ?? false },
181
+ signaling: { connected: signaling?.isConnected ?? false },
182
+ clients: { active: connectedClients.size },
183
+ }));
184
+ }
185
+ else {
186
+ res.writeHead(404);
187
+ res.end();
188
+ }
189
+ });
190
+ healthServer.listen(0, '127.0.0.1', () => {
191
+ const addr = healthServer.address();
192
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
193
+ fs.writeFileSync(RUNTIME_FILE, JSON.stringify({
194
+ pid: process.pid,
195
+ healthPort: addr.port,
196
+ startedAt: new Date().toISOString(),
197
+ }, null, 2), { mode: 0o600 });
198
+ ctx.logger.info(`Health endpoint on 127.0.0.1:${addr.port}`);
199
+ });
200
+ ctx.logger.info('ShellChat service started');
201
+ }
202
+ async function stopShellChat(ctx) {
203
+ ctx.logger.info('ShellChat service stopping...');
204
+ // 0. Tear down health endpoint
205
+ if (healthServer) {
206
+ healthServer.close();
207
+ healthServer = null;
208
+ }
209
+ try {
210
+ fs.unlinkSync(RUNTIME_FILE);
211
+ }
212
+ catch { /* already gone */ }
213
+ // 1. Notify connected browsers and close DataChannels
214
+ for (const [id, client] of connectedClients) {
215
+ try {
216
+ client.send(JSON.stringify({ type: 'gateway-shutdown' }));
217
+ }
218
+ catch { /* already closed */ }
219
+ connectedClients.delete(id);
220
+ }
221
+ // 2. Close all WebRTC peer connections
222
+ webrtcPeer?.closeAll();
223
+ webrtcPeer = null;
224
+ // 3. Disconnect from signaling server
225
+ signaling?.disconnect();
226
+ signaling = null;
227
+ // 4. Close SQLite databases
228
+ app?.shutdown();
229
+ app = null;
230
+ ctx.logger.info('ShellChat service stopped');
231
+ }
232
+ // ---------------------------------------------------------------------------
233
+ // DataChannel message handler (spec section 6.4)
234
+ // ---------------------------------------------------------------------------
235
+ function setupDataChannelHandler(dc, connectionId, ctx) {
236
+ connectedClients.set(connectionId, dc);
237
+ dc.onMessage(async (data) => {
238
+ console.log(`[DC:${connectionId}] Received: ${data.substring(0, 200)}`);
239
+ let msg;
240
+ try {
241
+ msg = JSON.parse(data);
242
+ }
243
+ catch {
244
+ dc.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
245
+ return;
246
+ }
247
+ switch (msg['type']) {
248
+ case 'rpc':
249
+ await handleRpcMessage(dc, msg, ctx);
250
+ break;
251
+ case 'gateway-msg':
252
+ // Forward to local OpenClaw gateway via createApp's gateway client
253
+ if (app?.gatewayClient && typeof msg['payload'] === 'string') {
254
+ app.gatewayClient.sendToGateway(msg['payload']);
255
+ }
256
+ break;
257
+ default:
258
+ dc.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
259
+ }
260
+ });
261
+ dc.onClosed(() => {
262
+ connectedClients.delete(connectionId);
263
+ });
264
+ }
265
+ async function handleRpcMessage(dc, msg, ctx) {
266
+ if (!app) {
267
+ dc.send(JSON.stringify({
268
+ type: 'rpc-res',
269
+ id: msg['id'],
270
+ status: 503,
271
+ body: { error: 'Plugin not ready' },
272
+ }));
273
+ return;
274
+ }
275
+ // API version compatibility check
276
+ const apiVersion = msg['apiVersion'];
277
+ if (apiVersion && apiVersion > CURRENT_API_VERSION) {
278
+ dc.send(JSON.stringify({
279
+ type: 'rpc-res',
280
+ id: msg['id'],
281
+ status: 426,
282
+ body: {
283
+ error: 'upgrade_required',
284
+ pluginVersion: PLUGIN_VERSION,
285
+ message: 'Your gateway plugin needs an update.',
286
+ },
287
+ }));
288
+ return;
289
+ }
290
+ const rpcReq = {
291
+ id: msg['id'] || '',
292
+ method: msg['method'] || 'GET',
293
+ url: msg['url'] || '/',
294
+ headers: msg['headers'] || { 'content-type': 'application/json' },
295
+ body: msg['body'] != null ? JSON.stringify(msg['body']) : undefined,
296
+ };
297
+ try {
298
+ const response = await dispatchRpc(rpcReq, app.handleRequest);
299
+ const responseMsg = {
300
+ type: 'rpc-res',
301
+ id: response.id,
302
+ status: response.status,
303
+ body: response.body,
304
+ };
305
+ const responseStr = JSON.stringify(responseMsg);
306
+ if (responseStr.length > MAX_DC_MESSAGE_SIZE) {
307
+ sendChunked(dc, response.id, response.status, response.body);
308
+ }
309
+ else {
310
+ dc.send(responseStr);
311
+ }
312
+ }
313
+ catch (e) {
314
+ ctx.logger.error(`RPC error: ${e.message}`);
315
+ dc.send(JSON.stringify({
316
+ type: 'rpc-res',
317
+ id: msg['id'],
318
+ status: 500,
319
+ body: { error: 'Internal plugin error' },
320
+ }));
321
+ }
322
+ }
323
+ function sendChunked(dc, id, status, body) {
324
+ const data = typeof body === 'string' ? body : JSON.stringify(body);
325
+ const chunkSize = MAX_DC_MESSAGE_SIZE - 200; // room for envelope
326
+ const totalChunks = Math.ceil(data.length / chunkSize);
327
+ for (let i = 0; i < totalChunks; i++) {
328
+ dc.send(JSON.stringify({
329
+ type: 'rpc-chunk',
330
+ id,
331
+ status,
332
+ index: i,
333
+ total: totalChunks,
334
+ data: data.slice(i * chunkSize, (i + 1) * chunkSize),
335
+ }));
336
+ }
337
+ }
338
+ // ---------------------------------------------------------------------------
339
+ // Broadcast helper
340
+ // ---------------------------------------------------------------------------
341
+ function broadcastToClients(msg) {
342
+ const data = JSON.stringify(msg);
343
+ for (const [id, client] of connectedClients) {
344
+ try {
345
+ client.send(data);
346
+ }
347
+ catch {
348
+ connectedClients.delete(id);
349
+ }
350
+ }
351
+ }
352
+ // ---------------------------------------------------------------------------
353
+ // Status helper
354
+ // ---------------------------------------------------------------------------
355
+ function formatStatus() {
356
+ const lines = [];
357
+ lines.push(`ShellChat Plugin v${PLUGIN_VERSION}`);
358
+ lines.push(`Gateway: ${app?.gatewayClient?.connected ? 'connected' : 'disconnected'}`);
359
+ lines.push(`Signaling: ${signaling?.isConnected ? 'connected' : 'disconnected'}`);
360
+ lines.push(`Clients: ${connectedClients.size}`);
361
+ return lines.join('\n');
362
+ }
363
+ // ---------------------------------------------------------------------------
364
+ // CLI handlers
365
+ // ---------------------------------------------------------------------------
366
+ async function handleSetup(token) {
367
+ // Decode base64 token
368
+ let tokenData;
369
+ try {
370
+ const decoded = Buffer.from(token, 'base64').toString('utf8');
371
+ tokenData = JSON.parse(decoded);
372
+ }
373
+ catch {
374
+ console.error('Invalid setup token. Check that you copied it correctly.');
375
+ return;
376
+ }
377
+ if (new Date(tokenData.expiresAt) < new Date()) {
378
+ console.error('Setup token has expired. Generate a new one from shellchat.example.com.');
379
+ return;
380
+ }
381
+ console.log('Setting up ShellChat...');
382
+ console.log(` Server: ${tokenData.serverUrl}`);
383
+ // Generate API key for signaling server auth
384
+ const { randomBytes } = await import('node:crypto');
385
+ const apiKey = randomBytes(32).toString('hex');
386
+ // Read gateway token from OpenClaw config
387
+ let gatewayToken = '';
388
+ try {
389
+ const openclawConfigPath = path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json');
390
+ const openclawConfig = JSON.parse(fs.readFileSync(openclawConfigPath, 'utf8'));
391
+ gatewayToken = openclawConfig.gateway?.auth?.token || openclawConfig.auth?.token || openclawConfig.token || '';
392
+ }
393
+ catch {
394
+ console.error('Could not read gateway token from ~/.openclaw/openclaw.json');
395
+ console.error('Make sure OpenClaw is installed and configured.');
396
+ return;
397
+ }
398
+ if (!gatewayToken) {
399
+ console.error('No gateway token found in ~/.openclaw/openclaw.json');
400
+ return;
401
+ }
402
+ // Connect to signaling server to complete setup
403
+ const { WebSocket } = await import('ws');
404
+ const ws = new WebSocket(tokenData.serverUrl);
405
+ await new Promise((resolve, reject) => {
406
+ const timeout = setTimeout(() => {
407
+ ws.close();
408
+ reject(new Error('Setup timed out'));
409
+ }, 30_000);
410
+ ws.on('open', () => {
411
+ ws.send(JSON.stringify({
412
+ type: 'setup',
413
+ setupSecret: tokenData.setupSecret,
414
+ apiKey,
415
+ }));
416
+ });
417
+ ws.on('message', (raw) => {
418
+ const msg = JSON.parse(raw.toString());
419
+ if (msg.type === 'setup-complete') {
420
+ clearTimeout(timeout);
421
+ console.log(` User: ${msg.userId}`);
422
+ // Save config
423
+ const config = {
424
+ userId: msg.userId,
425
+ serverUrl: tokenData.serverUrl,
426
+ apiKey,
427
+ gatewayToken,
428
+ schemaVersion: 1,
429
+ installedAt: new Date().toISOString(),
430
+ };
431
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
432
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
433
+ // Create data directories
434
+ const dataDir = path.join(CONFIG_DIR, 'data');
435
+ const uploadsDir = path.join(CONFIG_DIR, 'uploads');
436
+ fs.mkdirSync(dataDir, { recursive: true });
437
+ fs.mkdirSync(uploadsDir, { recursive: true });
438
+ console.log(' ShellChat is ready!');
439
+ console.log(' Open shellchat.example.com in your browser to start chatting.');
440
+ ws.close();
441
+ resolve();
442
+ }
443
+ else if (msg.type === 'setup-error') {
444
+ clearTimeout(timeout);
445
+ ws.close();
446
+ reject(new Error(`Setup failed: ${msg.reason}`));
447
+ }
448
+ });
449
+ ws.on('error', (err) => {
450
+ clearTimeout(timeout);
451
+ reject(err);
452
+ });
453
+ });
454
+ }
455
+ async function handleStatus() {
456
+ // CLI runs in a separate process — module-level vars are null here.
457
+ // Query the live service via the health endpoint instead.
458
+ let runtime;
459
+ try {
460
+ runtime = JSON.parse(fs.readFileSync(RUNTIME_FILE, 'utf8'));
461
+ }
462
+ catch {
463
+ console.log('ShellChat: offline (service not running)');
464
+ return;
465
+ }
466
+ // Verify PID is alive
467
+ try {
468
+ process.kill(runtime.pid, 0);
469
+ }
470
+ catch {
471
+ console.log('ShellChat: offline (stale runtime file)');
472
+ try {
473
+ fs.unlinkSync(RUNTIME_FILE);
474
+ }
475
+ catch { /* ignore */ }
476
+ return;
477
+ }
478
+ // Query health endpoint
479
+ try {
480
+ const body = await new Promise((resolve, reject) => {
481
+ const req = http.get(`http://127.0.0.1:${runtime.healthPort}/status`, (res) => {
482
+ let data = '';
483
+ res.on('data', (chunk) => { data += chunk; });
484
+ res.on('end', () => resolve(data));
485
+ });
486
+ req.on('error', reject);
487
+ req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); });
488
+ });
489
+ const status = JSON.parse(body);
490
+ console.log(`ShellChat Plugin v${status.version}`);
491
+ console.log(`Uptime: ${Math.floor(status.uptime)}s`);
492
+ console.log(`Gateway: ${status.gateway.connected ? 'connected' : 'disconnected'}`);
493
+ console.log(`Signaling: ${status.signaling.connected ? 'connected' : 'disconnected'}`);
494
+ console.log(`Clients: ${status.clients.active}`);
495
+ }
496
+ catch {
497
+ console.log('ShellChat: offline (could not reach service)');
498
+ }
499
+ }
500
+ async function handleReset() {
501
+ try {
502
+ fs.rmSync(CONFIG_DIR, { recursive: true, force: true });
503
+ console.log('ShellChat data removed. Plugin disconnected.');
504
+ }
505
+ catch (e) {
506
+ console.error(`Reset failed: ${e.message}`);
507
+ }
508
+ }
509
+ // ---------------------------------------------------------------------------
510
+ // Plugin definition
511
+ // ---------------------------------------------------------------------------
512
+ const plugin = {
513
+ id: PLUGIN_ID,
514
+ name: 'ShellChat',
515
+ description: 'Connects your gateway to ShellChat via WebRTC P2P',
516
+ register(api) {
517
+ // Background service: signaling + gateway bridge + future WebRTC
518
+ api.registerService({
519
+ id: 'shellchat-service',
520
+ start: (ctx) => startShellChat(ctx, api),
521
+ stop: (ctx) => stopShellChat(ctx),
522
+ });
523
+ // CLI commands
524
+ api.registerCli((ctx) => {
525
+ const cmd = ctx.program.command('shellchat');
526
+ cmd.command('setup <token>')
527
+ .description('Set up ShellChat with a setup token')
528
+ .action((token) => handleSetup(String(token)));
529
+ cmd.command('status')
530
+ .description('Show ShellChat connection status')
531
+ .action(() => handleStatus());
532
+ cmd.command('reset')
533
+ .description('Disconnect and remove all ShellChat data')
534
+ .action(() => handleReset());
535
+ });
536
+ // Slash command for status from any channel
537
+ api.registerCommand({
538
+ name: 'shellchat',
539
+ description: 'Show ShellChat tunnel status',
540
+ handler: () => ({ text: formatStatus() }),
541
+ });
542
+ },
543
+ };
544
+ export default plugin;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Schema migration runner for the ShellChat plugin SQLite database.
3
+ *
4
+ * - Tracks schema version in a `_schema_version` table
5
+ * - Runs migrations sequentially from current version to target
6
+ * - Creates a backup before migrating (if not `:memory:` and currentVersion > 0)
7
+ * - Wraps all migrations in a transaction with rollback on error
8
+ */
9
+ import type Database from 'better-sqlite3';
10
+ export declare const SCHEMA_VERSION = 1;
11
+ /**
12
+ * Run all pending migrations against the given database.
13
+ *
14
+ * Safe to call on every startup — exits early if already up to date.
15
+ */
16
+ export declare function runMigrations(db: Database): void;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Schema migration runner for the ShellChat plugin SQLite database.
3
+ *
4
+ * - Tracks schema version in a `_schema_version` table
5
+ * - Runs migrations sequentially from current version to target
6
+ * - Creates a backup before migrating (if not `:memory:` and currentVersion > 0)
7
+ * - Wraps all migrations in a transaction with rollback on error
8
+ */
9
+ import * as fs from 'node:fs';
10
+ export const SCHEMA_VERSION = 1;
11
+ const migrations = [
12
+ {
13
+ version: 1,
14
+ up: (db) => {
15
+ db.exec(`
16
+ CREATE TABLE IF NOT EXISTS threads (
17
+ id TEXT PRIMARY KEY,
18
+ session_key TEXT UNIQUE NOT NULL,
19
+ title TEXT DEFAULT 'New chat',
20
+ pinned INTEGER DEFAULT 0,
21
+ pin_order INTEGER DEFAULT 0,
22
+ model TEXT,
23
+ last_session_id TEXT,
24
+ sort_order INTEGER DEFAULT 0,
25
+ unread_count INTEGER DEFAULT 0,
26
+ created_at INTEGER NOT NULL,
27
+ updated_at INTEGER NOT NULL
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS messages (
31
+ id TEXT PRIMARY KEY,
32
+ thread_id TEXT NOT NULL,
33
+ role TEXT NOT NULL,
34
+ content TEXT NOT NULL,
35
+ status TEXT DEFAULT 'sent',
36
+ metadata TEXT,
37
+ seq INTEGER,
38
+ timestamp INTEGER NOT NULL,
39
+ created_at INTEGER NOT NULL,
40
+ FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
41
+ );
42
+
43
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, timestamp);
44
+ CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages(thread_id, role, timestamp);
45
+
46
+ CREATE TABLE IF NOT EXISTS unread_messages (
47
+ thread_id TEXT NOT NULL,
48
+ message_id TEXT NOT NULL,
49
+ created_at INTEGER NOT NULL,
50
+ PRIMARY KEY (thread_id, message_id),
51
+ FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
52
+ );
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id);
55
+
56
+ CREATE VIRTUAL TABLE messages_fts USING fts5(
57
+ content, content=messages, content_rowid=rowid,
58
+ tokenize='porter unicode61'
59
+ );
60
+
61
+ CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
62
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
63
+ END;
64
+ `);
65
+ },
66
+ },
67
+ ];
68
+ /**
69
+ * Run all pending migrations against the given database.
70
+ *
71
+ * Safe to call on every startup — exits early if already up to date.
72
+ */
73
+ export function runMigrations(db) {
74
+ // Ensure the version-tracking table exists
75
+ db.exec(`
76
+ CREATE TABLE IF NOT EXISTS _schema_version (
77
+ version INTEGER NOT NULL
78
+ );
79
+ `);
80
+ // Read the current schema version (0 = fresh database)
81
+ const row = db.prepare('SELECT version FROM _schema_version LIMIT 1').get();
82
+ const currentVersion = row?.version ?? 0;
83
+ if (currentVersion >= SCHEMA_VERSION) {
84
+ return;
85
+ }
86
+ // Create a backup before migrating an existing database
87
+ const dbPath = db.name;
88
+ if (currentVersion > 0 && dbPath !== ':memory:') {
89
+ const backupPath = `${dbPath}.backup-v${currentVersion}`;
90
+ fs.copyFileSync(dbPath, backupPath);
91
+ }
92
+ // Run all pending migrations inside a single transaction
93
+ const migrate = db.transaction(() => {
94
+ for (const migration of migrations) {
95
+ if (migration.version > currentVersion) {
96
+ migration.up(db);
97
+ }
98
+ }
99
+ // Record the new schema version
100
+ if (row === undefined) {
101
+ db.prepare('INSERT INTO _schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
102
+ }
103
+ else {
104
+ db.prepare('UPDATE _schema_version SET version = ?').run(SCHEMA_VERSION);
105
+ }
106
+ });
107
+ try {
108
+ migrate();
109
+ }
110
+ catch (err) {
111
+ // better-sqlite3 transactions automatically roll back on throw
112
+ throw err;
113
+ }
114
+ }