openclaw-plugin-memory-engine 4.5.0 → 4.5.1

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/admin.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { embed } from "./embeddings.js";
1
2
  import { Type } from "typebox";
2
3
  const T = Type;
3
4
  const NL = String.fromCharCode(10);
@@ -133,12 +134,40 @@ export function buildAdminDefs(db) {
133
134
  },
134
135
  {
135
136
  name: "memory_find_similar",
136
- description: "Find atoms similar to a reference atom. Uses embeddings when available, FTS title/body overlap otherwise.",
137
+ description: "Find atoms similar to a reference atom. Semantic via embeddings (cosine) with FTS fallback.",
137
138
  parameters: T.Object({ atom_id: T.String(), limit: T.Optional(T.Number()) }),
138
- execute: (a) => {
139
- const ref = q1("SELECT * FROM atoms WHERE id=?", a.atom_id);
139
+ execute: async (a) => {
140
+ const ref = q1("SELECT id, title, COALESCE(body_compact, body, '') body, domain FROM atoms WHERE id=?", a.atom_id);
140
141
  if (!ref)
141
142
  return { error: "atom not found" };
143
+ // 1) semantica: embedding del riferimento + coseno su atom_embeddings (parita' Python find_similar_atoms)
144
+ const refEmb = await embed(String(ref.title) + ". " + String(ref.body || "").slice(0, 1500));
145
+ if (refEmb) {
146
+ const rows = db.prepare("SELECT e.atom_id id, e.embedding, a.title, a.status, a.domain FROM atom_embeddings e JOIN atoms a ON a.id = e.atom_id WHERE a.status='active' AND e.atom_id != ?").all(ref.id);
147
+ const scored = [];
148
+ let skippedCorrupt = 0;
149
+ for (const r of rows) {
150
+ const buf = Buffer.isBuffer(r.embedding) ? r.embedding : Buffer.from(r.embedding?.buffer ?? r.embedding);
151
+ if (!buf || !buf.length || buf.length % 4 !== 0 || buf.length / 4 !== refEmb.length) {
152
+ skippedCorrupt++;
153
+ continue;
154
+ }
155
+ const v = new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
156
+ let dot = 0, na = 0, nb = 0;
157
+ for (let i = 0; i < v.length; i++) {
158
+ dot += refEmb[i] * v[i];
159
+ na += refEmb[i] * refEmb[i];
160
+ nb += v[i] * v[i];
161
+ }
162
+ const cos = na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
163
+ if (cos > 0.55)
164
+ scored.push({ id: r.id, title: r.title, domain: r.domain, score: Math.round(cos * 10000) / 10000 });
165
+ }
166
+ scored.sort((p2, c) => c.score - p2.score);
167
+ if (scored.length)
168
+ return { reference: ref.id, mode: "semantic", skipped_corrupt: skippedCorrupt, similar: scored.slice(0, a.limit ?? 5) };
169
+ }
170
+ // 2) fallback FTS (ollama down o nessun embedding sopra soglia)
142
171
  const words = String(ref.title || "").toLowerCase().split(/[^a-z0-9_]+/).filter((w) => w.length > 3);
143
172
  const out = [];
144
173
  const seen = new Set([ref.id]);
@@ -157,7 +186,7 @@ export function buildAdminDefs(db) {
157
186
  out.push({ id: h.id, title: h.title, score: h.score });
158
187
  }
159
188
  }
160
- return { reference: ref.id, mode: "fts (semantic embeddings: step 2)", similar: out.slice(0, a.limit ?? 5) };
189
+ return { reference: ref.id, mode: refEmb ? "fts (no embeddings above threshold)" : "fts-fallback (ollama non raggiungibile)", similar: out.slice(0, a.limit ?? 5) };
161
190
  },
162
191
  },
163
192
  {
@@ -32,6 +32,23 @@ function cosine(a, b) {
32
32
  return 0;
33
33
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
34
34
  }
35
+ // cache vettori Float32Array per atom_id (evita riconversione buffer a ogni query)
36
+ const _vecCache = new Map();
37
+ const VEC_CACHE_MAX = 20000;
38
+ function _cachedVec(id, buf) {
39
+ if (!buf || !buf.length || buf.length % 4 !== 0)
40
+ return null;
41
+ const hit = _vecCache.get(id);
42
+ if (hit && hit.byteLength === buf.length)
43
+ return hit;
44
+ if (_vecCache.size >= VEC_CACHE_MAX)
45
+ _vecCache.clear();
46
+ const v = new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
47
+ _vecCache.set(id, v);
48
+ return v;
49
+ }
50
+ // penalita' di dominio allineate all'engine FTS (session/daily inquinano la semantica)
51
+ const DOMAIN_PENALTY = [["session/", -0.05], ["daily/", -0.05]];
35
52
  export async function semanticSearch(db, query, limit = 10, domain) {
36
53
  const qv = await embed(query);
37
54
  if (!qv)
@@ -47,16 +64,28 @@ export async function semanticSearch(db, query, limit = 10, domain) {
47
64
  if (domain && r.domain !== domain)
48
65
  continue;
49
66
  const buf = Buffer.isBuffer(r.embedding) ? r.embedding : Buffer.from(r.embedding?.buffer ?? r.embedding);
50
- if (!buf || !buf.length || buf.length % 4 !== 0 || buf.length / 4 !== qv.length) {
67
+ const v = _cachedVec(r.id, buf);
68
+ if (!v || v.length !== qv.length) {
51
69
  skippedCorrupt++;
52
70
  continue;
53
71
  } // riga anomala: dimensione incoerente, scartata e conteggiata
54
- const v = new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
55
- scored.push({ id: r.id, title: r.title, domain: r.domain, type: r.type, semantic_score: Math.round(cosine(qv, v) * 10000) / 10000 });
72
+ let score = cosine(qv, v);
73
+ for (const [pfx, pen] of DOMAIN_PENALTY) {
74
+ if (String(r.domain || "").startsWith(pfx)) {
75
+ score += pen;
76
+ break;
77
+ }
78
+ }
79
+ scored.push({ id: r.id, title: r.title, domain: r.domain, type: r.type, semantic_score: Math.round(score * 10000) / 10000 });
56
80
  }
57
81
  semanticSearch._lastSkippedCorrupt = skippedCorrupt;
58
82
  scored.sort((a, b) => b.semantic_score - a.semantic_score);
59
- return scored.slice(0, limit);
83
+ // cutoff relativo: i punteggi nomic sono compressi (0.5-0.75); un top-N fisso mescola
84
+ // risultati rilevanti e rumore distanti pochi centesimi. Teniamo solo cio' che sta
85
+ // vicino al migliore, come fa un occhio umano a scartare i risultati "di riempimento".
86
+ const top = scored[0]?.semantic_score ?? 0;
87
+ const kept = scored.filter((s) => s.semantic_score >= top - 0.05);
88
+ return kept.slice(0, limit);
60
89
  }
61
90
  export async function embedAtom(db, atomId, text) {
62
91
  const v = await embed(text);
package/dist/entry.js CHANGED
@@ -53,6 +53,7 @@ export default definePluginEntry({
53
53
  const defs = [
54
54
  { name: "memory_version", description: "Memory Engine TS version.", parameters: T.Object({}), execute: () => me.version() },
55
55
  { name: "memory_recall", description: "Smart recall of past memories. Query short and specific. semantic=true merges embedding search.", parameters: T.Object({ query: T.String(), limit: T.Optional(T.Number()), domain: T.Optional(T.String()), semantic: T.Optional(T.Boolean()) }), execute: (a) => me.recall(a.query, a.limit ?? 5, a.domain, a.semantic ?? false) },
56
+ { name: "memory_recall_session", description: "Recall messages from a specific OpenClaw session (search within session/ domain atoms).", parameters: T.Object({ session_id: T.String(), query: T.String(), limit: T.Optional(T.Number()) }), execute: (a) => me.recall_session(a.session_id, a.query, a.limit ?? 10) },
56
57
  { name: "memory_remember", description: "Save a durable memory (fact, decision, procedure, preference).", parameters: T.Object({ title: T.String(), content: T.String(), type: T.Optional(T.String()), domain: T.Optional(T.String()), confidence: T.Optional(T.Number()) }), execute: (a) => me.remember(a.title, a.content, a.type ?? "fact", a.domain ?? "general", a.confidence ?? 0.8) },
57
58
  { name: "memory_working_set", description: "Task context pack with token budget.", parameters: T.Object({ query: T.String(), domain: T.Optional(T.String()), limit: T.Optional(T.Number()), token_budget: T.Optional(T.Number()) }), execute: (a) => me.working_set(a.query, a.domain, a.limit ?? 8, a.token_budget ?? 2000) },
58
59
  { name: "memory_get_atom", description: "Full atom by id.", parameters: T.Object({ id: T.String() }), execute: (a) => me.get_atom(a.id) },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, beforeAll } from "vitest";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createTools } from "./tools.js";
6
+ import { buildAdminDefs } from "./admin.js";
7
+ describe("parità 4.5.1: recall_session + find_similar", () => {
8
+ let me, findSimilar;
9
+ beforeAll(() => {
10
+ const dir = mkdtempSync(join(tmpdir(), "me-p451-"));
11
+ process.env.MEMORY_DB_PATH = join(dir, "memory.db");
12
+ me = createTools(process.env.MEMORY_DB_PATH);
13
+ const db = me.__db;
14
+ const now = Math.floor(Date.now() / 1000);
15
+ db.prepare("CREATE TABLE IF NOT EXISTS atoms (id TEXT PRIMARY KEY, title TEXT, domain TEXT, type TEXT, status TEXT, confidence REAL, access_count INTEGER, weight REAL, body TEXT, body_compact TEXT, tags TEXT, meta TEXT, created_at INTEGER, updated_at INTEGER, accessed_at INTEGER)").run();
16
+ db.prepare("CREATE TABLE IF NOT EXISTS human_questions (id TEXT PRIMARY KEY, atom_ids TEXT, question_type TEXT, question TEXT, options TEXT, meta TEXT, status TEXT, answer TEXT, answered_at INTEGER, created_at INTEGER)").run();
17
+ db.prepare("CREATE TABLE IF NOT EXISTS bonds (from_id TEXT, to_id TEXT, relation TEXT, strength REAL, evidence TEXT, created_at INTEGER)").run();
18
+ db.prepare("CREATE VIRTUAL TABLE IF NOT EXISTS atoms_fts USING fts5(title, body, content='atoms', content_rowid='rowid')").run();
19
+ db.prepare("CREATE TABLE IF NOT EXISTS atom_embeddings (atom_id TEXT PRIMARY KEY, embedding BLOB, model TEXT, dim INTEGER, created_at INTEGER, updated_at INTEGER)").run();
20
+ const ins = db.prepare("INSERT INTO atoms (id, title, domain, type, status, confidence, access_count, weight, body, created_at, updated_at, accessed_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
21
+ ins.run("s1-msg1", "Configurazione UPS NUT", "session/testsess", "session_msg", "active", 0.8, 1, 0.5, "abbiamo fermato il container nut-upsd e mascherato il NUT nativo", now, now, now);
22
+ ins.run("s1-msg2", "Diagnosi NAS rumoroso", "session/testsess", "session_msg", "active", 0.8, 1, 0.5, "il rumore era causato dal restart loop del container", now, now, now);
23
+ ins.run("other1", "Ricetta carbonara", "cucina", "fact", "active", 0.9, 2, 0.7, "guanciale, uova, pecorino, pepe nero", now, now, now);
24
+ db.prepare("INSERT INTO atoms_fts(atoms_fts) VALUES('rebuild')").run();
25
+ findSimilar = buildAdminDefs(db).find((d) => d.name === "memory_find_similar");
26
+ });
27
+ it("recall_session trova solo atomi del dominio session/testsess", async () => {
28
+ const res = await me.recall_session("testsess", "container NUT");
29
+ expect(Array.isArray(res)).toBe(true);
30
+ expect(res.length).toBeGreaterThan(0);
31
+ expect(res.every((r) => r.domain === "session/testsess")).toBe(true);
32
+ });
33
+ it("find_similar senza ollama: fallback FTS e mai crash", async () => {
34
+ const res = await findSimilar.execute({ atom_id: "s1-msg1", limit: 3 });
35
+ expect(res.reference).toBe("s1-msg1");
36
+ expect(typeof res.mode).toBe("string");
37
+ expect(Array.isArray(res.similar)).toBe(true);
38
+ });
39
+ it("find_similar su atomo inesistente: errore pulito", async () => {
40
+ const res = await findSimilar.execute({ atom_id: "non-esiste" });
41
+ expect(res.error).toBe("atom not found");
42
+ });
43
+ });
package/dist/tools.d.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * Python server and are exposed as explicit pointers.
5
5
  */
6
6
  import { DatabaseSync } from "node:sqlite";
7
- export declare const VERSION = "4.5.0";
7
+ import { Row } from "./engine.js";
8
+ export declare const VERSION = "4.5.1";
8
9
  export declare function createTools(dbPath: string): {
9
10
  __db: DatabaseSync;
10
11
  version: () => {
@@ -31,6 +32,7 @@ export declare function createTools(dbPath: string): {
31
32
  semantic_score: null;
32
33
  note: string;
33
34
  }[]>;
35
+ recall_session: (session_id: string, query: string, limit?: number) => Promise<Row[]>;
34
36
  remember: (title: string, content: string, type?: string, domain?: string, confidence?: number, tags?: string[]) => {
35
37
  id: string;
36
38
  title: string;
package/dist/tools.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import { DatabaseSync } from "node:sqlite";
7
7
  import { Engine } from "./engine.js";
8
8
  import { buildSessionDigest } from "./digest.js";
9
- export const VERSION = "4.5.0";
9
+ export const VERSION = "4.5.1";
10
10
  export function createTools(dbPath) {
11
11
  const db = new DatabaseSync(dbPath);
12
12
  const engine = new Engine(db, {});
@@ -39,6 +39,10 @@ export function createTools(dbPath) {
39
39
  // fallback FTS se ollama non raggiungibile
40
40
  return engine.searchFts(query, limit, ["active"]).map((r) => ({ id: r.id, title: r.title, semantic_score: null, note: "fts-fallback (ollama non raggiungibile)" }));
41
41
  },
42
+ recall_session: async (session_id, query, limit = 10) => {
43
+ // parità Python recall_session: recall limitato al dominio della sessione
44
+ return engine.recall(query, limit, { domain: "session/" + session_id, semantic: false });
45
+ },
42
46
  remember: (title, content, type = "fact", domain = "general", confidence = 0.8, tags = []) => {
43
47
  const now = Math.floor(Date.now() / 1000);
44
48
  const id = title.toLowerCase().replace(/[^a-z0-9_\\s]/g, "").trim().replace(/[\\s_]+/g, "_").slice(0, 60) || "atom_" + now;
@@ -256,6 +260,6 @@ export function createTools(dbPath) {
256
260
  return { created: true, ...draft };
257
261
  },
258
262
  // ── Admin jobs: puntano al server Python ──
259
- _admin_note: () => "curator_run, learning_run, decay_run, backup/restore, import/export, cleanup_*, reindex_embeddings, find_similar, suggest_bonds: disponibili sul server MCP Python.",
263
+ _admin_note: () => "curator_run, learning_run, decay_run, backup/restore, import/export, cleanup_*, reindex_embeddings, find_similar, suggest_bonds: tutti disponibili come tool nativi memory_* (parità completa dal 4.5.x).",
260
264
  };
261
265
  }
@@ -2,7 +2,7 @@
2
2
  "id": "memory-engine",
3
3
  "name": "Memory Engine",
4
4
  "description": "Native Memory Engine: 45 memory tools, session ingest/digest, curator and Control UI.",
5
- "version": "4.5.0",
5
+ "version": "4.5.1",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "properties": {
@@ -78,7 +78,8 @@
78
78
  "memory_import_markdown",
79
79
  "memory_learning_run",
80
80
  "memory_ask_pending",
81
- "memory_answer_human"
81
+ "memory_answer_human",
82
+ "memory_recall_session"
82
83
  ]
83
84
  }
84
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-memory-engine",
3
- "version": "4.5.0",
3
+ "version": "4.5.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc -p tsconfig.json && openclaw plugins build",