openclaw-plugin-memory-engine 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/entry.js CHANGED
@@ -6,27 +6,30 @@ import { Type } from "typebox";
6
6
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
7
7
  import { createTools, VERSION } from "./tools.js";
8
8
  import { Curator } from "./curator.js";
9
+ import { buildAdminDefs } from "./admin.js";
10
+ import { buildSemanticDefs } from "./semantic.js";
11
+ import { buildMaintenanceDefs } from "./maintenance.js";
9
12
  import { buildSessionDigest } from "./digest.js";
10
13
  import { createHash } from "node:crypto";
11
14
  /** Il gateway puo' passare un tool-call id (stringa) come primo argomento:
12
15
  * i params stanno in uno degli argomenti successivi, o in un envelope. */
13
16
  const PARAM_KEYS = ["query", "title", "content", "id", "session_id", "atom_id", "from_id", "to_id", "relation", "task_description", "task_type", "mistake", "correction", "old_atom_id", "limit", "domain", "type", "confidence", "depth", "strength", "token_budget", "body", "reason"];
14
17
  function normalizeParams(a, rest = []) {
18
+ const merged = {};
15
19
  const cands = [a, ...rest].filter((x) => x && typeof x === "object" && !Array.isArray(x));
16
- // 1) envelope
17
20
  for (const c of cands) {
18
21
  for (const k of ["arguments", "input", "params", "args"]) {
19
22
  if (c[k] && typeof c[k] === "object" && !Array.isArray(c[k]))
20
- return c[k];
23
+ Object.assign(merged, c[k]);
21
24
  }
22
25
  }
23
- // 2) primo candidato con chiavi param note
24
26
  for (const c of cands) {
25
- if (PARAM_KEYS.some((k) => k in c))
26
- return c;
27
+ for (const k of Object.keys(c)) {
28
+ if (!(k in merged))
29
+ merged[k] = c[k];
30
+ }
27
31
  }
28
- // 3) fallback: primo oggetto
29
- return cands[0] ?? {};
32
+ return merged;
30
33
  }
31
34
  const T = Type;
32
35
  /** Wrap nel tool-result shape richiesto dal contract "tools" di OpenClaw. */
@@ -46,7 +49,7 @@ export default definePluginEntry({
46
49
  // ── Tools ─────────────────────────────────────────────
47
50
  const defs = [
48
51
  { name: "memory_version", description: "Memory Engine TS version.", parameters: T.Object({}), execute: () => me.version() },
49
- { name: "memory_recall", description: "Smart recall of past memories. Query short and specific.", parameters: T.Object({ query: T.String(), limit: T.Optional(T.Number()), domain: T.Optional(T.String()) }), execute: (a) => me.recall(a.query, a.limit ?? 5, a.domain) },
52
+ { 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) },
50
53
  { 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) },
51
54
  { 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) },
52
55
  { name: "memory_get_atom", description: "Full atom by id.", parameters: T.Object({ id: T.String() }), execute: (a) => me.get_atom(a.id) },
@@ -65,6 +68,12 @@ export default definePluginEntry({
65
68
  { name: "memory_curator_run", description: "Conservative curation pass: compact, bonds, isolated classification, promotions, merges. dry_run default true.", parameters: T.Object({ dry_run: T.Optional(T.Boolean()), auto_apply: T.Optional(T.Boolean()), max_atoms: T.Optional(T.Number()) }), execute: (a) => curator.run(a.dry_run ?? true, a.auto_apply ?? false, a.max_atoms) },
66
69
  { name: "memory_cognitive_status", description: "Cognitive health metrics for the memory graph.", parameters: T.Object({}), execute: () => curator.status() },
67
70
  ];
71
+ for (const d of buildAdminDefs(me.__db))
72
+ defs.push(d);
73
+ for (const d of buildSemanticDefs(me.__db, curator))
74
+ defs.push(d);
75
+ for (const d of buildMaintenanceDefs(me.__db, dbPath))
76
+ defs.push(d);
68
77
  for (const d of defs) {
69
78
  api.registerTool({
70
79
  name: d.name,
@@ -122,7 +131,7 @@ export default definePluginEntry({
122
131
  db.prepare("INSERT OR REPLACE INTO atoms (id, type, memory_tier, domain, title, body, confidence, status, source, weight, tags, meta, created_at, updated_at, accessed_at, ttl) VALUES (?,?,?,?,?,?,0.7,'active','ai',1.0,'[]','{}',?,?,?,?)").run(draft.id, "session_digest", "episodic", "session/" + sid, draft.title, draft.body, now, now, now, now + 7 * 86400);
123
132
  }
124
133
  flushedCount.set(sid, msgs.length);
125
- logger.info?.("[memory-engine-ts] periodic flush session " + sid + " (" + fresh.length + " new msgs, digest " + (draft ? "ok" : "skip") + ")");
134
+ logger.info?.("[memory-engine] periodic flush session " + sid + " (" + fresh.length + " new msgs, digest " + (draft ? "ok" : "skip") + ")");
126
135
  }
127
136
  const FLUSH_MS = 60_000;
128
137
  const timer = setInterval(() => {
@@ -133,10 +142,10 @@ export default definePluginEntry({
133
142
  }
134
143
  }
135
144
  catch (err) {
136
- logger.warn?.("[memory-engine-ts] periodic flush error: " + String(err));
145
+ logger.warn?.("[memory-engine] periodic flush error: " + String(err));
137
146
  }
138
147
  }, FLUSH_MS);
139
148
  timer.unref?.();
140
- logger.info?.("[memory-engine-ts] v" + VERSION + " registered: 17 tools + periodic ingest/digest (" + (FLUSH_MS / 1000) + "s)");
149
+ logger.info?.("[memory-engine] v" + VERSION + " registered: " + defs.length + " tools + periodic ingest/digest (" + (FLUSH_MS / 1000) + "s)");
141
150
  },
142
151
  });
package/dist/index.d.ts CHANGED
@@ -1 +1,14 @@
1
- export { default } from "./entry.js";
1
+ declare const _default: Omit<{
2
+ id: string;
3
+ name: string;
4
+ description: string;
5
+ kind?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["kind"];
6
+ configSchema?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema | (() => import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema);
7
+ reload?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["reload"];
8
+ nodeHostCommands?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["nodeHostCommands"];
9
+ securityAuditCollectors?: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["securityAuditCollectors"];
10
+ register: NonNullable<import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginDefinition["register"]>;
11
+ }, "configSchema"> & {
12
+ configSchema: import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginConfigSchema;
13
+ };
14
+ export default _default;
package/dist/index.js CHANGED
@@ -1 +1,13 @@
1
- export { default } from "./entry.js";
1
+ import { defineFeaturePlugin } from "openclaw/plugin-sdk/feature-plugin";
2
+ import { contract } from "./ui-contract.js";
3
+ import { createFeatureHandlers } from "./ui-feature.js";
4
+ import pluginEntry from "./entry.js";
5
+ export default defineFeaturePlugin({
6
+ contract,
7
+ name: "Memory Engine",
8
+ description: "Native Memory Engine: 45 memory tools, session ingest/digest, curator and Control UI.",
9
+ setup(api, events) {
10
+ pluginEntry.register(api);
11
+ return createFeatureHandlers();
12
+ },
13
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Memory Engine TS — Step 3 (backup/import/export) + Step 4 (learning).
3
+ * Parity port of Python backup.py + learning.py; no Python residue.
4
+ */
5
+ import { DatabaseSync } from "node:sqlite";
6
+ export interface MaintToolDef {
7
+ name: string;
8
+ description: string;
9
+ parameters: any;
10
+ execute: (a: any) => any;
11
+ }
12
+ export declare function buildMaintenanceDefs(db: DatabaseSync, dbPath: string): MaintToolDef[];
@@ -0,0 +1,192 @@
1
+ import { Type } from "typebox";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ const T = Type;
5
+ export function buildMaintenanceDefs(db, dbPath) {
6
+ const q = (sql, ...p) => db.prepare(sql).all(...p);
7
+ const q1 = (sql, ...p) => db.prepare(sql).get(...p);
8
+ const x = (sql, ...p) => db.prepare(sql).run(...p);
9
+ const backupDir = path.join(path.dirname(dbPath), "backups");
10
+ return [
11
+ // ── STEP 3: BACKUP ────────────────────────────────
12
+ {
13
+ name: "memory_backup_database",
14
+ description: "Create a SQLite backup (VACUUM INTO) with timestamp. Returns backup path and size.",
15
+ parameters: T.Object({ label: T.Optional(T.String()) }),
16
+ execute: (a) => {
17
+ fs.mkdirSync(backupDir, { recursive: true });
18
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
19
+ const dest = path.join(backupDir, "memory-backup-" + ts + (a.label ? "-" + a.label : "") + ".db");
20
+ x("VACUUM INTO ?", dest);
21
+ const stat = fs.statSync(dest);
22
+ return { ok: true, path: dest, size_bytes: stat.size };
23
+ },
24
+ },
25
+ {
26
+ name: "memory_restore_database",
27
+ description: "Restore from a backup file. DANGEROUS: overwrites current DB. Requires confirm=true.",
28
+ parameters: T.Object({ backup_path: T.String(), confirm: T.Optional(T.Boolean()) }),
29
+ execute: (a) => {
30
+ if (!a.confirm)
31
+ return { ok: false, reason: "confirm=true required for restore" };
32
+ if (!fs.existsSync(a.backup_path))
33
+ return { ok: false, reason: "backup file not found" };
34
+ fs.copyFileSync(a.backup_path, dbPath);
35
+ return { ok: true, restored_from: a.backup_path, note: "Gateway restart required to reload the DB." };
36
+ },
37
+ },
38
+ {
39
+ name: "memory_list_backups",
40
+ description: "List available backup files with sizes and dates.",
41
+ parameters: T.Object({}),
42
+ execute: () => {
43
+ if (!fs.existsSync(backupDir))
44
+ return { backups: [] };
45
+ return {
46
+ backups: fs.readdirSync(backupDir).filter((f) => f.endsWith(".db")).map((f) => {
47
+ const st = fs.statSync(path.join(backupDir, f));
48
+ return { file: f, path: path.join(backupDir, f), size_bytes: st.size, created: st.mtime.toISOString() };
49
+ }).sort((a, b) => b.created.localeCompare(a.created)),
50
+ };
51
+ },
52
+ },
53
+ {
54
+ name: "memory_export_all",
55
+ description: "Export all atoms + bonds to a JSON file. Returns file path and counts.",
56
+ parameters: T.Object({ output_path: T.Optional(T.String()), include_deleted: T.Optional(T.Boolean()) }),
57
+ execute: (a) => {
58
+ const status = a.include_deleted ? "all" : "active";
59
+ const atoms = status === "active" ? q("SELECT * FROM atoms WHERE status='active'") : q("SELECT * FROM atoms");
60
+ const bonds = q("SELECT * FROM bonds");
61
+ const data = { exported_at: new Date().toISOString(), version: "3.3.0", atoms, bonds };
62
+ const out = a.output_path || path.join(path.dirname(dbPath), "memory-export-" + Date.now() + ".json");
63
+ fs.writeFileSync(out, JSON.stringify(data, null, 2));
64
+ return { ok: true, path: out, atoms: atoms.length, bonds: bonds.length };
65
+ },
66
+ },
67
+ {
68
+ name: "memory_export_atom",
69
+ description: "Export a single atom (with bonds) to JSON. Returns the data.",
70
+ parameters: T.Object({ atom_id: T.String() }),
71
+ execute: (a) => {
72
+ const atom = q1("SELECT * FROM atoms WHERE id=?", a.atom_id);
73
+ if (!atom)
74
+ return { error: "atom not found" };
75
+ const bonds = q("SELECT * FROM bonds WHERE from_id=? OR to_id=?", a.atom_id, a.atom_id);
76
+ return { atom, bonds };
77
+ },
78
+ },
79
+ {
80
+ name: "memory_import_data",
81
+ description: "Import atoms from a JSON export file (merge mode: skip existing IDs). Returns import counts.",
82
+ parameters: T.Object({ json_path: T.String() }),
83
+ execute: (a) => {
84
+ if (!fs.existsSync(a.json_path))
85
+ return { ok: false, reason: "file not found" };
86
+ const data = JSON.parse(fs.readFileSync(a.json_path, "utf8"));
87
+ if (!data.atoms)
88
+ return { ok: false, reason: "no atoms in file" };
89
+ let imported = 0, skipped = 0;
90
+ for (const at of data.atoms) {
91
+ const exists = q1("SELECT id FROM atoms WHERE id=?", at.id);
92
+ if (exists) {
93
+ skipped++;
94
+ continue;
95
+ }
96
+ x("INSERT INTO atoms (id, type, memory_tier, domain, title, body, body_compact, confidence, status, source, weight, tags, meta, created_at, updated_at, accessed_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", at.id, at.type || "fact", at.memory_tier || "semantic", at.domain || "general", at.title || at.id, at.body || "", at.body_compact || "", at.confidence ?? 0.7, at.status || "active", at.source || "import", at.weight ?? 1.0, at.tags || "[]", at.meta || "{}", at.created_at || Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000));
97
+ imported++;
98
+ }
99
+ let bondsAdded = 0;
100
+ if (data.bonds)
101
+ for (const b of data.bonds) {
102
+ x("INSERT OR IGNORE INTO bonds (from_id, to_id, relation, strength, evidence, created_at) VALUES (?,?,?,?,?,unixepoch())", b.from_id, b.to_id, b.relation, b.strength ?? 0.5, b.evidence || '{}');
103
+ bondsAdded++;
104
+ }
105
+ return { ok: true, atoms_imported: imported, atoms_skipped: skipped, bonds_added: bondsAdded };
106
+ },
107
+ },
108
+ {
109
+ name: "memory_import_markdown",
110
+ description: "Import atoms from a markdown file: each heading (##/###) becomes an atom, body is the content.",
111
+ parameters: T.Object({ md_path: T.String(), domain: T.Optional(T.String()), default_type: T.Optional(T.String()) }),
112
+ execute: (a) => {
113
+ if (!fs.existsSync(a.md_path))
114
+ return { ok: false, reason: "file not found" };
115
+ const text = fs.readFileSync(a.md_path, "utf8");
116
+ const lines = text.split(chr(10));
117
+ const domain = a.domain || "import/markdown";
118
+ const defType = a.default_type || "fact";
119
+ let imported = 0, currentTitle = "", currentBody = [];
120
+ const flush = () => {
121
+ if (!currentTitle)
122
+ return;
123
+ const id = currentTitle.toLowerCase().replace(/[^a-z0-9_]+/g, "_").slice(0, 50) + "_" + Date.now() + "_" + imported;
124
+ x("INSERT INTO atoms (id, type, memory_tier, domain, title, body, confidence, status, source, weight, tags, meta, created_at, updated_at, accessed_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", id, defType, "semantic", domain, currentTitle, currentBody.join(chr(10)).trim(), 0.7, "active", "import:markdown", 1.0, "[]", JSON.stringify({ source_file: a.md_path }), Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000));
125
+ imported++;
126
+ };
127
+ for (const ln of lines) {
128
+ const m = ln.match(/^(#{2,3}) (.+)/);
129
+ if (m) {
130
+ flush();
131
+ currentTitle = m[2].trim();
132
+ currentBody = [];
133
+ }
134
+ else if (currentTitle)
135
+ currentBody.push(ln);
136
+ }
137
+ flush();
138
+ return { ok: true, atoms_imported: imported, domain };
139
+ },
140
+ },
141
+ // ── STEP 4: LEARNING ──────────────────────────────
142
+ {
143
+ name: "memory_learning_run",
144
+ description: "Run learning checks: stale facts, missing compacts, low-bond durables. Creates human_questions for items needing review.",
145
+ parameters: T.Object({ dry_run: T.Optional(T.Boolean()) }),
146
+ execute: (a) => {
147
+ const now = Math.floor(Date.now() / 1000);
148
+ const questions = [];
149
+ // check 1: stale durable atoms (not accessed in 180 days)
150
+ 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
154
+ 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;
158
+ if (!a.dry_run) {
159
+ for (const qq of questions) {
160
+ const id = "q_" + qq.question_type + "_" + qq.atom_id.slice(0, 30) + "_" + now;
161
+ 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++;
163
+ }
164
+ }
165
+ return { dry_run: !!a.dry_run, questions_created: created, questions: questions.slice(0, 10) };
166
+ },
167
+ },
168
+ {
169
+ name: "memory_ask_pending",
170
+ description: "List pending human questions for review.",
171
+ parameters: T.Object({ limit: T.Optional(T.Number()) }),
172
+ execute: (a) => q("SELECT * FROM human_questions WHERE status='pending' ORDER BY created_at DESC LIMIT ?", a.limit ?? 10),
173
+ },
174
+ {
175
+ name: "memory_answer_human",
176
+ description: "Answer a pending question: resolves it and optionally updates the linked atom status.",
177
+ parameters: T.Object({ question_id: T.String(), answer: T.String(), action: T.Optional(T.String()) }),
178
+ execute: (a) => {
179
+ const row = q1("SELECT * FROM human_questions WHERE id=? AND status='pending'", a.question_id);
180
+ if (!row)
181
+ return { ok: false, reason: "question not found or not pending" };
182
+ x("UPDATE human_questions SET status='answered', answer=?, answered_at=unixepoch() WHERE id=?", a.answer, a.question_id);
183
+ if (a.action === "archive_atom" && JSON.parse(row.atom_ids || "[]")[0])
184
+ x("UPDATE atoms SET status='archived', updated_at=unixepoch() WHERE id=?", JSON.parse(row.atom_ids || "[]")[0]);
185
+ if (a.action === "delete_atom" && JSON.parse(row.atom_ids || "[]")[0])
186
+ 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" };
188
+ },
189
+ },
190
+ ];
191
+ }
192
+ const chr = (c) => String.fromCharCode(c);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Memory Engine TS — semantic tools (Step 2 parity port).
3
+ * semantic_search, reindex_embeddings, suggest_bonds(+all) via Ollama embeddings.
4
+ */
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { Curator } from "./curator.js";
7
+ export interface SemanticToolDef {
8
+ name: string;
9
+ description: string;
10
+ parameters: any;
11
+ execute: (a: any) => any;
12
+ }
13
+ export declare function buildSemanticDefs(db: DatabaseSync, curator: Curator): SemanticToolDef[];
@@ -0,0 +1,91 @@
1
+ import { Type } from "typebox";
2
+ const T = Type;
3
+ import { semanticSearch, embedAtom, embed } from "./embeddings.js";
4
+ export function buildSemanticDefs(db, curator) {
5
+ const q = (sql, ...p) => db.prepare(sql).all(...p);
6
+ const q1 = (sql, ...p) => db.prepare(sql).get(...p);
7
+ const x = (sql, ...p) => db.prepare(sql).run(...p);
8
+ return [
9
+ {
10
+ name: "memory_semantic_search",
11
+ description: "Semantic search over atom embeddings (Ollama). Falls back to FTS with a note when embeddings/Ollama are unavailable.",
12
+ parameters: T.Object({ query: T.String(), limit: T.Optional(T.Number()), domain: T.Optional(T.String()) }),
13
+ execute: async (a) => {
14
+ const embedded = q1("SELECT COUNT(*) n FROM atom_embeddings").n;
15
+ const res = await semanticSearch(db, a.query, a.limit ?? 10, a.domain);
16
+ if (res.length)
17
+ return { mode: "semantic", embeddings_available: embedded, results: res };
18
+ const fts = q("SELECT id, title, domain FROM atoms_fts JOIN atoms a ON atoms_fts.rowid = a.rowid WHERE atoms_fts MATCH ? AND a.status='active' LIMIT ?", '"' + String(a.query).replace(/[^a-zA-Z0-9 ]/g, " ").trim() + '"', a.limit ?? 10);
19
+ return { mode: "fts-fallback", note: "ollama non raggiungibile o nessun embedding", embeddings_available: embedded, results: fts };
20
+ },
21
+ },
22
+ {
23
+ name: "memory_reindex_embeddings",
24
+ description: "(Re)generate embeddings for active durable atoms missing one. Batch with max_atoms; returns indexed/failed counts.",
25
+ parameters: T.Object({ max_atoms: T.Optional(T.Number()) }),
26
+ execute: async (a) => {
27
+ const probe = await embed("probe");
28
+ if (!probe)
29
+ return { ok: false, reason: "ollama non raggiungibile (OLLAMA_HOST / ME_EMBED_MODEL)" };
30
+ const rows = q("SELECT id, title, COALESCE(body_compact, body, '') body FROM atoms a WHERE a.status='active' AND a.type IN ('fact','decision','procedure','preference','project','note') AND NOT EXISTS (SELECT 1 FROM atom_embeddings e WHERE e.atom_id = a.id) LIMIT ?", a.max_atoms ?? 100);
31
+ let indexed = 0, failed = 0;
32
+ for (const r of rows) {
33
+ const ok = await embedAtom(db, r.id, r.title + ". " + String(r.body || "").slice(0, 1500));
34
+ if (ok)
35
+ indexed++;
36
+ else
37
+ failed++;
38
+ }
39
+ const total = q1("SELECT COUNT(*) n FROM atom_embeddings").n;
40
+ return { ok: true, indexed, failed, scanned: rows.length, embeddings_total: total };
41
+ },
42
+ },
43
+ {
44
+ name: "memory_suggest_bonds",
45
+ description: "Suggest bonds for one atom (domain cluster, keyword overlap, id patterns). auto_apply=false by default.",
46
+ parameters: T.Object({ atom_id: T.String(), auto_apply: T.Optional(T.Boolean()), limit: T.Optional(T.Number()) }),
47
+ execute: (a) => {
48
+ const atom = q1("SELECT * FROM atoms WHERE id=?", a.atom_id);
49
+ if (!atom)
50
+ return { error: "atom not found" };
51
+ const seen = new Set();
52
+ const suggestions = curator.suggestBondsForAtom(atom, seen).slice(0, a.limit ?? 5);
53
+ let applied = 0;
54
+ if (a.auto_apply) {
55
+ for (const s of suggestions) {
56
+ x("INSERT OR IGNORE INTO bonds (from_id, to_id, relation, strength, evidence, created_at) VALUES (?,?,?,?,?,unixepoch())", s.from_id, s.to_id, s.relation, s.confidence, JSON.stringify({ strategy: s.strategy, reason: s.reason }));
57
+ applied++;
58
+ }
59
+ }
60
+ return { atom_id: a.atom_id, suggestions: suggestions.length, applied, detail: suggestions };
61
+ },
62
+ },
63
+ {
64
+ name: "memory_suggest_bonds_all",
65
+ description: "Bulk bond suggestion across active atoms (fast strategies only). auto_apply=false by default.",
66
+ parameters: T.Object({ auto_apply: T.Optional(T.Boolean()), max_atoms: T.Optional(T.Number()), limit_per_atom: T.Optional(T.Number()) }),
67
+ execute: (a) => {
68
+ const maxAtoms = a.max_atoms ?? 200;
69
+ const perAtom = a.limit_per_atom ?? 3;
70
+ const atoms = q("SELECT * FROM atoms WHERE status='active' ORDER BY weight DESC, access_count DESC LIMIT ?", maxAtoms);
71
+ const seen = new Set();
72
+ const all = [];
73
+ let appliedCount = 0;
74
+ for (const atom of atoms) {
75
+ const sug = curator.suggestBondsForAtom(atom, seen).slice(0, perAtom);
76
+ for (const s of sug) {
77
+ all.push(s);
78
+ if (a.auto_apply) {
79
+ x("INSERT OR IGNORE INTO bonds (from_id, to_id, relation, strength, evidence, created_at) VALUES (?,?,?,?,?,unixepoch())", s.from_id, s.to_id, s.relation, s.confidence, JSON.stringify({ strategy: s.strategy, reason: s.reason }));
80
+ appliedCount++;
81
+ }
82
+ }
83
+ }
84
+ return {
85
+ atoms_scanned: atoms.length, suggestions: all.length, applied: appliedCount,
86
+ top_suggestions: all.sort((p, c) => c.confidence - p.confidence).slice(0, 15).map((s) => ({ from: s.from_id, to: s.to_id, relation: s.relation, confidence: s.confidence, reason: s.reason.slice(0, 80) })),
87
+ };
88
+ },
89
+ },
90
+ ];
91
+ }
package/dist/tools.d.ts CHANGED
@@ -4,21 +4,21 @@
4
4
  * Python server and are exposed as explicit pointers.
5
5
  */
6
6
  import { DatabaseSync } from "node:sqlite";
7
- export declare const VERSION = "3.1.0";
7
+ export declare const VERSION = "4.0.0";
8
8
  export declare function createTools(dbPath: string): {
9
9
  __db: DatabaseSync;
10
10
  version: () => {
11
11
  version: string;
12
12
  note: string;
13
13
  };
14
- recall: (query: string, limit?: number, domain?: string) => {
14
+ recall: (query: string, limit?: number, domain?: string, semantic?: boolean) => Promise<{
15
15
  id: any;
16
16
  title: any;
17
17
  domain: any;
18
18
  type: any;
19
19
  confidence: any;
20
20
  snippet: string;
21
- }[];
21
+ }[]>;
22
22
  semantic_search: (query: string, limit?: number, domain?: string) => Promise<{
23
23
  id: string;
24
24
  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 = "3.1.0";
9
+ export const VERSION = "4.0.0";
10
10
  export function createTools(dbPath) {
11
11
  const db = new DatabaseSync(dbPath);
12
12
  const engine = new Engine(db, {});
@@ -17,7 +17,20 @@ export function createTools(dbPath) {
17
17
  return {
18
18
  __db: db,
19
19
  version: () => ({ version: VERSION, note: "Native Memory Engine plugin: 19 tools + session ingest/digest + curator." }),
20
- recall: (query, limit = 5, domain) => engine.recall(query, limit, { domain: domain ?? undefined, semantic: false }).map(slim),
20
+ recall: async (query, limit = 5, domain, semantic = false) => {
21
+ const base = engine.recall(query, limit, { domain: domain ?? undefined, semantic: false });
22
+ if (!semantic)
23
+ return base.map(slim);
24
+ const { semanticSearch } = await import("./embeddings.js");
25
+ const sem = await semanticSearch(db, query, limit, domain);
26
+ const seen = new Set(base.map((r) => r.id));
27
+ const merged = base.map(slim);
28
+ for (const s of sem) {
29
+ if (!seen.has(s.id))
30
+ merged.push(slim(s));
31
+ }
32
+ return merged;
33
+ },
21
34
  semantic_search: async (query, limit = 10, domain) => {
22
35
  const { semanticSearch } = await import("./embeddings.js");
23
36
  const results = await semanticSearch(db, query, limit, domain);
@@ -0,0 +1,135 @@
1
+ import { Type } from "typebox";
2
+ export declare const contract: {
3
+ readonly pluginId: "memory-engine";
4
+ readonly operations: {
5
+ readonly status: {
6
+ readonly kind: "query";
7
+ readonly description: "Cognitive health metrics for the memory graph.";
8
+ readonly input: Type.TObject<{}>;
9
+ readonly output: Type.TObject<{
10
+ atoms_active: Type.TInteger;
11
+ durable_atoms: Type.TInteger;
12
+ bonds: Type.TInteger;
13
+ isolated: Type.TInteger;
14
+ pending: Type.TInteger;
15
+ missing_compact: Type.TInteger;
16
+ by_type: Type.TRecord<"^.*$", Type.TInteger>;
17
+ recommendations: Type.TArray<Type.TString>;
18
+ }>;
19
+ };
20
+ readonly search: {
21
+ readonly kind: "query";
22
+ readonly description: "FTS search over memory atoms with optional type/domain filters.";
23
+ readonly input: Type.TObject<{
24
+ query: Type.TString;
25
+ limit: Type.TOptional<Type.TInteger>;
26
+ type: Type.TOptional<Type.TString>;
27
+ domain: Type.TOptional<Type.TString>;
28
+ }>;
29
+ readonly output: Type.TObject<{
30
+ results: Type.TArray<Type.TObject<{
31
+ id: Type.TString;
32
+ title: Type.TString;
33
+ domain: Type.TOptional<Type.TString>;
34
+ type: Type.TOptional<Type.TString>;
35
+ }>>;
36
+ }>;
37
+ };
38
+ readonly atom: {
39
+ readonly kind: "query";
40
+ readonly description: "Full atom detail with bonds.";
41
+ readonly input: Type.TObject<{
42
+ id: Type.TString;
43
+ }>;
44
+ readonly output: Type.TObject<{}>;
45
+ };
46
+ readonly domains: {
47
+ readonly kind: "query";
48
+ readonly description: "List distinct domains with atom counts.";
49
+ readonly input: Type.TObject<{}>;
50
+ readonly output: Type.TObject<{
51
+ domains: Type.TArray<Type.TObject<{
52
+ domain: Type.TString;
53
+ count: Type.TInteger;
54
+ }>>;
55
+ }>;
56
+ };
57
+ readonly curator_dry: {
58
+ readonly kind: "action";
59
+ readonly description: "Run curator in dry-run mode.";
60
+ readonly input: Type.TObject<{
61
+ max_atoms: Type.TOptional<Type.TInteger>;
62
+ }>;
63
+ readonly output: Type.TObject<{}>;
64
+ };
65
+ readonly curator_apply: {
66
+ readonly kind: "action";
67
+ readonly description: "Run curator with auto_apply (safe actions only).";
68
+ readonly input: Type.TObject<{
69
+ max_atoms: Type.TOptional<Type.TInteger>;
70
+ }>;
71
+ readonly output: Type.TObject<{}>;
72
+ };
73
+ readonly backups_list: {
74
+ readonly kind: "query";
75
+ readonly description: "List available backups.";
76
+ readonly input: Type.TObject<{}>;
77
+ readonly output: Type.TObject<{
78
+ backups: Type.TArray<Type.TObject<{
79
+ file: Type.TString;
80
+ size_bytes: Type.TInteger;
81
+ created: Type.TString;
82
+ }>>;
83
+ }>;
84
+ };
85
+ readonly backup_now: {
86
+ readonly kind: "action";
87
+ readonly description: "Create a database backup now.";
88
+ readonly input: Type.TObject<{}>;
89
+ readonly output: Type.TObject<{}>;
90
+ };
91
+ readonly questions_list: {
92
+ readonly kind: "query";
93
+ readonly description: "List pending human questions.";
94
+ readonly input: Type.TObject<{
95
+ limit: Type.TOptional<Type.TInteger>;
96
+ }>;
97
+ readonly output: Type.TObject<{
98
+ questions: Type.TArray<Type.TObject<{
99
+ id: Type.TString;
100
+ question: Type.TString;
101
+ question_type: Type.TOptional<Type.TString>;
102
+ status: Type.TString;
103
+ }>>;
104
+ }>;
105
+ };
106
+ readonly question_answer: {
107
+ readonly kind: "action";
108
+ readonly description: "Answer a pending question (resolve, optionally archive atom).";
109
+ readonly input: Type.TObject<{
110
+ question_id: Type.TString;
111
+ answer: Type.TString;
112
+ action: Type.TOptional<Type.TString>;
113
+ }>;
114
+ readonly output: Type.TObject<{}>;
115
+ };
116
+ readonly atom_soft_delete: {
117
+ readonly kind: "action";
118
+ readonly description: "Soft-delete an atom (status='deleted', reversible).";
119
+ readonly input: Type.TObject<{
120
+ id: Type.TString;
121
+ }>;
122
+ readonly output: Type.TObject<{}>;
123
+ };
124
+ readonly atom_update_domain: {
125
+ readonly kind: "action";
126
+ readonly description: "Move an atom to a different domain.";
127
+ readonly input: Type.TObject<{
128
+ id: Type.TString;
129
+ new_domain: Type.TString;
130
+ }>;
131
+ readonly output: Type.TObject<{}>;
132
+ };
133
+ };
134
+ readonly events: {};
135
+ };