@nxuss/lemma 1.16.1 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (483) hide show
  1. package/bin/init.js +101 -11
  2. package/dist/cjs/cli/lemma-proxy.d.ts.map +1 -1
  3. package/dist/cjs/cli/lemma-proxy.js +19 -11
  4. package/dist/cjs/cli/lemma-proxy.js.map +1 -1
  5. package/dist/cjs/cloud/TenantCache.d.ts.map +1 -1
  6. package/dist/cjs/cloud/TenantCache.js +0 -4
  7. package/dist/cjs/cloud/TenantCache.js.map +1 -1
  8. package/dist/cjs/cloud/identity/ApiKey.d.ts +32 -0
  9. package/dist/cjs/cloud/identity/ApiKey.d.ts.map +1 -0
  10. package/dist/cjs/cloud/identity/ApiKey.js +59 -0
  11. package/dist/cjs/cloud/identity/ApiKey.js.map +1 -0
  12. package/dist/cjs/cloud/identity/PlanPolicy.d.ts +79 -0
  13. package/dist/cjs/cloud/identity/PlanPolicy.d.ts.map +1 -0
  14. package/dist/cjs/cloud/identity/PlanPolicy.js +135 -0
  15. package/dist/cjs/cloud/identity/PlanPolicy.js.map +1 -0
  16. package/dist/cjs/cloud/identity/RateLimiter.d.ts +49 -0
  17. package/dist/cjs/cloud/identity/RateLimiter.d.ts.map +1 -0
  18. package/dist/cjs/cloud/identity/RateLimiter.js +75 -0
  19. package/dist/cjs/cloud/identity/RateLimiter.js.map +1 -0
  20. package/dist/cjs/cloud/identity/TenantResolver.d.ts +19 -0
  21. package/dist/cjs/cloud/identity/TenantResolver.d.ts.map +1 -0
  22. package/dist/cjs/cloud/identity/TenantResolver.js +40 -0
  23. package/dist/cjs/cloud/identity/TenantResolver.js.map +1 -0
  24. package/dist/cjs/cloud/identity/authenticateV2.d.ts +48 -0
  25. package/dist/cjs/cloud/identity/authenticateV2.d.ts.map +1 -0
  26. package/dist/cjs/cloud/identity/authenticateV2.js +66 -0
  27. package/dist/cjs/cloud/identity/authenticateV2.js.map +1 -0
  28. package/dist/cjs/cloud/identity/identityStore.d.ts +56 -0
  29. package/dist/cjs/cloud/identity/identityStore.d.ts.map +1 -0
  30. package/dist/cjs/cloud/identity/identityStore.js +99 -0
  31. package/dist/cjs/cloud/identity/identityStore.js.map +1 -0
  32. package/dist/cjs/cloud/identity/index.d.ts +14 -0
  33. package/dist/cjs/cloud/identity/index.d.ts.map +1 -0
  34. package/dist/cjs/cloud/identity/index.js +30 -0
  35. package/dist/cjs/cloud/identity/index.js.map +1 -0
  36. package/dist/cjs/cloud/metering/TokenCounter.d.ts +26 -0
  37. package/dist/cjs/cloud/metering/TokenCounter.d.ts.map +1 -0
  38. package/dist/cjs/cloud/metering/TokenCounter.js +80 -0
  39. package/dist/cjs/cloud/metering/TokenCounter.js.map +1 -0
  40. package/dist/cjs/cloud/metering/UsageLedger.d.ts +68 -0
  41. package/dist/cjs/cloud/metering/UsageLedger.d.ts.map +1 -0
  42. package/dist/cjs/cloud/metering/UsageLedger.js +106 -0
  43. package/dist/cjs/cloud/metering/UsageLedger.js.map +1 -0
  44. package/dist/cjs/cloud/metering/index.d.ts +12 -0
  45. package/dist/cjs/cloud/metering/index.d.ts.map +1 -0
  46. package/dist/cjs/cloud/metering/index.js +28 -0
  47. package/dist/cjs/cloud/metering/index.js.map +1 -0
  48. package/dist/cjs/cloud/scrub/StatelessScrubber.d.ts +53 -0
  49. package/dist/cjs/cloud/scrub/StatelessScrubber.d.ts.map +1 -0
  50. package/dist/cjs/cloud/scrub/StatelessScrubber.js +203 -0
  51. package/dist/cjs/cloud/scrub/StatelessScrubber.js.map +1 -0
  52. package/dist/cjs/cloud/scrub/index.d.ts +5 -0
  53. package/dist/cjs/cloud/scrub/index.d.ts.map +1 -0
  54. package/dist/cjs/cloud/scrub/index.js +21 -0
  55. package/dist/cjs/cloud/scrub/index.js.map +1 -0
  56. package/dist/cjs/cloud/store/FileBrainStore.d.ts +26 -0
  57. package/dist/cjs/cloud/store/FileBrainStore.d.ts.map +1 -0
  58. package/dist/cjs/cloud/store/FileBrainStore.js +219 -0
  59. package/dist/cjs/cloud/store/FileBrainStore.js.map +1 -0
  60. package/dist/cjs/cloud/store/PostgresBrainStore.d.ts +50 -0
  61. package/dist/cjs/cloud/store/PostgresBrainStore.d.ts.map +1 -0
  62. package/dist/cjs/cloud/store/PostgresBrainStore.js +181 -0
  63. package/dist/cjs/cloud/store/PostgresBrainStore.js.map +1 -0
  64. package/dist/cjs/cloud/store/index.d.ts +6 -0
  65. package/dist/cjs/cloud/store/index.d.ts.map +1 -0
  66. package/dist/cjs/cloud/store/index.js +22 -0
  67. package/dist/cjs/cloud/store/index.js.map +1 -0
  68. package/dist/cjs/cloud/types.d.ts +0 -4
  69. package/dist/cjs/cloud/types.d.ts.map +1 -1
  70. package/dist/cjs/cloud/types.js +1 -3
  71. package/dist/cjs/cloud/types.js.map +1 -1
  72. package/dist/cjs/cloud/v2/app.d.ts +15 -0
  73. package/dist/cjs/cloud/v2/app.d.ts.map +1 -0
  74. package/dist/cjs/cloud/v2/app.js +63 -0
  75. package/dist/cjs/cloud/v2/app.js.map +1 -0
  76. package/dist/cjs/cloud/v2/routes/index.d.ts +24 -0
  77. package/dist/cjs/cloud/v2/routes/index.d.ts.map +1 -0
  78. package/dist/cjs/cloud/v2/routes/index.js +35 -0
  79. package/dist/cjs/cloud/v2/routes/index.js.map +1 -0
  80. package/dist/cjs/cloud/v2/routes/memories.d.ts +83 -0
  81. package/dist/cjs/cloud/v2/routes/memories.d.ts.map +1 -0
  82. package/dist/cjs/cloud/v2/routes/memories.js +249 -0
  83. package/dist/cjs/cloud/v2/routes/memories.js.map +1 -0
  84. package/dist/cjs/cloud/v2/routes/recall.d.ts +33 -0
  85. package/dist/cjs/cloud/v2/routes/recall.d.ts.map +1 -0
  86. package/dist/cjs/cloud/v2/routes/recall.js +51 -0
  87. package/dist/cjs/cloud/v2/routes/recall.js.map +1 -0
  88. package/dist/cjs/cloud/v2/routes/scrub.d.ts +13 -0
  89. package/dist/cjs/cloud/v2/routes/scrub.d.ts.map +1 -0
  90. package/dist/cjs/cloud/v2/routes/scrub.js +82 -0
  91. package/dist/cjs/cloud/v2/routes/scrub.js.map +1 -0
  92. package/dist/cjs/cloud/v2/routes/usage.d.ts +18 -0
  93. package/dist/cjs/cloud/v2/routes/usage.d.ts.map +1 -0
  94. package/dist/cjs/cloud/v2/routes/usage.js +18 -0
  95. package/dist/cjs/cloud/v2/routes/usage.js.map +1 -0
  96. package/dist/cjs/cloud/v2/routes/verify.d.ts +35 -0
  97. package/dist/cjs/cloud/v2/routes/verify.d.ts.map +1 -0
  98. package/dist/cjs/cloud/v2/routes/verify.js +43 -0
  99. package/dist/cjs/cloud/v2/routes/verify.js.map +1 -0
  100. package/dist/cjs/cloud/v2/server.d.ts +27 -0
  101. package/dist/cjs/cloud/v2/server.d.ts.map +1 -0
  102. package/dist/cjs/cloud/v2/server.js +47 -0
  103. package/dist/cjs/cloud/v2/server.js.map +1 -0
  104. package/dist/cjs/contracts/api-v2.types.d.ts +103 -0
  105. package/dist/cjs/contracts/api-v2.types.d.ts.map +1 -0
  106. package/dist/cjs/contracts/api-v2.types.js +3 -0
  107. package/dist/cjs/contracts/api-v2.types.js.map +1 -0
  108. package/dist/cjs/contracts/errors.d.ts +40 -0
  109. package/dist/cjs/contracts/errors.d.ts.map +1 -0
  110. package/dist/cjs/contracts/errors.js +50 -0
  111. package/dist/cjs/contracts/errors.js.map +1 -0
  112. package/dist/cjs/contracts/evidence.d.ts +49 -0
  113. package/dist/cjs/contracts/evidence.d.ts.map +1 -0
  114. package/dist/cjs/contracts/evidence.js +12 -0
  115. package/dist/cjs/contracts/evidence.js.map +1 -0
  116. package/dist/cjs/contracts/index.d.ts +10 -0
  117. package/dist/cjs/contracts/index.d.ts.map +1 -0
  118. package/dist/cjs/contracts/index.js +26 -0
  119. package/dist/cjs/contracts/index.js.map +1 -0
  120. package/dist/cjs/contracts/store.d.ts +30 -0
  121. package/dist/cjs/contracts/store.d.ts.map +1 -0
  122. package/dist/cjs/contracts/store.js +3 -0
  123. package/dist/cjs/contracts/store.js.map +1 -0
  124. package/dist/cjs/mcp/tool-profiles.d.ts.map +1 -1
  125. package/dist/cjs/mcp/tool-profiles.js +46 -1
  126. package/dist/cjs/mcp/tool-profiles.js.map +1 -1
  127. package/dist/cjs/mcp/tools.d.ts +2 -0
  128. package/dist/cjs/mcp/tools.d.ts.map +1 -1
  129. package/dist/cjs/mcp/tools.js +319 -30
  130. package/dist/cjs/mcp/tools.js.map +1 -1
  131. package/dist/cjs/pr-review/bridge/BrainBridge.js +1 -1
  132. package/dist/cjs/pr-review/bridge/BrainBridge.js.map +1 -1
  133. package/dist/cjs/subconscious/BrainEmbeddings.d.ts +59 -0
  134. package/dist/cjs/subconscious/BrainEmbeddings.d.ts.map +1 -0
  135. package/dist/cjs/subconscious/BrainEmbeddings.js +222 -0
  136. package/dist/cjs/subconscious/BrainEmbeddings.js.map +1 -0
  137. package/dist/cjs/subconscious/GitIngest.js +2 -2
  138. package/dist/cjs/subconscious/GitIngest.js.map +1 -1
  139. package/dist/cjs/subconscious/TheBrainV2.d.ts +327 -9
  140. package/dist/cjs/subconscious/TheBrainV2.d.ts.map +1 -1
  141. package/dist/cjs/subconscious/TheBrainV2.js +952 -98
  142. package/dist/cjs/subconscious/TheBrainV2.js.map +1 -1
  143. package/dist/cjs/subconscious/freshness/LocalFsResolver.d.ts +16 -0
  144. package/dist/cjs/subconscious/freshness/LocalFsResolver.d.ts.map +1 -0
  145. package/dist/cjs/subconscious/freshness/LocalFsResolver.js +74 -0
  146. package/dist/cjs/subconscious/freshness/LocalFsResolver.js.map +1 -0
  147. package/dist/cjs/subconscious/freshness/SuppliedEvidenceResolver.d.ts +23 -0
  148. package/dist/cjs/subconscious/freshness/SuppliedEvidenceResolver.d.ts.map +1 -0
  149. package/dist/cjs/subconscious/freshness/SuppliedEvidenceResolver.js +24 -0
  150. package/dist/cjs/subconscious/freshness/SuppliedEvidenceResolver.js.map +1 -0
  151. package/dist/cjs/subconscious/freshness/index.d.ts +10 -0
  152. package/dist/cjs/subconscious/freshness/index.d.ts.map +1 -0
  153. package/dist/cjs/subconscious/freshness/index.js +26 -0
  154. package/dist/cjs/subconscious/freshness/index.js.map +1 -0
  155. package/dist/cjs/utils/ConversationCheckpoint.js +1 -1
  156. package/dist/cjs/utils/ConversationCheckpoint.js.map +1 -1
  157. package/dist/cjs/utils/ProjectIdentity.d.ts +12 -0
  158. package/dist/cjs/utils/ProjectIdentity.d.ts.map +1 -0
  159. package/dist/cjs/utils/ProjectIdentity.js +44 -0
  160. package/dist/cjs/utils/ProjectIdentity.js.map +1 -0
  161. package/dist/cjs/utils/PromptPatternCache.d.ts.map +1 -1
  162. package/dist/cjs/utils/PromptPatternCache.js +12 -2
  163. package/dist/cjs/utils/PromptPatternCache.js.map +1 -1
  164. package/dist/cjs/utils/RepeatGuard.d.ts.map +1 -1
  165. package/dist/cjs/utils/RepeatGuard.js +10 -2
  166. package/dist/cjs/utils/RepeatGuard.js.map +1 -1
  167. package/dist/cjs/utils/StateHashCache.d.ts +5 -0
  168. package/dist/cjs/utils/StateHashCache.d.ts.map +1 -1
  169. package/dist/cjs/utils/StateHashCache.js +40 -16
  170. package/dist/cjs/utils/StateHashCache.js.map +1 -1
  171. package/dist/esm/api/dashboardRoutes.d.ts +3 -3
  172. package/dist/esm/api/dashboardRoutes.js +1 -1
  173. package/dist/esm/api/server.d.ts +4 -4
  174. package/dist/esm/api/server.js +3 -3
  175. package/dist/esm/autopilot/AutopilotWatcher.js +3 -3
  176. package/dist/esm/autopilot/Notifier.d.ts +1 -1
  177. package/dist/esm/cli/lemma-proxy.d.ts.map +1 -1
  178. package/dist/esm/cli/lemma-proxy.js +33 -25
  179. package/dist/esm/cli/lemma-proxy.js.map +1 -1
  180. package/dist/esm/cli/stack.d.ts +4 -4
  181. package/dist/esm/cli/stack.js +5 -5
  182. package/dist/esm/cloud/KeyManager.d.ts +1 -1
  183. package/dist/esm/cloud/KeyManager.js +1 -1
  184. package/dist/esm/cloud/TenantCache.d.ts +1 -1
  185. package/dist/esm/cloud/TenantCache.d.ts.map +1 -1
  186. package/dist/esm/cloud/TenantCache.js +0 -4
  187. package/dist/esm/cloud/TenantCache.js.map +1 -1
  188. package/dist/esm/cloud/identity/ApiKey.d.ts +32 -0
  189. package/dist/esm/cloud/identity/ApiKey.d.ts.map +1 -0
  190. package/dist/esm/cloud/identity/ApiKey.js +49 -0
  191. package/dist/esm/cloud/identity/ApiKey.js.map +1 -0
  192. package/dist/esm/cloud/identity/PlanPolicy.d.ts +79 -0
  193. package/dist/esm/cloud/identity/PlanPolicy.d.ts.map +1 -0
  194. package/dist/esm/cloud/identity/PlanPolicy.js +129 -0
  195. package/dist/esm/cloud/identity/PlanPolicy.js.map +1 -0
  196. package/dist/esm/cloud/identity/RateLimiter.d.ts +49 -0
  197. package/dist/esm/cloud/identity/RateLimiter.d.ts.map +1 -0
  198. package/dist/esm/cloud/identity/RateLimiter.js +69 -0
  199. package/dist/esm/cloud/identity/RateLimiter.js.map +1 -0
  200. package/dist/esm/cloud/identity/TenantResolver.d.ts +19 -0
  201. package/dist/esm/cloud/identity/TenantResolver.d.ts.map +1 -0
  202. package/dist/esm/cloud/identity/TenantResolver.js +36 -0
  203. package/dist/esm/cloud/identity/TenantResolver.js.map +1 -0
  204. package/dist/esm/cloud/identity/authenticateV2.d.ts +48 -0
  205. package/dist/esm/cloud/identity/authenticateV2.d.ts.map +1 -0
  206. package/dist/esm/cloud/identity/authenticateV2.js +62 -0
  207. package/dist/esm/cloud/identity/authenticateV2.js.map +1 -0
  208. package/dist/esm/cloud/identity/identityStore.d.ts +56 -0
  209. package/dist/esm/cloud/identity/identityStore.d.ts.map +1 -0
  210. package/dist/esm/cloud/identity/identityStore.js +94 -0
  211. package/dist/esm/cloud/identity/identityStore.js.map +1 -0
  212. package/dist/esm/cloud/identity/index.d.ts +14 -0
  213. package/dist/esm/cloud/identity/index.d.ts.map +1 -0
  214. package/dist/esm/cloud/identity/index.js +14 -0
  215. package/dist/esm/cloud/identity/index.js.map +1 -0
  216. package/dist/esm/cloud/index.d.ts +3 -3
  217. package/dist/esm/cloud/index.js +3 -3
  218. package/dist/esm/cloud/metering/TokenCounter.d.ts +26 -0
  219. package/dist/esm/cloud/metering/TokenCounter.d.ts.map +1 -0
  220. package/dist/esm/cloud/metering/TokenCounter.js +76 -0
  221. package/dist/esm/cloud/metering/TokenCounter.js.map +1 -0
  222. package/dist/esm/cloud/metering/UsageLedger.d.ts +68 -0
  223. package/dist/esm/cloud/metering/UsageLedger.d.ts.map +1 -0
  224. package/dist/esm/cloud/metering/UsageLedger.js +98 -0
  225. package/dist/esm/cloud/metering/UsageLedger.js.map +1 -0
  226. package/dist/esm/cloud/metering/index.d.ts +12 -0
  227. package/dist/esm/cloud/metering/index.d.ts.map +1 -0
  228. package/dist/esm/cloud/metering/index.js +12 -0
  229. package/dist/esm/cloud/metering/index.js.map +1 -0
  230. package/dist/esm/cloud/scrub/StatelessScrubber.d.ts +53 -0
  231. package/dist/esm/cloud/scrub/StatelessScrubber.d.ts.map +1 -0
  232. package/dist/esm/cloud/scrub/StatelessScrubber.js +199 -0
  233. package/dist/esm/cloud/scrub/StatelessScrubber.js.map +1 -0
  234. package/dist/esm/cloud/scrub/index.d.ts +5 -0
  235. package/dist/esm/cloud/scrub/index.d.ts.map +1 -0
  236. package/dist/esm/cloud/scrub/index.js +5 -0
  237. package/dist/esm/cloud/scrub/index.js.map +1 -0
  238. package/dist/esm/cloud/server.d.ts +3 -3
  239. package/dist/esm/cloud/server.js +5 -5
  240. package/dist/esm/cloud/store/FileBrainStore.d.ts +26 -0
  241. package/dist/esm/cloud/store/FileBrainStore.d.ts.map +1 -0
  242. package/dist/esm/cloud/store/FileBrainStore.js +212 -0
  243. package/dist/esm/cloud/store/FileBrainStore.js.map +1 -0
  244. package/dist/esm/cloud/store/PostgresBrainStore.d.ts +50 -0
  245. package/dist/esm/cloud/store/PostgresBrainStore.d.ts.map +1 -0
  246. package/dist/esm/cloud/store/PostgresBrainStore.js +176 -0
  247. package/dist/esm/cloud/store/PostgresBrainStore.js.map +1 -0
  248. package/dist/esm/cloud/store/index.d.ts +6 -0
  249. package/dist/esm/cloud/store/index.d.ts.map +1 -0
  250. package/dist/esm/cloud/store/index.js +6 -0
  251. package/dist/esm/cloud/store/index.js.map +1 -0
  252. package/dist/esm/cloud/types.d.ts +0 -4
  253. package/dist/esm/cloud/types.d.ts.map +1 -1
  254. package/dist/esm/cloud/types.js +0 -2
  255. package/dist/esm/cloud/types.js.map +1 -1
  256. package/dist/esm/cloud/v2/app.d.ts +15 -0
  257. package/dist/esm/cloud/v2/app.d.ts.map +1 -0
  258. package/dist/esm/cloud/v2/app.js +57 -0
  259. package/dist/esm/cloud/v2/app.js.map +1 -0
  260. package/dist/esm/cloud/v2/routes/index.d.ts +24 -0
  261. package/dist/esm/cloud/v2/routes/index.d.ts.map +1 -0
  262. package/dist/esm/cloud/v2/routes/index.js +32 -0
  263. package/dist/esm/cloud/v2/routes/index.js.map +1 -0
  264. package/dist/esm/cloud/v2/routes/memories.d.ts +83 -0
  265. package/dist/esm/cloud/v2/routes/memories.d.ts.map +1 -0
  266. package/dist/esm/cloud/v2/routes/memories.js +238 -0
  267. package/dist/esm/cloud/v2/routes/memories.js.map +1 -0
  268. package/dist/esm/cloud/v2/routes/recall.d.ts +33 -0
  269. package/dist/esm/cloud/v2/routes/recall.d.ts.map +1 -0
  270. package/dist/esm/cloud/v2/routes/recall.js +47 -0
  271. package/dist/esm/cloud/v2/routes/recall.js.map +1 -0
  272. package/dist/esm/cloud/v2/routes/scrub.d.ts +13 -0
  273. package/dist/esm/cloud/v2/routes/scrub.d.ts.map +1 -0
  274. package/dist/esm/cloud/v2/routes/scrub.js +79 -0
  275. package/dist/esm/cloud/v2/routes/scrub.js.map +1 -0
  276. package/dist/esm/cloud/v2/routes/usage.d.ts +18 -0
  277. package/dist/esm/cloud/v2/routes/usage.d.ts.map +1 -0
  278. package/dist/esm/cloud/v2/routes/usage.js +15 -0
  279. package/dist/esm/cloud/v2/routes/usage.js.map +1 -0
  280. package/dist/esm/cloud/v2/routes/verify.d.ts +35 -0
  281. package/dist/esm/cloud/v2/routes/verify.d.ts.map +1 -0
  282. package/dist/esm/cloud/v2/routes/verify.js +39 -0
  283. package/dist/esm/cloud/v2/routes/verify.js.map +1 -0
  284. package/dist/esm/cloud/v2/server.d.ts +27 -0
  285. package/dist/esm/cloud/v2/server.d.ts.map +1 -0
  286. package/dist/esm/cloud/v2/server.js +44 -0
  287. package/dist/esm/cloud/v2/server.js.map +1 -0
  288. package/dist/esm/config/index.d.ts +1 -1
  289. package/dist/esm/config/index.js +1 -1
  290. package/dist/esm/consensus/index.d.ts +2 -2
  291. package/dist/esm/consensus/index.js +2 -2
  292. package/dist/esm/contracts/api-v2.types.d.ts +103 -0
  293. package/dist/esm/contracts/api-v2.types.d.ts.map +1 -0
  294. package/dist/esm/contracts/api-v2.types.js +2 -0
  295. package/dist/esm/contracts/api-v2.types.js.map +1 -0
  296. package/dist/esm/contracts/errors.d.ts +40 -0
  297. package/dist/esm/contracts/errors.d.ts.map +1 -0
  298. package/dist/esm/contracts/errors.js +45 -0
  299. package/dist/esm/contracts/errors.js.map +1 -0
  300. package/dist/esm/contracts/evidence.d.ts +49 -0
  301. package/dist/esm/contracts/evidence.d.ts.map +1 -0
  302. package/dist/esm/contracts/evidence.js +11 -0
  303. package/dist/esm/contracts/evidence.js.map +1 -0
  304. package/dist/esm/contracts/index.d.ts +10 -0
  305. package/dist/esm/contracts/index.d.ts.map +1 -0
  306. package/dist/esm/contracts/index.js +10 -0
  307. package/dist/esm/contracts/index.js.map +1 -0
  308. package/dist/esm/contracts/store.d.ts +30 -0
  309. package/dist/esm/contracts/store.d.ts.map +1 -0
  310. package/dist/esm/contracts/store.js +2 -0
  311. package/dist/esm/contracts/store.js.map +1 -0
  312. package/dist/esm/core/AgentRegistry.d.ts +1 -1
  313. package/dist/esm/core/AgentRegistry.js +1 -1
  314. package/dist/esm/core/DashboardBroadcaster.d.ts +3 -3
  315. package/dist/esm/core/DashboardBroadcaster.js +2 -2
  316. package/dist/esm/core/DashboardWebSocketServer.d.ts +1 -1
  317. package/dist/esm/core/DashboardWebSocketServer.js +1 -1
  318. package/dist/esm/core/GhostAlchemist.js +1 -1
  319. package/dist/esm/core/GhostListener.js +2 -2
  320. package/dist/esm/core/OrchestrationEngine.d.ts +3 -3
  321. package/dist/esm/core/OrchestrationEngine.js +2 -2
  322. package/dist/esm/core/SubconsciousEngine.d.ts +1 -1
  323. package/dist/esm/core/SubconsciousEngine.js +2 -2
  324. package/dist/esm/core/WebSocketServer.d.ts +2 -2
  325. package/dist/esm/core/WebSocketServer.js +1 -1
  326. package/dist/esm/core/index.d.ts +6 -6
  327. package/dist/esm/core/index.js +6 -6
  328. package/dist/esm/core/router.d.ts +3 -3
  329. package/dist/esm/core/router.js +1 -1
  330. package/dist/esm/db/database.js +1 -1
  331. package/dist/esm/embed.d.ts +2 -2
  332. package/dist/esm/embed.js +2 -2
  333. package/dist/esm/examples/basic-usage.js +1 -1
  334. package/dist/esm/index.d.ts +14 -14
  335. package/dist/esm/index.js +14 -14
  336. package/dist/esm/infra/analyzers/DriftDetector.d.ts +1 -1
  337. package/dist/esm/infra/analyzers/InfraAnalyzer.d.ts +1 -1
  338. package/dist/esm/infra/analyzers/MonolithAnalyzer.d.ts +1 -1
  339. package/dist/esm/infra/analyzers/index.d.ts +3 -3
  340. package/dist/esm/infra/analyzers/index.js +3 -3
  341. package/dist/esm/infra/cli.js +2 -2
  342. package/dist/esm/infra/config.d.ts +1 -1
  343. package/dist/esm/infra/index.d.ts +5 -5
  344. package/dist/esm/infra/index.js +5 -5
  345. package/dist/esm/infra/mcp-tools.js +5 -5
  346. package/dist/esm/infra/providers/InfraProvider.d.ts +1 -1
  347. package/dist/esm/infra/providers/JenkinsProvider.d.ts +2 -2
  348. package/dist/esm/infra/providers/OKDProvider.d.ts +2 -2
  349. package/dist/esm/infra/providers/TerraformProvider.d.ts +2 -2
  350. package/dist/esm/infra/providers/index.d.ts +5 -5
  351. package/dist/esm/infra/providers/index.js +3 -3
  352. package/dist/esm/mcp/index.js +5 -5
  353. package/dist/esm/mcp/prompts.js +1 -1
  354. package/dist/esm/mcp/resources.js +2 -2
  355. package/dist/esm/mcp/tasks.js +1 -1
  356. package/dist/esm/mcp/tool-profiles.d.ts.map +1 -1
  357. package/dist/esm/mcp/tool-profiles.js +47 -2
  358. package/dist/esm/mcp/tool-profiles.js.map +1 -1
  359. package/dist/esm/mcp/tools.d.ts +2 -0
  360. package/dist/esm/mcp/tools.d.ts.map +1 -1
  361. package/dist/esm/mcp/tools.js +366 -78
  362. package/dist/esm/mcp/tools.js.map +1 -1
  363. package/dist/esm/observability/IdeContextSync.d.ts +1 -1
  364. package/dist/esm/observability/IdeContextSync.js +5 -5
  365. package/dist/esm/pr-review/PRReviewEngine.d.ts +1 -1
  366. package/dist/esm/pr-review/PRReviewEngine.js +4 -4
  367. package/dist/esm/pr-review/ReviewStore.d.ts +1 -1
  368. package/dist/esm/pr-review/adapters/AzureDevOpsAdapter.d.ts +2 -2
  369. package/dist/esm/pr-review/adapters/AzureDevOpsAdapter.js +1 -1
  370. package/dist/esm/pr-review/adapters/GitHubAdapter.d.ts +2 -2
  371. package/dist/esm/pr-review/adapters/GitHubAdapter.js +1 -1
  372. package/dist/esm/pr-review/adapters/GitLabAdapter.d.ts +2 -2
  373. package/dist/esm/pr-review/adapters/GitLabAdapter.js +1 -1
  374. package/dist/esm/pr-review/adapters/GitPlatformAdapter.d.ts +1 -1
  375. package/dist/esm/pr-review/adapters/index.d.ts +4 -4
  376. package/dist/esm/pr-review/adapters/index.js +3 -3
  377. package/dist/esm/pr-review/analyzers/QualityGrader.d.ts +1 -1
  378. package/dist/esm/pr-review/analyzers/RegressionDetector.d.ts +1 -1
  379. package/dist/esm/pr-review/analyzers/SecurityScanner.d.ts +1 -1
  380. package/dist/esm/pr-review/analyzers/StaticAnalyzer.d.ts +1 -1
  381. package/dist/esm/pr-review/analyzers/index.d.ts +8 -8
  382. package/dist/esm/pr-review/analyzers/index.js +4 -4
  383. package/dist/esm/pr-review/bridge/BrainBridge.d.ts +1 -1
  384. package/dist/esm/pr-review/bridge/BrainBridge.js +2 -2
  385. package/dist/esm/pr-review/bridge/BrainBridge.js.map +1 -1
  386. package/dist/esm/pr-review/bridge/index.d.ts +1 -1
  387. package/dist/esm/pr-review/bridge/index.js +1 -1
  388. package/dist/esm/pr-review/index.d.ts +4 -4
  389. package/dist/esm/pr-review/index.js +2 -2
  390. package/dist/esm/pr-review/reviewRunner.d.ts +2 -2
  391. package/dist/esm/pr-review/reviewRunner.js +6 -6
  392. package/dist/esm/pr-review/webhook/WebhookServer.js +1 -1
  393. package/dist/esm/pr-review/webhook/index.d.ts +1 -1
  394. package/dist/esm/pr-review/webhook/index.js +1 -1
  395. package/dist/esm/protocol/flows.d.ts +1 -1
  396. package/dist/esm/protocol/flows.js +1 -1
  397. package/dist/esm/protocol/index.d.ts +4 -4
  398. package/dist/esm/protocol/index.js +4 -4
  399. package/dist/esm/protocol/utils.d.ts +1 -1
  400. package/dist/esm/protocol/utils.js +2 -2
  401. package/dist/esm/protocol/validators.d.ts +1 -1
  402. package/dist/esm/protocol/validators.js +1 -1
  403. package/dist/esm/proxy/ContextInjector.d.ts +1 -1
  404. package/dist/esm/proxy/Gatekeeper.d.ts +1 -1
  405. package/dist/esm/security/SecurityMiddleware.d.ts +1 -1
  406. package/dist/esm/security/SecurityMiddleware.js +3 -3
  407. package/dist/esm/security/index.d.ts +4 -4
  408. package/dist/esm/security/index.js +4 -4
  409. package/dist/esm/speculative/SpeculativeCache.d.ts +2 -2
  410. package/dist/esm/speculative/SpeculativeEngine.d.ts +2 -2
  411. package/dist/esm/speculative/SpeculativeEngine.js +3 -3
  412. package/dist/esm/speculative/WorkerPool.d.ts +1 -1
  413. package/dist/esm/speculative/index.d.ts +4 -4
  414. package/dist/esm/speculative/index.js +4 -4
  415. package/dist/esm/subconscious/BrainEmbeddings.d.ts +59 -0
  416. package/dist/esm/subconscious/BrainEmbeddings.d.ts.map +1 -0
  417. package/dist/esm/subconscious/BrainEmbeddings.js +211 -0
  418. package/dist/esm/subconscious/BrainEmbeddings.js.map +1 -0
  419. package/dist/esm/subconscious/GitIngest.d.ts +1 -1
  420. package/dist/esm/subconscious/GitIngest.js +3 -3
  421. package/dist/esm/subconscious/GitIngest.js.map +1 -1
  422. package/dist/esm/subconscious/SemanticCache.d.ts +2 -2
  423. package/dist/esm/subconscious/SemanticCache.js +2 -2
  424. package/dist/esm/subconscious/SubconsciousEngine.d.ts +1 -1
  425. package/dist/esm/subconscious/SubconsciousEngine.js +3 -3
  426. package/dist/esm/subconscious/TheBrainV2.d.ts +327 -9
  427. package/dist/esm/subconscious/TheBrainV2.d.ts.map +1 -1
  428. package/dist/esm/subconscious/TheBrainV2.js +947 -98
  429. package/dist/esm/subconscious/TheBrainV2.js.map +1 -1
  430. package/dist/esm/subconscious/freshness/LocalFsResolver.d.ts +16 -0
  431. package/dist/esm/subconscious/freshness/LocalFsResolver.d.ts.map +1 -0
  432. package/dist/esm/subconscious/freshness/LocalFsResolver.js +67 -0
  433. package/dist/esm/subconscious/freshness/LocalFsResolver.js.map +1 -0
  434. package/dist/esm/subconscious/freshness/SuppliedEvidenceResolver.d.ts +23 -0
  435. package/dist/esm/subconscious/freshness/SuppliedEvidenceResolver.d.ts.map +1 -0
  436. package/dist/esm/subconscious/freshness/SuppliedEvidenceResolver.js +20 -0
  437. package/dist/esm/subconscious/freshness/SuppliedEvidenceResolver.js.map +1 -0
  438. package/dist/esm/subconscious/freshness/index.d.ts +10 -0
  439. package/dist/esm/subconscious/freshness/index.d.ts.map +1 -0
  440. package/dist/esm/subconscious/freshness/index.js +10 -0
  441. package/dist/esm/subconscious/freshness/index.js.map +1 -0
  442. package/dist/esm/subconscious/index.d.ts +4 -4
  443. package/dist/esm/subconscious/index.js +4 -4
  444. package/dist/esm/utils/AffectedTests.js +1 -1
  445. package/dist/esm/utils/AutoContextBundle.d.ts +1 -1
  446. package/dist/esm/utils/AutoContextBundle.js +4 -4
  447. package/dist/esm/utils/BulkFileDigest.js +2 -2
  448. package/dist/esm/utils/ClipboardWatcher.js +2 -2
  449. package/dist/esm/utils/ContextCollapser.js +1 -1
  450. package/dist/esm/utils/ConversationCheckpoint.js +2 -2
  451. package/dist/esm/utils/ConversationCheckpoint.js.map +1 -1
  452. package/dist/esm/utils/ExecutiveReport.js +1 -1
  453. package/dist/esm/utils/OutputLedger.js +2 -2
  454. package/dist/esm/utils/ProjectIdentity.d.ts +12 -0
  455. package/dist/esm/utils/ProjectIdentity.d.ts.map +1 -0
  456. package/dist/esm/utils/ProjectIdentity.js +37 -0
  457. package/dist/esm/utils/ProjectIdentity.js.map +1 -0
  458. package/dist/esm/utils/PromptPatternCache.d.ts.map +1 -1
  459. package/dist/esm/utils/PromptPatternCache.js +12 -2
  460. package/dist/esm/utils/PromptPatternCache.js.map +1 -1
  461. package/dist/esm/utils/ReadWorkspaceCache.js +1 -1
  462. package/dist/esm/utils/RepeatGuard.d.ts.map +1 -1
  463. package/dist/esm/utils/RepeatGuard.js +10 -2
  464. package/dist/esm/utils/RepeatGuard.js.map +1 -1
  465. package/dist/esm/utils/SpeculativeWarmer.d.ts +1 -1
  466. package/dist/esm/utils/SpeculativeWarmer.js +2 -2
  467. package/dist/esm/utils/StateHashCache.d.ts +5 -0
  468. package/dist/esm/utils/StateHashCache.d.ts.map +1 -1
  469. package/dist/esm/utils/StateHashCache.js +40 -16
  470. package/dist/esm/utils/StateHashCache.js.map +1 -1
  471. package/dist/esm/utils/StructuralSearch.d.ts +1 -1
  472. package/dist/esm/utils/SurgicalASTInsert.js +1 -1
  473. package/dist/esm/utils/SymbolSurgicalContext.js +1 -1
  474. package/dist/esm/utils/TestAutofixInterceptor.js +1 -1
  475. package/dist/esm/utils/WorkspaceSearch.js +0 -0
  476. package/dist/esm/utils/logger.js +1 -1
  477. package/dist/esm/utils/reportSavings.js +1 -1
  478. package/package.json +1 -1
  479. package/sdks/crewai/README.md +25 -0
  480. package/dist/cjs/package.json +0 -3
  481. package/dist/esm/package.json +0 -3
  482. package/sdks/crewai/dist/index.d.ts +0 -130
  483. package/sdks/crewai/dist/index.js +0 -308
@@ -15,6 +15,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.TheBrainV2 = exports.MAX_DERIVED_DEPTH = exports.BloomFilter = void 0;
18
+ exports.mergeEntryMaps = mergeEntryMaps;
19
+ exports.currentGitContext = currentGitContext;
20
+ exports.resetGitContextCache = resetGitContextCache;
21
+ exports.attributeStaleness = attributeStaleness;
22
+ exports.entrySource = entrySource;
18
23
  exports.deriveProjectId = deriveProjectId;
19
24
  exports.tokenize = tokenize;
20
25
  exports.termFrequencies = termFrequencies;
@@ -30,10 +35,32 @@ const fs_1 = __importDefault(require("fs"));
30
35
  const path_1 = __importDefault(require("path"));
31
36
  const os_1 = __importDefault(require("os"));
32
37
  const crypto_1 = __importDefault(require("crypto"));
33
- const SymbolSurgicalContext_1 = require("../utils/SymbolSurgicalContext");
38
+ const child_process_1 = require("child_process");
39
+ const LocalFsResolver_1 = require("./freshness/LocalFsResolver");
40
+ const BrainEmbeddings_1 = require("./BrainEmbeddings");
41
+ /**
42
+ * Default resolver for the local MCP path. Behaviour is byte-for-byte identical to
43
+ * the pre-refactor inline `fs.readFileSync` hashing — it never returns `null`, so
44
+ * the verifier's fail-closed branch is never tripped and local freshness results
45
+ * are unchanged. Injectable via the `resolver` option on search/verifyByIds/refresh.
46
+ */
47
+ const defaultLocalResolver = new LocalFsResolver_1.LocalFsResolver();
34
48
  // ─── BM25 Constants ───────────────────────────────────────────────────────────
35
49
  const BM25_K1 = 1.5; // Term saturation (1.2-2.0)
36
50
  const BM25_B = 0.75; // Length normalization (0-1)
51
+ // ─── Recency decay ────────────────────────────────────────────────────────────
52
+ //
53
+ // Deliberately gentle and grace-periodded. Age is a weak proxy for "no longer true" —
54
+ // plenty of architectural decisions stay correct for years — so this is sized to break ties
55
+ // between comparable memories, not to bury an old one that is still the best match. A
56
+ // memory that goes stale is caught by hashing, which is the real signal; this only covers
57
+ // the case hashing can't see, where a memory tracks no files at all.
58
+ /** Days a memory is treated as current with no penalty at all. */
59
+ const RECENCY_GRACE_DAYS = 30;
60
+ /** Days past the grace period at which the penalty reaches its cap. */
61
+ const RECENCY_FULL_DECAY_DAYS = 365;
62
+ /** Cap, in the same 0-1 similarity space as the popularity/outcome/demerit adjustments. */
63
+ const MAX_RECENCY_PENALTY = 0.10;
37
64
  // ─── Storage Paths ────────────────────────────────────────────────────────────
38
65
  /**
39
66
  * LEMMA_BRAIN_DIR redirects storage — required so tests never write into the user's real
@@ -44,6 +71,273 @@ const BRAIN_DIR = process.env.LEMMA_BRAIN_DIR || path_1.default.join(os_1.defaul
44
71
  const ENTRIES_FILE = path_1.default.join(BRAIN_DIR, 'entries.ndjson');
45
72
  const INDEX_FILE = path_1.default.join(BRAIN_DIR, 'index.json');
46
73
  const META_FILE = path_1.default.join(BRAIN_DIR, 'meta.json');
74
+ /** Advisory cross-process write lock. See acquireLock() for why it is advisory, not a guarantee. */
75
+ const LOCK_FILE = path_1.default.join(BRAIN_DIR, '.write.lock');
76
+ /** Sidecar vector cache for the optional semantic re-rank. See BrainEmbeddings.ts. */
77
+ const EMBEDDINGS_FILE = path_1.default.join(BRAIN_DIR, 'embeddings.ndjson');
78
+ // ─── Cross-process durability ─────────────────────────────────────────────────
79
+ //
80
+ // Every MCP client session starts its own server process, and each one holds the whole
81
+ // corpus in memory. Before this section existed, save() blind-rewrote entries.ndjson in
82
+ // full: two concurrent sessions meant whichever flushed last silently erased everything the
83
+ // other had stored since it loaded. That is the exact usage pattern the tool descriptions
84
+ // advertise ("memories from ALL your projects"), so it was not a rare race.
85
+ //
86
+ // Three things fix it, and none of them require a database:
87
+ // 1. save() merges what is on disk into memory before writing, so a full rewrite can only
88
+ // ever be a superset — a writer can add, never subtract (except via tombstones).
89
+ // 2. Writes go through a temp file + rename, so a crash mid-write can't truncate the
90
+ // corpus into something load() would discard as "corrupt, start fresh".
91
+ // 3. An advisory lock file serializes the read-merge-write window between processes.
92
+ // Reads get the same benefit through syncIfChanged(): a long-lived server picks up a
93
+ // sibling session's memories on the next search instead of at the next restart.
94
+ /** A lock older than this is assumed to belong to a process that died holding it. */
95
+ const LOCK_STALE_MS = 10000;
96
+ /** How long a writer waits for another process's lock before proceeding regardless. */
97
+ const LOCK_WAIT_MS = 2000;
98
+ /** Deleted ids stop being suppressed after this long — a tombstone list must not grow forever. */
99
+ const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
100
+ /** Blocking sleep. save() is synchronous and its callers must never observe a partial corpus. */
101
+ function sleepSync(ms) {
102
+ try {
103
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
104
+ }
105
+ catch {
106
+ // SharedArrayBuffer unavailable (locked-down runtime) — spin instead of failing.
107
+ const until = Date.now() + ms;
108
+ while (Date.now() < until) { /* busy wait */ }
109
+ }
110
+ }
111
+ /**
112
+ * Take the write lock, or give up after LOCK_WAIT_MS and return null.
113
+ *
114
+ * Deliberately advisory and fail-open: a caller that can't get the lock still writes. The
115
+ * merge in save() is what actually prevents data loss; the lock only narrows the window
116
+ * where two mergers interleave. Blocking a memory write forever on a lock — or throwing —
117
+ * would be a worse outcome than the rare interleave it prevents.
118
+ */
119
+ function acquireLock() {
120
+ const deadline = Date.now() + LOCK_WAIT_MS;
121
+ for (;;) {
122
+ try {
123
+ return fs_1.default.openSync(LOCK_FILE, 'wx');
124
+ }
125
+ catch {
126
+ try {
127
+ if (Date.now() - fs_1.default.statSync(LOCK_FILE).mtimeMs > LOCK_STALE_MS) {
128
+ fs_1.default.unlinkSync(LOCK_FILE);
129
+ continue; // retry immediately against the freed lock
130
+ }
131
+ }
132
+ catch {
133
+ continue; // lock vanished between open and stat — the holder just released it
134
+ }
135
+ if (Date.now() >= deadline)
136
+ return null;
137
+ sleepSync(15);
138
+ }
139
+ }
140
+ }
141
+ function releaseLock(fd) {
142
+ if (fd === null)
143
+ return;
144
+ try {
145
+ fs_1.default.closeSync(fd);
146
+ }
147
+ catch { /* already closed */ }
148
+ try {
149
+ fs_1.default.unlinkSync(LOCK_FILE);
150
+ }
151
+ catch { /* already gone */ }
152
+ }
153
+ /**
154
+ * Write via temp file + rename. rename(2) is atomic within a filesystem, so a reader either
155
+ * sees the whole previous file or the whole new one — never a truncated prefix, which
156
+ * load()'s catch-all would have silently turned into an empty Brain.
157
+ */
158
+ function writeFileAtomic(target, data) {
159
+ const tmp = `${target}.tmp.${process.pid}.${Date.now().toString(36)}`;
160
+ try {
161
+ fs_1.default.writeFileSync(tmp, data, 'utf8');
162
+ fs_1.default.renameSync(tmp, target);
163
+ }
164
+ catch (err) {
165
+ try {
166
+ fs_1.default.unlinkSync(tmp);
167
+ }
168
+ catch { /* nothing to clean up */ }
169
+ throw err;
170
+ }
171
+ }
172
+ function stampOf(file) {
173
+ try {
174
+ const s = fs_1.default.statSync(file);
175
+ return { mtimeMs: s.mtimeMs, size: s.size };
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ function sameStamp(a, b) {
182
+ if (a === null || b === null)
183
+ return a === b;
184
+ return a.mtimeMs === b.mtimeMs && a.size === b.size;
185
+ }
186
+ /** Parses entries.ndjson exactly the way load() does, without touching instance state. */
187
+ function readEntriesFile() {
188
+ const out = new Map();
189
+ let raw;
190
+ try {
191
+ raw = fs_1.default.readFileSync(ENTRIES_FILE, 'utf8');
192
+ }
193
+ catch {
194
+ return out;
195
+ }
196
+ for (const line of raw.split('\n')) {
197
+ if (!line)
198
+ continue;
199
+ try {
200
+ const entry = JSON.parse(line);
201
+ if (!entry || typeof entry.id !== 'string')
202
+ continue;
203
+ // Entries written before the format was compacted still carry `terms`; newer ones
204
+ // don't. Rebuilding from termFreq covers both without a migration step.
205
+ if (!Array.isArray(entry.terms))
206
+ entry.terms = Object.keys(entry.termFreq || {});
207
+ out.set(entry.id, entry);
208
+ }
209
+ catch { /* skip corrupt lines */ }
210
+ }
211
+ return out;
212
+ }
213
+ /** Reads the tombstone map straight off disk, for absorbing a peer process's deletions. */
214
+ function readTombstonesFile() {
215
+ try {
216
+ return readTombstones(JSON.parse(fs_1.default.readFileSync(META_FILE, 'utf8')));
217
+ }
218
+ catch {
219
+ return new Map();
220
+ }
221
+ }
222
+ /** Reads the tombstone map out of a parsed meta.json, tolerating older files without one. */
223
+ function readTombstones(meta) {
224
+ const out = new Map();
225
+ const raw = meta?.tombstones;
226
+ if (!raw || typeof raw !== 'object')
227
+ return out;
228
+ for (const [id, at] of Object.entries(raw)) {
229
+ if (typeof at === 'string')
230
+ out.set(id, at);
231
+ }
232
+ return out;
233
+ }
234
+ /** Effective age anchor: a refreshed memory is as young as its last re-verification. */
235
+ function effectiveTime(entry) {
236
+ const t = Date.parse(entry.refreshedAt || entry.timestamp || '');
237
+ return Number.isFinite(t) ? t : 0;
238
+ }
239
+ /**
240
+ * Union two views of the corpus. Never subtracts: an id present in either side survives
241
+ * unless it is tombstoned, because "absent from my copy" and "deleted" are indistinguishable
242
+ * from inside one process, and guessing wrong loses a user's memory permanently.
243
+ *
244
+ * For an id on both sides, the newer version (by refreshedAt/timestamp) supplies the content
245
+ * and the counters take the max of both — `hits` and `demerits` are monotonic tallies that
246
+ * each process accumulated independently, so max is the only merge that doesn't discard
247
+ * feedback one of them collected.
248
+ */
249
+ function mergeEntryMaps(mine, theirs, tombstones = new Set()) {
250
+ const merged = new Map();
251
+ const put = (entry) => {
252
+ if (tombstones.has(entry.id))
253
+ return;
254
+ const existing = merged.get(entry.id);
255
+ if (!existing) {
256
+ merged.set(entry.id, entry);
257
+ return;
258
+ }
259
+ const winner = effectiveTime(entry) > effectiveTime(existing) ? entry : existing;
260
+ merged.set(entry.id, {
261
+ ...winner,
262
+ hits: Math.max(entry.hits || 0, existing.hits || 0),
263
+ demerits: Math.max(entry.demerits || 0, existing.demerits || 0),
264
+ });
265
+ };
266
+ for (const entry of mine.values())
267
+ put(entry);
268
+ for (const entry of theirs.values())
269
+ put(entry);
270
+ return merged;
271
+ }
272
+ // ─── Git context ──────────────────────────────────────────────────────────────
273
+ /**
274
+ * Current HEAD and branch, cached briefly.
275
+ *
276
+ * Read at store time and again when reporting a stale result, so the Brain can tell
277
+ * "someone edited this function" apart from "you switched branches" — a content hash
278
+ * reports both identically, and only the first is a reason to distrust the memory.
279
+ *
280
+ * Cached because a stale result is attributed inside the search result loop: without it,
281
+ * one search over five stale hits would spawn ten git processes.
282
+ */
283
+ const GIT_CONTEXT_TTL_MS = 5000;
284
+ let _gitContextCache = null;
285
+ function currentGitContext(cwd = process.cwd()) {
286
+ const now = Date.now();
287
+ if (_gitContextCache && _gitContextCache.cwd === cwd && now - _gitContextCache.at < GIT_CONTEXT_TTL_MS) {
288
+ return _gitContextCache.ctx;
289
+ }
290
+ let ctx = {};
291
+ try {
292
+ // Not a git repo, no git binary, detached worktree — all of these must produce {} and
293
+ // never throw. Git context is an explanatory nicety; nothing depends on it existing.
294
+ const run = (args) => (0, child_process_1.execSync)(`git ${args}`, { cwd, encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
295
+ const commit = run('rev-parse HEAD');
296
+ const branch = run('rev-parse --abbrev-ref HEAD');
297
+ ctx = {
298
+ ...(commit ? { commit } : {}),
299
+ // "HEAD" is what git reports in a detached checkout — not a branch name, so don't
300
+ // record it as one and later claim the user "switched branches" away from it.
301
+ ...(branch && branch !== 'HEAD' ? { branch } : {}),
302
+ };
303
+ }
304
+ catch {
305
+ ctx = {};
306
+ }
307
+ _gitContextCache = { cwd, at: now, ctx };
308
+ return ctx;
309
+ }
310
+ /** Test seam: forget the cached git context so a test can change branches mid-run. */
311
+ function resetGitContextCache() {
312
+ _gitContextCache = null;
313
+ }
314
+ /**
315
+ * Why a stale entry is stale. Pure attribution over data already gathered — it never
316
+ * changes whether something is stale, only how the staleness is explained.
317
+ */
318
+ function attributeStaleness(entry, git) {
319
+ if (entry.gitBranch && git.branch && entry.gitBranch !== git.branch) {
320
+ return { staleCause: 'branch-changed', storedOnBranch: entry.gitBranch };
321
+ }
322
+ return { staleCause: 'content-changed' };
323
+ }
324
+ /**
325
+ * Normalized provenance for an entry, back-deriving one for entries written before `source`
326
+ * existed. Those overloaded the free-text `provider` field with the same information, so
327
+ * reading it here keeps historical stats meaningful instead of a wall of "unknown".
328
+ */
329
+ function entrySource(entry) {
330
+ if (entry.source)
331
+ return entry.source;
332
+ switch (entry.provider) {
333
+ case 'test_oracle_auto': return 'auto-test';
334
+ case 'checkpoint': return 'auto-checkpoint';
335
+ case 'git-commit':
336
+ case 'changelog': return 'auto-git';
337
+ case 'pr-review': return 'auto-pr-review';
338
+ default: return 'unknown';
339
+ }
340
+ }
47
341
  // ─── Project Scoping ──────────────────────────────────────────────────────────
48
342
  /**
49
343
  * Stable identity for the project a memory belongs to.
@@ -284,12 +578,8 @@ function hashFilesForFreshness(filePaths) {
284
578
  const hashes = {};
285
579
  for (const p of filePaths) {
286
580
  const abs = path_1.default.resolve(p);
287
- try {
288
- hashes[abs] = crypto_1.default.createHash('sha256').update(fs_1.default.readFileSync(abs, 'utf8')).digest('hex');
289
- }
290
- catch {
291
- hashes[abs] = 'MISSING';
292
- }
581
+ // LocalFsResolver never returns null; ?? 'MISSING' is a defensive safety net only.
582
+ hashes[abs] = defaultLocalResolver.hashFile(abs) ?? 'MISSING';
293
583
  }
294
584
  return hashes;
295
585
  }
@@ -299,7 +589,7 @@ function hashSymbolsForFreshness(refs) {
299
589
  for (const ref of refs) {
300
590
  const abs = path_1.default.resolve(ref.filePath);
301
591
  const key = `${abs}::${ref.symbolName}`;
302
- hashes[key] = currentSymbolHashPair(abs, ref.symbolName).raw;
592
+ hashes[key] = (defaultLocalResolver.hashSymbol(abs, ref.symbolName) ?? { raw: 'MISSING' }).raw;
303
593
  }
304
594
  return hashes;
305
595
  }
@@ -314,21 +604,12 @@ function hashSymbolsNormalizedForFreshness(refs) {
314
604
  for (const ref of refs) {
315
605
  const abs = path_1.default.resolve(ref.filePath);
316
606
  const key = `${abs}::${ref.symbolName}`;
317
- hashes[key] = currentSymbolHashPair(abs, ref.symbolName).normalized;
607
+ hashes[key] = (defaultLocalResolver.hashSymbol(abs, ref.symbolName) ?? { normalized: 'MISSING' }).normalized;
318
608
  }
319
609
  return hashes;
320
610
  }
321
611
  function currentSymbolHashPair(abs, symbolName) {
322
- try {
323
- const ctx = (0, SymbolSurgicalContext_1.getSymbolSurgicalContext)(path_1.default.dirname(abs), path_1.default.basename(abs), symbolName);
324
- return {
325
- raw: crypto_1.default.createHash('sha256').update(ctx.implementation).digest('hex'),
326
- normalized: crypto_1.default.createHash('sha256').update((0, SymbolSurgicalContext_1.normalizeForFreshness)(ctx.implementation)).digest('hex'),
327
- };
328
- }
329
- catch {
330
- return { raw: 'MISSING', normalized: 'MISSING' };
331
- }
612
+ return defaultLocalResolver.hashSymbol(abs, symbolName) ?? { raw: 'MISSING', normalized: 'MISSING' };
332
613
  }
333
614
  /**
334
615
  * Looks up a `derivedFrom` id against both entries and claims in one pass. `claimIndex`
@@ -492,9 +773,16 @@ function findBlastRadius(entries, filePath, symbolName) {
492
773
  * also has a tracked symbol is judged by the symbol's hash, not the whole file's — an edit
493
774
  * elsewhere in the same file (a different function, an import, a comment) must not
494
775
  * invalidate a memory that was only ever about one specific symbol.
776
+ *
777
+ * `resolver` is the evidence seam: LocalFsResolver (local MCP, returns 'MISSING' on
778
+ * unreadable files — behaviour unchanged) or SuppliedEvidenceResolver (cloud, returns
779
+ * `null` for a tracked path the client omitted). A `null` from the resolver is the
780
+ * fail-closed signal: the outcome carries that path in `unverifiedFiles` and is never
781
+ * `fresh`.
495
782
  */
496
- function checkEntryFreshness(entry, resolveNode, depth = 0, visited = new Set(), semanticDiff = false) {
783
+ function checkEntryFreshness(entry, resolver, resolveNode, depth = 0, visited = new Set(), semanticDiff = false) {
497
784
  const staleFiles = [];
785
+ const unverifiedFiles = [];
498
786
  const cosmeticChanges = [];
499
787
  const relLabel = (abs) => {
500
788
  const rel = path_1.default.relative(process.cwd(), abs);
@@ -505,12 +793,10 @@ function checkEntryFreshness(entry, resolveNode, depth = 0, visited = new Set(),
505
793
  for (const [abs, storedHash] of Object.entries(entry.fileHashes)) {
506
794
  if (symbolCoveredAbsPaths.has(abs))
507
795
  continue;
508
- let currentHash;
509
- try {
510
- currentHash = crypto_1.default.createHash('sha256').update(fs_1.default.readFileSync(abs, 'utf8')).digest('hex');
511
- }
512
- catch {
513
- currentHash = 'MISSING';
796
+ const currentHash = resolver.hashFile(abs);
797
+ if (currentHash === null) {
798
+ unverifiedFiles.push(relLabel(abs)); // fail-closed: cannot verify ⇒ not fresh
799
+ continue;
514
800
  }
515
801
  if (currentHash !== storedHash)
516
802
  staleFiles.push(relLabel(abs));
@@ -522,7 +808,11 @@ function checkEntryFreshness(entry, resolveNode, depth = 0, visited = new Set(),
522
808
  const abs = key.substring(0, sep);
523
809
  const symbolName = key.substring(sep + 2);
524
810
  const label = `${relLabel(abs)}::${symbolName}`;
525
- const currentPair = currentSymbolHashPair(abs, symbolName);
811
+ const currentPair = resolver.hashSymbol(abs, symbolName);
812
+ if (currentPair === null) {
813
+ unverifiedFiles.push(label); // fail-closed: cannot verify ⇒ not fresh
814
+ continue;
815
+ }
526
816
  if (currentPair.raw === storedHash)
527
817
  continue;
528
818
  if (semanticDiff) {
@@ -548,14 +838,23 @@ function checkEntryFreshness(entry, resolveNode, depth = 0, visited = new Set(),
548
838
  }
549
839
  const nextVisited = new Set(visited);
550
840
  nextVisited.add(depId);
551
- const depFresh = node.kind === 'entry'
552
- ? checkEntryFreshness(node.entry, resolveNode, depth + 1, nextVisited, semanticDiff).fresh
553
- : checkClaimFreshness(node.claim, semanticDiff).fresh;
554
- if (!depFresh)
555
- staleFiles.push(`derived:${depId}`);
841
+ const depOutcome = node.kind === 'entry'
842
+ ? checkEntryFreshness(node.entry, resolver, resolveNode, depth + 1, nextVisited, semanticDiff)
843
+ : checkClaimFreshness(node.claim, resolver, semanticDiff);
844
+ if (!depOutcome.fresh) {
845
+ if ((depOutcome.unverifiedFiles?.length ?? 0) > 0)
846
+ unverifiedFiles.push(`derived:${depId}`);
847
+ else
848
+ staleFiles.push(`derived:${depId}`);
849
+ }
556
850
  }
557
851
  }
558
- return { fresh: staleFiles.length === 0, staleFiles, ...(cosmeticChanges.length > 0 ? { cosmeticChanges } : {}) };
852
+ return {
853
+ fresh: staleFiles.length === 0 && unverifiedFiles.length === 0,
854
+ staleFiles,
855
+ unverifiedFiles,
856
+ ...(cosmeticChanges.length > 0 ? { cosmeticChanges } : {}),
857
+ };
559
858
  }
560
859
  /** Builds the persisted `Claim[]` for an entry from the caller-supplied claim inputs. */
561
860
  function buildClaims(inputs) {
@@ -572,9 +871,12 @@ function buildClaims(inputs) {
572
871
  /**
573
872
  * Same hash-compare as checkEntryFreshness, scoped to one claim's own evidence — a claim
574
873
  * with no fileHashes/symbolHashes at all is always fresh (nothing tracked to go stale).
874
+ * `resolver` is the same evidence seam as checkEntryFreshness; a `null` from it fails
875
+ * closed to `unverified`, never `fresh`.
575
876
  */
576
- function checkClaimFreshness(claim, semanticDiff = false) {
877
+ function checkClaimFreshness(claim, resolver, semanticDiff = false) {
577
878
  const staleFiles = [];
879
+ const unverifiedFiles = [];
578
880
  const cosmeticChanges = [];
579
881
  const relLabel = (abs) => {
580
882
  const rel = path_1.default.relative(process.cwd(), abs);
@@ -585,12 +887,10 @@ function checkClaimFreshness(claim, semanticDiff = false) {
585
887
  for (const [abs, storedHash] of Object.entries(claim.fileHashes)) {
586
888
  if (symbolCoveredAbsPaths.has(abs))
587
889
  continue;
588
- let currentHash;
589
- try {
590
- currentHash = crypto_1.default.createHash('sha256').update(fs_1.default.readFileSync(abs, 'utf8')).digest('hex');
591
- }
592
- catch {
593
- currentHash = 'MISSING';
890
+ const currentHash = resolver.hashFile(abs);
891
+ if (currentHash === null) {
892
+ unverifiedFiles.push(relLabel(abs));
893
+ continue;
594
894
  }
595
895
  if (currentHash !== storedHash)
596
896
  staleFiles.push(relLabel(abs));
@@ -602,7 +902,11 @@ function checkClaimFreshness(claim, semanticDiff = false) {
602
902
  const abs = key.substring(0, sep);
603
903
  const symbolName = key.substring(sep + 2);
604
904
  const label = `${relLabel(abs)}::${symbolName}`;
605
- const currentPair = currentSymbolHashPair(abs, symbolName);
905
+ const currentPair = resolver.hashSymbol(abs, symbolName);
906
+ if (currentPair === null) {
907
+ unverifiedFiles.push(label);
908
+ continue;
909
+ }
606
910
  if (currentPair.raw === storedHash)
607
911
  continue;
608
912
  if (semanticDiff) {
@@ -618,9 +922,10 @@ function checkClaimFreshness(claim, semanticDiff = false) {
618
922
  return {
619
923
  id: claim.id,
620
924
  text: claim.text,
621
- fresh: staleFiles.length === 0,
925
+ fresh: staleFiles.length === 0 && unverifiedFiles.length === 0,
622
926
  ...(staleFiles.length > 0 ? { staleFiles } : {}),
623
927
  ...(cosmeticChanges.length > 0 ? { cosmeticChanges } : {}),
928
+ ...(unverifiedFiles.length > 0 ? { unverifiedFiles } : {}),
624
929
  };
625
930
  }
626
931
  // ─── TheBrainV2 ──────────────────────────────────────────────────────────────
@@ -635,6 +940,11 @@ class TheBrainV2 {
635
940
  this.sessionMisses = 0;
636
941
  this.dirty = false;
637
942
  this.flushTimer = null;
943
+ /** State of entries.ndjson as of our last read/write — see syncIfChanged(). */
944
+ this.diskStamp = null;
945
+ /** id -> ISO deletion time, for ids that must not come back through a merge. */
946
+ this.tombstones = new Map();
947
+ this.sidecar = null;
638
948
  this.ensureDir();
639
949
  this.load();
640
950
  }
@@ -645,36 +955,23 @@ class TheBrainV2 {
645
955
  }
646
956
  load() {
647
957
  try {
648
- // Load entries from NDJSON
649
- if (fs_1.default.existsSync(ENTRIES_FILE)) {
650
- const lines = fs_1.default.readFileSync(ENTRIES_FILE, 'utf8').split('\n').filter(Boolean);
651
- for (const line of lines) {
652
- try {
653
- const entry = JSON.parse(line);
654
- // Entries written before the format was compacted still carry `terms`; newer
655
- // ones don't. Rebuilding from termFreq covers both without a migration step.
656
- if (!Array.isArray(entry.terms))
657
- entry.terms = Object.keys(entry.termFreq || {});
658
- this.entries.set(entry.id, entry);
659
- }
660
- catch { /* skip corrupt lines */ }
661
- }
662
- }
663
- // Load inverted index
664
- if (fs_1.default.existsSync(INDEX_FILE)) {
665
- const raw = JSON.parse(fs_1.default.readFileSync(INDEX_FILE, 'utf8'));
666
- for (const [term, ids] of Object.entries(raw)) {
667
- this.invertedIndex.set(term, new Set(ids));
668
- }
669
- }
670
- // Load bloom filter and meta
958
+ // Meta first: the tombstone list decides which entries are allowed back in.
671
959
  if (fs_1.default.existsSync(META_FILE)) {
672
960
  const meta = JSON.parse(fs_1.default.readFileSync(META_FILE, 'utf8'));
673
- if (meta.bloom) {
961
+ if (meta.bloom)
674
962
  this.bloom = new BloomFilter(meta.bloom);
675
- }
676
963
  this.avgDocLength = meta.avgDocLength || 0;
964
+ this.tombstones = readTombstones(meta);
677
965
  }
966
+ this.entries = readEntriesFile();
967
+ for (const id of this.tombstones.keys())
968
+ this.entries.delete(id);
969
+ this.diskStamp = stampOf(ENTRIES_FILE);
970
+ // The inverted index is rebuilt from the entries rather than read back from
971
+ // index.json. It is a pure derivation of the entries, so reconstructing it is the
972
+ // only way it can never disagree with them — and a stale or half-written index.json
973
+ // used to be able to hide entries from search entirely while they sat on disk intact.
974
+ this.rebuildIndex();
678
975
  this.recalcAvgDocLength();
679
976
  }
680
977
  catch (err) {
@@ -682,6 +979,52 @@ class TheBrainV2 {
682
979
  this.entries.clear();
683
980
  this.invertedIndex.clear();
684
981
  this.bloom = new BloomFilter();
982
+ this.tombstones = new Map();
983
+ }
984
+ }
985
+ /** Recomputes the inverted index from scratch over the current entries. */
986
+ rebuildIndex() {
987
+ this.invertedIndex = new Map();
988
+ for (const entry of this.entries.values()) {
989
+ for (const term of entry.terms) {
990
+ let ids = this.invertedIndex.get(term);
991
+ if (!ids) {
992
+ ids = new Set();
993
+ this.invertedIndex.set(term, ids);
994
+ }
995
+ ids.add(entry.id);
996
+ }
997
+ }
998
+ }
999
+ /**
1000
+ * Pull in anything another process wrote since we last touched the corpus.
1001
+ *
1002
+ * Called at the top of every read path. Two statSync calls when nothing changed (the
1003
+ * overwhelmingly common case) is far below the cost of the search that follows, and it
1004
+ * turns sibling sessions from a data-loss hazard into a live shared corpus: a memory
1005
+ * stored in one project's session is searchable from another within one tool call.
1006
+ *
1007
+ * Merges rather than reloads, so memories stored locally but not yet flushed survive.
1008
+ */
1009
+ syncIfChanged() {
1010
+ const stamp = stampOf(ENTRIES_FILE);
1011
+ if (sameStamp(stamp, this.diskStamp))
1012
+ return;
1013
+ try {
1014
+ this.absorbPeerTombstones();
1015
+ const onDisk = readEntriesFile();
1016
+ const before = this.entries.size;
1017
+ this.entries = mergeEntryMaps(this.entries, onDisk, new Set(this.tombstones.keys()));
1018
+ this.diskStamp = stamp;
1019
+ if (this.entries.size !== before || onDisk.size > 0) {
1020
+ this.rebuildIndex();
1021
+ this.recalcAvgDocLength();
1022
+ }
1023
+ }
1024
+ catch {
1025
+ // A read failure here must never break a search: worst case we keep serving the
1026
+ // in-memory corpus, which is exactly the pre-existing behavior.
1027
+ this.diskStamp = stamp;
685
1028
  }
686
1029
  }
687
1030
  recalcAvgDocLength() {
@@ -703,38 +1046,111 @@ class TheBrainV2 {
703
1046
  }, 500);
704
1047
  }
705
1048
  save() {
1049
+ const fd = acquireLock();
706
1050
  try {
707
1051
  this.ensureDir();
1052
+ // Merge before writing. A full rewrite of what this process happens to hold would
1053
+ // erase every memory a sibling session stored since we loaded — the concurrency bug
1054
+ // this whole section exists to close. After the merge the file we write is a superset
1055
+ // of both views, so a writer can only ever add.
1056
+ this.absorbPeerTombstones();
1057
+ const onDisk = readEntriesFile();
1058
+ if (onDisk.size > 0) {
1059
+ this.entries = mergeEntryMaps(this.entries, onDisk, new Set(this.tombstones.keys()));
1060
+ this.rebuildIndex();
1061
+ this.recalcAvgDocLength();
1062
+ }
1063
+ this.pruneTombstones();
1064
+ // Post-merge, so the file we write respects the cap even when the merge pulled in
1065
+ // entries a peer had already evicted.
1066
+ this.evictIfOverCapacity();
708
1067
  // Write NDJSON entries. `terms` is dropped: it is exactly Object.keys(termFreq),
709
1068
  // and persisting both made the entry file 38% redundant bytes that every session
710
1069
  // re-read at startup. load() reconstructs it.
711
1070
  const ndjson = Array.from(this.entries.values())
712
1071
  .map(({ terms: _terms, ...persisted }) => JSON.stringify(persisted))
713
1072
  .join('\n');
714
- fs_1.default.writeFileSync(ENTRIES_FILE, ndjson, 'utf8');
715
- // Write inverted index
1073
+ writeFileAtomic(ENTRIES_FILE, ndjson);
1074
+ // Write inverted index. load() rebuilds this from the entries rather than reading it
1075
+ // back, so it is now purely an inspection artifact for the dashboard and for anyone
1076
+ // poking at the brain directory — kept because removing a file other tooling may read
1077
+ // is not worth the handful of bytes it saves.
716
1078
  const indexObj = {};
717
1079
  for (const [term, ids] of this.invertedIndex) {
718
1080
  indexObj[term] = Array.from(ids);
719
1081
  }
720
- fs_1.default.writeFileSync(INDEX_FILE, JSON.stringify(indexObj), 'utf8');
1082
+ writeFileAtomic(INDEX_FILE, JSON.stringify(indexObj));
721
1083
  // Write meta
722
- fs_1.default.writeFileSync(META_FILE, JSON.stringify({
1084
+ writeFileAtomic(META_FILE, JSON.stringify({
723
1085
  bloom: this.bloom.serialize(),
724
1086
  avgDocLength: this.avgDocLength,
725
1087
  totalEntries: this.entries.size,
726
1088
  savedAt: new Date().toISOString(),
727
- }), 'utf8');
1089
+ tombstones: Object.fromEntries(this.tombstones),
1090
+ }));
1091
+ this.diskStamp = stampOf(ENTRIES_FILE);
728
1092
  this.dirty = false;
729
1093
  }
730
1094
  catch { /* fail silently */ }
1095
+ finally {
1096
+ releaseLock(fd);
1097
+ }
1098
+ }
1099
+ /**
1100
+ * Pull in deletions made by other processes.
1101
+ *
1102
+ * Tombstones are shared state, not per-process bookkeeping: a peer that deleted a memory
1103
+ * wrote the tombstone to meta.json, and a process that still holds the entry in memory
1104
+ * would otherwise merge it right back on its next flush — undoing a deliberate deletion
1105
+ * from a session that had nothing to do with it.
1106
+ */
1107
+ absorbPeerTombstones() {
1108
+ for (const [id, at] of readTombstonesFile()) {
1109
+ if (!this.tombstones.has(id))
1110
+ this.tombstones.set(id, at);
1111
+ const entry = this.entries.get(id);
1112
+ if (entry)
1113
+ this.dropEntry(entry);
1114
+ }
1115
+ }
1116
+ /**
1117
+ * Records an id as deliberately deleted, so a merge with a peer that still holds it
1118
+ * doesn't resurrect it. Without this, forget() would be undone the moment any other
1119
+ * session flushed, and eviction would thrash forever between two processes.
1120
+ */
1121
+ tombstone(id) {
1122
+ this.tombstones.set(id, new Date().toISOString());
1123
+ }
1124
+ /** Drops tombstones older than TOMBSTONE_TTL_MS — by then no peer still holds the entry. */
1125
+ pruneTombstones() {
1126
+ const cutoff = Date.now() - TOMBSTONE_TTL_MS;
1127
+ for (const [id, at] of this.tombstones) {
1128
+ const t = Date.parse(at);
1129
+ if (!Number.isFinite(t) || t < cutoff)
1130
+ this.tombstones.delete(id);
1131
+ }
1132
+ }
1133
+ /** Removes an entry from the in-memory corpus and the inverted index. No persistence. */
1134
+ dropEntry(entry) {
1135
+ this.entries.delete(entry.id);
1136
+ for (const term of entry.terms) {
1137
+ const ids = this.invertedIndex.get(term);
1138
+ if (!ids)
1139
+ continue;
1140
+ ids.delete(entry.id);
1141
+ if (ids.size === 0)
1142
+ this.invertedIndex.delete(term);
1143
+ }
731
1144
  }
732
1145
  // ─── Store ────────────────────────────────────────────────────────────────
733
1146
  /**
734
1147
  * Store a query+response pair in the brain.
735
1148
  * Returns false if detected as duplicate (>= dupThreshold similarity).
736
1149
  */
737
- store(query, response, provider = 'generic', dupThreshold = 0.92, filePaths, projectId = deriveProjectId(), outcome, symbolRefs, claimInputs, domain, derivedFrom) {
1150
+ store(query, response, provider = 'generic', dupThreshold = 0.92, filePaths, projectId = deriveProjectId(), outcome, symbolRefs, claimInputs, domain, derivedFrom, options = {}) {
1151
+ // Another session may have stored this exact thing since we loaded; without the sync
1152
+ // the dedup check below would miss it and write a second copy.
1153
+ this.syncIfChanged();
738
1154
  // Quick bloom check
739
1155
  const queryKey = query.trim().toLowerCase().substring(0, 200);
740
1156
  if (this.bloom.has(queryKey)) {
@@ -772,11 +1188,20 @@ class TheBrainV2 {
772
1188
  }
773
1189
  const terms = tokenize(query + ' ' + response);
774
1190
  const termFreq = termFrequencies(terms);
1191
+ // Salted with random bytes, not just the clock: the id used to be sha1(queryKey + now),
1192
+ // so two memories sharing a query prefix and landing in the same millisecond produced
1193
+ // the same id and the second silently overwrote the first — a lost memory that nothing
1194
+ // reported. Batch paths (git ingest, importBundle, auto-capture) hit that window.
775
1195
  const id = crypto_1.default
776
1196
  .createHash('sha1')
777
- .update(queryKey + Date.now())
1197
+ .update(queryKey + Date.now() + crypto_1.default.randomBytes(8).toString('hex'))
778
1198
  .digest('hex')
779
1199
  .substring(0, 12);
1200
+ // Recorded so a later stale result can say whether the code changed or the caller
1201
+ // simply moved branches. Never consulted by freshness itself.
1202
+ const git = options.skipGitContext
1203
+ ? { commit: options.gitCommit, branch: options.gitBranch }
1204
+ : currentGitContext();
780
1205
  const entry = {
781
1206
  id,
782
1207
  query: query.trim(),
@@ -784,17 +1209,23 @@ class TheBrainV2 {
784
1209
  terms: [...new Set(terms)],
785
1210
  termFreq,
786
1211
  provider,
787
- timestamp: new Date().toISOString(),
1212
+ timestamp: options.timestamp || new Date().toISOString(),
788
1213
  hits: 0,
789
1214
  charCount: query.length + response.length,
790
1215
  projectId,
791
- fileHashes: filePaths && filePaths.length > 0 ? hashFilesForFreshness(filePaths) : undefined,
1216
+ fileHashes: options.evidence?.files ??
1217
+ (filePaths && filePaths.length > 0 ? hashFilesForFreshness(filePaths) : undefined),
792
1218
  outcome,
793
- symbolHashes: symbolRefs && symbolRefs.length > 0 ? hashSymbolsForFreshness(symbolRefs) : undefined,
794
- symbolNormalizedHashes: symbolRefs && symbolRefs.length > 0 ? hashSymbolsNormalizedForFreshness(symbolRefs) : undefined,
1219
+ symbolHashes: options.evidence?.symbols ??
1220
+ (symbolRefs && symbolRefs.length > 0 ? hashSymbolsForFreshness(symbolRefs) : undefined),
1221
+ symbolNormalizedHashes: options.evidence?.symbolsNormalized ??
1222
+ (symbolRefs && symbolRefs.length > 0 ? hashSymbolsNormalizedForFreshness(symbolRefs) : undefined),
795
1223
  claims: claimInputs && claimInputs.length > 0 ? buildClaims(claimInputs) : undefined,
796
1224
  domain,
797
1225
  derivedFrom: derivedFrom && derivedFrom.length > 0 ? derivedFrom : undefined,
1226
+ source: options.source || 'manual',
1227
+ ...(git.commit ? { gitCommit: git.commit } : {}),
1228
+ ...(git.branch ? { gitBranch: git.branch } : {}),
798
1229
  };
799
1230
  this.entries.set(id, entry);
800
1231
  // Update inverted index
@@ -809,7 +1240,7 @@ class TheBrainV2 {
809
1240
  this.recalcAvgDocLength();
810
1241
  this.evictIfOverCapacity();
811
1242
  this.scheduleSave();
812
- return { stored: true, reason: 'Stored successfully', conflicts };
1243
+ return { stored: true, reason: 'Stored successfully', id, conflicts };
813
1244
  }
814
1245
  /**
815
1246
  * Evict the lowest-value entries once the Brain is over capacity. Value = hits (proven
@@ -836,15 +1267,11 @@ class TheBrainV2 {
836
1267
  });
837
1268
  const toEvict = ranked.slice(0, TheBrainV2.EVICT_BATCH);
838
1269
  for (const entry of toEvict) {
839
- this.entries.delete(entry.id);
840
- for (const term of entry.terms) {
841
- const ids = this.invertedIndex.get(term);
842
- if (!ids)
843
- continue;
844
- ids.delete(entry.id);
845
- if (ids.size === 0)
846
- this.invertedIndex.delete(term);
847
- }
1270
+ this.dropEntry(entry);
1271
+ // Tombstoned, not just dropped: another session's merge would otherwise hand back
1272
+ // every entry this pass just decided was worthless, and the two processes would
1273
+ // evict-and-resurrect the same 500 entries against each other indefinitely.
1274
+ this.tombstone(entry.id);
848
1275
  }
849
1276
  this.recalcAvgDocLength();
850
1277
  }
@@ -856,6 +1283,8 @@ class TheBrainV2 {
856
1283
  search(query, limit = 5, minSimilarity = 0, options = {}) {
857
1284
  // Internal callers (dedup) must not move the counters the savings ledger reports.
858
1285
  const countStats = options.countStats !== false;
1286
+ // Pick up anything a sibling session stored since the last read. Two statSync calls.
1287
+ this.syncIfChanged();
859
1288
  if (this.entries.size === 0) {
860
1289
  if (countStats)
861
1290
  this.sessionMisses++;
@@ -946,6 +1375,16 @@ class TheBrainV2 {
946
1375
  // popularity prior above rather than just canceling it out.
947
1376
  if (s.entry.demerits)
948
1377
  similarity -= Math.min(s.entry.demerits * 0.06, 0.25);
1378
+ // Age decay. Every other signal here is about the memory's track record; none of them
1379
+ // notice that the codebase it describes has been rewritten twice since. A confirmed
1380
+ // memory from a year ago is not as likely to be current as yesterday's, and until now
1381
+ // they ranked identically. Kept in the same small, additive register as the rest — it
1382
+ // demotes among relevant candidates, it can't bury one. `refreshedAt` resets the clock,
1383
+ // which is the point of refresh(): a re-verified memory really is current again.
1384
+ const ageDays = (Date.now() - effectiveTime(s.entry)) / 86400000;
1385
+ if (Number.isFinite(ageDays) && ageDays > RECENCY_GRACE_DAYS) {
1386
+ similarity -= Math.min((ageDays - RECENCY_GRACE_DAYS) / RECENCY_FULL_DECAY_DAYS, 1) * MAX_RECENCY_PENALTY;
1387
+ }
949
1388
  return { entry: s.entry, similarity: Math.max(0, similarity) };
950
1389
  });
951
1390
  // 6. Sort and filter
@@ -953,12 +1392,17 @@ class TheBrainV2 {
953
1392
  const claimIndex = buildClaimIndex(this.entries);
954
1393
  const resolveNode = (id) => resolveFreshnessNode(this.entries, claimIndex, id);
955
1394
  const semanticDiff = options.semanticDiff === true;
1395
+ const resolver = options.resolver ?? defaultLocalResolver;
1396
+ // Resolved once per search, not per stale hit — five stale results would otherwise
1397
+ // spawn ten git processes for one query.
1398
+ const git = currentGitContext();
956
1399
  const results = combined
957
1400
  .filter(r => r.similarity >= minSimilarity)
958
1401
  .slice(0, limit)
959
1402
  .map(r => {
960
- const { fresh, staleFiles, cosmeticChanges } = checkEntryFreshness(r.entry, resolveNode, 0, new Set(), semanticDiff);
961
- const claims = r.entry.claims?.map((c) => checkClaimFreshness(c, semanticDiff));
1403
+ const { fresh, staleFiles, unverifiedFiles, cosmeticChanges } = checkEntryFreshness(r.entry, resolver, resolveNode, 0, new Set(), semanticDiff);
1404
+ const claims = r.entry.claims?.map((c) => checkClaimFreshness(c, resolver, semanticDiff));
1405
+ const unverified = unverifiedFiles.length > 0;
962
1406
  return {
963
1407
  id: r.entry.id,
964
1408
  query: r.entry.query,
@@ -967,7 +1411,8 @@ class TheBrainV2 {
967
1411
  provider: r.entry.provider,
968
1412
  timestamp: r.entry.timestamp,
969
1413
  fresh,
970
- ...(fresh ? {} : { staleFiles }),
1414
+ ...(fresh ? {} : { staleFiles, ...(unverified ? {} : attributeStaleness(r.entry, git)) }),
1415
+ ...(unverifiedFiles.length > 0 ? { unverifiedFiles } : {}),
971
1416
  ...(r.entry.outcome ? { outcome: r.entry.outcome } : {}),
972
1417
  ...(claims && claims.length > 0 ? { claims } : {}),
973
1418
  ...(r.entry.domain ? { domain: r.entry.domain } : {}),
@@ -995,6 +1440,69 @@ class TheBrainV2 {
995
1440
  * Entries belonging to a project, including the unscoped ones written before entries
996
1441
  * carried a projectId — same fallback rule search() uses, so counts and results agree.
997
1442
  */
1443
+ /**
1444
+ * search(), with an optional semantic re-rank layered on top.
1445
+ *
1446
+ * BM25 still does the retrieving — this only reorders what it already found, and only
1447
+ * when LEMMA_BRAIN_EMBEDDINGS is set and Ollama answers. With the flag off (the default)
1448
+ * this is exactly `search()` plus one boolean check, so callers can use it unconditionally
1449
+ * and no session pays for a feature it hasn't turned on. See BrainEmbeddings.ts.
1450
+ *
1451
+ * The candidate pool is widened before re-ranking: re-ordering the same `limit` results
1452
+ * BM25 already picked can only shuffle them, never surface the memory BM25 ranked 9th
1453
+ * because it happened to use different words — which is the entire point.
1454
+ */
1455
+ async searchHybrid(query, limit = 5, minSimilarity = 0, options = {}) {
1456
+ if (!(0, BrainEmbeddings_1.embeddingsEnabled)())
1457
+ return this.search(query, limit, minSimilarity, options);
1458
+ const poolSize = Math.max(limit * 4, 20);
1459
+ // minSimilarity 0 for the pool: a candidate the re-rank would promote must not be cut
1460
+ // by a lexical floor before the semantic score is ever computed. The floor is applied
1461
+ // again below, against the blended score.
1462
+ const pool = this.search(query, poolSize, 0, { ...options, countStats: false });
1463
+ if (pool.length === 0) {
1464
+ if (options.countStats !== false)
1465
+ this.sessionMisses++;
1466
+ return [];
1467
+ }
1468
+ let ranked = pool;
1469
+ try {
1470
+ const scores = await (0, BrainEmbeddings_1.semanticRerank)(query, pool.map((r) => ({ id: r.id, similarity: r.similarity, text: `${r.query}\n${r.response}` })), this.embeddingSidecar());
1471
+ if (scores) {
1472
+ ranked = pool
1473
+ .map((r) => ({ ...r, similarity: scores.get(r.id) ?? r.similarity }))
1474
+ .sort((a, b) => b.similarity - a.similarity);
1475
+ this.sidecar?.flush(new Set(this.entries.keys()));
1476
+ }
1477
+ }
1478
+ catch {
1479
+ // Re-ranking is an enhancement; a failure in it must never cost the caller the
1480
+ // results BM25 already produced.
1481
+ ranked = pool;
1482
+ }
1483
+ const results = ranked.filter((r) => r.similarity >= minSimilarity).slice(0, limit);
1484
+ // Stats bookkeeping the pool search was told to skip, applied once against the final list
1485
+ // so a hybrid search counts exactly like a lexical one.
1486
+ if (options.countStats !== false) {
1487
+ if (results.length > 0) {
1488
+ this.sessionHits++;
1489
+ const topEntry = this.entries.get(results[0].id);
1490
+ if (topEntry) {
1491
+ topEntry.hits++;
1492
+ this.scheduleSave();
1493
+ }
1494
+ }
1495
+ else {
1496
+ this.sessionMisses++;
1497
+ }
1498
+ }
1499
+ return results;
1500
+ }
1501
+ embeddingSidecar() {
1502
+ if (!this.sidecar)
1503
+ this.sidecar = new BrainEmbeddings_1.EmbeddingSidecar(EMBEDDINGS_FILE);
1504
+ return this.sidecar;
1505
+ }
998
1506
  getEntriesForProject(projectId) {
999
1507
  return Array.from(this.entries.values()).filter((e) => e.projectId === undefined || e.projectId === projectId);
1000
1508
  }
@@ -1035,6 +1543,7 @@ class TheBrainV2 {
1035
1543
  * differently-phrased query tomorrow.
1036
1544
  */
1037
1545
  downvote(id) {
1546
+ this.syncIfChanged();
1038
1547
  const entry = this.entries.get(id);
1039
1548
  if (!entry)
1040
1549
  return { ok: false, message: `No entry with id "${id}" in the Brain.` };
@@ -1042,6 +1551,136 @@ class TheBrainV2 {
1042
1551
  this.scheduleSave();
1043
1552
  return { ok: true, message: `Recorded negative feedback on entry "${id}" (demerits: ${entry.demerits}). It will rank lower and be evicted sooner.` };
1044
1553
  }
1554
+ // ─── Deletion ───────────────────────────────────────────────────────────────
1555
+ /**
1556
+ * Permanently remove one memory.
1557
+ *
1558
+ * The Brain had no way to delete anything: downvote() only demotes, and eviction only
1559
+ * fires at capacity. A memory that is simply wrong, or that captured something that
1560
+ * should never have been stored, had no exit — the only recourse was to downvote it
1561
+ * repeatedly and wait for the corpus to fill up. This is that exit.
1562
+ *
1563
+ * The id is tombstoned as well as dropped, so a concurrent session's merge can't hand it
1564
+ * straight back. The bloom filter cannot un-add a key, so the deleted query may still
1565
+ * register as a possible duplicate later; that costs one extra dedup search and never
1566
+ * produces a wrong answer, which is the right side of that trade for a probabilistic
1567
+ * filter that is rebuilt on the next clear().
1568
+ */
1569
+ forget(id) {
1570
+ this.syncIfChanged();
1571
+ const entry = this.entries.get(id);
1572
+ if (!entry)
1573
+ return { ok: false, message: `No entry with id "${id}" in the Brain.` };
1574
+ this.dropEntry(entry);
1575
+ this.tombstone(id);
1576
+ this.recalcAvgDocLength();
1577
+ this.scheduleSave();
1578
+ return {
1579
+ ok: true,
1580
+ message: `Deleted memory "${id}". It will not come back from another session's copy.`,
1581
+ // Echoed back so a deletion is auditable: the caller (and the user reading the
1582
+ // transcript) can see exactly what was destroyed, since nothing else can recover it.
1583
+ forgotten: { id, query: entry.query, timestamp: entry.timestamp, hits: entry.hits },
1584
+ };
1585
+ }
1586
+ // ─── Re-anchoring ───────────────────────────────────────────────────────────
1587
+ /**
1588
+ * Re-verify a memory against the code as it stands now, keeping its identity.
1589
+ *
1590
+ * Staleness used to be terminal: once tracked evidence changed, an entry was stale
1591
+ * forever, even in the very common case where the insight is still true and the code just
1592
+ * moved. The only workaround was to store a near-duplicate — which store()'s own dedup
1593
+ * guard would often refuse — losing the entry's id, its hit count, its demerits, and every
1594
+ * `derivedFrom` edge pointing at it.
1595
+ *
1596
+ * With no arguments this re-hashes whatever the entry already tracks (and each claim's own
1597
+ * evidence), which is the "yes, I checked, this is still correct" path. Passing filePaths
1598
+ * or symbols instead re-points the entry at new evidence, which is the "the code moved"
1599
+ * path. Either way the caller is asserting the memory is currently true — this tool
1600
+ * records that assertion, it cannot verify it, so it is never called automatically.
1601
+ *
1602
+ * `derivedFrom` dependencies are deliberately not re-anchored: a conclusion inherited from
1603
+ * a memory that is itself stale is exactly what Fase B exists to catch, and silently
1604
+ * clearing that would defeat it. Those come back in `stillStale` instead.
1605
+ */
1606
+ refresh(id, opts = {}) {
1607
+ this.syncIfChanged();
1608
+ const entry = this.entries.get(id);
1609
+ if (!entry)
1610
+ return { ok: false, message: `No entry with id "${id}" in the Brain.` };
1611
+ const retracked = [];
1612
+ const rel = (abs) => {
1613
+ const r = path_1.default.relative(process.cwd(), abs);
1614
+ return r.startsWith('..') ? abs : r;
1615
+ };
1616
+ const hasNewTracking = (opts.filePaths?.length || 0) > 0 || (opts.symbols?.length || 0) > 0;
1617
+ if (hasNewTracking) {
1618
+ // Re-point: the new tracking replaces the old wholesale rather than merging, because
1619
+ // a moved symbol's old path must stop counting against the entry forever.
1620
+ entry.fileHashes = opts.filePaths?.length ? hashFilesForFreshness(opts.filePaths) : undefined;
1621
+ entry.symbolHashes = opts.symbols?.length ? hashSymbolsForFreshness(opts.symbols) : undefined;
1622
+ entry.symbolNormalizedHashes = opts.symbols?.length ? hashSymbolsNormalizedForFreshness(opts.symbols) : undefined;
1623
+ for (const p of opts.filePaths || [])
1624
+ retracked.push(rel(path_1.default.resolve(p)));
1625
+ for (const s of opts.symbols || [])
1626
+ retracked.push(`${rel(path_1.default.resolve(s.filePath))}::${s.symbolName}`);
1627
+ }
1628
+ else {
1629
+ // Re-anchor in place: same files, same symbols, hashes recomputed against current
1630
+ // content. A tracked path that no longer exists hashes to 'MISSING' exactly as it
1631
+ // does at store time, so the entry stays honestly stale instead of being blessed.
1632
+ if (entry.fileHashes) {
1633
+ const paths = Object.keys(entry.fileHashes);
1634
+ entry.fileHashes = hashFilesForFreshness(paths);
1635
+ for (const p of paths)
1636
+ retracked.push(rel(p));
1637
+ }
1638
+ if (entry.symbolHashes) {
1639
+ const refs = Object.keys(entry.symbolHashes).map((key) => {
1640
+ const sep = key.lastIndexOf('::');
1641
+ return { filePath: key.substring(0, sep), symbolName: key.substring(sep + 2) };
1642
+ });
1643
+ entry.symbolHashes = hashSymbolsForFreshness(refs);
1644
+ entry.symbolNormalizedHashes = hashSymbolsNormalizedForFreshness(refs);
1645
+ for (const r of refs)
1646
+ retracked.push(`${rel(r.filePath)}::${r.symbolName}`);
1647
+ }
1648
+ if (entry.claims) {
1649
+ for (const claim of entry.claims) {
1650
+ if (claim.fileHashes)
1651
+ claim.fileHashes = hashFilesForFreshness(Object.keys(claim.fileHashes));
1652
+ if (claim.symbolHashes) {
1653
+ const refs = Object.keys(claim.symbolHashes).map((key) => {
1654
+ const sep = key.lastIndexOf('::');
1655
+ return { filePath: key.substring(0, sep), symbolName: key.substring(sep + 2) };
1656
+ });
1657
+ claim.symbolHashes = hashSymbolsForFreshness(refs);
1658
+ claim.symbolNormalizedHashes = hashSymbolsNormalizedForFreshness(refs);
1659
+ }
1660
+ }
1661
+ }
1662
+ }
1663
+ // Re-stamp git context and the age clock: this memory was just re-verified here, now.
1664
+ const git = currentGitContext();
1665
+ if (git.commit)
1666
+ entry.gitCommit = git.commit;
1667
+ if (git.branch)
1668
+ entry.gitBranch = git.branch;
1669
+ entry.refreshedAt = new Date().toISOString();
1670
+ const claimIndex = buildClaimIndex(this.entries);
1671
+ const { fresh, staleFiles } = checkEntryFreshness(entry, defaultLocalResolver, (depId) => resolveFreshnessNode(this.entries, claimIndex, depId));
1672
+ this.scheduleSave();
1673
+ return {
1674
+ ok: true,
1675
+ fresh,
1676
+ retracked,
1677
+ ...(staleFiles.length > 0 ? { stillStale: staleFiles } : {}),
1678
+ message: fresh
1679
+ ? `Memory "${id}" re-anchored to current code and is fresh again.`
1680
+ : `Memory "${id}" re-anchored, but still stale: ${staleFiles.join(', ')}. ` +
1681
+ `A "derived:" entry here means an upstream memory it was built on is stale — refresh that one first.`,
1682
+ };
1683
+ }
1045
1684
  // ─── Batch verification ─────────────────────────────────────────────────────
1046
1685
  /**
1047
1686
  * Revalidate ids from a prior search_memory/store_memory result via hash-compare only —
@@ -1051,28 +1690,39 @@ class TheBrainV2 {
1051
1690
  * either way there is nothing left to vouch for it).
1052
1691
  */
1053
1692
  verifyByIds(ids, options = {}) {
1693
+ // An id handed over by a subagent or another MCP client may belong to an entry this
1694
+ // process has never loaded. Without the sync it would come back 'unknown' — which the
1695
+ // caller is told means "purged", and acting on that would be wrong.
1696
+ this.syncIfChanged();
1054
1697
  const claimIndex = buildClaimIndex(this.entries);
1055
1698
  const resolveNode = (id) => resolveFreshnessNode(this.entries, claimIndex, id);
1056
1699
  const semanticDiff = options.semanticDiff === true;
1700
+ const resolver = options.resolver ?? defaultLocalResolver;
1701
+ const git = currentGitContext();
1057
1702
  return ids.map((id) => {
1058
1703
  const entry = this.entries.get(id);
1059
1704
  if (entry) {
1060
- const { fresh, staleFiles, cosmeticChanges } = checkEntryFreshness(entry, resolveNode, 0, new Set(), semanticDiff);
1705
+ const { fresh, staleFiles, unverifiedFiles, cosmeticChanges } = checkEntryFreshness(entry, resolver, resolveNode, 0, new Set(), semanticDiff);
1706
+ const unverified = unverifiedFiles.length > 0;
1061
1707
  return {
1062
1708
  id,
1063
- status: fresh ? 'fresh' : 'stale',
1709
+ status: unverified ? 'unverified' : fresh ? 'fresh' : 'stale',
1710
+ ...(unverified || fresh ? {} : attributeStaleness(entry, git)),
1064
1711
  ...(staleFiles.length > 0 ? { staleFiles } : {}),
1065
- ...(entry.claims ? { claimBreakdown: entry.claims.map((c) => checkClaimFreshness(c, semanticDiff)) } : {}),
1712
+ ...(unverified ? { unverifiedFiles } : {}),
1713
+ ...(entry.claims ? { claimBreakdown: entry.claims.map((c) => checkClaimFreshness(c, resolver, semanticDiff)) } : {}),
1066
1714
  ...(cosmeticChanges && cosmeticChanges.length > 0 ? { cosmeticChanges } : {}),
1067
1715
  };
1068
1716
  }
1069
1717
  const claim = claimIndex.get(id);
1070
1718
  if (claim) {
1071
- const cf = checkClaimFreshness(claim, semanticDiff);
1719
+ const cf = checkClaimFreshness(claim, resolver, semanticDiff);
1720
+ const unverified = (cf.unverifiedFiles?.length ?? 0) > 0;
1072
1721
  return {
1073
1722
  id,
1074
- status: cf.fresh ? 'fresh' : 'stale',
1723
+ status: cf.fresh ? 'fresh' : unverified ? 'unverified' : 'stale',
1075
1724
  ...(cf.staleFiles ? { staleFiles: cf.staleFiles } : {}),
1725
+ ...(cf.unverifiedFiles ? { unverifiedFiles: cf.unverifiedFiles } : {}),
1076
1726
  ...(cf.cosmeticChanges ? { cosmeticChanges: cf.cosmeticChanges } : {}),
1077
1727
  };
1078
1728
  }
@@ -1086,11 +1736,162 @@ class TheBrainV2 {
1086
1736
  * next search) — see findBlastRadius for the algorithm and cost.
1087
1737
  */
1088
1738
  findBlastRadius(filePath, symbolName) {
1739
+ this.syncIfChanged();
1089
1740
  return findBlastRadius(this.entries, filePath, symbolName);
1090
1741
  }
1091
- // ─── Stats ────────────────────────────────────────────────────────────────
1092
- getStats() {
1742
+ // ─── Portability ────────────────────────────────────────────────────────────
1743
+ /**
1744
+ * Serialize memories to a portable NDJSON bundle: a header line, then one entry per line.
1745
+ *
1746
+ * The Brain is a single file under one user's home directory with no way in or out. That
1747
+ * makes it unshareable with a teammate, unmovable to another machine, and unbackupable
1748
+ * except by copying the directory wholesale (which also copies every other project's
1749
+ * memories). This is the smallest thing that fixes all three.
1750
+ */
1751
+ exportBundle(opts = {}) {
1752
+ this.syncIfChanged();
1753
+ const claimIndex = buildClaimIndex(this.entries);
1754
+ const resolveNode = (id) => resolveFreshnessNode(this.entries, claimIndex, id);
1755
+ let skippedStale = 0;
1756
+ const selected = [];
1757
+ for (const entry of this.entries.values()) {
1758
+ // Same unscoped-entry fallback search() uses: an entry written before scoping existed
1759
+ // belongs to no project in particular, so excluding it from every export would quietly
1760
+ // make it unbackupable.
1761
+ if (opts.projectId && entry.projectId !== undefined && entry.projectId !== opts.projectId)
1762
+ continue;
1763
+ if (opts.domain && entry.domain !== opts.domain)
1764
+ continue;
1765
+ if (!opts.includeStale && !checkEntryFreshness(entry, defaultLocalResolver, resolveNode).fresh) {
1766
+ skippedStale++;
1767
+ continue;
1768
+ }
1769
+ selected.push(entry);
1770
+ }
1771
+ const header = JSON.stringify({
1772
+ lemmaBrainExport: 1,
1773
+ exportedAt: new Date().toISOString(),
1774
+ count: selected.length,
1775
+ ...(opts.projectId ? { projectId: opts.projectId } : {}),
1776
+ ...(opts.domain ? { domain: opts.domain } : {}),
1777
+ });
1778
+ const body = selected.map(({ terms: _t, ...persisted }) => JSON.stringify(persisted));
1779
+ return { text: [header, ...body].join('\n'), count: selected.length, skippedStale };
1780
+ }
1781
+ /**
1782
+ * Merge a bundle produced by exportBundle into this Brain.
1783
+ *
1784
+ * Import is additive and never destructive: an id already present keeps whichever version
1785
+ * is newer and the higher of both counters (the same rule cross-process merging uses), and
1786
+ * a tombstoned id stays deleted — importing a bundle must not resurrect something the user
1787
+ * deliberately forgot.
1788
+ *
1789
+ * Imported entries keep their origin machine's absolute paths, so most of them will read
1790
+ * as stale here until refresh() re-anchors them. That is the honest outcome: their
1791
+ * evidence genuinely cannot be verified against this checkout, and reporting them as fresh
1792
+ * would be the one failure mode this whole system is built to prevent.
1793
+ */
1794
+ importBundle(text, opts = {}) {
1795
+ this.syncIfChanged();
1796
+ const lines = text.split('\n').filter(Boolean);
1797
+ if (lines.length === 0)
1798
+ return { ok: false, imported: 0, updated: 0, skipped: 0, message: 'Bundle is empty.' };
1799
+ let start = 0;
1800
+ try {
1801
+ const header = JSON.parse(lines[0]);
1802
+ if (header && header.lemmaBrainExport)
1803
+ start = 1;
1804
+ }
1805
+ catch {
1806
+ // No header — treat the whole file as entries. A raw entries.ndjson copied off another
1807
+ // machine is a perfectly reasonable thing to hand this, and rejecting it would be
1808
+ // pedantry rather than safety.
1809
+ }
1810
+ let imported = 0;
1811
+ let updated = 0;
1812
+ let skipped = 0;
1813
+ const markSource = opts.markSource !== false;
1814
+ for (const line of lines.slice(start)) {
1815
+ let entry;
1816
+ try {
1817
+ entry = JSON.parse(line);
1818
+ }
1819
+ catch {
1820
+ skipped++;
1821
+ continue;
1822
+ }
1823
+ if (!entry || typeof entry.id !== 'string' || typeof entry.query !== 'string') {
1824
+ skipped++;
1825
+ continue;
1826
+ }
1827
+ if (this.tombstones.has(entry.id)) {
1828
+ skipped++;
1829
+ continue;
1830
+ }
1831
+ if (!Array.isArray(entry.terms))
1832
+ entry.terms = Object.keys(entry.termFreq || {});
1833
+ if (entry.terms.length === 0) {
1834
+ // A bundle from a version that persisted neither terms nor termFreq — re-tokenize
1835
+ // rather than admit an entry the inverted index could never retrieve.
1836
+ const terms = tokenize(entry.query + ' ' + (entry.response || ''));
1837
+ entry.terms = [...new Set(terms)];
1838
+ entry.termFreq = termFrequencies(terms);
1839
+ }
1840
+ if (markSource)
1841
+ entry.source = 'import';
1842
+ const existing = this.entries.get(entry.id);
1843
+ if (existing) {
1844
+ if (effectiveTime(entry) <= effectiveTime(existing)) {
1845
+ // Older or same age: keep ours, but never lose feedback the other side collected.
1846
+ existing.hits = Math.max(existing.hits || 0, entry.hits || 0);
1847
+ existing.demerits = Math.max(existing.demerits || 0, entry.demerits || 0);
1848
+ skipped++;
1849
+ continue;
1850
+ }
1851
+ this.dropEntry(existing);
1852
+ entry.hits = Math.max(existing.hits || 0, entry.hits || 0);
1853
+ entry.demerits = Math.max(existing.demerits || 0, entry.demerits || 0);
1854
+ updated++;
1855
+ }
1856
+ else {
1857
+ imported++;
1858
+ }
1859
+ this.entries.set(entry.id, entry);
1860
+ for (const term of entry.terms) {
1861
+ let ids = this.invertedIndex.get(term);
1862
+ if (!ids) {
1863
+ ids = new Set();
1864
+ this.invertedIndex.set(term, ids);
1865
+ }
1866
+ ids.add(entry.id);
1867
+ }
1868
+ this.bloom.add(entry.query.trim().toLowerCase().substring(0, 200));
1869
+ }
1870
+ this.recalcAvgDocLength();
1871
+ this.evictIfOverCapacity();
1872
+ this.scheduleSave();
1093
1873
  return {
1874
+ ok: true,
1875
+ imported,
1876
+ updated,
1877
+ skipped,
1878
+ message: `Imported ${imported} new memor${imported === 1 ? 'y' : 'ies'}, updated ${updated}, skipped ${skipped}. ` +
1879
+ `Imported memories track the paths of the machine they came from, so expect them to read as stale here ` +
1880
+ `until refresh_memory re-anchors them.`,
1881
+ };
1882
+ }
1883
+ // ─── Stats ────────────────────────────────────────────────────────────────
1884
+ /**
1885
+ * Corpus counters, plus health signals that answer the question counts alone can't:
1886
+ * is this Brain getting better or is it accumulating dead weight?
1887
+ *
1888
+ * Everything is computed from memory except `staleEntries`, which re-hashes every tracked
1889
+ * file and symbol in the corpus and is therefore behind `deep` — on a full Brain that is
1890
+ * thousands of file reads and has no business running on a routine stats call.
1891
+ */
1892
+ getStats(options = {}) {
1893
+ this.syncIfChanged();
1894
+ const base = {
1094
1895
  totalEntries: this.entries.size,
1095
1896
  totalTerms: this.invertedIndex.size,
1096
1897
  avgDocLength: Math.round(this.avgDocLength),
@@ -1098,6 +1899,52 @@ class TheBrainV2 {
1098
1899
  cacheHits: this.sessionHits,
1099
1900
  cacheMisses: this.sessionMisses,
1100
1901
  };
1902
+ let neverHit = 0;
1903
+ let downvoted = 0;
1904
+ let unscoped = 0;
1905
+ let oldest = '';
1906
+ const bySource = {};
1907
+ const projects = new Set();
1908
+ for (const entry of this.entries.values()) {
1909
+ if (!entry.hits)
1910
+ neverHit++;
1911
+ if (entry.demerits)
1912
+ downvoted++;
1913
+ if (entry.projectId === undefined)
1914
+ unscoped++;
1915
+ else
1916
+ projects.add(entry.projectId);
1917
+ const src = entrySource(entry);
1918
+ bySource[src] = (bySource[src] || 0) + 1;
1919
+ if (entry.timestamp && (!oldest || entry.timestamp < oldest))
1920
+ oldest = entry.timestamp;
1921
+ }
1922
+ base.neverHit = neverHit;
1923
+ base.downvoted = downvoted;
1924
+ base.bySource = bySource;
1925
+ base.projects = projects.size;
1926
+ base.unscopedEntries = unscoped;
1927
+ base.tombstones = this.tombstones.size;
1928
+ if (oldest)
1929
+ base.oldestEntry = oldest;
1930
+ base.corpusBytes = stampOf(ENTRIES_FILE)?.size ?? 0;
1931
+ if (options.deep) {
1932
+ const claimIndex = buildClaimIndex(this.entries);
1933
+ const resolveNode = (id) => resolveFreshnessNode(this.entries, claimIndex, id);
1934
+ let stale = 0;
1935
+ let tracked = 0;
1936
+ for (const entry of this.entries.values()) {
1937
+ const tracksSomething = !!entry.fileHashes || !!entry.symbolHashes || !!entry.derivedFrom || !!entry.claims;
1938
+ if (!tracksSomething)
1939
+ continue; // untracked entries are fresh by definition, not evidence
1940
+ tracked++;
1941
+ if (!checkEntryFreshness(entry, defaultLocalResolver, resolveNode).fresh)
1942
+ stale++;
1943
+ }
1944
+ base.staleEntries = stale;
1945
+ base.staleRatio = tracked > 0 ? Number((stale / tracked).toFixed(3)) : 0;
1946
+ }
1947
+ return base;
1101
1948
  }
1102
1949
  clear() {
1103
1950
  this.entries.clear();
@@ -1106,6 +1953,9 @@ class TheBrainV2 {
1106
1953
  this.avgDocLength = 0;
1107
1954
  this.sessionHits = 0;
1108
1955
  this.sessionMisses = 0;
1956
+ this.tombstones.clear();
1957
+ this.diskStamp = null;
1958
+ this.sidecar = null;
1109
1959
  try {
1110
1960
  if (fs_1.default.existsSync(ENTRIES_FILE))
1111
1961
  fs_1.default.unlinkSync(ENTRIES_FILE);
@@ -1113,6 +1963,10 @@ class TheBrainV2 {
1113
1963
  fs_1.default.unlinkSync(INDEX_FILE);
1114
1964
  if (fs_1.default.existsSync(META_FILE))
1115
1965
  fs_1.default.unlinkSync(META_FILE);
1966
+ if (fs_1.default.existsSync(LOCK_FILE))
1967
+ fs_1.default.unlinkSync(LOCK_FILE);
1968
+ if (fs_1.default.existsSync(EMBEDDINGS_FILE))
1969
+ fs_1.default.unlinkSync(EMBEDDINGS_FILE);
1116
1970
  }
1117
1971
  catch { /* ignore */ }
1118
1972
  }