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.
- package/.github/workflows/ci.yml +45 -0
- package/.github/workflows/pr-check.yml +16 -0
- package/CHANGELOG.md +64 -0
- package/README.github.md +40 -1
- package/SKILL.md +1 -1
- package/TOKEN_FLOW.md +184 -0
- package/package.json +1 -1
- package/src/acan.ts +28 -5
- package/src/causal.ts +18 -25
- package/src/cognitive-bootstrap.ts +6 -6
- package/src/cognitive-check.ts +17 -19
- package/src/config.ts +1 -1
- package/src/context-engine.ts +105 -50
- package/src/daemon-manager.ts +70 -19
- package/src/deferred-cleanup.ts +12 -10
- package/src/embeddings.ts +6 -7
- package/src/errors.ts +5 -3
- package/src/graph-context.ts +281 -178
- package/src/hooks/after-tool-call.ts +2 -1
- package/src/hooks/before-tool-call.ts +15 -11
- package/src/hooks/llm-output.ts +18 -10
- package/src/index.ts +39 -18
- package/src/intent.ts +9 -8
- package/src/log.ts +11 -0
- package/src/memory-daemon.ts +1 -0
- package/src/orchestrator.ts +11 -4
- package/src/prefetch.ts +2 -2
- package/src/reflection.ts +9 -2
- package/src/schema.surql +7 -0
- package/src/skills.ts +32 -10
- package/src/soul.ts +17 -1
- package/src/state.ts +31 -0
- package/src/supersedes.ts +99 -0
- package/src/surreal.ts +174 -110
- package/src/tools/introspect.ts +1 -1
- package/src/wakeup.ts +0 -142
|
@@ -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
|
-
|
|
143
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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,
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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 $
|
|
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
|
|
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 };
|
package/src/tools/introspect.ts
CHANGED
|
@@ -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
|
|
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
|
-
}
|