clawmem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +660 -0
  2. package/CLAUDE.md +660 -0
  3. package/LICENSE +21 -0
  4. package/README.md +993 -0
  5. package/SKILL.md +717 -0
  6. package/bin/clawmem +75 -0
  7. package/package.json +72 -0
  8. package/src/amem.ts +797 -0
  9. package/src/beads.ts +263 -0
  10. package/src/clawmem.ts +1849 -0
  11. package/src/collections.ts +405 -0
  12. package/src/config.ts +178 -0
  13. package/src/consolidation.ts +123 -0
  14. package/src/directory-context.ts +248 -0
  15. package/src/errors.ts +41 -0
  16. package/src/formatter.ts +427 -0
  17. package/src/graph-traversal.ts +247 -0
  18. package/src/hooks/context-surfacing.ts +317 -0
  19. package/src/hooks/curator-nudge.ts +89 -0
  20. package/src/hooks/decision-extractor.ts +639 -0
  21. package/src/hooks/feedback-loop.ts +214 -0
  22. package/src/hooks/handoff-generator.ts +345 -0
  23. package/src/hooks/postcompact-inject.ts +226 -0
  24. package/src/hooks/precompact-extract.ts +314 -0
  25. package/src/hooks/pretool-inject.ts +79 -0
  26. package/src/hooks/session-bootstrap.ts +324 -0
  27. package/src/hooks/staleness-check.ts +130 -0
  28. package/src/hooks.ts +367 -0
  29. package/src/indexer.ts +327 -0
  30. package/src/intent.ts +294 -0
  31. package/src/limits.ts +26 -0
  32. package/src/llm.ts +1175 -0
  33. package/src/mcp.ts +2138 -0
  34. package/src/memory.ts +336 -0
  35. package/src/mmr.ts +93 -0
  36. package/src/observer.ts +269 -0
  37. package/src/openclaw/engine.ts +283 -0
  38. package/src/openclaw/index.ts +221 -0
  39. package/src/openclaw/plugin.json +83 -0
  40. package/src/openclaw/shell.ts +207 -0
  41. package/src/openclaw/tools.ts +304 -0
  42. package/src/profile.ts +346 -0
  43. package/src/promptguard.ts +218 -0
  44. package/src/retrieval-gate.ts +106 -0
  45. package/src/search-utils.ts +127 -0
  46. package/src/server.ts +783 -0
  47. package/src/splitter.ts +325 -0
  48. package/src/store.ts +4062 -0
  49. package/src/validation.ts +67 -0
  50. package/src/watcher.ts +58 -0
package/src/amem.ts ADDED
@@ -0,0 +1,797 @@
1
+ /**
2
+ * A-MEM: Self-Evolving Memory System
3
+ *
4
+ * Constructs memory notes, generates typed links, and tracks memory evolution.
5
+ * All operations are non-fatal and log errors with [amem] prefix.
6
+ */
7
+
8
+ import type { Database } from "bun:sqlite";
9
+ import type { LlamaCpp } from "./llm.ts";
10
+ import type { Store } from "./store.ts";
11
+
12
+ export interface MemoryNote {
13
+ keywords: string[];
14
+ tags: string[];
15
+ context: string;
16
+ }
17
+
18
+ const EMPTY_NOTE: MemoryNote = {
19
+ keywords: [],
20
+ tags: [],
21
+ context: ""
22
+ };
23
+
24
+ /**
25
+ * Extract and parse JSON from LLM output, handling:
26
+ * - Markdown code blocks (```json ... ```)
27
+ * - Leading/trailing prose around JSON
28
+ * - Truncated JSON from token limits (repairs arrays/objects)
29
+ */
30
+ export function extractJsonFromLLM(raw: string): any | null {
31
+ let text = raw.trim();
32
+
33
+ // Strip markdown code blocks
34
+ const codeBlock = text.match(/```(?:json)?\s*\n?([\s\S]*?)(?:\n```|$)/);
35
+ if (codeBlock) {
36
+ text = codeBlock[1]!.trim();
37
+ }
38
+
39
+ // Find the first [ or { to skip leading prose
40
+ const arrStart = text.indexOf('[');
41
+ const objStart = text.indexOf('{');
42
+ if (arrStart === -1 && objStart === -1) return null;
43
+
44
+ const start = arrStart === -1 ? objStart : objStart === -1 ? arrStart : Math.min(arrStart, objStart);
45
+ text = text.slice(start);
46
+
47
+ // Try parsing as-is first
48
+ try {
49
+ return JSON.parse(text);
50
+ } catch {
51
+ // Attempt truncated JSON repair
52
+ }
53
+
54
+ // Repair truncated arrays: find last complete object, close the array
55
+ if (text.startsWith('[')) {
56
+ const lastBrace = text.lastIndexOf('}');
57
+ if (lastBrace > 0) {
58
+ const repaired = text.slice(0, lastBrace + 1) + ']';
59
+ try { return JSON.parse(repaired); } catch { /* continue */ }
60
+ }
61
+ // Might be an empty or trivial array
62
+ try { return JSON.parse(text.replace(/,\s*$/, '') + ']'); } catch { /* continue */ }
63
+ }
64
+
65
+ // Repair truncated objects: find last complete value, close the object
66
+ if (text.startsWith('{')) {
67
+ // Try closing at each } from the end
68
+ for (let i = text.length - 1; i > 0; i--) {
69
+ if (text[i] === '}' || text[i] === '"' || text[i] === '0' || text[i] === '1' ||
70
+ text[i] === '2' || text[i] === '3' || text[i] === '4' || text[i] === '5' ||
71
+ text[i] === '6' || text[i] === '7' || text[i] === '8' || text[i] === '9' ||
72
+ text[i] === 'e' || text[i] === 'l') {
73
+ const candidate = text.slice(0, i + 1) + '}';
74
+ try { return JSON.parse(candidate); } catch { /* continue */ }
75
+ }
76
+ }
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Construct a memory note for a document using LLM analysis.
84
+ * Extracts keywords, tags, and context summary.
85
+ *
86
+ * @param store - Store instance
87
+ * @param llm - LLM instance
88
+ * @param docId - Document numeric ID
89
+ * @returns Memory note with keywords, tags, and context
90
+ */
91
+ export async function constructMemoryNote(
92
+ store: Store,
93
+ llm: LlamaCpp,
94
+ docId: number
95
+ ): Promise<MemoryNote> {
96
+ try {
97
+ // Get document info
98
+ const doc = store.db.prepare(`
99
+ SELECT d.collection, d.path, d.title, c.doc as body
100
+ FROM documents d
101
+ JOIN content c ON c.hash = d.hash
102
+ WHERE d.id = ? AND d.active = 1
103
+ `).get(docId) as { collection: string; path: string; title: string; body: string } | null;
104
+
105
+ if (!doc) {
106
+ console.log(`[amem] Document ${docId} not found or inactive`);
107
+ return EMPTY_NOTE;
108
+ }
109
+
110
+ // Truncate content to 2000 chars
111
+ const content = doc.body.slice(0, 2000);
112
+
113
+ // LLM prompt for memory note construction
114
+ const prompt = `Analyze this document and extract structured memory metadata.
115
+
116
+ Title: ${doc.title}
117
+ Path: ${doc.collection}/${doc.path}
118
+
119
+ Content:
120
+ ${content}
121
+
122
+ Extract:
123
+ 1. keywords: 3-7 key concepts or terms
124
+ 2. tags: 2-5 categorical labels
125
+ 3. context: 1-2 sentence summary of what this document is about
126
+
127
+ Return ONLY valid JSON in this exact format:
128
+ {
129
+ "keywords": ["keyword1", "keyword2", "keyword3"],
130
+ "tags": ["tag1", "tag2"],
131
+ "context": "Brief summary of the document."
132
+ }`;
133
+
134
+ const result = await llm.generate(prompt, {
135
+ temperature: 0.3,
136
+ maxTokens: 300,
137
+ });
138
+
139
+ if (!result) {
140
+ console.log(`[amem] LLM returned null for docId ${docId}`);
141
+ return EMPTY_NOTE;
142
+ }
143
+
144
+ const parsed = extractJsonFromLLM(result.text) as MemoryNote | null;
145
+
146
+ if (!parsed || !Array.isArray(parsed.keywords) || !Array.isArray(parsed.tags) || typeof parsed.context !== 'string') {
147
+ console.log(`[amem] Invalid/unparseable JSON for docId ${docId}`);
148
+ return EMPTY_NOTE;
149
+ }
150
+
151
+ return {
152
+ keywords: parsed.keywords,
153
+ tags: parsed.tags,
154
+ context: parsed.context
155
+ };
156
+ } catch (err) {
157
+ console.log(`[amem] Error constructing memory note for docId ${docId}:`, err);
158
+ return EMPTY_NOTE;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Store memory note in the documents table.
164
+ * Updates amem_keywords, amem_tags, and amem_context columns.
165
+ *
166
+ * @param store - Store instance
167
+ * @param docId - Document numeric ID
168
+ * @param note - Memory note to store
169
+ */
170
+ export function storeMemoryNote(
171
+ store: Store,
172
+ docId: number,
173
+ note: MemoryNote
174
+ ): void {
175
+ try {
176
+ store.db.prepare(`
177
+ UPDATE documents
178
+ SET amem_keywords = ?,
179
+ amem_tags = ?,
180
+ amem_context = ?
181
+ WHERE id = ?
182
+ `).run(
183
+ JSON.stringify(note.keywords),
184
+ JSON.stringify(note.tags),
185
+ note.context,
186
+ docId
187
+ );
188
+ } catch (err) {
189
+ console.log(`[amem] Error storing memory note for docId ${docId}:`, err);
190
+ }
191
+ }
192
+
193
+ export interface MemoryLink {
194
+ target_id: number;
195
+ link_type: 'semantic' | 'supporting' | 'contradicts';
196
+ confidence: number;
197
+ reasoning: string;
198
+ }
199
+
200
+ /**
201
+ * Generate typed memory links for a document based on semantic similarity.
202
+ * Finds k-nearest neighbors and uses LLM to determine relationship types.
203
+ *
204
+ * @param store - Store instance
205
+ * @param llm - LLM instance
206
+ * @param docId - Source document numeric ID
207
+ * @param kNeighbors - Number of neighbors to find (default 8)
208
+ * @returns Number of links created
209
+ */
210
+ export async function generateMemoryLinks(
211
+ store: Store,
212
+ llm: LlamaCpp,
213
+ docId: number,
214
+ kNeighbors: number = 8
215
+ ): Promise<number> {
216
+ try {
217
+ // Get source document info
218
+ const sourceDoc = store.db.prepare(`
219
+ SELECT d.id, d.hash, d.title, d.collection, d.path, d.amem_context
220
+ FROM documents d
221
+ WHERE d.id = ? AND d.active = 1
222
+ `).get(docId) as { id: number; hash: string; title: string; collection: string; path: string; amem_context: string | null } | null;
223
+
224
+ if (!sourceDoc) {
225
+ console.log(`[amem] Source document ${docId} not found or inactive`);
226
+ return 0;
227
+ }
228
+
229
+ // Find k-nearest neighbors using vector similarity
230
+ const neighbors = store.db.prepare(`
231
+ SELECT
232
+ d2.id as target_id,
233
+ d2.title as target_title,
234
+ d2.amem_context as target_context,
235
+ vec_distance_cosine(v1.embedding, v2.embedding) as distance
236
+ FROM vectors_vec v1, vectors_vec v2
237
+ JOIN documents d2 ON v2.hash_seq = d2.hash || '_0'
238
+ WHERE v1.hash_seq = ? || '_0'
239
+ AND d2.id != ?
240
+ AND d2.active = 1
241
+ ORDER BY distance
242
+ LIMIT ?
243
+ `).all(sourceDoc.hash, sourceDoc.id, kNeighbors) as {
244
+ target_id: number;
245
+ target_title: string;
246
+ target_context: string | null;
247
+ distance: number;
248
+ }[];
249
+
250
+ if (neighbors.length === 0) {
251
+ console.log(`[amem] No neighbors found for docId ${docId}`);
252
+ return 0;
253
+ }
254
+
255
+ // Build LLM prompt to analyze relationships
256
+ const neighborsText = neighbors.map((n, idx) =>
257
+ `${idx + 1}. "${n.target_title}": ${n.target_context || 'No context available'}`
258
+ ).join('\n');
259
+
260
+ const prompt = `Analyze the relationship between a source document and its semantic neighbors.
261
+
262
+ Source Document:
263
+ Title: ${sourceDoc.title}
264
+ Context: ${sourceDoc.amem_context || 'No context available'}
265
+
266
+ Semantically Similar Documents:
267
+ ${neighborsText}
268
+
269
+ For each neighbor, determine the relationship type:
270
+ - "semantic": General topical similarity, related concepts
271
+ - "supporting": Provides evidence, examples, or elaboration for the source
272
+ - "contradicts": Presents conflicting information or opposing views
273
+
274
+ Also assign a confidence score (0.0-1.0) for each relationship.
275
+
276
+ Return ONLY valid JSON array in this exact format:
277
+ [
278
+ {
279
+ "target_idx": 1,
280
+ "link_type": "semantic",
281
+ "confidence": 0.85,
282
+ "reasoning": "Brief explanation"
283
+ },
284
+ {
285
+ "target_idx": 2,
286
+ "link_type": "supporting",
287
+ "confidence": 0.92,
288
+ "reasoning": "Brief explanation"
289
+ }
290
+ ]
291
+
292
+ Include all ${neighbors.length} neighbors in your response.`;
293
+
294
+ const result = await llm.generate(prompt, {
295
+ temperature: 0.3,
296
+ maxTokens: 500,
297
+ });
298
+
299
+ if (!result) {
300
+ console.log(`[amem] LLM returned null for link generation docId ${docId}`);
301
+ return 0;
302
+ }
303
+
304
+ const parsed = extractJsonFromLLM(result.text) as Array<{
305
+ target_idx: number;
306
+ link_type: 'semantic' | 'supporting' | 'contradicts';
307
+ confidence: number;
308
+ reasoning: string;
309
+ }> | null;
310
+
311
+ if (!Array.isArray(parsed)) {
312
+ console.log(`[amem] Invalid/unparseable JSON for link generation docId ${docId}`);
313
+ return 0;
314
+ }
315
+
316
+ // Insert links into memory_relations
317
+ let linksCreated = 0;
318
+ const now = new Date().toISOString();
319
+
320
+ for (const link of parsed) {
321
+ // Validate link structure
322
+ if (typeof link.target_idx !== 'number' ||
323
+ link.target_idx < 1 ||
324
+ link.target_idx > neighbors.length ||
325
+ !['semantic', 'supporting', 'contradicts'].includes(link.link_type) ||
326
+ typeof link.confidence !== 'number') {
327
+ continue;
328
+ }
329
+
330
+ const neighbor = neighbors[link.target_idx - 1];
331
+ if (!neighbor) continue;
332
+
333
+ // Insert link with INSERT OR IGNORE for idempotency
334
+ store.db.prepare(`
335
+ INSERT OR IGNORE INTO memory_relations (source_id, target_id, relation_type, weight, metadata, created_at)
336
+ VALUES (?, ?, ?, ?, ?, ?)
337
+ `).run(
338
+ sourceDoc.id,
339
+ neighbor.target_id,
340
+ link.link_type,
341
+ link.confidence,
342
+ JSON.stringify({ reasoning: link.reasoning }),
343
+ now
344
+ );
345
+ linksCreated++;
346
+ }
347
+
348
+ console.log(`[amem] Created ${linksCreated} links for docId ${docId}`);
349
+ return linksCreated;
350
+ } catch (err) {
351
+ console.log(`[amem] Error generating memory links for docId ${docId}:`, err);
352
+ return 0;
353
+ }
354
+ }
355
+
356
+ export interface MemoryEvolution {
357
+ should_evolve: boolean;
358
+ new_keywords: string[];
359
+ new_tags: string[];
360
+ new_context: string;
361
+ reasoning: string;
362
+ }
363
+
364
+ /**
365
+ * Evolve a memory note based on new evidence from linked neighbors.
366
+ * Tracks evolution history in memory_evolution table.
367
+ *
368
+ * @param store - Store instance
369
+ * @param llm - LLM instance
370
+ * @param memoryId - Memory document numeric ID
371
+ * @param triggeredBy - Document ID that triggered this evolution
372
+ * @returns True if evolution occurred, false otherwise
373
+ */
374
+ export async function evolveMemories(
375
+ store: Store,
376
+ llm: LlamaCpp,
377
+ memoryId: number,
378
+ triggeredBy: number
379
+ ): Promise<boolean> {
380
+ try {
381
+ // Get current memory state
382
+ const memory = store.db.prepare(`
383
+ SELECT id, title, amem_keywords, amem_tags, amem_context
384
+ FROM documents
385
+ WHERE id = ? AND active = 1
386
+ `).get(memoryId) as {
387
+ id: number;
388
+ title: string;
389
+ amem_keywords: string | null;
390
+ amem_tags: string | null;
391
+ amem_context: string | null;
392
+ } | null;
393
+
394
+ if (!memory || !memory.amem_context) {
395
+ console.log(`[amem] Memory ${memoryId} not found or has no context`);
396
+ return false;
397
+ }
398
+
399
+ // Get linked neighbors for context
400
+ const neighbors = store.db.prepare(`
401
+ SELECT
402
+ d.id,
403
+ d.title,
404
+ d.amem_context,
405
+ mr.relation_type,
406
+ mr.weight
407
+ FROM memory_relations mr
408
+ JOIN documents d ON d.id = mr.target_id
409
+ WHERE mr.source_id = ?
410
+ AND d.active = 1
411
+ AND d.amem_context IS NOT NULL
412
+ ORDER BY mr.weight DESC
413
+ LIMIT 5
414
+ `).all(memoryId) as Array<{
415
+ id: number;
416
+ title: string;
417
+ amem_context: string;
418
+ relation_type: string;
419
+ weight: number;
420
+ }>;
421
+
422
+ if (neighbors.length === 0) {
423
+ console.log(`[amem] No linked neighbors for memory ${memoryId}`);
424
+ return false;
425
+ }
426
+
427
+ // Build LLM prompt for evolution analysis
428
+ const currentKeywords = memory.amem_keywords ? JSON.parse(memory.amem_keywords) : [];
429
+ const currentTags = memory.amem_tags ? JSON.parse(memory.amem_tags) : [];
430
+
431
+ const neighborsText = neighbors.map((n, idx) =>
432
+ `${idx + 1}. [${n.relation_type}, conf=${n.weight.toFixed(2)}] "${n.title}": ${n.amem_context}`
433
+ ).join('\n');
434
+
435
+ const prompt = `Analyze if a memory note should evolve based on new evidence from linked documents.
436
+
437
+ Current Memory:
438
+ Title: ${memory.title}
439
+ Keywords: ${JSON.stringify(currentKeywords)}
440
+ Tags: ${JSON.stringify(currentTags)}
441
+ Context: ${memory.amem_context}
442
+
443
+ Linked Evidence:
444
+ ${neighborsText}
445
+
446
+ Determine if the memory should evolve based on:
447
+ 1. New contradictory information that changes understanding
448
+ 2. Supporting evidence that strengthens or refines the context
449
+ 3. New concepts that should be incorporated
450
+
451
+ If evolution is warranted, provide:
452
+ - new_keywords: Updated keyword list (maintain 3-7 items)
453
+ - new_tags: Updated tags (maintain 2-5 items)
454
+ - new_context: Refined context incorporating new evidence
455
+ - reasoning: Why this evolution is necessary
456
+
457
+ Return ONLY valid JSON in this exact format:
458
+ {
459
+ "should_evolve": true,
460
+ "new_keywords": ["keyword1", "keyword2", "keyword3"],
461
+ "new_tags": ["tag1", "tag2"],
462
+ "new_context": "Updated context summary.",
463
+ "reasoning": "Explanation of why evolution occurred."
464
+ }
465
+
466
+ If no evolution is needed:
467
+ {
468
+ "should_evolve": false,
469
+ "new_keywords": [],
470
+ "new_tags": [],
471
+ "new_context": "",
472
+ "reasoning": "No significant new information."
473
+ }`;
474
+
475
+ const result = await llm.generate(prompt, {
476
+ temperature: 0.4,
477
+ maxTokens: 400,
478
+ });
479
+
480
+ if (!result) {
481
+ console.log(`[amem] LLM returned null for evolution of memory ${memoryId}`);
482
+ return false;
483
+ }
484
+
485
+ const evolution = extractJsonFromLLM(result.text) as MemoryEvolution | null;
486
+
487
+ if (!evolution || typeof evolution.should_evolve !== 'boolean') {
488
+ console.log(`[amem] Invalid evolution JSON for memory ${memoryId}`);
489
+ return false;
490
+ }
491
+
492
+ if (!evolution.should_evolve) {
493
+ console.log(`[amem] No evolution needed for memory ${memoryId}`);
494
+ return false;
495
+ }
496
+
497
+ // Validate evolution data
498
+ if (!Array.isArray(evolution.new_keywords) ||
499
+ !Array.isArray(evolution.new_tags) ||
500
+ typeof evolution.new_context !== 'string' ||
501
+ typeof evolution.reasoning !== 'string') {
502
+ console.log(`[amem] Invalid evolution data for memory ${memoryId}`);
503
+ return false;
504
+ }
505
+
506
+ // Get current version number
507
+ const versionRow = store.db.prepare(`
508
+ SELECT COALESCE(MAX(version), 0) as max_version
509
+ FROM memory_evolution
510
+ WHERE memory_id = ?
511
+ `).get(memoryId) as { max_version: number } | null;
512
+
513
+ const nextVersion = (versionRow?.max_version || 0) + 1;
514
+
515
+ // Perform transactional update
516
+ const updateStmt = store.db.prepare(`
517
+ UPDATE documents
518
+ SET amem_keywords = ?,
519
+ amem_tags = ?,
520
+ amem_context = ?
521
+ WHERE id = ?
522
+ `);
523
+
524
+ const historyStmt = store.db.prepare(`
525
+ INSERT INTO memory_evolution (
526
+ memory_id,
527
+ triggered_by,
528
+ version,
529
+ previous_keywords,
530
+ new_keywords,
531
+ previous_context,
532
+ new_context,
533
+ reasoning
534
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
535
+ `);
536
+
537
+ try {
538
+ store.db.exec("BEGIN TRANSACTION");
539
+
540
+ updateStmt.run(
541
+ JSON.stringify(evolution.new_keywords),
542
+ JSON.stringify(evolution.new_tags),
543
+ evolution.new_context,
544
+ memoryId
545
+ );
546
+
547
+ historyStmt.run(
548
+ memoryId,
549
+ triggeredBy,
550
+ nextVersion,
551
+ memory.amem_keywords,
552
+ JSON.stringify(evolution.new_keywords),
553
+ memory.amem_context,
554
+ evolution.new_context,
555
+ evolution.reasoning
556
+ );
557
+
558
+ store.db.exec("COMMIT");
559
+ console.log(`[amem] Evolved memory ${memoryId} to version ${nextVersion}`);
560
+ return true;
561
+ } catch (err) {
562
+ store.db.exec("ROLLBACK");
563
+ console.log(`[amem] Transaction failed for memory ${memoryId}:`, err);
564
+ return false;
565
+ }
566
+ } catch (err) {
567
+ console.log(`[amem] Error evolving memory ${memoryId}:`, err);
568
+ return false;
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Post-index enrichment orchestrator.
574
+ * Runs A-MEM processing after document indexing.
575
+ *
576
+ * For new documents:
577
+ * - Construct memory note
578
+ * - Generate memory links
579
+ * - Evolve memories based on new evidence
580
+ *
581
+ * For updated documents:
582
+ * - Refresh memory note only (skip links/evolution to avoid churn)
583
+ *
584
+ * All operations are non-fatal and gated by CLAWMEM_ENABLE_AMEM feature flag.
585
+ *
586
+ * @param store - Store instance
587
+ * @param llm - LLM instance
588
+ * @param docId - Document numeric ID
589
+ * @param isNew - True if this is a new document, false if update
590
+ */
591
+ export async function postIndexEnrich(
592
+ store: Store,
593
+ llm: LlamaCpp,
594
+ docId: number,
595
+ isNew: boolean
596
+ ): Promise<void> {
597
+ try {
598
+ // Check feature flag
599
+ if (Bun.env.CLAWMEM_ENABLE_AMEM === 'false') {
600
+ return;
601
+ }
602
+
603
+ console.log(`[amem] Starting enrichment for docId ${docId} (isNew=${isNew})`);
604
+
605
+ // Step 1: Construct and store memory note (always)
606
+ const note = await constructMemoryNote(store, llm, docId);
607
+ storeMemoryNote(store, docId, note);
608
+
609
+ // For updated documents, stop here to avoid churn
610
+ if (!isNew) {
611
+ console.log(`[amem] Completed note refresh for docId ${docId}`);
612
+ return;
613
+ }
614
+
615
+ // Step 2: Generate memory links (new documents only)
616
+ const linksCreated = await generateMemoryLinks(store, llm, docId);
617
+ console.log(`[amem] Created ${linksCreated} links for docId ${docId}`);
618
+
619
+ // Step 3: Evolve memories based on new evidence (new documents only)
620
+ // The new document triggers evolution of its linked neighbors
621
+ if (linksCreated > 0) {
622
+ // Get neighbors this new document links to (outbound links from generateMemoryLinks)
623
+ const neighbors = store.db.prepare(`
624
+ SELECT DISTINCT target_id
625
+ FROM memory_relations
626
+ WHERE source_id = ?
627
+ `).all(docId) as Array<{ target_id: number }>;
628
+
629
+ for (const neighbor of neighbors) {
630
+ await evolveMemories(store, llm, neighbor.target_id, docId);
631
+ }
632
+ }
633
+
634
+ console.log(`[amem] Completed full enrichment for docId ${docId}`);
635
+ } catch (err) {
636
+ console.log(`[amem] Error in postIndexEnrich for docId ${docId}:`, err);
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Observation with document ID for causal inference
642
+ */
643
+ export interface ObservationWithDoc {
644
+ docId: number;
645
+ facts: string[];
646
+ }
647
+
648
+ /**
649
+ * Causal link identified by LLM
650
+ */
651
+ interface CausalLink {
652
+ source_fact_idx: number;
653
+ target_fact_idx: number;
654
+ confidence: number;
655
+ reasoning: string;
656
+ }
657
+
658
+ /**
659
+ * Infer causal relationships between facts from observations.
660
+ * Analyzes facts using LLM and creates causal edges in memory_relations.
661
+ *
662
+ * @param store - Store instance
663
+ * @param llm - LLM instance
664
+ * @param observations - Array of observations with docId and facts
665
+ * @returns Number of causal links created
666
+ */
667
+ export async function inferCausalLinks(
668
+ store: Store,
669
+ llm: LlamaCpp,
670
+ observations: ObservationWithDoc[]
671
+ ): Promise<number> {
672
+ try {
673
+ // Build flat list of facts with source document mapping
674
+ const factMap: Array<{ fact: string; docId: number }> = [];
675
+ for (const obs of observations) {
676
+ for (const fact of obs.facts) {
677
+ factMap.push({ fact, docId: obs.docId });
678
+ }
679
+ }
680
+
681
+ // Need at least 2 facts to infer causality
682
+ if (factMap.length < 2) {
683
+ console.log(`[amem] Insufficient facts (${factMap.length}) for causal inference`);
684
+ return 0;
685
+ }
686
+
687
+ console.log(`[amem] Inferring causal links from ${factMap.length} facts across ${observations.length} observations`);
688
+
689
+ // Build LLM prompt
690
+ const factsText = factMap.map((f, idx) =>
691
+ `${idx}. ${f.fact}`
692
+ ).join('\n');
693
+
694
+ const prompt = `Analyze the following facts from a session and identify causal relationships.
695
+
696
+ Facts:
697
+ ${factsText}
698
+
699
+ Identify cause-effect relationships where one fact directly or indirectly caused another.
700
+ Consider:
701
+ - Temporal ordering (causes precede effects)
702
+ - Logical dependencies (one fact enables or triggers another)
703
+ - Problem-solution patterns (a discovery leads to an action)
704
+
705
+ Return ONLY valid JSON array in this exact format:
706
+ [
707
+ {
708
+ "source_fact_idx": 0,
709
+ "target_fact_idx": 2,
710
+ "confidence": 0.85,
711
+ "reasoning": "Brief explanation of causal relationship"
712
+ },
713
+ {
714
+ "source_fact_idx": 1,
715
+ "target_fact_idx": 3,
716
+ "confidence": 0.72,
717
+ "reasoning": "Brief explanation of causal relationship"
718
+ }
719
+ ]
720
+
721
+ Only include relationships with confidence >= 0.6. Return empty array [] if no causal relationships found.`;
722
+
723
+ const result = await llm.generate(prompt, {
724
+ temperature: 0.3,
725
+ maxTokens: 600,
726
+ });
727
+
728
+ if (!result) {
729
+ console.log(`[amem] LLM returned null for causal inference`);
730
+ return 0;
731
+ }
732
+
733
+ const links = extractJsonFromLLM(result.text) as CausalLink[] | null;
734
+
735
+ if (!Array.isArray(links)) {
736
+ console.log(`[amem] Invalid JSON for causal inference (not an array)`);
737
+ return 0;
738
+ }
739
+
740
+ // Filter by confidence threshold and insert causal links
741
+ let linksCreated = 0;
742
+ const timestamp = new Date().toISOString();
743
+ const insertStmt = store.db.prepare(`
744
+ INSERT OR IGNORE INTO memory_relations (
745
+ source_id, target_id, relation_type, weight, metadata, created_at
746
+ ) VALUES (?, ?, 'causal', ?, ?, ?)
747
+ `);
748
+
749
+ for (const link of links) {
750
+ // Validate link structure
751
+ if (typeof link.source_fact_idx !== 'number' ||
752
+ typeof link.target_fact_idx !== 'number' ||
753
+ typeof link.confidence !== 'number' ||
754
+ typeof link.reasoning !== 'string') {
755
+ console.log(`[amem] Invalid causal link structure, skipping`);
756
+ continue;
757
+ }
758
+
759
+ // Filter by confidence threshold
760
+ if (link.confidence < 0.6) {
761
+ continue;
762
+ }
763
+
764
+ // Validate indices
765
+ if (link.source_fact_idx < 0 || link.source_fact_idx >= factMap.length ||
766
+ link.target_fact_idx < 0 || link.target_fact_idx >= factMap.length) {
767
+ console.log(`[amem] Invalid fact indices: ${link.source_fact_idx} -> ${link.target_fact_idx}`);
768
+ continue;
769
+ }
770
+
771
+ // Get document IDs (bounds already validated above)
772
+ const sourceEntry = factMap[link.source_fact_idx]!;
773
+ const targetEntry = factMap[link.target_fact_idx]!;
774
+
775
+ // Skip self-links (same document)
776
+ if (sourceEntry.docId === targetEntry.docId) {
777
+ continue;
778
+ }
779
+
780
+ // Insert causal relation
781
+ const metadata = JSON.stringify({
782
+ reasoning: link.reasoning,
783
+ source_fact: sourceEntry.fact,
784
+ target_fact: targetEntry.fact,
785
+ });
786
+
787
+ insertStmt.run(sourceEntry.docId, targetEntry.docId, link.confidence, metadata, timestamp);
788
+ linksCreated++;
789
+ }
790
+
791
+ console.log(`[amem] Created ${linksCreated} causal links from ${links.length} identified relationships`);
792
+ return linksCreated;
793
+ } catch (err) {
794
+ console.log(`[amem] Error in inferCausalLinks:`, err);
795
+ return 0;
796
+ }
797
+ }