@synapcores/openclaw-memory 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -25
- 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 +58 -104
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +576 -262
- package/dist/index.js.map +1 -1
- package/openclaw.plugin.json +8 -0
- package/package.json +23 -5
package/dist/index.js
CHANGED
|
@@ -2,232 +2,492 @@
|
|
|
2
2
|
* OpenClaw Memory (SynapCores) Plugin
|
|
3
3
|
*
|
|
4
4
|
* Long-term memory with vector search for AI conversations.
|
|
5
|
-
* Uses SynapCores AIDB for storage
|
|
5
|
+
* Uses SynapCores AIDB for storage via the engine-side
|
|
6
|
+
* `MEMORY_STORE` / `MEMORY_RECALL` / `MEMORY_FORGET` primitives and
|
|
7
|
+
* OpenAI for the relevance-extension features that still need a
|
|
8
|
+
* client-side embedding (cosine feature in `predictRelevance`,
|
|
9
|
+
* graph-node embedding when `autoLinkSimilar` is on).
|
|
10
|
+
*
|
|
6
11
|
* Provides seamless auto-recall and auto-capture via lifecycle hooks,
|
|
7
|
-
* plus
|
|
8
|
-
* walks,
|
|
9
|
-
* `recallRelated`,
|
|
12
|
+
* plus four SynapCores-only extensions (SQL-filtered recall, graph-relation
|
|
13
|
+
* walks, AutoML relevance scoring, and a model-training helper) — see
|
|
14
|
+
* `recallFiltered`, `recallRelated`, `predictRelevance`, and
|
|
15
|
+
* `trainRelevanceModel`.
|
|
10
16
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* gateway v1.6.5.2-ce. Requires @synapcores/sdk@^0.4.0 — which added
|
|
14
|
-
* `client.vectorCollection(name)` + `client.createVectorCollection(...)`
|
|
15
|
-
* and switched API-key auth to `Authorization: Bearer`.
|
|
17
|
+
* v0.4.0 migrates the core memory ops to `@synapcores/sdk@^0.5.0`'s
|
|
18
|
+
* `client.memory` surface. Requires SynapCores gateway v1.8.5-ce+.
|
|
16
19
|
*/
|
|
17
20
|
import { Type } from "typebox";
|
|
18
|
-
import { randomUUID } from "node:crypto";
|
|
19
21
|
import OpenAI from "openai";
|
|
20
|
-
import { stringEnum } from "openclaw/plugin-sdk";
|
|
22
|
+
import { stringEnum } from "openclaw/plugin-sdk/core";
|
|
23
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
21
24
|
import { SynapCores } from "@synapcores/sdk";
|
|
22
25
|
import { MEMORY_CATEGORIES, memoryConfigSchema, vectorDimsForModel, } from "./config.js";
|
|
23
26
|
// ============================================================================
|
|
24
|
-
//
|
|
27
|
+
// Constants
|
|
25
28
|
// ============================================================================
|
|
26
29
|
const DEFAULT_COLLECTION = "openclaw_memories";
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Connection preflight
|
|
32
|
+
//
|
|
33
|
+
// This plugin is a CLIENT — it never installs or starts a SynapCores database.
|
|
34
|
+
// The most common first-run failure is "installed the plugin, but the gateway
|
|
35
|
+
// isn't running", which otherwise surfaces as a raw ECONNREFUSED. Preflight
|
|
36
|
+
// turns that into an actionable message pointing at the installer + admin UI.
|
|
37
|
+
// ============================================================================
|
|
38
|
+
const PREFLIGHT_INSTALL_HINT = "This plugin needs a running SynapCores gateway — it does not install one. " +
|
|
39
|
+
"Install + start the free Community Edition:\n" +
|
|
40
|
+
" curl -fsSL https://synapcores.com/install.sh | sh\n" +
|
|
41
|
+
" synapcores start\n" +
|
|
42
|
+
"Docs: https://synapcores.com/install";
|
|
43
|
+
function httpStatusOf(err) {
|
|
44
|
+
const e = err;
|
|
45
|
+
return e?.status ?? e?.statusCode ?? e?.response?.status;
|
|
46
|
+
}
|
|
47
|
+
// Raw transport-error fallback (used if the error isn't one of the SDK's typed
|
|
48
|
+
// classes — e.g. a bare fetch/undici error).
|
|
49
|
+
function isRawConnError(err) {
|
|
50
|
+
const code = String(err?.code ?? "");
|
|
51
|
+
const msg = String(err?.message ?? err ?? "");
|
|
52
|
+
return ([
|
|
53
|
+
"ECONNREFUSED",
|
|
54
|
+
"ENOTFOUND",
|
|
55
|
+
"ETIMEDOUT",
|
|
56
|
+
"ECONNRESET",
|
|
57
|
+
"EAI_AGAIN",
|
|
58
|
+
"EHOSTUNREACH",
|
|
59
|
+
"EHOSTDOWN",
|
|
60
|
+
].includes(code) ||
|
|
61
|
+
/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|getaddrinfo|fetch failed|network error|socket hang up|Failed to connect to SynapCores|Connection refused/i.test(msg));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Classify a gateway error. The @synapcores/sdk normalises failures into typed
|
|
65
|
+
* errors that carry a stable `.code` ("CONNECTION_ERROR" / "AUTH_ERROR") and a
|
|
66
|
+
* `.name` ("ConnectionError" / "AuthenticationError" / "TimeoutError"); we key
|
|
67
|
+
* off those first, then fall back to raw transport heuristics + HTTP status.
|
|
68
|
+
*/
|
|
69
|
+
function classifyGatewayError(err) {
|
|
70
|
+
const code = String(err?.code ?? "");
|
|
71
|
+
const name = String(err?.name ?? "");
|
|
72
|
+
const status = httpStatusOf(err);
|
|
73
|
+
if (code === "CONNECTION_ERROR" ||
|
|
74
|
+
code === "TIMEOUT" ||
|
|
75
|
+
name === "ConnectionError" ||
|
|
76
|
+
name === "TimeoutError" ||
|
|
77
|
+
isRawConnError(err)) {
|
|
78
|
+
return "connection";
|
|
79
|
+
}
|
|
80
|
+
if (code === "AUTH_ERROR" || name === "AuthenticationError" || status === 401 || status === 403) {
|
|
81
|
+
return "auth";
|
|
82
|
+
}
|
|
83
|
+
return "other";
|
|
84
|
+
}
|
|
27
85
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
86
|
+
* One lightweight round-trip to confirm the gateway is reachable AND the API
|
|
87
|
+
* key is accepted, before any memory operation runs. Throws an Error with an
|
|
88
|
+
* actionable message on failure; resolves on success.
|
|
89
|
+
*
|
|
90
|
+
* v0.4.0 uses `client.executeQuery({ sql: "SELECT 1" })` — the lowest-cost
|
|
91
|
+
* authenticated probe available on the gateway and the same path
|
|
92
|
+
* `client.memory.*` rides for every operation.
|
|
34
93
|
*/
|
|
94
|
+
async function preflightGateway(client) {
|
|
95
|
+
let host = "localhost";
|
|
96
|
+
let port = 8080;
|
|
97
|
+
let scheme = "http";
|
|
98
|
+
try {
|
|
99
|
+
const cfg = client._getConfig?.();
|
|
100
|
+
if (cfg) {
|
|
101
|
+
host = cfg.host ?? host;
|
|
102
|
+
port = cfg.port ?? port;
|
|
103
|
+
scheme = cfg.useHttps ? "https" : "http";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// _getConfig is best-effort — only used to make the message specific.
|
|
108
|
+
}
|
|
109
|
+
const url = `${scheme}://${host}:${port}`;
|
|
110
|
+
try {
|
|
111
|
+
await client.executeQuery({ sql: "SELECT 1" });
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const kind = classifyGatewayError(err);
|
|
115
|
+
if (kind === "connection") {
|
|
116
|
+
throw new Error(`Cannot reach the SynapCores gateway at ${url}. ${PREFLIGHT_INSTALL_HINT}`);
|
|
117
|
+
}
|
|
118
|
+
if (kind === "auth") {
|
|
119
|
+
throw new Error(`The SynapCores gateway at ${url} rejected the API key. ` +
|
|
120
|
+
`Create a FullAccess key in the admin UI (http://${host}:8095) and set ` +
|
|
121
|
+
`synapcores.apiKey (or the SYNAPCORES_API_KEY env var).`);
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`SynapCores preflight against ${url} failed: ${String(err?.message ?? err)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// MemoryDB — thin wrapper around @synapcores/sdk@0.5.0's MemoryClient.
|
|
128
|
+
// ============================================================================
|
|
129
|
+
//
|
|
130
|
+
// v0.4.0 swaps the v0.3.x `client.vectorCollection(name).insert/search/delete`
|
|
131
|
+
// path for `client.memory.store/recall/forget`. The engine now owns
|
|
132
|
+
// embedding (one round-trip instead of two), the table schema, and the
|
|
133
|
+
// vector index — the plugin's job is to translate between OpenClaw's
|
|
134
|
+
// `MemoryEntry` shape and the engine's `MemoryRecord` shape.
|
|
135
|
+
//
|
|
136
|
+
// HARD CUT MIGRATION: the engine-managed table is `_memory_<namespace>`,
|
|
137
|
+
// which is a different storage backend from the v0.3.x vector collection.
|
|
138
|
+
// Existing v0.3.x installs WILL NOT see their old memories — see the
|
|
139
|
+
// README "Migration" section.
|
|
35
140
|
class MemoryDB {
|
|
36
|
-
|
|
37
|
-
vectorDim;
|
|
141
|
+
namespace;
|
|
38
142
|
client;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
this.collectionName = collectionName;
|
|
43
|
-
this.vectorDim = vectorDim;
|
|
143
|
+
preflightPromise = null;
|
|
144
|
+
constructor(client, namespace) {
|
|
145
|
+
this.namespace = namespace;
|
|
44
146
|
this.client = client;
|
|
45
147
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
148
|
+
/**
|
|
149
|
+
* One-time gateway preflight. Lazy — only runs on the first call,
|
|
150
|
+
* matching the v0.3.x init semantics.
|
|
151
|
+
*/
|
|
152
|
+
async ensureReady() {
|
|
153
|
+
if (this.preflightPromise) {
|
|
154
|
+
return this.preflightPromise;
|
|
52
155
|
}
|
|
53
|
-
this.
|
|
54
|
-
return this.initPromise;
|
|
55
|
-
}
|
|
56
|
-
async doInitialize() {
|
|
57
|
-
// Probe for an existing vector collection (idempotent). The SDK's
|
|
58
|
-
// `listVectorCollections()` calls GET /v1/vectors/collections and
|
|
59
|
-
// returns the bare array; we fall through to create() on any error.
|
|
60
|
-
let exists = false;
|
|
156
|
+
this.preflightPromise = preflightGateway(this.client);
|
|
61
157
|
try {
|
|
62
|
-
|
|
63
|
-
if (Array.isArray(items)) {
|
|
64
|
-
exists = items.some((it) => it?.name === this.collectionName);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
// best-effort; fall through and try to create
|
|
158
|
+
await this.preflightPromise;
|
|
69
159
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
dimensions: this.vectorDim,
|
|
75
|
-
distance_metric: "cosine",
|
|
76
|
-
});
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
catch (err) {
|
|
80
|
-
// Race with a concurrent creator — re-check before giving up.
|
|
81
|
-
try {
|
|
82
|
-
const items = await this.client.listVectorCollections();
|
|
83
|
-
if (!Array.isArray(items) ||
|
|
84
|
-
!items.some((it) => it?.name === this.collectionName)) {
|
|
85
|
-
throw err;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
throw err;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
// Reset on failure so the next call can retry.
|
|
162
|
+
this.preflightPromise = null;
|
|
163
|
+
throw err;
|
|
92
164
|
}
|
|
93
|
-
// Use the synchronous accessor — no extra round-trip.
|
|
94
|
-
this.vectorCollection = this.client.vectorCollection(this.collectionName);
|
|
95
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Store a memory. `vector` on the input is IGNORED — the engine embeds
|
|
168
|
+
* `text` server-side via the configured embedding model. Returns the
|
|
169
|
+
* fully-populated entry (with engine-assigned id + timestamp).
|
|
170
|
+
*/
|
|
96
171
|
async store(entry) {
|
|
97
|
-
await this.
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
id: randomUUID(),
|
|
101
|
-
createdAt: Date.now(),
|
|
102
|
-
};
|
|
103
|
-
// v0.2.0: SDK ships VectorCollection.insert(...) which posts to
|
|
104
|
-
// /v1/vectors/collections/{name}/vectors with the {vectors: [...]}
|
|
105
|
-
// envelope automatically. v0.1.0 had a direct _getHttpClient().post()
|
|
106
|
-
// workaround here — deleted now that SDK 0.4.0 covers the wire.
|
|
107
|
-
await this.vectorCollection.insert({
|
|
108
|
-
id: fullEntry.id,
|
|
109
|
-
values: fullEntry.vector,
|
|
172
|
+
await this.ensureReady();
|
|
173
|
+
const createdAt = Date.now();
|
|
174
|
+
const id = await this.client.memory.store(this.namespace, entry.text, {
|
|
110
175
|
metadata: {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
createdAt: fullEntry.createdAt,
|
|
176
|
+
importance: entry.importance,
|
|
177
|
+
category: entry.category,
|
|
178
|
+
createdAt,
|
|
115
179
|
},
|
|
116
180
|
});
|
|
117
|
-
return
|
|
181
|
+
return {
|
|
182
|
+
...entry,
|
|
183
|
+
id,
|
|
184
|
+
createdAt,
|
|
185
|
+
};
|
|
118
186
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Semantic recall by free-text query. `minScore` is applied client-side
|
|
189
|
+
* to the engine's `similarity` field (already in [0, 1]).
|
|
190
|
+
*/
|
|
191
|
+
async searchByQuery(query, limit = 5, minScore = 0.5) {
|
|
192
|
+
await this.ensureReady();
|
|
193
|
+
const records = await this.client.memory.recall(this.namespace, query, {
|
|
194
|
+
topK: clampTopK(limit),
|
|
125
195
|
});
|
|
126
|
-
return
|
|
196
|
+
return records
|
|
197
|
+
.map((r) => recordToResult(r))
|
|
198
|
+
.filter((r) => r.score >= minScore);
|
|
127
199
|
}
|
|
128
200
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
201
|
+
* SQL-filtered recall. v0.4.0 forwards the WHERE clause through to a
|
|
202
|
+
* `SELECT … FROM MEMORY_RECALL(?, ?, ?) WHERE <where>` query so the
|
|
203
|
+
* filter runs engine-side over the recall result-set.
|
|
204
|
+
*
|
|
205
|
+
* Legacy shorthand: callers wrote v0.3.x filters like
|
|
206
|
+
* `category = 'preference' AND importance >= 0.7`. v0.4.0 stores those
|
|
207
|
+
* fields inside the JSON `metadata` column, so the bare-identifier form
|
|
208
|
+
* would no longer resolve. We rewrite the four well-known shorthands
|
|
209
|
+
* (`category`, `importance`, `createdAt`, `text`) to their JSON-extract
|
|
210
|
+
* form on the way in so most existing callers keep working unchanged.
|
|
211
|
+
* Callers that already use `metadata->>'…'` get a no-op.
|
|
133
212
|
*/
|
|
134
|
-
async searchFiltered(
|
|
135
|
-
await this.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
213
|
+
async searchFiltered(semantic, where, limit = 5) {
|
|
214
|
+
await this.ensureReady();
|
|
215
|
+
// Oversample so the post-filter still has enough rows to return.
|
|
216
|
+
const oversample = clampTopK(Math.max(limit * 5, 25));
|
|
217
|
+
const rewritten = rewriteLegacyWhere(where);
|
|
218
|
+
const sql = "SELECT id, content, similarity, metadata, created_at " +
|
|
219
|
+
`FROM MEMORY_RECALL($1, $2, $3) WHERE ${rewritten} LIMIT ${Number(limit)}`;
|
|
220
|
+
let result;
|
|
221
|
+
try {
|
|
222
|
+
result = await this.client.executeQuery({
|
|
223
|
+
sql,
|
|
224
|
+
parameters: [this.namespace, semantic, oversample],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
// Missing namespace == empty result set.
|
|
229
|
+
if (isMissingNamespaceError(err)) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
return mapRecallRows(result);
|
|
143
235
|
}
|
|
144
236
|
async delete(id) {
|
|
145
|
-
await this.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!uuidRegex.test(id)) {
|
|
149
|
-
throw new Error(`Invalid memory ID format: ${id}`);
|
|
237
|
+
await this.ensureReady();
|
|
238
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
239
|
+
throw new Error(`Invalid memory ID: ${String(id)}`);
|
|
150
240
|
}
|
|
151
|
-
|
|
152
|
-
// DELETE /v1/vectors/collections/{name}/vectors/{id}.
|
|
153
|
-
await this.vectorCollection.delete(id);
|
|
154
|
-
return true;
|
|
241
|
+
return this.client.memory.forget(this.namespace, id);
|
|
155
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Count memories in the namespace. The engine table is
|
|
245
|
+
* `_memory_<namespace>`; we count via a direct SQL probe. Best-effort —
|
|
246
|
+
* returns 0 if the namespace hasn't been written to yet.
|
|
247
|
+
*/
|
|
156
248
|
async count() {
|
|
157
|
-
await this.
|
|
158
|
-
|
|
159
|
-
// falls back to info().vector_count. Either way, returns a number.
|
|
249
|
+
await this.ensureReady();
|
|
250
|
+
const table = `_memory_${this.namespace}`;
|
|
160
251
|
try {
|
|
161
|
-
|
|
252
|
+
const result = await this.client.executeQuery({
|
|
253
|
+
sql: `SELECT COUNT(*) FROM ${table}`,
|
|
254
|
+
});
|
|
255
|
+
const first = result.rows?.[0];
|
|
256
|
+
const raw = Array.isArray(first) ? first[0] : undefined;
|
|
257
|
+
if (typeof raw === "number")
|
|
258
|
+
return raw;
|
|
259
|
+
if (typeof raw === "string") {
|
|
260
|
+
const n = Number(raw);
|
|
261
|
+
return Number.isFinite(n) ? n : 0;
|
|
262
|
+
}
|
|
263
|
+
return 0;
|
|
162
264
|
}
|
|
163
|
-
catch {
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (isMissingNamespaceError(err))
|
|
267
|
+
return 0;
|
|
164
268
|
return 0;
|
|
165
269
|
}
|
|
166
270
|
}
|
|
167
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Fetch a single memory by id. Used by extensions (e.g.
|
|
273
|
+
* `trainRelevanceModel`) to hydrate feedback rows. Returns null if not
|
|
274
|
+
* found.
|
|
275
|
+
*
|
|
276
|
+
* Strategy: oversample MEMORY_RECALL with the id as the query string
|
|
277
|
+
* (any free text works — we only care about the id-filtered result),
|
|
278
|
+
* then look up by id. This avoids reaching into the engine-managed
|
|
279
|
+
* `_memory_<ns>` table directly.
|
|
280
|
+
*/
|
|
168
281
|
async get(id) {
|
|
169
|
-
await this.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// _getHttpClient().get() workaround here — deleted.
|
|
173
|
-
let hit = null;
|
|
282
|
+
await this.ensureReady();
|
|
283
|
+
if (typeof id !== "string" || id.length === 0)
|
|
284
|
+
return null;
|
|
174
285
|
try {
|
|
175
|
-
|
|
286
|
+
const result = await this.client.executeQuery({
|
|
287
|
+
sql: "SELECT id, content, similarity, metadata, created_at " +
|
|
288
|
+
"FROM MEMORY_RECALL($1, $2, $3) WHERE id = $4 LIMIT 1",
|
|
289
|
+
parameters: [this.namespace, " ", 100, id],
|
|
290
|
+
});
|
|
291
|
+
const mapped = mapRecallRows(result);
|
|
292
|
+
if (mapped.length === 0)
|
|
293
|
+
return null;
|
|
294
|
+
return mapped[0].entry;
|
|
176
295
|
}
|
|
177
296
|
catch (err) {
|
|
178
|
-
|
|
179
|
-
if (e?.code === "NOT_FOUND" || e?.status === 404)
|
|
297
|
+
if (isMissingNamespaceError(err))
|
|
180
298
|
return null;
|
|
181
299
|
throw err;
|
|
182
300
|
}
|
|
183
|
-
if (!hit)
|
|
184
|
-
return null;
|
|
185
|
-
const meta = hit.metadata ?? {};
|
|
186
|
-
return {
|
|
187
|
-
id: String(hit.id ?? id),
|
|
188
|
-
text: typeof meta.text === "string" ? meta.text : "",
|
|
189
|
-
vector: Array.isArray(hit.values) ? hit.values : [],
|
|
190
|
-
importance: typeof meta.importance === "number" ? meta.importance : 0,
|
|
191
|
-
category: meta.category ?? "other",
|
|
192
|
-
createdAt: typeof meta.createdAt === "number" ? meta.createdAt : 0,
|
|
193
|
-
};
|
|
194
301
|
}
|
|
302
|
+
/** Expose the namespace (used by recallRelated for graph queries). */
|
|
303
|
+
get ns() {
|
|
304
|
+
return this.namespace;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// ============================================================================
|
|
308
|
+
// Shape translators between engine MemoryRecord and plugin MemoryEntry
|
|
309
|
+
// ============================================================================
|
|
310
|
+
function clampTopK(k) {
|
|
311
|
+
if (!Number.isFinite(k) || k < 1)
|
|
312
|
+
return 1;
|
|
313
|
+
if (k > 100)
|
|
314
|
+
return 100;
|
|
315
|
+
return Math.floor(k);
|
|
316
|
+
}
|
|
317
|
+
function metadataField(metadata, key) {
|
|
318
|
+
if (!metadata)
|
|
319
|
+
return undefined;
|
|
320
|
+
return metadata[key];
|
|
195
321
|
}
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const vector = Array.isArray(hit.values) ? hit.values : [];
|
|
322
|
+
function recordToEntry(record) {
|
|
323
|
+
const meta = record.metadata ?? null;
|
|
324
|
+
const importance = metadataField(meta, "importance");
|
|
325
|
+
const category = metadataField(meta, "category");
|
|
326
|
+
const createdAtMeta = metadataField(meta, "createdAt");
|
|
327
|
+
const createdAtTs = record.createdAt instanceof Date
|
|
328
|
+
? record.createdAt.getTime()
|
|
329
|
+
: Number.NaN;
|
|
205
330
|
return {
|
|
206
|
-
id:
|
|
207
|
-
text,
|
|
208
|
-
vector,
|
|
209
|
-
importance,
|
|
210
|
-
category
|
|
211
|
-
|
|
331
|
+
id: record.id,
|
|
332
|
+
text: record.content,
|
|
333
|
+
vector: [],
|
|
334
|
+
importance: typeof importance === "number" ? importance : 0,
|
|
335
|
+
category: (typeof category === "string"
|
|
336
|
+
? category
|
|
337
|
+
: "other"),
|
|
338
|
+
createdAt: typeof createdAtMeta === "number"
|
|
339
|
+
? createdAtMeta
|
|
340
|
+
: Number.isFinite(createdAtTs)
|
|
341
|
+
? createdAtTs
|
|
342
|
+
: 0,
|
|
212
343
|
};
|
|
213
344
|
}
|
|
214
|
-
function
|
|
215
|
-
// Gateway v1.6.5.2-ce returns cosine **distance** as `score` (lower =
|
|
216
|
-
// closer; 0 = identical, 1 = orthogonal, 2 = opposite). Convert to a
|
|
217
|
-
// [0, 1] similarity for the public API. If a separate `distance` field
|
|
218
|
-
// ever appears we honour it for parity.
|
|
219
|
-
const rawScore = typeof hit.score === "number" ? hit.score : undefined;
|
|
220
|
-
const rawDistance = typeof hit.distance === "number" ? hit.distance : undefined;
|
|
221
|
-
const distance = rawDistance ?? rawScore ?? 0;
|
|
222
|
-
const similarity = Math.max(0, Math.min(1, 1 - distance));
|
|
345
|
+
function recordToResult(record) {
|
|
223
346
|
return {
|
|
224
|
-
entry:
|
|
225
|
-
score: similarity,
|
|
347
|
+
entry: recordToEntry(record),
|
|
348
|
+
score: clamp01(record.similarity),
|
|
226
349
|
};
|
|
227
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Parse a raw `executeQuery` result-set into MemorySearchResult[].
|
|
353
|
+
* Mirrors @synapcores/sdk@0.5.0's MemoryClient row mapping but operates
|
|
354
|
+
* on `executeQuery` output (which is what we use here for the WHERE-clause
|
|
355
|
+
* pass-through).
|
|
356
|
+
*/
|
|
357
|
+
function mapRecallRows(result) {
|
|
358
|
+
const cols = (result.columns ?? []).map((c) => typeof c === "string" ? c : (c?.name ?? ""));
|
|
359
|
+
const colIndex = (name) => cols.findIndex((c) => c === name);
|
|
360
|
+
const idIdx = colIndex("id");
|
|
361
|
+
const contentIdx = colIndex("content");
|
|
362
|
+
const simIdx = colIndex("similarity");
|
|
363
|
+
const metaIdx = colIndex("metadata");
|
|
364
|
+
const createdIdx = colIndex("created_at");
|
|
365
|
+
const rows = result.rows ?? [];
|
|
366
|
+
const out = [];
|
|
367
|
+
for (const row of rows) {
|
|
368
|
+
if (!Array.isArray(row))
|
|
369
|
+
continue;
|
|
370
|
+
const meta = parseMetadataValue(metaIdx >= 0 ? row[metaIdx] : undefined);
|
|
371
|
+
const createdRaw = createdIdx >= 0 ? row[createdIdx] : undefined;
|
|
372
|
+
const createdAtMs = parseTimestampMs(createdRaw);
|
|
373
|
+
const importance = metadataField(meta, "importance");
|
|
374
|
+
const category = metadataField(meta, "category");
|
|
375
|
+
const similarity = simIdx >= 0 ? toFiniteNumber(row[simIdx]) : 0;
|
|
376
|
+
const id = idIdx >= 0 ? String(row[idIdx] ?? "") : "";
|
|
377
|
+
const content = contentIdx >= 0 ? String(row[contentIdx] ?? "") : "";
|
|
378
|
+
out.push({
|
|
379
|
+
entry: {
|
|
380
|
+
id,
|
|
381
|
+
text: content,
|
|
382
|
+
vector: [],
|
|
383
|
+
importance: typeof importance === "number" ? importance : 0,
|
|
384
|
+
category: (typeof category === "string"
|
|
385
|
+
? category
|
|
386
|
+
: "other"),
|
|
387
|
+
createdAt: typeof metadataField(meta, "createdAt") === "number"
|
|
388
|
+
? metadataField(meta, "createdAt")
|
|
389
|
+
: createdAtMs,
|
|
390
|
+
},
|
|
391
|
+
score: clamp01(similarity),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
function parseMetadataValue(value) {
|
|
397
|
+
if (value == null || value === "")
|
|
398
|
+
return null;
|
|
399
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
400
|
+
return value;
|
|
401
|
+
}
|
|
402
|
+
if (typeof value === "string") {
|
|
403
|
+
try {
|
|
404
|
+
const parsed = JSON.parse(value);
|
|
405
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
406
|
+
return parsed;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// fall through
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
function parseTimestampMs(value) {
|
|
416
|
+
if (typeof value === "number") {
|
|
417
|
+
return value;
|
|
418
|
+
}
|
|
419
|
+
if (value instanceof Date) {
|
|
420
|
+
return value.getTime();
|
|
421
|
+
}
|
|
422
|
+
if (typeof value === "string" && value.length > 0) {
|
|
423
|
+
const candidate = value.includes("T") ? value : value.replace(" ", "T");
|
|
424
|
+
const d = new Date(candidate);
|
|
425
|
+
const t = d.getTime();
|
|
426
|
+
if (!Number.isNaN(t))
|
|
427
|
+
return t;
|
|
428
|
+
}
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
function toFiniteNumber(value) {
|
|
432
|
+
if (typeof value === "number")
|
|
433
|
+
return Number.isFinite(value) ? value : 0;
|
|
434
|
+
if (typeof value === "string") {
|
|
435
|
+
const n = Number(value);
|
|
436
|
+
return Number.isFinite(n) ? n : 0;
|
|
437
|
+
}
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
function isMissingNamespaceError(err) {
|
|
441
|
+
const e = err;
|
|
442
|
+
if (e?.code === "NOT_FOUND")
|
|
443
|
+
return true;
|
|
444
|
+
const msg = String(e?.message ?? "").toLowerCase();
|
|
445
|
+
return (msg.includes("does not exist") ||
|
|
446
|
+
msg.includes("no such table") ||
|
|
447
|
+
msg.includes("unknown namespace") ||
|
|
448
|
+
msg.includes("namespace not found"));
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Rewrite a handful of legacy column-name shorthands into the JSON-extract
|
|
452
|
+
* form expected against `MEMORY_RECALL`'s output schema. This is best-
|
|
453
|
+
* effort and intentionally limited — callers writing complex SQL should
|
|
454
|
+
* use the engine-native syntax directly.
|
|
455
|
+
*
|
|
456
|
+
* Rewrites:
|
|
457
|
+
* category -> metadata->>'category'
|
|
458
|
+
* importance -> CAST(metadata->>'importance' AS REAL)
|
|
459
|
+
* createdAt -> CAST(metadata->>'createdAt' AS INTEGER)
|
|
460
|
+
* text -> content
|
|
461
|
+
*
|
|
462
|
+
* Identifiers preceded by a dot (e.g. `m.category`) or wrapped in quotes
|
|
463
|
+
* are left untouched.
|
|
464
|
+
*/
|
|
465
|
+
function rewriteLegacyWhere(where) {
|
|
466
|
+
const rules = [
|
|
467
|
+
[/(?<![.'"\w])category(?![\w])/g, "metadata->>'category'"],
|
|
468
|
+
[/(?<![.'"\w])importance(?![\w])/g, "CAST(metadata->>'importance' AS REAL)"],
|
|
469
|
+
[/(?<![.'"\w])createdAt(?![\w])/g, "CAST(metadata->>'createdAt' AS INTEGER)"],
|
|
470
|
+
[/(?<![.'"\w])text(?![\w])/g, "content"],
|
|
471
|
+
];
|
|
472
|
+
let out = where;
|
|
473
|
+
for (const [re, repl] of rules) {
|
|
474
|
+
out = out.replace(re, repl);
|
|
475
|
+
}
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
228
478
|
// ============================================================================
|
|
229
479
|
// OpenAI Embeddings
|
|
230
480
|
// ============================================================================
|
|
481
|
+
//
|
|
482
|
+
// v0.4.0 NOTE: the engine now owns embedding for the core memory ops
|
|
483
|
+
// (`MEMORY_STORE` / `MEMORY_RECALL`). OpenAI is still used by:
|
|
484
|
+
// - `predictRelevance` / `trainRelevanceModel`: client-side cosine
|
|
485
|
+
// between query and candidate text.
|
|
486
|
+
// - `autoLinkSimilar` capture: writing an `embedding` property onto the
|
|
487
|
+
// Memory graph node so `recallRelated`'s synthetic SIMILAR_TO edge
|
|
488
|
+
// resolves at MATCH time.
|
|
489
|
+
// Callers that never use the relevance extensions and disable
|
|
490
|
+
// `autoLinkSimilar` will not hit the OpenAI client.
|
|
231
491
|
class Embeddings {
|
|
232
492
|
model;
|
|
233
493
|
client;
|
|
@@ -361,11 +621,12 @@ function detectCategory(text) {
|
|
|
361
621
|
/**
|
|
362
622
|
* Escape a string literal for safe inlining into a Cypher query.
|
|
363
623
|
*
|
|
364
|
-
* Gateway v1.6.5.2
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
* safe, BUT we never trust
|
|
368
|
-
* inside `'...'` in a Cypher
|
|
624
|
+
* Gateway v1.6.5.2+ rejects named-parameter bindings (`$param`) with HTTP
|
|
625
|
+
* 400; the supported path is to inline literal values into the query
|
|
626
|
+
* string. Memory IDs flow in from `MEMORY_STORE` (engine-generated, e.g.
|
|
627
|
+
* `mem_1kv69sxfn_5ofzwK`) so they are normally safe, BUT we never trust
|
|
628
|
+
* upstream input — every string that ends up inside `'...'` in a Cypher
|
|
629
|
+
* fragment must go through this helper.
|
|
369
630
|
*
|
|
370
631
|
* Escapes single quotes (`'` -> `\'`) and backslashes (`\` -> `\\`).
|
|
371
632
|
* Returns the inner content only; callers are responsible for the
|
|
@@ -385,47 +646,44 @@ const MAX_HOPS = 4;
|
|
|
385
646
|
// Default result cap for recallRelated.
|
|
386
647
|
const DEFAULT_RECALL_RELATED_LIMIT = 20;
|
|
387
648
|
/**
|
|
388
|
-
* `linkSimilarMemories` — capture-time graph wiring
|
|
649
|
+
* `linkSimilarMemories` — capture-time graph wiring.
|
|
389
650
|
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
* index at MATCH time.
|
|
394
|
-
*
|
|
395
|
-
* v0.2.0 takes the supported path instead: insert the Memory as a graph
|
|
396
|
-
* node carrying the embedding under the property name `embedding` — the
|
|
397
|
-
* field the gateway's brute-force vector index is wired against (see
|
|
651
|
+
* Inserts the Memory as a graph node carrying the OpenAI embedding under
|
|
652
|
+
* the property name `embedding` — the field the gateway's brute-force
|
|
653
|
+
* vector index is wired against (see
|
|
398
654
|
* `aidb_gateway::routes::graph::attach_default_vector_index`). Once a
|
|
399
655
|
* Memory node exists, `recallRelated` can MATCH `[:SIMILAR_TO > T]`
|
|
400
656
|
* against it and get neighbors back without any pre-stored edges.
|
|
401
657
|
*
|
|
658
|
+
* v0.4.0 NOTE: the engine no longer exposes the vector it used internally
|
|
659
|
+
* for `MEMORY_STORE`, so this helper still embeds the text via the
|
|
660
|
+
* OpenAI client. The two embeddings (engine + OpenAI) need not match;
|
|
661
|
+
* each is independent and serves a different recall path.
|
|
662
|
+
*
|
|
402
663
|
* Failures here are non-fatal — the capture itself still succeeded in the
|
|
403
|
-
*
|
|
664
|
+
* memory subsystem; we just log and move on so the rest of recall keeps
|
|
665
|
+
* working.
|
|
404
666
|
*/
|
|
405
|
-
async function linkSimilarMemories(entry,
|
|
667
|
+
async function linkSimilarMemories(entry, client, embedVector, logger) {
|
|
406
668
|
try {
|
|
407
|
-
//
|
|
408
|
-
// SIMILAR_TO edge in `recallRelated` has something to match against.
|
|
409
|
-
// The `id` property mirrors the vector-collection id so callers can
|
|
410
|
-
// join the two views.
|
|
411
|
-
//
|
|
412
|
-
// KNOWN SDK GAP (@synapcores/sdk@0.4.0):
|
|
669
|
+
// KNOWN SDK GAP (@synapcores/sdk@<=0.5.0):
|
|
413
670
|
// `client.graph.nodes.create(label, props)` posts
|
|
414
671
|
// `{label: <single>, properties}` but the gateway's
|
|
415
672
|
// /v1/graph/nodes handler expects `{labels: <array>, properties}`
|
|
416
673
|
// (see aidb_gateway::routes::graph::CreateNodeRequest). The result
|
|
417
674
|
// is a node with `labels: []`, which never matches the `Memory`
|
|
418
675
|
// label filter in MATCH. We bypass the SDK helper for THIS one
|
|
419
|
-
// call and post the correct wire shape ourselves. Once SDK
|
|
420
|
-
// fixes `GraphNodeApi.create` to send `labels`, this
|
|
421
|
-
// call can be replaced with
|
|
676
|
+
// call and post the correct wire shape ourselves. Once the SDK
|
|
677
|
+
// fixes `GraphNodeApi.create` to send `labels`, this
|
|
678
|
+
// `_getHttpClient` call can be replaced with
|
|
679
|
+
// `client.graph.nodes.create(...)`.
|
|
422
680
|
const http = client._getHttpClient();
|
|
423
681
|
await http.post("/graph/nodes", {
|
|
424
682
|
labels: ["Memory"],
|
|
425
683
|
properties: {
|
|
426
684
|
id: entry.id,
|
|
427
685
|
text: entry.text,
|
|
428
|
-
embedding:
|
|
686
|
+
embedding: embedVector,
|
|
429
687
|
importance: entry.importance,
|
|
430
688
|
category: entry.category,
|
|
431
689
|
createdAt: entry.createdAt,
|
|
@@ -440,7 +698,7 @@ async function linkSimilarMemories(entry, _db, client, _graphName, logger) {
|
|
|
440
698
|
}
|
|
441
699
|
}
|
|
442
700
|
// ============================================================================
|
|
443
|
-
// AutoML helpers — staged-collection training
|
|
701
|
+
// AutoML helpers — staged-collection training
|
|
444
702
|
// ============================================================================
|
|
445
703
|
const DEFAULT_RELEVANCE_MODEL = "openclaw_memory_relevance";
|
|
446
704
|
const MIN_TRAINING_SAMPLES = 10;
|
|
@@ -455,7 +713,20 @@ function trainingTableName(workspace) {
|
|
|
455
713
|
const safe = workspace ? workspace.replace(/[^a-zA-Z0-9_]/g, "_") : "";
|
|
456
714
|
return safe ? `${TRAINING_TABLE_PREFIX}_${safe}` : TRAINING_TABLE_PREFIX;
|
|
457
715
|
}
|
|
458
|
-
function
|
|
716
|
+
async function ensureCandidateVector(candidate, embeddings) {
|
|
717
|
+
if (Array.isArray(candidate.vector) && candidate.vector.length > 0) {
|
|
718
|
+
return candidate.vector;
|
|
719
|
+
}
|
|
720
|
+
if (!candidate.text)
|
|
721
|
+
return [];
|
|
722
|
+
try {
|
|
723
|
+
return await embeddings.embed(candidate.text);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
return [];
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function createExtensions(db, embeddings, client, _graphName, workspace) {
|
|
459
730
|
const modelName = relevanceModelName(workspace);
|
|
460
731
|
const stagingTable = trainingTableName(workspace);
|
|
461
732
|
async function modelExists(name) {
|
|
@@ -470,19 +741,16 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
|
|
|
470
741
|
return {
|
|
471
742
|
async recallFiltered(options) {
|
|
472
743
|
const limit = options.limit ?? 5;
|
|
473
|
-
|
|
474
|
-
return db.searchFiltered(vector, options.where, limit);
|
|
744
|
+
return db.searchFiltered(options.semantic, options.where, limit);
|
|
475
745
|
},
|
|
476
746
|
async recallRelated(memoryId, options = {}) {
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
// walks SIMILAR_TO (plus any non-synthetic edges the caller named
|
|
485
|
-
// via `edgeKinds`) and returns the neighborhood.
|
|
747
|
+
// The gateway derives `SIMILAR_TO` synthetically at MATCH time from
|
|
748
|
+
// the graph backend's vector index on the `embedding` property.
|
|
749
|
+
// `linkSimilarMemories` populates that index on every capture by
|
|
750
|
+
// posting a `Memory` graph node carrying the embedding. With nodes
|
|
751
|
+
// in place, this method composes a Cypher MATCH that walks
|
|
752
|
+
// SIMILAR_TO (plus any non-synthetic edges the caller named via
|
|
753
|
+
// `edgeKinds`) and returns the neighborhood.
|
|
486
754
|
//
|
|
487
755
|
// Wire constraints:
|
|
488
756
|
// - Multi-hop `[:SIMILAR_TO*1..N]` is rejected — SIMILAR_TO is
|
|
@@ -517,16 +785,38 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
|
|
|
517
785
|
}
|
|
518
786
|
let result;
|
|
519
787
|
try {
|
|
520
|
-
|
|
788
|
+
// SDK 0.5.0 no longer exposes `client.graph.cypher` — the
|
|
789
|
+
// canonical path is `POST /v1/graph/match` with `{sql: <cypher>}`.
|
|
790
|
+
// We reach through the SDK's underlying axios instance via
|
|
791
|
+
// `_getHttpClient()`, the same accessor `linkSimilarMemories`
|
|
792
|
+
// uses for the `/graph/nodes` workaround.
|
|
793
|
+
const http = client._getHttpClient();
|
|
794
|
+
const response = await http.post("/graph/match", { sql: cypher });
|
|
795
|
+
const body = (response?.data ?? {});
|
|
796
|
+
result = body;
|
|
521
797
|
}
|
|
522
798
|
catch (err) {
|
|
523
799
|
const msg = err?.message ?? String(err);
|
|
524
800
|
throw new Error(`memory-synapcores.recallRelated: graph query failed for edge kind '${safeKind}': ${msg}`);
|
|
525
801
|
}
|
|
526
|
-
//
|
|
527
|
-
//
|
|
802
|
+
// Gateway returns `{ columns, rows, count }`. Older SDK shims
|
|
803
|
+
// also emitted `records` (column-keyed objects); we honour both.
|
|
528
804
|
const records = Array.isArray(result?.records) ? result.records : [];
|
|
529
|
-
const
|
|
805
|
+
const cols = Array.isArray(result?.columns) ? result.columns : [];
|
|
806
|
+
const rawRows = Array.isArray(result?.rows) ? result.rows : [];
|
|
807
|
+
// Synthesize `records` from `(columns, rows)` if not provided.
|
|
808
|
+
const synthRecords = records.length > 0
|
|
809
|
+
? records
|
|
810
|
+
: rawRows
|
|
811
|
+
.filter((r) => Array.isArray(r))
|
|
812
|
+
.map((r) => {
|
|
813
|
+
const obj = {};
|
|
814
|
+
for (let i = 0; i < cols.length; i++) {
|
|
815
|
+
obj[cols[i]] = r[i];
|
|
816
|
+
}
|
|
817
|
+
return obj;
|
|
818
|
+
});
|
|
819
|
+
const rows = rawRows;
|
|
530
820
|
const harvest = (node) => {
|
|
531
821
|
if (!node || typeof node !== "object")
|
|
532
822
|
return;
|
|
@@ -551,7 +841,7 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
|
|
|
551
841
|
via: [safeKind],
|
|
552
842
|
});
|
|
553
843
|
};
|
|
554
|
-
for (const rec of
|
|
844
|
+
for (const rec of synthRecords) {
|
|
555
845
|
// records: { related: <node> }
|
|
556
846
|
harvest(rec?.related ?? Object.values(rec ?? {})[0]);
|
|
557
847
|
}
|
|
@@ -570,7 +860,11 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
|
|
|
570
860
|
return [];
|
|
571
861
|
const queryVector = await embeddings.embed(query);
|
|
572
862
|
const now = Date.now();
|
|
573
|
-
|
|
863
|
+
// v0.4.0: MEMORY_RECALL doesn't return embeddings, so candidates
|
|
864
|
+
// surfaced via the core recall path arrive with `vector: []`. We
|
|
865
|
+
// lazily embed the candidate text when we need a cosine feature.
|
|
866
|
+
const candidateVectors = await Promise.all(candidates.map((c) => ensureCandidateVector(c, embeddings)));
|
|
867
|
+
const features = candidates.map((c, i) => buildRelevanceFeatures(queryVector, { ...c, vector: candidateVectors[i] }, now));
|
|
574
868
|
// Try model mode; on any failure (no model, transport error) fall
|
|
575
869
|
// back to the heuristic so the caller never gets an empty result.
|
|
576
870
|
if (await modelExists(modelName)) {
|
|
@@ -601,11 +895,9 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
|
|
|
601
895
|
if (!Array.isArray(feedback) || feedback.length < MIN_TRAINING_SAMPLES) {
|
|
602
896
|
throw new Error(`memory-synapcores.trainRelevanceModel: need at least ${MIN_TRAINING_SAMPLES} samples to train a relevance model (got ${Array.isArray(feedback) ? feedback.length : 0})`);
|
|
603
897
|
}
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
// requires the data to land in a real collection/table before
|
|
608
|
-
// training. We:
|
|
898
|
+
// Stage rows in a SQL table the gateway's AutoML can `SELECT * FROM`.
|
|
899
|
+
// The gateway requires the data to land in a real collection/table
|
|
900
|
+
// before training. We:
|
|
609
901
|
// 1. Hydrate each feedback row's memory into a full feature
|
|
610
902
|
// vector (cosine, age_days, importance, one-hot category) via
|
|
611
903
|
// the same `buildRelevanceFeatures` helper `predictRelevance`
|
|
@@ -618,7 +910,8 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
|
|
|
618
910
|
// a soft warning to the caller via the throw message); training
|
|
619
911
|
// proceeds with the remainder.
|
|
620
912
|
// Hydrate feedback rows. Embed each query text once; pair with
|
|
621
|
-
// the stored Memory's
|
|
913
|
+
// the stored Memory's content (re-embedded via OpenAI) to get the
|
|
914
|
+
// cosine feature.
|
|
622
915
|
const hydrated = [];
|
|
623
916
|
let missing = 0;
|
|
624
917
|
for (const fb of feedback) {
|
|
@@ -627,8 +920,9 @@ function createExtensions(db, embeddings, client, _graphName, workspace, _collec
|
|
|
627
920
|
missing++;
|
|
628
921
|
continue;
|
|
629
922
|
}
|
|
923
|
+
const memVector = await ensureCandidateVector(mem, embeddings);
|
|
630
924
|
const queryVec = await embeddings.embed(fb.queryText);
|
|
631
|
-
const feats = buildRelevanceFeatures(queryVec, mem);
|
|
925
|
+
const feats = buildRelevanceFeatures(queryVec, { ...mem, vector: memVector });
|
|
632
926
|
hydrated.push({
|
|
633
927
|
cosine: feats.asRecord.cosine,
|
|
634
928
|
age_days: feats.asRecord.age_days,
|
|
@@ -741,24 +1035,29 @@ const memoryPlugin = {
|
|
|
741
1035
|
configSchema: memoryConfigSchema,
|
|
742
1036
|
register(api) {
|
|
743
1037
|
const cfg = memoryConfigSchema.parse(api.pluginConfig);
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
//
|
|
1038
|
+
// Validate `vectorDimsForModel` runs without throwing; the dimension
|
|
1039
|
+
// value itself isn't used in v0.4.0 because the engine owns embedding
|
|
1040
|
+
// for the core memory ops.
|
|
1041
|
+
vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small");
|
|
1042
|
+
const namespace = cfg.collection ?? DEFAULT_COLLECTION;
|
|
1043
|
+
// The new MemoryClient enforces namespace ^[A-Za-z_][A-Za-z0-9_]*$. Fail
|
|
1044
|
+
// loud and early if the OpenClaw config's `collection` value would be
|
|
1045
|
+
// rejected.
|
|
1046
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(namespace)) {
|
|
1047
|
+
throw new Error(`memory-synapcores: collection '${namespace}' is not a valid namespace ` +
|
|
1048
|
+
`(must match /^[A-Za-z_][A-Za-z0-9_]*$/). Update plugins.entries.memory-synapcores.config.collection.`);
|
|
1049
|
+
}
|
|
750
1050
|
const client = new SynapCores({
|
|
751
1051
|
host: cfg.synapcores.host,
|
|
752
1052
|
port: cfg.synapcores.port,
|
|
753
1053
|
useHttps: cfg.synapcores.useHttps,
|
|
754
1054
|
apiKey: cfg.synapcores.apiKey,
|
|
755
1055
|
});
|
|
756
|
-
const db = new MemoryDB(client,
|
|
1056
|
+
const db = new MemoryDB(client, namespace);
|
|
757
1057
|
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model);
|
|
758
|
-
const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace
|
|
1058
|
+
const extensions = createExtensions(db, embeddings, client, cfg.graph, cfg.workspace);
|
|
759
1059
|
const autoLinkSimilar = cfg.autoLinkSimilar !== false;
|
|
760
|
-
|
|
761
|
-
api.logger.info(`memory-synapcores: plugin registered (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, collection: ${collectionName}, autoLinkSimilar: ${autoLinkSimilar}, lazy init)`);
|
|
1060
|
+
api.logger.info(`memory-synapcores: plugin registered (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, namespace: ${namespace}, autoLinkSimilar: ${autoLinkSimilar}, lazy init)`);
|
|
762
1061
|
// ========================================================================
|
|
763
1062
|
// Tools
|
|
764
1063
|
// ========================================================================
|
|
@@ -772,8 +1071,7 @@ const memoryPlugin = {
|
|
|
772
1071
|
}),
|
|
773
1072
|
async execute(_toolCallId, params) {
|
|
774
1073
|
const { query, limit = 5 } = params;
|
|
775
|
-
const
|
|
776
|
-
const results = await db.search(vector, limit, 0.1);
|
|
1074
|
+
const results = await db.searchByQuery(query, limit, 0.1);
|
|
777
1075
|
if (results.length === 0) {
|
|
778
1076
|
return {
|
|
779
1077
|
content: [{ type: "text", text: "No relevant memories found." }],
|
|
@@ -783,7 +1081,6 @@ const memoryPlugin = {
|
|
|
783
1081
|
const text = results
|
|
784
1082
|
.map((r, i) => `${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`)
|
|
785
1083
|
.join("\n");
|
|
786
|
-
// Strip vector data for serialization (typed arrays can't be cloned)
|
|
787
1084
|
const sanitizedResults = results.map((r) => ({
|
|
788
1085
|
id: r.entry.id,
|
|
789
1086
|
text: r.entry.text,
|
|
@@ -808,9 +1105,9 @@ const memoryPlugin = {
|
|
|
808
1105
|
}),
|
|
809
1106
|
async execute(_toolCallId, params) {
|
|
810
1107
|
const { text, importance = 0.7, category = "other", } = params;
|
|
811
|
-
|
|
812
|
-
//
|
|
813
|
-
const existing = await db.
|
|
1108
|
+
// Check for duplicates via the engine's recall path. The engine
|
|
1109
|
+
// re-embeds `text` internally so we don't have to.
|
|
1110
|
+
const existing = await db.searchByQuery(text, 1, 0.95);
|
|
814
1111
|
if (existing.length > 0) {
|
|
815
1112
|
return {
|
|
816
1113
|
content: [
|
|
@@ -828,15 +1125,21 @@ const memoryPlugin = {
|
|
|
828
1125
|
}
|
|
829
1126
|
const entry = await db.store({
|
|
830
1127
|
text,
|
|
831
|
-
vector,
|
|
1128
|
+
vector: [],
|
|
832
1129
|
importance,
|
|
833
1130
|
category,
|
|
834
1131
|
});
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
// MATCH against. Failures are non-fatal (logged in-helper).
|
|
1132
|
+
// linkSimilarMemories inserts a Memory graph node carrying an
|
|
1133
|
+
// OpenAI-embedded representation so recallRelated has something
|
|
1134
|
+
// to MATCH against. Failures are non-fatal (logged in-helper).
|
|
838
1135
|
if (autoLinkSimilar) {
|
|
839
|
-
|
|
1136
|
+
try {
|
|
1137
|
+
const embedVec = await embeddings.embed(text);
|
|
1138
|
+
await linkSimilarMemories(entry, client, embedVec, api.logger);
|
|
1139
|
+
}
|
|
1140
|
+
catch (err) {
|
|
1141
|
+
api.logger.warn(`memory-synapcores: graph-node embed failed for ${entry.id}: ${String(err)} (capture still succeeded)`);
|
|
1142
|
+
}
|
|
840
1143
|
}
|
|
841
1144
|
return {
|
|
842
1145
|
content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }],
|
|
@@ -862,8 +1165,7 @@ const memoryPlugin = {
|
|
|
862
1165
|
};
|
|
863
1166
|
}
|
|
864
1167
|
if (query) {
|
|
865
|
-
const
|
|
866
|
-
const results = await db.search(vector, 5, 0.7);
|
|
1168
|
+
const results = await db.searchByQuery(query, 5, 0.7);
|
|
867
1169
|
if (results.length === 0) {
|
|
868
1170
|
return {
|
|
869
1171
|
content: [{ type: "text", text: "No matching memories found." }],
|
|
@@ -880,7 +1182,6 @@ const memoryPlugin = {
|
|
|
880
1182
|
const list = results
|
|
881
1183
|
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
|
|
882
1184
|
.join("\n");
|
|
883
|
-
// Strip vector data for serialization
|
|
884
1185
|
const sanitizedCandidates = results.map((r) => ({
|
|
885
1186
|
id: r.entry.id,
|
|
886
1187
|
text: r.entry.text,
|
|
@@ -921,9 +1222,7 @@ const memoryPlugin = {
|
|
|
921
1222
|
.argument("<query>", "Search query")
|
|
922
1223
|
.option("--limit <n>", "Max results", "5")
|
|
923
1224
|
.action(async (query, opts) => {
|
|
924
|
-
const
|
|
925
|
-
const results = await db.search(vector, parseInt(opts.limit), 0.3);
|
|
926
|
-
// Strip vectors for output
|
|
1225
|
+
const results = await db.searchByQuery(query, parseInt(opts.limit), 0.3);
|
|
927
1226
|
const output = results.map((r) => ({
|
|
928
1227
|
id: r.entry.id,
|
|
929
1228
|
text: r.entry.text,
|
|
@@ -951,8 +1250,7 @@ const memoryPlugin = {
|
|
|
951
1250
|
return;
|
|
952
1251
|
}
|
|
953
1252
|
try {
|
|
954
|
-
const
|
|
955
|
-
const results = await db.search(vector, 3, 0.3);
|
|
1253
|
+
const results = await db.searchByQuery(event.prompt, 3, 0.3);
|
|
956
1254
|
if (results.length === 0) {
|
|
957
1255
|
return;
|
|
958
1256
|
}
|
|
@@ -979,23 +1277,19 @@ const memoryPlugin = {
|
|
|
979
1277
|
// Extract text content from messages (handling unknown[] type)
|
|
980
1278
|
const texts = [];
|
|
981
1279
|
for (const msg of event.messages) {
|
|
982
|
-
// Type guard for message object
|
|
983
1280
|
if (!msg || typeof msg !== "object") {
|
|
984
1281
|
continue;
|
|
985
1282
|
}
|
|
986
1283
|
const msgObj = msg;
|
|
987
|
-
// Only process user and assistant messages
|
|
988
1284
|
const role = msgObj.role;
|
|
989
1285
|
if (role !== "user" && role !== "assistant") {
|
|
990
1286
|
continue;
|
|
991
1287
|
}
|
|
992
1288
|
const content = msgObj.content;
|
|
993
|
-
// Handle string content directly
|
|
994
1289
|
if (typeof content === "string") {
|
|
995
1290
|
texts.push(content);
|
|
996
1291
|
continue;
|
|
997
1292
|
}
|
|
998
|
-
// Handle array content (content blocks)
|
|
999
1293
|
if (Array.isArray(content)) {
|
|
1000
1294
|
for (const block of content) {
|
|
1001
1295
|
if (block &&
|
|
@@ -1018,20 +1312,25 @@ const memoryPlugin = {
|
|
|
1018
1312
|
let stored = 0;
|
|
1019
1313
|
for (const text of toCapture.slice(0, 3)) {
|
|
1020
1314
|
const category = detectCategory(text);
|
|
1021
|
-
const vector = await embeddings.embed(text);
|
|
1022
1315
|
// Check for duplicates (high similarity threshold)
|
|
1023
|
-
const existing = await db.
|
|
1316
|
+
const existing = await db.searchByQuery(text, 1, 0.95);
|
|
1024
1317
|
if (existing.length > 0) {
|
|
1025
1318
|
continue;
|
|
1026
1319
|
}
|
|
1027
1320
|
const entry = await db.store({
|
|
1028
1321
|
text,
|
|
1029
|
-
vector,
|
|
1322
|
+
vector: [],
|
|
1030
1323
|
importance: 0.7,
|
|
1031
1324
|
category,
|
|
1032
1325
|
});
|
|
1033
1326
|
if (autoLinkSimilar) {
|
|
1034
|
-
|
|
1327
|
+
try {
|
|
1328
|
+
const embedVec = await embeddings.embed(text);
|
|
1329
|
+
await linkSimilarMemories(entry, client, embedVec, api.logger);
|
|
1330
|
+
}
|
|
1331
|
+
catch (err) {
|
|
1332
|
+
api.logger.warn(`memory-synapcores: graph-node embed failed for ${entry.id}: ${String(err)}`);
|
|
1333
|
+
}
|
|
1035
1334
|
}
|
|
1036
1335
|
stored++;
|
|
1037
1336
|
}
|
|
@@ -1049,8 +1348,18 @@ const memoryPlugin = {
|
|
|
1049
1348
|
// ========================================================================
|
|
1050
1349
|
api.registerService({
|
|
1051
1350
|
id: "memory-synapcores",
|
|
1052
|
-
start: () => {
|
|
1053
|
-
|
|
1351
|
+
start: async () => {
|
|
1352
|
+
// Best-effort startup probe: surface a DB-down / bad-key problem
|
|
1353
|
+
// immediately in the logs (and `openclaw plugins doctor`) without
|
|
1354
|
+
// bricking the host — memory ops still lazily retry on first use.
|
|
1355
|
+
try {
|
|
1356
|
+
await preflightGateway(client);
|
|
1357
|
+
api.logger.info(`memory-synapcores: initialized (host: ${cfg.synapcores.host}:${cfg.synapcores.port}, namespace: ${namespace}, model: ${cfg.embedding.model})`);
|
|
1358
|
+
}
|
|
1359
|
+
catch (err) {
|
|
1360
|
+
api.logger.warn(`memory-synapcores: ${String(err?.message ?? err)}`);
|
|
1361
|
+
api.logger.warn("memory-synapcores: continuing with lazy init — memory operations will retry on first use.");
|
|
1362
|
+
}
|
|
1054
1363
|
},
|
|
1055
1364
|
stop: () => {
|
|
1056
1365
|
api.logger.info("memory-synapcores: stopped");
|
|
@@ -1063,9 +1372,14 @@ const memoryPlugin = {
|
|
|
1063
1372
|
// reach `recallFiltered` / `recallRelated` / `predictRelevance` /
|
|
1064
1373
|
// `trainRelevanceModel` via the OpenClaw plugin registry
|
|
1065
1374
|
// (e.g. `plugin.extensions.recallFiltered`).
|
|
1066
|
-
|
|
1067
|
-
|
|
1375
|
+
//
|
|
1376
|
+
// NOTE: attach to `pluginEntry` (the definePluginEntry result that is the
|
|
1377
|
+
// default export), NOT the inner `memoryPlugin` — definePluginEntry returns
|
|
1378
|
+
// a fresh normalized object, so attaching to the inner one would hide the
|
|
1379
|
+
// extensions from every consumer of the loaded plugin.
|
|
1380
|
+
pluginEntry.extensions = extensions;
|
|
1068
1381
|
},
|
|
1069
1382
|
};
|
|
1070
|
-
|
|
1383
|
+
const pluginEntry = definePluginEntry(memoryPlugin);
|
|
1384
|
+
export default pluginEntry;
|
|
1071
1385
|
//# sourceMappingURL=index.js.map
|