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 +21 -0
- package/README.md +49 -0
- package/dist/curator.d.ts +48 -0
- package/dist/curator.js +407 -0
- package/dist/db.d.ts +21 -0
- package/dist/db.js +41 -0
- package/dist/digest.d.ts +16 -0
- package/dist/digest.js +38 -0
- package/dist/embeddings.d.ts +14 -0
- package/dist/embeddings.js +63 -0
- package/dist/engine.d.ts +27 -0
- package/dist/engine.js +165 -0
- package/dist/entry.d.ts +14 -0
- package/dist/entry.js +142 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +8 -0
- package/dist/tools.d.ts +102 -0
- package/dist/tools.js +248 -0
- package/openclaw.plugin.json +37 -0
- package/package.json +51 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const OLLAMA = process.env.OLLAMA_HOST ?? "http://ollama:11434";
|
|
2
|
+
const MODEL = process.env.ME_EMBED_MODEL ?? "nomic-embed-text";
|
|
3
|
+
export async function embed(text) {
|
|
4
|
+
try {
|
|
5
|
+
const res = await fetch(OLLAMA + "/api/embed", {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "Content-Type": "application/json" },
|
|
8
|
+
body: JSON.stringify({ model: MODEL, input: text }),
|
|
9
|
+
signal: AbortSignal.timeout(8000),
|
|
10
|
+
});
|
|
11
|
+
if (!res.ok)
|
|
12
|
+
return null;
|
|
13
|
+
const data = await res.json();
|
|
14
|
+
const vec = data?.embeddings?.[0] ?? data?.embedding;
|
|
15
|
+
if (!vec || !vec.length)
|
|
16
|
+
return null;
|
|
17
|
+
return new Float32Array(vec);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function cosine(a, b) {
|
|
24
|
+
let dot = 0, na = 0, nb = 0;
|
|
25
|
+
const n = Math.min(a.length, b.length);
|
|
26
|
+
for (let i = 0; i < n; i++) {
|
|
27
|
+
dot += a[i] * b[i];
|
|
28
|
+
na += a[i] * a[i];
|
|
29
|
+
nb += b[i] * b[i];
|
|
30
|
+
}
|
|
31
|
+
if (!na || !nb)
|
|
32
|
+
return 0;
|
|
33
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
34
|
+
}
|
|
35
|
+
export async function semanticSearch(db, query, limit = 10, domain) {
|
|
36
|
+
const qv = await embed(query);
|
|
37
|
+
if (!qv)
|
|
38
|
+
return [];
|
|
39
|
+
const rows = db
|
|
40
|
+
.prepare("SELECT e.atom_id as id, e.embedding, a.title, a.domain, a.type, a.status " +
|
|
41
|
+
"FROM atom_embeddings e JOIN atoms a ON a.id = e.atom_id " +
|
|
42
|
+
"WHERE a.status = 'active'")
|
|
43
|
+
.all();
|
|
44
|
+
const scored = [];
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
if (domain && r.domain !== domain)
|
|
47
|
+
continue;
|
|
48
|
+
const v = new Float32Array(r.embedding.buffer, r.embedding.byteOffset, r.embedding.byteLength / 4);
|
|
49
|
+
scored.push({ id: r.id, title: r.title, domain: r.domain, type: r.type, semantic_score: Math.round(cosine(qv, v) * 10000) / 10000 });
|
|
50
|
+
}
|
|
51
|
+
scored.sort((a, b) => b.semantic_score - a.semantic_score);
|
|
52
|
+
return scored.slice(0, limit);
|
|
53
|
+
}
|
|
54
|
+
export async function embedAtom(db, atomId, text) {
|
|
55
|
+
const v = await embed(text);
|
|
56
|
+
if (!v)
|
|
57
|
+
return false;
|
|
58
|
+
const buf = Buffer.from(v.buffer, v.byteOffset, v.byteLength);
|
|
59
|
+
const now = Math.floor(Date.now() / 1000);
|
|
60
|
+
db.prepare("INSERT INTO atom_embeddings (atom_id, embedding, model, dim, created_at, updated_at) VALUES (?,?,?,?,?,?) " +
|
|
61
|
+
"ON CONFLICT(atom_id) DO UPDATE SET embedding = excluded.embedding, updated_at = excluded.updated_at").run(atomId, buf, MODEL, v.length, now, now);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Engine TS — F2: engine port (faithful translation of engine.py).
|
|
3
|
+
* Ranking formula, recall merge/filter and graph expansion are IDENTICAL
|
|
4
|
+
* to the Python implementation; parity tests are the spec.
|
|
5
|
+
*/
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
export interface Row {
|
|
8
|
+
[k: string]: any;
|
|
9
|
+
}
|
|
10
|
+
export declare class Engine {
|
|
11
|
+
db: DatabaseSync;
|
|
12
|
+
config: any;
|
|
13
|
+
constructor(db: DatabaseSync, config?: any);
|
|
14
|
+
static INTENT_RULES: Array<[string, string[]]>;
|
|
15
|
+
static classifyIntent(query: string): Record<string, number>;
|
|
16
|
+
searchFts(query: string, limit?: number, statuses?: string[], memoryTier?: string): Row[];
|
|
17
|
+
getRelatedAtoms(atomId: string, limit?: number, minStrength?: number): Row[];
|
|
18
|
+
rankResults(ftsResults: Row[], query: string): Row[];
|
|
19
|
+
recall(query: string, limit?: number, opts?: {
|
|
20
|
+
domain?: string | null;
|
|
21
|
+
semantic?: boolean;
|
|
22
|
+
memoryTier?: string;
|
|
23
|
+
includeSuperseded?: boolean;
|
|
24
|
+
minWeight?: number;
|
|
25
|
+
}): Row[];
|
|
26
|
+
expandGraphContext(ranked: Row[], limit: number, domain: string | null | undefined, minWeight: number, seedLimit: number, neighborLimit: number, graphWeight: number, minStrength: number): Row[];
|
|
27
|
+
}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const DEFAULT_CONFIG = {
|
|
2
|
+
ranking: {},
|
|
3
|
+
graph_recall: {},
|
|
4
|
+
};
|
|
5
|
+
function escRe(s) { return s; }
|
|
6
|
+
export class Engine {
|
|
7
|
+
db;
|
|
8
|
+
config;
|
|
9
|
+
constructor(db, config = {}) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
12
|
+
}
|
|
13
|
+
static INTENT_RULES = [
|
|
14
|
+
["preference", ["preferisci", "preferenza", "preferenze", "mi piace", "voglio che", "come devo rispondere", "style", "tone"]],
|
|
15
|
+
["decision", ["perche", "perché", "deciso", "scelto", "scelta", "decisione", "motivo", "ramificata", "trade-off"]],
|
|
16
|
+
["procedure", ["come si fa", "procedura", "passi", "installare", "configurare", "ripristinare", "deploy", "workflow", "istruzioni"]],
|
|
17
|
+
["error", ["errore", "error", "fallito", "fallisce", "problema", "rompe", "bug"]],
|
|
18
|
+
];
|
|
19
|
+
static classifyIntent(query) {
|
|
20
|
+
const q = (query || "").toLowerCase();
|
|
21
|
+
const boosts = {};
|
|
22
|
+
for (const [type, kws] of Engine.INTENT_RULES) {
|
|
23
|
+
const hits = kws.reduce((n, k) => (q.includes(k) ? n + 1 : n), 0);
|
|
24
|
+
if (hits)
|
|
25
|
+
boosts[type] = Math.min(0.15, 0.05 * hits);
|
|
26
|
+
}
|
|
27
|
+
return boosts;
|
|
28
|
+
}
|
|
29
|
+
searchFts(query, limit = 10, statuses = ["active"], memoryTier) {
|
|
30
|
+
const clean = query.replace(/["'*:()\-^]/g, " ").trim();
|
|
31
|
+
if (!clean)
|
|
32
|
+
return [];
|
|
33
|
+
const ftsQuery = clean.split(/\s+/).filter((w) => w.length > 1).map((w) => '"' + w + '"*').join(" OR ");
|
|
34
|
+
if (!ftsQuery)
|
|
35
|
+
return [];
|
|
36
|
+
const ph = statuses.map(() => "?").join(",");
|
|
37
|
+
const params = [ftsQuery, ...statuses];
|
|
38
|
+
let tier = "";
|
|
39
|
+
if (memoryTier) {
|
|
40
|
+
tier = " AND a.memory_tier = ?";
|
|
41
|
+
params.push(memoryTier);
|
|
42
|
+
}
|
|
43
|
+
params.push(limit);
|
|
44
|
+
const sql = "SELECT a.*, bm25(atoms_fts) as fts_score FROM atoms_fts JOIN atoms a ON atoms_fts.rowid = a.rowid " +
|
|
45
|
+
"WHERE atoms_fts MATCH ? AND a.status IN (" + ph + ")" + tier +
|
|
46
|
+
" ORDER BY CASE a.status WHEN 'active' THEN 0 ELSE 1 END, fts_score LIMIT ?";
|
|
47
|
+
return this.db.prepare(sql).all(...params);
|
|
48
|
+
}
|
|
49
|
+
getRelatedAtoms(atomId, limit = 10, minStrength = 0.0) {
|
|
50
|
+
const sql = "SELECT a.*, b.relation, b.strength, b.evidence, b.from_id, b.to_id, " +
|
|
51
|
+
"CASE WHEN b.from_id = ? THEN 'out' ELSE 'in' END as direction " +
|
|
52
|
+
"FROM bonds b JOIN atoms a ON a.id = CASE WHEN b.from_id = ? THEN b.to_id ELSE b.from_id END " +
|
|
53
|
+
"WHERE (b.from_id = ? OR b.to_id = ?) AND b.strength >= ? AND a.status = 'active' " +
|
|
54
|
+
"ORDER BY b.strength DESC, a.weight DESC, a.access_count DESC LIMIT ?";
|
|
55
|
+
return this.db.prepare(sql).all(atomId, atomId, atomId, atomId, minStrength, limit);
|
|
56
|
+
}
|
|
57
|
+
rankResults(ftsResults, query) {
|
|
58
|
+
const cfg = this.config.ranking || {};
|
|
59
|
+
const wFts = cfg.fts_weight ?? 0.30;
|
|
60
|
+
const wSem = cfg.semantic_weight ?? 0.30;
|
|
61
|
+
const wConf = cfg.confidence_weight ?? 0.20;
|
|
62
|
+
const wRecency = cfg.recency_weight ?? 0.10;
|
|
63
|
+
const wWeight = cfg.weight_factor ?? 0.10;
|
|
64
|
+
const tierBoosts = cfg.tier_boosts ?? { semantic: 0.04, procedural: 0.03, episodic: 0.0 };
|
|
65
|
+
const statusPenalties = cfg.status_penalties ?? { superseded: -0.25, archived: -0.15, stale: -0.10, merged: -0.35 };
|
|
66
|
+
const expiredPenalty = cfg.expired_penalty ?? -0.30;
|
|
67
|
+
const intentBoosts = Engine.classifyIntent(query);
|
|
68
|
+
const now = Math.floor(Date.now() / 1000);
|
|
69
|
+
const scored = [];
|
|
70
|
+
for (const row of ftsResults) {
|
|
71
|
+
const rawFts = row.fts_score;
|
|
72
|
+
const ftsNorm = rawFts !== null && rawFts !== undefined ? (rawFts ? 1.0 / (1.0 + Math.abs(rawFts)) : 0.5) : 0.0;
|
|
73
|
+
const semNorm = row.semantic_score ?? 0.0;
|
|
74
|
+
const conf = row.confidence ?? 0.5;
|
|
75
|
+
const ageDays = Math.max(0, (now - (row.accessed_at ?? now)) / 86400);
|
|
76
|
+
const recency = Math.exp(-ageDays / 90.0);
|
|
77
|
+
const weightNorm = Math.min(1.0, (row.weight ?? 1.0) / 2.0);
|
|
78
|
+
let tierBoost = tierBoosts[row.memory_tier ?? "semantic"] ?? 0.0;
|
|
79
|
+
let statusPenalty = statusPenalties[row.status ?? "active"] ?? 0.0;
|
|
80
|
+
const vuntil = row.valid_until;
|
|
81
|
+
if (vuntil && now > vuntil)
|
|
82
|
+
statusPenalty += expiredPenalty;
|
|
83
|
+
const intentBoost = intentBoosts[row.type ?? ""] ?? 0.0;
|
|
84
|
+
let score = wFts * ftsNorm + wSem * semNorm + wConf * conf + wRecency * recency +
|
|
85
|
+
wWeight * weightNorm + tierBoost + statusPenalty + intentBoost;
|
|
86
|
+
score = Math.max(0.0, Math.min(1.0, score));
|
|
87
|
+
row.rank_score = Math.round(score * 10000) / 10000;
|
|
88
|
+
row.rank_breakdown = { final: row.rank_score, intent_boost: Math.round(intentBoost * 1000) / 1000 };
|
|
89
|
+
scored.push(row);
|
|
90
|
+
}
|
|
91
|
+
scored.sort((a, b) => b.rank_score - a.rank_score);
|
|
92
|
+
return scored;
|
|
93
|
+
}
|
|
94
|
+
recall(query, limit = 5, opts = {}) {
|
|
95
|
+
const { domain, semantic = false, memoryTier, includeSuperseded = false, minWeight = 0.0 } = opts;
|
|
96
|
+
const g = this.config.graph_recall || {};
|
|
97
|
+
const graphEnabled = g.enabled ?? true;
|
|
98
|
+
const seedLimit = Math.max(1, g.seed_limit ?? 3);
|
|
99
|
+
const neighborLimit = Math.max(0, g.neighbors_per_seed ?? 4);
|
|
100
|
+
const graphWeight = g.graph_weight ?? 0.55;
|
|
101
|
+
const minStrength = g.min_strength ?? 0.35;
|
|
102
|
+
const statuses = includeSuperseded ? ["active", "superseded"] : ["active"];
|
|
103
|
+
const ftsResults = this.searchFts(query, limit * 3, statuses, memoryTier);
|
|
104
|
+
const merged = {};
|
|
105
|
+
for (const r of ftsResults) {
|
|
106
|
+
r.semantic_score = null;
|
|
107
|
+
r.match_kind = "direct";
|
|
108
|
+
merged[r.id] = r;
|
|
109
|
+
}
|
|
110
|
+
const filtered = Object.values(merged).filter((r) => {
|
|
111
|
+
if ((r.weight ?? 1.0) < minWeight)
|
|
112
|
+
return false;
|
|
113
|
+
if (domain && r.domain !== domain)
|
|
114
|
+
return false;
|
|
115
|
+
if (memoryTier && r.memory_tier !== memoryTier)
|
|
116
|
+
return false;
|
|
117
|
+
if (!includeSuperseded && (r.status ?? "active") !== "active")
|
|
118
|
+
return false;
|
|
119
|
+
return true;
|
|
120
|
+
});
|
|
121
|
+
let ranked = this.rankResults(filtered, query);
|
|
122
|
+
ranked = this.expandGraphContext(ranked, limit, domain, minWeight, seedLimit, neighborLimit, graphWeight, minStrength);
|
|
123
|
+
return ranked.slice(0, limit);
|
|
124
|
+
}
|
|
125
|
+
expandGraphContext(ranked, limit, domain, minWeight, seedLimit, neighborLimit, graphWeight, minStrength) {
|
|
126
|
+
if (neighborLimit <= 0 || ranked.length === 0)
|
|
127
|
+
return ranked;
|
|
128
|
+
const byId = {};
|
|
129
|
+
for (const r of ranked)
|
|
130
|
+
byId[r.id] = r;
|
|
131
|
+
const expanded = [...ranked];
|
|
132
|
+
for (const seed of ranked.slice(0, seedLimit)) {
|
|
133
|
+
const neighbors = this.getRelatedAtoms(seed.id, neighborLimit, minStrength);
|
|
134
|
+
for (const n of neighbors) {
|
|
135
|
+
if ((n.weight ?? 1.0) < minWeight)
|
|
136
|
+
continue;
|
|
137
|
+
if (domain && n.domain !== domain)
|
|
138
|
+
continue;
|
|
139
|
+
if ((n.status ?? "active") !== "active")
|
|
140
|
+
continue;
|
|
141
|
+
let rel = seed.rank_score * (n.strength ?? 0.5) * graphWeight;
|
|
142
|
+
rel *= Math.min(1.0, (n.weight ?? 1.0) / 2.0);
|
|
143
|
+
rel = Math.round(rel * 10000) / 10000;
|
|
144
|
+
if (byId[n.id]) {
|
|
145
|
+
const ex = byId[n.id];
|
|
146
|
+
if (rel > (ex.graph_score ?? 0))
|
|
147
|
+
ex.graph_score = rel;
|
|
148
|
+
ex.rank_score = Math.round(Math.min(1.0, ex.rank_score + rel * 0.25) * 10000) / 10000;
|
|
149
|
+
ex.match_kind = (ex.match_kind ?? "direct") + "+graph";
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
n.fts_score = null;
|
|
153
|
+
n.semantic_score = null;
|
|
154
|
+
n.rank_score = rel;
|
|
155
|
+
n.match_kind = "graph";
|
|
156
|
+
n.graph_score = rel;
|
|
157
|
+
expanded.push(n);
|
|
158
|
+
byId[n.id] = n;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
expanded.sort((a, b) => b.rank_score - a.rank_score);
|
|
163
|
+
return expanded;
|
|
164
|
+
}
|
|
165
|
+
}
|
package/dist/entry.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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/entry.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Engine TS — plugin entry with native hooks.
|
|
3
|
+
* Tools (17) + periodic session ingest & digest (flush ogni 60s solo se ci sono nuovi messaggi).
|
|
4
|
+
*/
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
7
|
+
import { createTools, VERSION } from "./tools.js";
|
|
8
|
+
import { Curator } from "./curator.js";
|
|
9
|
+
import { buildSessionDigest } from "./digest.js";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
/** Il gateway puo' passare un tool-call id (stringa) come primo argomento:
|
|
12
|
+
* i params stanno in uno degli argomenti successivi, o in un envelope. */
|
|
13
|
+
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
|
+
function normalizeParams(a, rest = []) {
|
|
15
|
+
const cands = [a, ...rest].filter((x) => x && typeof x === "object" && !Array.isArray(x));
|
|
16
|
+
// 1) envelope
|
|
17
|
+
for (const c of cands) {
|
|
18
|
+
for (const k of ["arguments", "input", "params", "args"]) {
|
|
19
|
+
if (c[k] && typeof c[k] === "object" && !Array.isArray(c[k]))
|
|
20
|
+
return c[k];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// 2) primo candidato con chiavi param note
|
|
24
|
+
for (const c of cands) {
|
|
25
|
+
if (PARAM_KEYS.some((k) => k in c))
|
|
26
|
+
return c;
|
|
27
|
+
}
|
|
28
|
+
// 3) fallback: primo oggetto
|
|
29
|
+
return cands[0] ?? {};
|
|
30
|
+
}
|
|
31
|
+
const T = Type;
|
|
32
|
+
/** Wrap nel tool-result shape richiesto dal contract "tools" di OpenClaw. */
|
|
33
|
+
function wrapResult(result) {
|
|
34
|
+
const text = typeof result === "string" ? result : JSON.stringify(result ?? null);
|
|
35
|
+
return { content: [{ type: "text", text }] };
|
|
36
|
+
}
|
|
37
|
+
export default definePluginEntry({
|
|
38
|
+
id: "memory-engine",
|
|
39
|
+
name: "Memory Engine",
|
|
40
|
+
description: "Memory Engine ported as native OpenClaw tools + periodic session ingest & digest.",
|
|
41
|
+
register(api) {
|
|
42
|
+
const dbPath = process.env.MEMORY_DB_PATH ?? "/data/memory.db";
|
|
43
|
+
const me = createTools(dbPath);
|
|
44
|
+
const curator = new Curator(me.__db, me.__engine, (api.config ?? {}).curator ?? {});
|
|
45
|
+
const logger = api.logger ?? console;
|
|
46
|
+
// ── Tools ─────────────────────────────────────────────
|
|
47
|
+
const defs = [
|
|
48
|
+
{ 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) },
|
|
50
|
+
{ 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
|
+
{ 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
|
+
{ name: "memory_get_atom", description: "Full atom by id.", parameters: T.Object({ id: T.String() }), execute: (a) => me.get_atom(a.id) },
|
|
53
|
+
{ name: "memory_list_atoms", description: "List atoms by domain/type.", parameters: T.Object({ domain: T.Optional(T.String()), type: T.Optional(T.String()), limit: T.Optional(T.Number()) }), execute: (a) => me.list_atoms(a.domain, a.type, a.limit ?? 50) },
|
|
54
|
+
{ name: "memory_link", description: "Bond two atoms.", parameters: T.Object({ from_id: T.String(), to_id: T.String(), relation: T.String(), strength: T.Optional(T.Number()) }), execute: (a) => me.link(a.from_id, a.to_id, a.relation, a.strength) },
|
|
55
|
+
{ name: "memory_search_graph", description: "BFS graph traversal.", parameters: T.Object({ atom_id: T.String(), depth: T.Optional(T.Number()) }), execute: (a) => me.search_graph(a.atom_id, a.depth ?? 2) },
|
|
56
|
+
{ name: "memory_contradict", description: "New atom supersedes old one.", parameters: T.Object({ old_atom_id: T.String(), title: T.String(), body: T.Optional(T.String()), reason: T.Optional(T.String()) }), execute: (a) => me.memory_contradict(a.old_atom_id, a.title, a.body ?? "", a.reason ?? "") },
|
|
57
|
+
{ name: "memory_impact", description: "What depends on an atom.", parameters: T.Object({ atom_id: T.String(), depth: T.Optional(T.Number()) }), execute: (a) => me.memory_impact(a.atom_id, a.depth ?? 2) },
|
|
58
|
+
{ name: "memory_preference_search", description: "Search stable preferences.", parameters: T.Object({ query: T.Optional(T.String()), limit: T.Optional(T.Number()) }), execute: (a) => me.preference_search(a.query, undefined, a.limit ?? 20) },
|
|
59
|
+
{ name: "memory_error_check", description: "Known errors for a task.", parameters: T.Object({ task_description: T.String() }), execute: (a) => me.error_check(a.task_description) },
|
|
60
|
+
{ name: "memory_error_log", description: "Log mistake + correction.", parameters: T.Object({ task_type: T.String(), mistake: T.String(), correction: T.String() }), execute: (a) => me.error_log(a.task_type, a.mistake, a.correction) },
|
|
61
|
+
{ name: "memory_stats", description: "Memory statistics.", parameters: T.Object({}), execute: () => me.stats() },
|
|
62
|
+
{ name: "memory_session_summary", description: "Digest of a session.", parameters: T.Object({ session_id: T.String() }), execute: (a) => me.session_summary(a.session_id) },
|
|
63
|
+
{ name: "memory_generate_session_digest", description: "Generate digest atom for a session.", parameters: T.Object({ session_id: T.String() }), execute: (a) => me.generate_session_digest(a.session_id) },
|
|
64
|
+
{ name: "memory_admin", description: "Admin jobs pointer.", parameters: T.Object({}), execute: () => me._admin_note() },
|
|
65
|
+
{ 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
|
+
{ name: "memory_cognitive_status", description: "Cognitive health metrics for the memory graph.", parameters: T.Object({}), execute: () => curator.status() },
|
|
67
|
+
];
|
|
68
|
+
for (const d of defs) {
|
|
69
|
+
api.registerTool({
|
|
70
|
+
name: d.name,
|
|
71
|
+
description: d.description,
|
|
72
|
+
parameters: d.parameters,
|
|
73
|
+
execute: async (a, ...rest) => {
|
|
74
|
+
return wrapResult(await d.execute(normalizeParams(a, rest)));
|
|
75
|
+
},
|
|
76
|
+
}, { contract: "tools" });
|
|
77
|
+
}
|
|
78
|
+
// ── Periodic ingest + digest (flush solo se cambiato) ──
|
|
79
|
+
const buffer = new Map();
|
|
80
|
+
const flushedCount = new Map(); // sid -> numero msg già scritti
|
|
81
|
+
api.on("message_received", (event) => {
|
|
82
|
+
try {
|
|
83
|
+
const sid = String(event?.sessionId ?? event?.sessionKey ?? "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 48) || "unknown";
|
|
84
|
+
const content = String(event?.body ?? event?.text ?? event?.content ?? "").slice(0, 2000);
|
|
85
|
+
if (!content)
|
|
86
|
+
return;
|
|
87
|
+
const arr = buffer.get(sid) ?? [];
|
|
88
|
+
arr.push({ sessionId: sid, role: "user", content, ts: new Date().toISOString() });
|
|
89
|
+
buffer.set(sid, arr);
|
|
90
|
+
}
|
|
91
|
+
catch { /* never break inbound */ }
|
|
92
|
+
});
|
|
93
|
+
api.on("agent_end", (event) => {
|
|
94
|
+
try {
|
|
95
|
+
const sid = String(event?.sessionId ?? event?.sessionKey ?? "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 48) || "unknown";
|
|
96
|
+
const text = String(event?.reply ?? event?.finalMessage ?? event?.text ?? "").slice(0, 1000);
|
|
97
|
+
if (!text)
|
|
98
|
+
return;
|
|
99
|
+
const arr = buffer.get(sid) ?? [];
|
|
100
|
+
arr.push({ sessionId: sid, role: "assistant", content: text, ts: new Date().toISOString() });
|
|
101
|
+
buffer.set(sid, arr);
|
|
102
|
+
}
|
|
103
|
+
catch { /* observe only */ }
|
|
104
|
+
});
|
|
105
|
+
function flushSession(sid, sidRaw) {
|
|
106
|
+
const db = me.__db;
|
|
107
|
+
const now = Math.floor(Date.now() / 1000);
|
|
108
|
+
const msgs = buffer.get(sid) ?? [];
|
|
109
|
+
const already = flushedCount.get(sid) ?? 0;
|
|
110
|
+
if (msgs.length <= already)
|
|
111
|
+
return; // nessun nuovo messaggio: skip
|
|
112
|
+
const fresh = msgs.slice(already);
|
|
113
|
+
// ingest session_msg atoms (dedup via content hash)
|
|
114
|
+
for (const m of fresh) {
|
|
115
|
+
const hash = createHash("md5").update(sid + "|" + m.role + "|" + m.content).digest("hex").slice(0, 12);
|
|
116
|
+
const atomId = "smsg_" + hash;
|
|
117
|
+
db.prepare("INSERT OR IGNORE INTO atoms (id, type, memory_tier, domain, title, body, confidence, status, source, weight, tags, meta, created_at, updated_at, accessed_at, ttl) VALUES (?,?,?,?,?,?,?,'active','ai',1.0,'[]','{}',?,?,?,?)").run(atomId, "session_msg", "episodic", "session/" + sid, "[" + m.role + "] " + m.content.slice(0, 60), JSON.stringify({ role: m.role, content: m.content, timestamp: m.ts }), 0.7, now, now, now, now + 7 * 86400);
|
|
118
|
+
}
|
|
119
|
+
// digest from ingested atoms (uses session_msg domain rows)
|
|
120
|
+
const draft = buildSessionDigest(db, sid, { ocSessionId: sidRaw });
|
|
121
|
+
if (draft) {
|
|
122
|
+
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
|
+
}
|
|
124
|
+
flushedCount.set(sid, msgs.length);
|
|
125
|
+
logger.info?.("[memory-engine-ts] periodic flush session " + sid + " (" + fresh.length + " new msgs, digest " + (draft ? "ok" : "skip") + ")");
|
|
126
|
+
}
|
|
127
|
+
const FLUSH_MS = 60_000;
|
|
128
|
+
const timer = setInterval(() => {
|
|
129
|
+
try {
|
|
130
|
+
for (const [sid, arr] of buffer) {
|
|
131
|
+
if (arr.length > (flushedCount.get(sid) ?? 0))
|
|
132
|
+
flushSession(sid, sid);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
logger.warn?.("[memory-engine-ts] periodic flush error: " + String(err));
|
|
137
|
+
}
|
|
138
|
+
}, FLUSH_MS);
|
|
139
|
+
timer.unref?.();
|
|
140
|
+
logger.info?.("[memory-engine-ts] v" + VERSION + " registered: 17 tools + periodic ingest/digest (" + (FLUSH_MS / 1000) + "s)");
|
|
141
|
+
},
|
|
142
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./entry.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./entry.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import entry from "./index.js";
|
|
3
|
+
import { getToolPluginMetadata } from "openclaw/plugin-sdk/tool-plugin";
|
|
4
|
+
describe("memory-engine-ts", () => {
|
|
5
|
+
it("declares tool metadata", () => {
|
|
6
|
+
expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(["echo"]);
|
|
7
|
+
});
|
|
8
|
+
});
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Engine TS — F3: complete tool registry.
|
|
3
|
+
* Agent-facing tools with full logic; heavy admin jobs stay on the
|
|
4
|
+
* Python server and are exposed as explicit pointers.
|
|
5
|
+
*/
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
export declare const VERSION = "3.1.0";
|
|
8
|
+
export declare function createTools(dbPath: string): {
|
|
9
|
+
__db: DatabaseSync;
|
|
10
|
+
version: () => {
|
|
11
|
+
version: string;
|
|
12
|
+
note: string;
|
|
13
|
+
};
|
|
14
|
+
recall: (query: string, limit?: number, domain?: string) => {
|
|
15
|
+
id: any;
|
|
16
|
+
title: any;
|
|
17
|
+
domain: any;
|
|
18
|
+
type: any;
|
|
19
|
+
confidence: any;
|
|
20
|
+
snippet: string;
|
|
21
|
+
}[];
|
|
22
|
+
semantic_search: (query: string, limit?: number, domain?: string) => Promise<{
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
domain: string;
|
|
26
|
+
type: string;
|
|
27
|
+
semantic_score: number;
|
|
28
|
+
}[] | {
|
|
29
|
+
id: any;
|
|
30
|
+
title: any;
|
|
31
|
+
semantic_score: null;
|
|
32
|
+
note: string;
|
|
33
|
+
}[]>;
|
|
34
|
+
remember: (title: string, content: string, type?: string, domain?: string, confidence?: number, tags?: string[]) => {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
type: string;
|
|
38
|
+
domain: string;
|
|
39
|
+
};
|
|
40
|
+
get_atom: (id: string) => any;
|
|
41
|
+
list_atoms: (domain?: string, type?: string, limit?: number) => any[];
|
|
42
|
+
delete_atom: (id: string) => {
|
|
43
|
+
deleted: boolean;
|
|
44
|
+
id: string;
|
|
45
|
+
};
|
|
46
|
+
link: (fromId: string, toId: string, relation: string, strength?: number, evidence?: string) => {
|
|
47
|
+
linked: boolean;
|
|
48
|
+
from: string;
|
|
49
|
+
to: string;
|
|
50
|
+
relation: string;
|
|
51
|
+
};
|
|
52
|
+
unlink: (fromId: string, toId: string, relation: string) => {
|
|
53
|
+
unlinked: boolean;
|
|
54
|
+
};
|
|
55
|
+
search_graph: (atomId: string, depth?: number) => {
|
|
56
|
+
nodes: any[];
|
|
57
|
+
edges: any[];
|
|
58
|
+
};
|
|
59
|
+
working_set: (query: string, domain?: string, limit?: number, tokenBudget?: number | null) => any;
|
|
60
|
+
memory_contradict: (oldAtomId: string, title: string, body?: string, reason?: string) => {
|
|
61
|
+
contradiction_id: string;
|
|
62
|
+
old_atom_id: string;
|
|
63
|
+
new_atom_id: string;
|
|
64
|
+
};
|
|
65
|
+
list_contradictions: (atomId?: string, limit?: number) => any[];
|
|
66
|
+
memory_impact: (atomId: string, depth?: number) => {
|
|
67
|
+
nodes: any[];
|
|
68
|
+
edges: any[];
|
|
69
|
+
relation_breakdown: Record<string, number>;
|
|
70
|
+
};
|
|
71
|
+
preference_search: (query?: string, category?: string, limit?: number) => any[];
|
|
72
|
+
error_check: (taskDescription: string, limit?: number) => any[];
|
|
73
|
+
error_log: (taskType: string, mistake: string, correction: string, severity?: string) => {
|
|
74
|
+
id: string;
|
|
75
|
+
logged: boolean;
|
|
76
|
+
};
|
|
77
|
+
error_list: (resolved?: boolean, limit?: number) => any[];
|
|
78
|
+
stats: () => {
|
|
79
|
+
total_atoms: any;
|
|
80
|
+
active: any;
|
|
81
|
+
bonds: any;
|
|
82
|
+
contradictions: any;
|
|
83
|
+
by_domain: any;
|
|
84
|
+
by_type: any;
|
|
85
|
+
};
|
|
86
|
+
update_domain: (atomIds: string[], newDomain: string) => {
|
|
87
|
+
updated: string[];
|
|
88
|
+
new_domain: string;
|
|
89
|
+
};
|
|
90
|
+
session_summary: (sessionId: string) => any;
|
|
91
|
+
generate_session_digest: (sessionId: string) => {
|
|
92
|
+
created: boolean;
|
|
93
|
+
note: string;
|
|
94
|
+
} | {
|
|
95
|
+
id: string;
|
|
96
|
+
title: string;
|
|
97
|
+
body: string;
|
|
98
|
+
note?: undefined;
|
|
99
|
+
created: boolean;
|
|
100
|
+
};
|
|
101
|
+
_admin_note: () => string;
|
|
102
|
+
};
|