@fgv/ts-agent-memory 5.1.0-46 → 5.1.0-48

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 (336) hide show
  1. package/README.md +55 -0
  2. package/dist/packlets/ingest/orchestrator.js +115 -20
  3. package/dist/packlets/ingest/orchestrator.js.map +1 -1
  4. package/dist/packlets/observe/memoryObservationStore.js +6 -0
  5. package/dist/packlets/observe/memoryObservationStore.js.map +1 -1
  6. package/dist/packlets/observe/observer.js.map +1 -1
  7. package/dist/packlets/store/fileTreeMemoryStore.js +63 -135
  8. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  9. package/dist/packlets/store/vectorMaintenance.js +251 -0
  10. package/dist/packlets/store/vectorMaintenance.js.map +1 -0
  11. package/dist/packlets/vector/inMemoryCosineIndex.js +81 -18
  12. package/dist/packlets/vector/inMemoryCosineIndex.js.map +1 -1
  13. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +7 -0
  14. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  15. package/dist/packlets/vector/vectorIndex.js.map +1 -1
  16. package/dist/ts-agent-memory.d.ts +389 -71
  17. package/lib/packlets/ingest/orchestrator.d.ts +62 -5
  18. package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
  19. package/lib/packlets/ingest/orchestrator.js +115 -20
  20. package/lib/packlets/ingest/orchestrator.js.map +1 -1
  21. package/lib/packlets/observe/memoryObservationStore.d.ts.map +1 -1
  22. package/lib/packlets/observe/memoryObservationStore.js +6 -0
  23. package/lib/packlets/observe/memoryObservationStore.js.map +1 -1
  24. package/lib/packlets/observe/observer.d.ts +61 -0
  25. package/lib/packlets/observe/observer.d.ts.map +1 -1
  26. package/lib/packlets/observe/observer.js.map +1 -1
  27. package/lib/packlets/store/fileTreeMemoryStore.d.ts +97 -60
  28. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  29. package/lib/packlets/store/fileTreeMemoryStore.js +63 -135
  30. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  31. package/lib/packlets/store/vectorMaintenance.d.ts +149 -0
  32. package/lib/packlets/store/vectorMaintenance.d.ts.map +1 -0
  33. package/lib/packlets/store/vectorMaintenance.js +255 -0
  34. package/lib/packlets/store/vectorMaintenance.js.map +1 -0
  35. package/lib/packlets/vector/inMemoryCosineIndex.d.ts +30 -7
  36. package/lib/packlets/vector/inMemoryCosineIndex.d.ts.map +1 -1
  37. package/lib/packlets/vector/inMemoryCosineIndex.js +80 -17
  38. package/lib/packlets/vector/inMemoryCosineIndex.js.map +1 -1
  39. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts +7 -0
  40. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -1
  41. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +7 -0
  42. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  43. package/lib/packlets/vector/vectorIndex.d.ts +129 -1
  44. package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
  45. package/lib/packlets/vector/vectorIndex.js.map +1 -1
  46. package/package.json +17 -7
  47. package/.rush/temp/6e1299d1cebb080772163a532c221cd181b54f9f.tar.log +0 -462
  48. package/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +0 -9
  49. package/.rush/temp/operation/build/all.log +0 -9
  50. package/.rush/temp/operation/build/log-chunks.jsonl +0 -9
  51. package/.rush/temp/operation/build/state.json +0 -3
  52. package/.rush/temp/shrinkwrap-deps.json +0 -689
  53. package/config/api-extractor.json +0 -343
  54. package/config/jest.config.json +0 -14
  55. package/config/rig.json +0 -4
  56. package/dist/test/unit/converters/antagonistRoundTrip.test.js +0 -95
  57. package/dist/test/unit/converters/antagonistRoundTrip.test.js.map +0 -1
  58. package/dist/test/unit/converters/bodyConverterRegistry.test.js +0 -72
  59. package/dist/test/unit/converters/bodyConverterRegistry.test.js.map +0 -1
  60. package/dist/test/unit/converters/envelopeConverter.test.js +0 -313
  61. package/dist/test/unit/converters/envelopeConverter.test.js.map +0 -1
  62. package/dist/test/unit/index/memoryIndex.test.js +0 -214
  63. package/dist/test/unit/index/memoryIndex.test.js.map +0 -1
  64. package/dist/test/unit/ingest/antagonistCycleAndParity.test.js +0 -277
  65. package/dist/test/unit/ingest/antagonistCycleAndParity.test.js.map +0 -1
  66. package/dist/test/unit/ingest/cycleGuard.test.js +0 -81
  67. package/dist/test/unit/ingest/cycleGuard.test.js.map +0 -1
  68. package/dist/test/unit/ingest/orchestrator.test.js +0 -1055
  69. package/dist/test/unit/ingest/orchestrator.test.js.map +0 -1
  70. package/dist/test/unit/observe/memoryObservationStore.test.js +0 -118
  71. package/dist/test/unit/observe/memoryObservationStore.test.js.map +0 -1
  72. package/dist/test/unit/retrieve/fragmentSemanticRetriever.test.js +0 -116
  73. package/dist/test/unit/retrieve/fragmentSemanticRetriever.test.js.map +0 -1
  74. package/dist/test/unit/retrieve/linkTraversalRetriever.test.js +0 -257
  75. package/dist/test/unit/retrieve/linkTraversalRetriever.test.js.map +0 -1
  76. package/dist/test/unit/retrieve/retrievers.test.js +0 -768
  77. package/dist/test/unit/retrieve/retrievers.test.js.map +0 -1
  78. package/dist/test/unit/retrieve/temporalRetrievers.test.js +0 -182
  79. package/dist/test/unit/retrieve/temporalRetrievers.test.js.map +0 -1
  80. package/dist/test/unit/store/antagonistTemporalBoundary.test.js +0 -120
  81. package/dist/test/unit/store/antagonistTemporalBoundary.test.js.map +0 -1
  82. package/dist/test/unit/store/embedOnWrite.test.js +0 -317
  83. package/dist/test/unit/store/embedOnWrite.test.js.map +0 -1
  84. package/dist/test/unit/store/fileTreeMemoryStore.test.js +0 -778
  85. package/dist/test/unit/store/fileTreeMemoryStore.test.js.map +0 -1
  86. package/dist/test/unit/store/fragmentEmbedOnWrite.test.js +0 -255
  87. package/dist/test/unit/store/fragmentEmbedOnWrite.test.js.map +0 -1
  88. package/dist/test/unit/store/indexInjection.test.js +0 -432
  89. package/dist/test/unit/store/indexInjection.test.js.map +0 -1
  90. package/dist/test/unit/store/lenientOpen.test.js +0 -248
  91. package/dist/test/unit/store/lenientOpen.test.js.map +0 -1
  92. package/dist/test/unit/store/listScoped.test.js +0 -109
  93. package/dist/test/unit/store/listScoped.test.js.map +0 -1
  94. package/dist/test/unit/store/observations.test.js +0 -239
  95. package/dist/test/unit/store/observations.test.js.map +0 -1
  96. package/dist/test/unit/store/rankAxis.test.js +0 -254
  97. package/dist/test/unit/store/rankAxis.test.js.map +0 -1
  98. package/dist/test/unit/store/scopeEncoding.test.js +0 -24
  99. package/dist/test/unit/store/scopeEncoding.test.js.map +0 -1
  100. package/dist/test/unit/store/temporalStore.test.js +0 -398
  101. package/dist/test/unit/store/temporalStore.test.js.map +0 -1
  102. package/dist/test/unit/tools/memoryTools.test.js +0 -841
  103. package/dist/test/unit/tools/memoryTools.test.js.map +0 -1
  104. package/dist/test/unit/types/identityCodec.test.js +0 -187
  105. package/dist/test/unit/types/identityCodec.test.js.map +0 -1
  106. package/dist/test/unit/types/ids.test.js +0 -84
  107. package/dist/test/unit/types/ids.test.js.map +0 -1
  108. package/dist/test/unit/types/temporalCodec.test.js +0 -203
  109. package/dist/test/unit/types/temporalCodec.test.js.map +0 -1
  110. package/dist/test/unit/types/temporalPolicy.test.js +0 -83
  111. package/dist/test/unit/types/temporalPolicy.test.js.map +0 -1
  112. package/dist/test/unit/types/writePolicy.test.js +0 -331
  113. package/dist/test/unit/types/writePolicy.test.js.map +0 -1
  114. package/dist/test/unit/vector/fragmentConverters.test.js +0 -58
  115. package/dist/test/unit/vector/fragmentConverters.test.js.map +0 -1
  116. package/dist/test/unit/vector/inMemoryCosineIndex.test.js +0 -252
  117. package/dist/test/unit/vector/inMemoryCosineIndex.test.js.map +0 -1
  118. package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js +0 -362
  119. package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +0 -1
  120. package/dist/test/unit/vector/vectorIndex.test.js +0 -51
  121. package/dist/test/unit/vector/vectorIndex.test.js.map +0 -1
  122. package/eslint.config.js +0 -15
  123. package/etc/ts-agent-memory.api.md +0 -938
  124. package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts +0 -10
  125. package/lib/test/unit/converters/antagonistRoundTrip.test.d.ts.map +0 -1
  126. package/lib/test/unit/converters/antagonistRoundTrip.test.js +0 -97
  127. package/lib/test/unit/converters/antagonistRoundTrip.test.js.map +0 -1
  128. package/lib/test/unit/converters/bodyConverterRegistry.test.d.ts +0 -2
  129. package/lib/test/unit/converters/bodyConverterRegistry.test.d.ts.map +0 -1
  130. package/lib/test/unit/converters/bodyConverterRegistry.test.js +0 -74
  131. package/lib/test/unit/converters/bodyConverterRegistry.test.js.map +0 -1
  132. package/lib/test/unit/converters/envelopeConverter.test.d.ts +0 -2
  133. package/lib/test/unit/converters/envelopeConverter.test.d.ts.map +0 -1
  134. package/lib/test/unit/converters/envelopeConverter.test.js +0 -315
  135. package/lib/test/unit/converters/envelopeConverter.test.js.map +0 -1
  136. package/lib/test/unit/index/memoryIndex.test.d.ts +0 -2
  137. package/lib/test/unit/index/memoryIndex.test.d.ts.map +0 -1
  138. package/lib/test/unit/index/memoryIndex.test.js +0 -216
  139. package/lib/test/unit/index/memoryIndex.test.js.map +0 -1
  140. package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts +0 -9
  141. package/lib/test/unit/ingest/antagonistCycleAndParity.test.d.ts.map +0 -1
  142. package/lib/test/unit/ingest/antagonistCycleAndParity.test.js +0 -279
  143. package/lib/test/unit/ingest/antagonistCycleAndParity.test.js.map +0 -1
  144. package/lib/test/unit/ingest/cycleGuard.test.d.ts +0 -2
  145. package/lib/test/unit/ingest/cycleGuard.test.d.ts.map +0 -1
  146. package/lib/test/unit/ingest/cycleGuard.test.js +0 -83
  147. package/lib/test/unit/ingest/cycleGuard.test.js.map +0 -1
  148. package/lib/test/unit/ingest/orchestrator.test.d.ts +0 -2
  149. package/lib/test/unit/ingest/orchestrator.test.d.ts.map +0 -1
  150. package/lib/test/unit/ingest/orchestrator.test.js +0 -1057
  151. package/lib/test/unit/ingest/orchestrator.test.js.map +0 -1
  152. package/lib/test/unit/observe/memoryObservationStore.test.d.ts +0 -2
  153. package/lib/test/unit/observe/memoryObservationStore.test.d.ts.map +0 -1
  154. package/lib/test/unit/observe/memoryObservationStore.test.js +0 -120
  155. package/lib/test/unit/observe/memoryObservationStore.test.js.map +0 -1
  156. package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.d.ts +0 -2
  157. package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.d.ts.map +0 -1
  158. package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.js +0 -118
  159. package/lib/test/unit/retrieve/fragmentSemanticRetriever.test.js.map +0 -1
  160. package/lib/test/unit/retrieve/linkTraversalRetriever.test.d.ts +0 -2
  161. package/lib/test/unit/retrieve/linkTraversalRetriever.test.d.ts.map +0 -1
  162. package/lib/test/unit/retrieve/linkTraversalRetriever.test.js +0 -259
  163. package/lib/test/unit/retrieve/linkTraversalRetriever.test.js.map +0 -1
  164. package/lib/test/unit/retrieve/retrievers.test.d.ts +0 -2
  165. package/lib/test/unit/retrieve/retrievers.test.d.ts.map +0 -1
  166. package/lib/test/unit/retrieve/retrievers.test.js +0 -770
  167. package/lib/test/unit/retrieve/retrievers.test.js.map +0 -1
  168. package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts +0 -2
  169. package/lib/test/unit/retrieve/temporalRetrievers.test.d.ts.map +0 -1
  170. package/lib/test/unit/retrieve/temporalRetrievers.test.js +0 -184
  171. package/lib/test/unit/retrieve/temporalRetrievers.test.js.map +0 -1
  172. package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts +0 -9
  173. package/lib/test/unit/store/antagonistTemporalBoundary.test.d.ts.map +0 -1
  174. package/lib/test/unit/store/antagonistTemporalBoundary.test.js +0 -122
  175. package/lib/test/unit/store/antagonistTemporalBoundary.test.js.map +0 -1
  176. package/lib/test/unit/store/embedOnWrite.test.d.ts +0 -2
  177. package/lib/test/unit/store/embedOnWrite.test.d.ts.map +0 -1
  178. package/lib/test/unit/store/embedOnWrite.test.js +0 -319
  179. package/lib/test/unit/store/embedOnWrite.test.js.map +0 -1
  180. package/lib/test/unit/store/fileTreeMemoryStore.test.d.ts +0 -2
  181. package/lib/test/unit/store/fileTreeMemoryStore.test.d.ts.map +0 -1
  182. package/lib/test/unit/store/fileTreeMemoryStore.test.js +0 -780
  183. package/lib/test/unit/store/fileTreeMemoryStore.test.js.map +0 -1
  184. package/lib/test/unit/store/fragmentEmbedOnWrite.test.d.ts +0 -2
  185. package/lib/test/unit/store/fragmentEmbedOnWrite.test.d.ts.map +0 -1
  186. package/lib/test/unit/store/fragmentEmbedOnWrite.test.js +0 -257
  187. package/lib/test/unit/store/fragmentEmbedOnWrite.test.js.map +0 -1
  188. package/lib/test/unit/store/indexInjection.test.d.ts +0 -2
  189. package/lib/test/unit/store/indexInjection.test.d.ts.map +0 -1
  190. package/lib/test/unit/store/indexInjection.test.js +0 -434
  191. package/lib/test/unit/store/indexInjection.test.js.map +0 -1
  192. package/lib/test/unit/store/lenientOpen.test.d.ts +0 -2
  193. package/lib/test/unit/store/lenientOpen.test.d.ts.map +0 -1
  194. package/lib/test/unit/store/lenientOpen.test.js +0 -250
  195. package/lib/test/unit/store/lenientOpen.test.js.map +0 -1
  196. package/lib/test/unit/store/listScoped.test.d.ts +0 -2
  197. package/lib/test/unit/store/listScoped.test.d.ts.map +0 -1
  198. package/lib/test/unit/store/listScoped.test.js +0 -111
  199. package/lib/test/unit/store/listScoped.test.js.map +0 -1
  200. package/lib/test/unit/store/observations.test.d.ts +0 -2
  201. package/lib/test/unit/store/observations.test.d.ts.map +0 -1
  202. package/lib/test/unit/store/observations.test.js +0 -241
  203. package/lib/test/unit/store/observations.test.js.map +0 -1
  204. package/lib/test/unit/store/rankAxis.test.d.ts +0 -2
  205. package/lib/test/unit/store/rankAxis.test.d.ts.map +0 -1
  206. package/lib/test/unit/store/rankAxis.test.js +0 -256
  207. package/lib/test/unit/store/rankAxis.test.js.map +0 -1
  208. package/lib/test/unit/store/scopeEncoding.test.d.ts +0 -2
  209. package/lib/test/unit/store/scopeEncoding.test.d.ts.map +0 -1
  210. package/lib/test/unit/store/scopeEncoding.test.js +0 -26
  211. package/lib/test/unit/store/scopeEncoding.test.js.map +0 -1
  212. package/lib/test/unit/store/temporalStore.test.d.ts +0 -2
  213. package/lib/test/unit/store/temporalStore.test.d.ts.map +0 -1
  214. package/lib/test/unit/store/temporalStore.test.js +0 -400
  215. package/lib/test/unit/store/temporalStore.test.js.map +0 -1
  216. package/lib/test/unit/tools/memoryTools.test.d.ts +0 -2
  217. package/lib/test/unit/tools/memoryTools.test.d.ts.map +0 -1
  218. package/lib/test/unit/tools/memoryTools.test.js +0 -843
  219. package/lib/test/unit/tools/memoryTools.test.js.map +0 -1
  220. package/lib/test/unit/types/identityCodec.test.d.ts +0 -2
  221. package/lib/test/unit/types/identityCodec.test.d.ts.map +0 -1
  222. package/lib/test/unit/types/identityCodec.test.js +0 -189
  223. package/lib/test/unit/types/identityCodec.test.js.map +0 -1
  224. package/lib/test/unit/types/ids.test.d.ts +0 -2
  225. package/lib/test/unit/types/ids.test.d.ts.map +0 -1
  226. package/lib/test/unit/types/ids.test.js +0 -86
  227. package/lib/test/unit/types/ids.test.js.map +0 -1
  228. package/lib/test/unit/types/temporalCodec.test.d.ts +0 -2
  229. package/lib/test/unit/types/temporalCodec.test.d.ts.map +0 -1
  230. package/lib/test/unit/types/temporalCodec.test.js +0 -205
  231. package/lib/test/unit/types/temporalCodec.test.js.map +0 -1
  232. package/lib/test/unit/types/temporalPolicy.test.d.ts +0 -2
  233. package/lib/test/unit/types/temporalPolicy.test.d.ts.map +0 -1
  234. package/lib/test/unit/types/temporalPolicy.test.js +0 -85
  235. package/lib/test/unit/types/temporalPolicy.test.js.map +0 -1
  236. package/lib/test/unit/types/writePolicy.test.d.ts +0 -2
  237. package/lib/test/unit/types/writePolicy.test.d.ts.map +0 -1
  238. package/lib/test/unit/types/writePolicy.test.js +0 -333
  239. package/lib/test/unit/types/writePolicy.test.js.map +0 -1
  240. package/lib/test/unit/vector/fragmentConverters.test.d.ts +0 -2
  241. package/lib/test/unit/vector/fragmentConverters.test.d.ts.map +0 -1
  242. package/lib/test/unit/vector/fragmentConverters.test.js +0 -60
  243. package/lib/test/unit/vector/fragmentConverters.test.js.map +0 -1
  244. package/lib/test/unit/vector/inMemoryCosineIndex.test.d.ts +0 -2
  245. package/lib/test/unit/vector/inMemoryCosineIndex.test.d.ts.map +0 -1
  246. package/lib/test/unit/vector/inMemoryCosineIndex.test.js +0 -254
  247. package/lib/test/unit/vector/inMemoryCosineIndex.test.js.map +0 -1
  248. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.d.ts +0 -2
  249. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.d.ts.map +0 -1
  250. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js +0 -364
  251. package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +0 -1
  252. package/lib/test/unit/vector/vectorIndex.test.d.ts +0 -2
  253. package/lib/test/unit/vector/vectorIndex.test.d.ts.map +0 -1
  254. package/lib/test/unit/vector/vectorIndex.test.js +0 -53
  255. package/lib/test/unit/vector/vectorIndex.test.js.map +0 -1
  256. package/rush-logs/ts-agent-memory.build.cache.log +0 -3
  257. package/rush-logs/ts-agent-memory.build.log +0 -9
  258. package/src/index.ts +0 -14
  259. package/src/packlets/converters/bodyConverterRegistry.ts +0 -105
  260. package/src/packlets/converters/envelopeConverter.ts +0 -233
  261. package/src/packlets/converters/index.ts +0 -7
  262. package/src/packlets/index/index.ts +0 -6
  263. package/src/packlets/index/memoryIndex.ts +0 -332
  264. package/src/packlets/ingest/cycleGuard.ts +0 -153
  265. package/src/packlets/ingest/hostStages.ts +0 -111
  266. package/src/packlets/ingest/index.ts +0 -9
  267. package/src/packlets/ingest/model.ts +0 -195
  268. package/src/packlets/ingest/orchestrator.ts +0 -873
  269. package/src/packlets/observe/index.ts +0 -7
  270. package/src/packlets/observe/memoryObservationStore.ts +0 -153
  271. package/src/packlets/observe/observer.ts +0 -119
  272. package/src/packlets/retrieve/fragmentSemanticRetriever.ts +0 -138
  273. package/src/packlets/retrieve/hybridRetriever.ts +0 -194
  274. package/src/packlets/retrieve/index.ts +0 -14
  275. package/src/packlets/retrieve/linkTraversalRetriever.ts +0 -158
  276. package/src/packlets/retrieve/recencyRetriever.ts +0 -54
  277. package/src/packlets/retrieve/retriever.ts +0 -291
  278. package/src/packlets/retrieve/semanticRetriever.ts +0 -152
  279. package/src/packlets/retrieve/structuredFilterRetriever.ts +0 -58
  280. package/src/packlets/retrieve/tagRetriever.ts +0 -58
  281. package/src/packlets/retrieve/temporalRetrievers.ts +0 -210
  282. package/src/packlets/store/fileTreeMemoryStore.ts +0 -1834
  283. package/src/packlets/store/index.ts +0 -7
  284. package/src/packlets/store/scopeEncoding.ts +0 -36
  285. package/src/packlets/tools/index.ts +0 -6
  286. package/src/packlets/tools/memoryTools.ts +0 -706
  287. package/src/packlets/types/envelope.ts +0 -196
  288. package/src/packlets/types/filenameSafety.ts +0 -57
  289. package/src/packlets/types/identityCodec.ts +0 -447
  290. package/src/packlets/types/ids.ts +0 -124
  291. package/src/packlets/types/index.ts +0 -11
  292. package/src/packlets/types/temporal.ts +0 -96
  293. package/src/packlets/types/writePolicy.ts +0 -612
  294. package/src/packlets/vector/fragmentConverters.ts +0 -55
  295. package/src/packlets/vector/inMemoryCosineIndex.ts +0 -196
  296. package/src/packlets/vector/inMemoryFragmentCosineIndex.ts +0 -289
  297. package/src/packlets/vector/index.ts +0 -9
  298. package/src/packlets/vector/vectorIndex.ts +0 -273
  299. package/src/test/unit/converters/antagonistRoundTrip.test.ts +0 -110
  300. package/src/test/unit/converters/bodyConverterRegistry.test.ts +0 -89
  301. package/src/test/unit/converters/envelopeConverter.test.ts +0 -418
  302. package/src/test/unit/index/memoryIndex.test.ts +0 -272
  303. package/src/test/unit/ingest/antagonistCycleAndParity.test.ts +0 -367
  304. package/src/test/unit/ingest/cycleGuard.test.ts +0 -110
  305. package/src/test/unit/ingest/orchestrator.test.ts +0 -1351
  306. package/src/test/unit/observe/memoryObservationStore.test.ts +0 -158
  307. package/src/test/unit/retrieve/fragmentSemanticRetriever.test.ts +0 -163
  308. package/src/test/unit/retrieve/linkTraversalRetriever.test.ts +0 -329
  309. package/src/test/unit/retrieve/retrievers.test.ts +0 -1018
  310. package/src/test/unit/retrieve/temporalRetrievers.test.ts +0 -226
  311. package/src/test/unit/store/antagonistTemporalBoundary.test.ts +0 -158
  312. package/src/test/unit/store/embedOnWrite.test.ts +0 -418
  313. package/src/test/unit/store/fileTreeMemoryStore.test.ts +0 -1041
  314. package/src/test/unit/store/fragmentEmbedOnWrite.test.ts +0 -349
  315. package/src/test/unit/store/indexInjection.test.ts +0 -550
  316. package/src/test/unit/store/lenientOpen.test.ts +0 -292
  317. package/src/test/unit/store/listScoped.test.ts +0 -138
  318. package/src/test/unit/store/observations.test.ts +0 -290
  319. package/src/test/unit/store/rankAxis.test.ts +0 -349
  320. package/src/test/unit/store/scopeEncoding.test.ts +0 -37
  321. package/src/test/unit/store/temporalStore.test.ts +0 -469
  322. package/src/test/unit/tools/memoryTools.test.ts +0 -1120
  323. package/src/test/unit/types/identityCodec.test.ts +0 -266
  324. package/src/test/unit/types/ids.test.ts +0 -94
  325. package/src/test/unit/types/temporalCodec.test.ts +0 -259
  326. package/src/test/unit/types/temporalPolicy.test.ts +0 -124
  327. package/src/test/unit/types/writePolicy.test.ts +0 -446
  328. package/src/test/unit/vector/fragmentConverters.test.ts +0 -80
  329. package/src/test/unit/vector/inMemoryCosineIndex.test.ts +0 -318
  330. package/src/test/unit/vector/inMemoryFragmentCosineIndex.test.ts +0 -477
  331. package/src/test/unit/vector/vectorIndex.test.ts +0 -64
  332. package/temp/build/lint/_eslint-5eVG3S6w.json +0 -302
  333. package/temp/build/typescript/ts_8nwakTlr.json +0 -1
  334. package/temp/ts-agent-memory.api.json +0 -20933
  335. package/temp/ts-agent-memory.api.md +0 -938
  336. package/tsconfig.json +0 -8
@@ -1,1834 +0,0 @@
1
- /*
2
- * Copyright (c) 2026 Erik Fortune
3
- * SPDX-License-Identifier: MIT
4
- */
5
-
6
- import { Hash, Logging, Result, fail, mapResults, mapSuccess, succeed } from '@fgv/ts-utils';
7
- import { FileTree } from '@fgv/ts-json-base';
8
- import {
9
- AdmissionDecision,
10
- DEFAULT_DEDUP_SCOPE,
11
- DedupScope,
12
- EntityId,
13
- IEdgeTarget,
14
- IIdentityCodec,
15
- IMemoryEnvelope,
16
- IMemoryRecord,
17
- IProvenance,
18
- ITemporalIdentityCodec,
19
- IWritePolicy,
20
- Kind,
21
- KnowledgeLwwPolicy,
22
- MemoryId,
23
- MemoryScopeKey,
24
- RankProjector,
25
- Tag,
26
- isTemporalIdentityCodec,
27
- isTemporalRecord,
28
- isVersionCurrent,
29
- selectCurrentVersion,
30
- selectVersionAsOf
31
- } from '../types';
32
- import { IBodyConverterRegistry as IRegistry, parseMemoryFile, serializeMemoryFile } from '../converters';
33
- import { IIndexedMemoryRecord, IMemoryIndex, MemoryIndex } from '../index';
34
- import {
35
- IMemoryObservationRecord,
36
- IMemoryObserver,
37
- MemoryObservationOutcome,
38
- MemoryObservationPhase
39
- } from '../observe';
40
- import {
41
- FragmentEmbedder,
42
- IEmbeddedFragment,
43
- IFragmentVectorIndex,
44
- IMemoryRecordSource,
45
- IScopedMemoryRecord,
46
- IVectorIndex,
47
- MemoryEmbedder
48
- } from '../vector';
49
- import { defaultMemoryScopeEncoding } from './scopeEncoding';
50
-
51
- /** The on-disk extension for a memory record file. */
52
- const MEMORY_FILE_EXTENSION: string = '.md';
53
-
54
- /**
55
- * Filter for {@link IMemoryStore.list}. All present fields are ANDed together.
56
- * @public
57
- */
58
- export interface IMemoryStoreListFilter {
59
- /** Restrict to records in this scope. */
60
- readonly scope?: MemoryScopeKey;
61
- /** Restrict to records of this kind. */
62
- readonly kind?: Kind;
63
- /** Restrict to records carrying this tag (exact match). */
64
- readonly tag?: Tag;
65
- /**
66
- * For temporal (versioned) kinds: collapse each entity to the single version
67
- * valid at this epoch ms. Non-temporal records are timeless and pass through
68
- * unchanged. Absent = no temporal projection (every version is returned).
69
- */
70
- readonly asOf?: number;
71
- }
72
-
73
- /**
74
- * Policy for how {@link FileTreeMemoryStore.create}'s initial vault walk reacts
75
- * to a record that fails to parse or validate.
76
- *
77
- * - `'fail'` (the default) — one unreadable record fails the whole open. The
78
- * walk collapses per-record results with `mapResults`, so any single failure
79
- * aborts `create()`. This is the historical behavior, preserved byte-for-byte.
80
- * - `'skip'` — an unreadable record is quarantined (not indexed) rather than
81
- * failing the open. Every record that DOES parse loads normally; each skip is
82
- * logged at `warn` and surfaced structurally on
83
- * {@link FileTreeMemoryStore.skippedRecords}. The offending file is never
84
- * deleted or mutated, so a later open (after the body converter is fixed)
85
- * re-indexes it. A vault holds every kind in one store, so a required-field
86
- * migration on one kind must not make every other record unreadable.
87
- * @public
88
- */
89
- export type MemoryRecordErrorMode = 'skip' | 'fail';
90
-
91
- /**
92
- * A record that {@link FileTreeMemoryStore.create} could not load and
93
- * quarantined (only produced in {@link MemoryRecordErrorMode | `'skip'` mode}).
94
- * The `path` identifies WHICH record was skipped so a host can repair it.
95
- * @public
96
- */
97
- export interface ISkippedRecord {
98
- /** The record file's path within the vault (`<scope>/<filename>.md`). */
99
- readonly path: string;
100
- /** The scope the record lives under (its parent directory path). */
101
- readonly scope: MemoryScopeKey;
102
- /** The parse/validation failure message (includes the record path). */
103
- readonly error: string;
104
- }
105
-
106
- /**
107
- * The writable, FileTree-backed, content-hash-deduped memory store.
108
- * @public
109
- */
110
- export interface IMemoryStore {
111
- /**
112
- * Keyed read by entity id. Resolves `entityId` to a storage address via the
113
- * registered {@link IIdentityCodec} for `kind`. Returns `undefined` when no
114
- * record exists. For a versioned (temporal) kind this returns the current
115
- * version, resolved from the derived in-memory index (not re-read/re-verified
116
- * from disk per call — the index is kept in sync with every write).
117
- */
118
- get(kind: Kind, entityId: EntityId): Promise<Result<IMemoryRecord<unknown> | undefined>>;
119
-
120
- /**
121
- * Direct read by `(scope, MemoryId)`. Returns `undefined` when not found.
122
- */
123
- getById(scope: MemoryScopeKey, id: MemoryId): Promise<Result<IMemoryRecord<unknown> | undefined>>;
124
-
125
- /**
126
- * List records, filtered in-memory over the derived index.
127
- */
128
- list(filter?: IMemoryStoreListFilter): Promise<Result<ReadonlyArray<IMemoryRecord<unknown>>>>;
129
-
130
- /**
131
- * List EVERY record in the vault, each paired with its scope-qualified
132
- * `(scope, id)` address — the projection {@link IMemoryRecordSource} requires.
133
- * Unlike {@link IMemoryStore.list | list}, it takes no filter (whole-vault) and
134
- * returns {@link IScopedMemoryRecord}s so a re-index keys each entry on the same
135
- * scoped target the incremental embed-on-write path uses. Two records that share
136
- * a filename stem across scopes appear as distinct entries.
137
- */
138
- listScoped(): Promise<Result<ReadonlyArray<IScopedMemoryRecord>>>;
139
-
140
- /**
141
- * Adapt this store to the {@link IMemoryRecordSource} seam so it can drive
142
- * {@link IVectorIndex} rebuilds (e.g. `InMemoryCosineIndex.rebuild`). The
143
- * returned source's `list()` delegates to {@link IMemoryStore.listScoped}. The
144
- * store cannot implement {@link IMemoryRecordSource} directly because its
145
- * `list(filter?)` returns bare records (the ergonomic query surface) while the
146
- * seam's `list()` returns scope-qualified records.
147
- */
148
- asRecordSource(): IMemoryRecordSource;
149
-
150
- /**
151
- * Write a record. Validates the body, computes a content hash, deduplicates
152
- * (scope-wide, before policy), applies the kind's {@link IWritePolicy}, stamps
153
- * transaction-time metadata (`created` / `updated` / `seq` / `contentHash`),
154
- * writes the file, and patches the index. Returns the written record — or the
155
- * existing record unchanged on a dedup no-op.
156
- */
157
- put(record: IMemoryRecord<unknown>): Promise<Result<IMemoryRecord<unknown>>>;
158
-
159
- /**
160
- * Delete a record by `(kind, entityId)`. Non-temporal kinds physically delete
161
- * the file and return the deleted record's {@link MemoryId}. Temporal
162
- * (versioned) kinds SOFT-delete: the current version is invalidated
163
- * (`invalid_at` set), history is retained, and the invalidated version's
164
- * {@link MemoryId} is returned.
165
- */
166
- delete(kind: Kind, entityId: EntityId): Promise<Result<MemoryId>>;
167
- }
168
-
169
- /**
170
- * Parameters for {@link FileTreeMemoryStore.create}.
171
- * @public
172
- */
173
- export interface IFileTreeMemoryStoreCreateParams {
174
- /** Root directory under which scope-encoded sub-trees live. Must be mutable. */
175
- readonly root: FileTree.IMutableFileTreeDirectoryItem;
176
- /** Per-kind body converter registry. Gates every body on write and read. */
177
- readonly registry: IRegistry;
178
- /** Per-kind write policies. Kinds without an entry use a default LWW policy. */
179
- readonly writePolicies?: ReadonlyMap<Kind, IWritePolicy>;
180
- /** Per-kind identity codecs. */
181
- readonly codecs?: ReadonlyMap<Kind, IIdentityCodec>;
182
- /**
183
- * Optional per-kind host projector map. When a kind has an entry, the store
184
- * runs the projector on the fully-resolved (post-merge) record on every put
185
- * AND every update — in the same pass that recomputes `contentHash` — and
186
- * stamps the numeric result into {@link IMemoryEnvelope.rank}, which the index's
187
- * rank view and `orderBy: 'rank'` retrieval sort by (descending, absent last).
188
- * Absent for a kind → that kind's records carry no `rank`. Purely additive and
189
- * zero-overhead when unwired: an absent map leaves every write byte-identical.
190
- * The projector is a host callback — a throw is logged at `warn` and the record
191
- * is stamped with no `rank`, never failing the write (mirrors the store's other
192
- * guard-host-callbacks conventions).
193
- */
194
- readonly rankProjectors?: ReadonlyMap<Kind, RankProjector>;
195
- /** Default codec for kinds without an explicit entry. */
196
- readonly defaultCodec?: IIdentityCodec;
197
- /**
198
- * Optional derived-index implementation. Defaults to a fresh {@link MemoryIndex} —
199
- * omitting this parameter is byte-identical to the store's behavior before the
200
- * parameter existed. When supplied, the store uses it for EVERY index operation
201
- * it performs and never holds a second index, so an injected index is the store's
202
- * only view of its own records:
203
- *
204
- * - `rebuild` — once, from the initial vault walk in `create()`.
205
- * - `patch` — on every persisted write, delete, version invalidation, and
206
- * cap-cull eviction.
207
- * - `entries` — behind {@link IMemoryStore.list | list} /
208
- * {@link IMemoryStore.listScoped | listScoped}, the keyed temporal reads, the
209
- * write path's content-hash dedup and write-policy admission cohort, AND the
210
- * temporal (versioned) write and delete paths, which resolve an entity's
211
- * version history entirely from the index.
212
- *
213
- * That last group is the one to weigh before injecting anything other than a
214
- * pass-through decorator: an index that filters, reorders, or otherwise reshapes
215
- * `entries()` changes WRITE semantics, not just what reads return. Concretely,
216
- * on a versioned kind the store derives an entity's whole version history from
217
- * `entries()` filtered by scope, and that derivation decides which version a
218
- * `put` treats as current (so what it dedups against and what it merges its
219
- * patch over), which prior versions it stamps `invalid_at` on, what the
220
- * admission cohort is, and which versions a `delete` tombstones. An index that
221
- * hides a version makes it invisible to all of those — the FileTree still holds
222
- * it, but the store will not supersede, invalidate, or tombstone it. On flat
223
- * kinds the same reshaping changes what dedups and what a cap-cull policy
224
- * evicts. A faithful delegating decorator — the intended use below — has no such
225
- * effect. Note the store's keyed reads ({@link IMemoryStore.get | get} on a flat
226
- * kind, and {@link IMemoryStore.getById | getById}) go to the FileTree, not the
227
- * index; the FileTree remains the source of truth and the index stays a derived
228
- * view.
229
- *
230
- * @remarks
231
- * **This is an instrumentation seam, NOT a resident-memory fix.** The intended
232
- * use is wrapping the shipped {@link MemoryIndex} in a decorator that counts and
233
- * times the calls the store makes — resident bytes by kind, open cost against
234
- * vault size, where the curve actually bends — so a decision about a partial-read
235
- * redesign can be driven by measurements instead of estimates.
236
- *
237
- * It does NOT lower the store's resident-memory ceiling, and injecting a
238
- * "persisted" or "lazy" index will not change that. {@link IMemoryIndex}'s read
239
- * surface returns whole records by construction: `entries()` yields
240
- * {@link IIndexedMemoryRecord}s and `byKind` / `byTag` / `byRecency` / `byRank`
241
- * yield `IMemoryRecord<unknown>` — `{ envelope, body }` pairs with the body
242
- * materialized. Any implementation satisfying the current contract must therefore
243
- * be able to produce every body on demand. An injected index changes WHERE records
244
- * come from; it does not change WHETHER bodies are held. Lowering the ceiling
245
- * requires a partial-read (id-or-envelope-only) redesign of `IMemoryIndex` itself,
246
- * which is separate, breaking, design-first work and is deliberately not part of
247
- * this seam.
248
- */
249
- readonly index?: IMemoryIndex;
250
- /** Scope encoding. Defaults to {@link defaultMemoryScopeEncoding}. */
251
- readonly scopeEncoding?: (scope: MemoryScopeKey) => Result<string>;
252
- /**
253
- * Transaction-time clock. Defaults to `Date.now`. Injectable so tests can
254
- * make `created` / `updated` deterministic. Also stamps observation
255
- * `timestamp`s.
256
- */
257
- readonly clock?: () => number;
258
- /**
259
- * Optional observers fired once per public `get` / `put` / `delete` call.
260
- * Purely additive — when absent, no observation records are produced and the
261
- * store behaves exactly as it did without this parameter. Observer errors
262
- * never affect the store operation (swallowed, logged to {@link
263
- * IFileTreeMemoryStoreCreateParams.logger | logger} at `warn`).
264
- */
265
- readonly observers?: ReadonlyArray<IMemoryObserver>;
266
- /**
267
- * Diagnostic logger for swallowed observer failures. Defaults to a
268
- * `Logging.NoOpLogger`.
269
- */
270
- readonly logger?: Logging.ILogger;
271
- /**
272
- * Optional vector index for semantic recall. Wired together with
273
- * {@link IFileTreeMemoryStoreCreateParams.embed | embed}: when both are present
274
- * the store embeds each written record and maintains the index on
275
- * `put` / `delete` / cap-cull eviction. Absent (or `embed` absent) → no
276
- * embedding work happens and the store behaves exactly as it does without this
277
- * parameter (the additive, zero-overhead-when-unwired default — mirrors the
278
- * observer hook).
279
- */
280
- readonly vectorIndex?: IVectorIndex;
281
- /**
282
- * Optional embedder applied to each record on write, wired together with
283
- * {@link IFileTreeMemoryStoreCreateParams.vectorIndex | vectorIndex}. The
284
- * consumer supplies it (e.g. `callProviderEmbedding` or in-process
285
- * transformers); the store never calls an embedding provider directly, so the
286
- * core stays embedder-agnostic. Embedding/index maintenance is **best-effort**:
287
- * a failed (or throwing) `embed` / `add` / `remove` is logged at `warn` via
288
- * {@link IFileTreeMemoryStoreCreateParams.logger | logger} and the record
289
- * operation still succeeds — the vector index is a derived view that a later
290
- * `rebuild` reconciles, so a vector failure never fails an authoritative write.
291
- */
292
- readonly embed?: MemoryEmbedder;
293
- /**
294
- * Optional fragment-granular vector index for sub-document semantic search.
295
- * Wired together with
296
- * {@link IFileTreeMemoryStoreCreateParams.fragmentEmbedder | fragmentEmbedder}:
297
- * when both are present the store chunks + embeds each written record and
298
- * maintains the fragment index on `put` / `delete` / cap-cull eviction — the
299
- * "discovery" half of a search-then-read contract, queried through a
300
- * {@link FragmentSemanticRetriever}. Independent of the record-granular
301
- * {@link IFileTreeMemoryStoreCreateParams.vectorIndex | vectorIndex} pair: a
302
- * store may wire record vectors, fragment vectors, both, or neither. Absent (or
303
- * `fragmentEmbedder` absent) → no fragment work happens and the store behaves
304
- * byte-identically (the additive, zero-overhead-when-unwired default).
305
- */
306
- readonly fragmentIndex?: IFragmentVectorIndex;
307
- /**
308
- * Optional fragment embedder applied to each record on write, wired together
309
- * with {@link IFileTreeMemoryStoreCreateParams.fragmentIndex | fragmentIndex}.
310
- * The consumer owns the chunking policy (window size, overlap) and the embedding
311
- * call; the store stays chunking- and embedder-agnostic. Fragment index
312
- * maintenance is **best-effort**, exactly like the record-vector path: a failed
313
- * (or throwing) `fragmentEmbedder` / `addFragments` / `remove` is logged at
314
- * `warn` and the record operation still succeeds — the fragment index is a
315
- * derived view a later `rebuild` reconciles.
316
- */
317
- readonly fragmentEmbedder?: FragmentEmbedder;
318
- /**
319
- * How the initial vault walk reacts to a record that fails to parse or
320
- * validate. Defaults to `'fail'` — one bad record fails the whole open, the
321
- * historical behavior, preserved byte-for-byte. Set `'skip'` to quarantine
322
- * unreadable records instead: valid records still load, each skip is logged
323
- * at `warn` and surfaced on {@link FileTreeMemoryStore.skippedRecords}, and
324
- * the offending file is left untouched for a later (post-fix) re-index. See
325
- * {@link MemoryRecordErrorMode}.
326
- */
327
- readonly onRecordError?: MemoryRecordErrorMode;
328
- }
329
-
330
- /**
331
- * The internal outcome of a locked `put`: the written (or existing, on a
332
- * dedup no-op) record plus the {@link MemoryId}s evicted by a `cull-oldest`
333
- * admission decision. The eviction file-deletes and index patches happen
334
- * inside the write-lock; the `evicted` ids are surfaced so the public `put`
335
- * can fire one `'delete'` observation per evicted record AFTER the lock
336
- * releases — consistent with the post-op firing of the other observation
337
- * hooks, and avoiding observer re-entrancy into the still-held write-lock.
338
- */
339
- interface IPutOutcome {
340
- readonly record: IMemoryRecord<unknown>;
341
- readonly evicted: ReadonlyArray<MemoryId>;
342
- }
343
-
344
- interface IInternalParams {
345
- readonly root: FileTree.IMutableFileTreeDirectoryItem;
346
- readonly registry: IRegistry;
347
- readonly writePolicies: ReadonlyMap<Kind, IWritePolicy>;
348
- readonly codecs: ReadonlyMap<Kind, IIdentityCodec>;
349
- readonly rankProjectors: ReadonlyMap<Kind, RankProjector>;
350
- readonly defaultCodec?: IIdentityCodec;
351
- readonly defaultPolicy: IWritePolicy;
352
- readonly scopeEncoding: (scope: MemoryScopeKey) => Result<string>;
353
- readonly clock: () => number;
354
- readonly index: IMemoryIndex;
355
- readonly observers: ReadonlyArray<IMemoryObserver>;
356
- readonly logger: Logging.ILogger;
357
- readonly vectorIndex?: IVectorIndex;
358
- readonly embed?: MemoryEmbedder;
359
- readonly fragmentIndex?: IFragmentVectorIndex;
360
- readonly fragmentEmbedder?: FragmentEmbedder;
361
- }
362
-
363
- /**
364
- * Flat-layout, FileTree-backed {@link IMemoryStore}. The FileTree is the source
365
- * of truth; the {@link IMemoryIndex} is a derived in-memory view patched on
366
- * every write. Concurrent writes are serialized through a per-instance async
367
- * write-lock so the index and the on-disk files never interleave.
368
- *
369
- * @remarks
370
- * Bodies are string (markdown). Both layouts are supported: flat
371
- * (one-file-per-entity, non-versioned) and versioned (subtree-per-entity,
372
- * invalidate-don't-delete), dispatched per kind by the codec's `isVersioned`
373
- * flag. A kind is flat with zero behavioral impact unless it opts into a
374
- * {@link ITemporalIdentityCodec}.
375
- * @public
376
- */
377
- export class FileTreeMemoryStore implements IMemoryStore {
378
- private readonly _root: FileTree.IMutableFileTreeDirectoryItem;
379
- private readonly _registry: IRegistry;
380
- private readonly _writePolicies: ReadonlyMap<Kind, IWritePolicy>;
381
- private readonly _codecs: ReadonlyMap<Kind, IIdentityCodec>;
382
- private readonly _rankProjectors: ReadonlyMap<Kind, RankProjector>;
383
- private readonly _defaultCodec: IIdentityCodec | undefined;
384
- private readonly _defaultPolicy: IWritePolicy;
385
- private readonly _scopeEncoding: (scope: MemoryScopeKey) => Result<string>;
386
- private readonly _clock: () => number;
387
- private readonly _index: IMemoryIndex;
388
- private readonly _hasher: Hash.Crc32Normalizer;
389
- private readonly _observers: ReadonlyArray<IMemoryObserver>;
390
- private readonly _logger: Logging.ILogger;
391
- private readonly _vectorIndex: IVectorIndex | undefined;
392
- private readonly _embed: MemoryEmbedder | undefined;
393
- private readonly _fragmentIndex: IFragmentVectorIndex | undefined;
394
- private readonly _fragmentEmbedder: FragmentEmbedder | undefined;
395
- /**
396
- * Records the initial walk could not load. Populated during `create()` in
397
- * {@link MemoryRecordErrorMode | `'skip'` mode}; empty otherwise.
398
- */
399
- private readonly _skippedRecords: ISkippedRecord[];
400
-
401
- /** Monotonic write counter; incremented inside the write-lock on each put. */
402
- private _seq: number;
403
- /**
404
- * Monotonic observation-sequence counter. A distinct authority from `_seq`
405
- * (the envelope write counter): it numbers the audit stream so a single
406
- * {@link MemoryObservationStore} fed by this store sees strictly increasing
407
- * `seq`, satisfying the ring buffer's cursor contract.
408
- */
409
- private _observationSeq: number;
410
- /** Tail of the write-lock promise chain that serializes mutating ops. */
411
- private _writeTail: Promise<unknown>;
412
-
413
- /**
414
- * The record-level mutable-field vocabulary: maps a declared mutable field
415
- * name to its canonical location on a record. Used to project an incoming
416
- * record into a merge-patch on update. B1 ships the knowledge-LWW surface
417
- * (body + envelope metadata); body-internal mutable fields (Phase-C cap-cull)
418
- * extend this map.
419
- */
420
- private static readonly _mutableFieldAccessors: ReadonlyMap<
421
- string,
422
- (record: IMemoryRecord<unknown>) => unknown
423
- > = new Map<string, (record: IMemoryRecord<unknown>) => unknown>([
424
- ['body', (r) => r.body],
425
- ['tags', (r) => r.envelope.tags],
426
- ['links', (r) => r.envelope.links],
427
- ['provenance', (r) => r.envelope.provenance],
428
- ['embeddingRef', (r) => r.envelope.embeddingRef]
429
- ]);
430
-
431
- private constructor(params: IInternalParams) {
432
- this._root = params.root;
433
- this._registry = params.registry;
434
- this._writePolicies = params.writePolicies;
435
- this._codecs = params.codecs;
436
- this._rankProjectors = params.rankProjectors;
437
- this._defaultCodec = params.defaultCodec;
438
- this._defaultPolicy = params.defaultPolicy;
439
- this._scopeEncoding = params.scopeEncoding;
440
- this._clock = params.clock;
441
- this._index = params.index;
442
- this._hasher = new Hash.Crc32Normalizer();
443
- this._observers = params.observers;
444
- this._logger = params.logger;
445
- this._vectorIndex = params.vectorIndex;
446
- this._embed = params.embed;
447
- this._fragmentIndex = params.fragmentIndex;
448
- this._fragmentEmbedder = params.fragmentEmbedder;
449
- this._skippedRecords = [];
450
- this._seq = 0;
451
- this._observationSeq = 0;
452
- this._writeTail = Promise.resolve();
453
- }
454
-
455
- /**
456
- * Records the initial vault walk could not parse or validate and quarantined
457
- * (not indexed). Non-empty only when the store was opened with
458
- * {@link MemoryRecordErrorMode | `onRecordError: 'skip'`} AND at least one
459
- * record failed to load. Each entry identifies the offending file so a host
460
- * can repair it; the file itself is never deleted or mutated, so a later open
461
- * (after the body converter is fixed) re-indexes it.
462
- */
463
- public get skippedRecords(): ReadonlyArray<ISkippedRecord> {
464
- return this._skippedRecords;
465
- }
466
-
467
- /**
468
- * Family-convention factory. Resolves the derived index (the caller's
469
- * {@link IFileTreeMemoryStoreCreateParams.index | index} when supplied, a fresh
470
- * {@link MemoryIndex} otherwise) and a default LWW policy, then performs an
471
- * initial FileTree walk so an existing vault is indexed (and the `seq` counter
472
- * resumes past the highest persisted `seq`).
473
- */
474
- public static create(params: IFileTreeMemoryStoreCreateParams): Result<FileTreeMemoryStore> {
475
- return KnowledgeLwwPolicy.create().onSuccess((defaultPolicy) =>
476
- FileTreeMemoryStore._resolveIndex(params.index).onSuccess((index) => {
477
- const store: FileTreeMemoryStore = new FileTreeMemoryStore({
478
- root: params.root,
479
- registry: params.registry,
480
- writePolicies: params.writePolicies ?? new Map<Kind, IWritePolicy>(),
481
- codecs: params.codecs ?? new Map<Kind, IIdentityCodec>(),
482
- rankProjectors: params.rankProjectors ?? new Map<Kind, RankProjector>(),
483
- defaultCodec: params.defaultCodec,
484
- defaultPolicy,
485
- scopeEncoding: params.scopeEncoding ?? defaultMemoryScopeEncoding,
486
- clock: params.clock ?? Date.now,
487
- index,
488
- observers: params.observers ?? [],
489
- logger: params.logger ?? new Logging.NoOpLogger(),
490
- vectorIndex: params.vectorIndex,
491
- embed: params.embed,
492
- fragmentIndex: params.fragmentIndex,
493
- fragmentEmbedder: params.fragmentEmbedder
494
- });
495
- return store._initialIndex(params.onRecordError ?? 'fail').onSuccess(() => succeed(store));
496
- })
497
- );
498
- }
499
-
500
- /**
501
- * Resolve the derived index for a `create()`: the caller's injected
502
- * {@link IMemoryIndex} verbatim, or a fresh {@link MemoryIndex} when none was
503
- * supplied (the default that keeps an omitting caller byte-identical).
504
- */
505
- private static _resolveIndex(index: IMemoryIndex | undefined): Result<IMemoryIndex> {
506
- // Nullish rather than strictly-undefined, matching how every sibling optional
507
- // param in `create()` handles absence (`params.codecs ?? new Map()`, and so on).
508
- // A JS caller — or a TS caller arriving through an `unknown` escape hatch —
509
- // passing `null` otherwise gets `null` installed as the store's index and fails
510
- // later inside `entries()` with a message that names neither the param nor the
511
- // cause.
512
- return index ? succeed(index) : MemoryIndex.create();
513
- }
514
-
515
- /** {@inheritDoc IMemoryStore.get} */
516
- public async get(kind: Kind, entityId: EntityId): Promise<Result<IMemoryRecord<unknown> | undefined>> {
517
- const result: Result<IMemoryRecord<unknown> | undefined> = this._codecFor(kind).onSuccess((codec) =>
518
- codec.encode(entityId).onSuccess((addr) => {
519
- if (addr.isVersioned) {
520
- if (!isTemporalIdentityCodec(codec)) {
521
- return fail(
522
- `memory get '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`
523
- );
524
- }
525
- // Versioned keyed read resolves the CURRENT version (highest-seq
526
- // version whose `invalid_at` is null/absent) from the entity subtree,
527
- // read off the derived index. `asOf` resolution is via `list({ asOf })`
528
- // and the temporal retrievers.
529
- return succeed(this._readVersionedCurrent(addr.scope));
530
- }
531
- return this._readRecord(addr.scope, addr.idStem);
532
- })
533
- );
534
- await this._fireObservation('read', kind, entityId, {
535
- outcome: result.isSuccess() ? 'success' : 'failure',
536
- id: result.isSuccess() ? result.value?.envelope.id : undefined,
537
- error: result.isFailure() ? result.message : undefined
538
- });
539
- return result;
540
- }
541
-
542
- /** {@inheritDoc IMemoryStore.getById} */
543
- public async getById(
544
- scope: MemoryScopeKey,
545
- id: MemoryId
546
- ): Promise<Result<IMemoryRecord<unknown> | undefined>> {
547
- return this._readRecord(scope, id);
548
- }
549
-
550
- /** {@inheritDoc IMemoryStore.list} */
551
- public async list(filter?: IMemoryStoreListFilter): Promise<Result<ReadonlyArray<IMemoryRecord<unknown>>>> {
552
- const matches: IMemoryRecord<unknown>[] = this._index
553
- .entries()
554
- .filter((entry) => {
555
- if (filter?.scope !== undefined && entry.scope !== filter.scope) {
556
- return false;
557
- }
558
- if (filter?.kind !== undefined && entry.record.envelope.kind !== filter.kind) {
559
- return false;
560
- }
561
- if (filter?.tag !== undefined && !entry.record.envelope.tags.includes(filter.tag)) {
562
- return false;
563
- }
564
- return true;
565
- })
566
- .map((entry) => entry.record);
567
- if (filter?.asOf === undefined) {
568
- // No temporal projection requested: byte-identical to the pre-temporal
569
- // behavior (the flat-path guarantee — every version is returned).
570
- return succeed(matches);
571
- }
572
- return succeed(FileTreeMemoryStore._projectAsOf(matches, filter.asOf));
573
- }
574
-
575
- /** {@inheritDoc IMemoryStore.listScoped} */
576
- public async listScoped(): Promise<Result<ReadonlyArray<IScopedMemoryRecord>>> {
577
- // The derived index already carries each record's scope
578
- // ({@link IIndexedMemoryRecord.scope}), so the scoped projection is a direct
579
- // map — the record's `(scope, id)` is exactly the address the vector index
580
- // keys on. No filter/temporal projection: the seam re-embeds the whole vault.
581
- return succeed(
582
- this._index.entries().map((entry) => ({
583
- target: { scope: entry.scope, id: entry.record.envelope.id },
584
- record: entry.record
585
- }))
586
- );
587
- }
588
-
589
- /** {@inheritDoc IMemoryStore.asRecordSource} */
590
- public asRecordSource(): IMemoryRecordSource {
591
- return { list: (): Promise<Result<ReadonlyArray<IScopedMemoryRecord>>> => this.listScoped() };
592
- }
593
-
594
- /**
595
- * Collapse temporal records to the single version valid at `asOf` per entity;
596
- * non-temporal records are timeless and pass through unchanged (valid-time
597
- * `asOf` applies only to versioned kinds; transaction-time / full bi-temporal
598
- * filtering is deferred — OQ-9). An entity with no version valid at `asOf`
599
- * contributes nothing.
600
- */
601
- private static _projectAsOf(
602
- records: ReadonlyArray<IMemoryRecord<unknown>>,
603
- asOf: number
604
- ): ReadonlyArray<IMemoryRecord<unknown>> {
605
- const passthrough: IMemoryRecord<unknown>[] = [];
606
- const groups: Map<string, IMemoryRecord<unknown>[]> = new Map<string, IMemoryRecord<unknown>[]>();
607
- for (const record of records) {
608
- if (!isTemporalRecord(record)) {
609
- passthrough.push(record);
610
- continue;
611
- }
612
- const key: string = `${record.envelope.kind}\0${record.envelope.entityId}`;
613
- const existing: IMemoryRecord<unknown>[] | undefined = groups.get(key);
614
- if (existing === undefined) {
615
- groups.set(key, [record]);
616
- } else {
617
- existing.push(record);
618
- }
619
- }
620
- const result: IMemoryRecord<unknown>[] = [...passthrough];
621
- for (const versions of groups.values()) {
622
- const valid: IMemoryRecord<unknown> | undefined = selectVersionAsOf(versions, asOf);
623
- if (valid !== undefined) {
624
- result.push(valid);
625
- }
626
- }
627
- return result;
628
- }
629
-
630
- /** {@inheritDoc IMemoryStore.put} */
631
- public async put(record: IMemoryRecord<unknown>): Promise<Result<IMemoryRecord<unknown>>> {
632
- const result: Result<IPutOutcome> = await this._enqueue(() => this._putLocked(record));
633
- await this._fireObservation('write', record.envelope.kind, record.envelope.entityId, {
634
- outcome: result.isSuccess() ? 'success' : 'failure',
635
- id: result.isSuccess() ? result.value.record.envelope.id : record.envelope.id,
636
- provenance: record.envelope.provenance,
637
- error: result.isFailure() ? result.message : undefined
638
- });
639
- // Fire one `'delete'` observation per record evicted by cap-cull. The
640
- // evicted records are always in the same `(scope, kind)` cohort as the
641
- // incoming record, so resolving the scope from the incoming `entityId`
642
- // yields the evicted record's scope; the observation records the evicted
643
- // record's own `id`. A no-op / first-write / reject path yields no
644
- // evictions, so this loop is empty there.
645
- if (result.isSuccess()) {
646
- for (const evictedId of result.value.evicted) {
647
- await this._fireObservation('delete', record.envelope.kind, record.envelope.entityId, {
648
- outcome: 'success',
649
- id: evictedId
650
- });
651
- }
652
- }
653
- return result.onSuccess((outcome) => succeed(outcome.record));
654
- }
655
-
656
- /** {@inheritDoc IMemoryStore.delete} */
657
- public async delete(kind: Kind, entityId: EntityId): Promise<Result<MemoryId>> {
658
- const result: Result<MemoryId> = await this._enqueue(() => this._deleteLocked(kind, entityId));
659
- await this._fireObservation('delete', kind, entityId, {
660
- outcome: result.isSuccess() ? 'success' : 'failure',
661
- id: result.isSuccess() ? result.value : undefined,
662
- error: result.isFailure() ? result.message : undefined
663
- });
664
- return result;
665
- }
666
-
667
- /**
668
- * Serialize a mutating task behind the write-lock. Tasks run in submission
669
- * order; a failed task does not break the chain for subsequent ones.
670
- */
671
- private _enqueue<T>(task: () => Promise<Result<T>>): Promise<Result<T>> {
672
- const result: Promise<Result<T>> = this._writeTail.then(task);
673
- this._writeTail = result.then(
674
- () => undefined,
675
- () => undefined
676
- );
677
- return result;
678
- }
679
-
680
- /**
681
- * Build and fan out one observation record for a completed op. A no-op when no
682
- * observers are wired (the additive-default path pays nothing). The store is
683
- * the seq authority — it mints `seq` / `timestamp` so every observer sees the
684
- * same record. The scope is resolved best-effort via the codec.
685
- */
686
- private async _fireObservation(
687
- phase: MemoryObservationPhase,
688
- kind: Kind,
689
- entityId: EntityId,
690
- details: {
691
- readonly outcome: MemoryObservationOutcome;
692
- readonly id?: MemoryId;
693
- readonly provenance?: IProvenance;
694
- readonly error?: string;
695
- }
696
- ): Promise<void> {
697
- if (this._observers.length === 0) {
698
- return;
699
- }
700
- const record: IMemoryObservationRecord = {
701
- seq: ++this._observationSeq,
702
- timestamp: this._clock(),
703
- phase,
704
- scope: this._scopeBestEffort(kind, entityId),
705
- id: details.id,
706
- kind,
707
- outcome: details.outcome,
708
- error: details.error,
709
- provenance: details.provenance
710
- };
711
- const awaited: Promise<void>[] = [];
712
- for (const observer of this._observers) {
713
- if (observer.fireAndForget === true) {
714
- // Intentionally not awaited: a fire-and-forget observer must not extend
715
- // the store op's latency. `_safeObserve` swallows internally; the
716
- // `.catch` keeps the detached promise from being flagged as floating.
717
- this._safeObserve(observer, record).catch(() => undefined);
718
- } else {
719
- awaited.push(this._safeObserve(observer, record));
720
- }
721
- }
722
- await Promise.all(awaited);
723
- }
724
-
725
- /** Invoke one observer, swallowing any failure or throw (logged at `warn`). */
726
- private async _safeObserve(observer: IMemoryObserver, record: IMemoryObservationRecord): Promise<void> {
727
- try {
728
- const observed: Result<unknown> = await observer.observe(record);
729
- if (observed.isFailure()) {
730
- this._warnSwallowed(`memory observer failed (swallowed): ${observed.message}`);
731
- }
732
- } catch (error) {
733
- this._warnSwallowed(`memory observer threw (swallowed): ${String(error)}`);
734
- }
735
- }
736
-
737
- /**
738
- * Log a swallowed-issue warning (observer failure or best-effort vector
739
- * maintenance), tolerating a logger that itself throws — diagnostic logging
740
- * must never make a store op reject.
741
- */
742
- private _warnSwallowed(message: string): void {
743
- try {
744
- this._logger.warn(message);
745
- } catch {
746
- // Diagnostic logging must not affect the store operation.
747
- }
748
- }
749
-
750
- /** Resolve a scope for an observation, best-effort (undefined when unresolvable). */
751
- private _scopeBestEffort(kind: Kind, entityId: EntityId): MemoryScopeKey | undefined {
752
- return this._codecFor(kind)
753
- .onSuccess((codec) => codec.encode(entityId))
754
- .onSuccess((addr) => succeed(addr.scope))
755
- .orDefault();
756
- }
757
-
758
- private async _putLocked(record: IMemoryRecord<unknown>): Promise<Result<IPutOutcome>> {
759
- const envelope: IMemoryEnvelope = record.envelope;
760
- if (typeof record.body !== 'string') {
761
- return fail(
762
- `memory put '${envelope.id}': only string (markdown) bodies are supported (got ${typeof record.body})`
763
- );
764
- }
765
- const body: string = record.body;
766
- // Resolve the body converter + codec, then dispatch on layout. The flat
767
- // (non-versioned) path keeps its exact dedup/policy ordering; the versioned
768
- // path is a wholly separate branch so the flat path is behaviorally
769
- // unchanged (the consumer adoption guarantee).
770
- return this._registry
771
- .convert(envelope.kind, body)
772
- .withErrorFormat((msg) => `memory put '${envelope.id}': invalid body: ${msg}`)
773
- .onSuccess(() => this._codecFor(envelope.kind))
774
- .thenOnSuccess((codec) =>
775
- codec.encode(envelope.entityId).thenOnSuccess((addr) => {
776
- if (addr.isVersioned) {
777
- if (!isTemporalIdentityCodec(codec)) {
778
- return Promise.resolve(
779
- fail<IPutOutcome>(
780
- `memory put '${envelope.entityId}': codec for versioned kind '${envelope.kind}' does not implement the temporal codec interface`
781
- )
782
- );
783
- }
784
- return this._putVersioned(record, body, codec, addr.scope);
785
- }
786
- if (envelope.id !== addr.idStem) {
787
- return Promise.resolve(
788
- fail<IPutOutcome>(
789
- `memory put: envelope id '${envelope.id}' does not match codec-derived stem '${addr.idStem}'`
790
- )
791
- );
792
- }
793
- return this._contentHash(envelope.kind, body, envelope.links).thenOnSuccess((hash) =>
794
- this._writeResolved(record, body, addr.scope, addr.idStem, hash)
795
- );
796
- })
797
- );
798
- }
799
-
800
- /**
801
- * Run dedup → policy → stamp → embed → write for a resolved address and content
802
- * hash. Async because the embed-on-write hook (when wired) does a network call
803
- * or in-process inference; the whole chain runs inside the write-lock so the
804
- * vector index, the on-disk file, and the derived index never interleave.
805
- */
806
- private async _writeResolved(
807
- record: IMemoryRecord<unknown>,
808
- body: string,
809
- scope: MemoryScopeKey,
810
- idStem: string,
811
- hash: string
812
- ): Promise<Result<IPutOutcome>> {
813
- const policy: IWritePolicy = this._policyFor(record.envelope.kind);
814
- const dedupScope: DedupScope = policy.dedupScope ?? DEFAULT_DEDUP_SCOPE;
815
- // Content-hash dedup runs BEFORE policy. Its granularity is the kind's
816
- // `dedupScope`:
817
- // - 'content': an identical { kind, body, links } triple ANYWHERE in the
818
- // scope (even under a different id) is a no-op (knowledge family).
819
- // - 'entity' (default): only an identical re-put of the SAME id is a no-op;
820
- // two distinct entities with identical content never collapse (experience
821
- // families). The same-id check is folded into the `_readRecord` below.
822
- // (Tags / provenance are metadata and are NOT part of the hash; see
823
- // design-lock §2.5.)
824
- if (dedupScope === 'content') {
825
- // Cross-id content collapse: an identical { kind, body, links } triple under
826
- // a DIFFERENT id is a no-op (knowledge family). A same-id match is excluded
827
- // here and falls through to the LWW path below so a metadata-only revision
828
- // (tags / provenance — outside the content hash but inside the policy's
829
- // mutableFields) actually applies. Content-dedup must never shadow LWW for
830
- // the same entity.
831
- const duplicate: IMemoryRecord<unknown> | undefined = this._findByContentHash(
832
- scope,
833
- hash,
834
- record.envelope.id
835
- );
836
- if (duplicate !== undefined) {
837
- return succeed({ record: duplicate, evicted: [] });
838
- }
839
- }
840
- return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
841
- // Same-id re-put is a no-op ONLY when the content hash matches AND the
842
- // mutable metadata is also unchanged. The content hash covers
843
- // { kind, body, links }; a matching hash with revised tags/provenance is a
844
- // real update that must reach applyUpdate, not be swallowed as a duplicate
845
- // (both dedup scopes).
846
- if (
847
- existing !== undefined &&
848
- existing.envelope.contentHash === hash &&
849
- this._isMutableMetadataUnchanged(existing, record)
850
- ) {
851
- return Promise.resolve(succeed({ record: existing, evicted: [] }));
852
- }
853
- // The admission cohort is the set of records the policy's cap applies to:
854
- // every record in this scope of this kind EXCEPT the target id. On a first
855
- // write the post-write count is `cohort.length + 1`; on an update the prior
856
- // same-id record is excluded so the count is still `cohort.length + 1`
857
- // (a replace, not a grow). The same-id `existing` record is threaded
858
- // separately into `_buildRecord` for the merge-patch. Knowledge LWW ignores
859
- // this argument, so its behavior is unchanged by the wider cohort.
860
- const cohort: ReadonlyArray<IMemoryRecord<unknown>> = this._admissionCohort(
861
- scope,
862
- record.envelope.kind,
863
- idStem
864
- );
865
- return policy
866
- .admit(record, cohort)
867
- .thenOnSuccess((decision) =>
868
- this._admitWrite(record, body, scope, idStem, hash, policy, existing, decision)
869
- );
870
- });
871
- }
872
-
873
- /**
874
- * Build → embed → persist → evict for an admitted write.
875
- *
876
- * The durable record store is authoritative; the vector index is a **derived,
877
- * rebuildable** view, so vector maintenance is **best-effort** — a failed embed
878
- * or `add` is logged and the durable write still succeeds (the index can be
879
- * rebuilt via {@link InMemoryCosineIndex.rebuild}). Only genuine record-store
880
- * failures (body/codec/policy, persist, file eviction) fail the `put`.
881
- *
882
- * Ordering: the embed + `add` run immediately before the single `_persist` so
883
- * the index-returned `embeddingRef` lands in one durable write (a post-persist
884
- * stamp would need a second write whose failure path is effectively untestable).
885
- * Build and persist the replacement BEFORE evicting the cull-oldest cohort, so a
886
- * later eviction failure never loses data with nothing written in its place.
887
- */
888
- private async _admitWrite(
889
- record: IMemoryRecord<unknown>,
890
- body: string,
891
- scope: MemoryScopeKey,
892
- idStem: string,
893
- hash: string,
894
- policy: IWritePolicy,
895
- existing: IMemoryRecord<unknown> | undefined,
896
- decision: AdmissionDecision
897
- ): Promise<Result<IPutOutcome>> {
898
- if (decision.decision === 'reject') {
899
- return fail(`memory put: rejected by policy: ${decision.reason}`);
900
- }
901
- return this._buildRecord(record, body, existing, policy, hash)
902
- .onSuccess((built) => succeed(this._stampRank(built)))
903
- .thenOnSuccess((built) => this._embedOnWrite(built, scope))
904
- .thenOnSuccess((built) => this._embedFragmentsOnWrite(built, scope))
905
- .onSuccess((embeddedBuilt) => this._persist(embeddedBuilt, scope, idStem))
906
- .thenOnSuccess(async (persisted) => {
907
- // Everything after the authoritative `_persist` commit is best-effort and
908
- // never turns a committed write into a `Failure`:
909
- // - a cull-oldest eviction that fails is logged (the per-(scope,kind)
910
- // cap may be transiently exceeded; the next write's admission restores
911
- // it), and only the successfully-evicted ids flow onward;
912
- // - vector pruning of the evicted cohort is likewise best-effort.
913
- const evicted: ReadonlyArray<MemoryId> = this._applyEvictions(decision, scope);
914
- await this._removeEvictedVectors(evicted, scope);
915
- return succeed({ record: persisted, evicted });
916
- });
917
- }
918
-
919
- /**
920
- * Best-effort embed-on-write. When a vector index AND an embedder are wired,
921
- * embeds the built record, `add`s the vector (replace semantics handle a same-id
922
- * re-embed — no explicit remove), and stamps the returned `embeddingRef`. A
923
- * failure (returned `fail` OR a thrown/rejected hook) is logged and the
924
- * unembedded record is returned unchanged — the put still persists, and the
925
- * derived index is reconciled by a later `rebuild`. A pass-through no-op when
926
- * unwired (byte-identical record).
927
- *
928
- * Always succeeds (`Result` is the chain's shape, never a vector-induced
929
- * failure).
930
- */
931
- private async _embedOnWrite(
932
- built: IMemoryRecord<string>,
933
- scope: MemoryScopeKey
934
- ): Promise<Result<IMemoryRecord<string>>> {
935
- if (this._vectorIndex === undefined || this._embed === undefined) {
936
- return succeed(built);
937
- }
938
- const vectorIndex: IVectorIndex = this._vectorIndex;
939
- const embed: MemoryEmbedder = this._embed;
940
- const target: IEdgeTarget = { scope, id: built.envelope.id };
941
- const embedded: Result<Float32Array> = await this._tryVectorOp(
942
- () => embed(built),
943
- `embedding '${built.envelope.id}'`
944
- );
945
- if (embedded.isFailure()) {
946
- return succeed(built);
947
- }
948
- const added: Result<string> = await this._tryVectorOp(
949
- () => vectorIndex.add(target, embedded.value),
950
- `vector add for '${built.envelope.id}'`
951
- );
952
- if (added.isFailure()) {
953
- return succeed(built);
954
- }
955
- return succeed({ envelope: { ...built.envelope, embeddingRef: added.value }, body: built.body });
956
- }
957
-
958
- /**
959
- * Best-effort fragment-embed-on-write. When a fragment index AND a fragment
960
- * embedder are wired, chunks + embeds the built record and replaces its
961
- * fragments in the index (`addFragments` is whole-record-replace, so a re-authored
962
- * document never leaves stale fragments behind — no explicit remove needed). A
963
- * failure (returned `fail` OR a thrown/rejected hook) is logged and the record is
964
- * returned unchanged — the put still persists, and the fragment index is a derived
965
- * view a later `rebuild` reconciles. Unlike {@link FileTreeMemoryStore._embedOnWrite}
966
- * it stamps nothing on the record (fragments have no per-record `embeddingRef`
967
- * analog). A pass-through no-op when unwired (byte-identical record).
968
- */
969
- private async _embedFragmentsOnWrite(
970
- built: IMemoryRecord<string>,
971
- scope: MemoryScopeKey
972
- ): Promise<Result<IMemoryRecord<string>>> {
973
- if (this._fragmentIndex === undefined || this._fragmentEmbedder === undefined) {
974
- return succeed(built);
975
- }
976
- const fragmentIndex: IFragmentVectorIndex = this._fragmentIndex;
977
- const fragmentEmbedder: FragmentEmbedder = this._fragmentEmbedder;
978
- const target: IEdgeTarget = { scope, id: built.envelope.id };
979
- const embedded: Result<ReadonlyArray<IEmbeddedFragment>> = await this._tryVectorOp(
980
- () => fragmentEmbedder(built),
981
- `fragment embedding '${built.envelope.id}'`
982
- );
983
- if (embedded.isFailure()) {
984
- return succeed(built);
985
- }
986
- await this._tryVectorOp(
987
- () => fragmentIndex.addFragments(target, embedded.value),
988
- `fragment add for '${built.envelope.id}'`
989
- );
990
- return succeed(built);
991
- }
992
-
993
- /**
994
- * Best-effort fragment removal. A no-op unless the full fragment lifecycle is
995
- * wired (both an index AND an embedder), so an unwired store does no fragment
996
- * work and behaves byte-identically. Failures are logged, never surfaced — a
997
- * committed delete/eviction must not fail because a derived fragment index could
998
- * not be pruned.
999
- */
1000
- private async _removeFragmentsBestEffort(target: IEdgeTarget): Promise<void> {
1001
- if (this._fragmentIndex === undefined || this._fragmentEmbedder === undefined) {
1002
- return;
1003
- }
1004
- const fragmentIndex: IFragmentVectorIndex = this._fragmentIndex;
1005
- await this._tryVectorOp(() => fragmentIndex.remove(target), `fragment removal for '${target.id}'`);
1006
- }
1007
-
1008
- /**
1009
- * Evict the records named by a `cull-oldest` decision, best-effort. Runs only
1010
- * after the authoritative `_persist`, so a failed eviction is logged (never
1011
- * fatal) and the cap self-corrects on the next admission. Returns the ids that
1012
- * were actually evicted (so observations / vector pruning cover only those).
1013
- * `accept` / `reject` decisions evict nothing.
1014
- */
1015
- private _applyEvictions(decision: AdmissionDecision, scope: MemoryScopeKey): ReadonlyArray<MemoryId> {
1016
- if (decision.decision !== 'cull-oldest') {
1017
- return [];
1018
- }
1019
- const evicted: MemoryId[] = [];
1020
- for (const id of decision.evict) {
1021
- const result: Result<MemoryId> = this._evict(scope, id);
1022
- if (result.isSuccess()) {
1023
- evicted.push(result.value);
1024
- } else {
1025
- this._warnSwallowed(
1026
- `memory put: best-effort eviction of '${id}' failed (cap may be transiently exceeded; restored on the next write): ${result.message}`
1027
- );
1028
- }
1029
- }
1030
- return evicted;
1031
- }
1032
-
1033
- /**
1034
- * Best-effort vector removal for each evicted record (never fails the put).
1035
- * Every evicted record is in the same `scope` as the incoming write (the
1036
- * cull-oldest cohort is the incoming record's `(scope, kind)` cohort), so that
1037
- * scope qualifies each removal target.
1038
- */
1039
- private async _removeEvictedVectors(
1040
- evicted: ReadonlyArray<MemoryId>,
1041
- scope: MemoryScopeKey
1042
- ): Promise<void> {
1043
- for (const id of evicted) {
1044
- await this._removeVectorBestEffort({ scope, id });
1045
- await this._removeFragmentsBestEffort({ scope, id });
1046
- }
1047
- }
1048
-
1049
- /**
1050
- * Run a consumer-supplied vector hook, normalizing a thrown/rejected hook into a
1051
- * `Failure` and logging any failure at `warn`. Best-effort: the caller proceeds
1052
- * regardless, since the index is rebuildable.
1053
- */
1054
- private async _tryVectorOp<T>(op: () => Promise<Result<T>>, label: string): Promise<Result<T>> {
1055
- let result: Result<T>;
1056
- try {
1057
- result = await op();
1058
- } catch (err) {
1059
- result = fail(`${label} threw: ${String(err)}`);
1060
- }
1061
- if (result.isFailure()) {
1062
- this._warnSwallowed(
1063
- `memory: ${label} failed (best-effort; derived index left for rebuild): ${result.message}`
1064
- );
1065
- }
1066
- return result;
1067
- }
1068
-
1069
- /**
1070
- * Best-effort vector removal. A no-op unless the full vector lifecycle is wired
1071
- * (both an index AND an embedder), so an unwired store does no vector work and
1072
- * behaves byte-identically. Failures are logged, never surfaced — a committed
1073
- * delete/eviction must not fail because a derived index could not be pruned.
1074
- */
1075
- private async _removeVectorBestEffort(target: IEdgeTarget): Promise<void> {
1076
- if (this._vectorIndex === undefined || this._embed === undefined) {
1077
- return;
1078
- }
1079
- const vectorIndex: IVectorIndex = this._vectorIndex;
1080
- await this._tryVectorOp(() => vectorIndex.remove(target), `vector removal for '${target.id}'`);
1081
- }
1082
-
1083
- /**
1084
- * Build the record to persist. On a first write the incoming envelope is the
1085
- * base (final content equals the incoming content, so the dedup `hash` is
1086
- * reused). On an update the incoming record's mutable fields are projected into
1087
- * a merge-patch and the policy's `applyUpdate` merges them over the existing
1088
- * record (preserving `created`); the persisted body and `contentHash` are then
1089
- * taken from the policy's actual output, so a body-transforming policy is never
1090
- * bypassed and the stored hash always matches the stored `{ kind, body, links }`.
1091
- * The store stamps the transaction-time metadata it owns
1092
- * (`created` / `updated` / `seq` / `contentHash`).
1093
- */
1094
- private _buildRecord(
1095
- incoming: IMemoryRecord<unknown>,
1096
- body: string,
1097
- existing: IMemoryRecord<unknown> | undefined,
1098
- policy: IWritePolicy,
1099
- hash: string
1100
- ): Result<IMemoryRecord<string>> {
1101
- const now: number = this._clock();
1102
- const seq: number = ++this._seq;
1103
- if (existing === undefined) {
1104
- return succeed({
1105
- envelope: { ...incoming.envelope, created: now, updated: now, seq, contentHash: hash },
1106
- body
1107
- });
1108
- }
1109
- const patch: Record<string, unknown> = this._projectMutablePatch(incoming, policy.mutableFields);
1110
- return policy
1111
- .applyUpdate(existing, patch)
1112
- .withErrorFormat((msg) => `memory put '${incoming.envelope.id}': update failed: ${msg}`)
1113
- .onSuccess((updated) => {
1114
- if (typeof updated.body !== 'string') {
1115
- return fail(
1116
- `memory put '${incoming.envelope.id}': policy returned a non-string body (${typeof updated.body})`
1117
- );
1118
- }
1119
- const finalBody: string = updated.body;
1120
- return this._contentHash(updated.envelope.kind, finalBody, updated.envelope.links).onSuccess(
1121
- (finalHash) =>
1122
- succeed({
1123
- envelope: {
1124
- ...updated.envelope,
1125
- created: existing.envelope.created,
1126
- updated: now,
1127
- seq,
1128
- contentHash: finalHash
1129
- },
1130
- body: finalBody
1131
- })
1132
- );
1133
- });
1134
- }
1135
-
1136
- /** Serialize and write a fully-stamped record, then patch the index. */
1137
- private _persist(
1138
- record: IMemoryRecord<string>,
1139
- scope: MemoryScopeKey,
1140
- idStem: string
1141
- ): Result<IMemoryRecord<unknown>> {
1142
- return serializeMemoryFile(record.envelope, record.body)
1143
- .onSuccess((raw) => this._writeFile(scope, idStem, raw))
1144
- .onSuccess(() => this._index.patch('put', { scope, record }))
1145
- .onSuccess(() => succeed(record));
1146
- }
1147
-
1148
- private async _deleteLocked(kind: Kind, entityId: EntityId): Promise<Result<MemoryId>> {
1149
- return this._codecFor(kind).thenOnSuccess((codec) =>
1150
- codec.encode(entityId).thenOnSuccess((addr) => {
1151
- if (addr.isVersioned) {
1152
- if (!isTemporalIdentityCodec(codec)) {
1153
- return Promise.resolve(
1154
- fail<MemoryId>(
1155
- `memory delete '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`
1156
- )
1157
- );
1158
- }
1159
- return this._deleteVersioned(entityId, addr.scope);
1160
- }
1161
- return this._deleteFlat(entityId, addr.scope, addr.idStem);
1162
- })
1163
- );
1164
- }
1165
-
1166
- /**
1167
- * Flat (non-versioned) delete: physically remove the record file + index
1168
- * entry, then prune the vector best-effort. Structurally unchanged from the
1169
- * pre-temporal delete path.
1170
- */
1171
- private async _deleteFlat(
1172
- entityId: EntityId,
1173
- scope: MemoryScopeKey,
1174
- idStem: string
1175
- ): Promise<Result<MemoryId>> {
1176
- return this._readRecord(scope, idStem).thenOnSuccess((existing) => {
1177
- if (existing === undefined) {
1178
- return Promise.resolve(fail<MemoryId>(`memory delete '${entityId}': no record found`));
1179
- }
1180
- // Delete the record file + index entry (authoritative), then prune the
1181
- // vector best-effort: a committed delete must not fail because the
1182
- // derived index could not be pruned.
1183
- return this._deleteFile(scope, idStem)
1184
- .onSuccess(() => this._index.patch('delete', { scope, record: existing }))
1185
- .thenOnSuccess(async () => {
1186
- await this._removeVectorBestEffort({ scope, id: existing.envelope.id });
1187
- await this._removeFragmentsBestEffort({ scope, id: existing.envelope.id });
1188
- return succeed(existing.envelope.id);
1189
- });
1190
- });
1191
- }
1192
-
1193
- /**
1194
- * Resolve the current version of a temporal entity from the derived index: the
1195
- * highest-`seq` version under the entity subtree `scope` whose `invalid_at` is
1196
- * null/absent. `undefined` when the entity has no current version (never
1197
- * written, or fully invalidated / soft-deleted).
1198
- */
1199
- private _readVersionedCurrent(scope: MemoryScopeKey): IMemoryRecord<unknown> | undefined {
1200
- return selectCurrentVersion(this._versionsForEntity(scope));
1201
- }
1202
-
1203
- /**
1204
- * Every persisted version of the entity whose subtree is `scope`. All version
1205
- * files for one entity live under exactly that scope (which encodes the
1206
- * entityId), so a scope filter over the index isolates one entity's versions.
1207
- */
1208
- private _versionsForEntity(scope: MemoryScopeKey): ReadonlyArray<IMemoryRecord<unknown>> {
1209
- return this._index
1210
- .entries()
1211
- .filter((entry) => entry.scope === scope)
1212
- .map((entry) => entry.record);
1213
- }
1214
-
1215
- /**
1216
- * Versioned write (invalidate-don't-delete). Builds the new version's content
1217
- * (a first version from the incoming record, or a merge of the incoming patch
1218
- * over the current version), persists it as a NEW version file, then sets
1219
- * `invalid_at` on the prior current version.
1220
- *
1221
- * Durability order: the new version is persisted FIRST. It carries the highest
1222
- * `seq`, so a crash before the prior-version invalidation completes still
1223
- * resolves the new version as current (`selectCurrentVersion` breaks a
1224
- * two-current tie by highest `seq`), and `asOf` reads stay correct because each
1225
- * version's `valid_at` lower-bounds its interval.
1226
- */
1227
- private async _putVersioned(
1228
- record: IMemoryRecord<unknown>,
1229
- body: string,
1230
- codec: ITemporalIdentityCodec,
1231
- scope: MemoryScopeKey
1232
- ): Promise<Result<IPutOutcome>> {
1233
- const envelope: IMemoryEnvelope = record.envelope;
1234
- const entityId: EntityId = envelope.entityId;
1235
- const kind: Kind = envelope.kind;
1236
- // Snapshot the entity's versions BEFORE the write. `priorCurrents` is every
1237
- // still-current version at snapshot time — normally one, but two-or-more if a
1238
- // prior invalidation partially failed; invalidating all of them lets the write
1239
- // self-heal a stuck state (P2-7).
1240
- const versions: ReadonlyArray<IMemoryRecord<unknown>> = this._versionsForEntity(scope);
1241
- const priorCurrents: ReadonlyArray<IMemoryRecord<unknown>> = versions.filter(isVersionCurrent);
1242
- const current: IMemoryRecord<unknown> | undefined = selectCurrentVersion(versions);
1243
- const policy: IWritePolicy = this._policyFor(kind);
1244
- const dedupScope: DedupScope = policy.dedupScope ?? DEFAULT_DEDUP_SCOPE;
1245
- return this._contentHash(kind, body, envelope.links).thenOnSuccess((hash) => {
1246
- // Entity-scoped dedup: a re-put is a no-op only when the CURRENT content AND
1247
- // its mutable metadata are unchanged (does not spawn a redundant version).
1248
- // A metadata-only revision (tags/provenance — declared mutable by
1249
- // TemporalVersionedPolicy) must NOT be swallowed here: it mints a new
1250
- // version via the applyUpdate merge in `_buildVersionedRecord`, mirroring
1251
- // the flat path. Content-scoped dedup is not a versioning concern, so only
1252
- // the entity granularity is honored here.
1253
- if (
1254
- dedupScope === 'entity' &&
1255
- current !== undefined &&
1256
- current.envelope.contentHash === hash &&
1257
- this._isMutableMetadataUnchanged(current, record)
1258
- ) {
1259
- return Promise.resolve(succeed<IPutOutcome>({ record: current, evicted: [] }));
1260
- }
1261
- // On the versioned path the admission cohort is the entity's ENTIRE version
1262
- // history (no target id to exclude — the new version does not exist yet),
1263
- // which differs from the flat path's "cohort excluding target id" shape.
1264
- return policy.admit(record, versions).thenOnSuccess((decision) => {
1265
- if (decision.decision === 'reject') {
1266
- return Promise.resolve(fail<IPutOutcome>(`memory put: rejected by policy: ${decision.reason}`));
1267
- }
1268
- // No culling on the versioned path — history is retained (invalidate-don't-delete).
1269
- const now: number = this._clock();
1270
- const seq: number = ++this._seq;
1271
- // World-truth start of the new version. Each prior current version's
1272
- // world-truth interval CLOSES at this same instant (not at `now`) so a
1273
- // backdated/future-dated `valid_at` leaves no gap or overlap on the
1274
- // valid-time axis.
1275
- const validAt: number = envelope.temporal?.valid_at ?? now;
1276
- return codec
1277
- .encodeVersion(entityId, seq)
1278
- .withErrorFormat((msg) => `memory put '${entityId}': ${msg}`)
1279
- .thenOnSuccess((versionStem) =>
1280
- this._buildVersionedRecord(record, body, current, policy, hash, versionStem, validAt, now, seq)
1281
- .onSuccess((built) => succeed(this._stampRank(built)))
1282
- .thenOnSuccess((built) => this._embedOnWrite(built, scope))
1283
- .thenOnSuccess((built) => this._embedFragmentsOnWrite(built, scope))
1284
- .onSuccess((embeddedBuilt) => this._persist(embeddedBuilt, scope, versionStem))
1285
- .onSuccess((persisted) =>
1286
- this._invalidateCurrents(scope, priorCurrents, validAt, now).onSuccess(() =>
1287
- succeed(persisted)
1288
- )
1289
- )
1290
- .onSuccess((persisted) => succeed({ record: persisted, evicted: [] }))
1291
- );
1292
- });
1293
- });
1294
- }
1295
-
1296
- /**
1297
- * Invalidate a set of still-current versions (close each world-truth interval at
1298
- * `invalidAt`; stamp transaction-time `updated` = `now`). Chained so a mid-list
1299
- * failure propagates. Invalidating every prior current — not just the
1300
- * highest-`seq` pick — self-heals a state where a previous write's invalidation
1301
- * only partially completed (P2-7).
1302
- */
1303
- private _invalidateCurrents(
1304
- scope: MemoryScopeKey,
1305
- currents: ReadonlyArray<IMemoryRecord<unknown>>,
1306
- invalidAt: number,
1307
- now: number
1308
- ): Result<true> {
1309
- return currents.reduce<Result<true>>(
1310
- (acc, version) =>
1311
- acc.onSuccess(() =>
1312
- this._invalidateVersion(scope, version, invalidAt, now).onSuccess(() => succeed(true))
1313
- ),
1314
- succeed(true)
1315
- );
1316
- }
1317
-
1318
- /**
1319
- * Build the new version to persist. A first version takes the incoming
1320
- * content verbatim (its dedup `hash` is reused); a subsequent version projects
1321
- * the incoming record's mutable fields into a merge-patch and lets the policy
1322
- * merge them over the CURRENT version (the merge-patch-under-versioning
1323
- * contract), recomputing the content hash from the policy's output. Each
1324
- * version is its own record with its own transaction time (`created` = `now`)
1325
- * and a store-minted `id` = the version stem, so `id === filename stem` holds.
1326
- */
1327
- private _buildVersionedRecord(
1328
- incoming: IMemoryRecord<unknown>,
1329
- body: string,
1330
- current: IMemoryRecord<unknown> | undefined,
1331
- policy: IWritePolicy,
1332
- // Reused only for the first-version branch (its content is taken verbatim); a
1333
- // subsequent version recomputes its hash from the merge-patched content.
1334
- firstVersionHash: string,
1335
- versionStem: string,
1336
- validAt: number,
1337
- now: number,
1338
- seq: number
1339
- ): Result<IMemoryRecord<string>> {
1340
- const mintedId: MemoryId = versionStem as MemoryId;
1341
- if (current === undefined) {
1342
- const envelope: IMemoryEnvelope = {
1343
- ...incoming.envelope,
1344
- id: mintedId,
1345
- entityId: incoming.envelope.entityId,
1346
- created: now,
1347
- updated: now,
1348
- seq,
1349
- contentHash: firstVersionHash,
1350
- temporal: { valid_at: validAt }
1351
- };
1352
- return succeed({ envelope, body });
1353
- }
1354
- const patch: Record<string, unknown> = this._projectMutablePatch(incoming, policy.mutableFields);
1355
- return policy
1356
- .applyUpdate(current, patch)
1357
- .withErrorFormat((msg) => `memory put '${incoming.envelope.entityId}': update failed: ${msg}`)
1358
- .onSuccess((updated) => {
1359
- if (typeof updated.body !== 'string') {
1360
- return fail(
1361
- `memory put '${
1362
- incoming.envelope.entityId
1363
- }': policy returned a non-string body (${typeof updated.body})`
1364
- );
1365
- }
1366
- const finalBody: string = updated.body;
1367
- return this._contentHash(updated.envelope.kind, finalBody, updated.envelope.links).onSuccess(
1368
- (finalHash) => {
1369
- const envelope: IMemoryEnvelope = {
1370
- ...updated.envelope,
1371
- id: mintedId,
1372
- entityId: incoming.envelope.entityId,
1373
- created: now,
1374
- updated: now,
1375
- seq,
1376
- contentHash: finalHash,
1377
- temporal: { valid_at: validAt }
1378
- };
1379
- return succeed({ envelope, body: finalBody });
1380
- }
1381
- );
1382
- });
1383
- }
1384
-
1385
- /**
1386
- * Set `invalid_at` on a prior current version (invalidate-don't-delete) and
1387
- * rewrite its file + index entry. The content hash is unchanged — `invalid_at`
1388
- * is temporal metadata, not part of `{ kind, body, links }` — so the version's
1389
- * identity is stable.
1390
- */
1391
- private _invalidateVersion(
1392
- scope: MemoryScopeKey,
1393
- version: IMemoryRecord<unknown>,
1394
- invalidAt: number,
1395
- now: number
1396
- ): Result<IMemoryRecord<unknown>> {
1397
- /* c8 ignore start -- defensive: every persisted version carries a string body (only string bodies are written) */
1398
- if (typeof version.body !== 'string') {
1399
- return fail(`memory put: cannot invalidate version '${version.envelope.id}': non-string body`);
1400
- }
1401
- /* c8 ignore stop */
1402
- // `invalid_at` is the WORLD-TRUTH close of the interval (the superseding version's
1403
- // `valid_at`, or the delete instant); `updated` is the transaction-time stamp.
1404
- const invalidated: IMemoryRecord<string> = {
1405
- envelope: {
1406
- ...version.envelope,
1407
- updated: now,
1408
- temporal: { ...version.envelope.temporal, invalid_at: invalidAt }
1409
- },
1410
- body: version.body
1411
- };
1412
- return this._persist(invalidated, scope, version.envelope.id);
1413
- }
1414
-
1415
- /**
1416
- * Versioned delete: SOFT delete (invalidate-don't-delete). Sets `invalid_at` on
1417
- * the current version, leaving the entity's history intact and the entity with
1418
- * no current version. Returns the invalidated version's {@link MemoryId}. Fails
1419
- * with "no record found" when there is no current version — matching the flat
1420
- * delete's not-found semantics. History is retained deliberately: temporal
1421
- * kinds exist to preserve the audit trail (and the L3 `contradicts` interlock
1422
- * builds on it), so a hard delete would defeat the purpose.
1423
- */
1424
- private async _deleteVersioned(entityId: EntityId, scope: MemoryScopeKey): Promise<Result<MemoryId>> {
1425
- const versions: ReadonlyArray<IMemoryRecord<unknown>> = this._versionsForEntity(scope);
1426
- const currents: ReadonlyArray<IMemoryRecord<unknown>> = versions.filter(isVersionCurrent);
1427
- const current: IMemoryRecord<unknown> | undefined = selectCurrentVersion(versions);
1428
- if (current === undefined) {
1429
- return fail(`memory delete '${entityId}': no record found`);
1430
- }
1431
- // A delete closes the world-truth interval at the delete instant (`now` for both
1432
- // the transaction stamp and the `invalid_at` boundary). Every still-current
1433
- // version is invalidated (self-heals a stuck two-current state — P2-7); the
1434
- // highest-`seq` current's id is returned.
1435
- const now: number = this._clock();
1436
- return this._invalidateCurrents(scope, currents, now, now).onSuccess(() => succeed(current.envelope.id));
1437
- }
1438
-
1439
- /** Evict (physically delete) a single record file by id, patching the index. */
1440
- private _evict(scope: MemoryScopeKey, id: MemoryId): Result<MemoryId> {
1441
- return this._readRecord(scope, id).onSuccess((existing) => {
1442
- if (existing === undefined) {
1443
- return fail(`memory put: cannot evict '${id}' in scope '${scope}': not found`);
1444
- }
1445
- return this._deleteFile(scope, id)
1446
- .onSuccess(() => this._index.patch('delete', { scope, record: existing }))
1447
- .onSuccess(() => succeed(id));
1448
- });
1449
- }
1450
-
1451
- /** Project the incoming record's mutable fields into a merge-patch. */
1452
- private _projectMutablePatch(
1453
- record: IMemoryRecord<unknown>,
1454
- mutableFields: ReadonlyArray<string>
1455
- ): Record<string, unknown> {
1456
- const patch: Record<string, unknown> = {};
1457
- for (const field of mutableFields) {
1458
- const accessor: ((record: IMemoryRecord<unknown>) => unknown) | undefined =
1459
- FileTreeMemoryStore._mutableFieldAccessors.get(field);
1460
- if (accessor !== undefined) {
1461
- const value: unknown = accessor(record);
1462
- if (value !== undefined) {
1463
- patch[field] = value;
1464
- }
1465
- }
1466
- }
1467
- return patch;
1468
- }
1469
-
1470
- /**
1471
- * The admission cohort for a write: every indexed record in `scope` of `kind`
1472
- * except the one at `idStem` (the record being written or updated). This is
1473
- * the set a per-kind cap (e.g. {@link MemoryCapCullPolicy}) counts against, so
1474
- * a bounded-ring policy can keep a per-scope/per-kind family within
1475
- * `maxRecords`. Excluding the target id makes the post-write count uniform
1476
- * across first-writes and updates.
1477
- */
1478
- private _admissionCohort(
1479
- scope: MemoryScopeKey,
1480
- kind: Kind,
1481
- idStem: string
1482
- ): ReadonlyArray<IMemoryRecord<unknown>> {
1483
- return this._index
1484
- .entries()
1485
- .filter(
1486
- (entry) =>
1487
- entry.scope === scope && entry.record.envelope.kind === kind && entry.record.envelope.id !== idStem
1488
- )
1489
- .map((entry) => entry.record);
1490
- }
1491
-
1492
- /**
1493
- * Find a record in `scope` whose `contentHash` equals `hash`, if any,
1494
- * optionally excluding a specific id. `excludeId` lets the content-scoped
1495
- * dedup skip the same-id record so it does not shadow the LWW update path.
1496
- */
1497
- private _findByContentHash(
1498
- scope: MemoryScopeKey,
1499
- hash: string,
1500
- excludeId?: string
1501
- ): IMemoryRecord<unknown> | undefined {
1502
- const match: IIndexedMemoryRecord | undefined = this._index
1503
- .entries()
1504
- .find(
1505
- (entry) =>
1506
- entry.scope === scope &&
1507
- entry.record.envelope.contentHash === hash &&
1508
- entry.record.envelope.id !== excludeId
1509
- );
1510
- return match?.record;
1511
- }
1512
-
1513
- /**
1514
- * True when `incoming`'s caller-authored mutable metadata (`tags` / `provenance`)
1515
- * canonically equals `existing`'s. `body` and `links` are covered by the content
1516
- * hash; `embeddingRef` is store-derived (not caller metadata) and is deliberately
1517
- * excluded so a same-content re-put is not treated as changed merely because the
1518
- * store already stamped an embedding. Used to keep an identical re-put a no-op
1519
- * without swallowing a genuine metadata revision.
1520
- *
1521
- * Canonicalization never fails for a validated record (`tags`/`provenance` are
1522
- * always plain JSON); a failure is defaulted to a non-matching sentinel so the
1523
- * write flows to `applyUpdate` (which re-validates) rather than silently
1524
- * no-op-ing on an un-canonicalizable value.
1525
- */
1526
- private _isMutableMetadataUnchanged(
1527
- existing: IMemoryRecord<unknown>,
1528
- incoming: IMemoryRecord<unknown>
1529
- ): boolean {
1530
- const key = (record: IMemoryRecord<unknown>): string =>
1531
- this._hasher
1532
- .canonicalize({ tags: record.envelope.tags, provenance: record.envelope.provenance })
1533
- .orDefault('');
1534
- const existingKey: string = key(existing);
1535
- return existingKey !== '' && existingKey === key(incoming);
1536
- }
1537
-
1538
- private _contentHash(kind: Kind, body: string, links: IMemoryEnvelope['links']): Result<string> {
1539
- return this._hasher.computeHash({ kind, body, links });
1540
- }
1541
-
1542
- /**
1543
- * Stamp the store-computed {@link IMemoryEnvelope.rank} onto a fully-built,
1544
- * fully-stamped record by running the kind's registered {@link RankProjector}.
1545
- * Runs on the SAME resolved (post-merge) record whose `contentHash` was just
1546
- * computed, so `rank` is always consistent with the current body — no separate
1547
- * write path and no consumer write-discipline rule. A no-op pass-through
1548
- * (byte-identical record) when the kind has no projector — the additive,
1549
- * zero-overhead-when-unwired default. The projector is a host callback: a throw
1550
- * is logged at `warn` and the record is stamped with NO `rank` (the field is
1551
- * explicitly cleared, so a throw on an update drops a now-stale prior rank rather
1552
- * than preserving it), so a ranking bug never loses an authoritative write.
1553
- */
1554
- private _stampRank(record: IMemoryRecord<string>): IMemoryRecord<string> {
1555
- const projector: RankProjector | undefined = this._rankProjectors.get(record.envelope.kind);
1556
- if (projector === undefined) {
1557
- return record;
1558
- }
1559
- try {
1560
- const rank: number = projector(record);
1561
- return { envelope: { ...record.envelope, rank }, body: record.body };
1562
- } catch (err) {
1563
- this._warnSwallowed(
1564
- `memory put '${record.envelope.id}': rank projector threw (swallowed; rank cleared): ${String(err)}`
1565
- );
1566
- // Explicitly CLEAR `rank` (not `return record`): on an update the merged
1567
- // envelope carries the prior version's `rank` (the write-policy merge does
1568
- // not touch `rank`), so returning it verbatim would keep a stale value
1569
- // inconsistent with the revised body. Clearing honors the staleness
1570
- // contract — an uncomputable rank means the record sorts last as "unranked".
1571
- return { envelope: { ...record.envelope, rank: undefined }, body: record.body };
1572
- }
1573
- }
1574
-
1575
- private _codecFor(kind: Kind): Result<IIdentityCodec> {
1576
- const codec: IIdentityCodec | undefined = this._codecs.get(kind) ?? this._defaultCodec;
1577
- if (codec === undefined) {
1578
- return fail(`no identity codec registered for kind '${kind}'`);
1579
- }
1580
- return succeed(codec);
1581
- }
1582
-
1583
- private _policyFor(kind: Kind): IWritePolicy {
1584
- return this._writePolicies.get(kind) ?? this._defaultPolicy;
1585
- }
1586
-
1587
- /**
1588
- * Read and validate the record at `<scope>/<idStem>.md`, returning `undefined`
1589
- * when the scope directory or file is absent. Verifies the on-disk id ↔
1590
- * filename round-trip on every load.
1591
- */
1592
- private _readRecord(scope: MemoryScopeKey, idStem: string): Result<IMemoryRecord<unknown> | undefined> {
1593
- return this._resolveScopeDir(scope).onSuccess((scopeDir) => {
1594
- if (scopeDir === undefined) {
1595
- return succeed(undefined);
1596
- }
1597
- return scopeDir.getChildren().onSuccess((children) => {
1598
- const targetName: string = `${idStem}${MEMORY_FILE_EXTENSION}`;
1599
- const file: FileTree.IFileTreeFileItem | undefined = children.find(
1600
- (c): c is FileTree.IFileTreeFileItem => c.type === 'file' && c.name === targetName
1601
- );
1602
- if (file === undefined) {
1603
- return succeed(undefined);
1604
- }
1605
- return file
1606
- .getRawContents()
1607
- .onSuccess((raw) => parseMemoryFile(raw, this._registry))
1608
- .onSuccess((parsedRecord) => this._verifyLoaded(scope, file, parsedRecord));
1609
- });
1610
- });
1611
- }
1612
-
1613
- /**
1614
- * Enforce `envelope.id === filename stem`, the codec round-trip, AND that the
1615
- * envelope's own `entityId` agrees with the id decoded from the subtree scope.
1616
- * The round-trip only validates (scope, stem) consistency; the codec derives
1617
- * `entityId` from the scope path and never reads the envelope's `entityId`
1618
- * field, so a tampered/corrupt file whose frontmatter declares a foreign
1619
- * `entityId` would otherwise load undetected — and `entityId` is trusted
1620
- * verbatim downstream (e.g. merge-into re-addressing). Cross-check it here.
1621
- */
1622
- private _verifyLoaded(
1623
- scope: MemoryScopeKey,
1624
- file: FileTree.IFileTreeFileItem,
1625
- record: IMemoryRecord<unknown>
1626
- ): Result<IMemoryRecord<unknown>> {
1627
- if (record.envelope.id !== file.baseName) {
1628
- return fail(
1629
- `memory file '${file.absolutePath}': envelope id '${record.envelope.id}' does not match filename stem '${file.baseName}'`
1630
- );
1631
- }
1632
- return this._codecFor(record.envelope.kind)
1633
- .onSuccess((codec) =>
1634
- codec.verifyRoundTrip(scope, file.baseName).onSuccess(() => codec.decode(scope, file.baseName))
1635
- )
1636
- .withErrorFormat((msg) => `memory file '${file.absolutePath}': ${msg}`)
1637
- .onSuccess((decodedEntityId) => {
1638
- if (decodedEntityId !== record.envelope.entityId) {
1639
- return fail(
1640
- `memory file '${file.absolutePath}': envelope entityId '${record.envelope.entityId}' does not match scope-derived entityId '${decodedEntityId}'`
1641
- );
1642
- }
1643
- return succeed(record);
1644
- });
1645
- }
1646
-
1647
- /**
1648
- * Resolve the directory for a scope, returning `undefined` when it does not
1649
- * exist. Navigation only — does not create. Folds the path segments through
1650
- * `getChildren` so an absent segment short-circuits to `undefined`.
1651
- */
1652
- private _resolveScopeDir(scope: MemoryScopeKey): Result<FileTree.IFileTreeDirectoryItem | undefined> {
1653
- return this._scopeEncoding(scope).onSuccess((encoded) => {
1654
- const segments: string[] = encoded.split('/').filter((s) => s.length > 0);
1655
- return segments.reduce<Result<FileTree.IFileTreeDirectoryItem | undefined>>(
1656
- (acc, segment) =>
1657
- acc.onSuccess((current) => {
1658
- if (current === undefined) {
1659
- return succeed(undefined);
1660
- }
1661
- return current
1662
- .getChildren()
1663
- .onSuccess((children) =>
1664
- succeed(
1665
- children.find(
1666
- (c): c is FileTree.IFileTreeDirectoryItem => c.type === 'directory' && c.name === segment
1667
- )
1668
- )
1669
- );
1670
- }),
1671
- succeed(this._root)
1672
- );
1673
- });
1674
- }
1675
-
1676
- /** Ensure the scope directory exists, creating segments as needed. */
1677
- private _ensureScopeDir(scope: MemoryScopeKey): Result<FileTree.IMutableFileTreeDirectoryItem> {
1678
- return this._scopeEncoding(scope).onSuccess((encoded) => {
1679
- const segments: string[] = encoded.split('/').filter((s) => s.length > 0);
1680
- return segments.reduce<Result<FileTree.IMutableFileTreeDirectoryItem>>(
1681
- (acc, segment) =>
1682
- acc.onSuccess((current) =>
1683
- current.getChildren().onSuccess((children) => {
1684
- const existing: FileTree.FileTreeItem | undefined = children.find(
1685
- (c) => c.type === 'directory' && c.name === segment
1686
- );
1687
- if (existing === undefined) {
1688
- return current.createChildDirectory(segment);
1689
- }
1690
- /* c8 ignore next 3 -- defensive: a child of a mutable in-memory/fs tree is itself mutable; the guard protects against a read-only adapter handed in as root */
1691
- if (!FileTree.isMutableDirectoryItem(existing)) {
1692
- return fail(`${existing.absolutePath}: directory is not mutable`);
1693
- }
1694
- return succeed(existing);
1695
- })
1696
- ),
1697
- succeed(this._root)
1698
- );
1699
- });
1700
- }
1701
-
1702
- /** Write (create or overwrite) `<scope>/<idStem>.md` with `raw`. */
1703
- private _writeFile(scope: MemoryScopeKey, idStem: string, raw: string): Result<true> {
1704
- return this._ensureScopeDir(scope).onSuccess((scopeDir) =>
1705
- scopeDir.getChildren().onSuccess((children) => {
1706
- const fileName: string = `${idStem}${MEMORY_FILE_EXTENSION}`;
1707
- const existing: FileTree.FileTreeItem | undefined = children.find(
1708
- (c) => c.type === 'file' && c.name === fileName
1709
- );
1710
- if (existing === undefined) {
1711
- return scopeDir.createChildFile(fileName, raw).onSuccess(() => succeed(true));
1712
- }
1713
- /* c8 ignore next 3 -- defensive: a file in a mutable tree is mutable; guards a read-only adapter */
1714
- if (!FileTree.isMutableFileItem(existing)) {
1715
- return fail(`${existing.absolutePath}: file is not mutable`);
1716
- }
1717
- return existing.setRawContents(raw).onSuccess(() => succeed(true));
1718
- })
1719
- );
1720
- }
1721
-
1722
- /**
1723
- * Physically delete `<scope>/<idStem>.md`. The scope-missing and file-missing
1724
- * guards are unreachable through the callers (`delete` / `_evict` both read the
1725
- * record first, so the directory and file exist) but are kept so a future
1726
- * direct caller degrades loudly rather than silently.
1727
- */
1728
- private _deleteFile(scope: MemoryScopeKey, idStem: string): Result<true> {
1729
- return this._resolveScopeDir(scope).onSuccess((scopeDir) => {
1730
- /* c8 ignore next 3 -- unreachable: callers read the record (hence the scope dir) first */
1731
- if (scopeDir === undefined) {
1732
- return fail(`memory delete: scope '${scope}' not found`);
1733
- }
1734
- const fileName: string = `${idStem}${MEMORY_FILE_EXTENSION}`;
1735
- return scopeDir.getChildren().onSuccess((children) => {
1736
- const file: FileTree.FileTreeItem | undefined = children.find(
1737
- (c) => c.type === 'file' && c.name === fileName
1738
- );
1739
- /* c8 ignore next 3 -- unreachable: callers read the record (hence the file) first */
1740
- if (file === undefined) {
1741
- return fail(`memory delete: file '${fileName}' not found in scope '${scope}'`);
1742
- }
1743
- /* c8 ignore next 3 -- defensive: a file in a mutable tree is mutable; guards a read-only adapter */
1744
- if (!FileTree.isMutableFileItem(file)) {
1745
- return fail(`${file.absolutePath}: file is not mutable`);
1746
- }
1747
- return file.delete().onSuccess(() => succeed(true));
1748
- });
1749
- });
1750
- }
1751
-
1752
- /**
1753
- * Walk the FileTree once and rebuild the index. Also resumes the `seq`
1754
- * counter past the highest persisted `seq` so new writes stay monotonic.
1755
- *
1756
- * In `'skip'` mode each per-record failure is captured structurally on
1757
- * `this._skippedRecords` (path + scope + path-tagged error) at its failure
1758
- * site and logged at `warn`; the walk keeps every record that loaded.
1759
- */
1760
- private _initialIndex(onRecordError: MemoryRecordErrorMode): Result<true> {
1761
- return this._collectEntries(this._root, [], onRecordError).onSuccess((entries) =>
1762
- this._index.rebuild(entries).onSuccess(() => {
1763
- for (const entry of entries) {
1764
- if (entry.record.envelope.seq > this._seq) {
1765
- this._seq = entry.record.envelope.seq;
1766
- }
1767
- }
1768
- return succeed(true);
1769
- })
1770
- );
1771
- }
1772
-
1773
- /** Recursively collect every `.md` record under `dir` (scope = path segments). */
1774
- private _collectEntries(
1775
- dir: FileTree.IFileTreeDirectoryItem,
1776
- scopeSegments: ReadonlyArray<string>,
1777
- onRecordError: MemoryRecordErrorMode
1778
- ): Result<ReadonlyArray<IIndexedMemoryRecord>> {
1779
- return dir.getChildren().onSuccess((children) => {
1780
- const results: Result<ReadonlyArray<IIndexedMemoryRecord>>[] = children.map((child) => {
1781
- if (child.type === 'directory') {
1782
- return this._collectEntries(child, [...scopeSegments, child.name], onRecordError);
1783
- }
1784
- if (!child.name.endsWith(MEMORY_FILE_EXTENSION) || scopeSegments.length === 0) {
1785
- // Skip non-record files and any record-shaped file sitting at the root
1786
- // (records always live under at least one scope segment).
1787
- return succeed<ReadonlyArray<IIndexedMemoryRecord>>([]);
1788
- }
1789
- const scope: MemoryScopeKey = scopeSegments.join('/') as MemoryScopeKey;
1790
- return this._loadRecordFile(scope, child, onRecordError);
1791
- });
1792
- // `'skip'` mode: keep every record that parsed, drop the ones that failed
1793
- // in a single pass (each failure is captured on `this._skippedRecords` and
1794
- // warn-logged at its site in `_loadRecordFile`). `.orDefault([])` covers
1795
- // the all-invalid-subtree edge where `mapSuccess` returns Failure because
1796
- // no element succeeded. `'fail'` mode: `mapResults` fails the whole open on
1797
- // any bad record — byte-identical to the historical load path.
1798
- if (onRecordError === 'skip') {
1799
- return succeed<ReadonlyArray<IIndexedMemoryRecord>>(mapSuccess(results).orDefault([]).flat());
1800
- }
1801
- return mapResults(results).onSuccess((perChild) =>
1802
- succeed<ReadonlyArray<IIndexedMemoryRecord>>(perChild.flat())
1803
- );
1804
- });
1805
- }
1806
-
1807
- /**
1808
- * Load and verify one record file. On failure in `'skip'` mode, records the
1809
- * structured {@link ISkippedRecord} identity (path + scope + path-tagged
1810
- * error) and logs the skip at `warn`; the failure is still returned so the
1811
- * caller's `mapSuccess` drops it from the loaded set. In `'fail'` mode the
1812
- * failure passes through untouched so the historical error is byte-identical.
1813
- */
1814
- private _loadRecordFile(
1815
- scope: MemoryScopeKey,
1816
- child: FileTree.IFileTreeFileItem,
1817
- onRecordError: MemoryRecordErrorMode
1818
- ): Result<ReadonlyArray<IIndexedMemoryRecord>> {
1819
- return child
1820
- .getRawContents()
1821
- .onSuccess((raw) => parseMemoryFile(raw, this._registry))
1822
- .onSuccess((parsedRecord) => this._verifyLoaded(scope, child, parsedRecord))
1823
- .onSuccess((verified) => succeed<ReadonlyArray<IIndexedMemoryRecord>>([{ scope, record: verified }]))
1824
- .onFailure((message) => {
1825
- if (onRecordError === 'skip') {
1826
- const path: string = `${scope}/${child.name}`;
1827
- const error: string = `memory record '${path}': ${message}`;
1828
- this._skippedRecords.push({ path, scope, error });
1829
- this._warnSwallowed(error);
1830
- }
1831
- return fail(message);
1832
- });
1833
- }
1834
- }