openclaw-plugin-memory-engine 4.4.0 → 4.5.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.
@@ -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
+ }
@@ -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
+ });
@@ -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: stale facts, missing compacts, low-bond durables. Creates human_questions for items needing review.",
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 questions = [];
149
- // check 1: stale durable atoms (not accessed in 180 days)
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
- questions.push({ question_type: "stale_fact", atom_id: s.id, question: "Is '" + s.title.slice(0, 60) + "' still accurate and needed?" });
153
- // check 2: isolated durable atoms
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
- questions.push({ question_type: "needs_link", atom_id: s.id, question: "Should '" + s.title.slice(0, 60) + "' be linked to related atoms?" });
157
- let created = 0;
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 questions) {
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
- created++;
180
+ extraCreated++;
163
181
  }
164
182
  }
165
- return { dry_run: !!a.dry_run, questions_created: created, questions: questions.slice(0, 10) };
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
  ];
package/dist/tools.d.ts CHANGED
@@ -4,7 +4,7 @@
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.4.0";
7
+ export declare const VERSION = "4.5.0";
8
8
  export declare function createTools(dbPath: string): {
9
9
  __db: DatabaseSync;
10
10
  version: () => {
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.4.0";
9
+ export const VERSION = "4.5.0";
10
10
  export function createTools(dbPath) {
11
11
  const db = new DatabaseSync(dbPath);
12
12
  const engine = new Engine(db, {});
@@ -2,11 +2,29 @@
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.4.0",
5
+ "version": "4.5.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
- "additionalProperties": false,
9
- "properties": {}
8
+ "properties": {
9
+ "ingest": {
10
+ "type": "object",
11
+ "description": "Session ingest (chat memory capture) controls.",
12
+ "properties": {
13
+ "enabled": {
14
+ "type": "boolean",
15
+ "default": false,
16
+ "description": "Capture OpenClaw chat contents into the memory database. Default false (privacy-first); set true to enable session ingest."
17
+ },
18
+ "retention_days": {
19
+ "type": "number",
20
+ "default": 7,
21
+ "description": "Days before session_msg/session_digest atoms expire automatically."
22
+ }
23
+ },
24
+ "additionalProperties": false
25
+ }
26
+ },
27
+ "additionalProperties": false
10
28
  },
11
29
  "activation": {
12
30
  "onStartup": true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-memory-engine",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc -p tsconfig.json && openclaw plugins build",