nlm-memory 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (472) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.github/workflows/ci.yml +30 -0
  3. package/LICENSE +151 -0
  4. package/README.md +119 -0
  5. package/dist/cli/classify-parity.d.ts +48 -0
  6. package/dist/cli/classify-parity.js +182 -0
  7. package/dist/cli/classify-parity.js.map +1 -0
  8. package/dist/cli/launchctl-helpers.d.ts +26 -0
  9. package/dist/cli/launchctl-helpers.js +42 -0
  10. package/dist/cli/launchctl-helpers.js.map +1 -0
  11. package/dist/cli/nlm.d.ts +25 -0
  12. package/dist/cli/nlm.js +832 -0
  13. package/dist/cli/nlm.js.map +1 -0
  14. package/dist/core/actions/actions-log.d.ts +40 -0
  15. package/dist/core/actions/actions-log.js +72 -0
  16. package/dist/core/actions/actions-log.js.map +1 -0
  17. package/dist/core/actions/overlay.d.ts +30 -0
  18. package/dist/core/actions/overlay.js +101 -0
  19. package/dist/core/actions/overlay.js.map +1 -0
  20. package/dist/core/adapters/aider.d.ts +33 -0
  21. package/dist/core/adapters/aider.js +167 -0
  22. package/dist/core/adapters/aider.js.map +1 -0
  23. package/dist/core/adapters/claude-code.d.ts +32 -0
  24. package/dist/core/adapters/claude-code.js +270 -0
  25. package/dist/core/adapters/claude-code.js.map +1 -0
  26. package/dist/core/adapters/common.d.ts +20 -0
  27. package/dist/core/adapters/common.js +60 -0
  28. package/dist/core/adapters/common.js.map +1 -0
  29. package/dist/core/adapters/from-source.d.ts +11 -0
  30. package/dist/core/adapters/from-source.js +55 -0
  31. package/dist/core/adapters/from-source.js.map +1 -0
  32. package/dist/core/adapters/hermes-agent.d.ts +34 -0
  33. package/dist/core/adapters/hermes-agent.js +192 -0
  34. package/dist/core/adapters/hermes-agent.js.map +1 -0
  35. package/dist/core/adapters/hermes.d.ts +31 -0
  36. package/dist/core/adapters/hermes.js +247 -0
  37. package/dist/core/adapters/hermes.js.map +1 -0
  38. package/dist/core/adapters/jsonl-generic.d.ts +56 -0
  39. package/dist/core/adapters/jsonl-generic.js +185 -0
  40. package/dist/core/adapters/jsonl-generic.js.map +1 -0
  41. package/dist/core/adapters/opencode.d.ts +36 -0
  42. package/dist/core/adapters/opencode.js +213 -0
  43. package/dist/core/adapters/opencode.js.map +1 -0
  44. package/dist/core/adapters/pi.d.ts +32 -0
  45. package/dist/core/adapters/pi.js +233 -0
  46. package/dist/core/adapters/pi.js.map +1 -0
  47. package/dist/core/classifier/prompt.d.ts +60 -0
  48. package/dist/core/classifier/prompt.js +178 -0
  49. package/dist/core/classifier/prompt.js.map +1 -0
  50. package/dist/core/dataset/build-dataset.d.ts +87 -0
  51. package/dist/core/dataset/build-dataset.js +335 -0
  52. package/dist/core/dataset/build-dataset.js.map +1 -0
  53. package/dist/core/embedding/chunk-body.d.ts +30 -0
  54. package/dist/core/embedding/chunk-body.js +60 -0
  55. package/dist/core/embedding/chunk-body.js.map +1 -0
  56. package/dist/core/embedding/embed-backfill.d.ts +36 -0
  57. package/dist/core/embedding/embed-backfill.js +168 -0
  58. package/dist/core/embedding/embed-backfill.js.map +1 -0
  59. package/dist/core/embedding/embed-normalize.d.ts +28 -0
  60. package/dist/core/embedding/embed-normalize.js +98 -0
  61. package/dist/core/embedding/embed-normalize.js.map +1 -0
  62. package/dist/core/facts/backfill-facts.d.ts +58 -0
  63. package/dist/core/facts/backfill-facts.js +169 -0
  64. package/dist/core/facts/backfill-facts.js.map +1 -0
  65. package/dist/core/facts/extract-facts.d.ts +20 -0
  66. package/dist/core/facts/extract-facts.js +37 -0
  67. package/dist/core/facts/extract-facts.js.map +1 -0
  68. package/dist/core/hook/citation-detect.d.ts +32 -0
  69. package/dist/core/hook/citation-detect.js +105 -0
  70. package/dist/core/hook/citation-detect.js.map +1 -0
  71. package/dist/core/hook/cite-memo.d.ts +20 -0
  72. package/dist/core/hook/cite-memo.js +68 -0
  73. package/dist/core/hook/cite-memo.js.map +1 -0
  74. package/dist/core/hook/claude-settings.d.ts +34 -0
  75. package/dist/core/hook/claude-settings.js +117 -0
  76. package/dist/core/hook/claude-settings.js.map +1 -0
  77. package/dist/core/hook/gate.d.ts +11 -0
  78. package/dist/core/hook/gate.js +19 -0
  79. package/dist/core/hook/gate.js.map +1 -0
  80. package/dist/core/hook/hook-log.d.ts +25 -0
  81. package/dist/core/hook/hook-log.js +28 -0
  82. package/dist/core/hook/hook-log.js.map +1 -0
  83. package/dist/core/hook/memo-sweep.d.ts +55 -0
  84. package/dist/core/hook/memo-sweep.js +134 -0
  85. package/dist/core/hook/memo-sweep.js.map +1 -0
  86. package/dist/core/hook/memo.d.ts +20 -0
  87. package/dist/core/hook/memo.js +66 -0
  88. package/dist/core/hook/memo.js.map +1 -0
  89. package/dist/core/hook/pointer-block.d.ts +14 -0
  90. package/dist/core/hook/pointer-block.js +19 -0
  91. package/dist/core/hook/pointer-block.js.map +1 -0
  92. package/dist/core/hook/select.d.ts +21 -0
  93. package/dist/core/hook/select.js +15 -0
  94. package/dist/core/hook/select.js.map +1 -0
  95. package/dist/core/hook/transcript.d.ts +31 -0
  96. package/dist/core/hook/transcript.js +103 -0
  97. package/dist/core/hook/transcript.js.map +1 -0
  98. package/dist/core/ingest/ingest-session.d.ts +40 -0
  99. package/dist/core/ingest/ingest-session.js +71 -0
  100. package/dist/core/ingest/ingest-session.js.map +1 -0
  101. package/dist/core/providers/provider-models.d.ts +24 -0
  102. package/dist/core/providers/provider-models.js +72 -0
  103. package/dist/core/providers/provider-models.js.map +1 -0
  104. package/dist/core/providers/provider-registry.d.ts +62 -0
  105. package/dist/core/providers/provider-registry.js +143 -0
  106. package/dist/core/providers/provider-registry.js.map +1 -0
  107. package/dist/core/recall/citation-log.d.ts +28 -0
  108. package/dist/core/recall/citation-log.js +90 -0
  109. package/dist/core/recall/citation-log.js.map +1 -0
  110. package/dist/core/recall/filter.d.ts +11 -0
  111. package/dist/core/recall/filter.js +20 -0
  112. package/dist/core/recall/filter.js.map +1 -0
  113. package/dist/core/recall/index.d.ts +6 -0
  114. package/dist/core/recall/index.js +5 -0
  115. package/dist/core/recall/index.js.map +1 -0
  116. package/dist/core/recall/match-fields.d.ts +10 -0
  117. package/dist/core/recall/match-fields.js +37 -0
  118. package/dist/core/recall/match-fields.js.map +1 -0
  119. package/dist/core/recall/query-log.d.ts +36 -0
  120. package/dist/core/recall/query-log.js +112 -0
  121. package/dist/core/recall/query-log.js.map +1 -0
  122. package/dist/core/recall/query-shape.d.ts +22 -0
  123. package/dist/core/recall/query-shape.js +64 -0
  124. package/dist/core/recall/query-shape.js.map +1 -0
  125. package/dist/core/recall/recall-service.d.ts +19 -0
  126. package/dist/core/recall/recall-service.js +252 -0
  127. package/dist/core/recall/recall-service.js.map +1 -0
  128. package/dist/core/recall/recent-log.d.ts +16 -0
  129. package/dist/core/recall/recent-log.js +46 -0
  130. package/dist/core/recall/recent-log.js.map +1 -0
  131. package/dist/core/recall/tokenize.d.ts +7 -0
  132. package/dist/core/recall/tokenize.js +18 -0
  133. package/dist/core/recall/tokenize.js.map +1 -0
  134. package/dist/core/recall/useful-scan.d.ts +52 -0
  135. package/dist/core/recall/useful-scan.js +300 -0
  136. package/dist/core/recall/useful-scan.js.map +1 -0
  137. package/dist/core/recall-facts/fact-query-log.d.ts +42 -0
  138. package/dist/core/recall-facts/fact-query-log.js +115 -0
  139. package/dist/core/recall-facts/fact-query-log.js.map +1 -0
  140. package/dist/core/recall-facts/fact-recall-service.d.ts +34 -0
  141. package/dist/core/recall-facts/fact-recall-service.js +246 -0
  142. package/dist/core/recall-facts/fact-recall-service.js.map +1 -0
  143. package/dist/core/scheduler/scan-once.d.ts +32 -0
  144. package/dist/core/scheduler/scan-once.js +100 -0
  145. package/dist/core/scheduler/scan-once.js.map +1 -0
  146. package/dist/core/scheduler/scheduler.d.ts +59 -0
  147. package/dist/core/scheduler/scheduler.js +158 -0
  148. package/dist/core/scheduler/scheduler.js.map +1 -0
  149. package/dist/core/sources/source-registry.d.ts +68 -0
  150. package/dist/core/sources/source-registry.js +208 -0
  151. package/dist/core/sources/source-registry.js.map +1 -0
  152. package/dist/core/storage/db-restore.d.ts +53 -0
  153. package/dist/core/storage/db-restore.js +113 -0
  154. package/dist/core/storage/db-restore.js.map +1 -0
  155. package/dist/core/storage/live-status.d.ts +15 -0
  156. package/dist/core/storage/live-status.js +43 -0
  157. package/dist/core/storage/live-status.js.map +1 -0
  158. package/dist/core/storage/migrate.d.ts +14 -0
  159. package/dist/core/storage/migrate.js +52 -0
  160. package/dist/core/storage/migrate.js.map +1 -0
  161. package/dist/core/storage/sqlite-fact-store.d.ts +50 -0
  162. package/dist/core/storage/sqlite-fact-store.js +256 -0
  163. package/dist/core/storage/sqlite-fact-store.js.map +1 -0
  164. package/dist/core/storage/sqlite-session-store.d.ts +152 -0
  165. package/dist/core/storage/sqlite-session-store.js +587 -0
  166. package/dist/core/storage/sqlite-session-store.js.map +1 -0
  167. package/dist/hook/pre-compact-hook.d.ts +26 -0
  168. package/dist/hook/pre-compact-hook.js +94 -0
  169. package/dist/hook/pre-compact-hook.js.map +1 -0
  170. package/dist/hook/prompt-recall-hook.d.ts +23 -0
  171. package/dist/hook/prompt-recall-hook.js +141 -0
  172. package/dist/hook/prompt-recall-hook.js.map +1 -0
  173. package/dist/hook/session-end-hook.d.ts +18 -0
  174. package/dist/hook/session-end-hook.js +67 -0
  175. package/dist/hook/session-end-hook.js.map +1 -0
  176. package/dist/hook/session-start-hook.d.ts +25 -0
  177. package/dist/hook/session-start-hook.js +129 -0
  178. package/dist/hook/session-start-hook.js.map +1 -0
  179. package/dist/hook/stop-hook.d.ts +38 -0
  180. package/dist/hook/stop-hook.js +171 -0
  181. package/dist/hook/stop-hook.js.map +1 -0
  182. package/dist/hook/subagent-start-hook.d.ts +30 -0
  183. package/dist/hook/subagent-start-hook.js +108 -0
  184. package/dist/hook/subagent-start-hook.js.map +1 -0
  185. package/dist/http/app.d.ts +65 -0
  186. package/dist/http/app.js +1009 -0
  187. package/dist/http/app.js.map +1 -0
  188. package/dist/install/claude-code.d.ts +57 -0
  189. package/dist/install/claude-code.js +76 -0
  190. package/dist/install/claude-code.js.map +1 -0
  191. package/dist/install/codex.d.ts +82 -0
  192. package/dist/install/codex.js +277 -0
  193. package/dist/install/codex.js.map +1 -0
  194. package/dist/install/hermes-agent.d.ts +35 -0
  195. package/dist/install/hermes-agent.js +48 -0
  196. package/dist/install/hermes-agent.js.map +1 -0
  197. package/dist/install/hermes.d.ts +29 -0
  198. package/dist/install/hermes.js +52 -0
  199. package/dist/install/hermes.js.map +1 -0
  200. package/dist/install/ollama.d.ts +54 -0
  201. package/dist/install/ollama.js +178 -0
  202. package/dist/install/ollama.js.map +1 -0
  203. package/dist/install/setup.d.ts +37 -0
  204. package/dist/install/setup.js +339 -0
  205. package/dist/install/setup.js.map +1 -0
  206. package/dist/llm/classifier-box.d.ts +29 -0
  207. package/dist/llm/classifier-box.js +43 -0
  208. package/dist/llm/classifier-box.js.map +1 -0
  209. package/dist/llm/deepseek-client.d.ts +40 -0
  210. package/dist/llm/deepseek-client.js +114 -0
  211. package/dist/llm/deepseek-client.js.map +1 -0
  212. package/dist/llm/env-autoload.d.ts +8 -0
  213. package/dist/llm/env-autoload.js +54 -0
  214. package/dist/llm/env-autoload.js.map +1 -0
  215. package/dist/llm/ollama-client.d.ts +47 -0
  216. package/dist/llm/ollama-client.js +156 -0
  217. package/dist/llm/ollama-client.js.map +1 -0
  218. package/dist/mcp/server.d.ts +64 -0
  219. package/dist/mcp/server.js +430 -0
  220. package/dist/mcp/server.js.map +1 -0
  221. package/dist/ports/fact-store.d.ts +82 -0
  222. package/dist/ports/fact-store.js +16 -0
  223. package/dist/ports/fact-store.js.map +1 -0
  224. package/dist/ports/llm-client.d.ts +42 -0
  225. package/dist/ports/llm-client.js +14 -0
  226. package/dist/ports/llm-client.js.map +1 -0
  227. package/dist/ports/logger.d.ts +13 -0
  228. package/dist/ports/logger.js +8 -0
  229. package/dist/ports/logger.js.map +1 -0
  230. package/dist/ports/session-store.d.ts +29 -0
  231. package/dist/ports/session-store.js +9 -0
  232. package/dist/ports/session-store.js.map +1 -0
  233. package/dist/ports/transcript-adapter.d.ts +48 -0
  234. package/dist/ports/transcript-adapter.js +15 -0
  235. package/dist/ports/transcript-adapter.js.map +1 -0
  236. package/dist/shared/types.d.ts +129 -0
  237. package/dist/shared/types.js +6 -0
  238. package/dist/shared/types.js.map +1 -0
  239. package/dist/ui/assets/index-BA6IpU8g.css +1 -0
  240. package/dist/ui/assets/index-B_qIVV0k.js +69 -0
  241. package/dist/ui/index.html +13 -0
  242. package/docs/methodology/re-derivation-rate.md +112 -0
  243. package/docs/methodology/useful-hit-rate.md +79 -0
  244. package/docs/plans/2026-05-20-fts5-lexical-recall.md +1088 -0
  245. package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +662 -0
  246. package/docs/plans/2026-05-20-recall-hook-design.md +131 -0
  247. package/docs/plans/2026-05-20-recall-hook-implementation.md +1222 -0
  248. package/docs/plans/desktop-product.md +69 -0
  249. package/docs/plans/factstore-design.md +236 -0
  250. package/logs/CHANGELOG/CHANGELOG-2026.md +1389 -0
  251. package/logs/CHANGELOG/CHANGELOG.md +320 -0
  252. package/migrations/000_initial_schema.sql +174 -0
  253. package/migrations/001_entity_type_rename.sql +17 -0
  254. package/migrations/002_adapter_state_extend.sql +12 -0
  255. package/migrations/003_session_embeddings.sql +11 -0
  256. package/migrations/004_facts.sql +46 -0
  257. package/migrations/005_sources.sql +31 -0
  258. package/migrations/006_providers.sql +33 -0
  259. package/migrations/007_source_tokens.sql +17 -0
  260. package/migrations/008_fts_rebuild.sql +9 -0
  261. package/migrations/009_session_embedding_chunks.sql +46 -0
  262. package/migrations/010_sources_opencode.sql +30 -0
  263. package/migrations/011_sources_hermes_agent.sql +30 -0
  264. package/migrations/012_sources_aider.sql +30 -0
  265. package/migrations/013_adapter_state_failure_count.sql +12 -0
  266. package/package.json +56 -0
  267. package/plugin/.codex-plugin/plugin.json +22 -0
  268. package/plugin/.mcp.json +8 -0
  269. package/plugin/README.md +51 -0
  270. package/plugin/hooks/hooks.json +25 -0
  271. package/plugin/scripts/prompt-recall-hook.mjs +202 -0
  272. package/plugin/scripts/stop-hook.mjs +306 -0
  273. package/plugin-hermes-agent/README.md +49 -0
  274. package/plugin-hermes-agent/__init__.py +75 -0
  275. package/plugin-hermes-agent/plugin.yaml +15 -0
  276. package/scripts/backfill-citations.mjs +0 -0
  277. package/scripts/build-codex-plugin.mjs +61 -0
  278. package/scripts/deepseek-probe.mjs +67 -0
  279. package/scripts/extract-triples.mjs +207 -0
  280. package/scripts/longmemeval/embedding-cache.ts +77 -0
  281. package/scripts/longmemeval/fetch-dataset.sh +25 -0
  282. package/scripts/longmemeval/run-harness.ts +315 -0
  283. package/scripts/longmemeval/scorer.ts +99 -0
  284. package/scripts/longmemeval/tsconfig.json +9 -0
  285. package/scripts/longmemeval/types.ts +35 -0
  286. package/scripts/nlm-daily-digest.py +239 -0
  287. package/scripts/nlm-daily-digest.sh +28 -0
  288. package/src/cli/classify-parity.ts +257 -0
  289. package/src/cli/launchctl-helpers.ts +49 -0
  290. package/src/cli/nlm.ts +885 -0
  291. package/src/core/actions/actions-log.ts +118 -0
  292. package/src/core/actions/overlay.ts +117 -0
  293. package/src/core/adapters/aider.ts +205 -0
  294. package/src/core/adapters/claude-code.ts +293 -0
  295. package/src/core/adapters/common.ts +54 -0
  296. package/src/core/adapters/from-source.ts +57 -0
  297. package/src/core/adapters/hermes-agent.ts +240 -0
  298. package/src/core/adapters/hermes.ts +277 -0
  299. package/src/core/adapters/jsonl-generic.ts +208 -0
  300. package/src/core/adapters/opencode.ts +281 -0
  301. package/src/core/adapters/pi.ts +264 -0
  302. package/src/core/classifier/prompt.ts +200 -0
  303. package/src/core/dataset/build-dataset.ts +463 -0
  304. package/src/core/embedding/chunk-body.ts +76 -0
  305. package/src/core/embedding/embed-backfill.ts +210 -0
  306. package/src/core/embedding/embed-normalize.ts +135 -0
  307. package/src/core/facts/backfill-facts.ts +254 -0
  308. package/src/core/facts/extract-facts.ts +50 -0
  309. package/src/core/hook/citation-detect.ts +124 -0
  310. package/src/core/hook/cite-memo.ts +68 -0
  311. package/src/core/hook/claude-settings.ts +166 -0
  312. package/src/core/hook/gate.ts +25 -0
  313. package/src/core/hook/hook-log.ts +41 -0
  314. package/src/core/hook/memo-sweep.ts +164 -0
  315. package/src/core/hook/memo.ts +67 -0
  316. package/src/core/hook/pointer-block.ts +26 -0
  317. package/src/core/hook/select.ts +32 -0
  318. package/src/core/hook/transcript.ts +121 -0
  319. package/src/core/ingest/ingest-session.ts +111 -0
  320. package/src/core/providers/provider-models.ts +100 -0
  321. package/src/core/providers/provider-registry.ts +196 -0
  322. package/src/core/recall/citation-log.ts +108 -0
  323. package/src/core/recall/filter.ts +27 -0
  324. package/src/core/recall/index.ts +6 -0
  325. package/src/core/recall/match-fields.ts +40 -0
  326. package/src/core/recall/query-log.ts +149 -0
  327. package/src/core/recall/query-shape.ts +66 -0
  328. package/src/core/recall/recall-service.ts +320 -0
  329. package/src/core/recall/recent-log.ts +59 -0
  330. package/src/core/recall/tokenize.ts +18 -0
  331. package/src/core/recall/useful-scan.ts +336 -0
  332. package/src/core/recall-facts/fact-query-log.ts +150 -0
  333. package/src/core/recall-facts/fact-recall-service.ts +327 -0
  334. package/src/core/scheduler/scan-once.ts +142 -0
  335. package/src/core/scheduler/scheduler.ts +225 -0
  336. package/src/core/sources/source-registry.ts +260 -0
  337. package/src/core/storage/db-restore.ts +133 -0
  338. package/src/core/storage/live-status.ts +45 -0
  339. package/src/core/storage/migrate.ts +72 -0
  340. package/src/core/storage/sqlite-fact-store.ts +304 -0
  341. package/src/core/storage/sqlite-session-store.ts +765 -0
  342. package/src/hook/prompt-recall-hook.ts +174 -0
  343. package/src/hook/session-end-hook.ts +81 -0
  344. package/src/hook/session-start-hook.ts +165 -0
  345. package/src/hook/stop-hook.ts +236 -0
  346. package/src/http/app.ts +1114 -0
  347. package/src/install/claude-code.ts +128 -0
  348. package/src/install/codex.ts +367 -0
  349. package/src/install/hermes-agent.ts +76 -0
  350. package/src/install/hermes.ts +78 -0
  351. package/src/install/ollama.ts +208 -0
  352. package/src/install/setup.ts +368 -0
  353. package/src/llm/classifier-box.ts +64 -0
  354. package/src/llm/deepseek-client.ts +150 -0
  355. package/src/llm/env-autoload.ts +55 -0
  356. package/src/llm/ollama-client.ts +189 -0
  357. package/src/mcp/server.ts +534 -0
  358. package/src/ports/fact-store.ts +102 -0
  359. package/src/ports/llm-client.ts +52 -0
  360. package/src/ports/logger.ts +16 -0
  361. package/src/ports/session-store.ts +45 -0
  362. package/src/ports/transcript-adapter.ts +55 -0
  363. package/src/shared/types.ts +145 -0
  364. package/src/ui/App.tsx +58 -0
  365. package/src/ui/components/PromoteOpenButton.tsx +65 -0
  366. package/src/ui/components/SessionDrawer.tsx +136 -0
  367. package/src/ui/components/SideNav.tsx +162 -0
  368. package/src/ui/components/Skeleton.tsx +107 -0
  369. package/src/ui/index.html +13 -0
  370. package/src/ui/lib/actions.ts +30 -0
  371. package/src/ui/lib/api.ts +92 -0
  372. package/src/ui/lib/dataset.ts +141 -0
  373. package/src/ui/lib/registries.ts +155 -0
  374. package/src/ui/lib/view-settings.ts +41 -0
  375. package/src/ui/main.tsx +15 -0
  376. package/src/ui/pages/Live.tsx +229 -0
  377. package/src/ui/pages/Pulse.tsx +415 -0
  378. package/src/ui/pages/Recall.tsx +190 -0
  379. package/src/ui/pages/River.tsx +308 -0
  380. package/src/ui/pages/Search.tsx +93 -0
  381. package/src/ui/pages/Stub.tsx +9 -0
  382. package/src/ui/pages/Thread.tsx +262 -0
  383. package/src/ui/pages/settings/Classifier.tsx +227 -0
  384. package/src/ui/pages/settings/Data.tsx +190 -0
  385. package/src/ui/pages/settings/Index.tsx +65 -0
  386. package/src/ui/pages/settings/Labels.tsx +224 -0
  387. package/src/ui/pages/settings/Providers.tsx +305 -0
  388. package/src/ui/pages/settings/SettingsSubnav.tsx +28 -0
  389. package/src/ui/pages/settings/Sources.tsx +326 -0
  390. package/src/ui/pages/settings/Views.tsx +96 -0
  391. package/src/ui/styles.css +1766 -0
  392. package/src/ui/tsconfig.json +21 -0
  393. package/src/ui/vite.config.ts +19 -0
  394. package/tests/fixtures/claude_code/short_session.jsonl +2 -0
  395. package/tests/fixtures/claude_code/standard_iso.jsonl +4 -0
  396. package/tests/fixtures/claude_code/tool_heavy.jsonl +8 -0
  397. package/tests/fixtures/claude_code/with_subagent.jsonl +7 -0
  398. package/tests/fixtures/facts.ts +17 -0
  399. package/tests/fixtures/golden-corpus.ts +85 -0
  400. package/tests/fixtures/hermes/paired_request_dump.json +24 -0
  401. package/tests/fixtures/hermes/paired_session.json +23 -0
  402. package/tests/fixtures/hermes/request_dump.json +28 -0
  403. package/tests/fixtures/hermes/session_iso.json +38 -0
  404. package/tests/fixtures/hermes/session_unix.json +38 -0
  405. package/tests/fixtures/hermes/system_only.json +18 -0
  406. package/tests/fixtures/pi/error-connection-abort.jsonl +8 -0
  407. package/tests/fixtures/pi/short-successful.jsonl +5 -0
  408. package/tests/fixtures/pi/with-custom-message.jsonl +6 -0
  409. package/tests/fixtures/sessions.ts +22 -0
  410. package/tests/integration/backfill-facts.test.ts +362 -0
  411. package/tests/integration/citation-explicit.test.ts +111 -0
  412. package/tests/integration/cite-event.test.ts +169 -0
  413. package/tests/integration/cite-memo.test.ts +87 -0
  414. package/tests/integration/db-restore.test.ts +153 -0
  415. package/tests/integration/embed-backfill.test.ts +176 -0
  416. package/tests/integration/fact-supersedence.test.ts +313 -0
  417. package/tests/integration/fts-index.test.ts +60 -0
  418. package/tests/integration/getbyids-sqlite.test.ts +60 -0
  419. package/tests/integration/hermes-agent-hooks.test.ts +248 -0
  420. package/tests/integration/hook-claude-settings.test.ts +205 -0
  421. package/tests/integration/hook-log.test.ts +54 -0
  422. package/tests/integration/hook-memo.test.ts +68 -0
  423. package/tests/integration/hook-pre-compact.test.ts +105 -0
  424. package/tests/integration/hook-subagent-start.test.ts +102 -0
  425. package/tests/integration/http.test.ts +401 -0
  426. package/tests/integration/keyword-search-fts.test.ts +66 -0
  427. package/tests/integration/mcp-recall-logging.test.ts +88 -0
  428. package/tests/integration/mcp.test.ts +248 -0
  429. package/tests/integration/memo-sweep.test.ts +91 -0
  430. package/tests/integration/prompt-recall-hook.test.ts +88 -0
  431. package/tests/integration/provider-registry.test.ts +107 -0
  432. package/tests/integration/recall-golden.test.ts +59 -0
  433. package/tests/integration/recall-sqlite.test.ts +169 -0
  434. package/tests/integration/scheduler.test.ts +391 -0
  435. package/tests/integration/session-end-hook.test.ts +48 -0
  436. package/tests/integration/session-start-hook.test.ts +126 -0
  437. package/tests/integration/source-registry.test.ts +120 -0
  438. package/tests/integration/sqlite-fact-store.test.ts +346 -0
  439. package/tests/integration/stop-hook.test.ts +560 -0
  440. package/tests/integration/wal-checkpoint.test.ts +49 -0
  441. package/tests/unit/cli/launchctl-helpers.test.ts +60 -0
  442. package/tests/unit/core/adapters/aider.test.ts +230 -0
  443. package/tests/unit/core/adapters/claude-code.test.ts +118 -0
  444. package/tests/unit/core/adapters/hermes-agent.test.ts +329 -0
  445. package/tests/unit/core/adapters/hermes.test.ts +81 -0
  446. package/tests/unit/core/adapters/jsonl-generic.test.ts +142 -0
  447. package/tests/unit/core/adapters/opencode.test.ts +354 -0
  448. package/tests/unit/core/adapters/pi.test.ts +110 -0
  449. package/tests/unit/core/classifier/prompt.test.ts +126 -0
  450. package/tests/unit/core/embedding/chunk-body.test.ts +100 -0
  451. package/tests/unit/core/facts/extract-facts.test.ts +117 -0
  452. package/tests/unit/core/filter.test.ts +40 -0
  453. package/tests/unit/core/hook/citation-detect-cite-session.test.ts +96 -0
  454. package/tests/unit/core/hook/citation-detect.test.ts +124 -0
  455. package/tests/unit/core/hook/gate.test.ts +29 -0
  456. package/tests/unit/core/hook/pointer-block.test.ts +22 -0
  457. package/tests/unit/core/hook/select.test.ts +66 -0
  458. package/tests/unit/core/match-fields.test.ts +39 -0
  459. package/tests/unit/core/mcp-cite-session.test.ts +51 -0
  460. package/tests/unit/core/providers/provider-models.test.ts +101 -0
  461. package/tests/unit/core/query-shape.test.ts +92 -0
  462. package/tests/unit/core/recall-facts/fact-recall-service.test.ts +258 -0
  463. package/tests/unit/core/recall-service.test.ts +200 -0
  464. package/tests/unit/core/storage/live-status.test.ts +54 -0
  465. package/tests/unit/core/tokenize.test.ts +32 -0
  466. package/tests/unit/core/useful-scan.test.ts +537 -0
  467. package/tests/unit/llm/embed.test.ts +93 -0
  468. package/tests/unit/llm/ollama-client.test.ts +124 -0
  469. package/tests/unit/scripts/longmemeval-scorer.test.ts +114 -0
  470. package/tsconfig.json +31 -0
  471. package/tsconfig.test.json +11 -0
  472. package/vitest.config.ts +22 -0
@@ -0,0 +1,126 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { runHook } from "../../src/hook/session-start-hook.js";
6
+ import type { RecallHitInput } from "../../src/core/hook/select.js";
7
+
8
+ const hits = (...ids: string[]): ReadonlyArray<RecallHitInput> =>
9
+ ids.map((id, i) => ({
10
+ id,
11
+ label: `Session ${id}`,
12
+ startedAt: "2026-05-15T10:00:00.000Z",
13
+ matchScore: 0.9 - i * 0.01,
14
+ }));
15
+
16
+ describe("session-start runHook", () => {
17
+ let tmp: string;
18
+
19
+ beforeEach(() => {
20
+ tmp = mkdtempSync(join(tmpdir(), "nlm-session-start-"));
21
+ process.env["NLM_HOOK_STATE_DIR"] = join(tmp, "state");
22
+ process.env["NLM_HOOK_LOG"] = join(tmp, "hook-log.jsonl");
23
+ });
24
+
25
+ afterEach(() => {
26
+ delete process.env["NLM_HOOK_STATE_DIR"];
27
+ delete process.env["NLM_HOOK_LOG"];
28
+ rmSync(tmp, { recursive: true, force: true });
29
+ });
30
+
31
+ it("shadow mode logs to hook-log but returns no stdout", async () => {
32
+ const out = await runHook(
33
+ { conversationId: "c1", query: "nlm-memory-ts recall" },
34
+ { mode: "shadow", recall: async () => hits("sess_a") },
35
+ );
36
+ expect(out).toBe("");
37
+ const log = readFileSync(join(tmp, "hook-log.jsonl"), "utf8").trim();
38
+ const entry = JSON.parse(log) as Record<string, unknown>;
39
+ expect(entry["wouldInject"]).toEqual(["sess_a"]);
40
+ expect(entry["mode"]).toBe("shadow");
41
+ // gate is always "evaluate" — no prompt classifier in session-start
42
+ expect(entry["gate"]).toBe("evaluate");
43
+ });
44
+
45
+ it("shadow mode does not write the memo", async () => {
46
+ await runHook(
47
+ { conversationId: "c1", query: "nlm-memory-ts" },
48
+ { mode: "shadow", recall: async () => hits("sess_a") },
49
+ );
50
+ expect(existsSync(join(tmp, "state", "c1.json"))).toBe(false);
51
+ });
52
+
53
+ it("live mode returns the pointer block and writes the memo", async () => {
54
+ const out = await runHook(
55
+ { conversationId: "c1", query: "nlm-memory-ts recall" },
56
+ { mode: "live", recall: async () => hits("sess_a", "sess_b") },
57
+ );
58
+ expect(out).toContain("## Possibly-relevant prior sessions (nlm-memory)");
59
+ expect(out).toContain("sess_a");
60
+ const memo = JSON.parse(
61
+ readFileSync(join(tmp, "state", "c1.json"), "utf8"),
62
+ ) as string[];
63
+ expect([...memo].sort()).toEqual(["sess_a", "sess_b"]);
64
+ });
65
+
66
+ it("live mode dedups: a second fire does not re-surface the same session", async () => {
67
+ const deps = { mode: "live" as const, recall: async () => hits("sess_a") };
68
+ const first = await runHook({ conversationId: "c1", query: "nlm-memory-ts" }, deps);
69
+ expect(first).toContain("sess_a");
70
+ const second = await runHook({ conversationId: "c1", query: "nlm-memory-ts" }, deps);
71
+ expect(second).toBe("");
72
+ });
73
+
74
+ it("returns empty and does not throw when recall rejects", async () => {
75
+ const out = await runHook(
76
+ { conversationId: "c1", query: "nlm-memory-ts" },
77
+ {
78
+ mode: "live",
79
+ recall: async () => {
80
+ throw new Error("daemon down");
81
+ },
82
+ },
83
+ );
84
+ expect(out).toBe("");
85
+ });
86
+
87
+ it("returns empty string in both modes when recall returns no hits", async () => {
88
+ for (const mode of ["shadow", "live"] as const) {
89
+ const out = await runHook(
90
+ { conversationId: `c-${mode}`, query: "nlm-memory-ts" },
91
+ { mode, recall: async () => [] },
92
+ );
93
+ expect(out).toBe("");
94
+ }
95
+ });
96
+
97
+ it("hook-log entry has promptPreview set to the query", async () => {
98
+ await runHook(
99
+ { conversationId: "c1", query: "whtnxt-agent session recall" },
100
+ { mode: "shadow", recall: async () => hits("sess_x") },
101
+ );
102
+ const entry = JSON.parse(
103
+ readFileSync(join(tmp, "hook-log.jsonl"), "utf8").trim(),
104
+ ) as Record<string, unknown>;
105
+ expect(entry["promptPreview"]).toBe("whtnxt-agent session recall");
106
+ });
107
+
108
+ it("live mode writes memo for each new session ID across multiple fires", async () => {
109
+ // First fire surfaces sess_a
110
+ await runHook(
111
+ { conversationId: "c1", query: "nlm-memory-ts" },
112
+ { mode: "live", recall: async () => hits("sess_a") },
113
+ );
114
+ // Second fire surfaces sess_b (sess_a already in memo — deduped out, sess_b is new)
115
+ const second = await runHook(
116
+ { conversationId: "c1", query: "nlm-memory-ts" },
117
+ { mode: "live", recall: async () => hits("sess_a", "sess_b") },
118
+ );
119
+ expect(second).toContain("sess_b");
120
+ expect(second).not.toContain("sess_a");
121
+ const memo = JSON.parse(
122
+ readFileSync(join(tmp, "state", "c1.json"), "utf8"),
123
+ ) as string[];
124
+ expect([...memo].sort()).toEqual(["sess_a", "sess_b"]);
125
+ });
126
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Phase 0 — SourceRegistry integration. Real SQLite, migrations apply,
3
+ * seed defaults, CRUD round-trip, name uniqueness.
4
+ */
5
+
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+ import { SqliteSessionStore } from "../../src/core/storage/sqlite-session-store.js";
11
+ import { SourceRegistry } from "../../src/core/sources/source-registry.js";
12
+
13
+ const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
14
+
15
+ describe("SourceRegistry", () => {
16
+ let tmp: string;
17
+ let store: SqliteSessionStore;
18
+ let registry: SourceRegistry;
19
+
20
+ beforeEach(() => {
21
+ tmp = mkdtempSync(join(tmpdir(), "nlm-sources-"));
22
+ store = new SqliteSessionStore({
23
+ dbPath: join(tmp, "canonical.sqlite"),
24
+ migrationsDir: MIGRATIONS_DIR,
25
+ });
26
+ registry = new SourceRegistry(store.rawDb());
27
+ });
28
+
29
+ afterEach(() => {
30
+ store.close();
31
+ rmSync(tmp, { recursive: true, force: true });
32
+ });
33
+
34
+ it("starts empty and seeds six presets", () => {
35
+ expect(registry.list()).toEqual([]);
36
+ registry.seedDefaults();
37
+ const rows = registry.list();
38
+ expect(rows.map((r) => r.kind)).toEqual(["claude-code", "hermes", "hermes-agent", "aider", "opencode", "pi"]);
39
+ expect(rows.every((r) => r.runtimeLabel.endsWith("/1.0"))).toBe(true);
40
+ });
41
+
42
+ it("seedDefaults is idempotent", () => {
43
+ registry.seedDefaults();
44
+ registry.seedDefaults();
45
+ expect(registry.list().length).toBe(6);
46
+ });
47
+
48
+ it("inserts a custom JSONL source and round-trips parse config", () => {
49
+ const inserted = registry.insert({
50
+ kind: "jsonl-generic",
51
+ name: "Cursor",
52
+ pathOrUrl: "/tmp/cursor",
53
+ runtimeLabel: "cursor/0.1",
54
+ parseConfig: { sessionIdField: "id", textField: "content" },
55
+ });
56
+ expect(inserted.id).toBeGreaterThan(0);
57
+ const fetched = registry.get(inserted.id);
58
+ expect(fetched?.parseConfig).toEqual({ sessionIdField: "id", textField: "content" });
59
+ });
60
+
61
+ it("rejects duplicate names at the unique-constraint level", () => {
62
+ registry.insert({ kind: "webhook", name: "Push", runtimeLabel: "push/1" });
63
+ expect(() => registry.insert({ kind: "webhook", name: "Push", runtimeLabel: "push/2" }))
64
+ .toThrow();
65
+ });
66
+
67
+ it("update patches only the supplied fields", () => {
68
+ const row = registry.insert({ kind: "webhook", name: "API", runtimeLabel: "api/1" });
69
+ const updated = registry.update(row.id, { enabled: false });
70
+ expect(updated?.enabled).toBe(false);
71
+ expect(updated?.runtimeLabel).toBe("api/1");
72
+ });
73
+
74
+ it("delete removes the row", () => {
75
+ const row = registry.insert({ kind: "webhook", name: "Tmp", runtimeLabel: "tmp/1" });
76
+ expect(registry.delete(row.id)).toBe(true);
77
+ expect(registry.get(row.id)).toBeNull();
78
+ });
79
+
80
+ it("mints a token on insert for webhook sources, redacts on list/get", () => {
81
+ const row = registry.insert({ kind: "webhook", name: "Tool A", runtimeLabel: "tool-a/1" });
82
+ expect(row.token).toMatch(/^nlm_[a-f0-9]{48}$/);
83
+ expect(row.hasToken).toBe(true);
84
+ const listed = registry.list().find((r) => r.id === row.id);
85
+ expect(listed?.token).toBeNull();
86
+ expect(listed?.hasToken).toBe(true);
87
+ expect(registry.get(row.id)?.token).toBeNull();
88
+ });
89
+
90
+ it("findByToken resolves to the owning source", () => {
91
+ const row = registry.insert({ kind: "webhook", name: "Tool B", runtimeLabel: "tool-b/1" });
92
+ expect(row.token).toBeTruthy();
93
+ const found = registry.findByToken(row.token!);
94
+ expect(found?.id).toBe(row.id);
95
+ expect(registry.findByToken("nlm_invalid")).toBeNull();
96
+ expect(registry.findByToken("")).toBeNull();
97
+ });
98
+
99
+ it("non-webhook sources do not get tokens", () => {
100
+ const row = registry.insert({
101
+ kind: "jsonl-generic", name: "Logs", runtimeLabel: "logs/1", pathOrUrl: "/tmp/logs",
102
+ });
103
+ expect(row.token).toBeNull();
104
+ expect(row.hasToken).toBe(false);
105
+ });
106
+
107
+ it("regenerateToken issues a new token only for webhook sources", () => {
108
+ const wh = registry.insert({ kind: "webhook", name: "Tool C", runtimeLabel: "tool-c/1" });
109
+ const first = wh.token!;
110
+ const second = registry.regenerateToken(wh.id)!;
111
+ expect(second).not.toBe(first);
112
+ expect(registry.findByToken(first)).toBeNull();
113
+ expect(registry.findByToken(second)?.id).toBe(wh.id);
114
+
115
+ const jsonl = registry.insert({
116
+ kind: "jsonl-generic", name: "L2", runtimeLabel: "l/1", pathOrUrl: "/tmp/x",
117
+ });
118
+ expect(registry.regenerateToken(jsonl.id)).toBeNull();
119
+ });
120
+ });
@@ -0,0 +1,346 @@
1
+ /**
2
+ * SqliteFactStore against real SQLite + real migrations. Uses a tmp DB per
3
+ * test so we exercise the actual schema, not a fake.
4
+ *
5
+ * Phase B.1: storage substrate only. No extraction, no recall service.
6
+ */
7
+
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
12
+ import { SqliteFactStore } from "../../src/core/storage/sqlite-fact-store.js";
13
+ import { SqliteSessionStore } from "../../src/core/storage/sqlite-session-store.js";
14
+ import { makeFact } from "../fixtures/facts.js";
15
+ import { makeSession } from "../fixtures/sessions.js";
16
+
17
+ const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
18
+
19
+ describe("SqliteFactStore (integration)", () => {
20
+ let tmp: string;
21
+ let sessionStore: SqliteSessionStore;
22
+ let factStore: SqliteFactStore;
23
+
24
+ beforeEach(() => {
25
+ tmp = mkdtempSync(join(tmpdir(), "nlm-facts-"));
26
+ sessionStore = new SqliteSessionStore({
27
+ dbPath: join(tmp, "canonical.sqlite"),
28
+ migrationsDir: MIGRATIONS_DIR,
29
+ });
30
+ factStore = new SqliteFactStore(sessionStore.rawDb());
31
+ // Facts FK to sessions(id); seed one parent session so inserts don't trip
32
+ // the foreign key constraint.
33
+ sessionStore.insertSessionForTest(
34
+ makeSession({ id: "sess_parent", label: "Parent session" }),
35
+ );
36
+ });
37
+
38
+ afterEach(() => {
39
+ sessionStore.close();
40
+ rmSync(tmp, { recursive: true, force: true });
41
+ });
42
+
43
+ it("inserts and retrieves a fact round-trip", async () => {
44
+ const fact = makeFact({ id: "fact_1", sourceSessionId: "sess_parent" });
45
+ await factStore.insert(fact);
46
+ const fetched = await factStore.getById("fact_1");
47
+ expect(fetched).toEqual(fact);
48
+ });
49
+
50
+ it("returns null for missing ids", async () => {
51
+ expect(await factStore.getById("nonexistent")).toBeNull();
52
+ });
53
+
54
+ it("insertMany commits atomically", async () => {
55
+ await factStore.insertMany([
56
+ makeFact({ id: "fact_a", subject: "alpha", sourceSessionId: "sess_parent" }),
57
+ makeFact({ id: "fact_b", subject: "beta", sourceSessionId: "sess_parent" }),
58
+ ]);
59
+ expect(await factStore.getById("fact_a")).not.toBeNull();
60
+ expect(await factStore.getById("fact_b")).not.toBeNull();
61
+ });
62
+
63
+ it("insertMany rolls back the whole batch on duplicate id", async () => {
64
+ await factStore.insert(
65
+ makeFact({ id: "fact_existing", sourceSessionId: "sess_parent" }),
66
+ );
67
+ await expect(
68
+ factStore.insertMany([
69
+ makeFact({ id: "fact_new", subject: "alpha", sourceSessionId: "sess_parent" }),
70
+ makeFact({ id: "fact_existing", subject: "beta", sourceSessionId: "sess_parent" }),
71
+ ]),
72
+ ).rejects.toThrow();
73
+ expect(await factStore.getById("fact_new")).toBeNull();
74
+ });
75
+
76
+ it("findCurrent returns only non-superseded facts", async () => {
77
+ await factStore.insert(
78
+ makeFact({
79
+ id: "fact_old",
80
+ subject: "nlm-memory-ts",
81
+ predicate: "framework",
82
+ value: "Fastify",
83
+ sourceSessionId: "sess_parent",
84
+ createdAt: "2026-05-18T10:00:00Z",
85
+ }),
86
+ );
87
+ await factStore.insert(
88
+ makeFact({
89
+ id: "fact_new",
90
+ subject: "nlm-memory-ts",
91
+ predicate: "framework",
92
+ value: "Hono",
93
+ sourceSessionId: "sess_parent",
94
+ createdAt: "2026-05-19T10:00:00Z",
95
+ }),
96
+ );
97
+ await factStore.markSuperseded("fact_old", "fact_new");
98
+
99
+ const current = await factStore.findCurrent("nlm-memory-ts", "framework");
100
+ expect(current?.id).toBe("fact_new");
101
+ expect(current?.value).toBe("Hono");
102
+ });
103
+
104
+ it("findCurrent returns null when no current fact exists", async () => {
105
+ expect(await factStore.findCurrent("nlm-memory-ts", "framework")).toBeNull();
106
+ });
107
+
108
+ it("list filters by subject and excludes superseded by default", async () => {
109
+ await factStore.insertMany([
110
+ makeFact({
111
+ id: "f1",
112
+ subject: "mac-pro",
113
+ predicate: "endpoint",
114
+ value: "http://macpro:8080/v1",
115
+ sourceSessionId: "sess_parent",
116
+ createdAt: "2026-05-19T10:00:00Z",
117
+ }),
118
+ makeFact({
119
+ id: "f2",
120
+ subject: "mac-pro",
121
+ predicate: "model",
122
+ value: "qwen2.5-3b",
123
+ sourceSessionId: "sess_parent",
124
+ createdAt: "2026-05-19T10:05:00Z",
125
+ }),
126
+ makeFact({
127
+ id: "f3",
128
+ subject: "other",
129
+ predicate: "framework",
130
+ value: "Hono",
131
+ sourceSessionId: "sess_parent",
132
+ }),
133
+ ]);
134
+ await factStore.markSuperseded("f1", "f2");
135
+
136
+ const current = await factStore.list({ subject: "mac-pro" });
137
+ expect(current.map((f) => f.id)).toEqual(["f2"]);
138
+
139
+ const all = await factStore.list({
140
+ subject: "mac-pro",
141
+ includeSuperseded: true,
142
+ });
143
+ expect(all.map((f) => f.id)).toEqual(["f2", "f1"]); // created_at DESC
144
+ });
145
+
146
+ it("list with predicate narrows further", async () => {
147
+ await factStore.insertMany([
148
+ makeFact({ id: "f1", subject: "x", predicate: "alpha", sourceSessionId: "sess_parent" }),
149
+ makeFact({ id: "f2", subject: "x", predicate: "beta", sourceSessionId: "sess_parent" }),
150
+ ]);
151
+ const out = await factStore.list({ subject: "x", predicate: "beta" });
152
+ expect(out.map((f) => f.id)).toEqual(["f2"]);
153
+ });
154
+
155
+ it("listBySession returns all facts (including superseded) for a session", async () => {
156
+ sessionStore.insertSessionForTest(makeSession({ id: "sess_other" }));
157
+ await factStore.insertMany([
158
+ makeFact({ id: "f1", subject: "a", sourceSessionId: "sess_parent" }),
159
+ makeFact({ id: "f2", subject: "b", sourceSessionId: "sess_parent" }),
160
+ makeFact({ id: "f3", subject: "c", sourceSessionId: "sess_other" }),
161
+ ]);
162
+ await factStore.markSuperseded("f1", "f2");
163
+
164
+ const out = await factStore.listBySession("sess_parent");
165
+ expect(out.map((f) => f.id).sort()).toEqual(["f1", "f2"]);
166
+ });
167
+
168
+ it("markSuperseded throws when either id is missing", async () => {
169
+ await factStore.insert(makeFact({ id: "real", sourceSessionId: "sess_parent" }));
170
+ await expect(factStore.markSuperseded("nope", "real")).rejects.toThrow(/not found/);
171
+ await expect(factStore.markSuperseded("real", "nope")).rejects.toThrow(/not found/);
172
+ });
173
+
174
+ it("markSuperseded rejects self-supersedence", async () => {
175
+ await factStore.insert(makeFact({ id: "self", sourceSessionId: "sess_parent" }));
176
+ await expect(factStore.markSuperseded("self", "self")).rejects.toThrow(/itself/);
177
+ });
178
+
179
+ it("markSuperseded with null reverses an earlier supersedence", async () => {
180
+ await factStore.insertMany([
181
+ makeFact({ id: "a", subject: "s", predicate: "p", value: "v1", sourceSessionId: "sess_parent" }),
182
+ makeFact({ id: "b", subject: "s", predicate: "p", value: "v2", sourceSessionId: "sess_parent" }),
183
+ ]);
184
+ await factStore.markSuperseded("a", "b");
185
+ expect((await factStore.getById("a"))?.supersededBy).toBe("b");
186
+ await factStore.markSuperseded("a", null);
187
+ expect((await factStore.getById("a"))?.supersededBy).toBeNull();
188
+ });
189
+
190
+ it("CHECK constraints reject invalid kind", async () => {
191
+ await expect(
192
+ factStore.insert(
193
+ // @ts-expect-error — exercising the CHECK constraint at runtime
194
+ makeFact({ id: "bad", kind: "garbage", sourceSessionId: "sess_parent" }),
195
+ ),
196
+ ).rejects.toThrow();
197
+ });
198
+
199
+ it("CHECK constraints reject confidence out of [0, 1]", async () => {
200
+ await expect(
201
+ factStore.insert(
202
+ makeFact({ id: "bad", confidence: 1.5, sourceSessionId: "sess_parent" }),
203
+ ),
204
+ ).rejects.toThrow();
205
+ });
206
+
207
+ it("FK constraint rejects facts pointing at missing sessions", async () => {
208
+ await expect(
209
+ factStore.insert(
210
+ makeFact({ id: "orphan", sourceSessionId: "no_such_session" }),
211
+ ),
212
+ ).rejects.toThrow();
213
+ });
214
+
215
+ describe("listForRecall (B.3)", () => {
216
+ beforeEach(async () => {
217
+ await factStore.insertMany([
218
+ makeFact({
219
+ id: "f_hono", subject: "nlm-memory-ts", predicate: "framework",
220
+ value: "Hono", confidence: 0.9, sourceSessionId: "sess_parent",
221
+ }),
222
+ makeFact({
223
+ id: "f_endpoint", kind: "attribute", subject: "mac-pro", predicate: "endpoint",
224
+ value: "http://macpro:8080/v1", confidence: 0.85, sourceSessionId: "sess_parent",
225
+ }),
226
+ makeFact({
227
+ id: "f_low", subject: "x", predicate: "other", value: "y",
228
+ confidence: 0.5, sourceSessionId: "sess_parent",
229
+ }),
230
+ makeFact({
231
+ id: "f_fastify", subject: "nlm-memory-ts", predicate: "framework",
232
+ value: "Fastify", confidence: 0.9, sourceSessionId: "sess_parent",
233
+ }),
234
+ ]);
235
+ await factStore.markSuperseded("f_fastify", "f_hono");
236
+ });
237
+
238
+ it("filters by subject + predicate, excluding superseded by default", async () => {
239
+ const out = await factStore.listForRecall({
240
+ subject: "nlm-memory-ts",
241
+ predicate: "framework",
242
+ });
243
+ expect(out.map((f) => f.id)).toEqual(["f_hono"]);
244
+ });
245
+
246
+ it("applies minConfidence at the SQL layer", async () => {
247
+ const all = await factStore.listForRecall({ minConfidence: 0 });
248
+ expect(all.map((f) => f.id).sort()).toEqual(["f_endpoint", "f_hono", "f_low"]);
249
+ const high = await factStore.listForRecall({ minConfidence: 0.8 });
250
+ expect(high.map((f) => f.id).sort()).toEqual(["f_endpoint", "f_hono"]);
251
+ });
252
+
253
+ it("kind filter restricts the result set", async () => {
254
+ const out = await factStore.listForRecall({ kind: "attribute" });
255
+ expect(out.map((f) => f.id)).toEqual(["f_endpoint"]);
256
+ });
257
+ });
258
+
259
+ describe("getHistory (B.3)", () => {
260
+ it("returns one chain per predicate when only subject is given", async () => {
261
+ await factStore.insertMany([
262
+ makeFact({
263
+ id: "f1", subject: "s", predicate: "framework", value: "Fastify",
264
+ sourceSessionId: "sess_parent", createdAt: "2026-05-18T00:00:00Z",
265
+ }),
266
+ makeFact({
267
+ id: "f2", subject: "s", predicate: "framework", value: "Hono",
268
+ sourceSessionId: "sess_parent", createdAt: "2026-05-19T00:00:00Z",
269
+ }),
270
+ makeFact({
271
+ id: "f3", subject: "s", predicate: "endpoint", value: ":8080",
272
+ sourceSessionId: "sess_parent", createdAt: "2026-05-19T00:00:00Z",
273
+ }),
274
+ ]);
275
+ await factStore.markSuperseded("f1", "f2");
276
+
277
+ const chains = await factStore.getHistory("s");
278
+ expect(chains).toHaveLength(2);
279
+ const framework = chains.find((c) => c.predicate === "framework");
280
+ expect(framework?.history.map((f) => f.id)).toEqual(["f2", "f1"]);
281
+ const endpoint = chains.find((c) => c.predicate === "endpoint");
282
+ expect(endpoint?.history.map((f) => f.id)).toEqual(["f3"]);
283
+ });
284
+
285
+ it("narrows to a single chain when predicate is provided", async () => {
286
+ await factStore.insertMany([
287
+ makeFact({
288
+ id: "a", subject: "s", predicate: "framework", value: "v1",
289
+ sourceSessionId: "sess_parent", createdAt: "2026-05-18T00:00:00Z",
290
+ }),
291
+ makeFact({
292
+ id: "b", subject: "s", predicate: "framework", value: "v2",
293
+ sourceSessionId: "sess_parent", createdAt: "2026-05-19T00:00:00Z",
294
+ }),
295
+ makeFact({
296
+ id: "c", subject: "s", predicate: "endpoint", value: ":8080",
297
+ sourceSessionId: "sess_parent",
298
+ }),
299
+ ]);
300
+ const chains = await factStore.getHistory("s", "framework");
301
+ expect(chains).toHaveLength(1);
302
+ expect(chains[0]?.history.map((f) => f.id)).toEqual(["b", "a"]);
303
+ });
304
+
305
+ it("returns empty array when no matches", async () => {
306
+ const chains = await factStore.getHistory("nonexistent");
307
+ expect(chains).toEqual([]);
308
+ });
309
+ });
310
+
311
+ describe("semanticSearch (B.3)", () => {
312
+ it("returns nearest neighbors by L2 distance over fact_embeddings", async () => {
313
+ await factStore.insertMany([
314
+ makeFact({ id: "near", sourceSessionId: "sess_parent" }),
315
+ makeFact({ id: "far", subject: "other", sourceSessionId: "sess_parent" }),
316
+ ]);
317
+ // Unit vectors: nearVec aligned with query, farVec orthogonal.
318
+ const near = new Float32Array(768);
319
+ near[0] = 1;
320
+ const far = new Float32Array(768);
321
+ far[1] = 1;
322
+ factStore.upsertEmbedding("near", near);
323
+ factStore.upsertEmbedding("far", far);
324
+
325
+ const query = new Float32Array(768);
326
+ query[0] = 1;
327
+ const neighbors = await factStore.semanticSearch(query, 5);
328
+ expect(neighbors[0]?.factId).toBe("near");
329
+ expect(neighbors[0]!.distance).toBeLessThan(neighbors[1]!.distance);
330
+ });
331
+
332
+ it("upsertEmbedding replaces, not duplicates", async () => {
333
+ await factStore.insert(makeFact({ id: "f1", sourceSessionId: "sess_parent" }));
334
+ const v1 = new Float32Array(768);
335
+ v1[0] = 1;
336
+ const v2 = new Float32Array(768);
337
+ v2[1] = 1;
338
+ factStore.upsertEmbedding("f1", v1);
339
+ factStore.upsertEmbedding("f1", v2);
340
+ const count = sessionStore.rawDb()
341
+ .prepare<[], { c: number }>("SELECT COUNT(*) AS c FROM fact_embeddings WHERE fact_id = 'f1'")
342
+ .get();
343
+ expect(count?.c).toBe(1);
344
+ });
345
+ });
346
+ });