@remnic/core 9.3.712 → 9.3.714

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 (328) hide show
  1. package/dist/access-boundary.d.ts +6 -6
  2. package/dist/access-boundary.js +17 -16
  3. package/dist/access-cli.js +40 -39
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +5 -5
  6. package/dist/access-http.js +24 -23
  7. package/dist/access-mcp.d.ts +18 -5
  8. package/dist/access-mcp.js +21 -20
  9. package/dist/access-operations-batch.js +18 -17
  10. package/dist/access-operations.d.ts +6 -5
  11. package/dist/access-operations.js +20 -19
  12. package/dist/access-schema.d.ts +10 -0
  13. package/dist/access-schema.js +1 -1
  14. package/dist/{access-service-Dx2rSJjH.d.ts → access-service-lddjZoRh.d.ts} +20 -5
  15. package/dist/access-service.d.ts +5 -5
  16. package/dist/access-service.js +16 -15
  17. package/dist/access-surface-catalog.d.ts +5 -5
  18. package/dist/access-surface-catalog.js +6 -1
  19. package/dist/access-surface-catalog.js.map +1 -1
  20. package/dist/action-confidence.d.ts +1 -1
  21. package/dist/active-memory-bridge.d.ts +3 -1
  22. package/dist/active-memory-bridge.js +2 -1
  23. package/dist/active-recall.d.ts +1 -1
  24. package/dist/active-recall.js +4 -3
  25. package/dist/active-recall.js.map +1 -1
  26. package/dist/adapters/index.js +4 -4
  27. package/dist/adapters/registry.js +2 -2
  28. package/dist/behavior-learner.d.ts +1 -1
  29. package/dist/behavior-signals.d.ts +1 -1
  30. package/dist/bootstrap.d.ts +4 -4
  31. package/dist/briefing.d.ts +1 -1
  32. package/dist/briefing.js +8 -7
  33. package/dist/buffer-surprise-report.d.ts +1 -1
  34. package/dist/buffer.d.ts +1 -1
  35. package/dist/calibration.d.ts +1 -1
  36. package/dist/capabilities.d.ts +1 -1
  37. package/dist/{catalog-CadPvH29.d.ts → catalog-CbR0CsUw.d.ts} +1 -1
  38. package/dist/causal-behavior.d.ts +1 -1
  39. package/dist/causal-consolidation.d.ts +1 -1
  40. package/dist/causal-consolidation.js +9 -8
  41. package/dist/causal-consolidation.js.map +1 -1
  42. package/dist/{chunk-OMSTU233.js → chunk-2KAYTPPT.js} +4 -4
  43. package/dist/{chunk-VYZNWH4J.js → chunk-2SXMCQEQ.js} +3 -3
  44. package/dist/{chunk-NXL5CVE7.js → chunk-2Y6LMOQQ.js} +2 -2
  45. package/dist/{chunk-AMXOCZHE.js → chunk-3VUPWDSE.js} +15 -3
  46. package/dist/{chunk-AMXOCZHE.js.map → chunk-3VUPWDSE.js.map} +1 -1
  47. package/dist/{chunk-FVQJYWH7.js → chunk-5GKPFL3S.js} +1 -1
  48. package/dist/{chunk-FVQJYWH7.js.map → chunk-5GKPFL3S.js.map} +1 -1
  49. package/dist/{chunk-LQ4J7ELC.js → chunk-67HG7KFE.js} +2 -2
  50. package/dist/{chunk-ZBG4K36I.js → chunk-6FUJ2VYY.js} +3 -3
  51. package/dist/{chunk-GMRNKPWO.js → chunk-6QUPUTT7.js} +2 -2
  52. package/dist/{chunk-Z56IHRVV.js → chunk-6UU43ZGD.js} +2 -2
  53. package/dist/{chunk-APJQ6UEA.js → chunk-AGNBY3VG.js} +4 -4
  54. package/dist/{chunk-OWOKOFXK.js → chunk-AJOIM6UR.js} +2 -2
  55. package/dist/{chunk-JBPKEARU.js → chunk-AU7Q3LSC.js} +4 -4
  56. package/dist/{chunk-YVWZW37D.js → chunk-BNOAB5HH.js} +62 -12
  57. package/dist/{chunk-YVWZW37D.js.map → chunk-BNOAB5HH.js.map} +1 -1
  58. package/dist/{chunk-A2R5NV6O.js → chunk-CTAHKIG6.js} +205 -29
  59. package/dist/chunk-CTAHKIG6.js.map +1 -0
  60. package/dist/{chunk-K22IJO4R.js → chunk-CTOQEZSN.js} +2 -2
  61. package/dist/{chunk-SINGJCUR.js → chunk-CX77GNMB.js} +14 -3
  62. package/dist/chunk-CX77GNMB.js.map +1 -0
  63. package/dist/{chunk-HSDJCT3V.js → chunk-DXGS5YOQ.js} +3 -3
  64. package/dist/{chunk-HV57RHMD.js → chunk-EH5ED3C6.js} +2 -2
  65. package/dist/{chunk-NUWZFSKC.js → chunk-FVZ2ISIX.js} +9 -1
  66. package/dist/chunk-FVZ2ISIX.js.map +1 -0
  67. package/dist/{chunk-GKPIY7EQ.js → chunk-FXZKXW7C.js} +117 -46
  68. package/dist/chunk-FXZKXW7C.js.map +1 -0
  69. package/dist/{chunk-7NDYFAJS.js → chunk-GM3BMWKR.js} +2 -2
  70. package/dist/{chunk-ZVDRL6SK.js → chunk-HHLWSWWF.js} +2 -2
  71. package/dist/chunk-HHLWSWWF.js.map +1 -0
  72. package/dist/{chunk-5QUS24F7.js → chunk-I56LKMF7.js} +2 -2
  73. package/dist/{chunk-Z3B2YW56.js → chunk-IHMT6XTY.js} +9 -5
  74. package/dist/chunk-IHMT6XTY.js.map +1 -0
  75. package/dist/chunk-JAKIDQB2.js +173 -0
  76. package/dist/chunk-JAKIDQB2.js.map +1 -0
  77. package/dist/{chunk-35YJ6KCV.js → chunk-JEIROMLX.js} +2 -2
  78. package/dist/{chunk-SKQCFAYU.js → chunk-JVEMUX6L.js} +2 -2
  79. package/dist/{chunk-CXMXAC5R.js → chunk-K44MIN6D.js} +3 -3
  80. package/dist/{chunk-5KQCOIPW.js → chunk-MECU6WSH.js} +188 -31
  81. package/dist/chunk-MECU6WSH.js.map +1 -0
  82. package/dist/{chunk-BQCFXAMN.js → chunk-MPHDVHIB.js} +2 -2
  83. package/dist/{chunk-BOGENF7P.js → chunk-MSVSP5VO.js} +1 -1
  84. package/dist/chunk-MSVSP5VO.js.map +1 -0
  85. package/dist/{chunk-QXNFQKWU.js → chunk-MTHNJCOJ.js} +2 -2
  86. package/dist/{chunk-3TCRU4JA.js → chunk-O62P2LFL.js} +2 -2
  87. package/dist/{chunk-JSDZMOT7.js → chunk-OC56UWV5.js} +11 -11
  88. package/dist/{chunk-7IBEWQLG.js → chunk-QOIONSOI.js} +2 -2
  89. package/dist/{chunk-4P5UAL3C.js → chunk-RBHANGVX.js} +6 -6
  90. package/dist/{chunk-SK2CR6MW.js → chunk-RVLB4E6N.js} +2 -2
  91. package/dist/{chunk-I3BT2IDW.js → chunk-SYDHFFAW.js} +1110 -28
  92. package/dist/chunk-SYDHFFAW.js.map +1 -0
  93. package/dist/{chunk-VILEUJXC.js → chunk-UC4YOAC2.js} +74 -2
  94. package/dist/{chunk-VILEUJXC.js.map → chunk-UC4YOAC2.js.map} +1 -1
  95. package/dist/{chunk-CNVIWMQI.js → chunk-UY4JMEAK.js} +2 -2
  96. package/dist/{chunk-S6FQLQGH.js → chunk-V7GDTQ6I.js} +2 -2
  97. package/dist/{chunk-JNOYYWCA.js → chunk-VO4UICII.js} +3 -3
  98. package/dist/{chunk-54TI5GLV.js → chunk-WI4QX7GH.js} +3 -3
  99. package/dist/{chunk-3XHD3XGK.js → chunk-XCTDHF7U.js} +30 -2
  100. package/dist/chunk-XCTDHF7U.js.map +1 -0
  101. package/dist/{chunk-XKU4YE6Z.js → chunk-XZTUMWKQ.js} +2 -2
  102. package/dist/{cli-DTWRoJEI.d.ts → cli-iAiVkbYJ.d.ts} +3 -3
  103. package/dist/cli.d.ts +6 -6
  104. package/dist/cli.js +43 -42
  105. package/dist/compounding/engine.d.ts +1 -1
  106. package/dist/compounding/engine.js +8 -7
  107. package/dist/compounding/preference-consolidator.d.ts +1 -1
  108. package/dist/compression-optimizer.d.ts +1 -1
  109. package/dist/config.d.ts +1 -1
  110. package/dist/config.js +4 -3
  111. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  112. package/dist/connectors/codex-materialize-runner.js +8 -7
  113. package/dist/connectors/codex-materialize.d.ts +1 -1
  114. package/dist/connectors/index.d.ts +1 -1
  115. package/dist/connectors/index.js +9 -8
  116. package/dist/consolidation-provenance-check.d.ts +1 -1
  117. package/dist/consolidation-undo.d.ts +1 -1
  118. package/dist/contradiction/index.d.ts +1 -1
  119. package/dist/conversation-index/backend.d.ts +1 -1
  120. package/dist/conversation-index/chunker.d.ts +1 -1
  121. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  122. package/dist/conversation-index/indexer.d.ts +1 -1
  123. package/dist/conversation-index/search.d.ts +1 -1
  124. package/dist/day-summary.d.ts +1 -1
  125. package/dist/delinearize.d.ts +1 -1
  126. package/dist/direct-answer-wiring.d.ts +1 -1
  127. package/dist/direct-answer.d.ts +1 -1
  128. package/dist/embedding-fallback.d.ts +1 -1
  129. package/dist/enrichment/index.d.ts +1 -1
  130. package/dist/entity-retrieval.d.ts +1 -1
  131. package/dist/entity-retrieval.js +8 -7
  132. package/dist/entity-schema.d.ts +1 -1
  133. package/dist/explicit-capture.d.ts +4 -4
  134. package/dist/explicit-capture.js +3 -2
  135. package/dist/extraction-faithfulness.d.ts +1 -1
  136. package/dist/extraction-judge-telemetry.d.ts +1 -1
  137. package/dist/extraction-judge-training.d.ts +1 -1
  138. package/dist/extraction-judge.d.ts +1 -1
  139. package/dist/extraction.d.ts +1 -1
  140. package/dist/extraction.js +4 -3
  141. package/dist/fallback-llm.d.ts +1 -1
  142. package/dist/identity-continuity.d.ts +1 -1
  143. package/dist/importance.d.ts +1 -1
  144. package/dist/index.d.ts +9 -9
  145. package/dist/index.js +62 -61
  146. package/dist/index.js.map +1 -1
  147. package/dist/intent.d.ts +1 -1
  148. package/dist/lcm/engine.d.ts +1 -1
  149. package/dist/lcm/index.d.ts +1 -1
  150. package/dist/lcm/index.js +3 -3
  151. package/dist/lcm/tools.d.ts +1 -1
  152. package/dist/lifecycle.d.ts +1 -1
  153. package/dist/live-connectors-runner.d.ts +1 -1
  154. package/dist/local-llm.d.ts +1 -1
  155. package/dist/maintenance/memory-governance.d.ts +1 -1
  156. package/dist/maintenance/memory-governance.js +8 -7
  157. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +8 -7
  158. package/dist/maintenance/rebuild-memory-projection.js +9 -8
  159. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  160. package/dist/memory-action-policy.d.ts +1 -1
  161. package/dist/memory-cache.d.ts +1 -1
  162. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  163. package/dist/memory-projection-store.d.ts +1 -1
  164. package/dist/memory-provenance.d.ts +1 -1
  165. package/dist/memory-worth-outcomes.d.ts +1 -1
  166. package/dist/models-json.d.ts +1 -1
  167. package/dist/namespaces/migrate.d.ts +2 -2
  168. package/dist/namespaces/migrate.js +12 -11
  169. package/dist/namespaces/principal.d.ts +1 -1
  170. package/dist/namespaces/search.d.ts +1 -1
  171. package/dist/namespaces/search.js +3 -3
  172. package/dist/namespaces/storage.d.ts +2 -2
  173. package/dist/namespaces/storage.js +8 -7
  174. package/dist/native-knowledge.d.ts +1 -1
  175. package/dist/operator-toolkit.d.ts +1 -1
  176. package/dist/operator-toolkit.js +17 -16
  177. package/dist/orchestration/maintenance.d.ts +2 -2
  178. package/dist/orchestration/maintenance.js +10 -9
  179. package/dist/{orchestrator-B1iVjEyc.d.ts → orchestrator-iVZWi8aW.d.ts} +21 -4
  180. package/dist/orchestrator.d.ts +4 -4
  181. package/dist/orchestrator.js +32 -31
  182. package/dist/patterns-cli.d.ts +1 -1
  183. package/dist/policy-runtime.d.ts +1 -1
  184. package/dist/provenance.d.ts +1 -1
  185. package/dist/provenance.js +3 -2
  186. package/dist/qmd-recall-cache.d.ts +1 -1
  187. package/dist/qmd.d.ts +1 -1
  188. package/dist/recall-disclosure-escalation.d.ts +1 -1
  189. package/dist/recall-explain-renderer.d.ts +1 -1
  190. package/dist/recall-explain-renderer.js +3 -3
  191. package/dist/recall-handles.d.ts +228 -0
  192. package/dist/recall-handles.js +42 -0
  193. package/dist/recall-handles.js.map +1 -0
  194. package/dist/recall-planner-llm.d.ts +1 -1
  195. package/dist/recall-state.d.ts +40 -2
  196. package/dist/recall-state.js +3 -1
  197. package/dist/recall-tag-filter.d.ts +1 -1
  198. package/dist/recall-xray-cli.d.ts +1 -1
  199. package/dist/recall-xray-cli.js +4 -4
  200. package/dist/recall-xray-renderer.d.ts +1 -1
  201. package/dist/recall-xray-renderer.js +3 -3
  202. package/dist/recall-xray.d.ts +1 -1
  203. package/dist/recall-xray.js +2 -2
  204. package/dist/resolve-auth-token.d.ts +1 -1
  205. package/dist/resume-bundles.js +5 -4
  206. package/dist/retrieval-agents.d.ts +1 -1
  207. package/dist/retrieval-tiers.d.ts +1 -1
  208. package/dist/routing/engine.d.ts +1 -1
  209. package/dist/routing/store.d.ts +1 -1
  210. package/dist/sanitize.d.ts +2 -0
  211. package/dist/sanitize.js +6 -2
  212. package/dist/search/embed-helper.d.ts +1 -1
  213. package/dist/search/factory.d.ts +1 -1
  214. package/dist/search/factory.js +2 -2
  215. package/dist/search/index.d.ts +1 -1
  216. package/dist/search/index.js +4 -4
  217. package/dist/search/lancedb-backend.d.ts +1 -1
  218. package/dist/search/meilisearch-backend.d.ts +1 -1
  219. package/dist/search/noop-backend.d.ts +1 -1
  220. package/dist/search/orama-backend.d.ts +1 -1
  221. package/dist/search/port.d.ts +1 -1
  222. package/dist/search/remote-backend.d.ts +1 -1
  223. package/dist/{semantic-consolidation-CY8uVRNJ.d.ts → semantic-consolidation-DSjMDZ1u.d.ts} +1 -1
  224. package/dist/semantic-consolidation.d.ts +2 -2
  225. package/dist/semantic-consolidation.js +9 -8
  226. package/dist/semantic-rule-promotion.js +8 -7
  227. package/dist/semantic-rule-verifier.d.ts +1 -1
  228. package/dist/semantic-rule-verifier.js +8 -7
  229. package/dist/session-observer-bands.d.ts +1 -1
  230. package/dist/session-observer-state.d.ts +1 -1
  231. package/dist/shared-context/manager.d.ts +3 -3
  232. package/dist/signal.d.ts +1 -1
  233. package/dist/storage.d.ts +1 -1
  234. package/dist/storage.js +7 -6
  235. package/dist/summarizer.d.ts +1 -1
  236. package/dist/summary-snapshot.d.ts +1 -1
  237. package/dist/temporal-supersession.d.ts +1 -1
  238. package/dist/temporal-validity.d.ts +1 -1
  239. package/dist/threading.d.ts +1 -1
  240. package/dist/tier-migration.d.ts +1 -1
  241. package/dist/tier-routing.d.ts +1 -1
  242. package/dist/topics.d.ts +1 -1
  243. package/dist/transcript.d.ts +1 -1
  244. package/dist/transfer/import-sqlite.js +2 -2
  245. package/dist/{types-Dr0Cw3L9.d.ts → types-D9dwzU_x.d.ts} +25 -1
  246. package/dist/types.d.ts +1 -1
  247. package/dist/types.js +1 -1
  248. package/dist/utility-runtime.d.ts +1 -1
  249. package/dist/verified-recall.js +8 -7
  250. package/package.json +2 -2
  251. package/src/access-boundary.ts +4 -1
  252. package/src/access-http.ts +78 -20
  253. package/src/access-mcp.test.ts +29 -0
  254. package/src/access-mcp.ts +99 -17
  255. package/src/access-operations-batch.ts +14 -0
  256. package/src/access-operations.ts +5 -0
  257. package/src/access-schema.ts +8 -0
  258. package/src/access-service-observe-idempotency.test.ts +308 -0
  259. package/src/access-service.ts +106 -5
  260. package/src/access-surface-catalog.test.ts +4 -2
  261. package/src/access-surface-catalog.ts +5 -0
  262. package/src/active-memory-bridge.test.ts +72 -0
  263. package/src/active-memory-bridge.ts +21 -2
  264. package/src/chat/chat-cli.test.ts +142 -0
  265. package/src/chat/chat-cli.ts +206 -0
  266. package/src/chat/chat-config.ts +57 -0
  267. package/src/chat/chat-engine.test.ts +272 -0
  268. package/src/chat/chat-engine.ts +518 -0
  269. package/src/chat/chat-executor.ts +133 -0
  270. package/src/chat/chat-factory.ts +161 -0
  271. package/src/chat/chat-http.test.ts +197 -0
  272. package/src/chat/chat-http.ts +222 -0
  273. package/src/chat/chat-llm.ts +229 -0
  274. package/src/chat/chat-mcp.test.ts +135 -0
  275. package/src/chat/chat-security.test.ts +491 -0
  276. package/src/chat/chat-session.ts +296 -0
  277. package/src/chat/chat-system-prompt.ts +76 -0
  278. package/src/chat/chat-tools.ts +255 -0
  279. package/src/chat/chat-types.ts +167 -0
  280. package/src/cli.ts +20 -0
  281. package/src/config.ts +9 -0
  282. package/src/orchestrator.ts +106 -5
  283. package/src/recall-handles-wiring.test.ts +477 -0
  284. package/src/recall-handles.test.ts +283 -0
  285. package/src/recall-handles.ts +414 -0
  286. package/src/recall-state.ts +99 -0
  287. package/src/sanitize.ts +6 -0
  288. package/src/types.ts +29 -0
  289. package/dist/chunk-3XHD3XGK.js.map +0 -1
  290. package/dist/chunk-5KQCOIPW.js.map +0 -1
  291. package/dist/chunk-A2R5NV6O.js.map +0 -1
  292. package/dist/chunk-BOGENF7P.js.map +0 -1
  293. package/dist/chunk-GKPIY7EQ.js.map +0 -1
  294. package/dist/chunk-I3BT2IDW.js.map +0 -1
  295. package/dist/chunk-NUWZFSKC.js.map +0 -1
  296. package/dist/chunk-SINGJCUR.js.map +0 -1
  297. package/dist/chunk-Z3B2YW56.js.map +0 -1
  298. package/dist/chunk-ZVDRL6SK.js.map +0 -1
  299. /package/dist/{chunk-OMSTU233.js.map → chunk-2KAYTPPT.js.map} +0 -0
  300. /package/dist/{chunk-VYZNWH4J.js.map → chunk-2SXMCQEQ.js.map} +0 -0
  301. /package/dist/{chunk-NXL5CVE7.js.map → chunk-2Y6LMOQQ.js.map} +0 -0
  302. /package/dist/{chunk-LQ4J7ELC.js.map → chunk-67HG7KFE.js.map} +0 -0
  303. /package/dist/{chunk-ZBG4K36I.js.map → chunk-6FUJ2VYY.js.map} +0 -0
  304. /package/dist/{chunk-GMRNKPWO.js.map → chunk-6QUPUTT7.js.map} +0 -0
  305. /package/dist/{chunk-Z56IHRVV.js.map → chunk-6UU43ZGD.js.map} +0 -0
  306. /package/dist/{chunk-APJQ6UEA.js.map → chunk-AGNBY3VG.js.map} +0 -0
  307. /package/dist/{chunk-OWOKOFXK.js.map → chunk-AJOIM6UR.js.map} +0 -0
  308. /package/dist/{chunk-JBPKEARU.js.map → chunk-AU7Q3LSC.js.map} +0 -0
  309. /package/dist/{chunk-K22IJO4R.js.map → chunk-CTOQEZSN.js.map} +0 -0
  310. /package/dist/{chunk-HSDJCT3V.js.map → chunk-DXGS5YOQ.js.map} +0 -0
  311. /package/dist/{chunk-HV57RHMD.js.map → chunk-EH5ED3C6.js.map} +0 -0
  312. /package/dist/{chunk-7NDYFAJS.js.map → chunk-GM3BMWKR.js.map} +0 -0
  313. /package/dist/{chunk-5QUS24F7.js.map → chunk-I56LKMF7.js.map} +0 -0
  314. /package/dist/{chunk-35YJ6KCV.js.map → chunk-JEIROMLX.js.map} +0 -0
  315. /package/dist/{chunk-SKQCFAYU.js.map → chunk-JVEMUX6L.js.map} +0 -0
  316. /package/dist/{chunk-CXMXAC5R.js.map → chunk-K44MIN6D.js.map} +0 -0
  317. /package/dist/{chunk-BQCFXAMN.js.map → chunk-MPHDVHIB.js.map} +0 -0
  318. /package/dist/{chunk-QXNFQKWU.js.map → chunk-MTHNJCOJ.js.map} +0 -0
  319. /package/dist/{chunk-3TCRU4JA.js.map → chunk-O62P2LFL.js.map} +0 -0
  320. /package/dist/{chunk-JSDZMOT7.js.map → chunk-OC56UWV5.js.map} +0 -0
  321. /package/dist/{chunk-7IBEWQLG.js.map → chunk-QOIONSOI.js.map} +0 -0
  322. /package/dist/{chunk-4P5UAL3C.js.map → chunk-RBHANGVX.js.map} +0 -0
  323. /package/dist/{chunk-SK2CR6MW.js.map → chunk-RVLB4E6N.js.map} +0 -0
  324. /package/dist/{chunk-CNVIWMQI.js.map → chunk-UY4JMEAK.js.map} +0 -0
  325. /package/dist/{chunk-S6FQLQGH.js.map → chunk-V7GDTQ6I.js.map} +0 -0
  326. /package/dist/{chunk-JNOYYWCA.js.map → chunk-VO4UICII.js.map} +0 -0
  327. /package/dist/{chunk-54TI5GLV.js.map → chunk-WI4QX7GH.js.map} +0 -0
  328. /package/dist/{chunk-XKU4YE6Z.js.map → chunk-XZTUMWKQ.js.map} +0 -0
@@ -0,0 +1,283 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ appendHandle,
6
+ DEFAULT_HANDLE_SNAPSHOT_DEPTH,
7
+ HANDLE_DEFAULT_WIDTH,
8
+ HANDLE_EXTENDED_WIDTH,
9
+ handleFor,
10
+ isHandleToken,
11
+ MEMORY_ID_PATTERN,
12
+ normalizeHandle,
13
+ parseHandles,
14
+ parseIdOrHandle,
15
+ renderHandle,
16
+ renderHandlesForInjection,
17
+ resolveHandle,
18
+ stripHandles,
19
+ type RecallSnapshotIds,
20
+ } from "./recall-handles.js";
21
+
22
+ // Deterministic handle for a fixed id, computed once for assertions.
23
+ const ID_A = "fact-1770469224307-eelr";
24
+ const HANDLE_A = handleFor(ID_A);
25
+
26
+ // ─── handleFor: determinism + width ───────────────────────────────────────
27
+
28
+ test("handleFor is deterministic for the same id", () => {
29
+ assert.equal(handleFor(ID_A), handleFor(ID_A));
30
+ assert.equal(handleFor(ID_A).length, HANDLE_DEFAULT_WIDTH);
31
+ });
32
+
33
+ test("handleFor yields a non-empty 4-hex string", () => {
34
+ const got = handleFor("fact-1");
35
+ assert.match(got, /^[0-9a-f]{4}$/);
36
+ assert.equal(got, handleFor("fact-1"));
37
+ });
38
+
39
+ test("handleFor wider width is a prefix-preserving extension", () => {
40
+ const h4 = handleFor(ID_A, 4);
41
+ const h6 = handleFor(ID_A, 6);
42
+ const h8 = handleFor(ID_A, 8);
43
+ assert.equal(h6.slice(0, 4), h4);
44
+ assert.equal(h8.slice(0, 6), h6);
45
+ assert.equal(h4.length, 4);
46
+ assert.equal(h6.length, 6);
47
+ });
48
+
49
+ test("handleFor clamps out-of-range widths", () => {
50
+ assert.equal(handleFor(ID_A, 2), handleFor(ID_A, 4));
51
+ assert.equal(handleFor(ID_A, 100), handleFor(ID_A, 8));
52
+ assert.equal(handleFor(ID_A, Number.NaN), handleFor(ID_A, 4));
53
+ });
54
+
55
+ test("handleFor is derived from id, not content — stable across edits", () => {
56
+ // Two ids that differ only by a suffix get different handles.
57
+ assert.notEqual(handleFor("fact-1"), handleFor("fact-2"));
58
+ });
59
+
60
+ // ─── renderHandle / appendHandle ──────────────────────────────────────────
61
+
62
+ test("renderHandle produces the bracketed token", () => {
63
+ assert.equal(renderHandle(ID_A), `[m:${HANDLE_A}]`);
64
+ });
65
+
66
+ test("appendHandle adds a single space before the handle and trims trailing ws", () => {
67
+ assert.equal(
68
+ appendHandle("API rate limit is 1000 rpm. ", ID_A),
69
+ `API rate limit is 1000 rpm. [m:${HANDLE_A}]`,
70
+ );
71
+ });
72
+
73
+ // ─── renderHandlesForInjection: collision extension ───────────────────────
74
+
75
+ test("renderHandlesForInjection widens EVERY member of a colliding group to 6 chars", () => {
76
+ // Engineer a collision: two synthetic ids whose sha256 prefix collides at 4
77
+ // chars. Search a small id space for a real collision so the test is honest.
78
+ let idA = "";
79
+ let idB = "";
80
+ const seen = new Map<string, string>();
81
+ for (let i = 0; i < 200_000 && !idA; i += 1) {
82
+ const id = `fact-${i}-probe`;
83
+ const h = handleFor(id, 4);
84
+ const prev = seen.get(h);
85
+ if (prev && prev !== id) {
86
+ idA = prev;
87
+ idB = id;
88
+ } else {
89
+ seen.set(h, id);
90
+ }
91
+ }
92
+ if (idA && idB) {
93
+ const entries = renderHandlesForInjection([idA, idB]);
94
+ assert.equal(entries.length, 2);
95
+ // BOTH colliding members widen: leaving the first at 4 chars would make its
96
+ // displayed handle ambiguous to resolve against the group (codex review).
97
+ assert.equal(entries[0]!.width, HANDLE_EXTENDED_WIDTH);
98
+ assert.equal(entries[1]!.width, HANDLE_EXTENDED_WIDTH);
99
+ // Both rendered tokens are unique in the set (6-char handles differ).
100
+ const tokens = entries.map((e) => e.handle);
101
+ assert.equal(new Set(tokens).size, tokens.length);
102
+ assert.notEqual(tokens[0], tokens[1]);
103
+ }
104
+ });
105
+
106
+ test("renderHandlesForInjection: no collision → all default width, unique", () => {
107
+ const ids = ["fact-1", "fact-2", "fact-3"];
108
+ const entries = renderHandlesForInjection(ids);
109
+ assert.equal(entries.length, 3);
110
+ for (const e of entries) assert.equal(e.width, HANDLE_DEFAULT_WIDTH);
111
+ const tokens = entries.map((e) => e.handle);
112
+ assert.equal(new Set(tokens).size, tokens.length);
113
+ });
114
+
115
+ test("renderHandlesForInjection is idempotent and skips empty ids", () => {
116
+ const ids = ["fact-1", "", "fact-2"];
117
+ const e1 = renderHandlesForInjection(ids);
118
+ const e2 = renderHandlesForInjection(ids);
119
+ assert.deepEqual(e1, e2);
120
+ assert.equal(e1.length, 2);
121
+ });
122
+
123
+ // ─── parseHandles ─────────────────────────────────────────────────────────
124
+
125
+ test("parseHandles extracts handles from prose, ignoring malformed", () => {
126
+ const text = "see [m:4f2a] (also [m:1b9e2f]) — not [m:XYZ!] or [m:ab] done";
127
+ assert.deepEqual(parseHandles(text), ["[m:4f2a]", "[m:1b9e2f]"]);
128
+ });
129
+
130
+ test("parseHandles handles mid-sentence and punctuation-adjacent tokens", () => {
131
+ assert.deepEqual(parseHandles("[m:4f2a], [m:1b9e]; [m:0c7d]."), [
132
+ "[m:4f2a]",
133
+ "[m:1b9e]",
134
+ "[m:0c7d]",
135
+ ]);
136
+ });
137
+
138
+ test("parseHandles preserves duplicates and order", () => {
139
+ assert.deepEqual(parseHandles("[m:4f2a] and again [m:4f2a]"), [
140
+ "[m:4f2a]",
141
+ "[m:4f2a]",
142
+ ]);
143
+ });
144
+
145
+ test("parseHandles returns [] for non-string / empty", () => {
146
+ assert.deepEqual(parseHandles(""), []);
147
+ });
148
+
149
+ // ─── normalizeHandle / isHandleToken ──────────────────────────────────────
150
+
151
+ test("normalizeHandle accepts bracketed, prefixed, and bare hex forms", () => {
152
+ assert.equal(normalizeHandle("[m:4f2a]"), "4f2a");
153
+ assert.equal(normalizeHandle("m:4f2a"), "4f2a");
154
+ assert.equal(normalizeHandle("4f2a"), "4f2a");
155
+ assert.equal(normalizeHandle("[M:4F2A]"), "4f2a"); // case-insensitive
156
+ });
157
+
158
+ test("normalizeHandle rejects non-handles", () => {
159
+ assert.equal(normalizeHandle("fact-1-abc"), null);
160
+ assert.equal(normalizeHandle("[m:xyz!]"), null);
161
+ assert.equal(normalizeHandle("[m:ab]"), null); // too short
162
+ assert.equal(normalizeHandle(""), null);
163
+ });
164
+
165
+ test("isHandleToken mirrors normalizeHandle", () => {
166
+ assert.equal(isHandleToken("[m:4f2a]"), true);
167
+ assert.equal(isHandleToken("fact-1"), false);
168
+ });
169
+
170
+ test("parseIdOrHandle classifies refs", () => {
171
+ const h = parseIdOrHandle("[m:4f2a]");
172
+ assert.equal(h.isHandle, true);
173
+ assert.equal(h.value, "4f2a");
174
+ const id = parseIdOrHandle("fact-1");
175
+ assert.equal(id.isHandle, false);
176
+ assert.equal(id.value, "fact-1");
177
+ });
178
+
179
+ // ─── stripHandles ─────────────────────────────────────────────────────────
180
+
181
+ test("stripHandles removes tokens and tidies spacing", () => {
182
+ assert.equal(
183
+ stripHandles("API limit 1000 rpm. [m:4f2a] Also [m:1b9e]."),
184
+ "API limit 1000 rpm. Also.",
185
+ );
186
+ assert.equal(stripHandles("no handles here"), "no handles here");
187
+ assert.equal(stripHandles(""), "");
188
+ });
189
+
190
+ // ─── resolveHandle ────────────────────────────────────────────────────────
191
+
192
+ function snap(...ids: string[]): RecallSnapshotIds {
193
+ return { memoryIds: ids };
194
+ }
195
+
196
+ test("resolveHandle: hit returns the exact memoryId", () => {
197
+ const res = resolveHandle(HANDLE_A, [snap(ID_A, "fact-other")]);
198
+ assert.equal(res.ok, true);
199
+ if (res.ok) assert.equal(res.memoryId, ID_A);
200
+ });
201
+
202
+ test("resolveHandle: miss is tagged not_found, never guessed", () => {
203
+ const res = resolveHandle("dead", [snap("fact-1")]);
204
+ assert.equal(res.ok, false);
205
+ if (!res.ok) assert.equal(res.reason, "not_found");
206
+ });
207
+
208
+ test("resolveHandle: ambiguous collision across snapshots lists candidates", () => {
209
+ // Two distinct ids that share the same 4-char handle, in different snapshots.
210
+ let idA = "";
211
+ let idB = "";
212
+ const seen = new Map<string, string>();
213
+ for (let i = 0; i < 200_000 && !idA; i += 1) {
214
+ const id = `fact-${i}-amb`;
215
+ const h = handleFor(id, 4);
216
+ const prev = seen.get(h);
217
+ if (prev && prev !== id) {
218
+ idA = prev;
219
+ idB = id;
220
+ } else {
221
+ seen.set(h, id);
222
+ }
223
+ }
224
+ if (idA && idB) {
225
+ const res = resolveHandle(handleFor(idA, 4), [snap(idA), snap(idB)]);
226
+ assert.equal(res.ok, false);
227
+ if (!res.ok && res.reason === "ambiguous") {
228
+ assert.ok(res.candidates.includes(idA));
229
+ assert.ok(res.candidates.includes(idB));
230
+ }
231
+ }
232
+ });
233
+
234
+ test("resolveHandle: snapshot depth is respected (older-than-N → miss)", () => {
235
+ // ID_A only in the 6th snapshot; depth 5 must miss.
236
+ const snapshots = [
237
+ snap("other-1"),
238
+ snap("other-2"),
239
+ snap("other-3"),
240
+ snap("other-4"),
241
+ snap("other-5"),
242
+ snap(ID_A),
243
+ ];
244
+ const within = resolveHandle(HANDLE_A, snapshots, DEFAULT_HANDLE_SNAPSHOT_DEPTH);
245
+ assert.equal(within.ok, false); // depth 5 skips the 6th
246
+ if (!within.ok) assert.equal(within.reason, "not_found");
247
+ // Widening depth finds it.
248
+ const found = resolveHandle(HANDLE_A, snapshots, 6);
249
+ assert.equal(found.ok, true);
250
+ });
251
+
252
+ test("resolveHandle: a non-handle input is not_found, not a throw", () => {
253
+ const res = resolveHandle("fact-1", [snap("fact-1")]);
254
+ assert.equal(res.ok, false);
255
+ if (!res.ok) assert.equal(res.reason, "not_found");
256
+ });
257
+
258
+ test("resolveHandle: newest-first ordering prefers the most recent recall", () => {
259
+ // Same id appears in two snapshots; resolution still yields a single match.
260
+ const res = resolveHandle(HANDLE_A, [snap(ID_A), snap(ID_A)]);
261
+ assert.equal(res.ok, true);
262
+ });
263
+
264
+
265
+ // ─── MEMORY_ID_PATTERN: handle eligibility gate (codex review) ──────────────
266
+
267
+ test("MEMORY_ID_PATTERN accepts underscore categories like reasoning_trace", () => {
268
+ // MemoryCategory includes reasoning_trace; StorageManager writes ids as
269
+ // `${category}-${Date.now()}-...`, so these must be handle-eligible.
270
+ assert.equal(MEMORY_ID_PATTERN.test("reasoning_trace-1770469224307-eelr"), true);
271
+ assert.equal(MEMORY_ID_PATTERN.test("fact-1770469224307-eelr"), true);
272
+ assert.equal(MEMORY_ID_PATTERN.test("artifact-1770469224308-ab12"), true);
273
+ });
274
+
275
+ test("MEMORY_ID_PATTERN rejects entity basenames and non-memory rows", () => {
276
+ // Entity reconstructions (entities/Widget.md) and bare names must NOT receive
277
+ // a handle — citing one would resolve to an unloadable basename.
278
+ assert.equal(MEMORY_ID_PATTERN.test("Widget"), false);
279
+ assert.equal(MEMORY_ID_PATTERN.test("entities/Widget.md"), false);
280
+ assert.equal(MEMORY_ID_PATTERN.test("Widget.md"), false);
281
+ // Category must start lowercase; capitalized names fail.
282
+ assert.equal(MEMORY_ID_PATTERN.test("Reasoning_trace-1-abc"), false);
283
+ });
@@ -0,0 +1,414 @@
1
+ /**
2
+ * recall-handles.ts — injection-time memory handles (issue #1582).
3
+ *
4
+ * Every injected memory gets a stable short handle — `[m:4f2a]` — so a user
5
+ * (or the agent) can react to ONE specific memory in-band ("[m:4f2a] is
6
+ * stale", thumbs-down, "correct that") without a context switch to a CLI or
7
+ * console. The handle is derived from the memory **id** (never the content),
8
+ * so it is stable across edits/versioning.
9
+ *
10
+ * This module is PURE — no I/O, no side effects, no state. It owns:
11
+ * - {@link handleFor} — deterministic 4-hex handle from a memory id.
12
+ * - {@link renderHandle} / {@link renderHandlesForInjection} — render the
13
+ * `[m:xxxx]` token for one memory (or a whole injection set, extending to
14
+ * 6 chars on intra-injection collision).
15
+ * - {@link parseHandles} — extract handle tokens from prose.
16
+ * - {@link normalizeHandle} / {@link isHandleToken} — classify a string.
17
+ * - {@link resolveHandle} — map a handle back to a memory id against the
18
+ * session's recent recall snapshots (hit / not-found / ambiguous).
19
+ *
20
+ * Design rules honored (issue #1582 design + pitfalls):
21
+ * - Handles are derived from the id only — never hashed with content, never
22
+ * persisted into memory files or rawContent (rule 23). Rendering happens
23
+ * at injection time only.
24
+ * - Resolution is per-session and snapshot-scoped — there is NEVER a global
25
+ * handle→id map (collision space too small globally, leaks across
26
+ * principals — rule 42). Misses are tagged, never guessed (rule 34/51).
27
+ * - The formatter is allocation-light: a string append at render time, no
28
+ * per-memory object churn, because it runs on every recall.
29
+ */
30
+
31
+ import { createHash } from "node:crypto";
32
+
33
+ /**
34
+ * Default handle width in hex characters (4 → 65 536-handle space).
35
+ * Per-injection sets are ~10–40 memories, so in-context collisions are
36
+ * vanishingly rare; {@link renderHandlesForInjection} widens the colliding
37
+ * member to {@link HANDLE_EXTENDED_WIDTH} when two ids in ONE injection would
38
+ * collide.
39
+ */
40
+ export const HANDLE_DEFAULT_WIDTH = 4;
41
+
42
+ /**
43
+ * Width used to disambiguate two ids that collide at the default width within
44
+ * a single injection. 6 hex chars → ~16 M-handle space, which is comfortably
45
+ * beyond per-injection set sizes.
46
+ */
47
+ export const HANDLE_EXTENDED_WIDTH = 6;
48
+
49
+ /** Minimum/maximum accepted hex widths for a rendered/parsed handle. */
50
+ export const HANDLE_MIN_WIDTH = 4;
51
+ export const HANDLE_MAX_WIDTH = 8;
52
+
53
+ /**
54
+ * Regex matching a rendered handle token anywhere in prose: `[m:` followed by
55
+ * 4–8 lowercase hex chars and a closing `]`. Malformed tokens (`[m:xyz!]`,
56
+ * `[m:abc]` with uppercase, missing bracket) are intentionally NOT matched.
57
+ * Used by {@link parseHandles} and the sanitizer.
58
+ */
59
+ export const HANDLE_REGEX = /\[m:[0-9a-f]{4,8}\]/g;
60
+
61
+ /**
62
+ * Memory ids are `<category>-<timestamp>-<suffix>` (e.g. `fact-1770469224307-eelr`,
63
+ * `artifact-...`, `reasoning_trace-...`, plus parent-`-chunk-N` variants). The
64
+ * category segment allows underscores because `MemoryCategory` includes
65
+ * `reasoning_trace` (codex review); bare names (`Widget`) still fail. Entity
66
+ * reconstructions and other non-memory `.md` rows use bare names (`Widget`)
67
+ * that must NOT receive a handle — citing one would resolve to a basename no
68
+ * storage can load. This pattern gates handle rendering/recording to plausible
69
+ * memory ids only (issue #1582, codex review).
70
+ */
71
+ export const MEMORY_ID_PATTERN = /^[a-z][a-z0-9_]*-\d+-[a-z0-9-]+$/;
72
+
73
+ /**
74
+ * Derive the deterministic handle hex for a memory id at a given width.
75
+ *
76
+ * `handleFor(id) === handleFor(id)` always, and `handleFor(id, w)` is a prefix
77
+ * of `handleFor(id, w+1)` (both slice the same sha256 hex digest), so widening
78
+ * for collision-disambiguation never changes the shorter prefix.
79
+ *
80
+ * @param memoryId Stable memory id (e.g. `fact-1770469224307-eelr`).
81
+ * @param width Hex width. Defaults to {@link HANDLE_DEFAULT_WIDTH}.
82
+ * Clamped to [4, 8]; the digest has 64 hex chars available.
83
+ */
84
+ export function handleFor(memoryId: string, width: number = HANDLE_DEFAULT_WIDTH): string {
85
+ const w = clampWidth(width);
86
+ return createHash("sha256").update(memoryId).digest("hex").slice(0, w);
87
+ }
88
+
89
+ function clampWidth(width: number): number {
90
+ if (!Number.isFinite(width)) return HANDLE_DEFAULT_WIDTH;
91
+ return Math.min(HANDLE_MAX_WIDTH, Math.max(HANDLE_MIN_WIDTH, Math.floor(width)));
92
+ }
93
+
94
+ /**
95
+ * Render the `[m:xxxx]` token for one memory id. Width override is for the
96
+ * collision-extension path in {@link renderHandlesForInjection}; callers that
97
+ * render a single handle in isolation should omit it.
98
+ */
99
+ export function renderHandle(memoryId: string, widthOverride?: number): string {
100
+ return `[m:${handleFor(memoryId, widthOverride ?? HANDLE_DEFAULT_WIDTH)}]`;
101
+ }
102
+
103
+ /**
104
+ * Append a handle to a memory line, allocation-light. When `widthOverride` is
105
+ * omitted the default-width handle is used; pass the widened width from
106
+ * {@link renderHandlesForInjection} for a colliding member.
107
+ *
108
+ * appendHandle("API rate limit is 1000 rpm.", "fact-1-abc")
109
+ * // → "API rate limit is 1000 rpm. [m:4f2a]"
110
+ */
111
+ export function appendHandle(line: string, memoryId: string, widthOverride?: number): string {
112
+ const handle = renderHandle(memoryId, widthOverride);
113
+ // Single trailing space before the handle; collapse a double space if the
114
+ // line already ended in whitespace so output stays clean.
115
+ const trimmed = line.replace(/\s+$/, "");
116
+ return `${trimmed} ${handle}`;
117
+ }
118
+
119
+ /**
120
+ * Result of {@link renderHandlesForInjection}: for each memory id, the width
121
+ * used (default 4, or 6 when widened to break an intra-injection collision)
122
+ * and the rendered token. Ordered for stable iteration.
123
+ */
124
+ export interface InjectionHandleEntry {
125
+ memoryId: string;
126
+ width: number;
127
+ handle: string;
128
+ }
129
+
130
+ /**
131
+ * Build handles for a whole injection set, widening to 6 chars when two ids
132
+ * collide at the default width within THIS set (so each rendered token is
133
+ * unique in context). Idempotent for the same input.
134
+ *
135
+ * The collision case is vanishingly rare (~10–40 memories per injection), but
136
+ * widening guarantees a user can always point at exactly one memory in-band.
137
+ */
138
+ export function renderHandlesForInjection(
139
+ memoryIds: readonly string[],
140
+ ): InjectionHandleEntry[] {
141
+ // Group ids by their default-width handle so every member of a 4-char
142
+ // collision group widens to 6 together — not just the later one. Widening
143
+ // only the second id would leave the first rendering a 4-char token whose
144
+ // resolution is ambiguous against the group (codex review).
145
+ const idsByDefaultHandle = new Map<string, string[]>();
146
+ for (const memoryId of memoryIds) {
147
+ if (!memoryId) continue;
148
+ const defaultHandle = handleFor(memoryId, HANDLE_DEFAULT_WIDTH);
149
+ const group = idsByDefaultHandle.get(defaultHandle);
150
+ if (group) group.push(memoryId);
151
+ else idsByDefaultHandle.set(defaultHandle, [memoryId]);
152
+ }
153
+ const entries: InjectionHandleEntry[] = [];
154
+ for (const memoryId of memoryIds) {
155
+ if (!memoryId) continue;
156
+ const defaultHandle = handleFor(memoryId, HANDLE_DEFAULT_WIDTH);
157
+ const group = idsByDefaultHandle.get(defaultHandle) ?? [memoryId];
158
+ const width = group.length > 1 ? HANDLE_EXTENDED_WIDTH : HANDLE_DEFAULT_WIDTH;
159
+ entries.push({ memoryId, width, handle: `[m:${handleFor(memoryId, width)}]` });
160
+ }
161
+ // Reconciliation pass: if two DIFFERENT ids still produce the same rendered
162
+ // token at their assigned width — the pathological case where they also
163
+ // collide at the extended 6-char width — widen ALL members of the collision
164
+ // group together, never just the later one. Leaving the first at 6 chars
165
+ // while widening the second to 7 makes the first token resolve ambiguously
166
+ // (both ids match at width 6). Iterate until no collision remains or every
167
+ // member reaches HANDLE_MAX_WIDTH (codex review).
168
+ let widened = true;
169
+ while (widened) {
170
+ widened = false;
171
+ const tokenToIndices = new Map<string, number[]>();
172
+ for (let i = 0; i < entries.length; i++) {
173
+ const token = entries[i]!.handle;
174
+ const arr = tokenToIndices.get(token);
175
+ if (arr) arr.push(i);
176
+ else tokenToIndices.set(token, [i]);
177
+ }
178
+ for (const indices of tokenToIndices.values()) {
179
+ if (indices.length < 2) continue;
180
+ const uniqueIds = new Set(indices.map((idx) => entries[idx]!.memoryId));
181
+ if (uniqueIds.size < 2) continue; // same id listed twice — same handle is fine
182
+ // Only continue widening if at least one entry has room to grow.
183
+ // When every member has reached HANDLE_MAX_WIDTH the collision is
184
+ // irreducible (two ids with identical sha256 8-char prefixes —
185
+ // astronomically unlikely for real data); stop to avoid an infinite loop.
186
+ let anyWidened = false;
187
+ for (const idx of indices) {
188
+ const entry = entries[idx]!;
189
+ if (entry.width >= HANDLE_MAX_WIDTH) continue;
190
+ entry.width += 1;
191
+ entry.handle = `[m:${handleFor(entry.memoryId, entry.width)}]`;
192
+ anyWidened = true;
193
+ }
194
+ if (anyWidened) widened = true;
195
+ }
196
+ }
197
+ return entries;
198
+ }
199
+
200
+ /**
201
+ * Build a `resultIndex → handle` map for a QMD result set. Returns an empty
202
+ * map when handles are disabled or no result carries a handle-eligible memory
203
+ * id. Extracted from the orchestrator's `formatQmdResults` so the rendering
204
+ * logic lives in the pure handles module, not the god file (issue #1582,
205
+ * rule 4).
206
+ *
207
+ * Only real memory ids (matching {@link MEMORY_ID_PATTERN}) are handle-eligible;
208
+ * entity reconstructions and other non-memory `.md` rows get no handle.
209
+ *
210
+ * @param results QMD search results (each has a `path` like `…/fact-x.md`).
211
+ * @param enabled Whether handle rendering is on. The caller gates on both the
212
+ * config flag and session-key availability — handles rendered
213
+ * without a session key can never be resolved, so they must
214
+ * not be shown (cursor review of #1582).
215
+ */
216
+ export function buildHandleIndexForResults(
217
+ results: readonly { path: string }[],
218
+ enabled: boolean,
219
+ ): Map<number, string> {
220
+ if (!enabled) return new Map();
221
+ const ids: string[] = [];
222
+ const idIndexByResult: Array<number | null> = results.map((r) => {
223
+ const match = r.path.match(/([^/]+)\.md$/);
224
+ if (!match) return null;
225
+ const candidate = match[1] as string;
226
+ if (!MEMORY_ID_PATTERN.test(candidate)) return null;
227
+ ids.push(candidate);
228
+ return ids.length - 1;
229
+ });
230
+ const entries = renderHandlesForInjection(ids);
231
+ const handleByIndex = new Map<number, string>();
232
+ results.forEach((_r, i) => {
233
+ const idIndex = idIndexByResult[i];
234
+ if (idIndex === null) return;
235
+ const entry = entries[idIndex];
236
+ if (entry) handleByIndex.set(i, entry.handle);
237
+ });
238
+ return handleByIndex;
239
+ }
240
+
241
+ /**
242
+ * Extract every handle token from prose, in order of appearance. Returns the
243
+ * full rendered tokens (e.g. `[m:4f2a]`) so callers can locate them in text.
244
+ * Malformed tokens are ignored. Duplicates are preserved (a user may cite the
245
+ * same memory twice).
246
+ *
247
+ * parseHandles("see [m:4f2a] (also [m:1b9e]) — not [m:xyz!]")
248
+ * // → ["[m:4f2a]", "[m:1b9e]"]
249
+ */
250
+ export function parseHandles(text: string): string[] {
251
+ if (typeof text !== "string" || text.length === 0) return [];
252
+ const out: string[] = [];
253
+ // Reset lastIndex because HANDLE_REGEX is /g and may be reused.
254
+ HANDLE_REGEX.lastIndex = 0;
255
+ let match: RegExpExecArray | null;
256
+ while ((match = HANDLE_REGEX.exec(text)) !== null) {
257
+ out.push(match[0]);
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /**
263
+ * Strip every handle token from text, collapsing the spacing it introduced.
264
+ * Used by the sanitizer so handles observed back into the pipeline never
265
+ * become memory content (issue #1582 hygiene §2).
266
+ *
267
+ * stripHandles("API limit 1000 rpm. [m:4f2a]") → "API limit 1000 rpm."
268
+ */
269
+ export function stripHandles(text: string): string {
270
+ if (typeof text !== "string" || text.length === 0) return text;
271
+ // Remove the token and the single preceding space that {@link appendHandle}
272
+ // added, then tidy any double space left behind.
273
+ // \\s? (not \\s*): the renderer appends exactly one preceding space, and a
274
+ // bounded quantifier avoids the polynomial-ReDoS flag on uncontrolled input.
275
+ // A stray run of spaces is collapsed by the following line.
276
+ return text
277
+ .replace(/\s?\[m:[0-9a-f]{4,8}\]/g, "")
278
+ .replace(/[ \t]{2,}/g, " ")
279
+ .replace(/[ \t]+$/g, "");
280
+ }
281
+
282
+ /**
283
+ * Normalize a handle token (or bare hex) to its lowercase hex core, or `null`
284
+ * when the input is not a handle. Accepts both the rendered `[m:4f2a]` form
285
+ * and the bare `m:4f2a` / `4f2a` form a user might type.
286
+ *
287
+ * normalizeHandle("[m:4f2a]") → "4f2a"
288
+ * normalizeHandle("m:4f2a") → "4f2a"
289
+ * normalizeHandle("4f2a") → "4f2a"
290
+ * normalizeHandle("fact-1") → null
291
+ */
292
+ export function normalizeHandle(token: string): string | null {
293
+ if (typeof token !== "string" || token.length === 0) return null;
294
+ const trimmed = token.trim().toLowerCase();
295
+ // [m:4f2a]
296
+ const bracketed = /^\[m:([0-9a-f]{4,8})\]$/.exec(trimmed);
297
+ if (bracketed) return bracketed[1] ?? null;
298
+ // m:4f2a
299
+ const prefixed = /^m:([0-9a-f]{4,8})$/.exec(trimmed);
300
+ if (prefixed) return prefixed[1] ?? null;
301
+ // bare 4f2a — only treat as a handle when it is pure hex in the width range,
302
+ // so we don't mis-classify a real memory id that happens to be 4 hex chars.
303
+ const bare = /^([0-9a-f]{4,8})$/.exec(trimmed);
304
+ if (bare) return bare[1] ?? null;
305
+ return null;
306
+ }
307
+
308
+ /**
309
+ * Whether a string looks like a handle token (rendered or bare). Cheaper
310
+ * boolean form of {@link normalizeHandle} for callers that only need to branch.
311
+ */
312
+ export function isHandleToken(token: string): boolean {
313
+ return normalizeHandle(token) !== null;
314
+ }
315
+
316
+ /**
317
+ * A flattened id-or-handle reference: either a raw memory id or the hex core
318
+ * of a handle. {@link resolveMemoryIdOrHandle} produces this so the snapshot
319
+ * lookup only runs for actual handles.
320
+ */
321
+ export interface ParsedIdOrHandle {
322
+ /** The original reference as supplied by the caller. */
323
+ raw: string;
324
+ /** `true` when `raw` is a handle that still needs snapshot resolution. */
325
+ isHandle: boolean;
326
+ /** For a handle, its hex core; for a raw id, the id itself. */
327
+ value: string;
328
+ }
329
+
330
+ /**
331
+ * Classify a single caller reference as id-or-handle.
332
+ */
333
+ export function parseIdOrHandle(ref: string): ParsedIdOrHandle {
334
+ const hex = normalizeHandle(ref);
335
+ if (hex !== null) {
336
+ return { raw: ref, isHandle: true, value: hex };
337
+ }
338
+ return { raw: ref, isHandle: false, value: ref };
339
+ }
340
+
341
+ /**
342
+ * A flattened view of the memory-id sets a session has recently recalled.
343
+ * Each entry is one past recall's admitted memory ids (newest first).
344
+ * Resolution never sees raw {@link LastRecallSnapshot}s — callers flatten to
345
+ * this so the pure resolver has no dependency on the snapshot type.
346
+ */
347
+ export interface RecallSnapshotIds {
348
+ memoryIds: readonly string[];
349
+ }
350
+
351
+ /**
352
+ * Result of resolving a handle against recent recall snapshots.
353
+ * - `{ ok: true, memoryId }` — exactly one match within the lookback window.
354
+ * - `{ ok: false, reason: "not_found" }` — no memory id produced this handle
355
+ * within the window (misses are acceptable and tagged, never guessed).
356
+ * - `{ ok: false, reason: "ambiguous", candidates }` — two DIFFERENT memory
357
+ * ids in the window collide on the same handle; the caller must disambiguate
358
+ * (rule 34/51: never guess).
359
+ */
360
+ export type ResolveHandleResult =
361
+ | { ok: true; memoryId: string }
362
+ | { ok: false; reason: "not_found" }
363
+ | { ok: false; reason: "ambiguous"; candidates: string[] };
364
+
365
+ /**
366
+ * Resolve a handle hex (or a rendered token / bare form) to its memory id
367
+ * against the session's recent recall snapshots.
368
+ *
369
+ * @param handle A handle token, bare hex, or `m:hex` form.
370
+ * @param snapshots Recent recall snapshots for the session, newest first.
371
+ * Only the first `depth` entries are searched.
372
+ * @param depth How many snapshots (newest-first) to search. Defaults to
373
+ * {@link DEFAULT_HANDLE_SNAPSHOT_DEPTH}.
374
+ */
375
+ export function resolveHandle(
376
+ handle: string,
377
+ snapshots: readonly RecallSnapshotIds[],
378
+ depth: number = DEFAULT_HANDLE_SNAPSHOT_DEPTH,
379
+ ): ResolveHandleResult {
380
+ const hex = normalizeHandle(handle);
381
+ if (hex === null) {
382
+ return { ok: false, reason: "not_found" };
383
+ }
384
+ // A handle resolves when a memory id's handle is a PREFIX of `hex` (handles
385
+ // widened to 6 chars for collision still match their own 4-char core, and a
386
+ // user citing the short form must still resolve the widened memory).
387
+ const width = hex.length;
388
+ const limit = Math.max(0, Math.min(depth, snapshots.length));
389
+ const matches = new Set<string>();
390
+ for (let i = 0; i < limit; i += 1) {
391
+ const snap = snapshots[i];
392
+ if (!snap) continue;
393
+ for (const memoryId of snap.memoryIds) {
394
+ if (!memoryId) continue;
395
+ if (handleFor(memoryId, width) === hex) {
396
+ matches.add(memoryId);
397
+ }
398
+ }
399
+ }
400
+ if (matches.size === 0) {
401
+ return { ok: false, reason: "not_found" };
402
+ }
403
+ if (matches.size === 1) {
404
+ return { ok: true, memoryId: matches.values().next().value as string };
405
+ }
406
+ return { ok: false, reason: "ambiguous", candidates: [...matches] };
407
+ }
408
+
409
+ /**
410
+ * Default snapshot lookback depth for handle resolution (issue #1582 config
411
+ * `recall.handleSnapshotDepth`). Older-than-N snapshots are not searched and a
412
+ * miss is tagged rather than widening the window.
413
+ */
414
+ export const DEFAULT_HANDLE_SNAPSHOT_DEPTH = 5;