@hoverlover/cc-discord 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.
Files changed (46) hide show
  1. package/.claude/settings.template.json +94 -0
  2. package/.env.example +41 -0
  3. package/.env.relay.example +46 -0
  4. package/.env.worker.example +40 -0
  5. package/README.md +313 -0
  6. package/hooks/check-discord-messages.ts +204 -0
  7. package/hooks/cleanup-attachment.ts +47 -0
  8. package/hooks/safe-bash.ts +157 -0
  9. package/hooks/steer-send.ts +108 -0
  10. package/hooks/track-activity.ts +220 -0
  11. package/memory/README.md +60 -0
  12. package/memory/core/MemoryCoordinator.ts +703 -0
  13. package/memory/core/MemoryStore.ts +72 -0
  14. package/memory/core/session-key.ts +14 -0
  15. package/memory/core/types.ts +59 -0
  16. package/memory/index.ts +19 -0
  17. package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
  18. package/memory/providers/sqlite/index.ts +1 -0
  19. package/package.json +45 -0
  20. package/prompts/autoreply-system.md +32 -0
  21. package/prompts/channel-system.md +22 -0
  22. package/prompts/orchestrator-system.md +56 -0
  23. package/scripts/channel-agent.sh +159 -0
  24. package/scripts/generate-settings.sh +17 -0
  25. package/scripts/load-env.sh +79 -0
  26. package/scripts/migrate-memory-to-channel-keys.ts +148 -0
  27. package/scripts/orchestrator.sh +325 -0
  28. package/scripts/parse-claude-stream.ts +349 -0
  29. package/scripts/start-orchestrator.sh +82 -0
  30. package/scripts/start-relay.sh +17 -0
  31. package/scripts/start.sh +175 -0
  32. package/server/attachment.ts +182 -0
  33. package/server/busy-notify.ts +69 -0
  34. package/server/config.ts +121 -0
  35. package/server/db.ts +249 -0
  36. package/server/index.ts +311 -0
  37. package/server/memory.ts +88 -0
  38. package/server/messages.ts +111 -0
  39. package/server/trace-thread.ts +340 -0
  40. package/server/typing.ts +101 -0
  41. package/tools/memory-inspect.ts +94 -0
  42. package/tools/memory-smoke.ts +173 -0
  43. package/tools/send-discord +2 -0
  44. package/tools/send-discord.ts +82 -0
  45. package/tools/wait-for-discord-messages +2 -0
  46. package/tools/wait-for-discord-messages.ts +369 -0
@@ -0,0 +1,838 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdirSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { MemoryStore } from "../../core/MemoryStore.ts";
6
+ import { clamp, MemoryCardTypes, MemoryScopes, nowIso, safeJsonParse, safeJsonStringify } from "../../core/types.ts";
7
+
8
+ const DEFAULT_DB_PATH = join(process.cwd(), "data", "memory.db");
9
+
10
+ const SCHEMA_SQL = `
11
+ CREATE TABLE IF NOT EXISTS memory_sessions (
12
+ id TEXT PRIMARY KEY,
13
+ session_key TEXT NOT NULL UNIQUE,
14
+ agent_id TEXT,
15
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
16
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
17
+ );
18
+
19
+ CREATE TABLE IF NOT EXISTS memory_turns (
20
+ id TEXT PRIMARY KEY,
21
+ session_key TEXT NOT NULL,
22
+ turn_index INTEGER NOT NULL,
23
+ role TEXT NOT NULL,
24
+ content TEXT NOT NULL,
25
+ metadata_json TEXT,
26
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
27
+ FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key),
28
+ UNIQUE(session_key, turn_index)
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS memory_snapshots (
32
+ id TEXT PRIMARY KEY,
33
+ session_key TEXT NOT NULL,
34
+ summary_text TEXT NOT NULL,
35
+ open_tasks_json TEXT NOT NULL DEFAULT '[]',
36
+ decisions_json TEXT NOT NULL DEFAULT '[]',
37
+ compacted_to_turn_id TEXT,
38
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
39
+ FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key)
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS memory_cards (
43
+ id TEXT PRIMARY KEY,
44
+ session_key TEXT NOT NULL,
45
+ scope TEXT NOT NULL DEFAULT 'session',
46
+ card_type TEXT NOT NULL,
47
+ title TEXT NOT NULL,
48
+ body TEXT NOT NULL,
49
+ confidence REAL DEFAULT 0.5,
50
+ pinned INTEGER DEFAULT 0,
51
+ source_turn_from TEXT,
52
+ source_turn_to TEXT,
53
+ expires_at TEXT,
54
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
55
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
56
+ FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key)
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS memory_compaction_state (
60
+ session_key TEXT PRIMARY KEY,
61
+ last_compacted_turn_id TEXT,
62
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
63
+ FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key)
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS memory_sync_jobs (
67
+ id TEXT PRIMARY KEY,
68
+ store_id TEXT NOT NULL,
69
+ batch_id TEXT NOT NULL,
70
+ payload_json TEXT NOT NULL,
71
+ status TEXT NOT NULL DEFAULT 'pending',
72
+ attempts INTEGER NOT NULL DEFAULT 0,
73
+ next_attempt_at TEXT,
74
+ last_error TEXT,
75
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
76
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS memory_runtime_state (
80
+ session_key TEXT PRIMARY KEY,
81
+ runtime_context_id TEXT NOT NULL,
82
+ runtime_epoch INTEGER NOT NULL DEFAULT 1,
83
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
84
+ FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key)
85
+ );
86
+
87
+ CREATE TABLE IF NOT EXISTS memory_batch_log (
88
+ batch_id TEXT PRIMARY KEY,
89
+ session_key TEXT NOT NULL,
90
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
91
+ );
92
+
93
+ CREATE INDEX IF NOT EXISTS idx_memory_turns_session_turn_index
94
+ ON memory_turns(session_key, turn_index);
95
+ CREATE INDEX IF NOT EXISTS idx_memory_cards_session_scope_type
96
+ ON memory_cards(session_key, scope, card_type);
97
+ CREATE INDEX IF NOT EXISTS idx_memory_sync_jobs_status_next_attempt
98
+ ON memory_sync_jobs(status, next_attempt_at);
99
+ CREATE INDEX IF NOT EXISTS idx_memory_runtime_state_context
100
+ ON memory_runtime_state(runtime_context_id);
101
+ `;
102
+
103
+ export class SqliteMemoryStore extends MemoryStore {
104
+ dbPath: string;
105
+ logger: Pick<Console, "log" | "warn" | "error">;
106
+ db: InstanceType<typeof Database> | null;
107
+
108
+ constructor(options: { dbPath?: string; logger?: Pick<Console, "log" | "warn" | "error"> } = {}) {
109
+ super("sqlite", {
110
+ atomicBatch: true,
111
+ fullTextSearch: false,
112
+ vectorSearch: false,
113
+ bidirectionalSync: false,
114
+ });
115
+
116
+ this.dbPath = options.dbPath || DEFAULT_DB_PATH;
117
+ this.logger = options.logger || console;
118
+ this.db = null;
119
+ }
120
+
121
+ async init() {
122
+ if (this.db) return;
123
+
124
+ mkdirSync(dirname(this.dbPath), { recursive: true });
125
+ this.db = new Database(this.dbPath);
126
+ this.db.exec("PRAGMA journal_mode = WAL;");
127
+ this.db.exec("PRAGMA foreign_keys = ON;");
128
+ this.db.exec(SCHEMA_SQL);
129
+ }
130
+
131
+ async health() {
132
+ try {
133
+ await this.init();
134
+ const db = this.#requireDb();
135
+ const row = db.prepare("SELECT 1 as ok").get() as any;
136
+ return { ok: row?.ok === 1, details: this.dbPath };
137
+ } catch (err: any) {
138
+ return { ok: false, details: err?.message || String(err) };
139
+ }
140
+ }
141
+
142
+ async writeBatch(inputBatch: any) {
143
+ await this.init();
144
+
145
+ const batch = normalizeBatch(inputBatch);
146
+ const db = this.#requireDb();
147
+
148
+ const result = {
149
+ ok: true,
150
+ idempotent: false,
151
+ batchId: batch.batchId,
152
+ sessionKey: batch.sessionKey,
153
+ counts: {
154
+ turns: 0,
155
+ snapshots: 0,
156
+ cardsUpserted: 0,
157
+ cardsDeleted: 0,
158
+ },
159
+ };
160
+
161
+ let attempts = 0;
162
+ while (true) {
163
+ attempts++;
164
+ try {
165
+ db.exec("BEGIN IMMEDIATE");
166
+
167
+ const existingBatch = db
168
+ .prepare(`
169
+ SELECT batch_id
170
+ FROM memory_batch_log
171
+ WHERE batch_id = ?
172
+ `)
173
+ .get(batch.batchId);
174
+
175
+ if (existingBatch) {
176
+ db.exec("COMMIT");
177
+ result.idempotent = true;
178
+ return result;
179
+ }
180
+
181
+ const now = nowIso();
182
+ ensureSessionRow(db, batch.sessionKey, batch.agentId, now);
183
+
184
+ db.prepare(`
185
+ INSERT INTO memory_batch_log (batch_id, session_key, created_at)
186
+ VALUES (?, ?, ?)
187
+ `).run(batch.batchId, batch.sessionKey, now);
188
+
189
+ if (batch.turns.length > 0) {
190
+ let nextTurnIndex = (
191
+ db
192
+ .prepare(`
193
+ SELECT COALESCE(MAX(turn_index), -1) + 1 AS next_index
194
+ FROM memory_turns
195
+ WHERE session_key = ?
196
+ `)
197
+ .get(batch.sessionKey) as any
198
+ ).next_index;
199
+
200
+ for (const turn of batch.turns) {
201
+ const turnIndex = Number.isInteger(turn.turnIndex) ? turn.turnIndex : nextTurnIndex;
202
+ if (turnIndex >= nextTurnIndex) {
203
+ nextTurnIndex = turnIndex + 1;
204
+ }
205
+
206
+ db.prepare(`
207
+ INSERT INTO memory_turns (
208
+ id,
209
+ session_key,
210
+ turn_index,
211
+ role,
212
+ content,
213
+ metadata_json,
214
+ created_at
215
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
216
+ `).run(
217
+ turn.id,
218
+ batch.sessionKey,
219
+ turnIndex,
220
+ turn.role,
221
+ turn.content,
222
+ safeJsonStringify(turn.metadata, "null"),
223
+ turn.createdAt,
224
+ );
225
+
226
+ result.counts.turns++;
227
+ }
228
+ }
229
+
230
+ if (batch.snapshot) {
231
+ db.prepare(`
232
+ INSERT INTO memory_snapshots (
233
+ id,
234
+ session_key,
235
+ summary_text,
236
+ open_tasks_json,
237
+ decisions_json,
238
+ compacted_to_turn_id,
239
+ created_at
240
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
241
+ `).run(
242
+ batch.snapshot.id,
243
+ batch.sessionKey,
244
+ batch.snapshot.summaryText,
245
+ safeJsonStringify(batch.snapshot.openTasks, "[]"),
246
+ safeJsonStringify(batch.snapshot.decisions, "[]"),
247
+ batch.snapshot.compactedToTurnId,
248
+ batch.snapshot.createdAt,
249
+ );
250
+
251
+ result.counts.snapshots++;
252
+ }
253
+
254
+ if (batch.cardsUpsert.length > 0) {
255
+ for (const card of batch.cardsUpsert) {
256
+ db.prepare(`
257
+ INSERT INTO memory_cards (
258
+ id,
259
+ session_key,
260
+ scope,
261
+ card_type,
262
+ title,
263
+ body,
264
+ confidence,
265
+ pinned,
266
+ source_turn_from,
267
+ source_turn_to,
268
+ expires_at,
269
+ created_at,
270
+ updated_at
271
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
272
+ ON CONFLICT(id) DO UPDATE SET
273
+ session_key = excluded.session_key,
274
+ scope = excluded.scope,
275
+ card_type = excluded.card_type,
276
+ title = excluded.title,
277
+ body = excluded.body,
278
+ confidence = excluded.confidence,
279
+ pinned = excluded.pinned,
280
+ source_turn_from = excluded.source_turn_from,
281
+ source_turn_to = excluded.source_turn_to,
282
+ expires_at = excluded.expires_at,
283
+ updated_at = excluded.updated_at
284
+ `).run(
285
+ card.id,
286
+ batch.sessionKey,
287
+ card.scope,
288
+ card.cardType,
289
+ card.title,
290
+ card.body,
291
+ card.confidence,
292
+ card.pinned ? 1 : 0,
293
+ card.sourceTurnFrom,
294
+ card.sourceTurnTo,
295
+ card.expiresAt,
296
+ card.createdAt,
297
+ card.updatedAt,
298
+ );
299
+ result.counts.cardsUpserted++;
300
+ }
301
+ }
302
+
303
+ if (batch.cardsDelete.length > 0) {
304
+ const placeholders = batch.cardsDelete.map(() => "?").join(",");
305
+ const deleteResult = db
306
+ .prepare(`
307
+ DELETE FROM memory_cards
308
+ WHERE id IN (${placeholders})
309
+ `)
310
+ .run(...batch.cardsDelete);
311
+ result.counts.cardsDeleted += (deleteResult as any).changes || 0;
312
+ }
313
+
314
+ const compactedToTurnId =
315
+ batch.compactedToTurnId !== undefined ? batch.compactedToTurnId : batch.snapshot?.compactedToTurnId;
316
+
317
+ if (compactedToTurnId !== undefined) {
318
+ db.prepare(`
319
+ INSERT INTO memory_compaction_state (session_key, last_compacted_turn_id, updated_at)
320
+ VALUES (?, ?, ?)
321
+ ON CONFLICT(session_key) DO UPDATE SET
322
+ last_compacted_turn_id = excluded.last_compacted_turn_id,
323
+ updated_at = excluded.updated_at
324
+ `).run(batch.sessionKey, compactedToTurnId, nowIso());
325
+ }
326
+
327
+ db.exec("COMMIT");
328
+ return result;
329
+ } catch (err) {
330
+ try {
331
+ db.exec("ROLLBACK");
332
+ } catch {
333
+ /* ignore */
334
+ }
335
+
336
+ if (isSqliteBusy(err) && attempts < 4) {
337
+ this.logger.warn?.(`[Memory] SQLite busy, retrying writeBatch (attempt ${attempts}/4)`);
338
+ continue;
339
+ }
340
+
341
+ throw err;
342
+ }
343
+ }
344
+ }
345
+
346
+ async readSessionSnapshot(sessionKey: string) {
347
+ await this.init();
348
+ const key = normalizeSessionKey(sessionKey);
349
+
350
+ const db = this.#requireDb();
351
+ const row = db
352
+ .prepare(`
353
+ SELECT id, session_key, summary_text, open_tasks_json, decisions_json, compacted_to_turn_id, created_at
354
+ FROM memory_snapshots
355
+ WHERE session_key = ?
356
+ ORDER BY created_at DESC, rowid DESC
357
+ LIMIT 1
358
+ `)
359
+ .get(key) as any;
360
+
361
+ if (!row) return null;
362
+ return mapSnapshotRow(row);
363
+ }
364
+
365
+ async listTurns(input: any) {
366
+ await this.init();
367
+
368
+ const { sessionKey, afterTurnId = null, limit = 50 } = input || {};
369
+ const key = normalizeSessionKey(sessionKey);
370
+ const safeLimit = clamp(Number(limit) || 50, 1, 500);
371
+
372
+ const db = this.#requireDb();
373
+
374
+ let afterIndex = -1;
375
+ if (afterTurnId) {
376
+ const row = db
377
+ .prepare(`
378
+ SELECT turn_index
379
+ FROM memory_turns
380
+ WHERE session_key = ? AND id = ?
381
+ LIMIT 1
382
+ `)
383
+ .get(key, String(afterTurnId)) as any;
384
+
385
+ if (row) afterIndex = row.turn_index;
386
+ }
387
+
388
+ const rows = db
389
+ .prepare(`
390
+ SELECT id, session_key, turn_index, role, content, metadata_json, created_at
391
+ FROM memory_turns
392
+ WHERE session_key = ?
393
+ AND turn_index > ?
394
+ ORDER BY turn_index ASC
395
+ LIMIT ?
396
+ `)
397
+ .all(key, afterIndex, safeLimit) as any[];
398
+
399
+ return rows.map(mapTurnRow);
400
+ }
401
+
402
+ async listRecentTurns(input: { sessionKey: string; limit?: number } = { sessionKey: "" }) {
403
+ await this.init();
404
+
405
+ const { sessionKey, limit = 20 } = input;
406
+ const key = normalizeSessionKey(sessionKey);
407
+ const safeLimit = clamp(Number(limit) || 20, 1, 2000);
408
+
409
+ const db = this.#requireDb();
410
+ const rows = db
411
+ .prepare(`
412
+ SELECT id, session_key, turn_index, role, content, metadata_json, created_at
413
+ FROM memory_turns
414
+ WHERE session_key = ?
415
+ ORDER BY turn_index DESC
416
+ LIMIT ?
417
+ `)
418
+ .all(key, safeLimit) as any[];
419
+
420
+ // Return ascending for chronological readability
421
+ rows.reverse();
422
+ return rows.map(mapTurnRow);
423
+ }
424
+
425
+ async queryCards(
426
+ input: { sessionKey?: string; scope?: string; cardType?: string; includeExpired?: boolean; limit?: number } = {},
427
+ ) {
428
+ await this.init();
429
+
430
+ const { sessionKey, scope, cardType, includeExpired = false, limit = 50 } = input;
431
+
432
+ const safeLimit = clamp(Number(limit) || 50, 1, 500);
433
+ const db = this.#requireDb();
434
+
435
+ const conditions: string[] = [];
436
+ const params: any[] = [];
437
+
438
+ if (sessionKey) {
439
+ conditions.push("session_key = ?");
440
+ params.push(normalizeSessionKey(sessionKey));
441
+ }
442
+
443
+ if (scope) {
444
+ conditions.push("scope = ?");
445
+ params.push(String(scope));
446
+ }
447
+
448
+ if (cardType) {
449
+ conditions.push("card_type = ?");
450
+ params.push(String(cardType));
451
+ }
452
+
453
+ if (!includeExpired) {
454
+ conditions.push("(expires_at IS NULL OR expires_at > ?)");
455
+ params.push(nowIso());
456
+ }
457
+
458
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
459
+
460
+ const rows = db
461
+ .prepare(`
462
+ SELECT
463
+ id,
464
+ session_key,
465
+ scope,
466
+ card_type,
467
+ title,
468
+ body,
469
+ confidence,
470
+ pinned,
471
+ source_turn_from,
472
+ source_turn_to,
473
+ expires_at,
474
+ created_at,
475
+ updated_at
476
+ FROM memory_cards
477
+ ${whereClause}
478
+ ORDER BY pinned DESC, updated_at DESC
479
+ LIMIT ?
480
+ `)
481
+ .all(...params, safeLimit) as any[];
482
+
483
+ return rows.map(mapCardRow);
484
+ }
485
+
486
+ async readCompactionState(sessionKey: string) {
487
+ await this.init();
488
+ const key = normalizeSessionKey(sessionKey);
489
+
490
+ const db = this.#requireDb();
491
+ const row = db
492
+ .prepare(`
493
+ SELECT session_key, last_compacted_turn_id, updated_at
494
+ FROM memory_compaction_state
495
+ WHERE session_key = ?
496
+ LIMIT 1
497
+ `)
498
+ .get(key) as any;
499
+
500
+ if (!row) return null;
501
+ return {
502
+ sessionKey: row.session_key,
503
+ lastCompactedTurnId: row.last_compacted_turn_id,
504
+ updatedAt: row.updated_at,
505
+ };
506
+ }
507
+
508
+ async getTurnById(input: { sessionKey?: string; turnId?: string } = {}) {
509
+ await this.init();
510
+
511
+ const { sessionKey, turnId } = input;
512
+ const key = normalizeSessionKey(sessionKey);
513
+ const id = nullableString(turnId);
514
+ if (!id) return null;
515
+
516
+ const db = this.#requireDb();
517
+ const row = db
518
+ .prepare(`
519
+ SELECT id, session_key, turn_index, role, content, metadata_json, created_at
520
+ FROM memory_turns
521
+ WHERE session_key = ?
522
+ AND id = ?
523
+ LIMIT 1
524
+ `)
525
+ .get(key, id) as any;
526
+
527
+ if (!row) return null;
528
+ return mapTurnRow(row);
529
+ }
530
+
531
+ async readRuntimeState(sessionKey: string) {
532
+ await this.init();
533
+ const key = normalizeSessionKey(sessionKey);
534
+
535
+ const db = this.#requireDb();
536
+ const row = db
537
+ .prepare(`
538
+ SELECT session_key, runtime_context_id, runtime_epoch, updated_at
539
+ FROM memory_runtime_state
540
+ WHERE session_key = ?
541
+ LIMIT 1
542
+ `)
543
+ .get(key) as any;
544
+
545
+ if (!row) return null;
546
+ return {
547
+ sessionKey: row.session_key,
548
+ runtimeContextId: row.runtime_context_id,
549
+ runtimeEpoch: row.runtime_epoch,
550
+ updatedAt: row.updated_at,
551
+ };
552
+ }
553
+
554
+ async upsertRuntimeState(input: { sessionKey?: string; runtimeContextId?: string; runtimeEpoch?: number } = {}) {
555
+ await this.init();
556
+
557
+ const sessionKey = normalizeSessionKey(input.sessionKey);
558
+ const runtimeContextId = nullableString(input.runtimeContextId) || makeRuntimeContextId("upsert");
559
+ const runtimeEpoch =
560
+ Number.isInteger(input.runtimeEpoch) && (input.runtimeEpoch as number) > 0 ? (input.runtimeEpoch as number) : 1;
561
+
562
+ const db = this.#requireDb();
563
+ const now = nowIso();
564
+
565
+ ensureSessionRow(db, sessionKey, null, now);
566
+
567
+ db.prepare(`
568
+ INSERT INTO memory_runtime_state (session_key, runtime_context_id, runtime_epoch, updated_at)
569
+ VALUES (?, ?, ?, ?)
570
+ ON CONFLICT(session_key) DO UPDATE SET
571
+ runtime_context_id = excluded.runtime_context_id,
572
+ runtime_epoch = excluded.runtime_epoch,
573
+ updated_at = excluded.updated_at
574
+ `).run(sessionKey, runtimeContextId, runtimeEpoch, now);
575
+
576
+ return {
577
+ sessionKey,
578
+ runtimeContextId,
579
+ runtimeEpoch,
580
+ updatedAt: now,
581
+ };
582
+ }
583
+
584
+ async bumpRuntimeContext(input: { sessionKey?: string; runtimeContextId?: string } = {}) {
585
+ await this.init();
586
+
587
+ const sessionKey = normalizeSessionKey(input.sessionKey);
588
+ const requestedRuntimeContextId = nullableString(input.runtimeContextId);
589
+ const db = this.#requireDb();
590
+
591
+ let attempts = 0;
592
+ while (true) {
593
+ attempts++;
594
+ try {
595
+ db.exec("BEGIN IMMEDIATE");
596
+
597
+ const current = db
598
+ .prepare(`
599
+ SELECT runtime_context_id, runtime_epoch
600
+ FROM memory_runtime_state
601
+ WHERE session_key = ?
602
+ LIMIT 1
603
+ `)
604
+ .get(sessionKey) as any;
605
+
606
+ const nextEpoch = current ? Number(current.runtime_epoch || 0) + 1 : 1;
607
+ const nextContextId = requestedRuntimeContextId || makeRuntimeContextId(`epoch${nextEpoch}`);
608
+ const now = nowIso();
609
+
610
+ ensureSessionRow(db, sessionKey, null, now);
611
+
612
+ db.prepare(`
613
+ INSERT INTO memory_runtime_state (session_key, runtime_context_id, runtime_epoch, updated_at)
614
+ VALUES (?, ?, ?, ?)
615
+ ON CONFLICT(session_key) DO UPDATE SET
616
+ runtime_context_id = excluded.runtime_context_id,
617
+ runtime_epoch = excluded.runtime_epoch,
618
+ updated_at = excluded.updated_at
619
+ `).run(sessionKey, nextContextId, nextEpoch, now);
620
+
621
+ db.exec("COMMIT");
622
+
623
+ return {
624
+ sessionKey,
625
+ runtimeContextId: nextContextId,
626
+ runtimeEpoch: nextEpoch,
627
+ updatedAt: now,
628
+ };
629
+ } catch (err) {
630
+ try {
631
+ db.exec("ROLLBACK");
632
+ } catch {
633
+ /* ignore */
634
+ }
635
+
636
+ if (isSqliteBusy(err) && attempts < 4) {
637
+ this.logger.warn?.(`[Memory] SQLite busy, retrying bumpRuntimeContext (attempt ${attempts}/4)`);
638
+ continue;
639
+ }
640
+
641
+ throw err;
642
+ }
643
+ }
644
+ }
645
+
646
+ async close() {
647
+ if (!this.db) return;
648
+ try {
649
+ this.db.close();
650
+ } finally {
651
+ this.db = null;
652
+ }
653
+ }
654
+
655
+ #requireDb(): InstanceType<typeof Database> {
656
+ if (!this.db) {
657
+ throw new Error("SqliteMemoryStore is not initialized. Call init() first.");
658
+ }
659
+ return this.db;
660
+ }
661
+ }
662
+
663
+ function normalizeBatch(batch: any) {
664
+ if (!batch || typeof batch !== "object") {
665
+ throw new Error("writeBatch() requires a batch object");
666
+ }
667
+
668
+ const sessionKey = normalizeSessionKey(batch.sessionKey);
669
+ const batchId = String(batch.batchId || batch.id || "").trim();
670
+ if (!batchId) {
671
+ throw new Error("writeBatch() requires batch.batchId (or batch.id)");
672
+ }
673
+
674
+ const compactedToTurnId = Object.hasOwn(batch, "compactedToTurnId")
675
+ ? nullableString(batch.compactedToTurnId)
676
+ : undefined;
677
+
678
+ return {
679
+ batchId,
680
+ sessionKey,
681
+ agentId: nullableString(batch.agentId),
682
+ turns: normalizeTurns(batch.turns),
683
+ snapshot: batch.snapshot ? normalizeSnapshot(batch.snapshot) : null,
684
+ cardsUpsert: normalizeCards(batch.cardsUpsert),
685
+ cardsDelete: normalizeCardDeletes(batch.cardsDelete),
686
+ compactedToTurnId,
687
+ };
688
+ }
689
+
690
+ function normalizeSessionKey(sessionKey: any): string {
691
+ const key = String(sessionKey || "").trim();
692
+ if (!key) throw new Error("sessionKey is required");
693
+ return key;
694
+ }
695
+
696
+ function normalizeTurns(turns: any) {
697
+ if (!Array.isArray(turns)) return [];
698
+
699
+ return turns.map((turn: any) => {
700
+ const role = String(turn?.role || "user").trim() || "user";
701
+ const content = String(turn?.content || "");
702
+
703
+ return {
704
+ id: nullableString(turn?.id) || `turn_${randomUUID()}`,
705
+ role,
706
+ content,
707
+ turnIndex: Number.isInteger(turn?.turnIndex) ? turn.turnIndex : null,
708
+ metadata: turn?.metadata ?? null,
709
+ createdAt: nullableString(turn?.createdAt) || nowIso(),
710
+ };
711
+ });
712
+ }
713
+
714
+ function normalizeSnapshot(snapshot: any) {
715
+ return {
716
+ id: nullableString(snapshot?.id) || `snapshot_${randomUUID()}`,
717
+ summaryText: String(snapshot?.summaryText || snapshot?.summary || ""),
718
+ openTasks: Array.isArray(snapshot?.openTasks) ? snapshot.openTasks : [],
719
+ decisions: Array.isArray(snapshot?.decisions) ? snapshot.decisions : [],
720
+ compactedToTurnId: nullableString(snapshot?.compactedToTurnId),
721
+ createdAt: nullableString(snapshot?.createdAt) || nowIso(),
722
+ };
723
+ }
724
+
725
+ function normalizeCards(cards: any) {
726
+ if (!Array.isArray(cards)) return [];
727
+
728
+ return cards.map((card: any) => {
729
+ const scope = normalizeScope(card?.scope);
730
+ const cardType = normalizeCardType(card?.cardType || card?.type);
731
+
732
+ return {
733
+ id: nullableString(card?.id) || `card_${randomUUID()}`,
734
+ scope,
735
+ cardType,
736
+ title: String(card?.title || ""),
737
+ body: String(card?.body || card?.content || ""),
738
+ confidence: clamp(Number(card?.confidence ?? 0.5) || 0.5, 0, 1),
739
+ pinned: Boolean(card?.pinned),
740
+ sourceTurnFrom: nullableString(card?.sourceTurnFrom),
741
+ sourceTurnTo: nullableString(card?.sourceTurnTo),
742
+ expiresAt: nullableString(card?.expiresAt),
743
+ createdAt: nullableString(card?.createdAt) || nowIso(),
744
+ updatedAt: nullableString(card?.updatedAt) || nowIso(),
745
+ };
746
+ });
747
+ }
748
+
749
+ function normalizeCardDeletes(cardIds: any) {
750
+ if (!Array.isArray(cardIds)) return [];
751
+ return cardIds.map(nullableString).filter(Boolean) as string[];
752
+ }
753
+
754
+ function normalizeScope(scope: any): string {
755
+ const value = String(scope || MemoryScopes.SESSION).toLowerCase();
756
+ if ((Object.values(MemoryScopes) as string[]).includes(value)) return value;
757
+ return MemoryScopes.SESSION;
758
+ }
759
+
760
+ function ensureSessionRow(
761
+ db: InstanceType<typeof Database>,
762
+ sessionKey: string,
763
+ agentId: string | null,
764
+ updatedAt: string = nowIso(),
765
+ ) {
766
+ db.prepare(`
767
+ INSERT INTO memory_sessions (id, session_key, agent_id, created_at, updated_at)
768
+ VALUES (?, ?, ?, ?, ?)
769
+ ON CONFLICT(session_key) DO UPDATE SET
770
+ agent_id = COALESCE(excluded.agent_id, memory_sessions.agent_id),
771
+ updated_at = excluded.updated_at
772
+ `).run(`session_${randomUUID()}`, sessionKey, agentId, updatedAt, updatedAt);
773
+ }
774
+
775
+ function normalizeCardType(cardType: any): string {
776
+ const value = String(cardType || MemoryCardTypes.CONTEXT).toLowerCase();
777
+ if ((Object.values(MemoryCardTypes) as string[]).includes(value)) return value;
778
+ return MemoryCardTypes.CONTEXT;
779
+ }
780
+
781
+ function nullableString(value: any): string | null {
782
+ if (value === undefined || value === null) return null;
783
+ const out = String(value).trim();
784
+ return out.length > 0 ? out : null;
785
+ }
786
+
787
+ function mapTurnRow(row: any) {
788
+ return {
789
+ id: row.id,
790
+ sessionKey: row.session_key,
791
+ turnIndex: row.turn_index,
792
+ role: row.role,
793
+ content: row.content,
794
+ metadata: safeJsonParse(row.metadata_json, null),
795
+ createdAt: row.created_at,
796
+ };
797
+ }
798
+
799
+ function mapSnapshotRow(row: any) {
800
+ return {
801
+ id: row.id,
802
+ sessionKey: row.session_key,
803
+ summaryText: row.summary_text,
804
+ openTasks: safeJsonParse(row.open_tasks_json, []),
805
+ decisions: safeJsonParse(row.decisions_json, []),
806
+ compactedToTurnId: row.compacted_to_turn_id,
807
+ createdAt: row.created_at,
808
+ };
809
+ }
810
+
811
+ function mapCardRow(row: any) {
812
+ return {
813
+ id: row.id,
814
+ sessionKey: row.session_key,
815
+ scope: row.scope,
816
+ cardType: row.card_type,
817
+ title: row.title,
818
+ body: row.body,
819
+ confidence: row.confidence,
820
+ pinned: row.pinned === 1,
821
+ sourceTurnFrom: row.source_turn_from,
822
+ sourceTurnTo: row.source_turn_to,
823
+ expiresAt: row.expires_at,
824
+ createdAt: row.created_at,
825
+ updatedAt: row.updated_at,
826
+ };
827
+ }
828
+
829
+ function makeRuntimeContextId(prefix: string = "runtime") {
830
+ const ts = Date.now().toString(36);
831
+ const rand = randomUUID().slice(0, 8);
832
+ return `${prefix}_${ts}_${rand}`;
833
+ }
834
+
835
+ function isSqliteBusy(err: unknown): boolean {
836
+ const msg = String((err as any)?.message || "");
837
+ return msg.includes("SQLITE_BUSY") || (err as any)?.code === "SQLITE_BUSY";
838
+ }