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,99 @@
1
+ /**
2
+ * Pure scoring functions for the LongMemEval harness. Two metrics:
3
+ *
4
+ * - R@k (recall at k): did the retriever return any gold session ID in
5
+ * its top-k results? Standard benchmark metric.
6
+ * - Session-body hit: did the gold answer text appear anywhere in the
7
+ * bodies of the top-k returned sessions? NLM-specific companion that
8
+ * captures session-as-primary-unit value the strict-ID R@k can miss
9
+ * (e.g. a session that supersedes the gold session and quotes its
10
+ * decision).
11
+ *
12
+ * Both functions are deterministic and dependency-free so the harness can
13
+ * test them with synthetic inputs.
14
+ */
15
+
16
+ export interface ScoreInputs {
17
+ readonly returnedIds: ReadonlyArray<string>;
18
+ readonly goldIds: ReadonlyArray<string>;
19
+ /** Map id → body for the bodies of the top-k returned sessions. */
20
+ readonly returnedBodies: ReadonlyArray<string>;
21
+ /** Some LongMemEval answers are ints (counting questions); coerced to string. */
22
+ readonly answer: string | number | boolean;
23
+ readonly k: number;
24
+ }
25
+
26
+ export interface SingleScore {
27
+ readonly recallAtK: 0 | 1;
28
+ readonly sessionBodyHit: 0 | 1;
29
+ }
30
+
31
+ /** Score a single question. Returns 0/1 indicators that aggregate via mean. */
32
+ export function scoreOne(input: ScoreInputs): SingleScore {
33
+ const topK = input.returnedIds.slice(0, input.k);
34
+ const goldSet = new Set(input.goldIds);
35
+ const recallAtK = topK.some((id) => goldSet.has(id)) ? 1 : 0;
36
+
37
+ // Session-body hit: substring match for multi-word answers; word-boundary
38
+ // match for short answers (single token <4 chars: "3", "yes", numeric
39
+ // counts). Without the boundary, a numeric answer "3" hits every body
40
+ // containing "3 days", "$3", etc., inflating the metric to noise.
41
+ const ans = normalize(String(input.answer));
42
+ let sessionBodyHit: 0 | 1 = 0;
43
+ if (ans.length > 0) {
44
+ const isShortToken = !ans.includes(" ") && ans.length < 4;
45
+ const test = isShortToken
46
+ ? (body: string): boolean =>
47
+ new RegExp(`\\b${escapeRegExp(ans)}\\b`).test(normalize(body))
48
+ : (body: string): boolean => normalize(body).includes(ans);
49
+ const bodies = input.returnedBodies.slice(0, input.k);
50
+ for (const body of bodies) {
51
+ if (test(body)) {
52
+ sessionBodyHit = 1;
53
+ break;
54
+ }
55
+ }
56
+ }
57
+ return { recallAtK, sessionBodyHit };
58
+ }
59
+
60
+ export interface Aggregate {
61
+ readonly n: number;
62
+ readonly recallAtK: number;
63
+ readonly sessionBodyHitRate: number;
64
+ }
65
+
66
+ /** Aggregate per-question scores into mean rates. */
67
+ export function aggregate(scores: ReadonlyArray<SingleScore>): Aggregate {
68
+ const n = scores.length;
69
+ if (n === 0) {
70
+ return { n: 0, recallAtK: 0, sessionBodyHitRate: 0 };
71
+ }
72
+ let r = 0;
73
+ let s = 0;
74
+ for (const x of scores) {
75
+ r += x.recallAtK;
76
+ s += x.sessionBodyHit;
77
+ }
78
+ return {
79
+ n,
80
+ recallAtK: round3(r / n),
81
+ sessionBodyHitRate: round3(s / n),
82
+ };
83
+ }
84
+
85
+ function normalize(s: string): string {
86
+ return s
87
+ .toLowerCase()
88
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
89
+ .replace(/\s+/g, " ")
90
+ .trim();
91
+ }
92
+
93
+ function round3(x: number): number {
94
+ return Math.round(x * 1000) / 1000;
95
+ }
96
+
97
+ function escapeRegExp(s: string): string {
98
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "../..",
5
+ "outDir": "../../dist-scripts",
6
+ "noEmit": true
7
+ },
8
+ "include": ["./*.ts", "../../src/**/*.ts"]
9
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * LongMemEval dataset schema. Mirrors the published JSON shape from
3
+ * huggingface.co/datasets/xiaowu0162/longmemeval-cleaned.
4
+ *
5
+ * Each instance: a question against a haystack of past chat sessions. The
6
+ * gold session IDs are in `answer_session_ids` — that's what the retrieval
7
+ * step is scored against (R@k: was any gold ID returned in the top k).
8
+ */
9
+
10
+ export interface LongMemEvalTurn {
11
+ readonly role: "user" | "assistant";
12
+ readonly content: string;
13
+ readonly has_answer?: boolean;
14
+ }
15
+
16
+ export interface LongMemEvalInstance {
17
+ readonly question_id: string;
18
+ readonly question_type: string;
19
+ readonly question: string;
20
+ // LongMemEval answers are sometimes ints/booleans for counting and
21
+ // temporal-reasoning questions — coerce at the call site.
22
+ readonly answer: string | number | boolean;
23
+ readonly question_date: string;
24
+ readonly haystack_session_ids: ReadonlyArray<string>;
25
+ readonly haystack_dates: ReadonlyArray<string>;
26
+ readonly haystack_sessions: ReadonlyArray<ReadonlyArray<LongMemEvalTurn>>;
27
+ readonly answer_session_ids: ReadonlyArray<string>;
28
+ }
29
+
30
+ /** Serialize a session's turn list to a single body string for NLM ingest. */
31
+ export function turnsToBody(turns: ReadonlyArray<LongMemEvalTurn>): string {
32
+ return turns
33
+ .map((t) => `${t.role === "user" ? "User" : "Assistant"}: ${t.content}`)
34
+ .join("\n\n");
35
+ }
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env python3
2
+ """NLM daily digest — composes and posts a Telegram summary of recall activity.
3
+
4
+ Called from nlm-daily-digest.sh. Reads from the local NLM daemon (default
5
+ http://localhost:3940) and posts to the Telegram chat configured in env.
6
+
7
+ Token / chat id are read from the Whtnxt Agent .env (sourced by the wrapper
8
+ shell script). NLM port defaults to 3940 but honors NLM_PORT.
9
+
10
+ No external deps — urllib only. Exits non-zero on any unrecoverable error so
11
+ the cron log captures the failure.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+ import urllib.parse
21
+ import urllib.request
22
+ from collections import Counter
23
+ from datetime import datetime, time, timedelta, timezone
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ # Hook log lives alongside the daemon DB at ~/.nlm/hook-log.jsonl unless
28
+ # overridden via NLM_HOOK_LOG (mirrors the TS hook-log module).
29
+ HOOK_LOG_PATH = Path(os.environ.get("NLM_HOOK_LOG", str(Path.home() / ".nlm" / "hook-log.jsonl")))
30
+
31
+ # Local timezone for yesterday-window math. Cron fires at 7am CT and Edward
32
+ # thinks in CT — "yesterday" means yesterday-in-CT, not yesterday-in-UTC.
33
+ LOCAL_TZ = datetime.now().astimezone().tzinfo
34
+
35
+ # Patterns that mark a recall as a probe/test, not real agent usage.
36
+ # Match is case-insensitive substring against the query text.
37
+ PROBE_PATTERNS: tuple[str, ...] = (
38
+ "concurrency probe",
39
+ "test probe",
40
+ "path test",
41
+ "recall test",
42
+ "smoke",
43
+ "cutover-test",
44
+ )
45
+
46
+
47
+ def is_probe(query: str | None) -> bool:
48
+ if not query:
49
+ return False
50
+ q = query.lower()
51
+ return any(p in q for p in PROBE_PATTERNS)
52
+
53
+
54
+ def http_get_json(url: str, timeout: int = 5) -> Any:
55
+ req = urllib.request.Request(url, headers={"User-Agent": "nlm-daily-digest/1.0"})
56
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
57
+ return json.loads(resp.read().decode("utf-8"))
58
+
59
+
60
+ def post_telegram(token: str, chat_id: str, text: str) -> None:
61
+ url = f"https://api.telegram.org/bot{token}/sendMessage"
62
+ payload = urllib.parse.urlencode({
63
+ "chat_id": chat_id,
64
+ "text": text,
65
+ "disable_web_page_preview": "true",
66
+ }).encode("utf-8")
67
+ req = urllib.request.Request(
68
+ url,
69
+ data=payload,
70
+ headers={"User-Agent": "nlm-daily-digest/1.0"},
71
+ )
72
+ with urllib.request.urlopen(req, timeout=10) as resp:
73
+ body = json.loads(resp.read().decode("utf-8"))
74
+ if not body.get("ok"):
75
+ raise RuntimeError(f"telegram api error: {body}")
76
+
77
+
78
+ def yesterday_window() -> tuple[datetime, datetime]:
79
+ """Return (start, end) of yesterday in local tz, both tz-aware."""
80
+ today_local = datetime.now(LOCAL_TZ).date()
81
+ yesterday = today_local - timedelta(days=1)
82
+ start = datetime.combine(yesterday, time(0, 0), tzinfo=LOCAL_TZ)
83
+ end = datetime.combine(today_local, time(0, 0), tzinfo=LOCAL_TZ)
84
+ return start, end
85
+
86
+
87
+ def parse_iso(ts: str) -> datetime | None:
88
+ try:
89
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
90
+ except (TypeError, ValueError, AttributeError):
91
+ return None
92
+
93
+
94
+ def hook_liveness_check(dataset_sessions: list[dict[str, Any]]) -> str | None:
95
+ """Return an alert string if Claude Code ran yesterday but the hook did not fire.
96
+
97
+ Returns None when either: (a) no Claude Code sessions yesterday, in which
98
+ case silence is expected, or (b) hook fires were recorded as expected.
99
+
100
+ This is the load-bearing liveness check. The install-time smoke test only
101
+ catches malformed commands at install moment — this catches post-install
102
+ drift (node upgrades, dist moves, settings.json hand-edits, Claude Code
103
+ hook dispatcher changes) by correlating real usage with real hook output.
104
+ """
105
+ start, end = yesterday_window()
106
+
107
+ cc_sessions_yesterday = 0
108
+ for s in dataset_sessions:
109
+ if not str(s.get("runtime", "")).startswith("claude-code"):
110
+ continue
111
+ ts = parse_iso(str(s.get("started_at", "")))
112
+ if ts is None:
113
+ continue
114
+ if start <= ts < end:
115
+ cc_sessions_yesterday += 1
116
+
117
+ if cc_sessions_yesterday == 0:
118
+ return None # Edward didn't use Claude Code yesterday; silence is fine.
119
+
120
+ if not HOOK_LOG_PATH.exists():
121
+ return (
122
+ f"⚠️ hook silent: {cc_sessions_yesterday} Claude Code sessions yesterday, "
123
+ f"0 hook fires (log file missing at {HOOK_LOG_PATH})"
124
+ )
125
+
126
+ live_fires_yesterday = 0
127
+ with HOOK_LOG_PATH.open("r", encoding="utf-8") as f:
128
+ for line in f:
129
+ try:
130
+ entry = json.loads(line)
131
+ except json.JSONDecodeError:
132
+ continue
133
+ if entry.get("mode") != "live":
134
+ continue
135
+ ts = parse_iso(str(entry.get("ts", "")))
136
+ if ts is None:
137
+ continue
138
+ if start <= ts < end:
139
+ live_fires_yesterday += 1
140
+
141
+ if live_fires_yesterday == 0:
142
+ return (
143
+ f"⚠️ hook silent: {cc_sessions_yesterday} Claude Code sessions yesterday, "
144
+ f"0 live hook fires — check `nlm hook install` + ~/.claude/settings.json"
145
+ )
146
+ return None
147
+
148
+
149
+ def compose(stats: dict[str, Any], recent: list[dict[str, Any]], port: int, hook_alert: str | None = None) -> str:
150
+ """Format the digest message body."""
151
+ # `stats` window is server-side (7 days currently). `recent` is the
152
+ # last ~200 events we use to compute the *24h* real-traffic slice.
153
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
154
+ real_24h: list[dict[str, Any]] = []
155
+ for e in recent:
156
+ try:
157
+ ts = datetime.fromisoformat(e["ts"].replace("Z", "+00:00"))
158
+ except (KeyError, ValueError):
159
+ continue
160
+ if ts < cutoff:
161
+ continue
162
+ if is_probe(e.get("query")):
163
+ continue
164
+ real_24h.append(e)
165
+
166
+ src_24h = Counter(e.get("source", "?") for e in real_24h)
167
+ top_q = Counter(e.get("query", "") for e in real_24h if e.get("query")).most_common(5)
168
+
169
+ # 7-day stats (server-computed) — strip probes from the totals.
170
+ total_7d = int(stats.get("total", 0))
171
+ probes_7d = sum(
172
+ count for q, count in stats.get("top_queries", [])
173
+ if is_probe(q)
174
+ ) if isinstance(stats.get("top_queries"), list) else 0
175
+ real_7d = max(total_7d - probes_7d, 0)
176
+
177
+ useful = stats.get("useful_hit_rate")
178
+ useful_line = (
179
+ "useful_hit_rate: pending (see docs/methodology/useful-hit-rate.md)"
180
+ if useful is None
181
+ else f"useful_hit_rate (7d): {useful:.0%}"
182
+ )
183
+
184
+ src_str = " · ".join(f"{k}={v}" for k, v in sorted(src_24h.items())) or "none"
185
+
186
+ top_lines = "\n".join(f" {n}. {q[:60]}" for n, (q, _) in enumerate(top_q, 1)) or " (none)"
187
+
188
+ today = datetime.now().strftime("%a %Y-%m-%d")
189
+ alert_block = f"{hook_alert}\n\n" if hook_alert else ""
190
+ return (
191
+ f"NLM digest — {today}\n"
192
+ f"\n"
193
+ f"{alert_block}"
194
+ f"Last 24h (real traffic): {len(real_24h)} queries · {src_str}\n"
195
+ f"Last 7d: {real_7d} real / {total_7d} total · hit_rate {float(stats.get('hit_rate', 0)):.0%}\n"
196
+ f"{useful_line}\n"
197
+ f"\n"
198
+ f"Top real queries (24h):\n"
199
+ f"{top_lines}\n"
200
+ f"\n"
201
+ f"UI: http://localhost:{port}/ui/"
202
+ )
203
+
204
+
205
+ def main() -> int:
206
+ port = int(os.environ.get("NLM_PORT", "3940"))
207
+ token = os.environ.get("TELEGRAM_BOT_TOKEN")
208
+ chat_id = os.environ.get("TELEGRAM_CHAT_ID")
209
+ if not token or not chat_id:
210
+ print("missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID", file=sys.stderr)
211
+ return 2
212
+
213
+ base = f"http://localhost:{port}"
214
+ try:
215
+ stats = http_get_json(f"{base}/api/recall/stats")
216
+ recent_resp = http_get_json(f"{base}/api/recall/recent?limit=200")
217
+ dataset_resp = http_get_json(f"{base}/api/dataset", timeout=15)
218
+ except Exception as e:
219
+ # Daemon down — post an alert instead of failing silently.
220
+ text = f"NLM digest — {datetime.now().strftime('%a %Y-%m-%d')}\n\nDaemon unreachable at {base}\n{e}"
221
+ try:
222
+ post_telegram(token, chat_id, text)
223
+ except Exception as send_err:
224
+ print(f"telegram send also failed: {send_err}", file=sys.stderr)
225
+ return 1
226
+
227
+ recent: list[dict[str, Any]] = recent_resp.get("entries", []) if isinstance(recent_resp, dict) else []
228
+ dataset_sessions: list[dict[str, Any]] = (
229
+ dataset_resp.get("sessions", []) if isinstance(dataset_resp, dict) else []
230
+ )
231
+ hook_alert = hook_liveness_check(dataset_sessions)
232
+ text = compose(stats, recent, port, hook_alert=hook_alert)
233
+ post_telegram(token, chat_id, text)
234
+ print(f"[{datetime.now().isoformat()}] digest posted ({len(text)} chars)")
235
+ return 0
236
+
237
+
238
+ if __name__ == "__main__":
239
+ sys.exit(main())
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # NLM daily digest — posts a morning Telegram summary of recall activity.
3
+ #
4
+ # Cron entry (7:00am CT, runs after daily-reminders at 6:50am):
5
+ # 0 7 * * * "/Users/echalupa/Documents/Coding Projects/nlm-memory-ts/scripts/nlm-daily-digest.sh" >> "/Users/echalupa/Documents/Coding Projects/nlm-memory-ts/logs/daily-digest/digest.log" 2>&1
6
+ #
7
+ # Reads credentials from the Whtnxt Agent .env (Telegram bot token + chat id).
8
+
9
+ set -euo pipefail
10
+
11
+ REPO_DIR="/Users/echalupa/Documents/Coding Projects/nlm-memory-ts"
12
+ WHTNXT_ENV="/Users/echalupa/Documents/Coding Projects/Whtnxt Agent/.env"
13
+ LOG_DIR="${REPO_DIR}/logs/daily-digest"
14
+ mkdir -p "${LOG_DIR}"
15
+
16
+ # shellcheck source=/dev/null
17
+ set -a
18
+ . "${WHTNXT_ENV}"
19
+ set +a
20
+
21
+ : "${TELEGRAM_BOT_TOKEN:?TELEGRAM_BOT_TOKEN missing}"
22
+ : "${TELEGRAM_CHAT_ID:?TELEGRAM_CHAT_ID missing}"
23
+
24
+ # Populate useful-hit-log before stats fetch so useful_hit_rate is live.
25
+ /Users/echalupa/.nvm/versions/node/v22.22.1/bin/nlm useful-scan --days 1 \
26
+ >> "${LOG_DIR}/useful-scan.log" 2>&1 || true
27
+
28
+ python3 "${REPO_DIR}/scripts/nlm-daily-digest.py"
@@ -0,0 +1,257 @@
1
+ /**
2
+ * `nlm classify-parity` — Phase C parity verification harness.
3
+ *
4
+ * Reads N sessions from ~/.nlm/canonical.sqlite (read-only by default),
5
+ * runs the TS OllamaClient.classify on each body, diffs the result
6
+ * against the persisted Python classifier output, and prints aggregate
7
+ * metrics: Jaccard similarity on entities/decisions/open sets, label
8
+ * exact match rate, summary length delta, schema-failure count.
9
+ *
10
+ * Safe: opens the live store in readonly mode. Does not write anything
11
+ * back. Designed to be run interactively from a terminal during the
12
+ * Phase C cutover-prep window.
13
+ */
14
+
15
+ import { homedir } from "node:os";
16
+ import { resolve } from "node:path";
17
+ import Database from "better-sqlite3";
18
+ import * as sqliteVec from "sqlite-vec";
19
+ import type { LLMClient } from "../ports/llm-client.js";
20
+ import { LLMUnreachableError } from "../ports/llm-client.js";
21
+ import { DeepSeekClient } from "../llm/deepseek-client.js";
22
+ import { OllamaClient, ClassifierSchemaError } from "../llm/ollama-client.js";
23
+ import { autoloadEnv } from "../llm/env-autoload.js";
24
+
25
+ export type Provider = "ollama" | "deepseek";
26
+
27
+ interface CliOptions {
28
+ readonly limit: number;
29
+ readonly dbPath: string;
30
+ readonly ollamaUrl: string;
31
+ readonly classifyModel: string;
32
+ readonly provider: Provider;
33
+ readonly verbose: boolean;
34
+ }
35
+
36
+ interface SessionRow {
37
+ id: string;
38
+ label: string;
39
+ summary: string;
40
+ body: string | null;
41
+ }
42
+
43
+ interface PersistedClassification {
44
+ label: string;
45
+ summary: string;
46
+ entities: string[];
47
+ decisions: string[];
48
+ open: string[];
49
+ }
50
+
51
+ interface DiffMetrics {
52
+ sessionId: string;
53
+ labelMatch: boolean;
54
+ labelTs: string;
55
+ labelPy: string;
56
+ entityJaccard: number;
57
+ decisionJaccard: number;
58
+ openJaccard: number;
59
+ summaryDeltaChars: number;
60
+ schemaFailure: boolean;
61
+ errorMessage?: string;
62
+ }
63
+
64
+ export interface ParityReport {
65
+ attempted: number;
66
+ succeeded: number;
67
+ schemaFailures: number;
68
+ networkFailures: number;
69
+ labelExactMatchRate: number;
70
+ meanEntityJaccard: number;
71
+ meanDecisionJaccard: number;
72
+ meanOpenJaccard: number;
73
+ diffs: ReadonlyArray<DiffMetrics>;
74
+ }
75
+
76
+ function parseArgs(argv: string[]): CliOptions {
77
+ const flag = (name: string, fallback?: string): string | undefined => {
78
+ const i = argv.indexOf(name);
79
+ if (i === -1) return fallback;
80
+ return argv[i + 1] ?? fallback;
81
+ };
82
+ const limit = Number.parseInt(flag("--limit", "10") ?? "10", 10);
83
+ const providerRaw = (flag("--provider", "deepseek") ?? "deepseek").toLowerCase();
84
+ const provider: Provider = providerRaw === "ollama" ? "ollama" : "deepseek";
85
+ const defaultModel = provider === "deepseek" ? "deepseek-v4-flash" : "phi4-mini:latest";
86
+ return {
87
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 10,
88
+ dbPath:
89
+ flag("--db", process.env["NLM_DB_PATH"] ?? resolve(homedir(), ".nlm/canonical.sqlite")) ??
90
+ resolve(homedir(), ".nlm/canonical.sqlite"),
91
+ ollamaUrl: flag("--ollama", process.env["NLM_OLLAMA_URL"] ?? "http://localhost:11434") ?? "http://localhost:11434",
92
+ classifyModel: flag("--model", defaultModel) ?? defaultModel,
93
+ provider,
94
+ verbose: argv.includes("--verbose"),
95
+ };
96
+ }
97
+
98
+ function buildClient(opts: { provider: Provider; classifyModel: string; ollamaUrl: string }): LLMClient {
99
+ if (opts.provider === "deepseek") {
100
+ autoloadEnv();
101
+ return new DeepSeekClient({ classifyModel: opts.classifyModel });
102
+ }
103
+ return new OllamaClient({ baseUrl: opts.ollamaUrl, classifyModel: opts.classifyModel });
104
+ }
105
+
106
+ function jaccard(a: ReadonlyArray<string>, b: ReadonlyArray<string>): number {
107
+ const setA = new Set(a.map((s) => s.toLowerCase().trim()));
108
+ const setB = new Set(b.map((s) => s.toLowerCase().trim()));
109
+ if (setA.size === 0 && setB.size === 0) return 1;
110
+ const inter = new Set([...setA].filter((x) => setB.has(x)));
111
+ const union = new Set([...setA, ...setB]);
112
+ return inter.size / union.size;
113
+ }
114
+
115
+ export async function runParity(opts: CliOptions): Promise<ParityReport> {
116
+ const db = new Database(opts.dbPath, { readonly: true });
117
+ sqliteVec.load(db);
118
+
119
+ const rows = db
120
+ .prepare<[number], SessionRow>(
121
+ `SELECT id, label, summary, body
122
+ FROM sessions
123
+ WHERE body IS NOT NULL AND body != ''
124
+ ORDER BY started_at DESC
125
+ LIMIT ?`,
126
+ )
127
+ .all(opts.limit);
128
+
129
+ const persistedById = new Map<string, PersistedClassification>();
130
+ for (const r of rows) {
131
+ const entities = db
132
+ .prepare<[string], { entity_canonical: string }>(
133
+ "SELECT entity_canonical FROM session_entities WHERE session_id = ?",
134
+ )
135
+ .all(r.id)
136
+ .map((x) => x.entity_canonical);
137
+ const markers = db
138
+ .prepare<[string], { kind: "decision" | "open"; text: string }>(
139
+ "SELECT kind, text FROM markers WHERE session_id = ? ORDER BY position",
140
+ )
141
+ .all(r.id);
142
+ persistedById.set(r.id, {
143
+ label: r.label,
144
+ summary: r.summary,
145
+ entities,
146
+ decisions: markers.filter((m) => m.kind === "decision").map((m) => m.text),
147
+ open: markers.filter((m) => m.kind === "open").map((m) => m.text),
148
+ });
149
+ }
150
+ db.close();
151
+
152
+ const client = buildClient(opts);
153
+
154
+ const diffs: DiffMetrics[] = [];
155
+ let schemaFailures = 0;
156
+ let networkFailures = 0;
157
+
158
+ let idx = 0;
159
+ for (const r of rows) {
160
+ idx += 1;
161
+ const py = persistedById.get(r.id);
162
+ if (!py || !r.body) continue;
163
+
164
+ const t0 = Date.now();
165
+ try {
166
+ const ts = await client.classify(r.body);
167
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
168
+ const labelMatch = ts.label.toLowerCase().trim() === py.label.toLowerCase().trim();
169
+ const entJ = jaccard(ts.entities, py.entities);
170
+ const decJ = jaccard(ts.decisions, py.decisions);
171
+ const openJ = jaccard(ts.open, py.open);
172
+ diffs.push({
173
+ sessionId: r.id,
174
+ labelMatch,
175
+ labelTs: ts.label,
176
+ labelPy: py.label,
177
+ entityJaccard: entJ,
178
+ decisionJaccard: decJ,
179
+ openJaccard: openJ,
180
+ summaryDeltaChars: ts.summary.length - py.summary.length,
181
+ schemaFailure: false,
182
+ });
183
+ if (opts.verbose) {
184
+ const tag = labelMatch ? "EQ " : "DIFF";
185
+ process.stderr.write(
186
+ ` [${idx}/${rows.length}] ${elapsed}s ${tag} ${r.id} ent=${entJ.toFixed(2)} dec=${decJ.toFixed(2)} open=${openJ.toFixed(2)}\n`,
187
+ );
188
+ }
189
+ } catch (e) {
190
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
191
+ const message = e instanceof Error ? e.message : String(e);
192
+ if (e instanceof ClassifierSchemaError) schemaFailures += 1;
193
+ else if (e instanceof LLMUnreachableError) networkFailures += 1;
194
+ diffs.push({
195
+ sessionId: r.id,
196
+ labelMatch: false,
197
+ labelTs: "",
198
+ labelPy: py.label,
199
+ entityJaccard: 0,
200
+ decisionJaccard: 0,
201
+ openJaccard: 0,
202
+ summaryDeltaChars: 0,
203
+ schemaFailure: e instanceof ClassifierSchemaError,
204
+ errorMessage: message,
205
+ });
206
+ if (opts.verbose) {
207
+ process.stderr.write(
208
+ ` [${idx}/${rows.length}] ${elapsed}s ERR ${r.id} :: ${message}\n`,
209
+ );
210
+ }
211
+ }
212
+ }
213
+
214
+ const successes = diffs.filter((d) => !d.errorMessage);
215
+ const mean = (xs: ReadonlyArray<number>): number =>
216
+ xs.length === 0 ? 0 : Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 1000) / 1000;
217
+
218
+ return {
219
+ attempted: diffs.length,
220
+ succeeded: successes.length,
221
+ schemaFailures,
222
+ networkFailures,
223
+ labelExactMatchRate: mean(successes.map((d) => (d.labelMatch ? 1 : 0))),
224
+ meanEntityJaccard: mean(successes.map((d) => d.entityJaccard)),
225
+ meanDecisionJaccard: mean(successes.map((d) => d.decisionJaccard)),
226
+ meanOpenJaccard: mean(successes.map((d) => d.openJaccard)),
227
+ diffs,
228
+ };
229
+ }
230
+
231
+ export async function main(): Promise<void> {
232
+ const opts = parseArgs(process.argv.slice(2));
233
+ console.error(`nlm classify-parity: ${opts.limit} sessions from ${opts.dbPath}`);
234
+ console.error(
235
+ ` provider: ${opts.provider} model: ${opts.classifyModel}` +
236
+ (opts.provider === "ollama" ? ` ollama: ${opts.ollamaUrl}` : ""),
237
+ );
238
+ const report = await runParity(opts);
239
+
240
+ console.error("");
241
+ console.error(`attempted: ${report.attempted}`);
242
+ console.error(`succeeded: ${report.succeeded}`);
243
+ console.error(`schema failures: ${report.schemaFailures}`);
244
+ console.error(`network failures: ${report.networkFailures}`);
245
+ console.error(`label exact match: ${(report.labelExactMatchRate * 100).toFixed(1)}%`);
246
+ console.error(`mean Jaccard ents: ${report.meanEntityJaccard.toFixed(3)}`);
247
+ console.error(`mean Jaccard decs: ${report.meanDecisionJaccard.toFixed(3)}`);
248
+ console.error(`mean Jaccard open: ${report.meanOpenJaccard.toFixed(3)}`);
249
+ }
250
+
251
+ const isMain = import.meta.url === `file://${process.argv[1]}`;
252
+ if (isMain) {
253
+ main().catch((e) => {
254
+ console.error("classify-parity fatal:", e);
255
+ process.exit(1);
256
+ });
257
+ }