@synapcores/openclaw-memory 0.3.0 → 0.5.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 the
7
+ * engine-native `client.embed()` for the relevance-extension features that
8
+ * need a client-side embedding (cosine feature in `predictRelevance`,
9
+ * graph-node embedding when `autoLinkSimilar` is on). No external
10
+ * embedding provider is required.
11
+ *
6
12
  * 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`.
13
+ * plus four SynapCores-only extensions (SQL-filtered recall, graph-relation
14
+ * walks, AutoML relevance scoring, and a model-training helper) — see
15
+ * `recallFiltered`, `recallRelated`, `predictRelevance`, and
16
+ * `trainRelevanceModel`.
10
17
  *
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`.
18
+ * v0.4.0 migrates the core memory ops to `@synapcores/sdk@^0.5.0`'s
19
+ * `client.memory` surface. Requires SynapCores gateway v1.8.5-ce+.
16
20
  */
17
21
  import { Type } from "typebox";
18
- import { randomUUID } from "node:crypto";
19
- 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
- import { MEMORY_CATEGORIES, memoryConfigSchema, vectorDimsForModel, } from "./config.js";
25
+ import { MEMORY_CATEGORIES, memoryConfigSchema, } 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,218 +123,602 @@ 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: a semantic vector search paired with a `WHERE`
202
+ * predicate over category / importance / createdAt / text / `metadata->>'…'`.
203
+ *
204
+ * ENGINE BUG (SynapCores gateway, confirmed v1.6.5.2-ce … v1.9.x-ce):
205
+ * applying ANY `WHERE` to the table-valued `MEMORY_RECALL(?, ?, ?)`
206
+ * function drops every row and returns an empty column set — even a bare
207
+ * `WHERE metadata->>'category' = 'preference'`. The engine cannot filter a
208
+ * table-valued-function result-set in the same SELECT. See the repro in the
209
+ * revalidation report; this needs an engine-side fix (planner should push
210
+ * the predicate as a post-filter over the TVF output, or materialize the
211
+ * TVF before applying WHERE).
212
+ *
213
+ * WORKAROUND (this method): fetch an oversampled, UNFILTERED
214
+ * `SELECT … FROM MEMORY_RECALL(?, ?, ?)` and apply the WHERE predicate
215
+ * CLIENT-SIDE in JS (see {@link compileWherePredicate}). The predicate
216
+ * evaluates against the recall row's category / importance / createdAt /
217
+ * text / id / similarity and its parsed `metadata` blob, so the documented
218
+ * filtering surface works despite the engine gap. Once the engine bug is
219
+ * fixed this can revert to a single WHERE-bearing SQL statement.
220
+ *
221
+ * Legacy shorthand (`category`, `importance`, `createdAt`, `text`) and the
222
+ * portable `metadata->>'…'` JSON-extract form are both accepted.
229
223
  */
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);
224
+ async searchFiltered(semantic, where, limit = 5) {
225
+ await this.ensureReady();
226
+ // Oversample so the client-side post-filter still has enough rows to return.
227
+ const oversample = clampTopK(Math.max(limit * 5, 25));
228
+ const sql = "SELECT id, content, similarity, metadata, created_at " +
229
+ `FROM MEMORY_RECALL($1, $2, $3) LIMIT ${oversample}`;
230
+ let result;
231
+ try {
232
+ result = await this.client.executeQuery({
233
+ sql,
234
+ parameters: [this.namespace, semantic, oversample],
235
+ });
236
+ }
237
+ catch (err) {
238
+ // Missing namespace == empty result set.
239
+ if (isMissingNamespaceError(err)) {
240
+ return [];
241
+ }
242
+ throw err;
243
+ }
244
+ const predicate = compileWherePredicate(where);
245
+ const filtered = mapRecallRowsRich(result).filter((r) => predicate({ entry: r.result.entry, score: r.result.score, metadata: r.metadata }));
246
+ return filtered.slice(0, Number(limit)).map((r) => r.result);
239
247
  }
240
248
  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}`);
249
+ await this.ensureReady();
250
+ if (typeof id !== "string" || id.length === 0) {
251
+ throw new Error(`Invalid memory ID: ${String(id)}`);
246
252
  }
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;
253
+ return this.client.memory.forget(this.namespace, id);
251
254
  }
255
+ /**
256
+ * Count memories in the namespace. The engine table is
257
+ * `_memory_<namespace>`; we count via a direct SQL probe. Best-effort —
258
+ * returns 0 if the namespace hasn't been written to yet.
259
+ */
252
260
  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.
261
+ await this.ensureReady();
262
+ const table = `_memory_${this.namespace}`;
256
263
  try {
257
- return await this.vectorCollection.count();
264
+ const result = await this.client.executeQuery({
265
+ sql: `SELECT COUNT(*) FROM ${table}`,
266
+ });
267
+ const first = result.rows?.[0];
268
+ const raw = Array.isArray(first) ? first[0] : undefined;
269
+ if (typeof raw === "number")
270
+ return raw;
271
+ if (typeof raw === "string") {
272
+ const n = Number(raw);
273
+ return Number.isFinite(n) ? n : 0;
274
+ }
275
+ return 0;
258
276
  }
259
- catch {
277
+ catch (err) {
278
+ if (isMissingNamespaceError(err))
279
+ return 0;
260
280
  return 0;
261
281
  }
262
282
  }
263
- /** Fetch a single memory by ID (returns null if not found). */
283
+ /**
284
+ * Fetch a single memory by id. Used by extensions (e.g.
285
+ * `trainRelevanceModel`) to hydrate feedback rows. Returns null if not
286
+ * found.
287
+ *
288
+ * Strategy: oversample MEMORY_RECALL with the id as the query string
289
+ * (any free text works — we only care about the id-filtered result),
290
+ * then look up by id. This avoids reaching into the engine-managed
291
+ * `_memory_<ns>` table directly.
292
+ */
264
293
  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;
294
+ await this.ensureReady();
295
+ if (typeof id !== "string" || id.length === 0)
296
+ return null;
270
297
  try {
271
- hit = await this.vectorCollection.get(id);
298
+ const result = await this.client.executeQuery({
299
+ sql: "SELECT id, content, similarity, metadata, created_at " +
300
+ "FROM MEMORY_RECALL($1, $2, $3) WHERE id = $4 LIMIT 1",
301
+ parameters: [this.namespace, " ", 100, id],
302
+ });
303
+ const mapped = mapRecallRows(result);
304
+ if (mapped.length === 0)
305
+ return null;
306
+ return mapped[0].entry;
272
307
  }
273
308
  catch (err) {
274
- const e = err;
275
- if (e?.code === "NOT_FOUND" || e?.status === 404)
309
+ if (isMissingNamespaceError(err))
276
310
  return null;
277
311
  throw err;
278
312
  }
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
- };
290
313
  }
314
+ /** Expose the namespace (used by recallRelated for graph queries). */
315
+ get ns() {
316
+ return this.namespace;
317
+ }
318
+ }
319
+ // ============================================================================
320
+ // Shape translators between engine MemoryRecord and plugin MemoryEntry
321
+ // ============================================================================
322
+ function clampTopK(k) {
323
+ if (!Number.isFinite(k) || k < 1)
324
+ return 1;
325
+ if (k > 100)
326
+ return 100;
327
+ return Math.floor(k);
328
+ }
329
+ function metadataField(metadata, key) {
330
+ if (!metadata)
331
+ return undefined;
332
+ return metadata[key];
291
333
  }
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 : [];
334
+ function recordToEntry(record) {
335
+ const meta = record.metadata ?? null;
336
+ const importance = metadataField(meta, "importance");
337
+ const category = metadataField(meta, "category");
338
+ const createdAtMeta = metadataField(meta, "createdAt");
339
+ const createdAtTs = record.createdAt instanceof Date
340
+ ? record.createdAt.getTime()
341
+ : Number.NaN;
301
342
  return {
302
- id: String(hit.id ?? ""),
303
- text,
304
- vector,
305
- importance,
306
- category,
307
- createdAt,
343
+ id: record.id,
344
+ text: record.content,
345
+ vector: [],
346
+ importance: typeof importance === "number" ? importance : 0,
347
+ category: (typeof category === "string"
348
+ ? category
349
+ : "other"),
350
+ createdAt: typeof createdAtMeta === "number"
351
+ ? createdAtMeta
352
+ : Number.isFinite(createdAtTs)
353
+ ? createdAtTs
354
+ : 0,
308
355
  };
309
356
  }
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));
357
+ function recordToResult(record) {
319
358
  return {
320
- entry: hitToEntry(hit),
321
- score: similarity,
359
+ entry: recordToEntry(record),
360
+ score: clamp01(record.similarity),
322
361
  };
323
362
  }
363
+ /**
364
+ * Parse a raw `executeQuery` result-set into MemorySearchResult[].
365
+ * Mirrors @synapcores/sdk@0.5.0's MemoryClient row mapping but operates
366
+ * on `executeQuery` output (which is what we use here for the WHERE-clause
367
+ * pass-through).
368
+ */
369
+ function mapRecallRows(result) {
370
+ return mapRecallRowsRich(result).map((r) => r.result);
371
+ }
372
+ /**
373
+ * Like {@link mapRecallRows}, but also surfaces the parsed `metadata` blob
374
+ * alongside each result. Used by `searchFiltered` so the WHERE clause can be
375
+ * evaluated client-side against the full metadata object (see
376
+ * {@link compileWherePredicate} and the ENGINE-BUG note on `searchFiltered`).
377
+ */
378
+ function mapRecallRowsRich(result) {
379
+ const cols = (result.columns ?? []).map((c) => typeof c === "string" ? c : (c?.name ?? ""));
380
+ const colIndex = (name) => cols.findIndex((c) => c === name);
381
+ const idIdx = colIndex("id");
382
+ const contentIdx = colIndex("content");
383
+ const simIdx = colIndex("similarity");
384
+ const metaIdx = colIndex("metadata");
385
+ const createdIdx = colIndex("created_at");
386
+ const rows = result.rows ?? [];
387
+ const out = [];
388
+ for (const row of rows) {
389
+ if (!Array.isArray(row))
390
+ continue;
391
+ const meta = parseMetadataValue(metaIdx >= 0 ? row[metaIdx] : undefined);
392
+ const createdRaw = createdIdx >= 0 ? row[createdIdx] : undefined;
393
+ const createdAtMs = parseTimestampMs(createdRaw);
394
+ const importance = metadataField(meta, "importance");
395
+ const category = metadataField(meta, "category");
396
+ const similarity = simIdx >= 0 ? toFiniteNumber(row[simIdx]) : 0;
397
+ const id = idIdx >= 0 ? String(row[idIdx] ?? "") : "";
398
+ const content = contentIdx >= 0 ? String(row[contentIdx] ?? "") : "";
399
+ out.push({
400
+ result: {
401
+ entry: {
402
+ id,
403
+ text: content,
404
+ vector: [],
405
+ importance: typeof importance === "number" ? importance : 0,
406
+ category: (typeof category === "string"
407
+ ? category
408
+ : "other"),
409
+ createdAt: typeof metadataField(meta, "createdAt") === "number"
410
+ ? metadataField(meta, "createdAt")
411
+ : createdAtMs,
412
+ },
413
+ score: clamp01(similarity),
414
+ },
415
+ metadata: meta,
416
+ });
417
+ }
418
+ return out;
419
+ }
420
+ function parseMetadataValue(value) {
421
+ if (value == null || value === "")
422
+ return null;
423
+ if (typeof value === "object" && !Array.isArray(value)) {
424
+ return value;
425
+ }
426
+ if (typeof value === "string") {
427
+ try {
428
+ const parsed = JSON.parse(value);
429
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
430
+ return parsed;
431
+ }
432
+ }
433
+ catch {
434
+ // fall through
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+ function parseTimestampMs(value) {
440
+ if (typeof value === "number") {
441
+ return value;
442
+ }
443
+ if (value instanceof Date) {
444
+ return value.getTime();
445
+ }
446
+ if (typeof value === "string" && value.length > 0) {
447
+ const candidate = value.includes("T") ? value : value.replace(" ", "T");
448
+ const d = new Date(candidate);
449
+ const t = d.getTime();
450
+ if (!Number.isNaN(t))
451
+ return t;
452
+ }
453
+ return 0;
454
+ }
455
+ function toFiniteNumber(value) {
456
+ if (typeof value === "number")
457
+ return Number.isFinite(value) ? value : 0;
458
+ if (typeof value === "string") {
459
+ const n = Number(value);
460
+ return Number.isFinite(n) ? n : 0;
461
+ }
462
+ return 0;
463
+ }
464
+ function isMissingNamespaceError(err) {
465
+ const e = err;
466
+ if (e?.code === "NOT_FOUND")
467
+ return true;
468
+ const msg = String(e?.message ?? "").toLowerCase();
469
+ return (msg.includes("does not exist") ||
470
+ msg.includes("no such table") ||
471
+ msg.includes("unknown namespace") ||
472
+ msg.includes("namespace not found"));
473
+ }
474
+ /**
475
+ * Compile a SQL-ish `WHERE` fragment into a JS predicate over a
476
+ * {@link WhereRecord}. This exists because the engine cannot apply a `WHERE`
477
+ * to the table-valued `MEMORY_RECALL(...)` output (see the ENGINE BUG note on
478
+ * `searchFiltered`), so `searchFiltered` fetches unfiltered rows and filters
479
+ * them here.
480
+ *
481
+ * Supported surface (the plugin's documented filtering contract):
482
+ * - Fields: `category`, `importance`, `createdAt`, `text`/`content`, `id`,
483
+ * `similarity`/`score`, and JSON-extract `metadata->>'key'`.
484
+ * - Comparison ops: `=`/`==`, `!=`/`<>`, `>`, `>=`, `<`, `<=`, `LIKE`
485
+ * (SQL `%`/`_` wildcards), `IN (...)`.
486
+ * - Boolean combinators: `AND`, `OR`, `NOT`, and parentheses.
487
+ * - Literals: single-quoted strings, numbers, `TRUE`/`FALSE`/`NULL`.
488
+ *
489
+ * A trivial always-true clause (empty or `1=1`) short-circuits to pass-all.
490
+ * Unsupported syntax throws a descriptive error rather than silently
491
+ * returning wrong rows.
492
+ */
493
+ function compileWherePredicate(where) {
494
+ const src = (where ?? "").trim();
495
+ if (src === "" || src === "1=1" || src === "1 = 1" || src.toLowerCase() === "true") {
496
+ return () => true;
497
+ }
498
+ const tokens = [];
499
+ const re = /\s+|metadata\s*->>\s*'([^']*)'|'((?:[^']|'')*)'|(>=|<=|!=|<>|==|=|>|<)|(\()|(\))|,|([A-Za-z_][A-Za-z0-9_]*)|(-?\d+(?:\.\d+)?)/g;
500
+ let m;
501
+ let lastIndex = 0;
502
+ while ((m = re.exec(src)) !== null) {
503
+ if (m.index !== lastIndex) {
504
+ throw new Error(`recallFiltered: unsupported token near "${src.slice(lastIndex, m.index + 1)}"`);
505
+ }
506
+ lastIndex = re.lastIndex;
507
+ const raw = m[0];
508
+ if (/^\s+$/.test(raw))
509
+ continue;
510
+ if (m[1] !== undefined)
511
+ tokens.push({ t: "field", v: `metadata:${m[1]}` });
512
+ else if (m[2] !== undefined)
513
+ tokens.push({ t: "str", v: m[2].replace(/''/g, "'") });
514
+ else if (m[3] !== undefined)
515
+ tokens.push({ t: "op", v: m[3] });
516
+ else if (m[4] !== undefined)
517
+ tokens.push({ t: "lparen", v: "(" });
518
+ else if (m[5] !== undefined)
519
+ tokens.push({ t: "rparen", v: ")" });
520
+ else if (raw === ",")
521
+ tokens.push({ t: "comma", v: "," });
522
+ else if (m[6] !== undefined) {
523
+ const kw = m[6].toUpperCase();
524
+ if (["AND", "OR", "NOT", "LIKE", "IN", "TRUE", "FALSE", "NULL"].includes(kw)) {
525
+ tokens.push({ t: "kw", v: kw });
526
+ }
527
+ else {
528
+ tokens.push({ t: "field", v: m[6] });
529
+ }
530
+ }
531
+ else if (m[7] !== undefined)
532
+ tokens.push({ t: "num", v: m[7] });
533
+ }
534
+ if (lastIndex !== src.length) {
535
+ throw new Error(`recallFiltered: unsupported token near "${src.slice(lastIndex)}"`);
536
+ }
537
+ // Recursive-descent parser -> predicate closure.
538
+ let pos = 0;
539
+ const peek = () => tokens[pos];
540
+ const next = () => tokens[pos++];
541
+ const fieldValue = (name, rec) => {
542
+ if (name.startsWith("metadata:")) {
543
+ const key = name.slice("metadata:".length);
544
+ return rec.metadata ? rec.metadata[key] : undefined;
545
+ }
546
+ switch (name.toLowerCase()) {
547
+ case "category": return rec.entry.category;
548
+ case "importance": return rec.entry.importance;
549
+ case "createdat": return rec.entry.createdAt;
550
+ case "text":
551
+ case "content": return rec.entry.text;
552
+ case "id": return rec.entry.id;
553
+ case "similarity":
554
+ case "score": return rec.score;
555
+ default:
556
+ return rec.metadata ? rec.metadata[name] : undefined;
557
+ }
558
+ };
559
+ const asNumber = (v) => {
560
+ if (typeof v === "number")
561
+ return Number.isFinite(v) ? v : null;
562
+ if (typeof v === "string" && v.trim() !== "") {
563
+ const n = Number(v);
564
+ return Number.isFinite(n) ? n : null;
565
+ }
566
+ return null;
567
+ };
568
+ const likeToRegExp = (pattern) => {
569
+ let out = "";
570
+ for (const ch of pattern) {
571
+ if (ch === "%")
572
+ out += ".*";
573
+ else if (ch === "_")
574
+ out += ".";
575
+ else
576
+ out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
577
+ }
578
+ return new RegExp(`^${out}$`, "i");
579
+ };
580
+ const compareOp = (op, left, right) => {
581
+ const ln = asNumber(left);
582
+ const rn = asNumber(right);
583
+ const bothNum = ln !== null && rn !== null;
584
+ switch (op) {
585
+ case "=":
586
+ case "==":
587
+ return bothNum ? ln === rn : String(left) === String(right);
588
+ case "!=":
589
+ case "<>":
590
+ return bothNum ? ln !== rn : String(left) !== String(right);
591
+ case ">": return bothNum ? ln > rn : String(left) > String(right);
592
+ case ">=": return bothNum ? ln >= rn : String(left) >= String(right);
593
+ case "<": return bothNum ? ln < rn : String(left) < String(right);
594
+ case "<=": return bothNum ? ln <= rn : String(left) <= String(right);
595
+ default:
596
+ throw new Error(`recallFiltered: unsupported operator "${op}"`);
597
+ }
598
+ };
599
+ // literal value parse (for RHS of a comparison / IN list)
600
+ const parseLiteral = () => {
601
+ const tk = next();
602
+ if (!tk)
603
+ throw new Error("recallFiltered: unexpected end of WHERE clause");
604
+ if (tk.t === "str")
605
+ return tk.v;
606
+ if (tk.t === "num")
607
+ return Number(tk.v);
608
+ if (tk.t === "kw" && tk.v === "TRUE")
609
+ return true;
610
+ if (tk.t === "kw" && tk.v === "FALSE")
611
+ return false;
612
+ if (tk.t === "kw" && tk.v === "NULL")
613
+ return null;
614
+ throw new Error(`recallFiltered: expected a literal, got "${tk.v}"`);
615
+ };
616
+ const parseComparison = () => {
617
+ const tk = next();
618
+ if (!tk || tk.t !== "field") {
619
+ throw new Error(`recallFiltered: expected a field name, got "${tk?.v ?? "<end>"}"`);
620
+ }
621
+ const fieldName = tk.v;
622
+ const opTok = peek();
623
+ // IN (...)
624
+ if (opTok && opTok.t === "kw" && opTok.v === "IN") {
625
+ next();
626
+ const lp = next();
627
+ if (!lp || lp.t !== "lparen")
628
+ throw new Error("recallFiltered: expected '(' after IN");
629
+ const values = [];
630
+ for (;;) {
631
+ values.push(parseLiteral());
632
+ const sep = next();
633
+ if (sep && sep.t === "comma")
634
+ continue;
635
+ if (sep && sep.t === "rparen")
636
+ break;
637
+ throw new Error("recallFiltered: malformed IN (...) list");
638
+ }
639
+ return (rec) => {
640
+ const lv = fieldValue(fieldName, rec);
641
+ return values.some((v) => compareOp("=", lv, v));
642
+ };
643
+ }
644
+ // LIKE
645
+ if (opTok && opTok.t === "kw" && opTok.v === "LIKE") {
646
+ next();
647
+ const lit = parseLiteral();
648
+ const rx = likeToRegExp(String(lit));
649
+ return (rec) => rx.test(String(fieldValue(fieldName, rec) ?? ""));
650
+ }
651
+ // comparison operator
652
+ if (!opTok || opTok.t !== "op") {
653
+ throw new Error(`recallFiltered: expected an operator after "${fieldName}", got "${opTok?.v ?? "<end>"}"`);
654
+ }
655
+ next();
656
+ const rhs = parseLiteral();
657
+ return (rec) => compareOp(opTok.v, fieldValue(fieldName, rec), rhs);
658
+ };
659
+ const parsePrimary = () => {
660
+ const tk = peek();
661
+ if (tk && tk.t === "lparen") {
662
+ next();
663
+ const inner = parseOr();
664
+ const rp = next();
665
+ if (!rp || rp.t !== "rparen")
666
+ throw new Error("recallFiltered: missing ')'");
667
+ return inner;
668
+ }
669
+ if (tk && tk.t === "kw" && tk.v === "NOT") {
670
+ next();
671
+ const inner = parsePrimary();
672
+ return (rec) => !inner(rec);
673
+ }
674
+ return parseComparison();
675
+ };
676
+ const parseAnd = () => {
677
+ let left = parsePrimary();
678
+ while (peek() && peek().t === "kw" && peek().v === "AND") {
679
+ next();
680
+ const right = parsePrimary();
681
+ const l = left;
682
+ left = (rec) => l(rec) && right(rec);
683
+ }
684
+ return left;
685
+ };
686
+ function parseOr() {
687
+ let left = parseAnd();
688
+ while (peek() && peek().t === "kw" && peek().v === "OR") {
689
+ next();
690
+ const right = parseAnd();
691
+ const l = left;
692
+ left = (rec) => l(rec) || right(rec);
693
+ }
694
+ return left;
695
+ }
696
+ const predicate = parseOr();
697
+ if (pos !== tokens.length) {
698
+ throw new Error(`recallFiltered: unexpected trailing tokens in WHERE clause near "${peek()?.v ?? ""}"`);
699
+ }
700
+ return predicate;
701
+ }
324
702
  // ============================================================================
325
- // OpenAI Embeddings
703
+ // Embeddings (engine-native)
326
704
  // ============================================================================
705
+ //
706
+ // OpenAI has been fully removed. Client-side embeddings are now produced by
707
+ // the SynapCores engine via `client.embed()` (native `EMBED()`), the same
708
+ // embedding space the engine uses for the core memory ops
709
+ // (`MEMORY_STORE` / `MEMORY_RECALL`). Used by:
710
+ // - `predictRelevance` / `trainRelevanceModel`: client-side cosine
711
+ // between query and candidate text.
712
+ // - `autoLinkSimilar` capture: writing an `embedding` property onto the
713
+ // Memory graph node so `recallRelated`'s synthetic SIMILAR_TO edge
714
+ // resolves at MATCH time.
327
715
  class Embeddings {
328
- model;
329
716
  client;
330
- constructor(apiKey, model) {
331
- this.model = model;
332
- this.client = new OpenAI({ apiKey });
717
+ constructor(client) {
718
+ this.client = client;
333
719
  }
334
720
  async embed(text) {
335
- const response = await this.client.embeddings.create({
336
- model: this.model,
337
- input: text,
338
- });
339
- return response.data[0].embedding;
721
+ return (await this.client.embed(text));
340
722
  }
341
723
  }
342
724
  // ============================================================================
@@ -457,11 +839,12 @@ function detectCategory(text) {
457
839
  /**
458
840
  * Escape a string literal for safe inlining into a Cypher query.
459
841
  *
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.
842
+ * Gateway v1.6.5.2+ rejects named-parameter bindings (`$param`) with HTTP
843
+ * 400; the supported path is to inline literal values into the query
844
+ * string. Memory IDs flow in from `MEMORY_STORE` (engine-generated, e.g.
845
+ * `mem_1kv69sxfn_5ofzwK`) so they are normally safe, BUT we never trust
846
+ * upstream input — every string that ends up inside `'...'` in a Cypher
847
+ * fragment must go through this helper.
465
848
  *
466
849
  * Escapes single quotes (`'` -> `\'`) and backslashes (`\` -> `\\`).
467
850
  * Returns the inner content only; callers are responsible for the
@@ -481,47 +864,46 @@ const MAX_HOPS = 4;
481
864
  // Default result cap for recallRelated.
482
865
  const DEFAULT_RECALL_RELATED_LIMIT = 20;
483
866
  /**
484
- * `linkSimilarMemories` — capture-time graph wiring (v0.2.0).
485
- *
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.
867
+ * `linkSimilarMemories` — capture-time graph wiring.
490
868
  *
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
869
+ * Inserts the Memory as a graph node carrying the engine-native embedding
870
+ * under the property name `embedding` — the field the gateway's brute-force
871
+ * vector index is wired against (see
494
872
  * `aidb_gateway::routes::graph::attach_default_vector_index`). Once a
495
873
  * Memory node exists, `recallRelated` can MATCH `[:SIMILAR_TO > T]`
496
874
  * against it and get neighbors back without any pre-stored edges.
497
875
  *
876
+ * NOTE: the engine no longer exposes the vector it used internally for
877
+ * `MEMORY_STORE`, so this helper re-embeds the text via `client.embed()`.
878
+ * Because the node-property vector now comes from the engine's own
879
+ * embedding space (previously it was a separate OpenAI space), it is
880
+ * consistent with the vectors the engine produces for the core memory
881
+ * ops — a consistency improvement over the prior OpenAI-based path.
882
+ *
498
883
  * 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.
884
+ * memory subsystem; we just log and move on so the rest of recall keeps
885
+ * working.
500
886
  */
501
- async function linkSimilarMemories(entry, _db, client, _graphName, logger) {
887
+ async function linkSimilarMemories(entry, client, embedVector, logger) {
502
888
  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):
889
+ // KNOWN SDK GAP (@synapcores/sdk@<=0.5.0):
509
890
  // `client.graph.nodes.create(label, props)` posts
510
891
  // `{label: <single>, properties}` but the gateway's
511
892
  // /v1/graph/nodes handler expects `{labels: <array>, properties}`
512
893
  // (see aidb_gateway::routes::graph::CreateNodeRequest). The result
513
894
  // is a node with `labels: []`, which never matches the `Memory`
514
895
  // 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(...)`.
896
+ // call and post the correct wire shape ourselves. Once the SDK
897
+ // fixes `GraphNodeApi.create` to send `labels`, this
898
+ // `_getHttpClient` call can be replaced with
899
+ // `client.graph.nodes.create(...)`.
518
900
  const http = client._getHttpClient();
519
901
  await http.post("/graph/nodes", {
520
902
  labels: ["Memory"],
521
903
  properties: {
522
904
  id: entry.id,
523
905
  text: entry.text,
524
- embedding: entry.vector,
906
+ embedding: embedVector,
525
907
  importance: entry.importance,
526
908
  category: entry.category,
527
909
  createdAt: entry.createdAt,
@@ -536,7 +918,7 @@ async function linkSimilarMemories(entry, _db, client, _graphName, logger) {
536
918
  }
537
919
  }
538
920
  // ============================================================================
539
- // AutoML helpers — staged-collection training (v0.2.0)
921
+ // AutoML helpers — staged-collection training
540
922
  // ============================================================================
541
923
  const DEFAULT_RELEVANCE_MODEL = "openclaw_memory_relevance";
542
924
  const MIN_TRAINING_SAMPLES = 10;
@@ -551,7 +933,20 @@ function trainingTableName(workspace) {
551
933
  const safe = workspace ? workspace.replace(/[^a-zA-Z0-9_]/g, "_") : "";
552
934
  return safe ? `${TRAINING_TABLE_PREFIX}_${safe}` : TRAINING_TABLE_PREFIX;
553
935
  }
554
- function createExtensions(db, embeddings, client, _graphName, workspace, _collectionName = DEFAULT_COLLECTION) {
936
+ async function ensureCandidateVector(candidate, embeddings) {
937
+ if (Array.isArray(candidate.vector) && candidate.vector.length > 0) {
938
+ return candidate.vector;
939
+ }
940
+ if (!candidate.text)
941
+ return [];
942
+ try {
943
+ return await embeddings.embed(candidate.text);
944
+ }
945
+ catch {
946
+ return [];
947
+ }
948
+ }
949
+ function createExtensions(db, embeddings, client, _graphName, workspace) {
555
950
  const modelName = relevanceModelName(workspace);
556
951
  const stagingTable = trainingTableName(workspace);
557
952
  async function modelExists(name) {
@@ -566,19 +961,16 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
566
961
  return {
567
962
  async recallFiltered(options) {
568
963
  const limit = options.limit ?? 5;
569
- const vector = await embeddings.embed(options.semantic);
570
- return db.searchFiltered(vector, options.where, limit);
964
+ return db.searchFiltered(options.semantic, options.where, limit);
571
965
  },
572
966
  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.
967
+ // The gateway derives `SIMILAR_TO` synthetically at MATCH time from
968
+ // the graph backend's vector index on the `embedding` property.
969
+ // `linkSimilarMemories` populates that index on every capture by
970
+ // posting a `Memory` graph node carrying the embedding. With nodes
971
+ // in place, this method composes a Cypher MATCH that walks
972
+ // SIMILAR_TO (plus any non-synthetic edges the caller named via
973
+ // `edgeKinds`) and returns the neighborhood.
582
974
  //
583
975
  // Wire constraints:
584
976
  // - Multi-hop `[:SIMILAR_TO*1..N]` is rejected — SIMILAR_TO is
@@ -613,16 +1005,38 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
613
1005
  }
614
1006
  let result;
615
1007
  try {
616
- result = await client.graph.cypher(cypher);
1008
+ // SDK 0.5.0 no longer exposes `client.graph.cypher` — the
1009
+ // canonical path is `POST /v1/graph/match` with `{sql: <cypher>}`.
1010
+ // We reach through the SDK's underlying axios instance via
1011
+ // `_getHttpClient()`, the same accessor `linkSimilarMemories`
1012
+ // uses for the `/graph/nodes` workaround.
1013
+ const http = client._getHttpClient();
1014
+ const response = await http.post("/graph/match", { sql: cypher });
1015
+ const body = (response?.data ?? {});
1016
+ result = body;
617
1017
  }
618
1018
  catch (err) {
619
1019
  const msg = err?.message ?? String(err);
620
1020
  throw new Error(`memory-synapcores.recallRelated: graph query failed for edge kind '${safeKind}': ${msg}`);
621
1021
  }
622
- // The SDK normalises responses to { columns, rows, records }.
623
- // Prefer `records` (column-keyed) and fall back to `rows`.
1022
+ // Gateway returns `{ columns, rows, count }`. Older SDK shims
1023
+ // also emitted `records` (column-keyed objects); we honour both.
624
1024
  const records = Array.isArray(result?.records) ? result.records : [];
625
- const rows = Array.isArray(result?.rows) ? result.rows : [];
1025
+ const cols = Array.isArray(result?.columns) ? result.columns : [];
1026
+ const rawRows = Array.isArray(result?.rows) ? result.rows : [];
1027
+ // Synthesize `records` from `(columns, rows)` if not provided.
1028
+ const synthRecords = records.length > 0
1029
+ ? records
1030
+ : rawRows
1031
+ .filter((r) => Array.isArray(r))
1032
+ .map((r) => {
1033
+ const obj = {};
1034
+ for (let i = 0; i < cols.length; i++) {
1035
+ obj[cols[i]] = r[i];
1036
+ }
1037
+ return obj;
1038
+ });
1039
+ const rows = rawRows;
626
1040
  const harvest = (node) => {
627
1041
  if (!node || typeof node !== "object")
628
1042
  return;
@@ -647,7 +1061,7 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
647
1061
  via: [safeKind],
648
1062
  });
649
1063
  };
650
- for (const rec of records) {
1064
+ for (const rec of synthRecords) {
651
1065
  // records: { related: <node> }
652
1066
  harvest(rec?.related ?? Object.values(rec ?? {})[0]);
653
1067
  }
@@ -666,7 +1080,11 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
666
1080
  return [];
667
1081
  const queryVector = await embeddings.embed(query);
668
1082
  const now = Date.now();
669
- const features = candidates.map((c) => buildRelevanceFeatures(queryVector, c, now));
1083
+ // v0.4.0: MEMORY_RECALL doesn't return embeddings, so candidates
1084
+ // surfaced via the core recall path arrive with `vector: []`. We
1085
+ // lazily embed the candidate text when we need a cosine feature.
1086
+ const candidateVectors = await Promise.all(candidates.map((c) => ensureCandidateVector(c, embeddings)));
1087
+ const features = candidates.map((c, i) => buildRelevanceFeatures(queryVector, { ...c, vector: candidateVectors[i] }, now));
670
1088
  // Try model mode; on any failure (no model, transport error) fall
671
1089
  // back to the heuristic so the caller never gets an empty result.
672
1090
  if (await modelExists(modelName)) {
@@ -697,11 +1115,9 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
697
1115
  if (!Array.isArray(feedback) || feedback.length < MIN_TRAINING_SAMPLES) {
698
1116
  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
1117
  }
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:
1118
+ // Stage rows in a SQL table the gateway's AutoML can `SELECT * FROM`.
1119
+ // The gateway requires the data to land in a real collection/table
1120
+ // before training. We:
705
1121
  // 1. Hydrate each feedback row's memory into a full feature
706
1122
  // vector (cosine, age_days, importance, one-hot category) via
707
1123
  // the same `buildRelevanceFeatures` helper `predictRelevance`
@@ -714,7 +1130,8 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
714
1130
  // a soft warning to the caller via the throw message); training
715
1131
  // proceeds with the remainder.
716
1132
  // Hydrate feedback rows. Embed each query text once; pair with
717
- // the stored Memory's vector to get the cosine feature.
1133
+ // the stored Memory's content (re-embedded via engine-native
1134
+ // `client.embed()`) to get the cosine feature.
718
1135
  const hydrated = [];
719
1136
  let missing = 0;
720
1137
  for (const fb of feedback) {
@@ -723,8 +1140,9 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
723
1140
  missing++;
724
1141
  continue;
725
1142
  }
1143
+ const memVector = await ensureCandidateVector(mem, embeddings);
726
1144
  const queryVec = await embeddings.embed(fb.queryText);
727
- const feats = buildRelevanceFeatures(queryVec, mem);
1145
+ const feats = buildRelevanceFeatures(queryVec, { ...mem, vector: memVector });
728
1146
  hydrated.push({
729
1147
  cosine: feats.asRecord.cosine,
730
1148
  age_days: feats.asRecord.age_days,
@@ -837,24 +1255,25 @@ const memoryPlugin = {
837
1255
  configSchema: memoryConfigSchema,
838
1256
  register(api) {
839
1257
  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.
1258
+ const namespace = cfg.collection ?? DEFAULT_COLLECTION;
1259
+ // The new MemoryClient enforces namespace ^[A-Za-z_][A-Za-z0-9_]*$. Fail
1260
+ // loud and early if the OpenClaw config's `collection` value would be
1261
+ // rejected.
1262
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(namespace)) {
1263
+ throw new Error(`memory-synapcores: collection '${namespace}' is not a valid namespace ` +
1264
+ `(must match /^[A-Za-z_][A-Za-z0-9_]*$/). Update plugins.entries.memory-synapcores.config.collection.`);
1265
+ }
846
1266
  const client = new SynapCores({
847
1267
  host: cfg.synapcores.host,
848
1268
  port: cfg.synapcores.port,
849
1269
  useHttps: cfg.synapcores.useHttps,
850
1270
  apiKey: cfg.synapcores.apiKey,
851
1271
  });
852
- const db = new MemoryDB(client, collectionName, vectorDim);
853
- const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model);
854
- const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace, collectionName);
1272
+ const db = new MemoryDB(client, namespace);
1273
+ const embeddings = new Embeddings(client);
1274
+ const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace);
855
1275
  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)`);
1276
+ api.logger.info(`memory-synapcores: plugin registered (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, namespace: ${namespace}, autoLinkSimilar: ${autoLinkSimilar}, lazy init)`);
858
1277
  // ========================================================================
859
1278
  // Tools
860
1279
  // ========================================================================
@@ -868,8 +1287,7 @@ const memoryPlugin = {
868
1287
  }),
869
1288
  async execute(_toolCallId, params) {
870
1289
  const { query, limit = 5 } = params;
871
- const vector = await embeddings.embed(query);
872
- const results = await db.search(vector, limit, 0.1);
1290
+ const results = await db.searchByQuery(query, limit, 0.1);
873
1291
  if (results.length === 0) {
874
1292
  return {
875
1293
  content: [{ type: "text", text: "No relevant memories found." }],
@@ -879,7 +1297,6 @@ const memoryPlugin = {
879
1297
  const text = results
880
1298
  .map((r, i) => `${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`)
881
1299
  .join("\n");
882
- // Strip vector data for serialization (typed arrays can't be cloned)
883
1300
  const sanitizedResults = results.map((r) => ({
884
1301
  id: r.entry.id,
885
1302
  text: r.entry.text,
@@ -904,9 +1321,9 @@ const memoryPlugin = {
904
1321
  }),
905
1322
  async execute(_toolCallId, params) {
906
1323
  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);
1324
+ // Check for duplicates via the engine's recall path. The engine
1325
+ // re-embeds `text` internally so we don't have to.
1326
+ const existing = await db.searchByQuery(text, 1, 0.95);
910
1327
  if (existing.length > 0) {
911
1328
  return {
912
1329
  content: [
@@ -924,15 +1341,22 @@ const memoryPlugin = {
924
1341
  }
925
1342
  const entry = await db.store({
926
1343
  text,
927
- vector,
1344
+ vector: [],
928
1345
  importance,
929
1346
  category,
930
1347
  });
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).
1348
+ // linkSimilarMemories inserts a Memory graph node carrying an
1349
+ // engine-native embedded representation (via `client.embed()`) so
1350
+ // recallRelated has something to MATCH against. Failures are
1351
+ // non-fatal (logged in-helper).
934
1352
  if (autoLinkSimilar) {
935
- await linkSimilarMemories(entry, db, client, graphName, api.logger);
1353
+ try {
1354
+ const embedVec = await embeddings.embed(text);
1355
+ await linkSimilarMemories(entry, client, embedVec, api.logger);
1356
+ }
1357
+ catch (err) {
1358
+ api.logger.warn(`memory-synapcores: graph-node embed failed for ${entry.id}: ${String(err)} (capture still succeeded)`);
1359
+ }
936
1360
  }
937
1361
  return {
938
1362
  content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }],
@@ -958,8 +1382,7 @@ const memoryPlugin = {
958
1382
  };
959
1383
  }
960
1384
  if (query) {
961
- const vector = await embeddings.embed(query);
962
- const results = await db.search(vector, 5, 0.7);
1385
+ const results = await db.searchByQuery(query, 5, 0.7);
963
1386
  if (results.length === 0) {
964
1387
  return {
965
1388
  content: [{ type: "text", text: "No matching memories found." }],
@@ -976,7 +1399,6 @@ const memoryPlugin = {
976
1399
  const list = results
977
1400
  .map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
978
1401
  .join("\n");
979
- // Strip vector data for serialization
980
1402
  const sanitizedCandidates = results.map((r) => ({
981
1403
  id: r.entry.id,
982
1404
  text: r.entry.text,
@@ -1017,9 +1439,7 @@ const memoryPlugin = {
1017
1439
  .argument("<query>", "Search query")
1018
1440
  .option("--limit <n>", "Max results", "5")
1019
1441
  .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
1442
+ const results = await db.searchByQuery(query, parseInt(opts.limit), 0.3);
1023
1443
  const output = results.map((r) => ({
1024
1444
  id: r.entry.id,
1025
1445
  text: r.entry.text,
@@ -1047,8 +1467,7 @@ const memoryPlugin = {
1047
1467
  return;
1048
1468
  }
1049
1469
  try {
1050
- const vector = await embeddings.embed(event.prompt);
1051
- const results = await db.search(vector, 3, 0.3);
1470
+ const results = await db.searchByQuery(event.prompt, 3, 0.3);
1052
1471
  if (results.length === 0) {
1053
1472
  return;
1054
1473
  }
@@ -1075,23 +1494,19 @@ const memoryPlugin = {
1075
1494
  // Extract text content from messages (handling unknown[] type)
1076
1495
  const texts = [];
1077
1496
  for (const msg of event.messages) {
1078
- // Type guard for message object
1079
1497
  if (!msg || typeof msg !== "object") {
1080
1498
  continue;
1081
1499
  }
1082
1500
  const msgObj = msg;
1083
- // Only process user and assistant messages
1084
1501
  const role = msgObj.role;
1085
1502
  if (role !== "user" && role !== "assistant") {
1086
1503
  continue;
1087
1504
  }
1088
1505
  const content = msgObj.content;
1089
- // Handle string content directly
1090
1506
  if (typeof content === "string") {
1091
1507
  texts.push(content);
1092
1508
  continue;
1093
1509
  }
1094
- // Handle array content (content blocks)
1095
1510
  if (Array.isArray(content)) {
1096
1511
  for (const block of content) {
1097
1512
  if (block &&
@@ -1114,20 +1529,25 @@ const memoryPlugin = {
1114
1529
  let stored = 0;
1115
1530
  for (const text of toCapture.slice(0, 3)) {
1116
1531
  const category = detectCategory(text);
1117
- const vector = await embeddings.embed(text);
1118
1532
  // Check for duplicates (high similarity threshold)
1119
- const existing = await db.search(vector, 1, 0.95);
1533
+ const existing = await db.searchByQuery(text, 1, 0.95);
1120
1534
  if (existing.length > 0) {
1121
1535
  continue;
1122
1536
  }
1123
1537
  const entry = await db.store({
1124
1538
  text,
1125
- vector,
1539
+ vector: [],
1126
1540
  importance: 0.7,
1127
1541
  category,
1128
1542
  });
1129
1543
  if (autoLinkSimilar) {
1130
- await linkSimilarMemories(entry, db, client, graphName, api.logger);
1544
+ try {
1545
+ const embedVec = await embeddings.embed(text);
1546
+ await linkSimilarMemories(entry, client, embedVec, api.logger);
1547
+ }
1548
+ catch (err) {
1549
+ api.logger.warn(`memory-synapcores: graph-node embed failed for ${entry.id}: ${String(err)}`);
1550
+ }
1131
1551
  }
1132
1552
  stored++;
1133
1553
  }
@@ -1151,7 +1571,7 @@ const memoryPlugin = {
1151
1571
  // bricking the host — memory ops still lazily retry on first use.
1152
1572
  try {
1153
1573
  await preflightGateway(client);
1154
- api.logger.info(`memory-synapcores: initialized (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, collection: ${collectionName}, model: ${cfg.embedding.model})`);
1574
+ api.logger.info(`memory-synapcores: initialized (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, namespace: ${namespace}, embeddings: engine-native)`);
1155
1575
  }
1156
1576
  catch (err) {
1157
1577
  api.logger.warn(`memory-synapcores: ${String(err?.message ?? err)}`);