openclaw-plugin-memory-engine 3.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/dist/tools.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Memory Engine TS — F3: complete tool registry.
3
+ * Agent-facing tools with full logic; heavy admin jobs stay on the
4
+ * Python server and are exposed as explicit pointers.
5
+ */
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { Engine } from "./engine.js";
8
+ import { buildSessionDigest } from "./digest.js";
9
+ export const VERSION = "3.1.0";
10
+ export function createTools(dbPath) {
11
+ const db = new DatabaseSync(dbPath);
12
+ const engine = new Engine(db, {});
13
+ const q = (sql, ...p) => db.prepare(sql).all(...p);
14
+ const q1 = (sql, ...p) => db.prepare(sql).get(...p);
15
+ const slim = (a) => ({ id: a.id, title: a.title, domain: a.domain, type: a.type, confidence: a.confidence, snippet: String(a.body_compact || a.body || "").slice(0, 500) });
16
+ const tokens = (a) => Math.max(16, Math.floor((String(a.title || "").length + String(a.snippet || "").length) / 4));
17
+ return {
18
+ __db: db,
19
+ version: () => ({ version: VERSION, note: "Native Memory Engine plugin: 19 tools + session ingest/digest + curator." }),
20
+ recall: (query, limit = 5, domain) => engine.recall(query, limit, { domain: domain ?? undefined, semantic: false }).map(slim),
21
+ semantic_search: async (query, limit = 10, domain) => {
22
+ const { semanticSearch } = await import("./embeddings.js");
23
+ const results = await semanticSearch(db, query, limit, domain);
24
+ if (results.length)
25
+ return results;
26
+ // fallback FTS se ollama non raggiungibile
27
+ return engine.searchFts(query, limit, ["active"]).map((r) => ({ id: r.id, title: r.title, semantic_score: null, note: "fts-fallback (ollama non raggiungibile)" }));
28
+ },
29
+ remember: (title, content, type = "fact", domain = "general", confidence = 0.8, tags = []) => {
30
+ const now = Math.floor(Date.now() / 1000);
31
+ const id = title.toLowerCase().replace(/[^a-z0-9_\\s]/g, "").trim().replace(/[\\s_]+/g, "_").slice(0, 60) || "atom_" + now;
32
+ const tier = ["preference", "procedure"].includes(type) ? "procedural" : ["log", "event", "session_msg", "session_digest"].includes(type) ? "episodic" : "semantic";
33
+ db.prepare("INSERT OR REPLACE INTO atoms (id, type, memory_tier, domain, title, body, confidence, status, source, weight, tags, meta, created_at, updated_at, accessed_at) " +
34
+ "VALUES (?,?,?,?,?,?,?,'active','ai',1.0,?,?,?,?,?)").run(id, type, tier, domain, title, content, confidence, JSON.stringify(tags), "{}", now, now, now);
35
+ return { id, title, type, domain };
36
+ },
37
+ get_atom: (id) => q1("SELECT * FROM atoms WHERE id = ?", id) ?? null,
38
+ list_atoms: (domain, type, limit = 50) => {
39
+ let sql = "SELECT id, title, type, domain, status, memory_tier FROM atoms WHERE status = 'active'";
40
+ const p = [];
41
+ if (domain) {
42
+ sql += " AND domain = ?";
43
+ p.push(domain);
44
+ }
45
+ if (type) {
46
+ sql += " AND type = ?";
47
+ p.push(type);
48
+ }
49
+ return q(sql + " ORDER BY weight DESC LIMIT ?", ...p, limit);
50
+ },
51
+ delete_atom: (id) => {
52
+ db.prepare("DELETE FROM bonds WHERE from_id = ? OR to_id = ?").run(id, id);
53
+ const r = db.prepare("DELETE FROM atoms WHERE id = ?").run(id);
54
+ return { deleted: Number(r.changes) > 0, id };
55
+ },
56
+ link: (fromId, toId, relation, strength = 0.5, evidence) => {
57
+ db.prepare("INSERT OR REPLACE INTO bonds (from_id, to_id, relation, strength, evidence) VALUES (?,?,?,?,?)").run(fromId, toId, relation, strength, evidence ?? null);
58
+ return { linked: true, from: fromId, to: toId, relation };
59
+ },
60
+ unlink: (fromId, toId, relation) => {
61
+ const r = db.prepare("DELETE FROM bonds WHERE from_id = ? AND to_id = ? AND relation = ?").run(fromId, toId, relation);
62
+ return { unlinked: Number(r.changes) > 0 };
63
+ },
64
+ search_graph: (atomId, depth = 2) => {
65
+ const visited = new Set();
66
+ const nodes = [];
67
+ const edges = [];
68
+ const queue = [[atomId, 0]];
69
+ while (queue.length) {
70
+ const [cur, d] = queue.shift();
71
+ if (visited.has(cur) || d > depth)
72
+ continue;
73
+ visited.add(cur);
74
+ const a = q1("SELECT id, title, type, domain, status FROM atoms WHERE id = ?", cur);
75
+ if (a)
76
+ nodes.push(a);
77
+ if (d < depth) {
78
+ for (const b of q("SELECT * FROM bonds WHERE from_id = ? OR to_id = ?", cur, cur)) {
79
+ edges.push(b);
80
+ const next = b.from_id === cur ? b.to_id : b.from_id;
81
+ if (!visited.has(next))
82
+ queue.push([next, d + 1]);
83
+ }
84
+ }
85
+ }
86
+ return { nodes, edges };
87
+ },
88
+ working_set: (query, domain, limit = 8, tokenBudget = 2000) => {
89
+ const focus = engine.recall(query, limit, { domain: domain ?? undefined, semantic: false });
90
+ const focusIds = new Set(focus.map((r) => r.id));
91
+ const procedures = focus.filter((r) => ["procedure", "preference", "decision"].includes(r.type));
92
+ const context = [];
93
+ for (const seed of focus.slice(0, 5)) {
94
+ for (const n of engine.getRelatedAtoms(seed.id, 4, 0.35))
95
+ if (!focusIds.has(n.id)) {
96
+ context.push(slim(n));
97
+ focusIds.add(n.id);
98
+ }
99
+ }
100
+ const pack = {
101
+ query, domain,
102
+ focus_atoms: focus.map(slim),
103
+ key_procedures_decisions: procedures.slice(0, 5).map(slim),
104
+ context_atoms: context.slice(0, limit),
105
+ usage_hint: "Use focus_atoms first; context for adjacent facts.",
106
+ };
107
+ if (tokenBudget !== null) {
108
+ const sections = [["focus_atoms", 0.5], ["key_procedures_decisions", 0.25], ["context_atoms", 0.25]];
109
+ let remaining = tokenBudget;
110
+ const tbs = {};
111
+ for (const pass of ["capped", "borrow"]) {
112
+ for (const [name, share] of sections) {
113
+ const cap = pass === "capped" ? Math.max(0, Math.floor(tokenBudget * share)) : remaining;
114
+ if (cap <= 0)
115
+ continue;
116
+ let spent = 0;
117
+ const kept = [];
118
+ for (const a of pack[name]) {
119
+ const c = tokens(a);
120
+ if (spent + c > cap || c > remaining)
121
+ break;
122
+ kept.push(a);
123
+ spent += c;
124
+ remaining -= c;
125
+ a.category = a.type ?? "episodic";
126
+ a.reason = name;
127
+ }
128
+ tbs[name] = { atoms: kept.length, tokens: spent };
129
+ pack[name] = pass === "capped" ? kept : [...pack[name], ...kept.filter((k) => !pack[name].includes(k))];
130
+ if (remaining <= 0)
131
+ break;
132
+ }
133
+ if (remaining <= 0)
134
+ break;
135
+ }
136
+ pack.token_budget = { requested: tokenBudget, used: tokenBudget - remaining, tokens_by_section: tbs };
137
+ }
138
+ return pack;
139
+ },
140
+ memory_contradict: (oldAtomId, title, body = "", reason = "") => {
141
+ const now = Math.floor(Date.now() / 1000);
142
+ const old = q1("SELECT * FROM atoms WHERE id = ?", oldAtomId);
143
+ if (!old)
144
+ throw new Error("Atom '" + oldAtomId + "' not found");
145
+ const newId = title.toLowerCase().replace(/[^a-z0-9_\s]/g, "").trim().replace(/[\s_]+/g, "_").slice(0, 60);
146
+ const cid = "contradiction_" + oldAtomId + "_" + now;
147
+ db.prepare("INSERT INTO atoms (id, type, memory_tier, domain, title, body, confidence, status, source, weight, tags, meta, created_at, updated_at, accessed_at, valid_from) VALUES (?,?,?,?,?,?,'ai',0.8,'active',1.0,?,?,?,?)")
148
+ .run(newId, old.type, old.memory_tier, old.domain, title, body, JSON.stringify(["contradiction", "supersedes"]), JSON.stringify({ supersedes_atom_id: oldAtomId, contradiction_id: cid }), now, now, now, now);
149
+ db.prepare("UPDATE atoms SET status = 'superseded', valid_until = COALESCE(valid_until, ?), updated_at = ? WHERE id = ?").run(now, now, oldAtomId);
150
+ db.prepare("INSERT INTO memory_contradictions (id, old_atom_id, new_atom_id, reason, resolution, created_at, created_by, meta) VALUES (?,?,?,?,?,?,?,?)").run(cid, oldAtomId, newId, reason, "superseded", now, "ai", "{}");
151
+ db.prepare("INSERT OR REPLACE INTO bonds (from_id, to_id, relation, strength, evidence) VALUES (?,?,'supersedes',1.0,?)").run(newId, oldAtomId, reason || "Newer memory supersedes older atom");
152
+ return { contradiction_id: cid, old_atom_id: oldAtomId, new_atom_id: newId };
153
+ },
154
+ list_contradictions: (atomId, limit = 20) => {
155
+ if (atomId)
156
+ return q("SELECT * FROM memory_contradictions WHERE old_atom_id = ? OR new_atom_id = ? ORDER BY created_at DESC LIMIT ?", atomId, atomId, limit);
157
+ return q("SELECT * FROM memory_contradictions ORDER BY created_at DESC LIMIT ?", limit);
158
+ },
159
+ memory_impact: (atomId, depth = 2) => {
160
+ const res = { nodes: [], edges: [], relation_breakdown: {} };
161
+ const visited = new Set();
162
+ const frontier = new Set([atomId]);
163
+ for (let hop = 0; hop < depth && frontier.size; hop++) {
164
+ const ph = [...frontier].map(() => "?").join(",");
165
+ const rows = q("SELECT from_id, to_id, relation FROM bonds WHERE from_id IN (" + ph + ") OR to_id IN (" + ph + ")", ...frontier, ...frontier);
166
+ const next = new Set();
167
+ for (const e of rows) {
168
+ res.edges.push(e);
169
+ res.relation_breakdown[e.relation] = (res.relation_breakdown[e.relation] ?? 0) + 1;
170
+ const nb = e.from_id === atomId || visited.has(e.from_id) ? e.to_id : e.from_id;
171
+ if (!visited.has(nb) && nb !== atomId)
172
+ next.add(nb);
173
+ }
174
+ for (const id of next) {
175
+ visited.add(id);
176
+ const a = q1("SELECT id, title, type, domain FROM atoms WHERE id = ?", id);
177
+ if (a)
178
+ res.nodes.push(a);
179
+ }
180
+ frontier.clear();
181
+ for (const n of next)
182
+ frontier.add(n);
183
+ }
184
+ return res;
185
+ },
186
+ preference_search: (query, category, limit = 20) => {
187
+ let sql = "SELECT id, title, body, meta FROM atoms WHERE type = 'preference' AND status = 'active'";
188
+ const p = [];
189
+ if (query) {
190
+ sql += " AND (title LIKE ? OR body LIKE ?)";
191
+ p.push("%" + query + "%", "%" + query + "%");
192
+ }
193
+ return q(sql + " ORDER BY confidence DESC LIMIT ?", ...p, limit);
194
+ },
195
+ error_check: (taskDescription, limit = 5) => {
196
+ const words = taskDescription.toLowerCase().split(/\s+/).filter((w) => w.length >= 3);
197
+ const conds = words.map(() => "(task_type LIKE ? OR mistake_description LIKE ?)").join(" OR ");
198
+ const params = [];
199
+ for (const w of words) {
200
+ const pat = "%" + w + "%";
201
+ params.push(pat, pat);
202
+ }
203
+ return q("SELECT * FROM error_memory WHERE is_resolved = 0 AND (" + conds + ") ORDER BY occurrence_count DESC LIMIT ?", ...params, limit);
204
+ },
205
+ error_log: (taskType, mistake, correction, severity = "minor") => {
206
+ const now = Math.floor(Date.now() / 1000);
207
+ const crypto = globalThis.crypto;
208
+ const id = Array.from(new TextEncoder().encode(taskType + "|" + mistake)).slice(0, 12).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 12);
209
+ db.prepare("INSERT OR REPLACE INTO error_memory (id, task_type, error_category, mistake_description, correction, severity, occurrence_count, last_occurrence, is_resolved, created_at, updated_at) VALUES (?,?,?,?,?,?,1,?,0,?,?)")
210
+ .run(id, taskType, "general", mistake, correction, severity, now, now, now);
211
+ return { id, logged: true };
212
+ },
213
+ error_list: (resolved = false, limit = 20) => q("SELECT * FROM error_memory WHERE is_resolved = ? ORDER BY occurrence_count DESC LIMIT ?", resolved ? 1 : 0, limit),
214
+ stats: () => ({
215
+ total_atoms: q1("SELECT COUNT(*) n FROM atoms").n,
216
+ active: q1("SELECT COUNT(*) n FROM atoms WHERE status='active'").n,
217
+ bonds: q1("SELECT COUNT(*) n FROM bonds").n,
218
+ contradictions: q1("SELECT COUNT(*) n FROM memory_contradictions").n,
219
+ by_domain: Object.fromEntries(q("SELECT domain k, COUNT(*) n FROM atoms WHERE status='active' GROUP BY domain").map((r) => [r.k, r.n])),
220
+ by_type: Object.fromEntries(q("SELECT type k, COUNT(*) n FROM atoms WHERE status='active' GROUP BY type").map((r) => [r.k, r.n])),
221
+ }),
222
+ update_domain: (atomIds, newDomain) => {
223
+ const now = Math.floor(Date.now() / 1000);
224
+ const updated = [];
225
+ for (const id of atomIds) {
226
+ const r = db.prepare("UPDATE atoms SET domain = ?, updated_at = ? WHERE id = ? AND status = 'active'").run(newDomain, now, id);
227
+ if (Number(r.changes) > 0)
228
+ updated.push(id);
229
+ }
230
+ return { updated, new_domain: newDomain };
231
+ },
232
+ session_summary: (sessionId) => {
233
+ const d = q1("SELECT * FROM atoms WHERE type='session_digest' AND domain = ? AND status='active' ORDER BY created_at DESC", "session/" + sessionId);
234
+ return d ?? { note: "no digest for session" };
235
+ },
236
+ generate_session_digest: (sessionId) => {
237
+ const draft = buildSessionDigest(db, sessionId);
238
+ if (!draft)
239
+ return { created: false, note: "no user messages for session" };
240
+ const now = Math.floor(Date.now() / 1000);
241
+ db.prepare("INSERT INTO atoms (id, type, memory_tier, domain, title, body, confidence, status, source, weight, tags, meta, created_at, updated_at, accessed_at) VALUES (?,?,?,?,?,?,0.7,'active','ai',1.0,'[]','{}',?,?,?)")
242
+ .run(draft.id, "session_digest", "episodic", "session/" + sessionId, draft.title, draft.body, now, now, now);
243
+ return { created: true, ...draft };
244
+ },
245
+ // ── Admin jobs: puntano al server Python ──
246
+ _admin_note: () => "curator_run, learning_run, decay_run, backup/restore, import/export, cleanup_*, reindex_embeddings, find_similar, suggest_bonds: disponibili sul server MCP Python.",
247
+ };
248
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "id": "memory-engine",
3
+ "name": "Memory Engine",
4
+ "description": "Native Memory Engine: 19 memory tools (recall, working set, remember, bonds, curator) + periodic session ingest/digest for OpenClaw.",
5
+ "version": "3.1.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "properties": {},
9
+ "additionalProperties": false
10
+ },
11
+ "activation": {
12
+ "onStartup": true
13
+ },
14
+ "contracts": {
15
+ "tools": [
16
+ "memory_version",
17
+ "memory_recall",
18
+ "memory_remember",
19
+ "memory_working_set",
20
+ "memory_get_atom",
21
+ "memory_list_atoms",
22
+ "memory_link",
23
+ "memory_search_graph",
24
+ "memory_contradict",
25
+ "memory_impact",
26
+ "memory_preference_search",
27
+ "memory_error_check",
28
+ "memory_error_log",
29
+ "memory_stats",
30
+ "memory_session_summary",
31
+ "memory_generate_session_digest",
32
+ "memory_admin",
33
+ "memory_curator_run",
34
+ "memory_cognitive_status"
35
+ ]
36
+ }
37
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "openclaw-plugin-memory-engine",
3
+ "version": "3.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "tsc -p tsconfig.json",
7
+ "plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js",
8
+ "plugin:validate": "npm run build && openclaw plugins validate --entry ./dist/index.js",
9
+ "test": "vitest run --config ./vitest.config.ts"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "openclaw.plugin.json",
14
+ "README.md"
15
+ ],
16
+ "peerDependencies": {
17
+ "openclaw": ">=2026.5.17"
18
+ },
19
+ "dependencies": {
20
+ "typebox": "^1.1.38"
21
+ },
22
+ "devDependencies": {
23
+ "openclaw": "latest",
24
+ "typescript": "^7.0.2",
25
+ "vitest": "^3.2.0"
26
+ },
27
+ "openclaw": {
28
+ "extensions": [
29
+ "./dist/index.js"
30
+ ],
31
+ "compat": {
32
+ "pluginApi": ">=2026.5.17"
33
+ },
34
+ "build": {
35
+ "openclawVersion": "2026.9.7"
36
+ }
37
+ },
38
+ "description": "Native Memory Engine for OpenClaw: 19 memory tools + session ingest/digest + curator",
39
+ "license": "MIT",
40
+ "keywords": [
41
+ "openclaw",
42
+ "plugin",
43
+ "memory",
44
+ "knowledge-graph",
45
+ "curator"
46
+ ],
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/SimoneB79/memory-engine.git"
50
+ }
51
+ }