bikky 0.3.1 → 0.3.3

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 (342) hide show
  1. package/README.md +124 -35
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +7 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/config.d.ts +22 -3
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +61 -6
  9. package/dist/config.js.map +1 -1
  10. package/dist/config.test.js +17 -9
  11. package/dist/config.test.js.map +1 -1
  12. package/dist/daemon/capture-policy.d.ts +95 -0
  13. package/dist/daemon/capture-policy.d.ts.map +1 -0
  14. package/dist/daemon/capture-policy.js +139 -0
  15. package/dist/daemon/capture-policy.js.map +1 -0
  16. package/dist/daemon/capture-policy.test.d.ts +2 -0
  17. package/dist/daemon/capture-policy.test.d.ts.map +1 -0
  18. package/dist/daemon/capture-policy.test.js +46 -0
  19. package/dist/daemon/capture-policy.test.js.map +1 -0
  20. package/dist/daemon/consolidation.d.ts.map +1 -1
  21. package/dist/daemon/consolidation.js +84 -98
  22. package/dist/daemon/consolidation.js.map +1 -1
  23. package/dist/daemon/episode-summary.d.ts +72 -0
  24. package/dist/daemon/episode-summary.d.ts.map +1 -0
  25. package/dist/daemon/episode-summary.js +208 -0
  26. package/dist/daemon/episode-summary.js.map +1 -0
  27. package/dist/daemon/episode-summary.test.d.ts +2 -0
  28. package/dist/daemon/episode-summary.test.d.ts.map +1 -0
  29. package/dist/daemon/episode-summary.test.js +101 -0
  30. package/dist/daemon/episode-summary.test.js.map +1 -0
  31. package/dist/daemon/extraction.d.ts +25 -0
  32. package/dist/daemon/extraction.d.ts.map +1 -1
  33. package/dist/daemon/extraction.js +244 -124
  34. package/dist/daemon/extraction.js.map +1 -1
  35. package/dist/daemon/extraction.test.d.ts +2 -0
  36. package/dist/daemon/extraction.test.d.ts.map +1 -0
  37. package/dist/daemon/extraction.test.js +106 -0
  38. package/dist/daemon/extraction.test.js.map +1 -0
  39. package/dist/daemon/loop.d.ts.map +1 -1
  40. package/dist/daemon/loop.js +8 -6
  41. package/dist/daemon/loop.js.map +1 -1
  42. package/dist/daemon/qdrant.d.ts +59 -8
  43. package/dist/daemon/qdrant.d.ts.map +1 -1
  44. package/dist/daemon/qdrant.js +74 -23
  45. package/dist/daemon/qdrant.js.map +1 -1
  46. package/dist/daemon/qdrant.test.js +2 -2
  47. package/dist/daemon/qdrant.test.js.map +1 -1
  48. package/dist/daemon/relations.d.ts +6 -1
  49. package/dist/daemon/relations.d.ts.map +1 -1
  50. package/dist/daemon/relations.js +44 -63
  51. package/dist/daemon/relations.js.map +1 -1
  52. package/dist/daemon/session-index.d.ts +60 -0
  53. package/dist/daemon/session-index.d.ts.map +1 -0
  54. package/dist/daemon/session-index.js +136 -0
  55. package/dist/daemon/session-index.js.map +1 -0
  56. package/dist/daemon/session-index.test.d.ts +2 -0
  57. package/dist/daemon/session-index.test.d.ts.map +1 -0
  58. package/dist/daemon/session-index.test.js +54 -0
  59. package/dist/daemon/session-index.test.js.map +1 -0
  60. package/dist/daemon/session-summary.d.ts +69 -0
  61. package/dist/daemon/session-summary.d.ts.map +1 -0
  62. package/dist/daemon/session-summary.js +200 -0
  63. package/dist/daemon/session-summary.js.map +1 -0
  64. package/dist/daemon/session-summary.test.d.ts +2 -0
  65. package/dist/daemon/session-summary.test.d.ts.map +1 -0
  66. package/dist/daemon/session-summary.test.js +160 -0
  67. package/dist/daemon/session-summary.test.js.map +1 -0
  68. package/dist/daemon/staleness.test.d.ts +7 -0
  69. package/dist/daemon/staleness.test.d.ts.map +1 -0
  70. package/dist/daemon/staleness.test.js +128 -0
  71. package/dist/daemon/staleness.test.js.map +1 -0
  72. package/dist/daemon/workstream-summary.d.ts +61 -0
  73. package/dist/daemon/workstream-summary.d.ts.map +1 -0
  74. package/dist/daemon/workstream-summary.js +220 -0
  75. package/dist/daemon/workstream-summary.js.map +1 -0
  76. package/dist/daemon/workstream-summary.test.d.ts +2 -0
  77. package/dist/daemon/workstream-summary.test.d.ts.map +1 -0
  78. package/dist/daemon/workstream-summary.test.js +86 -0
  79. package/dist/daemon/workstream-summary.test.js.map +1 -0
  80. package/dist/lib/qdrant-client.d.ts +6 -1
  81. package/dist/lib/qdrant-client.d.ts.map +1 -1
  82. package/dist/lib/qdrant-client.js +3 -4
  83. package/dist/lib/qdrant-client.js.map +1 -1
  84. package/dist/lib/qdrant-client.test.js +21 -2
  85. package/dist/lib/qdrant-client.test.js.map +1 -1
  86. package/dist/lifecycle.test.d.ts +8 -0
  87. package/dist/lifecycle.test.d.ts.map +1 -0
  88. package/dist/lifecycle.test.js +74 -0
  89. package/dist/lifecycle.test.js.map +1 -0
  90. package/dist/llm/embedding/index.d.ts +42 -0
  91. package/dist/llm/embedding/index.d.ts.map +1 -0
  92. package/dist/llm/embedding/index.js +78 -0
  93. package/dist/llm/embedding/index.js.map +1 -0
  94. package/dist/llm/embedding/index.test.d.ts +8 -0
  95. package/dist/llm/embedding/index.test.d.ts.map +1 -0
  96. package/dist/llm/embedding/index.test.js +100 -0
  97. package/dist/llm/embedding/index.test.js.map +1 -0
  98. package/dist/llm/embedding/providers/bedrock.d.ts +16 -0
  99. package/dist/llm/embedding/providers/bedrock.d.ts.map +1 -0
  100. package/dist/llm/embedding/providers/bedrock.js +90 -0
  101. package/dist/llm/embedding/providers/bedrock.js.map +1 -0
  102. package/dist/llm/embedding/providers/bedrock.test.d.ts +2 -0
  103. package/dist/llm/embedding/providers/bedrock.test.d.ts.map +1 -0
  104. package/dist/llm/embedding/providers/bedrock.test.js +24 -0
  105. package/dist/llm/embedding/providers/bedrock.test.js.map +1 -0
  106. package/dist/llm/embedding/providers/index.d.ts +9 -0
  107. package/dist/llm/embedding/providers/index.d.ts.map +1 -0
  108. package/dist/llm/embedding/providers/index.js +9 -0
  109. package/dist/llm/embedding/providers/index.js.map +1 -0
  110. package/dist/llm/embedding/providers/ollama.d.ts +6 -0
  111. package/dist/llm/embedding/providers/ollama.d.ts.map +1 -0
  112. package/dist/llm/embedding/providers/ollama.js +39 -0
  113. package/dist/llm/embedding/providers/ollama.js.map +1 -0
  114. package/dist/llm/embedding/providers/ollama.test.d.ts +2 -0
  115. package/dist/llm/embedding/providers/ollama.test.d.ts.map +1 -0
  116. package/dist/llm/embedding/providers/ollama.test.js +54 -0
  117. package/dist/llm/embedding/providers/ollama.test.js.map +1 -0
  118. package/dist/llm/embedding/providers/openai.d.ts +6 -0
  119. package/dist/llm/embedding/providers/openai.d.ts.map +1 -0
  120. package/dist/llm/embedding/providers/openai.js +44 -0
  121. package/dist/llm/embedding/providers/openai.js.map +1 -0
  122. package/dist/llm/embedding/providers/openai.test.d.ts +2 -0
  123. package/dist/llm/embedding/providers/openai.test.d.ts.map +1 -0
  124. package/dist/llm/embedding/providers/openai.test.js +48 -0
  125. package/dist/llm/embedding/providers/openai.test.js.map +1 -0
  126. package/dist/llm/embedding/providers/portkey.d.ts +15 -0
  127. package/dist/llm/embedding/providers/portkey.d.ts.map +1 -0
  128. package/dist/llm/embedding/providers/portkey.js +58 -0
  129. package/dist/llm/embedding/providers/portkey.js.map +1 -0
  130. package/dist/llm/embedding/providers/portkey.test.d.ts +2 -0
  131. package/dist/llm/embedding/providers/portkey.test.d.ts.map +1 -0
  132. package/dist/llm/embedding/providers/portkey.test.js +56 -0
  133. package/dist/llm/embedding/providers/portkey.test.js.map +1 -0
  134. package/dist/llm/embedding/registry.d.ts +14 -0
  135. package/dist/llm/embedding/registry.d.ts.map +1 -0
  136. package/dist/llm/embedding/registry.js +27 -0
  137. package/dist/llm/embedding/registry.js.map +1 -0
  138. package/dist/llm/embedding/registry.test.d.ts +7 -0
  139. package/dist/llm/embedding/registry.test.d.ts.map +1 -0
  140. package/dist/llm/embedding/registry.test.js +68 -0
  141. package/dist/llm/embedding/registry.test.js.map +1 -0
  142. package/dist/llm/embedding/types.d.ts +55 -0
  143. package/dist/llm/embedding/types.d.ts.map +1 -0
  144. package/dist/llm/embedding/types.js +12 -0
  145. package/dist/llm/embedding/types.js.map +1 -0
  146. package/dist/llm/errors.d.ts +95 -0
  147. package/dist/llm/errors.d.ts.map +1 -0
  148. package/dist/llm/errors.js +164 -0
  149. package/dist/llm/errors.js.map +1 -0
  150. package/dist/llm/errors.test.d.ts +2 -0
  151. package/dist/llm/errors.test.d.ts.map +1 -0
  152. package/dist/llm/errors.test.js +103 -0
  153. package/dist/llm/errors.test.js.map +1 -0
  154. package/dist/llm/fetch.d.ts +39 -0
  155. package/dist/llm/fetch.d.ts.map +1 -0
  156. package/dist/llm/fetch.js +52 -0
  157. package/dist/llm/fetch.js.map +1 -0
  158. package/dist/llm/index.d.ts +6 -3
  159. package/dist/llm/index.d.ts.map +1 -1
  160. package/dist/llm/index.js +2 -2
  161. package/dist/llm/index.js.map +1 -1
  162. package/dist/llm/inference/index.d.ts +39 -0
  163. package/dist/llm/inference/index.d.ts.map +1 -0
  164. package/dist/llm/inference/index.js +118 -0
  165. package/dist/llm/inference/index.js.map +1 -0
  166. package/dist/llm/inference/index.test.d.ts +6 -0
  167. package/dist/llm/inference/index.test.d.ts.map +1 -0
  168. package/dist/llm/inference/index.test.js +109 -0
  169. package/dist/llm/inference/index.test.js.map +1 -0
  170. package/dist/llm/inference/providers/bedrock.d.ts +18 -0
  171. package/dist/llm/inference/providers/bedrock.d.ts.map +1 -0
  172. package/dist/llm/inference/providers/bedrock.js +105 -0
  173. package/dist/llm/inference/providers/bedrock.js.map +1 -0
  174. package/dist/llm/inference/providers/bedrock.test.d.ts +2 -0
  175. package/dist/llm/inference/providers/bedrock.test.d.ts.map +1 -0
  176. package/dist/llm/inference/providers/bedrock.test.js +21 -0
  177. package/dist/llm/inference/providers/bedrock.test.js.map +1 -0
  178. package/dist/llm/inference/providers/index.d.ts +10 -0
  179. package/dist/llm/inference/providers/index.d.ts.map +1 -0
  180. package/dist/llm/inference/providers/index.js +10 -0
  181. package/dist/llm/inference/providers/index.js.map +1 -0
  182. package/dist/llm/inference/providers/ollama.d.ts +8 -0
  183. package/dist/llm/inference/providers/ollama.d.ts.map +1 -0
  184. package/dist/llm/inference/providers/ollama.js +63 -0
  185. package/dist/llm/inference/providers/ollama.js.map +1 -0
  186. package/dist/llm/inference/providers/ollama.test.d.ts +2 -0
  187. package/dist/llm/inference/providers/ollama.test.d.ts.map +1 -0
  188. package/dist/llm/inference/providers/ollama.test.js +57 -0
  189. package/dist/llm/inference/providers/ollama.test.js.map +1 -0
  190. package/dist/llm/inference/providers/openai.d.ts +11 -0
  191. package/dist/llm/inference/providers/openai.d.ts.map +1 -0
  192. package/dist/llm/inference/providers/openai.js +73 -0
  193. package/dist/llm/inference/providers/openai.js.map +1 -0
  194. package/dist/llm/inference/providers/openai.test.d.ts +2 -0
  195. package/dist/llm/inference/providers/openai.test.d.ts.map +1 -0
  196. package/dist/llm/inference/providers/openai.test.js +46 -0
  197. package/dist/llm/inference/providers/openai.test.js.map +1 -0
  198. package/dist/llm/inference/providers/portkey.d.ts +13 -0
  199. package/dist/llm/inference/providers/portkey.d.ts.map +1 -0
  200. package/dist/llm/inference/providers/portkey.js +80 -0
  201. package/dist/llm/inference/providers/portkey.js.map +1 -0
  202. package/dist/llm/inference/providers/portkey.test.d.ts +2 -0
  203. package/dist/llm/inference/providers/portkey.test.d.ts.map +1 -0
  204. package/dist/llm/inference/providers/portkey.test.js +48 -0
  205. package/dist/llm/inference/providers/portkey.test.js.map +1 -0
  206. package/dist/llm/inference/registry.d.ts +15 -0
  207. package/dist/llm/inference/registry.d.ts.map +1 -0
  208. package/dist/llm/inference/registry.js +28 -0
  209. package/dist/llm/inference/registry.js.map +1 -0
  210. package/dist/llm/inference/registry.test.d.ts +6 -0
  211. package/dist/llm/inference/registry.test.d.ts.map +1 -0
  212. package/dist/llm/inference/registry.test.js +63 -0
  213. package/dist/llm/inference/registry.test.js.map +1 -0
  214. package/dist/llm/inference/types.d.ts +84 -0
  215. package/dist/llm/inference/types.d.ts.map +1 -0
  216. package/dist/llm/inference/types.js +9 -0
  217. package/dist/llm/inference/types.js.map +1 -0
  218. package/dist/llm/telemetry.d.ts +25 -0
  219. package/dist/llm/telemetry.d.ts.map +1 -0
  220. package/dist/llm/telemetry.js +43 -0
  221. package/dist/llm/telemetry.js.map +1 -0
  222. package/dist/llm/telemetry.test.d.ts +5 -0
  223. package/dist/llm/telemetry.test.d.ts.map +1 -0
  224. package/dist/llm/telemetry.test.js +89 -0
  225. package/dist/llm/telemetry.test.js.map +1 -0
  226. package/dist/llm/types.d.ts +4 -37
  227. package/dist/llm/types.d.ts.map +1 -1
  228. package/dist/llm/types.js +4 -1
  229. package/dist/llm/types.js.map +1 -1
  230. package/dist/logger.d.ts +18 -3
  231. package/dist/logger.d.ts.map +1 -1
  232. package/dist/logger.js +102 -20
  233. package/dist/logger.js.map +1 -1
  234. package/dist/logger.test.d.ts +5 -0
  235. package/dist/logger.test.d.ts.map +1 -0
  236. package/dist/logger.test.js +103 -0
  237. package/dist/logger.test.js.map +1 -0
  238. package/dist/mcp/api.d.ts +15 -1
  239. package/dist/mcp/api.d.ts.map +1 -1
  240. package/dist/mcp/api.js +44 -19
  241. package/dist/mcp/api.js.map +1 -1
  242. package/dist/mcp/api.test.d.ts +6 -0
  243. package/dist/mcp/api.test.d.ts.map +1 -0
  244. package/dist/mcp/api.test.js +130 -0
  245. package/dist/mcp/api.test.js.map +1 -0
  246. package/dist/mcp/helpers.d.ts +1 -0
  247. package/dist/mcp/helpers.d.ts.map +1 -1
  248. package/dist/mcp/helpers.js +62 -6
  249. package/dist/mcp/helpers.js.map +1 -1
  250. package/dist/mcp/helpers.test.js +71 -10
  251. package/dist/mcp/helpers.test.js.map +1 -1
  252. package/dist/mcp/index.d.ts +7 -1
  253. package/dist/mcp/index.d.ts.map +1 -1
  254. package/dist/mcp/index.js +46 -21
  255. package/dist/mcp/index.js.map +1 -1
  256. package/dist/mcp/taxonomy.d.ts +251 -31
  257. package/dist/mcp/taxonomy.d.ts.map +1 -1
  258. package/dist/mcp/taxonomy.js +603 -171
  259. package/dist/mcp/taxonomy.js.map +1 -1
  260. package/dist/mcp/taxonomy.test.d.ts +1 -1
  261. package/dist/mcp/taxonomy.test.js +141 -302
  262. package/dist/mcp/taxonomy.test.js.map +1 -1
  263. package/dist/mcp/tools.d.ts +1 -1
  264. package/dist/mcp/tools.d.ts.map +1 -1
  265. package/dist/mcp/tools.integration.itest.d.ts +23 -0
  266. package/dist/mcp/tools.integration.itest.d.ts.map +1 -0
  267. package/dist/mcp/tools.integration.itest.js +172 -0
  268. package/dist/mcp/tools.integration.itest.js.map +1 -0
  269. package/dist/mcp/tools.js +422 -357
  270. package/dist/mcp/tools.js.map +1 -1
  271. package/dist/mcp/tools.test.d.ts +16 -0
  272. package/dist/mcp/tools.test.d.ts.map +1 -0
  273. package/dist/mcp/tools.test.js +472 -0
  274. package/dist/mcp/tools.test.js.map +1 -0
  275. package/dist/mcp/types.d.ts +63 -8
  276. package/dist/mcp/types.d.ts.map +1 -1
  277. package/dist/prompts/brief.d.ts +19 -0
  278. package/dist/prompts/brief.d.ts.map +1 -0
  279. package/dist/prompts/brief.js +67 -0
  280. package/dist/prompts/brief.js.map +1 -0
  281. package/dist/prompts/contradiction.d.ts +24 -0
  282. package/dist/prompts/contradiction.d.ts.map +1 -0
  283. package/dist/prompts/contradiction.js +73 -0
  284. package/dist/prompts/contradiction.js.map +1 -0
  285. package/dist/prompts/distill.d.ts +21 -0
  286. package/dist/prompts/distill.d.ts.map +1 -0
  287. package/dist/prompts/distill.js +92 -0
  288. package/dist/prompts/distill.js.map +1 -0
  289. package/dist/prompts/episode-summary.d.ts +15 -0
  290. package/dist/prompts/episode-summary.d.ts.map +1 -0
  291. package/dist/prompts/episode-summary.js +60 -0
  292. package/dist/prompts/episode-summary.js.map +1 -0
  293. package/dist/prompts/extraction.d.ts +14 -0
  294. package/dist/prompts/extraction.d.ts.map +1 -0
  295. package/dist/prompts/extraction.js +110 -0
  296. package/dist/prompts/extraction.js.map +1 -0
  297. package/dist/prompts/index.d.ts +52 -0
  298. package/dist/prompts/index.d.ts.map +1 -0
  299. package/dist/prompts/index.js +104 -0
  300. package/dist/prompts/index.js.map +1 -0
  301. package/dist/prompts/prompts.test.d.ts +8 -0
  302. package/dist/prompts/prompts.test.d.ts.map +1 -0
  303. package/dist/prompts/prompts.test.js +140 -0
  304. package/dist/prompts/prompts.test.js.map +1 -0
  305. package/dist/prompts/relations.d.ts +17 -0
  306. package/dist/prompts/relations.d.ts.map +1 -0
  307. package/dist/prompts/relations.js +72 -0
  308. package/dist/prompts/relations.js.map +1 -0
  309. package/dist/prompts/workstream-summary.d.ts +17 -0
  310. package/dist/prompts/workstream-summary.d.ts.map +1 -0
  311. package/dist/prompts/workstream-summary.js +72 -0
  312. package/dist/prompts/workstream-summary.js.map +1 -0
  313. package/dist/render.d.ts +41 -0
  314. package/dist/render.d.ts.map +1 -0
  315. package/dist/render.js +185 -0
  316. package/dist/render.js.map +1 -0
  317. package/dist/render.test.d.ts +8 -0
  318. package/dist/render.test.d.ts.map +1 -0
  319. package/dist/render.test.js +243 -0
  320. package/dist/render.test.js.map +1 -0
  321. package/docs/diagrams/architecture.svg +87 -0
  322. package/docs/diagrams/team-memory.svg +250 -0
  323. package/docs/screenshots/dashboard.png +0 -0
  324. package/docs/screenshots/graph.png +0 -0
  325. package/docs/screenshots/memory.png +0 -0
  326. package/package.json +12 -3
  327. package/dist/llm/embedding.d.ts +0 -13
  328. package/dist/llm/embedding.d.ts.map +0 -1
  329. package/dist/llm/embedding.js +0 -127
  330. package/dist/llm/embedding.js.map +0 -1
  331. package/dist/llm/embedding.test.d.ts +0 -8
  332. package/dist/llm/embedding.test.d.ts.map +0 -1
  333. package/dist/llm/embedding.test.js +0 -117
  334. package/dist/llm/embedding.test.js.map +0 -1
  335. package/dist/llm/inference.d.ts +0 -12
  336. package/dist/llm/inference.d.ts.map +0 -1
  337. package/dist/llm/inference.js +0 -146
  338. package/dist/llm/inference.js.map +0 -1
  339. package/dist/llm/inference.test.d.ts +0 -8
  340. package/dist/llm/inference.test.d.ts.map +0 -1
  341. package/dist/llm/inference.test.js +0 -117
  342. package/dist/llm/inference.test.js.map +0 -1
package/dist/mcp/tools.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * MCP tool definitions all 12 memory tools.
2
+ * MCP tool definitions for memory.
3
3
  */
4
4
  import crypto from "node:crypto";
5
5
  import { z } from "zod";
6
- import { STALENESS_DAYS, THRESHOLD_DUPLICATE, THRESHOLD_RELATED, QDRANT_INDEXES, categoryValues, domainValues, kindValues, sourceValues, DEFAULT_DOMAIN, DEFAULT_KIND, DEFAULT_SOURCE, } from "./taxonomy.js";
7
- import { contentHash, daysSince, lastActivityDate, computeCombinedScore, buildFilter, formatFact, } from "./helpers.js";
8
- import { ready, qdrantUrl, qdrantApiKey, setQdrantUrl, setQdrantApiKey, setReady, getCollection, log, embed, getEmbeddingConfig, chatComplete, qdrantReq, ensureCollection, qdrantUpsert, qdrantSearch, qdrantScroll, qdrantSetPayload, qdrantGetPoints, } from "./api.js";
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
+ import { contentHash, daysSince, lastActivityDate, computeCombinedScore, buildFilter, formatFact, 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";
9
9
  import { saveConfig, loadConfig } from "../config.js";
10
10
  // ---------------------------------------------------------------------------
11
11
  // Runtime state
@@ -22,13 +22,50 @@ function nowISO() {
22
22
  function newId() {
23
23
  return crypto.randomUUID();
24
24
  }
25
+ function redactionOptions() {
26
+ return { enabled: false, redactPii: false };
27
+ }
28
+ function redactStorageText(text) {
29
+ return { text, redacted: false, summary: "none", matches: [] };
30
+ }
31
+ function combineRedactions(_items) {
32
+ return { redacted: false, summary: "none", matches: [] };
33
+ }
34
+ function resolveScope(workspaceId, includeLegacyWorkspace = false) {
35
+ return {
36
+ workspaceId: workspaceId?.trim() || undefined,
37
+ includeLegacy: includeLegacyWorkspace,
38
+ };
39
+ }
40
+ function scopedFilter(scope, extra = {}) {
41
+ return buildFilter({
42
+ ...extra,
43
+ workspace_id: scope.workspaceId,
44
+ includeLegacyWorkspace: scope.includeLegacy,
45
+ });
46
+ }
47
+ function addWorkspacePayload(payload, scope) {
48
+ if (scope.workspaceId)
49
+ payload["workspace_id"] = scope.workspaceId;
50
+ if (scope.actorId)
51
+ payload["actor_id"] = scope.actorId;
52
+ }
53
+ function addRedactionPayload(_payload, _summary) {
54
+ // Task 243 keeps storage pass-through; redaction policy is out of scope for this branch.
55
+ }
56
+ async function getPointForWorkspaceWrite(factId, _scope) {
57
+ const existing = await qdrantGetPoints([factId]);
58
+ const point = existing.result?.[0];
59
+ if (!point) {
60
+ return { error: { status: "not_found", fact_id: factId } };
61
+ }
62
+ return { point };
63
+ }
25
64
  function requireReady() {
26
65
  if (!ready) {
27
66
  const missing = [];
28
67
  if (!qdrantUrl)
29
68
  missing.push("qdrant-url");
30
- if (!qdrantApiKey)
31
- missing.push("qdrant-api-key");
32
69
  return {
33
70
  content: [{
34
71
  type: "text",
@@ -36,6 +73,10 @@ function requireReady() {
36
73
  status: "setup_required",
37
74
  ready: false,
38
75
  missing,
76
+ // Surface the underlying init failure (embedding / Qdrant) when
77
+ // present so users see an actionable reason instead of a generic
78
+ // "setup required" message.
79
+ ...(setupError ? { setup_error: setupError } : {}),
39
80
  setup_instructions: "Memory is not configured. Run `bikky setup` or call configure_credentials:\n" +
40
81
  "1. Go to cloud.qdrant.io → sign up (free tier: 1GB, no credit card)\n" +
41
82
  "2. Create a cluster → copy the REST URL and API key\n" +
@@ -52,14 +93,20 @@ function buildMemoryNudge() {
52
93
  if (elapsed < NUDGE_INTERVAL_MS)
53
94
  return null;
54
95
  const mins = Math.round(elapsed / 60000);
96
+ // Suggest the most likely category to record based on what an engineering
97
+ // session typically produces. The agent picks the best fit.
55
98
  return `🧠 Memory nudge: No memory_store calls in ${mins} minutes. ` +
56
- "If you've learned project facts, made key decisions, discovered service quirks, " +
57
- "or resolved errors store them now so future sessions benefit.";
99
+ "Reflect on what's worth persisting:\n" +
100
+ " infrastructurenew services, ports, configs touched?\n" +
101
+ " • decisions — architectural choices made (with rationale)?\n" +
102
+ " • observation — debugging findings, gotchas, workarounds?\n" +
103
+ " • projects — work-in-progress, blockers, completions?\n" +
104
+ "If yes, call memory_store now so future sessions inherit the knowledge.";
58
105
  }
59
106
  /**
60
107
  * Entity-graph traversal for memory_recall.
61
108
  */
62
- async function graphTraversal(primaryResults, limit) {
109
+ async function graphTraversal(primaryResults, limit, scope) {
63
110
  try {
64
111
  const primaryEntities = new Set();
65
112
  const primaryIds = new Set();
@@ -73,22 +120,16 @@ async function graphTraversal(primaryResults, limit) {
73
120
  return [];
74
121
  const relatedEntities = new Set();
75
122
  for (const entity of primaryEntities) {
76
- const outgoing = await qdrantScroll({
77
- must: [
78
- { key: "from_entity", match: { value: entity } },
79
- { is_null: { key: "superseded_by" } },
80
- ],
81
- }, 10).catch(() => ({ result: { points: [] } }));
123
+ const outgoingFilter = scopedFilter(scope, { excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
124
+ outgoingFilter.must.push({ key: "from_entity", match: { value: entity } });
125
+ const outgoing = await qdrantScroll(outgoingFilter, 10).catch(() => ({ result: { points: [] } }));
82
126
  for (const pt of (outgoing.result?.points ?? [])) {
83
127
  if (pt.payload.to_entity)
84
128
  relatedEntities.add(pt.payload.to_entity);
85
129
  }
86
- const incoming = await qdrantScroll({
87
- must: [
88
- { key: "to_entity", match: { value: entity } },
89
- { is_null: { key: "superseded_by" } },
90
- ],
91
- }, 10).catch(() => ({ result: { points: [] } }));
130
+ const incomingFilter = scopedFilter(scope, { excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
131
+ incomingFilter.must.push({ key: "to_entity", match: { value: entity } });
132
+ const incoming = await qdrantScroll(incomingFilter, 10).catch(() => ({ result: { points: [] } }));
92
133
  for (const pt of (incoming.result?.points ?? [])) {
93
134
  if (pt.payload.from_entity)
94
135
  relatedEntities.add(pt.payload.from_entity);
@@ -101,12 +142,9 @@ async function graphTraversal(primaryResults, limit) {
101
142
  const relatedFacts = [];
102
143
  const maxPerEntity = Math.max(2, Math.floor(limit / relatedEntities.size));
103
144
  for (const entity of relatedEntities) {
104
- const result = await qdrantScroll({
105
- must: [
106
- { key: "entities", match: { value: entity } },
107
- { is_null: { key: "superseded_by" } },
108
- ],
109
- }, maxPerEntity).catch(() => ({ result: { points: [] } }));
145
+ const filter = scopedFilter(scope, { excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS }) ?? { must: [] };
146
+ filter.must.push({ key: "entities", match: { value: entity } });
147
+ const result = await qdrantScroll(filter, maxPerEntity).catch(() => ({ result: { points: [] } }));
110
148
  for (const pt of (result.result?.points ?? [])) {
111
149
  if (!primaryIds.has(pt.id)) {
112
150
  relatedFacts.push(pt);
@@ -128,7 +166,11 @@ async function graphTraversal(primaryResults, limit) {
128
166
  // ---------------------------------------------------------------------------
129
167
  export function registerTools(mcp) {
130
168
  // ── get_setup_status ────────────────────────────────────────────────────
131
- mcp.tool("get_setup_status", "Check memory system status. Returns which credentials are configured and whether Qdrant and embeddings are reachable.", {}, async () => {
169
+ mcp.tool("get_setup_status", [
170
+ "Check whether the memory system is configured and reachable.",
171
+ "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.",
172
+ "Read-only — safe to call any time.",
173
+ ].join(" "), {}, async () => {
132
174
  const status = {
133
175
  ready,
134
176
  qdrant_url: !!qdrantUrl,
@@ -139,13 +181,13 @@ export function registerTools(mcp) {
139
181
  embedding_provider: getEmbeddingConfig().provider,
140
182
  embedding_model: getEmbeddingConfig().model,
141
183
  embedding_dimensions: getEmbeddingConfig().dimensions,
184
+ ...(setupError ? { setup_error: setupError } : {}),
142
185
  };
143
186
  const missing = status["missing"];
144
187
  if (!qdrantUrl)
145
188
  missing.push("qdrant-url");
146
- if (!qdrantApiKey)
147
- missing.push("qdrant-api-key");
148
- if (qdrantUrl && qdrantApiKey) {
189
+ // qdrant-api-key is optional (local / self-hosted Qdrant doesn't need it).
190
+ if (qdrantUrl) {
149
191
  try {
150
192
  await qdrantReq("GET", "/collections");
151
193
  status["qdrant_connected"] = true;
@@ -159,17 +201,21 @@ export function registerTools(mcp) {
159
201
  catch { /* ignore */ }
160
202
  if (!status["ready"] && missing.length > 0) {
161
203
  status["setup_instructions"] =
162
- "Run `bikky setup` or guide the user:\n" +
163
- "1. Go to cloud.qdrant.io sign up (free tier: 1GB, no credit card)\n" +
164
- "2. Create a cluster copy the REST URL and API key\n" +
165
- "3. Call configure_credentials with Qdrant values";
204
+ "Run `bikky setup` or guide the user. Pick one Qdrant option:\n" +
205
+ " Qdrant Cloud (managed, free tier, 1GB): https://cloud.qdrant.io copy the REST URL + API key\n" +
206
+ " Local Docker: `docker run -p 6333:6333 qdrant/qdrant` URL `http://localhost:6333` (no API key needed)\n" +
207
+ " Self-hosted: any reachable Qdrant; API key only required if QDRANT__SERVICE__API_KEY is set on the server\n" +
208
+ "Then call configure_credentials with the URL (and API key if applicable).";
166
209
  }
167
210
  return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
168
211
  });
169
212
  // ── configure_credentials ───────────────────────────────────────────────
170
- mcp.tool("configure_credentials", "Store Qdrant + embedding credentials in ~/.bikky/config.json. Tests connectivity and creates the collection if needed.", {
171
- qdrant_url: z.string().optional().describe("Qdrant Cloud REST URL (e.g. https://xxx.cloud.qdrant.io:6333)"),
172
- qdrant_api_key: z.string().optional().describe("Qdrant Cloud API key"),
213
+ mcp.tool("configure_credentials", [
214
+ "Persist Qdrant and embedding credentials to ~/.bikky/config.json and bring the memory system online.",
215
+ "Call this only during onboarding (or when rotating credentials). After it succeeds, the collection is created if missing and embeddings are tested. For day-to-day use, prefer get_setup_status.",
216
+ ].join(" "), {
217
+ qdrant_url: z.string().optional().describe("Qdrant REST URL — Qdrant Cloud (https://xxx.cloud.qdrant.io:6333), local Docker (http://localhost:6333), or self-hosted"),
218
+ qdrant_api_key: z.string().optional().describe("Qdrant API key — required for Qdrant Cloud; optional / leave blank for unauthenticated local or self-hosted instances"),
173
219
  openai_api_key: z.string().optional().describe("OpenAI API key (for OpenAI embedding/LLM provider)"),
174
220
  }, async ({ qdrant_url, qdrant_api_key, openai_api_key }) => {
175
221
  const results = {};
@@ -191,7 +237,7 @@ export function registerTools(mcp) {
191
237
  results["openai_api_key"] = "stored ✓";
192
238
  }
193
239
  saveConfig(cfg);
194
- if (qdrantUrl && qdrantApiKey) {
240
+ if (qdrantUrl) {
195
241
  try {
196
242
  await ensureCollection(QDRANT_INDEXES);
197
243
  results["qdrant_collection"] = `'${getCollection()}' ready ✓`;
@@ -208,14 +254,18 @@ export function registerTools(mcp) {
208
254
  catch (e) {
209
255
  results["embedding"] = `error: ${e instanceof Error ? e.message : String(e)}`;
210
256
  }
211
- setReady(!!(qdrantUrl && qdrantApiKey));
257
+ setReady(!!qdrantUrl);
212
258
  results["ready"] = ready;
213
259
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
214
260
  });
215
261
  // ── verify_connection ───────────────────────────────────────────────────
216
- mcp.tool("verify_connection", "Test that Qdrant is reachable, embeddings work, and the collection exists.", {}, async () => {
262
+ mcp.tool("verify_connection", [
263
+ "Confirm Qdrant is reachable, embeddings work, and the collection exists.",
264
+ "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.",
265
+ "Read-only.",
266
+ ].join(" "), {}, async () => {
217
267
  const results = { qdrant: false, embedding: false, collection: false };
218
- if (qdrantUrl && qdrantApiKey) {
268
+ if (qdrantUrl) {
219
269
  try {
220
270
  await qdrantReq("GET", "/collections");
221
271
  results["qdrant"] = true;
@@ -242,42 +292,84 @@ export function registerTools(mcp) {
242
292
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
243
293
  });
244
294
  // ── memory_store ────────────────────────────────────────────────────────
245
- mcp.tool("memory_store", "Store a new fact in memory. Automatically deduplicates via content hash and vector similarity. " +
246
- "Returns the action taken (inserted/reinforced/duplicate) and any similar facts found.", {
247
- content: z.string().describe("The fact to store (atomic, single piece of knowledge)"),
248
- category: z.enum(categoryValues())
249
- .describe("Topic: infrastructure, decisions, observation, preferences, projects, team"),
250
- entities: z.array(z.string()).describe("Related entities (lowercase, e.g. ['qdrant', 'platform'])"),
251
- domain: z.enum(domainValues()).default(DEFAULT_DOMAIN)
252
- .describe("Life scope work or personal"),
253
- kind: z.enum(kindValues()).default(DEFAULT_KIND)
254
- .describe("Knowledge form fact, summary, distilled, relation"),
255
- source: z.enum(sourceValues()).default(DEFAULT_SOURCE)
256
- .describe("Creator — agent, daemon, system, user"),
257
- confidence: z.number().min(0).max(1).default(0.9).describe("How certain (0.0-1.0)"),
258
- importance: z.number().min(0).max(1).optional().describe("How important (0.0-1.0). Omit to default to 0.5."),
259
- supersedes: z.string().optional().describe("ID of a fact this one replaces"),
295
+ mcp.tool("memory_store", [
296
+ "Persist one atomic fact to long-term memory.",
297
+ "Call this whenever you learn something a future session would need: a service detail, a decision rationale, a workaround, a user preference, an ownership fact, a task-resume pointer. One fact per call split compound observations into separate calls.",
298
+ "Dedup is automatic (content hash + vector similarity), so you do NOT need to recall first. The tool returns one of: inserted (new fact), reinforced (exact or near-duplicate found — counters bumped), or — if there are similar-but-different facts — a list of potential conflicts so you can decide whether to use 'supersedes'.",
299
+ "To create a typed edge between two entities at the same time, set the optional 'relation' field — no separate tool call needed.",
300
+ "Do NOT use for ephemeral state (current cursor, in-flight todo). Use the harness task folder instead.",
301
+ ].join(" "), {
302
+ content: z.string().describe("The fact to store. Should be one atomic, self-contained statement (no compound 'A and B') that makes sense out of context."),
303
+ category: z.enum(categoryValues()).describe(categoryEnumDescription()),
304
+ entities: z.array(z.string()).describe("Lowercase entity names mentioned by this fact (e.g. ['qdrant', 'workspace_id']). Used for entity-scoped recall and graph traversal — keep them short and canonical."),
305
+ domain: z.enum(domainValues()).default(DEFAULT_DOMAIN).describe(domainEnumDescription()),
306
+ kind: z.enum(kindValues()).default(DEFAULT_KIND).describe(kindEnumDescription()),
307
+ memory_subtype: z.enum(memorySubtypeValues()).optional().describe(memorySubtypeEnumDescription()),
308
+ workspace_id: z.string().optional().describe("Workspace namespace for team-shared memory. Omit to use the default workspace from config."),
309
+ episode_id: z.string().optional().describe("Coherent activity-segment ID. Group facts captured during the same coherent task or transcript."),
310
+ workstream_key: z.string().optional().describe("Durable continuity key for a long-running objective (survives across sessions)."),
311
+ task_key: z.string().optional().describe("Task or issue key (e.g. GitHub issue number, JIRA key)."),
312
+ repo: z.string().optional().describe("Repository or project surface this fact relates to (e.g. 'bikky-dev/bikky')."),
313
+ branch: z.string().optional().describe("Branch or working surface (e.g. 'main', 'feat/x')."),
314
+ review_status: z.enum(["candidate", "reviewed", "approved", "rejected"]).optional().describe("Review lifecycle status. candidate=auto-extracted (daemon), reviewed=human-checked, approved=human-confirmed, rejected=incorrect. Agents normally leave this unset."),
315
+ source: z.enum(sourceValues()).default(DEFAULT_SOURCE).describe(sourceEnumDescription()),
316
+ confidence: z.number().min(0).max(1).default(0.9).describe("How certain you are this fact is correct (0.0-1.0). Default 0.9. Lower (~0.6) for inferred or unverified facts."),
317
+ importance: z.number().min(0).max(1).optional().describe("How important this fact is for future recall (0.0-1.0). Defaults to 0.5 if omitted. ≥0.8 surfaces in session briefings."),
318
+ supersedes: z.string().optional().describe("ID of an existing fact that this one replaces. The old fact is marked superseded and excluded from recall. Use this when a fact is updated; use memory_forget when a fact was simply wrong."),
260
319
  relation: z.object({
261
- from: z.string().describe("Source entity"),
262
- type: z.string().describe("Relation type (owns, uses, decided, prefers, works-on, etc.)"),
263
- to: z.string().describe("Target entity"),
264
- }).optional().describe("Optional typed relation between two entities"),
265
- metadata: z.record(z.string(), z.string()).optional()
266
- .describe("Optional key-value metadata. Stored with the fact and filterable via memory_recall."),
267
- }, async ({ content, category, entities, domain, kind, source, confidence, importance, supersedes, relation, metadata }) => {
320
+ from: z.string().describe("Source entity (lowercase)."),
321
+ type: z.string().describe("Relation type (e.g. 'owns', 'uses', 'decided', 'prefers', 'works-on')."),
322
+ to: z.string().describe("Target entity (lowercase)."),
323
+ }).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."),
324
+ 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)."),
325
+ }, async ({ content, category, entities, domain, kind, memory_subtype, workspace_id, episode_id, workstream_key, task_key, repo, branch, review_status, source, confidence, importance, supersedes, relation, metadata, }) => {
268
326
  const guard = requireReady();
269
327
  if (guard)
270
328
  return guard;
271
329
  lastStoreTime = Date.now();
272
330
  const now = nowISO();
273
- const hash = contentHash(category, content);
274
- const normalizedEntities = entities.map((e) => e.toLowerCase());
331
+ const scope = resolveScope(workspace_id);
332
+ const normalizedKind = normalizeKind(kind);
333
+ let normalizedSubtype = null;
334
+ try {
335
+ normalizedSubtype = validateMemorySubtype(normalizedKind, memory_subtype);
336
+ }
337
+ catch (e) {
338
+ return {
339
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
340
+ isError: true,
341
+ };
342
+ }
343
+ const normalizedCategory = normalizedSubtype
344
+ ? categoryForMemorySubtype(normalizedSubtype) ?? normalizeCategory(category)
345
+ : normalizeCategory(category);
346
+ const normalizedDomain = normalizeDomain(domain);
347
+ const normalizedLayer = normalizedSubtype ? layerForMemorySubtype(normalizedSubtype) : null;
348
+ const redactedContent = redactStorageText(content);
349
+ const redactedEntities = entities.map((entity) => redactStorageText(entity));
350
+ const sanitizedEntities = redactedEntities.map((entity) => entity.text);
351
+ const redactedRelation = relation ? {
352
+ from: redactStorageText(relation.from),
353
+ type: redactStorageText(relation.type),
354
+ to: redactStorageText(relation.to),
355
+ } : null;
356
+ const redactionSummary = combineRedactions([
357
+ redactedContent,
358
+ ...redactedEntities,
359
+ ...(redactedRelation ? [redactedRelation.from, redactedRelation.type, redactedRelation.to] : []),
360
+ ]);
361
+ const hash = contentHash(normalizedCategory, redactedContent.text);
362
+ const normalizedEntities = sanitizedEntities.map((e) => e.toLowerCase());
363
+ const sanitizedRelation = redactedRelation ? {
364
+ from: redactedRelation.from.text,
365
+ type: redactedRelation.type.text,
366
+ to: redactedRelation.to.text,
367
+ } : null;
275
368
  // 1. Exact dedup via content hash
276
369
  try {
277
- const existing = await qdrantScroll({ must: [
278
- { key: "content_hash", match: { value: hash } },
279
- { is_null: { key: "superseded_by" } },
280
- ] }, 1);
370
+ const hashFilter = scopedFilter(scope) ?? { must: [] };
371
+ hashFilter.must.push({ key: "content_hash", match: { value: hash } });
372
+ const existing = await qdrantScroll(hashFilter, 1);
281
373
  const existingPoint = existing.result?.points?.[0];
282
374
  if (existingPoint) {
283
375
  const point = existingPoint;
@@ -301,17 +393,16 @@ export function registerTools(mcp) {
301
393
  log("WARN", `Hash dedup check failed: ${e instanceof Error ? e.message : String(e)}`);
302
394
  }
303
395
  // 2. Generate embedding
304
- const vector = await embed(content);
396
+ const vector = await embed(redactedContent.text);
305
397
  // 3. Semantic dedup
306
398
  let similarFacts = [];
307
399
  let potentialConflicts = [];
308
400
  try {
309
- const filter = { must: [] };
401
+ const filter = scopedFilter(scope) ?? { must: [] };
310
402
  if (normalizedEntities.length > 0) {
311
403
  filter.must.push({ key: "entities", match: { any: normalizedEntities } });
312
404
  }
313
- filter.must.push({ is_null: { key: "superseded_by" } });
314
- const results = await qdrantSearch(vector, filter.must.length > 0 ? filter : undefined, 3);
405
+ const results = await qdrantSearch(vector, filter, 3);
315
406
  const firstResult = results.result?.[0];
316
407
  if (results.result?.length > 0 && firstResult) {
317
408
  const topScore = firstResult.score ?? 0;
@@ -368,6 +459,10 @@ export function registerTools(mcp) {
368
459
  // 5. Supersede old fact if requested
369
460
  if (supersedes) {
370
461
  try {
462
+ const existing = await getPointForWorkspaceWrite(supersedes, scope);
463
+ if (existing.error) {
464
+ return { content: [{ type: "text", text: JSON.stringify(existing.error, null, 2) }], isError: true };
465
+ }
371
466
  await qdrantSetPayload([supersedes], {
372
467
  superseded_by: factId,
373
468
  superseded_at: now,
@@ -379,10 +474,10 @@ export function registerTools(mcp) {
379
474
  }
380
475
  // 6. Insert new fact
381
476
  const payload = {
382
- content,
383
- category,
384
- domain,
385
- kind,
477
+ content: redactedContent.text,
478
+ category: normalizedCategory,
479
+ domain: normalizedDomain,
480
+ kind: normalizedKind,
386
481
  entities: normalizedEntities,
387
482
  source,
388
483
  confidence,
@@ -395,22 +490,43 @@ export function registerTools(mcp) {
395
490
  created_at: now,
396
491
  updated_at: now,
397
492
  };
493
+ if (normalizedSubtype) {
494
+ payload["memory_subtype"] = normalizedSubtype;
495
+ }
496
+ if (normalizedLayer) {
497
+ payload["layer"] = normalizedLayer;
498
+ }
499
+ if (episode_id)
500
+ payload["episode_id"] = episode_id;
501
+ if (workstream_key)
502
+ payload["workstream_key"] = workstream_key;
503
+ if (task_key)
504
+ payload["task_key"] = task_key;
505
+ if (repo)
506
+ payload["repo"] = repo;
507
+ if (branch)
508
+ payload["branch"] = branch;
509
+ if (review_status)
510
+ payload["review_status"] = review_status;
511
+ addWorkspacePayload(payload, scope);
512
+ addRedactionPayload(payload, redactionSummary);
398
513
  if (metadata && Object.keys(metadata).length > 0) {
399
514
  payload["metadata"] = metadata;
400
515
  }
401
516
  await qdrantUpsert(factId, vector, payload);
402
517
  // 7. Insert relation point if provided
403
518
  let relationId = null;
404
- if (relation) {
519
+ if (sanitizedRelation) {
405
520
  relationId = newId();
406
- const relContent = `${relation.from} ${relation.type} ${relation.to}`;
521
+ const relContent = `${sanitizedRelation.from} ${sanitizedRelation.type} ${sanitizedRelation.to}`;
407
522
  const relVector = await embed(relContent);
408
523
  const relPayload = {
409
524
  content: relContent,
410
- category,
411
- domain,
525
+ category: normalizedCategory,
526
+ domain: normalizedDomain,
412
527
  kind: "relation",
413
- entities: [relation.from.toLowerCase(), relation.to.toLowerCase()],
528
+ layer: "memory_object",
529
+ entities: [sanitizedRelation.from.toLowerCase(), sanitizedRelation.to.toLowerCase()],
414
530
  source,
415
531
  confidence,
416
532
  content_hash: contentHash("relation", relContent),
@@ -420,18 +536,23 @@ export function registerTools(mcp) {
420
536
  superseded_at: null,
421
537
  created_at: now,
422
538
  updated_at: now,
423
- from_entity: relation.from.toLowerCase(),
424
- relation_type: relation.type.toLowerCase(),
425
- to_entity: relation.to.toLowerCase(),
539
+ from_entity: sanitizedRelation.from.toLowerCase(),
540
+ relation_type: sanitizedRelation.type.toLowerCase(),
541
+ to_entity: sanitizedRelation.to.toLowerCase(),
426
542
  };
543
+ addWorkspacePayload(relPayload, scope);
544
+ addRedactionPayload(relPayload, redactionSummary);
427
545
  await qdrantUpsert(relationId, relVector, relPayload);
428
546
  }
429
547
  const result = {
430
548
  action: "inserted",
431
549
  fact_id: factId,
550
+ workspace_id: scope.workspaceId,
432
551
  };
433
552
  if (relationId)
434
553
  result["relation_id"] = relationId;
554
+ if (redactionSummary.redacted)
555
+ result["redaction"] = redactionSummary;
435
556
  if (similarFacts.length > 0)
436
557
  result["similar_facts"] = similarFacts;
437
558
  if (potentialConflicts.length > 0) {
@@ -443,26 +564,72 @@ export function registerTools(mcp) {
443
564
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
444
565
  });
445
566
  // ── memory_recall ───────────────────────────────────────────────────────
446
- mcp.tool("memory_recall", "Semantic search over memory. Returns facts ranked by relevance. " +
447
- "Use on session start with a broad query for context briefing.", {
448
- query: z.string().describe("What to search for (natural language)"),
449
- category: z.string().optional().describe("Filter by category"),
450
- domain: z.string().optional().describe("Filter by domain (work or personal)"),
451
- kind: z.string().optional().describe("Filter by kind (fact, summary, distilled, relation)"),
452
- entity: z.string().optional().describe("Filter by entity name"),
453
- since: z.string().optional().describe("Only facts created after this ISO date"),
454
- until: z.string().optional().describe("Only facts created before this ISO date"),
455
- limit: z.number().optional().default(10).describe("Max results (default 10)"),
456
- graph_depth: z.number().optional().default(0).describe("Entity graph traversal depth (0=none, 1=include 1-hop related entity facts)."),
457
- metadata_filter: z.record(z.string(), z.string()).optional()
458
- .describe("Filter by metadata key-value pairs. All pairs must match."),
459
- }, async ({ query, category, domain, kind, entity, since, until, limit, graph_depth, metadata_filter }) => {
567
+ mcp.tool("memory_recall", [
568
+ "Semantic + filtered search over memory. Returns facts ranked by relevance (vector similarity blended with recency, importance, and reinforcement).",
569
+ "Three main uses:",
570
+ " 1. Session-start briefing — broad query like 'session briefing: user preferences, active projects, recent decisions'.",
571
+ " 2. Per-prompt contextual recall focused query derived from what the user just asked.",
572
+ " 3. Pre-store conflict check recall similar facts before storing, so you can use 'supersedes' if the new fact replaces an older one.",
573
+ "Combine the natural-language query with structured filters (category, domain, entity, date range, metadata) for tighter results.",
574
+ "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.",
575
+ ].join("\n"), {
576
+ query: z.string().describe("Natural-language description of what you're looking for. Embedded and matched semantically — full sentences work better than keyword lists."),
577
+ category: z.string().optional().describe("Filter by category (same vocabulary as memory_store.category). Optional."),
578
+ domain: z.string().optional().describe("Filter by domain activity profile (same vocabulary as memory_store.domain). Optional."),
579
+ kind: z.string().optional().describe("Filter by kind: fact, summary, distilled, relation. Optional. Telemetry is excluded by default."),
580
+ memory_subtype: z.string().optional().describe("Filter by memory subtype (must be valid for the chosen kind). Optional."),
581
+ workspace_id: z.string().optional().describe("Filter to facts in this workspace namespace. Omit to use the default workspace from config."),
582
+ 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."),
583
+ entity: z.string().optional().describe("Restrict to facts mentioning this entity (case-insensitive). For full entity context prefer memory_entity."),
584
+ episode_id: z.string().optional().describe("Filter by coherent episode ID."),
585
+ workstream_key: z.string().optional().describe("Filter by durable workstream key."),
586
+ task_key: z.string().optional().describe("Filter by task or issue key."),
587
+ repo: z.string().optional().describe("Filter by repository or project surface."),
588
+ branch: z.string().optional().describe("Filter by branch or working surface."),
589
+ review_status: z.string().optional().describe("Filter by review lifecycle status (candidate / reviewed / approved / rejected)."),
590
+ since: z.string().optional().describe("Only facts created on or after this ISO 8601 date or datetime."),
591
+ until: z.string().optional().describe("Only facts created on or before this ISO 8601 date or datetime."),
592
+ limit: z.number().optional().default(10).describe("Max results to return (default 10)."),
593
+ graph_depth: z.number().optional().default(0).describe("Entity-graph traversal depth. 0 = vector search only (fast, default). 1 = also surface 1-hop entity-related facts (slower; use when the user asks 'what's connected to X?')."),
594
+ 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)."),
595
+ }, async ({ query, category, domain, kind, memory_subtype, workspace_id, include_legacy_workspace, entity, episode_id, workstream_key, task_key, repo, branch, review_status, since, until, limit, graph_depth, metadata_filter, }) => {
460
596
  const guard = requireReady();
461
597
  if (guard)
462
598
  return guard;
463
599
  const requestedLimit = limit ?? 10;
464
- const vector = await embed(query);
465
- const filter = buildFilter({ category, domain, kind, entity, since, until, metadata: metadata_filter });
600
+ const scope = resolveScope(workspace_id, include_legacy_workspace);
601
+ const redactedQuery = redactStorageText(query);
602
+ const vector = await embed(redactedQuery.text);
603
+ const normalizedKind = kind ? normalizeKind(kind) : undefined;
604
+ let normalizedSubtype;
605
+ if (memory_subtype) {
606
+ try {
607
+ normalizedSubtype = validateMemorySubtype(normalizedKind, memory_subtype) ?? undefined;
608
+ }
609
+ catch (e) {
610
+ return {
611
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
612
+ isError: true,
613
+ };
614
+ }
615
+ }
616
+ const filter = scopedFilter(scope, {
617
+ category: category ? normalizeCategory(category) : undefined,
618
+ domain: domain ? normalizeDomain(domain) : undefined,
619
+ kind: normalizedKind,
620
+ memory_subtype: normalizedSubtype,
621
+ entity,
622
+ episode_id,
623
+ workstream_key,
624
+ task_key,
625
+ repo,
626
+ branch,
627
+ review_status,
628
+ since,
629
+ until,
630
+ metadata: metadata_filter,
631
+ excludeKinds: MEMORY_RECALL_EXCLUDED_KINDS,
632
+ });
466
633
  const results = await qdrantSearch(vector, filter, requestedLimit * 2);
467
634
  if (!results.result?.length) {
468
635
  const nudge = buildMemoryNudge();
@@ -475,7 +642,7 @@ export function registerTools(mcp) {
475
642
  .slice(0, requestedLimit);
476
643
  const lines = ranked.map((r) => formatFact(r));
477
644
  if ((graph_depth ?? 0) >= 1) {
478
- const relatedLines = await graphTraversal(ranked, requestedLimit);
645
+ const relatedLines = await graphTraversal(ranked, requestedLimit, scope);
479
646
  if (relatedLines.length > 0) {
480
647
  lines.push("", "── Related (1-hop) ──");
481
648
  lines.push(...relatedLines);
@@ -487,28 +654,30 @@ export function registerTools(mcp) {
487
654
  return { content: [{ type: "text", text: lines.join("\n") }] };
488
655
  });
489
656
  // ── memory_entity ───────────────────────────────────────────────────────
490
- mcp.tool("memory_entity", "Get everything known about an entity — all facts mentioning it plus its relationships.", {
491
- name: z.string().describe("Entity name (e.g. 'qdrant', 'platform')"),
492
- limit: z.number().optional().default(20).describe("Max facts to return"),
493
- }, async ({ name, limit }) => {
657
+ mcp.tool("memory_entity", [
658
+ "Get everything bikky knows about a specific entity — facts mentioning it plus typed relations into and out of it.",
659
+ "Prefer this over memory_recall when the user asks 'tell me about X' or 'what do we know about X' and X is a known entity name (service, person, repo, concept). Faster and more complete than semantic search for entity-centric queries.",
660
+ "If you only have a fuzzy description, use memory_recall first to find the entity name.",
661
+ ].join(" "), {
662
+ name: z.string().describe("Entity name (case-insensitive, e.g. 'qdrant', 'workspace_id'). Should match the lowercase canonical form used when facts were stored."),
663
+ limit: z.number().optional().default(20).describe("Max facts to return (default 20). Relations are always returned in full, capped at 50 each direction."),
664
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
665
+ include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
666
+ }, async ({ name, limit, workspace_id, include_legacy_workspace }) => {
494
667
  const guard = requireReady();
495
668
  if (guard)
496
669
  return guard;
497
670
  const entityName = name.toLowerCase();
498
- const facts = await qdrantScroll({
499
- must: [
500
- { key: "entities", match: { value: entityName } },
501
- { is_null: { key: "superseded_by" } },
502
- ],
503
- }, limit ?? 20);
504
- const relationsFrom = await qdrantScroll({ must: [
505
- { key: "from_entity", match: { value: entityName } },
506
- { is_null: { key: "superseded_by" } },
507
- ] }, 50);
508
- const relationsTo = await qdrantScroll({ must: [
509
- { key: "to_entity", match: { value: entityName } },
510
- { is_null: { key: "superseded_by" } },
511
- ] }, 50);
671
+ const scope = resolveScope(workspace_id, include_legacy_workspace);
672
+ const factsFilter = scopedFilter(scope) ?? { must: [] };
673
+ factsFilter.must.push({ key: "entities", match: { value: entityName } });
674
+ const facts = await qdrantScroll(factsFilter, limit ?? 20);
675
+ const fromFilter = scopedFilter(scope) ?? { must: [] };
676
+ fromFilter.must.push({ key: "from_entity", match: { value: entityName } });
677
+ const relationsFrom = await qdrantScroll(fromFilter, 50);
678
+ const toFilter = scopedFilter(scope) ?? { must: [] };
679
+ toFilter.must.push({ key: "to_entity", match: { value: entityName } });
680
+ const relationsTo = await qdrantScroll(toFilter, 50);
512
681
  const output = [];
513
682
  const factPoints = facts.result?.points ?? [];
514
683
  if (factPoints.length > 0) {
@@ -543,22 +712,26 @@ export function registerTools(mcp) {
543
712
  return { content: [{ type: "text", text: output.join("\n") }] };
544
713
  });
545
714
  // ── memory_relations ────────────────────────────────────────────────────
546
- mcp.tool("memory_relations", "Query entity relationships. Returns typed edges between entities.", {
547
- entity: z.string().describe("Entity name to query"),
548
- relation_type: z.string().optional().describe("Filter by relation type (e.g. 'owns', 'uses', 'decided')"),
549
- direction: z.enum(["from", "to", "both"]).optional().default("both")
550
- .describe("Direction: 'from' (entity as source), 'to' (entity as target), 'both'"),
551
- }, async ({ entity, relation_type, direction }) => {
715
+ mcp.tool("memory_relations", [
716
+ "Query typed edges between entities. Returns 'A --[type]--> B' triples that semantic search alone wouldn't surface.",
717
+ "Use for 'what does X own / use / depend on?' and 'who owns Y?' style questions. Optionally filter by direction (from / to / both) and relation type.",
718
+ "To create relations, use memory_store with the 'relation' field — there is no separate create-relation tool.",
719
+ ].join(" "), {
720
+ entity: z.string().describe("Entity name to query (case-insensitive)."),
721
+ relation_type: z.string().optional().describe("Filter to a specific edge label (e.g. 'owns', 'uses', 'decided', 'prefers', 'works-on'). Optional."),
722
+ 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)."),
723
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
724
+ include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
725
+ }, async ({ entity, relation_type, direction, workspace_id, include_legacy_workspace }) => {
552
726
  const guard = requireReady();
553
727
  if (guard)
554
728
  return guard;
555
729
  const entityName = entity.toLowerCase();
730
+ const scope = resolveScope(workspace_id, include_legacy_workspace);
556
731
  const results = [];
557
732
  if (direction === "from" || direction === "both") {
558
- const filter = { must: [
559
- { key: "from_entity", match: { value: entityName } },
560
- { is_null: { key: "superseded_by" } },
561
- ] };
733
+ const filter = scopedFilter(scope) ?? { must: [] };
734
+ filter.must.push({ key: "from_entity", match: { value: entityName } });
562
735
  if (relation_type) {
563
736
  filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
564
737
  }
@@ -566,10 +739,8 @@ export function registerTools(mcp) {
566
739
  results.push(...(r.result?.points ?? []));
567
740
  }
568
741
  if (direction === "to" || direction === "both") {
569
- const filter = { must: [
570
- { key: "to_entity", match: { value: entityName } },
571
- { is_null: { key: "superseded_by" } },
572
- ] };
742
+ const filter = scopedFilter(scope) ?? { must: [] };
743
+ filter.must.push({ key: "to_entity", match: { value: entityName } });
573
744
  if (relation_type) {
574
745
  filter.must.push({ key: "relation_type", match: { value: relation_type.toLowerCase() } });
575
746
  }
@@ -593,38 +764,62 @@ export function registerTools(mcp) {
593
764
  return { content: [{ type: "text", text: lines.join("\n") }] };
594
765
  });
595
766
  // ── memory_forget ───────────────────────────────────────────────────────
596
- mcp.tool("memory_forget", "Mark a fact as superseded/wrong. The fact remains but is excluded from recall results.", {
597
- fact_id: z.string().describe("ID of the fact to forget"),
598
- reason: z.string().describe("Why this fact is being superseded"),
599
- }, async ({ fact_id, reason }) => {
767
+ mcp.tool("memory_forget", [
768
+ "Mark a fact as superseded/wrong. The fact stays in storage (for audit) but is excluded from all recall results.",
769
+ "Use this when a fact was simply incorrect or no longer applies and there is no replacement. If you have a corrected version, use memory_store with 'supersedes: <fact_id>' instead — that way the new fact stays linked to the old one.",
770
+ ].join(" "), {
771
+ fact_id: z.string().describe("ID of the fact to forget (returned by memory_store / memory_recall as 'id')."),
772
+ reason: z.string().describe("Short human-readable reason this fact is being retired (stored in 'superseded_by' for future audit)."),
773
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
774
+ }, async ({ fact_id, reason, workspace_id }) => {
600
775
  const guard = requireReady();
601
776
  if (guard)
602
777
  return guard;
603
778
  const now = nowISO();
604
779
  try {
780
+ const scope = resolveScope(workspace_id);
781
+ const existing = await getPointForWorkspaceWrite(fact_id, scope);
782
+ if (existing.error) {
783
+ return { content: [{ type: "text", text: JSON.stringify(existing.error, null, 2) }], isError: true };
784
+ }
785
+ const redactedReason = redactStorageText(reason);
605
786
  await qdrantSetPayload([fact_id], {
606
- superseded_by: `forgotten:${reason}`,
787
+ superseded_by: `forgotten:${redactedReason.text}`,
607
788
  superseded_at: now,
608
789
  updated_at: now,
609
790
  });
610
- return { content: [{ type: "text", text: JSON.stringify({ status: "forgotten", fact_id, reason }) }] };
791
+ return { content: [{ type: "text", text: JSON.stringify({
792
+ status: "forgotten",
793
+ fact_id,
794
+ reason: redactedReason.text,
795
+ ...(redactedReason.redacted ? { redaction: redactedReason } : {}),
796
+ }) }] };
611
797
  }
612
798
  catch (e) {
613
799
  return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
614
800
  }
615
801
  });
616
802
  // ── memory_verify ───────────────────────────────────────────────────────
617
- mcp.tool("memory_verify", "Confirm a fact is still accurate. Resets the staleness clock and bumps verification count.", {
618
- fact_id: z.string().describe("ID of the fact to verify"),
619
- }, async ({ fact_id }) => {
803
+ mcp.tool("memory_verify", [
804
+ "Confirm an existing fact is still accurate, without re-storing it. Resets the staleness clock and bumps a verification counter.",
805
+ "Use this when memory_heartbeat surfaces a stale fact ID and you can confirm it's still true (e.g. you just observed the system in that state). Lighter than memory_store(supersedes:) — same content, fresh timestamp.",
806
+ "If the fact is no longer true, use memory_forget or memory_store(supersedes:) instead.",
807
+ ].join(" "), {
808
+ fact_id: z.string().describe("ID of the fact to verify (from memory_recall or memory_heartbeat)."),
809
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
810
+ }, async ({ fact_id, workspace_id }) => {
620
811
  const guard = requireReady();
621
812
  if (guard)
622
813
  return guard;
623
814
  const now = nowISO();
624
815
  try {
625
- const existing = await qdrantGetPoints([fact_id]).catch(() => null);
816
+ const scope = resolveScope(workspace_id);
817
+ const writable = await getPointForWorkspaceWrite(fact_id, scope);
818
+ if (writable.error) {
819
+ return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
820
+ }
626
821
  let currentCount = 0;
627
- const existingPt = existing?.result?.[0];
822
+ const existingPt = writable.point;
628
823
  if (existingPt) {
629
824
  currentCount = existingPt.payload.verification_count ?? 0;
630
825
  }
@@ -649,24 +844,26 @@ export function registerTools(mcp) {
649
844
  }
650
845
  });
651
846
  // ── memory_review ───────────────────────────────────────────────────────
652
- mcp.tool("memory_review", "Review recent daemon-extracted facts. Supports approve (verify), reject (forget), or correct (supersede with edited text).", {
653
- limit: z.number().optional().default(10).describe("Max facts to return (default 10)"),
654
- action: z.enum(["list", "approve", "reject", "correct"]).optional().default("list")
655
- .describe("Action: list, approve, reject, correct"),
656
- fact_id: z.string().optional().describe("Fact ID (required for approve/reject/correct)"),
657
- reason: z.string().optional().describe("Reason for rejection"),
658
- corrected_content: z.string().optional().describe("Corrected fact text (for correct action)"),
659
- }, async ({ limit, action, fact_id, reason, corrected_content }) => {
847
+ mcp.tool("memory_review", [
848
+ "Triage facts that were extracted automatically by the bikky daemon (source='daemon').",
849
+ "Only useful when the daemon is running and capturing memories from logs/transcripts; otherwise this returns an empty list. Supports four actions: list (default — show recent daemon facts), approve (mark verified), reject (mark superseded with reason), correct (replace with edited content as a new fact).",
850
+ ].join(" "), {
851
+ limit: z.number().optional().default(10).describe("Max facts to return when action=list (default 10)."),
852
+ action: z.enum(["list", "approve", "reject", "correct"]).optional().default("list").describe("What to do. list = show recent daemon-extracted facts (default). approve = confirm a fact is correct (bumps verification count). reject = mark a fact as wrong (requires 'reason'). correct = supersede with an edited version (requires 'corrected_content')."),
853
+ fact_id: z.string().optional().describe("Fact ID to act on. Required for approve / reject / correct."),
854
+ reason: z.string().optional().describe("Required for action=reject. Short reason the fact is wrong."),
855
+ corrected_content: z.string().optional().describe("Required for action=correct. The fixed fact text. Stored as a new fact that supersedes the original."),
856
+ workspace_id: z.string().optional().describe("Workspace namespace. Omit to use the default from config."),
857
+ include_legacy_workspace: z.boolean().optional().describe("Backwards-compatibility: also include legacy facts with no workspace_id. Default false."),
858
+ }, async ({ limit, action, fact_id, reason, corrected_content, workspace_id, include_legacy_workspace }) => {
660
859
  const guard = requireReady();
661
860
  if (guard)
662
861
  return guard;
862
+ const scope = resolveScope(workspace_id, include_legacy_workspace);
663
863
  if (action === "list") {
664
- const result = await qdrantScroll({
665
- must: [
666
- { key: "source", match: { value: "daemon" } },
667
- { is_null: { key: "superseded_by" } },
668
- ],
669
- }, (limit ?? 10) * 2);
864
+ const filter = scopedFilter(scope) ?? { must: [] };
865
+ filter.must.push({ key: "source", match: { value: "daemon" } });
866
+ const result = await qdrantScroll(filter, (limit ?? 10) * 2);
670
867
  const points = (result.result?.points ?? [])
671
868
  .sort((a, b) => (b.payload.created_at ?? "").localeCompare(a.payload.created_at ?? ""))
672
869
  .slice(0, limit ?? 10);
@@ -684,9 +881,12 @@ export function registerTools(mcp) {
684
881
  }
685
882
  const now = nowISO();
686
883
  if (action === "approve") {
687
- const existing = await qdrantGetPoints([fact_id]).catch(() => null);
884
+ const writable = await getPointForWorkspaceWrite(fact_id, scope);
885
+ if (writable.error) {
886
+ return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
887
+ }
688
888
  let currentCount = 0;
689
- const approvePt = existing?.result?.[0];
889
+ const approvePt = writable.point;
690
890
  if (approvePt) {
691
891
  currentCount = approvePt.payload.verification_count ?? 0;
692
892
  }
@@ -701,28 +901,52 @@ export function registerTools(mcp) {
701
901
  if (!reason) {
702
902
  return { content: [{ type: "text", text: "Error: reason is required for reject action." }] };
703
903
  }
904
+ const writable = await getPointForWorkspaceWrite(fact_id, scope);
905
+ if (writable.error) {
906
+ return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
907
+ }
908
+ const redactedReason = redactStorageText(reason);
704
909
  await qdrantSetPayload([fact_id], {
705
- superseded_by: `rejected:${reason}`,
910
+ superseded_by: `rejected:${redactedReason.text}`,
706
911
  superseded_at: now,
707
912
  updated_at: now,
708
913
  });
709
- return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", fact_id, reason }) }] };
914
+ return { content: [{ type: "text", text: JSON.stringify({
915
+ status: "rejected",
916
+ fact_id,
917
+ reason: redactedReason.text,
918
+ ...(redactedReason.redacted ? { redaction: redactedReason } : {}),
919
+ }) }] };
710
920
  }
711
921
  if (action === "correct") {
712
922
  if (!corrected_content) {
713
923
  return { content: [{ type: "text", text: "Error: corrected_content is required for correct action." }] };
714
924
  }
715
- const original = await qdrantGetPoints([fact_id]).catch(() => null);
716
- const origPayload = original?.result?.[0]?.payload;
717
- const vector = await embed(corrected_content);
925
+ const writable = await getPointForWorkspaceWrite(fact_id, scope);
926
+ if (writable.error) {
927
+ return { content: [{ type: "text", text: JSON.stringify(writable.error, null, 2) }], isError: true };
928
+ }
929
+ const origPayload = writable.point?.payload;
930
+ const redactedCorrected = redactStorageText(corrected_content);
931
+ const correctionScope = origPayload?.workspace_id
932
+ ? resolveScope(origPayload.workspace_id, false)
933
+ : scope;
934
+ const vector = await embed(redactedCorrected.text);
718
935
  const correctedId = crypto.randomUUID();
719
- const origCategory = origPayload?.category ?? "observation";
720
- const hash = contentHash(origCategory, corrected_content);
721
- await qdrantUpsert(correctedId, vector, {
722
- content: corrected_content,
936
+ const origCategory = normalizeCategory(origPayload?.category ?? DEFAULT_CATEGORY);
937
+ const hash = contentHash(origCategory, redactedCorrected.text);
938
+ const correctedPayload = {
939
+ content: redactedCorrected.text,
723
940
  category: origCategory,
724
- domain: origPayload?.domain ?? "work",
725
- kind: origPayload?.kind ?? "fact",
941
+ domain: normalizeDomain(origPayload?.domain ?? DEFAULT_DOMAIN),
942
+ kind: normalizeKind(origPayload?.kind ?? "fact"),
943
+ ...(origPayload?.memory_subtype ? { memory_subtype: origPayload.memory_subtype } : {}),
944
+ ...(origPayload?.layer ? { layer: origPayload.layer } : {}),
945
+ ...(origPayload?.episode_id ? { episode_id: origPayload.episode_id } : {}),
946
+ ...(origPayload?.workstream_key ? { workstream_key: origPayload.workstream_key } : {}),
947
+ ...(origPayload?.task_key ? { task_key: origPayload.task_key } : {}),
948
+ ...(origPayload?.repo ? { repo: origPayload.repo } : {}),
949
+ ...(origPayload?.branch ? { branch: origPayload.branch } : {}),
726
950
  entities: origPayload?.entities ?? [],
727
951
  source: "user",
728
952
  confidence: 0.95,
@@ -735,7 +959,10 @@ export function registerTools(mcp) {
735
959
  created_at: now,
736
960
  updated_at: now,
737
961
  metadata: { ...(origPayload?.metadata ?? {}), corrected_from: fact_id },
738
- });
962
+ };
963
+ addWorkspacePayload(correctedPayload, correctionScope);
964
+ addRedactionPayload(correctedPayload, redactedCorrected);
965
+ await qdrantUpsert(correctedId, vector, correctedPayload);
739
966
  await qdrantSetPayload([fact_id], {
740
967
  superseded_by: correctedId,
741
968
  superseded_at: now,
@@ -745,177 +972,11 @@ export function registerTools(mcp) {
745
972
  }
746
973
  return { content: [{ type: "text", text: `Unknown action: ${String(action)}` }] };
747
974
  });
748
- // ── memory_session_summary ──────────────────────────────────────────────
749
- mcp.tool("memory_session_summary", "Store a compressed summary of the current session. Call before a session ends or when a major task completes. " +
750
- "Future sessions receive this via memory_recall('session briefing'). Idempotent per session_id.", {
751
- session_id: z.string().describe("Session UUID"),
752
- summary: z.string().describe("2-5 sentence compressed summary of what happened this session"),
753
- tasks_completed: z.array(z.string()).optional().default([]).describe("Task slugs completed"),
754
- decisions_made: z.array(z.string()).optional().default([]).describe("Key decisions"),
755
- entities_touched: z.array(z.string()).optional().default([]).describe("Entities involved (lowercase)"),
756
- }, async ({ session_id, summary, tasks_completed, decisions_made, entities_touched }) => {
757
- const guard = requireReady();
758
- if (guard)
759
- return guard;
760
- const now = nowISO();
761
- const normalizedEntities = entities_touched.map((e) => e.toLowerCase());
762
- // Check for existing summary for this session
763
- try {
764
- const existing = await qdrantScroll({ must: [
765
- { key: "session_id", match: { value: session_id } },
766
- { key: "kind", match: { value: "summary" } },
767
- { is_null: { key: "superseded_by" } },
768
- ] }, 1);
769
- const summaryPoint = existing.result?.points?.[0];
770
- if (summaryPoint) {
771
- const point = summaryPoint;
772
- const vector = await embed(summary);
773
- await qdrantUpsert(point.id, vector, {
774
- ...point.payload,
775
- content: summary,
776
- kind: "summary",
777
- domain: "work",
778
- source: "system",
779
- tasks_completed,
780
- decisions_made,
781
- entities: normalizedEntities,
782
- content_hash: contentHash("observation", summary),
783
- updated_at: now,
784
- });
785
- return {
786
- content: [{ type: "text", text: JSON.stringify({
787
- action: "updated", fact_id: point.id, session_id,
788
- }) }],
789
- };
790
- }
791
- }
792
- catch (e) {
793
- log("WARN", `Session summary lookup failed: ${e instanceof Error ? e.message : String(e)}`);
794
- }
795
- // Insert new summary
796
- const vector = await embed(summary);
797
- const factId = newId();
798
- await qdrantUpsert(factId, vector, {
799
- content: summary,
800
- category: "observation",
801
- domain: "work",
802
- kind: "summary",
803
- entities: normalizedEntities,
804
- source: "system",
805
- confidence: 1.0,
806
- content_hash: contentHash("observation", summary),
807
- reinforcement_count: 1,
808
- last_reinforced_at: now,
809
- superseded_by: null,
810
- superseded_at: null,
811
- created_at: now,
812
- updated_at: now,
813
- session_id,
814
- tasks_completed,
815
- decisions_made,
816
- });
817
- return {
818
- content: [{ type: "text", text: JSON.stringify({
819
- action: "stored", fact_id: factId, session_id,
820
- }) }],
821
- };
822
- });
823
- // ── memory_distill ──────────────────────────────────────────────────────
824
- mcp.tool("memory_distill", "Consolidate recent session summaries into distilled patterns. Call when 5+ session summaries exist. " +
825
- "Uses LLM to extract recurring patterns and key learnings, then supersedes the source summaries.", {
826
- days: z.number().optional().default(14).describe("Look-back period in days (default 14)"),
827
- max_summaries: z.number().optional().default(20).describe("Max summaries to consolidate"),
828
- }, async ({ days, max_summaries }) => {
829
- const guard = requireReady();
830
- if (guard)
831
- return guard;
832
- const now = nowISO();
833
- const daysVal = days ?? 14;
834
- const maxVal = max_summaries ?? 20;
835
- const since = new Date(Date.now() - daysVal * 86400000).toISOString();
836
- const summaryResults = await qdrantScroll({ must: [
837
- { key: "kind", match: { value: "summary" } },
838
- { key: "created_at", range: { gte: since } },
839
- { is_null: { key: "superseded_by" } },
840
- ] }, maxVal);
841
- const summaries = summaryResults.result?.points ?? [];
842
- if (summaries.length < 3) {
843
- return {
844
- content: [{ type: "text", text: JSON.stringify({
845
- action: "skipped",
846
- reason: `Only ${summaries.length} session summaries in the last ${daysVal} days. Need at least 3.`,
847
- }) }],
848
- };
849
- }
850
- const summaryTexts = summaries.map((s, i) => {
851
- const p = s.payload;
852
- const parts = [`Session ${i + 1} (${p.created_at}):\n${p.content}`];
853
- if (p.tasks_completed?.length)
854
- parts.push(`Tasks: ${p.tasks_completed.join(", ")}`);
855
- if (p.decisions_made?.length)
856
- parts.push(`Decisions: ${p.decisions_made.join("; ")}`);
857
- return parts.join("\n");
858
- }).join("\n\n---\n\n");
859
- const systemPrompt = "You are a memory consolidation system. Given session summaries from an engineering agent, " +
860
- "extract recurring patterns, consolidated learnings, and key facts. Output:\n" +
861
- "1. Recurring patterns (things that keep coming up)\n" +
862
- "2. Key infrastructure/project facts learned\n" +
863
- "3. Decisions made and their rationale\n" +
864
- "4. Open issues or recurring problems\n" +
865
- "Be concise — one line per point. Omit ephemeral details.";
866
- let distilledContent;
867
- try {
868
- distilledContent = await chatComplete(systemPrompt, summaryTexts);
869
- }
870
- catch (e) {
871
- return { content: [{ type: "text", text: `Distillation failed: ${e instanceof Error ? e.message : String(e)}` }] };
872
- }
873
- const allEntities = [...new Set(summaries.flatMap((s) => s.payload.entities ?? []))];
874
- const vector = await embed(distilledContent);
875
- const factId = newId();
876
- const sourceIds = summaries.map((s) => s.id);
877
- await qdrantUpsert(factId, vector, {
878
- content: distilledContent,
879
- category: "observation",
880
- domain: "work",
881
- kind: "distilled",
882
- entities: allEntities,
883
- source: "system",
884
- confidence: 0.9,
885
- content_hash: contentHash("observation", distilledContent),
886
- reinforcement_count: 1,
887
- last_reinforced_at: now,
888
- superseded_by: null,
889
- superseded_at: null,
890
- created_at: now,
891
- updated_at: now,
892
- distilled_from: sourceIds,
893
- distilled_period_start: since,
894
- distilled_period_end: now,
895
- summary_count: summaries.length,
896
- });
897
- try {
898
- await qdrantSetPayload(sourceIds, {
899
- superseded_by: factId,
900
- superseded_at: now,
901
- });
902
- }
903
- catch (e) {
904
- log("WARN", `Failed to supersede some source summaries: ${e instanceof Error ? e.message : String(e)}`);
905
- }
906
- return {
907
- content: [{ type: "text", text: JSON.stringify({
908
- action: "distilled",
909
- fact_id: factId,
910
- summaries_consolidated: summaries.length,
911
- period: { start: since, end: now },
912
- entities: allEntities,
913
- preview: distilledContent.substring(0, 300) + (distilledContent.length > 300 ? "..." : ""),
914
- }, null, 2) }],
915
- };
916
- });
917
975
  // ── memory_heartbeat ────────────────────────────────────────────────────
918
- mcp.tool("memory_heartbeat", "Lightweight reflection check. Returns memory nudge (if no stores in 10+ min), staleness alerts (every 3rd call), and reflection prompt.", {}, async () => {
976
+ mcp.tool("memory_heartbeat", [
977
+ "Reflection check-in. Returns up to three things: a memory nudge if you haven't stored anything in 10+ minutes, stale-fact alerts every 3rd call (with IDs you can pass to memory_verify or memory_forget), and a reflection prompt asking whether the last few minutes of work produced anything worth storing.",
978
+ "Call periodically during interactive sessions — roughly every 10 minutes or every 3rd user prompt. No arguments. Cheap and read-only.",
979
+ ].join(" "), {}, async () => {
919
980
  heartbeatCount++;
920
981
  const sections = [];
921
982
  const nudge = buildMemoryNudge();
@@ -924,17 +985,17 @@ export function registerTools(mcp) {
924
985
  if (heartbeatCount % 3 === 0 && ready) {
925
986
  try {
926
987
  const staleThreshold = new Date(Date.now() - STALENESS_DAYS * 86400000).toISOString();
927
- const staleResults = await qdrantScroll({ must: [
928
- { key: "category", match: { any: ["infrastructure", "projects", "decisions"] } },
929
- { is_null: { key: "superseded_by" } },
930
- ],
931
- should: [
932
- { key: "last_reinforced_at", range: { lte: staleThreshold } },
933
- { is_null: { key: "last_reinforced_at" } },
934
- ],
935
- must_not: [
936
- { key: "last_verified_at", range: { gte: staleThreshold } },
937
- ] }, 3);
988
+ const scope = resolveScope();
989
+ const staleFilter = scopedFilter(scope) ?? { must: [] };
990
+ staleFilter.must.push({ key: "category", match: { any: ["infrastructure", "projects", "decisions"] } });
991
+ staleFilter.should = [
992
+ { key: "last_reinforced_at", range: { lte: staleThreshold } },
993
+ { is_null: { key: "last_reinforced_at" } },
994
+ ];
995
+ staleFilter.must_not = [
996
+ { key: "last_verified_at", range: { gte: staleThreshold } },
997
+ ];
998
+ const staleResults = await qdrantScroll(staleFilter, 3);
938
999
  const staleFacts = staleResults.result?.points ?? [];
939
1000
  if (staleFacts.length > 0) {
940
1001
  const staleLines = staleFacts.map((f) => {
@@ -950,8 +1011,12 @@ export function registerTools(mcp) {
950
1011
  log("WARN", `Staleness check failed: ${e instanceof Error ? e.message : String(e)}`);
951
1012
  }
952
1013
  }
953
- sections.push("🔍 **Reflect:** What have you learned, decided, or discovered since the last heartbeat? " +
954
- "If anything is worth persisting for future sessions, call memory_store now.");
1014
+ sections.push("🔍 Reflect: think about the LAST 10 minutes of work and answer in your head:\n" +
1015
+ " 1. Did you touch a service, port, config, or file path you hadn't seen before?\n" +
1016
+ " 2. Did you make a choice (library, pattern, approach) you'd want a future session to know about?\n" +
1017
+ " 3. Did you hit an error and find a workaround?\n" +
1018
+ " 4. Did the user state a preference or constraint?\n" +
1019
+ "If any answer is yes, call memory_store now — one atomic fact per item, with category/domain/entities.");
955
1020
  return { content: [{ type: "text", text: sections.join("\n\n") }] };
956
1021
  });
957
1022
  }