@synapcores/openclaw-memory 0.2.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,232 +2,492 @@
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
- import { stringEnum } from "openclaw/plugin-sdk";
22
+ import { stringEnum } from "openclaw/plugin-sdk/core";
23
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
21
24
  import { SynapCores } from "@synapcores/sdk";
22
25
  import { MEMORY_CATEGORIES, memoryConfigSchema, vectorDimsForModel, } from "./config.js";
23
26
  // ============================================================================
24
- // SynapCores Provider
27
+ // Constants
25
28
  // ============================================================================
26
29
  const DEFAULT_COLLECTION = "openclaw_memories";
30
+ // ============================================================================
31
+ // Connection preflight
32
+ //
33
+ // This plugin is a CLIENT — it never installs or starts a SynapCores database.
34
+ // The most common first-run failure is "installed the plugin, but the gateway
35
+ // isn't running", which otherwise surfaces as a raw ECONNREFUSED. Preflight
36
+ // turns that into an actionable message pointing at the installer + admin UI.
37
+ // ============================================================================
38
+ const PREFLIGHT_INSTALL_HINT = "This plugin needs a running SynapCores gateway — it does not install one. " +
39
+ "Install + start the free Community Edition:\n" +
40
+ " curl -fsSL https://synapcores.com/install.sh | sh\n" +
41
+ " synapcores start\n" +
42
+ "Docs: https://synapcores.com/install";
43
+ function httpStatusOf(err) {
44
+ const e = err;
45
+ return e?.status ?? e?.statusCode ?? e?.response?.status;
46
+ }
47
+ // Raw transport-error fallback (used if the error isn't one of the SDK's typed
48
+ // classes — e.g. a bare fetch/undici error).
49
+ function isRawConnError(err) {
50
+ const code = String(err?.code ?? "");
51
+ const msg = String(err?.message ?? err ?? "");
52
+ return ([
53
+ "ECONNREFUSED",
54
+ "ENOTFOUND",
55
+ "ETIMEDOUT",
56
+ "ECONNRESET",
57
+ "EAI_AGAIN",
58
+ "EHOSTUNREACH",
59
+ "EHOSTDOWN",
60
+ ].includes(code) ||
61
+ /ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|getaddrinfo|fetch failed|network error|socket hang up|Failed to connect to SynapCores|Connection refused/i.test(msg));
62
+ }
63
+ /**
64
+ * Classify a gateway error. The @synapcores/sdk normalises failures into typed
65
+ * errors that carry a stable `.code` ("CONNECTION_ERROR" / "AUTH_ERROR") and a
66
+ * `.name` ("ConnectionError" / "AuthenticationError" / "TimeoutError"); we key
67
+ * off those first, then fall back to raw transport heuristics + HTTP status.
68
+ */
69
+ function classifyGatewayError(err) {
70
+ const code = String(err?.code ?? "");
71
+ const name = String(err?.name ?? "");
72
+ const status = httpStatusOf(err);
73
+ if (code === "CONNECTION_ERROR" ||
74
+ code === "TIMEOUT" ||
75
+ name === "ConnectionError" ||
76
+ name === "TimeoutError" ||
77
+ isRawConnError(err)) {
78
+ return "connection";
79
+ }
80
+ if (code === "AUTH_ERROR" || name === "AuthenticationError" || status === 401 || status === 403) {
81
+ return "auth";
82
+ }
83
+ return "other";
84
+ }
27
85
  /**
28
- * MemoryDB thin wrapper around `@synapcores/sdk@^0.4.0`'s
29
- * `VectorCollection` handle. v0.2.0 dropped the direct
30
- * `_getHttpClient().post('/vectors/collections/...')` workaround that
31
- * v0.1.0 needed against SDK 0.3.x — SDK 0.4.0 ships `createVectorCollection`
32
- * and `vectorCollection(name)` that target `/v1/vectors/collections/...`
33
- * directly, so the plugin no longer has to bypass the SDK.
86
+ * One lightweight round-trip to confirm the gateway is reachable AND the API
87
+ * key is accepted, before any memory operation runs. Throws an Error with an
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.
34
93
  */
94
+ async function preflightGateway(client) {
95
+ let host = "localhost";
96
+ let port = 8080;
97
+ let scheme = "http";
98
+ try {
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
+ }
105
+ }
106
+ catch {
107
+ // _getConfig is best-effort — only used to make the message specific.
108
+ }
109
+ const url = `${scheme}://${host}:${port}`;
110
+ try {
111
+ await client.executeQuery({ sql: "SELECT 1" });
112
+ }
113
+ catch (err) {
114
+ const kind = classifyGatewayError(err);
115
+ if (kind === "connection") {
116
+ throw new Error(`Cannot reach the SynapCores gateway at ${url}. ${PREFLIGHT_INSTALL_HINT}`);
117
+ }
118
+ if (kind === "auth") {
119
+ throw new Error(`The SynapCores gateway at ${url} rejected the API key. ` +
120
+ `Create a FullAccess key in the admin UI (http://${host}:8095) and set ` +
121
+ `synapcores.apiKey (or the SYNAPCORES_API_KEY env var).`);
122
+ }
123
+ throw new Error(`SynapCores preflight against ${url} failed: ${String(err?.message ?? err)}`);
124
+ }
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.
35
140
  class MemoryDB {
36
- collectionName;
37
- vectorDim;
141
+ namespace;
38
142
  client;
39
- vectorCollection = null;
40
- initPromise = null;
41
- constructor(client, collectionName, vectorDim) {
42
- this.collectionName = collectionName;
43
- this.vectorDim = vectorDim;
143
+ preflightPromise = null;
144
+ constructor(client, namespace) {
145
+ this.namespace = namespace;
44
146
  this.client = client;
45
147
  }
46
- async ensureInitialized() {
47
- if (this.vectorCollection) {
48
- return;
49
- }
50
- if (this.initPromise) {
51
- 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;
52
155
  }
53
- this.initPromise = this.doInitialize();
54
- return this.initPromise;
55
- }
56
- async doInitialize() {
57
- // Probe for an existing vector collection (idempotent). The SDK's
58
- // `listVectorCollections()` calls GET /v1/vectors/collections and
59
- // returns the bare array; we fall through to create() on any error.
60
- let exists = false;
156
+ this.preflightPromise = preflightGateway(this.client);
61
157
  try {
62
- const items = await this.client.listVectorCollections();
63
- if (Array.isArray(items)) {
64
- exists = items.some((it) => it?.name === this.collectionName);
65
- }
66
- }
67
- catch {
68
- // best-effort; fall through and try to create
158
+ await this.preflightPromise;
69
159
  }
70
- if (!exists) {
71
- try {
72
- this.vectorCollection = await this.client.createVectorCollection({
73
- name: this.collectionName,
74
- dimensions: this.vectorDim,
75
- distance_metric: "cosine",
76
- });
77
- return;
78
- }
79
- catch (err) {
80
- // Race with a concurrent creator — re-check before giving up.
81
- try {
82
- const items = await this.client.listVectorCollections();
83
- if (!Array.isArray(items) ||
84
- !items.some((it) => it?.name === this.collectionName)) {
85
- throw err;
86
- }
87
- }
88
- catch {
89
- throw err;
90
- }
91
- }
160
+ catch (err) {
161
+ // Reset on failure so the next call can retry.
162
+ this.preflightPromise = null;
163
+ throw err;
92
164
  }
93
- // Use the synchronous accessor — no extra round-trip.
94
- this.vectorCollection = this.client.vectorCollection(this.collectionName);
95
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
+ */
96
171
  async store(entry) {
97
- await this.ensureInitialized();
98
- const fullEntry = {
99
- ...entry,
100
- id: randomUUID(),
101
- createdAt: Date.now(),
102
- };
103
- // v0.2.0: SDK ships VectorCollection.insert(...) which posts to
104
- // /v1/vectors/collections/{name}/vectors with the {vectors: [...]}
105
- // envelope automatically. v0.1.0 had a direct _getHttpClient().post()
106
- // workaround here — deleted now that SDK 0.4.0 covers the wire.
107
- await this.vectorCollection.insert({
108
- id: fullEntry.id,
109
- values: fullEntry.vector,
172
+ await this.ensureReady();
173
+ const createdAt = Date.now();
174
+ const id = await this.client.memory.store(this.namespace, entry.text, {
110
175
  metadata: {
111
- text: fullEntry.text,
112
- importance: fullEntry.importance,
113
- category: fullEntry.category,
114
- createdAt: fullEntry.createdAt,
176
+ importance: entry.importance,
177
+ category: entry.category,
178
+ createdAt,
115
179
  },
116
180
  });
117
- return fullEntry;
181
+ return {
182
+ ...entry,
183
+ id,
184
+ createdAt,
185
+ };
118
186
  }
119
- async search(vector, limit = 5, minScore = 0.5) {
120
- await this.ensureInitialized();
121
- const hits = await this.vectorCollection.search({
122
- vector,
123
- k: limit,
124
- 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),
125
195
  });
126
- return hits.map(parseHitToResult).filter((r) => r.score >= minScore);
196
+ return records
197
+ .map((r) => recordToResult(r))
198
+ .filter((r) => r.score >= minScore);
127
199
  }
128
200
  /**
129
- * Same shape as `search`, but accepts a SQL `WHERE` clause. The SDK
130
- * forwards `filter` as-is to the gateway's `/v1/vectors/collections/{n}/search`
131
- * endpoint, which accepts either a JSON match object or `{ sql: "..." }`.
132
- * 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.
133
212
  */
134
- async searchFiltered(vector, where, limit = 5) {
135
- await this.ensureInitialized();
136
- const hits = await this.vectorCollection.search({
137
- vector,
138
- k: limit,
139
- includeMetadata: true,
140
- filter: { sql: where },
141
- });
142
- 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);
143
235
  }
144
236
  async delete(id) {
145
- await this.ensureInitialized();
146
- // Validate UUID format to prevent injection
147
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
148
- if (!uuidRegex.test(id)) {
149
- 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)}`);
150
240
  }
151
- // v0.2.0: SDK 0.4.0's VectorCollection.delete(id) hits
152
- // DELETE /v1/vectors/collections/{name}/vectors/{id}.
153
- await this.vectorCollection.delete(id);
154
- return true;
241
+ return this.client.memory.forget(this.namespace, id);
155
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
+ */
156
248
  async count() {
157
- await this.ensureInitialized();
158
- // v0.2.0: SDK 0.4.0's VectorCollection.count() probes /count first,
159
- // falls back to info().vector_count. Either way, returns a number.
249
+ await this.ensureReady();
250
+ const table = `_memory_${this.namespace}`;
160
251
  try {
161
- 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;
162
264
  }
163
- catch {
265
+ catch (err) {
266
+ if (isMissingNamespaceError(err))
267
+ return 0;
164
268
  return 0;
165
269
  }
166
270
  }
167
- /** 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
+ */
168
281
  async get(id) {
169
- await this.ensureInitialized();
170
- // v0.2.0: SDK 0.4.0's VectorCollection.get(id) returns
171
- // { id, values, metadata } | null. v0.1.0 had a direct
172
- // _getHttpClient().get() workaround here — deleted.
173
- let hit = null;
282
+ await this.ensureReady();
283
+ if (typeof id !== "string" || id.length === 0)
284
+ return null;
174
285
  try {
175
- 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;
176
295
  }
177
296
  catch (err) {
178
- const e = err;
179
- if (e?.code === "NOT_FOUND" || e?.status === 404)
297
+ if (isMissingNamespaceError(err))
180
298
  return null;
181
299
  throw err;
182
300
  }
183
- if (!hit)
184
- return null;
185
- const meta = hit.metadata ?? {};
186
- return {
187
- id: String(hit.id ?? id),
188
- text: typeof meta.text === "string" ? meta.text : "",
189
- vector: Array.isArray(hit.values) ? hit.values : [],
190
- importance: typeof meta.importance === "number" ? meta.importance : 0,
191
- category: meta.category ?? "other",
192
- createdAt: typeof meta.createdAt === "number" ? meta.createdAt : 0,
193
- };
194
301
  }
302
+ /** Expose the namespace (used by recallRelated for graph queries). */
303
+ get ns() {
304
+ return this.namespace;
305
+ }
306
+ }
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];
195
321
  }
196
- function hitToEntry(hit) {
197
- // SDK 0.4.0 VectorCollection.search returns the gateway's hit shape:
198
- // { id, score, values?, metadata: { text, importance, category, createdAt } }
199
- const meta = hit.metadata ?? {};
200
- const text = typeof meta.text === "string" ? meta.text : "";
201
- const importance = typeof meta.importance === "number" ? meta.importance : 0;
202
- const category = meta.category ?? "other";
203
- const createdAt = typeof meta.createdAt === "number" ? meta.createdAt : 0;
204
- const vector = Array.isArray(hit.values) ? hit.values : [];
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;
205
330
  return {
206
- id: String(hit.id ?? ""),
207
- text,
208
- vector,
209
- importance,
210
- category,
211
- 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,
212
343
  };
213
344
  }
214
- function parseHitToResult(hit) {
215
- // Gateway v1.6.5.2-ce returns cosine **distance** as `score` (lower =
216
- // closer; 0 = identical, 1 = orthogonal, 2 = opposite). Convert to a
217
- // [0, 1] similarity for the public API. If a separate `distance` field
218
- // ever appears we honour it for parity.
219
- const rawScore = typeof hit.score === "number" ? hit.score : undefined;
220
- const rawDistance = typeof hit.distance === "number" ? hit.distance : undefined;
221
- const distance = rawDistance ?? rawScore ?? 0;
222
- const similarity = Math.max(0, Math.min(1, 1 - distance));
345
+ function recordToResult(record) {
223
346
  return {
224
- entry: hitToEntry(hit),
225
- score: similarity,
347
+ entry: recordToEntry(record),
348
+ score: clamp01(record.similarity),
226
349
  };
227
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
+ }
228
478
  // ============================================================================
229
479
  // OpenAI Embeddings
230
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.
231
491
  class Embeddings {
232
492
  model;
233
493
  client;
@@ -361,11 +621,12 @@ function detectCategory(text) {
361
621
  /**
362
622
  * Escape a string literal for safe inlining into a Cypher query.
363
623
  *
364
- * Gateway v1.6.5.2-ce explicitly rejects named-parameter bindings (`$param`)
365
- * with HTTP 400; the supported path is to inline literal values into the
366
- * query string. Memory IDs flow in from `randomUUID()` so they are normally
367
- * safe, BUT we never trust upstream input — every string that ends up
368
- * 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.
369
630
  *
370
631
  * Escapes single quotes (`'` -> `\'`) and backslashes (`\` -> `\\`).
371
632
  * Returns the inner content only; callers are responsible for the
@@ -385,47 +646,44 @@ const MAX_HOPS = 4;
385
646
  // Default result cap for recallRelated.
386
647
  const DEFAULT_RECALL_RELATED_LIMIT = 20;
387
648
  /**
388
- * `linkSimilarMemories` — capture-time graph wiring (v0.2.0).
649
+ * `linkSimilarMemories` — capture-time graph wiring.
389
650
  *
390
- * v0.1.0 was a no-op: it tried to write explicit `SIMILAR_TO` edges via
391
- * Cypher `MERGE`, which gateway v1.6.5.x rejects because SIMILAR_TO is a
392
- * synthetic / derived edge type computed from the graph backend's vector
393
- * index at MATCH time.
394
- *
395
- * v0.2.0 takes the supported path instead: insert the Memory as a graph
396
- * node carrying the embedding under the property name `embedding` — the
397
- * 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
398
654
  * `aidb_gateway::routes::graph::attach_default_vector_index`). Once a
399
655
  * Memory node exists, `recallRelated` can MATCH `[:SIMILAR_TO > T]`
400
656
  * against it and get neighbors back without any pre-stored edges.
401
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
+ *
402
663
  * Failures here are non-fatal — the capture itself still succeeded in the
403
- * 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.
404
666
  */
405
- async function linkSimilarMemories(entry, _db, client, _graphName, logger) {
667
+ async function linkSimilarMemories(entry, client, embedVector, logger) {
406
668
  try {
407
- // Insert a Memory node with the embedding so the synthetic
408
- // SIMILAR_TO edge in `recallRelated` has something to match against.
409
- // The `id` property mirrors the vector-collection id so callers can
410
- // join the two views.
411
- //
412
- // KNOWN SDK GAP (@synapcores/sdk@0.4.0):
669
+ // KNOWN SDK GAP (@synapcores/sdk@<=0.5.0):
413
670
  // `client.graph.nodes.create(label, props)` posts
414
671
  // `{label: <single>, properties}` but the gateway's
415
672
  // /v1/graph/nodes handler expects `{labels: <array>, properties}`
416
673
  // (see aidb_gateway::routes::graph::CreateNodeRequest). The result
417
674
  // is a node with `labels: []`, which never matches the `Memory`
418
675
  // label filter in MATCH. We bypass the SDK helper for THIS one
419
- // call and post the correct wire shape ourselves. Once SDK >0.4.0
420
- // fixes `GraphNodeApi.create` to send `labels`, this `_getHttpClient`
421
- // 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(...)`.
422
680
  const http = client._getHttpClient();
423
681
  await http.post("/graph/nodes", {
424
682
  labels: ["Memory"],
425
683
  properties: {
426
684
  id: entry.id,
427
685
  text: entry.text,
428
- embedding: entry.vector,
686
+ embedding: embedVector,
429
687
  importance: entry.importance,
430
688
  category: entry.category,
431
689
  createdAt: entry.createdAt,
@@ -440,7 +698,7 @@ async function linkSimilarMemories(entry, _db, client, _graphName, logger) {
440
698
  }
441
699
  }
442
700
  // ============================================================================
443
- // AutoML helpers — staged-collection training (v0.2.0)
701
+ // AutoML helpers — staged-collection training
444
702
  // ============================================================================
445
703
  const DEFAULT_RELEVANCE_MODEL = "openclaw_memory_relevance";
446
704
  const MIN_TRAINING_SAMPLES = 10;
@@ -455,7 +713,20 @@ function trainingTableName(workspace) {
455
713
  const safe = workspace ? workspace.replace(/[^a-zA-Z0-9_]/g, "_") : "";
456
714
  return safe ? `${TRAINING_TABLE_PREFIX}_${safe}` : TRAINING_TABLE_PREFIX;
457
715
  }
458
- 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) {
459
730
  const modelName = relevanceModelName(workspace);
460
731
  const stagingTable = trainingTableName(workspace);
461
732
  async function modelExists(name) {
@@ -470,19 +741,16 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
470
741
  return {
471
742
  async recallFiltered(options) {
472
743
  const limit = options.limit ?? 5;
473
- const vector = await embeddings.embed(options.semantic);
474
- return db.searchFiltered(vector, options.where, limit);
744
+ return db.searchFiltered(options.semantic, options.where, limit);
475
745
  },
476
746
  async recallRelated(memoryId, options = {}) {
477
- // v0.2.0 implementation:
478
- //
479
- // Gateway v1.6.5.x derives `SIMILAR_TO` synthetically at MATCH
480
- // time from the graph backend's vector index on the `embedding`
481
- // property. `linkSimilarMemories` populates that index on every
482
- // capture by posting a `Memory` graph node carrying the embedding.
483
- // With nodes in place, this method composes a Cypher MATCH that
484
- // walks SIMILAR_TO (plus any non-synthetic edges the caller named
485
- // 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.
486
754
  //
487
755
  // Wire constraints:
488
756
  // - Multi-hop `[:SIMILAR_TO*1..N]` is rejected — SIMILAR_TO is
@@ -517,16 +785,38 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
517
785
  }
518
786
  let result;
519
787
  try {
520
- 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;
521
797
  }
522
798
  catch (err) {
523
799
  const msg = err?.message ?? String(err);
524
800
  throw new Error(`memory-synapcores.recallRelated: graph query failed for edge kind '${safeKind}': ${msg}`);
525
801
  }
526
- // The SDK normalises responses to { columns, rows, records }.
527
- // 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.
528
804
  const records = Array.isArray(result?.records) ? result.records : [];
529
- 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;
530
820
  const harvest = (node) => {
531
821
  if (!node || typeof node !== "object")
532
822
  return;
@@ -551,7 +841,7 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
551
841
  via: [safeKind],
552
842
  });
553
843
  };
554
- for (const rec of records) {
844
+ for (const rec of synthRecords) {
555
845
  // records: { related: <node> }
556
846
  harvest(rec?.related ?? Object.values(rec ?? {})[0]);
557
847
  }
@@ -570,7 +860,11 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
570
860
  return [];
571
861
  const queryVector = await embeddings.embed(query);
572
862
  const now = Date.now();
573
- 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));
574
868
  // Try model mode; on any failure (no model, transport error) fall
575
869
  // back to the heuristic so the caller never gets an empty result.
576
870
  if (await modelExists(modelName)) {
@@ -601,11 +895,9 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
601
895
  if (!Array.isArray(feedback) || feedback.length < MIN_TRAINING_SAMPLES) {
602
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})`);
603
897
  }
604
- // v0.2.0 implementation: stage rows in a SQL table the gateway's
605
- // AutoML can `SELECT * FROM`. Gateway v1.6.5.2-ce rejects
606
- // `config.inline_rows` (the workflow v0.1.0 attempted) and
607
- // requires the data to land in a real collection/table before
608
- // 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:
609
901
  // 1. Hydrate each feedback row's memory into a full feature
610
902
  // vector (cosine, age_days, importance, one-hot category) via
611
903
  // the same `buildRelevanceFeatures` helper `predictRelevance`
@@ -618,7 +910,8 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
618
910
  // a soft warning to the caller via the throw message); training
619
911
  // proceeds with the remainder.
620
912
  // Hydrate feedback rows. Embed each query text once; pair with
621
- // 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.
622
915
  const hydrated = [];
623
916
  let missing = 0;
624
917
  for (const fb of feedback) {
@@ -627,8 +920,9 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
627
920
  missing++;
628
921
  continue;
629
922
  }
923
+ const memVector = await ensureCandidateVector(mem, embeddings);
630
924
  const queryVec = await embeddings.embed(fb.queryText);
631
- const feats = buildRelevanceFeatures(queryVec, mem);
925
+ const feats = buildRelevanceFeatures(queryVec, { ...mem, vector: memVector });
632
926
  hydrated.push({
633
927
  cosine: feats.asRecord.cosine,
634
928
  age_days: feats.asRecord.age_days,
@@ -741,24 +1035,29 @@ const memoryPlugin = {
741
1035
  configSchema: memoryConfigSchema,
742
1036
  register(api) {
743
1037
  const cfg = memoryConfigSchema.parse(api.pluginConfig);
744
- const vectorDim = vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small");
745
- const collectionName = cfg.collection ?? DEFAULT_COLLECTION;
746
- // v0.2.0: pass apiKey straight through. @synapcores/sdk@0.4.0
747
- // routes both apiKey AND jwtToken via `Authorization: Bearer`, so
748
- // the v0.1.0 shim that re-routed `aidb_*` / `ak_*` keys through
749
- // `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
+ }
750
1050
  const client = new SynapCores({
751
1051
  host: cfg.synapcores.host,
752
1052
  port: cfg.synapcores.port,
753
1053
  useHttps: cfg.synapcores.useHttps,
754
1054
  apiKey: cfg.synapcores.apiKey,
755
1055
  });
756
- const db = new MemoryDB(client, collectionName, vectorDim);
1056
+ const db = new MemoryDB(client, namespace);
757
1057
  const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model);
758
- const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace, collectionName);
1058
+ const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace);
759
1059
  const autoLinkSimilar = cfg.autoLinkSimilar !== false;
760
- const graphName = cfg.graph;
761
- 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)`);
762
1061
  // ========================================================================
763
1062
  // Tools
764
1063
  // ========================================================================
@@ -772,8 +1071,7 @@ const memoryPlugin = {
772
1071
  }),
773
1072
  async execute(_toolCallId, params) {
774
1073
  const { query, limit = 5 } = params;
775
- const vector = await embeddings.embed(query);
776
- const results = await db.search(vector, limit, 0.1);
1074
+ const results = await db.searchByQuery(query, limit, 0.1);
777
1075
  if (results.length === 0) {
778
1076
  return {
779
1077
  content: [{ type: "text", text: "No relevant memories found." }],
@@ -783,7 +1081,6 @@ const memoryPlugin = {
783
1081
  const text = results
784
1082
  .map((r, i) => `${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`)
785
1083
  .join("\n");
786
- // Strip vector data for serialization (typed arrays can't be cloned)
787
1084
  const sanitizedResults = results.map((r) => ({
788
1085
  id: r.entry.id,
789
1086
  text: r.entry.text,
@@ -808,9 +1105,9 @@ const memoryPlugin = {
808
1105
  }),
809
1106
  async execute(_toolCallId, params) {
810
1107
  const { text, importance = 0.7, category = "other", } = params;
811
- const vector = await embeddings.embed(text);
812
- // Check for duplicates
813
- 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);
814
1111
  if (existing.length > 0) {
815
1112
  return {
816
1113
  content: [
@@ -828,15 +1125,21 @@ const memoryPlugin = {
828
1125
  }
829
1126
  const entry = await db.store({
830
1127
  text,
831
- vector,
1128
+ vector: [],
832
1129
  importance,
833
1130
  category,
834
1131
  });
835
- // v0.2.0: linkSimilarMemories now inserts a Memory graph node
836
- // carrying the embedding so recallRelated has something to
837
- // 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).
838
1135
  if (autoLinkSimilar) {
839
- 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
+ }
840
1143
  }
841
1144
  return {
842
1145
  content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }],
@@ -862,8 +1165,7 @@ const memoryPlugin = {
862
1165
  };
863
1166
  }
864
1167
  if (query) {
865
- const vector = await embeddings.embed(query);
866
- const results = await db.search(vector, 5, 0.7);
1168
+ const results = await db.searchByQuery(query, 5, 0.7);
867
1169
  if (results.length === 0) {
868
1170
  return {
869
1171
  content: [{ type: "text", text: "No matching memories found." }],
@@ -880,7 +1182,6 @@ const memoryPlugin = {
880
1182
  const list = results
881
1183
  .map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
882
1184
  .join("\n");
883
- // Strip vector data for serialization
884
1185
  const sanitizedCandidates = results.map((r) => ({
885
1186
  id: r.entry.id,
886
1187
  text: r.entry.text,
@@ -921,9 +1222,7 @@ const memoryPlugin = {
921
1222
  .argument("<query>", "Search query")
922
1223
  .option("--limit <n>", "Max results", "5")
923
1224
  .action(async (query, opts) => {
924
- const vector = await embeddings.embed(query);
925
- const results = await db.search(vector, parseInt(opts.limit), 0.3);
926
- // Strip vectors for output
1225
+ const results = await db.searchByQuery(query, parseInt(opts.limit), 0.3);
927
1226
  const output = results.map((r) => ({
928
1227
  id: r.entry.id,
929
1228
  text: r.entry.text,
@@ -951,8 +1250,7 @@ const memoryPlugin = {
951
1250
  return;
952
1251
  }
953
1252
  try {
954
- const vector = await embeddings.embed(event.prompt);
955
- const results = await db.search(vector, 3, 0.3);
1253
+ const results = await db.searchByQuery(event.prompt, 3, 0.3);
956
1254
  if (results.length === 0) {
957
1255
  return;
958
1256
  }
@@ -979,23 +1277,19 @@ const memoryPlugin = {
979
1277
  // Extract text content from messages (handling unknown[] type)
980
1278
  const texts = [];
981
1279
  for (const msg of event.messages) {
982
- // Type guard for message object
983
1280
  if (!msg || typeof msg !== "object") {
984
1281
  continue;
985
1282
  }
986
1283
  const msgObj = msg;
987
- // Only process user and assistant messages
988
1284
  const role = msgObj.role;
989
1285
  if (role !== "user" && role !== "assistant") {
990
1286
  continue;
991
1287
  }
992
1288
  const content = msgObj.content;
993
- // Handle string content directly
994
1289
  if (typeof content === "string") {
995
1290
  texts.push(content);
996
1291
  continue;
997
1292
  }
998
- // Handle array content (content blocks)
999
1293
  if (Array.isArray(content)) {
1000
1294
  for (const block of content) {
1001
1295
  if (block &&
@@ -1018,20 +1312,25 @@ const memoryPlugin = {
1018
1312
  let stored = 0;
1019
1313
  for (const text of toCapture.slice(0, 3)) {
1020
1314
  const category = detectCategory(text);
1021
- const vector = await embeddings.embed(text);
1022
1315
  // Check for duplicates (high similarity threshold)
1023
- const existing = await db.search(vector, 1, 0.95);
1316
+ const existing = await db.searchByQuery(text, 1, 0.95);
1024
1317
  if (existing.length > 0) {
1025
1318
  continue;
1026
1319
  }
1027
1320
  const entry = await db.store({
1028
1321
  text,
1029
- vector,
1322
+ vector: [],
1030
1323
  importance: 0.7,
1031
1324
  category,
1032
1325
  });
1033
1326
  if (autoLinkSimilar) {
1034
- 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
+ }
1035
1334
  }
1036
1335
  stored++;
1037
1336
  }
@@ -1049,8 +1348,18 @@ const memoryPlugin = {
1049
1348
  // ========================================================================
1050
1349
  api.registerService({
1051
1350
  id: "memory-synapcores",
1052
- start: () => {
1053
- api.logger.info(`memory-synapcores: initialized (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, collection: ${collectionName}, model: ${cfg.embedding.model})`);
1351
+ start: async () => {
1352
+ // Best-effort startup probe: surface a DB-down / bad-key problem
1353
+ // immediately in the logs (and `openclaw plugins doctor`) without
1354
+ // bricking the host — memory ops still lazily retry on first use.
1355
+ try {
1356
+ await preflightGateway(client);
1357
+ api.logger.info(`memory-synapcores: initialized (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, namespace: ${namespace}, model: ${cfg.embedding.model})`);
1358
+ }
1359
+ catch (err) {
1360
+ api.logger.warn(`memory-synapcores: ${String(err?.message ?? err)}`);
1361
+ api.logger.warn("memory-synapcores: continuing with lazy init — memory operations will retry on first use.");
1362
+ }
1054
1363
  },
1055
1364
  stop: () => {
1056
1365
  api.logger.info("memory-synapcores: stopped");
@@ -1063,9 +1372,14 @@ const memoryPlugin = {
1063
1372
  // reach `recallFiltered` / `recallRelated` / `predictRelevance` /
1064
1373
  // `trainRelevanceModel` via the OpenClaw plugin registry
1065
1374
  // (e.g. `plugin.extensions.recallFiltered`).
1066
- memoryPlugin.extensions =
1067
- extensions;
1375
+ //
1376
+ // NOTE: attach to `pluginEntry` (the definePluginEntry result that is the
1377
+ // default export), NOT the inner `memoryPlugin` — definePluginEntry returns
1378
+ // a fresh normalized object, so attaching to the inner one would hide the
1379
+ // extensions from every consumer of the loaded plugin.
1380
+ pluginEntry.extensions = extensions;
1068
1381
  },
1069
1382
  };
1070
- export default memoryPlugin;
1383
+ const pluginEntry = definePluginEntry(memoryPlugin);
1384
+ export default pluginEntry;
1071
1385
  //# sourceMappingURL=index.js.map