@remnic/core 9.52.1 → 9.53.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 (253) hide show
  1. package/dist/access-admin-ops-surface.d.ts +5 -5
  2. package/dist/access-admin-ops-surface.js +22 -22
  3. package/dist/access-authorization-probe.d.ts +5 -5
  4. package/dist/access-authorization-probe.js +23 -23
  5. package/dist/access-boundary.d.ts +5 -5
  6. package/dist/access-boundary.js +22 -22
  7. package/dist/access-cli.js +54 -54
  8. package/dist/access-extraction-force-flush.d.ts +5 -5
  9. package/dist/access-extraction-force-flush.js +22 -22
  10. package/dist/access-http-lcm-compaction.d.ts +5 -5
  11. package/dist/access-http-lifecycle-flush.d.ts +5 -5
  12. package/dist/access-http-offline-stream.d.ts +5 -5
  13. package/dist/access-http-query.js +22 -22
  14. package/dist/access-http.d.ts +5 -5
  15. package/dist/access-http.js +29 -29
  16. package/dist/access-identity-continuity-surface.d.ts +4 -4
  17. package/dist/access-identity-continuity-surface.js +22 -22
  18. package/dist/access-lcm-surface.d.ts +5 -5
  19. package/dist/access-lcm-surface.js +22 -22
  20. package/dist/access-mcp.d.ts +5 -5
  21. package/dist/access-mcp.js +27 -27
  22. package/dist/access-namespace-preflight.js +22 -22
  23. package/dist/access-observe-write-surface.d.ts +5 -5
  24. package/dist/access-observe-write-surface.js +22 -22
  25. package/dist/access-offline-manifest.d.ts +4 -4
  26. package/dist/access-offline-manifest.js +8 -4
  27. package/dist/access-operations-batch.js +24 -24
  28. package/dist/access-operations.d.ts +5 -5
  29. package/dist/access-operations.js +26 -26
  30. package/dist/access-recall-concurrency.d.ts +5 -5
  31. package/dist/access-recall-concurrency.js +22 -22
  32. package/dist/access-recall-response.d.ts +5 -5
  33. package/dist/access-recall-response.js +22 -22
  34. package/dist/access-recall-surface.d.ts +5 -5
  35. package/dist/access-recall-surface.js +22 -22
  36. package/dist/{access-service-Cbk3bNbD.d.ts → access-service-CgCjvbQu.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 +22 -22
  40. package/dist/access-surface-catalog.d.ts +5 -5
  41. package/dist/access-wearables-meetings-surface.d.ts +2 -2
  42. package/dist/active-recall.js +4 -4
  43. package/dist/{auto-sync-QFQGOEO2.js → auto-sync-JXEW7444.js} +6 -6
  44. package/dist/bootstrap.d.ts +4 -4
  45. package/dist/briefing.d.ts +1 -1
  46. package/dist/briefing.js +12 -12
  47. package/dist/buffer.d.ts +1 -1
  48. package/dist/causal-consolidation.js +13 -13
  49. package/dist/{chunk-MLXZD4WM.js → chunk-2GFK22OZ.js} +2 -2
  50. package/dist/{chunk-D5YOZ66N.js → chunk-3DWTDC54.js} +20 -20
  51. package/dist/{chunk-RANKJI5T.js → chunk-3JLPGJ43.js} +2 -2
  52. package/dist/chunk-5FVVY2EB.js +27 -0
  53. package/dist/chunk-5FVVY2EB.js.map +1 -0
  54. package/dist/{chunk-GXSOUIA4.js → chunk-5WQMORU5.js} +4 -4
  55. package/dist/{chunk-J272WKX2.js → chunk-6HPHEFXT.js} +2 -2
  56. package/dist/{chunk-B6LZ4IBH.js → chunk-6LAPLZOS.js} +2 -2
  57. package/dist/{chunk-WD5B5AIE.js → chunk-6R4G7N4S.js} +118 -173
  58. package/dist/chunk-6R4G7N4S.js.map +1 -0
  59. package/dist/{chunk-VXCNQYTC.js → chunk-APPFNGX6.js} +2 -2
  60. package/dist/{chunk-RMHMUUBW.js → chunk-AY43LBE4.js} +104 -48
  61. package/dist/{chunk-RMHMUUBW.js.map → chunk-AY43LBE4.js.map} +1 -1
  62. package/dist/{chunk-GXFSQ6SD.js → chunk-C4ZNGVK3.js} +2 -2
  63. package/dist/{chunk-JSEJH2YQ.js → chunk-CGVHPCP2.js} +2 -2
  64. package/dist/{chunk-BQIFZ2MP.js → chunk-CX5SW2EW.js} +41 -24
  65. package/dist/chunk-CX5SW2EW.js.map +1 -0
  66. package/dist/{chunk-TEMSWHQD.js → chunk-DZD576ZU.js} +5 -5
  67. package/dist/{chunk-AMTEYNYK.js → chunk-EVWWT3KE.js} +4 -4
  68. package/dist/{chunk-XAPMEU3K.js → chunk-FHFERRYE.js} +2 -2
  69. package/dist/{chunk-P2KBDJJ6.js → chunk-G2LUMAUF.js} +4 -3
  70. package/dist/{chunk-P2KBDJJ6.js.map → chunk-G2LUMAUF.js.map} +1 -1
  71. package/dist/{chunk-AXIDQG5A.js → chunk-G4T7L557.js} +5 -5
  72. package/dist/{chunk-7M6NYQWB.js → chunk-G7Z6EENO.js} +4 -4
  73. package/dist/{chunk-YXKVLYXU.js → chunk-HWRD23DW.js} +2 -2
  74. package/dist/{chunk-SOEN6KCF.js → chunk-IBKR2TFR.js} +2 -2
  75. package/dist/{chunk-R3DJVOSM.js → chunk-JDSVLVCG.js} +8 -1
  76. package/dist/chunk-JDSVLVCG.js.map +1 -0
  77. package/dist/{chunk-F4EU6VUC.js → chunk-JDZPNCMA.js} +2 -2
  78. package/dist/{chunk-DLG5V6ND.js → chunk-K4QS7LXU.js} +4 -7
  79. package/dist/chunk-K4QS7LXU.js.map +1 -0
  80. package/dist/{chunk-BAKXNQ3C.js → chunk-KRTY3ENI.js} +2 -2
  81. package/dist/{chunk-NK5RGJ65.js → chunk-L4DLLKPN.js} +2 -2
  82. package/dist/{chunk-VFSAWRMZ.js → chunk-LJS7KUPV.js} +2 -2
  83. package/dist/{chunk-JJRVPWFY.js → chunk-MLPTFODV.js} +166 -37
  84. package/dist/chunk-MLPTFODV.js.map +1 -0
  85. package/dist/{chunk-NOEMGE56.js → chunk-NH5IR2WM.js} +2 -2
  86. package/dist/{chunk-5V6IXHPI.js → chunk-NSVDUN76.js} +2 -2
  87. package/dist/{chunk-IARXXZU4.js → chunk-P7XI2AQ3.js} +2 -2
  88. package/dist/{chunk-W7Y5JDVB.js → chunk-PDDTI7IC.js} +4 -4
  89. package/dist/{chunk-QINJITDS.js → chunk-PX2LSHYM.js} +2 -2
  90. package/dist/{chunk-O5FFBO2M.js → chunk-QAVH6X2A.js} +2 -2
  91. package/dist/{chunk-HDIUYMSB.js → chunk-QWGE2PUH.js} +6 -6
  92. package/dist/{chunk-X57M2Q4I.js → chunk-RDJRFDJY.js} +3 -3
  93. package/dist/{chunk-RYLEDKQ2.js → chunk-RUMNY5DT.js} +3 -3
  94. package/dist/{chunk-BKIFTXHR.js → chunk-RZ4XGKOJ.js} +2 -2
  95. package/dist/{chunk-66SE3BNH.js → chunk-SUPEYLH2.js} +2 -2
  96. package/dist/{chunk-BF6SXNQJ.js → chunk-T2JHZWH6.js} +20 -20
  97. package/dist/{chunk-VHFXP27N.js → chunk-THPPCISA.js} +60 -23
  98. package/dist/chunk-THPPCISA.js.map +1 -0
  99. package/dist/{chunk-QPOHMO6M.js → chunk-UES4BHA2.js} +2 -2
  100. package/dist/{chunk-X7XEDOSL.js → chunk-VLL43QEQ.js} +8 -8
  101. package/dist/{chunk-QBSFOKY6.js → chunk-VOELBUS4.js} +4 -4
  102. package/dist/{chunk-2LY6GIIE.js → chunk-WDBX6VER.js} +2 -2
  103. package/dist/{chunk-BMEYMKWX.js → chunk-WM65LU67.js} +2 -2
  104. package/dist/{chunk-WLRD2TXF.js → chunk-WOPA3SGI.js} +4 -4
  105. package/dist/{chunk-5UZ6UCOZ.js → chunk-XUAMCLY4.js} +3 -3
  106. package/dist/{chunk-7HXZJAID.js → chunk-YGLY6QFV.js} +3 -3
  107. package/dist/{chunk-2OIQDTDC.js → chunk-ZPJ4QSGP.js} +2 -2
  108. package/dist/{chunk-3OY7KXHM.js → chunk-ZYLC7NFO.js} +3 -3
  109. package/dist/{cli-DMAFI0qt.d.ts → cli-D-HiEYhl.d.ts} +3 -3
  110. package/dist/cli.d.ts +6 -6
  111. package/dist/cli.js +41 -41
  112. package/dist/compounding/engine.d.ts +1 -1
  113. package/dist/compounding/engine.js +13 -13
  114. package/dist/config.js +4 -4
  115. package/dist/connectors/codex-materialize-runner.js +12 -12
  116. package/dist/connectors/index.js +12 -12
  117. package/dist/consolidation-provenance-check.d.ts +1 -1
  118. package/dist/consolidation-undo.d.ts +1 -1
  119. package/dist/content-hash.d.ts +12 -2
  120. package/dist/content-hash.js +9 -3
  121. package/dist/contradiction/index.d.ts +1 -1
  122. package/dist/contradiction/index.js +3 -3
  123. package/dist/convergence-refresh.d.ts +1 -1
  124. package/dist/convergence-refresh.js +14 -14
  125. package/dist/corpus-watermark.js +14 -14
  126. package/dist/entity-retrieval.d.ts +1 -1
  127. package/dist/entity-retrieval.js +12 -12
  128. package/dist/explicit-capture.d.ts +4 -4
  129. package/dist/explicit-capture.js +8 -8
  130. package/dist/external-wiki-access.d.ts +5 -5
  131. package/dist/external-wiki-access.js +23 -23
  132. package/dist/external-wiki-mcp-tools.d.ts +5 -5
  133. package/dist/extraction-normalization.js +2 -2
  134. package/dist/extraction-source-grounding.js +3 -3
  135. package/dist/extraction.js +16 -16
  136. package/dist/{forget-HDS4N6CG.js → forget-EHGGJZLX.js} +3 -2
  137. package/dist/{forget-HDS4N6CG.js.map → forget-EHGGJZLX.js.map} +1 -1
  138. package/dist/harmonic-construction.js +2 -2
  139. package/dist/harmonic-retrieval.js +4 -4
  140. package/dist/index.d.ts +12 -12
  141. package/dist/index.js +66 -66
  142. package/dist/maintenance/memory-governance.js +12 -12
  143. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
  144. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +12 -12
  145. package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
  146. package/dist/maintenance/rebuild-memory-projection.js +13 -13
  147. package/dist/{maintenance-kBII2qUI.d.ts → maintenance-CN_Ct-Hr.d.ts} +1 -1
  148. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  149. package/dist/memory-worth-outcomes.d.ts +1 -1
  150. package/dist/namespaces/migrate.d.ts +1 -1
  151. package/dist/namespaces/migrate.js +13 -13
  152. package/dist/namespaces/storage.d.ts +1 -1
  153. package/dist/namespaces/storage.js +12 -12
  154. package/dist/operator-doctor-corpus.js +15 -15
  155. package/dist/operator-toolkit.d.ts +1 -1
  156. package/dist/operator-toolkit.js +20 -20
  157. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  158. package/dist/orchestration/maintenance.d.ts +2 -2
  159. package/dist/orchestration/maintenance.js +17 -17
  160. package/dist/{orchestrator-DHy5LtTy.d.ts → orchestrator-B3Xe8qu0.d.ts} +3 -3
  161. package/dist/orchestrator.d.ts +4 -4
  162. package/dist/orchestrator.js +54 -54
  163. package/dist/provenance.js +2 -2
  164. package/dist/reconcile/manifest.d.ts +3 -1
  165. package/dist/reconcile/manifest.js +7 -3
  166. package/dist/{resolution-MDPNOEG6.js → resolution-MMOD5WC4.js} +4 -4
  167. package/dist/resume-bundles.js +5 -5
  168. package/dist/salvage-envelope.js +3 -3
  169. package/dist/schemas.d.ts +38 -38
  170. package/dist/semantic-consolidation.js +13 -13
  171. package/dist/semantic-rule-promotion.js +12 -12
  172. package/dist/semantic-rule-verifier.js +12 -12
  173. package/dist/{service-PT2_z7xm.d.ts → service-CQGEqL1g.d.ts} +1 -1
  174. package/dist/source-agent-qualifier.js +14 -14
  175. package/dist/{storage-BmVudhk4.d.ts → storage-DmkCjo0l.d.ts} +36 -32
  176. package/dist/storage.d.ts +1 -1
  177. package/dist/storage.js +14 -14
  178. package/dist/structured-attributes.d.ts +3 -1
  179. package/dist/structured-attributes.js +3 -1
  180. package/dist/tier-migration.d.ts +1 -1
  181. package/dist/transfer/types.d.ts +12 -12
  182. package/dist/verified-recall.js +12 -12
  183. package/dist/write-envelope.js +2 -2
  184. package/package.json +2 -2
  185. package/src/access-offline-manifest.ts +1 -0
  186. package/src/access-service-offline-file-content.test.ts +1 -0
  187. package/src/content-hash.ts +34 -1
  188. package/src/dedup/index.test.ts +56 -38
  189. package/src/dedup/index.ts +85 -38
  190. package/src/lifecycle/tombstones.test.ts +763 -16
  191. package/src/lifecycle/tombstones.ts +217 -50
  192. package/src/reconcile/manifest.test.ts +158 -1
  193. package/src/reconcile/manifest.ts +76 -22
  194. package/src/storage/content-hash-index.ts +56 -40
  195. package/src/storage/entity-ref-repair.ts +3 -3
  196. package/src/storage/memory-read-store.ts +9 -0
  197. package/src/storage/tombstone-blocked-capture-index.ts +4 -9
  198. package/src/storage-contract/content-hash.test.ts +13 -0
  199. package/src/storage-fact-hash-index-defer.test.ts +1 -1
  200. package/src/storage.ts +107 -93
  201. package/src/structured-attributes.ts +11 -0
  202. package/dist/chunk-BQIFZ2MP.js.map +0 -1
  203. package/dist/chunk-DLG5V6ND.js.map +0 -1
  204. package/dist/chunk-JJRVPWFY.js.map +0 -1
  205. package/dist/chunk-R3DJVOSM.js.map +0 -1
  206. package/dist/chunk-VHFXP27N.js.map +0 -1
  207. package/dist/chunk-W5H6ZRPT.js +0 -14
  208. package/dist/chunk-W5H6ZRPT.js.map +0 -1
  209. package/dist/chunk-WD5B5AIE.js.map +0 -1
  210. /package/dist/{auto-sync-QFQGOEO2.js.map → auto-sync-JXEW7444.js.map} +0 -0
  211. /package/dist/{chunk-MLXZD4WM.js.map → chunk-2GFK22OZ.js.map} +0 -0
  212. /package/dist/{chunk-D5YOZ66N.js.map → chunk-3DWTDC54.js.map} +0 -0
  213. /package/dist/{chunk-RANKJI5T.js.map → chunk-3JLPGJ43.js.map} +0 -0
  214. /package/dist/{chunk-GXSOUIA4.js.map → chunk-5WQMORU5.js.map} +0 -0
  215. /package/dist/{chunk-J272WKX2.js.map → chunk-6HPHEFXT.js.map} +0 -0
  216. /package/dist/{chunk-B6LZ4IBH.js.map → chunk-6LAPLZOS.js.map} +0 -0
  217. /package/dist/{chunk-VXCNQYTC.js.map → chunk-APPFNGX6.js.map} +0 -0
  218. /package/dist/{chunk-GXFSQ6SD.js.map → chunk-C4ZNGVK3.js.map} +0 -0
  219. /package/dist/{chunk-JSEJH2YQ.js.map → chunk-CGVHPCP2.js.map} +0 -0
  220. /package/dist/{chunk-TEMSWHQD.js.map → chunk-DZD576ZU.js.map} +0 -0
  221. /package/dist/{chunk-AMTEYNYK.js.map → chunk-EVWWT3KE.js.map} +0 -0
  222. /package/dist/{chunk-XAPMEU3K.js.map → chunk-FHFERRYE.js.map} +0 -0
  223. /package/dist/{chunk-AXIDQG5A.js.map → chunk-G4T7L557.js.map} +0 -0
  224. /package/dist/{chunk-7M6NYQWB.js.map → chunk-G7Z6EENO.js.map} +0 -0
  225. /package/dist/{chunk-YXKVLYXU.js.map → chunk-HWRD23DW.js.map} +0 -0
  226. /package/dist/{chunk-SOEN6KCF.js.map → chunk-IBKR2TFR.js.map} +0 -0
  227. /package/dist/{chunk-F4EU6VUC.js.map → chunk-JDZPNCMA.js.map} +0 -0
  228. /package/dist/{chunk-BAKXNQ3C.js.map → chunk-KRTY3ENI.js.map} +0 -0
  229. /package/dist/{chunk-NK5RGJ65.js.map → chunk-L4DLLKPN.js.map} +0 -0
  230. /package/dist/{chunk-VFSAWRMZ.js.map → chunk-LJS7KUPV.js.map} +0 -0
  231. /package/dist/{chunk-NOEMGE56.js.map → chunk-NH5IR2WM.js.map} +0 -0
  232. /package/dist/{chunk-5V6IXHPI.js.map → chunk-NSVDUN76.js.map} +0 -0
  233. /package/dist/{chunk-IARXXZU4.js.map → chunk-P7XI2AQ3.js.map} +0 -0
  234. /package/dist/{chunk-W7Y5JDVB.js.map → chunk-PDDTI7IC.js.map} +0 -0
  235. /package/dist/{chunk-QINJITDS.js.map → chunk-PX2LSHYM.js.map} +0 -0
  236. /package/dist/{chunk-O5FFBO2M.js.map → chunk-QAVH6X2A.js.map} +0 -0
  237. /package/dist/{chunk-HDIUYMSB.js.map → chunk-QWGE2PUH.js.map} +0 -0
  238. /package/dist/{chunk-X57M2Q4I.js.map → chunk-RDJRFDJY.js.map} +0 -0
  239. /package/dist/{chunk-RYLEDKQ2.js.map → chunk-RUMNY5DT.js.map} +0 -0
  240. /package/dist/{chunk-BKIFTXHR.js.map → chunk-RZ4XGKOJ.js.map} +0 -0
  241. /package/dist/{chunk-66SE3BNH.js.map → chunk-SUPEYLH2.js.map} +0 -0
  242. /package/dist/{chunk-BF6SXNQJ.js.map → chunk-T2JHZWH6.js.map} +0 -0
  243. /package/dist/{chunk-QPOHMO6M.js.map → chunk-UES4BHA2.js.map} +0 -0
  244. /package/dist/{chunk-X7XEDOSL.js.map → chunk-VLL43QEQ.js.map} +0 -0
  245. /package/dist/{chunk-QBSFOKY6.js.map → chunk-VOELBUS4.js.map} +0 -0
  246. /package/dist/{chunk-2LY6GIIE.js.map → chunk-WDBX6VER.js.map} +0 -0
  247. /package/dist/{chunk-BMEYMKWX.js.map → chunk-WM65LU67.js.map} +0 -0
  248. /package/dist/{chunk-WLRD2TXF.js.map → chunk-WOPA3SGI.js.map} +0 -0
  249. /package/dist/{chunk-5UZ6UCOZ.js.map → chunk-XUAMCLY4.js.map} +0 -0
  250. /package/dist/{chunk-7HXZJAID.js.map → chunk-YGLY6QFV.js.map} +0 -0
  251. /package/dist/{chunk-2OIQDTDC.js.map → chunk-ZPJ4QSGP.js.map} +0 -0
  252. /package/dist/{chunk-3OY7KXHM.js.map → chunk-ZYLC7NFO.js.map} +0 -0
  253. /package/dist/{resolution-MDPNOEG6.js.map → resolution-MMOD5WC4.js.map} +0 -0
package/src/storage.ts CHANGED
@@ -13,7 +13,11 @@ import {
13
13
  } from "node:fs/promises";
14
14
  import { appendFileSync, createReadStream, mkdirSync, readFileSync, statSync } from "node:fs";
15
15
  import { createHash } from "node:crypto";
16
- import { normalizeContent, computeContentHash } from "./content-hash.js";
16
+ import {
17
+ computeContentHash,
18
+ computeLegacyContentHash,
19
+ normalizeContent,
20
+ } from "./content-hash.js";
17
21
  import { raceAbort } from "./abort-error.js";
18
22
  import { checkCorpusReadAbort, type CorpusReadOptions } from "./corpus-read-cancellation.js";
19
23
  import { selectArtifactMatches, type ArtifactSearchOptions } from "./artifact-search.js";
@@ -115,7 +119,6 @@ import {
115
119
  sortStructuredSectionsBySchema,
116
120
  } from "./entity-schema.js";
117
121
  import {
118
- hasCitation,
119
122
  hasCitationForTemplate,
120
123
  stripCitationForTemplate,
121
124
  DEFAULT_CITATION_FORMAT,
@@ -1058,7 +1061,7 @@ const FACT_HASH_INDEX_REBUILD_RETRY_BASE_MS = 50;
1058
1061
  // the coding surfaces + wearable service). Imported here so internal callers
1059
1062
  // (snapshotBeforeWrite, snapshotForProvenance) resolve, and re-exported to
1060
1063
  // keep the public storage API stable for existing callers (wearables, dist).
1061
- import { assemblePersistedBody, stripAttributesSuffix } from "./structured-attributes.js";
1064
+ import { assemblePersistedBody, storedContentIdentityCandidates, stripAttributesSuffix } from "./structured-attributes.js";
1062
1065
  export { stripAttributesSuffix };
1063
1066
 
1064
1067
  // `normalizeAttributePairs` moved to ./structured-attributes.ts (issue #1989
@@ -3009,22 +3012,51 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3009
3012
  }
3010
3013
 
3011
3014
  private buildTombstoneStore(): TombstoneStore {
3015
+ let sourcePathsPromise: Promise<string[]> | undefined;
3016
+ let sourceContentsByIdPromise: Promise<Map<string, readonly string[]>> | undefined;
3012
3017
  const options: TombstoneStoreOptions = {
3013
3018
  enabled: this.tombstonesConfig.enabled,
3014
3019
  semanticMatch: this.tombstonesConfig.semanticMatch,
3015
3020
  semanticThreshold: this.tombstonesConfig.semanticThreshold,
3016
- // Wire the SAME helpers the dedup index uses (rule 23 / checklist §13):
3017
- // hashContent = ContentHashIndex.computeHash, normalizeText =
3018
- // ContentHashIndex.normalizeContent. Importing them here (not copying)
3019
- // guarantees the tombstone tiers can never drift from dedup.
3020
3021
  hashContent: ContentHashIndex.computeHash,
3021
3022
  normalizeText: ContentHashIndex.normalizeContent,
3023
+ sourceContentsForMemoryIds: async (sourceMemoryIds) => {
3024
+ const requested = new Set(sourceMemoryIds);
3025
+ const sourcePaths = await (sourcePathsPromise ??=
3026
+ this.memoryReadStore.collectTombstoneMigrationPaths());
3027
+ const directPaths = sourcePaths.filter((filePath) =>
3028
+ requested.has(path.basename(filePath, ".md"))
3029
+ );
3030
+ const contents = new Map<string, readonly string[]>();
3031
+ for (const memory of await this.readParsedMemoriesFromPaths(directPaths, 50)) {
3032
+ const id = memory.frontmatter.id;
3033
+ if (requested.has(id) && !contents.has(id)) {
3034
+ contents.set(id, this.storedContentIdentityCandidates(memory.content));
3035
+ }
3036
+ }
3037
+ if (contents.size === requested.size) return contents;
3038
+ sourceContentsByIdPromise ??= (async () => {
3039
+ const allContents = new Map<string, readonly string[]>();
3040
+ for (const memory of await this.readParsedMemoriesFromPaths(sourcePaths, 50)) {
3041
+ const id = memory.frontmatter.id;
3042
+ if (!allContents.has(id)) {
3043
+ allContents.set(id, this.storedContentIdentityCandidates(memory.content));
3044
+ }
3045
+ }
3046
+ return allContents;
3047
+ })();
3048
+ const allContents = await sourceContentsByIdPromise;
3049
+ for (const id of requested) {
3050
+ const content = allContents.get(id);
3051
+ if (content !== undefined) contents.set(id, content);
3052
+ }
3053
+ return contents;
3054
+ },
3022
3055
  };
3023
3056
  const io: TombstoneFileIo = {
3024
3057
  read: (filePath) => this.readStorageSecureFile(filePath),
3025
3058
  append: (filePath, content) => this.appendStorageSecureFile(filePath, content),
3026
3059
  write: (filePath, content) => this.writeStorageSecureFile(filePath, content),
3027
- // stat lets the store own its cross-process staleness probe (#1579).
3028
3060
  stat: (filePath) => statSync(filePath),
3029
3061
  };
3030
3062
  return new TombstoneStore(this.tombstonesPath, this.tombstonesConfig.namespace, options, io);
@@ -3263,13 +3295,14 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3263
3295
  }
3264
3296
  this.factHashIndexAuthoritative = false;
3265
3297
  this.factHashIndexAuthoritativePromise = (async () => {
3266
- // Round 11: ALWAYS rebuild the fact-hash index from the durable corpus on
3267
- // first use per process no on-disk "ready" marker is written or trusted,
3268
- // so a deferred write, crash, or multi-process interleave can never leave a
3269
- // stale index trusted. The scan AND publish run under the SAME per-index
3270
- // cross-process lock the reconciling saves use (rebuildUnderLock), so the
3271
- // rebuild can never overwrite a peer's newer lock-merged or deferred
3272
- // additions with an unlocked overwrite.
3298
+ // ALWAYS rebuild the index from the durable corpus on first use per
3299
+ // process, preserving the #2016 crash/restart invariant. Markerless
3300
+ // legacy indexes additionally require body-derived hashes because their
3301
+ // frontmatter.contentHash values may use the old normalizer.
3302
+ //
3303
+ // The scan and publish run under the SAME per-index cross-process lock
3304
+ // used by reconcile saves, so migration cannot overwrite a peer's newer
3305
+ // additions with an unlocked write.
3273
3306
  //
3274
3307
  // PR #2016 (findings 1-2): bounded-retry the LOCKED rebuild so transient
3275
3308
  // contention (a peer mid reconcile-save) clears within a short budget
@@ -3284,7 +3317,9 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3284
3317
  const baseMs = this.factHashIndexLockOptions.retryBaseMs ?? FACT_HASH_INDEX_REBUILD_RETRY_BASE_MS;
3285
3318
  let published = false;
3286
3319
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
3287
- published = await factHashIndex.rebuildUnderLock(() => this.rebuildFactHashIndexFromCorpus(factHashIndex));
3320
+ published = await factHashIndex.rebuildUnderLock(() =>
3321
+ this.rebuildFactHashIndexFromCorpus(factHashIndex)
3322
+ );
3288
3323
  if (published || attempt === maxAttempts - 1) break;
3289
3324
  const wait = Math.min(baseMs * 2 ** attempt, CONTENT_HASH_INDEX_RETRY_MAX_DELAY_MS);
3290
3325
  await new Promise<void>((resolve) => setTimeout(resolve, wait));
@@ -3346,17 +3381,16 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3346
3381
  // citation-strip reconstruction below, hashing the stored persist body
3347
3382
  // (title + steps) exactly as buildProcedurePersistBody registered it.
3348
3383
  if (inferMemoryStatus(memory.frontmatter, memory.path) !== "active") continue;
3349
- const hash = this.corpusRegisteredHash(memory);
3350
- if (hash === null) {
3351
- // Body carries a citation from an unknown/custom template we cannot
3352
- // safely strip — skip rather than register a wrong hash. A
3353
- // false-negative miss beats a wrong entry that would permanently
3354
- // suppress legitimate duplicate writes (see corpusRegisteredHash).
3384
+ const hashes = this.corpusRegisteredHashes(memory);
3385
+ if (hashes.length === 0) {
3386
+ // Skip bodies whose configured citation cannot be stripped safely.
3355
3387
  legacyRecovered++;
3356
3388
  continue;
3357
3389
  }
3358
- factHashIndex.addByHash(hash);
3359
- if (memory.frontmatter.category === "fact") this.factOnlyHashes.add(hash);
3390
+ for (const hash of hashes) {
3391
+ factHashIndex.addByHash(hash);
3392
+ if (memory.frontmatter.category === "fact") this.factOnlyHashes.add(hash);
3393
+ }
3360
3394
  }
3361
3395
  if (legacyRecovered > 0) {
3362
3396
  log.info(
@@ -3364,39 +3398,28 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3364
3398
  );
3365
3399
  }
3366
3400
  }
3367
- /**
3368
- * The content-hash the corpus rebuild registers for `memory`, or null when the
3369
- * body carries a citation from an unknown/custom template that cannot be
3370
- * safely stripped. Shared by the authoritative rebuild and the fact-only
3371
- * corpus confirmation (`hasFactContentHash`) so both derive the identical
3372
- * hash for a given stored body.
3373
- *
3374
- * Preference order:
3375
- * 1. frontmatter.contentHash — the raw pre-citation hash writeMemory records
3376
- * for facts (issue #369 round 8); matches hasFactContentHash(rawFact).
3377
- * 2. Reconstruct from the stored body: strip the "[Attributes: …]" suffix
3378
- * writeMemory appends for structuredAttributes (registration hashed the
3379
- * raw canonical content WITHOUT it — a no-op when absent), then strip a
3380
- * recognised citation and hash the bare body, or hash a citation-free
3381
- * body as-is.
3382
- * 3. A body with a citation from an unknown/custom template → null: a
3383
- * false-negative miss beats a wrong hash that would permanently suppress
3384
- * legitimate duplicate writes.
3385
- */
3386
- private corpusRegisteredHash(memory: MemoryFile): string | null {
3387
- if (memory.frontmatter.contentHash) {
3388
- return memory.frontmatter.contentHash;
3389
- }
3390
- const content = stripAttributesSuffix(memory.content);
3391
- const stripped = stripCitationForTemplate(content, this.citationTemplate);
3392
- if (stripped !== content) {
3393
- return ContentHashIndex.computeHash(sanitizeMemoryContent(stripped).text);
3394
- }
3395
- if (!hasCitation(content)) {
3396
- return ContentHashIndex.computeHash(sanitizeMemoryContent(content).text);
3397
- }
3398
- return null;
3401
+ private storedContentIdentityCandidates(content: string): string[] {
3402
+ return storedContentIdentityCandidates(content, (value) =>
3403
+ hasCitationForTemplate(value, this.citationTemplate) ? stripCitationForTemplate(value, this.citationTemplate) : value
3404
+ );
3405
+ }
3406
+
3407
+ /** Returns the current body identity while preserving explicit external identities. */
3408
+ private corpusRegisteredHashes(memory: MemoryFile): string[] {
3409
+ const persistedHash = memory.frontmatter.contentHash;
3410
+ const candidates = this.storedContentIdentityCandidates(memory.content)
3411
+ .map((content) => sanitizeMemoryContent(content).text);
3412
+ const currentHashes = candidates.map((content) => ContentHashIndex.computeHash(content));
3413
+ if (persistedHash && currentHashes.includes(persistedHash)) return [persistedHash];
3414
+ if (!persistedHash) return [currentHashes[0] ?? ContentHashIndex.computeHash("")];
3415
+ const legacyMatches = candidates.filter(
3416
+ (content) => computeLegacyContentHash(content) === persistedHash
3417
+ );
3418
+ return legacyMatches.length > 0
3419
+ ? [...new Set(legacyMatches.map((content) => ContentHashIndex.computeHash(content)))]
3420
+ : [persistedHash];
3399
3421
  }
3422
+
3400
3423
  private get questionsDir(): string {
3401
3424
  return path.join(this.baseDir, "questions");
3402
3425
  }
@@ -3787,10 +3810,7 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3787
3810
  let tombstoneBlocked = false;
3788
3811
  let gateRef: string | undefined;
3789
3812
  let statusBeforeBlock: MemoryFrontmatter["status"];
3790
- // Closure so the post-persist repair can RE-RUN it when a journal move
3791
- // changed the final entityRef: a verdict is only valid for the ref it was
3792
- // computed under, so reset and re-evaluate (parked mappings fall BACK to
3793
- // the legacy claimant; entity-independent tiers re-block in the lookup).
3813
+ // Re-run after journal moves that change the final entityRef.
3794
3814
  const applyTombstoneGate = async (): Promise<void> => {
3795
3815
  if (tombstoneBlocked) {
3796
3816
  if (fm.entityRef === gateRef) return;
@@ -3799,11 +3819,9 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3799
3819
  delete fm.blockedBy;
3800
3820
  delete fm.tombstoneBlockTier;
3801
3821
  }
3802
- // Status semantics (thread ObteQ: pending_review candidates included,
3803
- // terminal statuses respected) live in applyTombstoneResurrectionGate.
3804
3822
  if (category !== "fact" || factHashSourceForTombstone === null) return;
3805
3823
  const statusBefore = fm.status;
3806
- const match = await this.entityRefRepair.gate(fm, factHashSourceForTombstone, options.structuredAttributes);
3824
+ const match = await this.entityRefRepair.gate(fm, sanitized.text, options.structuredAttributes);
3807
3825
  if (match) {
3808
3826
  gateRef = fm.entityRef;
3809
3827
  statusBeforeBlock = statusBefore;
@@ -3980,34 +3998,28 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3980
3998
  for (const memory of existing) {
3981
3999
  if (memory.frontmatter.category !== "fact") continue;
3982
4000
  if (inferMemoryStatus(memory.frontmatter, memory.path) !== "active") continue;
3983
- if (this.corpusRegisteredHash(memory) === targetHash) return true;
4001
+ if (this.corpusRegisteredHashes(memory).includes(targetHash)) return true;
3984
4002
  }
3985
4003
  return false;
3986
4004
  }
3987
4005
 
3988
- private factContentHashForRemoval(memory: MemoryFile): string | null {
3989
- if (memory.frontmatter.category !== "fact") return null;
3990
- if (typeof memory.frontmatter.contentHash === "string" && memory.frontmatter.contentHash.length > 0) {
3991
- return memory.frontmatter.contentHash;
3992
- }
3993
- const configuredHashSource = stripCitationMarkersForHashRemoval(memory.content, this.citationTemplate);
3994
- const hashSource =
3995
- configuredHashSource !== memory.content
3996
- ? configuredHashSource
3997
- : stripDefaultCitationMarkersWithoutRegex(memory.content);
3998
- return ContentHashIndex.computeHash(sanitizeMemoryContent(hashSource).text);
4006
+ private factContentHashesForRemoval(memory: MemoryFile): string[] {
4007
+ if (memory.frontmatter.category !== "fact") return [];
4008
+ return this.corpusRegisteredHashes(memory);
3999
4009
  }
4000
4010
 
4001
4011
  private async addActiveFactContentHash(memory: MemoryFile): Promise<void> {
4002
4012
  if (memory.frontmatter.category !== "fact") return;
4003
4013
  if (inferMemoryStatus(memory.frontmatter, memory.path) !== "active") return;
4004
- const hash = this.factContentHashForRemoval(memory);
4005
- if (!hash) return;
4014
+ const hashes = this.factContentHashesForRemoval(memory);
4015
+ if (hashes.length === 0) return;
4006
4016
 
4007
4017
  await this.ensureFactHashIndexAuthoritative();
4008
4018
  const factHashIndex = await this.getFactHashIndex();
4009
- factHashIndex.addByHash(hash);
4010
- this.factOnlyHashes.add(hash);
4019
+ for (const hash of hashes) {
4020
+ factHashIndex.addByHash(hash);
4021
+ this.factOnlyHashes.add(hash);
4022
+ }
4011
4023
  // PR #2016 thread SDzOP: flush through the SAME cross-process locked
4012
4024
  // reconcile the write/batch/rebuild paths use, never the unlocked whole-file
4013
4025
  // save() — an unlocked overwrite drops a concurrent extraction's appended
@@ -4045,16 +4057,19 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
4045
4057
  private async syncFactHashIndexAfterRewrite(before: MemoryFile, after: MemoryFile): Promise<void> {
4046
4058
  if (before.frontmatter.category !== "fact" && after.frontmatter.category !== "fact") return;
4047
4059
 
4048
- const beforeHash = this.factContentHashForRemoval(before);
4049
- const afterHash = this.factContentHashForRemoval(after);
4060
+ const beforeHashes = this.factContentHashesForRemoval(before);
4061
+ const afterHashes = this.factContentHashesForRemoval(after);
4050
4062
  const beforeStatus = inferMemoryStatus(before.frontmatter, before.path);
4051
4063
  const afterStatus = inferMemoryStatus(after.frontmatter, after.path);
4052
- if (beforeHash === afterHash && beforeStatus === afterStatus) return;
4064
+ const hashesChanged =
4065
+ beforeHashes.length !== afterHashes.length ||
4066
+ beforeHashes.some((hash, index) => hash !== afterHashes[index]);
4067
+ if (!hashesChanged && beforeStatus === afterStatus) return;
4053
4068
 
4054
- if (beforeStatus === "active" && beforeHash && (beforeHash !== afterHash || afterStatus !== "active")) {
4069
+ if (beforeStatus === "active" && beforeHashes.length > 0 && (hashesChanged || afterStatus !== "active")) {
4055
4070
  await this.removeFactContentHashesForMemories([before]);
4056
4071
  }
4057
- if (afterStatus === "active" && afterHash && (beforeHash !== afterHash || beforeStatus !== "active")) {
4072
+ if (afterStatus === "active" && afterHashes.length > 0 && (hashesChanged || beforeStatus !== "active")) {
4058
4073
  await this.addActiveFactContentHash(after);
4059
4074
  }
4060
4075
  }
@@ -4067,11 +4082,10 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
4067
4082
  .map((memory) => memory.frontmatter.id)
4068
4083
  .filter((id): id is string => typeof id === "string" && id.length > 0)
4069
4084
  );
4070
- const removedHashes = new Map<MemoryFile, string>();
4085
+ const removedHashes = new Set<string>();
4071
4086
  for (const memory of memories) {
4072
- const hash = this.factContentHashForRemoval(memory);
4073
- if (hash) {
4074
- removedHashes.set(memory, hash);
4087
+ for (const hash of this.factContentHashesForRemoval(memory)) {
4088
+ removedHashes.add(hash);
4075
4089
  }
4076
4090
  }
4077
4091
  if (removedHashes.size === 0) return;
@@ -4082,13 +4096,12 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
4082
4096
  if (memory.frontmatter.category !== "fact") continue;
4083
4097
  if (removedIds.has(memory.frontmatter.id)) continue;
4084
4098
  if (inferMemoryStatus(memory.frontmatter, memory.path) !== "active") continue;
4085
- const hash = this.factContentHashForRemoval(memory);
4086
- if (hash) {
4099
+ for (const hash of this.factContentHashesForRemoval(memory)) {
4087
4100
  remainingActiveHashes.add(hash);
4088
4101
  }
4089
4102
  }
4090
4103
 
4091
- for (const hash of removedHashes.values()) {
4104
+ for (const hash of removedHashes) {
4092
4105
  if (!remainingActiveHashes.has(hash)) {
4093
4106
  factHashIndex.removeByHash(hash);
4094
4107
  this.factOnlyHashes.delete(hash);
@@ -4117,8 +4130,9 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
4117
4130
  */
4118
4131
  removeFactOnlyHashForMemory(memory: MemoryFile): void {
4119
4132
  if (memory.frontmatter.category !== "fact") return;
4120
- const hash = this.factContentHashForRemoval(memory);
4121
- if (hash) this.factOnlyHashes.delete(hash);
4133
+ for (const hash of this.factContentHashesForRemoval(memory)) {
4134
+ this.factOnlyHashes.delete(hash);
4135
+ }
4122
4136
  }
4123
4137
 
4124
4138
  async isFactContentHashAuthoritative(): Promise<boolean> {
@@ -39,6 +39,17 @@ export function stripAttributesSuffix(content: string): string {
39
39
  if (inner.includes("]") || inner.includes("\n")) return content.trim();
40
40
  return trimmed.slice(0, markerIndex).trim();
41
41
  }
42
+
43
+ /** Return stored-body identity forms, from raw semantic content to full body. */
44
+ export function storedContentIdentityCandidates(
45
+ content: string,
46
+ stripCitation: (value: string) => string,
47
+ ): string[] {
48
+ const fullBody = content.trim();
49
+ const bodyWithoutAttributes = stripAttributesSuffix(fullBody);
50
+ const rawBody = stripCitation(bodyWithoutAttributes);
51
+ return [...new Set([rawBody, bodyWithoutAttributes, fullBody])];
52
+ }
42
53
  // ---------------------------------------------------------------------------
43
54
  // Persisted-body assembly (issue #1989 PR2)
44
55
  // ---------------------------------------------------------------------------
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/storage/content-hash-index.ts"],"sourcesContent":["// Content-hash dedup index for facts, extracted from storage.ts (issue #1909 /\n// PR #2016 write-hot-paths). Self-contained: cross-process locked reconcile /\n// rebuild saves plus the deferred durable-retry machinery. storage.ts imports\n// these back and re-exports the class + companion types that were previously\n// part of its public API.\nimport { mkdir, stat } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { log } from \"../logger.js\";\nimport { isErrnoCode } from \"../utils/errno.js\";\nimport { withHeldFileLock } from \"../utils/serialize-mutations.js\";\nimport { normalizeContent, computeContentHash } from \"../content-hash.js\";\nimport {\n SecureStoreLockedError,\n readMaybeEncryptedFile,\n writeMaybeEncryptedFile,\n} from \"../secure-store/secure-fs.js\";\n\n/**\n * Stale threshold (ms) for the per-file advisory lock guarding a fact-hash\n * index merge-save (issue #1909 review round 7). The critical section is a\n * single read+atomic-rewrite of one index file (≤ a few MB), so a holder older\n * than this is treated as crashed. Generous to tolerate slow/encrypted disks\n * without breaking a legitimately in-progress publish.\n */\nconst CONTENT_HASH_INDEX_LOCK_STALE_MS = 30_000;\n\n/**\n * Durable-retry policy for a deferred content-hash reconcile save whose\n * cross-process lock acquire timed out (issue #1909 / PR #2016). When the lock\n * is contended past `withHeldFileLock`'s bounded wait, the added hashes are held\n * only in this process's memory; relying on \"some later batch save\" is not\n * durable (the run may be the last before idle/shutdown), and a long-lived peer\n * that already built its authoritative in-memory index will not see the write.\n * So we schedule a bounded, exponentially-backed-off background retry that keeps\n * re-attempting the locked publish until it lands. Bounded because the durable\n * fact `.md` is already on disk and a process restart rebuilds the index from\n * the corpus regardless — exhausting retries degrades to that rebuild safety\n * net, never to data loss or a fatal append failure.\n */\nconst CONTENT_HASH_INDEX_RETRY_MAX_ATTEMPTS = 5;\n/** Base backoff (ms) before the first deferred reconcile-save retry; doubles each attempt. */\nconst CONTENT_HASH_INDEX_RETRY_BASE_MS = 500;\n/** Ceiling (ms) for the exponential reconcile-save retry backoff. */\nexport const CONTENT_HASH_INDEX_RETRY_MAX_DELAY_MS = 5_000;\n\n/**\n * Tuning for {@link ContentHashIndex} cross-process lock waits and the\n * deferred reconcile-save durable retry (issue #1909 / PR #2016). All optional;\n * production uses the module defaults. Tests inject small values so the\n * lock-timeout + retry path is exercised deterministically without real\n * multi-second waits.\n */\nexport interface ContentHashIndexLockOptions {\n /** Bounded wait to acquire the per-file advisory lock; passed to `withHeldFileLock` (default 5000ms). */\n readonly maxWaitMs?: number;\n /** Poll interval while waiting for a busy lock (default 50ms). */\n readonly pollMs?: number;\n /** Base backoff before the first deferred-save retry (default 500ms). */\n readonly retryBaseMs?: number;\n /** Max deferred-save retries before falling back to the corpus-rebuild safety net (default 5). */\n readonly retryMaxAttempts?: number;\n}\n\n/**\n * Thrown when the fact-hash index cannot be made authoritative — the\n * cross-process rebuild lock could not be acquired within the bounded retry\n * budget (PR #2016). Callers that require an authoritative dedup answer surface\n * this instead of silently trusting a stale loaded snapshot.\n */\nexport class FactHashIndexNotAuthoritativeError extends Error {\n constructor(stateDir: string) {\n super(\n `fact-hash index is not authoritative: the cross-process rebuild lock for ${stateDir} could not be acquired within the bounded retry budget`,\n );\n this.name = \"FactHashIndexNotAuthoritativeError\";\n }\n}\n\ntype DiskFingerprint = {\n mtimeMs: number;\n size: number;\n dev: number;\n ino: number;\n ctimeMs: number;\n birthtimeMs: number;\n};\nexport interface ContentHashPathEntry {\n readonly path: string;\n readonly contentHash: string;\n}\n\n/**\n * Content-hash dedup index for facts.\n * Normalizes content (lowercase, strip punctuation, collapse whitespace),\n * computes SHA-256, and stores hashes in a line-delimited file.\n * Prevents writing semantically identical facts.\n */\nexport class ContentHashIndex {\n private hashes: Set<string> = new Set();\n private dirty = false;\n /**\n * Hashes explicitly removed since the last successful save (issue #1909 review\n * round 8 thread 3). Tracked separately so `saveMergingWithDisk` can be\n * removal-AWARE: under the file lock it reads the latest on-disk set, DROPS\n * these, then unions our additions — so a removal batch cannot resurrect a\n * hash (union alone would) and cannot clobber a concurrent extraction's\n * appended hash (blind overwrite would). Consumed + cleared by every save.\n */\n private removed: Set<string> = new Set();\n /**\n * Hashes added by THIS instance since the last successful save (issue #1909\n * review round 9 finding 3). Tracked separately from the loaded snapshot so a\n * reconciling save publishes `(on-disk \\ removed) ∪ added` — it re-applies only\n * OUR additions, never the stale loaded entries. Without this, seeding the\n * merge from `this.hashes` (loaded ∪ added) would resurrect a hash a peer\n * removed on disk while this instance held a stale snapshot.\n */\n private added: Set<string> = new Set();\n private readonly filePath: string;\n private readonly secureStoreKeyProvider: () => Buffer | null;\n private readonly secureStoreWriteKeyProvider: () => Buffer | null;\n private readonly memoryDir: string;\n private readonly lockOptions: ContentHashIndexLockOptions;\n /**\n * Deferred reconcile-save durable-retry state (issue #1909 / PR #2016). At\n * most ONE retry is ever armed (`reconcileRetryTimer` is the reentrancy guard)\n * so successive timed-out saves cannot stack duplicate/parallel retries.\n * `reconcileRetryBarrier` resolves when the chain settles (published or gave\n * up) — awaited by tests and any caller wanting the eventual result.\n */\n private reconcileRetryTimer: NodeJS.Timeout | null = null;\n private reconcileRetryAttempts = 0;\n private reconcileRetryBarrier: Promise<void> | null = null;\n private reconcileRetryResolve: (() => void) | null = null;\n /**\n * On-disk fingerprint captured at the last point THIS instance's in-memory\n * set matched disk — after `load()`, `save()`, or a reconcile publish. A peer\n * process that advances the durable index changes its file generation even\n * when byte size and mtime collide, so the freshness check remains safe\n * without an O(file-size) read on the hot path.\n */\n private lastSyncedFingerprint: DiskFingerprint | null = null;\n\n constructor(\n stateDir: string,\n secureStoreKeyProvider: () => Buffer | null = () => null,\n secureStoreWriteKeyProvider: () => Buffer | null = secureStoreKeyProvider,\n memoryDir: string = path.dirname(stateDir),\n lockOptions: ContentHashIndexLockOptions = {}\n ) {\n this.filePath = path.join(stateDir, \"fact-hashes.txt\");\n this.secureStoreKeyProvider = secureStoreKeyProvider;\n this.secureStoreWriteKeyProvider = secureStoreWriteKeyProvider;\n this.memoryDir = memoryDir;\n this.lockOptions = lockOptions;\n }\n\n /** Load existing hashes from disk. Safe to call multiple times. */\n async load(): Promise<void> {\n try {\n const raw = await readMaybeEncryptedFile(this.filePath, this.secureStoreKeyProvider(), this.memoryDir);\n for (const line of raw.split(\"\\n\")) {\n const trimmed = line.trim();\n if (trimmed.length > 0) {\n this.hashes.add(trimmed);\n }\n }\n log.debug(`content-hash index: loaded ${this.hashes.size} hashes`);\n } catch (err) {\n if (err instanceof SecureStoreLockedError) throw err;\n if (!isErrnoCode(err, \"ENOENT\")) throw err;\n log.debug(\"content-hash index: no existing index — starting fresh\");\n }\n // The loaded set now reflects the on-disk file — baseline the fingerprint so\n // a later peer write is detectable (PR #2016 review).\n await this.captureSyncedFingerprint();\n }\n\n /** Cheap `stat` of the durable index file; null when it does not exist yet. */\n private async statIndexFile(): Promise<DiskFingerprint | null> {\n try {\n const s = await stat(this.filePath);\n return {\n mtimeMs: s.mtimeMs,\n size: s.size,\n dev: s.dev,\n ino: s.ino,\n ctimeMs: s.ctimeMs,\n birthtimeMs: s.birthtimeMs,\n };\n } catch (err) {\n if (isErrnoCode(err, \"ENOENT\")) return null;\n throw err;\n }\n }\n\n /** Record the current on-disk fingerprint as this instance's synced baseline. */\n private async captureSyncedFingerprint(): Promise<void> {\n try {\n this.lastSyncedFingerprint = await this.statIndexFile();\n } catch (err) {\n // A stat failure other than ENOENT leaves no baseline; the next freshness\n // check reports non-current and the caller confirms against the corpus.\n this.lastSyncedFingerprint = null;\n log.debug(`content-hash index: could not fingerprint ${this.filePath}: ${err}`);\n }\n }\n\n /**\n * True when the durable index file is unchanged since this instance last\n * synced it (load / save / reconcile). False when a peer advanced it, when\n * the file appeared or vanished, or when freshness cannot be established\n * (stat error) — the caller then treats the in-memory snapshot as\n * non-authoritative and confirms a dedup miss against the durable corpus.\n * The stat includes file identity and generation metadata so same-size,\n * same-mtime atomic replacements are detected without reading the file body.\n */\n async isDiskFingerprintCurrent(): Promise<boolean> {\n let current: DiskFingerprint | null;\n try {\n current = await this.statIndexFile();\n } catch {\n return false;\n }\n const last = this.lastSyncedFingerprint;\n if (last === null || current === null) {\n return last === null && current === null;\n }\n return (\n current.mtimeMs === last.mtimeMs &&\n current.size === last.size &&\n current.dev === last.dev &&\n current.ino === last.ino &&\n current.ctimeMs === last.ctimeMs &&\n current.birthtimeMs === last.birthtimeMs\n );\n }\n\n /** Check if content already exists in the index. */\n has(content: string): boolean {\n return this.hashes.has(ContentHashIndex.computeHash(content));\n }\n\n /** Add content hash to the index. */\n add(content: string): void {\n const hash = ContentHashIndex.computeHash(content);\n // A re-add supersedes a pending removal of the same hash (round 8 thread 3).\n this.removed.delete(hash);\n // Record OUR durable delta even when the local snapshot already holds the\n // hash (PR #2016 thread SD-nG): local membership can be STALE — a peer\n // removed this hash on disk while this instance kept it in memory. Skipping\n // `added`/`dirty` on that path let the reconciling save compute\n // (on-disk \\ removed) ∪ added and silently drop the reintroduced hash, so\n // the peer never saw it again. Set semantics keep this idempotent (no\n // duplicate entries) and the `removed.delete` above preserves remove/add\n // ordering.\n this.hashes.add(hash);\n this.added.add(hash);\n this.dirty = true;\n }\n\n get size(): number {\n return this.hashes.size;\n }\n\n /** Clear all loaded hashes so the next save rewrites the index from scratch. */\n clear(): void {\n if (this.hashes.size > 0) {\n this.hashes.clear();\n }\n // A full clear is always paired with a plain overwrite save() (rebuild);\n // pending per-hash add/remove deltas are subsumed by writing the rebuilt set.\n // Cancel any armed deferred-reconcile retry (PR #2016) so it cannot fire in\n // the clear→rebuild→save window and union the pre-clear on-disk rows back.\n this.added.clear();\n this.removed.clear();\n this.cancelReconcileRetry();\n this.dirty = true;\n }\n\n /** Persist index to disk if changed (plain whole-file overwrite). */\n async save(): Promise<void> {\n if (!this.dirty) return;\n await mkdir(path.dirname(this.filePath), { recursive: true });\n // Snapshot the deltas + overwrite body THIS save publishes BEFORE the write\n // yields the event loop. `writeMaybeEncryptedFile` materializes `serialized`\n // eagerly, then the disk await lets a concurrent same-process add()/remove()\n // land a late delta (PR #2016 thread PRRT_kwDORJXyws6SEBri). Clearing the\n // LIVE added/removed sets afterward — as the previous unconditional\n // clear()+`dirty = false` did — would drop that late hash from the index AND\n // leave no retry, so peers never see it until a corpus rebuild. Consume only\n // what we published; late deltas stay pending. Mirrors saveMergingWithDisk.\n const publishedAdded = new Set<string>(this.added);\n const publishedRemoved = new Set<string>(this.removed);\n const serialized = [...this.hashes].join(\"\\n\") + \"\\n\";\n await writeMaybeEncryptedFile(\n this.filePath,\n serialized,\n this.secureStoreWriteKeyProvider(),\n {},\n this.memoryDir\n );\n // Consume ONLY the deltas this overwrite published; anything that arrived\n // during the awaits above remains pending for the next save.\n for (const h of publishedAdded) this.added.delete(h);\n for (const h of publishedRemoved) this.removed.delete(h);\n // Honest dirty state: true iff late deltas remain unpersisted.\n this.dirty = this.added.size > 0 || this.removed.size > 0;\n // This overwrite is now the on-disk state — re-baseline the freshness\n // fingerprint so our own write is not later mistaken for a peer's (PR #2016).\n await this.captureSyncedFingerprint();\n if (this.dirty) {\n // Late deltas landed mid-save: arm a bounded durable retry so they reach\n // disk even without a further batch save — never a silent in-memory-only\n // hash. The locked reconcile republishes only OUR delta onto the overwrite.\n this.scheduleReconcileRetry();\n } else {\n // Clean overwrite supersedes any pending deferred reconcile work.\n this.cancelReconcileRetry();\n }\n log.debug(`content-hash index: saved ${this.hashes.size} hashes`);\n }\n\n /**\n * Persist the index by RECONCILING with the latest on-disk state under a\n * cross-process lock (issue #1909). Used by the extraction-persist batch save\n * for BOTH append and removal batches, so appends and removals to the same\n * index serialize against each other:\n * final = (on-disk \\ this.removed) ∪ this.added\n * Note it re-applies only OUR local additions (`added`), NOT the loaded\n * snapshot (review round 9 finding 3): seeding from `this.hashes` would\n * resurrect a hash a peer removed on disk while we held a stale snapshot. This\n * preserves a concurrent writer's appended hashes (a blind overwrite would\n * drop them), never resurrects a removed hash, and never loses our additions.\n *\n * Hardening:\n * - Dirty short-circuit (round 7 finding 3): when this instance neither added\n * nor removed anything this run, skip the O(file) read+rewrite entirely.\n * - Cross-process lock (round 7 finding 2): serialize the read→reconcile→write\n * window on a per-file advisory lock. If the lock is NOT acquired (timeout),\n * do NOT publish (round 9 finding 2): keep `dirty` set AND schedule a bounded\n * durable background retry (PR #2016) that keeps re-attempting the locked\n * publish until the deferred additions land — never an unlocked write that\n * could clobber a peer, and never a silent \"batch save complete\" that leaves\n * the addition in this process's memory only.\n */\n async saveMergingWithDisk(): Promise<void> {\n if (!this.dirty) return;\n // The advisory lock file lives beside the index — ensure the dir exists\n // before acquiring it (the index dir may not exist on a first write).\n await mkdir(path.dirname(this.filePath), { recursive: true });\n const publish = async (): Promise<void> => {\n // Snapshot the deltas this publish is responsible for. The read+rewrite\n // below yields the event loop, so a concurrent add()/remove() can land\n // new deltas while we await disk (PR #2016 thread SD7Tj). Consuming the\n // LIVE sets afterward would drop those late deltas AND clear `dirty`,\n // stranding them in no save at all. We consume only what we published;\n // anything that arrived meanwhile stays pending for the next save.\n const publishedAdded = new Set<string>(this.added);\n const publishedRemoved = new Set<string>(this.removed);\n const merged = new Set<string>(publishedAdded);\n try {\n const raw = await readMaybeEncryptedFile(\n this.filePath,\n this.secureStoreKeyProvider(),\n this.memoryDir\n );\n for (const line of raw.split(\"\\n\")) {\n const trimmed = line.trim();\n // Keep prior/concurrent on-disk hashes EXCEPT the ones we removed.\n if (trimmed.length > 0 && !publishedRemoved.has(trimmed)) merged.add(trimmed);\n }\n } catch (err) {\n if (err instanceof SecureStoreLockedError) throw err;\n if (!isErrnoCode(err, \"ENOENT\")) throw err;\n // ENOENT → no on-disk index yet; write our additions as-is.\n }\n await mkdir(path.dirname(this.filePath), { recursive: true });\n await writeMaybeEncryptedFile(\n this.filePath,\n [...merged].join(\"\\n\") + \"\\n\",\n this.secureStoreWriteKeyProvider(),\n {},\n this.memoryDir\n );\n // Consume ONLY the deltas we just published; deltas that arrived during\n // the awaits above remain pending.\n for (const h of publishedAdded) this.added.delete(h);\n for (const h of publishedRemoved) this.removed.delete(h);\n // Fold any still-pending deltas onto the reconciled set so the in-memory\n // view stays consistent with what the next save will publish.\n for (const h of this.added) merged.add(h);\n for (const h of this.removed) merged.delete(h);\n this.hashes = merged;\n // Honest dirty state: true iff late deltas remain to be persisted. A\n // subsequent batch save or the shutdown flush drains them.\n this.dirty = this.added.size > 0 || this.removed.size > 0;\n // The reconciled set is now the on-disk state — re-baseline the freshness\n // fingerprint (captured under the held lock) so our own publish is not\n // later mistaken for a peer's advance (PR #2016 review).\n await this.captureSyncedFingerprint();\n };\n await withHeldFileLock(\n `${this.filePath}.lock`,\n {\n staleMs: CONTENT_HASH_INDEX_LOCK_STALE_MS,\n ...(this.lockOptions.maxWaitMs !== undefined ? { maxWaitMs: this.lockOptions.maxWaitMs } : {}),\n ...(this.lockOptions.pollMs !== undefined ? { pollMs: this.lockOptions.pollMs } : {}),\n },\n async (acquired) => {\n if (!acquired) {\n // Never publish unlocked (round 9 finding 2): keep dirty AND schedule a\n // durable background retry (PR #2016). Relying on an unguaranteed later\n // batch save silently left the addition in this process's memory only;\n // the retry keeps re-attempting the locked publish until the addition\n // lands on disk.\n log.warn(\n `content-hash index: lock not acquired for ${this.filePath}; deferring reconcile save (dirty retained, scheduling durable retry)`,\n );\n this.scheduleReconcileRetry();\n return;\n }\n await publish();\n if (this.dirty) {\n // A concurrent add()/remove() landed during publish's disk awaits, so\n // deltas remain unpersisted (PR #2016 thread SD7Tj). Arm a bounded\n // durable retry so they reach disk even if no further batch save comes\n // — never leave a settled barrier over pending work.\n this.scheduleReconcileRetry();\n } else {\n // Published cleanly: any pending deferred retry is now satisfied.\n this.cancelReconcileRetry();\n }\n },\n );\n log.debug(`content-hash index: reconcile-saved ${this.hashes.size} hashes`);\n }\n\n /**\n * Rebuild-and-publish the index from an authoritative source (the durable\n * corpus) under the SAME per-file cross-process lock the reconciling\n * append/removal saves (`saveMergingWithDisk`) use (issue #1909 / PR #2016).\n * The `populate` callback runs WHILE the lock is held and MUST repopulate this\n * index from the corpus (`clear()` then `addByHash(...)` for every corpus\n * hash); the rebuilt set is then published with a plain overwrite that is now\n * serialized against every locked writer.\n *\n * This closes the finding where the previous unlocked clear→scan→save() could\n * overwrite a peer's newer lock-merged or deferred additions: a locked writer\n * that committed BEFORE the rebuild acquired the lock has its memory `.md` on\n * disk and is re-scanned; one that commits AFTER the rebuild releases\n * reconciles its additions on top of the freshly-published set. No union with\n * the raw on-disk file is performed, so the rebuild keeps its\n * garbage-collection property (orphaned hashes for deleted memories drop).\n *\n * Lock-acquisition failure is explicit and non-destructive: `populate` is NOT\n * run and nothing is written (never an unlocked overwrite). Returns true when\n * the rebuild was published under the lock, false when the lock timed out —\n * the caller keeps the index non-authoritative and retries on next use. Uses\n * the same non-reentrant file lock as the reconcile path, so it can never\n * deadlock with the deferred reconcile-retry: a concurrent locked writer (or a\n * fired retry) simply yields `acquired=false` after the bounded wait.\n */\n async rebuildUnderLock(populate: () => Promise<void>): Promise<boolean> {\n // The advisory lock file lives beside the index — ensure the dir exists\n // before acquiring it (the index dir may not exist on a first rebuild).\n await mkdir(path.dirname(this.filePath), { recursive: true });\n let published = false;\n await withHeldFileLock(\n `${this.filePath}.lock`,\n {\n staleMs: CONTENT_HASH_INDEX_LOCK_STALE_MS,\n ...(this.lockOptions.maxWaitMs !== undefined ? { maxWaitMs: this.lockOptions.maxWaitMs } : {}),\n ...(this.lockOptions.pollMs !== undefined ? { pollMs: this.lockOptions.pollMs } : {}),\n },\n async (acquired) => {\n if (!acquired) {\n // Never publish unlocked: a concurrent locked writer holds the lock,\n // so overwriting now could clobber its addition. Leave the index as-is\n // and let the caller retry the authoritative rebuild on next use.\n log.warn(\n `content-hash index: lock not acquired for ${this.filePath}; deferring authoritative rebuild (index left non-authoritative, retried on next use)`,\n );\n return;\n }\n await populate();\n // Publish under the held lock. save() takes no lock (no re-entrancy) and\n // is a full overwrite that supersedes any pending add/remove deltas.\n await this.save();\n published = true;\n },\n );\n return published;\n }\n\n /**\n * True while a background durable reconcile-save retry is armed (PR #2016).\n * Test/introspection hook.\n */\n get hasPendingReconcileRetry(): boolean {\n return this.reconcileRetryTimer !== null;\n }\n\n /** True while this index has in-memory changes not yet published to disk. */\n get hasPendingChanges(): boolean {\n return this.dirty;\n }\n\n /**\n * Resolve once any armed background reconcile-save retry chain has settled —\n * either it published the deferred additions, or it exhausted its bounded\n * attempts and fell back to the corpus-rebuild safety net. Resolves\n * immediately when nothing is armed. Lets a caller (or a test) await the\n * eventual durability outcome of a lock-timed-out save.\n */\n async whenReconcileRetrySettled(): Promise<void> {\n await (this.reconcileRetryBarrier ?? Promise.resolve());\n }\n\n /**\n * Drive any deferred lock-timeout reconcile save to completion INLINE at a\n * lifecycle boundary (PR #2016 finding 3). The background retry timer is\n * `unref`'d so it never keeps a long-lived daemon alive — but a short-lived\n * writer (a one-shot CLI) can exit before it fires, leaving a durable fact\n * `.md` whose hash never reached `fact-hashes.txt` for peers that already\n * built their in-memory index. `orchestrator.destroy()` calls this so the\n * addition publishes before the process exits; long-lived hosts only reach it\n * at their own shutdown, so their in-flight retries keep running in the\n * background until then. Bounded by the same attempt ceiling as the background\n * retry; a permanently contended lock falls back to the corpus-rebuild-on-\n * restart safety net (the fact `.md` is already durable). No deadlock: each\n * attempt is the same non-reentrant, bounded-wait file lock.\n */\n async flushReconcileRetry(): Promise<void> {\n if (!this.dirty) return;\n const maxAttempts = this.lockOptions.retryMaxAttempts ?? CONTENT_HASH_INDEX_RETRY_MAX_ATTEMPTS;\n const baseMs = this.lockOptions.retryBaseMs ?? CONTENT_HASH_INDEX_RETRY_BASE_MS;\n for (let attempt = 0; this.dirty && attempt < maxAttempts; attempt += 1) {\n // Cancel the unref'd background timer — we drive the locked publish inline.\n if (this.reconcileRetryTimer) {\n clearTimeout(this.reconcileRetryTimer);\n this.reconcileRetryTimer = null;\n }\n await this.saveMergingWithDisk();\n if (!this.dirty) break;\n if (attempt < maxAttempts - 1) {\n const wait = Math.min(baseMs * 2 ** attempt, CONTENT_HASH_INDEX_RETRY_MAX_DELAY_MS);\n await new Promise<void>((resolve) => setTimeout(resolve, wait));\n }\n }\n // Clear any timer the final saveMergingWithDisk armed and settle the barrier\n // so awaiters unblock — the bounded inline drain has done its best.\n if (this.reconcileRetryTimer) {\n clearTimeout(this.reconcileRetryTimer);\n this.reconcileRetryTimer = null;\n }\n this.reconcileRetryAttempts = 0;\n this.settleReconcileRetry();\n }\n\n /**\n * Schedule ONE bounded, exponentially-backed-off background retry of the\n * deferred reconcile save (PR #2016). Reentrant-safe: an already-armed timer\n * short-circuits, so successive timed-out saves never stack duplicate retries.\n * When attempts are exhausted the chain settles without publishing — the\n * durable fact `.md` is on disk and a process restart rebuilds the index from\n * the corpus, so this degrades to that safety net, never a fatal failure.\n */\n private scheduleReconcileRetry(): void {\n if (this.reconcileRetryTimer) return; // one retry in flight — no duplicate/reentrant retries\n const maxAttempts = this.lockOptions.retryMaxAttempts ?? CONTENT_HASH_INDEX_RETRY_MAX_ATTEMPTS;\n if (this.reconcileRetryAttempts >= maxAttempts) {\n log.warn(\n `content-hash index: exhausted ${maxAttempts} deferred reconcile-save retries for ${this.filePath}; ` +\n `${this.added.size} addition(s) remain dirty in-memory (a process restart rebuilds the index from the durable corpus)`,\n );\n this.reconcileRetryAttempts = 0;\n this.settleReconcileRetry();\n return;\n }\n if (!this.reconcileRetryBarrier) {\n this.reconcileRetryBarrier = new Promise<void>((resolve) => {\n this.reconcileRetryResolve = resolve;\n });\n }\n const attempt = this.reconcileRetryAttempts + 1;\n const baseMs = this.lockOptions.retryBaseMs ?? CONTENT_HASH_INDEX_RETRY_BASE_MS;\n const delay = Math.min(baseMs * 2 ** (attempt - 1), CONTENT_HASH_INDEX_RETRY_MAX_DELAY_MS);\n this.reconcileRetryTimer = setTimeout(() => {\n this.reconcileRetryTimer = null;\n this.reconcileRetryAttempts = attempt;\n void this.saveMergingWithDisk()\n .catch((err) => {\n // A publish-time I/O/encryption error (not a lock timeout) is best-effort:\n // log and let the corpus-rebuild safety net cover it rather than loop.\n log.warn(`content-hash index: deferred reconcile-save retry failed for ${this.filePath}: ${err}`);\n })\n .finally(() => {\n // If the attempt neither published (dirty cleared → cancelReconcileRetry)\n // nor re-armed a further retry, the chain has ended — settle the barrier.\n if (this.dirty && !this.reconcileRetryTimer) this.settleReconcileRetry();\n });\n }, delay);\n this.reconcileRetryTimer.unref?.();\n }\n\n /** Cancel any armed reconcile-save retry and settle the barrier (a publish landed). */\n private cancelReconcileRetry(): void {\n if (this.reconcileRetryTimer) {\n clearTimeout(this.reconcileRetryTimer);\n this.reconcileRetryTimer = null;\n }\n this.reconcileRetryAttempts = 0;\n this.settleReconcileRetry();\n }\n\n /** Resolve and clear the retry barrier if one is outstanding. */\n private settleReconcileRetry(): void {\n const resolve = this.reconcileRetryResolve;\n this.reconcileRetryBarrier = null;\n this.reconcileRetryResolve = null;\n resolve?.();\n }\n\n /** Remove a hash from the index (used when archiving/deleting). */\n remove(content: string): void {\n const hash = ContentHashIndex.computeHash(content);\n // Record the removal (round 8 thread 3) even when the hash is not in our\n // in-memory set — a reconciling save must still drop it from the latest\n // on-disk state. A removal supersedes a pending add. Always mark dirty.\n this.added.delete(hash);\n this.removed.add(hash);\n this.hashes.delete(hash);\n this.dirty = true;\n }\n\n /**\n * Remove a pre-computed SHA-256 hash directly from the index without\n * re-hashing. Use this when the caller already holds the stored hash\n * (e.g. `memory.frontmatter.contentHash`) to avoid the double-hash bug\n * where `remove(hash)` would compute `hash(hash)` and never match the\n * entry.\n */\n removeByHash(hash: string): void {\n this.added.delete(hash);\n this.removed.add(hash);\n this.hashes.delete(hash);\n this.dirty = true;\n }\n\n /**\n * Add a pre-computed SHA-256 hash directly to the index without re-hashing.\n * Use this when the caller already holds the stored hash\n * (e.g. `memory.frontmatter.contentHash`) so that the index records the raw\n * content hash rather than re-hashing the citation-annotated body.\n *\n * @internal Only called from `StorageManager.ensureFactHashIndexAuthoritative`.\n * Not part of the public API — prefer `add(content)` for external callers.\n */\n addByHash(hash: string): void {\n // A re-add supersedes a pending removal of the same hash.\n this.removed.delete(hash);\n // Record OUR durable delta even when the local snapshot already holds the\n // hash (PR #2016 thread PRRT_kwDORJXyws6SEHve). Outside a rebuild\n // (StorageManager.addActiveFactContentHash on reactivation) local\n // membership can be STALE: a peer removed this hash on disk while this\n // instance kept it in memory. Skipping added/dirty on that path let the\n // reconciling save compute (on-disk \\ removed) ∪ added and silently drop\n // the reintroduced hash, so the peer never saw it again. Set semantics keep\n // this idempotent (no duplicate entries) and the removed.delete above\n // preserves remove/add ordering. Mirrors add().\n this.hashes.add(hash);\n this.added.add(hash);\n this.dirty = true;\n }\n\n /**\n * Resolve a pre-computed semantic hash to one deterministic corpus path.\n *\n * This lookup is separate from durable hash membership: `this.hashes` stays\n * one hash per line and `has(content)` keeps its existing raw-content contract.\n * Reconciliation supplies short-lived manifest rows where hash and path coexist.\n */\n static resolvePathByHash(\n hash: string,\n entries: Iterable<ContentHashPathEntry>,\n ): string | undefined {\n const canonicalHash = hash.toLowerCase();\n if (!/^[a-f0-9]{64}$/.test(canonicalHash)) return undefined;\n let canonicalPath: string | undefined;\n for (const entry of entries) {\n if (entry.contentHash.toLowerCase() !== canonicalHash || entry.path.length === 0) continue;\n if (canonicalPath === undefined || entry.path < canonicalPath) canonicalPath = entry.path;\n }\n return canonicalPath;\n }\n\n /** Normalize content (delegates to content-hash.ts for a single source of truth). */\n static normalizeContent(content: string): string {\n return normalizeContent(content);\n }\n\n /** Compute SHA-256 hash (delegates to content-hash.ts for a single source of truth). */\n static computeHash(content: string): string {\n return computeContentHash(content);\n }\n}\n"],"mappings":"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","names":[]}