nlm-memory 0.21.0 → 0.21.13

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 (413) hide show
  1. package/README.md +67 -12
  2. package/assets/replay-eval-dark.svg +26 -0
  3. package/assets/replay-eval-light.svg +26 -0
  4. package/dist/cli/digest.js +34 -7
  5. package/dist/cli/digest.js.map +1 -1
  6. package/dist/cli/nlm.d.ts +14 -0
  7. package/dist/cli/nlm.js +450 -65
  8. package/dist/cli/nlm.js.map +1 -1
  9. package/dist/cli/reprocess-job-adapters.d.ts +35 -0
  10. package/dist/cli/reprocess-job-adapters.js +80 -0
  11. package/dist/cli/reprocess-job-adapters.js.map +1 -0
  12. package/dist/cli/scope-coverage.js +4 -1
  13. package/dist/cli/scope-coverage.js.map +1 -1
  14. package/dist/cli/supersede.js +8 -7
  15. package/dist/cli/supersede.js.map +1 -1
  16. package/dist/core/actions/actions-log.d.ts +65 -8
  17. package/dist/core/actions/actions-log.js +134 -17
  18. package/dist/core/actions/actions-log.js.map +1 -1
  19. package/dist/core/actions/overlay.d.ts +11 -2
  20. package/dist/core/actions/overlay.js +18 -6
  21. package/dist/core/actions/overlay.js.map +1 -1
  22. package/dist/core/adapters/from-source.js +11 -3
  23. package/dist/core/adapters/from-source.js.map +1 -1
  24. package/dist/core/adapters/fs-wiki-writer.d.ts +44 -0
  25. package/dist/core/adapters/fs-wiki-writer.js +132 -0
  26. package/dist/core/adapters/fs-wiki-writer.js.map +1 -0
  27. package/dist/core/adapters/hermes-agent.d.ts +15 -2
  28. package/dist/core/adapters/hermes-agent.js +57 -11
  29. package/dist/core/adapters/hermes-agent.js.map +1 -1
  30. package/dist/core/adapters/memory-wiki-writer.d.ts +8 -0
  31. package/dist/core/adapters/memory-wiki-writer.js +16 -0
  32. package/dist/core/adapters/memory-wiki-writer.js.map +1 -0
  33. package/dist/core/adapters/muse.d.ts +51 -0
  34. package/dist/core/adapters/muse.js +271 -0
  35. package/dist/core/adapters/muse.js.map +1 -0
  36. package/dist/core/adapters/opencode.d.ts +11 -4
  37. package/dist/core/adapters/opencode.js +41 -7
  38. package/dist/core/adapters/opencode.js.map +1 -1
  39. package/dist/core/alerts/alert-state.d.ts +17 -0
  40. package/dist/core/alerts/alert-state.js +45 -0
  41. package/dist/core/alerts/alert-state.js.map +1 -0
  42. package/dist/core/alerts/check-and-alert.d.ts +28 -0
  43. package/dist/core/alerts/check-and-alert.js +53 -0
  44. package/dist/core/alerts/check-and-alert.js.map +1 -0
  45. package/dist/core/alerts/drift-transition.d.ts +27 -0
  46. package/dist/core/alerts/drift-transition.js +56 -0
  47. package/dist/core/alerts/drift-transition.js.map +1 -0
  48. package/dist/core/alerts/embedder-transition.d.ts +23 -0
  49. package/dist/core/alerts/embedder-transition.js +40 -0
  50. package/dist/core/alerts/embedder-transition.js.map +1 -0
  51. package/dist/core/alerts/fire-alert.d.ts +24 -0
  52. package/dist/core/alerts/fire-alert.js +72 -0
  53. package/dist/core/alerts/fire-alert.js.map +1 -0
  54. package/dist/core/alerts/job-alert.d.ts +12 -0
  55. package/dist/core/alerts/job-alert.js +37 -0
  56. package/dist/core/alerts/job-alert.js.map +1 -0
  57. package/dist/core/alerts/types.d.ts +104 -0
  58. package/dist/core/alerts/types.js +11 -0
  59. package/dist/core/alerts/types.js.map +1 -0
  60. package/dist/core/alerts/wiki-alert.d.ts +15 -0
  61. package/dist/core/alerts/wiki-alert.js +31 -0
  62. package/dist/core/alerts/wiki-alert.js.map +1 -0
  63. package/dist/core/classifier/prompt.d.ts +13 -0
  64. package/dist/core/classifier/prompt.js +52 -2
  65. package/dist/core/classifier/prompt.js.map +1 -1
  66. package/dist/core/dataset/build-dataset.d.ts +16 -1
  67. package/dist/core/dataset/build-dataset.js +105 -45
  68. package/dist/core/dataset/build-dataset.js.map +1 -1
  69. package/dist/core/digest/compose.d.ts +8 -0
  70. package/dist/core/digest/compose.js +54 -0
  71. package/dist/core/digest/compose.js.map +1 -1
  72. package/dist/core/digest/hook-injection.d.ts +1 -1
  73. package/dist/core/digest/hook-injection.js +9 -1
  74. package/dist/core/digest/hook-injection.js.map +1 -1
  75. package/dist/core/digest/hook-version-parity.d.ts +31 -0
  76. package/dist/core/digest/hook-version-parity.js +56 -0
  77. package/dist/core/digest/hook-version-parity.js.map +1 -0
  78. package/dist/core/embedding/pg-embed-backfill.js +1 -1
  79. package/dist/core/embedding/pg-embed-backfill.js.map +1 -1
  80. package/dist/core/exemplars/capture-from-session.d.ts +1 -1
  81. package/dist/core/exemplars/capture-from-session.js +3 -3
  82. package/dist/core/exemplars/capture-from-session.js.map +1 -1
  83. package/dist/core/exemplars/embed-backfill.d.ts +9 -0
  84. package/dist/core/exemplars/embed-backfill.js +9 -1
  85. package/dist/core/exemplars/embed-backfill.js.map +1 -1
  86. package/dist/core/exemplars/recall-code.d.ts +1 -1
  87. package/dist/core/exemplars/recall-code.js +2 -2
  88. package/dist/core/exemplars/recall-code.js.map +1 -1
  89. package/dist/core/facts/backfill-facts.d.ts +1 -1
  90. package/dist/core/facts/backfill-facts.js +4 -4
  91. package/dist/core/facts/backfill-facts.js.map +1 -1
  92. package/dist/core/facts/embed-backfill.d.ts +54 -0
  93. package/dist/core/facts/embed-backfill.js +109 -0
  94. package/dist/core/facts/embed-backfill.js.map +1 -0
  95. package/dist/core/facts/reconcile-facts.d.ts +42 -0
  96. package/dist/core/facts/reconcile-facts.js +133 -0
  97. package/dist/core/facts/reconcile-facts.js.map +1 -0
  98. package/dist/core/health/embed-failure-state.d.ts +4 -0
  99. package/dist/core/health/embed-failure-state.js +12 -0
  100. package/dist/core/health/embed-failure-state.js.map +1 -0
  101. package/dist/core/health/warmup-retry.d.ts +27 -0
  102. package/dist/core/health/warmup-retry.js +53 -0
  103. package/dist/core/health/warmup-retry.js.map +1 -0
  104. package/dist/core/health/warmup-state.d.ts +15 -0
  105. package/dist/core/health/warmup-state.js +15 -1
  106. package/dist/core/health/warmup-state.js.map +1 -1
  107. package/dist/core/hook/cite-memo.d.ts +11 -7
  108. package/dist/core/hook/cite-memo.js +25 -17
  109. package/dist/core/hook/cite-memo.js.map +1 -1
  110. package/dist/core/hook/hook-log.d.ts +13 -2
  111. package/dist/core/hook/hook-log.js +16 -8
  112. package/dist/core/hook/hook-log.js.map +1 -1
  113. package/dist/core/hook/memo.d.ts +10 -6
  114. package/dist/core/hook/memo.js +25 -17
  115. package/dist/core/hook/memo.js.map +1 -1
  116. package/dist/core/hook/pointer-block.d.ts +8 -1
  117. package/dist/core/hook/pointer-block.js +17 -4
  118. package/dist/core/hook/pointer-block.js.map +1 -1
  119. package/dist/core/hook/resolve-conversation-by-query.d.ts +6 -5
  120. package/dist/core/hook/resolve-conversation-by-query.js +24 -12
  121. package/dist/core/hook/resolve-conversation-by-query.js.map +1 -1
  122. package/dist/core/ingest/ingest-session.d.ts +1 -1
  123. package/dist/core/ingest/ingest-session.js +3 -3
  124. package/dist/core/ingest/ingest-session.js.map +1 -1
  125. package/dist/core/ingest/reclassify-oversized.d.ts +1 -1
  126. package/dist/core/ingest/reclassify-oversized.js +2 -2
  127. package/dist/core/ingest/reclassify-oversized.js.map +1 -1
  128. package/dist/core/ingest/reprocess.d.ts +1 -1
  129. package/dist/core/ingest/reprocess.js +2 -2
  130. package/dist/core/ingest/reprocess.js.map +1 -1
  131. package/dist/core/jobs/job-supervisor.d.ts +168 -0
  132. package/dist/core/jobs/job-supervisor.js +335 -0
  133. package/dist/core/jobs/job-supervisor.js.map +1 -0
  134. package/dist/core/learner/model.d.ts +63 -0
  135. package/dist/core/learner/model.js +30 -0
  136. package/dist/core/learner/model.js.map +1 -0
  137. package/dist/core/learner/service.d.ts +42 -0
  138. package/dist/core/learner/service.js +74 -0
  139. package/dist/core/learner/service.js.map +1 -0
  140. package/dist/core/learner/summary.d.ts +28 -0
  141. package/dist/core/learner/summary.js +70 -0
  142. package/dist/core/learner/summary.js.map +1 -0
  143. package/dist/core/learner/validate.d.ts +18 -0
  144. package/dist/core/learner/validate.js +95 -0
  145. package/dist/core/learner/validate.js.map +1 -0
  146. package/dist/core/metrics/re-derivation.d.ts +31 -2
  147. package/dist/core/metrics/re-derivation.js +36 -4
  148. package/dist/core/metrics/re-derivation.js.map +1 -1
  149. package/dist/core/outcome/coverage.d.ts +1 -1
  150. package/dist/core/outcome/coverage.js +2 -2
  151. package/dist/core/outcome/coverage.js.map +1 -1
  152. package/dist/core/outcome/rollup.d.ts +1 -1
  153. package/dist/core/outcome/rollup.js +4 -4
  154. package/dist/core/outcome/rollup.js.map +1 -1
  155. package/dist/core/providers/provider-registry.d.ts +40 -23
  156. package/dist/core/providers/provider-registry.js +99 -81
  157. package/dist/core/providers/provider-registry.js.map +1 -1
  158. package/dist/core/recall/citation-log.d.ts +10 -5
  159. package/dist/core/recall/citation-log.js +21 -10
  160. package/dist/core/recall/citation-log.js.map +1 -1
  161. package/dist/core/recall/hook-recall-log.d.ts +5 -2
  162. package/dist/core/recall/hook-recall-log.js +12 -6
  163. package/dist/core/recall/hook-recall-log.js.map +1 -1
  164. package/dist/core/recall/miss-log.d.ts +7 -4
  165. package/dist/core/recall/miss-log.js +18 -11
  166. package/dist/core/recall/miss-log.js.map +1 -1
  167. package/dist/core/recall/query-log.d.ts +8 -5
  168. package/dist/core/recall/query-log.js +16 -9
  169. package/dist/core/recall/query-log.js.map +1 -1
  170. package/dist/core/recall/recall-service.d.ts +10 -1
  171. package/dist/core/recall/recall-service.js +31 -12
  172. package/dist/core/recall/recall-service.js.map +1 -1
  173. package/dist/core/recall/recent-log.d.ts +6 -1
  174. package/dist/core/recall/recent-log.js +13 -5
  175. package/dist/core/recall/recent-log.js.map +1 -1
  176. package/dist/core/recall/related-concepts.d.ts +29 -0
  177. package/dist/core/recall/related-concepts.js +67 -0
  178. package/dist/core/recall/related-concepts.js.map +1 -0
  179. package/dist/core/recall/related-exemplars.d.ts +1 -1
  180. package/dist/core/recall/related-exemplars.js +2 -2
  181. package/dist/core/recall/related-exemplars.js.map +1 -1
  182. package/dist/core/recall/related-facts.d.ts +1 -1
  183. package/dist/core/recall/related-facts.js +3 -3
  184. package/dist/core/recall/related-facts.js.map +1 -1
  185. package/dist/core/recall-facts/fact-query-log.d.ts +8 -4
  186. package/dist/core/recall-facts/fact-query-log.js +16 -8
  187. package/dist/core/recall-facts/fact-query-log.js.map +1 -1
  188. package/dist/core/recall-facts/fact-recall-service.d.ts +2 -2
  189. package/dist/core/recall-facts/fact-recall-service.js +12 -12
  190. package/dist/core/recall-facts/fact-recall-service.js.map +1 -1
  191. package/dist/core/scheduler/scan-once.js +15 -14
  192. package/dist/core/scheduler/scan-once.js.map +1 -1
  193. package/dist/core/scheduler/scheduler.d.ts +3 -0
  194. package/dist/core/scheduler/scheduler.js +15 -11
  195. package/dist/core/scheduler/scheduler.js.map +1 -1
  196. package/dist/core/signals/failure-mode-recall.d.ts +1 -1
  197. package/dist/core/signals/failure-mode-recall.js +2 -2
  198. package/dist/core/signals/failure-mode-recall.js.map +1 -1
  199. package/dist/core/signals/stamp-scope.d.ts +2 -2
  200. package/dist/core/signals/stamp-scope.js +2 -2
  201. package/dist/core/signals/stamp-scope.js.map +1 -1
  202. package/dist/core/sources/source-registry.d.ts +49 -28
  203. package/dist/core/sources/source-registry.js +113 -70
  204. package/dist/core/sources/source-registry.js.map +1 -1
  205. package/dist/core/storage/pg-code-exemplar-store.d.ts +19 -10
  206. package/dist/core/storage/pg-code-exemplar-store.js +58 -27
  207. package/dist/core/storage/pg-code-exemplar-store.js.map +1 -1
  208. package/dist/core/storage/pg-entity-store.d.ts +6 -1
  209. package/dist/core/storage/pg-entity-store.js +33 -18
  210. package/dist/core/storage/pg-entity-store.js.map +1 -1
  211. package/dist/core/storage/pg-fact-ingest.d.ts +1 -1
  212. package/dist/core/storage/pg-fact-ingest.js +10 -7
  213. package/dist/core/storage/pg-fact-ingest.js.map +1 -1
  214. package/dist/core/storage/pg-fact-store.d.ts +29 -17
  215. package/dist/core/storage/pg-fact-store.js +99 -46
  216. package/dist/core/storage/pg-fact-store.js.map +1 -1
  217. package/dist/core/storage/pg-learner-store.d.ts +27 -0
  218. package/dist/core/storage/pg-learner-store.js +174 -0
  219. package/dist/core/storage/pg-learner-store.js.map +1 -0
  220. package/dist/core/storage/pg-session-store.d.ts +24 -20
  221. package/dist/core/storage/pg-session-store.js +162 -90
  222. package/dist/core/storage/pg-session-store.js.map +1 -1
  223. package/dist/core/storage/pg-signal-store.d.ts +10 -5
  224. package/dist/core/storage/pg-signal-store.js +24 -12
  225. package/dist/core/storage/pg-signal-store.js.map +1 -1
  226. package/dist/core/storage/pg-storage.d.ts +4 -0
  227. package/dist/core/storage/pg-storage.js +6 -0
  228. package/dist/core/storage/pg-storage.js.map +1 -1
  229. package/dist/core/storage/pg-workstream-store.d.ts +16 -11
  230. package/dist/core/storage/pg-workstream-store.js +46 -29
  231. package/dist/core/storage/pg-workstream-store.js.map +1 -1
  232. package/dist/core/storage/sqlite-code-exemplar-store.d.ts +24 -10
  233. package/dist/core/storage/sqlite-code-exemplar-store.js +84 -40
  234. package/dist/core/storage/sqlite-code-exemplar-store.js.map +1 -1
  235. package/dist/core/storage/sqlite-entity-store.d.ts +8 -1
  236. package/dist/core/storage/sqlite-entity-store.js +46 -27
  237. package/dist/core/storage/sqlite-entity-store.js.map +1 -1
  238. package/dist/core/storage/sqlite-fact-store.d.ts +45 -19
  239. package/dist/core/storage/sqlite-fact-store.js +172 -64
  240. package/dist/core/storage/sqlite-fact-store.js.map +1 -1
  241. package/dist/core/storage/sqlite-learner-store.d.ts +33 -0
  242. package/dist/core/storage/sqlite-learner-store.js +178 -0
  243. package/dist/core/storage/sqlite-learner-store.js.map +1 -0
  244. package/dist/core/storage/sqlite-outcome-store.d.ts +37 -22
  245. package/dist/core/storage/sqlite-outcome-store.js +90 -37
  246. package/dist/core/storage/sqlite-outcome-store.js.map +1 -1
  247. package/dist/core/storage/sqlite-session-store.d.ts +29 -20
  248. package/dist/core/storage/sqlite-session-store.js +207 -121
  249. package/dist/core/storage/sqlite-session-store.js.map +1 -1
  250. package/dist/core/storage/sqlite-signal-store.d.ts +12 -5
  251. package/dist/core/storage/sqlite-signal-store.js +27 -15
  252. package/dist/core/storage/sqlite-signal-store.js.map +1 -1
  253. package/dist/core/storage/sqlite-storage.d.ts +4 -0
  254. package/dist/core/storage/sqlite-storage.js +10 -2
  255. package/dist/core/storage/sqlite-storage.js.map +1 -1
  256. package/dist/core/storage/sqlite-workstream-store.d.ts +16 -11
  257. package/dist/core/storage/sqlite-workstream-store.js +52 -31
  258. package/dist/core/storage/sqlite-workstream-store.js.map +1 -1
  259. package/dist/core/storage/supersedence-log.d.ts +10 -7
  260. package/dist/core/storage/supersedence-log.js +19 -14
  261. package/dist/core/storage/supersedence-log.js.map +1 -1
  262. package/dist/core/tenancy/default-team.d.ts +6 -0
  263. package/dist/core/tenancy/default-team.js +7 -0
  264. package/dist/core/tenancy/default-team.js.map +1 -0
  265. package/dist/core/tenancy/hosted-mode.d.ts +9 -0
  266. package/dist/core/tenancy/hosted-mode.js +12 -0
  267. package/dist/core/tenancy/hosted-mode.js.map +1 -0
  268. package/dist/core/tenancy/team-auth.d.ts +16 -0
  269. package/dist/core/tenancy/team-auth.js +22 -0
  270. package/dist/core/tenancy/team-auth.js.map +1 -0
  271. package/dist/core/tenancy/team-token-store.d.ts +45 -0
  272. package/dist/core/tenancy/team-token-store.js +52 -0
  273. package/dist/core/tenancy/team-token-store.js.map +1 -0
  274. package/dist/core/tenancy/tenant-clause.d.ts +37 -0
  275. package/dist/core/tenancy/tenant-clause.js +11 -0
  276. package/dist/core/tenancy/tenant-clause.js.map +1 -0
  277. package/dist/core/tenancy/tenant-state-path.d.ts +28 -0
  278. package/dist/core/tenancy/tenant-state-path.js +49 -0
  279. package/dist/core/tenancy/tenant-state-path.js.map +1 -0
  280. package/dist/core/wiki/project.d.ts +17 -0
  281. package/dist/core/wiki/project.js +37 -0
  282. package/dist/core/wiki/project.js.map +1 -0
  283. package/dist/core/wiki/reconcile.d.ts +15 -0
  284. package/dist/core/wiki/reconcile.js +14 -0
  285. package/dist/core/wiki/reconcile.js.map +1 -0
  286. package/dist/core/wiki/render.d.ts +5 -0
  287. package/dist/core/wiki/render.js +84 -0
  288. package/dist/core/wiki/render.js.map +1 -0
  289. package/dist/core/wiki/rollup.d.ts +28 -0
  290. package/dist/core/wiki/rollup.js +91 -0
  291. package/dist/core/wiki/rollup.js.map +1 -0
  292. package/dist/core/wiki/select.d.ts +8 -0
  293. package/dist/core/wiki/select.js +7 -0
  294. package/dist/core/wiki/select.js.map +1 -0
  295. package/dist/core/wiki/slug.d.ts +36 -0
  296. package/dist/core/wiki/slug.js +71 -0
  297. package/dist/core/wiki/slug.js.map +1 -0
  298. package/dist/core/wiki/types.d.ts +37 -0
  299. package/dist/core/wiki/types.js +2 -0
  300. package/dist/core/wiki/types.js.map +1 -0
  301. package/dist/core/work-digest/build-work-digest.d.ts +1 -1
  302. package/dist/core/work-digest/build-work-digest.js +3 -3
  303. package/dist/core/work-digest/build-work-digest.js.map +1 -1
  304. package/dist/core/workstream/bind.d.ts +1 -1
  305. package/dist/core/workstream/bind.js +10 -10
  306. package/dist/core/workstream/bind.js.map +1 -1
  307. package/dist/core/workstream/rollup.d.ts +1 -1
  308. package/dist/core/workstream/rollup.js +6 -6
  309. package/dist/core/workstream/rollup.js.map +1 -1
  310. package/dist/hook/hook-helpers.d.ts +14 -0
  311. package/dist/hook/hook-helpers.js +29 -2
  312. package/dist/hook/hook-helpers.js.map +1 -1
  313. package/dist/hook/pre-compact-hook.js +6 -4
  314. package/dist/hook/pre-compact-hook.js.map +1 -1
  315. package/dist/hook/prompt-recall-hook.d.ts +5 -2
  316. package/dist/hook/prompt-recall-hook.js +28 -17
  317. package/dist/hook/prompt-recall-hook.js.map +1 -1
  318. package/dist/hook/recall-over-http.d.ts +27 -3
  319. package/dist/hook/recall-over-http.js +44 -11
  320. package/dist/hook/recall-over-http.js.map +1 -1
  321. package/dist/hook/session-end-hook.js +7 -5
  322. package/dist/hook/session-end-hook.js.map +1 -1
  323. package/dist/hook/session-start-hook.d.ts +14 -1
  324. package/dist/hook/session-start-hook.js +55 -15
  325. package/dist/hook/session-start-hook.js.map +1 -1
  326. package/dist/hook/stop-hook.js +9 -7
  327. package/dist/hook/stop-hook.js.map +1 -1
  328. package/dist/http/app.d.ts +41 -3
  329. package/dist/http/app.js +355 -96
  330. package/dist/http/app.js.map +1 -1
  331. package/dist/install/claude-code.d.ts +17 -0
  332. package/dist/install/claude-code.js +44 -5
  333. package/dist/install/claude-code.js.map +1 -1
  334. package/dist/install/codex.d.ts +23 -2
  335. package/dist/install/codex.js +111 -14
  336. package/dist/install/codex.js.map +1 -1
  337. package/dist/install/cursor.d.ts +2 -2
  338. package/dist/install/cursor.js +7 -7
  339. package/dist/install/cursor.js.map +1 -1
  340. package/dist/install/hermes.d.ts +8 -0
  341. package/dist/install/hermes.js +28 -5
  342. package/dist/install/hermes.js.map +1 -1
  343. package/dist/install/local-source.d.ts +20 -0
  344. package/dist/install/local-source.js +28 -0
  345. package/dist/install/local-source.js.map +1 -0
  346. package/dist/install/mcp-block.d.ts +77 -0
  347. package/dist/install/mcp-block.js +99 -0
  348. package/dist/install/mcp-block.js.map +1 -0
  349. package/dist/install/pi.d.ts +26 -0
  350. package/dist/install/pi.js +43 -0
  351. package/dist/install/pi.js.map +1 -1
  352. package/dist/install/setup.js +6 -0
  353. package/dist/install/setup.js.map +1 -1
  354. package/dist/install/windsurf.d.ts +2 -2
  355. package/dist/install/windsurf.js +7 -7
  356. package/dist/install/windsurf.js.map +1 -1
  357. package/dist/llm/classifier-box.d.ts +5 -0
  358. package/dist/llm/classifier-box.js +5 -0
  359. package/dist/llm/classifier-box.js.map +1 -1
  360. package/dist/mcp/server.d.ts +73 -16
  361. package/dist/mcp/server.js +306 -83
  362. package/dist/mcp/server.js.map +1 -1
  363. package/dist/ports/clock.d.ts +12 -0
  364. package/dist/ports/clock.js +10 -0
  365. package/dist/ports/clock.js.map +1 -0
  366. package/dist/ports/code-exemplar-store.d.ts +13 -13
  367. package/dist/ports/entity-store.d.ts +5 -1
  368. package/dist/ports/fact-store.d.ts +0 -0
  369. package/dist/ports/fact-store.js +0 -0
  370. package/dist/ports/fact-store.js.map +1 -1
  371. package/dist/ports/learner-store.d.ts +80 -0
  372. package/dist/ports/learner-store.js +19 -0
  373. package/dist/ports/learner-store.js.map +1 -0
  374. package/dist/ports/outcome.d.ts +3 -3
  375. package/dist/ports/session-store.d.ts +20 -14
  376. package/dist/ports/session-store.js +5 -0
  377. package/dist/ports/session-store.js.map +1 -1
  378. package/dist/ports/signal-store.d.ts +12 -8
  379. package/dist/ports/spawn-child.d.ts +14 -0
  380. package/dist/ports/spawn-child.js +10 -0
  381. package/dist/ports/spawn-child.js.map +1 -0
  382. package/dist/ports/storage.d.ts +6 -0
  383. package/dist/ports/transcript-adapter.d.ts +11 -0
  384. package/dist/ports/wiki-writer.d.ts +19 -0
  385. package/dist/ports/wiki-writer.js +12 -0
  386. package/dist/ports/wiki-writer.js.map +1 -0
  387. package/dist/ports/workstream-store.d.ts +19 -12
  388. package/dist/shared/types.d.ts +22 -0
  389. package/dist/shared/types.js.map +1 -1
  390. package/dist/shared/version.d.ts +9 -0
  391. package/dist/shared/version.js +11 -0
  392. package/dist/shared/version.js.map +1 -0
  393. package/dist/ui/assets/index-D6F3xAis.js +11 -0
  394. package/dist/ui/index.html +1 -1
  395. package/migrations/033_actions_kind_check.sql +58 -0
  396. package/migrations/034_tenancy_teams_and_stamps.sql +40 -0
  397. package/migrations/035_tenancy_entity_rekey.sql +75 -0
  398. package/migrations/036_tenant_name_uniqueness.sql +59 -0
  399. package/migrations/037_sessions_covering_index.sql +25 -0
  400. package/migrations/038_sources_muse.sql +33 -0
  401. package/migrations/039_learner_evidence.sql +51 -0
  402. package/migrations/pg/033_actions_kind_check.sql +17 -0
  403. package/migrations/pg/034_tenancy.sql +77 -0
  404. package/migrations/pg/035_tenant_name_uniqueness.sql +15 -0
  405. package/migrations/pg/036_sources_muse.sql +12 -0
  406. package/migrations/pg/037_learner_evidence.sql +55 -0
  407. package/nlm/index.js +246 -55
  408. package/package.json +10 -5
  409. package/plugin/.codex-plugin/plugin.json +2 -2
  410. package/plugin/scripts/prompt-recall-hook.mjs +244 -55
  411. package/plugin/scripts/session-start-hook.mjs +273 -53
  412. package/plugin/scripts/stop-hook.mjs +195 -37
  413. package/dist/ui/assets/index-CSaBtgXD.js +0 -11
package/dist/http/app.js CHANGED
@@ -19,7 +19,6 @@
19
19
  */
20
20
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
21
21
  import { timingSafeEqual } from "node:crypto";
22
- import { homedir } from "node:os";
23
22
  import { dirname, extname, join, normalize, sep } from "node:path";
24
23
  import { Hono } from "hono";
25
24
  import pkg from "../../package.json" with { type: "json" };
@@ -39,6 +38,7 @@ import { classifyPrompt } from "../core/hook/gate.js";
39
38
  import { selectHits } from "../core/hook/select.js";
40
39
  import { formatPointerBlock } from "../core/hook/pointer-block.js";
41
40
  import { factRecallStats, logFactQuery } from "../core/recall-facts/fact-query-log.js";
41
+ import { pickRelatedConcepts } from "../core/recall/related-concepts.js";
42
42
  import { buildDataset } from "../core/dataset/build-dataset.js";
43
43
  import { ClassifierBox } from "../llm/classifier-box.js";
44
44
  import { SourceRegistry, PgSourceRegistry, } from "../core/sources/source-registry.js";
@@ -62,6 +62,10 @@ import { laneHealthSnapshot } from "../core/health/embedding-lane-state.js";
62
62
  import { inflightSnapshot } from "../core/health/embed-inflight.js";
63
63
  import { corpusSnapshot } from "../core/health/corpus-state.js";
64
64
  import { DEFAULT_NLM_PORT } from "../shared/net.js";
65
+ import { DEFAULT_TEAM_ID } from "../core/tenancy/default-team.js";
66
+ import { tenantStatePath } from "../core/tenancy/tenant-state-path.js";
67
+ import { isHostedMode } from "../core/tenancy/hosted-mode.js";
68
+ import { resolveTeamByToken } from "../core/tenancy/team-auth.js";
65
69
  const HERMES_RELATIVE_FLOOR = parseRelativeFloor(process.env["NLM_RECALL_REL_FLOOR"], 0.9);
66
70
  const MIME_TYPES = {
67
71
  ".html": "text/html; charset=utf-8",
@@ -87,6 +91,16 @@ const DATA_STAT_TABLES = [
87
91
  "sources",
88
92
  "providers",
89
93
  ];
94
+ /**
95
+ * Tenancy (program spec §4.6 disposition LOCAL, M2 plan Wave B6): `GET
96
+ * /api/data/stats` is a LOCAL-dispositioned route (spec §4.6) — hard-403'd
97
+ * under `NLM_HOSTED=1` by Wave C's hosted-mode gate, same class as
98
+ * `/api/dataset` and `/api/data/backup`. These are per-table row counts +
99
+ * migration bookkeeping for the Settings → Data page, not corpus content;
100
+ * left whole-DB deliberately rather than fake-filtered by tenant, which
101
+ * would misrepresent what the counts mean (they're meant to show the whole
102
+ * install's state, e.g. for a local operator sanity-checking their db).
103
+ */
90
104
  function sqliteDataStats(db, dbPath) {
91
105
  const countOf = (table) => {
92
106
  try {
@@ -219,7 +233,8 @@ const VALID_FACT_KINDS = ["decision", "open", "attribute"];
219
233
  export function createApp(deps) {
220
234
  const app = new Hono();
221
235
  const boundPort = process.env["NLM_PORT"] ? Number.parseInt(process.env["NLM_PORT"], 10) : Number.parseInt(DEFAULT_NLM_PORT, 10);
222
- installLocalOnlyMiddleware(app, boundPort);
236
+ installLocalOnlyMiddleware(app, boundPort, deps);
237
+ installHostedModeGate(app);
223
238
  registerHealthRoute(app, deps);
224
239
  registerMcpRoute(app, deps);
225
240
  registerRecallRoutes(app, deps);
@@ -236,6 +251,7 @@ export function createApp(deps) {
236
251
  registerProviderRoutes(app, deps);
237
252
  registerSessionRoute(app, deps);
238
253
  registerSignalRoutes(app, deps);
254
+ registerJobRoutes(app, deps);
239
255
  const nonceStore = createNonceStore();
240
256
  registerNonceRoute(app, nonceStore);
241
257
  if (deps.uiDist) {
@@ -270,25 +286,68 @@ function registerNonceRoute(app, nonceStore) {
270
286
  function uiAuthMode() {
271
287
  return process.env["NLM_UI_AUTH"] === "cookie" ? "cookie" : "none";
272
288
  }
273
- function installLocalOnlyMiddleware(app, boundPort) {
289
+ /**
290
+ * Tenant resolution + auth for every /api/* request (program spec §3, M3).
291
+ * Host/Origin checks are network-position defenses and stay skippable under
292
+ * VITEST/NODE_ENV=test (in-process `app.request()` calls carry no real
293
+ * socket). Tenant resolution does NOT skip under that flag — the contract's
294
+ * token-swap case (spec §6 case 8) and any other test that wires a real
295
+ * `teamTokens` store must exercise the actual resolveTeamByToken path, not a
296
+ * bypass, so a garbage token really does 401 inside a `vitest` run.
297
+ *
298
+ * Resolution order:
299
+ * 1. Hosted mode (NLM_HOSTED=1): a resolvable Bearer token is mandatory.
300
+ * No cookie fallback, no ungated fallback — absent/unresolvable token
301
+ * is 401 before any corpus read, always.
302
+ * 2. Local mode, NLM_UI_AUTH unset ("none", the default): zero UX change
303
+ * from pre-M3 — requests resolve to DEFAULT_TEAM_ID. A resolvable
304
+ * Bearer token is still honored when presented (lets local multi-team
305
+ * dev and in-process tests exercise real resolution), but nothing is
306
+ * ever rejected in this mode — that's the point of leaving auth off.
307
+ * 3. Local mode, NLM_UI_AUTH=cookie: the existing HMAC session cookie
308
+ * (still keyed off NLM_MCP_TOKEN directly — unchanged, local-only
309
+ * concern) resolves to DEFAULT_TEAM_ID. A Bearer token resolves via
310
+ * the same team_tokens lookup as every other transport instead of the
311
+ * old exact-string compare; unresolved token or no credential is 401.
312
+ */
313
+ function installLocalOnlyMiddleware(app, boundPort, deps) {
274
314
  const skipLocalGate = !!process.env["VITEST"] || process.env["NODE_ENV"] === "test";
275
315
  const extras = parseAllowedOrigins(process.env["NLM_ALLOWED_ORIGINS"] ?? "");
276
316
  app.use("/api/*", async (c, next) => {
277
- if (skipLocalGate)
278
- return next();
279
- const host = c.req.header("host");
280
- if (!isLoopbackHost(host, boundPort) && !extras.hosts.has(host?.toLowerCase() ?? "")) {
281
- return c.json({ error: "host header not allowed" }, 403);
317
+ if (!skipLocalGate) {
318
+ const host = c.req.header("host");
319
+ if (!isLoopbackHost(host, boundPort) && !extras.hosts.has(host?.toLowerCase() ?? "")) {
320
+ return c.json({ error: "host header not allowed" }, 403);
321
+ }
282
322
  }
283
323
  if (c.req.path === "/api/health") {
284
324
  return next();
285
325
  }
286
- const origin = c.req.header("origin");
287
- if (origin !== undefined && !isLoopbackOrigin(origin, boundPort) && !extras.origins.has(origin.toLowerCase())) {
288
- return c.json({ error: "origin not allowed" }, 403);
326
+ if (!skipLocalGate) {
327
+ const origin = c.req.header("origin");
328
+ if (origin !== undefined && !isLoopbackOrigin(origin, boundPort) && !extras.origins.has(origin.toLowerCase())) {
329
+ return c.json({ error: "origin not allowed" }, 403);
330
+ }
331
+ }
332
+ const auth = c.req.header("authorization") ?? "";
333
+ const match = /^Bearer\s+(\S+)$/i.exec(auth);
334
+ const presentedToken = match?.[1];
335
+ if (isHostedMode()) {
336
+ const resolved = deps.teamTokens ? await resolveTeamByToken(deps.teamTokens, presentedToken) : null;
337
+ if (!resolved)
338
+ return c.json({ error: "unauthorized" }, 401);
339
+ c.set("tenantId", resolved.teamId);
340
+ return next();
289
341
  }
290
342
  if (uiAuthMode() === "none") {
291
- // Auth disabled by user. Loopback Host + Origin checks already passed.
343
+ // Auth disabled by user (or never configured). Loopback Host + Origin
344
+ // checks already passed. A resolvable token still wins when present;
345
+ // otherwise fall back to the single local operator team exactly as
346
+ // before M3 — this mode never rejects a request.
347
+ const resolved = presentedToken && deps.teamTokens
348
+ ? await resolveTeamByToken(deps.teamTokens, presentedToken)
349
+ : null;
350
+ c.set("tenantId", resolved ? resolved.teamId : DEFAULT_TEAM_ID);
292
351
  return next();
293
352
  }
294
353
  const token = process.env["NLM_MCP_TOKEN"];
@@ -299,26 +358,55 @@ function installLocalOnlyMiddleware(app, boundPort) {
299
358
  }
300
359
  // UI session cookie (HMAC of the token, set by /ui/auth bootstrap).
301
360
  // Carries the browser's API calls and survives token-stable restarts.
361
+ // Local-only concern (program spec §3 M3): unchanged, always resolves
362
+ // to the single local operator team.
302
363
  const cookies = parseCookies(c.req.header("cookie"));
303
364
  if (verifySessionCookie(cookies[SESSION_COOKIE_NAME], token)) {
304
365
  // Rolling expiry: every authenticated hit re-issues Set-Cookie so an
305
366
  // actively-used session never sees an expiry. Only 30 days of true
306
367
  // inactivity force a re-bootstrap.
307
368
  c.header("Set-Cookie", buildSessionCookie(deriveSessionValue(token)));
369
+ c.set("tenantId", DEFAULT_TEAM_ID);
308
370
  return next();
309
371
  }
310
372
  // Bearer: programmatic clients (Hermes WebUI, agents, the MCP path).
311
- // Same secret as the cookie HMAC derives from, but transmitted directly.
312
- const auth = c.req.header("authorization") ?? "";
313
- const match = /^Bearer\s+(\S+)$/i.exec(auth);
314
- const given = Buffer.from(match?.[1] ?? "", "utf8");
315
- const want = Buffer.from(token, "utf8");
316
- if (match && given.length === want.length && timingSafeEqual(given, want)) {
373
+ // Resolved via the same team_tokens lookup every other transport uses.
374
+ const resolved = presentedToken && deps.teamTokens
375
+ ? await resolveTeamByToken(deps.teamTokens, presentedToken)
376
+ : null;
377
+ if (resolved) {
378
+ c.set("tenantId", resolved.teamId);
317
379
  return next();
318
380
  }
319
381
  return c.json({ error: "unauthorized" }, 401);
320
382
  });
321
383
  }
384
+ const HOSTED_GATED_ROUTES = [
385
+ { method: "GET", path: "/api/dataset", disposition: "LOCAL" },
386
+ { method: "GET", path: "/api/data/backup", disposition: "LOCAL" },
387
+ { method: "POST", path: "/api/data/restore", disposition: "LOCAL" },
388
+ { method: "GET", path: "/api/data/stats", disposition: "LOCAL" },
389
+ { method: "POST", path: "/api/classifier", disposition: "LOCAL" },
390
+ // Single-operator process supervision — starting/stopping the daemon's
391
+ // own reprocess child has no per-tenant meaning, same LOCAL class as the
392
+ // classifier swap above.
393
+ { method: "POST", path: "/api/jobs/reprocess", disposition: "LOCAL" },
394
+ { method: "DELETE", path: "/api/jobs/reprocess", disposition: "LOCAL" },
395
+ ];
396
+ function installHostedModeGate(app) {
397
+ const gated = new Map();
398
+ for (const r of HOSTED_GATED_ROUTES)
399
+ gated.set(`${r.method} ${r.path}`, r.disposition);
400
+ app.use("/api/*", async (c, next) => {
401
+ if (!isHostedMode())
402
+ return next();
403
+ const disposition = gated.get(`${c.req.method} ${c.req.path}`);
404
+ if (disposition) {
405
+ return c.json({ error: `route disabled in hosted mode (disposition: ${disposition})`, disposition }, 403);
406
+ }
407
+ return next();
408
+ });
409
+ }
322
410
  // ── UI session-cookie gate ───────────────────────────────────────────
323
411
  //
324
412
  // Closes the port-forward bypass: any client reaching localhost:3940
@@ -416,7 +504,13 @@ function registerHealthRoute(app, deps) {
416
504
  latest: cachedUpdate.disabled ? null : cachedUpdate.latest,
417
505
  behind: cachedUpdate.disabled ? false : cachedUpdate.behind,
418
506
  };
419
- return c.json({ status: "ok", service: "nlm-memory", version: pkg.version, warmup: warmupSnapshot(), embedding: laneHealthSnapshot(), embedInflight: inflightSnapshot(), corpus, update });
507
+ // "idle" (never started, or this daemon process's initial state — see
508
+ // job-supervisor.ts) reports as `job: null` rather than an idle block:
509
+ // an operator polling /api/health wants to know whether a run exists,
510
+ // not to distinguish "never run" from a five-field block of zeros.
511
+ const jobSnap = deps.jobSupervisor ? deps.jobSupervisor.snapshot() : null;
512
+ const job = jobSnap && jobSnap.state !== "idle" ? jobSnap : null;
513
+ return c.json({ status: "ok", service: "nlm-memory", version: pkg.version, warmup: warmupSnapshot(), embedding: laneHealthSnapshot(), embedInflight: inflightSnapshot(), corpus, update, job });
420
514
  });
421
515
  // Passive update poll for the UI. Same daily-cached check the CLI
422
516
  // startup banner uses — see src/core/update-check/check.ts for the
@@ -433,30 +527,33 @@ function registerHealthRoute(app, deps) {
433
527
  function registerMcpRoute(app, deps) {
434
528
  if (!deps.mcpDeps)
435
529
  return;
436
- const mcpToken = process.env["NLM_MCP_TOKEN"];
437
- if (!mcpToken) {
438
- throw new Error("NLM_MCP_TOKEN must be set when mcpDeps is provided — " +
530
+ if (!deps.teamTokens) {
531
+ throw new Error("teamTokens must be wired when mcpDeps is provided — " +
439
532
  "refusing to mount an unauthenticated /mcp endpoint");
440
533
  }
441
534
  const capturedMcpDeps = deps.mcpDeps;
535
+ const teamTokens = deps.teamTokens;
442
536
  app.all("/mcp", async (c) => {
443
537
  const auth = c.req.header("authorization") ?? "";
444
538
  const match = /^Bearer\s+(\S+)$/i.exec(auth);
445
- const given = Buffer.from(match?.[1] ?? "", "utf8");
446
- const want = Buffer.from(mcpToken, "utf8");
447
- if (!match || given.length !== want.length || !timingSafeEqual(given, want)) {
539
+ const resolved = await resolveTeamByToken(teamTokens, match?.[1]);
540
+ if (!resolved) {
448
541
  return c.json({ error: "unauthorized" }, 401);
449
542
  }
450
543
  // No sessionIdGenerator = stateless mode: no session ID in responses,
451
544
  // no session validation. Correct for per-request agent calls.
452
545
  const transport = new WebStandardStreamableHTTPServerTransport({});
453
- const server = createMcpServer(capturedMcpDeps);
546
+ const server = createMcpServer(capturedMcpDeps, resolved.teamId);
454
547
  await server.connect(transport);
455
548
  return transport.handleRequest(c.req.raw);
456
549
  });
457
550
  }
458
551
  function registerRecallRoutes(app, deps) {
459
552
  app.get("/api/recall", async (c) => {
553
+ // Composition point for this request (program spec §3 M3): the tenant
554
+ // the general gate resolved from the Bearer token / cookie / local-mode
555
+ // default, stashed on the Hono context by installLocalOnlyMiddleware.
556
+ const tenantId = c.get("tenantId");
460
557
  const q = c.req.query("q") ?? "";
461
558
  const entity = c.req.query("entity");
462
559
  const kind = c.req.query("kind");
@@ -500,6 +597,18 @@ function registerRecallRoutes(app, deps) {
500
597
  else if (source === "hook") {
501
598
  withRelatedFacts = true;
502
599
  }
600
+ // Learner evidence (spec 2026-10-06): `?withConcepts=true` (or hook
601
+ // source = automatic) attaches a relatedConcepts array. Same posture
602
+ // as facts; empty when no enrollments match, so non-learner callers
603
+ // see zero behavior change.
604
+ const conceptsParam = c.req.query("withConcepts");
605
+ let withRelatedConcepts;
606
+ if (conceptsParam !== undefined) {
607
+ withRelatedConcepts = conceptsParam === "true" || conceptsParam === "1";
608
+ }
609
+ else if (source === "hook") {
610
+ withRelatedConcepts = true;
611
+ }
503
612
  // Spec §E: `?withExemplars=true` attaches relatedExemplars (passive code exemplar recall).
504
613
  const withExemplars = c.req.query("withExemplars") === "true";
505
614
  // Task #303: superseded sessions are opt-in over HTTP and DEFAULT OFF.
@@ -515,12 +624,13 @@ function registerRecallRoutes(app, deps) {
515
624
  ...(kind !== undefined ? { kind: kind } : {}),
516
625
  ...(rewrite !== undefined ? { rewrite } : {}),
517
626
  ...(withRelatedFacts !== undefined ? { withRelatedFacts } : {}),
627
+ ...(withRelatedConcepts !== undefined ? { withRelatedConcepts } : {}),
518
628
  ...(withExemplars ? { withRelatedExemplars: true } : {}),
519
629
  ...(includeSuperseded !== undefined ? { includeSuperseded } : {}),
520
630
  };
521
- const result = await deps.recall.search(query);
631
+ const result = await deps.recall.search(tenantId, query);
522
632
  // Fire-and-forget telemetry — never blocks the response.
523
- void logQuery({
633
+ void logQuery(tenantId, {
524
634
  source,
525
635
  runtime,
526
636
  query: q || null,
@@ -535,22 +645,29 @@ function registerRecallRoutes(app, deps) {
535
645
  return c.json(result);
536
646
  });
537
647
  app.get("/api/recall/stats", async (c) => {
648
+ const tenantId = c.get("tenantId");
538
649
  const daysStr = c.req.query("days") ?? "7";
539
650
  const days = Number.parseInt(daysStr, 10);
540
651
  if (!Number.isFinite(days) || days < 1 || days > 365) {
541
652
  return c.json({ error: "days must be 1..365" }, 400);
542
653
  }
543
- const stats = await recallStats(days, ...(deps.queryLogPath !== undefined ? [deps.queryLogPath] : []));
654
+ const stats = await recallStats(tenantId, days, ...(deps.queryLogPath !== undefined ? [deps.queryLogPath] : []));
544
655
  return c.json(stats);
545
656
  });
546
657
  app.get("/api/recall/recent", (c) => {
547
- const limit = parseLimit(c.req.query("limit"), 50, 200);
548
- const entries = recentQueryLog(limit, ...(deps.queryLogPath !== undefined ? [deps.queryLogPath] : []));
658
+ const tenantId = c.get("tenantId");
659
+ // Cap raised from 200: consumers strip probe traffic client-side, so the
660
+ // window has to hold a full day of RAW entries. A host emitting ~800
661
+ // probes/day filled all 200 rows with probes inside 7h, and the digest
662
+ // reported "0 queries yesterday" on a day with 27 real ones (2026-08-13).
663
+ const limit = parseLimit(c.req.query("limit"), 50, 5000);
664
+ const entries = recentQueryLog(tenantId, limit, ...(deps.queryLogPath !== undefined ? [deps.queryLogPath] : []));
549
665
  return c.json({ entries });
550
666
  });
551
667
  // Citation events from the Stop hook. One POST per surfaced ID the
552
668
  // assistant cited in its response. Feeds the recall precision metric.
553
669
  app.post("/api/recall/cite-event", async (c) => {
670
+ const tenantId = c.get("tenantId");
554
671
  let body;
555
672
  try {
556
673
  body = (await c.req.json());
@@ -568,7 +685,7 @@ function registerRecallRoutes(app, deps) {
568
685
  }
569
686
  const responsePreview = body["response_preview"];
570
687
  const kind = body["kind"];
571
- await appendCitation({
688
+ await appendCitation(tenantId, {
572
689
  conversationId,
573
690
  citedId,
574
691
  ...(kind === "tool_use" || kind === "prose" ? { kind } : {}),
@@ -579,12 +696,13 @@ function registerRecallRoutes(app, deps) {
579
696
  return c.json({ ok: true });
580
697
  });
581
698
  app.get("/api/recall/cite-stats", async (c) => {
699
+ const tenantId = c.get("tenantId");
582
700
  const daysStr = c.req.query("days") ?? "7";
583
701
  const days = Number.parseInt(daysStr, 10);
584
702
  if (!Number.isFinite(days) || days < 1 || days > 365) {
585
703
  return c.json({ error: "days must be 1..365" }, 400);
586
704
  }
587
- const stats = await citationStats(days, ...(deps.citationLogPath !== undefined ? [deps.citationLogPath] : []));
705
+ const stats = await citationStats(tenantId, days, ...(deps.citationLogPath !== undefined ? [deps.citationLogPath] : []));
588
706
  return c.json(stats);
589
707
  });
590
708
  // Explicit citation from the cite_session MCP tool. One POST per session
@@ -592,6 +710,7 @@ function registerRecallRoutes(app, deps) {
592
710
  // so the training extractor can distinguish deterministic tool citations
593
711
  // from stop-hook detected prose citations.
594
712
  app.post("/api/citation/explicit", async (c) => {
713
+ const tenantId = c.get("tenantId");
595
714
  let body;
596
715
  try {
597
716
  body = (await c.req.json());
@@ -603,10 +722,10 @@ function registerRecallRoutes(app, deps) {
603
722
  if (typeof id !== "string" || !id) {
604
723
  return c.json({ error: "id required" }, 400);
605
724
  }
606
- await appendCitation({
725
+ await appendCitation(tenantId, {
607
726
  conversationId: typeof body["conversation_id"] === "string"
608
727
  ? body["conversation_id"]
609
- : (resolveConversationForSession(id) ?? "mcp_tool"),
728
+ : (resolveConversationForSession(tenantId, id) ?? "mcp_tool"),
610
729
  citedId: id,
611
730
  kind: "tool_use",
612
731
  ...(typeof body["reason"] === "string" ? { responsePreview: body["reason"] } : {}),
@@ -621,6 +740,7 @@ function registerHookRoutes(app) {
621
740
  // suppressed by stale "already surfaced" gates.
622
741
  // Payload: { conversation_id, transcript_path?, surfaced_set?, ts? }
623
742
  app.post("/api/hook/pre-compact", async (c) => {
743
+ const tenantId = c.get("tenantId");
624
744
  let body;
625
745
  try {
626
746
  body = (await c.req.json());
@@ -632,10 +752,18 @@ function registerHookRoutes(app) {
632
752
  if (typeof conversationId !== "string" || !conversationId) {
633
753
  return c.json({ error: "conversation_id required" }, 400);
634
754
  }
635
- const flushed = loadSurfaced(conversationId).size;
636
- clearSurfaced(conversationId);
755
+ const flushed = loadSurfaced(tenantId, conversationId).size;
756
+ clearSurfaced(tenantId, conversationId);
637
757
  const compactedAt = new Date().toISOString();
638
- const logPath = process.env["NLM_HOOK_LOG"] ?? join(homedir(), ".nlm", "hook-log.jsonl");
758
+ // Mirrors hook-log.ts's own path derivation (core/tenancy/tenant-state-path.ts):
759
+ // the default team's log honors NLM_HOOK_LOG; every other tenant is isolated
760
+ // under ~/.nlm/tenants/<tenantId>/hook-log.jsonl. This route writes a
761
+ // differently-shaped record (`kind: "pre-compact"`) than appendHookLog's
762
+ // HookLogEntry, so it can't reuse that function directly, but must still
763
+ // land in the same tenant-scoped file.
764
+ const logPath = tenantId === DEFAULT_TEAM_ID
765
+ ? (process.env["NLM_HOOK_LOG"] ?? tenantStatePath(tenantId, "hook-log.jsonl"))
766
+ : tenantStatePath(tenantId, "hook-log.jsonl");
639
767
  try {
640
768
  mkdirSync(dirname(logPath), { recursive: true });
641
769
  appendFileSync(logPath, `${JSON.stringify({ ts: compactedAt, kind: "pre-compact", conversationId, flushed })}\n`, "utf8");
@@ -679,7 +807,8 @@ function registerHermesAgentHookRoutes(app, deps) {
679
807
  return c.json({ context: null });
680
808
  }
681
809
  try {
682
- const result = await deps.recall.search({
810
+ const tenantId = c.get("tenantId");
811
+ const result = await deps.recall.search(tenantId, {
683
812
  query: userMessage,
684
813
  mode: "keyword",
685
814
  limit: 5,
@@ -692,7 +821,7 @@ function registerHermesAgentHookRoutes(app, deps) {
692
821
  startedAt: r.startedAt,
693
822
  matchScore: r.matchScore,
694
823
  }));
695
- const surfaced = loadSurfaced(sessionId);
824
+ const surfaced = loadSurfaced(tenantId, sessionId);
696
825
  const selected = selectHits({ hits, surfaced, scoreThreshold: 0, relativeFloor: HERMES_RELATIVE_FLOOR, perFireCap: 3, perConversationCap: 10 });
697
826
  if (selected.length === 0 &&
698
827
  (result.relatedFacts ?? []).length === 0 &&
@@ -700,7 +829,7 @@ function registerHermesAgentHookRoutes(app, deps) {
700
829
  return c.json({ context: null });
701
830
  }
702
831
  if (selected.length > 0) {
703
- recordSurfaced(sessionId, selected.map((h) => h.id));
832
+ recordSurfaced(tenantId, sessionId, selected.map((h) => h.id));
704
833
  }
705
834
  return c.json({
706
835
  context: formatPointerBlock(selected, result.relatedFacts ?? [], (result.relatedExemplars ?? []).map((e) => ({
@@ -715,6 +844,7 @@ function registerHermesAgentHookRoutes(app, deps) {
715
844
  // post-turn: scan assistant_response for session IDs that were surfaced in
716
845
  // this conversation and log prose citation events.
717
846
  app.post("/api/hook/hermes-agent/post-turn", async (c) => {
847
+ const tenantId = c.get("tenantId");
718
848
  let body;
719
849
  try {
720
850
  body = (await c.req.json());
@@ -730,7 +860,7 @@ function registerHermesAgentHookRoutes(app, deps) {
730
860
  if (typeof assistantResponse !== "string" || !assistantResponse) {
731
861
  return c.json({ ok: true, cited: 0 });
732
862
  }
733
- const surfacedIds = [...loadSurfaced(sessionId)];
863
+ const surfacedIds = [...loadSurfaced(tenantId, sessionId)];
734
864
  const cited = [];
735
865
  for (const id of surfacedIds) {
736
866
  if (assistantResponse.includes(id))
@@ -738,13 +868,14 @@ function registerHermesAgentHookRoutes(app, deps) {
738
868
  }
739
869
  const preview = assistantResponse.slice(0, 200);
740
870
  for (const citedId of cited) {
741
- await appendCitation({ conversationId: sessionId, citedId, kind: "prose", responsePreview: preview }, ...(deps.citationLogPath !== undefined ? [deps.citationLogPath] : []));
871
+ await appendCitation(tenantId, { conversationId: sessionId, citedId, kind: "prose", responsePreview: preview }, ...(deps.citationLogPath !== undefined ? [deps.citationLogPath] : []));
742
872
  }
743
873
  return c.json({ ok: true, cited: cited.length });
744
874
  });
745
875
  // session-lifecycle: memo housekeeping for on_session_{start,end,finalize,reset}.
746
876
  // start is a no-op (memo is created lazily). end/finalize/reset clear the memo.
747
877
  app.post("/api/hook/hermes-agent/session-lifecycle", async (c) => {
878
+ const tenantId = c.get("tenantId");
748
879
  let body;
749
880
  try {
750
881
  body = (await c.req.json());
@@ -759,8 +890,8 @@ function registerHermesAgentHookRoutes(app, deps) {
759
890
  if (event !== "start") {
760
891
  const sessionId = body["session_id"];
761
892
  if (typeof sessionId === "string" && sessionId) {
762
- clearSurfaced(sessionId);
763
- clearCited(sessionId);
893
+ clearSurfaced(tenantId, sessionId);
894
+ clearCited(tenantId, sessionId);
764
895
  }
765
896
  }
766
897
  return c.json({ ok: true, event });
@@ -772,6 +903,7 @@ function registerFactRoutes(app, deps) {
772
903
  if (!deps.factRecall) {
773
904
  return c.json({ error: "fact recall not wired in this deployment" }, 503);
774
905
  }
906
+ const tenantId = c.get("tenantId");
775
907
  const q = c.req.query("q") ?? "";
776
908
  const subject = c.req.query("subject");
777
909
  const predicate = c.req.query("predicate");
@@ -807,10 +939,10 @@ function registerFactRoutes(app, deps) {
807
939
  ...(kind !== undefined ? { kind: kind } : {}),
808
940
  ...(minConfidence !== undefined ? { minConfidence } : {}),
809
941
  };
810
- const result = await deps.factRecall.search(query);
942
+ const result = await deps.factRecall.search(tenantId, query);
811
943
  const source = c.req.header("x-recall-source") ?? "http";
812
944
  const runtime = c.req.header("x-recall-runtime") ?? null;
813
- void logFactQuery({
945
+ void logFactQuery(tenantId, {
814
946
  source,
815
947
  runtime,
816
948
  query: q || null,
@@ -837,31 +969,40 @@ function registerFactRoutes(app, deps) {
837
969
  return c.json({ error: "subject is required" }, 400);
838
970
  }
839
971
  const predicate = c.req.query("predicate");
840
- const chains = await deps.factStore.getHistory(subject, predicate);
972
+ const tenantId = c.get("tenantId");
973
+ const chains = await deps.factStore.getHistory(tenantId, subject, predicate);
841
974
  return c.json({ subject, predicate: predicate ?? null, chains });
842
975
  });
843
976
  app.get("/api/recall/facts/stats", async (c) => {
977
+ const tenantId = c.get("tenantId");
844
978
  const daysStr = c.req.query("days") ?? "7";
845
979
  const days = Number.parseInt(daysStr, 10);
846
980
  if (!Number.isFinite(days) || days < 1 || days > 365) {
847
981
  return c.json({ error: "days must be 1..365" }, 400);
848
982
  }
849
- const stats = await factRecallStats(days, ...(deps.factQueryLogPath !== undefined ? [deps.factQueryLogPath] : []));
983
+ const stats = await factRecallStats(tenantId, days, ...(deps.factQueryLogPath !== undefined ? [deps.factQueryLogPath] : []));
850
984
  return c.json(stats);
851
985
  });
852
986
  }
853
987
  function registerLiveRoutes(app, deps) {
854
- app.get("/api/live/recent-writes", (c) => {
988
+ // Pre-existing bug fixed in passing (C2): PgSessionStore's recentWrites/
989
+ // recentMarkers are async (return a Promise), but SqliteSessionStore's are
990
+ // synchronous — these handlers must be async + await the call so pg
991
+ // callers get the resolved array, not an unresolved Promise serialized as
992
+ // JSON.
993
+ app.get("/api/live/recent-writes", async (c) => {
855
994
  if (!deps.liveStore)
856
995
  return c.json({ writes: [] });
996
+ const tenantId = c.get("tenantId");
857
997
  const limit = parseLimit(c.req.query("limit"), 50, 200);
858
- return c.json({ writes: deps.liveStore.recentWrites(limit) });
998
+ return c.json({ writes: await deps.liveStore.recentWrites(tenantId, limit) });
859
999
  });
860
- app.get("/api/live/recent-markers", (c) => {
1000
+ app.get("/api/live/recent-markers", async (c) => {
861
1001
  if (!deps.liveStore)
862
1002
  return c.json({ markers: [] });
1003
+ const tenantId = c.get("tenantId");
863
1004
  const limit = parseLimit(c.req.query("limit"), 50, 200);
864
- return c.json({ markers: deps.liveStore.recentMarkers(limit) });
1005
+ return c.json({ markers: await deps.liveStore.recentMarkers(tenantId, limit) });
865
1006
  });
866
1007
  }
867
1008
  function registerDatasetRoute(app, deps) {
@@ -869,7 +1010,7 @@ function registerDatasetRoute(app, deps) {
869
1010
  if (!deps.dbPath)
870
1011
  return c.json({ error: "dataset endpoint requires dbPath" }, 503);
871
1012
  const includePaths = c.req.query("include_paths") === "true";
872
- return c.json(buildDataset(deps.dbPath, { includePaths }));
1013
+ return c.json(buildDataset(deps.dbPath, DEFAULT_TEAM_ID, { includePaths }));
873
1014
  });
874
1015
  }
875
1016
  // ── Data management ─────────────────────────────────────────────
@@ -980,9 +1121,10 @@ function registerActionRoutes(app, deps) {
980
1121
  if (!parsed)
981
1122
  return c.json({ error: "invalid action payload" }, 400);
982
1123
  const store = deps.liveStore;
1124
+ const tenantId = c.get("tenantId");
983
1125
  const id = store instanceof PgSessionStore
984
- ? await writeActionPg(store.pool, parsed)
985
- : writeAction(store.rawDb(), parsed);
1126
+ ? await writeActionPg(store.pool, tenantId, parsed)
1127
+ : writeAction(store.rawDb(), tenantId, parsed);
986
1128
  store.invalidateOverlayCache();
987
1129
  return c.json({ id, timestamp: new Date().toISOString() });
988
1130
  });
@@ -998,9 +1140,10 @@ function registerActionRoutes(app, deps) {
998
1140
  if (inputs.length === 0)
999
1141
  return c.json({ accepted: 0, ids: [] });
1000
1142
  const store = deps.liveStore;
1143
+ const tenantId = c.get("tenantId");
1001
1144
  const ids = store instanceof PgSessionStore
1002
- ? await writeActionsBatchPg(store.pool, inputs)
1003
- : writeActionsBatch(store.rawDb(), inputs);
1145
+ ? await writeActionsBatchPg(store.pool, tenantId, inputs)
1146
+ : writeActionsBatch(store.rawDb(), tenantId, inputs);
1004
1147
  store.invalidateOverlayCache();
1005
1148
  return c.json({ accepted: ids.length, ids });
1006
1149
  });
@@ -1008,9 +1151,10 @@ function registerActionRoutes(app, deps) {
1008
1151
  if (!deps.liveStore)
1009
1152
  return c.json({ error: "actions require liveStore" }, 503);
1010
1153
  const store = deps.liveStore;
1154
+ const tenantId = c.get("tenantId");
1011
1155
  const result = store instanceof PgSessionStore
1012
- ? await undoActionPg(store.pool, c.req.param("id"))
1013
- : undoAction(store.rawDb(), c.req.param("id"));
1156
+ ? await undoActionPg(store.pool, tenantId, c.req.param("id"))
1157
+ : undoAction(store.rawDb(), tenantId, c.req.param("id"));
1014
1158
  if (!result)
1015
1159
  return c.json({ error: "action not found or already undone" }, 404);
1016
1160
  store.invalidateOverlayCache();
@@ -1029,9 +1173,10 @@ function registerActionRoutes(app, deps) {
1029
1173
  ...(kind ? { kind } : {}),
1030
1174
  };
1031
1175
  const store = deps.liveStore;
1176
+ const tenantId = c.get("tenantId");
1032
1177
  const rows = store instanceof PgSessionStore
1033
- ? await listActionsPg(store.pool, opts)
1034
- : listActions(store.rawDb(), opts);
1178
+ ? await listActionsPg(store.pool, tenantId, opts)
1179
+ : listActions(store.rawDb(), tenantId, opts);
1035
1180
  return c.json({ actions: rows });
1036
1181
  });
1037
1182
  }
@@ -1039,13 +1184,18 @@ function registerClassifierRoutes(app, deps) {
1039
1184
  app.get("/api/classifier/info", (c) => {
1040
1185
  const provider = deps.classifier?.provider ?? "ollama";
1041
1186
  const model = deps.classifier?.model ?? "qwen3.5:4b";
1187
+ const openAiSwapAvailable = deps.classifier?.openAiSwapAvailable ?? false;
1042
1188
  return c.json({
1043
1189
  provider,
1044
1190
  model,
1045
- available_providers: ["deepseek", "ollama"],
1191
+ available_providers: openAiSwapAvailable
1192
+ ? ["deepseek", "ollama", "openai"]
1193
+ : ["deepseek", "ollama"],
1194
+ openai_swap_available: openAiSwapAvailable,
1046
1195
  env_present: {
1047
1196
  deepseek: Boolean(process.env["DEEPSEEK_API_KEY"]),
1048
1197
  ollama: true,
1198
+ openai: openAiSwapAvailable,
1049
1199
  },
1050
1200
  default_models: {
1051
1201
  deepseek: ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat"],
@@ -1060,8 +1210,15 @@ function registerClassifierRoutes(app, deps) {
1060
1210
  const body = (await c.req.json().catch(() => null));
1061
1211
  const provider = body?.provider;
1062
1212
  const model = body?.model;
1063
- if (provider !== "deepseek" && provider !== "ollama") {
1064
- return c.json({ error: "runtime swap supports 'deepseek' or 'ollama'; 'openai' is set via NLM_CLASSIFIER env + restart" }, 400);
1213
+ // "openai-compatible" (the Providers-registry kind for local/self-hosted
1214
+ // endpoints like LM Studio) and "openai" (the registry's real-OpenAI kind)
1215
+ // both ride ClassifierBox's single "openai" ClassifierProvider — both are
1216
+ // just DeepSeekClient against a baseUrl. That baseUrl is fixed at daemon
1217
+ // startup (NLM_CLASSIFIER_BASE_URL), so this swap is model selection
1218
+ // only, never endpoint selection.
1219
+ const isOpenAiKind = provider === "openai" || provider === "openai-compatible";
1220
+ if (provider !== "deepseek" && provider !== "ollama" && !isOpenAiKind) {
1221
+ return c.json({ error: "runtime swap supports 'deepseek', 'ollama', 'openai', or 'openai-compatible'" }, 400);
1065
1222
  }
1066
1223
  if (!model || typeof model !== "string" || model.length === 0) {
1067
1224
  return c.json({ error: "model is required" }, 400);
@@ -1069,7 +1226,14 @@ function registerClassifierRoutes(app, deps) {
1069
1226
  if (provider === "deepseek" && !process.env["DEEPSEEK_API_KEY"]) {
1070
1227
  return c.json({ error: "DEEPSEEK_API_KEY not set — cannot swap to deepseek" }, 400);
1071
1228
  }
1072
- deps.classifier.swap(provider, model);
1229
+ if (isOpenAiKind && !deps.classifier.openAiSwapAvailable) {
1230
+ return c.json({
1231
+ error: "provider 'openai' has no baseUrl configured — set NLM_CLASSIFIER=openai and " +
1232
+ "NLM_CLASSIFIER_BASE_URL, then restart the daemon before swapping models on it",
1233
+ }, 400);
1234
+ }
1235
+ const classifierProvider = isOpenAiKind ? "openai" : provider;
1236
+ deps.classifier.swap(classifierProvider, model);
1073
1237
  return c.json({ provider: deps.classifier.provider, model: deps.classifier.model });
1074
1238
  });
1075
1239
  }
@@ -1080,7 +1244,8 @@ function registerSourceRoutes(app, deps) {
1080
1244
  app.get("/api/sources", async (c) => {
1081
1245
  if (!deps.sources)
1082
1246
  return c.json({ sources: [] });
1083
- return c.json({ sources: await deps.sources.list() });
1247
+ const tenantId = c.get("tenantId");
1248
+ return c.json({ sources: await deps.sources.list(tenantId) });
1084
1249
  });
1085
1250
  app.post("/api/sources", async (c) => {
1086
1251
  if (!deps.sources)
@@ -1089,10 +1254,11 @@ function registerSourceRoutes(app, deps) {
1089
1254
  const parsed = parseSourceInsert(body);
1090
1255
  if (!parsed)
1091
1256
  return c.json({ error: "invalid source payload" }, 400);
1092
- if (await deps.sources.getByName(parsed.name)) {
1257
+ const tenantId = c.get("tenantId");
1258
+ if (await deps.sources.getByName(tenantId, parsed.name)) {
1093
1259
  return c.json({ error: `source named '${parsed.name}' already exists` }, 409);
1094
1260
  }
1095
- return c.json(await deps.sources.insert(parsed), 201);
1261
+ return c.json(await deps.sources.insert(tenantId, parsed), 201);
1096
1262
  });
1097
1263
  app.patch("/api/sources/:id", async (c) => {
1098
1264
  if (!deps.sources)
@@ -1104,7 +1270,8 @@ function registerSourceRoutes(app, deps) {
1104
1270
  const patch = parseSourceUpdate(body);
1105
1271
  if (!patch)
1106
1272
  return c.json({ error: "invalid patch payload" }, 400);
1107
- const updated = await deps.sources.update(id, patch);
1273
+ const tenantId = c.get("tenantId");
1274
+ const updated = await deps.sources.update(tenantId, id, patch);
1108
1275
  if (!updated)
1109
1276
  return c.json({ error: `source ${id} not found` }, 404);
1110
1277
  return c.json(updated);
@@ -1115,7 +1282,8 @@ function registerSourceRoutes(app, deps) {
1115
1282
  const id = Number.parseInt(c.req.param("id"), 10);
1116
1283
  if (!Number.isFinite(id))
1117
1284
  return c.json({ error: "invalid id" }, 400);
1118
- const ok = await deps.sources.delete(id);
1285
+ const tenantId = c.get("tenantId");
1286
+ const ok = await deps.sources.delete(tenantId, id);
1119
1287
  if (!ok)
1120
1288
  return c.json({ error: `source ${id} not found` }, 404);
1121
1289
  return c.json({ deleted: id });
@@ -1126,7 +1294,8 @@ function registerSourceRoutes(app, deps) {
1126
1294
  const id = Number.parseInt(c.req.param("id"), 10);
1127
1295
  if (!Number.isFinite(id))
1128
1296
  return c.json({ error: "invalid id" }, 400);
1129
- const token = await deps.sources.regenerateToken(id);
1297
+ const tenantId = c.get("tenantId");
1298
+ const token = await deps.sources.regenerateToken(tenantId, id);
1130
1299
  if (!token)
1131
1300
  return c.json({ error: "regenerate-token only applies to webhook sources" }, 400);
1132
1301
  return c.json({ token });
@@ -1167,7 +1336,11 @@ function registerIngestRoute(app, deps) {
1167
1336
  sourceId: source.id,
1168
1337
  };
1169
1338
  const ingest = deps.ingest;
1170
- void ingestSession(input, ingest).catch((e) => {
1339
+ // M4 (spec §3/§8): the ingested session is stamped with the resolved
1340
+ // source row's own tenant_id, not the caller's general-gate tenantId —
1341
+ // ingest auth is a distinct credential (the source's webhook token)
1342
+ // from the team_tokens Bearer auth the rest of /api/* uses.
1343
+ void ingestSession(input, ingest, source.tenantId).catch((e) => {
1171
1344
  const msg = e instanceof Error ? e.message : String(e);
1172
1345
  console.error(`[ingest] background failure for ${id}: ${msg}`);
1173
1346
  });
@@ -1181,7 +1354,8 @@ function registerProviderRoutes(app, deps) {
1181
1354
  app.get("/api/providers", async (c) => {
1182
1355
  if (!deps.providers)
1183
1356
  return c.json({ providers: [] });
1184
- return c.json({ providers: await deps.providers.list() });
1357
+ const tenantId = c.get("tenantId");
1358
+ return c.json({ providers: await deps.providers.list(tenantId) });
1185
1359
  });
1186
1360
  app.post("/api/providers", async (c) => {
1187
1361
  if (!deps.providers)
@@ -1190,10 +1364,11 @@ function registerProviderRoutes(app, deps) {
1190
1364
  const parsed = parseProviderInsert(body);
1191
1365
  if (!parsed)
1192
1366
  return c.json({ error: "invalid provider payload" }, 400);
1193
- if (await deps.providers.getByName(parsed.name)) {
1367
+ const tenantId = c.get("tenantId");
1368
+ if (await deps.providers.getByName(tenantId, parsed.name)) {
1194
1369
  return c.json({ error: `provider named '${parsed.name}' already exists` }, 409);
1195
1370
  }
1196
- return c.json(await deps.providers.insert(parsed), 201);
1371
+ return c.json(await deps.providers.insert(tenantId, parsed), 201);
1197
1372
  });
1198
1373
  app.patch("/api/providers/:id", async (c) => {
1199
1374
  if (!deps.providers)
@@ -1205,7 +1380,8 @@ function registerProviderRoutes(app, deps) {
1205
1380
  const patch = parseProviderUpdate(body);
1206
1381
  if (!patch)
1207
1382
  return c.json({ error: "invalid patch payload" }, 400);
1208
- const updated = await deps.providers.update(id, patch);
1383
+ const tenantId = c.get("tenantId");
1384
+ const updated = await deps.providers.update(tenantId, id, patch);
1209
1385
  if (!updated)
1210
1386
  return c.json({ error: `provider ${id} not found` }, 404);
1211
1387
  return c.json(updated);
@@ -1216,7 +1392,8 @@ function registerProviderRoutes(app, deps) {
1216
1392
  const id = Number.parseInt(c.req.param("id"), 10);
1217
1393
  if (!Number.isFinite(id))
1218
1394
  return c.json({ error: "invalid id" }, 400);
1219
- const ok = await deps.providers.delete(id);
1395
+ const tenantId = c.get("tenantId");
1396
+ const ok = await deps.providers.delete(tenantId, id);
1220
1397
  if (!ok)
1221
1398
  return c.json({ error: `provider ${id} not found` }, 404);
1222
1399
  return c.json({ deleted: id });
@@ -1227,10 +1404,11 @@ function registerProviderRoutes(app, deps) {
1227
1404
  const id = Number.parseInt(c.req.param("id"), 10);
1228
1405
  if (!Number.isFinite(id))
1229
1406
  return c.json({ error: "invalid id" }, 400);
1230
- const provider = await deps.providers.get(id);
1407
+ const tenantId = c.get("tenantId");
1408
+ const provider = await deps.providers.get(tenantId, id);
1231
1409
  if (!provider)
1232
1410
  return c.json({ error: `provider ${id} not found` }, 404);
1233
- const key = await deps.providers.getSecret(id);
1411
+ const key = await deps.providers.getSecret(tenantId, id);
1234
1412
  try {
1235
1413
  const models = await listModels(provider, { apiKey: key });
1236
1414
  return c.json({ models });
@@ -1246,10 +1424,11 @@ function registerProviderRoutes(app, deps) {
1246
1424
  const id = Number.parseInt(c.req.param("id"), 10);
1247
1425
  if (!Number.isFinite(id))
1248
1426
  return c.json({ error: "invalid id" }, 400);
1249
- const provider = await deps.providers.get(id);
1427
+ const tenantId = c.get("tenantId");
1428
+ const provider = await deps.providers.get(tenantId, id);
1250
1429
  if (!provider)
1251
1430
  return c.json({ error: `provider ${id} not found` }, 404);
1252
- const key = await deps.providers.getSecret(id);
1431
+ const key = await deps.providers.getSecret(tenantId, id);
1253
1432
  const startedAt = Date.now();
1254
1433
  try {
1255
1434
  const models = await listModels(provider, { apiKey: key });
@@ -1268,17 +1447,44 @@ function registerProviderRoutes(app, deps) {
1268
1447
  function registerSessionRoute(app, deps) {
1269
1448
  app.get("/api/session/:id", async (c) => {
1270
1449
  const id = c.req.param("id");
1271
- const session = await deps.store.getById(id);
1450
+ const tenantId = c.get("tenantId");
1451
+ const session = await deps.store.getById(tenantId, id);
1272
1452
  if (!session) {
1273
1453
  return c.json({ error: `session ${id} not found` }, 404);
1274
1454
  }
1275
1455
  return c.json(session);
1276
1456
  });
1457
+ // Learner strip data for the session drawer (spec 2026-10-06 M5): concept
1458
+ // rows for enrollments bound to this session's workstream, reusing the
1459
+ // same picker the recall hook uses. Always 200 with possibly-empty arrays
1460
+ // once the session resolves — the UI hides the section when empty.
1461
+ app.get("/api/learner/by-session/:id", async (c) => {
1462
+ const tenantId = c.get("tenantId");
1463
+ if (!deps.learnerRecall) {
1464
+ return c.json({ error: "learner recall not wired" }, 404);
1465
+ }
1466
+ const id = c.req.param("id");
1467
+ const session = await deps.store.getById(tenantId, id);
1468
+ if (!session) {
1469
+ return c.json({ error: `session ${id} not found` }, 404);
1470
+ }
1471
+ const concepts = await pickRelatedConcepts(tenantId, [{ id }], deps.learnerRecall);
1472
+ const wsMap = await deps.learnerRecall.sessions.getWorkstreamIds(tenantId, [id]);
1473
+ const wsId = wsMap.get(id);
1474
+ const enrollments = wsId
1475
+ ? await deps.learnerRecall.learners.listEnrollmentsByWorkstreams(tenantId, [wsId])
1476
+ : [];
1477
+ return c.json({
1478
+ enrollments: enrollments.map((e) => ({ learnerId: e.learnerId, courseId: e.courseId, status: e.status })),
1479
+ concepts,
1480
+ });
1481
+ });
1277
1482
  // Post-hoc supersedence write path. Mirrors the `mark_superseded` MCP tool
1278
1483
  // so UI + CLI + MCP all share one backend gesture. Path param is the
1279
1484
  // predecessor (the session being retired); body names the successor.
1280
1485
  app.post("/api/session/:id/supersede", async (c) => {
1281
1486
  const predecessorId = c.req.param("id");
1487
+ const tenantId = c.get("tenantId");
1282
1488
  let body;
1283
1489
  try {
1284
1490
  body = await c.req.json();
@@ -1299,7 +1505,7 @@ function registerSessionRoute(app, deps) {
1299
1505
  return c.json({ error: "reason must be a string if provided" }, 400);
1300
1506
  }
1301
1507
  try {
1302
- await deps.store.markSuperseded(predecessorId, successorId);
1508
+ await deps.store.markSuperseded(tenantId, predecessorId, successorId);
1303
1509
  }
1304
1510
  catch (e) {
1305
1511
  const msg = e instanceof Error ? e.message : String(e);
@@ -1308,7 +1514,7 @@ function registerSessionRoute(app, deps) {
1308
1514
  : 500;
1309
1515
  return c.json({ error: msg }, status);
1310
1516
  }
1311
- void appendSupersedence({
1517
+ void appendSupersedence(tenantId, {
1312
1518
  predecessorId,
1313
1519
  successorId,
1314
1520
  source: c.req.header("x-supersedence-source") ?? "http",
@@ -1343,7 +1549,8 @@ function parseActionInput(raw) {
1343
1549
  };
1344
1550
  }
1345
1551
  const VALID_SOURCE_KINDS = [
1346
- "claude-code", "codex", "hermes", "pi", "jsonl-generic", "webhook",
1552
+ "claude-code", "codex", "hermes", "hermes-agent", "muse", "aider", "cursor",
1553
+ "windsurf", "opencode", "pi", "jsonl-generic", "webhook",
1347
1554
  ];
1348
1555
  function parseSourceInsert(raw) {
1349
1556
  if (!raw || typeof raw !== "object")
@@ -1479,12 +1686,13 @@ function registerSignalRoutes(app, deps) {
1479
1686
  return c.json({ error: e instanceof Error ? e.message : "invalid signal" }, 400);
1480
1687
  }
1481
1688
  const rawBody = body;
1482
- signal = await stampSignalScope(signal, {
1689
+ const tenantId = c.get("tenantId");
1690
+ signal = await stampSignalScope(signal, tenantId, {
1483
1691
  repoPath: typeof rawBody["repo_path"] === "string" ? rawBody["repo_path"] : null,
1484
1692
  sessionScopeReader: deps.sessionScopeReader,
1485
1693
  });
1486
1694
  try {
1487
- await deps.signalStore.insert(signal);
1695
+ await deps.signalStore.insert(tenantId, signal);
1488
1696
  }
1489
1697
  catch {
1490
1698
  return c.json({ error: "signal insert failed" }, 500);
@@ -1500,11 +1708,11 @@ function registerSignalRoutes(app, deps) {
1500
1708
  try {
1501
1709
  const exemplar = extractExemplar(signal, { installScope: deps.installScope });
1502
1710
  if (exemplar) {
1503
- const { id, skipped } = await exemplarStore.insert(exemplar);
1711
+ const { id, skipped } = await exemplarStore.insert(tenantId, exemplar);
1504
1712
  if (!skipped && deps.codeEmbedder) {
1505
1713
  void deps.codeEmbedder
1506
1714
  .embed(composeEmbedText(exemplar.taskContext, exemplar.code), "document")
1507
- .then((r) => exemplarStore.upsertEmbedding(id, r.vector))
1715
+ .then((r) => exemplarStore.upsertEmbedding(tenantId, id, r.vector))
1508
1716
  .catch(() => { });
1509
1717
  }
1510
1718
  }
@@ -1521,7 +1729,8 @@ function registerSignalRoutes(app, deps) {
1521
1729
  if (!repo)
1522
1730
  return c.json({ error: "repo is required" }, 400);
1523
1731
  const model = c.req.query("model");
1524
- const block = await buildFailureModeBlock(deps.signalStore, { installScope: deps.installScope, repo, ...(model ? { model } : {}) });
1732
+ const tenantId = c.get("tenantId");
1733
+ const block = await buildFailureModeBlock(tenantId, deps.signalStore, { installScope: deps.installScope, repo, ...(model ? { model } : {}) });
1525
1734
  return c.json({ repo, model: model ?? null, block });
1526
1735
  });
1527
1736
  app.get("/api/signals/stats", async (c) => {
@@ -1534,7 +1743,8 @@ function registerSignalRoutes(app, deps) {
1534
1743
  return c.json({ error: "days must be 1..365" }, 400);
1535
1744
  }
1536
1745
  const sinceTs = new Date(Date.now() - days * 86_400_000).toISOString();
1537
- const rows = await deps.signalStore.listForAggregation({ installScope: deps.installScope, sinceTs });
1746
+ const tenantId = c.get("tenantId");
1747
+ const rows = await deps.signalStore.listForAggregation(tenantId, { installScope: deps.installScope, sinceTs });
1538
1748
  const modes = aggregateFailureModes(rows);
1539
1749
  return c.json({ days, total: rows.length, modes });
1540
1750
  });
@@ -1564,11 +1774,12 @@ function registerSignalRoutes(app, deps) {
1564
1774
  return c.json({ error: e instanceof Error ? e.message : "invalid exemplar" }, 400);
1565
1775
  }
1566
1776
  inp = { ...inp, scope: scopeStampEnabled() ? inp.scope : null };
1567
- const { id, skipped } = await deps.exemplarStore.insert(inp);
1777
+ const tenantId = c.get("tenantId");
1778
+ const { id, skipped } = await deps.exemplarStore.insert(tenantId, inp);
1568
1779
  if (!skipped && deps.codeEmbedder) {
1569
1780
  // Best-effort embedding: fire-and-forget, never blocks the response.
1570
1781
  deps.codeEmbedder.embed(composeEmbedText(inp.taskContext, inp.code), "document")
1571
- .then((r) => deps.exemplarStore.upsertEmbedding(id, r.vector))
1782
+ .then((r) => deps.exemplarStore.upsertEmbedding(tenantId, id, r.vector))
1572
1783
  .catch(() => { });
1573
1784
  }
1574
1785
  return c.json({ id, skipped, status: "accepted" }, 202);
@@ -1596,7 +1807,8 @@ function registerSignalRoutes(app, deps) {
1596
1807
  if (patch.retired === undefined && patch.outcome === undefined) {
1597
1808
  return c.json({ error: "provide retire (boolean) and/or outcome" }, 400);
1598
1809
  }
1599
- const result = await deps.exemplarStore.setVerdict(c.req.param("id"), patch, "human");
1810
+ const tenantId = c.get("tenantId");
1811
+ const result = await deps.exemplarStore.setVerdict(tenantId, c.req.param("id"), patch, "human");
1600
1812
  if (result.status === "not_found")
1601
1813
  return c.json({ error: "exemplar not found" }, 404);
1602
1814
  return c.json({ id: c.req.param("id"), status: result.status }, 200);
@@ -1617,7 +1829,8 @@ function registerSignalRoutes(app, deps) {
1617
1829
  const lang = c.req.query("lang");
1618
1830
  const model = c.req.query("model");
1619
1831
  const includeNegatives = c.req.query("negatives") !== "0";
1620
- const result = await recallCode({
1832
+ const tenantId = c.get("tenantId");
1833
+ const result = await recallCode(tenantId, {
1621
1834
  query,
1622
1835
  installScope: deps.installScope,
1623
1836
  ...(repo ? { repo } : {}),
@@ -1629,6 +1842,52 @@ function registerSignalRoutes(app, deps) {
1629
1842
  return c.json(result);
1630
1843
  });
1631
1844
  }
1845
+ // ── Reprocess job supervision (single operator process, LOCAL disposition) ──
1846
+ // Thin HTTP wrapping over JobSupervisor (core/jobs/job-supervisor.ts).
1847
+ // start() is synchronous and throws BY DESIGN both when a run is already
1848
+ // active and when the injected spawnChild adapter itself fails to launch
1849
+ // the child — this route is the required catch point so neither case ever
1850
+ // crashes the daemon (binding note from Task 1's review).
1851
+ function registerJobRoutes(app, deps) {
1852
+ app.post("/api/jobs/reprocess", async (c) => {
1853
+ if (!deps.jobSupervisor)
1854
+ return c.json({ error: "job supervisor not wired in this deployment" }, 503);
1855
+ let body = null;
1856
+ try {
1857
+ const text = await c.req.text();
1858
+ body = text ? JSON.parse(text) : null;
1859
+ }
1860
+ catch {
1861
+ return c.json({ error: "body must be JSON" }, 400);
1862
+ }
1863
+ const rawArgs = body?.args;
1864
+ if (rawArgs !== undefined && (!Array.isArray(rawArgs) || !rawArgs.every((a) => typeof a === "string"))) {
1865
+ return c.json({ error: "args must be an array of strings" }, 400);
1866
+ }
1867
+ // Passed straight through as argv entries by the real spawnChild
1868
+ // adapter (never through a shell) — no interpolation risk here either
1869
+ // way, but validating shape (not content) keeps this a passthrough,
1870
+ // exactly as the reprocess CLI itself validates its own flags.
1871
+ const args = rawArgs ?? [];
1872
+ try {
1873
+ deps.jobSupervisor.start(args);
1874
+ }
1875
+ catch (e) {
1876
+ const message = e instanceof Error ? e.message : String(e);
1877
+ if (message === "reprocess job already active") {
1878
+ return c.json({ error: message, snapshot: deps.jobSupervisor.snapshot() }, 409);
1879
+ }
1880
+ return c.json({ error: message }, 500);
1881
+ }
1882
+ return c.json({ ok: true, snapshot: deps.jobSupervisor.snapshot() }, 202);
1883
+ });
1884
+ app.delete("/api/jobs/reprocess", (c) => {
1885
+ if (!deps.jobSupervisor)
1886
+ return c.json({ error: "job supervisor not wired in this deployment" }, 503);
1887
+ deps.jobSupervisor.stop();
1888
+ return c.json({ ok: true, snapshot: deps.jobSupervisor.snapshot() }, 200);
1889
+ });
1890
+ }
1632
1891
  function mountSpa(app, dist) {
1633
1892
  const indexHtml = join(dist, "index.html");
1634
1893
  if (!existsSync(indexHtml))