@icex-labs/openclaw-memory-engine 5.1.0 → 5.2.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/extras/auto-consolidation-crons.json +8 -0
- package/index.js +24 -5
- package/lib/consolidate.js +18 -1
- package/lib/graph.js +48 -41
- package/lib/reflection.js +27 -0
- package/package.json +1 -1
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
"message": "Run memory_dashboard to regenerate the HTML dashboard. Do NOT output anything to the main chat.",
|
|
33
33
|
"model": "anthropic/claude-sonnet-4-6",
|
|
34
34
|
"description": "Daily dashboard refresh at 9:30am"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "memory-graph-weekly",
|
|
38
|
+
"schedule": "0 5 * * 0",
|
|
39
|
+
"agent": "main",
|
|
40
|
+
"message": "Review the last 50 archival records via archival_search. For any that describe clear relationships between entities (person owns thing, person works at place, person has condition, etc.), add them via graph_add. Only add relationships you are confident about. Do NOT output anything to the main chat. Send summary to notify bot.",
|
|
41
|
+
"model": "anthropic/claude-sonnet-4-6",
|
|
42
|
+
"description": "Weekly: LLM-based graph triple extraction from recent archival"
|
|
35
43
|
}
|
|
36
44
|
]
|
|
37
45
|
}
|
package/index.js
CHANGED
|
@@ -323,13 +323,32 @@ export default definePluginEntry({
|
|
|
323
323
|
const old = records[idx].content;
|
|
324
324
|
records[idx].content = params.content;
|
|
325
325
|
records[idx].updated_at = new Date().toISOString();
|
|
326
|
-
|
|
326
|
+
|
|
327
|
+
// Re-classify if entity not explicitly provided
|
|
328
|
+
if (params.entity !== undefined) {
|
|
329
|
+
records[idx].entity = params.entity;
|
|
330
|
+
} else {
|
|
331
|
+
const { classify } = await import("./lib/classifier.js");
|
|
332
|
+
const cls = await classify(params.content, wsp);
|
|
333
|
+
if (cls.entity !== "general") records[idx].entity = cls.entity;
|
|
334
|
+
records[idx].importance = cls.importance;
|
|
335
|
+
// Reuse embedding for indexing
|
|
336
|
+
if (cls.embedding) {
|
|
337
|
+
const embCache = loadEmbeddingCache(wsp);
|
|
338
|
+
embCache[params.id] = cls.embedding;
|
|
339
|
+
saveEmbeddingCache(wsp);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
327
342
|
if (params.tags !== undefined) records[idx].tags = params.tags;
|
|
328
343
|
rewriteArchival(wsp, records);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
344
|
+
|
|
345
|
+
// Re-embed if classify didn't already do it
|
|
346
|
+
if (params.entity !== undefined) {
|
|
347
|
+
const embCache = loadEmbeddingCache(wsp);
|
|
348
|
+
delete embCache[params.id];
|
|
349
|
+
saveEmbeddingCache(wsp);
|
|
350
|
+
indexEmbedding(wsp, records[idx]).catch(() => {});
|
|
351
|
+
}
|
|
333
352
|
return text(`OK: Updated ${params.id}. Old: "${old.slice(0, 60)}..." → New: "${params.content.slice(0, 60)}..."`);
|
|
334
353
|
},
|
|
335
354
|
})));
|
package/lib/consolidate.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { loadArchival, appendRecord } from "./archival.js";
|
|
7
|
-
import { loadEmbeddingCache, saveEmbeddingCache } from "./embedding.js";
|
|
7
|
+
import { loadEmbeddingCache, saveEmbeddingCache, cosineSimilarity } from "./embedding.js";
|
|
8
8
|
import { classify } from "./classifier.js";
|
|
9
9
|
|
|
10
10
|
/** Split text into sentence-level fact candidates. */
|
|
@@ -54,6 +54,12 @@ export async function consolidateText(ws, text, defaultEntity = "", defaultTags
|
|
|
54
54
|
const inserted = [];
|
|
55
55
|
const skipped = [];
|
|
56
56
|
|
|
57
|
+
// Load embedding cache for semantic dedup
|
|
58
|
+
const embCache = loadEmbeddingCache(ws);
|
|
59
|
+
const recentEmbeddings = existing.slice(-100)
|
|
60
|
+
.map((r) => embCache[r.id])
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
|
|
57
63
|
for (const fact of candidates) {
|
|
58
64
|
const factLower = fact.toLowerCase();
|
|
59
65
|
if (isDuplicate(factLower, existingTexts)) {
|
|
@@ -62,6 +68,17 @@ export async function consolidateText(ws, text, defaultEntity = "", defaultTags
|
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
const { entity, importance, embedding } = await classify(fact, ws);
|
|
71
|
+
|
|
72
|
+
// Semantic dedup: skip if cosine similarity > 0.92 with any recent record
|
|
73
|
+
if (embedding && recentEmbeddings.length > 0) {
|
|
74
|
+
const isSemDupe = recentEmbeddings.some(
|
|
75
|
+
(emb) => cosineSimilarity(embedding, emb) > 0.92,
|
|
76
|
+
);
|
|
77
|
+
if (isSemDupe) {
|
|
78
|
+
skipped.push(fact.slice(0, 60));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
65
82
|
const finalEntity = (entity !== "general") ? entity : defaultEntity || "general";
|
|
66
83
|
|
|
67
84
|
const record = appendRecord(ws, {
|
package/lib/graph.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Knowledge Graph: triple store (subject, relation, object).
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Storage: memory/graph.jsonl — one triple per line.
|
|
6
|
-
* Auto-extraction: pattern-based extraction from archival insert content.
|
|
3
|
+
* v5.1: strict extraction — only extract triples from clear, short, structured statements.
|
|
7
4
|
*/
|
|
8
5
|
|
|
9
6
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
@@ -12,7 +9,7 @@ import { graphPath } from "./paths.js";
|
|
|
12
9
|
|
|
13
10
|
// ─── In-memory cache ───
|
|
14
11
|
|
|
15
|
-
const cache = new Map();
|
|
12
|
+
const cache = new Map();
|
|
16
13
|
|
|
17
14
|
export function loadGraph(ws) {
|
|
18
15
|
if (cache.has(ws) && cache.get(ws).loaded) return cache.get(ws).triples;
|
|
@@ -31,7 +28,6 @@ export function loadGraph(ws) {
|
|
|
31
28
|
export function addTriple(ws, subject, relation, object, sourceId = null) {
|
|
32
29
|
const triples = loadGraph(ws);
|
|
33
30
|
|
|
34
|
-
// Deduplicate: same subject+relation+object
|
|
35
31
|
const exists = triples.some(
|
|
36
32
|
(t) => t.s === subject && t.r === relation && t.o === object,
|
|
37
33
|
);
|
|
@@ -61,13 +57,6 @@ export function removeTriple(ws, tripleId) {
|
|
|
61
57
|
return true;
|
|
62
58
|
}
|
|
63
59
|
|
|
64
|
-
/**
|
|
65
|
-
* Query the graph from a starting entity, optionally filtering by relation.
|
|
66
|
-
* @param {string} entity - starting node
|
|
67
|
-
* @param {string} [relation] - optional relation filter
|
|
68
|
-
* @param {number} [depth=2] - traversal depth
|
|
69
|
-
* @returns {Array<{ path: string[], triple: object }>}
|
|
70
|
-
*/
|
|
71
60
|
export function queryGraph(ws, entity, relation = null, depth = 2) {
|
|
72
61
|
const triples = loadGraph(ws);
|
|
73
62
|
const entityLower = entity.toLowerCase();
|
|
@@ -82,16 +71,14 @@ export function queryGraph(ws, entity, relation = null, depth = 2) {
|
|
|
82
71
|
|
|
83
72
|
const currentLower = current.toLowerCase();
|
|
84
73
|
for (const t of triples) {
|
|
85
|
-
// Forward: subject matches
|
|
86
74
|
if (t.s.toLowerCase() === currentLower) {
|
|
87
75
|
if (relation && t.r.toLowerCase() !== relation.toLowerCase()) continue;
|
|
88
|
-
results.push({ path: [...path, `--${t.r}-->`], node: t.o, triple: t });
|
|
76
|
+
results.push({ path: [...path, `--${t.r}-->`], node: t.o, triple: { id: t.id, s: t.s, r: t.r, o: t.o } });
|
|
89
77
|
traverse(t.o, currentDepth + 1, [...path, `--${t.r}-->`, t.o]);
|
|
90
78
|
}
|
|
91
|
-
// Reverse: object matches
|
|
92
79
|
if (t.o.toLowerCase() === currentLower) {
|
|
93
80
|
if (relation && t.r.toLowerCase() !== relation.toLowerCase()) continue;
|
|
94
|
-
results.push({ path: [...path, `<--${t.r}--`], node: t.s, triple: t });
|
|
81
|
+
results.push({ path: [...path, `<--${t.r}--`], node: t.s, triple: { id: t.id, s: t.s, r: t.r, o: t.o } });
|
|
95
82
|
traverse(t.s, currentDepth + 1, [...path, `<--${t.r}--`, t.s]);
|
|
96
83
|
}
|
|
97
84
|
}
|
|
@@ -101,45 +88,65 @@ export function queryGraph(ws, entity, relation = null, depth = 2) {
|
|
|
101
88
|
return results;
|
|
102
89
|
}
|
|
103
90
|
|
|
104
|
-
// ─── Auto-extraction patterns ───
|
|
91
|
+
// ─── Auto-extraction: strict patterns only ───
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate that a string looks like a proper entity name (not a sentence fragment).
|
|
95
|
+
* - Must be short (≤25 chars)
|
|
96
|
+
* - Must not contain markdown, code, or sentence-like patterns
|
|
97
|
+
* - Must start with a capital letter or CJK character
|
|
98
|
+
*/
|
|
99
|
+
function isValidEntity(s) {
|
|
100
|
+
if (!s || s.length > 25 || s.length < 2) return false;
|
|
101
|
+
// Reject markdown, code, URLs, punctuation-heavy strings
|
|
102
|
+
if (/[`*\[\]{}()|→←⚠#>]/.test(s)) return false;
|
|
103
|
+
// Reject if it looks like a sentence (has verb-like patterns or too many words)
|
|
104
|
+
if (s.split(/\s+/).length > 5) return false;
|
|
105
|
+
// Must start with uppercase, CJK, or known pattern
|
|
106
|
+
if (!/^[A-Z\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(s)) return false;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
105
109
|
|
|
106
110
|
const EXTRACTION_PATTERNS = [
|
|
107
|
-
// "X's doctor is Y"
|
|
108
|
-
{ re:
|
|
109
|
-
// "X
|
|
110
|
-
{ re:
|
|
111
|
-
// "X
|
|
112
|
-
{ re:
|
|
113
|
-
// "X
|
|
114
|
-
{ re:
|
|
115
|
-
// "X
|
|
116
|
-
{ re:
|
|
111
|
+
// "X's doctor is Y"
|
|
112
|
+
{ re: /^([A-Z]\w+)(?:'s)\s+doctor\s+is\s+(.+)$/i, r: "has_doctor" },
|
|
113
|
+
// "X's lawyer is Y"
|
|
114
|
+
{ re: /^([A-Z]\w+)(?:'s)\s+(?:lawyer|attorney)\s+is\s+(.+)$/i, r: "has_lawyer" },
|
|
115
|
+
// "X lives in Y"
|
|
116
|
+
{ re: /^([A-Z]\w+)\s+lives?\s+in\s+([A-Z][\w\s,]+)$/i, r: "lives_in" },
|
|
117
|
+
// "X works at Y"
|
|
118
|
+
{ re: /^([A-Z]\w+)\s+works?\s+at\s+([A-Z][\w\s&.]+)$/i, r: "works_at" },
|
|
119
|
+
// "X owns Y" — only match short clear statements
|
|
120
|
+
{ re: /^([A-Z]\w+)\s+owns?\s+(?:a\s+)?([A-Z][\w\s]+)$/i, r: "owns" },
|
|
121
|
+
// "X drives a Y"
|
|
122
|
+
{ re: /^([A-Z]\w+)\s+drives?\s+(?:a\s+)?([A-Z][\w\s]+)$/i, r: "owns" },
|
|
123
|
+
// "X has chronic Y" / "X has Y disease/condition"
|
|
124
|
+
{ re: /^([A-Z]\w+)\s+has\s+(?:chronic\s+)?(\w[\w\s]*(?:disease|condition|syndrome|urticaria|diabetes|asthma))$/i, r: "has_condition" },
|
|
125
|
+
// "X takes Y" (medication)
|
|
126
|
+
{ re: /^([A-Z]\w+)\s+takes?\s+([A-Z][\w\s]+\d+\s*mg)$/i, r: "treated_by" },
|
|
127
|
+
// "X attends Y"
|
|
128
|
+
{ re: /^([A-Z]\w+)\s+attends?\s+([A-Z][\w\s]+)$/i, r: "attends" },
|
|
117
129
|
// "X's wife/husband is Y"
|
|
118
|
-
{ re:
|
|
130
|
+
{ re: /^([A-Z]\w+)(?:'s)\s+(?:wife|husband)\s+is\s+(.+)$/i, r: "spouse" },
|
|
119
131
|
// "X's son/daughter is Y"
|
|
120
|
-
{ re:
|
|
121
|
-
// "X costs/price Y" / 定价
|
|
122
|
-
{ re: /(.+?)\s*(?:costs?|定价|售价|price[ds]?\s*(?:at)?)\s*\$?([\d,.]+)/i, r: "price" },
|
|
123
|
-
// "X's lawyer is Y"
|
|
124
|
-
{ re: /(.+?)(?:'s|的)\s*(?:lawyer|律师|attorney)\s*(?:is|是|为)\s*(.+)/i, r: "has_lawyer" },
|
|
125
|
-
// "X owns Y" / 拥有
|
|
126
|
-
{ re: /(.+?)\s*(?:owns?|拥有|有一辆|drives?)\s*(.+)/i, r: "owns" },
|
|
127
|
-
// "X studies/attends Y" / 就读
|
|
128
|
-
{ re: /(.+?)\s*(?:attends?|studies? at|就读于?|在(.+?)(?:上学|读书))/i, r: "attends" },
|
|
132
|
+
{ re: /^([A-Z]\w+)(?:'s)\s+(?:son|daughter)\s+is\s+(.+)$/i, r: "has_child" },
|
|
129
133
|
];
|
|
130
134
|
|
|
131
135
|
/**
|
|
132
136
|
* Extract triples from a text string.
|
|
133
|
-
*
|
|
137
|
+
* Strict: only matches clean, short statements with proper entity names.
|
|
134
138
|
*/
|
|
135
139
|
export function extractTriples(text) {
|
|
136
140
|
const results = [];
|
|
141
|
+
// Only try extraction on short, clear text (not paragraphs)
|
|
142
|
+
if (text.length > 150) return results;
|
|
143
|
+
|
|
137
144
|
for (const pat of EXTRACTION_PATTERNS) {
|
|
138
145
|
const m = text.match(pat.re);
|
|
139
146
|
if (m) {
|
|
140
147
|
const s = (m[1] || "").trim();
|
|
141
|
-
const o = (
|
|
142
|
-
if (s && o
|
|
148
|
+
const o = (m[2] || "").trim();
|
|
149
|
+
if (isValidEntity(s) && o.length >= 2 && o.length <= 40) {
|
|
143
150
|
results.push({ s, r: pat.r, o });
|
|
144
151
|
}
|
|
145
152
|
}
|
package/lib/reflection.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { loadArchival } from "./archival.js";
|
|
10
10
|
import { loadEpisodes } from "./episodes.js";
|
|
11
11
|
import { loadGraph } from "./graph.js";
|
|
12
|
+
import { readCore } from "./core.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Analyze recent memory for patterns and trends.
|
|
@@ -132,9 +133,27 @@ export function analyzePatterns(ws, windowDays = 7) {
|
|
|
132
133
|
neglected_entities: neglected.slice(0, 10),
|
|
133
134
|
forgetting_candidates: forgettingCandidates.length,
|
|
134
135
|
},
|
|
136
|
+
// Core memory focus age check
|
|
137
|
+
stale_focus: checkStaleFocus(ws),
|
|
135
138
|
};
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Check if current_focus items in core memory are stale (>14 days since last update).
|
|
143
|
+
*/
|
|
144
|
+
function checkStaleFocus(ws) {
|
|
145
|
+
try {
|
|
146
|
+
const core = readCore(ws);
|
|
147
|
+
const updatedAt = core._meta?.updated_at;
|
|
148
|
+
if (!updatedAt) return [];
|
|
149
|
+
const daysSinceUpdate = (Date.now() - new Date(updatedAt).getTime()) / 86400000;
|
|
150
|
+
if (daysSinceUpdate > 14 && Array.isArray(core.current_focus) && core.current_focus.length > 0) {
|
|
151
|
+
return core.current_focus;
|
|
152
|
+
}
|
|
153
|
+
} catch { /* ignore */ }
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
138
157
|
/**
|
|
139
158
|
* Format analysis into a human-readable report for the agent.
|
|
140
159
|
*/
|
|
@@ -182,6 +201,14 @@ export function formatReflection(analysis) {
|
|
|
182
201
|
lines.push(``, `⚠️ ${analysis.health.forgetting_candidates} facts below forgetting threshold — consider archival_deduplicate or cleanup`);
|
|
183
202
|
}
|
|
184
203
|
|
|
204
|
+
if (analysis.stale_focus && analysis.stale_focus.length > 0) {
|
|
205
|
+
lines.push(``, `⚠️ Core memory current_focus not updated in >14 days. Review these items:`);
|
|
206
|
+
for (const item of analysis.stale_focus) {
|
|
207
|
+
lines.push(` - ${item}`);
|
|
208
|
+
}
|
|
209
|
+
lines.push(` Use core_memory_replace to update or remove completed items.`);
|
|
210
|
+
}
|
|
211
|
+
|
|
185
212
|
lines.push(``, `Totals: ${analysis.health.total_archival} facts, ${analysis.health.total_episodes} episodes, ${analysis.health.total_graph} graph triples`);
|
|
186
213
|
|
|
187
214
|
return lines.join("\n");
|
package/package.json
CHANGED