@synapcores/openclaw-memory 0.1.0 → 0.3.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 CHANGED
@@ -9,55 +9,135 @@
9
9
  * `recallRelated`, and `predictRelevance`.
10
10
  *
11
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.
12
+ * @openclaw/memory-lancedb. Verified end-to-end against SynapCores
13
+ * gateway v1.6.5.2-ce. Requires @synapcores/sdk@^0.4.0 which added
14
+ * `client.vectorCollection(name)` + `client.createVectorCollection(...)`
15
+ * and switched API-key auth to `Authorization: Bearer`.
15
16
  */
16
17
  import { Type } from "typebox";
17
18
  import { randomUUID } from "node:crypto";
18
19
  import OpenAI from "openai";
19
- import { stringEnum } from "openclaw/plugin-sdk";
20
+ import { stringEnum } from "openclaw/plugin-sdk/core";
21
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
20
22
  import { SynapCores } from "@synapcores/sdk";
21
23
  import { MEMORY_CATEGORIES, memoryConfigSchema, vectorDimsForModel, } from "./config.js";
22
24
  // ============================================================================
23
25
  // SynapCores Provider
24
26
  // ============================================================================
25
27
  const DEFAULT_COLLECTION = "openclaw_memories";
26
- function getSdkHttp(client) {
27
- return client._getHttpClient();
28
+ /**
29
+ * MemoryDB — thin wrapper around `@synapcores/sdk@^0.4.0`'s
30
+ * `VectorCollection` handle. v0.2.0 dropped the direct
31
+ * `_getHttpClient().post('/vectors/collections/...')` workaround that
32
+ * v0.1.0 needed against SDK 0.3.x — SDK 0.4.0 ships `createVectorCollection`
33
+ * and `vectorCollection(name)` that target `/v1/vectors/collections/...`
34
+ * directly, so the plugin no longer has to bypass the SDK.
35
+ */
36
+ // ============================================================================
37
+ // Connection preflight
38
+ //
39
+ // This plugin is a CLIENT — it never installs or starts a SynapCores database.
40
+ // The most common first-run failure is "installed the plugin, but the gateway
41
+ // isn't running", which otherwise surfaces as a raw ECONNREFUSED. Preflight
42
+ // turns that into an actionable message pointing at the installer + admin UI.
43
+ // ============================================================================
44
+ const PREFLIGHT_INSTALL_HINT = "This plugin needs a running SynapCores gateway — it does not install one. " +
45
+ "Install + start the free Community Edition:\n" +
46
+ " curl -fsSL https://synapcores.com/install.sh | sh\n" +
47
+ " synapcores start\n" +
48
+ "Docs: https://synapcores.com/install";
49
+ function httpStatusOf(err) {
50
+ const e = err;
51
+ return e?.status ?? e?.statusCode ?? e?.response?.status;
52
+ }
53
+ // Raw transport-error fallback (used if the error isn't one of the SDK's typed
54
+ // classes — e.g. a bare fetch/undici error).
55
+ function isRawConnError(err) {
56
+ const code = String(err?.code ?? "");
57
+ const msg = String(err?.message ?? err ?? "");
58
+ return ([
59
+ "ECONNREFUSED",
60
+ "ENOTFOUND",
61
+ "ETIMEDOUT",
62
+ "ECONNRESET",
63
+ "EAI_AGAIN",
64
+ "EHOSTUNREACH",
65
+ "EHOSTDOWN",
66
+ ].includes(code) ||
67
+ /ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|getaddrinfo|fetch failed|network error|socket hang up|Failed to connect to SynapCores|Connection refused/i.test(msg));
28
68
  }
29
69
  /**
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.
70
+ * Classify a gateway error. The @synapcores/sdk normalises failures into typed
71
+ * errors that carry a stable `.code` ("CONNECTION_ERROR" / "AUTH_ERROR") and a
72
+ * `.name` ("ConnectionError" / "AuthenticationError" / "TimeoutError"); we key
73
+ * off those first, then fall back to raw transport heuristics + HTTP status.
32
74
  */
33
- function unwrapEnvelope(raw) {
34
- if (raw && typeof raw === "object" && "data" in raw) {
35
- return raw.data;
75
+ function classifyGatewayError(err) {
76
+ const code = String(err?.code ?? "");
77
+ const name = String(err?.name ?? "");
78
+ const status = httpStatusOf(err);
79
+ if (code === "CONNECTION_ERROR" ||
80
+ code === "TIMEOUT" ||
81
+ name === "ConnectionError" ||
82
+ name === "TimeoutError" ||
83
+ isRawConnError(err)) {
84
+ return "connection";
85
+ }
86
+ if (code === "AUTH_ERROR" || name === "AuthenticationError" || status === 401 || status === 403) {
87
+ return "auth";
88
+ }
89
+ return "other";
90
+ }
91
+ /**
92
+ * One lightweight round-trip to confirm the gateway is reachable AND the API
93
+ * key is accepted, before any memory operation runs. Throws an Error with an
94
+ * actionable message on failure; resolves on success. Uses
95
+ * `listVectorCollections()` (GET /v1/vectors/collections) — the same call
96
+ * MemoryDB makes first, and a known-good CE endpoint.
97
+ */
98
+ async function preflightGateway(client) {
99
+ let host = "localhost";
100
+ let port = 8080;
101
+ let scheme = "http";
102
+ try {
103
+ const cfg = client._getConfig();
104
+ host = cfg.host ?? host;
105
+ port = cfg.port ?? port;
106
+ scheme = cfg.useHttps ? "https" : "http";
107
+ }
108
+ catch {
109
+ // _getConfig is best-effort — only used to make the message specific.
110
+ }
111
+ const url = `${scheme}://${host}:${port}`;
112
+ try {
113
+ await client.listVectorCollections();
114
+ }
115
+ catch (err) {
116
+ const kind = classifyGatewayError(err);
117
+ if (kind === "connection") {
118
+ throw new Error(`Cannot reach the SynapCores gateway at ${url}. ${PREFLIGHT_INSTALL_HINT}`);
119
+ }
120
+ if (kind === "auth") {
121
+ throw new Error(`The SynapCores gateway at ${url} rejected the API key. ` +
122
+ `Create a FullAccess key in the admin UI (http://${host}:8095) and set ` +
123
+ `synapcores.apiKey (or the SYNAPCORES_API_KEY env var).`);
124
+ }
125
+ throw new Error(`SynapCores preflight against ${url} failed: ${String(err?.message ?? err)}`);
36
126
  }
37
- return raw;
38
127
  }
39
128
  class MemoryDB {
40
129
  collectionName;
41
130
  vectorDim;
42
131
  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;
132
+ vectorCollection = null;
51
133
  initPromise = null;
52
- http;
53
134
  constructor(client, collectionName, vectorDim) {
54
135
  this.collectionName = collectionName;
55
136
  this.vectorDim = vectorDim;
56
137
  this.client = client;
57
- this.http = getSdkHttp(client);
58
138
  }
59
139
  async ensureInitialized() {
60
- if (this.collection) {
140
+ if (this.vectorCollection) {
61
141
  return;
62
142
  }
63
143
  if (this.initPromise) {
@@ -67,39 +147,37 @@ class MemoryDB {
67
147
  return this.initPromise;
68
148
  }
69
149
  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.
150
+ // Fail fast with an actionable message if the gateway is down or the key
151
+ // is wrong, before the collection probe muddies the error.
152
+ await preflightGateway(this.client);
153
+ // Probe for an existing vector collection (idempotent). The SDK's
154
+ // `listVectorCollections()` calls GET /v1/vectors/collections and
155
+ // returns the bare array; we fall through to create() on any error.
78
156
  let exists = false;
79
157
  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);
158
+ const items = await this.client.listVectorCollections();
159
+ if (Array.isArray(items)) {
160
+ exists = items.some((it) => it?.name === this.collectionName);
161
+ }
84
162
  }
85
163
  catch {
86
164
  // best-effort; fall through and try to create
87
165
  }
88
166
  if (!exists) {
89
167
  try {
90
- await this.http.post("/vectors/collections", {
168
+ this.vectorCollection = await this.client.createVectorCollection({
91
169
  name: this.collectionName,
92
170
  dimensions: this.vectorDim,
93
171
  distance_metric: "cosine",
94
172
  });
173
+ return;
95
174
  }
96
175
  catch (err) {
97
176
  // Race with a concurrent creator — re-check before giving up.
98
177
  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)) {
178
+ const items = await this.client.listVectorCollections();
179
+ if (!Array.isArray(items) ||
180
+ !items.some((it) => it?.name === this.collectionName)) {
103
181
  throw err;
104
182
  }
105
183
  }
@@ -108,18 +186,8 @@ class MemoryDB {
108
186
  }
109
187
  }
110
188
  }
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
- }
189
+ // Use the synchronous accessor no extra round-trip.
190
+ this.vectorCollection = this.client.vectorCollection(this.collectionName);
123
191
  }
124
192
  async store(entry) {
125
193
  await this.ensureInitialized();
@@ -128,61 +196,46 @@ class MemoryDB {
128
196
  id: randomUUID(),
129
197
  createdAt: Date.now(),
130
198
  };
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
- ],
199
+ // v0.2.0: SDK ships VectorCollection.insert(...) which posts to
200
+ // /v1/vectors/collections/{name}/vectors with the {vectors: [...]}
201
+ // envelope automatically. v0.1.0 had a direct _getHttpClient().post()
202
+ // workaround here deleted now that SDK 0.4.0 covers the wire.
203
+ await this.vectorCollection.insert({
204
+ id: fullEntry.id,
205
+ values: fullEntry.vector,
206
+ metadata: {
207
+ text: fullEntry.text,
208
+ importance: fullEntry.importance,
209
+ category: fullEntry.category,
210
+ createdAt: fullEntry.createdAt,
211
+ },
148
212
  });
149
213
  return fullEntry;
150
214
  }
151
215
  async search(vector, limit = 5, minScore = 0.5) {
152
216
  await this.ensureInitialized();
153
- const result = await this.collection.vectorSearch({
217
+ const hits = await this.vectorCollection.search({
154
218
  vector,
155
- field: "embedding",
156
- topK: limit,
157
- distanceMetric: "cosine",
219
+ k: limit,
158
220
  includeMetadata: true,
159
221
  });
160
- const documents = (result.documents ?? []);
161
- return documents.map(parseDocumentToResult).filter((r) => r.score >= minScore);
222
+ return hits.map(parseHitToResult).filter((r) => r.score >= minScore);
162
223
  }
163
224
  /**
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.
225
+ * Same shape as `search`, but accepts a SQL `WHERE` clause. The SDK
226
+ * forwards `filter` as-is to the gateway's `/v1/vectors/collections/{n}/search`
227
+ * endpoint, which accepts either a JSON match object or `{ sql: "..." }`.
228
+ * Used by the `recallFiltered` extension method.
167
229
  */
168
230
  async searchFiltered(vector, where, limit = 5) {
169
231
  await this.ensureInitialized();
170
- const result = await this.collection.vectorSearch({
232
+ const hits = await this.vectorCollection.search({
171
233
  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",
234
+ k: limit,
182
235
  includeMetadata: true,
236
+ filter: { sql: where },
183
237
  });
184
- const documents = (result.documents ?? []);
185
- return documents.map(parseDocumentToResult);
238
+ return hits.map(parseHitToResult);
186
239
  }
187
240
  async delete(id) {
188
241
  await this.ensureInitialized();
@@ -191,25 +244,31 @@ class MemoryDB {
191
244
  if (!uuidRegex.test(id)) {
192
245
  throw new Error(`Invalid memory ID format: ${id}`);
193
246
  }
194
- // Vector delete wire: DELETE /v1/vectors/collections/{name}/vectors/{id}
195
- await this.http.delete(`/vectors/collections/${encodeURIComponent(this.collectionName)}/vectors/${encodeURIComponent(id)}`);
247
+ // v0.2.0: SDK 0.4.0's VectorCollection.delete(id) hits
248
+ // DELETE /v1/vectors/collections/{name}/vectors/{id}.
249
+ await this.vectorCollection.delete(id);
196
250
  return true;
197
251
  }
198
252
  async count() {
199
253
  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;
254
+ // v0.2.0: SDK 0.4.0's VectorCollection.count() probes /count first,
255
+ // falls back to info().vector_count. Either way, returns a number.
256
+ try {
257
+ return await this.vectorCollection.count();
258
+ }
259
+ catch {
260
+ return 0;
261
+ }
205
262
  }
206
263
  /** Fetch a single memory by ID (returns null if not found). */
207
264
  async get(id) {
208
265
  await this.ensureInitialized();
209
- // Vector get wire: GET /v1/vectors/collections/{name}/vectors/{id}
210
- let raw;
266
+ // v0.2.0: SDK 0.4.0's VectorCollection.get(id) returns
267
+ // { id, values, metadata } | null. v0.1.0 had a direct
268
+ // _getHttpClient().get() workaround here — deleted.
269
+ let hit = null;
211
270
  try {
212
- raw = (await this.http.get(`/vectors/collections/${encodeURIComponent(this.collectionName)}/vectors/${encodeURIComponent(id)}`)).data;
271
+ hit = await this.vectorCollection.get(id);
213
272
  }
214
273
  catch (err) {
215
274
  const e = err;
@@ -217,73 +276,48 @@ class MemoryDB {
217
276
  return null;
218
277
  throw err;
219
278
  }
220
- if (!raw)
221
- return null;
222
- const vec = unwrapEnvelope(raw);
223
- if (!vec)
279
+ if (!hit)
224
280
  return null;
225
- // Gateway response shape: { id, values: [...], metadata: { ... } }
226
- const meta = vec.metadata ?? {};
281
+ const meta = hit.metadata ?? {};
227
282
  return {
228
- id: String(vec.id ?? id),
283
+ id: String(hit.id ?? id),
229
284
  text: typeof meta.text === "string" ? meta.text : "",
230
- vector: Array.isArray(vec.values) ? vec.values : [],
285
+ vector: Array.isArray(hit.values) ? hit.values : [],
231
286
  importance: typeof meta.importance === "number" ? meta.importance : 0,
232
287
  category: meta.category ?? "other",
233
288
  createdAt: typeof meta.createdAt === "number" ? meta.createdAt : 0,
234
289
  };
235
290
  }
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
291
  }
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);
292
+ function hitToEntry(hit) {
293
+ // SDK 0.4.0 VectorCollection.search returns the gateway's hit shape:
294
+ // { id, score, values?, metadata: { text, importance, category, createdAt } }
295
+ const meta = hit.metadata ?? {};
296
+ const text = typeof meta.text === "string" ? meta.text : "";
297
+ const importance = typeof meta.importance === "number" ? meta.importance : 0;
298
+ const category = meta.category ?? "other";
299
+ const createdAt = typeof meta.createdAt === "number" ? meta.createdAt : 0;
300
+ const vector = Array.isArray(hit.values) ? hit.values : [];
264
301
  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,
302
+ id: String(hit.id ?? ""),
303
+ text,
304
+ vector,
305
+ importance,
306
+ category,
307
+ createdAt,
271
308
  };
272
309
  }
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;
310
+ function parseHitToResult(hit) {
311
+ // Gateway v1.6.5.2-ce returns cosine **distance** as `score` (lower =
312
+ // closer; 0 = identical, 1 = orthogonal, 2 = opposite). Convert to a
313
+ // [0, 1] similarity for the public API. If a separate `distance` field
314
+ // ever appears we honour it for parity.
315
+ const rawScore = typeof hit.score === "number" ? hit.score : undefined;
316
+ const rawDistance = typeof hit.distance === "number" ? hit.distance : undefined;
280
317
  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
318
  const similarity = Math.max(0, Math.min(1, 1 - distance));
285
319
  return {
286
- entry: docToEntry(doc),
320
+ entry: hitToEntry(hit),
287
321
  score: similarity,
288
322
  };
289
323
  }
@@ -437,52 +471,89 @@ export function escapeCypherString(value) {
437
471
  return String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
438
472
  }
439
473
  // ============================================================================
440
- // Auto-link similar memories (capture-time graph edge creation)
474
+ // Graph wiring populate Memory nodes so SIMILAR_TO resolves at MATCH time
441
475
  // ============================================================================
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
476
+ // Default cosine-similarity threshold for synthetic SIMILAR_TO edges.
477
+ // Used as the operand in `[:SIMILAR_TO > THRESHOLD]` Cypher fragments.
478
+ const SIMILAR_TO_THRESHOLD = 0.5;
479
+ // Max hops for recallRelated walks — caps runaway traversals.
480
+ const MAX_HOPS = 4;
481
+ // Default result cap for recallRelated.
482
+ const DEFAULT_RECALL_RELATED_LIMIT = 20;
447
483
  /**
448
- * `linkSimilarMemories` is a no-op against gateway v1.6.5.x.
484
+ * `linkSimilarMemories` capture-time graph wiring (v0.2.0).
449
485
  *
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:
486
+ * v0.1.0 was a no-op: it tried to write explicit `SIMILAR_TO` edges via
487
+ * Cypher `MERGE`, which gateway v1.6.5.x rejects because SIMILAR_TO is a
488
+ * synthetic / derived edge type computed from the graph backend's vector
489
+ * index at MATCH time.
454
490
  *
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)
491
+ * v0.2.0 takes the supported path instead: insert the Memory as a graph
492
+ * node carrying the embedding under the property name `embedding` the
493
+ * field the gateway's brute-force vector index is wired against (see
494
+ * `aidb_gateway::routes::graph::attach_default_vector_index`). Once a
495
+ * Memory node exists, `recallRelated` can MATCH `[:SIMILAR_TO > T]`
496
+ * against it and get neighbors back without any pre-stored edges.
458
497
  *
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.
498
+ * Failures here are non-fatal the capture itself still succeeded in the
499
+ * vector subsystem; we just log and move on so recall continues to work.
468
500
  */
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;
501
+ async function linkSimilarMemories(entry, _db, client, _graphName, logger) {
502
+ try {
503
+ // Insert a Memory node with the embedding so the synthetic
504
+ // SIMILAR_TO edge in `recallRelated` has something to match against.
505
+ // The `id` property mirrors the vector-collection id so callers can
506
+ // join the two views.
507
+ //
508
+ // KNOWN SDK GAP (@synapcores/sdk@0.4.0):
509
+ // `client.graph.nodes.create(label, props)` posts
510
+ // `{label: <single>, properties}` but the gateway's
511
+ // /v1/graph/nodes handler expects `{labels: <array>, properties}`
512
+ // (see aidb_gateway::routes::graph::CreateNodeRequest). The result
513
+ // is a node with `labels: []`, which never matches the `Memory`
514
+ // label filter in MATCH. We bypass the SDK helper for THIS one
515
+ // call and post the correct wire shape ourselves. Once SDK >0.4.0
516
+ // fixes `GraphNodeApi.create` to send `labels`, this `_getHttpClient`
517
+ // call can be replaced with `client.graph.nodes.create(...)`.
518
+ const http = client._getHttpClient();
519
+ await http.post("/graph/nodes", {
520
+ labels: ["Memory"],
521
+ properties: {
522
+ id: entry.id,
523
+ text: entry.text,
524
+ embedding: entry.vector,
525
+ importance: entry.importance,
526
+ category: entry.category,
527
+ createdAt: entry.createdAt,
528
+ },
529
+ });
530
+ return 1;
531
+ }
532
+ catch (err) {
533
+ const msg = err?.message ?? String(err);
534
+ logger?.warn?.(`memory-synapcores: failed to insert Memory graph node for ${entry.id}: ${msg} (capture still succeeded; recallRelated for this entry will return [])`);
535
+ return 0;
536
+ }
475
537
  }
476
538
  // ============================================================================
477
- // SynapCores Extensions
539
+ // AutoML helpers — staged-collection training (v0.2.0)
478
540
  // ============================================================================
479
541
  const DEFAULT_RELEVANCE_MODEL = "openclaw_memory_relevance";
480
542
  const MIN_TRAINING_SAMPLES = 10;
543
+ const TRAINING_TABLE_PREFIX = "openclaw_memory_relevance_training";
481
544
  function relevanceModelName(workspace) {
482
545
  return workspace ? `${DEFAULT_RELEVANCE_MODEL}_${workspace}` : DEFAULT_RELEVANCE_MODEL;
483
546
  }
484
- function createExtensions(db, embeddings, client, graphName, workspace, collectionName = DEFAULT_COLLECTION) {
547
+ function trainingTableName(workspace) {
548
+ // SQL identifier — caller-controlled workspace must be alphanumeric.
549
+ // Gateway rejects non-alphanumeric `collection` values anyway, so the
550
+ // sanitisation here is belt-and-braces.
551
+ const safe = workspace ? workspace.replace(/[^a-zA-Z0-9_]/g, "_") : "";
552
+ return safe ? `${TRAINING_TABLE_PREFIX}_${safe}` : TRAINING_TABLE_PREFIX;
553
+ }
554
+ function createExtensions(db, embeddings, client, _graphName, workspace, _collectionName = DEFAULT_COLLECTION) {
485
555
  const modelName = relevanceModelName(workspace);
556
+ const stagingTable = trainingTableName(workspace);
486
557
  async function modelExists(name) {
487
558
  try {
488
559
  const models = await client.automl.listModels();
@@ -496,45 +567,99 @@ function createExtensions(db, embeddings, client, graphName, workspace, collecti
496
567
  async recallFiltered(options) {
497
568
  const limit = options.limit ?? 5;
498
569
  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
570
  return db.searchFiltered(vector, options.where, limit);
502
571
  },
503
572
  async recallRelated(memoryId, options = {}) {
504
- // 0.1.0 fallback strategy:
573
+ // v0.2.0 implementation:
505
574
  //
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.
575
+ // Gateway v1.6.5.x derives `SIMILAR_TO` synthetically at MATCH
576
+ // time from the graph backend's vector index on the `embedding`
577
+ // property. `linkSimilarMemories` populates that index on every
578
+ // capture by posting a `Memory` graph node carrying the embedding.
579
+ // With nodes in place, this method composes a Cypher MATCH that
580
+ // walks SIMILAR_TO (plus any non-synthetic edges the caller named
581
+ // via `edgeKinds`) and returns the neighborhood.
516
582
  //
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.");
583
+ // Wire constraints:
584
+ // - Multi-hop `[:SIMILAR_TO*1..N]` is rejected SIMILAR_TO is
585
+ // single-hop only. We honour `hops` for non-synthetic edges
586
+ // and special-case hops=1 for SIMILAR_TO.
587
+ // - `$param` bindings are rejected values are inlined and
588
+ // escaped via `escapeCypherString`.
589
+ const hops = Math.min(Math.max(1, options.hops ?? 1), MAX_HOPS);
590
+ const limit = options.limit ?? DEFAULT_RECALL_RELATED_LIMIT;
591
+ const threshold = options.similarityThreshold ?? SIMILAR_TO_THRESHOLD;
592
+ const edgeKinds = options.edgeKinds && options.edgeKinds.length > 0
593
+ ? options.edgeKinds
594
+ : ["SIMILAR_TO"];
595
+ const id = escapeCypherString(memoryId);
596
+ const out = [];
597
+ const seen = new Set();
598
+ for (const kind of edgeKinds) {
599
+ const safeKind = String(kind).replace(/[^A-Z_]/g, "");
600
+ if (!safeKind)
601
+ continue;
602
+ let cypher;
603
+ if (safeKind === "SIMILAR_TO") {
604
+ // Synthetic edge — single-hop only, threshold inlined.
605
+ // Use undirected pattern so we surface neighbors regardless
606
+ // of insertion order (the gateway computes similarity
607
+ // symmetrically anyway).
608
+ cypher = `MATCH (start:Memory {id: '${id}'})-[:SIMILAR_TO > ${threshold}]-(related:Memory) RETURN DISTINCT related LIMIT ${limit}`;
609
+ }
610
+ else {
611
+ // Non-synthetic edge — variable-length supported.
612
+ cypher = `MATCH (start:Memory {id: '${id}'})-[:${safeKind}*1..${hops}]-(related:Memory) RETURN DISTINCT related LIMIT ${limit}`;
613
+ }
614
+ let result;
615
+ try {
616
+ result = await client.graph.cypher(cypher);
617
+ }
618
+ catch (err) {
619
+ const msg = err?.message ?? String(err);
620
+ throw new Error(`memory-synapcores.recallRelated: graph query failed for edge kind '${safeKind}': ${msg}`);
621
+ }
622
+ // The SDK normalises responses to { columns, rows, records }.
623
+ // Prefer `records` (column-keyed) and fall back to `rows`.
624
+ const records = Array.isArray(result?.records) ? result.records : [];
625
+ const rows = Array.isArray(result?.rows) ? result.rows : [];
626
+ const harvest = (node) => {
627
+ if (!node || typeof node !== "object")
628
+ return;
629
+ const n = node;
630
+ const props = (n.properties ?? {});
631
+ const entryId = String(props.id ?? n.id ?? "");
632
+ if (!entryId || entryId === memoryId)
633
+ return;
634
+ if (seen.has(entryId))
635
+ return;
636
+ seen.add(entryId);
637
+ out.push({
638
+ entry: {
639
+ id: entryId,
640
+ text: typeof props.text === "string" ? props.text : "",
641
+ vector: Array.isArray(props.embedding) ? props.embedding : [],
642
+ importance: typeof props.importance === "number" ? props.importance : 0,
643
+ category: props.category ?? "other",
644
+ createdAt: typeof props.createdAt === "number" ? props.createdAt : 0,
645
+ },
646
+ hops: safeKind === "SIMILAR_TO" ? 1 : hops,
647
+ via: [safeKind],
648
+ });
649
+ };
650
+ for (const rec of records) {
651
+ // records: { related: <node> }
652
+ harvest(rec?.related ?? Object.values(rec ?? {})[0]);
653
+ }
654
+ for (const row of rows) {
655
+ if (!Array.isArray(row))
656
+ continue;
657
+ harvest(row[0]);
658
+ }
659
+ if (out.length >= limit)
660
+ break;
661
+ }
662
+ return out.slice(0, limit);
538
663
  },
539
664
  async predictRelevance(query, candidates) {
540
665
  if (candidates.length === 0)
@@ -572,24 +697,107 @@ function createExtensions(db, embeddings, client, graphName, workspace, collecti
572
697
  if (!Array.isArray(feedback) || feedback.length < MIN_TRAINING_SAMPLES) {
573
698
  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
699
  }
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.
700
+ // v0.2.0 implementation: stage rows in a SQL table the gateway's
701
+ // AutoML can `SELECT * FROM`. Gateway v1.6.5.2-ce rejects
702
+ // `config.inline_rows` (the workflow v0.1.0 attempted) and
703
+ // requires the data to land in a real collection/table before
704
+ // training. We:
705
+ // 1. Hydrate each feedback row's memory into a full feature
706
+ // vector (cosine, age_days, importance, one-hot category) via
707
+ // the same `buildRelevanceFeatures` helper `predictRelevance`
708
+ // uses — so train and predict see identical schemas.
709
+ // 2. CREATE TABLE IF NOT EXISTS the staging table.
710
+ // 3. INSERT each row.
711
+ // 4. Call client.automl.train({ collection, target: 'score', ... }).
581
712
  //
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.");
713
+ // Feedback rows whose memories have been deleted are skipped (with
714
+ // a soft warning to the caller via the throw message); training
715
+ // proceeds with the remainder.
716
+ // Hydrate feedback rows. Embed each query text once; pair with
717
+ // the stored Memory's vector to get the cosine feature.
718
+ const hydrated = [];
719
+ let missing = 0;
720
+ for (const fb of feedback) {
721
+ const mem = await db.get(fb.memoryId).catch(() => null);
722
+ if (!mem) {
723
+ missing++;
724
+ continue;
725
+ }
726
+ const queryVec = await embeddings.embed(fb.queryText);
727
+ const feats = buildRelevanceFeatures(queryVec, mem);
728
+ hydrated.push({
729
+ cosine: feats.asRecord.cosine,
730
+ age_days: feats.asRecord.age_days,
731
+ importance: feats.asRecord.importance,
732
+ category_preference: feats.asRecord.category_preference,
733
+ category_fact: feats.asRecord.category_fact,
734
+ category_decision: feats.asRecord.category_decision,
735
+ category_entity: feats.asRecord.category_entity,
736
+ category_other: feats.asRecord.category_other,
737
+ score: clamp01(fb.score),
738
+ });
739
+ }
740
+ if (hydrated.length < MIN_TRAINING_SAMPLES) {
741
+ throw new Error(`memory-synapcores.trainRelevanceModel: after hydrating, only ${hydrated.length} feedback rows resolve to known memories (${missing} were missing) — need at least ${MIN_TRAINING_SAMPLES}`);
742
+ }
743
+ // 2. CREATE TABLE IF NOT EXISTS. The gateway's SQL surface accepts
744
+ // standard CREATE TABLE. If the table already exists from a
745
+ // prior run we swallow the duplicate-table error and keep
746
+ // appending rows.
747
+ const createSql = `CREATE TABLE ${stagingTable} (cosine FLOAT, age_days FLOAT, importance FLOAT, category_preference FLOAT, category_fact FLOAT, category_decision FLOAT, category_entity FLOAT, category_other FLOAT, score FLOAT)`;
748
+ try {
749
+ await client.executeQuery({ sql: createSql });
750
+ }
751
+ catch (err) {
752
+ const msg = err?.message ?? String(err);
753
+ if (!/already exists|duplicate/i.test(msg)) {
754
+ // Some gateways return "Table … created successfully" as data
755
+ // rather than throwing on re-create; only re-throw on truly
756
+ // unexpected errors.
757
+ throw new Error(`memory-synapcores.trainRelevanceModel: failed to provision staging table '${stagingTable}': ${msg}`);
758
+ }
759
+ }
760
+ // 3. INSERT each row. We send them individually to keep the SQL
761
+ // body small and stay friendly to the gateway's row limit.
762
+ for (const row of hydrated) {
763
+ const sql = `INSERT INTO ${stagingTable} (cosine, age_days, importance, category_preference, category_fact, category_decision, category_entity, category_other, score) VALUES (` +
764
+ [
765
+ row.cosine,
766
+ row.age_days,
767
+ row.importance,
768
+ row.category_preference,
769
+ row.category_fact,
770
+ row.category_decision,
771
+ row.category_entity,
772
+ row.category_other,
773
+ row.score,
774
+ ]
775
+ .map((n) => Number.isFinite(n) ? String(n) : "0")
776
+ .join(", ") +
777
+ ")";
778
+ try {
779
+ await client.executeQuery({ sql });
780
+ }
781
+ catch (err) {
782
+ const msg = err?.message ?? String(err);
783
+ throw new Error(`memory-synapcores.trainRelevanceModel: failed to insert training row into '${stagingTable}': ${msg}`);
784
+ }
785
+ }
786
+ // 4. Train via /v1/automl/train. The gateway issues
787
+ // `SELECT * FROM {collection}` internally and trains on the
788
+ // `target` column.
789
+ const model = await client.automl.train({
790
+ collection: stagingTable,
791
+ target: "score",
792
+ task: "regression",
793
+ name: modelName,
794
+ max_trials: 5,
795
+ validation_split: 0.2,
796
+ });
797
+ return {
798
+ modelId: model.id ?? modelName,
799
+ modelName: model.name ?? modelName,
800
+ };
593
801
  },
594
802
  };
595
803
  }
@@ -631,32 +839,16 @@ const memoryPlugin = {
631
839
  const cfg = memoryConfigSchema.parse(api.pluginConfig);
632
840
  const vectorDim = vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small");
633
841
  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 = {
842
+ // v0.2.0: pass apiKey straight through. @synapcores/sdk@0.4.0
843
+ // routes both apiKey AND jwtToken via `Authorization: Bearer`, so
844
+ // the v0.1.0 shim that re-routed `aidb_*` / `ak_*` keys through
845
+ // `jwtToken` to coerce the right header is gone.
846
+ const client = new SynapCores({
649
847
  host: cfg.synapcores.host,
650
848
  port: cfg.synapcores.port,
651
849
  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);
850
+ apiKey: cfg.synapcores.apiKey,
851
+ });
660
852
  const db = new MemoryDB(client, collectionName, vectorDim);
661
853
  const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model);
662
854
  const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace, collectionName);
@@ -736,8 +928,9 @@ const memoryPlugin = {
736
928
  importance,
737
929
  category,
738
930
  });
739
- // Best-effort auto-link to similar memories so `recallRelated`
740
- // returns useful neighborhoods. Failures are swallowed and logged.
931
+ // v0.2.0: linkSimilarMemories now inserts a Memory graph node
932
+ // carrying the embedding so recallRelated has something to
933
+ // MATCH against. Failures are non-fatal (logged in-helper).
741
934
  if (autoLinkSimilar) {
742
935
  await linkSimilarMemories(entry, db, client, graphName, api.logger);
743
936
  }
@@ -952,8 +1145,18 @@ const memoryPlugin = {
952
1145
  // ========================================================================
953
1146
  api.registerService({
954
1147
  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})`);
1148
+ start: async () => {
1149
+ // Best-effort startup probe: surface a DB-down / bad-key problem
1150
+ // immediately in the logs (and `openclaw plugins doctor`) without
1151
+ // bricking the host — memory ops still lazily retry on first use.
1152
+ try {
1153
+ await preflightGateway(client);
1154
+ api.logger.info(`memory-synapcores: initialized (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, collection: ${collectionName}, model: ${cfg.embedding.model})`);
1155
+ }
1156
+ catch (err) {
1157
+ api.logger.warn(`memory-synapcores: ${String(err?.message ?? err)}`);
1158
+ api.logger.warn("memory-synapcores: continuing with lazy init — memory operations will retry on first use.");
1159
+ }
957
1160
  },
958
1161
  stop: () => {
959
1162
  api.logger.info("memory-synapcores: stopped");
@@ -965,13 +1168,15 @@ const memoryPlugin = {
965
1168
  // Expose the extension surface on the plugin instance so callers can
966
1169
  // reach `recallFiltered` / `recallRelated` / `predictRelevance` /
967
1170
  // `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;
1171
+ // (e.g. `plugin.extensions.recallFiltered`).
1172
+ //
1173
+ // NOTE: attach to `pluginEntry` (the definePluginEntry result that is the
1174
+ // default export), NOT the inner `memoryPlugin` definePluginEntry returns
1175
+ // a fresh normalized object, so attaching to the inner one would hide the
1176
+ // extensions from every consumer of the loaded plugin.
1177
+ pluginEntry.extensions = extensions;
974
1178
  },
975
1179
  };
976
- export default memoryPlugin;
1180
+ const pluginEntry = definePluginEntry(memoryPlugin);
1181
+ export default pluginEntry;
977
1182
  //# sourceMappingURL=index.js.map