@remnic/core 9.5.0 → 9.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/dist/access-admin-ops-surface.d.ts +6 -6
  2. package/dist/access-admin-ops-surface.js +12 -12
  3. package/dist/access-boundary.d.ts +6 -6
  4. package/dist/access-boundary.js +13 -13
  5. package/dist/access-cli.js +36 -36
  6. package/dist/access-http.d.ts +6 -6
  7. package/dist/access-http.js +17 -17
  8. package/dist/access-identity-continuity-surface.d.ts +5 -5
  9. package/dist/access-identity-continuity-surface.js +12 -12
  10. package/dist/access-lcm-surface.d.ts +6 -6
  11. package/dist/access-lcm-surface.js +12 -12
  12. package/dist/access-mcp.d.ts +6 -6
  13. package/dist/access-mcp.js +16 -16
  14. package/dist/access-observe-write-surface.d.ts +6 -6
  15. package/dist/access-observe-write-surface.js +12 -12
  16. package/dist/access-operations-batch.js +14 -14
  17. package/dist/access-operations.d.ts +7 -7
  18. package/dist/access-operations.js +15 -15
  19. package/dist/access-recall-surface.d.ts +6 -6
  20. package/dist/access-recall-surface.js +12 -12
  21. package/dist/access-schema.d.ts +4 -4
  22. package/dist/{access-service-DZX0sYAv.d.ts → access-service-CXJaql3A.d.ts} +4 -4
  23. package/dist/access-service.d.ts +6 -6
  24. package/dist/access-service.js +12 -12
  25. package/dist/access-surface-catalog.d.ts +6 -6
  26. package/dist/action-confidence.d.ts +1 -1
  27. package/dist/active-memory-bridge.d.ts +1 -1
  28. package/dist/active-recall.d.ts +1 -1
  29. package/dist/active-recall.js +1 -1
  30. package/dist/{auto-sync-G6IU7L6C.js → auto-sync-4A5YBWYO.js} +3 -3
  31. package/dist/behavior-learner.d.ts +1 -1
  32. package/dist/behavior-signals.d.ts +1 -1
  33. package/dist/bootstrap.d.ts +5 -5
  34. package/dist/briefing.d.ts +2 -2
  35. package/dist/briefing.js +4 -4
  36. package/dist/buffer-surprise-report.d.ts +1 -1
  37. package/dist/buffer.d.ts +2 -2
  38. package/dist/calibration.d.ts +1 -1
  39. package/dist/capabilities.d.ts +1 -1
  40. package/dist/{catalog-DoH2b0OR.d.ts → catalog-C9cu67Ig.d.ts} +1 -1
  41. package/dist/causal-behavior.d.ts +1 -1
  42. package/dist/causal-consolidation.d.ts +1 -1
  43. package/dist/causal-consolidation.js +5 -5
  44. package/dist/causal-trajectory-graph.d.ts +1 -1
  45. package/dist/{chunk-H5PFR5ZT.js → chunk-23JFRB73.js} +2 -2
  46. package/dist/{chunk-BGMSRN6I.js → chunk-3XIY7MDQ.js} +3 -3
  47. package/dist/{chunk-L2QJQSHL.js → chunk-4GNEDXDI.js} +2 -2
  48. package/dist/{chunk-MFDJ63U5.js → chunk-4LV23CHK.js} +2 -2
  49. package/dist/{chunk-AER6MT24.js → chunk-6SXVCD7W.js} +2 -2
  50. package/dist/{chunk-2SFEFR5I.js → chunk-72WG5QKN.js} +2 -2
  51. package/dist/{chunk-7BAJAXGB.js → chunk-7MW3CVLD.js} +2 -2
  52. package/dist/{chunk-SNNIUDRS.js → chunk-AYZJID4S.js} +2 -2
  53. package/dist/{chunk-M7XQSUBB.js → chunk-B3ABVQ36.js} +116 -9
  54. package/dist/chunk-B3ABVQ36.js.map +1 -0
  55. package/dist/{chunk-LNHJ32NK.js → chunk-BEM4D2QG.js} +3 -3
  56. package/dist/{chunk-AOYJVCFN.js → chunk-CYWA2CXR.js} +2 -2
  57. package/dist/{chunk-2JFDHI2K.js → chunk-DEG5ULFJ.js} +2 -2
  58. package/dist/{chunk-L6V3WJ55.js → chunk-DS7MM2ES.js} +3 -3
  59. package/dist/{chunk-CXEL3Y22.js → chunk-FKCRGLRD.js} +184 -29
  60. package/dist/{chunk-CXEL3Y22.js.map → chunk-FKCRGLRD.js.map} +1 -1
  61. package/dist/{chunk-TI7IHDBQ.js → chunk-H2DXDQCQ.js} +5 -5
  62. package/dist/{chunk-KMF67QDQ.js → chunk-HLN7RROI.js} +4 -4
  63. package/dist/{chunk-D2XYYU5Q.js → chunk-J3XHPFEU.js} +2 -2
  64. package/dist/{chunk-ODSYJXDU.js → chunk-JSNVWNGX.js} +2 -2
  65. package/dist/{chunk-7Z2DZNWN.js → chunk-KL4UAXUY.js} +43 -2
  66. package/dist/{chunk-7Z2DZNWN.js.map → chunk-KL4UAXUY.js.map} +1 -1
  67. package/dist/{chunk-4WNLSSHL.js → chunk-LADFWYHY.js} +4 -4
  68. package/dist/{chunk-UBGAOYBP.js → chunk-LR5DZ56F.js} +2 -2
  69. package/dist/{chunk-5K5VEDFY.js → chunk-N3JDIRBW.js} +2 -2
  70. package/dist/{chunk-2B7DP7AP.js → chunk-P454KXD6.js} +93 -15
  71. package/dist/chunk-P454KXD6.js.map +1 -0
  72. package/dist/{chunk-VWEWK2BM.js → chunk-PCLIWBGD.js} +3 -3
  73. package/dist/{chunk-TV7C3HCB.js → chunk-PNLFN2PZ.js} +2 -2
  74. package/dist/{chunk-STCCFQU5.js → chunk-PVUJ6WVV.js} +6 -6
  75. package/dist/{chunk-ITWTKT2R.js → chunk-RGK27ELN.js} +2 -2
  76. package/dist/{chunk-II6WHCCW.js → chunk-SIRPLF54.js} +2 -2
  77. package/dist/{chunk-UDCDGP6A.js → chunk-SVOHGUMI.js} +1188 -112
  78. package/dist/chunk-SVOHGUMI.js.map +1 -0
  79. package/dist/{chunk-NFTABV56.js → chunk-TLUDFPZL.js} +2 -2
  80. package/dist/{chunk-I74ZEBMA.js → chunk-UFUKHGZD.js} +4 -4
  81. package/dist/{chunk-L4UZBJNM.js → chunk-UTRJFDP2.js} +2 -2
  82. package/dist/{chunk-5GPPACXK.js → chunk-VSMRDBFT.js} +2 -1
  83. package/dist/{chunk-SNBEVJBX.js → chunk-XCL57M6Q.js} +3 -3
  84. package/dist/{chunk-DZSAXE3T.js → chunk-Y7UNNAZD.js} +2 -2
  85. package/dist/{chunk-XN3TUUX2.js → chunk-YQB32INI.js} +2 -2
  86. package/dist/{cli-1dghPCO2.d.ts → cli-CqdPA6z9.d.ts} +3 -3
  87. package/dist/cli.d.ts +7 -7
  88. package/dist/cli.js +29 -29
  89. package/dist/compounding/engine.d.ts +2 -2
  90. package/dist/compounding/engine.js +4 -4
  91. package/dist/compounding/preference-consolidator.d.ts +1 -1
  92. package/dist/compression-optimizer.d.ts +1 -1
  93. package/dist/config.d.ts +1 -1
  94. package/dist/config.js +1 -1
  95. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  96. package/dist/connectors/codex-materialize-runner.js +4 -4
  97. package/dist/connectors/codex-materialize.d.ts +1 -1
  98. package/dist/connectors/index.d.ts +1 -1
  99. package/dist/connectors/index.js +4 -4
  100. package/dist/consolidation-provenance-check.d.ts +2 -2
  101. package/dist/consolidation-undo.d.ts +2 -2
  102. package/dist/contradiction/index.d.ts +2 -2
  103. package/dist/conversation-index/backend.d.ts +1 -1
  104. package/dist/conversation-index/chunker.d.ts +1 -1
  105. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  106. package/dist/conversation-index/indexer.d.ts +1 -1
  107. package/dist/conversation-index/search.d.ts +1 -1
  108. package/dist/day-summary.d.ts +1 -1
  109. package/dist/delinearize.d.ts +1 -1
  110. package/dist/direct-answer-wiring.d.ts +1 -1
  111. package/dist/direct-answer.d.ts +1 -1
  112. package/dist/embedding-fallback.d.ts +1 -1
  113. package/dist/enrichment/index.d.ts +1 -1
  114. package/dist/entity-retrieval.d.ts +2 -2
  115. package/dist/entity-retrieval.js +4 -4
  116. package/dist/entity-schema.d.ts +1 -1
  117. package/dist/explicit-capture.d.ts +5 -5
  118. package/dist/extraction-faithfulness.d.ts +1 -1
  119. package/dist/extraction-judge-telemetry.d.ts +1 -1
  120. package/dist/extraction-judge-training.d.ts +1 -1
  121. package/dist/extraction-judge.d.ts +1 -1
  122. package/dist/extraction.d.ts +1 -1
  123. package/dist/fallback-llm.d.ts +1 -1
  124. package/dist/graph-dashboard-diff.d.ts +1 -1
  125. package/dist/graph-dashboard-key.d.ts +1 -1
  126. package/dist/graph-dashboard-parser.d.ts +1 -1
  127. package/dist/graph-edge-reinforcement.d.ts +1 -1
  128. package/dist/graph-snapshot.d.ts +1 -1
  129. package/dist/graph.d.ts +1 -1
  130. package/dist/identity-continuity.d.ts +1 -1
  131. package/dist/importance.d.ts +1 -1
  132. package/dist/index.d.ts +268 -17
  133. package/dist/index.js +80 -38
  134. package/dist/intent.d.ts +1 -1
  135. package/dist/lcm/engine.d.ts +1 -1
  136. package/dist/lcm/index.d.ts +1 -1
  137. package/dist/lcm/tools.d.ts +1 -1
  138. package/dist/lifecycle.d.ts +1 -1
  139. package/dist/live-connectors-runner.d.ts +1 -1
  140. package/dist/local-llm.d.ts +1 -1
  141. package/dist/local-model-endpoint.d.ts +1 -1
  142. package/dist/maintenance/memory-governance.d.ts +1 -1
  143. package/dist/maintenance/memory-governance.js +4 -4
  144. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
  145. package/dist/maintenance/rebuild-memory-projection.js +5 -5
  146. package/dist/mcp-memory-inspector-app.d.ts +6 -6
  147. package/dist/memory-action-policy.d.ts +1 -1
  148. package/dist/memory-cache.d.ts +1 -1
  149. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  150. package/dist/memory-projection-store.d.ts +1 -1
  151. package/dist/memory-provenance.d.ts +1 -1
  152. package/dist/memory-worth-outcomes.d.ts +2 -2
  153. package/dist/models-json.d.ts +1 -1
  154. package/dist/namespaces/migrate.d.ts +3 -3
  155. package/dist/namespaces/migrate.js +11 -11
  156. package/dist/namespaces/principal.d.ts +1 -1
  157. package/dist/namespaces/search.d.ts +1 -1
  158. package/dist/namespaces/search.js +7 -7
  159. package/dist/namespaces/storage.d.ts +3 -3
  160. package/dist/namespaces/storage.js +4 -4
  161. package/dist/native-knowledge.d.ts +1 -1
  162. package/dist/operator-toolkit.d.ts +2 -2
  163. package/dist/operator-toolkit.js +15 -15
  164. package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
  165. package/dist/orchestration/maintenance.d.ts +2 -2
  166. package/dist/orchestration/maintenance.js +6 -6
  167. package/dist/{orchestrator-Dh3WSW_O.d.ts → orchestrator-CWFrT8AF.d.ts} +91 -60
  168. package/dist/orchestrator.d.ts +5 -5
  169. package/dist/orchestrator.js +36 -36
  170. package/dist/patterns-cli.d.ts +1 -1
  171. package/dist/policy-runtime.d.ts +1 -1
  172. package/dist/provenance.d.ts +1 -1
  173. package/dist/{qmd-DoUVxqND.d.ts → qmd--fnPMK60.d.ts} +1 -1
  174. package/dist/qmd-preflight.d.ts +2 -2
  175. package/dist/qmd-recall-cache.d.ts +1 -1
  176. package/dist/qmd.d.ts +2 -2
  177. package/dist/recall-disclosure-escalation.d.ts +1 -1
  178. package/dist/recall-explain-renderer.d.ts +1 -1
  179. package/dist/recall-planner-llm.d.ts +1 -1
  180. package/dist/recall-state.d.ts +1 -1
  181. package/dist/recall-tag-filter.d.ts +1 -1
  182. package/dist/recall-timings.d.ts +1 -1
  183. package/dist/recall-xray-cli.d.ts +1 -1
  184. package/dist/recall-xray-renderer.d.ts +1 -1
  185. package/dist/recall-xray.d.ts +1 -1
  186. package/dist/resolve-auth-token.d.ts +1 -1
  187. package/dist/resume-bundles.js +2 -2
  188. package/dist/retrieval-agents.d.ts +2 -2
  189. package/dist/retrieval-tiers.d.ts +1 -1
  190. package/dist/routing/engine.d.ts +1 -1
  191. package/dist/routing/store.d.ts +1 -1
  192. package/dist/search/document-scanner.js +2 -2
  193. package/dist/search/embed-helper.d.ts +1 -1
  194. package/dist/search/factory.d.ts +1 -1
  195. package/dist/search/factory.js +6 -6
  196. package/dist/search/index.d.ts +1 -1
  197. package/dist/search/index.js +6 -6
  198. package/dist/search/lancedb-backend.d.ts +1 -1
  199. package/dist/search/lancedb-backend.js +3 -3
  200. package/dist/search/meilisearch-backend.d.ts +1 -1
  201. package/dist/search/meilisearch-backend.js +3 -3
  202. package/dist/search/noop-backend.d.ts +1 -1
  203. package/dist/search/orama-backend.d.ts +1 -1
  204. package/dist/search/orama-backend.js +3 -3
  205. package/dist/search/port.d.ts +1 -1
  206. package/dist/search/remote-backend.d.ts +1 -1
  207. package/dist/{semantic-consolidation-Dof1865o.d.ts → semantic-consolidation-O5lMZ4LG.d.ts} +1 -1
  208. package/dist/semantic-consolidation.d.ts +2 -2
  209. package/dist/semantic-consolidation.js +5 -5
  210. package/dist/semantic-rule-promotion.js +4 -4
  211. package/dist/semantic-rule-verifier.d.ts +1 -1
  212. package/dist/semantic-rule-verifier.js +4 -4
  213. package/dist/session-observer-bands.d.ts +1 -1
  214. package/dist/session-observer-state.d.ts +1 -1
  215. package/dist/shared-context/manager.d.ts +1 -1
  216. package/dist/signal.d.ts +1 -1
  217. package/dist/storage-C1maCNbc.d.ts +1591 -0
  218. package/dist/storage.d.ts +5 -1239
  219. package/dist/storage.js +3 -3
  220. package/dist/summarizer.d.ts +1 -1
  221. package/dist/summary-snapshot.d.ts +1 -1
  222. package/dist/temporal-supersession.d.ts +2 -2
  223. package/dist/temporal-validity.d.ts +1 -1
  224. package/dist/threading.d.ts +1 -1
  225. package/dist/tier-migration.d.ts +2 -2
  226. package/dist/tier-routing.d.ts +1 -1
  227. package/dist/topics.d.ts +1 -1
  228. package/dist/transcript.d.ts +1 -1
  229. package/dist/trust-score-stage.d.ts +1 -1
  230. package/dist/trust-score.d.ts +1 -1
  231. package/dist/{types-C7HUgOuA.d.ts → types-CGgcNWCG.d.ts} +31 -1
  232. package/dist/types.d.ts +1 -1
  233. package/dist/utility-runtime.d.ts +1 -1
  234. package/dist/verified-recall.js +4 -4
  235. package/package.json +2 -2
  236. package/src/cli-wearables-fuse.test.ts +197 -0
  237. package/src/cli.ts +25 -0
  238. package/src/storage.ts +16 -0
  239. package/src/wearables/cli.test.ts +102 -0
  240. package/src/wearables/cli.ts +65 -0
  241. package/src/wearables/config.test.ts +32 -0
  242. package/src/wearables/config.ts +52 -0
  243. package/src/wearables/day-store.test.ts +246 -0
  244. package/src/wearables/day-store.ts +272 -3
  245. package/src/wearables/fusion/cluster.ts +230 -0
  246. package/src/wearables/fusion/fuse.ts +218 -0
  247. package/src/wearables/fusion/fusion.test.ts +2575 -0
  248. package/src/wearables/fusion/index.ts +50 -0
  249. package/src/wearables/fusion/reconcile.ts +831 -0
  250. package/src/wearables/fusion/reconstruct.ts +251 -0
  251. package/src/wearables/fusion/store.ts +480 -0
  252. package/src/wearables/fusion/types.ts +228 -0
  253. package/src/wearables/index.ts +41 -0
  254. package/src/wearables/service.test.ts +1030 -2
  255. package/src/wearables/service.ts +258 -16
  256. package/src/wearables/speakers.test.ts +128 -0
  257. package/src/wearables/speakers.ts +58 -4
  258. package/src/wearables/storage-io.test.ts +146 -0
  259. package/src/wearables/types.ts +31 -0
  260. package/dist/chunk-2B7DP7AP.js.map +0 -1
  261. package/dist/chunk-M7XQSUBB.js.map +0 -1
  262. package/dist/chunk-UDCDGP6A.js.map +0 -1
  263. /package/dist/{auto-sync-G6IU7L6C.js.map → auto-sync-4A5YBWYO.js.map} +0 -0
  264. /package/dist/{chunk-H5PFR5ZT.js.map → chunk-23JFRB73.js.map} +0 -0
  265. /package/dist/{chunk-BGMSRN6I.js.map → chunk-3XIY7MDQ.js.map} +0 -0
  266. /package/dist/{chunk-L2QJQSHL.js.map → chunk-4GNEDXDI.js.map} +0 -0
  267. /package/dist/{chunk-MFDJ63U5.js.map → chunk-4LV23CHK.js.map} +0 -0
  268. /package/dist/{chunk-AER6MT24.js.map → chunk-6SXVCD7W.js.map} +0 -0
  269. /package/dist/{chunk-2SFEFR5I.js.map → chunk-72WG5QKN.js.map} +0 -0
  270. /package/dist/{chunk-7BAJAXGB.js.map → chunk-7MW3CVLD.js.map} +0 -0
  271. /package/dist/{chunk-SNNIUDRS.js.map → chunk-AYZJID4S.js.map} +0 -0
  272. /package/dist/{chunk-LNHJ32NK.js.map → chunk-BEM4D2QG.js.map} +0 -0
  273. /package/dist/{chunk-AOYJVCFN.js.map → chunk-CYWA2CXR.js.map} +0 -0
  274. /package/dist/{chunk-2JFDHI2K.js.map → chunk-DEG5ULFJ.js.map} +0 -0
  275. /package/dist/{chunk-L6V3WJ55.js.map → chunk-DS7MM2ES.js.map} +0 -0
  276. /package/dist/{chunk-TI7IHDBQ.js.map → chunk-H2DXDQCQ.js.map} +0 -0
  277. /package/dist/{chunk-KMF67QDQ.js.map → chunk-HLN7RROI.js.map} +0 -0
  278. /package/dist/{chunk-D2XYYU5Q.js.map → chunk-J3XHPFEU.js.map} +0 -0
  279. /package/dist/{chunk-ODSYJXDU.js.map → chunk-JSNVWNGX.js.map} +0 -0
  280. /package/dist/{chunk-4WNLSSHL.js.map → chunk-LADFWYHY.js.map} +0 -0
  281. /package/dist/{chunk-UBGAOYBP.js.map → chunk-LR5DZ56F.js.map} +0 -0
  282. /package/dist/{chunk-5K5VEDFY.js.map → chunk-N3JDIRBW.js.map} +0 -0
  283. /package/dist/{chunk-VWEWK2BM.js.map → chunk-PCLIWBGD.js.map} +0 -0
  284. /package/dist/{chunk-TV7C3HCB.js.map → chunk-PNLFN2PZ.js.map} +0 -0
  285. /package/dist/{chunk-STCCFQU5.js.map → chunk-PVUJ6WVV.js.map} +0 -0
  286. /package/dist/{chunk-ITWTKT2R.js.map → chunk-RGK27ELN.js.map} +0 -0
  287. /package/dist/{chunk-II6WHCCW.js.map → chunk-SIRPLF54.js.map} +0 -0
  288. /package/dist/{chunk-NFTABV56.js.map → chunk-TLUDFPZL.js.map} +0 -0
  289. /package/dist/{chunk-I74ZEBMA.js.map → chunk-UFUKHGZD.js.map} +0 -0
  290. /package/dist/{chunk-L4UZBJNM.js.map → chunk-UTRJFDP2.js.map} +0 -0
  291. /package/dist/{chunk-5GPPACXK.js.map → chunk-VSMRDBFT.js.map} +0 -0
  292. /package/dist/{chunk-SNBEVJBX.js.map → chunk-XCL57M6Q.js.map} +0 -0
  293. /package/dist/{chunk-DZSAXE3T.js.map → chunk-Y7UNNAZD.js.map} +0 -0
  294. /package/dist/{chunk-XN3TUUX2.js.map → chunk-YQB32INI.js.map} +0 -0
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Wearable cross-source fusion — derived-day file serialization.
3
+ *
4
+ * One derived file per fused day, stored ALONGSIDE raw source transcripts
5
+ * (under the wearables tree) but never overwriting them. Format mirrors
6
+ * the day-transcript convention: YAML frontmatter (`kind:
7
+ * wearable-fusion`) + a JSON body carrying the structured
8
+ * `FusedWearableConversation[]`. The frontmatter `contentHash` is the
9
+ * idempotency key derived from inputs plus the effective fusion config,
10
+ * so an unchanged re-run (same inputs AND config) produces a
11
+ * byte-identical file.
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import * as path from "node:path";
16
+ import { pathIsInside } from "../../utils/path-containment.js";
17
+ import { isValidTranscriptDate } from "../day-store.js";
18
+ import type {
19
+ FusedDayFile,
20
+ FusedDayMeta,
21
+ FusedWearableConversation,
22
+ } from "./types.js";
23
+
24
+ export const FUSION_KIND = "wearable-fusion" as const;
25
+
26
+ /** Hash the canonical JSON body for idempotent skip-unchanged checks. */
27
+ export function hashFusionBody(
28
+ conversations: readonly FusedWearableConversation[],
29
+ ): string {
30
+ return createHash("sha256")
31
+ .update(JSON.stringify(conversations), "utf-8")
32
+ .digest("hex");
33
+ }
34
+
35
+ export function composeFusionDayMeta(
36
+ date: string,
37
+ conversations: readonly FusedWearableConversation[],
38
+ sources: readonly string[],
39
+ contentHash: string,
40
+ fusedAt: string,
41
+ ): FusedDayMeta {
42
+ return {
43
+ kind: FUSION_KIND,
44
+ date,
45
+ sourceCount: sources.length,
46
+ conversationCount: conversations.length,
47
+ contentHash,
48
+ bodyHash: hashFusionBody(conversations),
49
+ fusedAt,
50
+ };
51
+ }
52
+
53
+ /** Serialize meta + conversations into the persisted file format. */
54
+ export function serializeFusionDay(
55
+ meta: FusedDayMeta,
56
+ conversations: readonly FusedWearableConversation[],
57
+ ): string {
58
+ const lines: string[] = ["---"];
59
+ lines.push(`kind: ${meta.kind}`);
60
+ lines.push(`date: ${JSON.stringify(meta.date)}`);
61
+ lines.push(`sourceCount: ${meta.sourceCount}`);
62
+ lines.push(`conversationCount: ${meta.conversationCount}`);
63
+ lines.push(`contentHash: ${JSON.stringify(meta.contentHash)}`);
64
+ lines.push(`bodyHash: ${JSON.stringify(meta.bodyHash)}`);
65
+ lines.push(`fusedAt: ${JSON.stringify(meta.fusedAt)}`);
66
+ lines.push("---");
67
+ lines.push("");
68
+ return `${lines.join("\n")}\n${JSON.stringify(conversations, null, 2)}\n`;
69
+ }
70
+
71
+ function parseScalar(value: string): string {
72
+ const trimmed = value.trim();
73
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
74
+ try {
75
+ const parsed = JSON.parse(trimmed);
76
+ if (typeof parsed === "string") return parsed;
77
+ } catch {
78
+ // Fall through to the raw value below.
79
+ }
80
+ }
81
+ return trimmed;
82
+ }
83
+
84
+ function parseNonNegativeInt(value: string | undefined): number {
85
+ if (value === undefined) return 0;
86
+ const parsed = Number(value);
87
+ if (!Number.isFinite(parsed) || parsed < 0) return 0;
88
+ return Math.floor(parsed);
89
+ }
90
+
91
+ const FUSED_SEGMENT_PICK_REASONS: Record<string, true> = {
92
+ "only-source": true,
93
+ "higher-trust": true,
94
+ "more-complete": true,
95
+ "tie-break": true,
96
+ };
97
+
98
+ const FUSED_DISAGREEMENT_KINDS: Record<string, true> = {
99
+ "asr-text": true,
100
+ speaker: true,
101
+ timestamp: true,
102
+ };
103
+
104
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
105
+ return typeof value === "object" && value !== null && !Array.isArray(value);
106
+ }
107
+
108
+ function isStringArray(value: unknown): value is string[] {
109
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
110
+ }
111
+
112
+ /** Structural guard for a parsed segment-provenance record. */
113
+ function isFusedSegmentProvenance(value: unknown): boolean {
114
+ if (!isPlainObject(value)) return false;
115
+ if (typeof value.source !== "string") return false;
116
+ if (typeof value.conversationId !== "string") return false;
117
+ if (typeof value.sourceTrust !== "number") return false;
118
+ if (typeof value.reason !== "string") return false;
119
+ if (!(value.reason in FUSED_SEGMENT_PICK_REASONS)) return false;
120
+ return (
121
+ Array.isArray(value.alternatives) &&
122
+ value.alternatives.every(
123
+ (alt) =>
124
+ isPlainObject(alt) && typeof alt.source === "string" && typeof alt.text === "string",
125
+ )
126
+ );
127
+ }
128
+
129
+ /** Structural guard for a parsed fused segment. */
130
+ function isFusedSegment(value: unknown): boolean {
131
+ if (!isPlainObject(value)) return false;
132
+ if (typeof value.speaker !== "string") return false;
133
+ if (typeof value.isSelf !== "boolean") return false;
134
+ if (typeof value.text !== "string") return false;
135
+ if (typeof value.confidence !== "number") return false;
136
+ return isFusedSegmentProvenance(value.provenance);
137
+ }
138
+
139
+ /** Structural guard for a parsed fused speaker. */
140
+ function isFusedSpeaker(value: unknown): boolean {
141
+ if (!isPlainObject(value)) return false;
142
+ if (typeof value.label !== "string") return false;
143
+ if (typeof value.isSelf !== "boolean") return false;
144
+ if (typeof value.confidence !== "number") return false;
145
+ return isStringArray(value.sources);
146
+ }
147
+
148
+ /** Structural guard for a parsed fused disagreement. */
149
+ function isFusedDisagreement(value: unknown): boolean {
150
+ if (!isPlainObject(value)) return false;
151
+ if (typeof value.kind !== "string") return false;
152
+ if (!(value.kind in FUSED_DISAGREEMENT_KINDS)) return false;
153
+ if (typeof value.subject !== "string") return false;
154
+ return (
155
+ Array.isArray(value.candidates) &&
156
+ value.candidates.every(
157
+ (cand) =>
158
+ isPlainObject(cand) && typeof cand.source === "string" && typeof cand.value === "string",
159
+ )
160
+ );
161
+ }
162
+
163
+ /** Structural guard for a parsed fused-conversation contribution record. */
164
+ function isFusedContribution(value: unknown): boolean {
165
+ if (!isPlainObject(value)) return false;
166
+ if (typeof value.source !== "string") return false;
167
+ if (typeof value.conversationId !== "string") return false;
168
+ if (typeof value.startIso !== "string") return false;
169
+ return typeof value.segmentCount === "number";
170
+ }
171
+
172
+ /** Structural guard for a parsed fused-conversation provenance record. */
173
+ function isFusedConversationProvenance(value: unknown): boolean {
174
+ if (!isPlainObject(value)) return false;
175
+ if (!Array.isArray(value.contributions) || !value.contributions.every(isFusedContribution)) {
176
+ return false;
177
+ }
178
+ if (typeof value.proximityGapMs !== "number") return false;
179
+ if (typeof value.windowToleranceMs !== "number") return false;
180
+ return value.method === "time-proximity";
181
+ }
182
+
183
+ /**
184
+ * Structural guard for a parsed fused conversation. Every required field of
185
+ * `FusedWearableConversation` (and its nested segments / speakers /
186
+ * disagreements / provenance) must be present with the right shape, so a
187
+ * null, wrong-typed, or partial element (`[{}]`, `[null]`, `[{id:1}]`) is
188
+ * rejected and the caller treats the body as corrupt (parseOk:false →
189
+ * self-repair rewrite) rather than trusting it.
190
+ */
191
+ function isFusedWearableConversation(value: unknown): value is FusedWearableConversation {
192
+ if (!isPlainObject(value)) return false;
193
+ if (typeof value.id !== "string") return false;
194
+ if (typeof value.date !== "string") return false;
195
+ if (typeof value.startIso !== "string") return false;
196
+ if (!isStringArray(value.sources)) return false;
197
+ if (!Array.isArray(value.speakers) || !value.speakers.every(isFusedSpeaker)) return false;
198
+ if (!Array.isArray(value.segments) || !value.segments.every(isFusedSegment)) return false;
199
+ if (!Array.isArray(value.disagreements) || !value.disagreements.every(isFusedDisagreement)) {
200
+ return false;
201
+ }
202
+ return isFusedConversationProvenance(value.provenance);
203
+ }
204
+
205
+ /**
206
+ * Parse a persisted fused-day file. Returns null when the content does
207
+ * not look like a fusion artifact (wrong kind / missing frontmatter) so
208
+ * callers can distinguish "not fused" from a malformed file. A non-null
209
+ * result carries `parseOk`: false when the JSON body failed to parse, was
210
+ * not an array, or held a malformed element (conversations is empty in
211
+ * that case), true for a well-formed (incl. legitimately-empty `[]`)
212
+ * body — so the skip-unchanged path can force a self-repair rewrite
213
+ * regardless of the recomputed conversation count or body hash.
214
+ */
215
+ export function parseFusionDay(raw: string): FusedDayFile | null {
216
+ if (!raw.startsWith("---\n")) return null;
217
+ const closeIndex = raw.indexOf("\n---\n", 4);
218
+ if (closeIndex === -1) return null;
219
+ const header = raw.slice(4, closeIndex);
220
+ const body = raw.slice(closeIndex + 5).replace(/^\n/, "");
221
+
222
+ const scalars = new Map<string, string>();
223
+ for (const line of header.split("\n")) {
224
+ const match = line.match(/^([A-Za-z][A-Za-z0-9]*): (.*)$/);
225
+ if (match) scalars.set(match[1], parseScalar(match[2]));
226
+ }
227
+
228
+ if (scalars.get("kind") !== FUSION_KIND) return null;
229
+ const date = scalars.get("date");
230
+ if (date === undefined) return null;
231
+
232
+ let conversations: FusedWearableConversation[] = [];
233
+ let parseOk = true;
234
+ const bodyTrimmed = body.trim();
235
+ if (bodyTrimmed.length === 0) {
236
+ // An existing artifact whose body is blank or whitespace-only (no
237
+ // JSON at all) is corrupt — it is NOT a valid empty `[]`. The
238
+ // serializer always writes `JSON.stringify(conversations, null, 2)`
239
+ // which produces `[]` for zero conversations, so a blank body means
240
+ // the file was truncated, partially written, or manually emptied.
241
+ // Flag parseOk:false so fusedConversations() surfaces the corruption
242
+ // and fuseDay() repairs it (issue #1849).
243
+ parseOk = false;
244
+ } else {
245
+ try {
246
+ const parsed: unknown = JSON.parse(bodyTrimmed);
247
+ if (Array.isArray(parsed) && parsed.every(isFusedWearableConversation)) {
248
+ conversations = parsed;
249
+ } else {
250
+ // Valid JSON but not a well-formed FusedWearableConversation[]
251
+ // (non-array, or an array carrying a null / wrong-typed /
252
+ // missing-required-field element) — flag corrupt so callers
253
+ // recompute fusion rather than trusting a partial read. A
254
+ // legitimately-empty [] is accepted with parseOk left true.
255
+ parseOk = false;
256
+ }
257
+ } catch {
258
+ // A truncated/corrupt body fails to parse; flag parseOk:false so the
259
+ // skip-unchanged path forces a self-repair rewrite (even when the
260
+ // frontmatter hash matches and the recomputed count is also zero).
261
+ parseOk = false;
262
+ }
263
+ }
264
+
265
+ const meta: FusedDayMeta = {
266
+ kind: FUSION_KIND,
267
+ date,
268
+ sourceCount: parseNonNegativeInt(scalars.get("sourceCount")),
269
+ conversationCount: parseNonNegativeInt(scalars.get("conversationCount")),
270
+ contentHash: scalars.get("contentHash") ?? "",
271
+ bodyHash: scalars.get("bodyHash") ?? "",
272
+ fusedAt: scalars.get("fusedAt") ?? "",
273
+ };
274
+ return { meta, conversations, parseOk };
275
+ }
276
+
277
+ /** Reserved underscore-prefixed subdir for derived fusion artifacts. */
278
+ export const FUSION_DIR_NAME = "_fusion";
279
+
280
+ /**
281
+ * Encrypted-at-rest + atomic file IO the fusion artifact store needs.
282
+ * Satisfied by StorageManager (which owns the secure-store key + atomic
283
+ * write path) and injected so this module performs NO direct fs — the
284
+ * derived files inherit the same encrypted-at-rest + atomic-write
285
+ * semantics as raw day transcripts without this module knowing the key.
286
+ */
287
+ export interface FusionFileIo {
288
+ writeFile(filePath: string, content: string): Promise<void>;
289
+ /** Read a file; rejects with ENOENT when it is absent. */
290
+ readFile(filePath: string): Promise<string>;
291
+ /** Directory entries; rejects with ENOENT when the directory is absent. */
292
+ readDir(dirPath: string): Promise<string[]>;
293
+ /** Remove a file; rejects with ENOENT when it is absent. */
294
+ deleteFile(filePath: string): Promise<void>;
295
+ /**
296
+ * Resolve a path through symlinks (node:fs/promises realpath); rejects
297
+ * with ENOENT when the path is absent. Used by the symlink-containment
298
+ * guard so a symlinked `_fusion` dir (or a symlinked wearables root)
299
+ * that resolves outside the memory dir is rejected before any
300
+ * read/write/delete (AGENTS.md #3).
301
+ */
302
+ realpath(filePath: string): Promise<string>;
303
+ /**
304
+ * Stat a path WITHOUT following a trailing symlink
305
+ * (node:fs/promises lstat); rejects with ENOENT when the path is
306
+ * absent. Used to detect a pre-existing symlink at the fusion
307
+ * artifact directory itself, which is rejected even when its
308
+ * target resolves inside the memory dir (issue #1849).
309
+ */
310
+ lstat(filePath: string): Promise<{ isSymbolicLink: boolean }>;
311
+ }
312
+
313
+ /**
314
+ * Owns the derived fused-day artifact files under
315
+ * `<wearablesDir>/_fusion/<YYYY-MM-DD>.md`. Path construction, date
316
+ * validation, and newest-first listing live here (relocated out of the
317
+ * root storage module — issue #1810 ratchet); the bytes flow through the
318
+ * injected secure IO.
319
+ */
320
+ export class FusionArtifactStore {
321
+ constructor(
322
+ private readonly wearablesDir: string,
323
+ private readonly memoryDir: string,
324
+ private readonly io: FusionFileIo,
325
+ ) {}
326
+
327
+ fusedDayPath(date: string): string {
328
+ if (!isValidTranscriptDate(date)) {
329
+ throw new Error(
330
+ `invalid wearable fusion date '${String(date)}' — expected YYYY-MM-DD`,
331
+ );
332
+ }
333
+ return path.join(this.wearablesDir, FUSION_DIR_NAME, `${date}.md`);
334
+ }
335
+
336
+ async writeFusedDay(date: string, serialized: string): Promise<void> {
337
+ const filePath = this.fusedDayPath(date);
338
+ await this.assertPathContained(filePath);
339
+ await this.io.writeFile(filePath, serialized);
340
+ }
341
+
342
+ /** Read a stored fused-day artifact; null when absent. */
343
+ async readFusedDay(date: string): Promise<string | null> {
344
+ const filePath = this.fusedDayPath(date);
345
+ await this.assertPathContained(filePath);
346
+ try {
347
+ return await this.io.readFile(filePath);
348
+ } catch (err) {
349
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
350
+ throw err;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Remove a stored fused-day artifact. Idempotent: a no-op when the
356
+ * artifact is already absent. Used to clear a now-stale derived file
357
+ * before a fusion run that deliberately refuses to fuse (e.g. a day
358
+ * whose sources later conflict on timezone) so fusedConversations()
359
+ * does not keep serving a stale view (issue #1849).
360
+ */
361
+ async deleteFusedDay(date: string): Promise<void> {
362
+ const filePath = this.fusedDayPath(date);
363
+ await this.assertPathContained(filePath);
364
+ try {
365
+ await this.io.deleteFile(filePath);
366
+ } catch (err) {
367
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
368
+ throw err;
369
+ }
370
+ }
371
+
372
+ /** List dates with stored fused artifacts, newest first. */
373
+ async listFusedDays(): Promise<string[]> {
374
+ const fusionDir = path.join(this.wearablesDir, FUSION_DIR_NAME);
375
+ await this.assertPathContained(fusionDir);
376
+ let entries: string[];
377
+ try {
378
+ entries = await this.io.readDir(fusionDir);
379
+ } catch (err) {
380
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
381
+ throw err;
382
+ }
383
+ const days: string[] = [];
384
+ for (const entry of entries) {
385
+ if (!entry.endsWith(".md")) continue;
386
+ const date = entry.slice(0, -3);
387
+ if (!isValidTranscriptDate(date)) continue;
388
+ days.push(date);
389
+ }
390
+ days.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0));
391
+ return days;
392
+ }
393
+
394
+ /**
395
+ * Reject a symlinked/traversing fusion artifact path before any IO. The
396
+ * fusion dir (`<wearablesDir>/_fusion`) and its parents are followed
397
+ * directly by the secure IO; a symlink placed there (attacker-writable
398
+ * or a malformed memory dir) would otherwise be followed and
399
+ * read/write/delete outside the allowed root (AGENTS.md pattern #3).
400
+ *
401
+ * Containment is anchored to the real MEMORY-DIR root, not the
402
+ * wearables dir, AND the wearables root is checked explicitly: if
403
+ * `wearablesDir` itself is a symlink that resolves outside memoryDir, a
404
+ * leaf candidate like `<wearablesDir>/_fusion/<date>.md` may not yet
405
+ * exist (so its realpath ENOENT-skips) and the write would silently
406
+ * create the file inside the symlinked escape target. Checking
407
+ * `wearablesDir` itself — which resolves through the symlink when it
408
+ * exists — closes that gap, while the per-leaf checks catch a nested
409
+ * `_fusion` or per-file symlink. Absent paths are allowed (a fresh
410
+ * write creates them lexically under the root; only a path that
411
+ * resolves to an existing target can redirect IO). Mirrors
412
+ * `assertPathInsideRoot` in the storage walkers.
413
+ */
414
+ private async assertPathContained(targetPath: string): Promise<void> {
415
+ let rootReal: string;
416
+ try {
417
+ rootReal = await this.io.realpath(this.memoryDir);
418
+ } catch {
419
+ // Fresh memory dir not yet on disk — no symlink can resolve through
420
+ // a missing root, so fall back to a lexical resolve.
421
+ rootReal = path.resolve(this.memoryDir);
422
+ }
423
+ // The wearables root first (catches a symlinked wearables dir even
424
+ // when the leaf artifact does not yet exist), then the fusion dir and
425
+ // the target file itself.
426
+ await this.assertNoEscape(rootReal, this.wearablesDir);
427
+ await this.assertNoEscape(rootReal, path.dirname(targetPath));
428
+ await this.assertNoEscape(rootReal, targetPath);
429
+ // Reject a symlinked `_fusion` dir outright — even when its
430
+ // target resolves INSIDE the memory dir. The managed fusion root
431
+ // is always a real directory created by the secure write; a
432
+ // pre-existing link there is tampering/aliasing and is refused
433
+ // before a single byte is written or read (issue #1849).
434
+ await this.assertFusionDirNotSymlinked();
435
+ }
436
+
437
+ /**
438
+ * Refuse a pre-existing symbolic link at the `<wearablesDir>/_fusion`
439
+ * directory. Unlike `assertNoEscape` (which follows the link and only
440
+ * rejects when the target lands outside the memory dir), this catches a
441
+ * fusion root that aliases an in-bounds path — e.g. a `_fusion` link
442
+ * pointed at a raw source transcript — so fusion IO never writes
443
+ * through it and overwrites the aliased file. Absent dirs are allowed
444
+ * (a fresh write creates a real directory lexically under the root).
445
+ */
446
+ private async assertFusionDirNotSymlinked(): Promise<void> {
447
+ const fusionDir = path.join(this.wearablesDir, FUSION_DIR_NAME);
448
+ let stat: { isSymbolicLink: boolean };
449
+ try {
450
+ stat = await this.io.lstat(fusionDir);
451
+ } catch (err) {
452
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
453
+ throw err;
454
+ }
455
+ if (stat.isSymbolicLink) {
456
+ throw new Error(
457
+ "wearable fusion dir '_fusion' is a symbolic link — refusing to follow it even when it resolves inside the memory dir (AGENTS.md pattern #3)",
458
+ );
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Refuse a candidate whose realpath escapes the memory root. Absent
464
+ * candidates are allowed: a missing path cannot yet redirect IO, and
465
+ * the secure write creates it lexically under the root.
466
+ */
467
+ private async assertNoEscape(rootReal: string, candidate: string): Promise<void> {
468
+ let candidateReal: string;
469
+ try {
470
+ candidateReal = await this.io.realpath(candidate);
471
+ } catch {
472
+ return; // absent — nothing to follow
473
+ }
474
+ if (!pathIsInside(rootReal, candidateReal)) {
475
+ throw new Error(
476
+ "wearable fusion artifact path resolves outside the memory dir — refusing to follow a symlink/traversal (AGENTS.md pattern #3)",
477
+ );
478
+ }
479
+ }
480
+ }