@remnic/core 9.43.0 → 9.45.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.
Files changed (258) hide show
  1. package/dist/access-admin-ops-surface.d.ts +5 -5
  2. package/dist/access-admin-ops-surface.js +42 -41
  3. package/dist/access-authorization-probe.d.ts +5 -5
  4. package/dist/access-authorization-probe.js +43 -42
  5. package/dist/access-boundary.d.ts +5 -5
  6. package/dist/access-boundary.js +42 -41
  7. package/dist/access-cli.js +100 -97
  8. package/dist/access-cli.js.map +1 -1
  9. package/dist/access-extraction-force-flush.d.ts +5 -5
  10. package/dist/access-extraction-force-flush.js +42 -41
  11. package/dist/access-http-lcm-compaction.d.ts +5 -5
  12. package/dist/access-http-lifecycle-flush.d.ts +5 -5
  13. package/dist/access-http-query.d.ts +8 -0
  14. package/dist/access-http-query.js +180 -0
  15. package/dist/access-http.d.ts +5 -5
  16. package/dist/access-http.js +51 -49
  17. package/dist/access-identity-continuity-surface.d.ts +4 -4
  18. package/dist/access-identity-continuity-surface.js +42 -41
  19. package/dist/access-lcm-surface.d.ts +5 -5
  20. package/dist/access-lcm-surface.js +42 -41
  21. package/dist/access-mcp.d.ts +5 -5
  22. package/dist/access-mcp.js +47 -46
  23. package/dist/access-namespace-preflight.js +42 -41
  24. package/dist/access-observe-write-surface.d.ts +5 -5
  25. package/dist/access-observe-write-surface.js +42 -41
  26. package/dist/access-operations-batch.js +44 -43
  27. package/dist/access-operations.d.ts +8 -8
  28. package/dist/access-operations.js +46 -45
  29. package/dist/access-recall-concurrency.d.ts +5 -5
  30. package/dist/access-recall-concurrency.js +42 -41
  31. package/dist/access-recall-response.d.ts +5 -5
  32. package/dist/access-recall-response.js +42 -41
  33. package/dist/access-recall-surface.d.ts +5 -5
  34. package/dist/access-recall-surface.js +42 -41
  35. package/dist/access-schema.d.ts +84 -84
  36. package/dist/access-schema.js +3 -3
  37. package/dist/{access-service-D1BkxwSf.d.ts → access-service-DBo88Dmm.d.ts} +14 -4
  38. package/dist/access-service-helpers.d.ts +5 -5
  39. package/dist/access-service.d.ts +5 -5
  40. package/dist/access-service.js +42 -41
  41. package/dist/access-surface-catalog.d.ts +5 -5
  42. package/dist/access-surface-catalog.js +5 -0
  43. package/dist/access-surface-catalog.js.map +1 -1
  44. package/dist/access-wearables-meetings-surface.d.ts +2 -2
  45. package/dist/{auto-sync-UPTUVE4X.js → auto-sync-5OE5QOZB.js} +3 -3
  46. package/dist/bootstrap.d.ts +4 -4
  47. package/dist/briefing.d.ts +1 -1
  48. package/dist/briefing.js +9 -8
  49. package/dist/buffer.d.ts +1 -1
  50. package/dist/buffer.js +6 -6
  51. package/dist/causal-behavior.js +2 -2
  52. package/dist/causal-chain.js +2 -2
  53. package/dist/causal-consolidation.js +13 -12
  54. package/dist/causal-consolidation.js.map +1 -1
  55. package/dist/causal-retrieval.js +2 -2
  56. package/dist/causal-trajectory-graph.js +2 -2
  57. package/dist/causal-trajectory.js +1 -1
  58. package/dist/{chunk-LEPOP46W.js → chunk-227UFSEQ.js} +4 -4
  59. package/dist/{chunk-CMENJQ6I.js → chunk-2QR2RRH6.js} +2 -2
  60. package/dist/{chunk-ZERSIPFC.js → chunk-3HKQOFOV.js} +22 -20
  61. package/dist/{chunk-ZERSIPFC.js.map → chunk-3HKQOFOV.js.map} +1 -1
  62. package/dist/{chunk-4DYBLQZC.js → chunk-3HLCRYBV.js} +3 -3
  63. package/dist/{chunk-L2DPF4DF.js → chunk-3SHB4YAW.js} +4 -4
  64. package/dist/{chunk-UV72DEZD.js → chunk-4B77XSG5.js} +5 -5
  65. package/dist/{chunk-GUQYMOPU.js → chunk-4LMA77JH.js} +5 -5
  66. package/dist/{chunk-Q53RLEW7.js → chunk-5SM35XZR.js} +2 -2
  67. package/dist/{chunk-YJHU5B3M.js → chunk-5YGO5VEH.js} +2 -2
  68. package/dist/{chunk-AX2J2LC4.js → chunk-5ZE7QHTK.js} +2 -2
  69. package/dist/{chunk-5QZEVMLY.js → chunk-65V4K4MT.js} +56 -38
  70. package/dist/chunk-65V4K4MT.js.map +1 -0
  71. package/dist/{chunk-YFHMW37V.js → chunk-6CZPDBJS.js} +5 -5
  72. package/dist/{chunk-BI6RE2ZS.js → chunk-6OLQJJYE.js} +2 -2
  73. package/dist/chunk-6UWB5PTR.js +413 -0
  74. package/dist/chunk-6UWB5PTR.js.map +1 -0
  75. package/dist/chunk-6ZYQ36IB.js +30 -0
  76. package/dist/chunk-6ZYQ36IB.js.map +1 -0
  77. package/dist/{chunk-GS5UVGQK.js → chunk-7JO6RVBI.js} +6 -6
  78. package/dist/{chunk-MBTPUVHZ.js → chunk-7PVFOFPJ.js} +2 -2
  79. package/dist/{chunk-KPJQTSRJ.js → chunk-C7EI7CA6.js} +2 -2
  80. package/dist/{chunk-NRV4Y3PM.js → chunk-CAGPP5NB.js} +1 -1
  81. package/dist/{chunk-NF3U3MPG.js → chunk-ECK75GDJ.js} +2 -2
  82. package/dist/{chunk-KDXD2AM6.js → chunk-FLBPATMZ.js} +44 -44
  83. package/dist/{chunk-OEXKYYVH.js → chunk-FVGQ5LIS.js} +6 -6
  84. package/dist/{chunk-AR2FSZBP.js → chunk-G24XIK6H.js} +2 -2
  85. package/dist/{chunk-5TOV3D6U.js → chunk-GBJJUPNV.js} +6 -6
  86. package/dist/{chunk-UP67AFRN.js → chunk-GOPWZYZH.js} +29 -29
  87. package/dist/chunk-GOPWZYZH.js.map +1 -0
  88. package/dist/{chunk-EPQ76W5T.js → chunk-GQBZ7EVF.js} +2 -2
  89. package/dist/{chunk-BGXHVOBI.js → chunk-HLPD7B7P.js} +2 -2
  90. package/dist/{chunk-KQAFEZQX.js → chunk-IPLYGWQF.js} +5 -5
  91. package/dist/{chunk-JDXDA5OO.js → chunk-J4OKD6QX.js} +2 -2
  92. package/dist/{chunk-LZ5JKQCS.js → chunk-JZ7SSAQ5.js} +3 -3
  93. package/dist/chunk-K6OTW3TP.js +24 -0
  94. package/dist/chunk-K6OTW3TP.js.map +1 -0
  95. package/dist/{chunk-EHKDORQN.js → chunk-NVRYW264.js} +9 -9
  96. package/dist/{chunk-MU7ZHSZE.js → chunk-PO5LSKQO.js} +2 -2
  97. package/dist/{chunk-ONHRRJZM.js → chunk-QJAQNXWI.js} +6 -6
  98. package/dist/{chunk-WGCTFGLW.js → chunk-QQ3NDJOM.js} +3 -3
  99. package/dist/{chunk-BEQOZOYZ.js → chunk-RVLJEII5.js} +30 -511
  100. package/dist/chunk-RVLJEII5.js.map +1 -0
  101. package/dist/chunk-S5SLVSVY.js +517 -0
  102. package/dist/chunk-S5SLVSVY.js.map +1 -0
  103. package/dist/{chunk-QY66HJ4X.js → chunk-SS7YWVPF.js} +5 -5
  104. package/dist/{chunk-ZVVASRUT.js → chunk-U5G66ORD.js} +90 -88
  105. package/dist/chunk-U5G66ORD.js.map +1 -0
  106. package/dist/{chunk-JYIFGGEM.js → chunk-UZGJFIIG.js} +2 -2
  107. package/dist/{chunk-NHRNYJPJ.js → chunk-VCGPO2VD.js} +7 -7
  108. package/dist/{chunk-F2KMAFEB.js → chunk-VRYJSKOM.js} +2 -2
  109. package/dist/{chunk-HNZKVLAN.js → chunk-W6ROK2KU.js} +2 -2
  110. package/dist/{chunk-SEUSFKNS.js → chunk-WC4SPYHX.js} +14 -14
  111. package/dist/{chunk-MSXWUXFE.js → chunk-WG2PI44B.js} +2 -2
  112. package/dist/{chunk-4VXGRIH6.js → chunk-WUNIWKY4.js} +5 -5
  113. package/dist/{chunk-3C2TGBDB.js → chunk-XFQRFWVS.js} +2 -2
  114. package/dist/{chunk-5N26WBPQ.js → chunk-XMZV27S5.js} +5 -5
  115. package/dist/{chunk-JGXXPDM2.js → chunk-YQQK7W7T.js} +3 -3
  116. package/dist/{chunk-3F2LKUMU.js → chunk-ZAB5PG65.js} +2 -2
  117. package/dist/{chunk-MVW234LO.js → chunk-ZPJJE7MF.js} +2 -2
  118. package/dist/{chunk-6GIMMNVK.js → chunk-ZS5UZU5M.js} +4 -4
  119. package/dist/{cli-IUhV-lmk.d.ts → cli-DQ4VhjqX.d.ts} +3 -3
  120. package/dist/cli.d.ts +6 -6
  121. package/dist/cli.js +69 -67
  122. package/dist/compounding/engine.d.ts +1 -1
  123. package/dist/compounding/engine.js +9 -8
  124. package/dist/connectors/codex-materialize-runner.js +9 -8
  125. package/dist/connectors/index.js +9 -8
  126. package/dist/consolidation-provenance-check.d.ts +1 -1
  127. package/dist/consolidation-undo.d.ts +1 -1
  128. package/dist/contradiction/index.d.ts +1 -1
  129. package/dist/convergence-refresh.d.ts +24 -0
  130. package/dist/convergence-refresh.js +75 -0
  131. package/dist/convergence-refresh.js.map +1 -0
  132. package/dist/conversation-index/backend.js +2 -2
  133. package/dist/corpus-watermark.js +20 -19
  134. package/dist/entity-retrieval.d.ts +1 -1
  135. package/dist/entity-retrieval.js +9 -8
  136. package/dist/explicit-capture.d.ts +4 -4
  137. package/dist/explicit-capture.js +4 -3
  138. package/dist/extraction.js +15 -14
  139. package/dist/{graph-edge-decay-Z6ENALU2.js → graph-edge-decay-GF3ZSGSP.js} +4 -4
  140. package/dist/graph-edge-decay-GF3ZSGSP.js.map +1 -0
  141. package/dist/graph-snapshot.js +3 -3
  142. package/dist/graph.js +2 -2
  143. package/dist/index.d.ts +398 -398
  144. package/dist/index.js +161 -158
  145. package/dist/lcm/index.js +3 -3
  146. package/dist/maintenance/memory-governance.js +10 -9
  147. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
  148. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +9 -8
  149. package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
  150. package/dist/maintenance/rebuild-memory-projection.js +11 -10
  151. package/dist/{maintenance-DVBLXkFw.d.ts → maintenance-_RV38GlE.d.ts} +1 -1
  152. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  153. package/dist/memory-worth-outcomes.d.ts +1 -1
  154. package/dist/namespaces/migrate.d.ts +1 -1
  155. package/dist/namespaces/migrate.js +19 -18
  156. package/dist/namespaces/search.js +9 -9
  157. package/dist/namespaces/storage.d.ts +1 -1
  158. package/dist/namespaces/storage.js +9 -8
  159. package/dist/offline-sync-impression-drain.js +4 -4
  160. package/dist/operator-doctor-corpus.js +21 -20
  161. package/dist/operator-toolkit.d.ts +1 -1
  162. package/dist/operator-toolkit.js +30 -29
  163. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  164. package/dist/orchestration/maintenance.d.ts +2 -2
  165. package/dist/orchestration/maintenance.js +16 -15
  166. package/dist/{orchestrator-fYTncnLy.d.ts → orchestrator-B2ecFpRO.d.ts} +4 -4
  167. package/dist/orchestrator.d.ts +4 -4
  168. package/dist/orchestrator.js +101 -98
  169. package/dist/qmd.js +4 -4
  170. package/dist/reconcile/manifest.d.ts +33 -0
  171. package/dist/reconcile/manifest.js +248 -0
  172. package/dist/reconcile/manifest.js.map +1 -0
  173. package/dist/reconcile/plan.d.ts +1 -1
  174. package/dist/reconcile/plan.js +8 -404
  175. package/dist/reconcile/plan.js.map +1 -1
  176. package/dist/resume-bundles.js +1 -1
  177. package/dist/schemas.d.ts +76 -76
  178. package/dist/search/factory.js +8 -8
  179. package/dist/search/index.js +14 -14
  180. package/dist/semantic-consolidation.js +11 -10
  181. package/dist/semantic-rule-promotion.js +9 -8
  182. package/dist/semantic-rule-verifier.js +10 -9
  183. package/dist/{service-CLuMO-pa.d.ts → service-DW06sY3P.d.ts} +1 -1
  184. package/dist/shared-context/manager.d.ts +8 -8
  185. package/dist/source-agent-qualifier.js +14 -13
  186. package/dist/{storage-Blr71GpS.d.ts → storage-qRIQPA6F.d.ts} +12 -0
  187. package/dist/storage.d.ts +1 -1
  188. package/dist/storage.js +8 -7
  189. package/dist/temporal-supersession.d.ts +1 -1
  190. package/dist/tier-migration.d.ts +1 -1
  191. package/dist/transfer/autodetect.js +1 -1
  192. package/dist/transfer/backup.js +1 -1
  193. package/dist/transfer/capsule-export.js +2 -2
  194. package/dist/transfer/types.d.ts +66 -66
  195. package/dist/verified-recall.js +10 -9
  196. package/package.json +12 -2
  197. package/src/access-http-query.ts +25 -0
  198. package/src/access-http.test.ts +136 -0
  199. package/src/access-http.ts +26 -26
  200. package/src/access-service-offline-file-content.test.ts +40 -0
  201. package/src/access-service.ts +34 -0
  202. package/src/access-surface-catalog.ts +5 -0
  203. package/src/convergence-refresh.ts +28 -0
  204. package/src/orchestrator-convergence-refresh.test.ts +114 -0
  205. package/src/orchestrator.ts +5 -4
  206. package/src/reconcile/manifest.test.ts +261 -0
  207. package/src/reconcile/manifest.ts +278 -0
  208. package/src/reconcile/plan.ts +1 -0
  209. package/src/storage/content-hash-index.ts +26 -0
  210. package/dist/chunk-5QZEVMLY.js.map +0 -1
  211. package/dist/chunk-BEQOZOYZ.js.map +0 -1
  212. package/dist/chunk-UP67AFRN.js.map +0 -1
  213. package/dist/chunk-ZVVASRUT.js.map +0 -1
  214. /package/dist/{graph-edge-decay-Z6ENALU2.js.map → access-http-query.js.map} +0 -0
  215. /package/dist/{auto-sync-UPTUVE4X.js.map → auto-sync-5OE5QOZB.js.map} +0 -0
  216. /package/dist/{chunk-LEPOP46W.js.map → chunk-227UFSEQ.js.map} +0 -0
  217. /package/dist/{chunk-CMENJQ6I.js.map → chunk-2QR2RRH6.js.map} +0 -0
  218. /package/dist/{chunk-4DYBLQZC.js.map → chunk-3HLCRYBV.js.map} +0 -0
  219. /package/dist/{chunk-L2DPF4DF.js.map → chunk-3SHB4YAW.js.map} +0 -0
  220. /package/dist/{chunk-UV72DEZD.js.map → chunk-4B77XSG5.js.map} +0 -0
  221. /package/dist/{chunk-GUQYMOPU.js.map → chunk-4LMA77JH.js.map} +0 -0
  222. /package/dist/{chunk-Q53RLEW7.js.map → chunk-5SM35XZR.js.map} +0 -0
  223. /package/dist/{chunk-YJHU5B3M.js.map → chunk-5YGO5VEH.js.map} +0 -0
  224. /package/dist/{chunk-AX2J2LC4.js.map → chunk-5ZE7QHTK.js.map} +0 -0
  225. /package/dist/{chunk-YFHMW37V.js.map → chunk-6CZPDBJS.js.map} +0 -0
  226. /package/dist/{chunk-BI6RE2ZS.js.map → chunk-6OLQJJYE.js.map} +0 -0
  227. /package/dist/{chunk-GS5UVGQK.js.map → chunk-7JO6RVBI.js.map} +0 -0
  228. /package/dist/{chunk-MBTPUVHZ.js.map → chunk-7PVFOFPJ.js.map} +0 -0
  229. /package/dist/{chunk-KPJQTSRJ.js.map → chunk-C7EI7CA6.js.map} +0 -0
  230. /package/dist/{chunk-NRV4Y3PM.js.map → chunk-CAGPP5NB.js.map} +0 -0
  231. /package/dist/{chunk-NF3U3MPG.js.map → chunk-ECK75GDJ.js.map} +0 -0
  232. /package/dist/{chunk-KDXD2AM6.js.map → chunk-FLBPATMZ.js.map} +0 -0
  233. /package/dist/{chunk-OEXKYYVH.js.map → chunk-FVGQ5LIS.js.map} +0 -0
  234. /package/dist/{chunk-AR2FSZBP.js.map → chunk-G24XIK6H.js.map} +0 -0
  235. /package/dist/{chunk-5TOV3D6U.js.map → chunk-GBJJUPNV.js.map} +0 -0
  236. /package/dist/{chunk-EPQ76W5T.js.map → chunk-GQBZ7EVF.js.map} +0 -0
  237. /package/dist/{chunk-BGXHVOBI.js.map → chunk-HLPD7B7P.js.map} +0 -0
  238. /package/dist/{chunk-KQAFEZQX.js.map → chunk-IPLYGWQF.js.map} +0 -0
  239. /package/dist/{chunk-JDXDA5OO.js.map → chunk-J4OKD6QX.js.map} +0 -0
  240. /package/dist/{chunk-LZ5JKQCS.js.map → chunk-JZ7SSAQ5.js.map} +0 -0
  241. /package/dist/{chunk-EHKDORQN.js.map → chunk-NVRYW264.js.map} +0 -0
  242. /package/dist/{chunk-MU7ZHSZE.js.map → chunk-PO5LSKQO.js.map} +0 -0
  243. /package/dist/{chunk-ONHRRJZM.js.map → chunk-QJAQNXWI.js.map} +0 -0
  244. /package/dist/{chunk-WGCTFGLW.js.map → chunk-QQ3NDJOM.js.map} +0 -0
  245. /package/dist/{chunk-QY66HJ4X.js.map → chunk-SS7YWVPF.js.map} +0 -0
  246. /package/dist/{chunk-JYIFGGEM.js.map → chunk-UZGJFIIG.js.map} +0 -0
  247. /package/dist/{chunk-NHRNYJPJ.js.map → chunk-VCGPO2VD.js.map} +0 -0
  248. /package/dist/{chunk-F2KMAFEB.js.map → chunk-VRYJSKOM.js.map} +0 -0
  249. /package/dist/{chunk-HNZKVLAN.js.map → chunk-W6ROK2KU.js.map} +0 -0
  250. /package/dist/{chunk-SEUSFKNS.js.map → chunk-WC4SPYHX.js.map} +0 -0
  251. /package/dist/{chunk-MSXWUXFE.js.map → chunk-WG2PI44B.js.map} +0 -0
  252. /package/dist/{chunk-4VXGRIH6.js.map → chunk-WUNIWKY4.js.map} +0 -0
  253. /package/dist/{chunk-3C2TGBDB.js.map → chunk-XFQRFWVS.js.map} +0 -0
  254. /package/dist/{chunk-5N26WBPQ.js.map → chunk-XMZV27S5.js.map} +0 -0
  255. /package/dist/{chunk-JGXXPDM2.js.map → chunk-YQQK7W7T.js.map} +0 -0
  256. /package/dist/{chunk-3F2LKUMU.js.map → chunk-ZAB5PG65.js.map} +0 -0
  257. /package/dist/{chunk-MVW234LO.js.map → chunk-ZPJJE7MF.js.map} +0 -0
  258. /package/dist/{chunk-6GIMMNVK.js.map → chunk-ZS5UZU5M.js.map} +0 -0
@@ -18,6 +18,7 @@ import {
18
18
  type EngramAccessWriteResponse,
19
19
  } from "./access-service.js";
20
20
  import { maybeHandleLifecycleFlush, type LifecycleFlushHttpDeps } from "./access-http-lifecycle-flush.js";
21
+ import { nonEmptyQueryParam, optionalQueryString, positiveIntQueryParam } from "./access-http-query.js";
21
22
  import { CorrectionContractError } from "./correction/correction-contract.js";
22
23
  import { WearablesInputError } from "./wearables/errors.js"; import { respondMeetingsList, respondMeetingsGet, respondMeetingsBuild } from "./meetings/http-glue.js";
23
24
  import { EngramMcpServer, MCP_SUPPORTED_PROTOCOL_VERSIONS } from "./access-mcp.js";
@@ -1322,6 +1323,31 @@ export class EngramAccessHttpServer {
1322
1323
  return;
1323
1324
  }
1324
1325
 
1326
+ if (
1327
+ req.method === "POST" &&
1328
+ (
1329
+ pathname === "/engram/v1/offline-sync/convergence-complete" ||
1330
+ pathname === "/remnic/v1/offline-sync/convergence-complete"
1331
+ )
1332
+ ) {
1333
+ this.enforceTokenOp("offline_sync_apply_file_content");
1334
+ const namespaceParams = parsed.searchParams.getAll("namespace").filter(Boolean);
1335
+ const requestedNamespaces: Array<string | undefined> =
1336
+ namespaceParams.length > 0 ? namespaceParams : [undefined];
1337
+ const namespaces = requestedNamespaces
1338
+ .map((namespace) => this.resolveNamespace(req, namespace))
1339
+ .filter((namespace): namespace is string => namespace !== undefined);
1340
+ this.ensureWriteRateLimitAvailable(req);
1341
+ const result = await this.service.offlineSyncFinalizeConvergence({
1342
+ ...(namespaces.length > 0 ? { namespaces } : {}),
1343
+ principal: this.resolveRequestPrincipal(req),
1344
+ sourceId: this.readRequiredDecodedHeader(req, "x-remnic-source-id"),
1345
+ });
1346
+ this.recordWriteRateLimitHit(req);
1347
+ this.respondJson(res, 200, result);
1348
+ return;
1349
+ }
1350
+
1325
1351
  if (
1326
1352
  req.method === "POST" &&
1327
1353
  (pathname === "/engram/v1/offline-sync/apply" || pathname === "/remnic/v1/offline-sync/apply")
@@ -3925,29 +3951,3 @@ export class EngramAccessHttpServer {
3925
3951
  return false;
3926
3952
  }
3927
3953
  }
3928
-
3929
- /** Optional string field from a JSON body: absent/null/"" → undefined. */
3930
- function optionalQueryString(value: unknown, label: string): string | undefined {
3931
- if (value === undefined || value === null || value === "") return undefined;
3932
- if (typeof value !== "string") {
3933
- throw new EngramAccessInputError(
3934
- `${label} must be a string (got ${JSON.stringify(value)})`,
3935
- );
3936
- }
3937
- return value;
3938
- }
3939
-
3940
- /** Optional non-empty query param: null/"" → undefined. */
3941
- function nonEmptyQueryParam(value: string | null): string | undefined {
3942
- return value !== null && value.length > 0 ? value : undefined;
3943
- }
3944
-
3945
- /** Optional positive-integer query param; rejects invalid values. */
3946
- function positiveIntQueryParam(value: string | null, label: string): number | undefined {
3947
- if (value === null || value.length === 0) return undefined;
3948
- const parsed = Number(value);
3949
- if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
3950
- throw new EngramAccessInputError(`${label} expects a positive integer`);
3951
- }
3952
- return parsed;
3953
- }
@@ -52,3 +52,43 @@ test("offline apply-file-content reports invalid metadata as input errors", asyn
52
52
  /mtimeMs must be within JavaScript Date range/.test(error.message),
53
53
  );
54
54
  });
55
+
56
+ test("offline convergence completion refreshes authorized namespaces as one batch and rejects other sources", async () => {
57
+ const refreshed: string[][] = [];
58
+ const orchestratorStub = {
59
+ config: {
60
+ memoryDir: "/tmp/remnic-access-service-convergence-test",
61
+ namespacesEnabled: false,
62
+ defaultNamespace: "global",
63
+ sharedNamespace: "shared",
64
+ },
65
+ refreshNamespacesAfterConvergence: async (namespaces: readonly string[]) => {
66
+ refreshed.push([...namespaces]);
67
+ },
68
+ };
69
+ const service = new EngramAccessService(
70
+ orchestratorStub as unknown as ConstructorParameters<typeof EngramAccessService>[0],
71
+ );
72
+ const convergenceService = service as unknown as {
73
+ offlineSyncFinalizeConvergence(options: {
74
+ namespaces?: string[];
75
+ principal?: string;
76
+ sourceId: string;
77
+ }): Promise<{ namespaces: string[]; refreshed: true }>;
78
+ };
79
+
80
+ await assert.rejects(
81
+ () => convergenceService.offlineSyncFinalizeConvergence({ sourceId: "laptop" }),
82
+ (error) =>
83
+ error instanceof EngramAccessInputError
84
+ && /sourceId must be remnic-converge/.test(error.message),
85
+ );
86
+ assert.deepEqual(refreshed, []);
87
+
88
+ const result = await convergenceService.offlineSyncFinalizeConvergence({
89
+ sourceId: "remnic-converge",
90
+ });
91
+
92
+ assert.deepEqual(result, { namespaces: ["global"], refreshed: true });
93
+ assert.deepEqual(refreshed, [["global"]]);
94
+ });
@@ -818,6 +818,12 @@ export interface EngramAccessOfflineSyncApplyRequest {
818
818
  returnCurrentFiles?: boolean;
819
819
  }
820
820
 
821
+ export interface EngramAccessOfflineSyncFinalizeConvergenceRequest {
822
+ namespaces?: string[];
823
+ principal?: string;
824
+ sourceId: string;
825
+ }
826
+
821
827
  export interface EngramAccessOfflineSyncSnapshotResponse extends OfflineSyncSnapshot {
822
828
  namespace: string;
823
829
  }
@@ -843,6 +849,11 @@ export interface EngramAccessOfflineSyncApplyResponse extends OfflineSyncApplyCh
843
849
  namespace: string;
844
850
  }
845
851
 
852
+ export interface EngramAccessOfflineSyncFinalizeConvergenceResponse {
853
+ namespaces: string[];
854
+ refreshed: true;
855
+ }
856
+
846
857
  export type EngramAccessActionConfidenceRequest = ActionConfidenceInput;
847
858
  export type EngramAccessActionConfidenceResponse = ActionConfidenceResult;
848
859
 
@@ -5621,6 +5632,29 @@ export class EngramAccessService {
5621
5632
  }
5622
5633
  }
5623
5634
 
5635
+ async offlineSyncFinalizeConvergence(
5636
+ options: EngramAccessOfflineSyncFinalizeConvergenceRequest,
5637
+ ): Promise<EngramAccessOfflineSyncFinalizeConvergenceResponse> {
5638
+ if (options.sourceId !== "remnic-converge") {
5639
+ throw new EngramAccessInputError("sourceId must be remnic-converge");
5640
+ }
5641
+ const requestedNamespaces = options.namespaces?.length
5642
+ ? options.namespaces
5643
+ : [undefined];
5644
+ const resolvedNamespaces = Array.from(new Set(
5645
+ requestedNamespaces.map((namespace) => this.writableNamespaceFor(
5646
+ namespace,
5647
+ undefined,
5648
+ options.principal,
5649
+ )),
5650
+ ));
5651
+ await this.orchestrator.refreshNamespacesAfterConvergence(resolvedNamespaces);
5652
+ return {
5653
+ namespaces: resolvedNamespaces,
5654
+ refreshed: true,
5655
+ };
5656
+ }
5657
+
5624
5658
  async offlineSyncApply(
5625
5659
  options: EngramAccessOfflineSyncApplyRequest,
5626
5660
  ): Promise<EngramAccessOfflineSyncApplyResponse> {
@@ -183,6 +183,11 @@ export const HTTP_ROUTES: readonly HttpRouteEntry[] = [
183
183
  { method: "POST", pathname: "/engram/v1/offline-sync/files", operation: "offline_sync_files" },
184
184
  { method: "POST", pathname: "/engram/v1/offline-sync/file-content", operation: "offline_sync_file_content" },
185
185
  { method: "POST", pathname: "/engram/v1/offline-sync/apply-file-content", operation: "offline_sync_apply_file_content" },
186
+ {
187
+ method: "POST",
188
+ pathname: "/engram/v1/offline-sync/convergence-complete",
189
+ operation: "offline_sync_apply_file_content",
190
+ },
186
191
  { method: "POST", pathname: "/engram/v1/offline-sync/apply", operation: "offline_sync_apply" },
187
192
  { method: "POST", pathname: "/engram/v1/recall/explain", operation: "recall_explain" },
188
193
  { method: "POST", pathname: "/engram/v1/action-confidence", operation: "action_confidence" },
@@ -0,0 +1,28 @@
1
+ import {
2
+ type RebuildMemoryProjectionOptions,
3
+ rebuildMemoryProjection,
4
+ } from "./maintenance/rebuild-memory-projection.js";
5
+ import type { NamespaceSearchRouter } from "./namespaces/search.js";
6
+
7
+ type ConvergenceStorage = NonNullable<RebuildMemoryProjectionOptions["storage"]>;
8
+
9
+ export async function refreshConvergedNamespaces(
10
+ namespaces: readonly string[],
11
+ namespaceSearchRouter: Pick<NamespaceSearchRouter, "updateNamespacesDetailed">,
12
+ getStorage: (namespace: string) => Promise<ConvergenceStorage>
13
+ ): Promise<void> {
14
+ const targetNamespaces = Array.from(new Set(namespaces.map((namespace) => namespace.trim()).filter(Boolean)));
15
+ if (targetNamespaces.length === 0) return;
16
+
17
+ await namespaceSearchRouter.updateNamespacesDetailed(targetNamespaces, undefined, { strict: true });
18
+
19
+ for (const namespace of targetNamespaces) {
20
+ const storage = await getStorage(namespace);
21
+ await rebuildMemoryProjection({
22
+ memoryDir: storage.dir,
23
+ storage,
24
+ defaultNamespace: namespace,
25
+ dryRun: false,
26
+ });
27
+ }
28
+ }
@@ -0,0 +1,114 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import { readProjectedMemoryBrowse } from "./memory-projection-store.js";
8
+ import { Orchestrator } from "./orchestrator.js";
9
+ import { StorageManager } from "./storage.js";
10
+
11
+ test("refreshNamespacesAfterConvergence runs one strict QMD batch and rebuilds each namespace projection once", async () => {
12
+ const teamDir = await mkdtemp(path.join(os.tmpdir(), "remnic-convergence-team-"));
13
+ const sharedDir = await mkdtemp(path.join(os.tmpdir(), "remnic-convergence-shared-"));
14
+ try {
15
+ const teamStorage = new StorageManager(teamDir);
16
+ const sharedStorage = new StorageManager(sharedDir);
17
+ const teamMemory = await teamStorage.writeMemory("fact", "A received team fact.", {
18
+ source: "test",
19
+ });
20
+ const sharedMemory = await sharedStorage.writeMemory("fact", "A received shared fact.", {
21
+ source: "test",
22
+ });
23
+ const qmdCalls: Array<{
24
+ namespaces: string[];
25
+ options: { strict?: boolean } | undefined;
26
+ }> = [];
27
+ const storageCalls: string[] = [];
28
+ const orchestrator = Object.create(Orchestrator.prototype) as Orchestrator;
29
+ const internals = orchestrator as unknown as {
30
+ config: { defaultNamespace: string };
31
+ namespaceSearchRouter: {
32
+ updateNamespacesDetailed(
33
+ namespaces: string[],
34
+ execution?: unknown,
35
+ options?: { strict?: boolean }
36
+ ): Promise<{ backendCount: number; eligibleNamespaces: string[] }>;
37
+ };
38
+ getStorage(namespace: string): Promise<StorageManager>;
39
+ };
40
+ internals.config = { defaultNamespace: "default" };
41
+ internals.namespaceSearchRouter = {
42
+ async updateNamespacesDetailed(namespaces, _execution, options) {
43
+ qmdCalls.push({ namespaces, options });
44
+ return { backendCount: 1, eligibleNamespaces: namespaces };
45
+ },
46
+ };
47
+ const storageByNamespace = new Map([
48
+ ["team", teamStorage],
49
+ ["shared", sharedStorage],
50
+ ]);
51
+ internals.getStorage = async (namespace) => {
52
+ storageCalls.push(namespace);
53
+ const storage = storageByNamespace.get(namespace);
54
+ assert.ok(storage);
55
+ return storage;
56
+ };
57
+ const refreshSurface = orchestrator as unknown as {
58
+ refreshNamespacesAfterConvergence(namespaces: readonly string[]): Promise<void>;
59
+ };
60
+
61
+ await refreshSurface.refreshNamespacesAfterConvergence(["team", "shared", "team"]);
62
+
63
+ assert.deepEqual(qmdCalls, [
64
+ {
65
+ namespaces: ["team", "shared"],
66
+ options: { strict: true },
67
+ },
68
+ ]);
69
+ assert.deepEqual(storageCalls, ["team", "shared"]);
70
+ const teamProjection = readProjectedMemoryBrowse(teamDir, { limit: 10, offset: 0 });
71
+ const sharedProjection = readProjectedMemoryBrowse(sharedDir, { limit: 10, offset: 0 });
72
+ assert.equal(teamProjection?.memories[0]?.id, teamMemory.id);
73
+ assert.equal(sharedProjection?.memories[0]?.id, sharedMemory.id);
74
+ } finally {
75
+ await Promise.all([rm(teamDir, { recursive: true, force: true }), rm(sharedDir, { recursive: true, force: true })]);
76
+ }
77
+ });
78
+
79
+ test("refreshNamespacesAfterConvergence rebuilds projection when search is disabled", async () => {
80
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-convergence-no-search-"));
81
+ try {
82
+ const storage = new StorageManager(memoryDir);
83
+ const memory = await storage.writeMemory("fact", "A received fact without search enabled.", {
84
+ source: "test",
85
+ });
86
+ const orchestrator = Object.create(Orchestrator.prototype) as Orchestrator;
87
+ const internals = orchestrator as unknown as {
88
+ namespaceSearchRouter: {
89
+ updateNamespacesDetailed(
90
+ namespaces: string[],
91
+ execution?: unknown,
92
+ options?: { strict?: boolean }
93
+ ): Promise<{ backendCount: number; eligibleNamespaces: string[] }>;
94
+ };
95
+ getStorage(namespace: string): Promise<StorageManager>;
96
+ };
97
+ internals.namespaceSearchRouter = {
98
+ async updateNamespacesDetailed() {
99
+ return { backendCount: 0, eligibleNamespaces: [] };
100
+ },
101
+ };
102
+ internals.getStorage = async () => storage;
103
+ const refreshSurface = orchestrator as unknown as {
104
+ refreshNamespacesAfterConvergence(namespaces: readonly string[]): Promise<void>;
105
+ };
106
+
107
+ await refreshSurface.refreshNamespacesAfterConvergence(["default"]);
108
+
109
+ const projection = readProjectedMemoryBrowse(memoryDir, { limit: 10, offset: 0 });
110
+ assert.equal(projection?.memories[0]?.id, memory.id);
111
+ } finally {
112
+ await rm(memoryDir, { recursive: true, force: true });
113
+ }
114
+ });
@@ -218,6 +218,7 @@ import {
218
218
  runPatternReinforcement,
219
219
  type PatternReinforcementResult,
220
220
  } from "./maintenance/pattern-reinforcement.js";
221
+ import { refreshConvergedNamespaces } from "./convergence-refresh.js";
221
222
  import { ModelRegistry } from "./model-registry.js";
222
223
  import { applyRuntimeRetrievalPolicy, expandQuery } from "./retrieval.js";
223
224
  import {
@@ -1278,11 +1279,11 @@ export class Orchestrator {
1278
1279
  return await this.namespaceSearchRouter.healthForNamespace(namespace, execution);
1279
1280
  }
1280
1281
 
1281
- private isSearchAvailableForNamespaceRouting(): boolean {
1282
- if (resolveNamespaceCapabilities(this.config).namespaces) return true;
1283
- return this.qmd.isAvailable();
1282
+ async refreshNamespacesAfterConvergence(namespaces: readonly string[]): Promise<void> {
1283
+ return refreshConvergedNamespaces(namespaces, this.namespaceSearchRouter, this.getStorage.bind(this));
1284
1284
  }
1285
1285
 
1286
+
1286
1287
  invalidateLiveContentHashIndex(): void {
1287
1288
  this.contentHashIndex = null;
1288
1289
  this.contentHashIndexesByStorageDir.clear();
@@ -1530,7 +1531,7 @@ export class Orchestrator {
1530
1531
  });
1531
1532
  this.contradictionLinkingCoordinator = new ContradictionLinkingCoordinator({
1532
1533
  getConfig: () => this.config,
1533
- isSearchAvailable: () => this.isSearchAvailableForNamespaceRouting(),
1534
+ isSearchAvailable: () => resolveNamespaceCapabilities(this.config).namespaces || this.qmd.isAvailable(),
1534
1535
  searchAcrossNamespaces: (options) => this.searchAcrossNamespaces(options),
1535
1536
  extractMemoryIdsFromResults: (results) => this.extractMemoryIdsFromResults(results),
1536
1537
  namespaceFromPath: (p) => this.namespaceFromPath(p),
@@ -0,0 +1,261 @@
1
+ import * as assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { test } from "node:test";
4
+ import { ContentHashIndex } from "../storage/content-hash-index.js";
5
+ import { type ReconcileManifest, buildReconcileManifest, collapseActiveFactDuplicates } from "./manifest.js";
6
+ import { planReconciliation } from "./plan.js";
7
+
8
+ const fileHash = (content: string): string => createHash("sha256").update(content).digest("hex");
9
+
10
+ function memoryFile(options: {
11
+ id: string;
12
+ content: string;
13
+ contentHash?: string;
14
+ status?: string;
15
+ }): string {
16
+ return [
17
+ "---",
18
+ `id: ${options.id}`,
19
+ "category: fact",
20
+ "created: 2026-01-01T00:00:00.000Z",
21
+ "updated: 2026-01-01T00:00:00.000Z",
22
+ ...(options.contentHash ? [`contentHash: ${options.contentHash}`] : []),
23
+ ...(options.status ? [`status: ${options.status}`] : []),
24
+ "---",
25
+ options.content,
26
+ ].join("\n");
27
+ }
28
+
29
+ test("reconcile manifest keeps file identity separate from canonical semantic identity", async () => {
30
+ const content = "The deployment uses a durable queue with an enrichment suffix.";
31
+ const semanticHash = ContentHashIndex.computeHash("The deployment uses a durable queue.");
32
+ const serialized = memoryFile({ id: "fact-a", content, contentHash: semanticHash, status: "active" });
33
+ const serializedHash = fileHash(serialized);
34
+
35
+ const manifest = await buildReconcileManifest({
36
+ files: [{ path: "facts/fact-a.md", sha256: serializedHash, bytes: Buffer.byteLength(serialized) }],
37
+ readFile: async () => Buffer.from(serialized),
38
+ });
39
+
40
+ assert.equal(manifest.format, "remnic-reconcile-manifest");
41
+ assert.equal(manifest.schemaVersion, 1);
42
+ assert.equal(manifest.files[0]?.sha256, serializedHash);
43
+ assert.notEqual(serializedHash, semanticHash);
44
+ assert.deepEqual(manifest.files[0]?.memory, {
45
+ id: "fact-a",
46
+ category: "fact",
47
+ contentHash: semanticHash,
48
+ status: "active",
49
+ });
50
+ });
51
+
52
+ test("reconcile manifest hashes parsed legacy content and uses effective lifecycle status", async () => {
53
+ const active = memoryFile({ id: "legacy", content: "Legacy fact body" });
54
+ const archived = memoryFile({ id: "archived", content: "Archived fact body", status: "active" });
55
+ const rawByPath = new Map([
56
+ ["facts/legacy.md", Buffer.from(active)],
57
+ ["archive/facts/archived.md", Buffer.from(archived)],
58
+ ]);
59
+
60
+ const manifest = await buildReconcileManifest({
61
+ files: [...rawByPath].map(([path, raw]) => ({ path, sha256: fileHash(raw.toString()) })),
62
+ readFile: async (file) => rawByPath.get(file.path) ?? null,
63
+ });
64
+
65
+ assert.equal(manifest.files[0]?.memory?.contentHash, ContentHashIndex.computeHash("Archived fact body"));
66
+ assert.equal(manifest.files[0]?.memory?.status, "archived");
67
+ assert.equal(manifest.files[1]?.memory?.contentHash, ContentHashIndex.computeHash("Legacy fact body"));
68
+ assert.equal(manifest.files[1]?.memory?.status, "active");
69
+ });
70
+
71
+ test("reconcile manifest keeps file identity when memory bytes cannot be trusted", async () => {
72
+ const readable = memoryFile({ id: "readable", content: "Readable fact" });
73
+ const changed = memoryFile({ id: "changed", content: "Changed after census" });
74
+ const files = [
75
+ { path: "facts/readable.md", sha256: fileHash(readable) },
76
+ { path: "facts/locked.md", sha256: "f".repeat(64) },
77
+ { path: "facts/changed.md", sha256: fileHash("bytes from census") },
78
+ ];
79
+
80
+ const manifest = await buildReconcileManifest({
81
+ files,
82
+ readFile: async (file) => {
83
+ if (file.path === "facts/locked.md") throw new Error("locked");
84
+ return file.path === "facts/changed.md" ? changed : readable;
85
+ },
86
+ });
87
+
88
+ assert.equal(manifest.files.length, 3);
89
+ assert.equal(manifest.files.find((file) => file.path === "facts/readable.md")?.memory?.id, "readable");
90
+ assert.equal(manifest.files.find((file) => file.path === "facts/locked.md")?.memory, undefined);
91
+ assert.equal(manifest.files.find((file) => file.path === "facts/changed.md")?.memory, undefined);
92
+ });
93
+
94
+ test("ContentHashIndex resolves a semantic hash to a stable canonical path without changing membership", () => {
95
+ const semanticHash = ContentHashIndex.computeHash("same fact");
96
+ const entries = [
97
+ { path: "facts/z.md", contentHash: semanticHash },
98
+ { path: "facts/a.md", contentHash: semanticHash },
99
+ { path: "facts/other.md", contentHash: ContentHashIndex.computeHash("other fact") },
100
+ ];
101
+ const index = new ContentHashIndex("unused");
102
+
103
+ assert.equal(ContentHashIndex.resolvePathByHash(semanticHash, entries), "facts/a.md");
104
+ assert.equal(ContentHashIndex.resolvePathByHash("f".repeat(64), entries), undefined);
105
+ assert.equal(index.has("same fact"), false);
106
+ });
107
+
108
+ test("semantic collapse prevents different active fact paths from transferring both directions", () => {
109
+ const semanticHash = ContentHashIndex.computeHash("same active fact");
110
+ const localFile = { path: "facts/local-id.md", sha256: "a".repeat(64) };
111
+ const peerFile = { path: "facts/peer-id.md", sha256: "b".repeat(64) };
112
+ const plan = planReconciliation([{ namespace: "default", local: [localFile], peer: [peerFile] }]);
113
+ const local: ReconcileManifest = {
114
+ format: "remnic-reconcile-manifest",
115
+ schemaVersion: 1,
116
+ files: [
117
+ { ...localFile, memory: { id: "local-id", category: "fact", contentHash: semanticHash, status: "active" } },
118
+ ],
119
+ };
120
+ const peer: ReconcileManifest = {
121
+ format: "remnic-reconcile-manifest",
122
+ schemaVersion: 1,
123
+ files: [{ ...peerFile, memory: { id: "peer-id", category: "fact", contentHash: semanticHash, status: "active" } }],
124
+ };
125
+
126
+ const collapsed = collapseActiveFactDuplicates(plan, new Map([["default", local]]), new Map([["default", peer]]));
127
+
128
+ assert.equal(collapsed.converged, true);
129
+ assert.deepEqual(collapsed.entries, [
130
+ {
131
+ path: "facts/local-id.md",
132
+ namespace: "default",
133
+ action: "identical",
134
+ reason: "semantic_duplicate",
135
+ localSha256: localFile.sha256,
136
+ peerSha256: peerFile.sha256,
137
+ },
138
+ ]);
139
+ assert.equal(collapsed.byNamespace[0]?.identical, 1);
140
+ assert.equal(collapsed.byNamespace[0]?.pull, 0);
141
+ assert.equal(collapsed.byNamespace[0]?.push, 0);
142
+ });
143
+
144
+ test("semantic collapse does not fold non-active facts or unrelated path conflicts", () => {
145
+ const semanticHash = ContentHashIndex.computeHash("same inactive fact");
146
+ const localFile = { path: "facts/local.md", sha256: "a".repeat(64) };
147
+ const peerFile = { path: "facts/peer.md", sha256: "b".repeat(64) };
148
+ const plan = planReconciliation([{ namespace: "default", local: [localFile], peer: [peerFile] }]);
149
+ const manifest = (file: typeof localFile, id: string, status: "active" | "archived"): ReconcileManifest => ({
150
+ format: "remnic-reconcile-manifest",
151
+ schemaVersion: 1,
152
+ files: [{ ...file, memory: { id, category: "fact", contentHash: semanticHash, status } }],
153
+ });
154
+
155
+ const collapsed = collapseActiveFactDuplicates(
156
+ plan,
157
+ new Map([["default", manifest(localFile, "local", "active")]]),
158
+ new Map([["default", manifest(peerFile, "peer", "archived")]])
159
+ );
160
+
161
+ assert.deepEqual(collapsed, plan);
162
+ });
163
+
164
+ test("semantic collapse leaves tombstone suppression authoritative", () => {
165
+ const semanticHash = ContentHashIndex.computeHash("retracted fact");
166
+ const localFile = { path: "facts/local.md", sha256: "a".repeat(64) };
167
+ const peerFile = { path: "facts/peer.md", sha256: "b".repeat(64) };
168
+ const plan = planReconciliation([
169
+ {
170
+ namespace: "default",
171
+ local: [localFile],
172
+ peer: [peerFile],
173
+ tombstonedFileSha256: [localFile.sha256],
174
+ },
175
+ ]);
176
+ const manifest = (file: typeof localFile, id: string): ReconcileManifest => ({
177
+ format: "remnic-reconcile-manifest",
178
+ schemaVersion: 1,
179
+ files: [{ ...file, memory: { id, category: "fact", contentHash: semanticHash, status: "active" } }],
180
+ });
181
+
182
+ const collapsed = collapseActiveFactDuplicates(
183
+ plan,
184
+ new Map([["default", manifest(localFile, "local")]]),
185
+ new Map([["default", manifest(peerFile, "peer")]])
186
+ );
187
+
188
+ assert.deepEqual(collapsed, plan);
189
+ assert.equal(
190
+ collapsed.entries.some((entry) => entry.action === "suppress"),
191
+ true
192
+ );
193
+ });
194
+
195
+ test("semantic collapse preserves same-path metadata updates", () => {
196
+ const path = "facts/shared.md";
197
+ const localFile = { path, sha256: "a".repeat(64) };
198
+ const peerFile = { path, sha256: "b".repeat(64) };
199
+ const baseFile = { path, sha256: peerFile.sha256 };
200
+ const semanticHash = ContentHashIndex.computeHash("same raw fact");
201
+ const plan = planReconciliation([{ namespace: "default", local: [localFile], peer: [peerFile], base: [baseFile] }]);
202
+ const manifest = (file: typeof localFile, id: string): ReconcileManifest => ({
203
+ format: "remnic-reconcile-manifest",
204
+ schemaVersion: 1,
205
+ files: [{ ...file, memory: { id, category: "fact", contentHash: semanticHash, status: "active" } }],
206
+ });
207
+
208
+ const collapsed = collapseActiveFactDuplicates(
209
+ plan,
210
+ new Map([["default", manifest(localFile, "local")]]),
211
+ new Map([["default", manifest(peerFile, "peer")]])
212
+ );
213
+
214
+ assert.deepEqual(collapsed, plan);
215
+ assert.equal(collapsed.entries[0]?.action, "push");
216
+ });
217
+
218
+ test("semantic collapse remains converged after transferring the canonical one-sided duplicate", () => {
219
+ const semanticHash = ContentHashIndex.computeHash("same one-sided fact");
220
+ const canonical = { path: "facts/a.md", sha256: "a".repeat(64) };
221
+ const duplicate = { path: "facts/b.md", sha256: "b".repeat(64) };
222
+ const manifest = (files: Array<{ path: string; sha256: string }>): ReconcileManifest => ({
223
+ format: "remnic-reconcile-manifest",
224
+ schemaVersion: 1,
225
+ files: files.map((file) => ({
226
+ ...file,
227
+ memory: {
228
+ id: file.path,
229
+ category: "fact",
230
+ contentHash: semanticHash,
231
+ status: "active",
232
+ },
233
+ })),
234
+ });
235
+ const localManifest = manifest([canonical, duplicate]);
236
+ const firstPlan = planReconciliation([{ namespace: "default", local: [canonical, duplicate], peer: [] }]);
237
+
238
+ const firstCollapsed = collapseActiveFactDuplicates(
239
+ firstPlan,
240
+ new Map([["default", localManifest]]),
241
+ new Map([["default", manifest([])]])
242
+ );
243
+
244
+ assert.deepEqual(
245
+ firstCollapsed.entries.map((entry) => [entry.path, entry.action]),
246
+ [[canonical.path, "push"]]
247
+ );
248
+
249
+ const rerunPlan = planReconciliation([{ namespace: "default", local: [canonical, duplicate], peer: [canonical] }]);
250
+ const rerunCollapsed = collapseActiveFactDuplicates(
251
+ rerunPlan,
252
+ new Map([["default", localManifest]]),
253
+ new Map([["default", manifest([canonical])]])
254
+ );
255
+
256
+ assert.equal(rerunCollapsed.converged, true, JSON.stringify(rerunCollapsed));
257
+ assert.deepEqual(
258
+ rerunCollapsed.entries.map((entry) => [entry.path, entry.action]),
259
+ [[canonical.path, "identical"]]
260
+ );
261
+ });