openclaw-amem 1.4.3 β 2.0.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 +11 -1
- package/dist/index.js +233 -61
- package/openclaw.plugin.json +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Requires a local [Qdrant](https://qdrant.tech). Implements [A-MEM](https://arxiv
|
|
|
29
29
|
- π§ **Knowledge vs episodic** β durable knowledge notes skip consolidation & time-decay; topic tags for precise recall.
|
|
30
30
|
- π§Ή **Self-consolidating** β daily 02:30 in-process merge of semantic duplicates with link cascading.
|
|
31
31
|
- π **Per-agent isolation** β private by default; explicit `owner`/`readers`/`writers`; Mode A (shared collection) or Mode B (dedicated collection).
|
|
32
|
-
- π **Chinese-optimized** & local embeddings (Transformers.js
|
|
32
|
+
- π **Chinese-optimized** & local embeddings (Transformers.js) β no Python, no external embedding API.
|
|
33
33
|
|
|
34
34
|
β Full feature list & internals: **[@amemhq/core README](../amem-core)** Β· **[docs](https://amem.owo.lc)**.
|
|
35
35
|
|
|
@@ -39,6 +39,7 @@ Requires a local [Qdrant](https://qdrant.tech). Implements [A-MEM](https://arxiv
|
|
|
39
39
|
- Node.js 24 (18+ works; 24/26 supported)
|
|
40
40
|
- Qdrant running on `:6333`
|
|
41
41
|
- An LLM: `ANTHROPIC_API_KEY` by default, or any OpenAI-compatible provider β see [LLM provider](#llm-provider)
|
|
42
|
+
- ~1.1 GB of disk for the embedding model, downloaded once on first run
|
|
42
43
|
|
|
43
44
|
## Installation
|
|
44
45
|
|
|
@@ -88,6 +89,15 @@ Add `openclaw-amem` to your allowed plugins and hook it into the `memory` slot:
|
|
|
88
89
|
openclaw gateway restart
|
|
89
90
|
```
|
|
90
91
|
|
|
92
|
+
First run downloads the embedding model (`bge-m3`, 1.08 GB) and caches it. Later
|
|
93
|
+
restarts are instant. Want something smaller? `AMEM_EMBED_MODEL=onnx-community/gte-multilingual-base`
|
|
94
|
+
is about a third the size and reads text just as long β set it **before** you have
|
|
95
|
+
memories, since changing it afterwards means a [migration](https://amem.owo.lc/reference/embedding-models#changing-the-model-on-a-store-you-already-have).
|
|
96
|
+
|
|
97
|
+
Upgrading from 1.x downloads nothing. Your existing memories keep the model that
|
|
98
|
+
built them, and the plugin says so at startup along with the one command that
|
|
99
|
+
moves them.
|
|
100
|
+
|
|
91
101
|
## LLM provider
|
|
92
102
|
|
|
93
103
|
The plugin calls an LLM for note construction, linking, and evolution. Pick the backend with `AMEM_LLM_PROVIDER`:
|
package/dist/index.js
CHANGED
|
@@ -53,8 +53,18 @@ var init_config = __esm({
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
// ../amem-core/src/embedding.ts
|
|
56
|
+
function pinEmbeddingModel(model) {
|
|
57
|
+
if (pinnedModel === model) return;
|
|
58
|
+
pinnedModel = model;
|
|
59
|
+
extractor = null;
|
|
60
|
+
loadedKey = null;
|
|
61
|
+
cachedDim = null;
|
|
62
|
+
}
|
|
63
|
+
function getPinnedEmbeddingModel() {
|
|
64
|
+
return pinnedModel;
|
|
65
|
+
}
|
|
56
66
|
function getEmbeddingModel() {
|
|
57
|
-
return process.env.AMEM_EMBED_MODEL?.trim() || DEFAULT_EMBEDDING_MODEL;
|
|
67
|
+
return process.env.AMEM_EMBED_MODEL?.trim() || pinnedModel || DEFAULT_EMBEDDING_MODEL;
|
|
58
68
|
}
|
|
59
69
|
function getEmbeddingPooling() {
|
|
60
70
|
const explicit = process.env.AMEM_EMBED_POOLING?.trim().toLowerCase();
|
|
@@ -66,7 +76,9 @@ function getEmbeddingDevice() {
|
|
|
66
76
|
return process.env.AMEM_EMBED_DEVICE?.trim() || void 0;
|
|
67
77
|
}
|
|
68
78
|
function getEmbeddingDtype() {
|
|
69
|
-
|
|
79
|
+
const explicit = process.env.AMEM_EMBED_DTYPE?.trim();
|
|
80
|
+
if (explicit) return explicit;
|
|
81
|
+
return getEmbeddingModel() === DEFAULT_EMBEDDING_MODEL ? DEFAULT_MODEL_DTYPE : void 0;
|
|
70
82
|
}
|
|
71
83
|
function extractorKey() {
|
|
72
84
|
return `${getEmbeddingModel()}|${getEmbeddingDevice() ?? ""}|${getEmbeddingDtype() ?? ""}`;
|
|
@@ -155,7 +167,7 @@ function cosineSimilarity(a, b) {
|
|
|
155
167
|
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
|
156
168
|
return dot;
|
|
157
169
|
}
|
|
158
|
-
var pipeline, extractor, loadedKey, cachedDim, DEFAULT_EMBEDDING_MODEL, CLS_POOLED_MODELS;
|
|
170
|
+
var pipeline, extractor, loadedKey, cachedDim, DEFAULT_EMBEDDING_MODEL, LEGACY_DEFAULT_EMBEDDING_MODEL, LEGACY_DEFAULT_DIM, DEFAULT_MODEL_DTYPE, pinnedModel, CLS_POOLED_MODELS;
|
|
159
171
|
var init_embedding = __esm({
|
|
160
172
|
"../amem-core/src/embedding.ts"() {
|
|
161
173
|
"use strict";
|
|
@@ -163,7 +175,11 @@ var init_embedding = __esm({
|
|
|
163
175
|
extractor = null;
|
|
164
176
|
loadedKey = null;
|
|
165
177
|
cachedDim = null;
|
|
166
|
-
DEFAULT_EMBEDDING_MODEL = "Xenova/
|
|
178
|
+
DEFAULT_EMBEDDING_MODEL = "Xenova/bge-m3";
|
|
179
|
+
LEGACY_DEFAULT_EMBEDDING_MODEL = "Xenova/paraphrase-multilingual-MiniLM-L12-v2";
|
|
180
|
+
LEGACY_DEFAULT_DIM = 384;
|
|
181
|
+
DEFAULT_MODEL_DTYPE = "fp16";
|
|
182
|
+
pinnedModel = null;
|
|
167
183
|
CLS_POOLED_MODELS = /* @__PURE__ */ new Set([
|
|
168
184
|
"bge-m3",
|
|
169
185
|
"bge-base-zh-v1.5",
|
|
@@ -187,20 +203,22 @@ function canWrite(note, callerAgentId) {
|
|
|
187
203
|
function canRead(note, callerAgentId) {
|
|
188
204
|
return note.owner === callerAgentId || note.readers.includes(callerAgentId) || note.readers.includes("*");
|
|
189
205
|
}
|
|
206
|
+
var SYSTEM_ACTOR;
|
|
190
207
|
var init_auth = __esm({
|
|
191
208
|
"../amem-core/src/auth.ts"() {
|
|
192
209
|
"use strict";
|
|
210
|
+
SYSTEM_ACTOR = "__amem_system__";
|
|
193
211
|
}
|
|
194
212
|
});
|
|
195
213
|
|
|
196
214
|
// ../amem-core/src/storage.ts
|
|
197
215
|
function migrationHint(collection, targetModel) {
|
|
198
|
-
return `migrate
|
|
216
|
+
return `migrate onto it:
|
|
199
217
|
|
|
200
218
|
AMEM_EMBED_MODEL=${targetModel} \\
|
|
201
|
-
npx --package=@amemhq/core amem-migrate --
|
|
219
|
+
npx --package=@amemhq/core amem-migrate --from-collection ${collection}
|
|
202
220
|
|
|
203
|
-
That
|
|
221
|
+
That only reports; it takes --apply to write anything, and "${collection}" is read either way. The new store ends up behind the name you already use, so there is nothing to change in your config afterwards. See https://amem.owo.lc/reference/embedding-models.`;
|
|
204
222
|
}
|
|
205
223
|
async function recordCollectionModel(collection, model) {
|
|
206
224
|
try {
|
|
@@ -242,18 +260,38 @@ async function ensureCollection(collectionName) {
|
|
|
242
260
|
}
|
|
243
261
|
if (existing) {
|
|
244
262
|
const collectionDim = existing.config?.params?.vectors?.size;
|
|
263
|
+
const recorded = existing.config?.metadata?.embedding_model;
|
|
264
|
+
const explicit = process.env.AMEM_EMBED_MODEL?.trim();
|
|
265
|
+
const inferLegacy = !explicit && recorded === void 0 && collectionDim === LEGACY_DEFAULT_DIM;
|
|
266
|
+
if (!explicit) {
|
|
267
|
+
const wanted = (
|
|
268
|
+
// The collection says what built it, which outranks whatever the shipped
|
|
269
|
+
// default happens to be today. This is what keeps changing the default
|
|
270
|
+
// from breaking every install that already has data.
|
|
271
|
+
typeof recorded === "string" ? recorded : inferLegacy ? LEGACY_DEFAULT_EMBEDDING_MODEL : DEFAULT_EMBEDDING_MODEL
|
|
272
|
+
);
|
|
273
|
+
const inUse = getPinnedEmbeddingModel();
|
|
274
|
+
if (inUse !== null && inUse !== wanted) throw new MixedEmbeddingModelsError(col, wanted, inUse);
|
|
275
|
+
pinEmbeddingModel(wanted);
|
|
276
|
+
if (wanted === LEGACY_DEFAULT_EMBEDDING_MODEL) {
|
|
277
|
+
console.warn(
|
|
278
|
+
`[amem] "${col}" is on ${LEGACY_DEFAULT_EMBEDDING_MODEL} (${LEGACY_DEFAULT_DIM}-dim).
|
|
279
|
+
[amem] ${DEFAULT_EMBEDDING_MODEL} reads 8192 tokens where that one stops at 128, so anything longer is being truncated before it reaches the vector.
|
|
280
|
+
[amem] To move: npx --package=@amemhq/core amem-migrate --from-collection ${col}`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
245
284
|
if (typeof collectionDim === "number") {
|
|
246
285
|
const modelDim = await getEmbeddingDim();
|
|
247
286
|
if (collectionDim !== modelDim) {
|
|
248
287
|
throw new EmbeddingDimensionMismatchError(col, collectionDim, modelDim, getEmbeddingModel());
|
|
249
288
|
}
|
|
250
289
|
}
|
|
251
|
-
const recorded = existing.config?.metadata?.embedding_model;
|
|
252
290
|
const current = getEmbeddingModel();
|
|
253
291
|
if (typeof recorded === "string" && recorded !== current) {
|
|
254
292
|
throw new EmbeddingModelMismatchError(col, recorded, current);
|
|
255
293
|
}
|
|
256
|
-
if (recorded === void 0) {
|
|
294
|
+
if (recorded === void 0 && !inferLegacy) {
|
|
257
295
|
await recordCollectionModel(col, current);
|
|
258
296
|
}
|
|
259
297
|
markReady();
|
|
@@ -267,7 +305,9 @@ async function ensureCollection(collectionName) {
|
|
|
267
305
|
} catch (err) {
|
|
268
306
|
if (!(err instanceof Error) || !err.message.includes("already exists")) throw err;
|
|
269
307
|
}
|
|
270
|
-
|
|
308
|
+
const created = getEmbeddingModel();
|
|
309
|
+
await recordCollectionModel(col, created);
|
|
310
|
+
if (!process.env.AMEM_EMBED_MODEL?.trim()) pinEmbeddingModel(created);
|
|
271
311
|
await qdrant("PUT", `/collections/${col}/index`, {
|
|
272
312
|
field_name: "agent_id",
|
|
273
313
|
field_schema: "keyword"
|
|
@@ -321,6 +361,43 @@ async function createCollectionRaw(collection, size) {
|
|
|
321
361
|
async function upsertPointsRaw(collection, points) {
|
|
322
362
|
await qdrant("PUT", `/collections/${collection}/points?wait=true`, { points });
|
|
323
363
|
}
|
|
364
|
+
async function scrollIdsRaw(collection, limit = 1e4) {
|
|
365
|
+
const ids = /* @__PURE__ */ new Set();
|
|
366
|
+
let offset = void 0;
|
|
367
|
+
for (; ; ) {
|
|
368
|
+
const body = { with_payload: false, with_vector: false, limit };
|
|
369
|
+
if (offset !== void 0 && offset !== null) body.offset = offset;
|
|
370
|
+
const res = await qdrant("POST", `/collections/${collection}/points/scroll`, body);
|
|
371
|
+
for (const p of res.points) ids.add(String(p.id));
|
|
372
|
+
offset = res.next_page_offset;
|
|
373
|
+
if (offset === void 0 || offset === null || res.points.length === 0) break;
|
|
374
|
+
}
|
|
375
|
+
return ids;
|
|
376
|
+
}
|
|
377
|
+
async function deleteCollectionRaw(collection) {
|
|
378
|
+
await qdrant("DELETE", `/collections/${collection}`);
|
|
379
|
+
}
|
|
380
|
+
async function resolveAliasRaw(alias) {
|
|
381
|
+
try {
|
|
382
|
+
const res = await qdrant("GET", `/aliases`);
|
|
383
|
+
return res.aliases.find((a) => a.alias_name === alias)?.collection_name ?? null;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async function createAliasRaw(alias, collection) {
|
|
389
|
+
await qdrant("POST", `/collections/aliases`, {
|
|
390
|
+
actions: [{ create_alias: { collection_name: collection, alias_name: alias } }]
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
async function setAliasRaw(alias, collection) {
|
|
394
|
+
await qdrant("POST", `/collections/aliases`, {
|
|
395
|
+
actions: [
|
|
396
|
+
{ delete_alias: { alias_name: alias } },
|
|
397
|
+
{ create_alias: { collection_name: collection, alias_name: alias } }
|
|
398
|
+
]
|
|
399
|
+
});
|
|
400
|
+
}
|
|
324
401
|
function noteToPoint(note) {
|
|
325
402
|
return {
|
|
326
403
|
id: note.id,
|
|
@@ -465,12 +542,15 @@ function makeCrud(collectionName, modeBIsolated = false) {
|
|
|
465
542
|
},
|
|
466
543
|
/**
|
|
467
544
|
* Story 36: this is the one read that bypasses the agent filter β it fetches
|
|
468
|
-
* straight by UUID.
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
545
|
+
* straight by UUID. An unreadable note comes back as `null`, indistinguishable
|
|
546
|
+
* from missing, so nothing leaks and callers already handle it.
|
|
547
|
+
*
|
|
548
|
+
* `reader` is required. It used to be optional, and omitting it skipped the
|
|
549
|
+
* check β which meant the safe behaviour was the one you had to remember to
|
|
550
|
+
* ask for. Pass `SYSTEM_ACTOR` to read as the engine itself; that reads as a
|
|
551
|
+
* deliberate act at the call site, where an absent argument did not.
|
|
472
552
|
*/
|
|
473
|
-
async getNote(id,
|
|
553
|
+
async getNote(id, reader) {
|
|
474
554
|
await ensureCollection(col);
|
|
475
555
|
try {
|
|
476
556
|
const result = await qdrant("POST", `/collections/${col}/points`, {
|
|
@@ -480,7 +560,7 @@ function makeCrud(collectionName, modeBIsolated = false) {
|
|
|
480
560
|
});
|
|
481
561
|
if (!result.length) return null;
|
|
482
562
|
const note = pointToNote(result[0]);
|
|
483
|
-
if (
|
|
563
|
+
if (reader !== SYSTEM_ACTOR && !canRead(note, reader)) return null;
|
|
484
564
|
return note;
|
|
485
565
|
} catch {
|
|
486
566
|
return null;
|
|
@@ -518,19 +598,22 @@ function makeCrud(collectionName, modeBIsolated = false) {
|
|
|
518
598
|
return pointToNote(result.points[0]);
|
|
519
599
|
},
|
|
520
600
|
/**
|
|
521
|
-
* Story 33:
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
601
|
+
* Story 33: enforces the writers policy. Returns false β without writing β
|
|
602
|
+
* when the caller may not write. This fetch-then-check path exists for callers
|
|
603
|
+
* that only have an id (the plugin's CRUD hook); callers already holding the
|
|
604
|
+
* note can check `canWrite` themselves and skip a round trip.
|
|
605
|
+
*
|
|
606
|
+
* `caller` is required. Pass `SYSTEM_ACTOR` for the engine's own maintenance
|
|
607
|
+
* writes. The self-read below is a genuine `SYSTEM_ACTOR` case: it fetches the
|
|
608
|
+
* note in order to decide whether the caller may write it, and gating that
|
|
609
|
+
* fetch on the same policy it exists to evaluate would be circular.
|
|
527
610
|
*/
|
|
528
|
-
async updateNoteContent(id, content, embedding, hash,
|
|
611
|
+
async updateNoteContent(id, content, embedding, hash, caller) {
|
|
529
612
|
await ensureCollection(col);
|
|
530
613
|
let existing = null;
|
|
531
|
-
if (
|
|
532
|
-
existing = await this.getNote(id);
|
|
533
|
-
if (existing && !canWrite(existing,
|
|
614
|
+
if (caller !== SYSTEM_ACTOR) {
|
|
615
|
+
existing = await this.getNote(id, SYSTEM_ACTOR);
|
|
616
|
+
if (existing && !canWrite(existing, caller)) return false;
|
|
534
617
|
}
|
|
535
618
|
await qdrant("PUT", `/collections/${col}/points/vectors?wait=true`, {
|
|
536
619
|
points: [{ id, vector: embedding }]
|
|
@@ -618,11 +701,11 @@ function makeCrud(collectionName, modeBIsolated = false) {
|
|
|
618
701
|
});
|
|
619
702
|
},
|
|
620
703
|
/** Story 33: see `updateNoteContent` β returns false, unwritten, when denied. */
|
|
621
|
-
async invalidateNote(id,
|
|
704
|
+
async invalidateNote(id, caller) {
|
|
622
705
|
await ensureCollection(col);
|
|
623
|
-
if (
|
|
624
|
-
const existing = await this.getNote(id);
|
|
625
|
-
if (existing && !canWrite(existing,
|
|
706
|
+
if (caller !== SYSTEM_ACTOR) {
|
|
707
|
+
const existing = await this.getNote(id, SYSTEM_ACTOR);
|
|
708
|
+
if (existing && !canWrite(existing, caller)) return false;
|
|
626
709
|
}
|
|
627
710
|
await qdrant("POST", `/collections/${col}/points/payload?wait=true`, {
|
|
628
711
|
payload: { is_active: false },
|
|
@@ -688,8 +771,8 @@ function makeCrud(collectionName, modeBIsolated = false) {
|
|
|
688
771
|
function createStorageContext(collectionName, modeBIsolated = false) {
|
|
689
772
|
return makeCrud(collectionName || getCollection(), modeBIsolated);
|
|
690
773
|
}
|
|
691
|
-
async function getNote(id,
|
|
692
|
-
return makeCrud(getCollection()).getNote(id,
|
|
774
|
+
async function getNote(id, reader) {
|
|
775
|
+
return makeCrud(getCollection()).getNote(id, reader);
|
|
693
776
|
}
|
|
694
777
|
async function updateNote(note) {
|
|
695
778
|
return makeCrud(getCollection()).updateNote(note);
|
|
@@ -700,13 +783,13 @@ async function listNotes(agentId, subject) {
|
|
|
700
783
|
async function deleteNote(id) {
|
|
701
784
|
return makeCrud(getCollection()).deleteNote(id);
|
|
702
785
|
}
|
|
703
|
-
async function invalidateNote(id,
|
|
704
|
-
return makeCrud(getCollection()).invalidateNote(id,
|
|
786
|
+
async function invalidateNote(id, caller) {
|
|
787
|
+
return makeCrud(getCollection()).invalidateNote(id, caller);
|
|
705
788
|
}
|
|
706
789
|
async function patchNotePayload(id, fields) {
|
|
707
790
|
return makeCrud(getCollection()).patchNotePayload(id, fields);
|
|
708
791
|
}
|
|
709
|
-
var QDRANT_URL, getCollection, EmbeddingDimensionMismatchError, EmbeddingModelMismatchError, _collectionReady, _collectionReadyMap;
|
|
792
|
+
var QDRANT_URL, getCollection, EmbeddingDimensionMismatchError, EmbeddingModelMismatchError, MixedEmbeddingModelsError, _collectionReady, _collectionReadyMap;
|
|
710
793
|
var init_storage = __esm({
|
|
711
794
|
"../amem-core/src/storage.ts"() {
|
|
712
795
|
"use strict";
|
|
@@ -746,6 +829,25 @@ Either set AMEM_EMBED_MODEL back to "${collectionModel}", or ` + migrationHint(c
|
|
|
746
829
|
collectionModel;
|
|
747
830
|
configuredModel;
|
|
748
831
|
};
|
|
832
|
+
MixedEmbeddingModelsError = class extends Error {
|
|
833
|
+
constructor(collection, wanted, inUse) {
|
|
834
|
+
super(
|
|
835
|
+
`Collection "${collection}" was built with "${wanted}", but this process is already embedding with "${inUse}" for another collection. One process can only use one model.
|
|
836
|
+
Migrate the remaining collections so they all agree:
|
|
837
|
+
|
|
838
|
+
npx --package=@amemhq/core amem-migrate --from-collection ${collection}
|
|
839
|
+
|
|
840
|
+
Or set AMEM_EMBED_MODEL to pin every collection to one model, which is only correct if they really were all built with it.`
|
|
841
|
+
);
|
|
842
|
+
this.collection = collection;
|
|
843
|
+
this.wanted = wanted;
|
|
844
|
+
this.inUse = inUse;
|
|
845
|
+
this.name = "MixedEmbeddingModelsError";
|
|
846
|
+
}
|
|
847
|
+
collection;
|
|
848
|
+
wanted;
|
|
849
|
+
inUse;
|
|
850
|
+
};
|
|
749
851
|
_collectionReady = false;
|
|
750
852
|
_collectionReadyMap = /* @__PURE__ */ new Map();
|
|
751
853
|
}
|
|
@@ -1511,7 +1613,7 @@ async function addMemory(content, agentId = "main", opts) {
|
|
|
1511
1613
|
if (topMatch.length > 0 && topMatch[0].score >= 0.85) {
|
|
1512
1614
|
if (canWrite(topMatch[0].note, agentId)) {
|
|
1513
1615
|
console.log(`[add] dedup: high-sim match (sim=${topMatch[0].score.toFixed(3)}), updating existing`);
|
|
1514
|
-
await ctx.updateNoteContent(topMatch[0].note.id, content, embedding, hash);
|
|
1616
|
+
await ctx.updateNoteContent(topMatch[0].note.id, content, embedding, hash, agentId);
|
|
1515
1617
|
return topMatch[0].note.id;
|
|
1516
1618
|
}
|
|
1517
1619
|
console.log(
|
|
@@ -1583,7 +1685,7 @@ async function addMemory(content, agentId = "main", opts) {
|
|
|
1583
1685
|
note.links = linkedIds;
|
|
1584
1686
|
await ctx.updateNote(note);
|
|
1585
1687
|
for (const lid of linkedIds) {
|
|
1586
|
-
const linked = await ctx.getNote(lid);
|
|
1688
|
+
const linked = await ctx.getNote(lid, agentId);
|
|
1587
1689
|
if (linked && !linked.links.includes(note.id)) {
|
|
1588
1690
|
if (!canWrite(linked, agentId)) {
|
|
1589
1691
|
console.log(`[link] back-link into ${lid.slice(0, 8)} skipped \u2014 not writable by ${logSafe(agentId)}`);
|
|
@@ -1596,7 +1698,7 @@ async function addMemory(content, agentId = "main", opts) {
|
|
|
1596
1698
|
if (shouldRunEvolution()) {
|
|
1597
1699
|
console.log(` [evo] threshold reached, running evolution for ${Math.min(linkedIds.length, 3)} linked notes`);
|
|
1598
1700
|
for (const lid of linkedIds.slice(0, 3)) {
|
|
1599
|
-
const linked = await ctx.getNote(lid);
|
|
1701
|
+
const linked = await ctx.getNote(lid, agentId);
|
|
1600
1702
|
if (!linked) continue;
|
|
1601
1703
|
if (!canWrite(linked, agentId)) {
|
|
1602
1704
|
console.log(` [evo] skipping ${lid.slice(0, 8)} \u2014 not writable by ${logSafe(agentId)}`);
|
|
@@ -1749,7 +1851,7 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
|
|
|
1749
1851
|
const allNotes = await ctx.listNotes(agentId, subject);
|
|
1750
1852
|
const bm25State = buildBM25(allNotes);
|
|
1751
1853
|
const queryTokens = simpleTokenize(query);
|
|
1752
|
-
const bm25Ranked = bm25Score(bm25State, queryTokens).slice(0, n);
|
|
1854
|
+
const bm25Ranked = bm25Score(bm25State, queryTokens).filter(([, score]) => score > 0).slice(0, n);
|
|
1753
1855
|
const merged = rrfMerge(
|
|
1754
1856
|
embResults.map((r) => r.note.id),
|
|
1755
1857
|
bm25Ranked.map((r) => r[0])
|
|
@@ -1772,6 +1874,7 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
|
|
|
1772
1874
|
const visitedIds = new Set(topIds);
|
|
1773
1875
|
const bfsQueue = useBfs ? topIds.map((id) => ({ id, hop: 0 })) : [];
|
|
1774
1876
|
const bfsExtra = [];
|
|
1877
|
+
const bfsSimMap = /* @__PURE__ */ new Map();
|
|
1775
1878
|
while (bfsQueue.length > 0 && bfsExtra.length < BFS_MAX_EXPAND) {
|
|
1776
1879
|
const item = bfsQueue.shift();
|
|
1777
1880
|
if (item.hop >= BFS_MAX_HOPS) continue;
|
|
@@ -1782,10 +1885,9 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
|
|
|
1782
1885
|
visitedIds.add(linkedId);
|
|
1783
1886
|
const linked = noteMap.get(linkedId);
|
|
1784
1887
|
if (!linked || linked.is_active === false) continue;
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
}
|
|
1888
|
+
const sim = cosineSimilarity(queryEmbedding, linked.embedding);
|
|
1889
|
+
if (bfsSimThreshold > 0 && sim < bfsSimThreshold) continue;
|
|
1890
|
+
bfsSimMap.set(linkedId, sim);
|
|
1789
1891
|
bfsExtra.push(linkedId);
|
|
1790
1892
|
bfsQueue.push({ id: linkedId, hop: item.hop + 1 });
|
|
1791
1893
|
if (bfsExtra.length >= BFS_MAX_EXPAND) break;
|
|
@@ -1801,7 +1903,11 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
|
|
|
1801
1903
|
const embSimMap = new Map(embResults.map((r) => [r.note.id, r.score]));
|
|
1802
1904
|
const rrfMap = new Map(boostedMerged.map(([id, score]) => [id, score]));
|
|
1803
1905
|
const results = [];
|
|
1804
|
-
|
|
1906
|
+
const ordered = [
|
|
1907
|
+
...filteredTopIds.map((id) => [id, "match"]),
|
|
1908
|
+
...bfsExtra.map((id) => [id, "link"])
|
|
1909
|
+
];
|
|
1910
|
+
for (const [id, via] of ordered) {
|
|
1805
1911
|
const note = noteMap.get(id);
|
|
1806
1912
|
if (!note) continue;
|
|
1807
1913
|
results.push({
|
|
@@ -1812,8 +1918,9 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
|
|
|
1812
1918
|
keywords: note.keywords,
|
|
1813
1919
|
links: note.links,
|
|
1814
1920
|
timestamp: note.timestamp,
|
|
1815
|
-
similarity: embSimMap.get(id) ?? 0,
|
|
1921
|
+
similarity: embSimMap.get(id) ?? bfsSimMap.get(id) ?? 0,
|
|
1816
1922
|
rrf: rrfMap.get(id) ?? 0,
|
|
1923
|
+
via,
|
|
1817
1924
|
topics: note.topics ?? [],
|
|
1818
1925
|
note_type: note.note_type ?? "memory"
|
|
1819
1926
|
});
|
|
@@ -1870,7 +1977,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
|
|
|
1870
1977
|
const mergedContent = judgment.mergedContent || pendingNote.content;
|
|
1871
1978
|
const newEmbedding = await encode(buildEmbedText({ ...bestNeighbor, content: mergedContent }));
|
|
1872
1979
|
const newHash = (0, import_crypto.createHash)("md5").update(mergedContent).digest("hex");
|
|
1873
|
-
await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash);
|
|
1980
|
+
await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash, agentId);
|
|
1874
1981
|
await ctx.patchNotePayload(bestNeighbor.id, {
|
|
1875
1982
|
evolution_history: JSON.stringify(oldHistory),
|
|
1876
1983
|
evolution_type: "EVOLVE"
|
|
@@ -1894,7 +2001,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
|
|
|
1894
2001
|
const mergedContent = judgment.mergedContent || `${bestNeighbor.content}\uFF1B${pendingNote.content}`;
|
|
1895
2002
|
const newEmbedding = await encode(buildEmbedText({ ...bestNeighbor, content: mergedContent }));
|
|
1896
2003
|
const newHash = (0, import_crypto.createHash)("md5").update(mergedContent).digest("hex");
|
|
1897
|
-
await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash);
|
|
2004
|
+
await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash, agentId);
|
|
1898
2005
|
await ctx.patchNotePayload(bestNeighbor.id, {
|
|
1899
2006
|
evolution_history: JSON.stringify(oldHistory),
|
|
1900
2007
|
evolution_type: "EXPAND"
|
|
@@ -1934,7 +2041,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
|
|
|
1934
2041
|
const [keepNote, dropNote] = noteA.content.length >= noteB.content.length ? [noteA, noteB] : [noteB, noteA];
|
|
1935
2042
|
const newEmbedding = await encode(result.merged);
|
|
1936
2043
|
const newHash = (0, import_crypto.createHash)("md5").update(result.merged).digest("hex");
|
|
1937
|
-
await ctx.updateNoteContent(keepNote.id, result.merged, newEmbedding, newHash);
|
|
2044
|
+
await ctx.updateNoteContent(keepNote.id, result.merged, newEmbedding, newHash, agentId);
|
|
1938
2045
|
await ctx.deleteNote(dropNote.id);
|
|
1939
2046
|
deletedIds.add(dropNote.id);
|
|
1940
2047
|
mergedCount++;
|
|
@@ -2043,7 +2150,7 @@ async function consolidateMemories(agentId, logger, storageCtx) {
|
|
|
2043
2150
|
action: "consolidate"
|
|
2044
2151
|
});
|
|
2045
2152
|
await ctx.updateNote(keepNote);
|
|
2046
|
-
await ctx.invalidateNote(dropNote.id);
|
|
2153
|
+
await ctx.invalidateNote(dropNote.id, agentId);
|
|
2047
2154
|
await ctx.replaceLinkReferences(dropNote.id, keepNote.id, agentId);
|
|
2048
2155
|
logMergeToFile(keepNote.id, dropNote.id, keepNote.content);
|
|
2049
2156
|
processedIds.add(keepNote.id);
|
|
@@ -2366,8 +2473,19 @@ async function migrateCollection(opts) {
|
|
|
2366
2473
|
);
|
|
2367
2474
|
if (dryRun) {
|
|
2368
2475
|
log("[migrate] dry run \u2014 nothing written. Pass dryRun: false to apply.");
|
|
2369
|
-
return {
|
|
2476
|
+
return {
|
|
2477
|
+
total: notes.length,
|
|
2478
|
+
missingDerived,
|
|
2479
|
+
refreshed: 0,
|
|
2480
|
+
migrated: 0,
|
|
2481
|
+
skipped: 0,
|
|
2482
|
+
sourceDim,
|
|
2483
|
+
targetDim,
|
|
2484
|
+
model,
|
|
2485
|
+
dryRun: true
|
|
2486
|
+
};
|
|
2370
2487
|
}
|
|
2488
|
+
let alreadyDone = /* @__PURE__ */ new Set();
|
|
2371
2489
|
const existingTargetDim = await collectionDimRaw(to);
|
|
2372
2490
|
if (existingTargetDim === null) {
|
|
2373
2491
|
await createCollectionRaw(to, targetDim);
|
|
@@ -2376,9 +2494,17 @@ async function migrateCollection(opts) {
|
|
|
2376
2494
|
if (existingTargetDim !== targetDim) {
|
|
2377
2495
|
throw new Error(`migrate: target "${to}" exists at ${existingTargetDim}d but the model produces ${targetDim}d`);
|
|
2378
2496
|
}
|
|
2379
|
-
const
|
|
2380
|
-
if (
|
|
2381
|
-
|
|
2497
|
+
const present = await scrollIdsRaw(to);
|
|
2498
|
+
if (present.size > 0) {
|
|
2499
|
+
const sourceIds = new Set(notes.map((n) => n.id));
|
|
2500
|
+
const foreign = [...present].filter((id) => !sourceIds.has(id));
|
|
2501
|
+
if (foreign.length > 0) {
|
|
2502
|
+
throw new Error(
|
|
2503
|
+
`migrate: target "${to}" holds ${foreign.length} point(s) that are not in "${from}" (e.g. ${foreign[0]}). That is not an interrupted migration \u2014 use a different target.`
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
alreadyDone = present;
|
|
2507
|
+
log(`[migrate] resuming: ${present.size} of ${notes.length} already in ${to}`);
|
|
2382
2508
|
}
|
|
2383
2509
|
}
|
|
2384
2510
|
let refreshed = 0;
|
|
@@ -2392,6 +2518,7 @@ async function migrateCollection(opts) {
|
|
|
2392
2518
|
buffer = [];
|
|
2393
2519
|
};
|
|
2394
2520
|
for (const note of notes) {
|
|
2521
|
+
if (alreadyDone.has(note.id)) continue;
|
|
2395
2522
|
if (refreshFields && missingDerivedFields(note)) {
|
|
2396
2523
|
try {
|
|
2397
2524
|
const built = await llmConstructNote(note.content);
|
|
@@ -2407,7 +2534,7 @@ async function migrateCollection(opts) {
|
|
|
2407
2534
|
buffer.push(point);
|
|
2408
2535
|
if (buffer.length >= BATCH) {
|
|
2409
2536
|
await flush();
|
|
2410
|
-
log(`[migrate] ${migrated}/${notes.length}`);
|
|
2537
|
+
log(`[migrate] ${alreadyDone.size + migrated}/${notes.length}`);
|
|
2411
2538
|
}
|
|
2412
2539
|
}
|
|
2413
2540
|
await flush();
|
|
@@ -2415,10 +2542,46 @@ async function migrateCollection(opts) {
|
|
|
2415
2542
|
if (finalCount !== notes.length) {
|
|
2416
2543
|
warn(`[migrate] target holds ${finalCount} point(s) but the source had ${notes.length} \u2014 check before switching`);
|
|
2417
2544
|
}
|
|
2418
|
-
log(
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2545
|
+
log(`[migrate] done: ${migrated} written, ${alreadyDone.size} already present, ${refreshed} re-extracted.`);
|
|
2546
|
+
return {
|
|
2547
|
+
total: notes.length,
|
|
2548
|
+
missingDerived,
|
|
2549
|
+
refreshed,
|
|
2550
|
+
migrated,
|
|
2551
|
+
skipped: alreadyDone.size,
|
|
2552
|
+
sourceDim,
|
|
2553
|
+
targetDim,
|
|
2554
|
+
model,
|
|
2555
|
+
dryRun: false
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
async function switchToMigrated(opts) {
|
|
2559
|
+
const { name, to } = opts;
|
|
2560
|
+
const log = opts.logger?.info ?? ((m) => console.log(m));
|
|
2561
|
+
if (name === to) throw new Error(`switch: "${name}" and "${to}" are the same collection`);
|
|
2562
|
+
const already = await resolveAliasRaw(name);
|
|
2563
|
+
if (already === to) {
|
|
2564
|
+
log(`[switch] "${name}" already points at "${to}" \u2014 nothing to do`);
|
|
2565
|
+
return { name, to, moved: await countPointsRaw(to) };
|
|
2566
|
+
}
|
|
2567
|
+
const targetCount = await countPointsRaw(to);
|
|
2568
|
+
if (targetCount === 0) throw new Error(`switch: "${to}" is empty \u2014 migrate into it first`);
|
|
2569
|
+
if (already === null) {
|
|
2570
|
+
const sourceCount = await countPointsRaw(name);
|
|
2571
|
+
if (targetCount < sourceCount) {
|
|
2572
|
+
throw new Error(
|
|
2573
|
+
`switch: "${to}" holds ${targetCount} point(s) but "${name}" still holds ${sourceCount}. The migration is not finished \u2014 run it again before switching.`
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
log(`[switch] verified ${targetCount} in "${to}" against ${sourceCount} in "${name}"`);
|
|
2577
|
+
await deleteCollectionRaw(name);
|
|
2578
|
+
log(`[switch] dropped "${name}"`);
|
|
2579
|
+
await createAliasRaw(name, to);
|
|
2580
|
+
} else {
|
|
2581
|
+
await setAliasRaw(name, to);
|
|
2582
|
+
}
|
|
2583
|
+
log(`[switch] "${name}" now resolves to "${to}"`);
|
|
2584
|
+
return { name, to, moved: targetCount };
|
|
2422
2585
|
}
|
|
2423
2586
|
var init_migrate = __esm({
|
|
2424
2587
|
"../amem-core/src/migrate.ts"() {
|
|
@@ -2458,6 +2621,10 @@ __export(src_exports, {
|
|
|
2458
2621
|
DEFAULT_EMBEDDING_MODEL: () => DEFAULT_EMBEDDING_MODEL,
|
|
2459
2622
|
EmbeddingDimensionMismatchError: () => EmbeddingDimensionMismatchError,
|
|
2460
2623
|
EmbeddingModelMismatchError: () => EmbeddingModelMismatchError,
|
|
2624
|
+
LEGACY_DEFAULT_DIM: () => LEGACY_DEFAULT_DIM,
|
|
2625
|
+
LEGACY_DEFAULT_EMBEDDING_MODEL: () => LEGACY_DEFAULT_EMBEDDING_MODEL,
|
|
2626
|
+
MixedEmbeddingModelsError: () => MixedEmbeddingModelsError,
|
|
2627
|
+
SYSTEM_ACTOR: () => SYSTEM_ACTOR,
|
|
2461
2628
|
addEpisodic: () => addEpisodic,
|
|
2462
2629
|
addMemory: () => addMemory,
|
|
2463
2630
|
canRead: () => canRead,
|
|
@@ -2492,6 +2659,7 @@ __export(src_exports, {
|
|
|
2492
2659
|
resolveCrudUpdateMinSim: () => resolveCrudUpdateMinSim,
|
|
2493
2660
|
scanLowQuality: () => scanLowQuality,
|
|
2494
2661
|
searchMemory: () => searchMemory,
|
|
2662
|
+
switchToMigrated: () => switchToMigrated,
|
|
2495
2663
|
updateNote: () => updateNote
|
|
2496
2664
|
});
|
|
2497
2665
|
var init_src = __esm({
|
|
@@ -2609,7 +2777,7 @@ function register(api) {
|
|
|
2609
2777
|
`openclaw-amem: registered (native TS, Qdrant, default agent_id=${defaultScope.agentId}, default collection=${pluginConfig.collection ?? "amem_notes (default)"}, per-agent scope resolved per call)`
|
|
2610
2778
|
);
|
|
2611
2779
|
ensureCollection(pluginConfig.collection).catch((e) => {
|
|
2612
|
-
if (e instanceof EmbeddingDimensionMismatchError || e instanceof EmbeddingModelMismatchError) {
|
|
2780
|
+
if (e instanceof EmbeddingDimensionMismatchError || e instanceof EmbeddingModelMismatchError || e instanceof MixedEmbeddingModelsError) {
|
|
2613
2781
|
logger.error(`openclaw-amem: memory is UNUSABLE \u2014 ${e.message}`);
|
|
2614
2782
|
} else {
|
|
2615
2783
|
logger.warn(`openclaw-amem: ensureCollection failed \u2014 ${e.message}`);
|
|
@@ -2732,9 +2900,13 @@ function register(api) {
|
|
|
2732
2900
|
details: { count: 0 }
|
|
2733
2901
|
};
|
|
2734
2902
|
}
|
|
2735
|
-
const
|
|
2903
|
+
const linked = results.filter((r) => r.via === "link").length;
|
|
2904
|
+
const text = results.map(
|
|
2905
|
+
(r, i) => `${i + 1}. ${r.content} (similarity ${(r.similarity * 100).toFixed(0)}%${r.via === "link" ? ", linked \u2014 did not match the query itself" : ""}, id: ${r.id})`
|
|
2906
|
+
).join("\n");
|
|
2907
|
+
const header = linked ? `Found ${results.length - linked} matching memories, plus ${linked} linked to them:` : `Found ${results.length} memories:`;
|
|
2736
2908
|
return {
|
|
2737
|
-
content: [{ type: "text", text:
|
|
2909
|
+
content: [{ type: "text", text: `${header}
|
|
2738
2910
|
|
|
2739
2911
|
${text}${hookWarning}` }],
|
|
2740
2912
|
details: { count: results.length, memories: results }
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"id": "openclaw-amem",
|
|
3
3
|
"name": "amem",
|
|
4
4
|
"description": "Catches memories that contradict each other. Notes rewrite themselves as new ones arrive, link into a graph, and stay separated per agent and per person. Runs on a small model. Requires a local Qdrant. δΈζθ΅° jieba εθ―οΌζη€Ίθ―ζδΈζηγ",
|
|
5
|
-
"
|
|
5
|
+
"icon": "https://amem.owo.lc/logo.png",
|
|
6
|
+
"version": "2.0.0",
|
|
6
7
|
"kind": "memory",
|
|
7
8
|
"openclaw": {
|
|
8
9
|
"compat": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-amem",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Catches memories that contradict each other. Notes rewrite themselves as new ones arrive, link into a graph, and stay separated per agent and per person. Runs on a small model. Requires a local Qdrant. δΈζθ΅° jieba εθ―οΌζη€Ίθ―ζδΈζηγ",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|