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 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();
@@ -66,7 +76,9 @@ function getEmbeddingDevice() {
66
76
  return process.env.AMEM_EMBED_DEVICE?.trim() || void 0;
67
77
  }
68
78
  function getEmbeddingDtype() {
69
- return process.env.AMEM_EMBED_DTYPE?.trim() || void 0;
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/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;
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 to a new collection:
216
+ return `migrate onto it:
199
217
 
200
218
  AMEM_EMBED_MODEL=${targetModel} \\
201
- npx --package=@amemhq/core amem-migrate --to ${collection}_v2
219
+ npx --package=@amemhq/core amem-migrate --from-collection ${collection}
202
220
 
203
- That is a dry run; add --apply to write. "${collection}" is only read, so nothing is lost either way. When it looks right, point whatever names this collection at the new one \u2014 AMEM_COLLECTION, or the plugin's "collection" setting if this agent has its own. See https://amem.owo.lc/reference/embedding-models.`;
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
- await recordCollectionModel(col, getEmbeddingModel());
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. Pass `readerAgentId` to enforce `readers`; an unreadable
469
- * note comes back as `null` (indistinguishable from missing, so nothing leaks,
470
- * and callers already handle null). Omitting it skips the check, preserving
471
- * 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.
472
552
  */
473
- async getNote(id, readerAgentId) {
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 (readerAgentId !== void 0 && !canRead(note, readerAgentId)) return null;
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: pass `callerAgentId` to enforce the writers policy. Callers that
522
- * hold the note already should prefer checking `canWrite` themselves; this
523
- * fetch-then-check path exists for callers that only have an id (the plugin's
524
- * CRUD hook). Returns false β€” without writing β€” when the caller may not write.
525
- * Omitting `callerAgentId` skips the check, preserving existing behaviour for
526
- * 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.
527
610
  */
528
- async updateNoteContent(id, content, embedding, hash, callerAgentId) {
611
+ async updateNoteContent(id, content, embedding, hash, caller) {
529
612
  await ensureCollection(col);
530
613
  let existing = null;
531
- if (callerAgentId !== void 0) {
532
- existing = await this.getNote(id);
533
- 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;
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, callerAgentId) {
704
+ async invalidateNote(id, caller) {
622
705
  await ensureCollection(col);
623
- if (callerAgentId !== void 0) {
624
- const existing = await this.getNote(id);
625
- 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;
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, readerAgentId) {
692
- return makeCrud(getCollection()).getNote(id, readerAgentId);
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, callerAgentId) {
704
- return makeCrud(getCollection()).invalidateNote(id, callerAgentId);
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
- if (bfsSimThreshold > 0 && linked.embedding) {
1786
- const sim = cosineSimilarity(queryEmbedding, linked.embedding);
1787
- if (sim < bfsSimThreshold) continue;
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
- 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) {
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 { 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
+ };
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 existingCount = await countPointsRaw(to);
2380
- if (existingCount > 0) {
2381
- 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}`);
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
- `[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.`
2420
- );
2421
- 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 };
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 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:`;
2736
2908
  return {
2737
- content: [{ type: "text", text: `Found ${results.length} memories:
2909
+ content: [{ type: "text", text: `${header}
2738
2910
 
2739
2911
  ${text}${hookWarning}` }],
2740
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.3",
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": "1.4.3",
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",