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 +33 -4
- package/dist/embeddings.js +33 -4
- package/dist/entry.js +1 -0
- package/dist/parity451.test.d.ts +1 -0
- package/dist/parity451.test.js +43 -0
- package/dist/tools.d.ts +3 -1
- package/dist/tools.js +6 -2
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
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.
|
|
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
|
|
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 (
|
|
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
|
{
|
package/dist/embeddings.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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
|
}
|