@synapcores/openclaw-memory 0.3.0 → 0.4.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
@@ -2,37 +2,31 @@
2
2
  * OpenClaw Memory (SynapCores) Plugin
3
3
  *
4
4
  * Long-term memory with vector search for AI conversations.
5
- * Uses SynapCores AIDB for storage and OpenAI for embeddings.
5
+ * Uses SynapCores AIDB for storage via the engine-side
6
+ * `MEMORY_STORE` / `MEMORY_RECALL` / `MEMORY_FORGET` primitives and
7
+ * OpenAI for the relevance-extension features that still need a
8
+ * client-side embedding (cosine feature in `predictRelevance`,
9
+ * graph-node embedding when `autoLinkSimilar` is on).
10
+ *
6
11
  * 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`.
12
+ * plus four SynapCores-only extensions (SQL-filtered recall, graph-relation
13
+ * walks, AutoML relevance scoring, and a model-training helper) — see
14
+ * `recallFiltered`, `recallRelated`, `predictRelevance`, and
15
+ * `trainRelevanceModel`.
10
16
  *
11
- * This is the @synapcores/openclaw-memory drop-in alternative to
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`.
17
+ * v0.4.0 migrates the core memory ops to `@synapcores/sdk@^0.5.0`'s
18
+ * `client.memory` surface. Requires SynapCores gateway v1.8.5-ce+.
16
19
  */
17
20
  import { Type } from "typebox";
18
- import { randomUUID } from "node:crypto";
19
21
  import OpenAI from "openai";
20
22
  import { stringEnum } from "openclaw/plugin-sdk/core";
21
23
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
22
24
  import { SynapCores } from "@synapcores/sdk";
23
25
  import { MEMORY_CATEGORIES, memoryConfigSchema, vectorDimsForModel, } from "./config.js";
24
26
  // ============================================================================
25
- // SynapCores Provider
27
+ // Constants
26
28
  // ============================================================================
27
29
  const DEFAULT_COLLECTION = "openclaw_memories";
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
30
  // ============================================================================
37
31
  // Connection preflight
38
32
  //
@@ -91,26 +85,30 @@ function classifyGatewayError(err) {
91
85
  /**
92
86
  * One lightweight round-trip to confirm the gateway is reachable AND the API
93
87
  * 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.
88
+ * actionable message on failure; resolves on success.
89
+ *
90
+ * v0.4.0 uses `client.executeQuery({ sql: "SELECT 1" })` — the lowest-cost
91
+ * authenticated probe available on the gateway and the same path
92
+ * `client.memory.*` rides for every operation.
97
93
  */
98
94
  async function preflightGateway(client) {
99
95
  let host = "localhost";
100
96
  let port = 8080;
101
97
  let scheme = "http";
102
98
  try {
103
- const cfg = client._getConfig();
104
- host = cfg.host ?? host;
105
- port = cfg.port ?? port;
106
- scheme = cfg.useHttps ? "https" : "http";
99
+ const cfg = client._getConfig?.();
100
+ if (cfg) {
101
+ host = cfg.host ?? host;
102
+ port = cfg.port ?? port;
103
+ scheme = cfg.useHttps ? "https" : "http";
104
+ }
107
105
  }
108
106
  catch {
109
107
  // _getConfig is best-effort — only used to make the message specific.
110
108
  }
111
109
  const url = `${scheme}://${host}:${port}`;
112
110
  try {
113
- await client.listVectorCollections();
111
+ await client.executeQuery({ sql: "SELECT 1" });
114
112
  }
115
113
  catch (err) {
116
114
  const kind = classifyGatewayError(err);
@@ -125,205 +123,371 @@ async function preflightGateway(client) {
125
123
  throw new Error(`SynapCores preflight against ${url} failed: ${String(err?.message ?? err)}`);
126
124
  }
127
125
  }
126
+ // ============================================================================
127
+ // MemoryDB — thin wrapper around @synapcores/sdk@0.5.0's MemoryClient.
128
+ // ============================================================================
129
+ //
130
+ // v0.4.0 swaps the v0.3.x `client.vectorCollection(name).insert/search/delete`
131
+ // path for `client.memory.store/recall/forget`. The engine now owns
132
+ // embedding (one round-trip instead of two), the table schema, and the
133
+ // vector index — the plugin's job is to translate between OpenClaw's
134
+ // `MemoryEntry` shape and the engine's `MemoryRecord` shape.
135
+ //
136
+ // HARD CUT MIGRATION: the engine-managed table is `_memory_<namespace>`,
137
+ // which is a different storage backend from the v0.3.x vector collection.
138
+ // Existing v0.3.x installs WILL NOT see their old memories — see the
139
+ // README "Migration" section.
128
140
  class MemoryDB {
129
- collectionName;
130
- vectorDim;
141
+ namespace;
131
142
  client;
132
- vectorCollection = null;
133
- initPromise = null;
134
- constructor(client, collectionName, vectorDim) {
135
- this.collectionName = collectionName;
136
- this.vectorDim = vectorDim;
143
+ preflightPromise = null;
144
+ constructor(client, namespace) {
145
+ this.namespace = namespace;
137
146
  this.client = client;
138
147
  }
139
- async ensureInitialized() {
140
- if (this.vectorCollection) {
141
- return;
142
- }
143
- if (this.initPromise) {
144
- return this.initPromise;
148
+ /**
149
+ * One-time gateway preflight. Lazy — only runs on the first call,
150
+ * matching the v0.3.x init semantics.
151
+ */
152
+ async ensureReady() {
153
+ if (this.preflightPromise) {
154
+ return this.preflightPromise;
145
155
  }
146
- this.initPromise = this.doInitialize();
147
- return this.initPromise;
148
- }
149
- async doInitialize() {
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.
156
- let exists = false;
156
+ this.preflightPromise = preflightGateway(this.client);
157
157
  try {
158
- const items = await this.client.listVectorCollections();
159
- if (Array.isArray(items)) {
160
- exists = items.some((it) => it?.name === this.collectionName);
161
- }
162
- }
163
- catch {
164
- // best-effort; fall through and try to create
158
+ await this.preflightPromise;
165
159
  }
166
- if (!exists) {
167
- try {
168
- this.vectorCollection = await this.client.createVectorCollection({
169
- name: this.collectionName,
170
- dimensions: this.vectorDim,
171
- distance_metric: "cosine",
172
- });
173
- return;
174
- }
175
- catch (err) {
176
- // Race with a concurrent creator — re-check before giving up.
177
- try {
178
- const items = await this.client.listVectorCollections();
179
- if (!Array.isArray(items) ||
180
- !items.some((it) => it?.name === this.collectionName)) {
181
- throw err;
182
- }
183
- }
184
- catch {
185
- throw err;
186
- }
187
- }
160
+ catch (err) {
161
+ // Reset on failure so the next call can retry.
162
+ this.preflightPromise = null;
163
+ throw err;
188
164
  }
189
- // Use the synchronous accessor — no extra round-trip.
190
- this.vectorCollection = this.client.vectorCollection(this.collectionName);
191
165
  }
166
+ /**
167
+ * Store a memory. `vector` on the input is IGNORED — the engine embeds
168
+ * `text` server-side via the configured embedding model. Returns the
169
+ * fully-populated entry (with engine-assigned id + timestamp).
170
+ */
192
171
  async store(entry) {
193
- await this.ensureInitialized();
194
- const fullEntry = {
195
- ...entry,
196
- id: randomUUID(),
197
- createdAt: Date.now(),
198
- };
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,
172
+ await this.ensureReady();
173
+ const createdAt = Date.now();
174
+ const id = await this.client.memory.store(this.namespace, entry.text, {
206
175
  metadata: {
207
- text: fullEntry.text,
208
- importance: fullEntry.importance,
209
- category: fullEntry.category,
210
- createdAt: fullEntry.createdAt,
176
+ importance: entry.importance,
177
+ category: entry.category,
178
+ createdAt,
211
179
  },
212
180
  });
213
- return fullEntry;
181
+ return {
182
+ ...entry,
183
+ id,
184
+ createdAt,
185
+ };
214
186
  }
215
- async search(vector, limit = 5, minScore = 0.5) {
216
- await this.ensureInitialized();
217
- const hits = await this.vectorCollection.search({
218
- vector,
219
- k: limit,
220
- includeMetadata: true,
187
+ /**
188
+ * Semantic recall by free-text query. `minScore` is applied client-side
189
+ * to the engine's `similarity` field (already in [0, 1]).
190
+ */
191
+ async searchByQuery(query, limit = 5, minScore = 0.5) {
192
+ await this.ensureReady();
193
+ const records = await this.client.memory.recall(this.namespace, query, {
194
+ topK: clampTopK(limit),
221
195
  });
222
- return hits.map(parseHitToResult).filter((r) => r.score >= minScore);
196
+ return records
197
+ .map((r) => recordToResult(r))
198
+ .filter((r) => r.score >= minScore);
223
199
  }
224
200
  /**
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.
201
+ * SQL-filtered recall. v0.4.0 forwards the WHERE clause through to a
202
+ * `SELECT FROM MEMORY_RECALL(?, ?, ?) WHERE <where>` query so the
203
+ * filter runs engine-side over the recall result-set.
204
+ *
205
+ * Legacy shorthand: callers wrote v0.3.x filters like
206
+ * `category = 'preference' AND importance >= 0.7`. v0.4.0 stores those
207
+ * fields inside the JSON `metadata` column, so the bare-identifier form
208
+ * would no longer resolve. We rewrite the four well-known shorthands
209
+ * (`category`, `importance`, `createdAt`, `text`) to their JSON-extract
210
+ * form on the way in so most existing callers keep working unchanged.
211
+ * Callers that already use `metadata->>'…'` get a no-op.
229
212
  */
230
- async searchFiltered(vector, where, limit = 5) {
231
- await this.ensureInitialized();
232
- const hits = await this.vectorCollection.search({
233
- vector,
234
- k: limit,
235
- includeMetadata: true,
236
- filter: { sql: where },
237
- });
238
- return hits.map(parseHitToResult);
213
+ async searchFiltered(semantic, where, limit = 5) {
214
+ await this.ensureReady();
215
+ // Oversample so the post-filter still has enough rows to return.
216
+ const oversample = clampTopK(Math.max(limit * 5, 25));
217
+ const rewritten = rewriteLegacyWhere(where);
218
+ const sql = "SELECT id, content, similarity, metadata, created_at " +
219
+ `FROM MEMORY_RECALL($1, $2, $3) WHERE ${rewritten} LIMIT ${Number(limit)}`;
220
+ let result;
221
+ try {
222
+ result = await this.client.executeQuery({
223
+ sql,
224
+ parameters: [this.namespace, semantic, oversample],
225
+ });
226
+ }
227
+ catch (err) {
228
+ // Missing namespace == empty result set.
229
+ if (isMissingNamespaceError(err)) {
230
+ return [];
231
+ }
232
+ throw err;
233
+ }
234
+ return mapRecallRows(result);
239
235
  }
240
236
  async delete(id) {
241
- await this.ensureInitialized();
242
- // Validate UUID format to prevent injection
243
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
244
- if (!uuidRegex.test(id)) {
245
- throw new Error(`Invalid memory ID format: ${id}`);
237
+ await this.ensureReady();
238
+ if (typeof id !== "string" || id.length === 0) {
239
+ throw new Error(`Invalid memory ID: ${String(id)}`);
246
240
  }
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);
250
- return true;
241
+ return this.client.memory.forget(this.namespace, id);
251
242
  }
243
+ /**
244
+ * Count memories in the namespace. The engine table is
245
+ * `_memory_<namespace>`; we count via a direct SQL probe. Best-effort —
246
+ * returns 0 if the namespace hasn't been written to yet.
247
+ */
252
248
  async count() {
253
- await this.ensureInitialized();
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.
249
+ await this.ensureReady();
250
+ const table = `_memory_${this.namespace}`;
256
251
  try {
257
- return await this.vectorCollection.count();
252
+ const result = await this.client.executeQuery({
253
+ sql: `SELECT COUNT(*) FROM ${table}`,
254
+ });
255
+ const first = result.rows?.[0];
256
+ const raw = Array.isArray(first) ? first[0] : undefined;
257
+ if (typeof raw === "number")
258
+ return raw;
259
+ if (typeof raw === "string") {
260
+ const n = Number(raw);
261
+ return Number.isFinite(n) ? n : 0;
262
+ }
263
+ return 0;
258
264
  }
259
- catch {
265
+ catch (err) {
266
+ if (isMissingNamespaceError(err))
267
+ return 0;
260
268
  return 0;
261
269
  }
262
270
  }
263
- /** Fetch a single memory by ID (returns null if not found). */
271
+ /**
272
+ * Fetch a single memory by id. Used by extensions (e.g.
273
+ * `trainRelevanceModel`) to hydrate feedback rows. Returns null if not
274
+ * found.
275
+ *
276
+ * Strategy: oversample MEMORY_RECALL with the id as the query string
277
+ * (any free text works — we only care about the id-filtered result),
278
+ * then look up by id. This avoids reaching into the engine-managed
279
+ * `_memory_<ns>` table directly.
280
+ */
264
281
  async get(id) {
265
- await this.ensureInitialized();
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;
282
+ await this.ensureReady();
283
+ if (typeof id !== "string" || id.length === 0)
284
+ return null;
270
285
  try {
271
- hit = await this.vectorCollection.get(id);
286
+ const result = await this.client.executeQuery({
287
+ sql: "SELECT id, content, similarity, metadata, created_at " +
288
+ "FROM MEMORY_RECALL($1, $2, $3) WHERE id = $4 LIMIT 1",
289
+ parameters: [this.namespace, " ", 100, id],
290
+ });
291
+ const mapped = mapRecallRows(result);
292
+ if (mapped.length === 0)
293
+ return null;
294
+ return mapped[0].entry;
272
295
  }
273
296
  catch (err) {
274
- const e = err;
275
- if (e?.code === "NOT_FOUND" || e?.status === 404)
297
+ if (isMissingNamespaceError(err))
276
298
  return null;
277
299
  throw err;
278
300
  }
279
- if (!hit)
280
- return null;
281
- const meta = hit.metadata ?? {};
282
- return {
283
- id: String(hit.id ?? id),
284
- text: typeof meta.text === "string" ? meta.text : "",
285
- vector: Array.isArray(hit.values) ? hit.values : [],
286
- importance: typeof meta.importance === "number" ? meta.importance : 0,
287
- category: meta.category ?? "other",
288
- createdAt: typeof meta.createdAt === "number" ? meta.createdAt : 0,
289
- };
301
+ }
302
+ /** Expose the namespace (used by recallRelated for graph queries). */
303
+ get ns() {
304
+ return this.namespace;
290
305
  }
291
306
  }
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 : [];
307
+ // ============================================================================
308
+ // Shape translators between engine MemoryRecord and plugin MemoryEntry
309
+ // ============================================================================
310
+ function clampTopK(k) {
311
+ if (!Number.isFinite(k) || k < 1)
312
+ return 1;
313
+ if (k > 100)
314
+ return 100;
315
+ return Math.floor(k);
316
+ }
317
+ function metadataField(metadata, key) {
318
+ if (!metadata)
319
+ return undefined;
320
+ return metadata[key];
321
+ }
322
+ function recordToEntry(record) {
323
+ const meta = record.metadata ?? null;
324
+ const importance = metadataField(meta, "importance");
325
+ const category = metadataField(meta, "category");
326
+ const createdAtMeta = metadataField(meta, "createdAt");
327
+ const createdAtTs = record.createdAt instanceof Date
328
+ ? record.createdAt.getTime()
329
+ : Number.NaN;
301
330
  return {
302
- id: String(hit.id ?? ""),
303
- text,
304
- vector,
305
- importance,
306
- category,
307
- createdAt,
331
+ id: record.id,
332
+ text: record.content,
333
+ vector: [],
334
+ importance: typeof importance === "number" ? importance : 0,
335
+ category: (typeof category === "string"
336
+ ? category
337
+ : "other"),
338
+ createdAt: typeof createdAtMeta === "number"
339
+ ? createdAtMeta
340
+ : Number.isFinite(createdAtTs)
341
+ ? createdAtTs
342
+ : 0,
308
343
  };
309
344
  }
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;
317
- const distance = rawDistance ?? rawScore ?? 0;
318
- const similarity = Math.max(0, Math.min(1, 1 - distance));
345
+ function recordToResult(record) {
319
346
  return {
320
- entry: hitToEntry(hit),
321
- score: similarity,
347
+ entry: recordToEntry(record),
348
+ score: clamp01(record.similarity),
322
349
  };
323
350
  }
351
+ /**
352
+ * Parse a raw `executeQuery` result-set into MemorySearchResult[].
353
+ * Mirrors @synapcores/sdk@0.5.0's MemoryClient row mapping but operates
354
+ * on `executeQuery` output (which is what we use here for the WHERE-clause
355
+ * pass-through).
356
+ */
357
+ function mapRecallRows(result) {
358
+ const cols = (result.columns ?? []).map((c) => typeof c === "string" ? c : (c?.name ?? ""));
359
+ const colIndex = (name) => cols.findIndex((c) => c === name);
360
+ const idIdx = colIndex("id");
361
+ const contentIdx = colIndex("content");
362
+ const simIdx = colIndex("similarity");
363
+ const metaIdx = colIndex("metadata");
364
+ const createdIdx = colIndex("created_at");
365
+ const rows = result.rows ?? [];
366
+ const out = [];
367
+ for (const row of rows) {
368
+ if (!Array.isArray(row))
369
+ continue;
370
+ const meta = parseMetadataValue(metaIdx >= 0 ? row[metaIdx] : undefined);
371
+ const createdRaw = createdIdx >= 0 ? row[createdIdx] : undefined;
372
+ const createdAtMs = parseTimestampMs(createdRaw);
373
+ const importance = metadataField(meta, "importance");
374
+ const category = metadataField(meta, "category");
375
+ const similarity = simIdx >= 0 ? toFiniteNumber(row[simIdx]) : 0;
376
+ const id = idIdx >= 0 ? String(row[idIdx] ?? "") : "";
377
+ const content = contentIdx >= 0 ? String(row[contentIdx] ?? "") : "";
378
+ out.push({
379
+ entry: {
380
+ id,
381
+ text: content,
382
+ vector: [],
383
+ importance: typeof importance === "number" ? importance : 0,
384
+ category: (typeof category === "string"
385
+ ? category
386
+ : "other"),
387
+ createdAt: typeof metadataField(meta, "createdAt") === "number"
388
+ ? metadataField(meta, "createdAt")
389
+ : createdAtMs,
390
+ },
391
+ score: clamp01(similarity),
392
+ });
393
+ }
394
+ return out;
395
+ }
396
+ function parseMetadataValue(value) {
397
+ if (value == null || value === "")
398
+ return null;
399
+ if (typeof value === "object" && !Array.isArray(value)) {
400
+ return value;
401
+ }
402
+ if (typeof value === "string") {
403
+ try {
404
+ const parsed = JSON.parse(value);
405
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
406
+ return parsed;
407
+ }
408
+ }
409
+ catch {
410
+ // fall through
411
+ }
412
+ }
413
+ return null;
414
+ }
415
+ function parseTimestampMs(value) {
416
+ if (typeof value === "number") {
417
+ return value;
418
+ }
419
+ if (value instanceof Date) {
420
+ return value.getTime();
421
+ }
422
+ if (typeof value === "string" && value.length > 0) {
423
+ const candidate = value.includes("T") ? value : value.replace(" ", "T");
424
+ const d = new Date(candidate);
425
+ const t = d.getTime();
426
+ if (!Number.isNaN(t))
427
+ return t;
428
+ }
429
+ return 0;
430
+ }
431
+ function toFiniteNumber(value) {
432
+ if (typeof value === "number")
433
+ return Number.isFinite(value) ? value : 0;
434
+ if (typeof value === "string") {
435
+ const n = Number(value);
436
+ return Number.isFinite(n) ? n : 0;
437
+ }
438
+ return 0;
439
+ }
440
+ function isMissingNamespaceError(err) {
441
+ const e = err;
442
+ if (e?.code === "NOT_FOUND")
443
+ return true;
444
+ const msg = String(e?.message ?? "").toLowerCase();
445
+ return (msg.includes("does not exist") ||
446
+ msg.includes("no such table") ||
447
+ msg.includes("unknown namespace") ||
448
+ msg.includes("namespace not found"));
449
+ }
450
+ /**
451
+ * Rewrite a handful of legacy column-name shorthands into the JSON-extract
452
+ * form expected against `MEMORY_RECALL`'s output schema. This is best-
453
+ * effort and intentionally limited — callers writing complex SQL should
454
+ * use the engine-native syntax directly.
455
+ *
456
+ * Rewrites:
457
+ * category -> metadata->>'category'
458
+ * importance -> CAST(metadata->>'importance' AS REAL)
459
+ * createdAt -> CAST(metadata->>'createdAt' AS INTEGER)
460
+ * text -> content
461
+ *
462
+ * Identifiers preceded by a dot (e.g. `m.category`) or wrapped in quotes
463
+ * are left untouched.
464
+ */
465
+ function rewriteLegacyWhere(where) {
466
+ const rules = [
467
+ [/(?<![.'"\w])category(?![\w])/g, "metadata->>'category'"],
468
+ [/(?<![.'"\w])importance(?![\w])/g, "CAST(metadata->>'importance' AS REAL)"],
469
+ [/(?<![.'"\w])createdAt(?![\w])/g, "CAST(metadata->>'createdAt' AS INTEGER)"],
470
+ [/(?<![.'"\w])text(?![\w])/g, "content"],
471
+ ];
472
+ let out = where;
473
+ for (const [re, repl] of rules) {
474
+ out = out.replace(re, repl);
475
+ }
476
+ return out;
477
+ }
324
478
  // ============================================================================
325
479
  // OpenAI Embeddings
326
480
  // ============================================================================
481
+ //
482
+ // v0.4.0 NOTE: the engine now owns embedding for the core memory ops
483
+ // (`MEMORY_STORE` / `MEMORY_RECALL`). OpenAI is still used by:
484
+ // - `predictRelevance` / `trainRelevanceModel`: client-side cosine
485
+ // between query and candidate text.
486
+ // - `autoLinkSimilar` capture: writing an `embedding` property onto the
487
+ // Memory graph node so `recallRelated`'s synthetic SIMILAR_TO edge
488
+ // resolves at MATCH time.
489
+ // Callers that never use the relevance extensions and disable
490
+ // `autoLinkSimilar` will not hit the OpenAI client.
327
491
  class Embeddings {
328
492
  model;
329
493
  client;
@@ -457,11 +621,12 @@ function detectCategory(text) {
457
621
  /**
458
622
  * Escape a string literal for safe inlining into a Cypher query.
459
623
  *
460
- * Gateway v1.6.5.2-ce explicitly rejects named-parameter bindings (`$param`)
461
- * with HTTP 400; the supported path is to inline literal values into the
462
- * query string. Memory IDs flow in from `randomUUID()` so they are normally
463
- * safe, BUT we never trust upstream input — every string that ends up
464
- * inside `'...'` in a Cypher fragment must go through this helper.
624
+ * Gateway v1.6.5.2+ rejects named-parameter bindings (`$param`) with HTTP
625
+ * 400; the supported path is to inline literal values into the query
626
+ * string. Memory IDs flow in from `MEMORY_STORE` (engine-generated, e.g.
627
+ * `mem_1kv69sxfn_5ofzwK`) so they are normally safe, BUT we never trust
628
+ * upstream input — every string that ends up inside `'...'` in a Cypher
629
+ * fragment must go through this helper.
465
630
  *
466
631
  * Escapes single quotes (`'` -> `\'`) and backslashes (`\` -> `\\`).
467
632
  * Returns the inner content only; callers are responsible for the
@@ -481,47 +646,44 @@ const MAX_HOPS = 4;
481
646
  // Default result cap for recallRelated.
482
647
  const DEFAULT_RECALL_RELATED_LIMIT = 20;
483
648
  /**
484
- * `linkSimilarMemories` — capture-time graph wiring (v0.2.0).
649
+ * `linkSimilarMemories` — capture-time graph wiring.
485
650
  *
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.
490
- *
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
651
+ * Inserts the Memory as a graph node carrying the OpenAI embedding under
652
+ * the property name `embedding` the field the gateway's brute-force
653
+ * vector index is wired against (see
494
654
  * `aidb_gateway::routes::graph::attach_default_vector_index`). Once a
495
655
  * Memory node exists, `recallRelated` can MATCH `[:SIMILAR_TO > T]`
496
656
  * against it and get neighbors back without any pre-stored edges.
497
657
  *
658
+ * v0.4.0 NOTE: the engine no longer exposes the vector it used internally
659
+ * for `MEMORY_STORE`, so this helper still embeds the text via the
660
+ * OpenAI client. The two embeddings (engine + OpenAI) need not match;
661
+ * each is independent and serves a different recall path.
662
+ *
498
663
  * 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.
664
+ * memory subsystem; we just log and move on so the rest of recall keeps
665
+ * working.
500
666
  */
501
- async function linkSimilarMemories(entry, _db, client, _graphName, logger) {
667
+ async function linkSimilarMemories(entry, client, embedVector, logger) {
502
668
  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):
669
+ // KNOWN SDK GAP (@synapcores/sdk@<=0.5.0):
509
670
  // `client.graph.nodes.create(label, props)` posts
510
671
  // `{label: <single>, properties}` but the gateway's
511
672
  // /v1/graph/nodes handler expects `{labels: <array>, properties}`
512
673
  // (see aidb_gateway::routes::graph::CreateNodeRequest). The result
513
674
  // is a node with `labels: []`, which never matches the `Memory`
514
675
  // 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(...)`.
676
+ // call and post the correct wire shape ourselves. Once the SDK
677
+ // fixes `GraphNodeApi.create` to send `labels`, this
678
+ // `_getHttpClient` call can be replaced with
679
+ // `client.graph.nodes.create(...)`.
518
680
  const http = client._getHttpClient();
519
681
  await http.post("/graph/nodes", {
520
682
  labels: ["Memory"],
521
683
  properties: {
522
684
  id: entry.id,
523
685
  text: entry.text,
524
- embedding: entry.vector,
686
+ embedding: embedVector,
525
687
  importance: entry.importance,
526
688
  category: entry.category,
527
689
  createdAt: entry.createdAt,
@@ -536,7 +698,7 @@ async function linkSimilarMemories(entry, _db, client, _graphName, logger) {
536
698
  }
537
699
  }
538
700
  // ============================================================================
539
- // AutoML helpers — staged-collection training (v0.2.0)
701
+ // AutoML helpers — staged-collection training
540
702
  // ============================================================================
541
703
  const DEFAULT_RELEVANCE_MODEL = "openclaw_memory_relevance";
542
704
  const MIN_TRAINING_SAMPLES = 10;
@@ -551,7 +713,20 @@ function trainingTableName(workspace) {
551
713
  const safe = workspace ? workspace.replace(/[^a-zA-Z0-9_]/g, "_") : "";
552
714
  return safe ? `${TRAINING_TABLE_PREFIX}_${safe}` : TRAINING_TABLE_PREFIX;
553
715
  }
554
- function createExtensions(db, embeddings, client, _graphName, workspace, _collectionName = DEFAULT_COLLECTION) {
716
+ async function ensureCandidateVector(candidate, embeddings) {
717
+ if (Array.isArray(candidate.vector) && candidate.vector.length > 0) {
718
+ return candidate.vector;
719
+ }
720
+ if (!candidate.text)
721
+ return [];
722
+ try {
723
+ return await embeddings.embed(candidate.text);
724
+ }
725
+ catch {
726
+ return [];
727
+ }
728
+ }
729
+ function createExtensions(db, embeddings, client, _graphName, workspace) {
555
730
  const modelName = relevanceModelName(workspace);
556
731
  const stagingTable = trainingTableName(workspace);
557
732
  async function modelExists(name) {
@@ -566,19 +741,16 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
566
741
  return {
567
742
  async recallFiltered(options) {
568
743
  const limit = options.limit ?? 5;
569
- const vector = await embeddings.embed(options.semantic);
570
- return db.searchFiltered(vector, options.where, limit);
744
+ return db.searchFiltered(options.semantic, options.where, limit);
571
745
  },
572
746
  async recallRelated(memoryId, options = {}) {
573
- // v0.2.0 implementation:
574
- //
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.
747
+ // The gateway derives `SIMILAR_TO` synthetically at MATCH time from
748
+ // the graph backend's vector index on the `embedding` property.
749
+ // `linkSimilarMemories` populates that index on every capture by
750
+ // posting a `Memory` graph node carrying the embedding. With nodes
751
+ // in place, this method composes a Cypher MATCH that walks
752
+ // SIMILAR_TO (plus any non-synthetic edges the caller named via
753
+ // `edgeKinds`) and returns the neighborhood.
582
754
  //
583
755
  // Wire constraints:
584
756
  // - Multi-hop `[:SIMILAR_TO*1..N]` is rejected — SIMILAR_TO is
@@ -613,16 +785,38 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
613
785
  }
614
786
  let result;
615
787
  try {
616
- result = await client.graph.cypher(cypher);
788
+ // SDK 0.5.0 no longer exposes `client.graph.cypher` — the
789
+ // canonical path is `POST /v1/graph/match` with `{sql: <cypher>}`.
790
+ // We reach through the SDK's underlying axios instance via
791
+ // `_getHttpClient()`, the same accessor `linkSimilarMemories`
792
+ // uses for the `/graph/nodes` workaround.
793
+ const http = client._getHttpClient();
794
+ const response = await http.post("/graph/match", { sql: cypher });
795
+ const body = (response?.data ?? {});
796
+ result = body;
617
797
  }
618
798
  catch (err) {
619
799
  const msg = err?.message ?? String(err);
620
800
  throw new Error(`memory-synapcores.recallRelated: graph query failed for edge kind '${safeKind}': ${msg}`);
621
801
  }
622
- // The SDK normalises responses to { columns, rows, records }.
623
- // Prefer `records` (column-keyed) and fall back to `rows`.
802
+ // Gateway returns `{ columns, rows, count }`. Older SDK shims
803
+ // also emitted `records` (column-keyed objects); we honour both.
624
804
  const records = Array.isArray(result?.records) ? result.records : [];
625
- const rows = Array.isArray(result?.rows) ? result.rows : [];
805
+ const cols = Array.isArray(result?.columns) ? result.columns : [];
806
+ const rawRows = Array.isArray(result?.rows) ? result.rows : [];
807
+ // Synthesize `records` from `(columns, rows)` if not provided.
808
+ const synthRecords = records.length > 0
809
+ ? records
810
+ : rawRows
811
+ .filter((r) => Array.isArray(r))
812
+ .map((r) => {
813
+ const obj = {};
814
+ for (let i = 0; i < cols.length; i++) {
815
+ obj[cols[i]] = r[i];
816
+ }
817
+ return obj;
818
+ });
819
+ const rows = rawRows;
626
820
  const harvest = (node) => {
627
821
  if (!node || typeof node !== "object")
628
822
  return;
@@ -647,7 +841,7 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
647
841
  via: [safeKind],
648
842
  });
649
843
  };
650
- for (const rec of records) {
844
+ for (const rec of synthRecords) {
651
845
  // records: { related: <node> }
652
846
  harvest(rec?.related ?? Object.values(rec ?? {})[0]);
653
847
  }
@@ -666,7 +860,11 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
666
860
  return [];
667
861
  const queryVector = await embeddings.embed(query);
668
862
  const now = Date.now();
669
- const features = candidates.map((c) => buildRelevanceFeatures(queryVector, c, now));
863
+ // v0.4.0: MEMORY_RECALL doesn't return embeddings, so candidates
864
+ // surfaced via the core recall path arrive with `vector: []`. We
865
+ // lazily embed the candidate text when we need a cosine feature.
866
+ const candidateVectors = await Promise.all(candidates.map((c) => ensureCandidateVector(c, embeddings)));
867
+ const features = candidates.map((c, i) => buildRelevanceFeatures(queryVector, { ...c, vector: candidateVectors[i] }, now));
670
868
  // Try model mode; on any failure (no model, transport error) fall
671
869
  // back to the heuristic so the caller never gets an empty result.
672
870
  if (await modelExists(modelName)) {
@@ -697,11 +895,9 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
697
895
  if (!Array.isArray(feedback) || feedback.length < MIN_TRAINING_SAMPLES) {
698
896
  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})`);
699
897
  }
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:
898
+ // Stage rows in a SQL table the gateway's AutoML can `SELECT * FROM`.
899
+ // The gateway requires the data to land in a real collection/table
900
+ // before training. We:
705
901
  // 1. Hydrate each feedback row's memory into a full feature
706
902
  // vector (cosine, age_days, importance, one-hot category) via
707
903
  // the same `buildRelevanceFeatures` helper `predictRelevance`
@@ -714,7 +910,8 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
714
910
  // a soft warning to the caller via the throw message); training
715
911
  // proceeds with the remainder.
716
912
  // Hydrate feedback rows. Embed each query text once; pair with
717
- // the stored Memory's vector to get the cosine feature.
913
+ // the stored Memory's content (re-embedded via OpenAI) to get the
914
+ // cosine feature.
718
915
  const hydrated = [];
719
916
  let missing = 0;
720
917
  for (const fb of feedback) {
@@ -723,8 +920,9 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
723
920
  missing++;
724
921
  continue;
725
922
  }
923
+ const memVector = await ensureCandidateVector(mem, embeddings);
726
924
  const queryVec = await embeddings.embed(fb.queryText);
727
- const feats = buildRelevanceFeatures(queryVec, mem);
925
+ const feats = buildRelevanceFeatures(queryVec, { ...mem, vector: memVector });
728
926
  hydrated.push({
729
927
  cosine: feats.asRecord.cosine,
730
928
  age_days: feats.asRecord.age_days,
@@ -837,24 +1035,29 @@ const memoryPlugin = {
837
1035
  configSchema: memoryConfigSchema,
838
1036
  register(api) {
839
1037
  const cfg = memoryConfigSchema.parse(api.pluginConfig);
840
- const vectorDim = vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small");
841
- const collectionName = cfg.collection ?? DEFAULT_COLLECTION;
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.
1038
+ // Validate `vectorDimsForModel` runs without throwing; the dimension
1039
+ // value itself isn't used in v0.4.0 because the engine owns embedding
1040
+ // for the core memory ops.
1041
+ vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small");
1042
+ const namespace = cfg.collection ?? DEFAULT_COLLECTION;
1043
+ // The new MemoryClient enforces namespace ^[A-Za-z_][A-Za-z0-9_]*$. Fail
1044
+ // loud and early if the OpenClaw config's `collection` value would be
1045
+ // rejected.
1046
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(namespace)) {
1047
+ throw new Error(`memory-synapcores: collection '${namespace}' is not a valid namespace ` +
1048
+ `(must match /^[A-Za-z_][A-Za-z0-9_]*$/). Update plugins.entries.memory-synapcores.config.collection.`);
1049
+ }
846
1050
  const client = new SynapCores({
847
1051
  host: cfg.synapcores.host,
848
1052
  port: cfg.synapcores.port,
849
1053
  useHttps: cfg.synapcores.useHttps,
850
1054
  apiKey: cfg.synapcores.apiKey,
851
1055
  });
852
- const db = new MemoryDB(client, collectionName, vectorDim);
1056
+ const db = new MemoryDB(client, namespace);
853
1057
  const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model);
854
- const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace, collectionName);
1058
+ const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace);
855
1059
  const autoLinkSimilar = cfg.autoLinkSimilar !== false;
856
- const graphName = cfg.graph;
857
- api.logger.info(`memory-synapcores: plugin registered (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, collection: ${collectionName}, autoLinkSimilar: ${autoLinkSimilar}, lazy init)`);
1060
+ api.logger.info(`memory-synapcores: plugin registered (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, namespace: ${namespace}, autoLinkSimilar: ${autoLinkSimilar}, lazy init)`);
858
1061
  // ========================================================================
859
1062
  // Tools
860
1063
  // ========================================================================
@@ -868,8 +1071,7 @@ const memoryPlugin = {
868
1071
  }),
869
1072
  async execute(_toolCallId, params) {
870
1073
  const { query, limit = 5 } = params;
871
- const vector = await embeddings.embed(query);
872
- const results = await db.search(vector, limit, 0.1);
1074
+ const results = await db.searchByQuery(query, limit, 0.1);
873
1075
  if (results.length === 0) {
874
1076
  return {
875
1077
  content: [{ type: "text", text: "No relevant memories found." }],
@@ -879,7 +1081,6 @@ const memoryPlugin = {
879
1081
  const text = results
880
1082
  .map((r, i) => `${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`)
881
1083
  .join("\n");
882
- // Strip vector data for serialization (typed arrays can't be cloned)
883
1084
  const sanitizedResults = results.map((r) => ({
884
1085
  id: r.entry.id,
885
1086
  text: r.entry.text,
@@ -904,9 +1105,9 @@ const memoryPlugin = {
904
1105
  }),
905
1106
  async execute(_toolCallId, params) {
906
1107
  const { text, importance = 0.7, category = "other", } = params;
907
- const vector = await embeddings.embed(text);
908
- // Check for duplicates
909
- const existing = await db.search(vector, 1, 0.95);
1108
+ // Check for duplicates via the engine's recall path. The engine
1109
+ // re-embeds `text` internally so we don't have to.
1110
+ const existing = await db.searchByQuery(text, 1, 0.95);
910
1111
  if (existing.length > 0) {
911
1112
  return {
912
1113
  content: [
@@ -924,15 +1125,21 @@ const memoryPlugin = {
924
1125
  }
925
1126
  const entry = await db.store({
926
1127
  text,
927
- vector,
1128
+ vector: [],
928
1129
  importance,
929
1130
  category,
930
1131
  });
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).
1132
+ // linkSimilarMemories inserts a Memory graph node carrying an
1133
+ // OpenAI-embedded representation so recallRelated has something
1134
+ // to MATCH against. Failures are non-fatal (logged in-helper).
934
1135
  if (autoLinkSimilar) {
935
- await linkSimilarMemories(entry, db, client, graphName, api.logger);
1136
+ try {
1137
+ const embedVec = await embeddings.embed(text);
1138
+ await linkSimilarMemories(entry, client, embedVec, api.logger);
1139
+ }
1140
+ catch (err) {
1141
+ api.logger.warn(`memory-synapcores: graph-node embed failed for ${entry.id}: ${String(err)} (capture still succeeded)`);
1142
+ }
936
1143
  }
937
1144
  return {
938
1145
  content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }],
@@ -958,8 +1165,7 @@ const memoryPlugin = {
958
1165
  };
959
1166
  }
960
1167
  if (query) {
961
- const vector = await embeddings.embed(query);
962
- const results = await db.search(vector, 5, 0.7);
1168
+ const results = await db.searchByQuery(query, 5, 0.7);
963
1169
  if (results.length === 0) {
964
1170
  return {
965
1171
  content: [{ type: "text", text: "No matching memories found." }],
@@ -976,7 +1182,6 @@ const memoryPlugin = {
976
1182
  const list = results
977
1183
  .map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
978
1184
  .join("\n");
979
- // Strip vector data for serialization
980
1185
  const sanitizedCandidates = results.map((r) => ({
981
1186
  id: r.entry.id,
982
1187
  text: r.entry.text,
@@ -1017,9 +1222,7 @@ const memoryPlugin = {
1017
1222
  .argument("<query>", "Search query")
1018
1223
  .option("--limit <n>", "Max results", "5")
1019
1224
  .action(async (query, opts) => {
1020
- const vector = await embeddings.embed(query);
1021
- const results = await db.search(vector, parseInt(opts.limit), 0.3);
1022
- // Strip vectors for output
1225
+ const results = await db.searchByQuery(query, parseInt(opts.limit), 0.3);
1023
1226
  const output = results.map((r) => ({
1024
1227
  id: r.entry.id,
1025
1228
  text: r.entry.text,
@@ -1047,8 +1250,7 @@ const memoryPlugin = {
1047
1250
  return;
1048
1251
  }
1049
1252
  try {
1050
- const vector = await embeddings.embed(event.prompt);
1051
- const results = await db.search(vector, 3, 0.3);
1253
+ const results = await db.searchByQuery(event.prompt, 3, 0.3);
1052
1254
  if (results.length === 0) {
1053
1255
  return;
1054
1256
  }
@@ -1075,23 +1277,19 @@ const memoryPlugin = {
1075
1277
  // Extract text content from messages (handling unknown[] type)
1076
1278
  const texts = [];
1077
1279
  for (const msg of event.messages) {
1078
- // Type guard for message object
1079
1280
  if (!msg || typeof msg !== "object") {
1080
1281
  continue;
1081
1282
  }
1082
1283
  const msgObj = msg;
1083
- // Only process user and assistant messages
1084
1284
  const role = msgObj.role;
1085
1285
  if (role !== "user" && role !== "assistant") {
1086
1286
  continue;
1087
1287
  }
1088
1288
  const content = msgObj.content;
1089
- // Handle string content directly
1090
1289
  if (typeof content === "string") {
1091
1290
  texts.push(content);
1092
1291
  continue;
1093
1292
  }
1094
- // Handle array content (content blocks)
1095
1293
  if (Array.isArray(content)) {
1096
1294
  for (const block of content) {
1097
1295
  if (block &&
@@ -1114,20 +1312,25 @@ const memoryPlugin = {
1114
1312
  let stored = 0;
1115
1313
  for (const text of toCapture.slice(0, 3)) {
1116
1314
  const category = detectCategory(text);
1117
- const vector = await embeddings.embed(text);
1118
1315
  // Check for duplicates (high similarity threshold)
1119
- const existing = await db.search(vector, 1, 0.95);
1316
+ const existing = await db.searchByQuery(text, 1, 0.95);
1120
1317
  if (existing.length > 0) {
1121
1318
  continue;
1122
1319
  }
1123
1320
  const entry = await db.store({
1124
1321
  text,
1125
- vector,
1322
+ vector: [],
1126
1323
  importance: 0.7,
1127
1324
  category,
1128
1325
  });
1129
1326
  if (autoLinkSimilar) {
1130
- await linkSimilarMemories(entry, db, client, graphName, api.logger);
1327
+ try {
1328
+ const embedVec = await embeddings.embed(text);
1329
+ await linkSimilarMemories(entry, client, embedVec, api.logger);
1330
+ }
1331
+ catch (err) {
1332
+ api.logger.warn(`memory-synapcores: graph-node embed failed for ${entry.id}: ${String(err)}`);
1333
+ }
1131
1334
  }
1132
1335
  stored++;
1133
1336
  }
@@ -1151,7 +1354,7 @@ const memoryPlugin = {
1151
1354
  // bricking the host — memory ops still lazily retry on first use.
1152
1355
  try {
1153
1356
  await preflightGateway(client);
1154
- api.logger.info(`memory-synapcores: initialized (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, collection: ${collectionName}, model: ${cfg.embedding.model})`);
1357
+ api.logger.info(`memory-synapcores: initialized (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, namespace: ${namespace}, model: ${cfg.embedding.model})`);
1155
1358
  }
1156
1359
  catch (err) {
1157
1360
  api.logger.warn(`memory-synapcores: ${String(err?.message ?? err)}`);