kongbrain 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/src/surreal.ts ADDED
@@ -0,0 +1,1371 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Surreal } from "surrealdb";
5
+ import type { SurrealConfig } from "./config.js";
6
+ import { swallow } from "./errors.js";
7
+
8
+ /** Record with a vector similarity score from SurrealDB search */
9
+ export interface VectorSearchResult {
10
+ id: string;
11
+ text: string;
12
+ score: number;
13
+ role?: string;
14
+ timestamp?: string;
15
+ importance?: number;
16
+ accessCount?: number;
17
+ source?: string;
18
+ sessionId?: string;
19
+ table: string;
20
+ embedding?: number[];
21
+ }
22
+
23
+ export interface TurnRecord {
24
+ session_id: string;
25
+ role: string;
26
+ text: string;
27
+ embedding: number[] | null;
28
+ token_count?: number;
29
+ tool_name?: string;
30
+ model?: string;
31
+ usage?: Record<string, unknown>;
32
+ }
33
+
34
+ export interface CoreMemoryEntry {
35
+ id: string;
36
+ text: string;
37
+ category: string;
38
+ priority: number;
39
+ tier: number;
40
+ active: boolean;
41
+ session_id?: string;
42
+ created_at?: string;
43
+ updated_at?: string;
44
+ }
45
+
46
+ export interface UtilityCacheEntry {
47
+ avg_utilization: number;
48
+ retrieval_count: number;
49
+ }
50
+
51
+ const RECORD_ID_RE = /^[a-zA-Z_][a-zA-Z0-9_]*:[a-zA-Z0-9_]+$/;
52
+
53
+ function assertRecordId(id: string): void {
54
+ if (!RECORD_ID_RE.test(id)) {
55
+ throw new Error(`Invalid record ID format: ${id.slice(0, 40)}`);
56
+ }
57
+ }
58
+
59
+ function patchOrderByFields(sql: string): string {
60
+ const s = sql.trim();
61
+ if (!/^\s*SELECT\b/i.test(s) || !/\bORDER\s+BY\b/i.test(s)) return sql;
62
+ if (/^\s*SELECT\s+\*/i.test(s)) return sql;
63
+
64
+ const selectMatch = s.match(/^\s*SELECT\s+([\s\S]+?)\s+FROM\b/i);
65
+ if (!selectMatch) return sql;
66
+ const selectClause = selectMatch[1];
67
+
68
+ const orderMatch = s.match(
69
+ /\bORDER\s+BY\s+([\s\S]+?)(?=\s+LIMIT\b|\s+GROUP\b|\s+HAVING\b|$)/i,
70
+ );
71
+ if (!orderMatch) return sql;
72
+
73
+ const orderFields = orderMatch[1]
74
+ .split(",")
75
+ .map((f) => f.trim().replace(/\s+(ASC|DESC)\s*$/i, "").trim())
76
+ .filter(Boolean);
77
+
78
+ const selectedFields = selectClause
79
+ .split(",")
80
+ .map((f) => f.trim().split(/\s+AS\s+/i)[0].trim())
81
+ .map((f) => f.split(".").pop()!)
82
+ .filter(Boolean)
83
+ .map((f) => f.toLowerCase());
84
+
85
+ const missing = orderFields.filter(
86
+ (f) => !selectedFields.includes(f.split(".").pop()!.toLowerCase()),
87
+ );
88
+
89
+ if (missing.length === 0) return sql;
90
+
91
+ return sql.replace(
92
+ /(\bSELECT\s+)([\s\S]+?)(\s+FROM\b)/i,
93
+ (_, pre, fields, post) => `${pre}${fields}, ${missing.join(", ")}${post}`,
94
+ );
95
+ }
96
+
97
+ const __dirname = dirname(fileURLToPath(import.meta.url));
98
+
99
+ /**
100
+ * SurrealDB store — wraps all database operations for the KongBrain plugin.
101
+ * Replaces the module-level singleton pattern from standalone KongBrain.
102
+ */
103
+ export class SurrealStore {
104
+ private db: Surreal;
105
+ private config: SurrealConfig;
106
+ private reconnecting: Promise<void> | null = null;
107
+ private shutdownFlag = false;
108
+
109
+ constructor(config: SurrealConfig) {
110
+ this.config = config;
111
+ this.db = new Surreal();
112
+ }
113
+
114
+ async initialize(): Promise<void> {
115
+ await this.db.connect(this.config.url, {
116
+ namespace: this.config.ns,
117
+ database: this.config.db,
118
+ authentication: { username: this.config.user, password: this.config.pass },
119
+ });
120
+ await this.runSchema();
121
+ }
122
+
123
+ markShutdown(): void {
124
+ this.shutdownFlag = true;
125
+ }
126
+
127
+ private async ensureConnected(): Promise<void> {
128
+ if (this.shutdownFlag) return;
129
+ if (this.db.isConnected) return;
130
+ if (this.reconnecting) return this.reconnecting;
131
+
132
+ this.reconnecting = (async () => {
133
+ const MAX_ATTEMPTS = 3;
134
+ const BACKOFF_MS = [500, 1500, 4000];
135
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
136
+ try {
137
+ console.warn(
138
+ `[warn] SurrealDB disconnected — reconnecting (attempt ${attempt}/${MAX_ATTEMPTS})...`,
139
+ );
140
+ this.db = new Surreal();
141
+ const CONNECT_TIMEOUT_MS = 5_000;
142
+ await Promise.race([
143
+ this.db.connect(this.config.url, {
144
+ namespace: this.config.ns,
145
+ database: this.config.db,
146
+ authentication: { username: this.config.user, password: this.config.pass },
147
+ }),
148
+ new Promise<never>((_, reject) =>
149
+ setTimeout(
150
+ () => reject(new Error(`SurrealDB connect timed out after ${CONNECT_TIMEOUT_MS}ms`)),
151
+ CONNECT_TIMEOUT_MS,
152
+ ),
153
+ ),
154
+ ]);
155
+ console.warn("[warn] SurrealDB reconnected successfully.");
156
+ return;
157
+ } catch (e) {
158
+ if (attempt < MAX_ATTEMPTS) {
159
+ await new Promise((r) => setTimeout(r, BACKOFF_MS[attempt - 1]));
160
+ } else {
161
+ console.error(`[ERROR] SurrealDB reconnection failed after ${MAX_ATTEMPTS} attempts.`);
162
+ throw e;
163
+ }
164
+ }
165
+ }
166
+ })().finally(() => {
167
+ this.reconnecting = null;
168
+ });
169
+
170
+ return this.reconnecting;
171
+ }
172
+
173
+ private async runSchema(): Promise<void> {
174
+ let schemaPath = join(__dirname, "schema.surql");
175
+ let schema: string;
176
+ try {
177
+ schema = readFileSync(schemaPath, "utf-8");
178
+ } catch {
179
+ schemaPath = join(__dirname, "..", "src", "schema.surql");
180
+ schema = readFileSync(schemaPath, "utf-8");
181
+ }
182
+ await this.db.query(schema);
183
+ }
184
+
185
+ getConnection(): Surreal {
186
+ return this.db;
187
+ }
188
+
189
+ isConnected(): boolean {
190
+ return this.db?.isConnected ?? false;
191
+ }
192
+
193
+ getInfo(): { url: string; ns: string; db: string; connected: boolean } {
194
+ return {
195
+ url: this.config.url,
196
+ ns: this.config.ns,
197
+ db: this.config.db,
198
+ connected: this.db?.isConnected ?? false,
199
+ };
200
+ }
201
+
202
+ async ping(): Promise<boolean> {
203
+ try {
204
+ await this.ensureConnected();
205
+ await this.db.query("RETURN 'ok'");
206
+ return true;
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ async close(): Promise<void> {
213
+ try {
214
+ this.markShutdown();
215
+ await this.db?.close();
216
+ } catch (e) {
217
+ swallow("surreal:close", e);
218
+ }
219
+ }
220
+
221
+ // ── Query helpers ──────────────────────────────────────────────────────
222
+
223
+ async queryFirst<T>(sql: string, bindings?: Record<string, unknown>): Promise<T[]> {
224
+ await this.ensureConnected();
225
+ const ns = this.config.ns;
226
+ const dbName = this.config.db;
227
+ const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
228
+ const result = await this.db.query<[T[]]>(fullSql, bindings);
229
+ const rows = Array.isArray(result) ? result[result.length - 1] : result;
230
+ return (Array.isArray(rows) ? rows : []).filter(Boolean);
231
+ }
232
+
233
+ async queryMulti<T = unknown>(
234
+ sql: string,
235
+ bindings?: Record<string, unknown>,
236
+ ): Promise<T | undefined> {
237
+ await this.ensureConnected();
238
+ const ns = this.config.ns;
239
+ const dbName = this.config.db;
240
+ const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
241
+ const raw = await this.db.query(fullSql, bindings);
242
+ const flat = (raw as unknown[]).flat();
243
+ return flat[flat.length - 1] as T | undefined;
244
+ }
245
+
246
+ async queryExec(sql: string, bindings?: Record<string, unknown>): Promise<void> {
247
+ await this.ensureConnected();
248
+ const ns = this.config.ns;
249
+ const dbName = this.config.db;
250
+ const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
251
+ await this.db.query(fullSql, bindings);
252
+ }
253
+
254
+ private async safeQuery(
255
+ sql: string,
256
+ bindings: Record<string, unknown>,
257
+ ): Promise<VectorSearchResult[]> {
258
+ try {
259
+ return await this.queryFirst<VectorSearchResult>(sql, bindings);
260
+ } catch (e) {
261
+ swallow.warn("surreal:safeQuery", e);
262
+ return [];
263
+ }
264
+ }
265
+
266
+ // ── Vector search ──────────────────────────────────────────────────────
267
+
268
+ async vectorSearch(
269
+ vec: number[],
270
+ sessionId: string,
271
+ limits: {
272
+ turn?: number;
273
+ identity?: number;
274
+ concept?: number;
275
+ memory?: number;
276
+ artifact?: number;
277
+ monologue?: number;
278
+ } = {},
279
+ withEmbeddings = false,
280
+ ): Promise<VectorSearchResult[]> {
281
+ const lim = {
282
+ turn: limits.turn ?? 20,
283
+ identity: limits.identity ?? 10,
284
+ concept: limits.concept ?? 15,
285
+ memory: limits.memory ?? 15,
286
+ artifact: limits.artifact ?? 10,
287
+ monologue: limits.monologue ?? 8,
288
+ };
289
+ const sessionTurnLim = Math.ceil(lim.turn / 2);
290
+ const crossTurnLim = lim.turn - sessionTurnLim;
291
+ const emb = withEmbeddings ? ", embedding" : "";
292
+
293
+ const [sessionTurns, crossTurns, concepts, memories, artifacts, monologues, identityChunks] =
294
+ await Promise.all([
295
+ this.safeQuery(
296
+ `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
297
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
298
+ FROM turn
299
+ WHERE embedding != NONE AND array::len(embedding) > 0
300
+ AND session_id = $sid
301
+ ORDER BY score DESC LIMIT $lim`,
302
+ { vec, lim: sessionTurnLim, sid: sessionId },
303
+ ),
304
+ this.safeQuery(
305
+ `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
306
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
307
+ FROM turn
308
+ WHERE embedding != NONE AND array::len(embedding) > 0
309
+ AND session_id != $sid
310
+ ORDER BY score DESC LIMIT $lim`,
311
+ { vec, lim: crossTurnLim, sid: sessionId },
312
+ ),
313
+ this.safeQuery(
314
+ `SELECT id, content AS text, stability AS importance, access_count AS accessCount,
315
+ created_at AS timestamp, 'concept' AS table,
316
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
317
+ FROM concept
318
+ WHERE embedding != NONE AND array::len(embedding) > 0
319
+ ORDER BY score DESC LIMIT $lim`,
320
+ { vec, lim: lim.concept },
321
+ ),
322
+ this.safeQuery(
323
+ `SELECT id, text, importance, access_count AS accessCount,
324
+ created_at AS timestamp, session_id AS sessionId, 'memory' AS table,
325
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
326
+ FROM memory
327
+ WHERE embedding != NONE AND array::len(embedding) > 0
328
+ AND (status = 'active' OR status IS NONE)
329
+ ORDER BY score DESC LIMIT $lim`,
330
+ { vec, lim: lim.memory },
331
+ ),
332
+ this.safeQuery(
333
+ `SELECT id, description AS text, 0 AS accessCount,
334
+ created_at AS timestamp, 'artifact' AS table,
335
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
336
+ FROM artifact
337
+ WHERE embedding != NONE AND array::len(embedding) > 0
338
+ ORDER BY score DESC LIMIT $lim`,
339
+ { vec, lim: lim.artifact },
340
+ ),
341
+ this.safeQuery(
342
+ `SELECT id, content AS text, category AS source, 0.5 AS importance, 0 AS accessCount,
343
+ timestamp, 'monologue' AS table,
344
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
345
+ FROM monologue
346
+ WHERE embedding != NONE AND array::len(embedding) > 0
347
+ ORDER BY score DESC LIMIT $lim`,
348
+ { vec, lim: lim.monologue },
349
+ ),
350
+ // Identity chunks — agent self-knowledge, searchable mid-conversation
351
+ this.safeQuery(
352
+ `SELECT id, text, importance, 0 AS accessCount,
353
+ 'identity_chunk' AS table,
354
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
355
+ FROM identity_chunk
356
+ WHERE embedding != NONE AND array::len(embedding) > 0
357
+ ORDER BY score DESC LIMIT $lim`,
358
+ { vec, lim: lim.identity },
359
+ ),
360
+ ]);
361
+ return [
362
+ ...sessionTurns,
363
+ ...crossTurns,
364
+ ...concepts,
365
+ ...memories,
366
+ ...artifacts,
367
+ ...monologues,
368
+ ...identityChunks,
369
+ ];
370
+ }
371
+
372
+ // ── Turn operations ────────────────────────────────────────────────────
373
+
374
+ async upsertTurn(turn: TurnRecord): Promise<string> {
375
+ const { embedding, ...rest } = turn;
376
+ const record = embedding?.length ? { ...rest, embedding } : rest;
377
+ const rows = await this.queryFirst<{ id: string }>(
378
+ `CREATE turn CONTENT $turn RETURN id`,
379
+ { turn: record },
380
+ );
381
+ return String(rows[0]?.id ?? "");
382
+ }
383
+
384
+ async getSessionTurns(
385
+ sessionId: string,
386
+ limit = 50,
387
+ ): Promise<{ role: string; text: string }[]> {
388
+ return this.queryFirst<{ role: string; text: string }>(
389
+ `SELECT role, text, timestamp FROM turn WHERE session_id = $sid ORDER BY timestamp ASC LIMIT $lim`,
390
+ { sid: sessionId, lim: limit },
391
+ );
392
+ }
393
+
394
+ async getSessionTurnsRich(
395
+ sessionId: string,
396
+ limit = 20,
397
+ ): Promise<{ role: string; text: string; tool_name?: string }[]> {
398
+ return this.queryFirst<{ role: string; text: string; tool_name?: string }>(
399
+ `SELECT role, text, tool_name, timestamp FROM turn WHERE session_id = $sid ORDER BY timestamp ASC LIMIT $lim`,
400
+ { sid: sessionId, lim: limit },
401
+ );
402
+ }
403
+
404
+ // ── Relation helpers ───────────────────────────────────────────────────
405
+
406
+ async relate(fromId: string, edge: string, toId: string): Promise<void> {
407
+ assertRecordId(fromId);
408
+ assertRecordId(toId);
409
+ const safeName = edge.replace(/[^a-zA-Z0-9_]/g, "");
410
+ await this.queryExec(`RELATE ${fromId}->${safeName}->${toId}`);
411
+ }
412
+
413
+ // ── 5-Pillar entity operations ─────────────────────────────────────────
414
+
415
+ async ensureAgent(name: string, model?: string): Promise<string> {
416
+ const rows = await this.queryFirst<{ id: string }>(
417
+ `SELECT id FROM agent WHERE name = $name LIMIT 1`,
418
+ { name },
419
+ );
420
+ if (rows.length > 0) return String(rows[0].id);
421
+ const created = await this.queryFirst<{ id: string }>(
422
+ `CREATE agent CONTENT { name: $name, model: $model } RETURN id`,
423
+ { name, ...(model != null ? { model } : {}) },
424
+ );
425
+ return String(created[0]?.id ?? "");
426
+ }
427
+
428
+ async ensureProject(name: string): Promise<string> {
429
+ const rows = await this.queryFirst<{ id: string }>(
430
+ `SELECT id FROM project WHERE name = $name LIMIT 1`,
431
+ { name },
432
+ );
433
+ if (rows.length > 0) return String(rows[0].id);
434
+ const created = await this.queryFirst<{ id: string }>(
435
+ `CREATE project CONTENT { name: $name } RETURN id`,
436
+ { name },
437
+ );
438
+ return String(created[0]?.id ?? "");
439
+ }
440
+
441
+ async createTask(description: string): Promise<string> {
442
+ const rows = await this.queryFirst<{ id: string }>(
443
+ `CREATE task CONTENT { description: $desc, status: "in_progress" } RETURN id`,
444
+ { desc: description },
445
+ );
446
+ return String(rows[0]?.id ?? "");
447
+ }
448
+
449
+ async createSession(agentId = "default"): Promise<string> {
450
+ const rows = await this.queryFirst<{ id: string }>(
451
+ `CREATE session CONTENT { agent_id: $agent_id } RETURN id`,
452
+ { agent_id: agentId },
453
+ );
454
+ return String(rows[0]?.id ?? "");
455
+ }
456
+
457
+ async updateSessionStats(
458
+ sessionId: string,
459
+ inputTokens: number,
460
+ outputTokens: number,
461
+ ): Promise<void> {
462
+ assertRecordId(sessionId);
463
+ await this.queryExec(
464
+ `UPDATE ${sessionId} SET
465
+ turn_count += 1,
466
+ total_input_tokens += $input,
467
+ total_output_tokens += $output,
468
+ last_active = time::now()`,
469
+ { input: inputTokens, output: outputTokens },
470
+ );
471
+ }
472
+
473
+ async endSession(sessionId: string, summary?: string): Promise<void> {
474
+ assertRecordId(sessionId);
475
+ if (summary) {
476
+ await this.queryExec(
477
+ `UPDATE ${sessionId} SET ended_at = time::now(), summary = $summary`,
478
+ { summary },
479
+ );
480
+ } else {
481
+ await this.queryExec(`UPDATE ${sessionId} SET ended_at = time::now()`);
482
+ }
483
+ }
484
+
485
+ async linkSessionToTask(sessionId: string, taskId: string): Promise<void> {
486
+ await this.queryExec(`RELATE ${sessionId}->session_task->${taskId}`);
487
+ }
488
+
489
+ async linkTaskToProject(taskId: string, projectId: string): Promise<void> {
490
+ await this.queryExec(`RELATE ${taskId}->task_part_of->${projectId}`);
491
+ }
492
+
493
+ async linkAgentToTask(agentId: string, taskId: string): Promise<void> {
494
+ await this.queryExec(`RELATE ${agentId}->performed->${taskId}`);
495
+ }
496
+
497
+ async linkAgentToProject(agentId: string, projectId: string): Promise<void> {
498
+ await this.queryExec(`RELATE ${agentId}->owns->${projectId}`);
499
+ }
500
+
501
+ // ── Graph traversal ────────────────────────────────────────────────────
502
+
503
+ async graphExpand(
504
+ nodeIds: string[],
505
+ queryVec: number[],
506
+ hops = 1,
507
+ ): Promise<VectorSearchResult[]> {
508
+ if (nodeIds.length === 0) return [];
509
+
510
+ const forwardEdges = [
511
+ // Semantic edges
512
+ "responds_to", "mentions", "related_to", "narrower", "broader",
513
+ "about_concept", "reflects_on", "skill_from_task",
514
+ // Structural pillar edges (Agent→Project→Task→Artifact→Concept)
515
+ "owns", "performed", "task_part_of", "session_task",
516
+ "produced", "derived_from", "relevant_to", "used_in",
517
+ "artifact_mentions",
518
+ ];
519
+ const reverseEdges = [
520
+ "reflects_on", "skill_from_task",
521
+ // Reverse pillar traversal (find what produced an artifact, what task a concept came from)
522
+ "produced", "derived_from", "performed", "owns",
523
+ ];
524
+
525
+ const scoreExpr =
526
+ ", IF embedding != NONE AND array::len(embedding) > 0 THEN vector::similarity::cosine(embedding, $vec) ELSE 0 END AS score";
527
+ const bindings = { vec: queryVec };
528
+ const selectFields = `SELECT id, text, content, description, importance, stability,
529
+ access_count AS accessCount, created_at AS timestamp,
530
+ meta::tb(id) AS table${scoreExpr}`;
531
+
532
+ const seen = new Set<string>(nodeIds);
533
+ const allNeighbors: VectorSearchResult[] = [];
534
+ let frontier = nodeIds.slice(0, 5).filter((id) => RECORD_ID_RE.test(id));
535
+
536
+ for (let hop = 0; hop < hops && frontier.length > 0; hop++) {
537
+ const forwardQueries = frontier.flatMap((id) =>
538
+ forwardEdges.map((edge) =>
539
+ this.queryFirst<any>(`${selectFields} FROM ${id}->${edge}->? LIMIT 3`, bindings).catch(
540
+ (e) => {
541
+ swallow.warn("surreal:graphExpand", e);
542
+ return [] as Record<string, unknown>[];
543
+ },
544
+ ),
545
+ ),
546
+ );
547
+
548
+ const reverseQueries = frontier.flatMap((id) =>
549
+ reverseEdges.map((edge) =>
550
+ this.queryFirst<any>(`${selectFields} FROM ${id}<-${edge}<-? LIMIT 3`, bindings).catch(
551
+ (e) => {
552
+ swallow.warn("surreal:graphExpand", e);
553
+ return [] as Record<string, unknown>[];
554
+ },
555
+ ),
556
+ ),
557
+ );
558
+
559
+ const queryResults = await Promise.all([...forwardQueries, ...reverseQueries]);
560
+ const nextFrontier: { id: string; score: number }[] = [];
561
+
562
+ for (const rows of queryResults) {
563
+ for (const row of rows) {
564
+ const nodeId = String(row.id);
565
+ if (seen.has(nodeId)) continue;
566
+ seen.add(nodeId);
567
+
568
+ const text = row.text ?? row.content ?? row.description ?? null;
569
+ if (text) {
570
+ const score = row.score ?? 0;
571
+ allNeighbors.push({
572
+ text,
573
+ importance: row.importance ?? row.stability,
574
+ accessCount: row.accessCount,
575
+ timestamp: row.timestamp,
576
+ table: String(row.table ?? "unknown"),
577
+ id: nodeId,
578
+ score,
579
+ });
580
+ if (RECORD_ID_RE.test(nodeId)) {
581
+ nextFrontier.push({ id: nodeId, score });
582
+ }
583
+ }
584
+ }
585
+ }
586
+
587
+ frontier = nextFrontier
588
+ .sort((a, b) => b.score - a.score)
589
+ .slice(0, 3)
590
+ .map((n) => n.id);
591
+ }
592
+
593
+ return allNeighbors;
594
+ }
595
+
596
+ async bumpAccessCounts(ids: string[]): Promise<void> {
597
+ for (const id of ids) {
598
+ try {
599
+ assertRecordId(id);
600
+ await this.queryExec(
601
+ `UPDATE ${id} SET access_count += 1, last_accessed = time::now()`,
602
+ );
603
+ } catch (e) {
604
+ swallow.warn("surreal:bumpAccessCounts", e);
605
+ }
606
+ }
607
+ }
608
+
609
+ // ── Concept / Memory / Artifact CRUD ───────────────────────────────────
610
+
611
+ async upsertConcept(
612
+ content: string,
613
+ embedding: number[] | null,
614
+ source?: string,
615
+ ): Promise<string> {
616
+ const rows = await this.queryFirst<{ id: string }>(
617
+ `SELECT id FROM concept WHERE string::lowercase(content) = string::lowercase($content) LIMIT 1`,
618
+ { content },
619
+ );
620
+ if (rows.length > 0) {
621
+ const id = String(rows[0].id);
622
+ await this.queryExec(`UPDATE ${id} SET access_count += 1, last_accessed = time::now()`);
623
+ return id;
624
+ }
625
+ const emb = embedding?.length ? embedding : undefined;
626
+ const record: Record<string, unknown> = { content, source: source ?? undefined };
627
+ if (emb) record.embedding = emb;
628
+ const created = await this.queryFirst<{ id: string }>(
629
+ `CREATE concept CONTENT $record RETURN id`,
630
+ { record },
631
+ );
632
+ return String(created[0]?.id ?? "");
633
+ }
634
+
635
+ async createArtifact(
636
+ path: string,
637
+ type: string,
638
+ description: string,
639
+ embedding: number[] | null,
640
+ ): Promise<string> {
641
+ const record: Record<string, unknown> = { path, type, description };
642
+ if (embedding?.length) record.embedding = embedding;
643
+ const rows = await this.queryFirst<{ id: string }>(
644
+ `CREATE artifact CONTENT $record RETURN id`,
645
+ { record },
646
+ );
647
+ return String(rows[0]?.id ?? "");
648
+ }
649
+
650
+ async createMemory(
651
+ text: string,
652
+ embedding: number[] | null,
653
+ importance: number,
654
+ category?: string,
655
+ sessionId?: string,
656
+ ): Promise<string> {
657
+ const source = category ?? "general";
658
+
659
+ if (embedding?.length) {
660
+ const dupes = await this.queryFirst<{
661
+ id: string;
662
+ importance: number;
663
+ score: number;
664
+ }>(
665
+ `SELECT id, importance,
666
+ vector::similarity::cosine(embedding, $vec) AS score
667
+ FROM memory
668
+ WHERE embedding != NONE AND array::len(embedding) > 0
669
+ AND category = $cat
670
+ ORDER BY score DESC
671
+ LIMIT 1`,
672
+ { vec: embedding, cat: source },
673
+ );
674
+ if (dupes.length > 0 && dupes[0].score > 0.92) {
675
+ const existing = dupes[0];
676
+ const newImp = Math.max(existing.importance ?? 0, importance);
677
+ await this.queryExec(
678
+ `UPDATE ${existing.id} SET access_count += 1, importance = $imp, last_accessed = time::now()`,
679
+ { imp: newImp },
680
+ );
681
+ return String(existing.id);
682
+ }
683
+ }
684
+
685
+ const record: Record<string, unknown> = { text, importance, category: source, source };
686
+ if (embedding?.length) record.embedding = embedding;
687
+ if (sessionId) record.session_id = sessionId;
688
+ const rows = await this.queryFirst<{ id: string }>(
689
+ `CREATE memory CONTENT $record RETURN id`,
690
+ { record },
691
+ );
692
+ return String(rows[0]?.id ?? "");
693
+ }
694
+
695
+ async createMonologue(
696
+ sessionId: string,
697
+ category: string,
698
+ content: string,
699
+ embedding: number[] | null,
700
+ ): Promise<string> {
701
+ const record: Record<string, unknown> = { session_id: sessionId, category, content };
702
+ if (embedding?.length) record.embedding = embedding;
703
+ const rows = await this.queryFirst<{ id: string }>(
704
+ `CREATE monologue CONTENT $record RETURN id`,
705
+ { record },
706
+ );
707
+ return String(rows[0]?.id ?? "");
708
+ }
709
+
710
+ // ── Core Memory (Tier 0/1) ─────────────────────────────────────────────
711
+
712
+ async getAllCoreMemory(tier?: number): Promise<CoreMemoryEntry[]> {
713
+ try {
714
+ if (tier != null) {
715
+ return await this.queryFirst<CoreMemoryEntry>(
716
+ `SELECT * FROM core_memory WHERE active = true AND tier = $tier ORDER BY priority DESC`,
717
+ { tier },
718
+ );
719
+ }
720
+ return await this.queryFirst<CoreMemoryEntry>(
721
+ `SELECT * FROM core_memory WHERE active = true ORDER BY tier ASC, priority DESC`,
722
+ );
723
+ } catch (e) {
724
+ swallow.warn("surreal:getAllCoreMemory", e);
725
+ return [];
726
+ }
727
+ }
728
+
729
+ async createCoreMemory(
730
+ text: string,
731
+ category: string,
732
+ priority: number,
733
+ tier: number,
734
+ sessionId?: string,
735
+ ): Promise<string> {
736
+ const record: Record<string, unknown> = { text, category, priority, tier, active: true };
737
+ if (sessionId) record.session_id = sessionId;
738
+ const rows = await this.queryFirst<{ id: string }>(
739
+ `CREATE core_memory CONTENT $record RETURN id`,
740
+ { record },
741
+ );
742
+ const id = String(rows[0]?.id ?? "");
743
+ if (!id) throw new Error("createCoreMemory: CREATE returned no ID");
744
+ return id;
745
+ }
746
+
747
+ async updateCoreMemory(
748
+ id: string,
749
+ fields: Partial<Pick<CoreMemoryEntry, "text" | "category" | "priority" | "tier" | "active">>,
750
+ ): Promise<boolean> {
751
+ assertRecordId(id);
752
+ const sets: string[] = [];
753
+ const bindings: Record<string, unknown> = {};
754
+ for (const [key, val] of Object.entries(fields)) {
755
+ if (val !== undefined) {
756
+ sets.push(`${key} = $${key}`);
757
+ bindings[key] = val;
758
+ }
759
+ }
760
+ if (sets.length === 0) return false;
761
+ sets.push("updated_at = time::now()");
762
+ const rows = await this.queryFirst<{ id: string }>(
763
+ `UPDATE ${id} SET ${sets.join(", ")} RETURN id`,
764
+ bindings,
765
+ );
766
+ return rows.length > 0;
767
+ }
768
+
769
+ async deleteCoreMemory(id: string): Promise<void> {
770
+ assertRecordId(id);
771
+ await this.queryExec(`UPDATE ${id} SET active = false, updated_at = time::now()`);
772
+ }
773
+
774
+ async deactivateSessionMemories(sessionId: string): Promise<void> {
775
+ try {
776
+ await this.queryExec(
777
+ `UPDATE core_memory SET active = false, updated_at = time::now() WHERE session_id = $sid AND tier = 1`,
778
+ { sid: sessionId },
779
+ );
780
+ } catch (e) {
781
+ swallow.warn("surreal:deactivateSessionMemories", e);
782
+ }
783
+ }
784
+
785
+ // ── Wakeup & lifecycle queries ─────────────────────────────────────────
786
+
787
+ async getLatestHandoff(): Promise<{ text: string; created_at: string } | null> {
788
+ try {
789
+ const rows = await this.queryFirst<{ text: string; created_at: string }>(
790
+ `SELECT text, created_at FROM memory WHERE category = "handoff" ORDER BY created_at DESC LIMIT 1`,
791
+ );
792
+ return rows[0] ?? null;
793
+ } catch (e) {
794
+ swallow.warn("surreal:getLatestHandoff", e);
795
+ return null;
796
+ }
797
+ }
798
+
799
+ async countResolvedSinceHandoff(handoffCreatedAt: string): Promise<number> {
800
+ try {
801
+ const rows = await this.queryFirst<{ count: number }>(
802
+ `SELECT count() AS count FROM memory WHERE status = 'resolved' AND resolved_at > $ts GROUP ALL`,
803
+ { ts: handoffCreatedAt },
804
+ );
805
+ return rows[0]?.count ?? 0;
806
+ } catch (e) {
807
+ swallow.warn("surreal:countResolvedSinceHandoff", e);
808
+ return 0;
809
+ }
810
+ }
811
+
812
+ async getAllIdentityChunks(): Promise<{ text: string }[]> {
813
+ try {
814
+ return await this.queryFirst<{ text: string }>(
815
+ `SELECT text, chunk_index FROM identity_chunk ORDER BY chunk_index ASC`,
816
+ );
817
+ } catch (e) {
818
+ swallow.warn("surreal:getAllIdentityChunks", e);
819
+ return [];
820
+ }
821
+ }
822
+
823
+ async getRecentMonologues(
824
+ limit = 5,
825
+ ): Promise<{ category: string; content: string; timestamp: string }[]> {
826
+ try {
827
+ return await this.queryFirst<{ category: string; content: string; timestamp: string }>(
828
+ `SELECT category, content, timestamp FROM monologue ORDER BY timestamp DESC LIMIT $lim`,
829
+ { lim: limit },
830
+ );
831
+ } catch (e) {
832
+ swallow.warn("surreal:getRecentMonologues", e);
833
+ return [];
834
+ }
835
+ }
836
+
837
+ async getPreviousSessionTurns(
838
+ currentSessionId?: string,
839
+ limit = 10,
840
+ ): Promise<{ role: string; text: string; tool_name?: string; timestamp: string }[]> {
841
+ try {
842
+ let prevSessionQuery: string;
843
+ const bindings: Record<string, unknown> = { lim: limit };
844
+
845
+ if (currentSessionId) {
846
+ prevSessionQuery = `SELECT id, started_at FROM session WHERE id != $current ORDER BY started_at DESC LIMIT 1`;
847
+ bindings.current = currentSessionId;
848
+ } else {
849
+ prevSessionQuery = `SELECT id, started_at FROM session ORDER BY started_at DESC LIMIT 1`;
850
+ }
851
+
852
+ const sessionRows = await this.queryFirst<{ id: string }>(prevSessionQuery, bindings);
853
+ if (sessionRows.length === 0) return [];
854
+
855
+ const prevSessionId = String(sessionRows[0].id);
856
+ const turns = await this.queryFirst<{
857
+ role: string;
858
+ text: string;
859
+ tool_name?: string;
860
+ timestamp: string;
861
+ }>(
862
+ `SELECT role, text, tool_name, timestamp FROM turn
863
+ WHERE session_id = $sid AND text != NONE AND text != ""
864
+ ORDER BY timestamp DESC LIMIT $lim`,
865
+ { sid: prevSessionId, lim: limit },
866
+ );
867
+
868
+ return turns.reverse();
869
+ } catch (e) {
870
+ swallow.warn("surreal:getPreviousSessionTurns", e);
871
+ return [];
872
+ }
873
+ }
874
+
875
+ async getUnresolvedMemories(
876
+ limit = 5,
877
+ ): Promise<{ id: string; text: string; importance: number; category: string }[]> {
878
+ try {
879
+ return await this.queryFirst<{
880
+ id: string;
881
+ text: string;
882
+ importance: number;
883
+ category: string;
884
+ }>(
885
+ `SELECT id, text,
886
+ math::max([importance - math::min([math::floor(duration::days(time::now() - created_at) / 7), 3]), 0]) AS importance,
887
+ category
888
+ FROM memory
889
+ WHERE (status IS NONE OR status != 'resolved')
890
+ AND category NOT IN ['handoff', 'monologue', 'reflection', 'compaction', 'consolidation']
891
+ AND importance >= 6
892
+ ORDER BY importance DESC
893
+ LIMIT $lim`,
894
+ { lim: limit },
895
+ );
896
+ } catch (e) {
897
+ swallow.warn("surreal:getUnresolvedMemories", e);
898
+ return [];
899
+ }
900
+ }
901
+
902
+ async getRecentFailedCausal(
903
+ limit = 3,
904
+ ): Promise<{ description: string; chain_type: string }[]> {
905
+ try {
906
+ return await this.queryFirst<{ description: string; chain_type: string }>(
907
+ `SELECT description, chain_type, created_at FROM causal_chain WHERE success = false ORDER BY created_at DESC LIMIT $lim`,
908
+ { lim: limit },
909
+ );
910
+ } catch (e) {
911
+ swallow.warn("surreal:getRecentFailedCausal", e);
912
+ return [];
913
+ }
914
+ }
915
+
916
+ async resolveMemory(memoryId: string): Promise<boolean> {
917
+ try {
918
+ await this.queryFirst(
919
+ `UPDATE type::record($id) SET status = 'resolved', resolved_at = time::now()`,
920
+ { id: memoryId },
921
+ );
922
+ return true;
923
+ } catch (e) {
924
+ swallow.warn("surreal:resolveMemory", e);
925
+ return false;
926
+ }
927
+ }
928
+
929
+ // ── Utility cache ──────────────────────────────────────────────────────
930
+
931
+ async updateUtilityCache(memoryId: string, utilization: number): Promise<void> {
932
+ try {
933
+ await this.queryExec(
934
+ `UPSERT memory_utility_cache SET
935
+ memory_id = $mid,
936
+ retrieval_count += 1,
937
+ avg_utilization = IF retrieval_count > 1
938
+ THEN (avg_utilization * (retrieval_count - 1) + $util) / retrieval_count
939
+ ELSE $util
940
+ END,
941
+ last_updated = time::now()
942
+ WHERE memory_id = $mid`,
943
+ { mid: memoryId, util: utilization },
944
+ );
945
+ } catch (e) {
946
+ swallow.warn("surreal:updateUtilityCache", e);
947
+ }
948
+ }
949
+
950
+ async getUtilityFromCache(ids: string[]): Promise<Map<string, number>> {
951
+ const result = new Map<string, number>();
952
+ if (ids.length === 0) return result;
953
+ try {
954
+ const rows = await this.queryFirst<{
955
+ memory_id: string;
956
+ avg_utilization: number;
957
+ }>(
958
+ `SELECT memory_id, avg_utilization FROM memory_utility_cache WHERE memory_id IN $ids`,
959
+ { ids },
960
+ );
961
+ for (const row of rows) {
962
+ if (row.avg_utilization != null) result.set(String(row.memory_id), row.avg_utilization);
963
+ }
964
+ } catch (e) {
965
+ swallow.warn("surreal:getUtilityFromCache", e);
966
+ }
967
+ return result;
968
+ }
969
+
970
+ async getUtilityCacheEntries(ids: string[]): Promise<Map<string, UtilityCacheEntry>> {
971
+ const result = new Map<string, UtilityCacheEntry>();
972
+ if (ids.length === 0) return result;
973
+ try {
974
+ const rows = await this.queryFirst<{
975
+ memory_id: string;
976
+ avg_utilization: number;
977
+ retrieval_count: number;
978
+ }>(
979
+ `SELECT memory_id, avg_utilization, retrieval_count FROM memory_utility_cache WHERE memory_id IN $ids`,
980
+ { ids },
981
+ );
982
+ for (const row of rows) {
983
+ if (row.avg_utilization != null) {
984
+ result.set(String(row.memory_id), {
985
+ avg_utilization: row.avg_utilization,
986
+ retrieval_count: row.retrieval_count ?? 0,
987
+ });
988
+ }
989
+ }
990
+ } catch (e) {
991
+ swallow.warn("surreal:getUtilityCacheEntries", e);
992
+ }
993
+ return result;
994
+ }
995
+
996
+ // ── Maintenance operations ─────────────────────────────────────────────
997
+
998
+ async runMemoryMaintenance(): Promise<void> {
999
+ try {
1000
+ await this.queryExec(
1001
+ `UPDATE memory SET importance = math::max([importance * 0.95, 2.0]) WHERE importance > 2.0`,
1002
+ );
1003
+ await this.queryExec(
1004
+ `UPDATE memory SET importance = math::max([importance, 3 + ((
1005
+ SELECT VALUE avg_utilization FROM memory_utility_cache WHERE memory_id = string::concat(meta::tb(id), ":", meta::id(id)) LIMIT 1
1006
+ )[0] ?? 0) * 4]) WHERE importance < 7`,
1007
+ );
1008
+ } catch (e) {
1009
+ swallow.warn("surreal:runMemoryMaintenance", e);
1010
+ }
1011
+ }
1012
+
1013
+ async garbageCollectMemories(): Promise<number> {
1014
+ try {
1015
+ const countRows = await this.queryFirst<{ count: number }>(
1016
+ `SELECT count() AS count FROM memory GROUP ALL`,
1017
+ );
1018
+ const count = countRows[0]?.count ?? 0;
1019
+ if (count <= 200) return 0;
1020
+
1021
+ const pruned = await this.db.query(
1022
+ `LET $stale = (
1023
+ SELECT id FROM memory
1024
+ WHERE created_at < time::now() - 14d
1025
+ AND importance <= 2.0
1026
+ AND (access_count = 0 OR access_count IS NONE)
1027
+ AND string::concat("memory:", id) NOT IN (
1028
+ SELECT VALUE memory_id FROM (
1029
+ SELECT memory_id FROM retrieval_outcome
1030
+ WHERE utilization > 0.2
1031
+ GROUP BY memory_id
1032
+ )
1033
+ )
1034
+ LIMIT 50
1035
+ );
1036
+ FOR $m IN $stale { DELETE $m.id; };
1037
+ RETURN array::len($stale);`,
1038
+ );
1039
+ return Number(pruned ?? 0);
1040
+ } catch (e) {
1041
+ swallow.warn("surreal:garbageCollectMemories", e);
1042
+ return 0;
1043
+ }
1044
+ }
1045
+
1046
+ async archiveOldTurns(): Promise<number> {
1047
+ try {
1048
+ const countRows = await this.queryFirst<{ count: number }>(
1049
+ `SELECT count() AS count FROM turn GROUP ALL`,
1050
+ );
1051
+ const count = countRows[0]?.count ?? 0;
1052
+ if (count <= 2000) return 0;
1053
+
1054
+ const archived = await this.queryMulti<number>(
1055
+ `LET $stale = (SELECT id FROM turn WHERE timestamp < time::now() - 7d AND id NOT IN (SELECT VALUE memory_id FROM retrieval_outcome WHERE memory_table = 'turn'));
1056
+ FOR $t IN $stale {
1057
+ INSERT INTO turn_archive (SELECT * FROM ONLY $t.id);
1058
+ DELETE $t.id;
1059
+ };
1060
+ RETURN array::len($stale);`,
1061
+ );
1062
+ return Number(archived ?? 0);
1063
+ } catch (e) {
1064
+ swallow.warn("surreal:archiveOldTurns", e);
1065
+ return 0;
1066
+ }
1067
+ }
1068
+
1069
+ async consolidateMemories(embedFn: (text: string) => Promise<number[]>): Promise<number> {
1070
+ try {
1071
+ const countRows = await this.queryFirst<{ count: number }>(
1072
+ `SELECT count() AS count FROM memory GROUP ALL`,
1073
+ );
1074
+ const count = countRows[0]?.count ?? 0;
1075
+ if (count <= 50) return 0;
1076
+
1077
+ let merged = 0;
1078
+ const seen = new Set<string>();
1079
+
1080
+ // Pass 1: Vector similarity dedup
1081
+ const embMemories = await this.queryFirst<{
1082
+ id: string;
1083
+ text: string;
1084
+ importance: number;
1085
+ category: string;
1086
+ access_count: number;
1087
+ embedding: number[];
1088
+ }>(
1089
+ `SELECT id, text, importance, category, access_count, embedding, created_at
1090
+ FROM memory
1091
+ WHERE embedding != NONE AND array::len(embedding) > 0
1092
+ ORDER BY created_at ASC
1093
+ LIMIT 50`,
1094
+ );
1095
+
1096
+ for (const mem of embMemories) {
1097
+ if (seen.has(String(mem.id))) continue;
1098
+
1099
+ const dupes = await this.queryFirst<{
1100
+ id: string;
1101
+ importance: number;
1102
+ access_count: number;
1103
+ score: number;
1104
+ }>(
1105
+ `SELECT id, importance, access_count,
1106
+ vector::similarity::cosine(embedding, $vec) AS score
1107
+ FROM memory
1108
+ WHERE id != $mid
1109
+ AND category = $cat
1110
+ AND embedding != NONE AND array::len(embedding) > 0
1111
+ ORDER BY score DESC
1112
+ LIMIT 3`,
1113
+ { vec: mem.embedding, mid: mem.id, cat: mem.category },
1114
+ );
1115
+
1116
+ for (const dupe of dupes) {
1117
+ if (dupe.score < 0.88) break;
1118
+ if (seen.has(String(dupe.id))) continue;
1119
+
1120
+ const keepMem =
1121
+ mem.importance > dupe.importance ||
1122
+ (mem.importance === dupe.importance &&
1123
+ (mem.access_count ?? 0) >= (dupe.access_count ?? 0));
1124
+ const [keep, drop] = keepMem ? [mem.id, dupe.id] : [dupe.id, mem.id];
1125
+ assertRecordId(String(keep));
1126
+ assertRecordId(String(drop));
1127
+ await this.queryExec(
1128
+ `UPDATE ${keep} SET access_count += 1, importance = math::max([importance, $imp])`,
1129
+ { imp: dupe.importance },
1130
+ );
1131
+ await this.queryExec(`DELETE ${drop}`);
1132
+ seen.add(String(drop));
1133
+ merged++;
1134
+ }
1135
+ }
1136
+
1137
+ // Pass 2: Backfill embeddings for memories missing them
1138
+ const unembedded = await this.queryFirst<{
1139
+ id: string;
1140
+ text: string;
1141
+ importance: number;
1142
+ category: string;
1143
+ access_count: number;
1144
+ }>(
1145
+ `SELECT id, text, importance, category, access_count
1146
+ FROM memory
1147
+ WHERE embedding IS NONE OR array::len(embedding) = 0
1148
+ LIMIT 20`,
1149
+ );
1150
+
1151
+ for (const mem of unembedded) {
1152
+ if (seen.has(String(mem.id))) continue;
1153
+ try {
1154
+ const emb = await embedFn(mem.text);
1155
+ if (!emb) continue;
1156
+ await this.queryExec(`UPDATE ${mem.id} SET embedding = $emb`, { emb });
1157
+
1158
+ const dupes = await this.queryFirst<{
1159
+ id: string;
1160
+ importance: number;
1161
+ access_count: number;
1162
+ score: number;
1163
+ }>(
1164
+ `SELECT id, importance, access_count,
1165
+ vector::similarity::cosine(embedding, $vec) AS score
1166
+ FROM memory
1167
+ WHERE id != $mid
1168
+ AND category = $cat
1169
+ AND embedding != NONE AND array::len(embedding) > 0
1170
+ ORDER BY score DESC
1171
+ LIMIT 3`,
1172
+ { vec: emb, mid: mem.id, cat: mem.category },
1173
+ );
1174
+ for (const dupe of dupes) {
1175
+ if (dupe.score < 0.88) break;
1176
+ if (seen.has(String(dupe.id))) continue;
1177
+ const keepMem =
1178
+ mem.importance > dupe.importance ||
1179
+ (mem.importance === dupe.importance &&
1180
+ (mem.access_count ?? 0) >= (dupe.access_count ?? 0));
1181
+ const [keep, drop] = keepMem ? [mem.id, dupe.id] : [dupe.id, mem.id];
1182
+ assertRecordId(String(keep));
1183
+ assertRecordId(String(drop));
1184
+ await this.queryExec(
1185
+ `UPDATE ${keep} SET access_count += 1, importance = math::max([importance, $imp])`,
1186
+ { imp: dupe.importance },
1187
+ );
1188
+ await this.queryExec(`DELETE ${drop}`);
1189
+ seen.add(String(drop));
1190
+ merged++;
1191
+ }
1192
+ } catch (e) {
1193
+ swallow.warn("surreal:consolidate-backfill", e);
1194
+ }
1195
+ }
1196
+
1197
+ return merged;
1198
+ } catch (e) {
1199
+ swallow.warn("surreal:consolidateMemories", e);
1200
+ return 0;
1201
+ }
1202
+ }
1203
+
1204
+ // ── Retrieval session memory ───────────────────────────────────────────
1205
+
1206
+ async getSessionRetrievedMemories(
1207
+ sessionId: string,
1208
+ ): Promise<{ id: string; text: string }[]> {
1209
+ try {
1210
+ const rows = await this.queryFirst<{ memory_id: string }>(
1211
+ `SELECT memory_id FROM retrieval_outcome WHERE session_id = $sid AND memory_table = 'memory' GROUP BY memory_id`,
1212
+ { sid: sessionId },
1213
+ );
1214
+ if (rows.length === 0) return [];
1215
+ const ids = rows.map((r) => r.memory_id).filter(Boolean);
1216
+ if (ids.length === 0) return [];
1217
+ return this.queryFirst<{ id: string; text: string }>(
1218
+ `SELECT id, text FROM memory WHERE id IN $ids AND (status = 'active' OR status IS NONE)`,
1219
+ { ids },
1220
+ );
1221
+ } catch (e) {
1222
+ swallow.warn("surreal:getSessionRetrievedMemories", e);
1223
+ return [];
1224
+ }
1225
+ }
1226
+
1227
+ // ── Fibonacci resurfacing ──────────────────────────────────────────────
1228
+
1229
+ async markSurfaceable(memoryId: string): Promise<void> {
1230
+ await this.queryExec(
1231
+ `UPDATE $id SET surfaceable = true, fib_index = 0, surface_count = 0, next_surface_at = time::now() + 1d`,
1232
+ { id: memoryId },
1233
+ );
1234
+ }
1235
+
1236
+ async getDueMemories(
1237
+ limit = 5,
1238
+ ): Promise<
1239
+ {
1240
+ id: string;
1241
+ text: string;
1242
+ importance: number;
1243
+ fib_index: number;
1244
+ surface_count: number;
1245
+ created_at: string;
1246
+ }[]
1247
+ > {
1248
+ return (
1249
+ (await this.queryFirst<any>(
1250
+ `SELECT id, text, importance, fib_index, surface_count, created_at
1251
+ FROM memory
1252
+ WHERE surfaceable = true
1253
+ AND next_surface_at <= time::now()
1254
+ AND status = 'active'
1255
+ ORDER BY importance DESC
1256
+ LIMIT $lim`,
1257
+ { lim: limit },
1258
+ )) ?? []
1259
+ );
1260
+ }
1261
+
1262
+ // ── Compaction checkpoints ─────────────────────────────────────────────
1263
+
1264
+ async createCompactionCheckpoint(
1265
+ sessionId: string,
1266
+ rangeStart: number,
1267
+ rangeEnd: number,
1268
+ ): Promise<string> {
1269
+ const rows = await this.queryFirst<{ id: string }>(
1270
+ `CREATE compaction_checkpoint CONTENT $data RETURN id`,
1271
+ {
1272
+ data: {
1273
+ session_id: sessionId,
1274
+ msg_range_start: rangeStart,
1275
+ msg_range_end: rangeEnd,
1276
+ status: "pending",
1277
+ },
1278
+ },
1279
+ );
1280
+ return String(rows[0]?.id ?? "");
1281
+ }
1282
+
1283
+ async completeCompactionCheckpoint(
1284
+ checkpointId: string,
1285
+ memoryId: string,
1286
+ ): Promise<void> {
1287
+ assertRecordId(checkpointId);
1288
+ await this.queryExec(`UPDATE ${checkpointId} SET status = "complete", memory_id = $mid`, {
1289
+ mid: memoryId,
1290
+ });
1291
+ }
1292
+
1293
+ async getPendingCheckpoints(
1294
+ sessionId: string,
1295
+ ): Promise<{ id: string; msg_range_start: number; msg_range_end: number }[]> {
1296
+ return this.queryFirst<{
1297
+ id: string;
1298
+ msg_range_start: number;
1299
+ msg_range_end: number;
1300
+ }>(
1301
+ `SELECT id, msg_range_start, msg_range_end FROM compaction_checkpoint WHERE session_id = $sid AND (status = "pending" OR status = "failed")`,
1302
+ { sid: sessionId },
1303
+ );
1304
+ }
1305
+
1306
+ // ── Availability check ────────────────────────────────────────────────
1307
+
1308
+ isAvailable(): boolean {
1309
+ try {
1310
+ return this.db?.isConnected ?? false;
1311
+ } catch {
1312
+ return false;
1313
+ }
1314
+ }
1315
+
1316
+ // ── Reflection session lookup ─────────────────────────────────────────
1317
+
1318
+ private _reflectionSessions: Set<string> | null = null;
1319
+
1320
+ async getReflectionSessionIds(): Promise<Set<string>> {
1321
+ if (this._reflectionSessions) return this._reflectionSessions;
1322
+ try {
1323
+ const rows = await this.queryFirst<{ session_id: string }>(
1324
+ `SELECT session_id FROM reflection GROUP BY session_id`,
1325
+ );
1326
+ this._reflectionSessions = new Set(rows.map(r => r.session_id).filter(Boolean));
1327
+ } catch (e) {
1328
+ swallow.warn("surreal:getReflectionSessionIds", e);
1329
+ this._reflectionSessions = new Set();
1330
+ }
1331
+ return this._reflectionSessions;
1332
+ }
1333
+
1334
+ // ── Fibonacci resurfacing: advance ────────────────────────────────────
1335
+
1336
+ private static readonly FIB_DAYS = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
1337
+
1338
+ async advanceSurfaceFade(memoryId: string): Promise<void> {
1339
+ const current = await this.queryFirst<{ fib_index: number }>(
1340
+ `SELECT fib_index FROM $id`, { id: memoryId },
1341
+ );
1342
+ const idx = (current as any)?.[0]?.fib_index ?? 0;
1343
+ const nextIdx = Math.min(idx + 1, SurrealStore.FIB_DAYS.length - 1);
1344
+ const days = nextIdx < SurrealStore.FIB_DAYS.length
1345
+ ? SurrealStore.FIB_DAYS[nextIdx]
1346
+ : SurrealStore.FIB_DAYS[SurrealStore.FIB_DAYS.length - 1];
1347
+ await this.queryExec(
1348
+ `UPDATE $id SET fib_index = $nextIdx, surface_count += 1, last_surfaced = time::now(), next_surface_at = time::now() + type::duration($dur)`,
1349
+ { id: memoryId, nextIdx, dur: `${days}d` },
1350
+ );
1351
+ }
1352
+
1353
+ async resolveSurfaceMemory(memoryId: string, outcome: "engaged" | "dismissed"): Promise<void> {
1354
+ await this.queryExec(
1355
+ `UPDATE $id SET surfaceable = false, last_engaged = time::now(), surface_outcome = $outcome`,
1356
+ { id: memoryId, outcome },
1357
+ );
1358
+ }
1359
+
1360
+ // ── Dispose ───────────────────────────────────────────────────────────
1361
+
1362
+ async dispose(): Promise<void> {
1363
+ try {
1364
+ await this.close();
1365
+ } catch (e) {
1366
+ swallow("surreal:dispose", e);
1367
+ }
1368
+ }
1369
+ }
1370
+
1371
+ export { assertRecordId };