openclaw-plugin-memory-engine 3.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simone Bonizzardi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # Memory Engine TS — OpenClaw Plugin
2
+
3
+ Native memory engine for OpenClaw: 19 agent tools, event-driven session ingest,
4
+ and a conservative night curator — all in one plugin, no external MCP server.
5
+
6
+ ## Features
7
+
8
+ - **17 memory tools**: recall (FTS + ranking), working_set (token-budgeted context pack),
9
+ remember, get_atom, list_atoms, link/search_graph (knowledge graph), contradict,
10
+ impact, preference_search, error_check/error_log (error memory), stats,
11
+ session_summary/generate_session_digest
12
+ - **Curator (v3.1)**: `memory_curator_run` — conservative maintenance pass
13
+ (extractive body_compact, auto-bonds by domain/keyword/pattern, isolated atom
14
+ classification, promotion and merge candidates). Dry-run by default.
15
+ - **Cognitive status**: `memory_cognitive_status` — graph health metrics + recommendations
16
+ - **Event hooks**: buffers chat messages and periodically (60s, only when changed)
17
+ ingests `session_msg` atoms and generates/refreshes `session_digest` atoms (7-day TTL)
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ openclaw plugins install memory-engine-ts
23
+ ```
24
+
25
+ Or from a local package:
26
+
27
+ ```bash
28
+ openclaw plugins install ./openclaw-plugin-memory-engine-ts-3.1.0.tgz
29
+ ```
30
+
31
+ ## Configuration
32
+
33
+ The database path is read from the `MEMORY_DB_PATH` environment variable
34
+ (default `/data/memory.db`). The SQLite database is created with the expected
35
+ schema on first use.
36
+
37
+ Curator tuning (optional) via plugin config `curator`: max_atoms_per_run,
38
+ compact_min_body_chars, bond_limit_per_atom, stale_after_days and more —
39
+ sensible defaults apply.
40
+
41
+ ## Session hooks
42
+
43
+ - `message_received` / `agent_end`: buffer chat content per session
44
+ - every 60s: ingest new messages as episodic `session_msg` atoms (7-day TTL)
45
+ and refresh the session digest atom
46
+
47
+ ## License
48
+
49
+ MIT
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Memory Engine TS — Curator port (from Python CognitiveCurator + AutoBondEngine).
3
+ * Conservative maintenance: dry-run by default, safe reversible actions only.
4
+ */
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { Engine } from "./engine.js";
7
+ export declare const DURABLE_TYPES: readonly ["decision", "fact", "note", "preference", "procedure", "project"];
8
+ export interface CuratorConfig {
9
+ max_atoms_per_run?: number;
10
+ compact_min_body_chars?: number;
11
+ compact_max_chars?: number;
12
+ bond_limit_per_atom?: number;
13
+ isolated_limit?: number;
14
+ isolated_min_age_days?: number;
15
+ isolated_archive_after_days?: number;
16
+ isolated_high_access_threshold?: number;
17
+ isolated_high_weight_threshold?: number;
18
+ stale_after_days?: number;
19
+ promotion_min_occurrences?: number;
20
+ domain_cluster_threshold?: number;
21
+ }
22
+ export declare class Curator {
23
+ private db;
24
+ private engine;
25
+ private cfg;
26
+ constructor(db: DatabaseSync, engine: Engine, config?: CuratorConfig);
27
+ private q;
28
+ private q1;
29
+ private exec;
30
+ status(): any;
31
+ private recommendations;
32
+ run(dryRun?: boolean, autoApply?: boolean, maxAtoms?: number): any;
33
+ private compactLongAtoms;
34
+ extractiveCompact(body: string, maxChars?: number): string;
35
+ private bondPass;
36
+ private suggestBondsForAtom;
37
+ private domainCluster;
38
+ private keywordOverlap;
39
+ private patternDetection;
40
+ private tagsToSet;
41
+ private classifyIsolatedAtoms;
42
+ private classifyOne;
43
+ private setIsolatedMeta;
44
+ private proposePromotions;
45
+ private candidatePhrases;
46
+ private proposeMerges;
47
+ private normTitle;
48
+ }
@@ -0,0 +1,407 @@
1
+ export const DURABLE_TYPES = ["decision", "fact", "note", "preference", "procedure", "project"];
2
+ const STOP_WORDS = new Set([
3
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "and", "or", "but",
4
+ "in", "on", "at", "to", "for", "of", "with", "by", "from", "as", "this", "that",
5
+ "these", "those", "il", "lo", "la", "i", "gli", "le", "un", "una", "di", "del",
6
+ "della", "dei", "delle", "dello", "degli", "che", "non", "con", "per", "su",
7
+ "come", "dove", "questo", "questa", "quello", "quella",
8
+ ]);
9
+ const IT_WORD = /[a-z0-9_àèéìòù]+/g;
10
+ export class Curator {
11
+ db;
12
+ engine;
13
+ cfg;
14
+ constructor(db, engine, config = {}) {
15
+ this.db = db;
16
+ this.engine = engine;
17
+ this.cfg = {
18
+ max_atoms_per_run: config.max_atoms_per_run ?? 80,
19
+ compact_min_body_chars: config.compact_min_body_chars ?? 1200,
20
+ compact_max_chars: config.compact_max_chars ?? 600,
21
+ bond_limit_per_atom: config.bond_limit_per_atom ?? 2,
22
+ isolated_limit: config.isolated_limit ?? 40,
23
+ isolated_min_age_days: config.isolated_min_age_days ?? 7,
24
+ isolated_archive_after_days: config.isolated_archive_after_days ?? 90,
25
+ isolated_high_access_threshold: config.isolated_high_access_threshold ?? 3,
26
+ isolated_high_weight_threshold: config.isolated_high_weight_threshold ?? 0.9,
27
+ stale_after_days: config.stale_after_days ?? 180,
28
+ promotion_min_occurrences: config.promotion_min_occurrences ?? 3,
29
+ domain_cluster_threshold: config.domain_cluster_threshold ?? 0.5,
30
+ };
31
+ }
32
+ q(sql, ...p) { return this.db.prepare(sql).all(...p); }
33
+ q1(sql, ...p) { return this.db.prepare(sql).get(...p); }
34
+ exec(sql, ...p) { this.db.prepare(sql).run(...p); }
35
+ // ─── STATUS ─────────────────────────────────────────────
36
+ status() {
37
+ const now = Math.floor(Date.now() / 1000);
38
+ const ph = DURABLE_TYPES.map(() => "?").join(",");
39
+ const atomTotal = this.q1("SELECT COUNT(*) n FROM atoms WHERE status='active'").n;
40
+ const durableTotal = this.q1("SELECT COUNT(*) n FROM atoms WHERE status='active' AND type IN (" + ph + ")", ...DURABLE_TYPES).n;
41
+ const bondTotal = this.q1("SELECT COUNT(*) n FROM bonds").n;
42
+ const pending = this.q1("SELECT COUNT(*) n FROM human_questions WHERE status='pending'").n;
43
+ const answered = this.q1("SELECT COUNT(*) n FROM human_questions WHERE status='answered'").n;
44
+ const missingCompact = this.q1("SELECT COUNT(*) n FROM atoms WHERE status='active' AND length(COALESCE(body,'')) >= ? AND COALESCE(body_compact,'')=''", this.cfg.compact_min_body_chars).n;
45
+ const stale = this.q1("SELECT COUNT(*) n FROM atoms WHERE status='active' AND ? - accessed_at > ?", now, this.cfg.stale_after_days * 86400).n;
46
+ const isolated = this.q1("SELECT COUNT(*) n FROM atoms a LEFT JOIN bonds b ON b.from_id=a.id OR b.to_id=a.id " +
47
+ "WHERE a.status='active' AND a.type IN (" + ph + ") AND b.from_id IS NULL " +
48
+ "AND a.domain NOT LIKE 'daily/%' AND a.domain NOT LIKE 'session/%'", ...DURABLE_TYPES).n;
49
+ const byType = {};
50
+ for (const r of this.q("SELECT type k, COUNT(*) n FROM atoms WHERE status='active' GROUP BY type ORDER BY n DESC"))
51
+ byType[r.k] = r.n;
52
+ const topDomains = this.q("SELECT domain k, COUNT(*) n FROM atoms WHERE status='active' GROUP BY domain ORDER BY n DESC LIMIT 12")
53
+ .map((r) => ({ domain: r.k, count: r.n }));
54
+ const density = durableTotal ? Math.round((bondTotal / durableTotal) * 1000) / 1000 : 0;
55
+ return {
56
+ atoms_active: atomTotal,
57
+ durable_atoms: durableTotal,
58
+ bonds: bondTotal,
59
+ graph_density_bonds_per_durable_atom: density,
60
+ isolated_durable_atoms: isolated,
61
+ pending_questions: pending,
62
+ answered_questions: answered,
63
+ long_atoms_missing_compact: missingCompact,
64
+ stale_atoms: stale,
65
+ by_type: byType,
66
+ top_domains: topDomains,
67
+ recommendations: this.recommendations(isolated, pending, missingCompact, density),
68
+ };
69
+ }
70
+ recommendations(isolated, pending, missingCompact, density) {
71
+ const recs = [];
72
+ if (pending)
73
+ recs.push("Review/dismiss pending human questions before enabling scheduled learning.");
74
+ if (isolated > 20 || density < 0.8)
75
+ recs.push("Run curator_run(auto_apply=true) to enrich the graph.");
76
+ if (missingCompact)
77
+ recs.push("Generate body_compact for long atoms to speed recall.");
78
+ if (!recs.length)
79
+ recs.push("Memory graph looks healthy enough for scheduled curator runs.");
80
+ return recs;
81
+ }
82
+ // ─── RUN ────────────────────────────────────────────────
83
+ run(dryRun = true, autoApply = false, maxAtoms) {
84
+ const max = maxAtoms ?? this.cfg.max_atoms_per_run;
85
+ const report = {
86
+ dry_run: dryRun,
87
+ auto_apply: autoApply,
88
+ started_at: Math.floor(Date.now() / 1000),
89
+ actions: [],
90
+ };
91
+ const apply = !dryRun && autoApply;
92
+ report.actions.push(...this.compactLongAtoms(apply, max));
93
+ report.actions.push(this.bondPass(apply, max));
94
+ report.actions.push(this.classifyIsolatedAtoms(apply, max));
95
+ report.actions.push(...this.proposePromotions(max));
96
+ report.actions.push(...this.proposeMerges(max));
97
+ report.actions.push({ kind: "learning", status: "skipped", reason: "learning checks not ported to TS plugin yet" });
98
+ report.finished_at = Math.floor(Date.now() / 1000);
99
+ report.status_after = this.status();
100
+ return report;
101
+ }
102
+ // ─── COMPACT ────────────────────────────────────────────
103
+ compactLongAtoms(apply, maxAtoms) {
104
+ const rows = this.q("SELECT * FROM atoms WHERE status='active' AND length(COALESCE(body,'')) >= ? " +
105
+ "AND COALESCE(body_compact,'')='' ORDER BY weight DESC, access_count DESC LIMIT ?", this.cfg.compact_min_body_chars, maxAtoms);
106
+ const actions = [];
107
+ for (const atom of rows) {
108
+ const compact = this.extractiveCompact(String(atom.body || ""));
109
+ const action = {
110
+ kind: "compact", atom_id: atom.id, title: atom.title,
111
+ chars_before: String(atom.body || "").length, body_compact: compact, applied: false,
112
+ };
113
+ if (apply && compact) {
114
+ this.exec("UPDATE atoms SET body_compact=?, updated_at=unixepoch() WHERE id=?", compact, atom.id);
115
+ action.applied = true;
116
+ }
117
+ actions.push(action);
118
+ }
119
+ return actions;
120
+ }
121
+ extractiveCompact(body, maxChars) {
122
+ const max = maxChars ?? this.cfg.compact_max_chars;
123
+ const useful = [];
124
+ for (const raw of body.split("\n")) {
125
+ const ln = raw.trim();
126
+ if (!ln)
127
+ continue;
128
+ if (ln.startsWith("#") || ln.startsWith("-") || ln.startsWith("*") || ln.includes(":") || ln.length > 60) {
129
+ useful.push(ln.replace(/\s+/g, " "));
130
+ }
131
+ if (useful.join("\n").length >= max)
132
+ break;
133
+ }
134
+ if (!useful.length)
135
+ useful.push(body.slice(0, max).replace(/\s+/g, " ").trim());
136
+ return useful.join("\n").slice(0, max).replace(/\s+$/, "");
137
+ }
138
+ // ─── BOND PASS (AutoBondEngine port, skip semantic) ──────
139
+ bondPass(apply, maxAtoms) {
140
+ const atoms = this.q("SELECT * FROM atoms WHERE status='active' ORDER BY weight DESC, access_count DESC LIMIT ?", maxAtoms);
141
+ const seen = new Set();
142
+ const all = [];
143
+ let applied = 0;
144
+ for (const atom of atoms) {
145
+ const sug = this.suggestBondsForAtom(atom, seen);
146
+ const top = sug.slice(0, this.cfg.bond_limit_per_atom);
147
+ for (const s of top) {
148
+ all.push(s);
149
+ if (apply) {
150
+ this.exec("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 }));
151
+ applied++;
152
+ }
153
+ }
154
+ }
155
+ return {
156
+ kind: "bond_pass", applied: apply, atoms_scanned: atoms.length,
157
+ suggestions: all.length, bonds_applied: applied,
158
+ top_suggestions: all.sort((a, b) => b.confidence - a.confidence).slice(0, 20)
159
+ .map(s => ({ from: s.from_id, to: s.to_id, relation: s.relation, confidence: s.confidence, reason: s.reason.slice(0, 80) })),
160
+ };
161
+ }
162
+ suggestBondsForAtom(atom, seen) {
163
+ return [
164
+ ...this.domainCluster(atom, seen),
165
+ ...this.keywordOverlap(atom, seen),
166
+ ...this.patternDetection(atom, seen),
167
+ ];
168
+ }
169
+ domainCluster(atom, seen) {
170
+ const domain = String(atom.domain || "general");
171
+ if (domain === "general" || domain === "session")
172
+ return [];
173
+ const siblings = this.q("SELECT * FROM atoms WHERE domain=? AND status='active' ORDER BY weight DESC LIMIT 50", domain);
174
+ const out = [];
175
+ const atomTags = this.tagsToSet(atom.tags);
176
+ for (const sib of siblings) {
177
+ if (sib.id === atom.id)
178
+ continue;
179
+ const key = atom.id + "|" + sib.id + "|related_to";
180
+ if (seen.has(key) || seen.has(sib.id + "|" + atom.id + "|related_to"))
181
+ continue;
182
+ const sibTags = this.tagsToSet(sib.tags);
183
+ let overlap = 0.3;
184
+ const union = new Set([...atomTags, ...sibTags]);
185
+ if (union.size) {
186
+ let inter = 0;
187
+ for (const t of atomTags)
188
+ if (sibTags.has(t))
189
+ inter++;
190
+ overlap = inter / union.size;
191
+ }
192
+ const conf = 0.3 + overlap * 0.3;
193
+ if (conf >= this.cfg.domain_cluster_threshold - 0.1) {
194
+ out.push({ from_id: atom.id, to_id: sib.id, relation: "related_to",
195
+ confidence: Math.round(conf * 1000) / 1000,
196
+ reason: "Same domain '" + domain + "', tag overlap " + Math.round(overlap * 100) + "%",
197
+ strategy: "domain_cluster" });
198
+ seen.add(key);
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+ keywordOverlap(atom, seen) {
204
+ const words = (String(atom.title || "").toLowerCase().match(IT_WORD) || [])
205
+ .filter(w => w.length > 2 && !STOP_WORDS.has(w));
206
+ if (!words.length)
207
+ return [];
208
+ const titleWords = new Set(words);
209
+ let ftsResults = [];
210
+ try {
211
+ ftsResults = this.engine.searchFts(words.join(" "), 15, ["active"]);
212
+ }
213
+ catch { /* fts optional */ }
214
+ const out = [];
215
+ for (const r of ftsResults) {
216
+ if (r.id === atom.id)
217
+ continue;
218
+ const key = atom.id + "|" + r.id + "|related_to";
219
+ if (seen.has(key))
220
+ continue;
221
+ const rWords = new Set((String(r.title || "").toLowerCase().match(IT_WORD) || []).filter(w => w.length > 2));
222
+ let overlap = 0;
223
+ for (const w of titleWords)
224
+ if (rWords.has(w))
225
+ overlap++;
226
+ if (overlap >= 2) {
227
+ out.push({ from_id: atom.id, to_id: r.id, relation: "related_to",
228
+ confidence: Math.min(0.8, 0.3 + overlap * 0.15),
229
+ reason: "Title keyword overlap: " + overlap + " words", strategy: "keyword_overlap" });
230
+ seen.add(key);
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+ patternDetection(atom, seen) {
236
+ const atomId = String(atom.id);
237
+ const parts = atomId.split("_");
238
+ if (parts.length < 2)
239
+ return [];
240
+ const out = [];
241
+ for (const prefixLen of [Math.min(3, parts.length - 1), 2]) {
242
+ if (prefixLen < 2)
243
+ break;
244
+ const prefix = parts.slice(0, prefixLen).join("_");
245
+ const rows = this.q("SELECT id FROM atoms WHERE id LIKE ? AND id != ? AND status='active' LIMIT 20", prefix + "%", atomId);
246
+ for (const r of rows) {
247
+ const isParent = /workspace|overview|setup|status/.test(atomId.toLowerCase());
248
+ const relation = isParent ? "part_of" : "detail_of";
249
+ const [fromId, toId] = isParent ? [atomId, r.id] : [r.id, atomId];
250
+ const key = fromId + "|" + toId + "|" + relation;
251
+ if (seen.has(key) || seen.has(toId + "|" + fromId + "|" + relation))
252
+ continue;
253
+ out.push({ from_id: fromId, to_id: toId, relation,
254
+ confidence: 0.7, reason: "Shared ID prefix '" + prefix + "'", strategy: "pattern_detection" });
255
+ seen.add(key);
256
+ }
257
+ if (out.length)
258
+ break;
259
+ }
260
+ return out;
261
+ }
262
+ tagsToSet(tags) {
263
+ if (Array.isArray(tags))
264
+ return new Set(tags);
265
+ if (typeof tags === "string") {
266
+ try {
267
+ return new Set(JSON.parse(tags));
268
+ }
269
+ catch {
270
+ return new Set();
271
+ }
272
+ }
273
+ return new Set();
274
+ }
275
+ // ─── ISOLATED CLASSIFICATION ────────────────────────────
276
+ classifyIsolatedAtoms(apply, maxAtoms) {
277
+ const now = Math.floor(Date.now() / 1000);
278
+ const limit = Math.min(maxAtoms, this.cfg.isolated_limit);
279
+ const rows = this.q("SELECT a.* FROM atoms a LEFT JOIN bonds b ON b.from_id=a.id OR b.to_id=a.id " +
280
+ "WHERE a.status='active' AND b.from_id IS NULL ORDER BY a.weight DESC, a.access_count DESC, a.created_at ASC LIMIT ?", limit * 3);
281
+ const buckets = { needs_link: [], standalone_ok: [], volatile_candidate: [], archive_candidate: [] };
282
+ let applied = 0;
283
+ for (const atom of rows) {
284
+ const [state, reason] = this.classifyOne(atom, now);
285
+ buckets[state].push({ id: atom.id, title: atom.title, domain: atom.domain, type: atom.type,
286
+ weight: atom.weight, access_count: atom.access_count, reason });
287
+ if (apply) {
288
+ this.setIsolatedMeta(atom.id, state, reason, now);
289
+ applied++;
290
+ }
291
+ if (Object.values(buckets).reduce((n, v) => n + v.length, 0) >= limit)
292
+ break;
293
+ }
294
+ return {
295
+ kind: "isolated_classification", applied: apply, updated_atoms: applied,
296
+ counts: Object.fromEntries(Object.entries(buckets).map(([k, v]) => [k, v.length])),
297
+ samples: Object.fromEntries(Object.entries(buckets).filter(([, v]) => v.length).map(([k, v]) => [k, v.slice(0, 5)])),
298
+ policy: "No pending questions created; curator tags meta and suggests links/archive candidates only.",
299
+ };
300
+ }
301
+ classifyOne(atom, now) {
302
+ const domain = String(atom.domain || "");
303
+ const type = String(atom.type || "");
304
+ const createdAt = atom.created_at || now;
305
+ const accessedAt = atom.accessed_at || createdAt;
306
+ const accessCount = atom.access_count || 0;
307
+ const weight = atom.weight || 0;
308
+ let meta = {};
309
+ try {
310
+ meta = JSON.parse(atom.meta || "{}");
311
+ }
312
+ catch {
313
+ meta = {};
314
+ }
315
+ if (meta.allow_isolated)
316
+ return ["standalone_ok", "Human/previous review marked allow_isolated"];
317
+ if (domain.startsWith("session/") || type === "session_msg")
318
+ return ["volatile_candidate", "Session/chat atom: let TTL/digest handle it"];
319
+ if (type === "preference")
320
+ return ["standalone_ok", "Type 'preference' can be naturally standalone"];
321
+ if (now - accessedAt > this.cfg.isolated_archive_after_days * 86400 && accessCount === 0)
322
+ return ["archive_candidate", "Old isolated atom with no access"];
323
+ if (now - createdAt < this.cfg.isolated_min_age_days * 86400 && accessCount < this.cfg.isolated_high_access_threshold)
324
+ return ["volatile_candidate", "Too new and not reused yet; observe"];
325
+ if (weight >= this.cfg.isolated_high_weight_threshold || accessCount >= this.cfg.isolated_high_access_threshold)
326
+ return ["needs_link", "Durable/high-signal isolated atom; suggest bonds"];
327
+ return ["standalone_ok", "Low-signal durable atom; keep standalone"];
328
+ }
329
+ setIsolatedMeta(atomId, state, reason, now) {
330
+ const row = this.q1("SELECT meta FROM atoms WHERE id=?", atomId);
331
+ if (!row)
332
+ return;
333
+ let meta = {};
334
+ try {
335
+ meta = JSON.parse(row.meta || "{}");
336
+ }
337
+ catch {
338
+ meta = {};
339
+ }
340
+ meta.isolated_state = state;
341
+ meta.isolated_reason = reason;
342
+ meta.isolated_reviewed_at = now;
343
+ this.exec("UPDATE atoms SET meta=?, updated_at=? WHERE id=?", JSON.stringify(meta), now, atomId);
344
+ }
345
+ // ─── PROMOTIONS / MERGES ────────────────────────────────
346
+ proposePromotions(maxAtoms) {
347
+ const rows = this.q("SELECT id, title FROM atoms WHERE status='active' AND (domain LIKE 'session/%' OR domain LIKE 'daily/%') " +
348
+ "ORDER BY accessed_at DESC LIMIT ?", maxAtoms * 4);
349
+ const counts = new Map();
350
+ const examples = new Map();
351
+ for (const r of rows)
352
+ for (const phrase of this.candidatePhrases(String(r.title || ""))) {
353
+ counts.set(phrase, (counts.get(phrase) || 0) + 1);
354
+ const ex = examples.get(phrase) || [];
355
+ if (ex.length < 3)
356
+ ex.push(r.id);
357
+ examples.set(phrase, ex);
358
+ }
359
+ const actions = [];
360
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
361
+ for (const [phrase, count] of sorted) {
362
+ if (count >= this.cfg.promotion_min_occurrences) {
363
+ actions.push({ kind: "promotion_candidate", phrase, occurrences: count,
364
+ example_atom_ids: examples.get(phrase), applied: false,
365
+ recommendation: "Create/update a durable fact/procedure if this concept is still useful." });
366
+ }
367
+ }
368
+ return actions;
369
+ }
370
+ candidatePhrases(title) {
371
+ const text = title.toLowerCase().replace(/[[^]]+]/g, " ");
372
+ const words = (text.match(IT_WORD) || []).filter(w => w.length >= 4 &&
373
+ !["assistant", "user", "fatto", "questo", "quello", "sono", "come", "della", "degli", "nelle"].includes(w));
374
+ const phrases = [];
375
+ for (const n of [2, 3])
376
+ for (let i = 0; i <= words.length - n; i++)
377
+ phrases.push(words.slice(i, i + n).join(" "));
378
+ return phrases.slice(0, 8);
379
+ }
380
+ proposeMerges(maxAtoms) {
381
+ const ph = DURABLE_TYPES.map(() => "?").join(",");
382
+ const rows = this.q("SELECT id, title, domain, type FROM atoms WHERE status='active' AND type IN (" + ph + ") " +
383
+ "ORDER BY updated_at DESC LIMIT ?", ...DURABLE_TYPES, maxAtoms * 3);
384
+ const buckets = new Map();
385
+ for (const r of rows) {
386
+ const key = this.normTitle(String(r.title || ""));
387
+ if (key.length >= 8) {
388
+ const b = buckets.get(key) || [];
389
+ b.push(r);
390
+ buckets.set(key, b);
391
+ }
392
+ }
393
+ const actions = [];
394
+ for (const [key, atoms] of buckets) {
395
+ if (atoms.length > 1) {
396
+ actions.push({ kind: "merge_candidate", normalized_title: key, atoms: atoms.slice(0, 5),
397
+ applied: false, recommendation: "Review before merge; curator does not merge automatically." });
398
+ }
399
+ }
400
+ return actions.slice(0, 10);
401
+ }
402
+ normTitle(title) {
403
+ let text = title.toLowerCase().replace(/([^)]*)/g, " ");
404
+ text = text.replace(/20\d{2}[-_/]?\d{0,2}[-_/]?\d{0,2}/g, " ");
405
+ return ((text.match(IT_WORD) || []).slice(0, 8)).join(" ");
406
+ }
407
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Memory Engine TS — F1: data layer.
3
+ * Opens the SAME SQLite database used by the Python Memory Engine.
4
+ * Parity is the exit criterion: identical counts, identical schema.
5
+ */
6
+ import { DatabaseSync } from "node:sqlite";
7
+ export interface ParityReport {
8
+ db_path: string;
9
+ tables: string[];
10
+ atoms_total: number;
11
+ atoms_active: number;
12
+ atoms_by_status: Record<string, number>;
13
+ atoms_by_type: Record<string, number>;
14
+ atoms_by_tier: Record<string, number>;
15
+ bonds: number;
16
+ contradictions: number;
17
+ retrieval_events: number;
18
+ columns_atoms: string[];
19
+ }
20
+ export declare function openMemoryDb(path: string): DatabaseSync;
21
+ export declare function parityReport(path: string): ParityReport;
package/dist/db.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Memory Engine TS — F1: data layer.
3
+ * Opens the SAME SQLite database used by the Python Memory Engine.
4
+ * Parity is the exit criterion: identical counts, identical schema.
5
+ */
6
+ import { DatabaseSync } from "node:sqlite";
7
+ export function openMemoryDb(path) {
8
+ return new DatabaseSync(path, { readOnly: true });
9
+ }
10
+ export function parityReport(path) {
11
+ const db = openMemoryDb(path);
12
+ const tables = db
13
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
14
+ .all()
15
+ .map((r) => r.name);
16
+ const cols = db
17
+ .prepare("PRAGMA table_info(atoms)")
18
+ .all()
19
+ .map((r) => r.name);
20
+ const group = (sql) => {
21
+ const out = {};
22
+ for (const row of db.prepare(sql).all())
23
+ out[row.k] = row.n;
24
+ return out;
25
+ };
26
+ const report = {
27
+ db_path: path,
28
+ tables,
29
+ columns_atoms: cols,
30
+ atoms_total: db.prepare("SELECT COUNT(*) n FROM atoms").get().n,
31
+ atoms_active: db.prepare("SELECT COUNT(*) n FROM atoms WHERE status = 'active'").get().n,
32
+ atoms_by_status: group("SELECT status k, COUNT(*) n FROM atoms GROUP BY status"),
33
+ atoms_by_type: group("SELECT type k, COUNT(*) n FROM atoms GROUP BY type"),
34
+ atoms_by_tier: group("SELECT memory_tier k, COUNT(*) n FROM atoms GROUP BY memory_tier"),
35
+ bonds: db.prepare("SELECT COUNT(*) n FROM bonds").get().n,
36
+ contradictions: db.prepare("SELECT COUNT(*) n FROM memory_contradictions").get().n,
37
+ retrieval_events: db.prepare("SELECT COUNT(*) n FROM retrieval_events").get().n,
38
+ };
39
+ db.close();
40
+ return report;
41
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Memory Engine TS — F4/F5: session digest generator.
3
+ * Aligned with session_watcher._upsert_sqlite_digest (2.0 format).
4
+ */
5
+ import { DatabaseSync } from "node:sqlite";
6
+ export interface DigestDraft {
7
+ id: string;
8
+ title: string;
9
+ body: string;
10
+ }
11
+ export declare function buildSessionDigest(db: DatabaseSync, sessionId: string, meta?: {
12
+ ocSessionId?: string;
13
+ generation?: string;
14
+ segment?: number;
15
+ sourceKey?: string;
16
+ }): DigestDraft | null;
package/dist/digest.js ADDED
@@ -0,0 +1,38 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ export function buildSessionDigest(db, sessionId, meta = {}) {
3
+ const rows = db
4
+ .prepare("SELECT body FROM atoms WHERE type = 'session_msg' AND domain = ? AND status = 'active' ORDER BY created_at ASC")
5
+ .all("session/" + sessionId);
6
+ if (!rows.length)
7
+ return null;
8
+ const messages = [];
9
+ for (const r of rows) {
10
+ try {
11
+ const b = JSON.parse(r.body);
12
+ messages.push({ role: b.role, content: String(b.content ?? ""), timestamp: b.timestamp });
13
+ }
14
+ catch {
15
+ continue;
16
+ }
17
+ }
18
+ const userMessages = messages.filter((m) => m.role === "user");
19
+ if (!userMessages.length)
20
+ return null;
21
+ let selected = userMessages;
22
+ if (selected.length > 30)
23
+ selected = [...selected.slice(0, 15), ...selected.slice(-15)];
24
+ const userLines = selected.map((m) => "- " + (m.content.length > 200 ? m.content.slice(0, 200) + "..." : m.content));
25
+ const firstTs = userMessages[0].timestamp ?? "";
26
+ const lastTs = userMessages[userMessages.length - 1].timestamp ?? "";
27
+ const body = "Session: " + sessionId + "\n" +
28
+ "OpenClaw session: " + (meta.ocSessionId ?? sessionId) + "\n" +
29
+ "Generation: " + (meta.generation ?? "active") + "; segment: " + (meta.segment ?? 0) + "\n" +
30
+ "Period: " + firstTs + " \u2192 " + lastTs + "\n" +
31
+ "Messages: " + messages.length + "\n\nUser messages:\n" +
32
+ userLines.join("\n");
33
+ const title = "Digest: " + userLines[0].slice(2, 62);
34
+ const id = meta.sourceKey
35
+ ? "digest_" + createHash("sha256").update(meta.sourceKey).digest("hex").slice(0, 24)
36
+ : "digest_" + sessionId.slice(0, 8) + "_" + randomBytes(3).toString("hex");
37
+ return { id, title, body };
38
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Memory Engine TS — embeddings via Ollama (nomic-embed-text).
3
+ * Reads/writes the same atom_embeddings table used by the Python engine.
4
+ */
5
+ import { DatabaseSync } from "node:sqlite";
6
+ export declare function embed(text: string): Promise<Float32Array | null>;
7
+ export declare function semanticSearch(db: DatabaseSync, query: string, limit?: number, domain?: string): Promise<Array<{
8
+ id: string;
9
+ title: string;
10
+ domain: string;
11
+ type: string;
12
+ semantic_score: number;
13
+ }>>;
14
+ export declare function embedAtom(db: DatabaseSync, atomId: string, text: string): Promise<boolean>;