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.
- package/AGENTS.md +660 -0
- package/CLAUDE.md +660 -0
- package/LICENSE +21 -0
- package/README.md +993 -0
- package/SKILL.md +717 -0
- package/bin/clawmem +75 -0
- package/package.json +72 -0
- package/src/amem.ts +797 -0
- package/src/beads.ts +263 -0
- package/src/clawmem.ts +1849 -0
- package/src/collections.ts +405 -0
- package/src/config.ts +178 -0
- package/src/consolidation.ts +123 -0
- package/src/directory-context.ts +248 -0
- package/src/errors.ts +41 -0
- package/src/formatter.ts +427 -0
- package/src/graph-traversal.ts +247 -0
- package/src/hooks/context-surfacing.ts +317 -0
- package/src/hooks/curator-nudge.ts +89 -0
- package/src/hooks/decision-extractor.ts +639 -0
- package/src/hooks/feedback-loop.ts +214 -0
- package/src/hooks/handoff-generator.ts +345 -0
- package/src/hooks/postcompact-inject.ts +226 -0
- package/src/hooks/precompact-extract.ts +314 -0
- package/src/hooks/pretool-inject.ts +79 -0
- package/src/hooks/session-bootstrap.ts +324 -0
- package/src/hooks/staleness-check.ts +130 -0
- package/src/hooks.ts +367 -0
- package/src/indexer.ts +327 -0
- package/src/intent.ts +294 -0
- package/src/limits.ts +26 -0
- package/src/llm.ts +1175 -0
- package/src/mcp.ts +2138 -0
- package/src/memory.ts +336 -0
- package/src/mmr.ts +93 -0
- package/src/observer.ts +269 -0
- package/src/openclaw/engine.ts +283 -0
- package/src/openclaw/index.ts +221 -0
- package/src/openclaw/plugin.json +83 -0
- package/src/openclaw/shell.ts +207 -0
- package/src/openclaw/tools.ts +304 -0
- package/src/profile.ts +346 -0
- package/src/promptguard.ts +218 -0
- package/src/retrieval-gate.ts +106 -0
- package/src/search-utils.ts +127 -0
- package/src/server.ts +783 -0
- package/src/splitter.ts +325 -0
- package/src/store.ts +4062 -0
- package/src/validation.ts +67 -0
- 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
|
+
}
|