@remnic/cli 9.44.0 → 9.45.1

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.
Files changed (2) hide show
  1. package/dist/index.js +191 -26
  2. package/package.json +29 -29
package/dist/index.js CHANGED
@@ -243,9 +243,12 @@ import * as fs3 from "fs";
243
243
  import { createHash as createHash2 } from "crypto";
244
244
  import * as path2 from "path";
245
245
  import {
246
+ CONVERGE_CONFLICT_POLICIES,
247
+ DEFAULT_CONVERGE_CONFLICT_POLICY,
246
248
  parseConfig as parseConfig2,
247
249
  buildOfflineSyncSnapshotFromBase,
248
250
  OFFLINE_SYNC_FILE_CONTENT_MAX_CHUNK_BYTES,
251
+ OFFLINE_SYNC_CHANGESET_FORMAT,
249
252
  OFFLINE_SYNC_FILE_CONTENT_TRANSFER_CHUNK_BYTES as OFFLINE_SYNC_FILE_CONTENT_TRANSFER_CHUNK_BYTES2,
250
253
  applyOfflineSyncFileContentChunk
251
254
  } from "@remnic/core";
@@ -259,6 +262,10 @@ import {
259
262
  readConvergeCursor,
260
263
  writeConvergeCursor
261
264
  } from "@remnic/core/reconcile/cursor.js";
265
+ import {
266
+ buildReconcileManifest,
267
+ collapseActiveFactDuplicates
268
+ } from "@remnic/core/reconcile/manifest.js";
262
269
 
263
270
  // src/offline-storage-io.ts
264
271
  import { mkdtemp, readdir, lstat, rm } from "fs/promises";
@@ -762,6 +769,45 @@ async function postPeerConvergenceComplete(peerUrl, namespaces, token, fetchImpl
762
769
  }
763
770
  return false;
764
771
  }
772
+ async function postPeerFileDeletion(peerUrl, namespace, filePath, baseSha256, token, fetchImpl = globalThis.fetch) {
773
+ const base = withoutTrailingSlashes(peerUrl);
774
+ const routes = ["/remnic/v1/offline-sync/apply", "/engram/v1/offline-sync/apply"];
775
+ const headers = {
776
+ "content-type": "application/json",
777
+ ...token ? { authorization: `Bearer ${token}` } : {}
778
+ };
779
+ let previousAttemptFailed = false;
780
+ for (const route of routes) {
781
+ try {
782
+ const response = await fetchImpl(`${base}${route}`, {
783
+ method: "POST",
784
+ headers,
785
+ body: JSON.stringify({
786
+ namespace,
787
+ changeset: {
788
+ format: OFFLINE_SYNC_CHANGESET_FORMAT,
789
+ schemaVersion: 1,
790
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
791
+ sourceId: "remnic-converge",
792
+ includeTranscripts: false,
793
+ changes: [{ type: "delete", path: filePath, baseSha256 }]
794
+ }
795
+ })
796
+ });
797
+ if (!response.ok) throw new Error(`offline apply request failed: ${response.status}`);
798
+ const result = await response.json().catch(() => null);
799
+ if (!result || typeof result !== "object" || !("appliedDeletes" in result) || typeof result.appliedDeletes !== "number" || !("skipped" in result) || typeof result.skipped !== "number" || !("conflicts" in result) || !Array.isArray(result.conflicts) || result.conflicts.length > 0) {
800
+ return false;
801
+ }
802
+ if (result.appliedDeletes === 1) return "applied";
803
+ if (result.skipped === 1) return previousAttemptFailed ? "applied" : "skipped";
804
+ return false;
805
+ } catch {
806
+ previousAttemptFailed = true;
807
+ }
808
+ }
809
+ return false;
810
+ }
765
811
  async function computeConvergePlan(options = {}) {
766
812
  const baseMap = /* @__PURE__ */ new Map();
767
813
  const namespacesToPlan = /* @__PURE__ */ new Set();
@@ -769,6 +815,10 @@ async function computeConvergePlan(options = {}) {
769
815
  const localTombstones = /* @__PURE__ */ new Map();
770
816
  const peerMap = /* @__PURE__ */ new Map();
771
817
  const peerTombstones = /* @__PURE__ */ new Map();
818
+ const localDeletionMtimeMs = /* @__PURE__ */ new Map();
819
+ const peerDeletionMtimeMs = /* @__PURE__ */ new Map();
820
+ const localManifests = /* @__PURE__ */ new Map();
821
+ const peerManifests = /* @__PURE__ */ new Map();
772
822
  if (options.baseFilesByNamespace) {
773
823
  for (const [ns, files] of options.baseFilesByNamespace) {
774
824
  namespacesToPlan.add(ns);
@@ -786,6 +836,12 @@ async function computeConvergePlan(options = {}) {
786
836
  localTombstones.set(ns, new Set(tombstones));
787
837
  }
788
838
  }
839
+ if (options.localDeletionMtimeMsByNamespace) {
840
+ for (const [ns, mtimes] of options.localDeletionMtimeMsByNamespace) {
841
+ namespacesToPlan.add(ns);
842
+ localDeletionMtimeMs.set(ns, mtimes);
843
+ }
844
+ }
789
845
  if (options.peerFilesByNamespace) {
790
846
  for (const [ns, files] of options.peerFilesByNamespace) {
791
847
  namespacesToPlan.add(ns);
@@ -797,6 +853,12 @@ async function computeConvergePlan(options = {}) {
797
853
  peerTombstones.set(ns, new Set(tombstones));
798
854
  }
799
855
  }
856
+ if (options.peerDeletionMtimeMsByNamespace) {
857
+ for (const [ns, mtimes] of options.peerDeletionMtimeMsByNamespace) {
858
+ namespacesToPlan.add(ns);
859
+ peerDeletionMtimeMs.set(ns, mtimes);
860
+ }
861
+ }
800
862
  let config = options.config;
801
863
  if (!config) {
802
864
  try {
@@ -826,6 +888,26 @@ async function computeConvergePlan(options = {}) {
826
888
  bytes: record.bytes
827
889
  }));
828
890
  localMap.set(ns, files);
891
+ try {
892
+ const io = await createOfflineStorageIo(rootInfo.rootDir);
893
+ localManifests.set(
894
+ ns,
895
+ await buildReconcileManifest({
896
+ files,
897
+ readFile: async (file) => {
898
+ const readFile2 = io.readFile;
899
+ if (!readFile2) throw new Error("offline storage cannot read reconciliation manifest files");
900
+ return await readFile2({
901
+ root: rootInfo.rootDir,
902
+ path: file.path,
903
+ filePath: path2.join(rootInfo.rootDir, file.path)
904
+ });
905
+ }
906
+ })
907
+ );
908
+ } catch {
909
+ localManifests.delete(ns);
910
+ }
829
911
  const tombstones = await readLocalTombstones(rootInfo.rootDir);
830
912
  localTombstones.set(ns, tombstones);
831
913
  } catch {
@@ -833,7 +915,8 @@ async function computeConvergePlan(options = {}) {
833
915
  }
834
916
  }
835
917
  }
836
- if (!options.peerFilesByNamespace && options.peerUrl) {
918
+ const peerUrl = options.peerUrl;
919
+ if (!options.peerFilesByNamespace && peerUrl) {
837
920
  let resolvedToken;
838
921
  if (options.peerToken) {
839
922
  try {
@@ -846,9 +929,23 @@ async function computeConvergePlan(options = {}) {
846
929
  }
847
930
  const fetchFn = options.fetchImpl ?? globalThis.fetch;
848
931
  for (const ns of namespacesToPlan) {
849
- const peerData = await fetchPeerSnapshot(options.peerUrl, ns, resolvedToken, fetchFn);
932
+ const peerData = await fetchPeerSnapshot(peerUrl, ns, resolvedToken, fetchFn);
850
933
  peerMap.set(ns, peerData.files);
851
934
  peerTombstones.set(ns, peerData.tombstones);
935
+ peerManifests.set(
936
+ ns,
937
+ await buildReconcileManifest({
938
+ files: peerData.files,
939
+ cachedFiles: localManifests.get(ns)?.files,
940
+ readFile: async (file) => {
941
+ const remote = await fetchPeerFileContent(peerUrl, ns, file.path, resolvedToken, fetchFn);
942
+ if (!remote || remote.sha256 !== file.sha256) {
943
+ throw new Error(`failed to read peer reconciliation manifest file: ${file.path}`);
944
+ }
945
+ return remote.content;
946
+ }
947
+ })
948
+ );
852
949
  }
853
950
  }
854
951
  const memoryDir = options.cursorDir ?? config?.memoryDir;
@@ -863,19 +960,28 @@ async function computeConvergePlan(options = {}) {
863
960
  }
864
961
  const inputs = [];
865
962
  for (const ns of [...namespacesToPlan].sort()) {
963
+ const baseFiles = baseMap.get(ns);
964
+ const localFiles = localMap.get(ns) ?? [];
965
+ const peerFiles = peerMap.get(ns) ?? [];
966
+ const localDeletions = new Map(localDeletionMtimeMs.get(ns) ?? []);
967
+ const peerDeletions = new Map(peerDeletionMtimeMs.get(ns) ?? []);
866
968
  inputs.push({
867
969
  namespace: ns,
868
- local: localMap.get(ns) ?? [],
869
- peer: peerMap.get(ns) ?? [],
870
- base: baseMap.get(ns),
970
+ local: localFiles,
971
+ peer: peerFiles,
972
+ base: baseFiles,
973
+ localDeletionMtimeMs: localDeletions,
974
+ peerDeletionMtimeMs: peerDeletions,
871
975
  tombstonedFileSha256: localTombstones.get(ns) ?? [],
872
976
  peerTombstonedFileSha256: peerTombstones.get(ns) ?? []
873
977
  });
874
978
  }
875
- return planReconciliation(inputs, { conflictPolicy: options.conflictPolicy });
979
+ const conflictPolicy = options.conflictPolicy ?? config?.converge.conflictPolicy ?? DEFAULT_CONVERGE_CONFLICT_POLICY;
980
+ const plan = planReconciliation(inputs, { conflictPolicy });
981
+ return collapseActiveFactDuplicates(plan, localManifests, peerManifests);
876
982
  }
877
983
  async function executeConvergeApply(options = {}) {
878
- const conflictPolicy = options.conflictPolicy ?? "manual";
984
+ const conflictPolicy = options.conflictPolicy ?? options.config?.converge.conflictPolicy ?? DEFAULT_CONVERGE_CONFLICT_POLICY;
879
985
  const plan = await computeConvergePlan({ ...options, conflictPolicy });
880
986
  if (plan.converged && !options.dryRun) {
881
987
  await updateCursorsForPlan(plan, options);
@@ -888,7 +994,7 @@ async function executeConvergeApply(options = {}) {
888
994
  };
889
995
  }
890
996
  const unresolvedCount = plan.byNamespace.reduce((acc, report) => acc + report.unresolved, 0);
891
- if (unresolvedCount > 0 && conflictPolicy === "manual") {
997
+ if (unresolvedCount > 0) {
892
998
  return {
893
999
  converged: false,
894
1000
  status: "stopped_unresolved_conflicts",
@@ -966,12 +1072,9 @@ async function executeConvergeApply(options = {}) {
966
1072
  transferType = "suppress";
967
1073
  } else if (entry.action === "conflict") {
968
1074
  if (entry.resolution === "peer-wins") {
969
- transferType = "pull";
1075
+ transferType = entry.peerSha256 ? "pull" : "delete-local";
970
1076
  } else if (entry.resolution === "local-wins") {
971
- transferType = "push";
972
- } else if (entry.resolution === "supersede-link") {
973
- if (entry.newerSide === "peer") transferType = "pull";
974
- else if (entry.newerSide === "local") transferType = "push";
1077
+ transferType = entry.localSha256 ? "push" : "delete-peer";
975
1078
  }
976
1079
  }
977
1080
  if (transferType === "pull") {
@@ -1116,6 +1219,56 @@ async function executeConvergeApply(options = {}) {
1116
1219
  } else {
1117
1220
  actualTransfers.failed += 1;
1118
1221
  }
1222
+ } else if (transferType === "delete-local") {
1223
+ let deleted = false;
1224
+ const bufferedFiles = options.localFileBuffers?.get(entry.namespace);
1225
+ if (options.localFileBuffers) {
1226
+ const current = bufferedFiles?.get(entry.path);
1227
+ if (current && entry.localSha256 && createHash2("sha256").update(current).digest("hex") === entry.localSha256) {
1228
+ bufferedFiles.delete(entry.path);
1229
+ deleted = true;
1230
+ }
1231
+ } else {
1232
+ const rootDir = rootMap.get(entry.namespace);
1233
+ if (rootDir && entry.localSha256) {
1234
+ try {
1235
+ const io = await createOfflineStorageIo(rootDir);
1236
+ const filePath = path2.join(rootDir, entry.path);
1237
+ const current = await io.readFileDigest({ root: rootDir, path: entry.path, filePath });
1238
+ if (current.sha256 === entry.localSha256) {
1239
+ await io.deleteFile({ root: rootDir, path: entry.path, filePath });
1240
+ deleted = true;
1241
+ }
1242
+ } catch {
1243
+ deleted = false;
1244
+ }
1245
+ }
1246
+ }
1247
+ if (deleted) actualTransfers.conflictsResolved += 1;
1248
+ else actualTransfers.failed += 1;
1249
+ } else if (transferType === "delete-peer") {
1250
+ let deleted = false;
1251
+ const bufferedFiles = options.peerFileBuffers?.get(entry.namespace);
1252
+ if (options.peerFileBuffers) {
1253
+ const current = bufferedFiles?.get(entry.path);
1254
+ if (current && entry.peerSha256 && createHash2("sha256").update(current).digest("hex") === entry.peerSha256) {
1255
+ bufferedFiles.delete(entry.path);
1256
+ deleted = true;
1257
+ }
1258
+ } else if (options.peerUrl && entry.peerSha256) {
1259
+ const deletionResult = await postPeerFileDeletion(
1260
+ options.peerUrl,
1261
+ entry.namespace,
1262
+ entry.path,
1263
+ entry.peerSha256,
1264
+ resolvedToken,
1265
+ fetchFn
1266
+ );
1267
+ deleted = Boolean(deletionResult);
1268
+ if (deletionResult === "applied") peerMutatedNamespaces.add(entry.namespace);
1269
+ }
1270
+ if (deleted) actualTransfers.conflictsResolved += 1;
1271
+ else actualTransfers.failed += 1;
1119
1272
  } else if (transferType === "suppress") {
1120
1273
  actualTransfers.suppressed += 1;
1121
1274
  }
@@ -1156,10 +1309,9 @@ async function updateCursorsForPlan(plan, options) {
1156
1309
  const namespaces = new Set(plan.byNamespace.map((n) => n.namespace));
1157
1310
  for (const ns of namespaces) {
1158
1311
  const cursorPath = defaultConvergeCursorPath(memoryDir, peerUrl, ns);
1159
- const nsEntries = plan.entries.filter((e) => e.namespace === ns);
1160
- const baseFiles = nsEntries.map((e) => ({
1161
- path: e.path,
1162
- sha256: e.localSha256 ?? e.peerSha256 ?? "unknown"
1312
+ const baseFiles = plan.entries.filter((entry) => entry.namespace === ns && entry.reason !== "semantic_duplicate" && !(entry.reason === "local_modified_peer_deleted" && entry.resolution === "peer-wins") && !(entry.reason === "local_deleted_peer_modified" && entry.resolution === "local-wins")).map((entry) => ({
1313
+ path: entry.path,
1314
+ sha256: entry.localSha256 ?? entry.peerSha256 ?? "unknown"
1163
1315
  }));
1164
1316
  const cursorState = {
1165
1317
  version: 1,
@@ -1209,7 +1361,7 @@ function formatConvergeApplyReport(result) {
1209
1361
  lines.push(formatConvergeReport(result.plan));
1210
1362
  return lines.join("\n");
1211
1363
  }
1212
- async function cmdConverge(action, rest, json) {
1364
+ async function cmdConverge(action, rest, json, config = parseConfig2({})) {
1213
1365
  if (action === "help" || action === "--help" || action === "-h" || rest.includes("--help") || rest.includes("-h")) {
1214
1366
  console.log(`Usage: remnic converge <plan|apply> [options]
1215
1367
 
@@ -1221,7 +1373,8 @@ Options:
1221
1373
  --peer <url> Peer server URL (or --remote-url / --remote)
1222
1374
  --token <token> Bearer token or SecretRef for peer authentication
1223
1375
  --conflict-policy <policy>
1224
- Conflict resolution policy (manual|newest-wins|keep-both)
1376
+ Policy override (newest-wins|manual)
1377
+ Default: converge.conflictPolicy (newest-wins)
1225
1378
  --dry-run Simulate transfers without mutating disk or remote peer
1226
1379
  --json Output detailed JSON plan report
1227
1380
  `);
@@ -1247,16 +1400,19 @@ Options:
1247
1400
  i += 1;
1248
1401
  } else if (arg === "--dry-run") {
1249
1402
  dryRun = true;
1250
- } else if (arg === "--conflict-policy" && rest[i + 1]) {
1251
- const pol = rest[i + 1];
1252
- if (pol === "manual" || pol === "newest-wins" || pol === "keep-both") {
1253
- conflictPolicy = pol;
1403
+ } else if (arg === "--conflict-policy") {
1404
+ const policy = rest[i + 1];
1405
+ if (typeof policy !== "string" || !CONVERGE_CONFLICT_POLICIES.includes(policy)) {
1406
+ throw new Error(
1407
+ `converge: --conflict-policy must be one of ${CONVERGE_CONFLICT_POLICIES.join(", ")}`
1408
+ );
1254
1409
  }
1410
+ conflictPolicy = policy;
1255
1411
  i += 1;
1256
1412
  }
1257
1413
  }
1258
1414
  if (action === "plan") {
1259
- const plan = await computeConvergePlan({ peerUrl, peerToken, conflictPolicy });
1415
+ const plan = await computeConvergePlan({ config, peerUrl, peerToken, conflictPolicy });
1260
1416
  if (json) {
1261
1417
  console.log(JSON.stringify(plan, null, 2));
1262
1418
  } else {
@@ -1268,7 +1424,8 @@ Options:
1268
1424
  peerUrl,
1269
1425
  peerToken,
1270
1426
  dryRun,
1271
- conflictPolicy
1427
+ conflictPolicy,
1428
+ config
1272
1429
  });
1273
1430
  if (json) {
1274
1431
  console.log(JSON.stringify(result, null, 2));
@@ -14385,7 +14542,15 @@ Options:
14385
14542
  case "converge": {
14386
14543
  const action = rest[0] ?? "plan";
14387
14544
  const json = rest.includes("--json");
14388
- await cmdConverge(action, rest.slice(1), json);
14545
+ const args = rest.slice(1);
14546
+ if (action === "help" || action === "--help" || action === "-h" || args.includes("--help") || args.includes("-h")) {
14547
+ await cmdConverge(action, args, json);
14548
+ break;
14549
+ }
14550
+ const configPath = resolveConfigPath();
14551
+ const raw = fs12.existsSync(configPath) ? JSON.parse(fs12.readFileSync(configPath, "utf8")) : {};
14552
+ const config = parseConfig5(resolveRemnicConfigRecord4(raw));
14553
+ await cmdConverge(action, args, json, config);
14389
14554
  break;
14390
14555
  }
14391
14556
  case "oauth": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/cli",
3
- "version": "9.44.0",
3
+ "version": "9.45.1",
4
4
  "description": "CLI for Remnic memory — init, query, doctor, daemon management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,24 +26,24 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "yaml": "^2.4.2",
29
- "@remnic/plugin-pi": "^9.44.0",
30
- "@remnic/core": "^9.44.0",
31
- "@remnic/server": "^9.44.0"
29
+ "@remnic/server": "^9.45.1",
30
+ "@remnic/plugin-pi": "^9.45.1",
31
+ "@remnic/core": "^9.45.1"
32
32
  },
33
33
  "peerDependencies": {
34
- "@remnic/bench": "^9.44.0",
35
- "@remnic/export-weclone": "^9.44.0",
36
- "@remnic/import-weclone": "^9.44.0",
37
- "@remnic/import-chatgpt": "^9.44.0",
38
- "@remnic/import-claude": "^9.44.0",
39
- "@remnic/import-gemini": "^9.44.0",
40
- "@remnic/import-lossless-claw": "^9.44.0",
41
- "@remnic/import-mem0": "^9.44.0",
42
- "@remnic/import-supermemory": "^9.44.0",
43
- "@remnic/connector-limitless": "^9.44.0",
44
- "@remnic/connector-bee": "^9.44.0",
45
- "@remnic/connector-omi": "^9.44.0",
46
- "@remnic/capture-audio": "^9.44.0"
34
+ "@remnic/bench": "^9.45.1",
35
+ "@remnic/export-weclone": "^9.45.1",
36
+ "@remnic/import-weclone": "^9.45.1",
37
+ "@remnic/import-chatgpt": "^9.45.1",
38
+ "@remnic/import-claude": "^9.45.1",
39
+ "@remnic/import-gemini": "^9.45.1",
40
+ "@remnic/import-lossless-claw": "^9.45.1",
41
+ "@remnic/import-mem0": "^9.45.1",
42
+ "@remnic/import-supermemory": "^9.45.1",
43
+ "@remnic/connector-limitless": "^9.45.1",
44
+ "@remnic/connector-bee": "^9.45.1",
45
+ "@remnic/connector-omi": "^9.45.1",
46
+ "@remnic/capture-audio": "^9.45.1"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@remnic/bench": {
@@ -89,18 +89,18 @@
89
89
  "devDependencies": {
90
90
  "tsup": "^8.5.1",
91
91
  "typescript": "^5.9.3",
92
- "@remnic/bench": "9.44.0",
93
- "@remnic/export-weclone": "9.44.0",
94
- "@remnic/import-weclone": "9.44.0",
95
- "@remnic/import-chatgpt": "9.44.0",
96
- "@remnic/import-gemini": "9.44.0",
97
- "@remnic/import-mem0": "9.44.0",
98
- "@remnic/import-supermemory": "9.44.0",
99
- "@remnic/import-lossless-claw": "9.44.0",
100
- "@remnic/connector-limitless": "9.44.0",
101
- "@remnic/connector-bee": "9.44.0",
102
- "@remnic/import-claude": "9.44.0",
103
- "@remnic/connector-omi": "9.44.0"
92
+ "@remnic/bench": "9.45.1",
93
+ "@remnic/export-weclone": "9.45.1",
94
+ "@remnic/import-weclone": "9.45.1",
95
+ "@remnic/import-chatgpt": "9.45.1",
96
+ "@remnic/import-claude": "9.45.1",
97
+ "@remnic/import-mem0": "9.45.1",
98
+ "@remnic/import-gemini": "9.45.1",
99
+ "@remnic/import-lossless-claw": "9.45.1",
100
+ "@remnic/import-supermemory": "9.45.1",
101
+ "@remnic/connector-bee": "9.45.1",
102
+ "@remnic/connector-limitless": "9.45.1",
103
+ "@remnic/connector-omi": "9.45.1"
104
104
  },
105
105
  "license": "MIT",
106
106
  "repository": {