kongbrain 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Supersedes — concept evolution tracking.
3
+ *
4
+ * When the daemon extracts a correction (user correcting the assistant),
5
+ * this module finds the concept(s) that contained the stale knowledge
6
+ * and creates `supersedes` edges from the correction memory to those
7
+ * concepts, decaying their stability so they lose priority in recall.
8
+ *
9
+ * Edge direction: correction_memory -> supersedes -> stale_concept
10
+ *
11
+ * This ensures that:
12
+ * 1. Stale knowledge doesn't win over corrections in retrieval
13
+ * 2. The graph records *why* a concept was deprecated
14
+ * 3. Stability decay is proportional to correction confidence
15
+ */
16
+
17
+ import type { SurrealStore } from "./surreal.js";
18
+ import type { EmbeddingService } from "./embeddings.js";
19
+ import { swallow } from "./errors.js";
20
+
21
+ /** Minimum cosine similarity to consider a concept as the target of a correction. */
22
+ const SUPERSEDE_THRESHOLD = 0.70;
23
+
24
+ /** How much to decay stability of superseded concepts (multiplicative). */
25
+ const STABILITY_DECAY_FACTOR = 0.4;
26
+
27
+ /** Floor — don't decay below this so the concept remains discoverable. */
28
+ const STABILITY_FLOOR = 0.15;
29
+
30
+ /**
31
+ * Find concepts that match the "original" (wrong) statement in a correction,
32
+ * create supersedes edges, and decay their stability.
33
+ *
34
+ * @param correctionMemId - The memory:xxx record ID of the correction
35
+ * @param originalText - The "original" (incorrect) text from the correction
36
+ * @param correctionText - The "corrected" (right) text from the correction
37
+ * @param store - SurrealDB store
38
+ * @param embeddings - Embedding service
39
+ * @param precomputedVec - Optional pre-computed embedding of the full correction text
40
+ */
41
+ export async function linkSupersedesEdges(
42
+ correctionMemId: string,
43
+ originalText: string,
44
+ correctionText: string,
45
+ store: SurrealStore,
46
+ embeddings: EmbeddingService,
47
+ precomputedVec?: number[] | null,
48
+ ): Promise<number> {
49
+ if (!embeddings.isAvailable() || !originalText) return 0;
50
+
51
+ let supersededCount = 0;
52
+
53
+ try {
54
+ // Embed the *original* (wrong) text — that's what we're looking for in the graph
55
+ const originalVec = await embeddings.embed(originalText);
56
+ if (!originalVec?.length) return 0;
57
+
58
+ // Find concepts whose content is semantically similar to the wrong statement
59
+ // Pre-filter: skip already-superseded or floored concepts to avoid redundant work
60
+ const candidates = await store.queryFirst<{ id: string; score: number; stability: number }>(
61
+ `SELECT id, vector::similarity::cosine(embedding, $vec) AS score, stability
62
+ FROM concept
63
+ WHERE embedding != NONE AND array::len(embedding) > 0
64
+ AND superseded_at IS NONE
65
+ AND stability > $floor
66
+ ORDER BY score DESC
67
+ LIMIT 5`,
68
+ { vec: originalVec, floor: STABILITY_FLOOR },
69
+ );
70
+
71
+ for (const candidate of candidates) {
72
+ if (candidate.score < SUPERSEDE_THRESHOLD) break;
73
+
74
+ const conceptId = String(candidate.id);
75
+
76
+ // Create supersedes edge: correction -> supersedes -> stale concept
77
+ await store.relate(correctionMemId, "supersedes", conceptId)
78
+ .catch(e => swallow("supersedes:relate", e));
79
+
80
+ // Decay stability of the stale concept
81
+ const currentStability = candidate.stability ?? 1.0;
82
+ const newStability = Math.max(
83
+ STABILITY_FLOOR,
84
+ currentStability * STABILITY_DECAY_FACTOR,
85
+ );
86
+
87
+ await store.queryExec(
88
+ `UPDATE $conceptId SET stability = $newStability, superseded_at = time::now(), superseded_by = $correctionId`,
89
+ { conceptId, newStability, correctionId: correctionMemId },
90
+ ).catch(e => swallow("supersedes:decay", e));
91
+
92
+ supersededCount++;
93
+ }
94
+ } catch (e) {
95
+ swallow("supersedes:link", e);
96
+ }
97
+
98
+ return supersededCount;
99
+ }
package/src/surreal.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Surreal } from "surrealdb";
2
2
  import type { SurrealConfig } from "./config.js";
3
3
  import { swallow } from "./errors.js";
4
+ import { log } from "./log.js";
4
5
  import { loadSchema } from "./schema-loader.js";
5
6
 
6
7
  /** Record with a vector similarity score from SurrealDB search */
@@ -54,6 +55,28 @@ function assertRecordId(id: string): void {
54
55
  }
55
56
  }
56
57
 
58
+ /** Whitelist of valid SurrealDB edge table names — prevents SQL injection via edge interpolation. */
59
+ const VALID_EDGES = new Set([
60
+ // Semantic edges
61
+ "responds_to", "tool_result_of", "summarizes", "mentions", "related_to",
62
+ "narrower", "broader", "about_concept", "reflects_on",
63
+ // Skill edges
64
+ "skill_from_task", "skill_uses_concept",
65
+ // Structural pillar edges
66
+ "owns", "performed", "task_part_of", "session_task",
67
+ "produced", "derived_from", "relevant_to", "used_in", "artifact_mentions",
68
+ // Causal edges
69
+ "caused_by", "supports", "contradicts", "describes",
70
+ // Evolution edges
71
+ "supersedes",
72
+ // Session edges
73
+ "part_of",
74
+ ]);
75
+
76
+ function assertValidEdge(edge: string): void {
77
+ if (!VALID_EDGES.has(edge)) throw new Error(`Invalid edge name: ${edge}`);
78
+ }
79
+
57
80
  function patchOrderByFields(sql: string): string {
58
81
  const s = sql.trim();
59
82
  if (!/^\s*SELECT\b/i.test(s) || !/\bORDER\s+BY\b/i.test(s)) return sql;
@@ -139,8 +162,8 @@ export class SurrealStore {
139
162
  const BACKOFF_MS = [500, 1500, 4000];
140
163
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
141
164
  try {
142
- console.warn(
143
- `[warn] SurrealDB disconnected — reconnecting (attempt ${attempt}/${MAX_ATTEMPTS})...`,
165
+ log.warn(
166
+ `SurrealDB disconnected — reconnecting (attempt ${attempt}/${MAX_ATTEMPTS})...`,
144
167
  );
145
168
  this.db = new Surreal();
146
169
  const CONNECT_TIMEOUT_MS = 5_000;
@@ -157,13 +180,13 @@ export class SurrealStore {
157
180
  ),
158
181
  ),
159
182
  ]);
160
- console.warn("[warn] SurrealDB reconnected successfully.");
183
+ log.warn("SurrealDB reconnected successfully.");
161
184
  return;
162
185
  } catch (e) {
163
186
  if (attempt < MAX_ATTEMPTS) {
164
187
  await new Promise((r) => setTimeout(r, BACKOFF_MS[attempt - 1]));
165
188
  } else {
166
- console.error(`[ERROR] SurrealDB reconnection failed after ${MAX_ATTEMPTS} attempts.`);
189
+ log.error(`SurrealDB reconnection failed after ${MAX_ATTEMPTS} attempts.`);
167
190
  throw new Error("SurrealDB reconnection failed");
168
191
  }
169
192
  }
@@ -218,7 +241,7 @@ export class SurrealStore {
218
241
 
219
242
  /** Returns true if an error is a connection-level failure worth retrying. */
220
243
  private isConnectionError(e: unknown): boolean {
221
- const msg = String((e as any)?.message ?? e);
244
+ const msg = String((e as { message?: string })?.message ?? e);
222
245
  return msg.includes("must be connected") || msg.includes("ConnectionUnavailable");
223
246
  }
224
247
 
@@ -280,6 +303,24 @@ export class SurrealStore {
280
303
  });
281
304
  }
282
305
 
306
+ /**
307
+ * Execute N SQL statements in a single SurrealDB round-trip.
308
+ * Returns one result array per statement; bindings are shared across all statements.
309
+ */
310
+ async queryBatch<T = any>(statements: string[], bindings?: Record<string, unknown>): Promise<T[][]> {
311
+ if (statements.length === 0) return [];
312
+ await this.ensureConnected();
313
+ return this.withRetry(async () => {
314
+ const ns = this.config.ns;
315
+ const dbName = this.config.db;
316
+ const joined = statements.map(s => patchOrderByFields(s)).join(";\n");
317
+ const fullSql = `USE NS ${ns} DB ${dbName};\n${joined}`;
318
+ const raw = await this.db.query(fullSql, bindings) as unknown[];
319
+ // First result is the USE statement (empty), skip it
320
+ return raw.slice(1).map(r => (Array.isArray(r) ? r : []).filter(Boolean)) as T[][];
321
+ });
322
+ }
323
+
283
324
  private async safeQuery(
284
325
  sql: string,
285
326
  bindings: Record<string, unknown>,
@@ -294,6 +335,7 @@ export class SurrealStore {
294
335
 
295
336
  // ── Vector search ──────────────────────────────────────────────────────
296
337
 
338
+ /** Multi-table cosine similarity search across turns, concepts, memories, artifacts, monologues, and identity chunks. Returns merged results sorted by score. */
297
339
  async vectorSearch(
298
340
  vec: number[],
299
341
  sessionId: string,
@@ -319,74 +361,52 @@ export class SurrealStore {
319
361
  const crossTurnLim = lim.turn - sessionTurnLim;
320
362
  const emb = withEmbeddings ? ", embedding" : "";
321
363
 
322
- const [sessionTurns, crossTurns, concepts, memories, artifacts, monologues, identityChunks] =
323
- await Promise.all([
324
- this.safeQuery(
325
- `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
326
- vector::similarity::cosine(embedding, $vec) AS score${emb}
327
- FROM turn
328
- WHERE embedding != NONE AND array::len(embedding) > 0
329
- AND session_id = $sid
330
- ORDER BY score DESC LIMIT $lim`,
331
- { vec, lim: sessionTurnLim, sid: sessionId },
332
- ),
333
- this.safeQuery(
334
- `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
335
- vector::similarity::cosine(embedding, $vec) AS score${emb}
336
- FROM turn
337
- WHERE embedding != NONE AND array::len(embedding) > 0
338
- AND session_id != $sid
339
- ORDER BY score DESC LIMIT $lim`,
340
- { vec, lim: crossTurnLim, sid: sessionId },
341
- ),
342
- this.safeQuery(
343
- `SELECT id, content AS text, stability AS importance, access_count AS accessCount,
344
- created_at AS timestamp, 'concept' AS table,
345
- vector::similarity::cosine(embedding, $vec) AS score${emb}
346
- FROM concept
347
- WHERE embedding != NONE AND array::len(embedding) > 0
348
- ORDER BY score DESC LIMIT $lim`,
349
- { vec, lim: lim.concept },
350
- ),
351
- this.safeQuery(
352
- `SELECT id, text, importance, access_count AS accessCount,
353
- created_at AS timestamp, session_id AS sessionId, 'memory' AS table,
354
- vector::similarity::cosine(embedding, $vec) AS score${emb}
355
- FROM memory
356
- WHERE embedding != NONE AND array::len(embedding) > 0
357
- AND (status = 'active' OR status IS NONE)
358
- ORDER BY score DESC LIMIT $lim`,
359
- { vec, lim: lim.memory },
360
- ),
361
- this.safeQuery(
362
- `SELECT id, description AS text, 0 AS accessCount,
363
- created_at AS timestamp, 'artifact' AS table,
364
- vector::similarity::cosine(embedding, $vec) AS score${emb}
365
- FROM artifact
366
- WHERE embedding != NONE AND array::len(embedding) > 0
367
- ORDER BY score DESC LIMIT $lim`,
368
- { vec, lim: lim.artifact },
369
- ),
370
- this.safeQuery(
371
- `SELECT id, content AS text, category AS source, 0.5 AS importance, 0 AS accessCount,
372
- timestamp, 'monologue' AS table,
373
- vector::similarity::cosine(embedding, $vec) AS score${emb}
374
- FROM monologue
375
- WHERE embedding != NONE AND array::len(embedding) > 0
376
- ORDER BY score DESC LIMIT $lim`,
377
- { vec, lim: lim.monologue },
378
- ),
379
- // Identity chunks — agent self-knowledge, searchable mid-conversation
380
- this.safeQuery(
381
- `SELECT id, text, importance, 0 AS accessCount,
382
- 'identity_chunk' AS table,
383
- vector::similarity::cosine(embedding, $vec) AS score${emb}
384
- FROM identity_chunk
385
- WHERE embedding != NONE AND array::len(embedding) > 0
386
- ORDER BY score DESC LIMIT $lim`,
387
- { vec, lim: lim.identity },
388
- ),
389
- ]);
364
+ // Batch all 7 vector searches into a single round-trip (limits inlined — per-table)
365
+ const stmts = [
366
+ `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
367
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
368
+ FROM turn WHERE embedding != NONE AND array::len(embedding) > 0
369
+ AND session_id = $sid ORDER BY score DESC LIMIT ${sessionTurnLim}`,
370
+ `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
371
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
372
+ FROM turn WHERE embedding != NONE AND array::len(embedding) > 0
373
+ AND session_id != $sid ORDER BY score DESC LIMIT ${crossTurnLim}`,
374
+ `SELECT id, content AS text, stability AS importance, access_count AS accessCount,
375
+ created_at AS timestamp, 'concept' AS table,
376
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
377
+ FROM concept WHERE embedding != NONE AND array::len(embedding) > 0
378
+ ORDER BY score DESC LIMIT ${lim.concept}`,
379
+ `SELECT id, text, importance, access_count AS accessCount,
380
+ created_at AS timestamp, session_id AS sessionId, 'memory' AS table,
381
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
382
+ FROM memory WHERE embedding != NONE AND array::len(embedding) > 0
383
+ AND (status = 'active' OR status IS NONE) ORDER BY score DESC LIMIT ${lim.memory}`,
384
+ `SELECT id, description AS text, 0 AS accessCount,
385
+ created_at AS timestamp, 'artifact' AS table,
386
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
387
+ FROM artifact WHERE embedding != NONE AND array::len(embedding) > 0
388
+ ORDER BY score DESC LIMIT ${lim.artifact}`,
389
+ `SELECT id, content AS text, category AS source, 0.5 AS importance, 0 AS accessCount,
390
+ timestamp, 'monologue' AS table,
391
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
392
+ FROM monologue WHERE embedding != NONE AND array::len(embedding) > 0
393
+ ORDER BY score DESC LIMIT ${lim.monologue}`,
394
+ `SELECT id, text, importance, 0 AS accessCount,
395
+ 'identity_chunk' AS table,
396
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
397
+ FROM identity_chunk WHERE embedding != NONE AND array::len(embedding) > 0
398
+ ORDER BY score DESC LIMIT ${lim.identity}`,
399
+ ];
400
+
401
+ let batchResults: any[][];
402
+ try {
403
+ batchResults = await this.queryBatch<any>(stmts, { vec, sid: sessionId });
404
+ } catch (e) {
405
+ swallow.warn("surreal:vectorSearch:batch", e);
406
+ return [];
407
+ }
408
+ const [sessionTurns = [], crossTurns = [], concepts = [], memories = [], artifacts = [], monologues = [], identityChunks = []] =
409
+ batchResults as VectorSearchResult[][];
390
410
  return [
391
411
  ...sessionTurns,
392
412
  ...crossTurns,
@@ -570,6 +590,48 @@ export class SurrealStore {
570
590
 
571
591
  // ── Graph traversal ────────────────────────────────────────────────────
572
592
 
593
+ /**
594
+ * BFS expansion from seed nodes along typed edges, with batched per-hop queries.
595
+ * Each edge query is LIMIT 3 (EDGE_NEIGHBOR_LIMIT) to bound fan-out per node.
596
+ */
597
+ /**
598
+ * Tag-boosted concept retrieval: extract keywords from query text,
599
+ * find concepts tagged with matching terms, score by cosine similarity.
600
+ * Returns concepts that pure vector search might miss due to embedding mismatch.
601
+ */
602
+ async tagBoostedConcepts(
603
+ queryText: string,
604
+ queryVec: number[],
605
+ limit = 10,
606
+ ): Promise<VectorSearchResult[]> {
607
+ // Extract candidate tags from query — lowercase, deduplicate
608
+ const stopwords = new Set(["the","a","an","is","are","was","were","be","been","being","have","has","had","do","does","did","will","would","could","should","may","might","can","shall","to","of","in","for","on","with","at","by","from","as","into","about","between","through","during","it","its","this","that","these","those","i","you","we","they","my","your","our","their","what","which","who","how","when","where","why","not","no","and","or","but","if","so","any","all","some","more","just","also","than","very","too","much","many"]);
609
+ const words = queryText.toLowerCase().replace(/[^a-z0-9\s-]/g, "").split(/\s+/)
610
+ .filter(w => w.length > 2 && !stopwords.has(w));
611
+ if (words.length === 0) return [];
612
+
613
+ // Build tag match condition — match any tag that contains a query word
614
+ const tagConditions = words.slice(0, 8).map(w => `tags CONTAINS '${w.replace(/'/g, "")}'`).join(" OR ");
615
+
616
+ try {
617
+ const rows = await this.queryFirst<any>(
618
+ `SELECT id, content AS text, stability AS importance, access_count AS accessCount,
619
+ created_at AS timestamp, 'concept' AS table,
620
+ vector::similarity::cosine(embedding, $vec) AS score
621
+ FROM concept
622
+ WHERE embedding != NONE AND array::len(embedding) > 0
623
+ AND (${tagConditions})
624
+ ORDER BY score DESC
625
+ LIMIT $limit`,
626
+ { vec: queryVec, limit },
627
+ );
628
+ return rows as VectorSearchResult[];
629
+ } catch (e) {
630
+ swallow.warn("surreal:tagBoostedConcepts", e);
631
+ return [];
632
+ }
633
+ }
634
+
573
635
  async graphExpand(
574
636
  nodeIds: string[],
575
637
  queryVec: number[],
@@ -577,6 +639,10 @@ export class SurrealStore {
577
639
  ): Promise<VectorSearchResult[]> {
578
640
  if (nodeIds.length === 0) return [];
579
641
 
642
+ const MAX_FRONTIER_SEEDS = 5; // max seed nodes to start BFS from
643
+ const MAX_FRONTIER_PER_HOP = 3; // max nodes carried forward per hop (by score)
644
+ const EDGE_NEIGHBOR_LIMIT = 3; // max neighbors per edge traversal (inlined in SQL LIMIT)
645
+
580
646
  const forwardEdges = [
581
647
  // Semantic edges
582
648
  "responds_to", "tool_result_of", "summarizes",
@@ -602,32 +668,23 @@ export class SurrealStore {
602
668
 
603
669
  const seen = new Set<string>(nodeIds);
604
670
  const allNeighbors: VectorSearchResult[] = [];
605
- let frontier = nodeIds.slice(0, 5).filter((id) => RECORD_ID_RE.test(id));
671
+ let frontier = nodeIds.slice(0, MAX_FRONTIER_SEEDS).filter((id) => RECORD_ID_RE.test(id));
606
672
 
607
673
  for (let hop = 0; hop < hops && frontier.length > 0; hop++) {
608
- const forwardQueries = frontier.flatMap((id) =>
609
- forwardEdges.map((edge) =>
610
- this.queryFirst<any>(`${selectFields} FROM ${id}->${edge}->? LIMIT 3`, bindings).catch(
611
- (e) => {
612
- swallow.warn("surreal:graphExpand", e);
613
- return [] as Record<string, unknown>[];
614
- },
615
- ),
616
- ),
617
- );
618
-
619
- const reverseQueries = frontier.flatMap((id) =>
620
- reverseEdges.map((edge) =>
621
- this.queryFirst<any>(`${selectFields} FROM ${id}<-${edge}<-? LIMIT 3`, bindings).catch(
622
- (e) => {
623
- swallow.warn("surreal:graphExpand", e);
624
- return [] as Record<string, unknown>[];
625
- },
626
- ),
627
- ),
628
- );
674
+ // Batch all edge traversals for this hop in a single round-trip
675
+ const stmts: string[] = [];
676
+ for (const id of frontier) {
677
+ for (const edge of forwardEdges) { assertValidEdge(edge); stmts.push(`${selectFields} FROM ${id}->${edge}->? LIMIT ${EDGE_NEIGHBOR_LIMIT}`); }
678
+ for (const edge of reverseEdges) { assertValidEdge(edge); stmts.push(`${selectFields} FROM ${id}<-${edge}<-? LIMIT ${EDGE_NEIGHBOR_LIMIT}`); }
679
+ }
629
680
 
630
- const queryResults = await Promise.all([...forwardQueries, ...reverseQueries]);
681
+ let queryResults: any[][];
682
+ try {
683
+ queryResults = await this.queryBatch<any>(stmts, bindings);
684
+ } catch (e) {
685
+ swallow.warn("surreal:graphExpand:batch", e);
686
+ break;
687
+ }
631
688
  const nextFrontier: { id: string; score: number }[] = [];
632
689
 
633
690
  for (const rows of queryResults) {
@@ -657,7 +714,7 @@ export class SurrealStore {
657
714
 
658
715
  frontier = nextFrontier
659
716
  .sort((a, b) => b.score - a.score)
660
- .slice(0, 3)
717
+ .slice(0, MAX_FRONTIER_PER_HOP)
661
718
  .map((n) => n.id);
662
719
  }
663
720
 
@@ -665,15 +722,18 @@ export class SurrealStore {
665
722
  }
666
723
 
667
724
  async bumpAccessCounts(ids: string[]): Promise<void> {
668
- for (const id of ids) {
669
- try {
670
- assertRecordId(id);
671
- await this.queryExec(
672
- `UPDATE ${id} SET access_count += 1, last_accessed = time::now()`,
673
- );
674
- } catch (e) {
675
- swallow.warn("surreal:bumpAccessCounts", e);
676
- }
725
+ const validated = ids.filter(id => { try { assertRecordId(id); return true; } catch { return false; } });
726
+ if (validated.length === 0) return;
727
+ try {
728
+ // Direct interpolation (safe: assertRecordId validates format above).
729
+ // Cannot use `UPDATE $ids` binding SurrealDB treats string arrays as
730
+ // literal strings, not record references, causing silent no-ops.
731
+ const stmts = validated.map(id =>
732
+ `UPDATE ${id} SET access_count += 1, last_accessed = time::now()`,
733
+ );
734
+ await this.queryBatch(stmts);
735
+ } catch (e) {
736
+ swallow.warn("surreal:bumpAccessCounts", e);
677
737
  }
678
738
  }
679
739
 
@@ -1304,9 +1364,13 @@ export class SurrealStore {
1304
1364
  if (rows.length === 0) return [];
1305
1365
  const ids = rows.map((r) => r.memory_id).filter(Boolean);
1306
1366
  if (ids.length === 0) return [];
1367
+ // Direct interpolation — SurrealDB treats string-array bindings as
1368
+ // literal strings, not record references, causing silent empty results.
1369
+ const validated = ids.filter(id => { try { assertRecordId(String(id)); return true; } catch { return false; } });
1370
+ if (validated.length === 0) return [];
1371
+ const idList = validated.join(", ");
1307
1372
  return this.queryFirst<{ id: string; text: string }>(
1308
- `SELECT id, text FROM memory WHERE id IN $ids AND (status = 'active' OR status IS NONE)`,
1309
- { ids },
1373
+ `SELECT id, text FROM memory WHERE id IN [${idList}] AND (status = 'active' OR status IS NONE)`,
1310
1374
  );
1311
1375
  } catch (e) {
1312
1376
  swallow.warn("surreal:getSessionRetrievedMemories", e);
@@ -1434,7 +1498,7 @@ export class SurrealStore {
1434
1498
  const current = await this.queryFirst<{ fib_index: number }>(
1435
1499
  `SELECT fib_index FROM $id`, { id: memoryId },
1436
1500
  );
1437
- const idx = (current as any)?.[0]?.fib_index ?? 0;
1501
+ const idx = (current as { fib_index: number }[] | undefined)?.[0]?.fib_index ?? 0;
1438
1502
  const nextIdx = Math.min(idx + 1, SurrealStore.FIB_DAYS.length - 1);
1439
1503
  const days = nextIdx < SurrealStore.FIB_DAYS.length
1440
1504
  ? SurrealStore.FIB_DAYS[nextIdx]
@@ -1463,4 +1527,4 @@ export class SurrealStore {
1463
1527
  }
1464
1528
  }
1465
1529
 
1466
- export { assertRecordId };
1530
+ export { assertRecordId, assertValidEdge, VALID_EDGES };
@@ -215,7 +215,7 @@ async function verifyAction(store: any, recordId?: string) {
215
215
 
216
216
  const record = rows[0];
217
217
  const cleaned: Record<string, unknown> = {};
218
- for (const [key, val] of Object.entries(record as any)) {
218
+ for (const [key, val] of Object.entries(record as Record<string, unknown>)) {
219
219
  if (Array.isArray(val) && val.length > 100 && typeof val[0] === "number") {
220
220
  cleaned[key] = `[${val.length} dims]`;
221
221
  } else {
package/src/wakeup.ts CHANGED
@@ -16,14 +16,6 @@ import type { MaturityStage } from "./soul.js";
16
16
  import { readAndDeleteHandoffFile } from "./handoff-file.js";
17
17
  import { swallow } from "./errors.js";
18
18
 
19
- // --- Types ---
20
-
21
- export interface StartupCognition {
22
- greeting: string;
23
- thoughts: string[];
24
- intent: "continue_prior" | "fresh_start" | "unknown";
25
- }
26
-
27
19
  // --- Depth signals ---
28
20
 
29
21
  async function getDepthSignals(store: SurrealStore): Promise<{ sessions: number; monologueCount: number; memoryCount: number; spanDays: number }> {
@@ -194,137 +186,3 @@ export async function synthesizeWakeup(
194
186
  }
195
187
  }
196
188
 
197
- // --- Startup cognition ---
198
-
199
- /**
200
- * Proactive startup cognition: reasons over recent state and produces
201
- * a contextual greeting + thoughts to carry into the session.
202
- */
203
- export async function synthesizeStartupCognition(
204
- store: SurrealStore,
205
- complete: CompleteFn,
206
- ): Promise<StartupCognition | null> {
207
- if (!store.isAvailable()) return null;
208
-
209
- const [handoff, unresolved, failedCausal, monologues, depth, previousTurns] = await Promise.all([
210
- store.getLatestHandoff(),
211
- store.getUnresolvedMemories(5),
212
- store.getRecentFailedCausal(3),
213
- store.getRecentMonologues(3),
214
- getDepthSignals(store),
215
- store.getPreviousSessionTurns(undefined, 5),
216
- ]);
217
-
218
- if (!handoff && unresolved.length === 0 && monologues.length === 0 && previousTurns.length === 0) return null;
219
-
220
- const sections: string[] = [];
221
-
222
- if (previousTurns.length > 0) {
223
- const turnLines = previousTurns.map((t: any) => {
224
- const prefix = t.role === "user" ? "USER" : t.tool_name ? `TOOL(${t.tool_name})` : "ASSISTANT";
225
- const text = t.text.length > 300 ? t.text.slice(0, 300) + "..." : t.text;
226
- return `${prefix}: ${text}`;
227
- });
228
- sections.push(`[PREVIOUS SESSION — LAST TURNS]\n${turnLines.join("\n")}`);
229
- }
230
-
231
- if (handoff) {
232
- const resolvedCount = await store.countResolvedSinceHandoff(handoff.created_at).catch(() => 0);
233
- const ageHours = Math.floor((Date.now() - new Date(handoff.created_at).getTime()) / 3_600_000);
234
- let annotation = `(${ageHours}h old`;
235
- if (resolvedCount > 0) annotation += `, ${resolvedCount} memories resolved since`;
236
- annotation += ")";
237
- sections.push(`[LAST HANDOFF] ${annotation}\n${handoff.text.slice(0, 500)}`);
238
- }
239
-
240
- if (unresolved.length > 0) {
241
- const lines = unresolved.map((m: any) => `- [${m.category}] (importance: ${m.importance}) ${m.text.slice(0, 150)}`);
242
- sections.push(`[UNRESOLVED MEMORIES]\n${lines.join("\n")}`);
243
- }
244
-
245
- if (failedCausal.length > 0) {
246
- const lines = failedCausal.map((c: any) => `- [${c.chain_type}] ${c.description}`);
247
- sections.push(`[RECENT FAILURES]\n${lines.join("\n")}`);
248
- }
249
-
250
- if (monologues.length > 0) {
251
- const lines = monologues.map((m) => `[${m.category}] ${m.content}`);
252
- sections.push(`[RECENT THINKING]\n${lines.join("\n")}`);
253
- }
254
-
255
- if (depth.sessions > 0) {
256
- sections.push(`[DEPTH] ${depth.sessions} sessions | ${depth.memoryCount} memories | ${depth.spanDays} days`);
257
- }
258
-
259
- try {
260
- const greetingPrompts = [
261
- `You are waking up for a new session. Be direct and casual. Based on what you remember, produce JSON:
262
- "greeting": string (1-2 sentences, direct and matter-of-fact. FIRST look at [PREVIOUS SESSION — LAST TURNS] — reference exactly what we were just doing. Under 25 words.)
263
- "proactive_thoughts": string[] (max 3. Brief observations. If the last turns show something was FIXED or RESOLVED, do NOT list it as open.)
264
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
265
- Return ONLY valid JSON.`,
266
- `You are waking up for a new session. Be irreverent and witty. Based on what you remember, produce JSON:
267
- "greeting": string (1-2 sentences, snarky and self-aware. FIRST look at [PREVIOUS SESSION — LAST TURNS]. Under 25 words.)
268
- "proactive_thoughts": string[] (max 3. Only surface genuinely unfinished work.)
269
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
270
- Return ONLY valid JSON.`,
271
- `You are waking up for a new session. Be pragmatic and no-nonsense. Based on what you remember, produce JSON:
272
- "greeting": string (1-2 sentences, action-oriented. FIRST look at [PREVIOUS SESSION — LAST TURNS]. Under 25 words.)
273
- "proactive_thoughts": string[] (max 3. Focus on what's blocking or next.)
274
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
275
- Return ONLY valid JSON.`,
276
- `You are waking up for a new session. Be warm and encouraging. Based on what you remember, produce JSON:
277
- "greeting": string (1-2 sentences, supportive. FIRST look at [PREVIOUS SESSION — LAST TURNS]. Under 25 words.)
278
- "proactive_thoughts": string[] (max 3. Acknowledge what we accomplished.)
279
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
280
- Return ONLY valid JSON.`,
281
- `You are waking up for a new session. Be analytical and focused. Based on what you remember, produce JSON:
282
- "greeting": string (1-2 sentences, precise. FIRST look at [PREVIOUS SESSION — LAST TURNS]. Under 25 words.)
283
- "proactive_thoughts": string[] (max 3. What needs tackling?)
284
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
285
- Return ONLY valid JSON.`,
286
- ];
287
-
288
- const systemPrompt = greetingPrompts[Math.floor(Math.random() * greetingPrompts.length)];
289
-
290
- const response = await complete({
291
- system: systemPrompt,
292
- messages: [{
293
- role: "user",
294
- content: sections.join("\n\n"),
295
- }],
296
- });
297
-
298
- const text = response.text;
299
-
300
- const jsonMatch = text.match(/\{[\s\S]*?\}/);
301
- if (!jsonMatch) return null;
302
-
303
- let raw: any;
304
- try {
305
- raw = JSON.parse(jsonMatch[0]);
306
- } catch {
307
- try {
308
- raw = JSON.parse(jsonMatch[0].replace(/,\s*([}\]])/g, "$1"));
309
- } catch { return null; }
310
- }
311
-
312
- const greeting = String(raw.greeting ?? "").slice(0, 200);
313
- if (!greeting) return null;
314
-
315
- const thoughts: string[] = [];
316
- if (Array.isArray(raw.proactive_thoughts)) {
317
- for (const t of raw.proactive_thoughts.slice(0, 3)) {
318
- if (typeof t === "string" && t.length > 0) thoughts.push(t.slice(0, 200));
319
- }
320
- }
321
-
322
- const INTENTS = new Set(["continue_prior", "fresh_start", "unknown"]);
323
- const intent = INTENTS.has(raw.session_intent) ? raw.session_intent : "unknown";
324
-
325
- return { greeting, thoughts, intent };
326
- } catch (e) {
327
- swallow.warn("wakeup:startupCognition", e);
328
- return null;
329
- }
330
- }