@amemhq/core 1.1.0 → 2.1.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.
@@ -31,6 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  // src/cli-migrate.ts
32
32
  var cli_migrate_exports = {};
33
33
  __export(cli_migrate_exports, {
34
+ carried: () => carried,
35
+ deriveTarget: () => deriveTarget,
34
36
  parseArgs: () => parseArgs
35
37
  });
36
38
  module.exports = __toCommonJS(cli_migrate_exports);
@@ -40,9 +42,10 @@ var pipeline = null;
40
42
  var extractor = null;
41
43
  var loadedKey = null;
42
44
  var cachedDim = null;
43
- var DEFAULT_EMBEDDING_MODEL = "Xenova/paraphrase-multilingual-MiniLM-L12-v2";
45
+ var DEFAULT_EMBEDDING_MODEL = "Xenova/bge-m3";
46
+ var pinnedModel = null;
44
47
  function getEmbeddingModel() {
45
- return process.env.AMEM_EMBED_MODEL?.trim() || DEFAULT_EMBEDDING_MODEL;
48
+ return process.env.AMEM_EMBED_MODEL?.trim() || pinnedModel || DEFAULT_EMBEDDING_MODEL;
46
49
  }
47
50
  var CLS_POOLED_MODELS = /* @__PURE__ */ new Set([
48
51
  "bge-m3",
@@ -69,6 +72,27 @@ function getEmbeddingDevice() {
69
72
  function getEmbeddingDtype() {
70
73
  return process.env.AMEM_EMBED_DTYPE?.trim() || void 0;
71
74
  }
75
+ function applyModelPaths(env) {
76
+ const cache = process.env.AMEM_MODEL_CACHE?.trim();
77
+ if (cache) env.cacheDir = cache;
78
+ const local = process.env.AMEM_MODEL_DIR?.trim();
79
+ if (local) {
80
+ env.localModelPath = local;
81
+ env.allowLocalModels = true;
82
+ }
83
+ }
84
+ function makeProgressReporter() {
85
+ const lastPct = /* @__PURE__ */ new Map();
86
+ return (e) => {
87
+ if (e.status !== "progress" || !e.file || typeof e.progress !== "number") return;
88
+ if (!e.file.endsWith(".onnx") && !e.file.endsWith(".onnx_data")) return;
89
+ const pct = Math.floor(e.progress / 10) * 10;
90
+ if (lastPct.get(e.file) === pct) return;
91
+ lastPct.set(e.file, pct);
92
+ const size = e.total ? ` of ${(e.total / 1e9).toFixed(2)} GB` : "";
93
+ console.log(`[amem] downloading ${e.file}: ${pct}%${size}`);
94
+ };
95
+ }
72
96
  function extractorKey() {
73
97
  return `${getEmbeddingModel()}|${getEmbeddingDevice() ?? ""}|${getEmbeddingDtype() ?? ""}`;
74
98
  }
@@ -78,11 +102,13 @@ async function getExtractor() {
78
102
  if (!pipeline) {
79
103
  const mod = await import("@huggingface/transformers");
80
104
  pipeline = mod.pipeline;
105
+ applyModelPaths(mod.env);
81
106
  }
82
107
  const device = getEmbeddingDevice();
83
108
  const dtype = getEmbeddingDtype();
84
109
  extractor = await pipeline("feature-extraction", getEmbeddingModel(), {
85
110
  revision: "main",
111
+ progress_callback: makeProgressReporter(),
86
112
  // Omitted entirely when unset, so an unconfigured install gets exactly the
87
113
  // library defaults it got before these existed.
88
114
  ...device ? { device } : {},
@@ -202,6 +228,47 @@ async function createCollectionRaw(collection, size) {
202
228
  async function upsertPointsRaw(collection, points) {
203
229
  await qdrant("PUT", `/collections/${collection}/points?wait=true`, { points });
204
230
  }
231
+ async function scrollIdsRaw(collection, limit = 1e4) {
232
+ const ids = /* @__PURE__ */ new Set();
233
+ let offset = void 0;
234
+ for (; ; ) {
235
+ const body = { with_payload: false, with_vector: false, limit };
236
+ if (offset !== void 0 && offset !== null) body.offset = offset;
237
+ const res = await qdrant("POST", `/collections/${collection}/points/scroll`, body);
238
+ for (const p of res.points) ids.add(String(p.id));
239
+ offset = res.next_page_offset;
240
+ if (offset === void 0 || offset === null || res.points.length === 0) break;
241
+ }
242
+ return ids;
243
+ }
244
+ async function deleteCollectionRaw(collection) {
245
+ await qdrant("DELETE", `/collections/${collection}`);
246
+ }
247
+ async function snapshotCollectionRaw(collection) {
248
+ const r = await qdrant("POST", `/collections/${collection}/snapshots`);
249
+ return { name: r.name, size: r.size ?? 0 };
250
+ }
251
+ async function resolveAliasRaw(alias) {
252
+ try {
253
+ const res = await qdrant("GET", `/aliases`);
254
+ return res.aliases.find((a) => a.alias_name === alias)?.collection_name ?? null;
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+ async function createAliasRaw(alias, collection) {
260
+ await qdrant("POST", `/collections/aliases`, {
261
+ actions: [{ create_alias: { collection_name: collection, alias_name: alias } }]
262
+ });
263
+ }
264
+ async function setAliasRaw(alias, collection) {
265
+ await qdrant("POST", `/collections/aliases`, {
266
+ actions: [
267
+ { delete_alias: { alias_name: alias } },
268
+ { create_alias: { collection_name: collection, alias_name: alias } }
269
+ ]
270
+ });
271
+ }
205
272
  function noteToPoint(note) {
206
273
  return {
207
274
  id: note.id,
@@ -795,8 +862,19 @@ async function migrateCollection(opts) {
795
862
  );
796
863
  if (dryRun) {
797
864
  log("[migrate] dry run \u2014 nothing written. Pass dryRun: false to apply.");
798
- return { total: notes.length, missingDerived, refreshed: 0, migrated: 0, sourceDim, targetDim, model, dryRun: true };
865
+ return {
866
+ total: notes.length,
867
+ missingDerived,
868
+ refreshed: 0,
869
+ migrated: 0,
870
+ skipped: 0,
871
+ sourceDim,
872
+ targetDim,
873
+ model,
874
+ dryRun: true
875
+ };
799
876
  }
877
+ let alreadyDone = /* @__PURE__ */ new Set();
800
878
  const existingTargetDim = await collectionDimRaw(to);
801
879
  if (existingTargetDim === null) {
802
880
  await createCollectionRaw(to, targetDim);
@@ -805,9 +883,17 @@ async function migrateCollection(opts) {
805
883
  if (existingTargetDim !== targetDim) {
806
884
  throw new Error(`migrate: target "${to}" exists at ${existingTargetDim}d but the model produces ${targetDim}d`);
807
885
  }
808
- const existingCount = await countPointsRaw(to);
809
- if (existingCount > 0) {
810
- throw new Error(`migrate: target "${to}" already holds ${existingCount} point(s); use an empty collection`);
886
+ const present = await scrollIdsRaw(to);
887
+ if (present.size > 0) {
888
+ const sourceIds = new Set(notes.map((n) => n.id));
889
+ const foreign = [...present].filter((id) => !sourceIds.has(id));
890
+ if (foreign.length > 0) {
891
+ throw new Error(
892
+ `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.`
893
+ );
894
+ }
895
+ alreadyDone = present;
896
+ log(`[migrate] resuming: ${present.size} of ${notes.length} already in ${to}`);
811
897
  }
812
898
  }
813
899
  let refreshed = 0;
@@ -821,6 +907,7 @@ async function migrateCollection(opts) {
821
907
  buffer = [];
822
908
  };
823
909
  for (const note of notes) {
910
+ if (alreadyDone.has(note.id)) continue;
824
911
  if (refreshFields && missingDerivedFields(note)) {
825
912
  try {
826
913
  const built = await llmConstructNote(note.content);
@@ -836,7 +923,7 @@ async function migrateCollection(opts) {
836
923
  buffer.push(point);
837
924
  if (buffer.length >= BATCH) {
838
925
  await flush();
839
- log(`[migrate] ${migrated}/${notes.length}`);
926
+ log(`[migrate] ${alreadyDone.size + migrated}/${notes.length}`);
840
927
  }
841
928
  }
842
929
  await flush();
@@ -844,82 +931,200 @@ async function migrateCollection(opts) {
844
931
  if (finalCount !== notes.length) {
845
932
  warn(`[migrate] target holds ${finalCount} point(s) but the source had ${notes.length} \u2014 check before switching`);
846
933
  }
847
- log(
848
- `[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.`
849
- );
850
- return { total: notes.length, missingDerived, refreshed, migrated, sourceDim, targetDim, model, dryRun: false };
934
+ log(`[migrate] done: ${migrated} written, ${alreadyDone.size} already present, ${refreshed} re-extracted.`);
935
+ return {
936
+ total: notes.length,
937
+ missingDerived,
938
+ refreshed,
939
+ migrated,
940
+ skipped: alreadyDone.size,
941
+ sourceDim,
942
+ targetDim,
943
+ model,
944
+ dryRun: false
945
+ };
946
+ }
947
+ async function switchToMigrated(opts) {
948
+ const { name, to } = opts;
949
+ const log = opts.logger?.info ?? ((m) => console.log(m));
950
+ if (name === to) throw new Error(`switch: "${name}" and "${to}" are the same collection`);
951
+ let snap;
952
+ const already = await resolveAliasRaw(name);
953
+ if (already === to) {
954
+ log(`[switch] "${name}" already points at "${to}" \u2014 nothing to do`);
955
+ return { name, to, moved: await countPointsRaw(to) };
956
+ }
957
+ const targetCount = await countPointsRaw(to);
958
+ if (targetCount === 0) throw new Error(`switch: "${to}" is empty \u2014 migrate into it first`);
959
+ if (already === null) {
960
+ const sourceCount = await countPointsRaw(name);
961
+ if (targetCount < sourceCount) {
962
+ throw new Error(
963
+ `switch: "${to}" holds ${targetCount} point(s) but "${name}" still holds ${sourceCount}. The migration is not finished \u2014 run it again before switching.`
964
+ );
965
+ }
966
+ log(`[switch] verified ${targetCount} in "${to}" against ${sourceCount} in "${name}"`);
967
+ if (opts.snapshot !== false) {
968
+ snap = await snapshotCollectionRaw(name);
969
+ log(`[switch] snapshotted "${name}" \u2192 ${snap.name} (${(snap.size / 1e6).toFixed(0)} MB)`);
970
+ }
971
+ await deleteCollectionRaw(name);
972
+ log(`[switch] dropped "${name}"`);
973
+ await createAliasRaw(name, to);
974
+ } else {
975
+ await setAliasRaw(name, to);
976
+ }
977
+ log(`[switch] "${name}" now resolves to "${to}"`);
978
+ return { name, to, moved: targetCount, snapshot: snap };
851
979
  }
852
980
 
853
981
  // src/cli-migrate.ts
854
- var USAGE = `amem-migrate \u2014 rebuild a collection under the current embedding model
982
+ var USAGE = `amem-migrate \u2014 move a memory store onto a different embedding model
855
983
 
856
- npx --package=@amemhq/core amem-migrate --to <collection> [options]
984
+ amem-migrate what state the store is in, and what comes next
985
+ amem-migrate --apply do the next step; safe to interrupt and re-run
986
+ amem-migrate --switch put the new store behind the old name (irreversible)
857
987
 
858
988
  Options
859
- --to <name> Target collection. Required. Must not already hold points.
860
- --from <name> Source collection. Defaults to AMEM_COLLECTION.
861
- --apply Actually write. Without this it is a dry run.
862
- --no-refresh-fields Skip re-extracting keywords/tags for notes that never
863
- had them. Faster, and makes no LLM calls at all.
989
+ --from-collection <name> the store to migrate. Defaults to AMEM_COLLECTION.
990
+ --to-collection <name> where to build it. Derived from the source if omitted.
991
+ --no-refresh-fields skip re-extracting keywords for notes that never had
992
+ them. Makes the run completely offline.
864
993
  -h, --help
865
994
 
866
- The model is whatever AMEM_EMBED_MODEL says, so set that first:
995
+ AMEM_MODEL_CACHE=<dir> where model weights are cached. Set the same value
996
+ everywhere and the plugin and this command share one
997
+ copy instead of downloading it each.
998
+ AMEM_MODEL_DIR=<dir> read weights already on disk instead of downloading.
999
+
1000
+ Migrates onto amem's current default unless AMEM_EMBED_MODEL says otherwise:
1001
+
1002
+ AMEM_EMBED_MODEL=Alibaba-NLP/gte-multilingual-base amem-migrate --apply
867
1003
 
868
- AMEM_EMBED_MODEL=Xenova/bge-m3 \\
869
- npx --package=@amemhq/core amem-migrate --to amem_notes_v2 --apply
1004
+ Stop your agent before --apply, not before this. The bare run only downloads and
1005
+ reports, and the download is the long part \u2014 leaving the agent up through it costs
1006
+ nothing, while writing underneath a running agent means notes arrive after the
1007
+ rebuild has read them and you have to run --apply again.
870
1008
 
871
- The source is only ever read. If the result looks wrong, point AMEM_COLLECTION
872
- back at it and nothing has been lost.`;
1009
+ Roughly: the download is your bandwidth (2.27 GB for the default), the rebuild is
1010
+ your CPU (about 3 notes a second on an M4), and notes missing keywords cost one
1011
+ LLM call each.
1012
+
1013
+ Nothing before --switch touches the original. If a run looks wrong, delete the
1014
+ target and start again.`;
873
1015
  function parseArgs(argv) {
874
1016
  const value = (flag) => {
875
1017
  const i = argv.indexOf(flag);
876
1018
  return i === -1 ? void 0 : argv[i + 1];
877
1019
  };
878
1020
  return {
879
- help: argv.includes("-h") || argv.includes("--help"),
880
- to: value("--to"),
881
- from: value("--from"),
882
- dryRun: !argv.includes("--apply"),
1021
+ // `help` as a bare word too. Without it, typing what most CLIs accept starts
1022
+ // a multi-gigabyte model download instead of printing anything.
1023
+ help: argv.includes("-h") || argv.includes("--help") || argv[0] === "help",
1024
+ apply: argv.includes("--apply"),
1025
+ switchOver: argv.includes("--switch"),
1026
+ from: value("--from-collection"),
1027
+ to: value("--to-collection"),
883
1028
  refreshFields: !argv.includes("--no-refresh-fields")
884
1029
  };
885
1030
  }
1031
+ function deriveTarget(source) {
1032
+ const m = source.match(/^(.*)_v(\d+)$/);
1033
+ return m ? `${m[1]}_v${Number(m[2]) + 1}` : `${source}_v2`;
1034
+ }
1035
+ function carried(args) {
1036
+ return (args.from ? ` --from-collection ${args.from}` : "") + (args.to ? ` --to-collection ${args.to}` : "");
1037
+ }
1038
+ async function detect(from, to) {
1039
+ const alias = await resolveAliasRaw(from);
1040
+ if (alias !== null) return { kind: "switched", points: await countPointsRaw(from) };
1041
+ const sourceDim = await collectionDimRaw(from);
1042
+ if (sourceDim === null) return { kind: "no-source" };
1043
+ const modelDim = await getEmbeddingDim();
1044
+ if (sourceDim === modelDim) return { kind: "already-current", model: getEmbeddingModel() };
1045
+ const notes = await countPointsRaw(from);
1046
+ const targetDim = await collectionDimRaw(to);
1047
+ if (targetDim === null) return { kind: "not-started", notes };
1048
+ const done = (await scrollIdsRaw(to)).size;
1049
+ return done >= notes ? { kind: "ready-to-switch", notes } : { kind: "partial", done, notes };
1050
+ }
886
1051
  async function main() {
887
1052
  const args = parseArgs(process.argv.slice(2));
888
1053
  if (args.help) {
889
1054
  console.log(USAGE);
890
1055
  return;
891
1056
  }
892
- const to = args.to;
893
- if (!to) {
894
- console.error("amem-migrate: --to <collection> is required\n");
895
- console.error(USAGE);
896
- process.exitCode = 1;
897
- return;
898
- }
899
1057
  const from = args.from ?? getCollection();
900
- const { dryRun, refreshFields } = args;
1058
+ const to = args.to ?? deriveTarget(from);
1059
+ const flags = carried(args);
1060
+ const phase = await detect(from, to);
1061
+ console.log(`store: ${from}`);
901
1062
  console.log(`model: ${getEmbeddingModel()}`);
902
- console.log(`from: ${from}`);
903
- console.log(`to: ${to}`);
904
- console.log(`mode: ${dryRun ? "dry run \u2014 nothing will be written" : "APPLY"}
905
- `);
906
- const result = await migrateCollection({ from, to, dryRun, refreshFields });
907
- console.log(`
908
- ${result.total} notes in source (${result.sourceDim}-dim)`);
909
- console.log(`target is ${result.targetDim}-dim`);
910
- if (result.missingDerived > 0) {
911
- console.log(
912
- `${result.missingDerived} note(s) predate the extraction pipeline` + (refreshFields ? `, ${result.refreshed} re-extracted` : " \u2014 skipped (--no-refresh-fields)")
913
- );
1063
+ switch (phase.kind) {
1064
+ case "no-source":
1065
+ console.error(`
1066
+ No collection named "${from}". Nothing to migrate.`);
1067
+ process.exitCode = 1;
1068
+ return;
1069
+ case "switched":
1070
+ console.log(`
1071
+ "${from}" is already an alias \u2014 ${phase.points} notes, nothing to do.`);
1072
+ return;
1073
+ case "already-current":
1074
+ console.log(`
1075
+ Already on ${phase.model}. Nothing to migrate.`);
1076
+ return;
1077
+ case "not-started":
1078
+ if (!args.apply) {
1079
+ console.log(`
1080
+ ${phase.notes} notes to rebuild into "${to}".`);
1081
+ console.log(`Run "amem-migrate${flags} --apply" to start. "${from}" is only read.`);
1082
+ return;
1083
+ }
1084
+ break;
1085
+ case "partial":
1086
+ if (!args.apply) {
1087
+ console.log(`
1088
+ ${phase.done} of ${phase.notes} rebuilt into "${to}".`);
1089
+ console.log(`Run "amem-migrate${flags} --apply" to carry on from there.`);
1090
+ return;
1091
+ }
1092
+ break;
1093
+ case "ready-to-switch":
1094
+ if (!args.switchOver) {
1095
+ console.log(`
1096
+ All ${phase.notes} notes are in "${to}". "${from}" is untouched.`);
1097
+ console.log(`Check it, then run "amem-migrate${flags} --switch" to put "${to}" behind the name "${from}".`);
1098
+ console.log(`That drops "${from}" and cannot be undone.`);
1099
+ return;
1100
+ }
1101
+ {
1102
+ const res = await switchToMigrated({ name: from, to });
1103
+ console.log(`
1104
+ Done. Nothing to change in your config \u2014 "${from}" now resolves to "${to}".`);
1105
+ if (res.snapshot) {
1106
+ console.log(`
1107
+ The old store is kept as a snapshot, ${(res.snapshot.size / 1e6).toFixed(0)} MB:`);
1108
+ console.log(` <qdrant storage>/snapshots/${from}/${res.snapshot.name}`);
1109
+ console.log(`Delete that file once the new store has proven itself. Nothing else will.`);
1110
+ }
1111
+ }
1112
+ return;
914
1113
  }
915
- if (dryRun) {
916
- console.log(`
917
- Nothing was written. Re-run with --apply to migrate.`);
1114
+ if (args.switchOver) {
1115
+ console.error(`
1116
+ Not finished yet \u2014 run "amem-migrate${flags} --apply" until it is before switching.`);
1117
+ process.exitCode = 1;
918
1118
  return;
919
1119
  }
1120
+ const result = await migrateCollection({ from, to, dryRun: false, refreshFields: args.refreshFields });
920
1121
  console.log(`
921
- ${result.migrated} notes written to "${to}".`);
922
- console.log(`Point AMEM_COLLECTION at "${to}" to start using it. "${from}" is untouched.`);
1122
+ ${result.migrated + result.skipped} of ${result.total} rebuilt.`);
1123
+ if (result.migrated + result.skipped >= result.total) {
1124
+ console.log(`Check "${to}", then run "amem-migrate${flags} --switch".`);
1125
+ } else {
1126
+ console.log(`Run "amem-migrate${flags} --apply" again to carry on.`);
1127
+ }
923
1128
  }
924
1129
  main().catch((err) => {
925
1130
  console.error(`amem-migrate: ${err instanceof Error ? err.message : String(err)}`);
@@ -927,6 +1132,8 @@ main().catch((err) => {
927
1132
  });
928
1133
  // Annotate the CommonJS export names for ESM import in node:
929
1134
  0 && (module.exports = {
1135
+ carried,
1136
+ deriveTarget,
930
1137
  parseArgs
931
1138
  });
932
1139
  //# sourceMappingURL=cli-migrate.cjs.map