openclaw-amem 1.4.2 β†’ 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 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, 384-dim) β€” no Python, no external embedding API.
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();
@@ -62,22 +72,39 @@ function getEmbeddingPooling() {
62
72
  const basename2 = getEmbeddingModel().split("/").pop()?.toLowerCase() ?? "";
63
73
  return CLS_POOLED_MODELS.has(basename2) ? "cls" : "mean";
64
74
  }
75
+ function getEmbeddingDevice() {
76
+ return process.env.AMEM_EMBED_DEVICE?.trim() || void 0;
77
+ }
78
+ function getEmbeddingDtype() {
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;
82
+ }
83
+ function extractorKey() {
84
+ return `${getEmbeddingModel()}|${getEmbeddingDevice() ?? ""}|${getEmbeddingDtype() ?? ""}`;
85
+ }
65
86
  async function getExtractor() {
66
- const wanted = getEmbeddingModel();
67
- if (extractor && loadedModelName === wanted) return extractor;
87
+ const wanted = extractorKey();
88
+ if (extractor && loadedKey === wanted) return extractor;
68
89
  if (!pipeline) {
69
90
  const mod = await import("@huggingface/transformers");
70
91
  pipeline = mod.pipeline;
71
92
  }
72
- extractor = await pipeline("feature-extraction", wanted, {
73
- revision: "main"
93
+ const device = getEmbeddingDevice();
94
+ const dtype = getEmbeddingDtype();
95
+ extractor = await pipeline("feature-extraction", getEmbeddingModel(), {
96
+ revision: "main",
97
+ // Omitted entirely when unset, so an unconfigured install gets exactly the
98
+ // library defaults it got before these existed.
99
+ ...device ? { device } : {},
100
+ ...dtype ? { dtype } : {}
74
101
  });
75
- loadedModelName = wanted;
102
+ loadedKey = wanted;
76
103
  cachedDim = null;
77
104
  return extractor;
78
105
  }
79
106
  async function getEmbeddingDim() {
80
- if (cachedDim !== null && loadedModelName === getEmbeddingModel()) return cachedDim;
107
+ if (cachedDim !== null && loadedKey === extractorKey()) return cachedDim;
81
108
  const probe = await encode("dimension probe");
82
109
  cachedDim = probe.length;
83
110
  return cachedDim;
@@ -140,15 +167,19 @@ function cosineSimilarity(a, b) {
140
167
  for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
141
168
  return dot;
142
169
  }
143
- var pipeline, extractor, loadedModelName, 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;
144
171
  var init_embedding = __esm({
145
172
  "../amem-core/src/embedding.ts"() {
146
173
  "use strict";
147
174
  pipeline = null;
148
175
  extractor = null;
149
- loadedModelName = null;
176
+ loadedKey = null;
150
177
  cachedDim = null;
151
- DEFAULT_EMBEDDING_MODEL = "Xenova/paraphrase-multilingual-MiniLM-L12-v2";
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;
152
183
  CLS_POOLED_MODELS = /* @__PURE__ */ new Set([
153
184
  "bge-m3",
154
185
  "bge-base-zh-v1.5",
@@ -172,13 +203,29 @@ function canWrite(note, callerAgentId) {
172
203
  function canRead(note, callerAgentId) {
173
204
  return note.owner === callerAgentId || note.readers.includes(callerAgentId) || note.readers.includes("*");
174
205
  }
206
+ var SYSTEM_ACTOR;
175
207
  var init_auth = __esm({
176
208
  "../amem-core/src/auth.ts"() {
177
209
  "use strict";
210
+ SYSTEM_ACTOR = "__amem_system__";
178
211
  }
179
212
  });
180
213
 
181
214
  // ../amem-core/src/storage.ts
215
+ function migrationHint(collection, targetModel) {
216
+ return `migrate onto it:
217
+
218
+ AMEM_EMBED_MODEL=${targetModel} \\
219
+ npx --package=@amemhq/core amem-migrate --from-collection ${collection}
220
+
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.`;
222
+ }
223
+ async function recordCollectionModel(collection, model) {
224
+ try {
225
+ await qdrant("PATCH", `/collections/${collection}`, { metadata: { embedding_model: model } });
226
+ } catch {
227
+ }
228
+ }
182
229
  async function qdrant(method, path6, body) {
183
230
  const res = await fetch(`${QDRANT_URL}${path6}`, {
184
231
  method,
@@ -213,12 +260,40 @@ async function ensureCollection(collectionName) {
213
260
  }
214
261
  if (existing) {
215
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
+ }
216
284
  if (typeof collectionDim === "number") {
217
285
  const modelDim = await getEmbeddingDim();
218
286
  if (collectionDim !== modelDim) {
219
287
  throw new EmbeddingDimensionMismatchError(col, collectionDim, modelDim, getEmbeddingModel());
220
288
  }
221
289
  }
290
+ const current = getEmbeddingModel();
291
+ if (typeof recorded === "string" && recorded !== current) {
292
+ throw new EmbeddingModelMismatchError(col, recorded, current);
293
+ }
294
+ if (recorded === void 0 && !inferLegacy) {
295
+ await recordCollectionModel(col, current);
296
+ }
222
297
  markReady();
223
298
  return;
224
299
  }
@@ -230,6 +305,9 @@ async function ensureCollection(collectionName) {
230
305
  } catch (err) {
231
306
  if (!(err instanceof Error) || !err.message.includes("already exists")) throw err;
232
307
  }
308
+ const created = getEmbeddingModel();
309
+ await recordCollectionModel(col, created);
310
+ if (!process.env.AMEM_EMBED_MODEL?.trim()) pinEmbeddingModel(created);
233
311
  await qdrant("PUT", `/collections/${col}/index`, {
234
312
  field_name: "agent_id",
235
313
  field_schema: "keyword"
@@ -275,6 +353,7 @@ async function collectionDimRaw(collection) {
275
353
  }
276
354
  async function createCollectionRaw(collection, size) {
277
355
  await qdrant("PUT", `/collections/${collection}`, { vectors: { size, distance: "Cosine" } });
356
+ await recordCollectionModel(collection, getEmbeddingModel());
278
357
  for (const field_name of ["agent_id", "hash", "topics", "subjects"]) {
279
358
  await qdrant("PUT", `/collections/${collection}/index`, { field_name, field_schema: "keyword" });
280
359
  }
@@ -282,6 +361,43 @@ async function createCollectionRaw(collection, size) {
282
361
  async function upsertPointsRaw(collection, points) {
283
362
  await qdrant("PUT", `/collections/${collection}/points?wait=true`, { points });
284
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
+ }
285
401
  function noteToPoint(note) {
286
402
  return {
287
403
  id: note.id,
@@ -426,12 +542,15 @@ function makeCrud(collectionName, modeBIsolated = false) {
426
542
  },
427
543
  /**
428
544
  * Story 36: this is the one read that bypasses the agent filter β€” it fetches
429
- * straight by UUID. Pass `readerAgentId` to enforce `readers`; an unreadable
430
- * note comes back as `null` (indistinguishable from missing, so nothing leaks,
431
- * and callers already handle null). Omitting it skips the check, preserving
432
- * behaviour for internal callers that only ever hold their own ids.
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.
433
552
  */
434
- async getNote(id, readerAgentId) {
553
+ async getNote(id, reader) {
435
554
  await ensureCollection(col);
436
555
  try {
437
556
  const result = await qdrant("POST", `/collections/${col}/points`, {
@@ -441,7 +560,7 @@ function makeCrud(collectionName, modeBIsolated = false) {
441
560
  });
442
561
  if (!result.length) return null;
443
562
  const note = pointToNote(result[0]);
444
- if (readerAgentId !== void 0 && !canRead(note, readerAgentId)) return null;
563
+ if (reader !== SYSTEM_ACTOR && !canRead(note, reader)) return null;
445
564
  return note;
446
565
  } catch {
447
566
  return null;
@@ -479,19 +598,22 @@ function makeCrud(collectionName, modeBIsolated = false) {
479
598
  return pointToNote(result.points[0]);
480
599
  },
481
600
  /**
482
- * Story 33: pass `callerAgentId` to enforce the writers policy. Callers that
483
- * hold the note already should prefer checking `canWrite` themselves; this
484
- * fetch-then-check path exists for callers that only have an id (the plugin's
485
- * CRUD hook). Returns false β€” without writing β€” when the caller may not write.
486
- * Omitting `callerAgentId` skips the check, preserving existing behaviour for
487
- * internal callers that are already scoped to their own notes.
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.
488
610
  */
489
- async updateNoteContent(id, content, embedding, hash, callerAgentId) {
611
+ async updateNoteContent(id, content, embedding, hash, caller) {
490
612
  await ensureCollection(col);
491
613
  let existing = null;
492
- if (callerAgentId !== void 0) {
493
- existing = await this.getNote(id);
494
- if (existing && !canWrite(existing, callerAgentId)) return false;
614
+ if (caller !== SYSTEM_ACTOR) {
615
+ existing = await this.getNote(id, SYSTEM_ACTOR);
616
+ if (existing && !canWrite(existing, caller)) return false;
495
617
  }
496
618
  await qdrant("PUT", `/collections/${col}/points/vectors?wait=true`, {
497
619
  points: [{ id, vector: embedding }]
@@ -579,11 +701,11 @@ function makeCrud(collectionName, modeBIsolated = false) {
579
701
  });
580
702
  },
581
703
  /** Story 33: see `updateNoteContent` β€” returns false, unwritten, when denied. */
582
- async invalidateNote(id, callerAgentId) {
704
+ async invalidateNote(id, caller) {
583
705
  await ensureCollection(col);
584
- if (callerAgentId !== void 0) {
585
- const existing = await this.getNote(id);
586
- if (existing && !canWrite(existing, callerAgentId)) return false;
706
+ if (caller !== SYSTEM_ACTOR) {
707
+ const existing = await this.getNote(id, SYSTEM_ACTOR);
708
+ if (existing && !canWrite(existing, caller)) return false;
587
709
  }
588
710
  await qdrant("POST", `/collections/${col}/points/payload?wait=true`, {
589
711
  payload: { is_active: false },
@@ -649,8 +771,8 @@ function makeCrud(collectionName, modeBIsolated = false) {
649
771
  function createStorageContext(collectionName, modeBIsolated = false) {
650
772
  return makeCrud(collectionName || getCollection(), modeBIsolated);
651
773
  }
652
- async function getNote(id, readerAgentId) {
653
- return makeCrud(getCollection()).getNote(id, readerAgentId);
774
+ async function getNote(id, reader) {
775
+ return makeCrud(getCollection()).getNote(id, reader);
654
776
  }
655
777
  async function updateNote(note) {
656
778
  return makeCrud(getCollection()).updateNote(note);
@@ -661,13 +783,13 @@ async function listNotes(agentId, subject) {
661
783
  async function deleteNote(id) {
662
784
  return makeCrud(getCollection()).deleteNote(id);
663
785
  }
664
- async function invalidateNote(id, callerAgentId) {
665
- return makeCrud(getCollection()).invalidateNote(id, callerAgentId);
786
+ async function invalidateNote(id, caller) {
787
+ return makeCrud(getCollection()).invalidateNote(id, caller);
666
788
  }
667
789
  async function patchNotePayload(id, fields) {
668
790
  return makeCrud(getCollection()).patchNotePayload(id, fields);
669
791
  }
670
- var QDRANT_URL, getCollection, EmbeddingDimensionMismatchError, _collectionReady, _collectionReadyMap;
792
+ var QDRANT_URL, getCollection, EmbeddingDimensionMismatchError, EmbeddingModelMismatchError, MixedEmbeddingModelsError, _collectionReady, _collectionReadyMap;
671
793
  var init_storage = __esm({
672
794
  "../amem-core/src/storage.ts"() {
673
795
  "use strict";
@@ -679,7 +801,7 @@ var init_storage = __esm({
679
801
  constructor(collection, collectionDim, modelDim, model) {
680
802
  super(
681
803
  `Collection "${collection}" stores ${collectionDim}-dimension vectors, but the embedding model "${model}" produces ${modelDim}. Qdrant fixes a collection's vector size at creation and cannot change it, so writes and searches would both fail.
682
- Either set AMEM_EMBED_MODEL back to the model this collection was built with, or migrate: build a new collection with the new model, backfill it, then point AMEM_COLLECTION at it. See docs/reference/embedding-models.md.`
804
+ Either set AMEM_EMBED_MODEL back to the model this collection was built with, or ${migrationHint(collection, model)}`
683
805
  );
684
806
  this.collection = collection;
685
807
  this.collectionDim = collectionDim;
@@ -692,6 +814,40 @@ Either set AMEM_EMBED_MODEL back to the model this collection was built with, or
692
814
  modelDim;
693
815
  model;
694
816
  };
817
+ EmbeddingModelMismatchError = class extends Error {
818
+ constructor(collection, collectionModel, configuredModel) {
819
+ super(
820
+ `Collection "${collection}" was built with the embedding model "${collectionModel}", but this process is configured for "${configuredModel}". Both produce vectors of the same width, so nothing would fail \u2014 searches would just quietly compare vectors from two different models.
821
+ Either set AMEM_EMBED_MODEL back to "${collectionModel}", or ` + migrationHint(collection, configuredModel)
822
+ );
823
+ this.collection = collection;
824
+ this.collectionModel = collectionModel;
825
+ this.configuredModel = configuredModel;
826
+ this.name = "EmbeddingModelMismatchError";
827
+ }
828
+ collection;
829
+ collectionModel;
830
+ configuredModel;
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
+ };
695
851
  _collectionReady = false;
696
852
  _collectionReadyMap = /* @__PURE__ */ new Map();
697
853
  }
@@ -1457,7 +1613,7 @@ async function addMemory(content, agentId = "main", opts) {
1457
1613
  if (topMatch.length > 0 && topMatch[0].score >= 0.85) {
1458
1614
  if (canWrite(topMatch[0].note, agentId)) {
1459
1615
  console.log(`[add] dedup: high-sim match (sim=${topMatch[0].score.toFixed(3)}), updating existing`);
1460
- await ctx.updateNoteContent(topMatch[0].note.id, content, embedding, hash);
1616
+ await ctx.updateNoteContent(topMatch[0].note.id, content, embedding, hash, agentId);
1461
1617
  return topMatch[0].note.id;
1462
1618
  }
1463
1619
  console.log(
@@ -1529,7 +1685,7 @@ async function addMemory(content, agentId = "main", opts) {
1529
1685
  note.links = linkedIds;
1530
1686
  await ctx.updateNote(note);
1531
1687
  for (const lid of linkedIds) {
1532
- const linked = await ctx.getNote(lid);
1688
+ const linked = await ctx.getNote(lid, agentId);
1533
1689
  if (linked && !linked.links.includes(note.id)) {
1534
1690
  if (!canWrite(linked, agentId)) {
1535
1691
  console.log(`[link] back-link into ${lid.slice(0, 8)} skipped \u2014 not writable by ${logSafe(agentId)}`);
@@ -1542,7 +1698,7 @@ async function addMemory(content, agentId = "main", opts) {
1542
1698
  if (shouldRunEvolution()) {
1543
1699
  console.log(` [evo] threshold reached, running evolution for ${Math.min(linkedIds.length, 3)} linked notes`);
1544
1700
  for (const lid of linkedIds.slice(0, 3)) {
1545
- const linked = await ctx.getNote(lid);
1701
+ const linked = await ctx.getNote(lid, agentId);
1546
1702
  if (!linked) continue;
1547
1703
  if (!canWrite(linked, agentId)) {
1548
1704
  console.log(` [evo] skipping ${lid.slice(0, 8)} \u2014 not writable by ${logSafe(agentId)}`);
@@ -1695,7 +1851,7 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1695
1851
  const allNotes = await ctx.listNotes(agentId, subject);
1696
1852
  const bm25State = buildBM25(allNotes);
1697
1853
  const queryTokens = simpleTokenize(query);
1698
- const bm25Ranked = bm25Score(bm25State, queryTokens).slice(0, n);
1854
+ const bm25Ranked = bm25Score(bm25State, queryTokens).filter(([, score]) => score > 0).slice(0, n);
1699
1855
  const merged = rrfMerge(
1700
1856
  embResults.map((r) => r.note.id),
1701
1857
  bm25Ranked.map((r) => r[0])
@@ -1718,6 +1874,7 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1718
1874
  const visitedIds = new Set(topIds);
1719
1875
  const bfsQueue = useBfs ? topIds.map((id) => ({ id, hop: 0 })) : [];
1720
1876
  const bfsExtra = [];
1877
+ const bfsSimMap = /* @__PURE__ */ new Map();
1721
1878
  while (bfsQueue.length > 0 && bfsExtra.length < BFS_MAX_EXPAND) {
1722
1879
  const item = bfsQueue.shift();
1723
1880
  if (item.hop >= BFS_MAX_HOPS) continue;
@@ -1728,10 +1885,9 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1728
1885
  visitedIds.add(linkedId);
1729
1886
  const linked = noteMap.get(linkedId);
1730
1887
  if (!linked || linked.is_active === false) continue;
1731
- if (bfsSimThreshold > 0 && linked.embedding) {
1732
- const sim = cosineSimilarity(queryEmbedding, linked.embedding);
1733
- if (sim < bfsSimThreshold) continue;
1734
- }
1888
+ const sim = cosineSimilarity(queryEmbedding, linked.embedding);
1889
+ if (bfsSimThreshold > 0 && sim < bfsSimThreshold) continue;
1890
+ bfsSimMap.set(linkedId, sim);
1735
1891
  bfsExtra.push(linkedId);
1736
1892
  bfsQueue.push({ id: linkedId, hop: item.hop + 1 });
1737
1893
  if (bfsExtra.length >= BFS_MAX_EXPAND) break;
@@ -1747,7 +1903,11 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1747
1903
  const embSimMap = new Map(embResults.map((r) => [r.note.id, r.score]));
1748
1904
  const rrfMap = new Map(boostedMerged.map(([id, score]) => [id, score]));
1749
1905
  const results = [];
1750
- for (const id of [...filteredTopIds, ...bfsExtra]) {
1906
+ const ordered = [
1907
+ ...filteredTopIds.map((id) => [id, "match"]),
1908
+ ...bfsExtra.map((id) => [id, "link"])
1909
+ ];
1910
+ for (const [id, via] of ordered) {
1751
1911
  const note = noteMap.get(id);
1752
1912
  if (!note) continue;
1753
1913
  results.push({
@@ -1758,8 +1918,9 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1758
1918
  keywords: note.keywords,
1759
1919
  links: note.links,
1760
1920
  timestamp: note.timestamp,
1761
- similarity: embSimMap.get(id) ?? 0,
1921
+ similarity: embSimMap.get(id) ?? bfsSimMap.get(id) ?? 0,
1762
1922
  rrf: rrfMap.get(id) ?? 0,
1923
+ via,
1763
1924
  topics: note.topics ?? [],
1764
1925
  note_type: note.note_type ?? "memory"
1765
1926
  });
@@ -1816,7 +1977,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
1816
1977
  const mergedContent = judgment.mergedContent || pendingNote.content;
1817
1978
  const newEmbedding = await encode(buildEmbedText({ ...bestNeighbor, content: mergedContent }));
1818
1979
  const newHash = (0, import_crypto.createHash)("md5").update(mergedContent).digest("hex");
1819
- await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash);
1980
+ await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash, agentId);
1820
1981
  await ctx.patchNotePayload(bestNeighbor.id, {
1821
1982
  evolution_history: JSON.stringify(oldHistory),
1822
1983
  evolution_type: "EVOLVE"
@@ -1840,7 +2001,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
1840
2001
  const mergedContent = judgment.mergedContent || `${bestNeighbor.content}\uFF1B${pendingNote.content}`;
1841
2002
  const newEmbedding = await encode(buildEmbedText({ ...bestNeighbor, content: mergedContent }));
1842
2003
  const newHash = (0, import_crypto.createHash)("md5").update(mergedContent).digest("hex");
1843
- await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash);
2004
+ await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash, agentId);
1844
2005
  await ctx.patchNotePayload(bestNeighbor.id, {
1845
2006
  evolution_history: JSON.stringify(oldHistory),
1846
2007
  evolution_type: "EXPAND"
@@ -1880,7 +2041,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
1880
2041
  const [keepNote, dropNote] = noteA.content.length >= noteB.content.length ? [noteA, noteB] : [noteB, noteA];
1881
2042
  const newEmbedding = await encode(result.merged);
1882
2043
  const newHash = (0, import_crypto.createHash)("md5").update(result.merged).digest("hex");
1883
- await ctx.updateNoteContent(keepNote.id, result.merged, newEmbedding, newHash);
2044
+ await ctx.updateNoteContent(keepNote.id, result.merged, newEmbedding, newHash, agentId);
1884
2045
  await ctx.deleteNote(dropNote.id);
1885
2046
  deletedIds.add(dropNote.id);
1886
2047
  mergedCount++;
@@ -1989,7 +2150,7 @@ async function consolidateMemories(agentId, logger, storageCtx) {
1989
2150
  action: "consolidate"
1990
2151
  });
1991
2152
  await ctx.updateNote(keepNote);
1992
- await ctx.invalidateNote(dropNote.id);
2153
+ await ctx.invalidateNote(dropNote.id, agentId);
1993
2154
  await ctx.replaceLinkReferences(dropNote.id, keepNote.id, agentId);
1994
2155
  logMergeToFile(keepNote.id, dropNote.id, keepNote.content);
1995
2156
  processedIds.add(keepNote.id);
@@ -2312,8 +2473,19 @@ async function migrateCollection(opts) {
2312
2473
  );
2313
2474
  if (dryRun) {
2314
2475
  log("[migrate] dry run \u2014 nothing written. Pass dryRun: false to apply.");
2315
- return { total: notes.length, missingDerived, refreshed: 0, migrated: 0, sourceDim, targetDim, model, dryRun: true };
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
+ };
2316
2487
  }
2488
+ let alreadyDone = /* @__PURE__ */ new Set();
2317
2489
  const existingTargetDim = await collectionDimRaw(to);
2318
2490
  if (existingTargetDim === null) {
2319
2491
  await createCollectionRaw(to, targetDim);
@@ -2322,9 +2494,17 @@ async function migrateCollection(opts) {
2322
2494
  if (existingTargetDim !== targetDim) {
2323
2495
  throw new Error(`migrate: target "${to}" exists at ${existingTargetDim}d but the model produces ${targetDim}d`);
2324
2496
  }
2325
- const existingCount = await countPointsRaw(to);
2326
- if (existingCount > 0) {
2327
- throw new Error(`migrate: target "${to}" already holds ${existingCount} point(s); use an empty collection`);
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}`);
2328
2508
  }
2329
2509
  }
2330
2510
  let refreshed = 0;
@@ -2338,6 +2518,7 @@ async function migrateCollection(opts) {
2338
2518
  buffer = [];
2339
2519
  };
2340
2520
  for (const note of notes) {
2521
+ if (alreadyDone.has(note.id)) continue;
2341
2522
  if (refreshFields && missingDerivedFields(note)) {
2342
2523
  try {
2343
2524
  const built = await llmConstructNote(note.content);
@@ -2353,7 +2534,7 @@ async function migrateCollection(opts) {
2353
2534
  buffer.push(point);
2354
2535
  if (buffer.length >= BATCH) {
2355
2536
  await flush();
2356
- log(`[migrate] ${migrated}/${notes.length}`);
2537
+ log(`[migrate] ${alreadyDone.size + migrated}/${notes.length}`);
2357
2538
  }
2358
2539
  }
2359
2540
  await flush();
@@ -2361,10 +2542,46 @@ async function migrateCollection(opts) {
2361
2542
  if (finalCount !== notes.length) {
2362
2543
  warn(`[migrate] target holds ${finalCount} point(s) but the source had ${notes.length} \u2014 check before switching`);
2363
2544
  }
2364
- log(
2365
- `[migrate] done: ${migrated} migrated, ${refreshed} re-extracted. "${from}" is untouched \u2014 switch with AMEM_COLLECTION=${to}, and keep the old one until you are satisfied.`
2366
- );
2367
- return { total: notes.length, missingDerived, refreshed, migrated, sourceDim, targetDim, model, dryRun: false };
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 };
2368
2585
  }
2369
2586
  var init_migrate = __esm({
2370
2587
  "../amem-core/src/migrate.ts"() {
@@ -2403,6 +2620,11 @@ __export(src_exports, {
2403
2620
  DEFAULT_CRUD_UPDATE_MIN_SIM: () => DEFAULT_CRUD_UPDATE_MIN_SIM,
2404
2621
  DEFAULT_EMBEDDING_MODEL: () => DEFAULT_EMBEDDING_MODEL,
2405
2622
  EmbeddingDimensionMismatchError: () => EmbeddingDimensionMismatchError,
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,
2406
2628
  addEpisodic: () => addEpisodic,
2407
2629
  addMemory: () => addMemory,
2408
2630
  canRead: () => canRead,
@@ -2417,7 +2639,9 @@ __export(src_exports, {
2417
2639
  encode: () => encode,
2418
2640
  ensureCollection: () => ensureCollection,
2419
2641
  generateReviewBatch: () => generateReviewBatch,
2642
+ getEmbeddingDevice: () => getEmbeddingDevice,
2420
2643
  getEmbeddingDim: () => getEmbeddingDim,
2644
+ getEmbeddingDtype: () => getEmbeddingDtype,
2421
2645
  getEmbeddingModel: () => getEmbeddingModel,
2422
2646
  getEmbeddingPooling: () => getEmbeddingPooling,
2423
2647
  getNote: () => getNote,
@@ -2435,6 +2659,7 @@ __export(src_exports, {
2435
2659
  resolveCrudUpdateMinSim: () => resolveCrudUpdateMinSim,
2436
2660
  scanLowQuality: () => scanLowQuality,
2437
2661
  searchMemory: () => searchMemory,
2662
+ switchToMigrated: () => switchToMigrated,
2438
2663
  updateNote: () => updateNote
2439
2664
  });
2440
2665
  var init_src = __esm({
@@ -2552,7 +2777,7 @@ function register(api) {
2552
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)`
2553
2778
  );
2554
2779
  ensureCollection(pluginConfig.collection).catch((e) => {
2555
- if (e instanceof EmbeddingDimensionMismatchError) {
2780
+ if (e instanceof EmbeddingDimensionMismatchError || e instanceof EmbeddingModelMismatchError || e instanceof MixedEmbeddingModelsError) {
2556
2781
  logger.error(`openclaw-amem: memory is UNUSABLE \u2014 ${e.message}`);
2557
2782
  } else {
2558
2783
  logger.warn(`openclaw-amem: ensureCollection failed \u2014 ${e.message}`);
@@ -2675,9 +2900,13 @@ function register(api) {
2675
2900
  details: { count: 0 }
2676
2901
  };
2677
2902
  }
2678
- const text = results.map((r, i) => `${i + 1}. ${r.content} (score: ${(r.similarity * 100).toFixed(0)}%, id: ${r.id})`).join("\n");
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:`;
2679
2908
  return {
2680
- content: [{ type: "text", text: `Found ${results.length} memories:
2909
+ content: [{ type: "text", text: `${header}
2681
2910
 
2682
2911
  ${text}${hookWarning}` }],
2683
2912
  details: { count: results.length, memories: results }
@@ -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
- "version": "1.4.2",
5
+ "icon": "https://amem.owo.lc/logo.png",
6
+ "version": "2.0.0",
6
7
  "kind": "memory",
7
8
  "openclaw": {
8
9
  "compat": {
@@ -40,6 +41,8 @@
40
41
  "AMEM_CRUD_UPDATE_MIN_SIM",
41
42
  "AMEM_EMBED_MODEL",
42
43
  "AMEM_EMBED_POOLING",
44
+ "AMEM_EMBED_DEVICE",
45
+ "AMEM_EMBED_DTYPE",
43
46
  "AMEM_COLLECTION",
44
47
  "AMEM_DATA_DIR",
45
48
  "AMEM_EVO_COUNTER_PATH",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-amem",
3
- "version": "1.4.2",
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",