@agent-relay/storage 0.1.0

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.
@@ -0,0 +1,752 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ /** Default retention: 7 days */
5
+ const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
6
+ /** Default cleanup interval: 1 hour */
7
+ const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
8
+ export class SqliteStorageAdapter {
9
+ dbPath;
10
+ db;
11
+ insertStmt;
12
+ insertSessionStmt;
13
+ driver;
14
+ retentionMs;
15
+ cleanupIntervalMs;
16
+ cleanupTimer;
17
+ constructor(options) {
18
+ this.dbPath = options.dbPath;
19
+ this.retentionMs = options.messageRetentionMs ?? DEFAULT_RETENTION_MS;
20
+ this.cleanupIntervalMs = options.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS;
21
+ }
22
+ resolvePreferredDriver() {
23
+ const raw = process.env.AGENT_RELAY_SQLITE_DRIVER?.trim().toLowerCase();
24
+ if (!raw)
25
+ return undefined;
26
+ if (raw === 'node' || raw === 'node:sqlite' || raw === 'nodesqlite')
27
+ return 'node';
28
+ if (raw === 'better-sqlite3' || raw === 'better' || raw === 'bss')
29
+ return 'better-sqlite3';
30
+ return undefined;
31
+ }
32
+ async openDatabase(driver) {
33
+ if (driver === 'node') {
34
+ // Use require() to avoid toolchains that don't recognize node:sqlite yet (Vitest/Vite).
35
+ const require = createRequire(import.meta.url);
36
+ const mod = require('node:sqlite');
37
+ const db = new mod.DatabaseSync(this.dbPath);
38
+ db.exec('PRAGMA journal_mode = WAL;');
39
+ return db;
40
+ }
41
+ const mod = await import('better-sqlite3');
42
+ const DatabaseCtor = mod.default ?? mod;
43
+ const db = new DatabaseCtor(this.dbPath);
44
+ if (typeof db.pragma === 'function') {
45
+ db.pragma('journal_mode = WAL');
46
+ }
47
+ else {
48
+ db.exec('PRAGMA journal_mode = WAL;');
49
+ }
50
+ return db;
51
+ }
52
+ async init() {
53
+ const dir = path.dirname(this.dbPath);
54
+ if (!fs.existsSync(dir)) {
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ }
57
+ const preferred = this.resolvePreferredDriver();
58
+ const attempts = preferred
59
+ ? [preferred, preferred === 'better-sqlite3' ? 'node' : 'better-sqlite3']
60
+ : ['better-sqlite3', 'node'];
61
+ let lastError = null;
62
+ for (const driver of attempts) {
63
+ try {
64
+ this.db = await this.openDatabase(driver);
65
+ this.driver = driver;
66
+ lastError = null;
67
+ break;
68
+ }
69
+ catch (err) {
70
+ lastError = err;
71
+ }
72
+ }
73
+ if (!this.db) {
74
+ throw new Error(`Failed to initialize SQLite storage at ${this.dbPath}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
75
+ }
76
+ // Check if messages table exists for migration decisions
77
+ const messagesTableExists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'").get();
78
+ if (!messagesTableExists) {
79
+ // Fresh install: create messages table with all columns
80
+ this.db.exec(`
81
+ CREATE TABLE messages (
82
+ id TEXT PRIMARY KEY,
83
+ ts INTEGER NOT NULL,
84
+ sender TEXT NOT NULL,
85
+ recipient TEXT NOT NULL,
86
+ topic TEXT,
87
+ kind TEXT NOT NULL,
88
+ body TEXT NOT NULL,
89
+ data TEXT,
90
+ payload_meta TEXT,
91
+ thread TEXT,
92
+ delivery_seq INTEGER,
93
+ delivery_session_id TEXT,
94
+ session_id TEXT,
95
+ status TEXT NOT NULL DEFAULT 'unread',
96
+ is_urgent INTEGER NOT NULL DEFAULT 0,
97
+ is_broadcast INTEGER NOT NULL DEFAULT 0
98
+ );
99
+ CREATE INDEX idx_messages_ts ON messages (ts);
100
+ CREATE INDEX idx_messages_sender ON messages (sender);
101
+ CREATE INDEX idx_messages_recipient ON messages (recipient);
102
+ CREATE INDEX idx_messages_topic ON messages (topic);
103
+ CREATE INDEX idx_messages_thread ON messages (thread);
104
+ CREATE INDEX idx_messages_status ON messages (status);
105
+ CREATE INDEX idx_messages_is_urgent ON messages (is_urgent);
106
+ `);
107
+ }
108
+ else {
109
+ // Existing database: run migrations for missing columns
110
+ const columns = this.db.prepare("PRAGMA table_info(messages)").all();
111
+ const columnNames = new Set(columns.map(c => c.name));
112
+ if (!columnNames.has('thread')) {
113
+ this.db.exec('ALTER TABLE messages ADD COLUMN thread TEXT');
114
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread)');
115
+ }
116
+ if (!columnNames.has('payload_meta')) {
117
+ this.db.exec('ALTER TABLE messages ADD COLUMN payload_meta TEXT');
118
+ }
119
+ if (!columnNames.has('status')) {
120
+ this.db.exec("ALTER TABLE messages ADD COLUMN status TEXT NOT NULL DEFAULT 'unread'");
121
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_messages_status ON messages (status)');
122
+ }
123
+ if (!columnNames.has('is_urgent')) {
124
+ this.db.exec("ALTER TABLE messages ADD COLUMN is_urgent INTEGER NOT NULL DEFAULT 0");
125
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_messages_is_urgent ON messages (is_urgent)');
126
+ }
127
+ if (!columnNames.has('is_broadcast')) {
128
+ this.db.exec("ALTER TABLE messages ADD COLUMN is_broadcast INTEGER NOT NULL DEFAULT 0");
129
+ }
130
+ }
131
+ // Create sessions table (IF NOT EXISTS is safe here)
132
+ // Note: Don't create resume_token index here - it's created after migration check
133
+ this.db.exec(`
134
+ CREATE TABLE IF NOT EXISTS sessions (
135
+ id TEXT PRIMARY KEY,
136
+ agent_name TEXT NOT NULL,
137
+ cli TEXT,
138
+ project_id TEXT,
139
+ project_root TEXT,
140
+ started_at INTEGER NOT NULL,
141
+ ended_at INTEGER,
142
+ message_count INTEGER DEFAULT 0,
143
+ summary TEXT,
144
+ resume_token TEXT,
145
+ closed_by TEXT
146
+ );
147
+ CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions (agent_name);
148
+ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions (started_at);
149
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions (project_id);
150
+ `);
151
+ // Migrate existing sessions table to add resume_token if missing
152
+ const sessionColumns = this.db.prepare("PRAGMA table_info(sessions)").all();
153
+ const sessionColumnNames = new Set(sessionColumns.map(c => c.name));
154
+ if (!sessionColumnNames.has('resume_token')) {
155
+ this.db.exec('ALTER TABLE sessions ADD COLUMN resume_token TEXT');
156
+ }
157
+ // Create index after ensuring column exists (either from CREATE TABLE or migration)
158
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_resume_token ON sessions (resume_token)');
159
+ // Create agent_summaries table (IF NOT EXISTS is safe here - no new columns to migrate)
160
+ this.db.exec(`
161
+ CREATE TABLE IF NOT EXISTS agent_summaries (
162
+ agent_name TEXT PRIMARY KEY,
163
+ project_id TEXT,
164
+ last_updated INTEGER NOT NULL,
165
+ current_task TEXT,
166
+ completed_tasks TEXT,
167
+ decisions TEXT,
168
+ context TEXT,
169
+ files TEXT
170
+ );
171
+ CREATE INDEX IF NOT EXISTS idx_summaries_updated ON agent_summaries (last_updated);
172
+ `);
173
+ // Create presence table for real-time status tracking
174
+ this.db.exec(`
175
+ CREATE TABLE IF NOT EXISTS presence (
176
+ agent_name TEXT PRIMARY KEY,
177
+ status TEXT NOT NULL DEFAULT 'offline',
178
+ status_text TEXT,
179
+ last_activity INTEGER NOT NULL,
180
+ typing_in TEXT
181
+ );
182
+ CREATE INDEX IF NOT EXISTS idx_presence_status ON presence (status);
183
+ CREATE INDEX IF NOT EXISTS idx_presence_activity ON presence (last_activity);
184
+ `);
185
+ // Create read_state table for tracking last read message per channel/conversation
186
+ this.db.exec(`
187
+ CREATE TABLE IF NOT EXISTS read_state (
188
+ agent_name TEXT NOT NULL,
189
+ channel TEXT NOT NULL,
190
+ last_read_ts INTEGER NOT NULL,
191
+ last_read_id TEXT,
192
+ PRIMARY KEY (agent_name, channel)
193
+ );
194
+ `);
195
+ this.insertStmt = this.db.prepare(`
196
+ INSERT OR REPLACE INTO messages
197
+ (id, ts, sender, recipient, topic, kind, body, data, payload_meta, thread, delivery_seq, delivery_session_id, session_id, status, is_urgent, is_broadcast)
198
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
199
+ `);
200
+ // Start automatic cleanup if enabled
201
+ if (this.cleanupIntervalMs > 0) {
202
+ this.startCleanupTimer();
203
+ }
204
+ }
205
+ /**
206
+ * Start the automatic cleanup timer
207
+ */
208
+ startCleanupTimer() {
209
+ // Run cleanup once at startup (async, don't block)
210
+ this.cleanupExpiredMessages().catch(() => { });
211
+ // Schedule periodic cleanup
212
+ this.cleanupTimer = setInterval(() => {
213
+ this.cleanupExpiredMessages().catch(() => { });
214
+ }, this.cleanupIntervalMs);
215
+ // Prevent timer from keeping process alive
216
+ if (this.cleanupTimer.unref) {
217
+ this.cleanupTimer.unref();
218
+ }
219
+ }
220
+ /**
221
+ * Clean up messages older than the retention period
222
+ * @returns Number of messages deleted
223
+ */
224
+ async cleanupExpiredMessages() {
225
+ if (!this.db) {
226
+ return 0;
227
+ }
228
+ const cutoffTs = Date.now() - this.retentionMs;
229
+ const stmt = this.db.prepare('DELETE FROM messages WHERE ts < ?');
230
+ const result = stmt.run(cutoffTs);
231
+ const deleted = result.changes ?? 0;
232
+ if (deleted > 0) {
233
+ console.log(`[storage] Cleaned up ${deleted} expired messages (older than ${Math.round(this.retentionMs / 86400000)}d)`);
234
+ }
235
+ return deleted;
236
+ }
237
+ /**
238
+ * Get storage statistics
239
+ */
240
+ async getStats() {
241
+ if (!this.db) {
242
+ throw new Error('SqliteStorageAdapter not initialized');
243
+ }
244
+ const msgCount = this.db.prepare('SELECT COUNT(*) as count FROM messages').get();
245
+ const sessionCount = this.db.prepare('SELECT COUNT(*) as count FROM sessions').get();
246
+ const oldest = this.db.prepare('SELECT MIN(ts) as ts FROM messages').get();
247
+ return {
248
+ messageCount: msgCount.count,
249
+ sessionCount: sessionCount.count,
250
+ oldestMessageTs: oldest.ts ?? undefined,
251
+ };
252
+ }
253
+ async saveMessage(message) {
254
+ if (!this.db || !this.insertStmt) {
255
+ throw new Error('SqliteStorageAdapter not initialized');
256
+ }
257
+ this.insertStmt.run(message.id, message.ts, message.from, message.to, message.topic ?? null, message.kind, message.body, message.data ? JSON.stringify(message.data) : null, message.payloadMeta ? JSON.stringify(message.payloadMeta) : null, message.thread ?? null, message.deliverySeq ?? null, message.deliverySessionId ?? null, message.sessionId ?? null, message.status, message.is_urgent ? 1 : 0, message.is_broadcast ? 1 : 0);
258
+ }
259
+ async getMessages(query = {}) {
260
+ if (!this.db) {
261
+ throw new Error('SqliteStorageAdapter not initialized');
262
+ }
263
+ const clauses = [];
264
+ const params = [];
265
+ if (query.sinceTs) {
266
+ clauses.push('m.ts >= ?');
267
+ params.push(query.sinceTs);
268
+ }
269
+ if (query.from) {
270
+ clauses.push('m.sender = ?');
271
+ params.push(query.from);
272
+ }
273
+ if (query.to) {
274
+ clauses.push('m.recipient = ?');
275
+ params.push(query.to);
276
+ }
277
+ if (query.topic) {
278
+ clauses.push('m.topic = ?');
279
+ params.push(query.topic);
280
+ }
281
+ if (query.thread) {
282
+ clauses.push('m.thread = ?');
283
+ params.push(query.thread);
284
+ }
285
+ if (query.unreadOnly) {
286
+ clauses.push('m.status = ?');
287
+ params.push('unread');
288
+ }
289
+ if (query.urgentOnly) {
290
+ clauses.push('m.is_urgent = ?');
291
+ params.push(1);
292
+ }
293
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
294
+ const order = query.order === 'asc' ? 'ASC' : 'DESC';
295
+ const limit = query.limit ?? 200;
296
+ const stmt = this.db.prepare(`
297
+ SELECT m.id, m.ts, m.sender, m.recipient, m.topic, m.kind, m.body, m.data, m.payload_meta, m.thread, m.delivery_seq, m.delivery_session_id, m.session_id, m.status, m.is_urgent, m.is_broadcast,
298
+ (SELECT COUNT(*) FROM messages WHERE thread = m.id) as reply_count
299
+ FROM messages m
300
+ ${where}
301
+ ORDER BY m.ts ${order}
302
+ LIMIT ?
303
+ `);
304
+ const rows = stmt.all(...params, limit);
305
+ return rows.map((row) => ({
306
+ id: row.id,
307
+ ts: row.ts,
308
+ from: row.sender,
309
+ to: row.recipient,
310
+ topic: row.topic ?? undefined,
311
+ kind: row.kind,
312
+ body: row.body,
313
+ data: row.data ? JSON.parse(row.data) : undefined,
314
+ payloadMeta: row.payload_meta ? JSON.parse(row.payload_meta) : undefined,
315
+ thread: row.thread ?? undefined,
316
+ deliverySeq: row.delivery_seq ?? undefined,
317
+ deliverySessionId: row.delivery_session_id ?? undefined,
318
+ sessionId: row.session_id ?? undefined,
319
+ status: row.status,
320
+ is_urgent: row.is_urgent === 1,
321
+ is_broadcast: row.is_broadcast === 1,
322
+ replyCount: row.reply_count || 0,
323
+ }));
324
+ }
325
+ async updateMessageStatus(id, status) {
326
+ if (!this.db) {
327
+ throw new Error('SqliteStorageAdapter not initialized');
328
+ }
329
+ const stmt = this.db.prepare('UPDATE messages SET status = ? WHERE id = ?');
330
+ stmt.run(status, id);
331
+ }
332
+ async getMessageById(id) {
333
+ if (!this.db) {
334
+ throw new Error('SqliteStorageAdapter not initialized');
335
+ }
336
+ // Support both exact match and prefix match (for short IDs like "06eb33da")
337
+ const stmt = this.db.prepare(`
338
+ SELECT m.id, m.ts, m.sender, m.recipient, m.topic, m.kind, m.body, m.data, m.payload_meta, m.thread, m.delivery_seq, m.delivery_session_id, m.session_id, m.status, m.is_urgent, m.is_broadcast,
339
+ (SELECT COUNT(*) FROM messages WHERE thread = m.id) as reply_count
340
+ FROM messages m
341
+ WHERE m.id = ? OR m.id LIKE ?
342
+ ORDER BY m.ts DESC
343
+ LIMIT 1
344
+ `);
345
+ const row = stmt.get(id, `${id}%`);
346
+ if (!row)
347
+ return null;
348
+ return {
349
+ id: row.id,
350
+ ts: row.ts,
351
+ from: row.sender,
352
+ to: row.recipient,
353
+ topic: row.topic ?? undefined,
354
+ kind: row.kind,
355
+ body: row.body,
356
+ data: row.data ? JSON.parse(row.data) : undefined,
357
+ payloadMeta: row.payload_meta ? JSON.parse(row.payload_meta) : undefined,
358
+ thread: row.thread ?? undefined,
359
+ deliverySeq: row.delivery_seq ?? undefined,
360
+ deliverySessionId: row.delivery_session_id ?? undefined,
361
+ sessionId: row.session_id ?? undefined,
362
+ status: row.status ?? 'unread',
363
+ is_urgent: row.is_urgent === 1,
364
+ is_broadcast: row.is_broadcast === 1,
365
+ replyCount: row.reply_count || 0,
366
+ };
367
+ }
368
+ async getPendingMessagesForSession(agentName, sessionId) {
369
+ if (!this.db) {
370
+ throw new Error('SqliteStorageAdapter not initialized');
371
+ }
372
+ const stmt = this.db.prepare(`
373
+ SELECT id, ts, sender, recipient, topic, kind, body, data, payload_meta, thread, delivery_seq, delivery_session_id, session_id, status, is_urgent, is_broadcast
374
+ FROM messages
375
+ WHERE recipient = ? AND delivery_session_id = ? AND status != 'acked'
376
+ ORDER BY delivery_seq ASC, ts ASC
377
+ `);
378
+ const rows = stmt.all(agentName, sessionId);
379
+ return rows.map((row) => ({
380
+ id: row.id,
381
+ ts: row.ts,
382
+ from: row.sender,
383
+ to: row.recipient,
384
+ topic: row.topic ?? undefined,
385
+ kind: row.kind,
386
+ body: row.body,
387
+ data: row.data ? JSON.parse(row.data) : undefined,
388
+ payloadMeta: row.payload_meta ? JSON.parse(row.payload_meta) : undefined,
389
+ thread: row.thread ?? undefined,
390
+ deliverySeq: row.delivery_seq ?? undefined,
391
+ deliverySessionId: row.delivery_session_id ?? undefined,
392
+ sessionId: row.session_id ?? undefined,
393
+ status: row.status ?? 'unread',
394
+ is_urgent: row.is_urgent === 1,
395
+ is_broadcast: row.is_broadcast === 1,
396
+ }));
397
+ }
398
+ async getMaxSeqByStream(agentName, sessionId) {
399
+ if (!this.db) {
400
+ throw new Error('SqliteStorageAdapter not initialized');
401
+ }
402
+ const stmt = this.db.prepare(`
403
+ SELECT sender, topic, MAX(delivery_seq) as max_seq
404
+ FROM messages
405
+ WHERE recipient = ? AND delivery_session_id = ? AND delivery_seq IS NOT NULL
406
+ GROUP BY sender, topic
407
+ `);
408
+ const rows = stmt.all(agentName, sessionId);
409
+ return rows.map(row => ({
410
+ peer: row.sender,
411
+ topic: row.topic ?? undefined,
412
+ maxSeq: row.max_seq,
413
+ }));
414
+ }
415
+ async close() {
416
+ // Stop cleanup timer
417
+ if (this.cleanupTimer) {
418
+ clearInterval(this.cleanupTimer);
419
+ this.cleanupTimer = undefined;
420
+ }
421
+ if (this.db) {
422
+ this.db.close();
423
+ this.db = undefined;
424
+ }
425
+ }
426
+ // ============ Session Management ============
427
+ async startSession(session) {
428
+ if (!this.db) {
429
+ throw new Error('SqliteStorageAdapter not initialized');
430
+ }
431
+ const stmt = this.db.prepare(`
432
+ INSERT INTO sessions
433
+ (id, agent_name, cli, project_id, project_root, started_at, ended_at, message_count, summary, resume_token, closed_by)
434
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
435
+ ON CONFLICT(id) DO UPDATE SET
436
+ agent_name = excluded.agent_name,
437
+ cli = COALESCE(excluded.cli, sessions.cli),
438
+ project_id = COALESCE(excluded.project_id, sessions.project_id),
439
+ project_root = COALESCE(excluded.project_root, sessions.project_root),
440
+ started_at = COALESCE(sessions.started_at, excluded.started_at),
441
+ ended_at = excluded.ended_at,
442
+ message_count = COALESCE(sessions.message_count, excluded.message_count),
443
+ summary = COALESCE(excluded.summary, sessions.summary),
444
+ resume_token = COALESCE(excluded.resume_token, sessions.resume_token),
445
+ closed_by = excluded.closed_by
446
+ `);
447
+ stmt.run(session.id, session.agentName, session.cli ?? null, session.projectId ?? null, session.projectRoot ?? null, session.startedAt, session.endedAt ?? null, 0, session.summary ?? null, session.resumeToken ?? null, null);
448
+ }
449
+ /**
450
+ * End a session and optionally set a summary.
451
+ *
452
+ * Note: The summary uses COALESCE(?, summary) - if a summary was previously
453
+ * set (e.g., during startSession or a prior endSession call), passing null/undefined
454
+ * for summary will preserve the existing value rather than clearing it.
455
+ * To explicitly clear a summary, pass an empty string.
456
+ */
457
+ async endSession(sessionId, options) {
458
+ if (!this.db) {
459
+ throw new Error('SqliteStorageAdapter not initialized');
460
+ }
461
+ const stmt = this.db.prepare(`
462
+ UPDATE sessions
463
+ SET ended_at = ?, summary = COALESCE(?, summary), closed_by = ?
464
+ WHERE id = ?
465
+ `);
466
+ stmt.run(Date.now(), options?.summary ?? null, options?.closedBy ?? null, sessionId);
467
+ }
468
+ async incrementSessionMessageCount(sessionId) {
469
+ if (!this.db) {
470
+ throw new Error('SqliteStorageAdapter not initialized');
471
+ }
472
+ const stmt = this.db.prepare(`
473
+ UPDATE sessions SET message_count = message_count + 1 WHERE id = ?
474
+ `);
475
+ stmt.run(sessionId);
476
+ }
477
+ async getSessions(query = {}) {
478
+ if (!this.db) {
479
+ throw new Error('SqliteStorageAdapter not initialized');
480
+ }
481
+ const clauses = [];
482
+ const params = [];
483
+ if (query.agentName) {
484
+ clauses.push('agent_name = ?');
485
+ params.push(query.agentName);
486
+ }
487
+ if (query.projectId) {
488
+ clauses.push('project_id = ?');
489
+ params.push(query.projectId);
490
+ }
491
+ if (query.since) {
492
+ clauses.push('started_at >= ?');
493
+ params.push(query.since);
494
+ }
495
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
496
+ const limit = query.limit ?? 50;
497
+ const stmt = this.db.prepare(`
498
+ SELECT id, agent_name, cli, project_id, project_root, started_at, ended_at, message_count, summary, resume_token, closed_by
499
+ FROM sessions
500
+ ${where}
501
+ ORDER BY started_at DESC
502
+ LIMIT ?
503
+ `);
504
+ const rows = stmt.all(...params, limit);
505
+ return rows.map((row) => ({
506
+ id: row.id,
507
+ agentName: row.agent_name,
508
+ cli: row.cli ?? undefined,
509
+ projectId: row.project_id ?? undefined,
510
+ projectRoot: row.project_root ?? undefined,
511
+ startedAt: row.started_at,
512
+ endedAt: row.ended_at ?? undefined,
513
+ messageCount: row.message_count,
514
+ summary: row.summary ?? undefined,
515
+ resumeToken: row.resume_token ?? undefined,
516
+ closedBy: row.closed_by ?? undefined,
517
+ }));
518
+ }
519
+ async getRecentSessions(limit = 10) {
520
+ return this.getSessions({ limit });
521
+ }
522
+ async getSessionByResumeToken(resumeToken) {
523
+ if (!this.db) {
524
+ throw new Error('SqliteStorageAdapter not initialized');
525
+ }
526
+ const row = this.db.prepare(`
527
+ SELECT id, agent_name, cli, project_id, project_root, started_at, ended_at, message_count, summary, resume_token, closed_by
528
+ FROM sessions
529
+ WHERE resume_token = ?
530
+ LIMIT 1
531
+ `).get(resumeToken);
532
+ if (!row) {
533
+ return null;
534
+ }
535
+ return {
536
+ id: row.id,
537
+ agentName: row.agent_name,
538
+ cli: row.cli ?? undefined,
539
+ projectId: row.project_id ?? undefined,
540
+ projectRoot: row.project_root ?? undefined,
541
+ startedAt: row.started_at,
542
+ endedAt: row.ended_at ?? undefined,
543
+ messageCount: row.message_count,
544
+ summary: row.summary ?? undefined,
545
+ resumeToken: row.resume_token ?? undefined,
546
+ closedBy: row.closed_by ?? undefined,
547
+ };
548
+ }
549
+ // ============ Agent Summaries ============
550
+ async saveAgentSummary(summary) {
551
+ if (!this.db) {
552
+ throw new Error('SqliteStorageAdapter not initialized');
553
+ }
554
+ const stmt = this.db.prepare(`
555
+ INSERT OR REPLACE INTO agent_summaries
556
+ (agent_name, project_id, last_updated, current_task, completed_tasks, decisions, context, files)
557
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
558
+ `);
559
+ stmt.run(summary.agentName, summary.projectId ?? null, Date.now(), summary.currentTask ?? null, summary.completedTasks ? JSON.stringify(summary.completedTasks) : null, summary.decisions ? JSON.stringify(summary.decisions) : null, summary.context ?? null, summary.files ? JSON.stringify(summary.files) : null);
560
+ }
561
+ async getAgentSummary(agentName) {
562
+ if (!this.db) {
563
+ throw new Error('SqliteStorageAdapter not initialized');
564
+ }
565
+ const stmt = this.db.prepare(`
566
+ SELECT agent_name, project_id, last_updated, current_task, completed_tasks, decisions, context, files
567
+ FROM agent_summaries
568
+ WHERE agent_name = ?
569
+ `);
570
+ const row = stmt.get(agentName);
571
+ if (!row)
572
+ return null;
573
+ return {
574
+ agentName: row.agent_name,
575
+ projectId: row.project_id ?? undefined,
576
+ lastUpdated: row.last_updated,
577
+ currentTask: row.current_task ?? undefined,
578
+ completedTasks: row.completed_tasks ? JSON.parse(row.completed_tasks) : undefined,
579
+ decisions: row.decisions ? JSON.parse(row.decisions) : undefined,
580
+ context: row.context ?? undefined,
581
+ files: row.files ? JSON.parse(row.files) : undefined,
582
+ };
583
+ }
584
+ async getAllAgentSummaries() {
585
+ if (!this.db) {
586
+ throw new Error('SqliteStorageAdapter not initialized');
587
+ }
588
+ const stmt = this.db.prepare(`
589
+ SELECT agent_name, project_id, last_updated, current_task, completed_tasks, decisions, context, files
590
+ FROM agent_summaries
591
+ ORDER BY last_updated DESC
592
+ `);
593
+ const rows = stmt.all();
594
+ return rows.map((row) => ({
595
+ agentName: row.agent_name,
596
+ projectId: row.project_id ?? undefined,
597
+ lastUpdated: row.last_updated,
598
+ currentTask: row.current_task ?? undefined,
599
+ completedTasks: row.completed_tasks ? JSON.parse(row.completed_tasks) : undefined,
600
+ decisions: row.decisions ? JSON.parse(row.decisions) : undefined,
601
+ context: row.context ?? undefined,
602
+ files: row.files ? JSON.parse(row.files) : undefined,
603
+ }));
604
+ }
605
+ // ============ Presence Management ============
606
+ async updatePresence(presence) {
607
+ if (!this.db) {
608
+ throw new Error('SqliteStorageAdapter not initialized');
609
+ }
610
+ const stmt = this.db.prepare(`
611
+ INSERT OR REPLACE INTO presence
612
+ (agent_name, status, status_text, last_activity, typing_in)
613
+ VALUES (?, ?, ?, ?, ?)
614
+ `);
615
+ stmt.run(presence.agentName, presence.status, presence.statusText ?? null, Date.now(), presence.typingIn ?? null);
616
+ }
617
+ async getPresence(agentName) {
618
+ if (!this.db) {
619
+ throw new Error('SqliteStorageAdapter not initialized');
620
+ }
621
+ const stmt = this.db.prepare(`
622
+ SELECT agent_name, status, status_text, last_activity, typing_in
623
+ FROM presence
624
+ WHERE agent_name = ?
625
+ `);
626
+ const row = stmt.get(agentName);
627
+ if (!row)
628
+ return null;
629
+ return {
630
+ agentName: row.agent_name,
631
+ status: row.status,
632
+ statusText: row.status_text ?? undefined,
633
+ lastActivity: row.last_activity,
634
+ typingIn: row.typing_in ?? undefined,
635
+ };
636
+ }
637
+ async getAllPresence() {
638
+ if (!this.db) {
639
+ throw new Error('SqliteStorageAdapter not initialized');
640
+ }
641
+ const stmt = this.db.prepare(`
642
+ SELECT agent_name, status, status_text, last_activity, typing_in
643
+ FROM presence
644
+ ORDER BY last_activity DESC
645
+ `);
646
+ const rows = stmt.all();
647
+ return rows.map((row) => ({
648
+ agentName: row.agent_name,
649
+ status: row.status,
650
+ statusText: row.status_text ?? undefined,
651
+ lastActivity: row.last_activity,
652
+ typingIn: row.typing_in ?? undefined,
653
+ }));
654
+ }
655
+ async setTypingIndicator(agentName, channel) {
656
+ if (!this.db) {
657
+ throw new Error('SqliteStorageAdapter not initialized');
658
+ }
659
+ const stmt = this.db.prepare(`
660
+ UPDATE presence
661
+ SET typing_in = ?, last_activity = ?
662
+ WHERE agent_name = ?
663
+ `);
664
+ stmt.run(channel, Date.now(), agentName);
665
+ }
666
+ // ============ Read State Management ============
667
+ async updateReadState(agentName, channel, lastReadTs, lastReadId) {
668
+ if (!this.db) {
669
+ throw new Error('SqliteStorageAdapter not initialized');
670
+ }
671
+ const stmt = this.db.prepare(`
672
+ INSERT OR REPLACE INTO read_state
673
+ (agent_name, channel, last_read_ts, last_read_id)
674
+ VALUES (?, ?, ?, ?)
675
+ `);
676
+ stmt.run(agentName, channel, lastReadTs, lastReadId ?? null);
677
+ }
678
+ async getReadState(agentName, channel) {
679
+ if (!this.db) {
680
+ throw new Error('SqliteStorageAdapter not initialized');
681
+ }
682
+ const stmt = this.db.prepare(`
683
+ SELECT last_read_ts, last_read_id
684
+ FROM read_state
685
+ WHERE agent_name = ? AND channel = ?
686
+ `);
687
+ const row = stmt.get(agentName, channel);
688
+ if (!row)
689
+ return null;
690
+ return {
691
+ lastReadTs: row.last_read_ts,
692
+ lastReadId: row.last_read_id ?? undefined,
693
+ };
694
+ }
695
+ async getUnreadCounts(agentName) {
696
+ if (!this.db) {
697
+ throw new Error('SqliteStorageAdapter not initialized');
698
+ }
699
+ // Get all read states for this agent
700
+ const readStates = this.db.prepare(`
701
+ SELECT channel, last_read_ts FROM read_state WHERE agent_name = ?
702
+ `).all(agentName);
703
+ const counts = {};
704
+ // Count unread messages for each channel (conversation with agent)
705
+ for (const { channel, last_read_ts } of readStates) {
706
+ const count = this.db.prepare(`
707
+ SELECT COUNT(*) as count FROM messages
708
+ WHERE recipient = ? AND ts > ?
709
+ `).get(channel, last_read_ts);
710
+ if (count.count > 0) {
711
+ counts[channel] = count.count;
712
+ }
713
+ }
714
+ return counts;
715
+ }
716
+ // ============ Channel Membership Helpers ============
717
+ /**
718
+ * Get channels that an agent is a member of based on stored membership events.
719
+ * Uses window function to find the most recent action per channel.
720
+ * @returns List of channel names where the agent's latest action is not "leave"
721
+ */
722
+ async getChannelMembershipsForAgent(memberName) {
723
+ if (!this.db) {
724
+ throw new Error('SqliteStorageAdapter not initialized');
725
+ }
726
+ // Query messages with _channelMembership metadata to find channels where
727
+ // the agent's most recent action is NOT "leave" (i.e., 'join' or 'invite')
728
+ // Note: 'invite' also adds a member to a channel (see handleMembershipUpdate)
729
+ const stmt = this.db.prepare(`
730
+ WITH membership_events AS (
731
+ SELECT
732
+ recipient AS channel,
733
+ json_extract(data, '$._channelMembership.member') AS member,
734
+ json_extract(data, '$._channelMembership.action') AS action,
735
+ ts,
736
+ ROW_NUMBER() OVER (
737
+ PARTITION BY recipient, json_extract(data, '$._channelMembership.member')
738
+ ORDER BY ts DESC
739
+ ) AS rn
740
+ FROM messages
741
+ WHERE json_extract(data, '$._channelMembership.member') = ?
742
+ AND json_extract(data, '$._channelMembership.action') IS NOT NULL
743
+ )
744
+ SELECT channel
745
+ FROM membership_events
746
+ WHERE rn = 1 AND action != 'leave'
747
+ `);
748
+ const rows = stmt.all(memberName);
749
+ return rows.map(row => row.channel);
750
+ }
751
+ }
752
+ //# sourceMappingURL=sqlite-adapter.js.map