@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/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