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