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