@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
@@ -13,10 +13,32 @@ import fs from 'fs';
13
13
  import path from 'path';
14
14
  import os from 'os';
15
15
  import crypto from 'crypto';
16
- import { getSymbolSurgicalContext, normalizeForFreshness } from '../utils/SymbolSurgicalContext.js';
16
+ import { execSync } from 'child_process';
17
+ import { LocalFsResolver } from './freshness/LocalFsResolver';
18
+ import { EmbeddingSidecar, embeddingsEnabled, semanticRerank } from './BrainEmbeddings';
19
+ /**
20
+ * Default resolver for the local MCP path. Behaviour is byte-for-byte identical to
21
+ * the pre-refactor inline `fs.readFileSync` hashing — it never returns `null`, so
22
+ * the verifier's fail-closed branch is never tripped and local freshness results
23
+ * are unchanged. Injectable via the `resolver` option on search/verifyByIds/refresh.
24
+ */
25
+ const defaultLocalResolver = new LocalFsResolver();
17
26
  // ─── BM25 Constants ───────────────────────────────────────────────────────────
18
27
  const BM25_K1 = 1.5; // Term saturation (1.2-2.0)
19
28
  const BM25_B = 0.75; // Length normalization (0-1)
29
+ // ─── Recency decay ────────────────────────────────────────────────────────────
30
+ //
31
+ // Deliberately gentle and grace-periodded. Age is a weak proxy for "no longer true" —
32
+ // plenty of architectural decisions stay correct for years — so this is sized to break ties
33
+ // between comparable memories, not to bury an old one that is still the best match. A
34
+ // memory that goes stale is caught by hashing, which is the real signal; this only covers
35
+ // the case hashing can't see, where a memory tracks no files at all.
36
+ /** Days a memory is treated as current with no penalty at all. */
37
+ const RECENCY_GRACE_DAYS = 30;
38
+ /** Days past the grace period at which the penalty reaches its cap. */
39
+ const RECENCY_FULL_DECAY_DAYS = 365;
40
+ /** Cap, in the same 0-1 similarity space as the popularity/outcome/demerit adjustments. */
41
+ const MAX_RECENCY_PENALTY = 0.10;
20
42
  // ─── Storage Paths ────────────────────────────────────────────────────────────
21
43
  /**
22
44
  * LEMMA_BRAIN_DIR redirects storage — required so tests never write into the user's real
@@ -27,6 +49,273 @@ const BRAIN_DIR = process.env.LEMMA_BRAIN_DIR || path.join(os.homedir(), '.lemma
27
49
  const ENTRIES_FILE = path.join(BRAIN_DIR, 'entries.ndjson');
28
50
  const INDEX_FILE = path.join(BRAIN_DIR, 'index.json');
29
51
  const META_FILE = path.join(BRAIN_DIR, 'meta.json');
52
+ /** Advisory cross-process write lock. See acquireLock() for why it is advisory, not a guarantee. */
53
+ const LOCK_FILE = path.join(BRAIN_DIR, '.write.lock');
54
+ /** Sidecar vector cache for the optional semantic re-rank. See BrainEmbeddings.ts. */
55
+ const EMBEDDINGS_FILE = path.join(BRAIN_DIR, 'embeddings.ndjson');
56
+ // ─── Cross-process durability ─────────────────────────────────────────────────
57
+ //
58
+ // Every MCP client session starts its own server process, and each one holds the whole
59
+ // corpus in memory. Before this section existed, save() blind-rewrote entries.ndjson in
60
+ // full: two concurrent sessions meant whichever flushed last silently erased everything the
61
+ // other had stored since it loaded. That is the exact usage pattern the tool descriptions
62
+ // advertise ("memories from ALL your projects"), so it was not a rare race.
63
+ //
64
+ // Three things fix it, and none of them require a database:
65
+ // 1. save() merges what is on disk into memory before writing, so a full rewrite can only
66
+ // ever be a superset — a writer can add, never subtract (except via tombstones).
67
+ // 2. Writes go through a temp file + rename, so a crash mid-write can't truncate the
68
+ // corpus into something load() would discard as "corrupt, start fresh".
69
+ // 3. An advisory lock file serializes the read-merge-write window between processes.
70
+ // Reads get the same benefit through syncIfChanged(): a long-lived server picks up a
71
+ // sibling session's memories on the next search instead of at the next restart.
72
+ /** A lock older than this is assumed to belong to a process that died holding it. */
73
+ const LOCK_STALE_MS = 10000;
74
+ /** How long a writer waits for another process's lock before proceeding regardless. */
75
+ const LOCK_WAIT_MS = 2000;
76
+ /** Deleted ids stop being suppressed after this long — a tombstone list must not grow forever. */
77
+ const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
78
+ /** Blocking sleep. save() is synchronous and its callers must never observe a partial corpus. */
79
+ function sleepSync(ms) {
80
+ try {
81
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
82
+ }
83
+ catch {
84
+ // SharedArrayBuffer unavailable (locked-down runtime) — spin instead of failing.
85
+ const until = Date.now() + ms;
86
+ while (Date.now() < until) { /* busy wait */ }
87
+ }
88
+ }
89
+ /**
90
+ * Take the write lock, or give up after LOCK_WAIT_MS and return null.
91
+ *
92
+ * Deliberately advisory and fail-open: a caller that can't get the lock still writes. The
93
+ * merge in save() is what actually prevents data loss; the lock only narrows the window
94
+ * where two mergers interleave. Blocking a memory write forever on a lock — or throwing —
95
+ * would be a worse outcome than the rare interleave it prevents.
96
+ */
97
+ function acquireLock() {
98
+ const deadline = Date.now() + LOCK_WAIT_MS;
99
+ for (;;) {
100
+ try {
101
+ return fs.openSync(LOCK_FILE, 'wx');
102
+ }
103
+ catch {
104
+ try {
105
+ if (Date.now() - fs.statSync(LOCK_FILE).mtimeMs > LOCK_STALE_MS) {
106
+ fs.unlinkSync(LOCK_FILE);
107
+ continue; // retry immediately against the freed lock
108
+ }
109
+ }
110
+ catch {
111
+ continue; // lock vanished between open and stat — the holder just released it
112
+ }
113
+ if (Date.now() >= deadline)
114
+ return null;
115
+ sleepSync(15);
116
+ }
117
+ }
118
+ }
119
+ function releaseLock(fd) {
120
+ if (fd === null)
121
+ return;
122
+ try {
123
+ fs.closeSync(fd);
124
+ }
125
+ catch { /* already closed */ }
126
+ try {
127
+ fs.unlinkSync(LOCK_FILE);
128
+ }
129
+ catch { /* already gone */ }
130
+ }
131
+ /**
132
+ * Write via temp file + rename. rename(2) is atomic within a filesystem, so a reader either
133
+ * sees the whole previous file or the whole new one — never a truncated prefix, which
134
+ * load()'s catch-all would have silently turned into an empty Brain.
135
+ */
136
+ function writeFileAtomic(target, data) {
137
+ const tmp = `${target}.tmp.${process.pid}.${Date.now().toString(36)}`;
138
+ try {
139
+ fs.writeFileSync(tmp, data, 'utf8');
140
+ fs.renameSync(tmp, target);
141
+ }
142
+ catch (err) {
143
+ try {
144
+ fs.unlinkSync(tmp);
145
+ }
146
+ catch { /* nothing to clean up */ }
147
+ throw err;
148
+ }
149
+ }
150
+ function stampOf(file) {
151
+ try {
152
+ const s = fs.statSync(file);
153
+ return { mtimeMs: s.mtimeMs, size: s.size };
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ }
159
+ function sameStamp(a, b) {
160
+ if (a === null || b === null)
161
+ return a === b;
162
+ return a.mtimeMs === b.mtimeMs && a.size === b.size;
163
+ }
164
+ /** Parses entries.ndjson exactly the way load() does, without touching instance state. */
165
+ function readEntriesFile() {
166
+ const out = new Map();
167
+ let raw;
168
+ try {
169
+ raw = fs.readFileSync(ENTRIES_FILE, 'utf8');
170
+ }
171
+ catch {
172
+ return out;
173
+ }
174
+ for (const line of raw.split('\n')) {
175
+ if (!line)
176
+ continue;
177
+ try {
178
+ const entry = JSON.parse(line);
179
+ if (!entry || typeof entry.id !== 'string')
180
+ continue;
181
+ // Entries written before the format was compacted still carry `terms`; newer ones
182
+ // don't. Rebuilding from termFreq covers both without a migration step.
183
+ if (!Array.isArray(entry.terms))
184
+ entry.terms = Object.keys(entry.termFreq || {});
185
+ out.set(entry.id, entry);
186
+ }
187
+ catch { /* skip corrupt lines */ }
188
+ }
189
+ return out;
190
+ }
191
+ /** Reads the tombstone map straight off disk, for absorbing a peer process's deletions. */
192
+ function readTombstonesFile() {
193
+ try {
194
+ return readTombstones(JSON.parse(fs.readFileSync(META_FILE, 'utf8')));
195
+ }
196
+ catch {
197
+ return new Map();
198
+ }
199
+ }
200
+ /** Reads the tombstone map out of a parsed meta.json, tolerating older files without one. */
201
+ function readTombstones(meta) {
202
+ const out = new Map();
203
+ const raw = meta?.tombstones;
204
+ if (!raw || typeof raw !== 'object')
205
+ return out;
206
+ for (const [id, at] of Object.entries(raw)) {
207
+ if (typeof at === 'string')
208
+ out.set(id, at);
209
+ }
210
+ return out;
211
+ }
212
+ /** Effective age anchor: a refreshed memory is as young as its last re-verification. */
213
+ function effectiveTime(entry) {
214
+ const t = Date.parse(entry.refreshedAt || entry.timestamp || '');
215
+ return Number.isFinite(t) ? t : 0;
216
+ }
217
+ /**
218
+ * Union two views of the corpus. Never subtracts: an id present in either side survives
219
+ * unless it is tombstoned, because "absent from my copy" and "deleted" are indistinguishable
220
+ * from inside one process, and guessing wrong loses a user's memory permanently.
221
+ *
222
+ * For an id on both sides, the newer version (by refreshedAt/timestamp) supplies the content
223
+ * and the counters take the max of both — `hits` and `demerits` are monotonic tallies that
224
+ * each process accumulated independently, so max is the only merge that doesn't discard
225
+ * feedback one of them collected.
226
+ */
227
+ export function mergeEntryMaps(mine, theirs, tombstones = new Set()) {
228
+ const merged = new Map();
229
+ const put = (entry) => {
230
+ if (tombstones.has(entry.id))
231
+ return;
232
+ const existing = merged.get(entry.id);
233
+ if (!existing) {
234
+ merged.set(entry.id, entry);
235
+ return;
236
+ }
237
+ const winner = effectiveTime(entry) > effectiveTime(existing) ? entry : existing;
238
+ merged.set(entry.id, {
239
+ ...winner,
240
+ hits: Math.max(entry.hits || 0, existing.hits || 0),
241
+ demerits: Math.max(entry.demerits || 0, existing.demerits || 0),
242
+ });
243
+ };
244
+ for (const entry of mine.values())
245
+ put(entry);
246
+ for (const entry of theirs.values())
247
+ put(entry);
248
+ return merged;
249
+ }
250
+ // ─── Git context ──────────────────────────────────────────────────────────────
251
+ /**
252
+ * Current HEAD and branch, cached briefly.
253
+ *
254
+ * Read at store time and again when reporting a stale result, so the Brain can tell
255
+ * "someone edited this function" apart from "you switched branches" — a content hash
256
+ * reports both identically, and only the first is a reason to distrust the memory.
257
+ *
258
+ * Cached because a stale result is attributed inside the search result loop: without it,
259
+ * one search over five stale hits would spawn ten git processes.
260
+ */
261
+ const GIT_CONTEXT_TTL_MS = 5000;
262
+ let _gitContextCache = null;
263
+ export function currentGitContext(cwd = process.cwd()) {
264
+ const now = Date.now();
265
+ if (_gitContextCache && _gitContextCache.cwd === cwd && now - _gitContextCache.at < GIT_CONTEXT_TTL_MS) {
266
+ return _gitContextCache.ctx;
267
+ }
268
+ let ctx = {};
269
+ try {
270
+ // Not a git repo, no git binary, detached worktree — all of these must produce {} and
271
+ // never throw. Git context is an explanatory nicety; nothing depends on it existing.
272
+ const run = (args) => execSync(`git ${args}`, { cwd, encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
273
+ const commit = run('rev-parse HEAD');
274
+ const branch = run('rev-parse --abbrev-ref HEAD');
275
+ ctx = {
276
+ ...(commit ? { commit } : {}),
277
+ // "HEAD" is what git reports in a detached checkout — not a branch name, so don't
278
+ // record it as one and later claim the user "switched branches" away from it.
279
+ ...(branch && branch !== 'HEAD' ? { branch } : {}),
280
+ };
281
+ }
282
+ catch {
283
+ ctx = {};
284
+ }
285
+ _gitContextCache = { cwd, at: now, ctx };
286
+ return ctx;
287
+ }
288
+ /** Test seam: forget the cached git context so a test can change branches mid-run. */
289
+ export function resetGitContextCache() {
290
+ _gitContextCache = null;
291
+ }
292
+ /**
293
+ * Why a stale entry is stale. Pure attribution over data already gathered — it never
294
+ * changes whether something is stale, only how the staleness is explained.
295
+ */
296
+ export function attributeStaleness(entry, git) {
297
+ if (entry.gitBranch && git.branch && entry.gitBranch !== git.branch) {
298
+ return { staleCause: 'branch-changed', storedOnBranch: entry.gitBranch };
299
+ }
300
+ return { staleCause: 'content-changed' };
301
+ }
302
+ /**
303
+ * Normalized provenance for an entry, back-deriving one for entries written before `source`
304
+ * existed. Those overloaded the free-text `provider` field with the same information, so
305
+ * reading it here keeps historical stats meaningful instead of a wall of "unknown".
306
+ */
307
+ export function entrySource(entry) {
308
+ if (entry.source)
309
+ return entry.source;
310
+ switch (entry.provider) {
311
+ case 'test_oracle_auto': return 'auto-test';
312
+ case 'checkpoint': return 'auto-checkpoint';
313
+ case 'git-commit':
314
+ case 'changelog': return 'auto-git';
315
+ case 'pr-review': return 'auto-pr-review';
316
+ default: return 'unknown';
317
+ }
318
+ }
30
319
  // ─── Project Scoping ──────────────────────────────────────────────────────────
31
320
  /**
32
321
  * Stable identity for the project a memory belongs to.
@@ -266,12 +555,8 @@ function hashFilesForFreshness(filePaths) {
266
555
  const hashes = {};
267
556
  for (const p of filePaths) {
268
557
  const abs = path.resolve(p);
269
- try {
270
- hashes[abs] = crypto.createHash('sha256').update(fs.readFileSync(abs, 'utf8')).digest('hex');
271
- }
272
- catch {
273
- hashes[abs] = 'MISSING';
274
- }
558
+ // LocalFsResolver never returns null; ?? 'MISSING' is a defensive safety net only.
559
+ hashes[abs] = defaultLocalResolver.hashFile(abs) ?? 'MISSING';
275
560
  }
276
561
  return hashes;
277
562
  }
@@ -281,7 +566,7 @@ function hashSymbolsForFreshness(refs) {
281
566
  for (const ref of refs) {
282
567
  const abs = path.resolve(ref.filePath);
283
568
  const key = `${abs}::${ref.symbolName}`;
284
- hashes[key] = currentSymbolHashPair(abs, ref.symbolName).raw;
569
+ hashes[key] = (defaultLocalResolver.hashSymbol(abs, ref.symbolName) ?? { raw: 'MISSING' }).raw;
285
570
  }
286
571
  return hashes;
287
572
  }
@@ -296,21 +581,12 @@ function hashSymbolsNormalizedForFreshness(refs) {
296
581
  for (const ref of refs) {
297
582
  const abs = path.resolve(ref.filePath);
298
583
  const key = `${abs}::${ref.symbolName}`;
299
- hashes[key] = currentSymbolHashPair(abs, ref.symbolName).normalized;
584
+ hashes[key] = (defaultLocalResolver.hashSymbol(abs, ref.symbolName) ?? { normalized: 'MISSING' }).normalized;
300
585
  }
301
586
  return hashes;
302
587
  }
303
588
  function currentSymbolHashPair(abs, symbolName) {
304
- try {
305
- const ctx = getSymbolSurgicalContext(path.dirname(abs), path.basename(abs), symbolName);
306
- return {
307
- raw: crypto.createHash('sha256').update(ctx.implementation).digest('hex'),
308
- normalized: crypto.createHash('sha256').update(normalizeForFreshness(ctx.implementation)).digest('hex'),
309
- };
310
- }
311
- catch {
312
- return { raw: 'MISSING', normalized: 'MISSING' };
313
- }
589
+ return defaultLocalResolver.hashSymbol(abs, symbolName) ?? { raw: 'MISSING', normalized: 'MISSING' };
314
590
  }
315
591
  /**
316
592
  * Looks up a `derivedFrom` id against both entries and claims in one pass. `claimIndex`
@@ -474,9 +750,16 @@ export function findBlastRadius(entries, filePath, symbolName) {
474
750
  * also has a tracked symbol is judged by the symbol's hash, not the whole file's — an edit
475
751
  * elsewhere in the same file (a different function, an import, a comment) must not
476
752
  * invalidate a memory that was only ever about one specific symbol.
753
+ *
754
+ * `resolver` is the evidence seam: LocalFsResolver (local MCP, returns 'MISSING' on
755
+ * unreadable files — behaviour unchanged) or SuppliedEvidenceResolver (cloud, returns
756
+ * `null` for a tracked path the client omitted). A `null` from the resolver is the
757
+ * fail-closed signal: the outcome carries that path in `unverifiedFiles` and is never
758
+ * `fresh`.
477
759
  */
478
- export function checkEntryFreshness(entry, resolveNode, depth = 0, visited = new Set(), semanticDiff = false) {
760
+ export function checkEntryFreshness(entry, resolver, resolveNode, depth = 0, visited = new Set(), semanticDiff = false) {
479
761
  const staleFiles = [];
762
+ const unverifiedFiles = [];
480
763
  const cosmeticChanges = [];
481
764
  const relLabel = (abs) => {
482
765
  const rel = path.relative(process.cwd(), abs);
@@ -487,12 +770,10 @@ export function checkEntryFreshness(entry, resolveNode, depth = 0, visited = new
487
770
  for (const [abs, storedHash] of Object.entries(entry.fileHashes)) {
488
771
  if (symbolCoveredAbsPaths.has(abs))
489
772
  continue;
490
- let currentHash;
491
- try {
492
- currentHash = crypto.createHash('sha256').update(fs.readFileSync(abs, 'utf8')).digest('hex');
493
- }
494
- catch {
495
- currentHash = 'MISSING';
773
+ const currentHash = resolver.hashFile(abs);
774
+ if (currentHash === null) {
775
+ unverifiedFiles.push(relLabel(abs)); // fail-closed: cannot verify ⇒ not fresh
776
+ continue;
496
777
  }
497
778
  if (currentHash !== storedHash)
498
779
  staleFiles.push(relLabel(abs));
@@ -504,7 +785,11 @@ export function checkEntryFreshness(entry, resolveNode, depth = 0, visited = new
504
785
  const abs = key.substring(0, sep);
505
786
  const symbolName = key.substring(sep + 2);
506
787
  const label = `${relLabel(abs)}::${symbolName}`;
507
- const currentPair = currentSymbolHashPair(abs, symbolName);
788
+ const currentPair = resolver.hashSymbol(abs, symbolName);
789
+ if (currentPair === null) {
790
+ unverifiedFiles.push(label); // fail-closed: cannot verify ⇒ not fresh
791
+ continue;
792
+ }
508
793
  if (currentPair.raw === storedHash)
509
794
  continue;
510
795
  if (semanticDiff) {
@@ -530,14 +815,23 @@ export function checkEntryFreshness(entry, resolveNode, depth = 0, visited = new
530
815
  }
531
816
  const nextVisited = new Set(visited);
532
817
  nextVisited.add(depId);
533
- const depFresh = node.kind === 'entry'
534
- ? checkEntryFreshness(node.entry, resolveNode, depth + 1, nextVisited, semanticDiff).fresh
535
- : checkClaimFreshness(node.claim, semanticDiff).fresh;
536
- if (!depFresh)
537
- staleFiles.push(`derived:${depId}`);
818
+ const depOutcome = node.kind === 'entry'
819
+ ? checkEntryFreshness(node.entry, resolver, resolveNode, depth + 1, nextVisited, semanticDiff)
820
+ : checkClaimFreshness(node.claim, resolver, semanticDiff);
821
+ if (!depOutcome.fresh) {
822
+ if ((depOutcome.unverifiedFiles?.length ?? 0) > 0)
823
+ unverifiedFiles.push(`derived:${depId}`);
824
+ else
825
+ staleFiles.push(`derived:${depId}`);
826
+ }
538
827
  }
539
828
  }
540
- return { fresh: staleFiles.length === 0, staleFiles, ...(cosmeticChanges.length > 0 ? { cosmeticChanges } : {}) };
829
+ return {
830
+ fresh: staleFiles.length === 0 && unverifiedFiles.length === 0,
831
+ staleFiles,
832
+ unverifiedFiles,
833
+ ...(cosmeticChanges.length > 0 ? { cosmeticChanges } : {}),
834
+ };
541
835
  }
542
836
  /** Builds the persisted `Claim[]` for an entry from the caller-supplied claim inputs. */
543
837
  function buildClaims(inputs) {
@@ -554,9 +848,12 @@ function buildClaims(inputs) {
554
848
  /**
555
849
  * Same hash-compare as checkEntryFreshness, scoped to one claim's own evidence — a claim
556
850
  * with no fileHashes/symbolHashes at all is always fresh (nothing tracked to go stale).
851
+ * `resolver` is the same evidence seam as checkEntryFreshness; a `null` from it fails
852
+ * closed to `unverified`, never `fresh`.
557
853
  */
558
- export function checkClaimFreshness(claim, semanticDiff = false) {
854
+ export function checkClaimFreshness(claim, resolver, semanticDiff = false) {
559
855
  const staleFiles = [];
856
+ const unverifiedFiles = [];
560
857
  const cosmeticChanges = [];
561
858
  const relLabel = (abs) => {
562
859
  const rel = path.relative(process.cwd(), abs);
@@ -567,12 +864,10 @@ export function checkClaimFreshness(claim, semanticDiff = false) {
567
864
  for (const [abs, storedHash] of Object.entries(claim.fileHashes)) {
568
865
  if (symbolCoveredAbsPaths.has(abs))
569
866
  continue;
570
- let currentHash;
571
- try {
572
- currentHash = crypto.createHash('sha256').update(fs.readFileSync(abs, 'utf8')).digest('hex');
573
- }
574
- catch {
575
- currentHash = 'MISSING';
867
+ const currentHash = resolver.hashFile(abs);
868
+ if (currentHash === null) {
869
+ unverifiedFiles.push(relLabel(abs));
870
+ continue;
576
871
  }
577
872
  if (currentHash !== storedHash)
578
873
  staleFiles.push(relLabel(abs));
@@ -584,7 +879,11 @@ export function checkClaimFreshness(claim, semanticDiff = false) {
584
879
  const abs = key.substring(0, sep);
585
880
  const symbolName = key.substring(sep + 2);
586
881
  const label = `${relLabel(abs)}::${symbolName}`;
587
- const currentPair = currentSymbolHashPair(abs, symbolName);
882
+ const currentPair = resolver.hashSymbol(abs, symbolName);
883
+ if (currentPair === null) {
884
+ unverifiedFiles.push(label);
885
+ continue;
886
+ }
588
887
  if (currentPair.raw === storedHash)
589
888
  continue;
590
889
  if (semanticDiff) {
@@ -600,9 +899,10 @@ export function checkClaimFreshness(claim, semanticDiff = false) {
600
899
  return {
601
900
  id: claim.id,
602
901
  text: claim.text,
603
- fresh: staleFiles.length === 0,
902
+ fresh: staleFiles.length === 0 && unverifiedFiles.length === 0,
604
903
  ...(staleFiles.length > 0 ? { staleFiles } : {}),
605
904
  ...(cosmeticChanges.length > 0 ? { cosmeticChanges } : {}),
905
+ ...(unverifiedFiles.length > 0 ? { unverifiedFiles } : {}),
606
906
  };
607
907
  }
608
908
  // ─── TheBrainV2 ──────────────────────────────────────────────────────────────
@@ -617,6 +917,11 @@ export class TheBrainV2 {
617
917
  this.sessionMisses = 0;
618
918
  this.dirty = false;
619
919
  this.flushTimer = null;
920
+ /** State of entries.ndjson as of our last read/write — see syncIfChanged(). */
921
+ this.diskStamp = null;
922
+ /** id -> ISO deletion time, for ids that must not come back through a merge. */
923
+ this.tombstones = new Map();
924
+ this.sidecar = null;
620
925
  this.ensureDir();
621
926
  this.load();
622
927
  }
@@ -627,36 +932,23 @@ export class TheBrainV2 {
627
932
  }
628
933
  load() {
629
934
  try {
630
- // Load entries from NDJSON
631
- if (fs.existsSync(ENTRIES_FILE)) {
632
- const lines = fs.readFileSync(ENTRIES_FILE, 'utf8').split('\n').filter(Boolean);
633
- for (const line of lines) {
634
- try {
635
- const entry = JSON.parse(line);
636
- // Entries written before the format was compacted still carry `terms`; newer
637
- // ones don't. Rebuilding from termFreq covers both without a migration step.
638
- if (!Array.isArray(entry.terms))
639
- entry.terms = Object.keys(entry.termFreq || {});
640
- this.entries.set(entry.id, entry);
641
- }
642
- catch { /* skip corrupt lines */ }
643
- }
644
- }
645
- // Load inverted index
646
- if (fs.existsSync(INDEX_FILE)) {
647
- const raw = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8'));
648
- for (const [term, ids] of Object.entries(raw)) {
649
- this.invertedIndex.set(term, new Set(ids));
650
- }
651
- }
652
- // Load bloom filter and meta
935
+ // Meta first: the tombstone list decides which entries are allowed back in.
653
936
  if (fs.existsSync(META_FILE)) {
654
937
  const meta = JSON.parse(fs.readFileSync(META_FILE, 'utf8'));
655
- if (meta.bloom) {
938
+ if (meta.bloom)
656
939
  this.bloom = new BloomFilter(meta.bloom);
657
- }
658
940
  this.avgDocLength = meta.avgDocLength || 0;
941
+ this.tombstones = readTombstones(meta);
659
942
  }
943
+ this.entries = readEntriesFile();
944
+ for (const id of this.tombstones.keys())
945
+ this.entries.delete(id);
946
+ this.diskStamp = stampOf(ENTRIES_FILE);
947
+ // The inverted index is rebuilt from the entries rather than read back from
948
+ // index.json. It is a pure derivation of the entries, so reconstructing it is the
949
+ // only way it can never disagree with them — and a stale or half-written index.json
950
+ // used to be able to hide entries from search entirely while they sat on disk intact.
951
+ this.rebuildIndex();
660
952
  this.recalcAvgDocLength();
661
953
  }
662
954
  catch (err) {
@@ -664,6 +956,52 @@ export class TheBrainV2 {
664
956
  this.entries.clear();
665
957
  this.invertedIndex.clear();
666
958
  this.bloom = new BloomFilter();
959
+ this.tombstones = new Map();
960
+ }
961
+ }
962
+ /** Recomputes the inverted index from scratch over the current entries. */
963
+ rebuildIndex() {
964
+ this.invertedIndex = new Map();
965
+ for (const entry of this.entries.values()) {
966
+ for (const term of entry.terms) {
967
+ let ids = this.invertedIndex.get(term);
968
+ if (!ids) {
969
+ ids = new Set();
970
+ this.invertedIndex.set(term, ids);
971
+ }
972
+ ids.add(entry.id);
973
+ }
974
+ }
975
+ }
976
+ /**
977
+ * Pull in anything another process wrote since we last touched the corpus.
978
+ *
979
+ * Called at the top of every read path. Two statSync calls when nothing changed (the
980
+ * overwhelmingly common case) is far below the cost of the search that follows, and it
981
+ * turns sibling sessions from a data-loss hazard into a live shared corpus: a memory
982
+ * stored in one project's session is searchable from another within one tool call.
983
+ *
984
+ * Merges rather than reloads, so memories stored locally but not yet flushed survive.
985
+ */
986
+ syncIfChanged() {
987
+ const stamp = stampOf(ENTRIES_FILE);
988
+ if (sameStamp(stamp, this.diskStamp))
989
+ return;
990
+ try {
991
+ this.absorbPeerTombstones();
992
+ const onDisk = readEntriesFile();
993
+ const before = this.entries.size;
994
+ this.entries = mergeEntryMaps(this.entries, onDisk, new Set(this.tombstones.keys()));
995
+ this.diskStamp = stamp;
996
+ if (this.entries.size !== before || onDisk.size > 0) {
997
+ this.rebuildIndex();
998
+ this.recalcAvgDocLength();
999
+ }
1000
+ }
1001
+ catch {
1002
+ // A read failure here must never break a search: worst case we keep serving the
1003
+ // in-memory corpus, which is exactly the pre-existing behavior.
1004
+ this.diskStamp = stamp;
667
1005
  }
668
1006
  }
669
1007
  recalcAvgDocLength() {
@@ -685,38 +1023,111 @@ export class TheBrainV2 {
685
1023
  }, 500);
686
1024
  }
687
1025
  save() {
1026
+ const fd = acquireLock();
688
1027
  try {
689
1028
  this.ensureDir();
1029
+ // Merge before writing. A full rewrite of what this process happens to hold would
1030
+ // erase every memory a sibling session stored since we loaded — the concurrency bug
1031
+ // this whole section exists to close. After the merge the file we write is a superset
1032
+ // of both views, so a writer can only ever add.
1033
+ this.absorbPeerTombstones();
1034
+ const onDisk = readEntriesFile();
1035
+ if (onDisk.size > 0) {
1036
+ this.entries = mergeEntryMaps(this.entries, onDisk, new Set(this.tombstones.keys()));
1037
+ this.rebuildIndex();
1038
+ this.recalcAvgDocLength();
1039
+ }
1040
+ this.pruneTombstones();
1041
+ // Post-merge, so the file we write respects the cap even when the merge pulled in
1042
+ // entries a peer had already evicted.
1043
+ this.evictIfOverCapacity();
690
1044
  // Write NDJSON entries. `terms` is dropped: it is exactly Object.keys(termFreq),
691
1045
  // and persisting both made the entry file 38% redundant bytes that every session
692
1046
  // re-read at startup. load() reconstructs it.
693
1047
  const ndjson = Array.from(this.entries.values())
694
1048
  .map(({ terms: _terms, ...persisted }) => JSON.stringify(persisted))
695
1049
  .join('\n');
696
- fs.writeFileSync(ENTRIES_FILE, ndjson, 'utf8');
697
- // Write inverted index
1050
+ writeFileAtomic(ENTRIES_FILE, ndjson);
1051
+ // Write inverted index. load() rebuilds this from the entries rather than reading it
1052
+ // back, so it is now purely an inspection artifact for the dashboard and for anyone
1053
+ // poking at the brain directory — kept because removing a file other tooling may read
1054
+ // is not worth the handful of bytes it saves.
698
1055
  const indexObj = {};
699
1056
  for (const [term, ids] of this.invertedIndex) {
700
1057
  indexObj[term] = Array.from(ids);
701
1058
  }
702
- fs.writeFileSync(INDEX_FILE, JSON.stringify(indexObj), 'utf8');
1059
+ writeFileAtomic(INDEX_FILE, JSON.stringify(indexObj));
703
1060
  // Write meta
704
- fs.writeFileSync(META_FILE, JSON.stringify({
1061
+ writeFileAtomic(META_FILE, JSON.stringify({
705
1062
  bloom: this.bloom.serialize(),
706
1063
  avgDocLength: this.avgDocLength,
707
1064
  totalEntries: this.entries.size,
708
1065
  savedAt: new Date().toISOString(),
709
- }), 'utf8');
1066
+ tombstones: Object.fromEntries(this.tombstones),
1067
+ }));
1068
+ this.diskStamp = stampOf(ENTRIES_FILE);
710
1069
  this.dirty = false;
711
1070
  }
712
1071
  catch { /* fail silently */ }
1072
+ finally {
1073
+ releaseLock(fd);
1074
+ }
1075
+ }
1076
+ /**
1077
+ * Pull in deletions made by other processes.
1078
+ *
1079
+ * Tombstones are shared state, not per-process bookkeeping: a peer that deleted a memory
1080
+ * wrote the tombstone to meta.json, and a process that still holds the entry in memory
1081
+ * would otherwise merge it right back on its next flush — undoing a deliberate deletion
1082
+ * from a session that had nothing to do with it.
1083
+ */
1084
+ absorbPeerTombstones() {
1085
+ for (const [id, at] of readTombstonesFile()) {
1086
+ if (!this.tombstones.has(id))
1087
+ this.tombstones.set(id, at);
1088
+ const entry = this.entries.get(id);
1089
+ if (entry)
1090
+ this.dropEntry(entry);
1091
+ }
1092
+ }
1093
+ /**
1094
+ * Records an id as deliberately deleted, so a merge with a peer that still holds it
1095
+ * doesn't resurrect it. Without this, forget() would be undone the moment any other
1096
+ * session flushed, and eviction would thrash forever between two processes.
1097
+ */
1098
+ tombstone(id) {
1099
+ this.tombstones.set(id, new Date().toISOString());
1100
+ }
1101
+ /** Drops tombstones older than TOMBSTONE_TTL_MS — by then no peer still holds the entry. */
1102
+ pruneTombstones() {
1103
+ const cutoff = Date.now() - TOMBSTONE_TTL_MS;
1104
+ for (const [id, at] of this.tombstones) {
1105
+ const t = Date.parse(at);
1106
+ if (!Number.isFinite(t) || t < cutoff)
1107
+ this.tombstones.delete(id);
1108
+ }
1109
+ }
1110
+ /** Removes an entry from the in-memory corpus and the inverted index. No persistence. */
1111
+ dropEntry(entry) {
1112
+ this.entries.delete(entry.id);
1113
+ for (const term of entry.terms) {
1114
+ const ids = this.invertedIndex.get(term);
1115
+ if (!ids)
1116
+ continue;
1117
+ ids.delete(entry.id);
1118
+ if (ids.size === 0)
1119
+ this.invertedIndex.delete(term);
1120
+ }
713
1121
  }
714
1122
  // ─── Store ────────────────────────────────────────────────────────────────
715
1123
  /**
716
1124
  * Store a query+response pair in the brain.
717
1125
  * Returns false if detected as duplicate (>= dupThreshold similarity).
718
1126
  */
719
- store(query, response, provider = 'generic', dupThreshold = 0.92, filePaths, projectId = deriveProjectId(), outcome, symbolRefs, claimInputs, domain, derivedFrom) {
1127
+ store(query, response, provider = 'generic', dupThreshold = 0.92, filePaths, projectId = deriveProjectId(), outcome, symbolRefs, claimInputs, domain, derivedFrom, options = {}) {
1128
+ // Another session may have stored this exact thing since we loaded; without the sync
1129
+ // the dedup check below would miss it and write a second copy.
1130
+ this.syncIfChanged();
720
1131
  // Quick bloom check
721
1132
  const queryKey = query.trim().toLowerCase().substring(0, 200);
722
1133
  if (this.bloom.has(queryKey)) {
@@ -754,11 +1165,20 @@ export class TheBrainV2 {
754
1165
  }
755
1166
  const terms = tokenize(query + ' ' + response);
756
1167
  const termFreq = termFrequencies(terms);
1168
+ // Salted with random bytes, not just the clock: the id used to be sha1(queryKey + now),
1169
+ // so two memories sharing a query prefix and landing in the same millisecond produced
1170
+ // the same id and the second silently overwrote the first — a lost memory that nothing
1171
+ // reported. Batch paths (git ingest, importBundle, auto-capture) hit that window.
757
1172
  const id = crypto
758
1173
  .createHash('sha1')
759
- .update(queryKey + Date.now())
1174
+ .update(queryKey + Date.now() + crypto.randomBytes(8).toString('hex'))
760
1175
  .digest('hex')
761
1176
  .substring(0, 12);
1177
+ // Recorded so a later stale result can say whether the code changed or the caller
1178
+ // simply moved branches. Never consulted by freshness itself.
1179
+ const git = options.skipGitContext
1180
+ ? { commit: options.gitCommit, branch: options.gitBranch }
1181
+ : currentGitContext();
762
1182
  const entry = {
763
1183
  id,
764
1184
  query: query.trim(),
@@ -766,17 +1186,23 @@ export class TheBrainV2 {
766
1186
  terms: [...new Set(terms)],
767
1187
  termFreq,
768
1188
  provider,
769
- timestamp: new Date().toISOString(),
1189
+ timestamp: options.timestamp || new Date().toISOString(),
770
1190
  hits: 0,
771
1191
  charCount: query.length + response.length,
772
1192
  projectId,
773
- fileHashes: filePaths && filePaths.length > 0 ? hashFilesForFreshness(filePaths) : undefined,
1193
+ fileHashes: options.evidence?.files ??
1194
+ (filePaths && filePaths.length > 0 ? hashFilesForFreshness(filePaths) : undefined),
774
1195
  outcome,
775
- symbolHashes: symbolRefs && symbolRefs.length > 0 ? hashSymbolsForFreshness(symbolRefs) : undefined,
776
- symbolNormalizedHashes: symbolRefs && symbolRefs.length > 0 ? hashSymbolsNormalizedForFreshness(symbolRefs) : undefined,
1196
+ symbolHashes: options.evidence?.symbols ??
1197
+ (symbolRefs && symbolRefs.length > 0 ? hashSymbolsForFreshness(symbolRefs) : undefined),
1198
+ symbolNormalizedHashes: options.evidence?.symbolsNormalized ??
1199
+ (symbolRefs && symbolRefs.length > 0 ? hashSymbolsNormalizedForFreshness(symbolRefs) : undefined),
777
1200
  claims: claimInputs && claimInputs.length > 0 ? buildClaims(claimInputs) : undefined,
778
1201
  domain,
779
1202
  derivedFrom: derivedFrom && derivedFrom.length > 0 ? derivedFrom : undefined,
1203
+ source: options.source || 'manual',
1204
+ ...(git.commit ? { gitCommit: git.commit } : {}),
1205
+ ...(git.branch ? { gitBranch: git.branch } : {}),
780
1206
  };
781
1207
  this.entries.set(id, entry);
782
1208
  // Update inverted index
@@ -791,7 +1217,7 @@ export class TheBrainV2 {
791
1217
  this.recalcAvgDocLength();
792
1218
  this.evictIfOverCapacity();
793
1219
  this.scheduleSave();
794
- return { stored: true, reason: 'Stored successfully', conflicts };
1220
+ return { stored: true, reason: 'Stored successfully', id, conflicts };
795
1221
  }
796
1222
  /**
797
1223
  * Evict the lowest-value entries once the Brain is over capacity. Value = hits (proven
@@ -818,15 +1244,11 @@ export class TheBrainV2 {
818
1244
  });
819
1245
  const toEvict = ranked.slice(0, TheBrainV2.EVICT_BATCH);
820
1246
  for (const entry of toEvict) {
821
- this.entries.delete(entry.id);
822
- for (const term of entry.terms) {
823
- const ids = this.invertedIndex.get(term);
824
- if (!ids)
825
- continue;
826
- ids.delete(entry.id);
827
- if (ids.size === 0)
828
- this.invertedIndex.delete(term);
829
- }
1247
+ this.dropEntry(entry);
1248
+ // Tombstoned, not just dropped: another session's merge would otherwise hand back
1249
+ // every entry this pass just decided was worthless, and the two processes would
1250
+ // evict-and-resurrect the same 500 entries against each other indefinitely.
1251
+ this.tombstone(entry.id);
830
1252
  }
831
1253
  this.recalcAvgDocLength();
832
1254
  }
@@ -838,6 +1260,8 @@ export class TheBrainV2 {
838
1260
  search(query, limit = 5, minSimilarity = 0, options = {}) {
839
1261
  // Internal callers (dedup) must not move the counters the savings ledger reports.
840
1262
  const countStats = options.countStats !== false;
1263
+ // Pick up anything a sibling session stored since the last read. Two statSync calls.
1264
+ this.syncIfChanged();
841
1265
  if (this.entries.size === 0) {
842
1266
  if (countStats)
843
1267
  this.sessionMisses++;
@@ -928,6 +1352,16 @@ export class TheBrainV2 {
928
1352
  // popularity prior above rather than just canceling it out.
929
1353
  if (s.entry.demerits)
930
1354
  similarity -= Math.min(s.entry.demerits * 0.06, 0.25);
1355
+ // Age decay. Every other signal here is about the memory's track record; none of them
1356
+ // notice that the codebase it describes has been rewritten twice since. A confirmed
1357
+ // memory from a year ago is not as likely to be current as yesterday's, and until now
1358
+ // they ranked identically. Kept in the same small, additive register as the rest — it
1359
+ // demotes among relevant candidates, it can't bury one. `refreshedAt` resets the clock,
1360
+ // which is the point of refresh(): a re-verified memory really is current again.
1361
+ const ageDays = (Date.now() - effectiveTime(s.entry)) / 86400000;
1362
+ if (Number.isFinite(ageDays) && ageDays > RECENCY_GRACE_DAYS) {
1363
+ similarity -= Math.min((ageDays - RECENCY_GRACE_DAYS) / RECENCY_FULL_DECAY_DAYS, 1) * MAX_RECENCY_PENALTY;
1364
+ }
931
1365
  return { entry: s.entry, similarity: Math.max(0, similarity) };
932
1366
  });
933
1367
  // 6. Sort and filter
@@ -935,12 +1369,17 @@ export class TheBrainV2 {
935
1369
  const claimIndex = buildClaimIndex(this.entries);
936
1370
  const resolveNode = (id) => resolveFreshnessNode(this.entries, claimIndex, id);
937
1371
  const semanticDiff = options.semanticDiff === true;
1372
+ const resolver = options.resolver ?? defaultLocalResolver;
1373
+ // Resolved once per search, not per stale hit — five stale results would otherwise
1374
+ // spawn ten git processes for one query.
1375
+ const git = currentGitContext();
938
1376
  const results = combined
939
1377
  .filter(r => r.similarity >= minSimilarity)
940
1378
  .slice(0, limit)
941
1379
  .map(r => {
942
- const { fresh, staleFiles, cosmeticChanges } = checkEntryFreshness(r.entry, resolveNode, 0, new Set(), semanticDiff);
943
- const claims = r.entry.claims?.map((c) => checkClaimFreshness(c, semanticDiff));
1380
+ const { fresh, staleFiles, unverifiedFiles, cosmeticChanges } = checkEntryFreshness(r.entry, resolver, resolveNode, 0, new Set(), semanticDiff);
1381
+ const claims = r.entry.claims?.map((c) => checkClaimFreshness(c, resolver, semanticDiff));
1382
+ const unverified = unverifiedFiles.length > 0;
944
1383
  return {
945
1384
  id: r.entry.id,
946
1385
  query: r.entry.query,
@@ -949,7 +1388,8 @@ export class TheBrainV2 {
949
1388
  provider: r.entry.provider,
950
1389
  timestamp: r.entry.timestamp,
951
1390
  fresh,
952
- ...(fresh ? {} : { staleFiles }),
1391
+ ...(fresh ? {} : { staleFiles, ...(unverified ? {} : attributeStaleness(r.entry, git)) }),
1392
+ ...(unverifiedFiles.length > 0 ? { unverifiedFiles } : {}),
953
1393
  ...(r.entry.outcome ? { outcome: r.entry.outcome } : {}),
954
1394
  ...(claims && claims.length > 0 ? { claims } : {}),
955
1395
  ...(r.entry.domain ? { domain: r.entry.domain } : {}),
@@ -977,6 +1417,69 @@ export class TheBrainV2 {
977
1417
  * Entries belonging to a project, including the unscoped ones written before entries
978
1418
  * carried a projectId — same fallback rule search() uses, so counts and results agree.
979
1419
  */
1420
+ /**
1421
+ * search(), with an optional semantic re-rank layered on top.
1422
+ *
1423
+ * BM25 still does the retrieving — this only reorders what it already found, and only
1424
+ * when LEMMA_BRAIN_EMBEDDINGS is set and Ollama answers. With the flag off (the default)
1425
+ * this is exactly `search()` plus one boolean check, so callers can use it unconditionally
1426
+ * and no session pays for a feature it hasn't turned on. See BrainEmbeddings.ts.
1427
+ *
1428
+ * The candidate pool is widened before re-ranking: re-ordering the same `limit` results
1429
+ * BM25 already picked can only shuffle them, never surface the memory BM25 ranked 9th
1430
+ * because it happened to use different words — which is the entire point.
1431
+ */
1432
+ async searchHybrid(query, limit = 5, minSimilarity = 0, options = {}) {
1433
+ if (!embeddingsEnabled())
1434
+ return this.search(query, limit, minSimilarity, options);
1435
+ const poolSize = Math.max(limit * 4, 20);
1436
+ // minSimilarity 0 for the pool: a candidate the re-rank would promote must not be cut
1437
+ // by a lexical floor before the semantic score is ever computed. The floor is applied
1438
+ // again below, against the blended score.
1439
+ const pool = this.search(query, poolSize, 0, { ...options, countStats: false });
1440
+ if (pool.length === 0) {
1441
+ if (options.countStats !== false)
1442
+ this.sessionMisses++;
1443
+ return [];
1444
+ }
1445
+ let ranked = pool;
1446
+ try {
1447
+ const scores = await semanticRerank(query, pool.map((r) => ({ id: r.id, similarity: r.similarity, text: `${r.query}\n${r.response}` })), this.embeddingSidecar());
1448
+ if (scores) {
1449
+ ranked = pool
1450
+ .map((r) => ({ ...r, similarity: scores.get(r.id) ?? r.similarity }))
1451
+ .sort((a, b) => b.similarity - a.similarity);
1452
+ this.sidecar?.flush(new Set(this.entries.keys()));
1453
+ }
1454
+ }
1455
+ catch {
1456
+ // Re-ranking is an enhancement; a failure in it must never cost the caller the
1457
+ // results BM25 already produced.
1458
+ ranked = pool;
1459
+ }
1460
+ const results = ranked.filter((r) => r.similarity >= minSimilarity).slice(0, limit);
1461
+ // Stats bookkeeping the pool search was told to skip, applied once against the final list
1462
+ // so a hybrid search counts exactly like a lexical one.
1463
+ if (options.countStats !== false) {
1464
+ if (results.length > 0) {
1465
+ this.sessionHits++;
1466
+ const topEntry = this.entries.get(results[0].id);
1467
+ if (topEntry) {
1468
+ topEntry.hits++;
1469
+ this.scheduleSave();
1470
+ }
1471
+ }
1472
+ else {
1473
+ this.sessionMisses++;
1474
+ }
1475
+ }
1476
+ return results;
1477
+ }
1478
+ embeddingSidecar() {
1479
+ if (!this.sidecar)
1480
+ this.sidecar = new EmbeddingSidecar(EMBEDDINGS_FILE);
1481
+ return this.sidecar;
1482
+ }
980
1483
  getEntriesForProject(projectId) {
981
1484
  return Array.from(this.entries.values()).filter((e) => e.projectId === undefined || e.projectId === projectId);
982
1485
  }
@@ -1017,6 +1520,7 @@ export class TheBrainV2 {
1017
1520
  * differently-phrased query tomorrow.
1018
1521
  */
1019
1522
  downvote(id) {
1523
+ this.syncIfChanged();
1020
1524
  const entry = this.entries.get(id);
1021
1525
  if (!entry)
1022
1526
  return { ok: false, message: `No entry with id "${id}" in the Brain.` };
@@ -1024,6 +1528,136 @@ export class TheBrainV2 {
1024
1528
  this.scheduleSave();
1025
1529
  return { ok: true, message: `Recorded negative feedback on entry "${id}" (demerits: ${entry.demerits}). It will rank lower and be evicted sooner.` };
1026
1530
  }
1531
+ // ─── Deletion ───────────────────────────────────────────────────────────────
1532
+ /**
1533
+ * Permanently remove one memory.
1534
+ *
1535
+ * The Brain had no way to delete anything: downvote() only demotes, and eviction only
1536
+ * fires at capacity. A memory that is simply wrong, or that captured something that
1537
+ * should never have been stored, had no exit — the only recourse was to downvote it
1538
+ * repeatedly and wait for the corpus to fill up. This is that exit.
1539
+ *
1540
+ * The id is tombstoned as well as dropped, so a concurrent session's merge can't hand it
1541
+ * straight back. The bloom filter cannot un-add a key, so the deleted query may still
1542
+ * register as a possible duplicate later; that costs one extra dedup search and never
1543
+ * produces a wrong answer, which is the right side of that trade for a probabilistic
1544
+ * filter that is rebuilt on the next clear().
1545
+ */
1546
+ forget(id) {
1547
+ this.syncIfChanged();
1548
+ const entry = this.entries.get(id);
1549
+ if (!entry)
1550
+ return { ok: false, message: `No entry with id "${id}" in the Brain.` };
1551
+ this.dropEntry(entry);
1552
+ this.tombstone(id);
1553
+ this.recalcAvgDocLength();
1554
+ this.scheduleSave();
1555
+ return {
1556
+ ok: true,
1557
+ message: `Deleted memory "${id}". It will not come back from another session's copy.`,
1558
+ // Echoed back so a deletion is auditable: the caller (and the user reading the
1559
+ // transcript) can see exactly what was destroyed, since nothing else can recover it.
1560
+ forgotten: { id, query: entry.query, timestamp: entry.timestamp, hits: entry.hits },
1561
+ };
1562
+ }
1563
+ // ─── Re-anchoring ───────────────────────────────────────────────────────────
1564
+ /**
1565
+ * Re-verify a memory against the code as it stands now, keeping its identity.
1566
+ *
1567
+ * Staleness used to be terminal: once tracked evidence changed, an entry was stale
1568
+ * forever, even in the very common case where the insight is still true and the code just
1569
+ * moved. The only workaround was to store a near-duplicate — which store()'s own dedup
1570
+ * guard would often refuse — losing the entry's id, its hit count, its demerits, and every
1571
+ * `derivedFrom` edge pointing at it.
1572
+ *
1573
+ * With no arguments this re-hashes whatever the entry already tracks (and each claim's own
1574
+ * evidence), which is the "yes, I checked, this is still correct" path. Passing filePaths
1575
+ * or symbols instead re-points the entry at new evidence, which is the "the code moved"
1576
+ * path. Either way the caller is asserting the memory is currently true — this tool
1577
+ * records that assertion, it cannot verify it, so it is never called automatically.
1578
+ *
1579
+ * `derivedFrom` dependencies are deliberately not re-anchored: a conclusion inherited from
1580
+ * a memory that is itself stale is exactly what Fase B exists to catch, and silently
1581
+ * clearing that would defeat it. Those come back in `stillStale` instead.
1582
+ */
1583
+ refresh(id, opts = {}) {
1584
+ this.syncIfChanged();
1585
+ const entry = this.entries.get(id);
1586
+ if (!entry)
1587
+ return { ok: false, message: `No entry with id "${id}" in the Brain.` };
1588
+ const retracked = [];
1589
+ const rel = (abs) => {
1590
+ const r = path.relative(process.cwd(), abs);
1591
+ return r.startsWith('..') ? abs : r;
1592
+ };
1593
+ const hasNewTracking = (opts.filePaths?.length || 0) > 0 || (opts.symbols?.length || 0) > 0;
1594
+ if (hasNewTracking) {
1595
+ // Re-point: the new tracking replaces the old wholesale rather than merging, because
1596
+ // a moved symbol's old path must stop counting against the entry forever.
1597
+ entry.fileHashes = opts.filePaths?.length ? hashFilesForFreshness(opts.filePaths) : undefined;
1598
+ entry.symbolHashes = opts.symbols?.length ? hashSymbolsForFreshness(opts.symbols) : undefined;
1599
+ entry.symbolNormalizedHashes = opts.symbols?.length ? hashSymbolsNormalizedForFreshness(opts.symbols) : undefined;
1600
+ for (const p of opts.filePaths || [])
1601
+ retracked.push(rel(path.resolve(p)));
1602
+ for (const s of opts.symbols || [])
1603
+ retracked.push(`${rel(path.resolve(s.filePath))}::${s.symbolName}`);
1604
+ }
1605
+ else {
1606
+ // Re-anchor in place: same files, same symbols, hashes recomputed against current
1607
+ // content. A tracked path that no longer exists hashes to 'MISSING' exactly as it
1608
+ // does at store time, so the entry stays honestly stale instead of being blessed.
1609
+ if (entry.fileHashes) {
1610
+ const paths = Object.keys(entry.fileHashes);
1611
+ entry.fileHashes = hashFilesForFreshness(paths);
1612
+ for (const p of paths)
1613
+ retracked.push(rel(p));
1614
+ }
1615
+ if (entry.symbolHashes) {
1616
+ const refs = Object.keys(entry.symbolHashes).map((key) => {
1617
+ const sep = key.lastIndexOf('::');
1618
+ return { filePath: key.substring(0, sep), symbolName: key.substring(sep + 2) };
1619
+ });
1620
+ entry.symbolHashes = hashSymbolsForFreshness(refs);
1621
+ entry.symbolNormalizedHashes = hashSymbolsNormalizedForFreshness(refs);
1622
+ for (const r of refs)
1623
+ retracked.push(`${rel(r.filePath)}::${r.symbolName}`);
1624
+ }
1625
+ if (entry.claims) {
1626
+ for (const claim of entry.claims) {
1627
+ if (claim.fileHashes)
1628
+ claim.fileHashes = hashFilesForFreshness(Object.keys(claim.fileHashes));
1629
+ if (claim.symbolHashes) {
1630
+ const refs = Object.keys(claim.symbolHashes).map((key) => {
1631
+ const sep = key.lastIndexOf('::');
1632
+ return { filePath: key.substring(0, sep), symbolName: key.substring(sep + 2) };
1633
+ });
1634
+ claim.symbolHashes = hashSymbolsForFreshness(refs);
1635
+ claim.symbolNormalizedHashes = hashSymbolsNormalizedForFreshness(refs);
1636
+ }
1637
+ }
1638
+ }
1639
+ }
1640
+ // Re-stamp git context and the age clock: this memory was just re-verified here, now.
1641
+ const git = currentGitContext();
1642
+ if (git.commit)
1643
+ entry.gitCommit = git.commit;
1644
+ if (git.branch)
1645
+ entry.gitBranch = git.branch;
1646
+ entry.refreshedAt = new Date().toISOString();
1647
+ const claimIndex = buildClaimIndex(this.entries);
1648
+ const { fresh, staleFiles } = checkEntryFreshness(entry, defaultLocalResolver, (depId) => resolveFreshnessNode(this.entries, claimIndex, depId));
1649
+ this.scheduleSave();
1650
+ return {
1651
+ ok: true,
1652
+ fresh,
1653
+ retracked,
1654
+ ...(staleFiles.length > 0 ? { stillStale: staleFiles } : {}),
1655
+ message: fresh
1656
+ ? `Memory "${id}" re-anchored to current code and is fresh again.`
1657
+ : `Memory "${id}" re-anchored, but still stale: ${staleFiles.join(', ')}. ` +
1658
+ `A "derived:" entry here means an upstream memory it was built on is stale — refresh that one first.`,
1659
+ };
1660
+ }
1027
1661
  // ─── Batch verification ─────────────────────────────────────────────────────
1028
1662
  /**
1029
1663
  * Revalidate ids from a prior search_memory/store_memory result via hash-compare only —
@@ -1033,28 +1667,39 @@ export class TheBrainV2 {
1033
1667
  * either way there is nothing left to vouch for it).
1034
1668
  */
1035
1669
  verifyByIds(ids, options = {}) {
1670
+ // An id handed over by a subagent or another MCP client may belong to an entry this
1671
+ // process has never loaded. Without the sync it would come back 'unknown' — which the
1672
+ // caller is told means "purged", and acting on that would be wrong.
1673
+ this.syncIfChanged();
1036
1674
  const claimIndex = buildClaimIndex(this.entries);
1037
1675
  const resolveNode = (id) => resolveFreshnessNode(this.entries, claimIndex, id);
1038
1676
  const semanticDiff = options.semanticDiff === true;
1677
+ const resolver = options.resolver ?? defaultLocalResolver;
1678
+ const git = currentGitContext();
1039
1679
  return ids.map((id) => {
1040
1680
  const entry = this.entries.get(id);
1041
1681
  if (entry) {
1042
- const { fresh, staleFiles, cosmeticChanges } = checkEntryFreshness(entry, resolveNode, 0, new Set(), semanticDiff);
1682
+ const { fresh, staleFiles, unverifiedFiles, cosmeticChanges } = checkEntryFreshness(entry, resolver, resolveNode, 0, new Set(), semanticDiff);
1683
+ const unverified = unverifiedFiles.length > 0;
1043
1684
  return {
1044
1685
  id,
1045
- status: fresh ? 'fresh' : 'stale',
1686
+ status: unverified ? 'unverified' : fresh ? 'fresh' : 'stale',
1687
+ ...(unverified || fresh ? {} : attributeStaleness(entry, git)),
1046
1688
  ...(staleFiles.length > 0 ? { staleFiles } : {}),
1047
- ...(entry.claims ? { claimBreakdown: entry.claims.map((c) => checkClaimFreshness(c, semanticDiff)) } : {}),
1689
+ ...(unverified ? { unverifiedFiles } : {}),
1690
+ ...(entry.claims ? { claimBreakdown: entry.claims.map((c) => checkClaimFreshness(c, resolver, semanticDiff)) } : {}),
1048
1691
  ...(cosmeticChanges && cosmeticChanges.length > 0 ? { cosmeticChanges } : {}),
1049
1692
  };
1050
1693
  }
1051
1694
  const claim = claimIndex.get(id);
1052
1695
  if (claim) {
1053
- const cf = checkClaimFreshness(claim, semanticDiff);
1696
+ const cf = checkClaimFreshness(claim, resolver, semanticDiff);
1697
+ const unverified = (cf.unverifiedFiles?.length ?? 0) > 0;
1054
1698
  return {
1055
1699
  id,
1056
- status: cf.fresh ? 'fresh' : 'stale',
1700
+ status: cf.fresh ? 'fresh' : unverified ? 'unverified' : 'stale',
1057
1701
  ...(cf.staleFiles ? { staleFiles: cf.staleFiles } : {}),
1702
+ ...(cf.unverifiedFiles ? { unverifiedFiles: cf.unverifiedFiles } : {}),
1058
1703
  ...(cf.cosmeticChanges ? { cosmeticChanges: cf.cosmeticChanges } : {}),
1059
1704
  };
1060
1705
  }
@@ -1068,11 +1713,162 @@ export class TheBrainV2 {
1068
1713
  * next search) — see findBlastRadius for the algorithm and cost.
1069
1714
  */
1070
1715
  findBlastRadius(filePath, symbolName) {
1716
+ this.syncIfChanged();
1071
1717
  return findBlastRadius(this.entries, filePath, symbolName);
1072
1718
  }
1073
- // ─── Stats ────────────────────────────────────────────────────────────────
1074
- getStats() {
1719
+ // ─── Portability ────────────────────────────────────────────────────────────
1720
+ /**
1721
+ * Serialize memories to a portable NDJSON bundle: a header line, then one entry per line.
1722
+ *
1723
+ * The Brain is a single file under one user's home directory with no way in or out. That
1724
+ * makes it unshareable with a teammate, unmovable to another machine, and unbackupable
1725
+ * except by copying the directory wholesale (which also copies every other project's
1726
+ * memories). This is the smallest thing that fixes all three.
1727
+ */
1728
+ exportBundle(opts = {}) {
1729
+ this.syncIfChanged();
1730
+ const claimIndex = buildClaimIndex(this.entries);
1731
+ const resolveNode = (id) => resolveFreshnessNode(this.entries, claimIndex, id);
1732
+ let skippedStale = 0;
1733
+ const selected = [];
1734
+ for (const entry of this.entries.values()) {
1735
+ // Same unscoped-entry fallback search() uses: an entry written before scoping existed
1736
+ // belongs to no project in particular, so excluding it from every export would quietly
1737
+ // make it unbackupable.
1738
+ if (opts.projectId && entry.projectId !== undefined && entry.projectId !== opts.projectId)
1739
+ continue;
1740
+ if (opts.domain && entry.domain !== opts.domain)
1741
+ continue;
1742
+ if (!opts.includeStale && !checkEntryFreshness(entry, defaultLocalResolver, resolveNode).fresh) {
1743
+ skippedStale++;
1744
+ continue;
1745
+ }
1746
+ selected.push(entry);
1747
+ }
1748
+ const header = JSON.stringify({
1749
+ lemmaBrainExport: 1,
1750
+ exportedAt: new Date().toISOString(),
1751
+ count: selected.length,
1752
+ ...(opts.projectId ? { projectId: opts.projectId } : {}),
1753
+ ...(opts.domain ? { domain: opts.domain } : {}),
1754
+ });
1755
+ const body = selected.map(({ terms: _t, ...persisted }) => JSON.stringify(persisted));
1756
+ return { text: [header, ...body].join('\n'), count: selected.length, skippedStale };
1757
+ }
1758
+ /**
1759
+ * Merge a bundle produced by exportBundle into this Brain.
1760
+ *
1761
+ * Import is additive and never destructive: an id already present keeps whichever version
1762
+ * is newer and the higher of both counters (the same rule cross-process merging uses), and
1763
+ * a tombstoned id stays deleted — importing a bundle must not resurrect something the user
1764
+ * deliberately forgot.
1765
+ *
1766
+ * Imported entries keep their origin machine's absolute paths, so most of them will read
1767
+ * as stale here until refresh() re-anchors them. That is the honest outcome: their
1768
+ * evidence genuinely cannot be verified against this checkout, and reporting them as fresh
1769
+ * would be the one failure mode this whole system is built to prevent.
1770
+ */
1771
+ importBundle(text, opts = {}) {
1772
+ this.syncIfChanged();
1773
+ const lines = text.split('\n').filter(Boolean);
1774
+ if (lines.length === 0)
1775
+ return { ok: false, imported: 0, updated: 0, skipped: 0, message: 'Bundle is empty.' };
1776
+ let start = 0;
1777
+ try {
1778
+ const header = JSON.parse(lines[0]);
1779
+ if (header && header.lemmaBrainExport)
1780
+ start = 1;
1781
+ }
1782
+ catch {
1783
+ // No header — treat the whole file as entries. A raw entries.ndjson copied off another
1784
+ // machine is a perfectly reasonable thing to hand this, and rejecting it would be
1785
+ // pedantry rather than safety.
1786
+ }
1787
+ let imported = 0;
1788
+ let updated = 0;
1789
+ let skipped = 0;
1790
+ const markSource = opts.markSource !== false;
1791
+ for (const line of lines.slice(start)) {
1792
+ let entry;
1793
+ try {
1794
+ entry = JSON.parse(line);
1795
+ }
1796
+ catch {
1797
+ skipped++;
1798
+ continue;
1799
+ }
1800
+ if (!entry || typeof entry.id !== 'string' || typeof entry.query !== 'string') {
1801
+ skipped++;
1802
+ continue;
1803
+ }
1804
+ if (this.tombstones.has(entry.id)) {
1805
+ skipped++;
1806
+ continue;
1807
+ }
1808
+ if (!Array.isArray(entry.terms))
1809
+ entry.terms = Object.keys(entry.termFreq || {});
1810
+ if (entry.terms.length === 0) {
1811
+ // A bundle from a version that persisted neither terms nor termFreq — re-tokenize
1812
+ // rather than admit an entry the inverted index could never retrieve.
1813
+ const terms = tokenize(entry.query + ' ' + (entry.response || ''));
1814
+ entry.terms = [...new Set(terms)];
1815
+ entry.termFreq = termFrequencies(terms);
1816
+ }
1817
+ if (markSource)
1818
+ entry.source = 'import';
1819
+ const existing = this.entries.get(entry.id);
1820
+ if (existing) {
1821
+ if (effectiveTime(entry) <= effectiveTime(existing)) {
1822
+ // Older or same age: keep ours, but never lose feedback the other side collected.
1823
+ existing.hits = Math.max(existing.hits || 0, entry.hits || 0);
1824
+ existing.demerits = Math.max(existing.demerits || 0, entry.demerits || 0);
1825
+ skipped++;
1826
+ continue;
1827
+ }
1828
+ this.dropEntry(existing);
1829
+ entry.hits = Math.max(existing.hits || 0, entry.hits || 0);
1830
+ entry.demerits = Math.max(existing.demerits || 0, entry.demerits || 0);
1831
+ updated++;
1832
+ }
1833
+ else {
1834
+ imported++;
1835
+ }
1836
+ this.entries.set(entry.id, entry);
1837
+ for (const term of entry.terms) {
1838
+ let ids = this.invertedIndex.get(term);
1839
+ if (!ids) {
1840
+ ids = new Set();
1841
+ this.invertedIndex.set(term, ids);
1842
+ }
1843
+ ids.add(entry.id);
1844
+ }
1845
+ this.bloom.add(entry.query.trim().toLowerCase().substring(0, 200));
1846
+ }
1847
+ this.recalcAvgDocLength();
1848
+ this.evictIfOverCapacity();
1849
+ this.scheduleSave();
1075
1850
  return {
1851
+ ok: true,
1852
+ imported,
1853
+ updated,
1854
+ skipped,
1855
+ message: `Imported ${imported} new memor${imported === 1 ? 'y' : 'ies'}, updated ${updated}, skipped ${skipped}. ` +
1856
+ `Imported memories track the paths of the machine they came from, so expect them to read as stale here ` +
1857
+ `until refresh_memory re-anchors them.`,
1858
+ };
1859
+ }
1860
+ // ─── Stats ────────────────────────────────────────────────────────────────
1861
+ /**
1862
+ * Corpus counters, plus health signals that answer the question counts alone can't:
1863
+ * is this Brain getting better or is it accumulating dead weight?
1864
+ *
1865
+ * Everything is computed from memory except `staleEntries`, which re-hashes every tracked
1866
+ * file and symbol in the corpus and is therefore behind `deep` — on a full Brain that is
1867
+ * thousands of file reads and has no business running on a routine stats call.
1868
+ */
1869
+ getStats(options = {}) {
1870
+ this.syncIfChanged();
1871
+ const base = {
1076
1872
  totalEntries: this.entries.size,
1077
1873
  totalTerms: this.invertedIndex.size,
1078
1874
  avgDocLength: Math.round(this.avgDocLength),
@@ -1080,6 +1876,52 @@ export class TheBrainV2 {
1080
1876
  cacheHits: this.sessionHits,
1081
1877
  cacheMisses: this.sessionMisses,
1082
1878
  };
1879
+ let neverHit = 0;
1880
+ let downvoted = 0;
1881
+ let unscoped = 0;
1882
+ let oldest = '';
1883
+ const bySource = {};
1884
+ const projects = new Set();
1885
+ for (const entry of this.entries.values()) {
1886
+ if (!entry.hits)
1887
+ neverHit++;
1888
+ if (entry.demerits)
1889
+ downvoted++;
1890
+ if (entry.projectId === undefined)
1891
+ unscoped++;
1892
+ else
1893
+ projects.add(entry.projectId);
1894
+ const src = entrySource(entry);
1895
+ bySource[src] = (bySource[src] || 0) + 1;
1896
+ if (entry.timestamp && (!oldest || entry.timestamp < oldest))
1897
+ oldest = entry.timestamp;
1898
+ }
1899
+ base.neverHit = neverHit;
1900
+ base.downvoted = downvoted;
1901
+ base.bySource = bySource;
1902
+ base.projects = projects.size;
1903
+ base.unscopedEntries = unscoped;
1904
+ base.tombstones = this.tombstones.size;
1905
+ if (oldest)
1906
+ base.oldestEntry = oldest;
1907
+ base.corpusBytes = stampOf(ENTRIES_FILE)?.size ?? 0;
1908
+ if (options.deep) {
1909
+ const claimIndex = buildClaimIndex(this.entries);
1910
+ const resolveNode = (id) => resolveFreshnessNode(this.entries, claimIndex, id);
1911
+ let stale = 0;
1912
+ let tracked = 0;
1913
+ for (const entry of this.entries.values()) {
1914
+ const tracksSomething = !!entry.fileHashes || !!entry.symbolHashes || !!entry.derivedFrom || !!entry.claims;
1915
+ if (!tracksSomething)
1916
+ continue; // untracked entries are fresh by definition, not evidence
1917
+ tracked++;
1918
+ if (!checkEntryFreshness(entry, defaultLocalResolver, resolveNode).fresh)
1919
+ stale++;
1920
+ }
1921
+ base.staleEntries = stale;
1922
+ base.staleRatio = tracked > 0 ? Number((stale / tracked).toFixed(3)) : 0;
1923
+ }
1924
+ return base;
1083
1925
  }
1084
1926
  clear() {
1085
1927
  this.entries.clear();
@@ -1088,6 +1930,9 @@ export class TheBrainV2 {
1088
1930
  this.avgDocLength = 0;
1089
1931
  this.sessionHits = 0;
1090
1932
  this.sessionMisses = 0;
1933
+ this.tombstones.clear();
1934
+ this.diskStamp = null;
1935
+ this.sidecar = null;
1091
1936
  try {
1092
1937
  if (fs.existsSync(ENTRIES_FILE))
1093
1938
  fs.unlinkSync(ENTRIES_FILE);
@@ -1095,6 +1940,10 @@ export class TheBrainV2 {
1095
1940
  fs.unlinkSync(INDEX_FILE);
1096
1941
  if (fs.existsSync(META_FILE))
1097
1942
  fs.unlinkSync(META_FILE);
1943
+ if (fs.existsSync(LOCK_FILE))
1944
+ fs.unlinkSync(LOCK_FILE);
1945
+ if (fs.existsSync(EMBEDDINGS_FILE))
1946
+ fs.unlinkSync(EMBEDDINGS_FILE);
1098
1947
  }
1099
1948
  catch { /* ignore */ }
1100
1949
  }