@amemhq/core 1.1.0 → 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
@@ -17,7 +17,7 @@ Unlike a flat vector store, A-MEM maintains memory as a living, self-evolving se
17
17
  1. **Note Construction** — an LLM extracts keywords, tags, and a context summary; categorizes the note; and classifies it as `memory` (episodic) or `knowledge` (durable), extracting 1–5 `topics` for knowledge notes.
18
18
  2. **Link Generation** — retrieves top-6 candidates; the LLM judges whether to link bidirectionally (similarity > 0.3).
19
19
  3. **Memory Evolution** — up to 3 linked notes have their attributes evolved from the new context, possibly triggering further links.
20
- 4. **Hybrid Retrieval** — fuses dense vectors (Transformers.js `paraphrase-multilingual-MiniLM-L12-v2`, 384-dim) and BM25 via Reciprocal Rank Fusion (RRF), boosted by retrieval heat.
20
+ 4. **Hybrid Retrieval** — fuses dense vectors (Transformers.js `bge-m3`, 1024-dim) and BM25 via Reciprocal Rank Fusion (RRF), boosted by retrieval heat.
21
21
  5. **2-hop BFS Graph Expansion** — after RRF top-K, BFS walks the link graph up to 2 hops, admitting up to 8 graph-connected notes that pass an embedding relevance gate (cos-sim ≥ 0.25). This is the key advantage over flat vector systems.
22
22
 
23
23
  ## Features
@@ -47,7 +47,7 @@ host (OpenClaw plugin / amem-api / game agent)
47
47
 
48
48
  @amemhq/core (TypeScript)
49
49
  ├── LLM (Anthropic) note construction · link judgment · CRUD · evolution
50
- ├── Transformers.js (ONNX) 384-dim local embeddings + Jieba BM25
50
+ ├── Transformers.js (ONNX) local embeddings + Jieba BM25
51
51
  └── Qdrant :6333 vector store · owner/readers/writers · agent_id isolation
52
52
  ```
53
53
 
@@ -1,4 +1,5 @@
1
1
  // src/auth.ts
2
+ var SYSTEM_ACTOR = "__amem_system__";
2
3
  function canWrite(note, callerAgentId) {
3
4
  return note.owner === callerAgentId || note.writers.includes(callerAgentId) || note.writers.includes("*");
4
5
  }
@@ -11,9 +12,23 @@ var pipeline = null;
11
12
  var extractor = null;
12
13
  var loadedKey = null;
13
14
  var cachedDim = null;
14
- var DEFAULT_EMBEDDING_MODEL = "Xenova/paraphrase-multilingual-MiniLM-L12-v2";
15
+ var DEFAULT_EMBEDDING_MODEL = "Xenova/bge-m3";
16
+ var LEGACY_DEFAULT_EMBEDDING_MODEL = "Xenova/paraphrase-multilingual-MiniLM-L12-v2";
17
+ var LEGACY_DEFAULT_DIM = 384;
18
+ var DEFAULT_MODEL_DTYPE = "fp16";
19
+ var pinnedModel = null;
20
+ function pinEmbeddingModel(model) {
21
+ if (pinnedModel === model) return;
22
+ pinnedModel = model;
23
+ extractor = null;
24
+ loadedKey = null;
25
+ cachedDim = null;
26
+ }
27
+ function getPinnedEmbeddingModel() {
28
+ return pinnedModel;
29
+ }
15
30
  function getEmbeddingModel() {
16
- return process.env.AMEM_EMBED_MODEL?.trim() || DEFAULT_EMBEDDING_MODEL;
31
+ return process.env.AMEM_EMBED_MODEL?.trim() || pinnedModel || DEFAULT_EMBEDDING_MODEL;
17
32
  }
18
33
  var CLS_POOLED_MODELS = /* @__PURE__ */ new Set([
19
34
  "bge-m3",
@@ -38,7 +53,9 @@ function getEmbeddingDevice() {
38
53
  return process.env.AMEM_EMBED_DEVICE?.trim() || void 0;
39
54
  }
40
55
  function getEmbeddingDtype() {
41
- return process.env.AMEM_EMBED_DTYPE?.trim() || void 0;
56
+ const explicit = process.env.AMEM_EMBED_DTYPE?.trim();
57
+ if (explicit) return explicit;
58
+ return getEmbeddingModel() === DEFAULT_EMBEDDING_MODEL ? DEFAULT_MODEL_DTYPE : void 0;
42
59
  }
43
60
  function extractorKey() {
44
61
  return `${getEmbeddingModel()}|${getEmbeddingDevice() ?? ""}|${getEmbeddingDtype() ?? ""}`;
@@ -149,12 +166,12 @@ Either set AMEM_EMBED_MODEL back to the model this collection was built with, or
149
166
  model;
150
167
  };
151
168
  function migrationHint(collection, targetModel) {
152
- return `migrate to a new collection:
169
+ return `migrate onto it:
153
170
 
154
171
  AMEM_EMBED_MODEL=${targetModel} \\
155
- npx --package=@amemhq/core amem-migrate --to ${collection}_v2
172
+ npx --package=@amemhq/core amem-migrate --from-collection ${collection}
156
173
 
157
- 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.`;
174
+ 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.`;
158
175
  }
159
176
  var EmbeddingModelMismatchError = class extends Error {
160
177
  constructor(collection, collectionModel, configuredModel) {
@@ -171,6 +188,25 @@ Either set AMEM_EMBED_MODEL back to "${collectionModel}", or ` + migrationHint(c
171
188
  collectionModel;
172
189
  configuredModel;
173
190
  };
191
+ var MixedEmbeddingModelsError = class extends Error {
192
+ constructor(collection, wanted, inUse) {
193
+ super(
194
+ `Collection "${collection}" was built with "${wanted}", but this process is already embedding with "${inUse}" for another collection. One process can only use one model.
195
+ Migrate the remaining collections so they all agree:
196
+
197
+ npx --package=@amemhq/core amem-migrate --from-collection ${collection}
198
+
199
+ Or set AMEM_EMBED_MODEL to pin every collection to one model, which is only correct if they really were all built with it.`
200
+ );
201
+ this.collection = collection;
202
+ this.wanted = wanted;
203
+ this.inUse = inUse;
204
+ this.name = "MixedEmbeddingModelsError";
205
+ }
206
+ collection;
207
+ wanted;
208
+ inUse;
209
+ };
174
210
  async function recordCollectionModel(collection, model) {
175
211
  try {
176
212
  await qdrant("PATCH", `/collections/${collection}`, { metadata: { embedding_model: model } });
@@ -213,18 +249,38 @@ async function ensureCollection(collectionName) {
213
249
  }
214
250
  if (existing) {
215
251
  const collectionDim = existing.config?.params?.vectors?.size;
252
+ const recorded = existing.config?.metadata?.embedding_model;
253
+ const explicit = process.env.AMEM_EMBED_MODEL?.trim();
254
+ const inferLegacy = !explicit && recorded === void 0 && collectionDim === LEGACY_DEFAULT_DIM;
255
+ if (!explicit) {
256
+ const wanted = (
257
+ // The collection says what built it, which outranks whatever the shipped
258
+ // default happens to be today. This is what keeps changing the default
259
+ // from breaking every install that already has data.
260
+ typeof recorded === "string" ? recorded : inferLegacy ? LEGACY_DEFAULT_EMBEDDING_MODEL : DEFAULT_EMBEDDING_MODEL
261
+ );
262
+ const inUse = getPinnedEmbeddingModel();
263
+ if (inUse !== null && inUse !== wanted) throw new MixedEmbeddingModelsError(col, wanted, inUse);
264
+ pinEmbeddingModel(wanted);
265
+ if (wanted === LEGACY_DEFAULT_EMBEDDING_MODEL) {
266
+ console.warn(
267
+ `[amem] "${col}" is on ${LEGACY_DEFAULT_EMBEDDING_MODEL} (${LEGACY_DEFAULT_DIM}-dim).
268
+ [amem] ${DEFAULT_EMBEDDING_MODEL} reads 8192 tokens where that one stops at 128, so anything longer is being truncated before it reaches the vector.
269
+ [amem] To move: npx --package=@amemhq/core amem-migrate --from-collection ${col}`
270
+ );
271
+ }
272
+ }
216
273
  if (typeof collectionDim === "number") {
217
274
  const modelDim = await getEmbeddingDim();
218
275
  if (collectionDim !== modelDim) {
219
276
  throw new EmbeddingDimensionMismatchError(col, collectionDim, modelDim, getEmbeddingModel());
220
277
  }
221
278
  }
222
- const recorded = existing.config?.metadata?.embedding_model;
223
279
  const current = getEmbeddingModel();
224
280
  if (typeof recorded === "string" && recorded !== current) {
225
281
  throw new EmbeddingModelMismatchError(col, recorded, current);
226
282
  }
227
- if (recorded === void 0) {
283
+ if (recorded === void 0 && !inferLegacy) {
228
284
  await recordCollectionModel(col, current);
229
285
  }
230
286
  markReady();
@@ -238,7 +294,9 @@ async function ensureCollection(collectionName) {
238
294
  } catch (err) {
239
295
  if (!(err instanceof Error) || !err.message.includes("already exists")) throw err;
240
296
  }
241
- await recordCollectionModel(col, getEmbeddingModel());
297
+ const created = getEmbeddingModel();
298
+ await recordCollectionModel(col, created);
299
+ if (!process.env.AMEM_EMBED_MODEL?.trim()) pinEmbeddingModel(created);
242
300
  await qdrant("PUT", `/collections/${col}/index`, {
243
301
  field_name: "agent_id",
244
302
  field_schema: "keyword"
@@ -292,6 +350,43 @@ async function createCollectionRaw(collection, size) {
292
350
  async function upsertPointsRaw(collection, points) {
293
351
  await qdrant("PUT", `/collections/${collection}/points?wait=true`, { points });
294
352
  }
353
+ async function scrollIdsRaw(collection, limit = 1e4) {
354
+ const ids = /* @__PURE__ */ new Set();
355
+ let offset = void 0;
356
+ for (; ; ) {
357
+ const body = { with_payload: false, with_vector: false, limit };
358
+ if (offset !== void 0 && offset !== null) body.offset = offset;
359
+ const res = await qdrant("POST", `/collections/${collection}/points/scroll`, body);
360
+ for (const p of res.points) ids.add(String(p.id));
361
+ offset = res.next_page_offset;
362
+ if (offset === void 0 || offset === null || res.points.length === 0) break;
363
+ }
364
+ return ids;
365
+ }
366
+ async function deleteCollectionRaw(collection) {
367
+ await qdrant("DELETE", `/collections/${collection}`);
368
+ }
369
+ async function resolveAliasRaw(alias) {
370
+ try {
371
+ const res = await qdrant("GET", `/aliases`);
372
+ return res.aliases.find((a) => a.alias_name === alias)?.collection_name ?? null;
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+ async function createAliasRaw(alias, collection) {
378
+ await qdrant("POST", `/collections/aliases`, {
379
+ actions: [{ create_alias: { collection_name: collection, alias_name: alias } }]
380
+ });
381
+ }
382
+ async function setAliasRaw(alias, collection) {
383
+ await qdrant("POST", `/collections/aliases`, {
384
+ actions: [
385
+ { delete_alias: { alias_name: alias } },
386
+ { create_alias: { collection_name: collection, alias_name: alias } }
387
+ ]
388
+ });
389
+ }
295
390
  function noteToPoint(note) {
296
391
  return {
297
392
  id: note.id,
@@ -436,12 +531,15 @@ function makeCrud(collectionName, modeBIsolated = false) {
436
531
  },
437
532
  /**
438
533
  * Story 36: this is the one read that bypasses the agent filter — it fetches
439
- * straight by UUID. Pass `readerAgentId` to enforce `readers`; an unreadable
440
- * note comes back as `null` (indistinguishable from missing, so nothing leaks,
441
- * and callers already handle null). Omitting it skips the check, preserving
442
- * behaviour for internal callers that only ever hold their own ids.
534
+ * straight by UUID. An unreadable note comes back as `null`, indistinguishable
535
+ * from missing, so nothing leaks and callers already handle it.
536
+ *
537
+ * `reader` is required. It used to be optional, and omitting it skipped the
538
+ * check — which meant the safe behaviour was the one you had to remember to
539
+ * ask for. Pass `SYSTEM_ACTOR` to read as the engine itself; that reads as a
540
+ * deliberate act at the call site, where an absent argument did not.
443
541
  */
444
- async getNote(id, readerAgentId) {
542
+ async getNote(id, reader) {
445
543
  await ensureCollection(col);
446
544
  try {
447
545
  const result = await qdrant("POST", `/collections/${col}/points`, {
@@ -451,7 +549,7 @@ function makeCrud(collectionName, modeBIsolated = false) {
451
549
  });
452
550
  if (!result.length) return null;
453
551
  const note = pointToNote(result[0]);
454
- if (readerAgentId !== void 0 && !canRead(note, readerAgentId)) return null;
552
+ if (reader !== SYSTEM_ACTOR && !canRead(note, reader)) return null;
455
553
  return note;
456
554
  } catch {
457
555
  return null;
@@ -489,19 +587,22 @@ function makeCrud(collectionName, modeBIsolated = false) {
489
587
  return pointToNote(result.points[0]);
490
588
  },
491
589
  /**
492
- * Story 33: pass `callerAgentId` to enforce the writers policy. Callers that
493
- * hold the note already should prefer checking `canWrite` themselves; this
494
- * fetch-then-check path exists for callers that only have an id (the plugin's
495
- * CRUD hook). Returns false without writing when the caller may not write.
496
- * Omitting `callerAgentId` skips the check, preserving existing behaviour for
497
- * internal callers that are already scoped to their own notes.
590
+ * Story 33: enforces the writers policy. Returns false — without writing —
591
+ * when the caller may not write. This fetch-then-check path exists for callers
592
+ * that only have an id (the plugin's CRUD hook); callers already holding the
593
+ * note can check `canWrite` themselves and skip a round trip.
594
+ *
595
+ * `caller` is required. Pass `SYSTEM_ACTOR` for the engine's own maintenance
596
+ * writes. The self-read below is a genuine `SYSTEM_ACTOR` case: it fetches the
597
+ * note in order to decide whether the caller may write it, and gating that
598
+ * fetch on the same policy it exists to evaluate would be circular.
498
599
  */
499
- async updateNoteContent(id, content, embedding, hash, callerAgentId) {
600
+ async updateNoteContent(id, content, embedding, hash, caller) {
500
601
  await ensureCollection(col);
501
602
  let existing = null;
502
- if (callerAgentId !== void 0) {
503
- existing = await this.getNote(id);
504
- if (existing && !canWrite(existing, callerAgentId)) return false;
603
+ if (caller !== SYSTEM_ACTOR) {
604
+ existing = await this.getNote(id, SYSTEM_ACTOR);
605
+ if (existing && !canWrite(existing, caller)) return false;
505
606
  }
506
607
  await qdrant("PUT", `/collections/${col}/points/vectors?wait=true`, {
507
608
  points: [{ id, vector: embedding }]
@@ -589,11 +690,11 @@ function makeCrud(collectionName, modeBIsolated = false) {
589
690
  });
590
691
  },
591
692
  /** Story 33: see `updateNoteContent` — returns false, unwritten, when denied. */
592
- async invalidateNote(id, callerAgentId) {
693
+ async invalidateNote(id, caller) {
593
694
  await ensureCollection(col);
594
- if (callerAgentId !== void 0) {
595
- const existing = await this.getNote(id);
596
- if (existing && !canWrite(existing, callerAgentId)) return false;
695
+ if (caller !== SYSTEM_ACTOR) {
696
+ const existing = await this.getNote(id, SYSTEM_ACTOR);
697
+ if (existing && !canWrite(existing, caller)) return false;
597
698
  }
598
699
  await qdrant("POST", `/collections/${col}/points/payload?wait=true`, {
599
700
  payload: { is_active: false },
@@ -659,8 +760,8 @@ function makeCrud(collectionName, modeBIsolated = false) {
659
760
  function createStorageContext(collectionName, modeBIsolated = false) {
660
761
  return makeCrud(collectionName || getCollection(), modeBIsolated);
661
762
  }
662
- async function getNote(id, readerAgentId) {
663
- return makeCrud(getCollection()).getNote(id, readerAgentId);
763
+ async function getNote(id, reader) {
764
+ return makeCrud(getCollection()).getNote(id, reader);
664
765
  }
665
766
  async function updateNote(note) {
666
767
  return makeCrud(getCollection()).updateNote(note);
@@ -671,8 +772,8 @@ async function listNotes(agentId, subject) {
671
772
  async function deleteNote(id) {
672
773
  return makeCrud(getCollection()).deleteNote(id);
673
774
  }
674
- async function invalidateNote(id, callerAgentId) {
675
- return makeCrud(getCollection()).invalidateNote(id, callerAgentId);
775
+ async function invalidateNote(id, caller) {
776
+ return makeCrud(getCollection()).invalidateNote(id, caller);
676
777
  }
677
778
  async function patchNotePayload(id, fields) {
678
779
  return makeCrud(getCollection()).patchNotePayload(id, fields);
@@ -1441,7 +1542,7 @@ async function addMemory(content, agentId = "main", opts) {
1441
1542
  if (topMatch.length > 0 && topMatch[0].score >= 0.85) {
1442
1543
  if (canWrite(topMatch[0].note, agentId)) {
1443
1544
  console.log(`[add] dedup: high-sim match (sim=${topMatch[0].score.toFixed(3)}), updating existing`);
1444
- await ctx.updateNoteContent(topMatch[0].note.id, content, embedding, hash);
1545
+ await ctx.updateNoteContent(topMatch[0].note.id, content, embedding, hash, agentId);
1445
1546
  return topMatch[0].note.id;
1446
1547
  }
1447
1548
  console.log(
@@ -1513,7 +1614,7 @@ async function addMemory(content, agentId = "main", opts) {
1513
1614
  note.links = linkedIds;
1514
1615
  await ctx.updateNote(note);
1515
1616
  for (const lid of linkedIds) {
1516
- const linked = await ctx.getNote(lid);
1617
+ const linked = await ctx.getNote(lid, agentId);
1517
1618
  if (linked && !linked.links.includes(note.id)) {
1518
1619
  if (!canWrite(linked, agentId)) {
1519
1620
  console.log(`[link] back-link into ${lid.slice(0, 8)} skipped \u2014 not writable by ${logSafe(agentId)}`);
@@ -1526,7 +1627,7 @@ async function addMemory(content, agentId = "main", opts) {
1526
1627
  if (shouldRunEvolution()) {
1527
1628
  console.log(` [evo] threshold reached, running evolution for ${Math.min(linkedIds.length, 3)} linked notes`);
1528
1629
  for (const lid of linkedIds.slice(0, 3)) {
1529
- const linked = await ctx.getNote(lid);
1630
+ const linked = await ctx.getNote(lid, agentId);
1530
1631
  if (!linked) continue;
1531
1632
  if (!canWrite(linked, agentId)) {
1532
1633
  console.log(` [evo] skipping ${lid.slice(0, 8)} \u2014 not writable by ${logSafe(agentId)}`);
@@ -1679,7 +1780,7 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1679
1780
  const allNotes = await ctx.listNotes(agentId, subject);
1680
1781
  const bm25State = buildBM25(allNotes);
1681
1782
  const queryTokens = simpleTokenize(query);
1682
- const bm25Ranked = bm25Score(bm25State, queryTokens).slice(0, n);
1783
+ const bm25Ranked = bm25Score(bm25State, queryTokens).filter(([, score]) => score > 0).slice(0, n);
1683
1784
  const merged = rrfMerge(
1684
1785
  embResults.map((r) => r.note.id),
1685
1786
  bm25Ranked.map((r) => r[0])
@@ -1702,6 +1803,7 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1702
1803
  const visitedIds = new Set(topIds);
1703
1804
  const bfsQueue = useBfs ? topIds.map((id) => ({ id, hop: 0 })) : [];
1704
1805
  const bfsExtra = [];
1806
+ const bfsSimMap = /* @__PURE__ */ new Map();
1705
1807
  while (bfsQueue.length > 0 && bfsExtra.length < BFS_MAX_EXPAND) {
1706
1808
  const item = bfsQueue.shift();
1707
1809
  if (item.hop >= BFS_MAX_HOPS) continue;
@@ -1712,10 +1814,9 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1712
1814
  visitedIds.add(linkedId);
1713
1815
  const linked = noteMap.get(linkedId);
1714
1816
  if (!linked || linked.is_active === false) continue;
1715
- if (bfsSimThreshold > 0 && linked.embedding) {
1716
- const sim = cosineSimilarity(queryEmbedding, linked.embedding);
1717
- if (sim < bfsSimThreshold) continue;
1718
- }
1817
+ const sim = cosineSimilarity(queryEmbedding, linked.embedding);
1818
+ if (bfsSimThreshold > 0 && sim < bfsSimThreshold) continue;
1819
+ bfsSimMap.set(linkedId, sim);
1719
1820
  bfsExtra.push(linkedId);
1720
1821
  bfsQueue.push({ id: linkedId, hop: item.hop + 1 });
1721
1822
  if (bfsExtra.length >= BFS_MAX_EXPAND) break;
@@ -1731,7 +1832,11 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1731
1832
  const embSimMap = new Map(embResults.map((r) => [r.note.id, r.score]));
1732
1833
  const rrfMap = new Map(boostedMerged.map(([id, score]) => [id, score]));
1733
1834
  const results = [];
1734
- for (const id of [...filteredTopIds, ...bfsExtra]) {
1835
+ const ordered = [
1836
+ ...filteredTopIds.map((id) => [id, "match"]),
1837
+ ...bfsExtra.map((id) => [id, "link"])
1838
+ ];
1839
+ for (const [id, via] of ordered) {
1735
1840
  const note = noteMap.get(id);
1736
1841
  if (!note) continue;
1737
1842
  results.push({
@@ -1742,8 +1847,9 @@ async function searchMemory(query, topK = 5, agentId = "main", opts) {
1742
1847
  keywords: note.keywords,
1743
1848
  links: note.links,
1744
1849
  timestamp: note.timestamp,
1745
- similarity: embSimMap.get(id) ?? 0,
1850
+ similarity: embSimMap.get(id) ?? bfsSimMap.get(id) ?? 0,
1746
1851
  rrf: rrfMap.get(id) ?? 0,
1852
+ via,
1747
1853
  topics: note.topics ?? [],
1748
1854
  note_type: note.note_type ?? "memory"
1749
1855
  });
@@ -1800,7 +1906,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
1800
1906
  const mergedContent = judgment.mergedContent || pendingNote.content;
1801
1907
  const newEmbedding = await encode(buildEmbedText({ ...bestNeighbor, content: mergedContent }));
1802
1908
  const newHash = createHash("md5").update(mergedContent).digest("hex");
1803
- await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash);
1909
+ await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash, agentId);
1804
1910
  await ctx.patchNotePayload(bestNeighbor.id, {
1805
1911
  evolution_history: JSON.stringify(oldHistory),
1806
1912
  evolution_type: "EVOLVE"
@@ -1824,7 +1930,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
1824
1930
  const mergedContent = judgment.mergedContent || `${bestNeighbor.content}\uFF1B${pendingNote.content}`;
1825
1931
  const newEmbedding = await encode(buildEmbedText({ ...bestNeighbor, content: mergedContent }));
1826
1932
  const newHash = createHash("md5").update(mergedContent).digest("hex");
1827
- await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash);
1933
+ await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash, agentId);
1828
1934
  await ctx.patchNotePayload(bestNeighbor.id, {
1829
1935
  evolution_history: JSON.stringify(oldHistory),
1830
1936
  evolution_type: "EXPAND"
@@ -1864,7 +1970,7 @@ async function mergeSimilarNotes(agentId, storageCtx) {
1864
1970
  const [keepNote, dropNote] = noteA.content.length >= noteB.content.length ? [noteA, noteB] : [noteB, noteA];
1865
1971
  const newEmbedding = await encode(result.merged);
1866
1972
  const newHash = createHash("md5").update(result.merged).digest("hex");
1867
- await ctx.updateNoteContent(keepNote.id, result.merged, newEmbedding, newHash);
1973
+ await ctx.updateNoteContent(keepNote.id, result.merged, newEmbedding, newHash, agentId);
1868
1974
  await ctx.deleteNote(dropNote.id);
1869
1975
  deletedIds.add(dropNote.id);
1870
1976
  mergedCount++;
@@ -1973,7 +2079,7 @@ async function consolidateMemories(agentId, logger, storageCtx) {
1973
2079
  action: "consolidate"
1974
2080
  });
1975
2081
  await ctx.updateNote(keepNote);
1976
- await ctx.invalidateNote(dropNote.id);
2082
+ await ctx.invalidateNote(dropNote.id, agentId);
1977
2083
  await ctx.replaceLinkReferences(dropNote.id, keepNote.id, agentId);
1978
2084
  logMergeToFile(keepNote.id, dropNote.id, keepNote.content);
1979
2085
  processedIds.add(keepNote.id);
@@ -2086,8 +2192,19 @@ async function migrateCollection(opts) {
2086
2192
  );
2087
2193
  if (dryRun) {
2088
2194
  log("[migrate] dry run \u2014 nothing written. Pass dryRun: false to apply.");
2089
- return { total: notes.length, missingDerived, refreshed: 0, migrated: 0, sourceDim, targetDim, model, dryRun: true };
2195
+ return {
2196
+ total: notes.length,
2197
+ missingDerived,
2198
+ refreshed: 0,
2199
+ migrated: 0,
2200
+ skipped: 0,
2201
+ sourceDim,
2202
+ targetDim,
2203
+ model,
2204
+ dryRun: true
2205
+ };
2090
2206
  }
2207
+ let alreadyDone = /* @__PURE__ */ new Set();
2091
2208
  const existingTargetDim = await collectionDimRaw(to);
2092
2209
  if (existingTargetDim === null) {
2093
2210
  await createCollectionRaw(to, targetDim);
@@ -2096,9 +2213,17 @@ async function migrateCollection(opts) {
2096
2213
  if (existingTargetDim !== targetDim) {
2097
2214
  throw new Error(`migrate: target "${to}" exists at ${existingTargetDim}d but the model produces ${targetDim}d`);
2098
2215
  }
2099
- const existingCount = await countPointsRaw(to);
2100
- if (existingCount > 0) {
2101
- throw new Error(`migrate: target "${to}" already holds ${existingCount} point(s); use an empty collection`);
2216
+ const present = await scrollIdsRaw(to);
2217
+ if (present.size > 0) {
2218
+ const sourceIds = new Set(notes.map((n) => n.id));
2219
+ const foreign = [...present].filter((id) => !sourceIds.has(id));
2220
+ if (foreign.length > 0) {
2221
+ throw new Error(
2222
+ `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.`
2223
+ );
2224
+ }
2225
+ alreadyDone = present;
2226
+ log(`[migrate] resuming: ${present.size} of ${notes.length} already in ${to}`);
2102
2227
  }
2103
2228
  }
2104
2229
  let refreshed = 0;
@@ -2112,6 +2237,7 @@ async function migrateCollection(opts) {
2112
2237
  buffer = [];
2113
2238
  };
2114
2239
  for (const note of notes) {
2240
+ if (alreadyDone.has(note.id)) continue;
2115
2241
  if (refreshFields && missingDerivedFields(note)) {
2116
2242
  try {
2117
2243
  const built = await llmConstructNote(note.content);
@@ -2127,7 +2253,7 @@ async function migrateCollection(opts) {
2127
2253
  buffer.push(point);
2128
2254
  if (buffer.length >= BATCH) {
2129
2255
  await flush();
2130
- log(`[migrate] ${migrated}/${notes.length}`);
2256
+ log(`[migrate] ${alreadyDone.size + migrated}/${notes.length}`);
2131
2257
  }
2132
2258
  }
2133
2259
  await flush();
@@ -2135,16 +2261,55 @@ async function migrateCollection(opts) {
2135
2261
  if (finalCount !== notes.length) {
2136
2262
  warn(`[migrate] target holds ${finalCount} point(s) but the source had ${notes.length} \u2014 check before switching`);
2137
2263
  }
2138
- log(
2139
- `[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.`
2140
- );
2141
- return { total: notes.length, missingDerived, refreshed, migrated, sourceDim, targetDim, model, dryRun: false };
2264
+ log(`[migrate] done: ${migrated} written, ${alreadyDone.size} already present, ${refreshed} re-extracted.`);
2265
+ return {
2266
+ total: notes.length,
2267
+ missingDerived,
2268
+ refreshed,
2269
+ migrated,
2270
+ skipped: alreadyDone.size,
2271
+ sourceDim,
2272
+ targetDim,
2273
+ model,
2274
+ dryRun: false
2275
+ };
2276
+ }
2277
+ async function switchToMigrated(opts) {
2278
+ const { name, to } = opts;
2279
+ const log = opts.logger?.info ?? ((m) => console.log(m));
2280
+ if (name === to) throw new Error(`switch: "${name}" and "${to}" are the same collection`);
2281
+ const already = await resolveAliasRaw(name);
2282
+ if (already === to) {
2283
+ log(`[switch] "${name}" already points at "${to}" \u2014 nothing to do`);
2284
+ return { name, to, moved: await countPointsRaw(to) };
2285
+ }
2286
+ const targetCount = await countPointsRaw(to);
2287
+ if (targetCount === 0) throw new Error(`switch: "${to}" is empty \u2014 migrate into it first`);
2288
+ if (already === null) {
2289
+ const sourceCount = await countPointsRaw(name);
2290
+ if (targetCount < sourceCount) {
2291
+ throw new Error(
2292
+ `switch: "${to}" holds ${targetCount} point(s) but "${name}" still holds ${sourceCount}. The migration is not finished \u2014 run it again before switching.`
2293
+ );
2294
+ }
2295
+ log(`[switch] verified ${targetCount} in "${to}" against ${sourceCount} in "${name}"`);
2296
+ await deleteCollectionRaw(name);
2297
+ log(`[switch] dropped "${name}"`);
2298
+ await createAliasRaw(name, to);
2299
+ } else {
2300
+ await setAliasRaw(name, to);
2301
+ }
2302
+ log(`[switch] "${name}" now resolves to "${to}"`);
2303
+ return { name, to, moved: targetCount };
2142
2304
  }
2143
2305
 
2144
2306
  export {
2307
+ SYSTEM_ACTOR,
2145
2308
  canWrite,
2146
2309
  canRead,
2147
2310
  DEFAULT_EMBEDDING_MODEL,
2311
+ LEGACY_DEFAULT_EMBEDDING_MODEL,
2312
+ LEGACY_DEFAULT_DIM,
2148
2313
  getEmbeddingModel,
2149
2314
  getEmbeddingPooling,
2150
2315
  getEmbeddingDevice,
@@ -2157,8 +2322,13 @@ export {
2157
2322
  getCollection,
2158
2323
  EmbeddingDimensionMismatchError,
2159
2324
  EmbeddingModelMismatchError,
2325
+ MixedEmbeddingModelsError,
2160
2326
  pingQdrant,
2161
2327
  ensureCollection,
2328
+ countPointsRaw,
2329
+ collectionDimRaw,
2330
+ scrollIdsRaw,
2331
+ resolveAliasRaw,
2162
2332
  createStorageContext,
2163
2333
  getNote,
2164
2334
  updateNote,
@@ -2177,6 +2347,7 @@ export {
2177
2347
  mergeSimilarNotes,
2178
2348
  consolidateMemories,
2179
2349
  conflictSweep,
2180
- migrateCollection
2350
+ migrateCollection,
2351
+ switchToMigrated
2181
2352
  };
2182
- //# sourceMappingURL=chunk-B2NS7WAM.js.map
2353
+ //# sourceMappingURL=chunk-K6WZTDM7.js.map