@remnic/core 9.57.4 → 9.57.6

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 (248) hide show
  1. package/dist/access-admin-ops-surface.d.ts +1 -1
  2. package/dist/access-admin-ops-surface.js +23 -23
  3. package/dist/access-authorization-probe.d.ts +1 -1
  4. package/dist/access-authorization-probe.js +24 -24
  5. package/dist/access-boundary.d.ts +1 -1
  6. package/dist/access-boundary.js +23 -23
  7. package/dist/access-cli.js +60 -60
  8. package/dist/access-extraction-force-flush.d.ts +1 -1
  9. package/dist/access-extraction-force-flush.js +23 -23
  10. package/dist/access-http-lcm-compaction.d.ts +1 -1
  11. package/dist/access-http-lifecycle-flush.d.ts +1 -1
  12. package/dist/access-http-offline-stream.d.ts +1 -1
  13. package/dist/access-http-query.js +23 -23
  14. package/dist/access-http.d.ts +2 -2
  15. package/dist/access-http.js +34 -34
  16. package/dist/access-identity-continuity-surface.js +23 -23
  17. package/dist/access-lcm-surface.d.ts +1 -1
  18. package/dist/access-lcm-surface.js +23 -23
  19. package/dist/access-mcp.d.ts +1 -1
  20. package/dist/access-mcp.js +30 -30
  21. package/dist/access-memory-search-fanout.js +4 -3
  22. package/dist/access-namespace-preflight.js +23 -23
  23. package/dist/access-observe-write-surface.d.ts +1 -1
  24. package/dist/access-observe-write-surface.js +23 -23
  25. package/dist/access-offline-manifest.js +10 -10
  26. package/dist/access-operations-batch.js +26 -26
  27. package/dist/access-operations.d.ts +4 -4
  28. package/dist/access-operations.js +29 -29
  29. package/dist/access-recall-concurrency.d.ts +1 -1
  30. package/dist/access-recall-concurrency.js +23 -23
  31. package/dist/access-recall-response.d.ts +1 -1
  32. package/dist/access-recall-response.js +23 -23
  33. package/dist/access-recall-surface.d.ts +1 -1
  34. package/dist/access-recall-surface.js +23 -23
  35. package/dist/access-schema.d.ts +88 -88
  36. package/dist/access-service-helpers.d.ts +1 -1
  37. package/dist/access-service.d.ts +1 -1
  38. package/dist/access-service.js +23 -23
  39. package/dist/access-surface-catalog.d.ts +1 -1
  40. package/dist/access-surface-catalog.js +25 -25
  41. package/dist/active-memory-bridge.js +5 -5
  42. package/dist/briefing.js +5 -5
  43. package/dist/buffer-surprise.js +3 -3
  44. package/dist/buffer.js +6 -5
  45. package/dist/causal-consolidation.js +7 -7
  46. package/dist/{chunk-O54B5YNM.js → chunk-24PNX2GF.js} +3 -3
  47. package/dist/{chunk-ONCWJBWV.js → chunk-3AJOJOFP.js} +2 -2
  48. package/dist/{chunk-QSDPVZPD.js → chunk-4O6R3GPK.js} +8 -8
  49. package/dist/{chunk-IXGQP4RZ.js → chunk-4SGDYYWV.js} +4 -4
  50. package/dist/{chunk-UTDMY476.js → chunk-4UUF2EX3.js} +3 -3
  51. package/dist/{chunk-T6EQPZ6E.js → chunk-4YZCIK5E.js} +16 -14
  52. package/dist/chunk-4YZCIK5E.js.map +1 -0
  53. package/dist/{chunk-S6AH7WSF.js → chunk-5PGYCWA7.js} +5 -5
  54. package/dist/{chunk-7NSC4IVA.js → chunk-6RK2X7BR.js} +3 -3
  55. package/dist/{chunk-VWP5DEHU.js → chunk-6SX4MY5Q.js} +2 -2
  56. package/dist/{chunk-GUNIXT36.js → chunk-7BY7V4YX.js} +2 -2
  57. package/dist/{chunk-R7UHWPYI.js → chunk-7PJARJS4.js} +3 -3
  58. package/dist/{chunk-V3IT3XNG.js → chunk-7VSHDLJQ.js} +6 -6
  59. package/dist/{chunk-SFCJCMZQ.js → chunk-A7266TRJ.js} +2 -2
  60. package/dist/{chunk-TF3LMM5E.js → chunk-CSV34HIW.js} +3 -3
  61. package/dist/{chunk-FK7T55BA.js → chunk-CZUHNJHX.js} +19 -19
  62. package/dist/{chunk-NFQB6RJT.js → chunk-D4OCJSLV.js} +3 -3
  63. package/dist/{chunk-JKL53MOY.js → chunk-DLQQU4EG.js} +3 -3
  64. package/dist/{chunk-CME347TM.js → chunk-DNC4A7RS.js} +2 -2
  65. package/dist/{chunk-4THNU7OF.js → chunk-DUHMYBVE.js} +23 -23
  66. package/dist/{chunk-F2JRDPON.js → chunk-EGBDFJ36.js} +2 -2
  67. package/dist/{chunk-TFXYQNBH.js → chunk-FI3PIOOI.js} +9 -9
  68. package/dist/{chunk-CKSEJD7K.js → chunk-FKTD7IVS.js} +2 -2
  69. package/dist/{chunk-FONW766A.js → chunk-GXIY65UH.js} +2 -2
  70. package/dist/{chunk-2YREVL4T.js → chunk-HZC3OECF.js} +2 -2
  71. package/dist/{chunk-X5OIPQME.js → chunk-HZNYJN77.js} +6 -6
  72. package/dist/{chunk-LYPG7GOO.js → chunk-I2GIYTRU.js} +2 -2
  73. package/dist/{chunk-FR3FNPCJ.js → chunk-IT57AOMC.js} +4 -4
  74. package/dist/{chunk-CMRUIKVA.js → chunk-KCT36JAW.js} +25 -9
  75. package/dist/chunk-KCT36JAW.js.map +1 -0
  76. package/dist/{chunk-KJH6W2I7.js → chunk-L3CHRBHE.js} +4 -4
  77. package/dist/{chunk-PSH3VFZ7.js → chunk-L3LRWDJW.js} +7 -7
  78. package/dist/{chunk-JFPZEWY3.js → chunk-LIF6DO6U.js} +37 -13
  79. package/dist/chunk-LIF6DO6U.js.map +1 -0
  80. package/dist/{chunk-QZR3WT3L.js → chunk-LJJWFV2Q.js} +2 -2
  81. package/dist/chunk-LMXUWAXW.js +200 -0
  82. package/dist/chunk-LMXUWAXW.js.map +1 -0
  83. package/dist/{chunk-TLVLJSWS.js → chunk-LRDP2AC2.js} +2 -2
  84. package/dist/{chunk-QV7OEHFN.js → chunk-LTBKKSCX.js} +2 -2
  85. package/dist/{chunk-IGWVMA4Y.js → chunk-MRV7VSQK.js} +20 -10
  86. package/dist/chunk-MRV7VSQK.js.map +1 -0
  87. package/dist/{chunk-6P2NUVM5.js → chunk-NEJQ65NV.js} +2 -2
  88. package/dist/{chunk-7PKNOXYB.js → chunk-NTJ6TY44.js} +2 -2
  89. package/dist/{chunk-HYVBJLEK.js → chunk-O5AFC5KM.js} +2 -2
  90. package/dist/{chunk-P4BC54KI.js → chunk-P6WBAR27.js} +8 -32
  91. package/dist/chunk-P6WBAR27.js.map +1 -0
  92. package/dist/{chunk-U3PN77QT.js → chunk-PCXDUHQH.js} +11 -4
  93. package/dist/chunk-PCXDUHQH.js.map +1 -0
  94. package/dist/{chunk-N655QIA2.js → chunk-PEDYKCJI.js} +3 -3
  95. package/dist/{chunk-TVKWT2CY.js → chunk-Q57GOHLF.js} +11 -9
  96. package/dist/{chunk-TVKWT2CY.js.map → chunk-Q57GOHLF.js.map} +1 -1
  97. package/dist/{chunk-IFFW25NG.js → chunk-REVMY7RA.js} +36 -36
  98. package/dist/{chunk-4QFCAKNM.js → chunk-RUZM6Z52.js} +1 -1
  99. package/dist/{chunk-O4TTIMOP.js → chunk-SFR7J2G6.js} +2 -2
  100. package/dist/{chunk-ION47U3Z.js → chunk-SNW3YRO6.js} +2 -2
  101. package/dist/{chunk-XL24QQ63.js → chunk-TBUEE7XF.js} +2 -2
  102. package/dist/{chunk-66SLUXKM.js → chunk-TEHPIMXD.js} +2 -2
  103. package/dist/{chunk-TAPFJ3ET.js → chunk-U7XLOSG2.js} +3 -3
  104. package/dist/{chunk-T3HC7RJ7.js → chunk-USAUBGBC.js} +2 -2
  105. package/dist/{chunk-TAMY4NKO.js → chunk-USGKHF36.js} +2 -2
  106. package/dist/{chunk-XRPPHIRI.js → chunk-VUDALPI5.js} +2 -2
  107. package/dist/{chunk-EGGE52UU.js → chunk-VUFL7X3H.js} +13 -6
  108. package/dist/chunk-VUFL7X3H.js.map +1 -0
  109. package/dist/{chunk-HC55YCNK.js → chunk-VYIXCMUP.js} +4 -4
  110. package/dist/{chunk-HQ2VGCGP.js → chunk-XBGLEOID.js} +8 -8
  111. package/dist/{chunk-XX4HPFFM.js → chunk-XDL5CYZN.js} +4 -4
  112. package/dist/{chunk-THACANBI.js → chunk-YOKSRIFI.js} +3 -3
  113. package/dist/{chunk-BWJMBJCY.js → chunk-YTZA7U6Q.js} +6 -6
  114. package/dist/{chunk-4KM3V6VS.js → chunk-ZN7SMKAK.js} +2 -2
  115. package/dist/chunking.d.ts +3 -1
  116. package/dist/chunking.js +7 -3
  117. package/dist/{cli-DTOkYhKI.d.ts → cli-CQdYilcx.d.ts} +1 -1
  118. package/dist/cli.d.ts +2 -2
  119. package/dist/cli.js +46 -46
  120. package/dist/compounding/engine.js +5 -5
  121. package/dist/connectors/codex-materialize-runner.js +6 -6
  122. package/dist/connectors/index.js +6 -6
  123. package/dist/contradiction/index.js +3 -3
  124. package/dist/{contradiction-scan-522AYLQC.js → contradiction-scan-VOTQRRSX.js} +4 -4
  125. package/dist/convergence-refresh.js +7 -7
  126. package/dist/corpus-watermark.js +8 -8
  127. package/dist/entity-retrieval.js +5 -5
  128. package/dist/explicit-capture.js +4 -4
  129. package/dist/external-wiki-access.d.ts +1 -1
  130. package/dist/external-wiki-access.js +24 -24
  131. package/dist/external-wiki-mcp-tools.d.ts +1 -1
  132. package/dist/extraction.js +11 -11
  133. package/dist/{first-start-migration-HZ4VPRFQ.js → first-start-migration-4NETATFF.js} +4 -4
  134. package/dist/{graph-edge-decay-ZXGBXNPW.js → graph-edge-decay-A4SDFMMS.js} +4 -3
  135. package/dist/harmonic-retrieval.js +1 -1
  136. package/dist/index.d.ts +680 -680
  137. package/dist/index.js +60 -60
  138. package/dist/maintenance/memory-governance.js +5 -5
  139. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +5 -5
  140. package/dist/maintenance/rebuild-memory-projection.js +6 -6
  141. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  142. package/dist/namespaces/migrate.js +7 -7
  143. package/dist/namespaces/principal.js +3 -2
  144. package/dist/namespaces/search.js +2 -2
  145. package/dist/namespaces/storage.js +6 -6
  146. package/dist/offline-sync-impression-drain.js +6 -6
  147. package/dist/operator-doctor-corpus.js +9 -9
  148. package/dist/operator-toolkit.js +16 -16
  149. package/dist/orchestration/maintenance.js +14 -14
  150. package/dist/orchestrator.js +60 -60
  151. package/dist/{public-http-C01DLhAp.d.ts → public-http-Dw0bJhcC.d.ts} +1 -1
  152. package/dist/reconcile/manifest.js +3 -3
  153. package/dist/reconcile/plan.js +2 -2
  154. package/dist/routing/engine.js +2 -1
  155. package/dist/routing/store.js +3 -2
  156. package/dist/schemas.d.ts +138 -138
  157. package/dist/semantic-chunking.js +2 -2
  158. package/dist/semantic-consolidation.js +7 -7
  159. package/dist/semantic-rule-promotion.js +5 -5
  160. package/dist/semantic-rule-verifier.js +5 -5
  161. package/dist/shared-context/manager.d.ts +8 -8
  162. package/dist/source-agent-qualifier.js +10 -10
  163. package/dist/storage.js +4 -4
  164. package/dist/support-passport/index.d.ts +4 -4
  165. package/dist/support-passport/index.js +26 -26
  166. package/dist/tier-routing.js +3 -3
  167. package/dist/{tier-stats-7E6OBUSD.js → tier-stats-4B5IJ3NE.js} +4 -4
  168. package/dist/transfer/types.d.ts +66 -66
  169. package/dist/verified-recall.js +5 -5
  170. package/package.json +2 -2
  171. package/src/chunking.ts +119 -34
  172. package/src/namespaces/catalog.ts +9 -9
  173. package/src/namespaces/identity.ts +15 -6
  174. package/src/namespaces/principal.test.ts +10 -1
  175. package/src/namespaces/principal.ts +17 -9
  176. package/src/namespaces/scope-profiles.ts +35 -13
  177. package/src/namespaces/storage.ts +63 -13
  178. package/src/namespaces/unicode-routing.test.ts +71 -0
  179. package/src/routing/engine.test.ts +30 -0
  180. package/src/routing/engine.ts +12 -3
  181. package/src/scopes/scope-plan.ts +8 -2
  182. package/src/semantic-chunking.ts +10 -48
  183. package/src/storage-contract/namespace-routing.test.ts +18 -1
  184. package/dist/chunk-CMRUIKVA.js.map +0 -1
  185. package/dist/chunk-EGGE52UU.js.map +0 -1
  186. package/dist/chunk-IGWVMA4Y.js.map +0 -1
  187. package/dist/chunk-JFPZEWY3.js.map +0 -1
  188. package/dist/chunk-P4BC54KI.js.map +0 -1
  189. package/dist/chunk-T6EQPZ6E.js.map +0 -1
  190. package/dist/chunk-U3PN77QT.js.map +0 -1
  191. package/dist/chunk-UQ7RN5HK.js +0 -126
  192. package/dist/chunk-UQ7RN5HK.js.map +0 -1
  193. package/dist/{access-service-D9xXmKvd.d.ts → access-service-4DO8XMq6.d.ts} +64 -64
  194. /package/dist/{chunk-O54B5YNM.js.map → chunk-24PNX2GF.js.map} +0 -0
  195. /package/dist/{chunk-ONCWJBWV.js.map → chunk-3AJOJOFP.js.map} +0 -0
  196. /package/dist/{chunk-QSDPVZPD.js.map → chunk-4O6R3GPK.js.map} +0 -0
  197. /package/dist/{chunk-IXGQP4RZ.js.map → chunk-4SGDYYWV.js.map} +0 -0
  198. /package/dist/{chunk-UTDMY476.js.map → chunk-4UUF2EX3.js.map} +0 -0
  199. /package/dist/{chunk-S6AH7WSF.js.map → chunk-5PGYCWA7.js.map} +0 -0
  200. /package/dist/{chunk-7NSC4IVA.js.map → chunk-6RK2X7BR.js.map} +0 -0
  201. /package/dist/{chunk-VWP5DEHU.js.map → chunk-6SX4MY5Q.js.map} +0 -0
  202. /package/dist/{chunk-GUNIXT36.js.map → chunk-7BY7V4YX.js.map} +0 -0
  203. /package/dist/{chunk-R7UHWPYI.js.map → chunk-7PJARJS4.js.map} +0 -0
  204. /package/dist/{chunk-V3IT3XNG.js.map → chunk-7VSHDLJQ.js.map} +0 -0
  205. /package/dist/{chunk-SFCJCMZQ.js.map → chunk-A7266TRJ.js.map} +0 -0
  206. /package/dist/{chunk-TF3LMM5E.js.map → chunk-CSV34HIW.js.map} +0 -0
  207. /package/dist/{chunk-FK7T55BA.js.map → chunk-CZUHNJHX.js.map} +0 -0
  208. /package/dist/{chunk-NFQB6RJT.js.map → chunk-D4OCJSLV.js.map} +0 -0
  209. /package/dist/{chunk-JKL53MOY.js.map → chunk-DLQQU4EG.js.map} +0 -0
  210. /package/dist/{chunk-CME347TM.js.map → chunk-DNC4A7RS.js.map} +0 -0
  211. /package/dist/{chunk-4THNU7OF.js.map → chunk-DUHMYBVE.js.map} +0 -0
  212. /package/dist/{chunk-F2JRDPON.js.map → chunk-EGBDFJ36.js.map} +0 -0
  213. /package/dist/{chunk-TFXYQNBH.js.map → chunk-FI3PIOOI.js.map} +0 -0
  214. /package/dist/{chunk-CKSEJD7K.js.map → chunk-FKTD7IVS.js.map} +0 -0
  215. /package/dist/{chunk-FONW766A.js.map → chunk-GXIY65UH.js.map} +0 -0
  216. /package/dist/{chunk-2YREVL4T.js.map → chunk-HZC3OECF.js.map} +0 -0
  217. /package/dist/{chunk-X5OIPQME.js.map → chunk-HZNYJN77.js.map} +0 -0
  218. /package/dist/{chunk-LYPG7GOO.js.map → chunk-I2GIYTRU.js.map} +0 -0
  219. /package/dist/{chunk-FR3FNPCJ.js.map → chunk-IT57AOMC.js.map} +0 -0
  220. /package/dist/{chunk-KJH6W2I7.js.map → chunk-L3CHRBHE.js.map} +0 -0
  221. /package/dist/{chunk-PSH3VFZ7.js.map → chunk-L3LRWDJW.js.map} +0 -0
  222. /package/dist/{chunk-QZR3WT3L.js.map → chunk-LJJWFV2Q.js.map} +0 -0
  223. /package/dist/{chunk-TLVLJSWS.js.map → chunk-LRDP2AC2.js.map} +0 -0
  224. /package/dist/{chunk-QV7OEHFN.js.map → chunk-LTBKKSCX.js.map} +0 -0
  225. /package/dist/{chunk-6P2NUVM5.js.map → chunk-NEJQ65NV.js.map} +0 -0
  226. /package/dist/{chunk-7PKNOXYB.js.map → chunk-NTJ6TY44.js.map} +0 -0
  227. /package/dist/{chunk-HYVBJLEK.js.map → chunk-O5AFC5KM.js.map} +0 -0
  228. /package/dist/{chunk-N655QIA2.js.map → chunk-PEDYKCJI.js.map} +0 -0
  229. /package/dist/{chunk-IFFW25NG.js.map → chunk-REVMY7RA.js.map} +0 -0
  230. /package/dist/{chunk-4QFCAKNM.js.map → chunk-RUZM6Z52.js.map} +0 -0
  231. /package/dist/{chunk-O4TTIMOP.js.map → chunk-SFR7J2G6.js.map} +0 -0
  232. /package/dist/{chunk-ION47U3Z.js.map → chunk-SNW3YRO6.js.map} +0 -0
  233. /package/dist/{chunk-XL24QQ63.js.map → chunk-TBUEE7XF.js.map} +0 -0
  234. /package/dist/{chunk-66SLUXKM.js.map → chunk-TEHPIMXD.js.map} +0 -0
  235. /package/dist/{chunk-TAPFJ3ET.js.map → chunk-U7XLOSG2.js.map} +0 -0
  236. /package/dist/{chunk-T3HC7RJ7.js.map → chunk-USAUBGBC.js.map} +0 -0
  237. /package/dist/{chunk-TAMY4NKO.js.map → chunk-USGKHF36.js.map} +0 -0
  238. /package/dist/{chunk-XRPPHIRI.js.map → chunk-VUDALPI5.js.map} +0 -0
  239. /package/dist/{chunk-HC55YCNK.js.map → chunk-VYIXCMUP.js.map} +0 -0
  240. /package/dist/{chunk-HQ2VGCGP.js.map → chunk-XBGLEOID.js.map} +0 -0
  241. /package/dist/{chunk-XX4HPFFM.js.map → chunk-XDL5CYZN.js.map} +0 -0
  242. /package/dist/{chunk-THACANBI.js.map → chunk-YOKSRIFI.js.map} +0 -0
  243. /package/dist/{chunk-BWJMBJCY.js.map → chunk-YTZA7U6Q.js.map} +0 -0
  244. /package/dist/{chunk-4KM3V6VS.js.map → chunk-ZN7SMKAK.js.map} +0 -0
  245. /package/dist/{contradiction-scan-522AYLQC.js.map → contradiction-scan-VOTQRRSX.js.map} +0 -0
  246. /package/dist/{first-start-migration-HZ4VPRFQ.js.map → first-start-migration-4NETATFF.js.map} +0 -0
  247. /package/dist/{graph-edge-decay-ZXGBXNPW.js.map → graph-edge-decay-A4SDFMMS.js.map} +0 -0
  248. /package/dist/{tier-stats-7E6OBUSD.js.map → tier-stats-4B5IJ3NE.js.map} +0 -0
package/src/chunking.ts CHANGED
@@ -47,51 +47,136 @@ function estimateTokens(text: string): number {
47
47
 
48
48
  /**
49
49
  * Split text into sentences.
50
- * Handles common abbreviations and edge cases.
50
+ *
51
+ * The scan stays linear and does not use a backtracking regular expression.
52
+ * ASCII punctuation keeps its old whitespace rule. Unicode terminators also
53
+ * split when the next sentence starts immediately, as in CJK text.
51
54
  */
52
- function splitSentences(text: string): string[] {
53
- // Split on sentence-ending punctuation (. ! ?) that is followed by whitespace
54
- // or end of string; the punctuation stays with the sentence.
55
- //
56
- // Implemented as a single linear scan rather than a regex. Every regex form of
57
- // this split is either polynomial (CodeQL js/polynomial-redos) or — once
58
- // bounded/anchored to satisfy CodeQL — mishandles long runs or non-boundary
59
- // punctuation (a global match silently drops a skipped prefix; a sticky match
60
- // stops at the first interior `.` that is not a real boundary, e.g. "v1.2.3"
61
- // or "example.com", emitting the whole document as one chunk). A character
62
- // scan is O(n), allocation-free, drops nothing, and treats interior
63
- // punctuation correctly. Normal prose splits identically to the previous
64
- // /[^.!?]*[.!?]+(?:\s+|$)/g form.
65
- const sentences: string[] = [];
55
+ const UNICODE_SENTENCE_TERMINATORS = "。.!?؟۔।॥。…";
56
+ const ASCII_SENTENCE_TERMINATORS = ".!?";
57
+ const CJK_NO_SPACE_TERMINATORS = "。.!?。";
58
+ const CLOSING_PUNCTUATION = "\"'”’»」』)]】〉》)]}";
59
+ const DIGITS = "01234567890123456789";
60
+ const SENTENCE_SEGMENTER =
61
+ typeof Intl.Segmenter === "function"
62
+ ? new Intl.Segmenter(undefined, { granularity: "sentence" })
63
+ : undefined;
64
+
65
+ function isSentenceTerminator(character: string): boolean {
66
+ return (
67
+ ASCII_SENTENCE_TERMINATORS.includes(character) ||
68
+ UNICODE_SENTENCE_TERMINATORS.includes(character)
69
+ );
70
+ }
71
+
72
+ function isCjk(character: string): boolean {
73
+ return /[\u3000-\u9fff\uf900-\ufaff\uac00-\ud7af]/u.test(character);
74
+ }
75
+
76
+ type SentenceList = string[] & { separators: string[] };
77
+
78
+ function splitSentencesFallback(text: string): SentenceList {
79
+ const sentences = [] as unknown as SentenceList;
80
+ Object.defineProperty(sentences, "separators", {
81
+ value: [],
82
+ writable: true,
83
+ });
66
84
  let start = 0;
85
+ let separator = "";
67
86
  for (let i = 0; i < text.length; i++) {
68
87
  const ch = text[i];
69
- if (ch !== "." && ch !== "!" && ch !== "?") continue;
70
- // Consume a run of terminators (e.g. "?!", "...").
88
+ if (!isSentenceTerminator(ch)) continue;
89
+
71
90
  let end = i;
72
- while (end + 1 < text.length) {
73
- const n = text[end + 1];
74
- if (n !== "." && n !== "!" && n !== "?") break;
91
+ let hasUnicodeTerminator = UNICODE_SENTENCE_TERMINATORS.includes(ch);
92
+ while (end + 1 < text.length && isSentenceTerminator(text[end + 1])) {
93
+ end++;
94
+ hasUnicodeTerminator ||= UNICODE_SENTENCE_TERMINATORS.includes(text[end]);
95
+ }
96
+
97
+ const isFullwidthDecimal =
98
+ ch === "." &&
99
+ DIGITS.includes(text[i - 1] ?? "") &&
100
+ DIGITS.includes(text[end + 1] ?? "");
101
+ while (end + 1 < text.length && CLOSING_PUNCTUATION.includes(text[end + 1])) {
75
102
  end++;
76
103
  }
104
+
77
105
  const after = text[end + 1];
78
- // A real boundary only if the terminator run ends the string or is followed
79
- // by whitespace. Interior punctuation (no following whitespace) is left in
80
- // place and the scan continues.
81
- if (after === undefined || /\s/.test(after)) {
106
+ const boundary =
107
+ !isFullwidthDecimal &&
108
+ (hasUnicodeTerminator || after === undefined || /\s/u.test(after));
109
+ if (boundary) {
82
110
  const sentence = text.slice(start, end + 1).trim();
83
- if (sentence.length > 0) sentences.push(sentence);
84
- start = end + 1;
111
+ if (sentence.length > 0) {
112
+ sentences.push(sentence);
113
+ if (sentences.length > 1) sentences.separators.push(separator);
114
+ }
115
+ let nextStart = end + 1;
116
+ while (nextStart < text.length && /\s/u.test(text[nextStart])) nextStart++;
117
+ separator = text.slice(end + 1, nextStart);
118
+ start = nextStart;
85
119
  }
86
120
  i = end;
87
121
  }
88
- // Trailing text without a closing terminator.
89
- if (start < text.length) {
90
- const remaining = text.slice(start).trim();
91
- if (remaining.length > 0) sentences.push(remaining);
122
+
123
+ const remaining = text.slice(start).trim();
124
+ if (remaining.length > 0) {
125
+ sentences.push(remaining);
126
+ if (sentences.length > 1) sentences.separators.push(separator);
92
127
  }
93
128
  return sentences;
94
129
  }
130
+ export function splitSentences(text: string): string[] {
131
+ const canUseSegmenter =
132
+ SENTENCE_SEGMENTER !== undefined &&
133
+ !/\s/u.test(text) &&
134
+ [...text].some((character) => UNICODE_SENTENCE_TERMINATORS.includes(character));
135
+ if (canUseSegmenter) {
136
+ const segments = Array.from(SENTENCE_SEGMENTER.segment(text), ({ segment }) =>
137
+ segment.trim(),
138
+ ).filter((segment) => segment.length > 0) as SentenceList;
139
+ if (
140
+ segments.length > 1 &&
141
+ segments.slice(0, -1).every((segment) =>
142
+ [...segment].some((character) =>
143
+ UNICODE_SENTENCE_TERMINATORS.includes(character),
144
+ ),
145
+ )
146
+ ) {
147
+ Object.defineProperty(segments, "separators", {
148
+ value: Array(segments.length - 1).fill(""),
149
+ writable: true,
150
+ });
151
+ return segments;
152
+ }
153
+ }
154
+ return splitSentencesFallback(text);
155
+ }
156
+
157
+ function endsWithoutSpace(text: string): boolean {
158
+ let index = text.length - 1;
159
+ while (index >= 0 && CLOSING_PUNCTUATION.includes(text[index])) index--;
160
+ const terminator = text[index] ?? "";
161
+ if (CJK_NO_SPACE_TERMINATORS.includes(terminator)) {
162
+ return isCjk(text[index - 1] ?? "");
163
+ }
164
+ return terminator === "…" && isCjk(text[index - 1] ?? "");
165
+ }
166
+ export function joinSentences(sentences: string[]): string {
167
+ let result = "";
168
+ const separators = (sentences as Partial<SentenceList>).separators;
169
+ for (let i = 0; i < sentences.length; i++) {
170
+ const sentence = sentences[i];
171
+ if (result.length > 0) {
172
+ const separator = separators?.[i - 1];
173
+ result += separator ?? (endsWithoutSpace(result) ? "" : " ");
174
+ }
175
+ result += sentence;
176
+ }
177
+ return result;
178
+ }
179
+
95
180
 
96
181
  /**
97
182
  * Chunk content into overlapping segments at sentence boundaries.
@@ -152,7 +237,7 @@ export function chunkContent(
152
237
 
153
238
  if (atTarget || isLastSentence) {
154
239
  // Create chunk from accumulated sentences
155
- const chunkContent = currentChunkSentences.join(" ");
240
+ const chunkContent = joinSentences(currentChunkSentences);
156
241
  chunks.push({
157
242
  content: chunkContent,
158
243
  index: chunkIndex,
@@ -216,14 +301,14 @@ export function reassembleChunks(chunks: string[]): string {
216
301
  const prevEnd = prevSentences.slice(-(j + 1));
217
302
  const currStart = currSentences.slice(0, j + 1);
218
303
 
219
- if (prevEnd.join(" ") === currStart.join(" ")) {
304
+ if (joinSentences(prevEnd) === joinSentences(currStart)) {
220
305
  overlapCount = j + 1;
221
306
  }
222
307
  }
223
308
 
224
309
  // Add non-overlapping portion
225
310
  if (overlapCount > 0 && overlapCount < currSentences.length) {
226
- result.push(currSentences.slice(overlapCount).join(" "));
311
+ result.push(joinSentences(currSentences.slice(overlapCount)));
227
312
  } else if (overlapCount === 0) {
228
313
  // No detected overlap, add full chunk
229
314
  result.push(currChunk);
@@ -231,5 +316,5 @@ export function reassembleChunks(chunks: string[]): string {
231
316
  // If overlapCount === currSentences.length, skip (fully contained)
232
317
  }
233
318
 
234
- return result.join(" ");
319
+ return joinSentences(result);
235
320
  }
@@ -19,7 +19,7 @@ import {
19
19
  withHeldFileLock,
20
20
  } from "../utils/serialize-mutations.js";
21
21
  import { isSafeRouteNamespace } from "../routing/engine.js";
22
- import { namespaceIdentityFromToken, namespaceIdentityToken, normalizeNamespaceIdentity } from "./identity.js";
22
+ import { namespaceIdentityFromToken, namespaceIdentityLegacyToken, namespaceIdentityToken, normalizeNamespaceIdentity } from "./identity.js";
23
23
  import { resolveDefaultNamespaceRoot, resolveNamespaceStorageRoot } from "./storage.js";
24
24
  import { ALL_CATEGORY_DIRS } from "../utils/category-dir.js";
25
25
 
@@ -1721,8 +1721,7 @@ export class NamespaceCatalog {
1721
1721
  // sourced from their tokenized dir so a later legacy-named `readdir` entry
1722
1722
  // cannot overwrite the tokenized record (and vice-versa: a tokenized entry
1723
1723
  // always wins over a previously-set legacy one).
1724
- const scannedFromTokenized = new Set<string>();
1725
-
1724
+ const scannedFromTokenized = new Map<string, number>();
1726
1725
  for (const entry of entries) {
1727
1726
  const token = entry.name;
1728
1727
  const fullPath = path.join(namespacesDir, token);
@@ -1798,7 +1797,7 @@ export class NamespaceCatalog {
1798
1797
  const tokenDecoded = literalOwnsRoot ? null : namespaceIdentityFromToken(token);
1799
1798
  const rawDecoded = tokenDecoded && tokenDecoded.length > 0 ? tokenDecoded : token;
1800
1799
  const decoded = normalizeNamespaceIdentity(rawDecoded);
1801
- if (decoded.length === 0 || rawDecoded !== decoded) {
1800
+ if (decoded.length === 0 || (rawDecoded !== decoded && rawDecoded.normalize("NFC") !== decoded)) {
1802
1801
  skipped.push({ token, reason: "unsafe", detail: rawDecoded });
1803
1802
  continue;
1804
1803
  }
@@ -1841,11 +1840,12 @@ export class NamespaceCatalog {
1841
1840
  // is the on-disk dir name; it is the tokenized dir iff it equals the
1842
1841
  // namespace's identity token. If we already recorded this namespace from
1843
1842
  // its tokenized dir, a later legacy-named entry must not clobber it.
1844
- const isTokenizedEntry = token === namespaceIdentityToken(decoded);
1845
- if (rebuilt.has(decoded) && scannedFromTokenized.has(decoded) && !isTokenizedEntry) {
1846
- continue;
1847
- }
1848
- if (isTokenizedEntry) scannedFromTokenized.add(decoded);
1843
+ const canonicalToken = namespaceIdentityToken(decoded);
1844
+ const legacyToken = namespaceIdentityLegacyToken(decoded.normalize("NFD"));
1845
+ const tokenKind = token === canonicalToken ? 2 : token === legacyToken ? 1 : 0;
1846
+ const priorTokenKind = scannedFromTokenized.get(decoded) ?? 0;
1847
+ if (rebuilt.has(decoded) && priorTokenKind > tokenKind) continue;
1848
+ if (tokenKind > 0) scannedFromTokenized.set(decoded, tokenKind);
1849
1849
 
1850
1850
  const prior = existing.get(decoded);
1851
1851
  rebuilt.set(
@@ -1,14 +1,21 @@
1
1
  export function normalizeNamespaceIdentity(namespace: string | null | undefined): string {
2
- return namespace?.trim() ?? "";
2
+ return namespace?.trim().normalize("NFC") ?? "";
3
3
  }
4
4
 
5
- export function namespaceIdentityToken(namespace: string): string {
6
- const normalized = normalizeNamespaceIdentity(namespace);
7
- const bytes = new TextEncoder().encode(normalized);
5
+ function encodeNamespaceIdentityToken(namespace: string): string {
6
+ const bytes = new TextEncoder().encode(namespace);
8
7
  const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
9
8
  return `ns-${hex || "default"}`;
10
9
  }
11
10
 
11
+ export function namespaceIdentityToken(namespace: string): string {
12
+ return encodeNamespaceIdentityToken(normalizeNamespaceIdentity(namespace));
13
+ }
14
+
15
+ export function namespaceIdentityLegacyToken(namespace: string): string {
16
+ return encodeNamespaceIdentityToken(namespace.trim());
17
+ }
18
+
12
19
  export function namespaceIdentityFromToken(token: string): string | null {
13
20
  if (!token.startsWith("ns-")) return null;
14
21
  const hex = token.slice(3);
@@ -17,7 +24,9 @@ export function namespaceIdentityFromToken(token: string): string | null {
17
24
  return null;
18
25
  }
19
26
  const decoded = Buffer.from(hex, "hex").toString("utf8");
20
- return namespaceIdentityToken(decoded).toLowerCase() === token.toLowerCase()
21
- ? decoded
27
+ const normalized = normalizeNamespaceIdentity(decoded);
28
+ return namespaceIdentityToken(decoded).toLowerCase() === token.toLowerCase() ||
29
+ namespaceIdentityLegacyToken(decoded).toLowerCase() === token.toLowerCase()
30
+ ? normalized
22
31
  : null;
23
32
  }
@@ -12,8 +12,8 @@
12
12
  import assert from "node:assert/strict";
13
13
  import test from "node:test";
14
14
 
15
- import { canReadNamespace, isNamespacePolicyCovered, resolvePrincipal } from "./principal.js";
16
15
  import type { PluginConfig } from "../types.js";
16
+ import { canReadNamespace, canWriteNamespace, isNamespacePolicyCovered, resolvePrincipal } from "./principal.js";
17
17
 
18
18
  // ──────────────────────────────────────────────────────────────────────────
19
19
  // Helpers
@@ -285,3 +285,12 @@ test("isNamespacePolicyCovered: an explicit default-namespace policy overrides t
285
285
  const grantedDefault = makeConfig(true, [{ name: "default", readPrincipals: [], writePrincipals: ["alice"] }]);
286
286
  assert.equal(isNamespacePolicyCovered("default", grantedDefault), true);
287
287
  });
288
+
289
+ test("namespace ACL matches canonically equivalent Unicode identities", () => {
290
+ const config = makeConfig(true, [
291
+ { name: "Cafe\u0301", readPrincipals: ["reader"], writePrincipals: ["writer"] },
292
+ ]);
293
+
294
+ assert.equal(canReadNamespace("reader", "Café", config), true);
295
+ assert.equal(canWriteNamespace("writer", "Café", config), true);
296
+ });
@@ -1,7 +1,7 @@
1
1
  import { resolveNamespaceCapabilities } from "../capabilities.js";
2
2
  import type { PluginConfig } from "../types.js";
3
3
  import { isLikelyUnsafeRegex } from "../routing/engine.js";
4
-
4
+ import { normalizeNamespaceIdentity } from "./identity.js";
5
5
  const MAX_REGEX_SESSION_KEY_LENGTH = 512;
6
6
 
7
7
  function compileSafePrincipalRegex(pattern: string): RegExp | null {
@@ -54,16 +54,21 @@ export function resolvePrincipal(sessionKey: string | undefined, config: PluginC
54
54
  export function canReadNamespace(principal: string | undefined, namespace: string, config: PluginConfig): boolean {
55
55
  if (!resolveNamespaceCapabilities(config).namespaces) return true;
56
56
  if (!principal) return false;
57
- const policy = config.namespacePolicies.find((p) => p.name === namespace);
58
- if (!policy) return namespace === config.defaultNamespace || namespace === config.sharedNamespace;
57
+ const identity = normalizeNamespaceIdentity(namespace);
58
+ const policy = config.namespacePolicies.find((p) => normalizeNamespaceIdentity(p.name) === identity);
59
+ if (!policy) {
60
+ return identity === normalizeNamespaceIdentity(config.defaultNamespace) ||
61
+ identity === normalizeNamespaceIdentity(config.sharedNamespace);
62
+ }
59
63
  return policy.readPrincipals.includes(principal) || policy.readPrincipals.includes("*");
60
64
  }
61
65
 
62
66
  export function canWriteNamespace(principal: string | undefined, namespace: string, config: PluginConfig): boolean {
63
67
  if (!resolveNamespaceCapabilities(config).namespaces) return true;
64
68
  if (!principal) return false;
65
- const policy = config.namespacePolicies.find((p) => p.name === namespace);
66
- if (!policy) return namespace === config.defaultNamespace;
69
+ const identity = normalizeNamespaceIdentity(namespace);
70
+ const policy = config.namespacePolicies.find((p) => normalizeNamespaceIdentity(p.name) === identity);
71
+ if (!policy) return identity === normalizeNamespaceIdentity(config.defaultNamespace);
67
72
  return policy.writePrincipals.includes(principal) || policy.writePrincipals.includes("*");
68
73
  }
69
74
 
@@ -87,8 +92,9 @@ export function isNamespacePolicyCovered(namespace: string, config: PluginConfig
87
92
  // An explicit policy wins over the default fallback (exactly as
88
93
  // canWriteNamespace), so a policy naming the default with no writers is NOT
89
94
  // covered. Only fall back to defaultNamespace when no policy matches.
90
- const policy = config.namespacePolicies.find((p) => p.name === namespace);
91
- if (!policy) return namespace === config.defaultNamespace;
95
+ const identity = normalizeNamespaceIdentity(namespace);
96
+ const policy = config.namespacePolicies.find((p) => normalizeNamespaceIdentity(p.name) === identity);
97
+ if (!policy) return identity === normalizeNamespaceIdentity(config.defaultNamespace);
92
98
  return policy.writePrincipals.some((w) => w.trim().length > 0);
93
99
  }
94
100
 
@@ -102,8 +108,10 @@ export function isNamespacePolicyCovered(namespace: string, config: PluginConfig
102
108
  export function defaultNamespaceForPrincipal(principal: string | undefined, config: PluginConfig): string {
103
109
  if (!resolveNamespaceCapabilities(config).namespaces) return config.defaultNamespace;
104
110
  if (!principal) return config.defaultNamespace;
105
- const exists = config.namespacePolicies.some((p) => p.name === principal);
106
- return exists ? principal : config.defaultNamespace;
111
+ const policy = config.namespacePolicies.find(
112
+ (p) => normalizeNamespaceIdentity(p.name) === normalizeNamespaceIdentity(principal),
113
+ );
114
+ return policy ? normalizeNamespaceIdentity(policy.name) : config.defaultNamespace;
107
115
  }
108
116
 
109
117
  export function recallNamespacesForPrincipal(principal: string | undefined, config: PluginConfig): string[] {
@@ -12,7 +12,12 @@ import type {
12
12
  ScopeProfilePromotionTarget,
13
13
  ScopeTeamConfig,
14
14
  } from "../types.js";
15
- import { canReadNamespace, canWriteNamespace, defaultNamespaceForPrincipal } from "./principal.js";
15
+ import {
16
+ canReadNamespace,
17
+ canWriteNamespace,
18
+ defaultNamespaceForPrincipal,
19
+ } from "./principal.js";
20
+ import { normalizeNamespaceIdentity } from "./identity.js";
16
21
 
17
22
  type ScopeProfileCodingOverlay = Pick<CodingNamespaceOverlay, "namespace" | "readFallbacks">;
18
23
 
@@ -65,19 +70,28 @@ function principalListed(list: string[], principal: string | undefined): boolean
65
70
  }
66
71
 
67
72
  function derivedScopeProfileSelfNamespace(principal: string | undefined, config: PluginConfig): string | null {
68
- if (!principal || principal === config.defaultNamespace || principal === config.sharedNamespace) return null;
73
+ if (
74
+ !principal ||
75
+ normalizeNamespaceIdentity(principal) === normalizeNamespaceIdentity(config.defaultNamespace) ||
76
+ normalizeNamespaceIdentity(principal) === normalizeNamespaceIdentity(config.sharedNamespace)
77
+ ) {
78
+ return null;
79
+ }
69
80
  if (isSafeRouteNamespace(principal)) return principal;
70
81
  return "principal-" + createHash("sha256").update(principal).digest("hex").slice(0, 54);
71
82
  }
72
83
 
73
84
  function scopeProfileSelfNamespace(principal: string | undefined, config: PluginConfig): string {
74
85
  const existing = defaultNamespaceForPrincipal(principal, config);
75
- if (existing !== config.defaultNamespace) return existing;
86
+ if (normalizeNamespaceIdentity(existing) !== normalizeNamespaceIdentity(config.defaultNamespace)) return existing;
76
87
  return derivedScopeProfileSelfNamespace(principal, config) ?? existing;
77
88
  }
78
89
 
79
90
  function hasExplicitNamespacePolicy(namespace: string, config: PluginConfig): boolean {
80
- return (config.namespacePolicies ?? []).some((policy) => policy.name === namespace);
91
+ const identity = normalizeNamespaceIdentity(namespace);
92
+ return (config.namespacePolicies ?? []).some(
93
+ (policy) => normalizeNamespaceIdentity(policy.name) === identity,
94
+ );
81
95
  }
82
96
 
83
97
  function isScopeProfileImplicitSelfNamespace(
@@ -88,9 +102,9 @@ function isScopeProfileImplicitSelfNamespace(
88
102
  const derived = derivedScopeProfileSelfNamespace(principal, config);
89
103
  return Boolean(
90
104
  derived &&
91
- namespace === derived &&
92
- namespace !== config.defaultNamespace &&
93
- namespace !== config.sharedNamespace &&
105
+ normalizeNamespaceIdentity(namespace) === normalizeNamespaceIdentity(derived) &&
106
+ normalizeNamespaceIdentity(namespace) !== normalizeNamespaceIdentity(config.defaultNamespace) &&
107
+ normalizeNamespaceIdentity(namespace) !== normalizeNamespaceIdentity(config.sharedNamespace) &&
94
108
  isSafeRouteNamespace(namespace) &&
95
109
  !hasExplicitNamespacePolicy(namespace, config),
96
110
  );
@@ -297,16 +311,24 @@ function resolveLayer(params: {
297
311
  const userProjectBase = namespace.endsWith(userProjectSuffix)
298
312
  ? namespace.slice(0, -userProjectSuffix.length)
299
313
  : "";
314
+ const userProjectBaseIdentity = normalizeNamespaceIdentity(userProjectBase);
315
+ const defaultIdentity = normalizeNamespaceIdentity(config.defaultNamespace);
316
+ const sharedIdentity = normalizeNamespaceIdentity(config.sharedNamespace);
317
+ const namespaceIdentity = normalizeNamespaceIdentity(namespace);
300
318
  const dynamicUserProjectCollision =
301
319
  userProjectBase.length > 0 &&
302
- (userProjectBase === config.defaultNamespace ||
303
- userProjectBase === config.sharedNamespace ||
304
- (config.namespacePolicies ?? []).some((policy) => policy.name === userProjectBase));
320
+ (userProjectBaseIdentity === defaultIdentity ||
321
+ userProjectBaseIdentity === sharedIdentity ||
322
+ (config.namespacePolicies ?? []).some(
323
+ (policy) => normalizeNamespaceIdentity(policy.name) === userProjectBaseIdentity,
324
+ ));
305
325
  const protectedNamespace =
306
- namespace === config.defaultNamespace ||
307
- namespace === config.sharedNamespace ||
326
+ namespaceIdentity === defaultIdentity ||
327
+ namespaceIdentity === sharedIdentity ||
308
328
  dynamicUserProjectCollision ||
309
- (config.namespacePolicies ?? []).some((policy) => policy.name === namespace);
329
+ (config.namespacePolicies ?? []).some(
330
+ (policy) => normalizeNamespaceIdentity(policy.name) === namespaceIdentity,
331
+ );
310
332
  const policyReadable = !protectedNamespace || canReadNamespace(principal, namespace, config);
311
333
  const policyWritable = !protectedNamespace || canWriteNamespace(principal, namespace, config);
312
334
  const policyBlocked = protectedNamespace && (!policyReadable || !policyWritable);
@@ -6,7 +6,11 @@ import { StorageManager } from "../storage.js";
6
6
  import { keyring, secureStoreDir } from "../secure-store/index.js";
7
7
  import type { PluginConfig } from "../types.js";
8
8
  import { ALL_CATEGORY_DIRS } from "../utils/category-dir.js";
9
- import { namespaceIdentityToken, normalizeNamespaceIdentity } from "./identity.js";
9
+ import {
10
+ namespaceIdentityLegacyToken,
11
+ namespaceIdentityToken,
12
+ normalizeNamespaceIdentity,
13
+ } from "./identity.js";
10
14
  import type { NamespaceCatalog } from "./catalog.js";
11
15
  import { MutationSerializer } from "../utils/serialize-mutations.js";
12
16
 
@@ -153,19 +157,43 @@ export async function resolveDefaultNamespaceRoot(config: PluginConfig): Promise
153
157
  // back to memoryDir/tokenized. `namespaceIdentityToken` already normalizes
154
158
  // internally, so the tokenized path is unaffected.
155
159
  const defaultIdentity = normalizeNamespaceIdentity(config.defaultNamespace);
160
+ const defaultNfdIdentity = defaultIdentity.normalize("NFD");
156
161
  const legacyNsDir = resolveNamespaceDir(config.memoryDir, defaultIdentity);
162
+ const legacyNfdNsDir = resolveNamespaceDir(config.memoryDir, defaultNfdIdentity);
157
163
  const tokenizedNsDir = resolveNamespaceDir(
158
164
  config.memoryDir,
159
- namespaceIdentityToken(config.defaultNamespace),
165
+ namespaceIdentityToken(defaultIdentity),
166
+ );
167
+ const legacyTokenNsDir = resolveNamespaceDir(
168
+ config.memoryDir,
169
+ namespaceIdentityLegacyToken(defaultNfdIdentity),
160
170
  );
161
171
  const tokenizedHasData =
162
172
  (await exists(tokenizedNsDir)) &&
163
173
  (await hasAnyNamespaceStorageMarker(tokenizedNsDir, { includeRuntimeState: true }));
174
+ const legacyTokenHasData =
175
+ (await exists(legacyTokenNsDir)) &&
176
+ (await hasAnyNamespaceStorageMarker(legacyTokenNsDir, { includeRuntimeState: true }));
177
+ const legacyHasData =
178
+ (await exists(legacyNsDir)) &&
179
+ (await hasAnyNamespaceStorageMarker(legacyNsDir, { includeRuntimeState: true }));
180
+ const legacyNfdHasData =
181
+ legacyNfdNsDir !== legacyNsDir &&
182
+ (await exists(legacyNfdNsDir)) &&
183
+ (await hasAnyNamespaceStorageMarker(legacyNfdNsDir, { includeRuntimeState: true }));
164
184
  const nsDir = tokenizedHasData
165
185
  ? tokenizedNsDir
166
- : (await exists(legacyNsDir))
167
- ? legacyNsDir
168
- : tokenizedNsDir;
186
+ : legacyTokenHasData
187
+ ? legacyTokenNsDir
188
+ : legacyHasData
189
+ ? legacyNsDir
190
+ : legacyNfdHasData
191
+ ? legacyNfdNsDir
192
+ : (await exists(legacyNsDir))
193
+ ? legacyNsDir
194
+ : legacyNfdNsDir !== legacyNsDir && (await exists(legacyNfdNsDir))
195
+ ? legacyNfdNsDir
196
+ : tokenizedNsDir;
169
197
  return (await exists(nsDir)) && !(await hasAnyLegacyData(config.memoryDir))
170
198
  ? nsDir
171
199
  : config.memoryDir;
@@ -192,15 +220,37 @@ export async function resolveNamespaceStorageRoot(
192
220
  if (normalizeNamespaceIdentity(namespace) === normalizeNamespaceIdentity(config.defaultNamespace)) {
193
221
  return resolveDefaultNamespaceRoot(config);
194
222
  }
195
- const legacyRoot = resolveNamespaceDir(config.memoryDir, namespace);
196
- const tokenizedRoot = resolveNamespaceDir(config.memoryDir, namespaceIdentityToken(namespace));
197
- if (
198
- (await exists(tokenizedRoot)) &&
199
- (await hasAnyNamespaceStorageMarker(tokenizedRoot, { includeRuntimeState: true }))
200
- ) {
201
- return tokenizedRoot;
223
+ const normalized = normalizeNamespaceIdentity(namespace);
224
+ const legacyExactNamespace = namespace.trim();
225
+ const legacyNfdNamespace = normalized.normalize("NFD");
226
+ const legacyRoot = resolveNamespaceDir(config.memoryDir, normalized);
227
+ const legacyExactRoot =
228
+ legacyExactNamespace !== normalized && isSafeRouteNamespace(legacyExactNamespace)
229
+ ? resolveNamespaceDir(config.memoryDir, legacyExactNamespace)
230
+ : null;
231
+ const legacyNfdRoot =
232
+ legacyNfdNamespace !== normalized
233
+ ? resolveNamespaceDir(config.memoryDir, legacyNfdNamespace)
234
+ : null;
235
+ const tokenizedRoot = resolveNamespaceDir(config.memoryDir, namespaceIdentityToken(normalized));
236
+ const legacyTokenRoots = [
237
+ namespaceIdentityLegacyToken(legacyExactNamespace),
238
+ namespaceIdentityLegacyToken(legacyNfdNamespace),
239
+ ]
240
+ .filter((token, index, tokens) => tokens.indexOf(token) === index)
241
+ .map((token) => resolveNamespaceDir(config.memoryDir, token));
242
+ for (const candidate of [tokenizedRoot, ...legacyTokenRoots]) {
243
+ if (
244
+ (await exists(candidate)) &&
245
+ (await hasAnyNamespaceStorageMarker(candidate, { includeRuntimeState: true }))
246
+ ) {
247
+ return candidate;
248
+ }
249
+ }
250
+ for (const candidate of [legacyRoot, legacyExactRoot, legacyNfdRoot]) {
251
+ if (candidate && (await exists(candidate))) return candidate;
202
252
  }
203
- return (await exists(legacyRoot)) ? legacyRoot : tokenizedRoot;
253
+ return tokenizedRoot;
204
254
  }
205
255
 
206
256
  export class NamespaceStorageRouter {
@@ -0,0 +1,71 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import type { PluginConfig } from "../types.js";
8
+ import { NamespaceCatalog } from "./catalog.js";
9
+ import { namespaceIdentityFromToken, namespaceIdentityLegacyToken, namespaceIdentityToken } from "./identity.js";
10
+ import { resolveDefaultNamespaceRoot, resolveNamespaceStorageRoot } from "./storage.js";
11
+
12
+ function makeConfig(memoryDir: string): PluginConfig {
13
+ return {
14
+ memoryDir,
15
+ namespacesEnabled: true,
16
+ namespaceCatalogEnabled: true,
17
+ defaultNamespace: "default",
18
+ sharedNamespace: "shared",
19
+ namespacePolicies: [],
20
+ entitySchemas: {},
21
+ } as unknown as PluginConfig;
22
+ }
23
+
24
+ test("Unicode namespaces decode from disk and list in stable order", async () => {
25
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-unicode-routing-"));
26
+ try {
27
+ const config = makeConfig(memoryDir);
28
+ const catalog = new NamespaceCatalog(config);
29
+ const cjk = "项目";
30
+ const ascii = "alpha";
31
+
32
+ assert.equal(namespaceIdentityFromToken(namespaceIdentityToken(cjk)), cjk);
33
+ assert.equal(namespaceIdentityToken("Cafe\u0301"), namespaceIdentityToken("Café"));
34
+ const nfd = "Café".normalize("NFD");
35
+ const legacyToken = namespaceIdentityLegacyToken(nfd);
36
+ assert.equal(namespaceIdentityFromToken(legacyToken), "Café");
37
+ await mkdir(path.join(memoryDir, "namespaces", legacyToken, "facts"), { recursive: true });
38
+ assert.equal(
39
+ await resolveNamespaceStorageRoot(config, "Café"),
40
+ path.join(memoryDir, "namespaces", legacyToken),
41
+ );
42
+ const rebuilt = await catalog.rebuildFromDisk({ dryRun: true });
43
+ const legacyRecord = rebuilt.records.find((record) => record.namespace === "Café");
44
+ assert.equal(legacyRecord?.storageDir, path.join(memoryDir, "namespaces", legacyToken));
45
+ await catalog.markWrite(cjk, { discoveredBy: "write" });
46
+ await catalog.markWrite(ascii, { discoveredBy: "write" });
47
+
48
+ const first = await catalog.listNamespaces();
49
+ const second = await catalog.listNamespaces();
50
+ assert.deepEqual(
51
+ first.map((record) => record.namespace),
52
+ second.map((record) => record.namespace)
53
+ );
54
+ assert.ok(first.some((record) => record.namespace === cjk));
55
+ } finally {
56
+ await rm(memoryDir, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test("default Unicode namespace resolves legacy NFD roots", async () => {
61
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-unicode-default-"));
62
+ try {
63
+ const config = { ...makeConfig(memoryDir), defaultNamespace: "Café" };
64
+ const nfd = config.defaultNamespace.normalize("NFD");
65
+ const nfdRoot = path.join(memoryDir, "namespaces", nfd);
66
+ await mkdir(path.join(nfdRoot, "facts"), { recursive: true });
67
+ assert.equal(await resolveDefaultNamespaceRoot(config), nfdRoot);
68
+ } finally {
69
+ await rm(memoryDir, { recursive: true, force: true });
70
+ }
71
+ });