@synapcores/openclaw-memory 0.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 +214 -0
- package/dist/config.d.ts +104 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +165 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +241 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +977 -0
- package/dist/index.js.map +1 -0
- package/openclaw.plugin.json +128 -0
- package/package.json +82 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Memory (SynapCores) Plugin
|
|
3
|
+
*
|
|
4
|
+
* Long-term memory with vector search for AI conversations.
|
|
5
|
+
* Uses SynapCores AIDB for storage and OpenAI for embeddings.
|
|
6
|
+
* Provides seamless auto-recall and auto-capture via lifecycle hooks,
|
|
7
|
+
* plus three SynapCores-only extensions (SQL-filtered recall, graph-relation
|
|
8
|
+
* walks, and AutoML relevance scoring) — see `recallFiltered`,
|
|
9
|
+
* `recallRelated`, and `predictRelevance`.
|
|
10
|
+
*
|
|
11
|
+
* This is the @synapcores/openclaw-memory drop-in alternative to
|
|
12
|
+
* @openclaw/memory-lancedb. The parity API (recall + capture +
|
|
13
|
+
* auto-recall/auto-capture) plus the three SynapCores-only extensions
|
|
14
|
+
* are all fully wired in 0.1.0.
|
|
15
|
+
*/
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import OpenAI from "openai";
|
|
19
|
+
import { stringEnum } from "openclaw/plugin-sdk";
|
|
20
|
+
import { SynapCores } from "@synapcores/sdk";
|
|
21
|
+
import { MEMORY_CATEGORIES, memoryConfigSchema, vectorDimsForModel, } from "./config.js";
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// SynapCores Provider
|
|
24
|
+
// ============================================================================
|
|
25
|
+
const DEFAULT_COLLECTION = "openclaw_memories";
|
|
26
|
+
function getSdkHttp(client) {
|
|
27
|
+
return client._getHttpClient();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Unwrap a JSON-API-style `{ data, meta }` envelope down to the inner payload.
|
|
31
|
+
* Tolerates both the wrapped form and a bare body for forward-compat.
|
|
32
|
+
*/
|
|
33
|
+
function unwrapEnvelope(raw) {
|
|
34
|
+
if (raw && typeof raw === "object" && "data" in raw) {
|
|
35
|
+
return raw.data;
|
|
36
|
+
}
|
|
37
|
+
return raw;
|
|
38
|
+
}
|
|
39
|
+
class MemoryDB {
|
|
40
|
+
collectionName;
|
|
41
|
+
vectorDim;
|
|
42
|
+
client;
|
|
43
|
+
// 0.1.0 talks to the gateway's /v1/vectors/collections subsystem directly.
|
|
44
|
+
// The SDK's `Collection` class targets the document-collection world
|
|
45
|
+
// (`/v1/collections/...`) which is a separate storage tree on the gateway,
|
|
46
|
+
// so reusing it for vector CRUD lands in the wrong subsystem. We keep one
|
|
47
|
+
// SDK `Collection` handle around purely so the SDK's normalised
|
|
48
|
+
// `vectorSearch()` (the only Collection method whose wire path matches
|
|
49
|
+
// the vector subsystem) stays in use.
|
|
50
|
+
collection = null;
|
|
51
|
+
initPromise = null;
|
|
52
|
+
http;
|
|
53
|
+
constructor(client, collectionName, vectorDim) {
|
|
54
|
+
this.collectionName = collectionName;
|
|
55
|
+
this.vectorDim = vectorDim;
|
|
56
|
+
this.client = client;
|
|
57
|
+
this.http = getSdkHttp(client);
|
|
58
|
+
}
|
|
59
|
+
async ensureInitialized() {
|
|
60
|
+
if (this.collection) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (this.initPromise) {
|
|
64
|
+
return this.initPromise;
|
|
65
|
+
}
|
|
66
|
+
this.initPromise = this.doInitialize();
|
|
67
|
+
return this.initPromise;
|
|
68
|
+
}
|
|
69
|
+
async doInitialize() {
|
|
70
|
+
// Provision a vector collection on first use.
|
|
71
|
+
//
|
|
72
|
+
// The SDK's `client.createCollection()` posts to /v1/collections (the
|
|
73
|
+
// document-collection subsystem) and drops the vector_size / dimensions
|
|
74
|
+
// field on the way through — so it never lands in the vector subsystem.
|
|
75
|
+
// We bypass and POST /v1/vectors/collections directly with
|
|
76
|
+
// `{name, dimensions, distance_metric}`. Auth is already wired on the
|
|
77
|
+
// SDK's http client.
|
|
78
|
+
let exists = false;
|
|
79
|
+
try {
|
|
80
|
+
const raw = (await this.http.get("/vectors/collections")).data;
|
|
81
|
+
const items = unwrapEnvelope(raw);
|
|
82
|
+
const list = Array.isArray(items) ? items : [];
|
|
83
|
+
exists = list.some((it) => it?.name === this.collectionName);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// best-effort; fall through and try to create
|
|
87
|
+
}
|
|
88
|
+
if (!exists) {
|
|
89
|
+
try {
|
|
90
|
+
await this.http.post("/vectors/collections", {
|
|
91
|
+
name: this.collectionName,
|
|
92
|
+
dimensions: this.vectorDim,
|
|
93
|
+
distance_metric: "cosine",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
// Race with a concurrent creator — re-check before giving up.
|
|
98
|
+
try {
|
|
99
|
+
const raw = (await this.http.get("/vectors/collections")).data;
|
|
100
|
+
const items = unwrapEnvelope(raw);
|
|
101
|
+
const list = Array.isArray(items) ? items : [];
|
|
102
|
+
if (!list.some((it) => it?.name === this.collectionName)) {
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Cache a Collection handle just for vectorSearch (the one SDK method
|
|
112
|
+
// that already routes through the vector subsystem). v0.3.0 added
|
|
113
|
+
// `client.collection(name)` as a synchronous handle factory; fall back
|
|
114
|
+
// to the async getCollection path if not present.
|
|
115
|
+
const collFn = this.client
|
|
116
|
+
.collection;
|
|
117
|
+
if (typeof collFn === "function") {
|
|
118
|
+
this.collection = collFn.call(this.client, this.collectionName);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
this.collection = await this.client.getCollection(this.collectionName);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async store(entry) {
|
|
125
|
+
await this.ensureInitialized();
|
|
126
|
+
const fullEntry = {
|
|
127
|
+
...entry,
|
|
128
|
+
id: randomUUID(),
|
|
129
|
+
createdAt: Date.now(),
|
|
130
|
+
};
|
|
131
|
+
// Vector insert wire: POST /v1/vectors/collections/{name}/vectors
|
|
132
|
+
// body = { vectors: [ { id, values, metadata } ] }
|
|
133
|
+
// We carry text + importance + category + createdAt in metadata so the
|
|
134
|
+
// search response can hydrate a MemoryEntry without a second round-trip.
|
|
135
|
+
await this.http.post(`/vectors/collections/${encodeURIComponent(this.collectionName)}/vectors`, {
|
|
136
|
+
vectors: [
|
|
137
|
+
{
|
|
138
|
+
id: fullEntry.id,
|
|
139
|
+
values: fullEntry.vector,
|
|
140
|
+
metadata: {
|
|
141
|
+
text: fullEntry.text,
|
|
142
|
+
importance: fullEntry.importance,
|
|
143
|
+
category: fullEntry.category,
|
|
144
|
+
createdAt: fullEntry.createdAt,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
return fullEntry;
|
|
150
|
+
}
|
|
151
|
+
async search(vector, limit = 5, minScore = 0.5) {
|
|
152
|
+
await this.ensureInitialized();
|
|
153
|
+
const result = await this.collection.vectorSearch({
|
|
154
|
+
vector,
|
|
155
|
+
field: "embedding",
|
|
156
|
+
topK: limit,
|
|
157
|
+
distanceMetric: "cosine",
|
|
158
|
+
includeMetadata: true,
|
|
159
|
+
});
|
|
160
|
+
const documents = (result.documents ?? []);
|
|
161
|
+
return documents.map(parseDocumentToResult).filter((r) => r.score >= minScore);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Same shape as `search`, but accepts a SQL `WHERE` clause (forwarded
|
|
165
|
+
* to the gateway as the `filter` field on `/vector_search`). Used by
|
|
166
|
+
* the `recallFiltered` extension method.
|
|
167
|
+
*/
|
|
168
|
+
async searchFiltered(vector, where, limit = 5) {
|
|
169
|
+
await this.ensureInitialized();
|
|
170
|
+
const result = await this.collection.vectorSearch({
|
|
171
|
+
vector,
|
|
172
|
+
field: "embedding",
|
|
173
|
+
topK: limit,
|
|
174
|
+
// The SDK forwards `filter` as the `filter` field in the POST body;
|
|
175
|
+
// the gateway accepts either a JSON match object or a SQL WHERE
|
|
176
|
+
// string. We pass the user's WHERE as `{ sql: where }` so the
|
|
177
|
+
// gateway routes it through the SQL path rather than the JSON-match
|
|
178
|
+
// path. If the gateway can't parse, the SDK's error wrapper will
|
|
179
|
+
// surface the message verbatim — we do NOT validate SQL client-side.
|
|
180
|
+
filter: { sql: where },
|
|
181
|
+
distanceMetric: "cosine",
|
|
182
|
+
includeMetadata: true,
|
|
183
|
+
});
|
|
184
|
+
const documents = (result.documents ?? []);
|
|
185
|
+
return documents.map(parseDocumentToResult);
|
|
186
|
+
}
|
|
187
|
+
async delete(id) {
|
|
188
|
+
await this.ensureInitialized();
|
|
189
|
+
// Validate UUID format to prevent injection
|
|
190
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
191
|
+
if (!uuidRegex.test(id)) {
|
|
192
|
+
throw new Error(`Invalid memory ID format: ${id}`);
|
|
193
|
+
}
|
|
194
|
+
// Vector delete wire: DELETE /v1/vectors/collections/{name}/vectors/{id}
|
|
195
|
+
await this.http.delete(`/vectors/collections/${encodeURIComponent(this.collectionName)}/vectors/${encodeURIComponent(id)}`);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
async count() {
|
|
199
|
+
await this.ensureInitialized();
|
|
200
|
+
// Vector collection info wire: GET /v1/vectors/collections/{name}
|
|
201
|
+
// returns { data: { name, dimensions, vector_count, distance_metric, index_type } }
|
|
202
|
+
const raw = (await this.http.get(`/vectors/collections/${encodeURIComponent(this.collectionName)}`)).data;
|
|
203
|
+
const info = unwrapEnvelope(raw);
|
|
204
|
+
return typeof info?.vector_count === "number" ? info.vector_count : 0;
|
|
205
|
+
}
|
|
206
|
+
/** Fetch a single memory by ID (returns null if not found). */
|
|
207
|
+
async get(id) {
|
|
208
|
+
await this.ensureInitialized();
|
|
209
|
+
// Vector get wire: GET /v1/vectors/collections/{name}/vectors/{id}
|
|
210
|
+
let raw;
|
|
211
|
+
try {
|
|
212
|
+
raw = (await this.http.get(`/vectors/collections/${encodeURIComponent(this.collectionName)}/vectors/${encodeURIComponent(id)}`)).data;
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
const e = err;
|
|
216
|
+
if (e?.code === "NOT_FOUND" || e?.status === 404)
|
|
217
|
+
return null;
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
if (!raw)
|
|
221
|
+
return null;
|
|
222
|
+
const vec = unwrapEnvelope(raw);
|
|
223
|
+
if (!vec)
|
|
224
|
+
return null;
|
|
225
|
+
// Gateway response shape: { id, values: [...], metadata: { ... } }
|
|
226
|
+
const meta = vec.metadata ?? {};
|
|
227
|
+
return {
|
|
228
|
+
id: String(vec.id ?? id),
|
|
229
|
+
text: typeof meta.text === "string" ? meta.text : "",
|
|
230
|
+
vector: Array.isArray(vec.values) ? vec.values : [],
|
|
231
|
+
importance: typeof meta.importance === "number" ? meta.importance : 0,
|
|
232
|
+
category: meta.category ?? "other",
|
|
233
|
+
createdAt: typeof meta.createdAt === "number" ? meta.createdAt : 0,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** Internal accessor — used by extension methods. */
|
|
237
|
+
_collection() {
|
|
238
|
+
if (!this.collection) {
|
|
239
|
+
throw new Error("MemoryDB not initialized; call a method that triggers ensureInitialized first");
|
|
240
|
+
}
|
|
241
|
+
return this.collection;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function docToEntry(doc) {
|
|
245
|
+
// Vector-collection search results land here in two shapes:
|
|
246
|
+
// (1) flat (legacy doc-collection):
|
|
247
|
+
// { id, text, embedding, importance, category, createdAt, score }
|
|
248
|
+
// (2) nested (vector-collection v2):
|
|
249
|
+
// { id, values, metadata: { text, importance, category, createdAt }, score }
|
|
250
|
+
// We support both so the same parser works against either subsystem.
|
|
251
|
+
const meta = doc.metadata ?? {};
|
|
252
|
+
const pick = (key) => {
|
|
253
|
+
if (doc[key] !== undefined)
|
|
254
|
+
return doc[key];
|
|
255
|
+
if (meta[key] !== undefined)
|
|
256
|
+
return meta[key];
|
|
257
|
+
return undefined;
|
|
258
|
+
};
|
|
259
|
+
const text = pick("text") ?? "";
|
|
260
|
+
const importance = pick("importance");
|
|
261
|
+
const category = pick("category");
|
|
262
|
+
const createdAt = pick("createdAt");
|
|
263
|
+
const vector = (doc.values ?? doc.embedding);
|
|
264
|
+
return {
|
|
265
|
+
id: String(doc.id ?? ""),
|
|
266
|
+
text: String(text),
|
|
267
|
+
vector: Array.isArray(vector) ? vector : [],
|
|
268
|
+
importance: typeof importance === "number" ? importance : 0,
|
|
269
|
+
category: category ?? "other",
|
|
270
|
+
createdAt: typeof createdAt === "number" ? createdAt : 0,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function parseDocumentToResult(doc) {
|
|
274
|
+
// Gateway v1.6.5.2-ce vector search returns `score` as **cosine distance**
|
|
275
|
+
// (lower is better, 0 = identical, 1 = orthogonal, 2 = opposite). We
|
|
276
|
+
// convert to a [0, 1] similarity for the public API. If we ever see a
|
|
277
|
+
// `distance` field too (older / future shape) we honour that for parity.
|
|
278
|
+
const rawScore = typeof doc.score === "number" ? doc.score : undefined;
|
|
279
|
+
const rawDistance = typeof doc.distance === "number" ? doc.distance : undefined;
|
|
280
|
+
const distance = rawDistance ?? rawScore ?? 0;
|
|
281
|
+
// Map cosine distance to a 0..1 similarity. cosine distance is in [0, 2]
|
|
282
|
+
// so we use `max(0, 1 - distance)` — for typical cosine-similarity-style
|
|
283
|
+
// ranges in [0, 1] this is equivalent to `1 - distance`.
|
|
284
|
+
const similarity = Math.max(0, Math.min(1, 1 - distance));
|
|
285
|
+
return {
|
|
286
|
+
entry: docToEntry(doc),
|
|
287
|
+
score: similarity,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// ============================================================================
|
|
291
|
+
// OpenAI Embeddings
|
|
292
|
+
// ============================================================================
|
|
293
|
+
class Embeddings {
|
|
294
|
+
model;
|
|
295
|
+
client;
|
|
296
|
+
constructor(apiKey, model) {
|
|
297
|
+
this.model = model;
|
|
298
|
+
this.client = new OpenAI({ apiKey });
|
|
299
|
+
}
|
|
300
|
+
async embed(text) {
|
|
301
|
+
const response = await this.client.embeddings.create({
|
|
302
|
+
model: this.model,
|
|
303
|
+
input: text,
|
|
304
|
+
});
|
|
305
|
+
return response.data[0].embedding;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// ============================================================================
|
|
309
|
+
// Math helpers (shared between linker + relevance scorer)
|
|
310
|
+
// ============================================================================
|
|
311
|
+
function cosineSimilarity(a, b) {
|
|
312
|
+
let dot = 0;
|
|
313
|
+
let na = 0;
|
|
314
|
+
let nb = 0;
|
|
315
|
+
const len = Math.min(a.length, b.length);
|
|
316
|
+
for (let i = 0; i < len; i++) {
|
|
317
|
+
dot += a[i] * b[i];
|
|
318
|
+
na += a[i] * a[i];
|
|
319
|
+
nb += b[i] * b[i];
|
|
320
|
+
}
|
|
321
|
+
if (na === 0 || nb === 0)
|
|
322
|
+
return 0;
|
|
323
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
324
|
+
}
|
|
325
|
+
function ageDays(createdAt, now = Date.now()) {
|
|
326
|
+
return Math.max(0, (now - createdAt) / (1000 * 60 * 60 * 24));
|
|
327
|
+
}
|
|
328
|
+
const CATEGORY_INDEX = {
|
|
329
|
+
preference: 0,
|
|
330
|
+
fact: 1,
|
|
331
|
+
decision: 2,
|
|
332
|
+
entity: 3,
|
|
333
|
+
other: 4,
|
|
334
|
+
};
|
|
335
|
+
function categoryOneHot(category) {
|
|
336
|
+
const vec = [0, 0, 0, 0, 0];
|
|
337
|
+
vec[CATEGORY_INDEX[category] ?? 4] = 1;
|
|
338
|
+
return vec;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Feature vector used by both the heuristic relevance scorer and the
|
|
342
|
+
* AutoML training path. Keeping the layout in one place means
|
|
343
|
+
* `predictRelevance` and `trainRelevanceModel` always see the same shape.
|
|
344
|
+
*/
|
|
345
|
+
function buildRelevanceFeatures(queryVector, candidate, now = Date.now()) {
|
|
346
|
+
const cosine = cosineSimilarity(queryVector, candidate.vector);
|
|
347
|
+
const age = ageDays(candidate.createdAt, now);
|
|
348
|
+
const oneHot = categoryOneHot(candidate.category);
|
|
349
|
+
return {
|
|
350
|
+
cosine,
|
|
351
|
+
ageDays: age,
|
|
352
|
+
importance: candidate.importance,
|
|
353
|
+
category: candidate.category,
|
|
354
|
+
vector: [cosine, age, candidate.importance, ...oneHot],
|
|
355
|
+
asRecord: {
|
|
356
|
+
cosine,
|
|
357
|
+
age_days: age,
|
|
358
|
+
importance: candidate.importance,
|
|
359
|
+
category_preference: oneHot[0],
|
|
360
|
+
category_fact: oneHot[1],
|
|
361
|
+
category_decision: oneHot[2],
|
|
362
|
+
category_entity: oneHot[3],
|
|
363
|
+
category_other: oneHot[4],
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// ============================================================================
|
|
368
|
+
// Rule-based capture filter
|
|
369
|
+
// ============================================================================
|
|
370
|
+
const MEMORY_TRIGGERS = [
|
|
371
|
+
/zapamatuj si|pamatuj|remember/i,
|
|
372
|
+
/preferuji|radši|nechci|prefer/i,
|
|
373
|
+
/rozhodli jsme|budeme používat/i,
|
|
374
|
+
/\+\d{10,}/,
|
|
375
|
+
/[\w.-]+@[\w.-]+\.\w+/,
|
|
376
|
+
/můj\s+\w+\s+je|je\s+můj/i,
|
|
377
|
+
/my\s+\w+\s+is|is\s+my/i,
|
|
378
|
+
/i (like|prefer|hate|love|want|need)/i,
|
|
379
|
+
/always|never|important/i,
|
|
380
|
+
];
|
|
381
|
+
function shouldCapture(text) {
|
|
382
|
+
if (text.length < 10 || text.length > 500) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
// Skip injected context from memory recall
|
|
386
|
+
if (text.includes("<relevant-memories>")) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
// Skip system-generated content
|
|
390
|
+
if (text.startsWith("<") && text.includes("</")) {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
// Skip agent summary responses (contain markdown formatting)
|
|
394
|
+
if (text.includes("**") && text.includes("\n-")) {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
// Skip emoji-heavy responses (likely agent output)
|
|
398
|
+
const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
|
|
399
|
+
if (emojiCount > 3) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
return MEMORY_TRIGGERS.some((r) => r.test(text));
|
|
403
|
+
}
|
|
404
|
+
function detectCategory(text) {
|
|
405
|
+
const lower = text.toLowerCase();
|
|
406
|
+
if (/prefer|radši|like|love|hate|want/i.test(lower)) {
|
|
407
|
+
return "preference";
|
|
408
|
+
}
|
|
409
|
+
if (/rozhodli|decided|will use|budeme/i.test(lower)) {
|
|
410
|
+
return "decision";
|
|
411
|
+
}
|
|
412
|
+
if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) {
|
|
413
|
+
return "entity";
|
|
414
|
+
}
|
|
415
|
+
if (/is|are|has|have|je|má|jsou/i.test(lower)) {
|
|
416
|
+
return "fact";
|
|
417
|
+
}
|
|
418
|
+
return "other";
|
|
419
|
+
}
|
|
420
|
+
// ============================================================================
|
|
421
|
+
// Cypher value escaping
|
|
422
|
+
// ============================================================================
|
|
423
|
+
/**
|
|
424
|
+
* Escape a string literal for safe inlining into a Cypher query.
|
|
425
|
+
*
|
|
426
|
+
* Gateway v1.6.5.2-ce explicitly rejects named-parameter bindings (`$param`)
|
|
427
|
+
* with HTTP 400; the supported path is to inline literal values into the
|
|
428
|
+
* query string. Memory IDs flow in from `randomUUID()` so they are normally
|
|
429
|
+
* safe, BUT we never trust upstream input — every string that ends up
|
|
430
|
+
* inside `'...'` in a Cypher fragment must go through this helper.
|
|
431
|
+
*
|
|
432
|
+
* Escapes single quotes (`'` -> `\'`) and backslashes (`\` -> `\\`).
|
|
433
|
+
* Returns the inner content only; callers are responsible for the
|
|
434
|
+
* surrounding quotes (so the helper is composable with template literals).
|
|
435
|
+
*/
|
|
436
|
+
export function escapeCypherString(value) {
|
|
437
|
+
return String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
438
|
+
}
|
|
439
|
+
// ============================================================================
|
|
440
|
+
// Auto-link similar memories (capture-time graph edge creation)
|
|
441
|
+
// ============================================================================
|
|
442
|
+
// Default cosine-similarity threshold for treating two memories as
|
|
443
|
+
// "similar enough" to surface from `recallRelated`. Used as the operand on
|
|
444
|
+
// the gateway's synthetic-edge syntax `[:SIMILAR_TO > THRESHOLD]`.
|
|
445
|
+
const SIMILAR_TO_THRESHOLD = 0.7;
|
|
446
|
+
const SIMILAR_TO_TOPK = 4; // legacy constant — kept for symmetry with v0.2.0 plan
|
|
447
|
+
/**
|
|
448
|
+
* `linkSimilarMemories` is a no-op against gateway v1.6.5.x.
|
|
449
|
+
*
|
|
450
|
+
* The gateway treats `SIMILAR_TO` as a **synthetic / derived** edge type:
|
|
451
|
+
* it computes the edge on-the-fly from the underlying vector similarity at
|
|
452
|
+
* `MATCH` time, using `[:SIMILAR_TO > THRESHOLD]` syntax (where THRESHOLD
|
|
453
|
+
* is a literal float). Explicit edge creation is rejected:
|
|
454
|
+
*
|
|
455
|
+
* `'SIMILAR_TO' is a reserved synthetic edge type — the Cypher engine
|
|
456
|
+
* derives it from vector similarity and it cannot be stored as a literal
|
|
457
|
+
* edge.` (HTTP 400 from /v1/graph/edges)
|
|
458
|
+
*
|
|
459
|
+
* Multi-statement Cypher (`MERGE ... MERGE ...`) is also rejected by this
|
|
460
|
+
* gateway's parser. Together those constraints mean the original capture-time
|
|
461
|
+
* MERGE pipeline doesn't apply — and doesn't need to: `recallRelated` reads
|
|
462
|
+
* the synthetic similarity edges directly without any pre-stored state.
|
|
463
|
+
*
|
|
464
|
+
* The function is left as a public-API shim so `autoLinkSimilar = true`
|
|
465
|
+
* doesn't error out for callers carrying the option from older configs.
|
|
466
|
+
* 0.2.0 will likely add `MENTIONS` / `RELATES_TO` edges (which are NOT
|
|
467
|
+
* synthetic) via the REST `/v1/graph/edges` endpoint.
|
|
468
|
+
*/
|
|
469
|
+
async function linkSimilarMemories(entry, _db, _client, _graphName, _logger) {
|
|
470
|
+
void entry;
|
|
471
|
+
// No-op in 0.1.0. The gateway's synthetic-SIMILAR_TO edges make this
|
|
472
|
+
// unnecessary at capture-time: `recallRelated` reads similarity at query
|
|
473
|
+
// time, so there's nothing to pre-link.
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
// ============================================================================
|
|
477
|
+
// SynapCores Extensions
|
|
478
|
+
// ============================================================================
|
|
479
|
+
const DEFAULT_RELEVANCE_MODEL = "openclaw_memory_relevance";
|
|
480
|
+
const MIN_TRAINING_SAMPLES = 10;
|
|
481
|
+
function relevanceModelName(workspace) {
|
|
482
|
+
return workspace ? `${DEFAULT_RELEVANCE_MODEL}_${workspace}` : DEFAULT_RELEVANCE_MODEL;
|
|
483
|
+
}
|
|
484
|
+
function createExtensions(db, embeddings, client, graphName, workspace, collectionName = DEFAULT_COLLECTION) {
|
|
485
|
+
const modelName = relevanceModelName(workspace);
|
|
486
|
+
async function modelExists(name) {
|
|
487
|
+
try {
|
|
488
|
+
const models = await client.automl.listModels();
|
|
489
|
+
return models.some((m) => m.name === name);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
async recallFiltered(options) {
|
|
497
|
+
const limit = options.limit ?? 5;
|
|
498
|
+
const vector = await embeddings.embed(options.semantic);
|
|
499
|
+
// Empty / `1=1` filter behaves the same as plain recall; pass it
|
|
500
|
+
// through anyway so the gateway sees a uniform request shape.
|
|
501
|
+
return db.searchFiltered(vector, options.where, limit);
|
|
502
|
+
},
|
|
503
|
+
async recallRelated(memoryId, options = {}) {
|
|
504
|
+
// 0.1.0 fallback strategy:
|
|
505
|
+
//
|
|
506
|
+
// Gateway v1.6.5.x treats `SIMILAR_TO` as a **synthetic, derived**
|
|
507
|
+
// edge — it computes it on-the-fly from vector similarity at MATCH
|
|
508
|
+
// time, and the supported syntax is `[:SIMILAR_TO > THRESHOLD]`
|
|
509
|
+
// (single-hop only — `[:SIMILAR_TO*1..N]` and explicit MERGE / CREATE
|
|
510
|
+
// on `SIMILAR_TO` are both rejected). Crucially, the synthetic
|
|
511
|
+
// edge resolves against the **graph backend's** vector index, not
|
|
512
|
+
// the vector collection we write into. Without first promoting
|
|
513
|
+
// every Memory node into the graph with its embedding (a third
|
|
514
|
+
// subsystem that 0.1.0 does not wire), the MATCH walks always
|
|
515
|
+
// return zero rows.
|
|
516
|
+
//
|
|
517
|
+
// Rather than ship a method that silently returns `[]`, we surface
|
|
518
|
+
// the same kind of clear "ships in 0.2.0" error as
|
|
519
|
+
// `trainRelevanceModel`, but only when the caller actually asks for
|
|
520
|
+
// graph-backed recall. The signature stays so downstream code that
|
|
521
|
+
// wires this up today keeps compiling after the 0.2.0 upgrade.
|
|
522
|
+
//
|
|
523
|
+
// 0.2.0 plan: on capture, post the Memory node to /v1/graph/nodes
|
|
524
|
+
// with its embedding, then have this method compose
|
|
525
|
+
// `MATCH (start:Memory {id:'X'})-[:SIMILAR_TO > T]-(related)
|
|
526
|
+
// RETURN related.id, related.text LIMIT 20`.
|
|
527
|
+
void options;
|
|
528
|
+
void client;
|
|
529
|
+
void graphName;
|
|
530
|
+
void escapeCypherString;
|
|
531
|
+
void db;
|
|
532
|
+
void memoryId;
|
|
533
|
+
throw new Error("memory-synapcores.recallRelated: signature-only in 0.1.0. " +
|
|
534
|
+
"Gateway v1.6.5.x derives SIMILAR_TO edges synthetically from a graph-node " +
|
|
535
|
+
"vector index that the plugin does not populate in this release; full graph-backed " +
|
|
536
|
+
"recall (with auto-indexed Memory nodes) ships in 0.2.0. Use predictRelevance + " +
|
|
537
|
+
"recallFiltered in the meantime for relevance-scoped recall.");
|
|
538
|
+
},
|
|
539
|
+
async predictRelevance(query, candidates) {
|
|
540
|
+
if (candidates.length === 0)
|
|
541
|
+
return [];
|
|
542
|
+
const queryVector = await embeddings.embed(query);
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
const features = candidates.map((c) => buildRelevanceFeatures(queryVector, c, now));
|
|
545
|
+
// Try model mode; on any failure (no model, transport error) fall
|
|
546
|
+
// back to the heuristic so the caller never gets an empty result.
|
|
547
|
+
if (await modelExists(modelName)) {
|
|
548
|
+
try {
|
|
549
|
+
const model = await client.automl.getModel(modelName);
|
|
550
|
+
const inputs = features.map((f) => f.asRecord);
|
|
551
|
+
const raw = await model.predict(inputs);
|
|
552
|
+
const preds = Array.isArray(raw) ? raw : [raw];
|
|
553
|
+
return candidates.map((entry, i) => ({
|
|
554
|
+
entry,
|
|
555
|
+
relevance: clamp01(extractPrediction(preds[i])),
|
|
556
|
+
}));
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
// fall through to heuristic
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// Heuristic mode (always available)
|
|
563
|
+
return candidates.map((entry, i) => {
|
|
564
|
+
const f = features[i];
|
|
565
|
+
const recency = Math.exp(-f.ageDays / 14);
|
|
566
|
+
const cosTerm = (f.cosine + 1) / 2; // map [-1, 1] -> [0, 1]
|
|
567
|
+
const score = 0.6 * cosTerm + 0.25 * recency + 0.15 * entry.importance;
|
|
568
|
+
return { entry, relevance: clamp01(score) };
|
|
569
|
+
});
|
|
570
|
+
},
|
|
571
|
+
async trainRelevanceModel(feedback) {
|
|
572
|
+
if (!Array.isArray(feedback) || feedback.length < MIN_TRAINING_SAMPLES) {
|
|
573
|
+
throw new Error(`memory-synapcores.trainRelevanceModel: need at least ${MIN_TRAINING_SAMPLES} samples to train a relevance model (got ${Array.isArray(feedback) ? feedback.length : 0})`);
|
|
574
|
+
}
|
|
575
|
+
// Gateway v1.6.5.2-ce explicitly rejects `config.inline_rows` on
|
|
576
|
+
// /v1/automl/train (HTTP 400 "config.inline_rows is not supported
|
|
577
|
+
// in this version. Stage the rows in a collection first."). The
|
|
578
|
+
// 0.1.0 release keeps the public method signature so callers can
|
|
579
|
+
// wire feedback collection now and have it light up the moment the
|
|
580
|
+
// 0.2.0 line ships the staged-collection workflow.
|
|
581
|
+
//
|
|
582
|
+
// 0.2.0 plan: stage `feedback` into a sibling collection
|
|
583
|
+
// (`openclaw_memory_relevance_training[_<workspace>]`) and call
|
|
584
|
+
// `automl.train({ collection, target, features, task: "regression" })`
|
|
585
|
+
// pointing at it, then prune the staged rows on success.
|
|
586
|
+
void feedback;
|
|
587
|
+
void collectionName;
|
|
588
|
+
void modelName;
|
|
589
|
+
throw new Error("memory-synapcores.trainRelevanceModel: signature-only in 0.1.0. " +
|
|
590
|
+
"Gateway v1.6.5.x rejects inline training rows; full implementation ships in 0.2.0 " +
|
|
591
|
+
"(stages rows in a sibling collection before calling /v1/automl/train). " +
|
|
592
|
+
"predictRelevance continues to work in heuristic mode in the meantime.");
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function clamp01(n) {
|
|
597
|
+
if (!Number.isFinite(n))
|
|
598
|
+
return 0;
|
|
599
|
+
if (n < 0)
|
|
600
|
+
return 0;
|
|
601
|
+
if (n > 1)
|
|
602
|
+
return 1;
|
|
603
|
+
return n;
|
|
604
|
+
}
|
|
605
|
+
function extractPrediction(raw) {
|
|
606
|
+
if (typeof raw === "number")
|
|
607
|
+
return raw;
|
|
608
|
+
if (raw && typeof raw === "object") {
|
|
609
|
+
const r = raw;
|
|
610
|
+
if (typeof r.relevance === "number")
|
|
611
|
+
return r.relevance;
|
|
612
|
+
if (typeof r.prediction === "number")
|
|
613
|
+
return r.prediction;
|
|
614
|
+
if (typeof r.score === "number")
|
|
615
|
+
return r.score;
|
|
616
|
+
if (typeof r.value === "number")
|
|
617
|
+
return r.value;
|
|
618
|
+
}
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
// ============================================================================
|
|
622
|
+
// Plugin Definition
|
|
623
|
+
// ============================================================================
|
|
624
|
+
const memoryPlugin = {
|
|
625
|
+
id: "memory-synapcores",
|
|
626
|
+
name: "Memory (SynapCores)",
|
|
627
|
+
description: "SynapCores-backed long-term memory with auto-recall/capture, SQL filtering, graph relations, and AutoML relevance",
|
|
628
|
+
kind: "memory",
|
|
629
|
+
configSchema: memoryConfigSchema,
|
|
630
|
+
register(api) {
|
|
631
|
+
const cfg = memoryConfigSchema.parse(api.pluginConfig);
|
|
632
|
+
const vectorDim = vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small");
|
|
633
|
+
const collectionName = cfg.collection ?? DEFAULT_COLLECTION;
|
|
634
|
+
// Auth-header shim:
|
|
635
|
+
// @synapcores/sdk@0.3.0 sends `aidb_*` / `ak_*` keys via the `X-API-Key`
|
|
636
|
+
// header when constructed with `{ apiKey }`. Gateway v1.6.5.2-ce only
|
|
637
|
+
// honours `Authorization: Bearer aidb_*` (or `Authorization: ApiKey ...`)
|
|
638
|
+
// and will reject `X-API-Key` with HTTP 401 "missing_authorization".
|
|
639
|
+
//
|
|
640
|
+
// The SDK *does* route `{ jwtToken }` through `Authorization: Bearer`,
|
|
641
|
+
// and the gateway accepts an `aidb_*` value in that header (it tries JWT
|
|
642
|
+
// validation first, then falls back to api-key lookup on failure). So we
|
|
643
|
+
// route any `aidb_*` / `ak_*` key supplied as `apiKey` through `jwtToken`
|
|
644
|
+
// here. End-users keep writing `synapcores.apiKey` in their config — the
|
|
645
|
+
// plugin handles the header difference internally. When the SDK ships a
|
|
646
|
+
// version that uses Bearer for api keys, this branch becomes a no-op.
|
|
647
|
+
const rawKey = cfg.synapcores.apiKey;
|
|
648
|
+
const sdkConfig = {
|
|
649
|
+
host: cfg.synapcores.host,
|
|
650
|
+
port: cfg.synapcores.port,
|
|
651
|
+
useHttps: cfg.synapcores.useHttps,
|
|
652
|
+
};
|
|
653
|
+
if (rawKey.startsWith("aidb_") || rawKey.startsWith("ak_")) {
|
|
654
|
+
sdkConfig.jwtToken = rawKey;
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
sdkConfig.apiKey = rawKey;
|
|
658
|
+
}
|
|
659
|
+
const client = new SynapCores(sdkConfig);
|
|
660
|
+
const db = new MemoryDB(client, collectionName, vectorDim);
|
|
661
|
+
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model);
|
|
662
|
+
const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace, collectionName);
|
|
663
|
+
const autoLinkSimilar = cfg.autoLinkSimilar !== false;
|
|
664
|
+
const graphName = cfg.graph;
|
|
665
|
+
api.logger.info(`memory-synapcores: plugin registered (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, collection: ${collectionName}, autoLinkSimilar: ${autoLinkSimilar}, lazy init)`);
|
|
666
|
+
// ========================================================================
|
|
667
|
+
// Tools
|
|
668
|
+
// ========================================================================
|
|
669
|
+
api.registerTool({
|
|
670
|
+
name: "memory_recall",
|
|
671
|
+
label: "Memory Recall",
|
|
672
|
+
description: "Search through long-term memories. Use when you need context about user preferences, past decisions, or previously discussed topics.",
|
|
673
|
+
parameters: Type.Object({
|
|
674
|
+
query: Type.String({ description: "Search query" }),
|
|
675
|
+
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
|
|
676
|
+
}),
|
|
677
|
+
async execute(_toolCallId, params) {
|
|
678
|
+
const { query, limit = 5 } = params;
|
|
679
|
+
const vector = await embeddings.embed(query);
|
|
680
|
+
const results = await db.search(vector, limit, 0.1);
|
|
681
|
+
if (results.length === 0) {
|
|
682
|
+
return {
|
|
683
|
+
content: [{ type: "text", text: "No relevant memories found." }],
|
|
684
|
+
details: { count: 0 },
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
const text = results
|
|
688
|
+
.map((r, i) => `${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`)
|
|
689
|
+
.join("\n");
|
|
690
|
+
// Strip vector data for serialization (typed arrays can't be cloned)
|
|
691
|
+
const sanitizedResults = results.map((r) => ({
|
|
692
|
+
id: r.entry.id,
|
|
693
|
+
text: r.entry.text,
|
|
694
|
+
category: r.entry.category,
|
|
695
|
+
importance: r.entry.importance,
|
|
696
|
+
score: r.score,
|
|
697
|
+
}));
|
|
698
|
+
return {
|
|
699
|
+
content: [{ type: "text", text: `Found ${results.length} memories:\n\n${text}` }],
|
|
700
|
+
details: { count: results.length, memories: sanitizedResults },
|
|
701
|
+
};
|
|
702
|
+
},
|
|
703
|
+
}, { name: "memory_recall" });
|
|
704
|
+
api.registerTool({
|
|
705
|
+
name: "memory_store",
|
|
706
|
+
label: "Memory Store",
|
|
707
|
+
description: "Save important information in long-term memory. Use for preferences, facts, decisions.",
|
|
708
|
+
parameters: Type.Object({
|
|
709
|
+
text: Type.String({ description: "Information to remember" }),
|
|
710
|
+
importance: Type.Optional(Type.Number({ description: "Importance 0-1 (default: 0.7)" })),
|
|
711
|
+
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
|
|
712
|
+
}),
|
|
713
|
+
async execute(_toolCallId, params) {
|
|
714
|
+
const { text, importance = 0.7, category = "other", } = params;
|
|
715
|
+
const vector = await embeddings.embed(text);
|
|
716
|
+
// Check for duplicates
|
|
717
|
+
const existing = await db.search(vector, 1, 0.95);
|
|
718
|
+
if (existing.length > 0) {
|
|
719
|
+
return {
|
|
720
|
+
content: [
|
|
721
|
+
{
|
|
722
|
+
type: "text",
|
|
723
|
+
text: `Similar memory already exists: "${existing[0].entry.text}"`,
|
|
724
|
+
},
|
|
725
|
+
],
|
|
726
|
+
details: {
|
|
727
|
+
action: "duplicate",
|
|
728
|
+
existingId: existing[0].entry.id,
|
|
729
|
+
existingText: existing[0].entry.text,
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const entry = await db.store({
|
|
734
|
+
text,
|
|
735
|
+
vector,
|
|
736
|
+
importance,
|
|
737
|
+
category,
|
|
738
|
+
});
|
|
739
|
+
// Best-effort auto-link to similar memories so `recallRelated`
|
|
740
|
+
// returns useful neighborhoods. Failures are swallowed and logged.
|
|
741
|
+
if (autoLinkSimilar) {
|
|
742
|
+
await linkSimilarMemories(entry, db, client, graphName, api.logger);
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }],
|
|
746
|
+
details: { action: "created", id: entry.id },
|
|
747
|
+
};
|
|
748
|
+
},
|
|
749
|
+
}, { name: "memory_store" });
|
|
750
|
+
api.registerTool({
|
|
751
|
+
name: "memory_forget",
|
|
752
|
+
label: "Memory Forget",
|
|
753
|
+
description: "Delete specific memories. GDPR-compliant.",
|
|
754
|
+
parameters: Type.Object({
|
|
755
|
+
query: Type.Optional(Type.String({ description: "Search to find memory" })),
|
|
756
|
+
memoryId: Type.Optional(Type.String({ description: "Specific memory ID" })),
|
|
757
|
+
}),
|
|
758
|
+
async execute(_toolCallId, params) {
|
|
759
|
+
const { query, memoryId } = params;
|
|
760
|
+
if (memoryId) {
|
|
761
|
+
await db.delete(memoryId);
|
|
762
|
+
return {
|
|
763
|
+
content: [{ type: "text", text: `Memory ${memoryId} forgotten.` }],
|
|
764
|
+
details: { action: "deleted", id: memoryId },
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (query) {
|
|
768
|
+
const vector = await embeddings.embed(query);
|
|
769
|
+
const results = await db.search(vector, 5, 0.7);
|
|
770
|
+
if (results.length === 0) {
|
|
771
|
+
return {
|
|
772
|
+
content: [{ type: "text", text: "No matching memories found." }],
|
|
773
|
+
details: { found: 0 },
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
if (results.length === 1 && results[0].score > 0.9) {
|
|
777
|
+
await db.delete(results[0].entry.id);
|
|
778
|
+
return {
|
|
779
|
+
content: [{ type: "text", text: `Forgotten: "${results[0].entry.text}"` }],
|
|
780
|
+
details: { action: "deleted", id: results[0].entry.id },
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
const list = results
|
|
784
|
+
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
|
|
785
|
+
.join("\n");
|
|
786
|
+
// Strip vector data for serialization
|
|
787
|
+
const sanitizedCandidates = results.map((r) => ({
|
|
788
|
+
id: r.entry.id,
|
|
789
|
+
text: r.entry.text,
|
|
790
|
+
category: r.entry.category,
|
|
791
|
+
score: r.score,
|
|
792
|
+
}));
|
|
793
|
+
return {
|
|
794
|
+
content: [
|
|
795
|
+
{
|
|
796
|
+
type: "text",
|
|
797
|
+
text: `Found ${results.length} candidates. Specify memoryId:\n${list}`,
|
|
798
|
+
},
|
|
799
|
+
],
|
|
800
|
+
details: { action: "candidates", candidates: sanitizedCandidates },
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
content: [{ type: "text", text: "Provide query or memoryId." }],
|
|
805
|
+
details: { error: "missing_param" },
|
|
806
|
+
};
|
|
807
|
+
},
|
|
808
|
+
}, { name: "memory_forget" });
|
|
809
|
+
// ========================================================================
|
|
810
|
+
// CLI Commands
|
|
811
|
+
// ========================================================================
|
|
812
|
+
api.registerCli(({ program }) => {
|
|
813
|
+
const memory = program.command("ltm").description("SynapCores memory plugin commands");
|
|
814
|
+
memory
|
|
815
|
+
.command("list")
|
|
816
|
+
.description("List memories")
|
|
817
|
+
.action(async () => {
|
|
818
|
+
const count = await db.count();
|
|
819
|
+
console.log(`Total memories: ${count}`);
|
|
820
|
+
});
|
|
821
|
+
memory
|
|
822
|
+
.command("search")
|
|
823
|
+
.description("Search memories")
|
|
824
|
+
.argument("<query>", "Search query")
|
|
825
|
+
.option("--limit <n>", "Max results", "5")
|
|
826
|
+
.action(async (query, opts) => {
|
|
827
|
+
const vector = await embeddings.embed(query);
|
|
828
|
+
const results = await db.search(vector, parseInt(opts.limit), 0.3);
|
|
829
|
+
// Strip vectors for output
|
|
830
|
+
const output = results.map((r) => ({
|
|
831
|
+
id: r.entry.id,
|
|
832
|
+
text: r.entry.text,
|
|
833
|
+
category: r.entry.category,
|
|
834
|
+
importance: r.entry.importance,
|
|
835
|
+
score: r.score,
|
|
836
|
+
}));
|
|
837
|
+
console.log(JSON.stringify(output, null, 2));
|
|
838
|
+
});
|
|
839
|
+
memory
|
|
840
|
+
.command("stats")
|
|
841
|
+
.description("Show memory statistics")
|
|
842
|
+
.action(async () => {
|
|
843
|
+
const count = await db.count();
|
|
844
|
+
console.log(`Total memories: ${count}`);
|
|
845
|
+
});
|
|
846
|
+
}, { commands: ["ltm"] });
|
|
847
|
+
// ========================================================================
|
|
848
|
+
// Lifecycle Hooks
|
|
849
|
+
// ========================================================================
|
|
850
|
+
// Auto-recall: inject relevant memories before agent starts
|
|
851
|
+
if (cfg.autoRecall) {
|
|
852
|
+
api.on("before_agent_start", async (event) => {
|
|
853
|
+
if (!event.prompt || event.prompt.length < 5) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
const vector = await embeddings.embed(event.prompt);
|
|
858
|
+
const results = await db.search(vector, 3, 0.3);
|
|
859
|
+
if (results.length === 0) {
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const memoryContext = results
|
|
863
|
+
.map((r) => `- [${r.entry.category}] ${r.entry.text}`)
|
|
864
|
+
.join("\n");
|
|
865
|
+
api.logger.info?.(`memory-synapcores: injecting ${results.length} memories into context`);
|
|
866
|
+
return {
|
|
867
|
+
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
catch (err) {
|
|
871
|
+
api.logger.warn(`memory-synapcores: recall failed: ${String(err)}`);
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
// Auto-capture: analyze and store important information after agent ends
|
|
876
|
+
if (cfg.autoCapture) {
|
|
877
|
+
api.on("agent_end", async (event) => {
|
|
878
|
+
if (!event.success || !event.messages || event.messages.length === 0) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
// Extract text content from messages (handling unknown[] type)
|
|
883
|
+
const texts = [];
|
|
884
|
+
for (const msg of event.messages) {
|
|
885
|
+
// Type guard for message object
|
|
886
|
+
if (!msg || typeof msg !== "object") {
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
const msgObj = msg;
|
|
890
|
+
// Only process user and assistant messages
|
|
891
|
+
const role = msgObj.role;
|
|
892
|
+
if (role !== "user" && role !== "assistant") {
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const content = msgObj.content;
|
|
896
|
+
// Handle string content directly
|
|
897
|
+
if (typeof content === "string") {
|
|
898
|
+
texts.push(content);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
// Handle array content (content blocks)
|
|
902
|
+
if (Array.isArray(content)) {
|
|
903
|
+
for (const block of content) {
|
|
904
|
+
if (block &&
|
|
905
|
+
typeof block === "object" &&
|
|
906
|
+
"type" in block &&
|
|
907
|
+
block.type === "text" &&
|
|
908
|
+
"text" in block &&
|
|
909
|
+
typeof block.text === "string") {
|
|
910
|
+
texts.push(block.text);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// Filter for capturable content
|
|
916
|
+
const toCapture = texts.filter((text) => text && shouldCapture(text));
|
|
917
|
+
if (toCapture.length === 0) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
// Store each capturable piece (limit to 3 per conversation)
|
|
921
|
+
let stored = 0;
|
|
922
|
+
for (const text of toCapture.slice(0, 3)) {
|
|
923
|
+
const category = detectCategory(text);
|
|
924
|
+
const vector = await embeddings.embed(text);
|
|
925
|
+
// Check for duplicates (high similarity threshold)
|
|
926
|
+
const existing = await db.search(vector, 1, 0.95);
|
|
927
|
+
if (existing.length > 0) {
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const entry = await db.store({
|
|
931
|
+
text,
|
|
932
|
+
vector,
|
|
933
|
+
importance: 0.7,
|
|
934
|
+
category,
|
|
935
|
+
});
|
|
936
|
+
if (autoLinkSimilar) {
|
|
937
|
+
await linkSimilarMemories(entry, db, client, graphName, api.logger);
|
|
938
|
+
}
|
|
939
|
+
stored++;
|
|
940
|
+
}
|
|
941
|
+
if (stored > 0) {
|
|
942
|
+
api.logger.info(`memory-synapcores: auto-captured ${stored} memories`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
api.logger.warn(`memory-synapcores: capture failed: ${String(err)}`);
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
// ========================================================================
|
|
951
|
+
// Service
|
|
952
|
+
// ========================================================================
|
|
953
|
+
api.registerService({
|
|
954
|
+
id: "memory-synapcores",
|
|
955
|
+
start: () => {
|
|
956
|
+
api.logger.info(`memory-synapcores: initialized (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, collection: ${collectionName}, model: ${cfg.embedding.model})`);
|
|
957
|
+
},
|
|
958
|
+
stop: () => {
|
|
959
|
+
api.logger.info("memory-synapcores: stopped");
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
// ========================================================================
|
|
963
|
+
// SynapCores-only extensions
|
|
964
|
+
// ========================================================================
|
|
965
|
+
// Expose the extension surface on the plugin instance so callers can
|
|
966
|
+
// reach `recallFiltered` / `recallRelated` / `predictRelevance` /
|
|
967
|
+
// `trainRelevanceModel` via the OpenClaw plugin registry
|
|
968
|
+
// (e.g. `plugin.extensions.recallFiltered`). We attach via Object.assign
|
|
969
|
+
// rather than a top-level field on the plugin definition because
|
|
970
|
+
// `register()` is what produces the live backend; the extensions need
|
|
971
|
+
// access to the instantiated client + db.
|
|
972
|
+
memoryPlugin.extensions =
|
|
973
|
+
extensions;
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
export default memoryPlugin;
|
|
977
|
+
//# sourceMappingURL=index.js.map
|