@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.
- package/dist/adapter.d.ts +156 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +167 -0
- package/dist/adapter.js.map +1 -0
- package/dist/batched-sqlite-adapter.d.ts +75 -0
- package/dist/batched-sqlite-adapter.d.ts.map +1 -0
- package/dist/batched-sqlite-adapter.js +189 -0
- package/dist/batched-sqlite-adapter.js.map +1 -0
- package/dist/dead-letter-queue.d.ts +196 -0
- package/dist/dead-letter-queue.d.ts.map +1 -0
- package/dist/dead-letter-queue.js +427 -0
- package/dist/dead-letter-queue.js.map +1 -0
- package/dist/dlq-adapter.d.ts +195 -0
- package/dist/dlq-adapter.d.ts.map +1 -0
- package/dist/dlq-adapter.js +664 -0
- package/dist/dlq-adapter.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/sqlite-adapter.d.ts +113 -0
- package/dist/sqlite-adapter.d.ts.map +1 -0
- package/dist/sqlite-adapter.js +752 -0
- package/dist/sqlite-adapter.js.map +1 -0
- package/package.json +69 -0
|
@@ -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
|