bikky 0.3.13 → 0.4.1

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 (362) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CODE_OF_CONDUCT.md +80 -0
  3. package/CONTRIBUTING.md +206 -0
  4. package/README.md +94 -20
  5. package/SECURITY.md +58 -0
  6. package/SUPPORT.md +22 -0
  7. package/dist/config.d.ts +68 -1
  8. package/dist/config.js +197 -4
  9. package/dist/daemon/extraction.d.ts +12 -2
  10. package/dist/daemon/extraction.js +85 -133
  11. package/dist/daemon/loop.js +15 -1
  12. package/dist/daemon/qdrant.js +0 -1
  13. package/dist/daemon/transcript-sources.d.ts +26 -0
  14. package/dist/daemon/transcript-sources.js +193 -0
  15. package/dist/daemon/watcher.d.ts +3 -2
  16. package/dist/daemon/watcher.js +51 -2
  17. package/dist/install.d.ts +9 -1
  18. package/dist/install.js +62 -34
  19. package/dist/lib/qdrant-pool.d.ts +57 -0
  20. package/dist/lib/qdrant-pool.js +104 -0
  21. package/dist/mcp/api.d.ts +57 -19
  22. package/dist/mcp/api.js +134 -72
  23. package/dist/mcp/helpers.d.ts +0 -1
  24. package/dist/mcp/helpers.js +2 -15
  25. package/dist/mcp/index.js +29 -14
  26. package/dist/mcp/tools.d.ts +0 -7
  27. package/dist/mcp/tools.js +618 -276
  28. package/dist/mcp/types.d.ts +0 -3
  29. package/dist/routing.d.ts +53 -0
  30. package/dist/routing.js +129 -0
  31. package/dist/search-scope.d.ts +24 -0
  32. package/dist/search-scope.js +174 -0
  33. package/docs/config/fully-hosted.md +57 -0
  34. package/docs/config/hosted-models.md +50 -0
  35. package/docs/config/hosted-qdrant-local-models.md +39 -0
  36. package/docs/config/local.md +34 -0
  37. package/docs/configuration.md +403 -0
  38. package/docs/privacy-first.md +140 -0
  39. package/docs/screenshots/dashboard.png +0 -0
  40. package/docs/screenshots/graph.png +0 -0
  41. package/docs/screenshots/memory.png +0 -0
  42. package/package.json +28 -7
  43. package/dist/cli.d.ts.map +0 -1
  44. package/dist/cli.js.map +0 -1
  45. package/dist/config.d.ts.map +0 -1
  46. package/dist/config.js.map +0 -1
  47. package/dist/config.test.d.ts +0 -9
  48. package/dist/config.test.d.ts.map +0 -1
  49. package/dist/config.test.js +0 -576
  50. package/dist/config.test.js.map +0 -1
  51. package/dist/daemon/capture-policy.d.ts.map +0 -1
  52. package/dist/daemon/capture-policy.js.map +0 -1
  53. package/dist/daemon/capture-policy.test.d.ts +0 -2
  54. package/dist/daemon/capture-policy.test.d.ts.map +0 -1
  55. package/dist/daemon/capture-policy.test.js +0 -48
  56. package/dist/daemon/capture-policy.test.js.map +0 -1
  57. package/dist/daemon/consolidation.d.ts.map +0 -1
  58. package/dist/daemon/consolidation.js.map +0 -1
  59. package/dist/daemon/entity-typing.d.ts.map +0 -1
  60. package/dist/daemon/entity-typing.js.map +0 -1
  61. package/dist/daemon/entity-typing.test.d.ts +0 -2
  62. package/dist/daemon/entity-typing.test.d.ts.map +0 -1
  63. package/dist/daemon/entity-typing.test.js +0 -50
  64. package/dist/daemon/entity-typing.test.js.map +0 -1
  65. package/dist/daemon/episode-summary.d.ts.map +0 -1
  66. package/dist/daemon/episode-summary.js.map +0 -1
  67. package/dist/daemon/episode-summary.test.d.ts +0 -2
  68. package/dist/daemon/episode-summary.test.d.ts.map +0 -1
  69. package/dist/daemon/episode-summary.test.js +0 -104
  70. package/dist/daemon/episode-summary.test.js.map +0 -1
  71. package/dist/daemon/extraction-quality.test.d.ts +0 -2
  72. package/dist/daemon/extraction-quality.test.d.ts.map +0 -1
  73. package/dist/daemon/extraction-quality.test.js +0 -283
  74. package/dist/daemon/extraction-quality.test.js.map +0 -1
  75. package/dist/daemon/extraction-rules.d.ts.map +0 -1
  76. package/dist/daemon/extraction-rules.js.map +0 -1
  77. package/dist/daemon/extraction-rules.test.d.ts +0 -2
  78. package/dist/daemon/extraction-rules.test.d.ts.map +0 -1
  79. package/dist/daemon/extraction-rules.test.js +0 -203
  80. package/dist/daemon/extraction-rules.test.js.map +0 -1
  81. package/dist/daemon/extraction.d.ts.map +0 -1
  82. package/dist/daemon/extraction.js.map +0 -1
  83. package/dist/daemon/extraction.test.d.ts +0 -2
  84. package/dist/daemon/extraction.test.d.ts.map +0 -1
  85. package/dist/daemon/extraction.test.js +0 -225
  86. package/dist/daemon/extraction.test.js.map +0 -1
  87. package/dist/daemon/index.d.ts.map +0 -1
  88. package/dist/daemon/index.js.map +0 -1
  89. package/dist/daemon/loop.d.ts.map +0 -1
  90. package/dist/daemon/loop.js.map +0 -1
  91. package/dist/daemon/loop.test.d.ts +0 -2
  92. package/dist/daemon/loop.test.d.ts.map +0 -1
  93. package/dist/daemon/loop.test.js +0 -85
  94. package/dist/daemon/loop.test.js.map +0 -1
  95. package/dist/daemon/maintenance-state.d.ts.map +0 -1
  96. package/dist/daemon/maintenance-state.js.map +0 -1
  97. package/dist/daemon/maintenance-state.test.d.ts +0 -2
  98. package/dist/daemon/maintenance-state.test.d.ts.map +0 -1
  99. package/dist/daemon/maintenance-state.test.js +0 -56
  100. package/dist/daemon/maintenance-state.test.js.map +0 -1
  101. package/dist/daemon/qdrant.d.ts.map +0 -1
  102. package/dist/daemon/qdrant.js.map +0 -1
  103. package/dist/daemon/qdrant.test.d.ts +0 -8
  104. package/dist/daemon/qdrant.test.d.ts.map +0 -1
  105. package/dist/daemon/qdrant.test.js +0 -265
  106. package/dist/daemon/qdrant.test.js.map +0 -1
  107. package/dist/daemon/relations-vocab.d.ts.map +0 -1
  108. package/dist/daemon/relations-vocab.js.map +0 -1
  109. package/dist/daemon/relations-vocab.test.d.ts +0 -2
  110. package/dist/daemon/relations-vocab.test.d.ts.map +0 -1
  111. package/dist/daemon/relations-vocab.test.js +0 -69
  112. package/dist/daemon/relations-vocab.test.js.map +0 -1
  113. package/dist/daemon/relations.d.ts.map +0 -1
  114. package/dist/daemon/relations.js.map +0 -1
  115. package/dist/daemon/relations.test.d.ts +0 -2
  116. package/dist/daemon/relations.test.d.ts.map +0 -1
  117. package/dist/daemon/relations.test.js +0 -36
  118. package/dist/daemon/relations.test.js.map +0 -1
  119. package/dist/daemon/session-index.d.ts.map +0 -1
  120. package/dist/daemon/session-index.js.map +0 -1
  121. package/dist/daemon/session-index.test.d.ts +0 -2
  122. package/dist/daemon/session-index.test.d.ts.map +0 -1
  123. package/dist/daemon/session-index.test.js +0 -60
  124. package/dist/daemon/session-index.test.js.map +0 -1
  125. package/dist/daemon/session-summary.d.ts.map +0 -1
  126. package/dist/daemon/session-summary.js.map +0 -1
  127. package/dist/daemon/session-summary.test.d.ts +0 -2
  128. package/dist/daemon/session-summary.test.d.ts.map +0 -1
  129. package/dist/daemon/session-summary.test.js +0 -162
  130. package/dist/daemon/session-summary.test.js.map +0 -1
  131. package/dist/daemon/staleness.d.ts.map +0 -1
  132. package/dist/daemon/staleness.js.map +0 -1
  133. package/dist/daemon/staleness.test.d.ts +0 -7
  134. package/dist/daemon/staleness.test.d.ts.map +0 -1
  135. package/dist/daemon/staleness.test.js +0 -128
  136. package/dist/daemon/staleness.test.js.map +0 -1
  137. package/dist/daemon/watcher-health.d.ts.map +0 -1
  138. package/dist/daemon/watcher-health.js.map +0 -1
  139. package/dist/daemon/watcher-health.test.d.ts +0 -5
  140. package/dist/daemon/watcher-health.test.d.ts.map +0 -1
  141. package/dist/daemon/watcher-health.test.js +0 -119
  142. package/dist/daemon/watcher-health.test.js.map +0 -1
  143. package/dist/daemon/watcher.d.ts.map +0 -1
  144. package/dist/daemon/watcher.js.map +0 -1
  145. package/dist/daemon/watcher.test.d.ts +0 -9
  146. package/dist/daemon/watcher.test.d.ts.map +0 -1
  147. package/dist/daemon/watcher.test.js +0 -204
  148. package/dist/daemon/watcher.test.js.map +0 -1
  149. package/dist/daemon/workstream-resolver.d.ts.map +0 -1
  150. package/dist/daemon/workstream-resolver.js.map +0 -1
  151. package/dist/daemon/workstream-resolver.test.d.ts +0 -2
  152. package/dist/daemon/workstream-resolver.test.d.ts.map +0 -1
  153. package/dist/daemon/workstream-resolver.test.js +0 -128
  154. package/dist/daemon/workstream-resolver.test.js.map +0 -1
  155. package/dist/daemon/workstream-summary.d.ts.map +0 -1
  156. package/dist/daemon/workstream-summary.js.map +0 -1
  157. package/dist/daemon/workstream-summary.test.d.ts +0 -2
  158. package/dist/daemon/workstream-summary.test.d.ts.map +0 -1
  159. package/dist/daemon/workstream-summary.test.js +0 -89
  160. package/dist/daemon/workstream-summary.test.js.map +0 -1
  161. package/dist/install.d.ts.map +0 -1
  162. package/dist/install.js.map +0 -1
  163. package/dist/install.test.d.ts +0 -9
  164. package/dist/install.test.d.ts.map +0 -1
  165. package/dist/install.test.js +0 -126
  166. package/dist/install.test.js.map +0 -1
  167. package/dist/lib/qdrant-client.d.ts.map +0 -1
  168. package/dist/lib/qdrant-client.js.map +0 -1
  169. package/dist/lib/qdrant-client.test.d.ts +0 -8
  170. package/dist/lib/qdrant-client.test.d.ts.map +0 -1
  171. package/dist/lib/qdrant-client.test.js +0 -274
  172. package/dist/lib/qdrant-client.test.js.map +0 -1
  173. package/dist/lifecycle.d.ts.map +0 -1
  174. package/dist/lifecycle.js.map +0 -1
  175. package/dist/lifecycle.test.d.ts +0 -8
  176. package/dist/lifecycle.test.d.ts.map +0 -1
  177. package/dist/lifecycle.test.js +0 -74
  178. package/dist/lifecycle.test.js.map +0 -1
  179. package/dist/llm/embedding/index.d.ts.map +0 -1
  180. package/dist/llm/embedding/index.js.map +0 -1
  181. package/dist/llm/embedding/index.test.d.ts +0 -8
  182. package/dist/llm/embedding/index.test.d.ts.map +0 -1
  183. package/dist/llm/embedding/index.test.js +0 -100
  184. package/dist/llm/embedding/index.test.js.map +0 -1
  185. package/dist/llm/embedding/providers/bedrock.d.ts.map +0 -1
  186. package/dist/llm/embedding/providers/bedrock.js.map +0 -1
  187. package/dist/llm/embedding/providers/bedrock.test.d.ts +0 -2
  188. package/dist/llm/embedding/providers/bedrock.test.d.ts.map +0 -1
  189. package/dist/llm/embedding/providers/bedrock.test.js +0 -24
  190. package/dist/llm/embedding/providers/bedrock.test.js.map +0 -1
  191. package/dist/llm/embedding/providers/index.d.ts.map +0 -1
  192. package/dist/llm/embedding/providers/index.js.map +0 -1
  193. package/dist/llm/embedding/providers/ollama.d.ts.map +0 -1
  194. package/dist/llm/embedding/providers/ollama.js.map +0 -1
  195. package/dist/llm/embedding/providers/ollama.test.d.ts +0 -2
  196. package/dist/llm/embedding/providers/ollama.test.d.ts.map +0 -1
  197. package/dist/llm/embedding/providers/ollama.test.js +0 -54
  198. package/dist/llm/embedding/providers/ollama.test.js.map +0 -1
  199. package/dist/llm/embedding/providers/openai.d.ts.map +0 -1
  200. package/dist/llm/embedding/providers/openai.js.map +0 -1
  201. package/dist/llm/embedding/providers/openai.test.d.ts +0 -2
  202. package/dist/llm/embedding/providers/openai.test.d.ts.map +0 -1
  203. package/dist/llm/embedding/providers/openai.test.js +0 -48
  204. package/dist/llm/embedding/providers/openai.test.js.map +0 -1
  205. package/dist/llm/embedding/providers/portkey.d.ts.map +0 -1
  206. package/dist/llm/embedding/providers/portkey.js.map +0 -1
  207. package/dist/llm/embedding/providers/portkey.test.d.ts +0 -2
  208. package/dist/llm/embedding/providers/portkey.test.d.ts.map +0 -1
  209. package/dist/llm/embedding/providers/portkey.test.js +0 -56
  210. package/dist/llm/embedding/providers/portkey.test.js.map +0 -1
  211. package/dist/llm/embedding/registry.d.ts.map +0 -1
  212. package/dist/llm/embedding/registry.js.map +0 -1
  213. package/dist/llm/embedding/registry.test.d.ts +0 -7
  214. package/dist/llm/embedding/registry.test.d.ts.map +0 -1
  215. package/dist/llm/embedding/registry.test.js +0 -68
  216. package/dist/llm/embedding/registry.test.js.map +0 -1
  217. package/dist/llm/embedding/types.d.ts.map +0 -1
  218. package/dist/llm/embedding/types.js.map +0 -1
  219. package/dist/llm/errors.d.ts.map +0 -1
  220. package/dist/llm/errors.js.map +0 -1
  221. package/dist/llm/errors.test.d.ts +0 -2
  222. package/dist/llm/errors.test.d.ts.map +0 -1
  223. package/dist/llm/errors.test.js +0 -103
  224. package/dist/llm/errors.test.js.map +0 -1
  225. package/dist/llm/fetch.d.ts.map +0 -1
  226. package/dist/llm/fetch.js.map +0 -1
  227. package/dist/llm/index.d.ts.map +0 -1
  228. package/dist/llm/index.js.map +0 -1
  229. package/dist/llm/inference/index.d.ts.map +0 -1
  230. package/dist/llm/inference/index.js.map +0 -1
  231. package/dist/llm/inference/index.test.d.ts +0 -6
  232. package/dist/llm/inference/index.test.d.ts.map +0 -1
  233. package/dist/llm/inference/index.test.js +0 -150
  234. package/dist/llm/inference/index.test.js.map +0 -1
  235. package/dist/llm/inference/providers/bedrock.d.ts.map +0 -1
  236. package/dist/llm/inference/providers/bedrock.js.map +0 -1
  237. package/dist/llm/inference/providers/bedrock.test.d.ts +0 -2
  238. package/dist/llm/inference/providers/bedrock.test.d.ts.map +0 -1
  239. package/dist/llm/inference/providers/bedrock.test.js +0 -68
  240. package/dist/llm/inference/providers/bedrock.test.js.map +0 -1
  241. package/dist/llm/inference/providers/index.d.ts.map +0 -1
  242. package/dist/llm/inference/providers/index.js.map +0 -1
  243. package/dist/llm/inference/providers/ollama.d.ts.map +0 -1
  244. package/dist/llm/inference/providers/ollama.js.map +0 -1
  245. package/dist/llm/inference/providers/ollama.test.d.ts +0 -2
  246. package/dist/llm/inference/providers/ollama.test.d.ts.map +0 -1
  247. package/dist/llm/inference/providers/ollama.test.js +0 -57
  248. package/dist/llm/inference/providers/ollama.test.js.map +0 -1
  249. package/dist/llm/inference/providers/openai.d.ts.map +0 -1
  250. package/dist/llm/inference/providers/openai.js.map +0 -1
  251. package/dist/llm/inference/providers/openai.test.d.ts +0 -2
  252. package/dist/llm/inference/providers/openai.test.d.ts.map +0 -1
  253. package/dist/llm/inference/providers/openai.test.js +0 -82
  254. package/dist/llm/inference/providers/openai.test.js.map +0 -1
  255. package/dist/llm/inference/providers/portkey.d.ts.map +0 -1
  256. package/dist/llm/inference/providers/portkey.js.map +0 -1
  257. package/dist/llm/inference/providers/portkey.test.d.ts +0 -2
  258. package/dist/llm/inference/providers/portkey.test.d.ts.map +0 -1
  259. package/dist/llm/inference/providers/portkey.test.js +0 -48
  260. package/dist/llm/inference/providers/portkey.test.js.map +0 -1
  261. package/dist/llm/inference/registry.d.ts.map +0 -1
  262. package/dist/llm/inference/registry.js.map +0 -1
  263. package/dist/llm/inference/registry.test.d.ts +0 -6
  264. package/dist/llm/inference/registry.test.d.ts.map +0 -1
  265. package/dist/llm/inference/registry.test.js +0 -63
  266. package/dist/llm/inference/registry.test.js.map +0 -1
  267. package/dist/llm/inference/types.d.ts.map +0 -1
  268. package/dist/llm/inference/types.js.map +0 -1
  269. package/dist/llm/telemetry.d.ts.map +0 -1
  270. package/dist/llm/telemetry.js.map +0 -1
  271. package/dist/llm/telemetry.test.d.ts +0 -5
  272. package/dist/llm/telemetry.test.d.ts.map +0 -1
  273. package/dist/llm/telemetry.test.js +0 -89
  274. package/dist/llm/telemetry.test.js.map +0 -1
  275. package/dist/llm/types.d.ts.map +0 -1
  276. package/dist/llm/types.js.map +0 -1
  277. package/dist/logger.d.ts.map +0 -1
  278. package/dist/logger.js.map +0 -1
  279. package/dist/logger.test.d.ts +0 -5
  280. package/dist/logger.test.d.ts.map +0 -1
  281. package/dist/logger.test.js +0 -103
  282. package/dist/logger.test.js.map +0 -1
  283. package/dist/mcp/api.d.ts.map +0 -1
  284. package/dist/mcp/api.js.map +0 -1
  285. package/dist/mcp/api.test.d.ts +0 -6
  286. package/dist/mcp/api.test.d.ts.map +0 -1
  287. package/dist/mcp/api.test.js +0 -130
  288. package/dist/mcp/api.test.js.map +0 -1
  289. package/dist/mcp/helpers.d.ts.map +0 -1
  290. package/dist/mcp/helpers.js.map +0 -1
  291. package/dist/mcp/helpers.test.d.ts +0 -5
  292. package/dist/mcp/helpers.test.d.ts.map +0 -1
  293. package/dist/mcp/helpers.test.js +0 -548
  294. package/dist/mcp/helpers.test.js.map +0 -1
  295. package/dist/mcp/index.d.ts.map +0 -1
  296. package/dist/mcp/index.js.map +0 -1
  297. package/dist/mcp/taxonomy.d.ts.map +0 -1
  298. package/dist/mcp/taxonomy.js.map +0 -1
  299. package/dist/mcp/taxonomy.test.d.ts +0 -5
  300. package/dist/mcp/taxonomy.test.d.ts.map +0 -1
  301. package/dist/mcp/taxonomy.test.js +0 -215
  302. package/dist/mcp/taxonomy.test.js.map +0 -1
  303. package/dist/mcp/tools.d.ts.map +0 -1
  304. package/dist/mcp/tools.integration.itest.d.ts +0 -23
  305. package/dist/mcp/tools.integration.itest.d.ts.map +0 -1
  306. package/dist/mcp/tools.integration.itest.js +0 -171
  307. package/dist/mcp/tools.integration.itest.js.map +0 -1
  308. package/dist/mcp/tools.js.map +0 -1
  309. package/dist/mcp/tools.test.d.ts +0 -16
  310. package/dist/mcp/tools.test.d.ts.map +0 -1
  311. package/dist/mcp/tools.test.js +0 -908
  312. package/dist/mcp/tools.test.js.map +0 -1
  313. package/dist/mcp/types.d.ts.map +0 -1
  314. package/dist/mcp/types.js.map +0 -1
  315. package/dist/postinstall.d.ts.map +0 -1
  316. package/dist/postinstall.js.map +0 -1
  317. package/dist/privacy/redaction.d.ts.map +0 -1
  318. package/dist/privacy/redaction.js.map +0 -1
  319. package/dist/privacy/redaction.test.d.ts +0 -2
  320. package/dist/privacy/redaction.test.d.ts.map +0 -1
  321. package/dist/privacy/redaction.test.js +0 -51
  322. package/dist/privacy/redaction.test.js.map +0 -1
  323. package/dist/prompts/brief.d.ts.map +0 -1
  324. package/dist/prompts/brief.js.map +0 -1
  325. package/dist/prompts/contradiction.d.ts.map +0 -1
  326. package/dist/prompts/contradiction.js.map +0 -1
  327. package/dist/prompts/distill.d.ts.map +0 -1
  328. package/dist/prompts/distill.js.map +0 -1
  329. package/dist/prompts/entity-typing.d.ts.map +0 -1
  330. package/dist/prompts/entity-typing.js.map +0 -1
  331. package/dist/prompts/episode-summary.d.ts.map +0 -1
  332. package/dist/prompts/episode-summary.js.map +0 -1
  333. package/dist/prompts/extraction.d.ts.map +0 -1
  334. package/dist/prompts/extraction.js.map +0 -1
  335. package/dist/prompts/index.d.ts.map +0 -1
  336. package/dist/prompts/index.js.map +0 -1
  337. package/dist/prompts/prompts.test.d.ts +0 -8
  338. package/dist/prompts/prompts.test.d.ts.map +0 -1
  339. package/dist/prompts/prompts.test.js +0 -140
  340. package/dist/prompts/prompts.test.js.map +0 -1
  341. package/dist/prompts/relations.d.ts.map +0 -1
  342. package/dist/prompts/relations.js.map +0 -1
  343. package/dist/prompts/workstream-summary.d.ts.map +0 -1
  344. package/dist/prompts/workstream-summary.js.map +0 -1
  345. package/dist/provenance/actor.d.ts.map +0 -1
  346. package/dist/provenance/actor.js.map +0 -1
  347. package/dist/provenance/actor.test.d.ts +0 -2
  348. package/dist/provenance/actor.test.d.ts.map +0 -1
  349. package/dist/provenance/actor.test.js +0 -49
  350. package/dist/provenance/actor.test.js.map +0 -1
  351. package/dist/render.d.ts.map +0 -1
  352. package/dist/render.js.map +0 -1
  353. package/dist/render.test.d.ts +0 -8
  354. package/dist/render.test.d.ts.map +0 -1
  355. package/dist/render.test.js +0 -244
  356. package/dist/render.test.js.map +0 -1
  357. package/dist/status.d.ts.map +0 -1
  358. package/dist/status.js.map +0 -1
  359. package/dist/status.test.d.ts +0 -5
  360. package/dist/status.test.d.ts.map +0 -1
  361. package/dist/status.test.js +0 -203
  362. package/dist/status.test.js.map +0 -1
package/dist/mcp/tools.js CHANGED
@@ -5,8 +5,10 @@ import crypto from "node:crypto";
5
5
  import { z } from "zod";
6
6
  import { STALENESS_DAYS, THRESHOLD_DUPLICATE, THRESHOLD_RELATED, QDRANT_INDEXES, categoryValues, categoryEnumDescription, domainValues, domainEnumDescription, kindValues, kindEnumDescription, memorySubtypeValues, memorySubtypeEnumDescription, sourceValues, sourceEnumDescription, DEFAULT_CATEGORY, DEFAULT_DOMAIN, DEFAULT_KIND, DEFAULT_SOURCE, categoryForMemorySubtype, layerForMemorySubtype, normalizeCategory, normalizeDomain, normalizeKind, validateMemorySubtype, } from "./taxonomy.js";
7
7
  import { contentHash, daysSince, lastActivityDate, computeCombinedScore, buildFilter, formatFact, structuredFact, MEMORY_RECALL_EXCLUDED_KINDS, } from "./helpers.js";
8
- import { ready, qdrantUrl, qdrantApiKey, setupError, setQdrantUrl, setQdrantApiKey, setReady, getCollection, log, embed, getEmbeddingConfig, qdrantReq, ensureCollection, qdrantUpsert, qdrantSearch, qdrantScroll, qdrantSetPayload, qdrantGetPoints, } from "./api.js";
8
+ import { ready, setupError, setReady, log, embed, getEmbeddingConfig, qdrantReq, ensureCollectionsAll, qdrantUpsert, qdrantSearch, qdrantScroll, qdrantSetPayload, qdrantGetPoints, rebuildPool, hasPool, listDestinations, resolveDest, findPointById, } from "./api.js";
9
+ import { DestinationNotFoundError } from "../routing.js";
9
10
  import { saveConfig, loadConfig, EXTRACTION_HEALTH_PATH } from "../config.js";
11
+ import { availableSearchScopes, resolveSearchScope, SearchScopeNotFoundError, } from "../search-scope.js";
10
12
  import { existsSync, readFileSync } from "node:fs";
11
13
  import { inspectWatcherPaths, formatIssue, repairSuspiciousWatcherPaths } from "../daemon/watcher-health.js";
12
14
  import { normalizeActorId, resolveActorIdentity } from "../provenance/actor.js";
@@ -17,6 +19,7 @@ import { addRedactionPayload, combineRedactions, redactStorageText, } from "../p
17
19
  const NUDGE_INTERVAL_MS = 10 * 60 * 1000;
18
20
  const MEMORY_RECALL_DEFAULT_LIMIT = 10;
19
21
  const MEMORY_RECALL_MAX_LIMIT = 50;
22
+ const searchScopeSchema = z.union([z.string(), z.array(z.string())]).optional();
20
23
  let lastStoreTime = Date.now();
21
24
  let heartbeatCount = 0;
22
25
  // ---------------------------------------------------------------------------
@@ -28,32 +31,105 @@ function nowISO() {
28
31
  function newId() {
29
32
  return crypto.randomUUID();
30
33
  }
31
- export function resolveScope(workspaceId, includeLegacyWorkspace = false, actorId) {
32
- const resolved = workspaceId?.trim()
33
- || process.env.BIKKY_WORKSPACE?.trim()
34
- || loadConfig().default_workspace?.trim()
35
- || undefined;
36
- // The literal "default" workspace also includes legacy facts that have no
37
- // workspace_id payload (pre-migration data). Any other named workspace stays
38
- // strict. An explicit includeLegacyWorkspace=true from the caller still wins.
39
- const isDefault = resolved === "default";
34
+ // Build a RoutingInput from the standard memory-tool fields.
35
+ function routingInput(args) {
40
36
  return {
41
- workspaceId: resolved,
42
- actorId: normalizeActorId(actorId),
43
- includeLegacy: includeLegacyWorkspace || isDefault,
37
+ destination: args.destination,
38
+ cwd: process.cwd(),
39
+ content: args.content,
40
+ entities: args.entities,
41
+ metadata: args.metadata,
44
42
  };
45
43
  }
46
- function scopedFilter(scope, extra = {}) {
47
- return buildFilter({
48
- ...extra,
49
- workspace_id: scope.workspaceId,
50
- includeLegacyWorkspace: scope.includeLegacy,
51
- });
44
+ // Resolve a destination from a routing input, returning either the destination
45
+ // or an MCP error result if the override is invalid / no destinations exist.
46
+ function resolveDestOrError(input) {
47
+ try {
48
+ return { dest: resolveDest(input) };
49
+ }
50
+ catch (e) {
51
+ const msg = e instanceof Error ? e.message : String(e);
52
+ if (e instanceof DestinationNotFoundError) {
53
+ return {
54
+ error: {
55
+ content: [{ type: "text", text: JSON.stringify({
56
+ status: "destination_not_found",
57
+ message: msg,
58
+ available_destinations: listDestinations().map((d) => d.name),
59
+ }, null, 2) }],
60
+ isError: true,
61
+ },
62
+ };
63
+ }
64
+ return {
65
+ error: {
66
+ content: [{ type: "text", text: JSON.stringify({ status: "error", message: msg }, null, 2) }],
67
+ isError: true,
68
+ },
69
+ };
70
+ }
71
+ }
72
+ function withDestination(point, destination) {
73
+ return { ...point, _destination: destination };
74
+ }
75
+ function structuredScopedFact(point) {
76
+ return {
77
+ ...structuredFact(point),
78
+ destination: point._destination.name,
79
+ };
80
+ }
81
+ function formatScopedFact(point, includeDestination) {
82
+ const formatted = formatFact(point);
83
+ return includeDestination ? `[${point._destination.name}] ${formatted}` : formatted;
84
+ }
85
+ function resolveSearchScopeOrError(args) {
86
+ if (args.destination && args.search_scope !== undefined) {
87
+ return {
88
+ error: {
89
+ content: [{ type: "text", text: JSON.stringify({
90
+ status: "ambiguous_search_scope",
91
+ message: "Use either destination for a single destination override or search_scope for routed/all/list search, not both.",
92
+ }, null, 2) }],
93
+ isError: true,
94
+ },
95
+ };
96
+ }
97
+ if (args.destination) {
98
+ const resolved = resolveDestOrError({ ...args.input, destination: args.destination });
99
+ if (resolved.error)
100
+ return { error: resolved.error };
101
+ return {
102
+ scope: {
103
+ name: resolved.dest.name,
104
+ description: resolved.dest.description ?? `Search only the '${resolved.dest.name}' destination.`,
105
+ requested: resolved.dest.name,
106
+ destinations: [resolved.dest],
107
+ },
108
+ };
109
+ }
110
+ const cfg = loadConfig();
111
+ const dests = listDestinations();
112
+ try {
113
+ return { scope: resolveSearchScope(args.search_scope, cfg, dests, args.input) };
114
+ }
115
+ catch (e) {
116
+ const msg = e instanceof Error ? e.message : String(e);
117
+ return {
118
+ error: {
119
+ content: [{ type: "text", text: JSON.stringify({
120
+ status: e instanceof SearchScopeNotFoundError ? "search_scope_not_found" : "search_scope_error",
121
+ message: msg,
122
+ available_search_scopes: availableSearchScopes(cfg, dests),
123
+ }, null, 2) }],
124
+ isError: true,
125
+ },
126
+ };
127
+ }
52
128
  }
53
- function addWorkspacePayload(payload, scope, actor) {
54
- if (scope.workspaceId)
55
- payload["workspace_id"] = scope.workspaceId;
56
- const actorId = actor?.actor_id ?? scope.actorId;
129
+ // Add actor identity payload fields. Workspace was removed in v0.4.0 — physical
130
+ // separation now happens via routing destinations (see routing.ts).
131
+ function addActorPayload(payload, actor, actorIdOverride) {
132
+ const actorId = actor?.actor_id ?? normalizeActorId(actorIdOverride);
57
133
  if (actorId)
58
134
  payload["actor_id"] = actorId;
59
135
  if (actor?.actor_label) {
@@ -66,19 +142,34 @@ function addWorkspacePayload(payload, scope, actor) {
66
142
  payload["metadata"] = metadata;
67
143
  }
68
144
  }
69
- async function getPointForWorkspaceWrite(factId, _scope) {
70
- const existing = await qdrantGetPoints([factId]);
145
+ async function getPointForWrite(dest, factId) {
146
+ const existing = await qdrantGetPoints(dest, [factId]);
71
147
  const point = existing.result?.[0];
72
148
  if (!point) {
73
149
  return { error: { status: "not_found", fact_id: factId } };
74
150
  }
75
151
  return { point };
76
152
  }
153
+ // Locate which destination owns a fact ID (fan-out across pool). Used by
154
+ // ID-based ops where the caller doesn't know upfront which destination holds
155
+ // the point (memory_forget, memory_verify, memory_report_outcome, etc.).
156
+ async function locatePoint(factId) {
157
+ const found = await findPointById(factId);
158
+ if (!found)
159
+ return null;
160
+ return { dest: found.destination, point: found.point };
161
+ }
162
+ function notFoundResult(factId) {
163
+ return {
164
+ content: [{ type: "text", text: JSON.stringify({ status: "not_found", fact_id: factId }, null, 2) }],
165
+ isError: true,
166
+ };
167
+ }
77
168
  function requireReady() {
78
169
  if (!ready) {
79
170
  const missing = [];
80
- if (!qdrantUrl)
81
- missing.push("qdrant-url");
171
+ if (!hasPool())
172
+ missing.push("destinations");
82
173
  return {
83
174
  content: [{
84
175
  type: "text",
@@ -86,9 +177,6 @@ function requireReady() {
86
177
  status: "setup_required",
87
178
  ready: false,
88
179
  missing,
89
- // Surface the underlying init failure (embedding / Qdrant) when
90
- // present so users see an actionable reason instead of a generic
91
- // "setup required" message.
92
180
  ...(setupError ? { setup_error: setupError } : {}),
93
181
  setup_instructions: "Memory is not configured. Run `bikky setup` or call configure_credentials:\n" +
94
182
  "1. Go to cloud.qdrant.io → sign up (free tier: 1GB, no credit card)\n" +
@@ -126,7 +214,7 @@ function clampRecallLimit(limit) {
126
214
  /**
127
215
  * Entity-graph traversal for memory_recall.
128
216
  */
129
- async function graphTraversal(primaryResults, limit, scope) {
217
+ async function graphTraversal(dest, primaryResults, limit) {
130
218
  try {
131
219
  const primaryEntities = new Set();
132
220
  const primaryIds = new Set();
@@ -140,16 +228,16 @@ async function graphTraversal(primaryResults, limit, scope) {
140
228
  return { points: [] };
141
229
  const relatedEntities = new Set();
142
230
  for (const entity of primaryEntities) {
143
- const outgoingFilter = scopedFilter(scope, { excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
231
+ const outgoingFilter = buildFilter({ excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
144
232
  outgoingFilter.must.push({ key: "from_entity", match: { value: entity } });
145
- const outgoing = await qdrantScroll(outgoingFilter, 10).catch(() => ({ result: { points: [] } }));
233
+ const outgoing = await qdrantScroll(dest, outgoingFilter, 10).catch(() => ({ result: { points: [] } }));
146
234
  for (const pt of (outgoing.result?.points ?? [])) {
147
235
  if (pt.payload.to_entity)
148
236
  relatedEntities.add(pt.payload.to_entity);
149
237
  }
150
- const incomingFilter = scopedFilter(scope, { excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
238
+ const incomingFilter = buildFilter({ excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
151
239
  incomingFilter.must.push({ key: "to_entity", match: { value: entity } });
152
- const incoming = await qdrantScroll(incomingFilter, 10).catch(() => ({ result: { points: [] } }));
240
+ const incoming = await qdrantScroll(dest, incomingFilter, 10).catch(() => ({ result: { points: [] } }));
153
241
  for (const pt of (incoming.result?.points ?? [])) {
154
242
  if (pt.payload.from_entity)
155
243
  relatedEntities.add(pt.payload.from_entity);
@@ -162,9 +250,9 @@ async function graphTraversal(primaryResults, limit, scope) {
162
250
  const relatedFacts = [];
163
251
  const maxPerEntity = Math.max(2, Math.floor(limit / relatedEntities.size));
164
252
  for (const entity of relatedEntities) {
165
- const filter = scopedFilter(scope, { excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
253
+ const filter = buildFilter({ excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
166
254
  filter.must.push({ key: "entities", match: { value: entity } });
167
- const result = await qdrantScroll(filter, maxPerEntity).catch(() => ({ result: { points: [] } }));
255
+ const result = await qdrantScroll(dest, filter, maxPerEntity).catch(() => ({ result: { points: [] } }));
168
256
  for (const pt of (result.result?.points ?? [])) {
169
257
  if (!primaryIds.has(pt.id)) {
170
258
  relatedFacts.push(pt);
@@ -189,33 +277,57 @@ export function registerTools(mcp) {
189
277
  "Use this when memory tools return a 'setup_required' error, or once at session start if you're not sure bikky is wired up. Reports which credentials are missing and includes onboarding instructions if anything is incomplete.",
190
278
  "Read-only — safe to call any time.",
191
279
  ].join(" "), {}, async () => {
192
- const activeWorkspace = process.env.BIKKY_WORKSPACE?.trim()
193
- || loadConfig().default_workspace?.trim()
194
- || null;
280
+ const cfg = loadConfig();
281
+ const dests = listDestinations();
195
282
  const status = {
196
283
  ready,
197
- qdrant_url: !!qdrantUrl,
198
- qdrant_api_key: !!qdrantApiKey,
284
+ destinations_configured: dests.length,
199
285
  missing: [],
200
- qdrant_connected: false,
201
286
  embedding_connected: false,
202
287
  embedding_provider: getEmbeddingConfig().provider,
203
288
  embedding_model: getEmbeddingConfig().model,
204
289
  embedding_dimensions: getEmbeddingConfig().dimensions,
205
- ...(activeWorkspace ? { active_workspace: activeWorkspace } : {}),
206
290
  ...(setupError ? { setup_error: setupError } : {}),
207
291
  };
208
292
  const missing = status["missing"];
209
- if (!qdrantUrl)
210
- missing.push("qdrant-url");
211
- // qdrant-api-key is optional (local / self-hosted Qdrant doesn't need it).
212
- if (qdrantUrl) {
293
+ if (dests.length === 0)
294
+ missing.push("destinations");
295
+ // Per-destination health
296
+ const destStatus = [];
297
+ for (const d of dests) {
298
+ const block = {
299
+ name: d.name,
300
+ qdrant_url_host: (() => { try {
301
+ return new URL(d.qdrant_url).host;
302
+ }
303
+ catch {
304
+ return d.qdrant_url;
305
+ } })(),
306
+ collection: d.collection,
307
+ default: d.default ?? false,
308
+ ...(d.description ? { description: d.description } : {}),
309
+ connected: false,
310
+ collection_exists: false,
311
+ };
312
+ try {
313
+ await qdrantReq(d, "GET", "/collections");
314
+ block["connected"] = true;
315
+ }
316
+ catch (e) {
317
+ block["last_error"] = e instanceof Error ? e.message : String(e);
318
+ }
213
319
  try {
214
- await qdrantReq("GET", "/collections");
215
- status["qdrant_connected"] = true;
320
+ await qdrantReq(d, "GET", `/collections/${d.collection}`);
321
+ block["collection_exists"] = true;
216
322
  }
217
323
  catch { /* ignore */ }
324
+ destStatus.push(block);
218
325
  }
326
+ status["destinations"] = destStatus;
327
+ status["default_search_scope"] = cfg.default_search_scope;
328
+ status["search_scopes"] = availableSearchScopes(cfg, dests);
329
+ status["search_scope_hint"] =
330
+ "Read/search tools accept search_scope: 'routed', 'all', a destination name, a configured scope name, a comma-separated destination list, or an array of destination names. Use destination only when you want an exact single-destination override.";
219
331
  try {
220
332
  await embed("test");
221
333
  status["embedding_connected"] = true;
@@ -224,8 +336,11 @@ export function registerTools(mcp) {
224
336
  // Watcher / extraction health (issue #58)
225
337
  const warnings = [];
226
338
  try {
227
- const cfg = loadConfig();
228
339
  status["watcher_path"] = cfg.watchers.copilot.path;
340
+ status["watcher_paths"] = {
341
+ copilot: cfg.watchers.copilot.path,
342
+ claude: cfg.watchers.claude.path,
343
+ };
229
344
  for (const issue of inspectWatcherPaths(cfg)) {
230
345
  warnings.push(formatIssue(issue));
231
346
  }
@@ -237,17 +352,19 @@ export function registerTools(mcp) {
237
352
  status["extraction_last_tick_at"] = health.last_tick_at ?? null;
238
353
  status["extraction_last_active_session_at"] = health.last_active_session_at ?? null;
239
354
  status["extraction_active_session_count"] = health.active_session_count ?? 0;
355
+ if (health.sources)
356
+ status["extraction_sources"] = health.sources;
240
357
  if (health.last_active_session_at) {
241
358
  const hours = (Date.now() - Date.parse(health.last_active_session_at)) / 3_600_000;
242
359
  status["extraction_hours_since_active_session"] = Math.round(hours * 10) / 10;
243
360
  if (hours > 6) {
244
- warnings.push(`Watcher has not seen any active Copilot sessions for ${Math.round(hours)}h — ` +
361
+ warnings.push(`Watcher has not seen any active transcript sources for ${Math.round(hours)}h — ` +
245
362
  `check watcher_path (${health.watcher_path ?? "unknown"}) and that the daemon is running.`);
246
363
  }
247
364
  }
248
365
  else {
249
366
  status["extraction_hours_since_active_session"] = null;
250
- warnings.push("Daemon has never observed an active Copilot session — extraction may be stalled.");
367
+ warnings.push("Daemon has never observed an active transcript source — extraction may be stalled.");
251
368
  }
252
369
  }
253
370
  else {
@@ -269,6 +386,25 @@ export function registerTools(mcp) {
269
386
  }
270
387
  return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
271
388
  });
389
+ // ── memory_search_scopes ─────────────────────────────────────────────────
390
+ mcp.tool("memory_search_scopes", [
391
+ "List the configured memory search scopes and destination descriptions.",
392
+ "Use this before memory_recall, memory_entity, or memory_relations when multiple destinations exist so you can choose the right search_scope.",
393
+ "Read-only — returns built-in scopes ('routed', 'all'), destination-name scopes, configured named scopes, and the default_search_scope.",
394
+ ].join(" "), {}, async () => {
395
+ const cfg = loadConfig();
396
+ const dests = listDestinations();
397
+ return {
398
+ content: [{ type: "text", text: JSON.stringify({
399
+ default_search_scope: cfg.default_search_scope,
400
+ scopes: availableSearchScopes(cfg, dests),
401
+ usage: {
402
+ search_scope: "Pass one scope name, 'routed', 'all', a destination name, a comma-separated destination list, or an array of destination names.",
403
+ destination: "Use this older parameter only for an exact single-destination override. Do not combine it with search_scope.",
404
+ },
405
+ }, null, 2) }],
406
+ };
407
+ });
272
408
  // ── configure_credentials ───────────────────────────────────────────────
273
409
  mcp.tool("configure_credentials", [
274
410
  "Persist Qdrant and embedding credentials to ~/.bikky/config.json and bring the memory system online.",
@@ -283,12 +419,10 @@ export function registerTools(mcp) {
283
419
  if (qdrant_url) {
284
420
  const url = qdrant_url.replace(/\/+$/, "");
285
421
  cfg.qdrant_url = url;
286
- setQdrantUrl(url);
287
422
  results["qdrant_url"] = "stored ✓";
288
423
  }
289
424
  if (qdrant_api_key) {
290
425
  cfg.qdrant_api_key = qdrant_api_key;
291
- setQdrantApiKey(qdrant_api_key);
292
426
  results["qdrant_api_key"] = "stored ✓";
293
427
  }
294
428
  if (openai_api_key) {
@@ -301,14 +435,22 @@ export function registerTools(mcp) {
301
435
  results["watcher_path_repairs"] = watcherRepairs;
302
436
  }
303
437
  saveConfig(cfg);
304
- if (qdrantUrl) {
305
- try {
306
- await ensureCollection(QDRANT_INDEXES);
307
- results["qdrant_collection"] = `'${getCollection()}' ready ✓`;
308
- }
309
- catch (e) {
310
- results["qdrant_collection"] = `error: ${e instanceof Error ? e.message : String(e)}`;
311
- }
438
+ // Rebuild the destination pool from the updated config so the
439
+ // synthesized default destination picks up the new url/key.
440
+ try {
441
+ rebuildPool();
442
+ }
443
+ catch (e) {
444
+ results["pool_rebuild"] = `error: ${e instanceof Error ? e.message : String(e)}`;
445
+ }
446
+ if (hasPool()) {
447
+ const ensured = await ensureCollectionsAll(QDRANT_INDEXES);
448
+ results["destinations"] = ensured.map((r) => ({
449
+ name: r.destination.name,
450
+ collection: r.destination.collection,
451
+ ok: r.ok,
452
+ ...(r.error ? { error: r.error } : {}),
453
+ }));
312
454
  }
313
455
  try {
314
456
  await embed("memory system test");
@@ -318,7 +460,7 @@ export function registerTools(mcp) {
318
460
  catch (e) {
319
461
  results["embedding"] = `error: ${e instanceof Error ? e.message : String(e)}`;
320
462
  }
321
- setReady(!!qdrantUrl);
463
+ setReady(hasPool());
322
464
  results["ready"] = ready;
323
465
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
324
466
  });
@@ -328,21 +470,31 @@ export function registerTools(mcp) {
328
470
  "Use this to debug a sudden 'setup_required' or empty-recall after a network blip or credential change. Lighter than configure_credentials — does not write to disk.",
329
471
  "Read-only.",
330
472
  ].join(" "), {}, async () => {
331
- const results = { qdrant: false, embedding: false, collection: false };
332
- if (qdrantUrl) {
473
+ const results = { embedding: false };
474
+ const dests = listDestinations();
475
+ const destResults = [];
476
+ for (const d of dests) {
477
+ const block = {
478
+ name: d.name,
479
+ collection: d.collection,
480
+ qdrant: false,
481
+ collection_exists: false,
482
+ };
333
483
  try {
334
- await qdrantReq("GET", "/collections");
335
- results["qdrant"] = true;
484
+ await qdrantReq(d, "GET", "/collections");
485
+ block["qdrant"] = true;
336
486
  }
337
487
  catch (e) {
338
- results["qdrant_error"] = e instanceof Error ? e.message : String(e);
488
+ block["qdrant_error"] = e instanceof Error ? e.message : String(e);
339
489
  }
340
490
  try {
341
- await qdrantReq("GET", `/collections/${getCollection()}`);
342
- results["collection"] = true;
491
+ await qdrantReq(d, "GET", `/collections/${d.collection}`);
492
+ block["collection_exists"] = true;
343
493
  }
344
494
  catch { /* ignore */ }
495
+ destResults.push(block);
345
496
  }
497
+ results["destinations"] = destResults;
346
498
  try {
347
499
  await embed("connection test");
348
500
  results["embedding"] = true;
@@ -350,7 +502,8 @@ export function registerTools(mcp) {
350
502
  catch (e) {
351
503
  results["embedding_error"] = e instanceof Error ? e.message : String(e);
352
504
  }
353
- const allReady = results["qdrant"] === true && results["embedding"] === true && results["collection"] === true;
505
+ const allReady = results["embedding"] === true && destResults.length > 0
506
+ && destResults.every((b) => b["qdrant"] === true && b["collection_exists"] === true);
354
507
  results["ready"] = allReady;
355
508
  setReady(allReady);
356
509
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
@@ -369,7 +522,8 @@ export function registerTools(mcp) {
369
522
  domain: z.enum(domainValues()).default(DEFAULT_DOMAIN).describe(domainEnumDescription()),
370
523
  kind: z.enum(kindValues()).default(DEFAULT_KIND).describe(kindEnumDescription()),
371
524
  memory_subtype: z.enum(memorySubtypeValues()).optional().describe(memorySubtypeEnumDescription()),
372
- workspace_id: z.string().optional().describe("Workspace namespace for team-shared memory. Omit to use the default workspace from config."),
525
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op. Routing now uses destinations see destination."),
526
+ destination: z.string().optional().describe("Optional destination override. When set, routes to that destination by name. Hard-errors if no such destination exists. Omit to let routing rules in ~/.bikky/config.json decide based on cwd/entities/content/metadata."),
373
527
  actor_id: z.string().optional().describe("Stable actor/person/agent identity associated with this capture. Overrides identity config/env/Git-derived fallback for this write."),
374
528
  episode_id: z.string().optional().describe("Coherent activity-segment ID. Group facts captured during the same coherent task or transcript."),
375
529
  workstream_key: z.string().optional().describe("Durable continuity key for a long-running objective (survives across sessions)."),
@@ -387,13 +541,21 @@ export function registerTools(mcp) {
387
541
  to: z.string().describe("Target entity (lowercase)."),
388
542
  }).optional().describe("Optional typed edge between two entities — created in the same call. Use this whenever the fact also expresses a relationship; no separate tool call needed."),
389
543
  metadata: z.record(z.string(), z.string()).optional().describe("Arbitrary key-value metadata. Stored with the fact and exact-match filterable via memory_recall.metadata_filter (all key/value pairs must match — AND logic)."),
390
- }, async ({ content, category, entities, domain, kind, memory_subtype, workspace_id, actor_id, episode_id, workstream_key, task_key, repo, branch, review_status, source, confidence, importance, supersedes, relation, metadata, }) => {
544
+ }, async ({ content, category, entities, domain, kind, memory_subtype, workspace_id: _workspace_id, destination, actor_id, episode_id, workstream_key, task_key, repo, branch, review_status, source, confidence, importance, supersedes, relation, metadata, }) => {
391
545
  const guard = requireReady();
392
546
  if (guard)
393
547
  return guard;
394
548
  lastStoreTime = Date.now();
395
549
  const now = nowISO();
396
- const scope = resolveScope(workspace_id);
550
+ const resolved = resolveDestOrError(routingInput({
551
+ destination,
552
+ content,
553
+ entities,
554
+ metadata,
555
+ }));
556
+ if (resolved.error)
557
+ return resolved.error;
558
+ const dest = resolved.dest;
397
559
  const actor = resolveActorIdentity({ actorId: actor_id, config: loadConfig() });
398
560
  const normalizedKind = normalizeKind(kind);
399
561
  let normalizedSubtype = null;
@@ -439,14 +601,14 @@ export function registerTools(mcp) {
439
601
  } : null;
440
602
  // 1. Exact dedup via content hash
441
603
  try {
442
- const hashFilter = scopedFilter(scope) ?? { must: [] };
604
+ const hashFilter = buildFilter({}) ?? { must: [] };
443
605
  hashFilter.must.push({ key: "content_hash", match: { value: hash } });
444
- const existing = await qdrantScroll(hashFilter, 1);
606
+ const existing = await qdrantScroll(dest, hashFilter, 1);
445
607
  const existingPoint = existing.result?.points?.[0];
446
608
  if (existingPoint) {
447
609
  const point = existingPoint;
448
610
  const count = (point.payload.reinforcement_count || 1) + 1;
449
- await qdrantSetPayload([point.id], {
611
+ await qdrantSetPayload(dest, [point.id], {
450
612
  reinforcement_count: count,
451
613
  last_reinforced_at: now,
452
614
  updated_at: now,
@@ -470,18 +632,18 @@ export function registerTools(mcp) {
470
632
  let similarFacts = [];
471
633
  let potentialConflicts = [];
472
634
  try {
473
- const filter = scopedFilter(scope) ?? { must: [] };
635
+ const filter = buildFilter({}) ?? { must: [] };
474
636
  if (normalizedEntities.length > 0) {
475
637
  filter.must.push({ key: "entities", match: { any: normalizedEntities } });
476
638
  }
477
- const results = await qdrantSearch(vector, filter, 3);
639
+ const results = await qdrantSearch(dest, vector, filter, 3);
478
640
  const firstResult = results.result?.[0];
479
641
  if (results.result?.length > 0 && firstResult) {
480
642
  const topScore = firstResult.score ?? 0;
481
643
  if (topScore > THRESHOLD_DUPLICATE) {
482
644
  const point = firstResult;
483
645
  const count = (point.payload.reinforcement_count || 1) + 1;
484
- await qdrantSetPayload([point.id], {
646
+ await qdrantSetPayload(dest, [point.id], {
485
647
  reinforcement_count: count,
486
648
  last_reinforced_at: now,
487
649
  updated_at: now,
@@ -531,11 +693,11 @@ export function registerTools(mcp) {
531
693
  // 5. Supersede old fact if requested
532
694
  if (supersedes) {
533
695
  try {
534
- const existing = await getPointForWorkspaceWrite(supersedes, scope);
696
+ const existing = await getPointForWrite(dest, supersedes);
535
697
  if (existing.error) {
536
698
  return { content: [{ type: "text", text: JSON.stringify(existing.error, null, 2) }], isError: true };
537
699
  }
538
- await qdrantSetPayload([supersedes], {
700
+ await qdrantSetPayload(dest, [supersedes], {
539
701
  superseded_by: factId,
540
702
  superseded_at: now,
541
703
  });
@@ -583,9 +745,9 @@ export function registerTools(mcp) {
583
745
  }
584
746
  if (review_status)
585
747
  payload["review_status"] = review_status;
586
- addWorkspacePayload(payload, scope, actor);
748
+ addActorPayload(payload, actor);
587
749
  addRedactionPayload(payload, factRedactionSummary);
588
- await qdrantUpsert(factId, vector, payload);
750
+ await qdrantUpsert(dest, factId, vector, payload);
589
751
  // 7. Insert relation point if provided
590
752
  let relationId = null;
591
753
  if (sanitizedRelation) {
@@ -612,14 +774,14 @@ export function registerTools(mcp) {
612
774
  relation_type: sanitizedRelation.type.toLowerCase(),
613
775
  to_entity: sanitizedRelation.to.toLowerCase(),
614
776
  };
615
- addWorkspacePayload(relPayload, scope, actor);
777
+ addActorPayload(relPayload, actor);
616
778
  addRedactionPayload(relPayload, relationRedactionSummary);
617
- await qdrantUpsert(relationId, relVector, relPayload);
779
+ await qdrantUpsert(dest, relationId, relVector, relPayload);
618
780
  }
619
781
  const result = {
620
782
  action: "inserted",
621
783
  fact_id: factId,
622
- workspace_id: scope.workspaceId,
784
+ destination: dest.name,
623
785
  };
624
786
  if (actor.actor_id)
625
787
  result["actor_id"] = actor.actor_id;
@@ -646,6 +808,7 @@ export function registerTools(mcp) {
646
808
  " 3. Conflict/replacement check — recall similar facts when you suspect new information may supersede an older fact. Deduplication during memory_store is automatic.",
647
809
  "Combine the natural-language query with structured filters (category, domain, entity, date range, metadata) for tighter results.",
648
810
  "If you have a known entity name and want everything about it, prefer memory_entity. For 'what does X own/use?' style questions, prefer memory_relations.",
811
+ "When multiple Qdrant destinations are configured, use search_scope to choose 'routed' (routing/default behavior), 'all', a destination name, a configured scope name, a comma-separated destination list, or an array of destination names. Call memory_search_scopes to inspect available scopes and descriptions.",
649
812
  `By default output is human-readable text. Use output_format=json for machine-parseable results with separate results and related arrays. Default limit is ${MEMORY_RECALL_DEFAULT_LIMIT}; maximum effective limit is ${MEMORY_RECALL_MAX_LIMIT}.`,
650
813
  ].join("\n"), {
651
814
  query: z.string().describe("Natural-language description of what you're looking for. Embedded and matched semantically — full sentences work better than keyword lists."),
@@ -653,9 +816,11 @@ export function registerTools(mcp) {
653
816
  domain: z.string().optional().describe("Filter by domain activity profile (same vocabulary as memory_store.domain). Optional."),
654
817
  kind: z.string().optional().describe("Filter by kind: fact, summary, distilled, relation. Optional. Telemetry is excluded by default."),
655
818
  memory_subtype: z.string().optional().describe("Filter by memory subtype (must be valid for the chosen kind). Optional."),
656
- workspace_id: z.string().optional().describe("Filter to facts in this workspace namespace. Omit to use the default workspace from config."),
819
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
820
+ destination: z.string().optional().describe("Optional legacy single-destination override. Do not combine with search_scope. Prefer search_scope for routed/all/list search."),
821
+ search_scope: searchScopeSchema.describe("Optional read/search scope. Accepts 'routed', 'all', a destination name, a configured scope name, a comma-separated destination list, or an array of destination names. Omit to use config.default_search_scope."),
657
822
  actor_id: z.string().optional().describe("Filter to facts captured by or associated with this stable actor identity. Optional."),
658
- include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility flag: also include legacy facts that have no workspace_id. Default false. Only set this if you suspect pre-migration data is missing from results."),
823
+ include_legacy_workspace: z.boolean().optional().describe("[Removed in v0.4.0] No-op."),
659
824
  entity: z.string().optional().describe("Restrict to facts mentioning this entity (case-insensitive). For full entity context prefer memory_entity."),
660
825
  episode_id: z.string().optional().describe("Filter by coherent episode ID."),
661
826
  workstream_key: z.string().optional().describe("Filter by durable workstream key."),
@@ -669,14 +834,25 @@ export function registerTools(mcp) {
669
834
  graph_depth: z.number().optional().default(0).describe("Entity-graph traversal depth. 0 = vector search only (fast, default). 1 = also surface up to ceil(limit / 2) extra 1-hop entity-related facts (slower; use when the user asks 'what's connected to X?'). In JSON output these are returned separately as related."),
670
835
  output_format: z.enum(["text", "json"]).optional().default("text").describe("Response format. text = backward-compatible human-readable lines (default). json = parseable object with query, limit metadata, results, related, counts, and optional nudge."),
671
836
  metadata_filter: z.record(z.string(), z.string()).optional().describe("Exact-match filter on the metadata map stored with each fact. All key/value pairs must match (AND logic)."),
672
- }, async ({ query, category, domain, kind, memory_subtype, workspace_id, actor_id, include_legacy_workspace, entity, episode_id, workstream_key, task_key, repo, branch, review_status, since, until, limit, graph_depth, output_format, metadata_filter, }) => {
837
+ }, async ({ query, category, domain, kind, memory_subtype, workspace_id: _workspace_id, destination, search_scope, actor_id, include_legacy_workspace: _include_legacy_workspace, entity, episode_id, workstream_key, task_key, repo, branch, review_status, since, until, limit, graph_depth, output_format, metadata_filter, }) => {
673
838
  const guard = requireReady();
674
839
  if (guard)
675
840
  return guard;
676
841
  const requestedLimit = limit ?? MEMORY_RECALL_DEFAULT_LIMIT;
677
842
  const effectiveLimit = clampRecallLimit(limit);
678
843
  const actorFilter = resolveActorIdentity({ actorId: actor_id, useGitFallback: false });
679
- const scope = resolveScope(workspace_id, include_legacy_workspace, actorFilter.actor_id);
844
+ const scopeResolved = resolveSearchScopeOrError({
845
+ destination,
846
+ search_scope,
847
+ input: routingInput({
848
+ content: query,
849
+ entities: entity ? [entity] : [],
850
+ metadata: metadata_filter,
851
+ }),
852
+ });
853
+ if (scopeResolved.error)
854
+ return scopeResolved.error;
855
+ const searchScope = scopeResolved.scope;
680
856
  const redactedQuery = redactStorageText(query);
681
857
  const vector = await embed(redactedQuery.text);
682
858
  const normalizedKind = kind ? normalizeKind(kind) : undefined;
@@ -692,11 +868,12 @@ export function registerTools(mcp) {
692
868
  };
693
869
  }
694
870
  }
695
- const filter = scopedFilter(scope, {
871
+ const filter = buildFilter({
696
872
  category: category ? normalizeCategory(category) : undefined,
697
873
  domain: domain ? normalizeDomain(domain) : undefined,
698
874
  kind: normalizedKind,
699
875
  memory_subtype: normalizedSubtype,
876
+ actor_id: actorFilter.actor_id,
700
877
  entity,
701
878
  episode_id,
702
879
  workstream_key,
@@ -709,12 +886,42 @@ export function registerTools(mcp) {
709
886
  metadata: metadata_filter,
710
887
  excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS,
711
888
  });
712
- const results = await qdrantSearch(vector, filter, effectiveLimit * 2);
713
- if (!results.result?.length) {
889
+ const searchedDestinations = searchScope.destinations.map((dest) => dest.name);
890
+ const failedDestinations = [];
891
+ const scopedResults = [];
892
+ for (const dest of searchScope.destinations) {
893
+ try {
894
+ const results = await qdrantSearch(dest, vector, filter, effectiveLimit * 2);
895
+ scopedResults.push(...(results.result ?? []).map((point) => withDestination(point, dest)));
896
+ }
897
+ catch (e) {
898
+ failedDestinations.push({
899
+ destination: dest.name,
900
+ error: e instanceof Error ? e.message : String(e),
901
+ });
902
+ }
903
+ }
904
+ if (failedDestinations.length === searchScope.destinations.length && searchScope.destinations.length > 0) {
905
+ return {
906
+ content: [{ type: "text", text: JSON.stringify({
907
+ status: "search_failed",
908
+ query: redactedQuery.text,
909
+ search_scope: searchScope.name,
910
+ searched_destinations: searchedDestinations,
911
+ failed_destinations: failedDestinations,
912
+ }, null, 2) }],
913
+ isError: true,
914
+ };
915
+ }
916
+ if (scopedResults.length === 0) {
714
917
  const nudge = buildMemoryNudge();
715
918
  if (output_format === "json") {
716
919
  return { content: [{ type: "text", text: JSON.stringify({
717
920
  query: redactedQuery.text,
921
+ search_scope: searchScope.name,
922
+ search_scope_description: searchScope.description,
923
+ searched_destinations: searchedDestinations,
924
+ failed_destinations: failedDestinations,
718
925
  requested_limit: requestedLimit,
719
926
  effective_limit: effectiveLimit,
720
927
  max_limit: MEMORY_RECALL_MAX_LIMIT,
@@ -728,31 +935,61 @@ export function registerTools(mcp) {
728
935
  ...(redactedQuery.redacted ? { query_redaction: redactedQuery } : {}),
729
936
  }, null, 2) }] };
730
937
  }
731
- const text = nudge ? `No matching facts found.\n\n${nudge}` : "No matching facts found.";
938
+ const warning = failedDestinations.length > 0
939
+ ? `\n\nSearch warnings: ${failedDestinations.map((failure) => `${failure.destination}: ${failure.error}`).join("; ")}`
940
+ : "";
941
+ const text = nudge
942
+ ? `No matching facts found.${warning}\n\n${nudge}`
943
+ : `No matching facts found.${warning}`;
732
944
  return { content: [{ type: "text", text }] };
733
945
  }
734
- const ranked = results.result
946
+ const ranked = scopedResults
735
947
  .map((r) => ({ ...r, _combinedScore: computeCombinedScore(r) }))
736
948
  .sort((a, b) => b._combinedScore - a._combinedScore)
737
949
  .slice(0, effectiveLimit);
738
- const lines = ranked.map((r) => formatFact(r));
739
- let related = { points: [] };
950
+ const includeDestination = searchScope.destinations.length > 1 || searchScope.name === "all";
951
+ const lines = ranked.map((r) => formatScopedFact(r, includeDestination));
952
+ const related = { points: [], errors: [] };
740
953
  if ((graph_depth ?? 0) >= 1) {
741
- related = await graphTraversal(ranked, effectiveLimit, scope);
954
+ const byDestination = new Map();
955
+ for (const point of ranked) {
956
+ const existing = byDestination.get(point._destination.name);
957
+ if (existing) {
958
+ existing.points.push(point);
959
+ }
960
+ else {
961
+ byDestination.set(point._destination.name, { dest: point._destination, points: [point] });
962
+ }
963
+ }
964
+ for (const entry of byDestination.values()) {
965
+ const traversal = await graphTraversal(entry.dest, entry.points, effectiveLimit);
966
+ related.points.push(...traversal.points.map((point) => withDestination(point, entry.dest)));
967
+ if (traversal.error) {
968
+ related.errors.push({ destination: entry.dest.name, error: traversal.error });
969
+ }
970
+ }
971
+ related.points = related.points.slice(0, Math.ceil(effectiveLimit / 2));
742
972
  if (related.points.length > 0) {
743
973
  lines.push("", "── Related (1-hop) ──");
744
- lines.push(...related.points.map((r) => formatFact(r)));
974
+ lines.push(...related.points.map((r) => formatScopedFact(r, includeDestination)));
745
975
  }
746
- else if (related.error) {
747
- lines.push("", `(graph traversal failed: ${related.error})`);
976
+ if (related.errors.length > 0) {
977
+ lines.push("", `(graph traversal warnings: ${related.errors.map((failure) => `${failure.destination}: ${failure.error}`).join("; ")})`);
748
978
  }
749
979
  }
980
+ if (failedDestinations.length > 0) {
981
+ lines.push("", `(search warnings: ${failedDestinations.map((failure) => `${failure.destination}: ${failure.error}`).join("; ")})`);
982
+ }
750
983
  const nudge = buildMemoryNudge();
751
984
  if (nudge)
752
985
  lines.push("", nudge);
753
986
  if (output_format === "json") {
754
987
  return { content: [{ type: "text", text: JSON.stringify({
755
988
  query: redactedQuery.text,
989
+ search_scope: searchScope.name,
990
+ search_scope_description: searchScope.description,
991
+ searched_destinations: searchedDestinations,
992
+ failed_destinations: failedDestinations,
756
993
  requested_limit: requestedLimit,
757
994
  effective_limit: effectiveLimit,
758
995
  max_limit: MEMORY_RECALL_MAX_LIMIT,
@@ -760,9 +997,9 @@ export function registerTools(mcp) {
760
997
  graph_depth: graph_depth ?? 0,
761
998
  result_count: ranked.length,
762
999
  related_count: related.points.length,
763
- results: ranked.map((r) => structuredFact(r)),
764
- related: related.points.map((r) => structuredFact(r)),
765
- ...(related.error ? { graph_error: related.error } : {}),
1000
+ results: ranked.map((r) => structuredScopedFact(r)),
1001
+ related: related.points.map((r) => structuredScopedFact(r)),
1002
+ ...(related.errors.length > 0 ? { graph_errors: related.errors } : {}),
766
1003
  ...(nudge ? { nudge } : {}),
767
1004
  ...(redactedQuery.redacted ? { query_redaction: redactedQuery } : {}),
768
1005
  }, null, 2) }] };
@@ -777,73 +1014,113 @@ export function registerTools(mcp) {
777
1014
  ].join(" "), {
778
1015
  name: z.string().describe("Entity name (case-insensitive, e.g. 'qdrant', 'workspace_id'). Should match the lowercase canonical form used when facts were stored."),
779
1016
  limit: z.number().optional().default(20).describe("Max facts to return (default 20). Relations are always returned in full, capped at 50 each direction."),
780
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
781
- include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
782
- }, async ({ name, limit, workspace_id, include_legacy_workspace }) => {
1017
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1018
+ destination: z.string().optional().describe("Optional legacy single-destination override. Do not combine with search_scope."),
1019
+ search_scope: searchScopeSchema.describe("Optional read/search scope. Accepts 'routed', 'all', a destination name, a configured scope name, a comma-separated destination list, or an array of destination names. Omit to use config.default_search_scope."),
1020
+ include_legacy_workspace: z.boolean().optional().describe("[Removed in v0.4.0] No-op."),
1021
+ }, async ({ name, limit, workspace_id: _workspace_id, destination, search_scope, include_legacy_workspace: _include_legacy_workspace }) => {
783
1022
  const guard = requireReady();
784
1023
  if (guard)
785
1024
  return guard;
786
1025
  const entityName = name.toLowerCase();
787
- const scope = resolveScope(workspace_id, include_legacy_workspace);
788
- // Look up the daemon-classified entity type, if any.
789
- let entityType = null;
790
- try {
791
- const typeFilter = scopedFilter(scope) ?? { must: [] };
792
- typeFilter.must.push({ key: "kind", match: { value: "entity_type" } });
793
- typeFilter.must.push({ key: "entity_name", match: { value: entityName } });
794
- const typePoints = await qdrantScroll(typeFilter, 1);
795
- const typePoint = typePoints.result?.points?.[0];
796
- const payload = typePoint?.payload;
797
- if (payload?.entity_type) {
798
- entityType = String(payload.entity_type);
1026
+ const scopeResolved = resolveSearchScopeOrError({
1027
+ destination,
1028
+ search_scope,
1029
+ input: routingInput({ entities: [entityName] }),
1030
+ });
1031
+ if (scopeResolved.error)
1032
+ return scopeResolved.error;
1033
+ const searchScope = scopeResolved.scope;
1034
+ const effectiveLimit = Math.max(1, Math.trunc(limit ?? 20));
1035
+ const entityTypes = new Map();
1036
+ const factPoints = [];
1037
+ const relationPoints = [];
1038
+ const failures = [];
1039
+ for (const dest of searchScope.destinations) {
1040
+ // Look up the daemon-classified entity type, if any.
1041
+ try {
1042
+ const typeFilter = buildFilter({}) ?? { must: [] };
1043
+ typeFilter.must.push({ key: "kind", match: { value: "entity_type" } });
1044
+ typeFilter.must.push({ key: "entity_name", match: { value: entityName } });
1045
+ const typePoints = await qdrantScroll(dest, typeFilter, 1);
1046
+ const typePoint = typePoints.result?.points?.[0];
1047
+ const payload = typePoint?.payload;
1048
+ if (payload?.entity_type) {
1049
+ entityTypes.set(dest.name, String(payload.entity_type));
1050
+ }
1051
+ }
1052
+ catch {
1053
+ // Type lookup is best-effort — never fails the request.
1054
+ }
1055
+ try {
1056
+ const factsFilter = buildFilter({}) ?? { must: [] };
1057
+ factsFilter.must.push({ key: "entities", match: { value: entityName } });
1058
+ const facts = await qdrantScroll(dest, factsFilter, effectiveLimit);
1059
+ factPoints.push(...(facts.result?.points ?? []).map((point) => withDestination(point, dest)));
1060
+ const fromFilter = buildFilter({}) ?? { must: [] };
1061
+ fromFilter.must.push({ key: "from_entity", match: { value: entityName } });
1062
+ const relationsFrom = await qdrantScroll(dest, fromFilter, 50);
1063
+ const toFilter = buildFilter({}) ?? { must: [] };
1064
+ toFilter.must.push({ key: "to_entity", match: { value: entityName } });
1065
+ const relationsTo = await qdrantScroll(dest, toFilter, 50);
1066
+ relationPoints.push(...[
1067
+ ...(relationsFrom.result?.points ?? []),
1068
+ ...(relationsTo.result?.points ?? []),
1069
+ ].map((point) => withDestination(point, dest)));
1070
+ }
1071
+ catch (e) {
1072
+ failures.push({ destination: dest.name, error: e instanceof Error ? e.message : String(e) });
799
1073
  }
800
1074
  }
801
- catch {
802
- // Type lookup is best-effort — never fails the request.
803
- }
804
- const factsFilter = scopedFilter(scope) ?? { must: [] };
805
- factsFilter.must.push({ key: "entities", match: { value: entityName } });
806
- const facts = await qdrantScroll(factsFilter, limit ?? 20);
807
- const fromFilter = scopedFilter(scope) ?? { must: [] };
808
- fromFilter.must.push({ key: "from_entity", match: { value: entityName } });
809
- const relationsFrom = await qdrantScroll(fromFilter, 50);
810
- const toFilter = scopedFilter(scope) ?? { must: [] };
811
- toFilter.must.push({ key: "to_entity", match: { value: entityName } });
812
- const relationsTo = await qdrantScroll(toFilter, 50);
1075
+ if (failures.length === searchScope.destinations.length && searchScope.destinations.length > 0) {
1076
+ return {
1077
+ content: [{ type: "text", text: JSON.stringify({
1078
+ status: "search_failed",
1079
+ entity: entityName,
1080
+ search_scope: searchScope.name,
1081
+ searched_destinations: searchScope.destinations.map((dest) => dest.name),
1082
+ failed_destinations: failures,
1083
+ }, null, 2) }],
1084
+ isError: true,
1085
+ };
1086
+ }
813
1087
  const output = [];
814
- const factPoints = facts.result?.points ?? [];
1088
+ const includeDestination = searchScope.destinations.length > 1 || searchScope.name === "all";
1089
+ const entityTypeValues = [...new Set(entityTypes.values())];
1090
+ const entityType = entityTypeValues.length === 1 ? entityTypeValues[0] : null;
815
1091
  if (factPoints.length > 0) {
816
1092
  const header = entityType
817
- ? `## Facts about ${name} [type: ${entityType}] (${factPoints.length})`
818
- : `## Facts about ${name} (${factPoints.length})`;
1093
+ ? `## Facts about ${name} [type: ${entityType}] (${Math.min(factPoints.length, effectiveLimit)})`
1094
+ : `## Facts about ${name} (${Math.min(factPoints.length, effectiveLimit)})`;
819
1095
  output.push(header);
820
- for (const p of factPoints) {
1096
+ for (const p of factPoints.slice(0, effectiveLimit)) {
821
1097
  if (p.payload.category !== "relation") {
822
- output.push(`- ${formatFact(p)}`);
1098
+ output.push(`- ${formatScopedFact(p, includeDestination)}`);
823
1099
  }
824
1100
  }
825
1101
  }
826
1102
  else if (entityType) {
827
1103
  output.push(`## ${name} [type: ${entityType}]`);
828
1104
  }
829
- const allRelations = [
830
- ...(relationsFrom.result?.points ?? []),
831
- ...(relationsTo.result?.points ?? []),
832
- ];
833
1105
  const seen = new Set();
834
- const uniqueRelations = allRelations.filter((r) => {
835
- if (seen.has(r.id))
1106
+ const uniqueRelations = relationPoints.filter((r) => {
1107
+ const key = `${r._destination.name}:${r.id}`;
1108
+ if (seen.has(key))
836
1109
  return false;
837
- seen.add(r.id);
1110
+ seen.add(key);
838
1111
  return true;
839
1112
  });
840
1113
  if (uniqueRelations.length > 0) {
841
1114
  output.push(`\n## Relations (${uniqueRelations.length})`);
842
1115
  for (const r of uniqueRelations) {
843
1116
  const p = r.payload;
844
- output.push(`- ${p.from_entity} --[${p.relation_type}]--> ${p.to_entity}`);
1117
+ const prefix = includeDestination ? `[${r._destination.name}] ` : "";
1118
+ output.push(`- ${prefix}${p.from_entity} --[${p.relation_type}]--> ${p.to_entity}`);
845
1119
  }
846
1120
  }
1121
+ if (failures.length > 0) {
1122
+ output.push(`\nSearch warnings: ${failures.map((failure) => `${failure.destination}: ${failure.error}`).join("; ")}`);
1123
+ }
847
1124
  if (output.length === 0) {
848
1125
  return { content: [{ type: "text", text: `No facts or relations found for '${name}'.` }] };
849
1126
  }
@@ -858,47 +1135,85 @@ export function registerTools(mcp) {
858
1135
  entity: z.string().describe("Entity name to query (case-insensitive)."),
859
1136
  relation_type: z.string().optional().describe("Filter to a specific edge label (e.g. 'owns', 'uses', 'decided', 'prefers', 'works-on'). Optional."),
860
1137
  direction: z.enum(["from", "to", "both"]).optional().default("both").describe("Which side of the edge the entity is on. 'from' = entity is the source (X --[?]--> ?). 'to' = entity is the target (? --[?]--> X). 'both' = either (default)."),
861
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
862
- include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
863
- }, async ({ entity, relation_type, direction, workspace_id, include_legacy_workspace }) => {
1138
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1139
+ destination: z.string().optional().describe("Optional legacy single-destination override. Do not combine with search_scope."),
1140
+ search_scope: searchScopeSchema.describe("Optional read/search scope. Accepts 'routed', 'all', a destination name, a configured scope name, a comma-separated destination list, or an array of destination names. Omit to use config.default_search_scope."),
1141
+ include_legacy_workspace: z.boolean().optional().describe("[Removed in v0.4.0] No-op."),
1142
+ }, async ({ entity, relation_type, direction, workspace_id: _workspace_id, destination, search_scope, include_legacy_workspace: _include_legacy_workspace }) => {
864
1143
  const guard = requireReady();
865
1144
  if (guard)
866
1145
  return guard;
867
1146
  const entityName = entity.toLowerCase();
868
- const scope = resolveScope(workspace_id, include_legacy_workspace);
1147
+ const scopeResolved = resolveSearchScopeOrError({
1148
+ destination,
1149
+ search_scope,
1150
+ input: routingInput({ entities: [entityName] }),
1151
+ });
1152
+ if (scopeResolved.error)
1153
+ return scopeResolved.error;
1154
+ const searchScope = scopeResolved.scope;
869
1155
  const results = [];
870
- if (direction === "from" || direction === "both") {
871
- const filter = scopedFilter(scope) ?? { must: [] };
872
- filter.must.push({ key: "from_entity", match: { value: entityName } });
873
- if (relation_type) {
874
- filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
1156
+ const failures = [];
1157
+ for (const dest of searchScope.destinations) {
1158
+ try {
1159
+ if (direction === "from" || direction === "both") {
1160
+ const filter = buildFilter({}) ?? { must: [] };
1161
+ filter.must.push({ key: "from_entity", match: { value: entityName } });
1162
+ if (relation_type) {
1163
+ filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
1164
+ }
1165
+ const r = await qdrantScroll(dest, filter, 50);
1166
+ results.push(...(r.result?.points ?? []).map((point) => withDestination(point, dest)));
1167
+ }
1168
+ if (direction === "to" || direction === "both") {
1169
+ const filter = buildFilter({}) ?? { must: [] };
1170
+ filter.must.push({ key: "to_entity", match: { value: entityName } });
1171
+ if (relation_type) {
1172
+ filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
1173
+ }
1174
+ const r = await qdrantScroll(dest, filter, 50);
1175
+ results.push(...(r.result?.points ?? []).map((point) => withDestination(point, dest)));
1176
+ }
875
1177
  }
876
- const r = await qdrantScroll(filter, 50);
877
- results.push(...(r.result?.points ?? []));
878
- }
879
- if (direction === "to" || direction === "both") {
880
- const filter = scopedFilter(scope) ?? { must: [] };
881
- filter.must.push({ key: "to_entity", match: { value: entityName } });
882
- if (relation_type) {
883
- filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
1178
+ catch (e) {
1179
+ failures.push({ destination: dest.name, error: e instanceof Error ? e.message : String(e) });
884
1180
  }
885
- const r = await qdrantScroll(filter, 50);
886
- results.push(...(r.result?.points ?? []));
1181
+ }
1182
+ if (failures.length === searchScope.destinations.length && searchScope.destinations.length > 0) {
1183
+ return {
1184
+ content: [{ type: "text", text: JSON.stringify({
1185
+ status: "search_failed",
1186
+ entity: entityName,
1187
+ search_scope: searchScope.name,
1188
+ searched_destinations: searchScope.destinations.map((dest) => dest.name),
1189
+ failed_destinations: failures,
1190
+ }, null, 2) }],
1191
+ isError: true,
1192
+ };
887
1193
  }
888
1194
  const seen = new Set();
889
1195
  const unique = results.filter((r) => {
890
- if (seen.has(r.id))
1196
+ const key = `${r._destination.name}:${r.id}`;
1197
+ if (seen.has(key))
891
1198
  return false;
892
- seen.add(r.id);
1199
+ seen.add(key);
893
1200
  return true;
894
1201
  });
895
1202
  if (unique.length === 0) {
896
- return { content: [{ type: "text", text: `No relations found for '${entity}'.` }] };
1203
+ const warning = failures.length > 0
1204
+ ? ` Search warnings: ${failures.map((failure) => `${failure.destination}: ${failure.error}`).join("; ")}`
1205
+ : "";
1206
+ return { content: [{ type: "text", text: `No relations found for '${entity}'.${warning}` }] };
897
1207
  }
1208
+ const includeDestination = searchScope.destinations.length > 1 || searchScope.name === "all";
898
1209
  const lines = unique.map((r) => {
899
1210
  const p = r.payload;
900
- return `${p.from_entity} --[${p.relation_type}]--> ${p.to_entity} (confidence: ${p.confidence}, id: ${r.id})`;
1211
+ const prefix = includeDestination ? `[${r._destination.name}] ` : "";
1212
+ return `${prefix}${p.from_entity} --[${p.relation_type}]--> ${p.to_entity} (confidence: ${p.confidence}, id: ${r.id})`;
901
1213
  });
1214
+ if (failures.length > 0) {
1215
+ lines.push("", `Search warnings: ${failures.map((failure) => `${failure.destination}: ${failure.error}`).join("; ")}`);
1216
+ }
902
1217
  return { content: [{ type: "text", text: lines.join("\n") }] };
903
1218
  });
904
1219
  // ── memory_forget ───────────────────────────────────────────────────────
@@ -908,21 +1223,19 @@ export function registerTools(mcp) {
908
1223
  ].join(" "), {
909
1224
  fact_id: z.string().describe("ID of the fact to forget (returned by memory_store / memory_recall as 'id')."),
910
1225
  reason: z.string().describe("Short human-readable reason this fact is being retired (stored in 'superseded_by' for future audit)."),
911
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
912
- }, async ({ fact_id, reason, workspace_id }) => {
1226
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1227
+ }, async ({ fact_id, reason, workspace_id: _workspace_id }) => {
913
1228
  const guard = requireReady();
914
1229
  if (guard)
915
1230
  return guard;
916
1231
  const now = nowISO();
917
1232
  try {
918
- const scope = resolveScope(workspace_id);
919
- const _actor = resolveActorIdentity({ config: loadConfig() });
920
- const existing = await getPointForWorkspaceWrite(fact_id, scope);
921
- if (existing.error) {
922
- return { content: [{ type: "text", text: JSON.stringify(existing.error, null, 2) }], isError: true };
923
- }
1233
+ const located = await locatePoint(fact_id);
1234
+ if (!located)
1235
+ return notFoundResult(fact_id);
1236
+ const { dest } = located;
924
1237
  const redactedReason = redactStorageText(reason);
925
- await qdrantSetPayload([fact_id], {
1238
+ await qdrantSetPayload(dest, [fact_id], {
926
1239
  superseded_by: `forgotten:${redactedReason.text}`,
927
1240
  superseded_at: now,
928
1241
  updated_at: now,
@@ -937,6 +1250,7 @@ export function registerTools(mcp) {
937
1250
  return { content: [{ type: "text", text: JSON.stringify({
938
1251
  status: "forgotten",
939
1252
  fact_id,
1253
+ destination: dest.name,
940
1254
  reason: redactedReason.text,
941
1255
  ...(redactedReason.redacted ? { redaction: redactedReason } : {}),
942
1256
  }) }] };
@@ -952,26 +1266,20 @@ export function registerTools(mcp) {
952
1266
  "If the fact is no longer true, use memory_forget or memory_store(supersedes:) instead.",
953
1267
  ].join(" "), {
954
1268
  fact_id: z.string().describe("ID of the fact to verify (from memory_recall or memory_heartbeat)."),
955
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
956
- }, async ({ fact_id, workspace_id }) => {
1269
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1270
+ }, async ({ fact_id, workspace_id: _workspace_id }) => {
957
1271
  const guard = requireReady();
958
1272
  if (guard)
959
1273
  return guard;
960
1274
  const now = nowISO();
961
1275
  try {
962
- const scope = resolveScope(workspace_id);
963
- const _actor = resolveActorIdentity({ config: loadConfig() });
964
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
965
- if (writable.error) {
966
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
967
- }
968
- let currentCount = 0;
969
- const existingPt = writable.point;
970
- if (existingPt) {
971
- currentCount = existingPt.payload.verification_count ?? 0;
972
- }
1276
+ const located = await locatePoint(fact_id);
1277
+ if (!located)
1278
+ return notFoundResult(fact_id);
1279
+ const { dest, point } = located;
1280
+ const currentCount = point.payload.verification_count ?? 0;
973
1281
  const newCount = currentCount + 1;
974
- await qdrantSetPayload([fact_id], {
1282
+ await qdrantSetPayload(dest, [fact_id], {
975
1283
  last_verified_at: now,
976
1284
  last_reinforced_at: now,
977
1285
  verification_count: newCount,
@@ -981,6 +1289,7 @@ export function registerTools(mcp) {
981
1289
  content: [{ type: "text", text: JSON.stringify({
982
1290
  status: "verified",
983
1291
  fact_id,
1292
+ destination: dest.name,
984
1293
  verification_count: newCount,
985
1294
  message: "Fact confirmed as still accurate. Staleness clock reset.",
986
1295
  }) }],
@@ -998,23 +1307,21 @@ export function registerTools(mcp) {
998
1307
  ].join(" "), {
999
1308
  fact_id: z.string().describe("ID of the fact that was useful (from memory_recall or memory_entity)."),
1000
1309
  note: z.string().optional().describe("Optional short note about how the fact was useful (e.g. 'unblocked auth debug'). Stored on the telemetry event for future analysis."),
1001
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1002
- }, async ({ fact_id, note, workspace_id }) => {
1310
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1311
+ }, async ({ fact_id, note, workspace_id: _workspace_id }) => {
1003
1312
  const guard = requireReady();
1004
1313
  if (guard)
1005
1314
  return guard;
1006
1315
  const now = nowISO();
1007
1316
  try {
1008
- const scope = resolveScope(workspace_id);
1317
+ const located = await locatePoint(fact_id);
1318
+ if (!located)
1319
+ return notFoundResult(fact_id);
1320
+ const { dest, point } = located;
1009
1321
  const actor = resolveActorIdentity({ config: loadConfig() });
1010
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
1011
- if (writable.error) {
1012
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
1013
- }
1014
- const existingPt = writable.point;
1015
- const currentCount = existingPt?.payload.useful_count ?? 0;
1322
+ const currentCount = point.payload.useful_count ?? 0;
1016
1323
  const newCount = currentCount + 1;
1017
- await qdrantSetPayload([fact_id], {
1324
+ await qdrantSetPayload(dest, [fact_id], {
1018
1325
  useful_count: newCount,
1019
1326
  last_useful_at: now,
1020
1327
  updated_at: now,
@@ -1043,11 +1350,11 @@ export function registerTools(mcp) {
1043
1350
  created_at: now,
1044
1351
  updated_at: now,
1045
1352
  };
1046
- addWorkspacePayload(eventPayload, scope, actor);
1353
+ addActorPayload(eventPayload, actor);
1047
1354
  addRedactionPayload(eventPayload, redactedEvent);
1048
1355
  try {
1049
1356
  const eventVector = await embed(redactedEvent.text);
1050
- await qdrantUpsert(eventId, eventVector, eventPayload);
1357
+ await qdrantUpsert(dest, eventId, eventVector, eventPayload);
1051
1358
  }
1052
1359
  catch (e) {
1053
1360
  log("WARN", `Failed to record feedback_event: ${e instanceof Error ? e.message : String(e)}`);
@@ -1056,6 +1363,7 @@ export function registerTools(mcp) {
1056
1363
  content: [{ type: "text", text: JSON.stringify({
1057
1364
  status: "marked_useful",
1058
1365
  fact_id,
1366
+ destination: dest.name,
1059
1367
  useful_count: newCount,
1060
1368
  event_id: eventId,
1061
1369
  }) }],
@@ -1074,19 +1382,18 @@ export function registerTools(mcp) {
1074
1382
  fact_id: z.string().describe("ID of the fact whose outcome you are reporting."),
1075
1383
  outcome: z.enum(["useful", "misleading", "irrelevant", "wrong"]).describe("How the fact actually played out. 'useful' = helped you finish the task; 'misleading' = sent you the wrong way; 'irrelevant' = semantically matched but didn't help; 'wrong' = factually incorrect."),
1076
1384
  notes: z.string().optional().describe("Optional short context for the outcome (e.g. 'API moved in v2', 'wrong port number'). Stored on the telemetry event for future analysis."),
1077
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1078
- }, async ({ fact_id, outcome, notes, workspace_id }) => {
1385
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1386
+ }, async ({ fact_id, outcome, notes, workspace_id: _workspace_id }) => {
1079
1387
  const guard = requireReady();
1080
1388
  if (guard)
1081
1389
  return guard;
1082
1390
  const now = nowISO();
1083
1391
  try {
1084
- const scope = resolveScope(workspace_id);
1392
+ const located = await locatePoint(fact_id);
1393
+ if (!located)
1394
+ return notFoundResult(fact_id);
1395
+ const { dest } = located;
1085
1396
  const actor = resolveActorIdentity({ config: loadConfig() });
1086
- const target = await getPointForWorkspaceWrite(fact_id, scope);
1087
- if (target.error) {
1088
- return { content: [{ type: "text", text: JSON.stringify(target.error, null, 2) }], isError: true };
1089
- }
1090
1397
  const eventId = newId();
1091
1398
  const eventContent = notes
1092
1399
  ? `Fact ${fact_id} outcome=${outcome}: ${notes}`
@@ -1109,14 +1416,15 @@ export function registerTools(mcp) {
1109
1416
  created_at: now,
1110
1417
  updated_at: now,
1111
1418
  };
1112
- addWorkspacePayload(eventPayload, scope, actor);
1419
+ addActorPayload(eventPayload, actor);
1113
1420
  addRedactionPayload(eventPayload, redactedEvent);
1114
1421
  const eventVector = await embed(redactedEvent.text);
1115
- await qdrantUpsert(eventId, eventVector, eventPayload);
1422
+ await qdrantUpsert(dest, eventId, eventVector, eventPayload);
1116
1423
  return {
1117
1424
  content: [{ type: "text", text: JSON.stringify({
1118
1425
  status: "outcome_recorded",
1119
1426
  fact_id,
1427
+ destination: dest.name,
1120
1428
  outcome,
1121
1429
  event_id: eventId,
1122
1430
  }) }],
@@ -1138,16 +1446,24 @@ export function registerTools(mcp) {
1138
1446
  workstream_key: z.string().optional().describe("Durable continuity key for a long-running objective (survives across sessions)."),
1139
1447
  task_key: z.string().optional().describe("Task or issue key (e.g. GitHub issue number, JIRA key)."),
1140
1448
  repo: z.string().optional().describe("Repository or project surface this summary relates to."),
1141
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1449
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1450
+ destination: z.string().optional().describe("Optional destination override. Omit to let routing rules decide."),
1142
1451
  actor_id: z.string().optional().describe("Stable actor identity associated with this session summary. Overrides identity config/env/Git fallback."),
1143
- }, async ({ content, entities, episode_id, workstream_key, task_key, repo, workspace_id, actor_id }) => {
1452
+ }, async ({ content, entities, episode_id, workstream_key, task_key, repo, workspace_id: _workspace_id, destination, actor_id }) => {
1144
1453
  const guard = requireReady();
1145
1454
  if (guard)
1146
1455
  return guard;
1147
1456
  lastStoreTime = Date.now();
1148
1457
  const now = nowISO();
1149
1458
  try {
1150
- const scope = resolveScope(workspace_id);
1459
+ const resolved = resolveDestOrError(routingInput({
1460
+ destination,
1461
+ content,
1462
+ entities: entities ?? [],
1463
+ }));
1464
+ if (resolved.error)
1465
+ return resolved.error;
1466
+ const dest = resolved.dest;
1151
1467
  const actor = resolveActorIdentity({ actorId: actor_id, config: loadConfig() });
1152
1468
  const normalizedEntities = (entities ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean);
1153
1469
  const summaryId = newId();
@@ -1180,14 +1496,14 @@ export function registerTools(mcp) {
1180
1496
  payload["task_key"] = task_key;
1181
1497
  if (repo)
1182
1498
  payload["repo"] = repo;
1183
- addWorkspacePayload(payload, scope, actor);
1499
+ addActorPayload(payload, actor);
1184
1500
  addRedactionPayload(payload, redactedContent);
1185
- await qdrantUpsert(summaryId, vector, payload);
1501
+ await qdrantUpsert(dest, summaryId, vector, payload);
1186
1502
  return {
1187
1503
  content: [{ type: "text", text: JSON.stringify({
1188
1504
  status: "summary_stored",
1189
1505
  summary_id: summaryId,
1190
- workspace_id: scope.workspaceId,
1506
+ destination: dest.name,
1191
1507
  actor_id: actor.actor_id,
1192
1508
  }) }],
1193
1509
  };
@@ -1207,27 +1523,35 @@ export function registerTools(mcp) {
1207
1523
  supersedes: z.string().optional().describe("ID of an earlier distilled fact that this one replaces. Old fact is marked superseded and excluded from recall."),
1208
1524
  task_key: z.string().optional().describe("Task or issue key associated with this learning, if relevant."),
1209
1525
  repo: z.string().optional().describe("Repository or project surface this learning applies to."),
1210
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1526
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1527
+ destination: z.string().optional().describe("Optional destination override. Omit to let routing rules decide."),
1211
1528
  actor_id: z.string().optional().describe("Stable actor identity associated with this distillation. Overrides identity config/env/Git fallback."),
1212
- }, async ({ content, entities, supersedes, task_key, repo, workspace_id, actor_id }) => {
1529
+ }, async ({ content, entities, supersedes, task_key, repo, workspace_id: _workspace_id, destination, actor_id }) => {
1213
1530
  const guard = requireReady();
1214
1531
  if (guard)
1215
1532
  return guard;
1216
1533
  lastStoreTime = Date.now();
1217
1534
  const now = nowISO();
1218
1535
  try {
1219
- const scope = resolveScope(workspace_id);
1536
+ const resolved = resolveDestOrError(routingInput({
1537
+ destination,
1538
+ content,
1539
+ entities,
1540
+ }));
1541
+ if (resolved.error)
1542
+ return resolved.error;
1543
+ const dest = resolved.dest;
1220
1544
  const actor = resolveActorIdentity({ actorId: actor_id, config: loadConfig() });
1221
1545
  const normalizedEntities = entities.map((e) => e.trim().toLowerCase()).filter(Boolean);
1222
1546
  const distilledId = newId();
1223
1547
  const redactedContent = redactStorageText(content);
1224
1548
  const vector = await embed(redactedContent.text);
1225
1549
  if (supersedes) {
1226
- const existing = await getPointForWorkspaceWrite(supersedes, scope);
1227
- if (existing.error) {
1228
- return { content: [{ type: "text", text: JSON.stringify(existing.error, null, 2) }], isError: true };
1229
- }
1230
- await qdrantSetPayload([supersedes], {
1550
+ // Supersede may live in a different destination — locate it.
1551
+ const located = await locatePoint(supersedes);
1552
+ if (!located)
1553
+ return notFoundResult(supersedes);
1554
+ await qdrantSetPayload(located.dest, [supersedes], {
1231
1555
  superseded_by: distilledId,
1232
1556
  superseded_at: now,
1233
1557
  });
@@ -1255,15 +1579,15 @@ export function registerTools(mcp) {
1255
1579
  payload["task_key"] = task_key;
1256
1580
  if (repo)
1257
1581
  payload["repo"] = repo;
1258
- addWorkspacePayload(payload, scope, actor);
1582
+ addActorPayload(payload, actor);
1259
1583
  addRedactionPayload(payload, redactedContent);
1260
- await qdrantUpsert(distilledId, vector, payload);
1584
+ await qdrantUpsert(dest, distilledId, vector, payload);
1261
1585
  return {
1262
1586
  content: [{ type: "text", text: JSON.stringify({
1263
1587
  status: "distilled_stored",
1264
1588
  distilled_id: distilledId,
1589
+ destination: dest.name,
1265
1590
  supersedes: supersedes ?? null,
1266
- workspace_id: scope.workspaceId,
1267
1591
  actor_id: actor.actor_id,
1268
1592
  }) }],
1269
1593
  };
@@ -1282,26 +1606,37 @@ export function registerTools(mcp) {
1282
1606
  fact_id: z.string().optional().describe("Fact ID to act on. Required for approve / reject / correct."),
1283
1607
  reason: z.string().optional().describe("Required for action=reject. Short reason the fact is wrong."),
1284
1608
  corrected_content: z.string().optional().describe("Required for action=correct. The fixed fact text. Stored as a new fact that supersedes the original."),
1285
- workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
1286
- include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
1287
- }, async ({ limit, action, fact_id, reason, corrected_content, workspace_id, include_legacy_workspace }) => {
1609
+ workspace_id: z.string().optional().describe("[Removed in v0.4.0] No-op."),
1610
+ include_legacy_workspace: z.boolean().optional().describe("[Removed in v0.4.0] No-op."),
1611
+ }, async ({ limit, action, fact_id, reason, corrected_content, workspace_id: _workspace_id, include_legacy_workspace: _include_legacy_workspace }) => {
1288
1612
  const guard = requireReady();
1289
1613
  if (guard)
1290
1614
  return guard;
1291
- const scope = resolveScope(workspace_id, include_legacy_workspace);
1292
1615
  if (action === "list") {
1293
- const filter = scopedFilter(scope) ?? { must: [] };
1294
- filter.must.push({ key: "source", match: { any: ["system", "daemon"] } });
1295
- const result = await qdrantScroll(filter, (limit ?? 10) * 2);
1296
- const points = (result.result?.points ?? [])
1297
- .sort((a, b) => (b.payload.created_at ?? "").localeCompare(a.payload.created_at ?? ""))
1616
+ // List spans all destinations.
1617
+ const destinations = listDestinations();
1618
+ const allPoints = [];
1619
+ const filter = { must: [{ key: "source", match: { any: ["system", "daemon"] } }] };
1620
+ for (const dest of destinations) {
1621
+ try {
1622
+ const result = await qdrantScroll(dest, filter, (limit ?? 10) * 2);
1623
+ const points = result.result?.points ?? [];
1624
+ for (const pt of points)
1625
+ allPoints.push({ dest: dest.name, point: pt });
1626
+ }
1627
+ catch (e) {
1628
+ log("WARN", `memory_review list scroll failed on ${dest.name}: ${e instanceof Error ? e.message : String(e)}`);
1629
+ }
1630
+ }
1631
+ const sorted = allPoints
1632
+ .sort((a, b) => (b.point.payload.created_at ?? "").localeCompare(a.point.payload.created_at ?? ""))
1298
1633
  .slice(0, limit ?? 10);
1299
- if (points.length === 0) {
1634
+ if (sorted.length === 0) {
1300
1635
  return { content: [{ type: "text", text: "No system-captured facts found." }] };
1301
1636
  }
1302
- const lines = points.map((pt) => {
1637
+ const lines = sorted.map(({ dest, point: pt }) => {
1303
1638
  const p = pt.payload;
1304
- return `[${p.category}] ${p.content}\n id: ${pt.id} | confidence: ${p.confidence} | importance: ${p.importance} | entities: ${(p.entities ?? []).join(", ")} | created: ${p.created_at}`;
1639
+ return `[${p.category}] ${p.content}\n id: ${pt.id} | dest: ${dest} | confidence: ${p.confidence} | importance: ${p.importance} | entities: ${(p.entities ?? []).join(", ")} | created: ${p.created_at}`;
1305
1640
  });
1306
1641
  return { content: [{ type: "text", text: lines.join("\n\n") }] };
1307
1642
  }
@@ -1310,32 +1645,28 @@ export function registerTools(mcp) {
1310
1645
  }
1311
1646
  const now = nowISO();
1312
1647
  if (action === "approve") {
1313
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
1314
- if (writable.error) {
1315
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
1316
- }
1317
- let currentCount = 0;
1318
- const approvePt = writable.point;
1319
- if (approvePt) {
1320
- currentCount = approvePt.payload.verification_count ?? 0;
1321
- }
1322
- await qdrantSetPayload([fact_id], {
1648
+ const located = await locatePoint(fact_id);
1649
+ if (!located)
1650
+ return notFoundResult(fact_id);
1651
+ const { dest, point } = located;
1652
+ const currentCount = point.payload.verification_count ?? 0;
1653
+ await qdrantSetPayload(dest, [fact_id], {
1323
1654
  last_verified_at: now,
1324
1655
  verification_count: currentCount + 1,
1325
1656
  updated_at: now,
1326
1657
  });
1327
- return { content: [{ type: "text", text: JSON.stringify({ status: "approved", fact_id }) }] };
1658
+ return { content: [{ type: "text", text: JSON.stringify({ status: "approved", fact_id, destination: dest.name }) }] };
1328
1659
  }
1329
1660
  if (action === "reject") {
1330
1661
  if (!reason) {
1331
1662
  return { content: [{ type: "text", text: "Error: reason is required for reject action." }] };
1332
1663
  }
1333
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
1334
- if (writable.error) {
1335
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
1336
- }
1664
+ const located = await locatePoint(fact_id);
1665
+ if (!located)
1666
+ return notFoundResult(fact_id);
1667
+ const { dest } = located;
1337
1668
  const redactedReason = redactStorageText(reason);
1338
- await qdrantSetPayload([fact_id], {
1669
+ await qdrantSetPayload(dest, [fact_id], {
1339
1670
  superseded_by: `rejected:${redactedReason.text}`,
1340
1671
  superseded_at: now,
1341
1672
  updated_at: now,
@@ -1343,6 +1674,7 @@ export function registerTools(mcp) {
1343
1674
  return { content: [{ type: "text", text: JSON.stringify({
1344
1675
  status: "rejected",
1345
1676
  fact_id,
1677
+ destination: dest.name,
1346
1678
  reason: redactedReason.text,
1347
1679
  ...(redactedReason.redacted ? { redaction: redactedReason } : {}),
1348
1680
  }) }] };
@@ -1351,15 +1683,12 @@ export function registerTools(mcp) {
1351
1683
  if (!corrected_content) {
1352
1684
  return { content: [{ type: "text", text: "Error: corrected_content is required for correct action." }] };
1353
1685
  }
1354
- const writable = await getPointForWorkspaceWrite(fact_id, scope);
1355
- if (writable.error) {
1356
- return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
1357
- }
1358
- const origPayload = writable.point?.payload;
1686
+ const located = await locatePoint(fact_id);
1687
+ if (!located)
1688
+ return notFoundResult(fact_id);
1689
+ const { dest, point } = located;
1690
+ const origPayload = point.payload;
1359
1691
  const redactedCorrected = redactStorageText(corrected_content);
1360
- const correctionScope = origPayload?.workspace_id
1361
- ? resolveScope(origPayload.workspace_id, false)
1362
- : scope;
1363
1692
  const actor = resolveActorIdentity({ config: loadConfig() });
1364
1693
  const vector = await embed(redactedCorrected.text);
1365
1694
  const correctedId = crypto.randomUUID();
@@ -1390,15 +1719,15 @@ export function registerTools(mcp) {
1390
1719
  updated_at: now,
1391
1720
  metadata: { ...(origPayload?.metadata ?? {}), corrected_from: fact_id },
1392
1721
  };
1393
- addWorkspacePayload(correctedPayload, correctionScope, actor);
1722
+ addActorPayload(correctedPayload, actor);
1394
1723
  addRedactionPayload(correctedPayload, redactedCorrected);
1395
- await qdrantUpsert(correctedId, vector, correctedPayload);
1396
- await qdrantSetPayload([fact_id], {
1724
+ await qdrantUpsert(dest, correctedId, vector, correctedPayload);
1725
+ await qdrantSetPayload(dest, [fact_id], {
1397
1726
  superseded_by: correctedId,
1398
1727
  superseded_at: now,
1399
1728
  updated_at: now,
1400
1729
  });
1401
- return { content: [{ type: "text", text: JSON.stringify({ status: "corrected", old_fact_id: fact_id, new_fact_id: correctedId }) }] };
1730
+ return { content: [{ type: "text", text: JSON.stringify({ status: "corrected", old_fact_id: fact_id, new_fact_id: correctedId, destination: dest.name }) }] };
1402
1731
  }
1403
1732
  return { content: [{ type: "text", text: `Unknown action: ${String(action)}` }] };
1404
1733
  });
@@ -1415,8 +1744,7 @@ export function registerTools(mcp) {
1415
1744
  if (heartbeatCount % 3 === 0 && ready) {
1416
1745
  try {
1417
1746
  const staleThreshold = new Date(Date.now() - STALENESS_DAYS * 86400000).toISOString();
1418
- const scope = resolveScope();
1419
- const staleFilter = scopedFilter(scope) ?? { must: [] };
1747
+ const staleFilter = { must: [] };
1420
1748
  staleFilter.must.push({ key: "category", match: { any: ["engineering", "product", "human", "system"] } });
1421
1749
  staleFilter.should = [
1422
1750
  { key: "last_reinforced_at", range: { lte: staleThreshold } },
@@ -1425,10 +1753,24 @@ export function registerTools(mcp) {
1425
1753
  staleFilter.must_not = [
1426
1754
  { key: "last_verified_at", range: { gte: staleThreshold } },
1427
1755
  ];
1428
- const staleResults = await qdrantScroll(staleFilter, 3);
1429
- const staleFacts = staleResults.result?.points ?? [];
1430
- if (staleFacts.length > 0) {
1431
- const staleLines = staleFacts.map((f) => {
1756
+ // Aggregate stale facts across all destinations.
1757
+ const staleFacts = [];
1758
+ for (const dest of listDestinations()) {
1759
+ try {
1760
+ const r = await qdrantScroll(dest, staleFilter, 3);
1761
+ const pts = r.result?.points ?? [];
1762
+ for (const pt of pts)
1763
+ staleFacts.push(pt);
1764
+ if (staleFacts.length >= 3)
1765
+ break;
1766
+ }
1767
+ catch (e) {
1768
+ log("WARN", `Staleness check failed on ${dest.name}: ${e instanceof Error ? e.message : String(e)}`);
1769
+ }
1770
+ }
1771
+ const trimmed = staleFacts.slice(0, 3);
1772
+ if (trimmed.length > 0) {
1773
+ const staleLines = trimmed.map((f) => {
1432
1774
  const d = Math.round(daysSince(lastActivityDate(f.payload)));
1433
1775
  return ` • [${f.payload.category}] ${f.payload.content} (${d}d old, id: ${f.id})`;
1434
1776
  });