@remnic/core 9.44.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 (187) hide show
  1. package/dist/access-admin-ops-surface.d.ts +5 -5
  2. package/dist/access-admin-ops-surface.js +14 -13
  3. package/dist/access-authorization-probe.d.ts +5 -5
  4. package/dist/access-authorization-probe.js +15 -14
  5. package/dist/access-boundary.d.ts +5 -5
  6. package/dist/access-boundary.js +14 -13
  7. package/dist/access-cli.js +37 -36
  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 +14 -13
  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.js +14 -13
  14. package/dist/access-http.d.ts +5 -5
  15. package/dist/access-http.js +21 -20
  16. package/dist/access-identity-continuity-surface.d.ts +4 -4
  17. package/dist/access-identity-continuity-surface.js +14 -13
  18. package/dist/access-lcm-surface.d.ts +5 -5
  19. package/dist/access-lcm-surface.js +14 -13
  20. package/dist/access-mcp.d.ts +5 -5
  21. package/dist/access-mcp.js +19 -18
  22. package/dist/access-namespace-preflight.js +14 -13
  23. package/dist/access-observe-write-surface.d.ts +5 -5
  24. package/dist/access-observe-write-surface.js +14 -13
  25. package/dist/access-operations-batch.js +16 -15
  26. package/dist/access-operations.d.ts +8 -8
  27. package/dist/access-operations.js +18 -17
  28. package/dist/access-recall-concurrency.d.ts +5 -5
  29. package/dist/access-recall-concurrency.js +14 -13
  30. package/dist/access-recall-response.d.ts +5 -5
  31. package/dist/access-recall-response.js +14 -13
  32. package/dist/access-recall-surface.d.ts +5 -5
  33. package/dist/access-recall-surface.js +14 -13
  34. package/dist/access-schema.d.ts +84 -84
  35. package/dist/access-schema.js +3 -3
  36. package/dist/{access-service-BCZJbX0f.d.ts → access-service-DBo88Dmm.d.ts} +3 -3
  37. package/dist/access-service-helpers.d.ts +5 -5
  38. package/dist/access-service.d.ts +5 -5
  39. package/dist/access-service.js +14 -13
  40. package/dist/access-surface-catalog.d.ts +5 -5
  41. package/dist/access-wearables-meetings-surface.d.ts +2 -2
  42. package/dist/bootstrap.d.ts +4 -4
  43. package/dist/briefing.d.ts +1 -1
  44. package/dist/briefing.js +4 -3
  45. package/dist/buffer.d.ts +1 -1
  46. package/dist/causal-consolidation.js +5 -4
  47. package/dist/causal-consolidation.js.map +1 -1
  48. package/dist/{chunk-OHLNFI6Z.js → chunk-3HKQOFOV.js} +7 -5
  49. package/dist/{chunk-OHLNFI6Z.js.map → chunk-3HKQOFOV.js.map} +1 -1
  50. package/dist/{chunk-PJUHQC4T.js → chunk-3HLCRYBV.js} +3 -3
  51. package/dist/{chunk-N7LPS4Z3.js → chunk-5SM35XZR.js} +2 -2
  52. package/dist/{chunk-E6JYVPBK.js → chunk-5YGO5VEH.js} +2 -2
  53. package/dist/{chunk-EUVPB5TA.js → chunk-65V4K4MT.js} +8 -8
  54. package/dist/{chunk-WX6KOVVK.js → chunk-6OLQJJYE.js} +2 -2
  55. package/dist/chunk-6UWB5PTR.js +413 -0
  56. package/dist/chunk-6UWB5PTR.js.map +1 -0
  57. package/dist/{chunk-G24HJJ67.js → chunk-7JO6RVBI.js} +6 -6
  58. package/dist/{chunk-WUDWRUGK.js → chunk-7PVFOFPJ.js} +2 -2
  59. package/dist/{chunk-UUQLB2HJ.js → chunk-C7EI7CA6.js} +2 -2
  60. package/dist/{chunk-MUKLQDJP.js → chunk-FLBPATMZ.js} +18 -18
  61. package/dist/{chunk-HGFILS2Q.js → chunk-FVGQ5LIS.js} +2 -2
  62. package/dist/{chunk-OPVOUBHT.js → chunk-G24XIK6H.js} +2 -2
  63. package/dist/{chunk-CH2WTP6R.js → chunk-GBJJUPNV.js} +2 -2
  64. package/dist/{chunk-HXTUA7QM.js → chunk-GOPWZYZH.js} +6 -6
  65. package/dist/{chunk-WMHO3WLC.js → chunk-GQBZ7EVF.js} +2 -2
  66. package/dist/{chunk-KQAFEZQX.js → chunk-IPLYGWQF.js} +5 -5
  67. package/dist/{chunk-OZL7KB6B.js → chunk-J4OKD6QX.js} +2 -2
  68. package/dist/{chunk-TRBIJS2T.js → chunk-JZ7SSAQ5.js} +3 -3
  69. package/dist/{chunk-YUOKZ3LJ.js → chunk-K6OTW3TP.js} +2 -2
  70. package/dist/{chunk-I22U5YYD.js → chunk-QQ3NDJOM.js} +2 -2
  71. package/dist/{chunk-2FTRRQKZ.js → chunk-RVLJEII5.js} +30 -511
  72. package/dist/chunk-RVLJEII5.js.map +1 -0
  73. package/dist/chunk-S5SLVSVY.js +517 -0
  74. package/dist/chunk-S5SLVSVY.js.map +1 -0
  75. package/dist/{chunk-7PLHD7EO.js → chunk-SS7YWVPF.js} +2 -2
  76. package/dist/{chunk-J43XN7N3.js → chunk-U5G66ORD.js} +18 -18
  77. package/dist/{chunk-EMC5GT4H.js → chunk-VCGPO2VD.js} +4 -4
  78. package/dist/{chunk-JDDTNK5P.js → chunk-VRYJSKOM.js} +2 -2
  79. package/dist/{chunk-PLQCPMJA.js → chunk-WC4SPYHX.js} +7 -7
  80. package/dist/{chunk-IADUAH35.js → chunk-WG2PI44B.js} +2 -2
  81. package/dist/{chunk-35IC3EVP.js → chunk-XFQRFWVS.js} +2 -2
  82. package/dist/{chunk-MYFFOPO2.js → chunk-XMZV27S5.js} +2 -2
  83. package/dist/{chunk-IXFI6E3Q.js → chunk-YQQK7W7T.js} +2 -2
  84. package/dist/{chunk-3F2LKUMU.js → chunk-ZAB5PG65.js} +2 -2
  85. package/dist/{chunk-O4BADU6R.js → chunk-ZPJJE7MF.js} +2 -2
  86. package/dist/{chunk-U5SX74DV.js → chunk-ZS5UZU5M.js} +3 -3
  87. package/dist/{cli-U30A3jyx.d.ts → cli-DQ4VhjqX.d.ts} +3 -3
  88. package/dist/cli.d.ts +6 -6
  89. package/dist/cli.js +31 -30
  90. package/dist/compounding/engine.d.ts +1 -1
  91. package/dist/compounding/engine.js +4 -3
  92. package/dist/connectors/codex-materialize-runner.js +4 -3
  93. package/dist/connectors/index.js +4 -3
  94. package/dist/consolidation-provenance-check.d.ts +1 -1
  95. package/dist/consolidation-undo.d.ts +1 -1
  96. package/dist/contradiction/index.d.ts +1 -1
  97. package/dist/convergence-refresh.d.ts +1 -1
  98. package/dist/convergence-refresh.js +6 -5
  99. package/dist/corpus-watermark.js +6 -5
  100. package/dist/entity-retrieval.d.ts +1 -1
  101. package/dist/entity-retrieval.js +4 -3
  102. package/dist/explicit-capture.d.ts +4 -4
  103. package/dist/explicit-capture.js +3 -2
  104. package/dist/extraction.js +4 -3
  105. package/dist/index.d.ts +398 -398
  106. package/dist/index.js +37 -36
  107. package/dist/maintenance/memory-governance.js +4 -3
  108. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
  109. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -3
  110. package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
  111. package/dist/maintenance/rebuild-memory-projection.js +5 -4
  112. package/dist/{maintenance-DVBLXkFw.d.ts → maintenance-_RV38GlE.d.ts} +1 -1
  113. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  114. package/dist/memory-worth-outcomes.d.ts +1 -1
  115. package/dist/namespaces/migrate.d.ts +1 -1
  116. package/dist/namespaces/migrate.js +5 -4
  117. package/dist/namespaces/storage.d.ts +1 -1
  118. package/dist/namespaces/storage.js +4 -3
  119. package/dist/operator-doctor-corpus.js +7 -6
  120. package/dist/operator-toolkit.d.ts +1 -1
  121. package/dist/operator-toolkit.js +11 -10
  122. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  123. package/dist/orchestration/maintenance.d.ts +2 -2
  124. package/dist/orchestration/maintenance.js +9 -8
  125. package/dist/{orchestrator-DUxbhhqO.d.ts → orchestrator-B2ecFpRO.d.ts} +3 -3
  126. package/dist/orchestrator.d.ts +4 -4
  127. package/dist/orchestrator.js +37 -36
  128. package/dist/reconcile/manifest.d.ts +33 -0
  129. package/dist/reconcile/manifest.js +248 -0
  130. package/dist/reconcile/manifest.js.map +1 -0
  131. package/dist/reconcile/plan.d.ts +1 -1
  132. package/dist/reconcile/plan.js +8 -404
  133. package/dist/reconcile/plan.js.map +1 -1
  134. package/dist/schemas.d.ts +76 -76
  135. package/dist/semantic-consolidation.js +5 -4
  136. package/dist/semantic-rule-promotion.js +4 -3
  137. package/dist/semantic-rule-verifier.js +4 -3
  138. package/dist/{service-CLuMO-pa.d.ts → service-DW06sY3P.d.ts} +1 -1
  139. package/dist/shared-context/manager.d.ts +8 -8
  140. package/dist/source-agent-qualifier.js +4 -3
  141. package/dist/{storage-Blr71GpS.d.ts → storage-qRIQPA6F.d.ts} +12 -0
  142. package/dist/storage.d.ts +1 -1
  143. package/dist/storage.js +3 -2
  144. package/dist/temporal-supersession.d.ts +1 -1
  145. package/dist/tier-migration.d.ts +1 -1
  146. package/dist/transfer/autodetect.js +1 -1
  147. package/dist/transfer/backup.js +1 -1
  148. package/dist/transfer/capsule-export.js +2 -2
  149. package/dist/transfer/types.d.ts +66 -66
  150. package/dist/verified-recall.js +4 -3
  151. package/package.json +12 -2
  152. package/src/reconcile/manifest.test.ts +261 -0
  153. package/src/reconcile/manifest.ts +278 -0
  154. package/src/reconcile/plan.ts +1 -0
  155. package/src/storage/content-hash-index.ts +26 -0
  156. package/dist/chunk-2FTRRQKZ.js.map +0 -1
  157. /package/dist/{chunk-PJUHQC4T.js.map → chunk-3HLCRYBV.js.map} +0 -0
  158. /package/dist/{chunk-N7LPS4Z3.js.map → chunk-5SM35XZR.js.map} +0 -0
  159. /package/dist/{chunk-E6JYVPBK.js.map → chunk-5YGO5VEH.js.map} +0 -0
  160. /package/dist/{chunk-EUVPB5TA.js.map → chunk-65V4K4MT.js.map} +0 -0
  161. /package/dist/{chunk-WX6KOVVK.js.map → chunk-6OLQJJYE.js.map} +0 -0
  162. /package/dist/{chunk-G24HJJ67.js.map → chunk-7JO6RVBI.js.map} +0 -0
  163. /package/dist/{chunk-WUDWRUGK.js.map → chunk-7PVFOFPJ.js.map} +0 -0
  164. /package/dist/{chunk-UUQLB2HJ.js.map → chunk-C7EI7CA6.js.map} +0 -0
  165. /package/dist/{chunk-MUKLQDJP.js.map → chunk-FLBPATMZ.js.map} +0 -0
  166. /package/dist/{chunk-HGFILS2Q.js.map → chunk-FVGQ5LIS.js.map} +0 -0
  167. /package/dist/{chunk-OPVOUBHT.js.map → chunk-G24XIK6H.js.map} +0 -0
  168. /package/dist/{chunk-CH2WTP6R.js.map → chunk-GBJJUPNV.js.map} +0 -0
  169. /package/dist/{chunk-HXTUA7QM.js.map → chunk-GOPWZYZH.js.map} +0 -0
  170. /package/dist/{chunk-WMHO3WLC.js.map → chunk-GQBZ7EVF.js.map} +0 -0
  171. /package/dist/{chunk-KQAFEZQX.js.map → chunk-IPLYGWQF.js.map} +0 -0
  172. /package/dist/{chunk-OZL7KB6B.js.map → chunk-J4OKD6QX.js.map} +0 -0
  173. /package/dist/{chunk-TRBIJS2T.js.map → chunk-JZ7SSAQ5.js.map} +0 -0
  174. /package/dist/{chunk-YUOKZ3LJ.js.map → chunk-K6OTW3TP.js.map} +0 -0
  175. /package/dist/{chunk-I22U5YYD.js.map → chunk-QQ3NDJOM.js.map} +0 -0
  176. /package/dist/{chunk-7PLHD7EO.js.map → chunk-SS7YWVPF.js.map} +0 -0
  177. /package/dist/{chunk-J43XN7N3.js.map → chunk-U5G66ORD.js.map} +0 -0
  178. /package/dist/{chunk-EMC5GT4H.js.map → chunk-VCGPO2VD.js.map} +0 -0
  179. /package/dist/{chunk-JDDTNK5P.js.map → chunk-VRYJSKOM.js.map} +0 -0
  180. /package/dist/{chunk-PLQCPMJA.js.map → chunk-WC4SPYHX.js.map} +0 -0
  181. /package/dist/{chunk-IADUAH35.js.map → chunk-WG2PI44B.js.map} +0 -0
  182. /package/dist/{chunk-35IC3EVP.js.map → chunk-XFQRFWVS.js.map} +0 -0
  183. /package/dist/{chunk-MYFFOPO2.js.map → chunk-XMZV27S5.js.map} +0 -0
  184. /package/dist/{chunk-IXFI6E3Q.js.map → chunk-YQQK7W7T.js.map} +0 -0
  185. /package/dist/{chunk-3F2LKUMU.js.map → chunk-ZAB5PG65.js.map} +0 -0
  186. /package/dist/{chunk-O4BADU6R.js.map → chunk-ZPJJE7MF.js.map} +0 -0
  187. /package/dist/{chunk-U5SX74DV.js.map → chunk-ZS5UZU5M.js.map} +0 -0
@@ -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
+ });
@@ -0,0 +1,278 @@
1
+ import { createHash } from "node:crypto";
2
+ import { inferMemoryStatus } from "../memory-lifecycle-ledger-utils.js";
3
+ import { ContentHashIndex, type ContentHashPathEntry } from "../storage/content-hash-index.js";
4
+ import type { MemoryFrontmatter, MemoryStatus } from "../types.js";
5
+ import { RECALL_FALLBACK_DIRS } from "../utils/category-dir.js";
6
+ import {
7
+ type ReconcileFileState,
8
+ type ReconcilePlan,
9
+ type ReconcilePlanEntry,
10
+ summarizeReconcilePlan,
11
+ } from "./plan.js";
12
+
13
+ export const RECONCILE_MANIFEST_FORMAT = "remnic-reconcile-manifest";
14
+ export const RECONCILE_MANIFEST_SCHEMA_VERSION = 1;
15
+
16
+ export interface ReconcileMemoryIdentity {
17
+ id: string;
18
+ category: string;
19
+ contentHash: string;
20
+ status: MemoryStatus;
21
+ }
22
+
23
+ export interface ReconcileManifestFile extends ReconcileFileState {
24
+ memory?: ReconcileMemoryIdentity;
25
+ }
26
+
27
+ type ActiveFactManifestFile = ReconcileManifestFile & { memory: ReconcileMemoryIdentity };
28
+
29
+ export interface ReconcileManifest {
30
+ format: typeof RECONCILE_MANIFEST_FORMAT;
31
+ schemaVersion: typeof RECONCILE_MANIFEST_SCHEMA_VERSION;
32
+ files: ReconcileManifestFile[];
33
+ }
34
+
35
+ export interface BuildReconcileManifestOptions {
36
+ files: Iterable<ReconcileFileState>;
37
+ readFile: (file: ReconcileFileState) => Promise<Buffer | string | null>;
38
+ cachedFiles?: Iterable<ReconcileManifestFile>;
39
+ }
40
+
41
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/i;
42
+ const MEMORY_DIRS = new Set(RECALL_FALLBACK_DIRS);
43
+
44
+ function isMemoryPath(filePath: string): boolean {
45
+ if (!filePath.endsWith(".md")) return false;
46
+ const segments = filePath.split("/");
47
+ let index = 0;
48
+ if (segments[index] === "cold" || segments[index] === "archive") index += 1;
49
+ return MEMORY_DIRS.has(segments[index] ?? "");
50
+ }
51
+
52
+ function parseScalar(value: string | undefined): string | undefined {
53
+ if (value === undefined) return undefined;
54
+ const trimmed = value.trim();
55
+ if (trimmed.length === 0) return undefined;
56
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
57
+ try {
58
+ const parsed = JSON.parse(trimmed) as unknown;
59
+ return typeof parsed === "string" ? parsed : trimmed;
60
+ } catch {
61
+ return trimmed.slice(1, -1).replace(/\\"/g, '"');
62
+ }
63
+ }
64
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
65
+ return trimmed.slice(1, -1).replace(/''/g, "'");
66
+ }
67
+ return trimmed;
68
+ }
69
+
70
+ function parsedMemoryIdentity(filePath: string, raw: Buffer | string): ReconcileMemoryIdentity | undefined {
71
+ if (!isMemoryPath(filePath)) return undefined;
72
+ const match = (Buffer.isBuffer(raw) ? raw.toString("utf8") : raw).match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
73
+ if (!match) return undefined;
74
+ const fields = new Map<string, string>();
75
+ for (const line of match[1].split("\n")) {
76
+ const separator = line.indexOf(":");
77
+ if (separator > 0) fields.set(line.slice(0, separator).trim(), line.slice(separator + 1).trim());
78
+ }
79
+ const id = parseScalar(fields.get("id"));
80
+ if (!id) return undefined;
81
+ const category = parseScalar(fields.get("category")) ?? "fact";
82
+ const storedHash = parseScalar(fields.get("contentHash"));
83
+ const contentHash =
84
+ storedHash && SHA256_PATTERN.test(storedHash)
85
+ ? storedHash.toLowerCase()
86
+ : ContentHashIndex.computeHash(match[2].trim());
87
+ const status = inferMemoryStatus(
88
+ {
89
+ status: parseScalar(fields.get("status")) as MemoryStatus | undefined,
90
+ archivedAt: parseScalar(fields.get("archivedAt")),
91
+ } as MemoryFrontmatter,
92
+ filePath
93
+ );
94
+ return { id, category, contentHash, status };
95
+ }
96
+
97
+ export async function buildReconcileManifest(options: BuildReconcileManifestOptions): Promise<ReconcileManifest> {
98
+ const cachedByPath = new Map<string, ReconcileManifestFile>();
99
+ for (const cached of options.cachedFiles ?? []) {
100
+ cachedByPath.set(cached.path, cached);
101
+ }
102
+
103
+ const files: ReconcileManifestFile[] = [];
104
+ for (const file of options.files) {
105
+ const cached = cachedByPath.get(file.path);
106
+ if (cached?.sha256.toLowerCase() === file.sha256.toLowerCase()) {
107
+ files.push({ ...file, ...(cached.memory ? { memory: cached.memory } : {}) });
108
+ continue;
109
+ }
110
+
111
+ let raw: Buffer | string | null = null;
112
+ if (isMemoryPath(file.path)) {
113
+ try {
114
+ raw = await options.readFile(file);
115
+ } catch {
116
+ raw = null;
117
+ }
118
+ }
119
+ if (raw !== null && createHash("sha256").update(raw).digest("hex") !== file.sha256.toLowerCase()) {
120
+ raw = null;
121
+ }
122
+ const memory = raw === null ? undefined : parsedMemoryIdentity(file.path, raw);
123
+ files.push({ ...file, ...(memory ? { memory } : {}) });
124
+ }
125
+ files.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
126
+ return {
127
+ format: RECONCILE_MANIFEST_FORMAT,
128
+ schemaVersion: RECONCILE_MANIFEST_SCHEMA_VERSION,
129
+ files,
130
+ };
131
+ }
132
+
133
+ function activeFactByPath(manifest: ReconcileManifest | undefined): Map<string, ActiveFactManifestFile> {
134
+ const result = new Map<string, ActiveFactManifestFile>();
135
+ for (const file of manifest?.files ?? []) {
136
+ if (file.memory?.category === "fact" && file.memory.status === "active") {
137
+ result.set(file.path, file as ActiveFactManifestFile);
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+
143
+ function contentHashRows(files: Iterable<ActiveFactManifestFile>): ContentHashPathEntry[] {
144
+ const rows: ContentHashPathEntry[] = [];
145
+ for (const file of files) {
146
+ rows.push({ path: file.path, contentHash: file.memory.contentHash });
147
+ }
148
+ return rows;
149
+ }
150
+
151
+ function comparePlanEntries(left: ReconcilePlanEntry, right: ReconcilePlanEntry): number {
152
+ if (left.namespace !== right.namespace) return left.namespace < right.namespace ? -1 : 1;
153
+ return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
154
+ }
155
+
156
+ export function collapseActiveFactDuplicates(
157
+ plan: ReconcilePlan,
158
+ localManifests: ReadonlyMap<string, ReconcileManifest>,
159
+ peerManifests: ReadonlyMap<string, ReconcileManifest>
160
+ ): ReconcilePlan {
161
+ const entriesByNamespace = new Map<string, ReconcilePlanEntry[]>();
162
+ for (const entry of plan.entries) {
163
+ const entries = entriesByNamespace.get(entry.namespace) ?? [];
164
+ entries.push({ ...entry });
165
+ entriesByNamespace.set(entry.namespace, entries);
166
+ }
167
+
168
+ let changed = false;
169
+ for (const namespace of new Set([...localManifests.keys(), ...peerManifests.keys()])) {
170
+ const entries = entriesByNamespace.get(namespace) ?? [];
171
+ const localManifest = localManifests.get(namespace);
172
+ const peerManifest = peerManifests.get(namespace);
173
+ const localByPath = activeFactByPath(localManifest);
174
+ const peerByPath = activeFactByPath(peerManifest);
175
+ const localFilesByPath = new Map((localManifest?.files ?? []).map((file) => [file.path, file]));
176
+ const peerFilesByPath = new Map((peerManifest?.files ?? []).map((file) => [file.path, file]));
177
+ const localByHash = new Map<string, ActiveFactManifestFile[]>();
178
+ const peerByHash = new Map<string, ActiveFactManifestFile[]>();
179
+
180
+ for (const file of localByPath.values()) {
181
+ const hash = file.memory.contentHash;
182
+ const bucket = localByHash.get(hash) ?? [];
183
+ bucket.push(file);
184
+ localByHash.set(hash, bucket);
185
+ }
186
+ for (const file of peerByPath.values()) {
187
+ const hash = file.memory.contentHash;
188
+ const bucket = peerByHash.get(hash) ?? [];
189
+ bucket.push(file);
190
+ peerByHash.set(hash, bucket);
191
+ }
192
+
193
+ const removed = new Set<ReconcilePlanEntry>();
194
+ const replacements: ReconcilePlanEntry[] = [];
195
+ for (const hash of new Set([...localByHash.keys(), ...peerByHash.keys()])) {
196
+ const localCandidates = (localByHash.get(hash) ?? []).filter((file) => {
197
+ const opposite = peerFilesByPath.get(file.path);
198
+ const activeOpposite = peerByPath.get(file.path);
199
+ return opposite === undefined || activeOpposite?.memory.contentHash === hash;
200
+ });
201
+ const peerCandidates = (peerByHash.get(hash) ?? []).filter((file) => {
202
+ const opposite = localFilesByPath.get(file.path);
203
+ const activeOpposite = localByPath.get(file.path);
204
+ return opposite === undefined || activeOpposite?.memory.contentHash === hash;
205
+ });
206
+ const localPath = ContentHashIndex.resolvePathByHash(hash, contentHashRows(localCandidates));
207
+ const peerPath = ContentHashIndex.resolvePathByHash(hash, contentHashRows(peerCandidates));
208
+
209
+ if (localPath && peerPath && localPath !== peerPath) {
210
+ const localFile = localByPath.get(localPath);
211
+ const peerFile = peerByPath.get(peerPath);
212
+ if (!localFile || !peerFile) continue;
213
+ const duplicatePaths = new Set([
214
+ ...localCandidates.map((file) => file.path),
215
+ ...peerCandidates.map((file) => file.path),
216
+ ]);
217
+ const unsafeEntry = entries.some(
218
+ (entry) => duplicatePaths.has(entry.path) && (entry.action === "suppress" || entry.action === "conflict")
219
+ );
220
+ if (unsafeEntry) continue;
221
+ for (const entry of entries) {
222
+ if (
223
+ duplicatePaths.has(entry.path) &&
224
+ (entry.action === "pull" || entry.action === "push" || entry.action === "identical")
225
+ ) {
226
+ removed.add(entry);
227
+ }
228
+ }
229
+ replacements.push({
230
+ path: localPath < peerPath ? localPath : peerPath,
231
+ namespace,
232
+ action: "identical",
233
+ reason: "semantic_duplicate",
234
+ localSha256: localFile.sha256,
235
+ peerSha256: peerFile.sha256,
236
+ });
237
+ changed = true;
238
+ continue;
239
+ }
240
+
241
+ const sameSideEntries = entries.filter((entry) => {
242
+ if (localPath && entry.reason === "local_only") {
243
+ return localCandidates.some((file) => file.path === entry.path);
244
+ }
245
+ if (peerPath && entry.reason === "peer_only") {
246
+ return peerCandidates.some((file) => file.path === entry.path);
247
+ }
248
+ return false;
249
+ });
250
+ const canonicalPath = localPath ?? peerPath;
251
+ const hasSharedCanonical = localPath !== undefined && localPath === peerPath;
252
+ if (!canonicalPath || sameSideEntries.length === 0 || (!hasSharedCanonical && sameSideEntries.length < 2)) {
253
+ continue;
254
+ }
255
+ for (const entry of sameSideEntries) {
256
+ if (entry.path !== canonicalPath) {
257
+ removed.add(entry);
258
+ changed = true;
259
+ }
260
+ }
261
+ }
262
+
263
+ if (removed.size > 0 || replacements.length > 0) {
264
+ entriesByNamespace.set(
265
+ namespace,
266
+ [...entries.filter((entry) => !removed.has(entry)), ...replacements].sort(comparePlanEntries)
267
+ );
268
+ }
269
+ }
270
+
271
+ if (!changed) return plan;
272
+ const entries = [...entriesByNamespace.values()].flat().sort(comparePlanEntries);
273
+ return {
274
+ entries,
275
+ byNamespace: summarizeReconcilePlan(entries),
276
+ converged: entries.every((entry) => entry.action === "identical"),
277
+ };
278
+ }
@@ -79,6 +79,7 @@ export type ReconcileReason =
79
79
  | "peer_changed"
80
80
  | "local_changed"
81
81
  | "same_content"
82
+ | "semantic_duplicate"
82
83
  | "both_modified"
83
84
  | "peer_deleted"
84
85
  | "local_deleted"
@@ -84,6 +84,11 @@ type DiskFingerprint = {
84
84
  ctimeMs: number;
85
85
  birthtimeMs: number;
86
86
  };
87
+ export interface ContentHashPathEntry {
88
+ readonly path: string;
89
+ readonly contentHash: string;
90
+ }
91
+
87
92
  /**
88
93
  * Content-hash dedup index for facts.
89
94
  * Normalizes content (lowercase, strip punctuation, collapse whitespace),
@@ -669,6 +674,27 @@ export class ContentHashIndex {
669
674
  this.dirty = true;
670
675
  }
671
676
 
677
+ /**
678
+ * Resolve a pre-computed semantic hash to one deterministic corpus path.
679
+ *
680
+ * This lookup is separate from durable hash membership: `this.hashes` stays
681
+ * one hash per line and `has(content)` keeps its existing raw-content contract.
682
+ * Reconciliation supplies short-lived manifest rows where hash and path coexist.
683
+ */
684
+ static resolvePathByHash(
685
+ hash: string,
686
+ entries: Iterable<ContentHashPathEntry>,
687
+ ): string | undefined {
688
+ const canonicalHash = hash.toLowerCase();
689
+ if (!/^[a-f0-9]{64}$/.test(canonicalHash)) return undefined;
690
+ let canonicalPath: string | undefined;
691
+ for (const entry of entries) {
692
+ if (entry.contentHash.toLowerCase() !== canonicalHash || entry.path.length === 0) continue;
693
+ if (canonicalPath === undefined || entry.path < canonicalPath) canonicalPath = entry.path;
694
+ }
695
+ return canonicalPath;
696
+ }
697
+
672
698
  /** Normalize content (delegates to content-hash.ts for a single source of truth). */
673
699
  static normalizeContent(content: string): string {
674
700
  return normalizeContent(content);