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