openclaw-plugin-memory-engine 4.4.1 → 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/learning.d.ts +39 -0
- package/dist/learning.js +277 -0
- package/dist/learning.test.d.ts +1 -0
- package/dist/learning.test.js +52 -0
- package/dist/maintenance.js +40 -14
- 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,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Engine TS — Learning module (full Python parity port, v4.5.0).
|
|
3
|
+
* 7 detector + dedup + capacity + answer side-effects, port fedele di learning.py/engine.py.
|
|
4
|
+
*/
|
|
5
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
6
|
+
export interface LearningConfig {
|
|
7
|
+
max_pending_questions?: number;
|
|
8
|
+
contradiction_threshold?: number;
|
|
9
|
+
weak_confidence_threshold?: number;
|
|
10
|
+
weak_access_threshold?: number;
|
|
11
|
+
merge_similarity_threshold?: number;
|
|
12
|
+
decay_critical_threshold?: number;
|
|
13
|
+
gap_body_min_chars?: number;
|
|
14
|
+
graph_gap_enabled?: boolean;
|
|
15
|
+
graph_gap_min_weight?: number;
|
|
16
|
+
graph_gap_limit?: number;
|
|
17
|
+
graph_gap_types?: string[];
|
|
18
|
+
graph_gap_excluded_domain_prefixes?: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare function computeSimilarity(a: any, b: any): number;
|
|
21
|
+
export declare class Learning {
|
|
22
|
+
private db;
|
|
23
|
+
config: LearningConfig;
|
|
24
|
+
constructor(db: DatabaseSync, config?: LearningConfig);
|
|
25
|
+
private q;
|
|
26
|
+
private q1;
|
|
27
|
+
private x;
|
|
28
|
+
/** Garantisce le colonne options/meta (migration idempotente). */
|
|
29
|
+
ensureSchema(): void;
|
|
30
|
+
detectContradictions(): any[];
|
|
31
|
+
detectWeakAtoms(): any[];
|
|
32
|
+
detectMergeCandidates(): any[];
|
|
33
|
+
detectDecayCritical(): any[];
|
|
34
|
+
detectGaps(): any[];
|
|
35
|
+
detectGraphGaps(): any[];
|
|
36
|
+
addQuestion(atomIds: string[], questionType: string, question: string, options: string[], meta: any): any;
|
|
37
|
+
runAllChecks(): any[];
|
|
38
|
+
applyAnswerSideEffects(qrow: any, answer: string): string[];
|
|
39
|
+
}
|
package/dist/learning.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
function parseTags(v) {
|
|
2
|
+
if (Array.isArray(v))
|
|
3
|
+
return new Set(v.map(String));
|
|
4
|
+
try {
|
|
5
|
+
return new Set(JSON.parse(v || "[]").map(String));
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return new Set();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function computeSimilarity(a, b) {
|
|
12
|
+
const wa = new Set(String(a.title || "").toLowerCase().split(/\s+/).filter(Boolean));
|
|
13
|
+
const wb = new Set(String(b.title || "").toLowerCase().split(/\s+/).filter(Boolean));
|
|
14
|
+
const inter = [...wa].filter((w) => wb.has(w)).length;
|
|
15
|
+
const union = new Set([...wa, ...wb]).size;
|
|
16
|
+
const jaccard = wa.size && wb.size && union ? inter / union : 0;
|
|
17
|
+
const domainBonus = a.domain === b.domain ? 0.2 : 0;
|
|
18
|
+
const ta = parseTags(a.tags), tb = parseTags(b.tags);
|
|
19
|
+
const tInter = [...ta].filter((t) => tb.has(t)).length;
|
|
20
|
+
const tUnion = new Set([...ta, ...tb]).size;
|
|
21
|
+
const tagSim = ta.size && tb.size && tUnion ? tInter / tUnion : 0;
|
|
22
|
+
return Math.min(1, jaccard * 0.5 + tagSim * 0.3 + domainBonus);
|
|
23
|
+
}
|
|
24
|
+
export class Learning {
|
|
25
|
+
db;
|
|
26
|
+
config;
|
|
27
|
+
constructor(db, config = {}) {
|
|
28
|
+
this.db = db;
|
|
29
|
+
this.config = config;
|
|
30
|
+
}
|
|
31
|
+
q(sql, ...p) { return this.db.prepare(sql).all(...p); }
|
|
32
|
+
q1(sql, ...p) { return this.db.prepare(sql).get(...p); }
|
|
33
|
+
x(sql, ...p) { this.db.prepare(sql).run(...p); }
|
|
34
|
+
/** Garantisce le colonne options/meta (migration idempotente). */
|
|
35
|
+
ensureSchema() {
|
|
36
|
+
const cols = new Set(this.q("PRAGMA table_info(human_questions)").map((c) => c.name));
|
|
37
|
+
if (!cols.has("options"))
|
|
38
|
+
this.x("ALTER TABLE human_questions ADD COLUMN options TEXT");
|
|
39
|
+
if (!cols.has("meta"))
|
|
40
|
+
this.x("ALTER TABLE human_questions ADD COLUMN meta TEXT");
|
|
41
|
+
}
|
|
42
|
+
// ─── DETECTOR (parità engine.py) ───────────────────────
|
|
43
|
+
detectContradictions() {
|
|
44
|
+
const thr = this.config.contradiction_threshold ?? 0.7;
|
|
45
|
+
const atoms = this.q("SELECT * FROM atoms WHERE status='active' ORDER BY id LIMIT 500");
|
|
46
|
+
const out = [];
|
|
47
|
+
for (let i = 0; i < atoms.length; i++) {
|
|
48
|
+
for (let j = i + 1; j < atoms.length; j++) {
|
|
49
|
+
const a = atoms[i], b = atoms[j];
|
|
50
|
+
if (a.domain !== b.domain)
|
|
51
|
+
continue;
|
|
52
|
+
const sim = computeSimilarity(a, b);
|
|
53
|
+
if (sim >= thr)
|
|
54
|
+
out.push({ atom_a: a.id, atom_b: b.id, title_a: a.title, title_b: b.title, similarity: Math.round(sim * 1000) / 1000 });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
detectWeakAtoms() {
|
|
60
|
+
const conf = this.config.weak_confidence_threshold ?? 0.4;
|
|
61
|
+
const acc = this.config.weak_access_threshold ?? 5;
|
|
62
|
+
return this.q("SELECT * FROM atoms WHERE status='active' AND confidence < ? AND access_count >= ? ORDER BY access_count DESC", conf, acc);
|
|
63
|
+
}
|
|
64
|
+
detectMergeCandidates() {
|
|
65
|
+
const thr = this.config.merge_similarity_threshold ?? 0.85;
|
|
66
|
+
const atoms = this.q("SELECT * FROM atoms WHERE status='active' ORDER BY id LIMIT 500");
|
|
67
|
+
const out = [];
|
|
68
|
+
for (let i = 0; i < atoms.length; i++) {
|
|
69
|
+
for (let j = i + 1; j < atoms.length; j++) {
|
|
70
|
+
const sim = computeSimilarity(atoms[i], atoms[j]);
|
|
71
|
+
if (sim >= thr)
|
|
72
|
+
out.push({ atom_a: atoms[i].id, atom_b: atoms[j].id, title_a: atoms[i].title, title_b: atoms[j].title, similarity: Math.round(sim * 1000) / 1000 });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
detectDecayCritical() {
|
|
78
|
+
const thr = this.config.decay_critical_threshold ?? 0.15;
|
|
79
|
+
return this.q("SELECT * FROM atoms WHERE status='active' AND weight < ? ORDER BY weight ASC", thr);
|
|
80
|
+
}
|
|
81
|
+
detectGaps() {
|
|
82
|
+
const minChars = this.config.gap_body_min_chars ?? 50;
|
|
83
|
+
return this.q("SELECT * FROM atoms WHERE status='active' AND (body IS NULL OR LENGTH(COALESCE(body,'')) < ?) ORDER BY access_count DESC", minChars);
|
|
84
|
+
}
|
|
85
|
+
detectGraphGaps() {
|
|
86
|
+
const minWeight = this.config.graph_gap_min_weight ?? 0.6;
|
|
87
|
+
const limit = this.config.graph_gap_limit ?? 20;
|
|
88
|
+
const types = this.config.graph_gap_types ?? ["fact", "decision", "procedure", "preference", "project", "note"];
|
|
89
|
+
const excluded = this.config.graph_gap_excluded_domain_prefixes ?? ["daily/", "session/"];
|
|
90
|
+
const rows = this.q("SELECT a.* FROM atoms a LEFT JOIN bonds bo ON bo.from_id = a.id OR bo.to_id = a.id WHERE a.status='active' AND a.weight >= ? AND bo.from_id IS NULL ORDER BY a.weight DESC, a.access_count DESC LIMIT ?", minWeight, limit * 3);
|
|
91
|
+
return rows.filter((r) => {
|
|
92
|
+
const t = JSON.parse(r.type ? JSON.stringify(r.type) : '"' + r.type + '"');
|
|
93
|
+
return types.includes(String(r.type)) && !excluded.some((p) => String(r.domain || "").startsWith(p));
|
|
94
|
+
}).slice(0, limit);
|
|
95
|
+
}
|
|
96
|
+
// ─── RUN (parità learning.py run_all_checks) ───────────
|
|
97
|
+
addQuestion(atomIds, questionType, question, options, meta) {
|
|
98
|
+
const now = Math.floor(Date.now() / 1000);
|
|
99
|
+
const id = "q_" + questionType + "_" + atomIds.join("-").slice(0, 40) + "_" + now;
|
|
100
|
+
this.x("INSERT OR IGNORE INTO human_questions (id, atom_ids, question_type, question, options, meta, status, created_at) VALUES (?,?,?,?,?,?,?,?)", id, JSON.stringify(atomIds), questionType, question, JSON.stringify(options), JSON.stringify(meta), "pending", now);
|
|
101
|
+
return { id, question_type: questionType, question };
|
|
102
|
+
}
|
|
103
|
+
runAllChecks() {
|
|
104
|
+
this.ensureSchema();
|
|
105
|
+
const maxPending = this.config.max_pending_questions ?? 20;
|
|
106
|
+
const pending = this.q("SELECT * FROM human_questions WHERE status='pending' LIMIT 100");
|
|
107
|
+
if (pending.length >= maxPending)
|
|
108
|
+
return [];
|
|
109
|
+
// dedup: non ricreare domande già pending/answered/dismissed (atom_ids normalizzati sorted)
|
|
110
|
+
const existingKeys = new Set();
|
|
111
|
+
for (const qrow of this.q("SELECT question_type, atom_ids FROM human_questions WHERE status IN ('pending','answered','dismissed')")) {
|
|
112
|
+
let ids;
|
|
113
|
+
try {
|
|
114
|
+
ids = JSON.parse(qrow.atom_ids || "[]");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
ids = [qrow.atom_ids];
|
|
118
|
+
}
|
|
119
|
+
existingKeys.add(qrow.question_type + "|" + JSON.stringify([...ids].sort()));
|
|
120
|
+
}
|
|
121
|
+
const created = [];
|
|
122
|
+
const hasCapacity = () => pending.length + created.length < maxPending;
|
|
123
|
+
const key = (t, ids) => t + "|" + JSON.stringify([...ids].sort());
|
|
124
|
+
const seen = (t, ids) => { const k = key(t, ids); if (existingKeys.has(k))
|
|
125
|
+
return true; existingKeys.add(k); return false; };
|
|
126
|
+
// 1. Contraddizioni
|
|
127
|
+
for (const c of this.detectContradictions()) {
|
|
128
|
+
if (!hasCapacity())
|
|
129
|
+
return created;
|
|
130
|
+
const ids = [c.atom_a, c.atom_b];
|
|
131
|
+
if (seen("contradiction", ids))
|
|
132
|
+
continue;
|
|
133
|
+
created.push(this.addQuestion(ids, "contradiction", `Ho due informazioni potenzialmente in conflitto:\n• ${c.title_a}\n• ${c.title_b}\nSimilarità: ${c.similarity}. Quale è corretta, o vanno unify?`, [c.title_a, c.title_b, "Entrambe corrette (contesti diversi)"], { similarity: c.similarity }));
|
|
134
|
+
}
|
|
135
|
+
// 2. Weak atoms
|
|
136
|
+
for (const w of this.detectWeakAtoms()) {
|
|
137
|
+
if (!hasCapacity())
|
|
138
|
+
return created;
|
|
139
|
+
if (seen("weak", [w.id]))
|
|
140
|
+
continue;
|
|
141
|
+
created.push(this.addQuestion([w.id], "weak", `'${w.title}' è consultato spesso (${w.access_count} volte) ma ha confidence bassa (${w.confidence}). Puoi confermare che è corretto?`, ["Sì, corretto", "No, da correggere", "Non sono sicuro"], { confidence: w.confidence, access_count: w.access_count }));
|
|
142
|
+
}
|
|
143
|
+
// 3. Merge candidates
|
|
144
|
+
for (const m of this.detectMergeCandidates()) {
|
|
145
|
+
if (!hasCapacity())
|
|
146
|
+
return created;
|
|
147
|
+
const ids = [m.atom_a, m.atom_b];
|
|
148
|
+
if (seen("merge_candidate", ids))
|
|
149
|
+
continue;
|
|
150
|
+
created.push(this.addQuestion(ids, "merge_candidate", `Questi due atomi sembrano duplicati (similarità ${m.similarity}):\n• ${m.title_a}\n• ${m.title_b}\nLi unifico?`, ["Sì, unifica", "No, sono diversi"], { similarity: m.similarity }));
|
|
151
|
+
}
|
|
152
|
+
// 4. Decay critical
|
|
153
|
+
for (const d of this.detectDecayCritical()) {
|
|
154
|
+
if (!hasCapacity())
|
|
155
|
+
return created;
|
|
156
|
+
if (seen("decay_critical", [d.id]))
|
|
157
|
+
continue;
|
|
158
|
+
created.push(this.addQuestion([d.id], "decay_critical", `'${d.title}' non è più consultato da tempo (weight: ${Number(d.weight).toFixed(3)}). Archivio o è ancora rilevante?`, ["Archivia", "Rilevante, aggiorna weight", "Elimina"], { weight: d.weight }));
|
|
159
|
+
}
|
|
160
|
+
// 5. Gaps
|
|
161
|
+
for (const g of this.detectGaps()) {
|
|
162
|
+
if (!hasCapacity())
|
|
163
|
+
return created;
|
|
164
|
+
if (seen("gap", [g.id]))
|
|
165
|
+
continue;
|
|
166
|
+
created.push(this.addQuestion([g.id], "gap", `'${g.title}' ha informazioni incomplete. Puoi aggiungere dettagli?`, [], { body_length: String(g.body || "").length }));
|
|
167
|
+
}
|
|
168
|
+
// 6. Graph gaps (default OFF, come Python v1.5.2)
|
|
169
|
+
if (this.config.graph_gap_enabled === true) {
|
|
170
|
+
for (const g of this.detectGraphGaps()) {
|
|
171
|
+
if (!hasCapacity())
|
|
172
|
+
return created;
|
|
173
|
+
if (seen("graph_gap", [g.id]))
|
|
174
|
+
continue;
|
|
175
|
+
created.push(this.addQuestion([g.id], "graph_gap", `'${g.title}' è un atomo importante ma isolato nel grafo. Vuoi collegarlo ad altri atomi correlati?`, ["Suggerisci bond", "Mantieni isolato", "Archivia"], { weight: g.weight, access_count: g.access_count }));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return created;
|
|
179
|
+
}
|
|
180
|
+
// ─── ANSWER SIDE-EFFECTS (parità learning.py process_answer) ───
|
|
181
|
+
applyAnswerSideEffects(qrow, answer) {
|
|
182
|
+
const effects = [];
|
|
183
|
+
let atomIds = [];
|
|
184
|
+
try {
|
|
185
|
+
atomIds = JSON.parse(qrow.atom_ids || "[]");
|
|
186
|
+
}
|
|
187
|
+
catch { /* noop */ }
|
|
188
|
+
const now = Math.floor(Date.now() / 1000);
|
|
189
|
+
const low = answer.toLowerCase();
|
|
190
|
+
const updateAtom = (id, sets, ...vals) => {
|
|
191
|
+
this.x(`UPDATE atoms SET ${sets}, updated_at=? WHERE id=?`, ...vals, now, id);
|
|
192
|
+
effects.push(`${id}: ${sets}`);
|
|
193
|
+
};
|
|
194
|
+
switch (qrow.question_type) {
|
|
195
|
+
case "weak":
|
|
196
|
+
if (low.includes("corretto") || low.includes("sì") || low.includes("si,")) {
|
|
197
|
+
for (const aid of atomIds)
|
|
198
|
+
updateAtom(aid, "confidence=0.9");
|
|
199
|
+
}
|
|
200
|
+
else if (low.includes("correggere")) {
|
|
201
|
+
for (const aid of atomIds)
|
|
202
|
+
updateAtom(aid, "confidence=0.2");
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
case "decay_critical":
|
|
206
|
+
if (low.includes("archivia")) {
|
|
207
|
+
for (const aid of atomIds)
|
|
208
|
+
updateAtom(aid, "status='archived'");
|
|
209
|
+
}
|
|
210
|
+
else if (low.includes("rilevante")) {
|
|
211
|
+
for (const aid of atomIds)
|
|
212
|
+
updateAtom(aid, "weight=1.0");
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
case "merge_candidate":
|
|
216
|
+
if (low.includes("unifi") && atomIds.length >= 2) {
|
|
217
|
+
// merge soft: bond + status merged sul duplicato (stessa semantica di memory_merge_atoms)
|
|
218
|
+
for (const b of this.q("SELECT * FROM bonds WHERE from_id=? OR to_id=?", atomIds[1], atomIds[1])) {
|
|
219
|
+
const nf = b.from_id === atomIds[1] ? atomIds[0] : b.from_id;
|
|
220
|
+
const nt = b.to_id === atomIds[1] ? atomIds[0] : b.to_id;
|
|
221
|
+
if (nf !== nt)
|
|
222
|
+
this.x("INSERT OR IGNORE INTO bonds (from_id, to_id, relation, strength, evidence, created_at) VALUES (?,?,?,?,?,unixepoch())", nf, nt, b.relation, b.strength, b.evidence);
|
|
223
|
+
}
|
|
224
|
+
updateAtom(atomIds[1], "status='merged'");
|
|
225
|
+
effects.push(`merged ${atomIds[1]} -> ${atomIds[0]}`);
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case "contradiction":
|
|
229
|
+
if (atomIds.length >= 2) {
|
|
230
|
+
for (const aid of atomIds) {
|
|
231
|
+
const at = this.q1("SELECT meta FROM atoms WHERE id=?", aid);
|
|
232
|
+
if (!at)
|
|
233
|
+
continue;
|
|
234
|
+
let meta = {};
|
|
235
|
+
try {
|
|
236
|
+
meta = JSON.parse(at.meta || "{}");
|
|
237
|
+
}
|
|
238
|
+
catch { /* noop */ }
|
|
239
|
+
meta.contradiction_resolution = answer;
|
|
240
|
+
updateAtom(aid, "meta=?", JSON.stringify(meta));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
case "gap":
|
|
245
|
+
// la risposta umana arricchisce il body in place ([Human note], come il Python)
|
|
246
|
+
for (const aid of atomIds) {
|
|
247
|
+
const at = this.q1("SELECT body FROM atoms WHERE id=?", aid);
|
|
248
|
+
if (!at)
|
|
249
|
+
continue;
|
|
250
|
+
const newBody = String(at.body || "") + `\n\n[Human note]: ${answer}`;
|
|
251
|
+
updateAtom(aid, "body=?", newBody);
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
case "graph_gap":
|
|
255
|
+
if (low.includes("archivia")) {
|
|
256
|
+
for (const aid of atomIds)
|
|
257
|
+
updateAtom(aid, "status='archived'");
|
|
258
|
+
}
|
|
259
|
+
else if (low.includes("mantieni")) {
|
|
260
|
+
for (const aid of atomIds) {
|
|
261
|
+
const at = this.q1("SELECT meta FROM atoms WHERE id=?", aid);
|
|
262
|
+
if (!at)
|
|
263
|
+
continue;
|
|
264
|
+
let meta = {};
|
|
265
|
+
try {
|
|
266
|
+
meta = JSON.parse(at.meta || "{}");
|
|
267
|
+
}
|
|
268
|
+
catch { /* noop */ }
|
|
269
|
+
meta.allow_isolated = true;
|
|
270
|
+
updateAtom(aid, "meta=?", JSON.stringify(meta));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
return effects;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { Learning } from "./learning.js";
|
|
7
|
+
describe("learning parity (v4.5.0)", () => {
|
|
8
|
+
let me, learning;
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "me-learn-"));
|
|
11
|
+
process.env.MEMORY_DB_PATH = join(dir, "memory.db");
|
|
12
|
+
me = createTools(process.env.MEMORY_DB_PATH);
|
|
13
|
+
const db = me.__db;
|
|
14
|
+
// schema minimo (in produzione il DB esiste già, migrato dal Python)
|
|
15
|
+
const now0 = Math.floor(Date.now() / 1000);
|
|
16
|
+
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, tags TEXT, meta TEXT, created_at INTEGER, updated_at INTEGER, accessed_at INTEGER)").run();
|
|
17
|
+
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();
|
|
18
|
+
db.prepare("CREATE TABLE IF NOT EXISTS bonds (from_id TEXT, to_id TEXT, relation TEXT, strength REAL, evidence TEXT, created_at INTEGER)").run();
|
|
19
|
+
learning = new Learning(db);
|
|
20
|
+
const now = Math.floor(Date.now() / 1000);
|
|
21
|
+
// gap: body quasi vuoto
|
|
22
|
+
db.prepare("INSERT INTO atoms (id, title, domain, type, status, confidence, access_count, weight, body, created_at, updated_at, accessed_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")
|
|
23
|
+
.run("a-gap", "Config incompleta", "test:learn", "fact", "active", 0.9, 3, 0.8, "poche parole", now, now, now);
|
|
24
|
+
// weak: confidence bassa + molto access
|
|
25
|
+
db.prepare("INSERT INTO atoms (id, title, domain, type, status, confidence, access_count, weight, body, created_at, updated_at, accessed_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")
|
|
26
|
+
.run("a-weak", "Porta garage", "test:learn", "fact", "active", 0.2, 9, 0.5, "descrizione sufficientemente lunga del fatto sulla porta del garage", now, now, now);
|
|
27
|
+
// decay: weight sotto soglia
|
|
28
|
+
db.prepare("INSERT INTO atoms (id, title, domain, type, status, confidence, access_count, weight, body, created_at, updated_at, accessed_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")
|
|
29
|
+
.run("a-decay", "Vecchio server", "test:learn", "fact", "active", 0.8, 1, 0.05, "descrizione sufficientemente lunga del vecchio server di test", now, now, now);
|
|
30
|
+
});
|
|
31
|
+
it("crea domande gap/weak/decay_critical con i tipi Python", () => {
|
|
32
|
+
const created = learning.runAllChecks();
|
|
33
|
+
const types = created.map((c) => c.question_type);
|
|
34
|
+
expect(types).toContain("gap");
|
|
35
|
+
expect(types).toContain("weak");
|
|
36
|
+
expect(types).toContain("decay_critical");
|
|
37
|
+
});
|
|
38
|
+
it("dedup: un secondo run non ricrea le stesse domande", () => {
|
|
39
|
+
const second = learning.runAllChecks();
|
|
40
|
+
const overlap = second.filter((c) => ["gap", "weak", "decay_critical"].includes(c.question_type));
|
|
41
|
+
expect(overlap.length).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
it("risposta gap appende [Human note] al body", () => {
|
|
44
|
+
const db = me.__db;
|
|
45
|
+
const qrow = db.prepare("SELECT * FROM human_questions WHERE question_type='gap' AND status='pending' LIMIT 1").get();
|
|
46
|
+
expect(qrow).toBeTruthy();
|
|
47
|
+
const effects = learning.applyAnswerSideEffects(qrow, "Il codice e' 1234");
|
|
48
|
+
expect(effects.length).toBeGreaterThan(0);
|
|
49
|
+
const body = db.prepare("SELECT body FROM atoms WHERE id='a-gap'").get();
|
|
50
|
+
expect(String(body.body)).toContain("[Human note]: Il codice e' 1234");
|
|
51
|
+
});
|
|
52
|
+
});
|
package/dist/maintenance.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { Learning } from "./learning.js";
|
|
4
5
|
const T = Type;
|
|
5
6
|
export function buildMaintenanceDefs(db, dbPath) {
|
|
6
7
|
const q = (sql, ...p) => db.prepare(sql).all(...p);
|
|
@@ -138,31 +139,48 @@ export function buildMaintenanceDefs(db, dbPath) {
|
|
|
138
139
|
return { ok: true, atoms_imported: imported, domain };
|
|
139
140
|
},
|
|
140
141
|
},
|
|
141
|
-
// ── STEP 4: LEARNING
|
|
142
|
+
// ── STEP 4: LEARNING (parità Python: 7 tipi + extras TS) ─────
|
|
142
143
|
{
|
|
143
144
|
name: "memory_learning_run",
|
|
144
|
-
description: "Run learning checks:
|
|
145
|
+
description: "Run learning checks (full Python parity): contradictions, weak atoms, merge candidates, decay critical, gaps (body vuoti), graph gaps (opt-in) + stale_fact/needs_link. Creates deduped human_questions; answers apply side effects.",
|
|
145
146
|
parameters: T.Object({ dry_run: T.Optional(T.Boolean()) }),
|
|
146
147
|
execute: (a) => {
|
|
148
|
+
const learning = new Learning(db, (globalThis.__meLearningCfg ?? {}));
|
|
149
|
+
const created = a.dry_run ? [] : learning.runAllChecks();
|
|
147
150
|
const now = Math.floor(Date.now() / 1000);
|
|
148
|
-
const
|
|
149
|
-
|
|
151
|
+
const dupKey = (t, ids) => t + "|" + JSON.stringify([...ids].sort());
|
|
152
|
+
const existing = new Set(q("SELECT question_type, atom_ids FROM human_questions WHERE status IN ('pending','answered','dismissed')").map((r) => { let ids; try {
|
|
153
|
+
ids = JSON.parse(r.atom_ids || "[]");
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
ids = [r.atom_ids];
|
|
157
|
+
} return dupKey(r.question_type, ids); }));
|
|
158
|
+
const extra = [];
|
|
150
159
|
const stale = q("SELECT id, title FROM atoms WHERE status='active' AND type IN ('fact','decision','procedure') AND ? - accessed_at > ? LIMIT 5", now, 180 * 86400);
|
|
151
|
-
for (const s of stale)
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
for (const s of stale) {
|
|
161
|
+
const k = dupKey("stale_fact", [s.id]);
|
|
162
|
+
if (!existing.has(k)) {
|
|
163
|
+
existing.add(k);
|
|
164
|
+
extra.push({ question_type: "stale_fact", atom_id: s.id, question: "Is '" + s.title.slice(0, 60) + "' still accurate and needed?" });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
154
167
|
const isolated = q("SELECT a.id, a.title FROM atoms a LEFT JOIN bonds b ON b.from_id=a.id OR b.to_id=a.id WHERE a.status='active' AND a.type IN ('fact','decision','procedure') AND b.from_id IS NULL AND a.domain NOT LIKE 'session/%' AND a.domain NOT LIKE 'daily/%' ORDER BY a.weight DESC LIMIT 5");
|
|
155
|
-
for (const s of isolated)
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
for (const s of isolated) {
|
|
169
|
+
const k = dupKey("needs_link", [s.id]);
|
|
170
|
+
if (!existing.has(k)) {
|
|
171
|
+
existing.add(k);
|
|
172
|
+
extra.push({ question_type: "needs_link", atom_id: s.id, question: "Should '" + s.title.slice(0, 60) + "' be linked to related atoms?" });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
let extraCreated = 0;
|
|
158
176
|
if (!a.dry_run) {
|
|
159
|
-
for (const qq of
|
|
177
|
+
for (const qq of extra) {
|
|
160
178
|
const id = "q_" + qq.question_type + "_" + qq.atom_id.slice(0, 30) + "_" + now;
|
|
161
179
|
x("INSERT OR IGNORE INTO human_questions (id, atom_ids, question_type, question, status, created_at) VALUES (?,?,?,?,?,?)", id, JSON.stringify([qq.atom_id]), qq.question_type, qq.question, "pending", now);
|
|
162
|
-
|
|
180
|
+
extraCreated++;
|
|
163
181
|
}
|
|
164
182
|
}
|
|
165
|
-
return { dry_run: !!a.dry_run, questions_created: created, questions:
|
|
183
|
+
return { dry_run: !!a.dry_run, questions_created: created.length + extraCreated, by_type: { python_parity: created.length, extras: extraCreated }, questions: [...created, ...extra].slice(0, 15) };
|
|
166
184
|
},
|
|
167
185
|
},
|
|
168
186
|
{
|
|
@@ -180,11 +198,19 @@ export function buildMaintenanceDefs(db, dbPath) {
|
|
|
180
198
|
if (!row)
|
|
181
199
|
return { ok: false, reason: "question not found or not pending" };
|
|
182
200
|
x("UPDATE human_questions SET status='answered', answer=?, answered_at=unixepoch() WHERE id=?", a.answer, a.question_id);
|
|
201
|
+
// side-effects per tipo (parità Python learning.process_answer)
|
|
202
|
+
let effects = [];
|
|
203
|
+
try {
|
|
204
|
+
effects = new Learning(db).applyAnswerSideEffects(row, a.answer);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
effects = ["side-effect error: " + String(err?.message || err).slice(0, 120)];
|
|
208
|
+
}
|
|
183
209
|
if (a.action === "archive_atom" && JSON.parse(row.atom_ids || "[]")[0])
|
|
184
210
|
x("UPDATE atoms SET status='archived', updated_at=unixepoch() WHERE id=?", JSON.parse(row.atom_ids || "[]")[0]);
|
|
185
211
|
if (a.action === "delete_atom" && JSON.parse(row.atom_ids || "[]")[0])
|
|
186
212
|
x("UPDATE atoms SET status='deleted', updated_at=unixepoch() WHERE id=?", JSON.parse(row.atom_ids || "[]")[0]);
|
|
187
|
-
return { ok: true, question_id: a.question_id, answer: a.answer, action: a.action || "resolve_only" };
|
|
213
|
+
return { ok: true, question_id: a.question_id, answer: a.answer, action: a.action || "resolve_only", side_effects: effects };
|
|
188
214
|
},
|
|
189
215
|
},
|
|
190
216
|
];
|
|
@@ -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.
|
|
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
|
+
"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
|
}
|