nodedex 0.1.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 (469) hide show
  1. package/adapters/claude-code-watcher.mjs +336 -0
  2. package/adapters/hermes-statedb-watcher.mjs +234 -0
  3. package/adapters/nodedex-capture-core.mjs +129 -0
  4. package/adapters/nodedex-capture.mjs +169 -0
  5. package/dist/agent-protocol.d.ts +7 -0
  6. package/dist/agent-protocol.d.ts.map +1 -0
  7. package/dist/agent-protocol.js +38 -0
  8. package/dist/agent-protocol.js.map +1 -0
  9. package/dist/api-server.d.ts +5 -0
  10. package/dist/api-server.d.ts.map +1 -0
  11. package/dist/api-server.js +351 -0
  12. package/dist/api-server.js.map +1 -0
  13. package/dist/boot-env.d.ts +2 -0
  14. package/dist/boot-env.d.ts.map +1 -0
  15. package/dist/boot-env.js +12 -0
  16. package/dist/boot-env.js.map +1 -0
  17. package/dist/engine/__tests__/search-core.test.d.ts +2 -0
  18. package/dist/engine/__tests__/search-core.test.d.ts.map +1 -0
  19. package/dist/engine/__tests__/search-core.test.js +139 -0
  20. package/dist/engine/__tests__/search-core.test.js.map +1 -0
  21. package/dist/engine/ai-provider.d.ts +45 -0
  22. package/dist/engine/ai-provider.d.ts.map +1 -0
  23. package/dist/engine/ai-provider.js +5 -0
  24. package/dist/engine/ai-provider.js.map +1 -0
  25. package/dist/engine/embeddings.d.ts +51 -0
  26. package/dist/engine/embeddings.d.ts.map +1 -0
  27. package/dist/engine/embeddings.js +89 -0
  28. package/dist/engine/embeddings.js.map +1 -0
  29. package/dist/engine/providers/__tests__/failure-policy.test.d.ts +2 -0
  30. package/dist/engine/providers/__tests__/failure-policy.test.d.ts.map +1 -0
  31. package/dist/engine/providers/__tests__/failure-policy.test.js +134 -0
  32. package/dist/engine/providers/__tests__/failure-policy.test.js.map +1 -0
  33. package/dist/engine/providers/__tests__/model-caps.test.d.ts +2 -0
  34. package/dist/engine/providers/__tests__/model-caps.test.d.ts.map +1 -0
  35. package/dist/engine/providers/__tests__/model-caps.test.js +38 -0
  36. package/dist/engine/providers/__tests__/model-caps.test.js.map +1 -0
  37. package/dist/engine/providers/__tests__/openai-structured.test.d.ts +2 -0
  38. package/dist/engine/providers/__tests__/openai-structured.test.d.ts.map +1 -0
  39. package/dist/engine/providers/__tests__/openai-structured.test.js +73 -0
  40. package/dist/engine/providers/__tests__/openai-structured.test.js.map +1 -0
  41. package/dist/engine/providers/__tests__/usage-ledger.test.d.ts +2 -0
  42. package/dist/engine/providers/__tests__/usage-ledger.test.d.ts.map +1 -0
  43. package/dist/engine/providers/__tests__/usage-ledger.test.js +108 -0
  44. package/dist/engine/providers/__tests__/usage-ledger.test.js.map +1 -0
  45. package/dist/engine/providers/anthropic.d.ts +17 -0
  46. package/dist/engine/providers/anthropic.d.ts.map +1 -0
  47. package/dist/engine/providers/anthropic.js +125 -0
  48. package/dist/engine/providers/anthropic.js.map +1 -0
  49. package/dist/engine/providers/failure-policy.d.ts +56 -0
  50. package/dist/engine/providers/failure-policy.d.ts.map +1 -0
  51. package/dist/engine/providers/failure-policy.js +120 -0
  52. package/dist/engine/providers/failure-policy.js.map +1 -0
  53. package/dist/engine/providers/gemini.d.ts +22 -0
  54. package/dist/engine/providers/gemini.d.ts.map +1 -0
  55. package/dist/engine/providers/gemini.js +180 -0
  56. package/dist/engine/providers/gemini.js.map +1 -0
  57. package/dist/engine/providers/index.d.ts +8 -0
  58. package/dist/engine/providers/index.d.ts.map +1 -0
  59. package/dist/engine/providers/index.js +67 -0
  60. package/dist/engine/providers/index.js.map +1 -0
  61. package/dist/engine/providers/local.d.ts +12 -0
  62. package/dist/engine/providers/local.d.ts.map +1 -0
  63. package/dist/engine/providers/local.js +46 -0
  64. package/dist/engine/providers/local.js.map +1 -0
  65. package/dist/engine/providers/model-caps.d.ts +6 -0
  66. package/dist/engine/providers/model-caps.d.ts.map +1 -0
  67. package/dist/engine/providers/model-caps.js +49 -0
  68. package/dist/engine/providers/model-caps.js.map +1 -0
  69. package/dist/engine/providers/openai.d.ts +30 -0
  70. package/dist/engine/providers/openai.d.ts.map +1 -0
  71. package/dist/engine/providers/openai.js +309 -0
  72. package/dist/engine/providers/openai.js.map +1 -0
  73. package/dist/engine/providers/usage-ledger.d.ts +69 -0
  74. package/dist/engine/providers/usage-ledger.d.ts.map +1 -0
  75. package/dist/engine/providers/usage-ledger.js +209 -0
  76. package/dist/engine/providers/usage-ledger.js.map +1 -0
  77. package/dist/engine/search-core.d.ts +40 -0
  78. package/dist/engine/search-core.d.ts.map +1 -0
  79. package/dist/engine/search-core.js +109 -0
  80. package/dist/engine/search-core.js.map +1 -0
  81. package/dist/engine/vector-math.d.ts +5 -0
  82. package/dist/engine/vector-math.d.ts.map +1 -0
  83. package/dist/engine/vector-math.js +25 -0
  84. package/dist/engine/vector-math.js.map +1 -0
  85. package/dist/home-env.d.ts +26 -0
  86. package/dist/home-env.d.ts.map +1 -0
  87. package/dist/home-env.js +87 -0
  88. package/dist/home-env.js.map +1 -0
  89. package/dist/mcp-server.d.ts +13 -0
  90. package/dist/mcp-server.d.ts.map +1 -0
  91. package/dist/mcp-server.js +79 -0
  92. package/dist/mcp-server.js.map +1 -0
  93. package/dist/middleware/auth.d.ts +23 -0
  94. package/dist/middleware/auth.d.ts.map +1 -0
  95. package/dist/middleware/auth.js +104 -0
  96. package/dist/middleware/auth.js.map +1 -0
  97. package/dist/middleware/auto-recall.d.ts +7 -0
  98. package/dist/middleware/auto-recall.d.ts.map +1 -0
  99. package/dist/middleware/auto-recall.js +257 -0
  100. package/dist/middleware/auto-recall.js.map +1 -0
  101. package/dist/middleware/auto-reflect.d.ts +4 -0
  102. package/dist/middleware/auto-reflect.d.ts.map +1 -0
  103. package/dist/middleware/auto-reflect.js +5 -0
  104. package/dist/middleware/auto-reflect.js.map +1 -0
  105. package/dist/middleware/reflect/apply-flag-verdict.d.ts +27 -0
  106. package/dist/middleware/reflect/apply-flag-verdict.d.ts.map +1 -0
  107. package/dist/middleware/reflect/apply-flag-verdict.js +57 -0
  108. package/dist/middleware/reflect/apply-flag-verdict.js.map +1 -0
  109. package/dist/middleware/reflect/arc-entity-resolve.d.ts +29 -0
  110. package/dist/middleware/reflect/arc-entity-resolve.d.ts.map +1 -0
  111. package/dist/middleware/reflect/arc-entity-resolve.js +356 -0
  112. package/dist/middleware/reflect/arc-entity-resolve.js.map +1 -0
  113. package/dist/middleware/reflect/arc-inactivity-timer.d.ts +47 -0
  114. package/dist/middleware/reflect/arc-inactivity-timer.d.ts.map +1 -0
  115. package/dist/middleware/reflect/arc-inactivity-timer.js +175 -0
  116. package/dist/middleware/reflect/arc-inactivity-timer.js.map +1 -0
  117. package/dist/middleware/reflect/arc-pipeline.d.ts +33 -0
  118. package/dist/middleware/reflect/arc-pipeline.d.ts.map +1 -0
  119. package/dist/middleware/reflect/arc-pipeline.js +498 -0
  120. package/dist/middleware/reflect/arc-pipeline.js.map +1 -0
  121. package/dist/middleware/reflect/comprehend-pergroup.d.ts +100 -0
  122. package/dist/middleware/reflect/comprehend-pergroup.d.ts.map +1 -0
  123. package/dist/middleware/reflect/comprehend-pergroup.js +610 -0
  124. package/dist/middleware/reflect/comprehend-pergroup.js.map +1 -0
  125. package/dist/middleware/reflect/comprehend.d.ts +237 -0
  126. package/dist/middleware/reflect/comprehend.d.ts.map +1 -0
  127. package/dist/middleware/reflect/comprehend.js +706 -0
  128. package/dist/middleware/reflect/comprehend.js.map +1 -0
  129. package/dist/middleware/reflect/config.d.ts +34 -0
  130. package/dist/middleware/reflect/config.d.ts.map +1 -0
  131. package/dist/middleware/reflect/config.js +131 -0
  132. package/dist/middleware/reflect/config.js.map +1 -0
  133. package/dist/middleware/reflect/context.d.ts +138 -0
  134. package/dist/middleware/reflect/context.d.ts.map +1 -0
  135. package/dist/middleware/reflect/context.js +619 -0
  136. package/dist/middleware/reflect/context.js.map +1 -0
  137. package/dist/middleware/reflect/cost-breakdown.d.ts +69 -0
  138. package/dist/middleware/reflect/cost-breakdown.d.ts.map +1 -0
  139. package/dist/middleware/reflect/cost-breakdown.js +63 -0
  140. package/dist/middleware/reflect/cost-breakdown.js.map +1 -0
  141. package/dist/middleware/reflect/cost-guard.d.ts +102 -0
  142. package/dist/middleware/reflect/cost-guard.d.ts.map +1 -0
  143. package/dist/middleware/reflect/cost-guard.js +243 -0
  144. package/dist/middleware/reflect/cost-guard.js.map +1 -0
  145. package/dist/middleware/reflect/cost-pricing.d.ts +54 -0
  146. package/dist/middleware/reflect/cost-pricing.d.ts.map +1 -0
  147. package/dist/middleware/reflect/cost-pricing.js +148 -0
  148. package/dist/middleware/reflect/cost-pricing.js.map +1 -0
  149. package/dist/middleware/reflect/cross-group-link.d.ts +61 -0
  150. package/dist/middleware/reflect/cross-group-link.d.ts.map +1 -0
  151. package/dist/middleware/reflect/cross-group-link.js +212 -0
  152. package/dist/middleware/reflect/cross-group-link.js.map +1 -0
  153. package/dist/middleware/reflect/dedup-by-source-and-value.d.ts +70 -0
  154. package/dist/middleware/reflect/dedup-by-source-and-value.d.ts.map +1 -0
  155. package/dist/middleware/reflect/dedup-by-source-and-value.js +0 -0
  156. package/dist/middleware/reflect/dedup-by-source-and-value.js.map +1 -0
  157. package/dist/middleware/reflect/describe-roots.d.ts +58 -0
  158. package/dist/middleware/reflect/describe-roots.d.ts.map +1 -0
  159. package/dist/middleware/reflect/describe-roots.js +266 -0
  160. package/dist/middleware/reflect/describe-roots.js.map +1 -0
  161. package/dist/middleware/reflect/flag-reviewer-startup.d.ts +16 -0
  162. package/dist/middleware/reflect/flag-reviewer-startup.d.ts.map +1 -0
  163. package/dist/middleware/reflect/flag-reviewer-startup.js +107 -0
  164. package/dist/middleware/reflect/flag-reviewer-startup.js.map +1 -0
  165. package/dist/middleware/reflect/flag-reviewer.d.ts +69 -0
  166. package/dist/middleware/reflect/flag-reviewer.d.ts.map +1 -0
  167. package/dist/middleware/reflect/flag-reviewer.js +520 -0
  168. package/dist/middleware/reflect/flag-reviewer.js.map +1 -0
  169. package/dist/middleware/reflect/inline-dedup.d.ts +26 -0
  170. package/dist/middleware/reflect/inline-dedup.d.ts.map +1 -0
  171. package/dist/middleware/reflect/inline-dedup.js +131 -0
  172. package/dist/middleware/reflect/inline-dedup.js.map +1 -0
  173. package/dist/middleware/reflect/justify-decisions.d.ts +37 -0
  174. package/dist/middleware/reflect/justify-decisions.d.ts.map +1 -0
  175. package/dist/middleware/reflect/justify-decisions.js +159 -0
  176. package/dist/middleware/reflect/justify-decisions.js.map +1 -0
  177. package/dist/middleware/reflect/nl-accept.d.ts +35 -0
  178. package/dist/middleware/reflect/nl-accept.d.ts.map +1 -0
  179. package/dist/middleware/reflect/nl-accept.js +167 -0
  180. package/dist/middleware/reflect/nl-accept.js.map +1 -0
  181. package/dist/middleware/reflect/pass0.d.ts +20 -0
  182. package/dist/middleware/reflect/pass0.d.ts.map +1 -0
  183. package/dist/middleware/reflect/pass0.js +423 -0
  184. package/dist/middleware/reflect/pass0.js.map +1 -0
  185. package/dist/middleware/reflect/pass1.d.ts +17 -0
  186. package/dist/middleware/reflect/pass1.d.ts.map +1 -0
  187. package/dist/middleware/reflect/pass1.js +241 -0
  188. package/dist/middleware/reflect/pass1.js.map +1 -0
  189. package/dist/middleware/reflect/pass2-quarantine.d.ts +129 -0
  190. package/dist/middleware/reflect/pass2-quarantine.d.ts.map +1 -0
  191. package/dist/middleware/reflect/pass2-quarantine.js +272 -0
  192. package/dist/middleware/reflect/pass2-quarantine.js.map +1 -0
  193. package/dist/middleware/reflect/pass2-seams.d.ts +205 -0
  194. package/dist/middleware/reflect/pass2-seams.d.ts.map +1 -0
  195. package/dist/middleware/reflect/pass2-seams.js +279 -0
  196. package/dist/middleware/reflect/pass2-seams.js.map +1 -0
  197. package/dist/middleware/reflect/pass2-split-orchestrator.d.ts +37 -0
  198. package/dist/middleware/reflect/pass2-split-orchestrator.d.ts.map +1 -0
  199. package/dist/middleware/reflect/pass2-split-orchestrator.js +531 -0
  200. package/dist/middleware/reflect/pass2-split-orchestrator.js.map +1 -0
  201. package/dist/middleware/reflect/pass2.d.ts +17 -0
  202. package/dist/middleware/reflect/pass2.d.ts.map +1 -0
  203. package/dist/middleware/reflect/pass2.js +324 -0
  204. package/dist/middleware/reflect/pass2.js.map +1 -0
  205. package/dist/middleware/reflect/pass2a.d.ts +141 -0
  206. package/dist/middleware/reflect/pass2a.d.ts.map +1 -0
  207. package/dist/middleware/reflect/pass2a.js +404 -0
  208. package/dist/middleware/reflect/pass2a.js.map +1 -0
  209. package/dist/middleware/reflect/pass2b.d.ts +108 -0
  210. package/dist/middleware/reflect/pass2b.d.ts.map +1 -0
  211. package/dist/middleware/reflect/pass2b.js +480 -0
  212. package/dist/middleware/reflect/pass2b.js.map +1 -0
  213. package/dist/middleware/reflect/pass2c.d.ts +113 -0
  214. package/dist/middleware/reflect/pass2c.d.ts.map +1 -0
  215. package/dist/middleware/reflect/pass2c.js +360 -0
  216. package/dist/middleware/reflect/pass2c.js.map +1 -0
  217. package/dist/middleware/reflect/pass3-batch.d.ts +62 -0
  218. package/dist/middleware/reflect/pass3-batch.d.ts.map +1 -0
  219. package/dist/middleware/reflect/pass3-batch.js +139 -0
  220. package/dist/middleware/reflect/pass3-batch.js.map +1 -0
  221. package/dist/middleware/reflect/pass3.d.ts +23 -0
  222. package/dist/middleware/reflect/pass3.d.ts.map +1 -0
  223. package/dist/middleware/reflect/pass3.js +371 -0
  224. package/dist/middleware/reflect/pass3.js.map +1 -0
  225. package/dist/middleware/reflect/pass4-slice.d.ts +25 -0
  226. package/dist/middleware/reflect/pass4-slice.d.ts.map +1 -0
  227. package/dist/middleware/reflect/pass4-slice.js +315 -0
  228. package/dist/middleware/reflect/pass4-slice.js.map +1 -0
  229. package/dist/middleware/reflect/pass4.d.ts +30 -0
  230. package/dist/middleware/reflect/pass4.d.ts.map +1 -0
  231. package/dist/middleware/reflect/pass4.js +193 -0
  232. package/dist/middleware/reflect/pass4.js.map +1 -0
  233. package/dist/middleware/reflect/pass5.d.ts +22 -0
  234. package/dist/middleware/reflect/pass5.d.ts.map +1 -0
  235. package/dist/middleware/reflect/pass5.js +178 -0
  236. package/dist/middleware/reflect/pass5.js.map +1 -0
  237. package/dist/middleware/reflect/pass_judge.d.ts +44 -0
  238. package/dist/middleware/reflect/pass_judge.d.ts.map +1 -0
  239. package/dist/middleware/reflect/pass_judge.js +263 -0
  240. package/dist/middleware/reflect/pass_judge.js.map +1 -0
  241. package/dist/middleware/reflect/pipeline-flags.d.ts +140 -0
  242. package/dist/middleware/reflect/pipeline-flags.d.ts.map +1 -0
  243. package/dist/middleware/reflect/pipeline-flags.js +314 -0
  244. package/dist/middleware/reflect/pipeline-flags.js.map +1 -0
  245. package/dist/middleware/reflect/pipeline.d.ts +237 -0
  246. package/dist/middleware/reflect/pipeline.d.ts.map +1 -0
  247. package/dist/middleware/reflect/pipeline.js +3114 -0
  248. package/dist/middleware/reflect/pipeline.js.map +1 -0
  249. package/dist/middleware/reflect/promptOverride.d.ts +14 -0
  250. package/dist/middleware/reflect/promptOverride.d.ts.map +1 -0
  251. package/dist/middleware/reflect/promptOverride.js +28 -0
  252. package/dist/middleware/reflect/promptOverride.js.map +1 -0
  253. package/dist/middleware/reflect/provenance-check.d.ts +48 -0
  254. package/dist/middleware/reflect/provenance-check.d.ts.map +1 -0
  255. package/dist/middleware/reflect/provenance-check.js +180 -0
  256. package/dist/middleware/reflect/provenance-check.js.map +1 -0
  257. package/dist/middleware/reflect/provenance-reviewer.d.ts +52 -0
  258. package/dist/middleware/reflect/provenance-reviewer.d.ts.map +1 -0
  259. package/dist/middleware/reflect/provenance-reviewer.js +253 -0
  260. package/dist/middleware/reflect/provenance-reviewer.js.map +1 -0
  261. package/dist/middleware/reflect/prune-collapsed-types.d.ts +11 -0
  262. package/dist/middleware/reflect/prune-collapsed-types.d.ts.map +1 -0
  263. package/dist/middleware/reflect/prune-collapsed-types.js +32 -0
  264. package/dist/middleware/reflect/prune-collapsed-types.js.map +1 -0
  265. package/dist/middleware/reflect/recognize-root.d.ts +75 -0
  266. package/dist/middleware/reflect/recognize-root.d.ts.map +1 -0
  267. package/dist/middleware/reflect/recognize-root.js +204 -0
  268. package/dist/middleware/reflect/recognize-root.js.map +1 -0
  269. package/dist/middleware/reflect/render-agent-flag.d.ts +25 -0
  270. package/dist/middleware/reflect/render-agent-flag.d.ts.map +1 -0
  271. package/dist/middleware/reflect/render-agent-flag.js +39 -0
  272. package/dist/middleware/reflect/render-agent-flag.js.map +1 -0
  273. package/dist/middleware/reflect/retrieve-graph-slice.d.ts +54 -0
  274. package/dist/middleware/reflect/retrieve-graph-slice.d.ts.map +1 -0
  275. package/dist/middleware/reflect/retrieve-graph-slice.js +173 -0
  276. package/dist/middleware/reflect/retrieve-graph-slice.js.map +1 -0
  277. package/dist/middleware/reflect/root-relatedness.d.ts +31 -0
  278. package/dist/middleware/reflect/root-relatedness.d.ts.map +1 -0
  279. package/dist/middleware/reflect/root-relatedness.js +92 -0
  280. package/dist/middleware/reflect/root-relatedness.js.map +1 -0
  281. package/dist/middleware/reflect/schema-heal.d.ts +22 -0
  282. package/dist/middleware/reflect/schema-heal.d.ts.map +1 -0
  283. package/dist/middleware/reflect/schema-heal.js +119 -0
  284. package/dist/middleware/reflect/schema-heal.js.map +1 -0
  285. package/dist/middleware/reflect/schema-validator.d.ts +85 -0
  286. package/dist/middleware/reflect/schema-validator.d.ts.map +1 -0
  287. package/dist/middleware/reflect/schema-validator.js +196 -0
  288. package/dist/middleware/reflect/schema-validator.js.map +1 -0
  289. package/dist/middleware/reflect/stage-audit-graph.d.ts +115 -0
  290. package/dist/middleware/reflect/stage-audit-graph.d.ts.map +1 -0
  291. package/dist/middleware/reflect/stage-audit-graph.js +563 -0
  292. package/dist/middleware/reflect/stage-audit-graph.js.map +1 -0
  293. package/dist/middleware/reflect/stage-d-resolve-graph.d.ts +87 -0
  294. package/dist/middleware/reflect/stage-d-resolve-graph.d.ts.map +1 -0
  295. package/dist/middleware/reflect/stage-d-resolve-graph.js +256 -0
  296. package/dist/middleware/reflect/stage-d-resolve-graph.js.map +1 -0
  297. package/dist/middleware/reflect/synthesizeFromSceneCard.d.ts +15 -0
  298. package/dist/middleware/reflect/synthesizeFromSceneCard.d.ts.map +1 -0
  299. package/dist/middleware/reflect/synthesizeFromSceneCard.js +91 -0
  300. package/dist/middleware/reflect/synthesizeFromSceneCard.js.map +1 -0
  301. package/dist/middleware/reflect/types.d.ts +261 -0
  302. package/dist/middleware/reflect/types.d.ts.map +1 -0
  303. package/dist/middleware/reflect/types.js +3 -0
  304. package/dist/middleware/reflect/types.js.map +1 -0
  305. package/dist/middleware/reflect/v2-integrate.d.ts +120 -0
  306. package/dist/middleware/reflect/v2-integrate.d.ts.map +1 -0
  307. package/dist/middleware/reflect/v2-integrate.js +388 -0
  308. package/dist/middleware/reflect/v2-integrate.js.map +1 -0
  309. package/dist/middleware/reflect/v2-judge.d.ts +44 -0
  310. package/dist/middleware/reflect/v2-judge.d.ts.map +1 -0
  311. package/dist/middleware/reflect/v2-judge.js +191 -0
  312. package/dist/middleware/reflect/v2-judge.js.map +1 -0
  313. package/dist/relation-sets.d.ts +2 -0
  314. package/dist/relation-sets.d.ts.map +1 -0
  315. package/dist/relation-sets.js +35 -0
  316. package/dist/relation-sets.js.map +1 -0
  317. package/dist/routes/__tests__/flags.test.d.ts +2 -0
  318. package/dist/routes/__tests__/flags.test.d.ts.map +1 -0
  319. package/dist/routes/__tests__/flags.test.js +257 -0
  320. package/dist/routes/__tests__/flags.test.js.map +1 -0
  321. package/dist/routes/__tests__/models-catalog.test.d.ts +2 -0
  322. package/dist/routes/__tests__/models-catalog.test.d.ts.map +1 -0
  323. package/dist/routes/__tests__/models-catalog.test.js +130 -0
  324. package/dist/routes/__tests__/models-catalog.test.js.map +1 -0
  325. package/dist/routes/__tests__/reflect-pause-drain.test.d.ts +2 -0
  326. package/dist/routes/__tests__/reflect-pause-drain.test.d.ts.map +1 -0
  327. package/dist/routes/__tests__/reflect-pause-drain.test.js +38 -0
  328. package/dist/routes/__tests__/reflect-pause-drain.test.js.map +1 -0
  329. package/dist/routes/__tests__/spend-pause-drain.test.d.ts +2 -0
  330. package/dist/routes/__tests__/spend-pause-drain.test.d.ts.map +1 -0
  331. package/dist/routes/__tests__/spend-pause-drain.test.js +38 -0
  332. package/dist/routes/__tests__/spend-pause-drain.test.js.map +1 -0
  333. package/dist/routes/admin.d.ts +49 -0
  334. package/dist/routes/admin.d.ts.map +1 -0
  335. package/dist/routes/admin.js +471 -0
  336. package/dist/routes/admin.js.map +1 -0
  337. package/dist/routes/blocks.d.ts +4 -0
  338. package/dist/routes/blocks.d.ts.map +1 -0
  339. package/dist/routes/blocks.js +893 -0
  340. package/dist/routes/blocks.js.map +1 -0
  341. package/dist/routes/chat-proxy.d.ts +5 -0
  342. package/dist/routes/chat-proxy.d.ts.map +1 -0
  343. package/dist/routes/chat-proxy.js +225 -0
  344. package/dist/routes/chat-proxy.js.map +1 -0
  345. package/dist/routes/conversations.d.ts +4 -0
  346. package/dist/routes/conversations.d.ts.map +1 -0
  347. package/dist/routes/conversations.js +139 -0
  348. package/dist/routes/conversations.js.map +1 -0
  349. package/dist/routes/flags.d.ts +4 -0
  350. package/dist/routes/flags.d.ts.map +1 -0
  351. package/dist/routes/flags.js +151 -0
  352. package/dist/routes/flags.js.map +1 -0
  353. package/dist/routes/inject.d.ts +4 -0
  354. package/dist/routes/inject.d.ts.map +1 -0
  355. package/dist/routes/inject.js +183 -0
  356. package/dist/routes/inject.js.map +1 -0
  357. package/dist/routes/mcp-http.d.ts +5 -0
  358. package/dist/routes/mcp-http.d.ts.map +1 -0
  359. package/dist/routes/mcp-http.js +94 -0
  360. package/dist/routes/mcp-http.js.map +1 -0
  361. package/dist/routes/quarantine.d.ts +4 -0
  362. package/dist/routes/quarantine.d.ts.map +1 -0
  363. package/dist/routes/quarantine.js +66 -0
  364. package/dist/routes/quarantine.js.map +1 -0
  365. package/dist/routes/recall.d.ts +5 -0
  366. package/dist/routes/recall.d.ts.map +1 -0
  367. package/dist/routes/recall.js +573 -0
  368. package/dist/routes/recall.js.map +1 -0
  369. package/dist/routes/reflect.d.ts +5 -0
  370. package/dist/routes/reflect.d.ts.map +1 -0
  371. package/dist/routes/reflect.js +231 -0
  372. package/dist/routes/reflect.js.map +1 -0
  373. package/dist/routes/session.d.ts +4 -0
  374. package/dist/routes/session.d.ts.map +1 -0
  375. package/dist/routes/session.js +418 -0
  376. package/dist/routes/session.js.map +1 -0
  377. package/dist/routes/state.d.ts +116 -0
  378. package/dist/routes/state.d.ts.map +1 -0
  379. package/dist/routes/state.js +621 -0
  380. package/dist/routes/state.js.map +1 -0
  381. package/dist/routes/usage.d.ts +3 -0
  382. package/dist/routes/usage.d.ts.map +1 -0
  383. package/dist/routes/usage.js +141 -0
  384. package/dist/routes/usage.js.map +1 -0
  385. package/dist/routes/workspace.d.ts +5 -0
  386. package/dist/routes/workspace.d.ts.map +1 -0
  387. package/dist/routes/workspace.js +435 -0
  388. package/dist/routes/workspace.js.map +1 -0
  389. package/dist/server.d.ts +13 -0
  390. package/dist/server.d.ts.map +1 -0
  391. package/dist/server.js +298 -0
  392. package/dist/server.js.map +1 -0
  393. package/dist/store/__tests__/backup.test.d.ts +2 -0
  394. package/dist/store/__tests__/backup.test.d.ts.map +1 -0
  395. package/dist/store/__tests__/backup.test.js +53 -0
  396. package/dist/store/__tests__/backup.test.js.map +1 -0
  397. package/dist/store/__tests__/quality.test.d.ts +2 -0
  398. package/dist/store/__tests__/quality.test.d.ts.map +1 -0
  399. package/dist/store/__tests__/quality.test.js +75 -0
  400. package/dist/store/__tests__/quality.test.js.map +1 -0
  401. package/dist/store/backup.d.ts +14 -0
  402. package/dist/store/backup.d.ts.map +1 -0
  403. package/dist/store/backup.js +95 -0
  404. package/dist/store/backup.js.map +1 -0
  405. package/dist/store/database.d.ts +407 -0
  406. package/dist/store/database.d.ts.map +1 -0
  407. package/dist/store/database.js +2004 -0
  408. package/dist/store/database.js.map +1 -0
  409. package/dist/store/quality.d.ts +25 -0
  410. package/dist/store/quality.d.ts.map +1 -0
  411. package/dist/store/quality.js +48 -0
  412. package/dist/store/quality.js.map +1 -0
  413. package/dist/tools/__tests__/assemble-block-chains.test.d.ts +2 -0
  414. package/dist/tools/__tests__/assemble-block-chains.test.d.ts.map +1 -0
  415. package/dist/tools/__tests__/assemble-block-chains.test.js +118 -0
  416. package/dist/tools/__tests__/assemble-block-chains.test.js.map +1 -0
  417. package/dist/tools/__tests__/filter-roots-by-concepts.test.d.ts +2 -0
  418. package/dist/tools/__tests__/filter-roots-by-concepts.test.d.ts.map +1 -0
  419. package/dist/tools/__tests__/filter-roots-by-concepts.test.js +68 -0
  420. package/dist/tools/__tests__/filter-roots-by-concepts.test.js.map +1 -0
  421. package/dist/tools/__tests__/flag-surface.test.d.ts +2 -0
  422. package/dist/tools/__tests__/flag-surface.test.d.ts.map +1 -0
  423. package/dist/tools/__tests__/flag-surface.test.js +130 -0
  424. package/dist/tools/__tests__/flag-surface.test.js.map +1 -0
  425. package/dist/tools/core.d.ts +5 -0
  426. package/dist/tools/core.d.ts.map +1 -0
  427. package/dist/tools/core.js +962 -0
  428. package/dist/tools/core.js.map +1 -0
  429. package/dist/tools/derive.d.ts +5 -0
  430. package/dist/tools/derive.d.ts.map +1 -0
  431. package/dist/tools/derive.js +182 -0
  432. package/dist/tools/derive.js.map +1 -0
  433. package/dist/tools/flag-surface.d.ts +26 -0
  434. package/dist/tools/flag-surface.d.ts.map +1 -0
  435. package/dist/tools/flag-surface.js +59 -0
  436. package/dist/tools/flag-surface.js.map +1 -0
  437. package/dist/tools/helpers.d.ts +99 -0
  438. package/dist/tools/helpers.d.ts.map +1 -0
  439. package/dist/tools/helpers.js +243 -0
  440. package/dist/tools/helpers.js.map +1 -0
  441. package/dist/tools/projects.d.ts +5 -0
  442. package/dist/tools/projects.d.ts.map +1 -0
  443. package/dist/tools/projects.js +175 -0
  444. package/dist/tools/projects.js.map +1 -0
  445. package/dist/tools/system.d.ts +5 -0
  446. package/dist/tools/system.d.ts.map +1 -0
  447. package/dist/tools/system.js +1361 -0
  448. package/dist/tools/system.js.map +1 -0
  449. package/dist/tools/tasks.d.ts +5 -0
  450. package/dist/tools/tasks.d.ts.map +1 -0
  451. package/dist/tools/tasks.js +289 -0
  452. package/dist/tools/tasks.js.map +1 -0
  453. package/package.json +69 -0
  454. package/scripts/nodedex-entry.mjs +396 -0
  455. package/tui-dist/App.js +185 -0
  456. package/tui-dist/api.js +197 -0
  457. package/tui-dist/cli.js +53 -0
  458. package/tui-dist/components.js +63 -0
  459. package/tui-dist/config.js +242 -0
  460. package/tui-dist/connect-snippets.js +98 -0
  461. package/tui-dist/feed.js +51 -0
  462. package/tui-dist/health.js +465 -0
  463. package/tui-dist/hooks.js +23 -0
  464. package/tui-dist/memory.js +220 -0
  465. package/tui-dist/onboarding.js +498 -0
  466. package/tui-dist/review.js +193 -0
  467. package/tui-dist/servers.js +556 -0
  468. package/tui-dist/smoke.js +15 -0
  469. package/tui-dist/theme.js +106 -0
@@ -0,0 +1,2004 @@
1
+ import Database from "better-sqlite3";
2
+ import { cosineSim } from "../engine/vector-math.js";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import CryptoJS from "crypto-js";
7
+ // ─── Database ────────────────────────────────────────────────────
8
+ export class WorkspaceDB {
9
+ db = null;
10
+ dbPath;
11
+ constructor(dbPath) {
12
+ this.dbPath = dbPath
13
+ || process.env.WORKSPACE_DB_PATH
14
+ || path.resolve(__dirname, "../../../data/workspace.db");
15
+ }
16
+ // Kept async for API compatibility with server.ts callers
17
+ async init() {
18
+ const dir = path.dirname(this.dbPath);
19
+ if (!fs.existsSync(dir))
20
+ fs.mkdirSync(dir, { recursive: true });
21
+ this.db = new Database(this.dbPath);
22
+ // Encryption at rest (security slice 2). OPT-IN: when NODEDEX_DB_ENCRYPTION_KEY
23
+ // is set, key the connection (SQLCipher via better-sqlite3-multiple-ciphers)
24
+ // BEFORE any other statement — the PRAGMA key must come first. Unset = plaintext
25
+ // (the default, unchanged). File-level (whole DB encrypted on disk, decrypted in
26
+ // memory) so keyword search + embeddings still work — unlike the per-block
27
+ // WORKSPACE_ENCRYPTION_KEY layer, which encrypts individual fields. An EXISTING
28
+ // plaintext DB must be migrated first (scripts/db-encryption.mjs); opening a
29
+ // plaintext DB WITH a key is caught below and fails loudly rather than corrupting.
30
+ const encKey = process.env.NODEDEX_DB_ENCRYPTION_KEY;
31
+ if (encKey && encKey.length > 0) {
32
+ this.db.pragma(`key = '${encKey.replace(/'/g, "''")}'`);
33
+ try {
34
+ this.db.prepare("SELECT count(*) FROM sqlite_master").get();
35
+ }
36
+ catch {
37
+ throw new Error("NODEDEX_DB_ENCRYPTION_KEY is set but the database could not be opened with it. " +
38
+ "The DB is likely plaintext (migrate it: `node scripts/db-encryption.mjs encrypt <db-path>`) " +
39
+ "or the key is wrong.");
40
+ }
41
+ }
42
+ // WAL mode: faster writes, allows concurrent readers
43
+ this.db.pragma("journal_mode = WAL");
44
+ this.db.pragma("foreign_keys = ON");
45
+ this.createTables();
46
+ this.runMigrations();
47
+ }
48
+ // No-op: better-sqlite3 writes directly to disk on every statement
49
+ save() { }
50
+ /**
51
+ * Close the underlying connection, releasing the DB file lock. Used on graceful
52
+ * shutdown so a restart doesn't fight a lingering handle (the stale-process trap:
53
+ * a zombie server holding workspace.db made it un-deletable / un-swappable).
54
+ * Idempotent.
55
+ */
56
+ close() {
57
+ try {
58
+ this.db?.close();
59
+ }
60
+ catch { /* already closed */ }
61
+ this.db = null;
62
+ }
63
+ /**
64
+ * Raw better-sqlite3 Database accessor. Added 2026-05-25 to allow new
65
+ * middleware tables (e.g. `pass2_audit_quarantine` from the Pass 2 split
66
+ * orchestrator) to manage their own schema without going through every
67
+ * WorkspaceDB method. Use sparingly — direct access bypasses the wrapper's
68
+ * encryption + status conventions, so it's appropriate only for tables
69
+ * the wrapper does not own.
70
+ */
71
+ get rawDb() {
72
+ if (!this.db)
73
+ throw new Error("Database not initialized — call init() first");
74
+ return this.db;
75
+ }
76
+ // ─── Cryptography ────────────────────────────────────────────────
77
+ getEncryptionKey() {
78
+ const key = process.env.WORKSPACE_ENCRYPTION_KEY;
79
+ if (!key) {
80
+ throw new Error("WORKSPACE_ENCRYPTION_KEY environment variable is required to save or read sensitive blocks.");
81
+ }
82
+ return key;
83
+ }
84
+ encryptText(text) {
85
+ return CryptoJS.AES.encrypt(text, this.getEncryptionKey()).toString();
86
+ }
87
+ decryptText(ciphertext) {
88
+ const bytes = CryptoJS.AES.decrypt(ciphertext, this.getEncryptionKey());
89
+ return bytes.toString(CryptoJS.enc.Utf8);
90
+ }
91
+ createTables() {
92
+ if (!this.db)
93
+ throw new Error("Database not initialized");
94
+ this.db.exec(`
95
+ CREATE TABLE IF NOT EXISTS blocks (
96
+ id TEXT PRIMARY KEY,
97
+ label TEXT NOT NULL,
98
+ type TEXT NOT NULL DEFAULT 'note',
99
+ status TEXT NOT NULL DEFAULT 'created',
100
+ ttl TEXT NOT NULL DEFAULT 'permanent',
101
+ project_id TEXT,
102
+ essence TEXT NOT NULL DEFAULT '',
103
+ content TEXT NOT NULL DEFAULT '{}',
104
+ source TEXT,
105
+ created_by TEXT,
106
+ created_at TEXT NOT NULL,
107
+ updated_at TEXT NOT NULL,
108
+ last_accessed TEXT NOT NULL,
109
+ access_count INTEGER NOT NULL DEFAULT 0,
110
+ concepts TEXT NOT NULL DEFAULT '[]',
111
+ aliases TEXT NOT NULL DEFAULT '[]',
112
+ embedding TEXT,
113
+ is_sensitive INTEGER NOT NULL DEFAULT 0
114
+ )
115
+ `);
116
+ this.db.exec(`
117
+ CREATE TABLE IF NOT EXISTS relations (
118
+ id TEXT PRIMARY KEY,
119
+ source_id TEXT NOT NULL,
120
+ target_id TEXT NOT NULL,
121
+ type TEXT NOT NULL,
122
+ bidirectional INTEGER NOT NULL DEFAULT 0,
123
+ created_by TEXT,
124
+ created_at TEXT NOT NULL,
125
+ status TEXT NOT NULL DEFAULT 'active',
126
+ FOREIGN KEY (source_id) REFERENCES blocks(id),
127
+ FOREIGN KEY (target_id) REFERENCES blocks(id)
128
+ )
129
+ `);
130
+ this.db.exec(`
131
+ CREATE TABLE IF NOT EXISTS block_history (
132
+ id TEXT PRIMARY KEY,
133
+ block_id TEXT NOT NULL,
134
+ field_changed TEXT NOT NULL,
135
+ old_value TEXT,
136
+ new_value TEXT,
137
+ changed_by TEXT,
138
+ changed_at TEXT NOT NULL,
139
+ reason TEXT,
140
+ FOREIGN KEY (block_id) REFERENCES blocks(id)
141
+ )
142
+ `);
143
+ this.db.exec(`
144
+ CREATE TABLE IF NOT EXISTS block_types (
145
+ name TEXT PRIMARY KEY,
146
+ extends TEXT NOT NULL,
147
+ description TEXT NOT NULL,
148
+ typical_fields TEXT NOT NULL DEFAULT '[]'
149
+ )
150
+ `);
151
+ this.db.exec(`
152
+ CREATE TABLE IF NOT EXISTS relation_types (
153
+ name TEXT PRIMARY KEY,
154
+ inverse TEXT,
155
+ description TEXT NOT NULL
156
+ )
157
+ `);
158
+ this.db.exec(`
159
+ CREATE TABLE IF NOT EXISTS project_logs (
160
+ id TEXT PRIMARY KEY,
161
+ project TEXT NOT NULL,
162
+ entry TEXT NOT NULL,
163
+ created_at TEXT NOT NULL
164
+ )
165
+ `);
166
+ this.db.exec(`
167
+ CREATE TABLE IF NOT EXISTS recall_log (
168
+ id TEXT PRIMARY KEY,
169
+ block_id TEXT NOT NULL,
170
+ recalled_at TEXT NOT NULL,
171
+ project_id TEXT,
172
+ reason TEXT,
173
+ used INTEGER NOT NULL DEFAULT 0,
174
+ FOREIGN KEY (block_id) REFERENCES blocks(id)
175
+ )
176
+ `);
177
+ this.db.exec(`
178
+ CREATE TABLE IF NOT EXISTS near_duplicate_conflicts (
179
+ id TEXT PRIMARY KEY,
180
+ block_a_id TEXT NOT NULL,
181
+ block_b_id TEXT NOT NULL,
182
+ similarity REAL NOT NULL,
183
+ detected_at TEXT NOT NULL,
184
+ resolved INTEGER NOT NULL DEFAULT 0,
185
+ resolution TEXT,
186
+ FOREIGN KEY (block_a_id) REFERENCES blocks(id),
187
+ FOREIGN KEY (block_b_id) REFERENCES blocks(id)
188
+ )
189
+ `);
190
+ // Agent registry — tracks live agents with heartbeat
191
+ this.db.exec(`
192
+ CREATE TABLE IF NOT EXISTS agent_registry (
193
+ agent_id TEXT PRIMARY KEY,
194
+ role TEXT NOT NULL DEFAULT 'general',
195
+ last_heartbeat TEXT NOT NULL,
196
+ status TEXT NOT NULL DEFAULT 'active',
197
+ current_task TEXT,
198
+ metadata TEXT NOT NULL DEFAULT '{}'
199
+ )
200
+ `);
201
+ // Block claims — separate table for atomic, auditable ownership
202
+ this.db.exec(`
203
+ CREATE TABLE IF NOT EXISTS block_claims (
204
+ block_id TEXT PRIMARY KEY,
205
+ agent_id TEXT NOT NULL,
206
+ claimed_at TEXT NOT NULL,
207
+ expires_at TEXT NOT NULL,
208
+ FOREIGN KEY (block_id) REFERENCES blocks(id)
209
+ )
210
+ `);
211
+ // ── Write audit log — captures ALL writes regardless of path (tool, API, or direct SQL) ──
212
+ this.db.exec(`
213
+ CREATE TABLE IF NOT EXISTS write_log (
214
+ id TEXT PRIMARY KEY,
215
+ table_name TEXT NOT NULL,
216
+ operation TEXT NOT NULL,
217
+ row_id TEXT NOT NULL,
218
+ snapshot TEXT,
219
+ changed_at TEXT NOT NULL
220
+ )
221
+ `);
222
+ // SQLite triggers — fire on every INSERT/UPDATE to blocks and relations
223
+ this.db.exec(`
224
+ CREATE TRIGGER IF NOT EXISTS wlog_block_insert
225
+ AFTER INSERT ON blocks
226
+ BEGIN
227
+ INSERT INTO write_log (id, table_name, operation, row_id, snapshot, changed_at)
228
+ VALUES (lower(hex(randomblob(8))), 'blocks', 'INSERT', NEW.id,
229
+ json_object('label', NEW.label, 'type', NEW.type, 'essence', NEW.essence),
230
+ datetime('now'));
231
+ END
232
+ `);
233
+ // wlog_block_update is managed in runMigrations() so it can be rebuilt when the schema changes
234
+ this.db.exec(`
235
+ CREATE TRIGGER IF NOT EXISTS wlog_relation_insert
236
+ AFTER INSERT ON relations
237
+ BEGIN
238
+ INSERT INTO write_log (id, table_name, operation, row_id, snapshot, changed_at)
239
+ VALUES (lower(hex(randomblob(8))), 'relations', 'INSERT', NEW.id,
240
+ json_object('source_id', NEW.source_id, 'target_id', NEW.target_id, 'type', NEW.type),
241
+ datetime('now'));
242
+ END
243
+ `);
244
+ // Reflect job queue — persists jobs across server restarts and Gemini outages
245
+ this.db.exec(`
246
+ CREATE TABLE IF NOT EXISTS reflect_jobs (
247
+ id TEXT PRIMARY KEY,
248
+ status TEXT NOT NULL DEFAULT 'pending',
249
+ agent_id TEXT,
250
+ payload TEXT NOT NULL,
251
+ precomputed TEXT,
252
+ retry_attempts INTEGER NOT NULL DEFAULT 0,
253
+ retry_after INTEGER,
254
+ created_at INTEGER NOT NULL,
255
+ updated_at INTEGER NOT NULL,
256
+ error TEXT
257
+ )
258
+ `);
259
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_reflect_jobs_status ON reflect_jobs(status)`);
260
+ // ─── DEBT 5 Phase 1: arc-extraction persistence layer ─────────────────────
261
+ //
262
+ // conversation_turns: per-turn transcript + pass 0-1 intermediate state.
263
+ // INSERT on Stop event (status='captured'), UPDATE after per-turn Pass 0-1
264
+ // completes (status='pass01_done'), UPDATE on arc-extract completion
265
+ // (status='extracted', pairing_range_id set). Per Variant A: NO graph
266
+ // blocks are written per-turn; only at arc time.
267
+ //
268
+ // Naming per inventory §5/§10 (GATE 5): agent_id matches existing
269
+ // convention (agent_registry, agent_session_state_<id>, reflect-buffer,
270
+ // ReflectJob, chat-proxy x-nodedex-agent-id header).
271
+ //
272
+ // Charter Rule 2 (never delete): rows are forward-only-archived; even
273
+ // re-extraction creates NEW conversation_turn_ranges rather than mutating
274
+ // pairing_range_id. See design §2.5.
275
+ this.db.exec(`
276
+ CREATE TABLE IF NOT EXISTS conversation_turns (
277
+ id TEXT PRIMARY KEY,
278
+ agent_id TEXT NOT NULL,
279
+ turn_number INTEGER NOT NULL,
280
+ turn_name TEXT,
281
+ transcript_json TEXT NOT NULL,
282
+ pass01_output_json TEXT,
283
+ pass01_completed_at TEXT,
284
+ status TEXT NOT NULL DEFAULT 'captured',
285
+ created_at TEXT NOT NULL,
286
+ extracted_at TEXT,
287
+ pairing_range_id TEXT,
288
+ UNIQUE (agent_id, turn_number)
289
+ )
290
+ `);
291
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conv_turns_agent ON conversation_turns(agent_id)`);
292
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conv_turns_status ON conversation_turns(status)`);
293
+ // conversation_turn_ranges: extraction-batch entity. One row per arc-or-
294
+ // re-extract event (atomic per-turn extractions don't create rows — they
295
+ // are implicit). Per design §2.2.
296
+ this.db.exec(`
297
+ CREATE TABLE IF NOT EXISTS conversation_turn_ranges (
298
+ id TEXT PRIMARY KEY,
299
+ agent_id TEXT NOT NULL,
300
+ start_turn_number INTEGER NOT NULL,
301
+ end_turn_number INTEGER NOT NULL,
302
+ extraction_type TEXT NOT NULL,
303
+ extracted_at TEXT NOT NULL,
304
+ trigger_source TEXT,
305
+ pipeline_run_id TEXT,
306
+ superseded_range_id TEXT
307
+ )
308
+ `);
309
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_ctr_agent ON conversation_turn_ranges(agent_id)`);
310
+ // block_extractions: provenance join. Each arc-extracted block points back
311
+ // to the conversation_turn_ranges row it was emitted from. Per design §2.3
312
+ // the conceptual relation was `extracted_from`; we use a dedicated join
313
+ // table because relations.target_id has a FK to blocks(id) and a turn-range
314
+ // is not a block (it COULD become one per §2.4-deferred, but for Phase 9
315
+ // the join table is cheaper and queryable). The 'extracted_from' relation
316
+ // type seed (Phase 1) remains for the future when ranges become blocks —
317
+ // dropping it would lose seed continuity.
318
+ //
319
+ // Cardinality: one row per (block, range) pair. A re-extract creates a NEW
320
+ // range; the original block→range row stays (audit trail) and a new
321
+ // block→new_range row joins via the same block_id (multi-row per block).
322
+ // Query for "which range produced this block" returns the most recent row.
323
+ this.db.exec(`
324
+ CREATE TABLE IF NOT EXISTS block_extractions (
325
+ id TEXT PRIMARY KEY,
326
+ block_id TEXT NOT NULL,
327
+ range_id TEXT NOT NULL,
328
+ extracted_at TEXT NOT NULL,
329
+ UNIQUE (block_id, range_id),
330
+ FOREIGN KEY (block_id) REFERENCES blocks(id)
331
+ )
332
+ `);
333
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_block_extractions_block ON block_extractions(block_id)`);
334
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_block_extractions_range ON block_extractions(range_id)`);
335
+ // DEBT 5 Slice 1 (Sub-step 1.1) — pipeline_flags: durable flag persistence
336
+ // for the Stage FLAG mechanism. Replaces auto-drop semantics in
337
+ // dedupBySourceAndValue (per user direction: system FLAGS, reasoning MERGES).
338
+ //
339
+ // Multi-writer table: Stage FLAG (Sub-step 1.4), Stage AUDIT (Slice 2),
340
+ // Stage C entity-unresolved (Sub-step 1.2). Consumed by async LLM reviewer
341
+ // (Slice 2). origin_writer column discriminates source.
342
+ //
343
+ // criteria_json + review/action fields stored as TEXT so flag_type families
344
+ // can evolve without per-field column adds (pass2_audit_quarantine pattern).
345
+ //
346
+ // FK to conversation_turn_ranges optional (origin_range_id NULL for non-arc
347
+ // sources like Stage AUDIT background scan).
348
+ //
349
+ // No writes yet in Sub-step 1.1 — table exists empty. Sub-step 1.4 wires
350
+ // Stage FLAG to call writePipelineFlag (pipeline-flags.ts module).
351
+ this.db.exec(`
352
+ CREATE TABLE IF NOT EXISTS pipeline_flags (
353
+ id TEXT PRIMARY KEY,
354
+ flag_type TEXT NOT NULL,
355
+ block_id_a TEXT NOT NULL,
356
+ block_id_b TEXT,
357
+ criteria_json TEXT NOT NULL,
358
+ scope_check TEXT NOT NULL,
359
+ origin_writer TEXT NOT NULL,
360
+ origin_range_id TEXT,
361
+ created_at TEXT NOT NULL,
362
+
363
+ reviewed_at TEXT,
364
+ review_verdict TEXT,
365
+ review_reason TEXT,
366
+
367
+ action_taken TEXT,
368
+ action_at TEXT,
369
+ winning_block_id TEXT,
370
+
371
+ FOREIGN KEY (block_id_a) REFERENCES blocks(id),
372
+ FOREIGN KEY (block_id_b) REFERENCES blocks(id),
373
+ FOREIGN KEY (origin_range_id) REFERENCES conversation_turn_ranges(id),
374
+ FOREIGN KEY (winning_block_id) REFERENCES blocks(id)
375
+ )
376
+ `);
377
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pipeline_flags_unreviewed
378
+ ON pipeline_flags(reviewed_at) WHERE reviewed_at IS NULL`);
379
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pipeline_flags_type
380
+ ON pipeline_flags(flag_type)`);
381
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pipeline_flags_block_a
382
+ ON pipeline_flags(block_id_a)`);
383
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pipeline_flags_origin_writer
384
+ ON pipeline_flags(origin_writer)`);
385
+ // Indexes for performance
386
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_blocks_label ON blocks(label)`);
387
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_blocks_type ON blocks(type)`);
388
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_blocks_status ON blocks(status)`);
389
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_source ON relations(source_id)`);
390
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_target ON relations(target_id)`);
391
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_history_block ON block_history(block_id)`);
392
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_project_logs_project ON project_logs(project)`);
393
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_write_log_table ON write_log(table_name, changed_at)`);
394
+ }
395
+ runMigrations() {
396
+ if (!this.db)
397
+ throw new Error("Database not initialized");
398
+ try {
399
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN is_sensitive INTEGER NOT NULL DEFAULT 0`);
400
+ }
401
+ catch { /* exists */ }
402
+ try {
403
+ this.db.exec(`ALTER TABLE relations ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`);
404
+ }
405
+ catch { /* exists */ }
406
+ try {
407
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN locked INTEGER NOT NULL DEFAULT 0`);
408
+ }
409
+ catch { /* exists */ }
410
+ try {
411
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN enriched_at TEXT`);
412
+ }
413
+ catch { /* exists */ }
414
+ // followup 1: failed-attempt marker on conversation_turns (non-destructive — the
415
+ // turn stays pass01_done = re-extractable; lets the freshness surface tell
416
+ // "queued/coming" from "last attempt failed").
417
+ try {
418
+ this.db.exec(`ALTER TABLE conversation_turns ADD COLUMN last_extract_error TEXT`);
419
+ }
420
+ catch { /* exists */ }
421
+ try {
422
+ this.db.exec(`ALTER TABLE conversation_turns ADD COLUMN last_extract_attempt_at TEXT`);
423
+ }
424
+ catch { /* exists */ }
425
+ try {
426
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_recall_log_block ON recall_log(block_id)`);
427
+ }
428
+ catch { /* exists */ }
429
+ try {
430
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_recall_log_used ON recall_log(used)`);
431
+ }
432
+ catch { /* exists */ }
433
+ // Bitemporal edge modeling — valid_from/valid_to on relations
434
+ try {
435
+ this.db.exec(`ALTER TABLE relations ADD COLUMN valid_from TEXT`);
436
+ }
437
+ catch { /* exists */ }
438
+ try {
439
+ this.db.exec(`ALTER TABLE relations ADD COLUMN valid_to TEXT`);
440
+ }
441
+ catch { /* exists */ }
442
+ // Back-fill valid_from for existing relations
443
+ try {
444
+ this.db.exec(`UPDATE relations SET valid_from = created_at WHERE valid_from IS NULL`);
445
+ }
446
+ catch { /* */ }
447
+ // First-class concepts column on blocks
448
+ try {
449
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN concepts TEXT NOT NULL DEFAULT '[]'`);
450
+ }
451
+ catch { /* exists */ }
452
+ // Back-fill: migrate concepts stored in content JSON into the new column
453
+ try {
454
+ const rows = this.db.prepare(`SELECT id, content FROM blocks WHERE concepts = '[]'`).all();
455
+ const upd = this.db.prepare(`UPDATE blocks SET concepts = ? WHERE id = ?`);
456
+ const migrate = this.db.transaction(() => {
457
+ for (const row of rows) {
458
+ try {
459
+ const c = JSON.parse(row.content);
460
+ const extracted = c?.concepts || [];
461
+ if (extracted.length > 0)
462
+ upd.run(JSON.stringify(extracted), row.id);
463
+ }
464
+ catch { /* skip */ }
465
+ }
466
+ });
467
+ migrate();
468
+ }
469
+ catch { /* */ }
470
+ try {
471
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_blocks_concepts ON blocks(concepts)`);
472
+ }
473
+ catch { /* exists */ }
474
+ // quality_score — persisted so recall can weight by block depth
475
+ try {
476
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN quality_score INTEGER NOT NULL DEFAULT 0`);
477
+ }
478
+ catch { /* exists */ }
479
+ // source_type — provenance enum: who/what created this block
480
+ try {
481
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN source_type TEXT NOT NULL DEFAULT 'agent_derived'`);
482
+ }
483
+ catch { /* exists */ }
484
+ // last_challenged_at — when the adversarial challenger last ran on this block
485
+ try {
486
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN last_challenged_at TEXT`);
487
+ }
488
+ catch { /* exists */ }
489
+ try {
490
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN priority TEXT`);
491
+ }
492
+ catch { /* exists */ }
493
+ try {
494
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN flow_role TEXT`);
495
+ }
496
+ catch { /* exists */ }
497
+ try {
498
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN chain_id TEXT`);
499
+ }
500
+ catch { /* exists */ }
501
+ // Repair: updateBlock used to stringify null (typeof null === "object" →
502
+ // JSON.stringify(null) = 'null'), so Pass 5's chain_id cleanup wrote the literal
503
+ // string into standalone blocks — which then read as members of one fake "null"
504
+ // chain. Only the corrupt literals are repaired; blk_/UUID/chain_ values are the
505
+ // three legitimate chain_id families and must not be touched. Idempotent.
506
+ try {
507
+ this.db.exec(`UPDATE blocks SET chain_id = NULL WHERE chain_id IN ('null', 'undefined')`);
508
+ }
509
+ catch { /* */ }
510
+ try {
511
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN review_status TEXT`);
512
+ }
513
+ catch { /* exists */ }
514
+ try {
515
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN review_reason TEXT`);
516
+ }
517
+ catch { /* exists */ }
518
+ try {
519
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN last_reflected_at TEXT`);
520
+ }
521
+ catch { /* exists */ }
522
+ try {
523
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_blocks_last_reflected ON blocks(last_reflected_at)`);
524
+ }
525
+ catch { /* exists */ }
526
+ // ─── DEBT 5 Phase 1 (D3): source_excerpt — line-level provenance ──────────
527
+ // Per design §2.3.2: each arc-extracted block carries the exact transcript
528
+ // text it was extracted from (80-600 chars, sentence-boundary truncated).
529
+ // Composes with extracted_from relation (turn-range pointer) but is
530
+ // strictly stronger (line-level vs turn-level provenance). Solves: dedup-
531
+ // by-content (D2), audit navigability, quality verification, re-extraction
532
+ // supersedes wiring, provider-variance robustness (per [[project-pass1-pass2a-
533
+ // provider-drift-2026-05-30]] — LLM-typing varies; source-pin doesn't).
534
+ //
535
+ // NULL signals "pre-Debt-5 atomic block" — dedup logic must NEVER match
536
+ // two NULL excerpts as duplicates. Index is partial (NULL excluded) to
537
+ // keep it tight; only arc-extracted blocks live in the index.
538
+ try {
539
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN source_excerpt TEXT`);
540
+ }
541
+ catch { /* exists */ }
542
+ try {
543
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_blocks_source_excerpt ON blocks(source_excerpt) WHERE source_excerpt IS NOT NULL`);
544
+ }
545
+ catch { /* exists */ }
546
+ // Unique label constraint — prevents silent duplicate rows on same label.
547
+ // Dedup first (keep newest per label), then replace non-unique index with unique one.
548
+ try {
549
+ this.db.exec(`
550
+ DELETE FROM blocks WHERE rowid NOT IN (
551
+ SELECT MAX(rowid) FROM blocks GROUP BY label
552
+ )
553
+ `);
554
+ this.db.exec(`DROP INDEX IF EXISTS idx_blocks_label`);
555
+ this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_blocks_label ON blocks(label)`);
556
+ }
557
+ catch { /* already unique or other error — leave as-is */ }
558
+ // Agent registry extensions: name + created_at for multi-agent support
559
+ try {
560
+ this.db.exec(`ALTER TABLE agent_registry ADD COLUMN name TEXT`);
561
+ }
562
+ catch { /* exists */ }
563
+ try {
564
+ this.db.exec(`ALTER TABLE agent_registry ADD COLUMN created_at TEXT`);
565
+ }
566
+ catch { /* exists */ }
567
+ // project_id column — direct FK to owning project, replaces part_of relation for non-project blocks
568
+ try {
569
+ this.db.exec(`ALTER TABLE blocks ADD COLUMN project_id TEXT`);
570
+ }
571
+ catch { /* exists */ }
572
+ // Back-fill project_id from existing part_of relations
573
+ try {
574
+ this.db.exec(`
575
+ UPDATE blocks SET project_id = (
576
+ SELECT r.target_id FROM relations r
577
+ WHERE r.source_id = blocks.id AND r.type = 'part_of'
578
+ AND r.status = 'active' AND r.valid_to IS NULL
579
+ LIMIT 1
580
+ )
581
+ WHERE project_id IS NULL AND type != 'project'
582
+ `);
583
+ }
584
+ catch { /* */ }
585
+ // Remove confidence column — replaced by quality_score + recency
586
+ // Trigger must be dropped first because it referenced NEW.confidence
587
+ try {
588
+ this.db.exec(`DROP TRIGGER IF EXISTS wlog_block_update`);
589
+ this.db.exec(`
590
+ CREATE TRIGGER wlog_block_update
591
+ AFTER UPDATE ON blocks
592
+ BEGIN
593
+ INSERT INTO write_log (id, table_name, operation, row_id, snapshot, changed_at)
594
+ VALUES (lower(hex(randomblob(8))), 'blocks', 'UPDATE', NEW.id,
595
+ json_object('label', NEW.label, 'type', NEW.type, 'essence', NEW.essence),
596
+ datetime('now'));
597
+ END
598
+ `);
599
+ }
600
+ catch { /* */ }
601
+ try {
602
+ this.db.exec(`ALTER TABLE blocks DROP COLUMN confidence`);
603
+ }
604
+ catch { /* already dropped or old SQLite */ }
605
+ try {
606
+ this.db.exec(`ALTER TABLE relations DROP COLUMN confidence`);
607
+ }
608
+ catch { /* already dropped or old SQLite */ }
609
+ // Seed built-in relation types (idempotent)
610
+ const seedRelations = this.db.prepare(`INSERT OR IGNORE INTO relation_types (name, inverse, description) VALUES (?, ?, ?)`);
611
+ const builtinRelations = [
612
+ ["related_to", "related_to", "Generic bidirectional association"],
613
+ ["part_of", "contains", "Block belongs to a parent entity"],
614
+ ["contains", "part_of", "Parent entity contains this block"],
615
+ ["derived_from", "produced", "Block was reasoned or inferred from source"],
616
+ ["produced", "derived_from", "Source that produced a derived block"],
617
+ ["based_on", null, "Decision or conclusion grounded in evidence"],
618
+ ["contradicts", "contradicts", "Blocks hold conflicting information"],
619
+ ["extends", null, "Block builds upon or refines another"],
620
+ ["affects", "affected_by", "Block influences another"],
621
+ ["affected_by", "affects", "Block is influenced by another"],
622
+ ["implements", "implemented_by", "Block realizes a higher-level concept"],
623
+ ["implemented_by", "implements", "Higher-level concept realized by this block"],
624
+ ["describes", "described_by", "Block provides description of another"],
625
+ ["described_by", "describes", "Block is described by another"],
626
+ ["supersedes", "superseded_by", "Block replaces an older block"],
627
+ ["superseded_by", "supersedes", "Block has been replaced by a newer one"],
628
+ ["depends_on", null, "Block requires another to function"],
629
+ ["enables", null, "Block makes another possible"],
630
+ ["prompted_by", "triggered", "This block was cognitively triggered by reading the target block — distinct from derived_from (logical) and related_to (loose)"],
631
+ ["triggered", "prompted_by", "Target block prompted the creation of this block"],
632
+ // member_of — Pass 5 writes one row per (member block, chain block) pair.
633
+ // Many-to-many: a block can participate in multiple narrative arcs (e.g.
634
+ // when Pass 5 emits overlapping chains through shared blocks). The chain
635
+ // block's `members[]` field is the canonical ORDERED narrative; this
636
+ // relation is the unordered fact-of-membership, enabling reverse lookup
637
+ // ("which chains contain this block?") without losing memberships to
638
+ // chain_id column overwrites. Per debt-4 §2.3 + S1.3 root cause.
639
+ // Inverse: null — the chain block's `members[]` carries the canonical
640
+ // ordering; a `has_member` inverse would duplicate that information in
641
+ // a worse shape (unordered rows vs ordered array).
642
+ ["member_of", null, "Block participates in a chain's narrative arc (many-to-many; chain.members[] is the canonical ordering)"],
643
+ // extracted_from — Debt 5 §2.3. Block was extracted from a specific
644
+ // conversation_turn_ranges row. Direction: block → range. Inverse: null
645
+ // (reverse query is `SELECT * FROM relations WHERE target_id=<range_id>
646
+ // AND type='extracted_from'`). Cardinality: each block has 0 or 1
647
+ // extracted_from (one extraction event creates one wiring; re-extraction
648
+ // creates NEW blocks with NEW extracted_from to the new range).
649
+ // Composes with the per-block source_excerpt column (line-level
650
+ // provenance) — extracted_from gives turn-range scope, source_excerpt
651
+ // pins the exact source line.
652
+ ["extracted_from", null, "Block was extracted from a specific conversation_turn_ranges row — the turn-range provenance audit link (composes with source_excerpt column for line-level pinning)"],
653
+ ];
654
+ for (const [name, inverse, description] of builtinRelations) {
655
+ try {
656
+ seedRelations.run(name, inverse, description);
657
+ }
658
+ catch { /* skip */ }
659
+ }
660
+ // Seed built-in block types (idempotent)
661
+ const seedTypes = this.db.prepare(`INSERT OR IGNORE INTO block_types (name, extends, description, typical_fields) VALUES (?, ?, ?, ?)`);
662
+ const builtinTypes = [
663
+ // Core DB types
664
+ ["fact", "base", "Verified piece of information"],
665
+ ["insight", "base", "Synthesized understanding from multiple facts"],
666
+ ["decision", "base", "A choice made with reasoning"],
667
+ ["constraint", "base", "A limitation or hard requirement"],
668
+ ["note", "base", "General observation or annotation"],
669
+ ["process", "base", "A repeatable procedure or workflow"],
670
+ ["project", "base", "A container for related work"],
671
+ ["question", "base", "An open question to be resolved"],
672
+ ["task", "base", "An actionable item with status"],
673
+ ["dead_end", "base", "An approach that was tried and failed"],
674
+ ["draft", "base", "Work-in-progress not yet verified"],
675
+ ["artifact", "base", "A file, document, or generated output"],
676
+ // Extended types used by Gemini auto-reflect
677
+ ["hypothesis", "fact", "A proposed explanation not yet confirmed — could be wrong"],
678
+ ["preference", "decision", "A strong user preference or non-negotiable rule"],
679
+ ["blueprint", "process", "A deferred design or feature plan — not built yet"],
680
+ ["entity", "note", "A named thing in the domain — person, user type, organization, component"],
681
+ ["event", "note", "Something that occurred — launch, incident, experiment result"],
682
+ // reasoning_chain/metric/claim collapsed → insight/fact (2026-06-15); removed from the
683
+ // seed so new DBs don't advertise them. Rows already seeded in existing DBs are pruned
684
+ // separately via the admin migration (INSERT OR IGNORE never deletes). entity KEPT —
685
+ // the pipeline auto-creates entity blocks as label sub-group containers (structural).
686
+ ];
687
+ for (const [name, extends_, description] of builtinTypes) {
688
+ try {
689
+ seedTypes.run(name, extends_, description, "[]");
690
+ }
691
+ catch { /* skip */ }
692
+ }
693
+ }
694
+ // Convert raw DB row (with 0/1 integers) to typed Block
695
+ rowToBlock(row) {
696
+ return {
697
+ ...row,
698
+ is_sensitive: row.is_sensitive === 1 || row.is_sensitive === true,
699
+ locked: row.locked === 1 || row.locked === true,
700
+ };
701
+ }
702
+ decryptBlockIfSensitive(block) {
703
+ if (!block.is_sensitive)
704
+ return block;
705
+ try {
706
+ return {
707
+ ...block,
708
+ essence: this.decryptText(block.essence),
709
+ content: this.decryptText(block.content),
710
+ };
711
+ }
712
+ catch {
713
+ return block;
714
+ }
715
+ }
716
+ // ─── Block CRUD ──────────────────────────────────────────────
717
+ createBlock(params) {
718
+ if (!this.db)
719
+ throw new Error("Database not initialized");
720
+ const now = new Date().toISOString();
721
+ const id = `blk_${uuidv4().slice(0, 8)}`;
722
+ let finalEssence = params.essence;
723
+ let finalContent = JSON.stringify(params.content || {});
724
+ if (params.is_sensitive) {
725
+ finalEssence = this.encryptText(finalEssence);
726
+ finalContent = this.encryptText(finalContent);
727
+ }
728
+ const conceptsJson = JSON.stringify(params.concepts || []);
729
+ const insertResult = this.db.prepare(`INSERT OR IGNORE INTO blocks (id, label, type, status, ttl, project_id, essence, content, source, source_type, created_by, created_at, updated_at, last_accessed, access_count, concepts, aliases, embedding, is_sensitive, source_excerpt)
730
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.label, params.type || "note", params.status || "created", params.ttl || "permanent", params.project_id || null, finalEssence, finalContent, params.source || null, params.source_type || "agent_derived", params.created_by || null, now, now, now, 0, conceptsJson, JSON.stringify(params.aliases || []), params.embedding ? JSON.stringify(params.embedding) : null, params.is_sensitive ? 1 : 0, params.source_excerpt ?? null);
731
+ // Duplicate label — merge unique fields into existing block instead of creating a second row
732
+ if (insertResult.changes === 0) {
733
+ const existing = this.db.prepare(`SELECT * FROM blocks WHERE LOWER(label) = ?`).get(params.label.toLowerCase());
734
+ if (existing) {
735
+ // If archived, reactivate it with the new values
736
+ if (existing.status === "archived") {
737
+ this.db.prepare(`UPDATE blocks SET status = 'active', essence = ?, ttl = ?, updated_at = ? WHERE id = ?`).run(params.essence || existing.essence, params.ttl || existing.ttl, now, existing.id);
738
+ return { ...existing, status: "active", essence: params.essence || existing.essence };
739
+ }
740
+ // Active block with same label — merge unique fields
741
+ try {
742
+ const existingContent = JSON.parse(existing.content || "{}");
743
+ const newContent = JSON.parse(finalContent || "{}");
744
+ const merged = {
745
+ ...existingContent,
746
+ unique: { ...(existingContent.unique || {}), ...(newContent.unique || {}) },
747
+ };
748
+ this.db.prepare(`UPDATE blocks SET content = ?, updated_at = ? WHERE id = ?`)
749
+ .run(JSON.stringify(merged), now, existing.id);
750
+ }
751
+ catch { /* merge failed — return existing as-is */ }
752
+ return { ...existing, id: existing.id };
753
+ }
754
+ }
755
+ const block = {
756
+ id, label: params.label, type: params.type || "note",
757
+ status: params.status || "created",
758
+ ttl: params.ttl || "permanent",
759
+ essence: params.is_sensitive ? finalEssence : params.essence,
760
+ content: params.is_sensitive ? finalContent : JSON.stringify(params.content || {}),
761
+ source: params.source || null, created_by: params.created_by || null,
762
+ created_at: now, updated_at: now, last_accessed: now, access_count: 0,
763
+ concepts: conceptsJson,
764
+ aliases: JSON.stringify(params.aliases || []),
765
+ embedding: params.embedding ? JSON.stringify(params.embedding) : null,
766
+ is_sensitive: params.is_sensitive || false, locked: false, enriched_at: null,
767
+ quality_score: 0, // updated immediately after creation via workspace_remember
768
+ last_challenged_at: null,
769
+ priority: params.priority || null,
770
+ flow_role: params.flow_role || null,
771
+ chain_id: params.chain_id || null,
772
+ review_status: params.review_status || null,
773
+ review_reason: params.review_reason || null,
774
+ last_reflected_at: null,
775
+ project_id: params.project_id || null,
776
+ };
777
+ if (block.is_sensitive) {
778
+ return { ...block, essence: params.essence, content: JSON.stringify(params.content || {}) };
779
+ }
780
+ return block;
781
+ }
782
+ getBlock(idOrLabel) {
783
+ if (!this.db)
784
+ throw new Error("Database not initialized");
785
+ const row = this.db.prepare(`SELECT * FROM blocks WHERE (id = ? OR LOWER(label) = ?)`).get(idOrLabel, idOrLabel.toLowerCase());
786
+ if (!row)
787
+ return null;
788
+ const block = this.decryptBlockIfSensitive(this.rowToBlock(row));
789
+ this.db.prepare(`UPDATE blocks SET last_accessed = ?, access_count = access_count + 1,
790
+ status = CASE WHEN status = 'stale' THEN 'active' WHEN status = 'created' THEN 'active' ELSE status END
791
+ WHERE id = ?`).run(new Date().toISOString(), block.id);
792
+ return block;
793
+ }
794
+ getAllBlocks() {
795
+ if (!this.db)
796
+ throw new Error("Database not initialized");
797
+ const rows = this.db.prepare(`SELECT * FROM blocks WHERE status != 'archived'`).all();
798
+ return rows.map((row) => this.decryptBlockIfSensitive(this.rowToBlock(row)));
799
+ }
800
+ // Status-explicit fetch. getAllBlocks() excludes archived rows by design (the live working set);
801
+ // listing archived/quarantined blocks requires asking for that status explicitly — without this,
802
+ // a `?status=archived` query silently matches nothing.
803
+ getBlocksByStatus(status) {
804
+ if (!this.db)
805
+ throw new Error("Database not initialized");
806
+ const rows = this.db.prepare(`SELECT * FROM blocks WHERE status = ?`).all(status);
807
+ return rows.map((row) => this.decryptBlockIfSensitive(this.rowToBlock(row)));
808
+ }
809
+ getBlocksByChain(chainId) {
810
+ if (!this.db)
811
+ throw new Error("Database not initialized");
812
+ const rows = this.db.prepare(`SELECT * FROM blocks WHERE chain_id = ? AND status != 'archived' ORDER BY created_at ASC`).all(chainId);
813
+ return rows.map((row) => this.decryptBlockIfSensitive(this.rowToBlock(row)));
814
+ }
815
+ updateBlock(idOrLabel, changes, reason, changedBy, force) {
816
+ if (!this.db)
817
+ throw new Error("Database not initialized");
818
+ const block = this.getBlock(idOrLabel);
819
+ if (!block)
820
+ return null;
821
+ if (block.locked && !force && !("locked" in changes)) {
822
+ throw new Error(`Block '${block.label}' (${block.id}) is locked. Pass force:true to override.`);
823
+ }
824
+ const allowedFields = [
825
+ "label", "type", "status", "ttl", "project_id",
826
+ "essence", "content", "source", "concepts", "aliases", "embedding", "locked", "enriched_at",
827
+ "quality_score", "last_challenged_at", "priority", "flow_role", "chain_id",
828
+ "review_status", "review_reason",
829
+ "source_excerpt", // gap ④(b): the provenance reviewer corrects a mis-quoted excerpt (with history)
830
+ ];
831
+ const now = new Date().toISOString();
832
+ const isSensitive = block.is_sensitive;
833
+ const fullSnapshot = JSON.stringify(block);
834
+ for (const [field, newValue] of Object.entries(changes)) {
835
+ if (!allowedFields.includes(field))
836
+ continue;
837
+ const oldValue = block[field];
838
+ // null/undefined must stay SQL NULL. `typeof null === "object"` made the old
839
+ // code JSON.stringify(null) → the literal string 'null' landed in nullable
840
+ // columns (hit: Pass 5's chain_id cleanup — 146 standalone blocks read as one
841
+ // fake "null" chain). Clearing a field means NULL, never the string.
842
+ const serializedNew = newValue == null
843
+ ? null
844
+ : typeof newValue === "object" ? JSON.stringify(newValue) : String(newValue);
845
+ let dbValue = serializedNew;
846
+ const isEncryptedField = isSensitive && (field === "essence" || field === "content");
847
+ if (isEncryptedField && serializedNew !== null)
848
+ dbValue = this.encryptText(serializedNew);
849
+ const histOldValue = (field === "essence" || field === "content") && !isEncryptedField
850
+ ? JSON.stringify({ snapshot: fullSnapshot, field_value: String(oldValue ?? "") })
851
+ : isEncryptedField
852
+ ? "[ENCRYPTED]"
853
+ : (typeof oldValue === "object" ? JSON.stringify(oldValue) : String(oldValue ?? ""));
854
+ this.db.prepare(`INSERT INTO block_history (id, block_id, field_changed, old_value, new_value, changed_by, changed_at, reason)
855
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(`hist_${uuidv4().slice(0, 8)}`, block.id, field, histOldValue, isEncryptedField ? "[ENCRYPTED]" : serializedNew, changedBy || null, now, reason || null);
856
+ this.db.prepare(`UPDATE blocks SET ${field} = ?, updated_at = ? WHERE id = ?`).run(dbValue, now, block.id);
857
+ }
858
+ if ("essence" in changes || "content" in changes) {
859
+ this._invalidateDerivedBlocks(block.id, now);
860
+ }
861
+ return this.getBlock(block.id);
862
+ }
863
+ _invalidateDerivedBlocks(sourceId, now) {
864
+ if (!this.db)
865
+ return;
866
+ const rows = this.db.prepare(`SELECT id, content FROM blocks WHERE status NOT IN ('archived', 'stale') AND content LIKE ?`).all(`%${sourceId}%`);
867
+ for (const row of rows) {
868
+ try {
869
+ const c = JSON.parse(row.content);
870
+ const inputIds = c?.derivation?.input_ids || [];
871
+ if (inputIds.includes(sourceId)) {
872
+ this.db.prepare(`UPDATE blocks SET status = 'stale', updated_at = ? WHERE id = ?`).run(now, row.id);
873
+ this.db.prepare(`INSERT INTO block_history (id, block_id, field_changed, old_value, new_value, changed_by, changed_at, reason) VALUES (?, ?, 'status', 'active', 'stale', ?, ?, ?)`).run(`hist_${uuidv4().slice(0, 8)}`, row.id, "system", now, `source block ${sourceId} was updated`);
874
+ }
875
+ }
876
+ catch { /* skip */ }
877
+ }
878
+ }
879
+ archiveBlock(idOrLabel, reason) {
880
+ if (!this.db)
881
+ throw new Error("Database not initialized");
882
+ const block = this.getBlock(idOrLabel);
883
+ if (!block)
884
+ return false;
885
+ const now = new Date().toISOString();
886
+ this.db.prepare(`UPDATE blocks SET status = 'archived', updated_at = ? WHERE id = ?`).run(now, block.id);
887
+ this.db.prepare(`INSERT INTO block_history (id, block_id, field_changed, old_value, new_value, changed_by, changed_at, reason) VALUES (?, ?, 'status', ?, 'archived', ?, ?, ?)`).run(`hist_${uuidv4().slice(0, 8)}`, block.id, block.status, null, now, reason || "Archived by agent");
888
+ // Archive outgoing relations so orphaned rows no longer affect GC protection,
889
+ // children_count, incoming_count, or quality_score on other blocks.
890
+ // Incoming relations are left intact — they are historical record of what cited this block.
891
+ this.db.prepare(`UPDATE relations SET valid_to = ?, status = 'archived' WHERE source_id = ? AND valid_to IS NULL`).run(now, block.id);
892
+ return true;
893
+ }
894
+ // ─── Search ──────────────────────────────────────────────────
895
+ /**
896
+ * Find blocks whose concepts column contains any of the given concept strings.
897
+ * Uses SQLite's json_each() to match inside the JSON array — no JS-level parsing loop.
898
+ * Returns map of blockId → overlap count (number of matching concepts).
899
+ */
900
+ conceptSearch(queryConcepts) {
901
+ if (!this.db)
902
+ throw new Error("Database not initialized");
903
+ if (queryConcepts.length === 0)
904
+ return new Map();
905
+ const result = new Map();
906
+ // One query per concept — SQLite json_each expands the JSON array into rows
907
+ const stmt = this.db.prepare(`
908
+ SELECT b.*
909
+ FROM blocks b, json_each(b.concepts) je
910
+ WHERE b.status != 'archived'
911
+ AND (LOWER(je.value) LIKE ? OR ? LIKE '%' || LOWER(je.value) || '%')
912
+ `);
913
+ for (const qc of queryConcepts) {
914
+ const term = qc.toLowerCase();
915
+ const rows = stmt.all(`%${term}%`, term);
916
+ for (const row of rows) {
917
+ const block = this.rowToBlock(row);
918
+ const existing = result.get(block.id);
919
+ if (existing) {
920
+ existing.matches++;
921
+ }
922
+ else {
923
+ result.set(block.id, { block, matches: 1 });
924
+ }
925
+ }
926
+ }
927
+ return result;
928
+ }
929
+ keywordSearch(query, limit = 10, type, status) {
930
+ if (!this.db)
931
+ throw new Error("Database not initialized");
932
+ const terms = query.toLowerCase().split(/\s+/);
933
+ const statusFilter = status === "all" ? "" : (status ? `AND status = '${status}'` : `AND status IN ('active', 'created')`);
934
+ const typeFilter = type ? `AND type = '${type}'` : "";
935
+ const termConditions = terms.map(() => `(LOWER(label) LIKE ? OR LOWER(essence) LIKE ? OR LOWER(content) LIKE ? OR LOWER(concepts) LIKE ? OR LOWER(aliases) LIKE ?)`);
936
+ const params = terms.flatMap((t) => {
937
+ const like = `%${t}%`;
938
+ return [like, like, like, like, like];
939
+ });
940
+ const sql = `
941
+ SELECT * FROM blocks
942
+ WHERE (${termConditions.join(" OR ")})
943
+ ${statusFilter} ${typeFilter}
944
+ ORDER BY access_count DESC, updated_at DESC
945
+ LIMIT ?
946
+ `;
947
+ const rows = this.db.prepare(sql).all(...params, limit);
948
+ return rows.map((row) => this.rowToBlock(row));
949
+ }
950
+ semanticSearch(queryEmbedding, limit = 10, type, minSimilarity = 0.2) {
951
+ if (!this.db)
952
+ throw new Error("Database not initialized");
953
+ const typeFilter = type ? `AND type = '${type}'` : "";
954
+ const rows = this.db.prepare(`SELECT * FROM blocks WHERE embedding IS NOT NULL AND status != 'archived' ${typeFilter}`).all();
955
+ const blocks = rows.map((row) => this.rowToBlock(row));
956
+ return blocks
957
+ .map((block) => ({
958
+ block,
959
+ similarity: cosineSim(queryEmbedding, JSON.parse(block.embedding)),
960
+ }))
961
+ .filter((s) => s.similarity > minSimilarity)
962
+ .sort((a, b) => b.similarity - a.similarity)
963
+ .slice(0, limit)
964
+ .map((s) => s.block);
965
+ }
966
+ // ─── Relations ─────────────────────────────────────────────────
967
+ createRelation(params) {
968
+ if (!this.db)
969
+ throw new Error("Database not initialized");
970
+ // Guard: both blocks must exist before creating a relation.
971
+ // Prevents orphaned edges that silently break chain traversal.
972
+ // Logs and returns a null-like stub instead of throwing — callers check for null if needed.
973
+ const srcExists = this.db.prepare(`SELECT id FROM blocks WHERE id = ?`).get(params.source_id);
974
+ const tgtExists = this.db.prepare(`SELECT id FROM blocks WHERE id = ?`).get(params.target_id);
975
+ if (!srcExists || !tgtExists) {
976
+ const missing = !srcExists ? `source "${params.source_id}"` : `target "${params.target_id}"`;
977
+ console.warn(`createRelation: ${missing} does not exist — skipping "${params.type}" relation`);
978
+ // Return a stub so callers don't need to null-check; the relation is simply not persisted.
979
+ return { id: "", source_id: params.source_id, target_id: params.target_id, type: params.type,
980
+ bidirectional: false, created_by: null, created_at: "", status: "skipped",
981
+ valid_from: null, valid_to: null };
982
+ }
983
+ // Idempotency guard: return existing active relation if same source/target/type already exists
984
+ const existing = this.db.prepare(`SELECT * FROM relations WHERE source_id = ? AND target_id = ? AND type = ? AND valid_to IS NULL LIMIT 1`).get(params.source_id, params.target_id, params.type);
985
+ if (existing) {
986
+ return {
987
+ id: existing.id,
988
+ source_id: existing.source_id,
989
+ target_id: existing.target_id,
990
+ type: existing.type,
991
+ bidirectional: existing.bidirectional === 1 || existing.bidirectional === true,
992
+ created_by: existing.created_by,
993
+ created_at: existing.created_at,
994
+ status: existing.status ?? "active",
995
+ valid_from: existing.valid_from,
996
+ valid_to: existing.valid_to,
997
+ };
998
+ }
999
+ const now = new Date().toISOString();
1000
+ const relation = {
1001
+ id: `rel_${uuidv4().slice(0, 8)}`,
1002
+ source_id: params.source_id,
1003
+ target_id: params.target_id,
1004
+ type: params.type,
1005
+ bidirectional: params.bidirectional ?? false,
1006
+ created_by: params.created_by || null,
1007
+ created_at: now,
1008
+ status: params.status ?? "active",
1009
+ valid_from: params.valid_from ?? now,
1010
+ valid_to: null,
1011
+ };
1012
+ this.db.prepare(`INSERT INTO relations (id, source_id, target_id, type, bidirectional, created_by, created_at, status, valid_from, valid_to)
1013
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(relation.id, relation.source_id, relation.target_id, relation.type, relation.bidirectional ? 1 : 0, relation.created_by, relation.created_at, relation.status, relation.valid_from, null);
1014
+ // Recompute quality_score for the source block — gaining a relation bumps the score.
1015
+ // Only recompute if current score is below the max (6) to avoid unnecessary writes.
1016
+ const srcRow = this.db.prepare(`SELECT * FROM blocks WHERE id = ?`).get(params.source_id);
1017
+ if (srcRow && srcRow.quality_score < 5) {
1018
+ const qc = (() => { try {
1019
+ return typeof srcRow.content === "string" ? JSON.parse(srcRow.content) : (srcRow.content || {});
1020
+ }
1021
+ catch {
1022
+ return {};
1023
+ } })();
1024
+ const concepts = (() => { try {
1025
+ return JSON.parse(srcRow.concepts || "[]");
1026
+ }
1027
+ catch {
1028
+ return [];
1029
+ } })();
1030
+ const relCount = this.db.prepare(`SELECT COUNT(*) as n FROM relations WHERE source_id = ? AND valid_to IS NULL`).get(params.source_id)?.n ?? 0;
1031
+ let qScore = 1; // essence always present
1032
+ if (qc.is_a)
1033
+ qScore++;
1034
+ if (qc.unique && Object.keys(qc.unique).length >= 2)
1035
+ qScore++;
1036
+ if (concepts.length >= 3)
1037
+ qScore++;
1038
+ if (relCount > 0)
1039
+ qScore++;
1040
+ const newScore = Math.min(qScore, 5);
1041
+ if (newScore !== srcRow.quality_score) {
1042
+ this.db.prepare(`UPDATE blocks SET quality_score = ? WHERE id = ?`).run(newScore, params.source_id);
1043
+ }
1044
+ }
1045
+ // SEMANTIC (2026-07-02): a supersedes relation does NOT archive the target.
1046
+ // The EDGE itself is the source of truth for currency — old block stays visible
1047
+ // history (like a dead_end), with its superseded_by edge telling any reader what
1048
+ // replaced it. Archiving here made superseded decisions INVISIBLE to search/list
1049
+ // (archived is filtered everywhere), so the "we already tried X" signal vanished —
1050
+ // and it was type-inconsistent (only decision/blueprint archived; preferences etc.
1051
+ // stayed active). `archived` is reserved for actual removal-from-view: confirmed
1052
+ // duplicates (executeMerge archives explicitly), TTL expiry, and workspace_forget.
1053
+ return relation;
1054
+ }
1055
+ /**
1056
+ * Batch currency lookup: which of these blocks have been superseded, and by what.
1057
+ * Returns target_id → label of the ACTIVE superseding block. Read paths that list
1058
+ * blocks WITHOUT relations (search, filters) use this so a superseded-but-visible
1059
+ * block can never leak as current — the annotation says what replaced it.
1060
+ */
1061
+ getSupersededByLabels(blockIds) {
1062
+ if (!this.db)
1063
+ throw new Error("Database not initialized");
1064
+ const out = new Map();
1065
+ if (!blockIds.length)
1066
+ return out;
1067
+ const rows = this.db.prepare(`SELECT r.target_id AS tid, b.label AS lbl
1068
+ FROM relations r JOIN blocks b ON b.id = r.source_id
1069
+ WHERE r.type = 'supersedes' AND r.valid_to IS NULL
1070
+ AND b.status != 'archived'
1071
+ AND r.target_id IN (${blockIds.map(() => "?").join(",")})`).all(...blockIds);
1072
+ for (const row of rows)
1073
+ out.set(row.tid, row.lbl);
1074
+ return out;
1075
+ }
1076
+ getRelations(blockId) {
1077
+ if (!this.db)
1078
+ throw new Error("Database not initialized");
1079
+ const outgoing = this.db.prepare(`SELECT r.type, r.target_id, b.label as target_label
1080
+ FROM relations r JOIN blocks b ON r.target_id = b.id
1081
+ WHERE r.source_id = ? AND r.valid_to IS NULL`).all(blockId);
1082
+ const incoming = this.db.prepare(`SELECT r.type, r.source_id as target_id, b.label as target_label
1083
+ FROM relations r JOIN blocks b ON r.source_id = b.id
1084
+ WHERE r.target_id = ? AND r.bidirectional = 1 AND r.valid_to IS NULL`).all(blockId);
1085
+ return [
1086
+ ...outgoing.map((r) => ({ ...r, direction: "outgoing" })),
1087
+ ...incoming.map((r) => ({ ...r, direction: "incoming" })),
1088
+ ];
1089
+ }
1090
+ getAllRelations(includePending = false) {
1091
+ if (!this.db)
1092
+ throw new Error("Database not initialized");
1093
+ // Always exclude expired (valid_to IS NOT NULL) unless explicitly querying history
1094
+ const sql = includePending
1095
+ ? `SELECT * FROM relations WHERE valid_to IS NULL`
1096
+ : `SELECT * FROM relations WHERE (status = 'active' OR status IS NULL) AND valid_to IS NULL`;
1097
+ const rows = this.db.prepare(sql).all();
1098
+ return rows.map((row) => ({
1099
+ id: row.id,
1100
+ source_id: row.source_id,
1101
+ target_id: row.target_id,
1102
+ type: row.type,
1103
+ bidirectional: row.bidirectional === 1 || row.bidirectional === true,
1104
+ created_by: row.created_by,
1105
+ created_at: row.created_at,
1106
+ status: row.status ?? "active",
1107
+ valid_from: row.valid_from,
1108
+ valid_to: row.valid_to,
1109
+ }));
1110
+ }
1111
+ // Batch: get incoming contradicts/challenges for a set of block IDs.
1112
+ // Also surfaces challenges on 1-hop neighbors so caveat blocks are never invisible:
1113
+ // recalled block X → neighbor Y (any relation) → challenger Z contradicts Y
1114
+ // → Z appears in X's challenges array
1115
+ getChallengesForBlocks(blockIds) {
1116
+ if (!this.db || blockIds.length === 0)
1117
+ return new Map();
1118
+ const placeholders = blockIds.map(() => "?").join(",");
1119
+ // Direct challenges: Z contradicts recalled block X
1120
+ const directRows = this.db.prepare(`SELECT r.target_id as block_id, b.label, b.essence
1121
+ FROM relations r
1122
+ JOIN blocks b ON r.source_id = b.id
1123
+ WHERE r.target_id IN (${placeholders})
1124
+ AND r.type IN ('contradicts','challenges')
1125
+ AND r.valid_to IS NULL
1126
+ AND (r.status = 'active' OR r.status IS NULL)`).all(...blockIds);
1127
+ const result = new Map();
1128
+ for (const row of directRows) {
1129
+ if (!result.has(row.block_id))
1130
+ result.set(row.block_id, []);
1131
+ result.get(row.block_id).push({ label: row.label, essence: row.essence });
1132
+ }
1133
+ // 1-hop expansion: find neighbors of recalled blocks, then their challengers
1134
+ const neighborRows = this.db.prepare(`SELECT CASE WHEN r.source_id IN (${placeholders}) THEN r.source_id ELSE r.target_id END as recalled_id,
1135
+ CASE WHEN r.source_id IN (${placeholders}) THEN r.target_id ELSE r.source_id END as neighbor_id
1136
+ FROM relations r
1137
+ WHERE (r.source_id IN (${placeholders}) OR r.target_id IN (${placeholders}))
1138
+ AND r.type NOT IN ('contradicts','challenges')
1139
+ AND r.valid_to IS NULL`).all(...blockIds, ...blockIds, ...blockIds, ...blockIds);
1140
+ if (neighborRows.length > 0) {
1141
+ const neighborIds = [...new Set(neighborRows.map(r => r.neighbor_id))];
1142
+ // Include ALL neighbors — even if they're also in the recall result.
1143
+ // A block in the result set may still be a 1-hop neighbor of another result block;
1144
+ // its challengers should surface on the originating recalled block.
1145
+ if (neighborIds.length > 0) {
1146
+ const nPlaceholders = neighborIds.map(() => "?").join(",");
1147
+ const neighborChallengeRows = this.db.prepare(`SELECT r.target_id as neighbor_id, b.label, b.essence
1148
+ FROM relations r
1149
+ JOIN blocks b ON r.source_id = b.id
1150
+ WHERE r.target_id IN (${nPlaceholders})
1151
+ AND r.type IN ('contradicts','challenges')
1152
+ AND r.valid_to IS NULL
1153
+ AND (r.status = 'active' OR r.status IS NULL)`).all(...neighborIds);
1154
+ // Map neighbor challenges back to their recalled block origin
1155
+ const neighborToRecalled = new Map();
1156
+ for (const row of neighborRows)
1157
+ neighborToRecalled.set(row.neighbor_id, row.recalled_id);
1158
+ for (const row of neighborChallengeRows) {
1159
+ const recalledId = neighborToRecalled.get(row.neighbor_id);
1160
+ if (!recalledId)
1161
+ continue;
1162
+ if (!result.has(recalledId))
1163
+ result.set(recalledId, []);
1164
+ // avoid duplicates
1165
+ const existing = result.get(recalledId);
1166
+ if (!existing.some(e => e.label === row.label))
1167
+ existing.push({ label: row.label, essence: row.essence });
1168
+ }
1169
+ }
1170
+ }
1171
+ return result;
1172
+ }
1173
+ // Batch: count incoming relations per block (signals graph importance)
1174
+ getIncomingCounts(blockIds) {
1175
+ if (!this.db || blockIds.length === 0)
1176
+ return new Map();
1177
+ const placeholders = blockIds.map(() => "?").join(",");
1178
+ const rows = this.db.prepare(`SELECT target_id, COUNT(*) as cnt
1179
+ FROM relations
1180
+ WHERE target_id IN (${placeholders})
1181
+ AND valid_to IS NULL
1182
+ AND (status = 'active' OR status IS NULL)
1183
+ GROUP BY target_id`).all(...blockIds);
1184
+ const result = new Map();
1185
+ for (const row of rows)
1186
+ result.set(row.target_id, row.cnt);
1187
+ return result;
1188
+ }
1189
+ // Mark a relation as no longer valid (bitemporal invalidation)
1190
+ invalidateRelation(id, reason) {
1191
+ if (!this.db)
1192
+ throw new Error("Database not initialized");
1193
+ const now = new Date().toISOString();
1194
+ const result = this.db.prepare(`UPDATE relations SET valid_to = ?, status = 'archived' WHERE id = ? AND valid_to IS NULL`).run(now, id);
1195
+ if (result.changes > 0 && reason) {
1196
+ // Log to history of the source block
1197
+ const rel = this.db.prepare(`SELECT source_id FROM relations WHERE id = ?`).get(id);
1198
+ if (rel) {
1199
+ this.db.prepare(`INSERT INTO block_history (id, block_id, field_changed, old_value, new_value, changed_by, changed_at, reason) VALUES (?, ?, 'relation', ?, 'invalidated', 'system', ?, ?)`).run(`hist_${uuidv4().slice(0, 8)}`, rel.source_id, id, now, reason);
1200
+ }
1201
+ }
1202
+ return result.changes > 0;
1203
+ }
1204
+ getPendingRelations() {
1205
+ if (!this.db)
1206
+ throw new Error("Database not initialized");
1207
+ const rows = this.db.prepare(`SELECT r.id, r.source_id, s.label as source_label, r.target_id, t.label as target_label,
1208
+ r.type, r.created_by, r.created_at
1209
+ FROM relations r
1210
+ JOIN blocks s ON r.source_id = s.id
1211
+ JOIN blocks t ON r.target_id = t.id
1212
+ WHERE r.status = 'pending'
1213
+ ORDER BY r.created_at DESC`).all();
1214
+ return rows.map((row) => ({
1215
+ id: row.id,
1216
+ source_id: row.source_id,
1217
+ source_label: row.source_label,
1218
+ target_id: row.target_id,
1219
+ target_label: row.target_label,
1220
+ type: row.type,
1221
+ created_by: row.created_by,
1222
+ created_at: row.created_at,
1223
+ }));
1224
+ }
1225
+ approveRelation(id) {
1226
+ if (!this.db)
1227
+ throw new Error("Database not initialized");
1228
+ this.db.prepare(`UPDATE relations SET status = 'active' WHERE id = ? AND status = 'pending'`).run(id);
1229
+ return true;
1230
+ }
1231
+ // Update relation type and/or status — used by Gemini async validation
1232
+ updateRelation(id, updates) {
1233
+ if (!this.db)
1234
+ throw new Error("Database not initialized");
1235
+ const fields = [];
1236
+ const values = [];
1237
+ if (updates.type !== undefined) {
1238
+ fields.push("type = ?");
1239
+ values.push(updates.type);
1240
+ }
1241
+ if (updates.status !== undefined) {
1242
+ fields.push("status = ?");
1243
+ values.push(updates.status);
1244
+ }
1245
+ if (fields.length === 0)
1246
+ return false;
1247
+ values.push(id);
1248
+ const result = this.db.prepare(`UPDATE relations SET ${fields.join(", ")} WHERE id = ?`).run(...values);
1249
+ return result.changes > 0;
1250
+ }
1251
+ // Hard delete a pending relation — used when Gemini determines no real connection exists
1252
+ deleteRelation(id) {
1253
+ if (!this.db)
1254
+ throw new Error("Database not initialized");
1255
+ const result = this.db.prepare(`DELETE FROM relations WHERE id = ? AND status = 'pending'`).run(id);
1256
+ return result.changes > 0;
1257
+ }
1258
+ rejectRelation(id) {
1259
+ if (!this.db)
1260
+ throw new Error("Database not initialized");
1261
+ this.db.prepare(`DELETE FROM relations WHERE id = ? AND status = 'pending'`).run(id);
1262
+ return true;
1263
+ }
1264
+ // IDF helpers — count blocks with a concept tag to detect generic vs specific tags
1265
+ countBlocksWithConcept(tag) {
1266
+ if (!this.db)
1267
+ throw new Error("Database not initialized");
1268
+ const result = this.db.prepare(`SELECT COUNT(*) as c FROM blocks WHERE concepts LIKE ? AND status != 'archived'`).get(`%"${tag}"%`);
1269
+ return result?.c ?? 0;
1270
+ }
1271
+ getTotalBlockCount() {
1272
+ if (!this.db)
1273
+ throw new Error("Database not initialized");
1274
+ const result = this.db.prepare(`SELECT COUNT(*) as c FROM blocks WHERE status != 'archived'`).get();
1275
+ return result?.c ?? 0;
1276
+ }
1277
+ deleteInferredRelations() {
1278
+ if (!this.db)
1279
+ throw new Error("Database not initialized");
1280
+ const result = this.db.prepare(`DELETE FROM relations WHERE created_by LIKE 'infer_%'`).run();
1281
+ return result.changes;
1282
+ }
1283
+ getAllIncomingRelations(blockId) {
1284
+ if (!this.db)
1285
+ throw new Error("Database not initialized");
1286
+ // Translate r.type to its inverse so the type field reads from the READER'S perspective
1287
+ // (the reader is the target block; the stored type is from the source's perspective).
1288
+ // - Paired inverses (supersedes↔superseded_by, part_of↔contains, prompted_by↔triggered,
1289
+ // derived_from↔produced, affects↔affected_by, implements↔implemented_by,
1290
+ // describes↔described_by): translates — agent reading the OLD block of a supersede
1291
+ // sees `superseded_by`, not `supersedes`.
1292
+ // - Self-inverse (related_to, contradicts): rt.inverse equals r.type → COALESCE no-op.
1293
+ // - Null-inverse (based_on, extends, depends_on, enables, member_of): rt.inverse IS NULL
1294
+ // → COALESCE keeps r.type. These types have no semantic inverse name by design.
1295
+ // - Unknown type (not seeded in relation_types): LEFT JOIN returns NULL → COALESCE keeps r.type.
1296
+ // Patch — Debt 4 §2.2 will subsume with reason/evidence_basis columns on relations;
1297
+ // read-side translation has zero conflict with that future schema work.
1298
+ const rows = this.db.prepare(`SELECT COALESCE(rt.inverse, r.type) AS type,
1299
+ r.source_id,
1300
+ b.label as source_label
1301
+ FROM relations r
1302
+ JOIN blocks b ON r.source_id = b.id
1303
+ LEFT JOIN relation_types rt ON rt.name = r.type
1304
+ WHERE r.target_id = ? AND r.valid_to IS NULL AND (r.status = 'active' OR r.status IS NULL)`).all(blockId);
1305
+ return rows;
1306
+ }
1307
+ getBlockTypes() {
1308
+ if (!this.db)
1309
+ throw new Error("Database not initialized");
1310
+ const rows = this.db.prepare(`SELECT * FROM block_types`).all();
1311
+ return rows.map((row) => ({
1312
+ name: row.name,
1313
+ extends: row.extends,
1314
+ description: row.description,
1315
+ typical_fields: row.typical_fields,
1316
+ }));
1317
+ }
1318
+ // ─── History ──────────────────────────────────────────────────
1319
+ // ─── Graph health + maintenance ───────────────────────────────────────────
1320
+ /** Returns blocks with no project_id — invisible to project-scoped queries. */
1321
+ getGraphHealth() {
1322
+ if (!this.db)
1323
+ throw new Error("Database not initialized");
1324
+ const unlinked = this.db.prepare(`
1325
+ SELECT id, label, type FROM blocks
1326
+ WHERE status = 'active' AND type != 'project' AND project_id IS NULL
1327
+ `).all();
1328
+ return { unlinked };
1329
+ }
1330
+ pruneEmbeddingHistory() {
1331
+ if (!this.db)
1332
+ throw new Error("Database not initialized");
1333
+ const result = this.db.prepare(`DELETE FROM block_history WHERE field_changed = 'embedding'`).run();
1334
+ return { deleted: result.changes };
1335
+ }
1336
+ getHistory(blockId, limit = 10) {
1337
+ if (!this.db)
1338
+ throw new Error("Database not initialized");
1339
+ const rows = blockId
1340
+ ? this.db.prepare(`SELECT * FROM block_history WHERE block_id = ? ORDER BY changed_at DESC LIMIT ?`).all(blockId, limit)
1341
+ : this.db.prepare(`SELECT * FROM block_history ORDER BY changed_at DESC LIMIT ?`).all(limit);
1342
+ return rows.map((row) => ({
1343
+ id: row.id,
1344
+ block_id: row.block_id,
1345
+ field_changed: row.field_changed,
1346
+ old_value: row.old_value,
1347
+ new_value: row.new_value,
1348
+ changed_by: row.changed_by,
1349
+ changed_at: row.changed_at,
1350
+ reason: row.reason,
1351
+ }));
1352
+ }
1353
+ // ─── Custom Types ───────────────────────────────────────────────
1354
+ createBlockType(params) {
1355
+ if (!this.db)
1356
+ throw new Error("Database not initialized");
1357
+ try {
1358
+ this.db.prepare(`INSERT INTO block_types (name, extends, description, typical_fields) VALUES (?, ?, ?, ?)`).run(params.name, params.extends, params.description, JSON.stringify(params.typical_fields || []));
1359
+ return true;
1360
+ }
1361
+ catch (e) {
1362
+ if (String(e).includes("UNIQUE constraint"))
1363
+ return false;
1364
+ throw e;
1365
+ }
1366
+ }
1367
+ createRelationType(params) {
1368
+ if (!this.db)
1369
+ throw new Error("Database not initialized");
1370
+ try {
1371
+ this.db.prepare(`INSERT INTO relation_types (name, inverse, description) VALUES (?, ?, ?)`).run(params.name, params.inverse || null, params.description);
1372
+ return true;
1373
+ }
1374
+ catch (e) {
1375
+ if (String(e).includes("UNIQUE constraint"))
1376
+ return false;
1377
+ throw e;
1378
+ }
1379
+ }
1380
+ // ─── Projects ───────────────────────────────────────────────────
1381
+ createProjectLog(project, entry) {
1382
+ if (!this.db)
1383
+ throw new Error("Database not initialized");
1384
+ const log = {
1385
+ id: `log_${uuidv4().slice(0, 8)}`,
1386
+ project,
1387
+ entry,
1388
+ created_at: new Date().toISOString(),
1389
+ };
1390
+ this.db.prepare(`INSERT INTO project_logs (id, project, entry, created_at) VALUES (?, ?, ?, ?)`)
1391
+ .run(log.id, log.project, log.entry, log.created_at);
1392
+ return log;
1393
+ }
1394
+ getProjectLogs(project, limit = 5) {
1395
+ if (!this.db)
1396
+ throw new Error("Database not initialized");
1397
+ const rows = this.db.prepare(`SELECT * FROM project_logs WHERE project = ? ORDER BY created_at DESC LIMIT ?`).all(project, limit);
1398
+ return rows.map((row) => ({
1399
+ id: row.id,
1400
+ project: row.project,
1401
+ entry: row.entry,
1402
+ created_at: row.created_at,
1403
+ }));
1404
+ }
1405
+ getProjectBlocks(project) {
1406
+ if (!this.db)
1407
+ throw new Error("Database not initialized");
1408
+ const projectKey = project.toLowerCase();
1409
+ const projectRow = this.db.prepare(`SELECT id FROM blocks WHERE type = 'project' AND LOWER(label) = ? AND status != 'archived'`).get(projectKey);
1410
+ const projectId = projectRow?.id || null;
1411
+ const rows = this.db.prepare(`SELECT * FROM blocks
1412
+ WHERE status != 'archived'
1413
+ AND (
1414
+ (type = 'project' AND LOWER(label) = ?)
1415
+ OR (project_id IS NOT NULL AND project_id = ?)
1416
+ )`).all(projectKey, projectId);
1417
+ return rows.map((row) => this.decryptBlockIfSensitive(this.rowToBlock(row)));
1418
+ }
1419
+ updateEmbedding(id, embedding) {
1420
+ if (!this.db)
1421
+ throw new Error("Database not initialized");
1422
+ this.db.prepare(`UPDATE blocks SET embedding = ? WHERE id = ?`).run(JSON.stringify(embedding), id);
1423
+ // No history log — embeddings are derived data
1424
+ }
1425
+ getBlocksWithoutEmbeddings() {
1426
+ if (!this.db)
1427
+ throw new Error("Database not initialized");
1428
+ const rows = this.db.prepare(`SELECT * FROM blocks WHERE (embedding IS NULL OR embedding = '') AND status != 'archived'`).all();
1429
+ return rows.map((row) => this.decryptBlockIfSensitive(this.rowToBlock(row)));
1430
+ }
1431
+ getRecentBlocks(limit = 10) {
1432
+ if (!this.db)
1433
+ throw new Error("Database not initialized");
1434
+ const rows = this.db.prepare(`SELECT * FROM blocks WHERE status != 'archived' AND type != 'project' ORDER BY updated_at DESC LIMIT ?`).all(limit);
1435
+ return rows.map((row) => this.decryptBlockIfSensitive(this.rowToBlock(row)));
1436
+ }
1437
+ // ─── Lifecycle / GC ───────────────────────────────────────────
1438
+ promoteHotBlocks() {
1439
+ if (!this.db)
1440
+ throw new Error("Database not initialized");
1441
+ const HOT_TTLS = ["session", "1hr"];
1442
+ const PROMOTION_THRESHOLD = 3;
1443
+ const rows = this.db.prepare(`SELECT id, ttl, access_count FROM blocks WHERE status != 'archived' AND ttl IN ('session', '1hr')`).all();
1444
+ let promotedCount = 0;
1445
+ for (const row of rows) {
1446
+ if (HOT_TTLS.includes(row.ttl) && row.access_count >= PROMOTION_THRESHOLD) {
1447
+ this.db.prepare(`UPDATE blocks SET ttl = 'permanent', updated_at = ? WHERE id = ?`).run(new Date().toISOString(), row.id);
1448
+ promotedCount++;
1449
+ }
1450
+ }
1451
+ return { promoted: promotedCount };
1452
+ }
1453
+ runGC() {
1454
+ if (!this.db)
1455
+ throw new Error("Database not initialized");
1456
+ const now = Date.now();
1457
+ let archivedCount = 0;
1458
+ let protectedCount = 0;
1459
+ // Promote hot blocks BEFORE GC loop so they become permanent and are skipped by GC.
1460
+ // If promotion runs after, hot session blocks are archived before they can be saved.
1461
+ const { promoted } = this.promoteHotBlocks();
1462
+ const PROTECTED_TYPES = new Set(["decision", "constraint", "project"]);
1463
+ const rows = this.db.prepare(`SELECT id, status, ttl, updated_at, access_count, type FROM blocks WHERE status != 'archived'`).all();
1464
+ for (const row of rows) {
1465
+ const ageMs = now - new Date(row.updated_at).getTime();
1466
+ if (PROTECTED_TYPES.has(row.type) || row.ttl === "permanent") {
1467
+ protectedCount++;
1468
+ continue;
1469
+ }
1470
+ const relCheck = this.db.prepare(`SELECT COUNT(*) as cnt FROM relations WHERE (source_id = ? OR target_id = ?) AND valid_to IS NULL AND status = 'active'`).get(row.id, row.id);
1471
+ if (relCheck.cnt > 0) {
1472
+ protectedCount++;
1473
+ continue;
1474
+ }
1475
+ // Archive TTL blocks — agent explicitly set these as temporary
1476
+ if (row.ttl === "1hr" && ageMs > 60 * 60 * 1000) {
1477
+ this.db.prepare(`UPDATE blocks SET status = 'archived' WHERE id = ?`).run(row.id);
1478
+ archivedCount++;
1479
+ }
1480
+ else if (row.ttl === "24hr" && ageMs > 24 * 60 * 60 * 1000) {
1481
+ this.db.prepare(`UPDATE blocks SET status = 'archived' WHERE id = ?`).run(row.id);
1482
+ archivedCount++;
1483
+ }
1484
+ else if (row.ttl === "1week" && ageMs > 7 * 24 * 60 * 60 * 1000) {
1485
+ this.db.prepare(`UPDATE blocks SET status = 'archived' WHERE id = ?`).run(row.id);
1486
+ archivedCount++;
1487
+ }
1488
+ else if (row.ttl === "session" && ageMs > 24 * 60 * 60 * 1000) {
1489
+ this.db.prepare(`UPDATE blocks SET status = 'archived' WHERE id = ?`).run(row.id);
1490
+ archivedCount++;
1491
+ }
1492
+ }
1493
+ return { archived: archivedCount, protected: protectedCount, promoted };
1494
+ }
1495
+ // ─── Stats ────────────────────────────────────────────────────
1496
+ getStats() {
1497
+ if (!this.db)
1498
+ throw new Error("Database not initialized");
1499
+ const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM blocks WHERE status != 'archived'`).get().cnt;
1500
+ const byStatus = this.db.prepare(`SELECT status, COUNT(*) as cnt FROM blocks GROUP BY status`).all();
1501
+ const byType = this.db.prepare(`SELECT type, COUNT(*) as cnt FROM blocks WHERE status != 'archived' GROUP BY type`).all();
1502
+ const statusMap = {};
1503
+ for (const row of byStatus)
1504
+ statusMap[row.status] = row.cnt;
1505
+ const typeMap = {};
1506
+ for (const row of byType)
1507
+ typeMap[row.type] = row.cnt;
1508
+ return { total_blocks: total, by_status: statusMap, by_type: typeMap };
1509
+ }
1510
+ // ─── Recall Log ───────────────────────────────────────────────
1511
+ logRecall(blockId, projectId, reason, used = false) {
1512
+ if (!this.db)
1513
+ return;
1514
+ try {
1515
+ this.db.prepare(`INSERT INTO recall_log (id, block_id, recalled_at, project_id, reason, used) VALUES (?, ?, ?, ?, ?, ?)`).run(`rl_${uuidv4().slice(0, 8)}`, blockId, new Date().toISOString(), projectId || null, reason || null, used ? 1 : 0);
1516
+ this.db.prepare(`DELETE FROM recall_log WHERE recalled_at < datetime('now', '-90 days')`).run();
1517
+ }
1518
+ catch { /* non-critical */ }
1519
+ }
1520
+ markRecallUsed(blockId) {
1521
+ if (!this.db)
1522
+ return;
1523
+ try {
1524
+ this.db.prepare(`UPDATE recall_log SET used = 1 WHERE block_id = ? AND used = 0 AND recalled_at = (SELECT MAX(recalled_at) FROM recall_log WHERE block_id = ? AND used = 0)`).run(blockId, blockId);
1525
+ }
1526
+ catch { /* non-critical */ }
1527
+ }
1528
+ // ─── Reflect stamp ────────────────────────────────────────────────────────────
1529
+ // Updates last_reflected_at for a batch of block IDs. Bypasses updateBlock
1530
+ // intentionally — last_reflected_at is an operational timestamp (like last_accessed),
1531
+ // not a content field. Should not appear in block_history.
1532
+ stampReflectedAt(blockIds) {
1533
+ if (!this.db || blockIds.length === 0)
1534
+ return;
1535
+ const now = new Date().toISOString();
1536
+ const CHUNK = 900; // SQLite binding limit
1537
+ for (let i = 0; i < blockIds.length; i += CHUNK) {
1538
+ const chunk = blockIds.slice(i, i + CHUNK);
1539
+ const placeholders = chunk.map(() => "?").join(",");
1540
+ try {
1541
+ this.db.prepare(`UPDATE blocks SET last_reflected_at = ? WHERE id IN (${placeholders})`).run(now, ...chunk);
1542
+ }
1543
+ catch { /* non-critical */ }
1544
+ }
1545
+ }
1546
+ getWriteLog(limit = 50) {
1547
+ if (!this.db)
1548
+ return [];
1549
+ return this.db.prepare(`SELECT id, table_name, operation, row_id, snapshot, changed_at
1550
+ FROM write_log ORDER BY changed_at DESC LIMIT ?`).all(limit);
1551
+ }
1552
+ getRecallStats(limit = 20) {
1553
+ if (!this.db)
1554
+ return [];
1555
+ const rows = this.db.prepare(`SELECT rl.block_id, b.label, COUNT(*) as recall_count, SUM(rl.used) as use_count
1556
+ FROM recall_log rl
1557
+ INNER JOIN blocks b ON b.id = rl.block_id
1558
+ GROUP BY rl.block_id
1559
+ ORDER BY recall_count DESC
1560
+ LIMIT ?`).all(limit);
1561
+ return rows.map((row) => {
1562
+ const recallCount = row.recall_count || 1;
1563
+ const useCount = row.use_count || 0;
1564
+ return {
1565
+ block_id: row.block_id,
1566
+ label: row.label || "unknown",
1567
+ recall_count: recallCount,
1568
+ use_count: useCount,
1569
+ precision: Math.round((useCount / recallCount) * 100) / 100,
1570
+ };
1571
+ });
1572
+ }
1573
+ // ─── Near-Duplicate Conflicts ─────────────────────────────────
1574
+ createConflict(blockAId, blockBId, similarity) {
1575
+ if (!this.db)
1576
+ throw new Error("Database not initialized");
1577
+ const existing = this.db.prepare(`SELECT id FROM near_duplicate_conflicts WHERE ((block_a_id = ? AND block_b_id = ?) OR (block_a_id = ? AND block_b_id = ?)) AND resolved = 0`).get(blockAId, blockBId, blockBId, blockAId);
1578
+ if (existing)
1579
+ return existing.id;
1580
+ const id = `cnf_${uuidv4().slice(0, 8)}`;
1581
+ this.db.prepare(`INSERT INTO near_duplicate_conflicts (id, block_a_id, block_b_id, similarity, detected_at, resolved, resolution) VALUES (?, ?, ?, ?, ?, 0, NULL)`).run(id, blockAId, blockBId, similarity, new Date().toISOString());
1582
+ return id;
1583
+ }
1584
+ getOpenConflicts() {
1585
+ if (!this.db)
1586
+ return [];
1587
+ const rows = this.db.prepare(`SELECT id, block_a_id, block_b_id, similarity, detected_at FROM near_duplicate_conflicts WHERE resolved = 0 ORDER BY detected_at DESC`).all();
1588
+ return rows.map((row) => {
1589
+ const a = this.getBlock(row.block_a_id);
1590
+ const b = this.getBlock(row.block_b_id);
1591
+ return {
1592
+ id: row.id,
1593
+ block_a: a ? { id: a.id, label: a.label, essence: a.essence, type: a.type } : { id: row.block_a_id, label: "unknown" },
1594
+ block_b: b ? { id: b.id, label: b.label, essence: b.essence, type: b.type } : { id: row.block_b_id, label: "unknown" },
1595
+ similarity: row.similarity,
1596
+ detected_at: row.detected_at,
1597
+ };
1598
+ });
1599
+ }
1600
+ resolveConflict(conflictId, resolution) {
1601
+ if (!this.db)
1602
+ throw new Error("Database not initialized");
1603
+ this.db.prepare(`UPDATE near_duplicate_conflicts SET resolved = 1, resolution = ? WHERE id = ?`).run(resolution, conflictId);
1604
+ return true;
1605
+ }
1606
+ // ─── Multi-Agent: Atomic Claim ────────────────────────────────
1607
+ // Uses SQLite's synchronous writes + WAL to make claim check-and-set atomic.
1608
+ // No two agents can claim the same block simultaneously.
1609
+ claimBlock(blockId, agentId, ttlSeconds = 300) {
1610
+ if (!this.db)
1611
+ throw new Error("Database not initialized");
1612
+ const now = new Date().toISOString();
1613
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
1614
+ // Single transaction: expire stale claims, then attempt insert
1615
+ const result = this.db.transaction(() => {
1616
+ // Release any expired claims on this block first
1617
+ this.db.prepare(`DELETE FROM block_claims WHERE block_id = ? AND expires_at < ?`).run(blockId, now);
1618
+ // Try to insert — UNIQUE constraint on block_id means only one can succeed
1619
+ try {
1620
+ this.db.prepare(`INSERT INTO block_claims (block_id, agent_id, claimed_at, expires_at) VALUES (?, ?, ?, ?)`).run(blockId, agentId, now, expiresAt);
1621
+ return { claimed: true, expires_at: expiresAt };
1622
+ }
1623
+ catch {
1624
+ // Already claimed by another agent
1625
+ const existing = this.db.prepare(`SELECT agent_id, expires_at FROM block_claims WHERE block_id = ?`).get(blockId);
1626
+ return { claimed: false, claimed_by: existing?.agent_id };
1627
+ }
1628
+ })();
1629
+ return result;
1630
+ }
1631
+ releaseBlock(blockId, agentId) {
1632
+ if (!this.db)
1633
+ throw new Error("Database not initialized");
1634
+ const result = this.db.prepare(`DELETE FROM block_claims WHERE block_id = ? AND agent_id = ?`).run(blockId, agentId);
1635
+ return result.changes > 0;
1636
+ }
1637
+ getBlockClaim(blockId) {
1638
+ if (!this.db)
1639
+ throw new Error("Database not initialized");
1640
+ const now = new Date().toISOString();
1641
+ // Clean expired first
1642
+ this.db.prepare(`DELETE FROM block_claims WHERE expires_at < ?`).run(now);
1643
+ return this.db.prepare(`SELECT agent_id, claimed_at, expires_at FROM block_claims WHERE block_id = ?`).get(blockId);
1644
+ }
1645
+ // ─── Multi-Agent: Agent Registry ──────────────────────────────
1646
+ agentHeartbeat(agentId, role = "general", currentTask, metadata) {
1647
+ if (!this.db)
1648
+ throw new Error("Database not initialized");
1649
+ const now = new Date().toISOString();
1650
+ this.db.prepare(`
1651
+ INSERT INTO agent_registry (agent_id, role, last_heartbeat, status, current_task, metadata)
1652
+ VALUES (?, ?, ?, 'active', ?, ?)
1653
+ ON CONFLICT(agent_id) DO UPDATE SET
1654
+ last_heartbeat = excluded.last_heartbeat,
1655
+ status = 'active',
1656
+ current_task = excluded.current_task,
1657
+ metadata = excluded.metadata
1658
+ `).run(agentId, role, now, currentTask || null, JSON.stringify(metadata || {}));
1659
+ }
1660
+ getActiveAgents(staleAfterSeconds = 120) {
1661
+ if (!this.db)
1662
+ throw new Error("Database not initialized");
1663
+ const cutoff = new Date(Date.now() - staleAfterSeconds * 1000).toISOString();
1664
+ return this.db.prepare(`SELECT agent_id, role, last_heartbeat, current_task FROM agent_registry WHERE last_heartbeat > ? AND status = 'active' ORDER BY last_heartbeat DESC`).all(cutoff);
1665
+ }
1666
+ registerAgent(agentId, name, role = "general", metadata) {
1667
+ if (!this.db)
1668
+ throw new Error("Database not initialized");
1669
+ const now = new Date().toISOString();
1670
+ this.db.prepare(`
1671
+ INSERT INTO agent_registry (agent_id, name, role, last_heartbeat, status, current_task, metadata, created_at)
1672
+ VALUES (?, ?, ?, ?, 'active', NULL, ?, ?)
1673
+ ON CONFLICT(agent_id) DO UPDATE SET
1674
+ name = COALESCE(excluded.name, agent_registry.name),
1675
+ role = excluded.role,
1676
+ last_heartbeat = excluded.last_heartbeat,
1677
+ status = 'active',
1678
+ metadata = excluded.metadata
1679
+ `).run(agentId, name || null, role, now, JSON.stringify(metadata || {}), now);
1680
+ }
1681
+ getRegisteredAgents() {
1682
+ if (!this.db)
1683
+ throw new Error("Database not initialized");
1684
+ return this.db.prepare(`SELECT agent_id, name, role, last_heartbeat, status, created_at FROM agent_registry ORDER BY last_heartbeat DESC`).all();
1685
+ }
1686
+ // ─── Reflect Job Persistence ──────────────────────────────────
1687
+ insertReflectJob(id, agentId, payload) {
1688
+ if (!this.db)
1689
+ return;
1690
+ const now = Date.now();
1691
+ this.db.prepare(`INSERT INTO reflect_jobs (id, status, agent_id, payload, precomputed, retry_attempts, retry_after, created_at, updated_at, error)
1692
+ VALUES (?, 'pending', ?, ?, NULL, 0, NULL, ?, ?, NULL)`).run(id, agentId, payload, now, now);
1693
+ }
1694
+ updateReflectJob(id, fields) {
1695
+ if (!this.db)
1696
+ return;
1697
+ const sets = ['updated_at = ?'];
1698
+ const vals = [Date.now()];
1699
+ if (fields.status !== undefined) {
1700
+ sets.push('status = ?');
1701
+ vals.push(fields.status);
1702
+ }
1703
+ if (fields.precomputed !== undefined) {
1704
+ sets.push('precomputed = ?');
1705
+ vals.push(fields.precomputed);
1706
+ }
1707
+ if (fields.retry_attempts !== undefined) {
1708
+ sets.push('retry_attempts = ?');
1709
+ vals.push(fields.retry_attempts);
1710
+ }
1711
+ if (fields.retry_after !== undefined) {
1712
+ sets.push('retry_after = ?');
1713
+ vals.push(fields.retry_after);
1714
+ }
1715
+ if (fields.error !== undefined) {
1716
+ sets.push('error = ?');
1717
+ vals.push(fields.error);
1718
+ }
1719
+ vals.push(id);
1720
+ this.db.prepare(`UPDATE reflect_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
1721
+ }
1722
+ // Returns all jobs that need to be in memory: pending, retry_wait, and any
1723
+ // processing rows (which indicate a crash mid-job — caller resets them to pending).
1724
+ getActiveReflectJobs() {
1725
+ if (!this.db)
1726
+ return [];
1727
+ // Also recover jobs that were killed by the old attempt cap (error = 'max retries exceeded')
1728
+ // — those are retriable, not real failures.
1729
+ return this.db.prepare(`SELECT * FROM reflect_jobs WHERE status IN ('pending', 'processing', 'retry_wait')
1730
+ OR (status = 'dead' AND error = 'max retries exceeded')
1731
+ ORDER BY created_at ASC`).all();
1732
+ }
1733
+ // Deletes done jobs older than 24h and dead jobs older than 7 days.
1734
+ cleanupReflectJobs() {
1735
+ if (!this.db)
1736
+ return 0;
1737
+ const oneDayAgo = Date.now() - 86_400_000;
1738
+ const sevenDaysAgo = Date.now() - 604_800_000;
1739
+ const r1 = this.db.prepare(`DELETE FROM reflect_jobs WHERE status = 'done' AND updated_at < ?`).run(oneDayAgo);
1740
+ const r2 = this.db.prepare(`DELETE FROM reflect_jobs WHERE status = 'dead' AND updated_at < ?`).run(sevenDaysAgo);
1741
+ return (r1.changes ?? 0) + (r2.changes ?? 0);
1742
+ }
1743
+ // ─── DEBT 5 Phase 2: Conversation turn + range CRUD ────────────────────────
1744
+ //
1745
+ // Helpers for the Variant A persistence layer. Per-turn flow uses the first
1746
+ // four; arc-extract flow uses the range helpers + status flip helpers.
1747
+ //
1748
+ // All helpers throw on missing db. Use INSERT (not INSERT OR IGNORE) so
1749
+ // caller sees UNIQUE-constraint violations on duplicate (agent_id, turn_number) —
1750
+ // re-entry is a real bug, not silent idempotency.
1751
+ createConversationTurn(input) {
1752
+ if (!this.db)
1753
+ throw new Error("Database not initialized");
1754
+ const id = `ct_${uuidv4().slice(0, 12)}`;
1755
+ const now = new Date().toISOString();
1756
+ this.db.prepare(`INSERT INTO conversation_turns
1757
+ (id, agent_id, turn_number, turn_name, transcript_json, status, created_at)
1758
+ VALUES (?, ?, ?, ?, ?, 'captured', ?)`).run(id, input.agent_id, input.turn_number, input.turn_name ?? null, input.transcript_json, now);
1759
+ return this.getConversationTurnById(id);
1760
+ }
1761
+ updateConversationTurnPass01(id, pass01_output_json) {
1762
+ if (!this.db)
1763
+ throw new Error("Database not initialized");
1764
+ const now = new Date().toISOString();
1765
+ const r = this.db.prepare(`UPDATE conversation_turns
1766
+ SET pass01_output_json = ?, pass01_completed_at = ?, status = 'pass01_done'
1767
+ WHERE id = ? AND status = 'captured'`).run(pass01_output_json, now, id);
1768
+ if (r.changes === 0) {
1769
+ // Either row missing or status was already past 'captured' — both are bugs
1770
+ // upstream (caller should not double-flip). Surface loudly.
1771
+ throw new Error(`updateConversationTurnPass01: no row updated for id=${id} (already past 'captured' or missing)`);
1772
+ }
1773
+ }
1774
+ /** Lazy-capture REFILL: a turn was marked pass01_done WITHOUT Pass 0-1 (v2 lazy
1775
+ * capture skipped it); v2 then failed at arc, so the v1 fallback computed Pass
1776
+ * 0-1 now and writes the items back IN PLACE. Unlike updateConversationTurnPass01
1777
+ * (one-shot captured→pass01_done), this updates an already-pass01_done turn's
1778
+ * items without re-flipping status. Best-effort (no throw): a missing/extracted
1779
+ * row just means the fallback has nothing to fill. */
1780
+ refillConversationTurnPass01(id, pass01_output_json) {
1781
+ if (!this.db)
1782
+ throw new Error("Database not initialized");
1783
+ const now = new Date().toISOString();
1784
+ this.db.prepare(`UPDATE conversation_turns
1785
+ SET pass01_output_json = ?, pass01_completed_at = ?
1786
+ WHERE id = ? AND status = 'pass01_done'`).run(pass01_output_json, now, id);
1787
+ }
1788
+ markConversationTurnExtracted(id, pairing_range_id) {
1789
+ if (!this.db)
1790
+ throw new Error("Database not initialized");
1791
+ const now = new Date().toISOString();
1792
+ const r = this.db.prepare(`UPDATE conversation_turns
1793
+ SET extracted_at = ?, pairing_range_id = ?, status = 'extracted'
1794
+ WHERE id = ?`).run(now, pairing_range_id, id);
1795
+ if (r.changes === 0) {
1796
+ throw new Error(`markConversationTurnExtracted: no row updated for id=${id} (missing?)`);
1797
+ }
1798
+ }
1799
+ // followup 1: record a FAILED extraction attempt on a range's still-pending
1800
+ // turns. NON-destructive — turns stay pass01_done (re-extractable); this only
1801
+ // sets a marker so the freshness surface can tell "queued/coming" from "last
1802
+ // attempt failed, re-trigger". Best-effort; never throws (a failure path must
1803
+ // not be derailed by its own bookkeeping).
1804
+ markConversationTurnsExtractFailed(agent_id, startTurn, endTurn, error) {
1805
+ if (!this.db)
1806
+ throw new Error("Database not initialized");
1807
+ const now = new Date().toISOString();
1808
+ this.db.prepare(`UPDATE conversation_turns
1809
+ SET last_extract_error = ?, last_extract_attempt_at = ?
1810
+ WHERE agent_id = ? AND status = 'pass01_done' AND turn_number >= ? AND turn_number <= ?`).run(String(error).slice(0, 500), now, agent_id, startTurn, endTurn);
1811
+ }
1812
+ /** Distinct agent_ids whose arc extraction FAILED — turns are still pass01_done (re-extractable)
1813
+ * AND carry a last_extract_error marker. Used by the credit auto-resume to re-fire arc
1814
+ * extraction for arcs that were paused mid-flight: the per-turn reflectQueue's resume-drain
1815
+ * can't reach them (arc turns live here, not in the queue). Targets FAILED arcs only, so an
1816
+ * agent merely accumulating sub-threshold turns isn't extracted prematurely. */
1817
+ listAgentsWithFailedArc() {
1818
+ if (!this.db)
1819
+ throw new Error("Database not initialized");
1820
+ return this.db.prepare(`SELECT DISTINCT agent_id FROM conversation_turns
1821
+ WHERE status = 'pass01_done' AND last_extract_error IS NOT NULL`).all().map((r) => r.agent_id);
1822
+ }
1823
+ getConversationTurnById(id) {
1824
+ if (!this.db)
1825
+ throw new Error("Database not initialized");
1826
+ return this.db.prepare(`SELECT * FROM conversation_turns WHERE id = ?`).get(id) ?? null;
1827
+ }
1828
+ getConversationTurnByAgentTurn(agent_id, turn_number) {
1829
+ if (!this.db)
1830
+ throw new Error("Database not initialized");
1831
+ return this.db.prepare(`SELECT * FROM conversation_turns WHERE agent_id = ? AND turn_number = ?`).get(agent_id, turn_number) ?? null;
1832
+ }
1833
+ listConversationTurnsByAgent(agent_id, opts) {
1834
+ if (!this.db)
1835
+ throw new Error("Database not initialized");
1836
+ const clauses = [`agent_id = ?`];
1837
+ const params = [agent_id];
1838
+ if (opts?.status !== undefined) {
1839
+ clauses.push(`status = ?`);
1840
+ params.push(opts.status);
1841
+ }
1842
+ if (opts?.minTurn !== undefined) {
1843
+ clauses.push(`turn_number >= ?`);
1844
+ params.push(opts.minTurn);
1845
+ }
1846
+ if (opts?.maxTurn !== undefined) {
1847
+ clauses.push(`turn_number <= ?`);
1848
+ params.push(opts.maxTurn);
1849
+ }
1850
+ return this.db.prepare(`SELECT * FROM conversation_turns WHERE ${clauses.join(' AND ')} ORDER BY turn_number ASC`).all(...params);
1851
+ }
1852
+ createConversationTurnRange(input) {
1853
+ if (!this.db)
1854
+ throw new Error("Database not initialized");
1855
+ if (input.end_turn_number < input.start_turn_number) {
1856
+ throw new Error(`createConversationTurnRange: end_turn_number (${input.end_turn_number}) < start_turn_number (${input.start_turn_number})`);
1857
+ }
1858
+ const id = `ctr_${uuidv4().slice(0, 12)}`;
1859
+ const now = new Date().toISOString();
1860
+ this.db.prepare(`INSERT INTO conversation_turn_ranges
1861
+ (id, agent_id, start_turn_number, end_turn_number, extraction_type, extracted_at, trigger_source, pipeline_run_id, superseded_range_id)
1862
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.agent_id, input.start_turn_number, input.end_turn_number, input.extraction_type, now, input.trigger_source ?? null, input.pipeline_run_id ?? null, input.superseded_range_id ?? null);
1863
+ return this.getConversationTurnRange(id);
1864
+ }
1865
+ getConversationTurnRange(id) {
1866
+ if (!this.db)
1867
+ throw new Error("Database not initialized");
1868
+ return this.db.prepare(`SELECT * FROM conversation_turn_ranges WHERE id = ?`).get(id) ?? null;
1869
+ }
1870
+ // ─── DEBT 5 Phase 9: extracted_from provenance join ────────────────────────
1871
+ // Records the (block, range) pair. Idempotent — INSERT OR IGNORE on the
1872
+ // UNIQUE(block_id, range_id) constraint means a re-call for the same pair
1873
+ // is a no-op. Returns true when a new row was created, false on duplicate.
1874
+ recordBlockExtraction(block_id, range_id) {
1875
+ if (!this.db)
1876
+ throw new Error("Database not initialized");
1877
+ const id = `bx_${uuidv4().slice(0, 12)}`;
1878
+ const now = new Date().toISOString();
1879
+ const r = this.db.prepare(`INSERT OR IGNORE INTO block_extractions (id, block_id, range_id, extracted_at) VALUES (?, ?, ?, ?)`).run(id, block_id, range_id, now);
1880
+ return (r.changes ?? 0) > 0;
1881
+ }
1882
+ // List range IDs a block was extracted from (chronological — oldest first).
1883
+ // Multiple rows when the block has been re-extracted.
1884
+ getBlockExtractions(block_id) {
1885
+ if (!this.db)
1886
+ throw new Error("Database not initialized");
1887
+ return this.db.prepare(`SELECT * FROM block_extractions WHERE block_id = ? ORDER BY extracted_at ASC`).all(block_id);
1888
+ }
1889
+ // List block IDs produced by a given range (for audit + range diff).
1890
+ getBlocksByRange(range_id) {
1891
+ if (!this.db)
1892
+ throw new Error("Database not initialized");
1893
+ return this.db.prepare(`SELECT * FROM block_extractions WHERE range_id = ? ORDER BY extracted_at ASC`).all(range_id);
1894
+ }
1895
+ // ─── DEBT 5: agent-facing EXTRACTION FRESHNESS (pull via workspace_stats) ────
1896
+ // The agent is a PASSIVE MCP client — the system CANNOT push it a "your arc
1897
+ // extracted" signal. So the agent PULLS this inside a read result and learns
1898
+ // freshness exactly when it queries. It lets the agent tell three look-alike
1899
+ // "empty graph" states apart:
1900
+ // • pending (turns captured, not yet promoted to blocks) → "view may be stale"
1901
+ // • extracted WITH blocks → queryable now
1902
+ // • extracted with 0 blocks → ran, nothing worth saving
1903
+ // Identity is by TOPIC (the {project} label-prefix the arc produced), NOT
1904
+ // turn_number — that's host-assigned and the agent never holds it.
1905
+ listConversationTurnRangesByAgent(agent_id, limit = 5) {
1906
+ if (!this.db)
1907
+ throw new Error("Database not initialized");
1908
+ return this.db.prepare(`SELECT * FROM conversation_turn_ranges WHERE agent_id = ? ORDER BY extracted_at DESC LIMIT ?`).all(agent_id, limit);
1909
+ }
1910
+ // agent_id is OPTIONAL (followup 2): with it → scoped to that agent; without it
1911
+ // → GLOBAL most-recent activity (in single-agent use that IS the caller's own,
1912
+ // so the agent gets a freshness signal even without knowing its host-assigned id).
1913
+ getExtractionStatus(agent_id, recentLimit = 5) {
1914
+ if (!this.db)
1915
+ throw new Error("Database not initialized");
1916
+ // PENDING = captured (Pass 0-1 done) but not yet promoted to graph blocks.
1917
+ // followup 1: a FAILED arc fail-cleans to here too (turns stay re-extractable)
1918
+ // but now carries last_extract_error — so `failed` tells "queued/coming" from
1919
+ // "last attempt failed, re-trigger".
1920
+ const staged = agent_id
1921
+ ? this.listConversationTurnsByAgent(agent_id, { status: 'pass01_done' })
1922
+ : this.db.prepare(`SELECT * FROM conversation_turns WHERE status = 'pass01_done' ORDER BY turn_number ASC`).all();
1923
+ const failedTurns = staged.filter(t => t.last_extract_error);
1924
+ const pending = staged.length === 0 ? null : {
1925
+ turns: staged.length,
1926
+ span: `${staged[0].turn_number}-${staged[staged.length - 1].turn_number}`,
1927
+ topics: [...new Set(staged.map(t => t.turn_name).filter((n) => !!n))].slice(0, 3),
1928
+ failed: failedTurns.length > 0,
1929
+ last_error: failedTurns.length > 0 ? failedTurns[failedTurns.length - 1].last_extract_error : null,
1930
+ };
1931
+ // RECENT = the last N extracted arcs, summarized by what the agent ACTS on:
1932
+ // its topic (to recognize the work), the chain (the readable story handle),
1933
+ // and the block count (0 ⇒ ran-but-nothing-worth-saving). One indexed JOIN
1934
+ // per range — no getAllBlocks scan.
1935
+ const rangeBlocks = this.db.prepare(`SELECT b.label AS label, b.type AS type, b.chain_id AS chain_id FROM block_extractions bx
1936
+ JOIN blocks b ON b.id = bx.block_id WHERE bx.range_id = ?`);
1937
+ const ranges = agent_id
1938
+ ? this.listConversationTurnRangesByAgent(agent_id, recentLimit)
1939
+ : this.db.prepare(`SELECT * FROM conversation_turn_ranges ORDER BY extracted_at DESC LIMIT ?`).all(recentLimit);
1940
+ const recent = ranges.map(r => {
1941
+ const rows = rangeBlocks.all(r.id);
1942
+ // topic = the most common {project} label-prefix the arc produced.
1943
+ const prefixCounts = new Map();
1944
+ for (const row of rows) {
1945
+ const proj = String(row.label).split('_')[0];
1946
+ if (proj)
1947
+ prefixCounts.set(proj, (prefixCounts.get(proj) ?? 0) + 1);
1948
+ }
1949
+ const topic = [...prefixCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
1950
+ ?? `turns ${r.start_turn_number}-${r.end_turn_number}`;
1951
+ // chain = the readable-story handle. The Pass-5 chain block is NOT in
1952
+ // block_extractions, but the arc's atomic blocks carry chain_id = the chain
1953
+ // block's OWN id (verified on real data: atomic.chain_id === chain.id). So:
1954
+ // prefer an in-range chain block, else resolve the dominant chain_id to it.
1955
+ let chain = rows.find(row => row.type === 'chain')?.label ?? null;
1956
+ if (!chain) {
1957
+ const chainCounts = new Map();
1958
+ for (const row of rows)
1959
+ if (row.chain_id)
1960
+ chainCounts.set(row.chain_id, (chainCounts.get(row.chain_id) ?? 0) + 1);
1961
+ const topChainId = [...chainCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
1962
+ if (topChainId) {
1963
+ const cb = this.getBlock(topChainId);
1964
+ if (cb && cb.type === 'chain')
1965
+ chain = cb.label;
1966
+ }
1967
+ }
1968
+ return {
1969
+ topic,
1970
+ turns: `${r.start_turn_number}-${r.end_turn_number}`,
1971
+ blocks: rows.length,
1972
+ chain,
1973
+ extracted_at: r.extracted_at,
1974
+ };
1975
+ });
1976
+ return { pending, recent };
1977
+ }
1978
+ // ─── DEBT 5 Phase 10: inactivity safety net query ──────────────────────────
1979
+ // Returns distinct agent_ids that have AT LEAST ONE pass01_done turn whose
1980
+ // OLDEST pass01_done turn is older than `thresholdMs` ago AND no newer
1981
+ // 'captured' or 'pass01_done' activity since. These are stale conversations
1982
+ // where the agent likely walked away — fire arc extraction to capture before
1983
+ // residue is lost forever (per design §3.8 safety net 3).
1984
+ //
1985
+ // Returns at most `limit` (default 16) to bound work per timer tick.
1986
+ getAgentsWithStalePass01Turns(thresholdMs, limit = 16) {
1987
+ if (!this.db)
1988
+ throw new Error("Database not initialized");
1989
+ const cutoff = new Date(Date.now() - thresholdMs).toISOString();
1990
+ // Strategy: for each agent_id with pass01_done turns, find the MAX
1991
+ // (created_at) across all non-extracted statuses ('captured' OR
1992
+ // 'pass01_done') — that's the LAST activity. If MAX < cutoff, the
1993
+ // conversation is inactive enough to extract.
1994
+ const rows = this.db.prepare(`SELECT agent_id, MAX(created_at) AS last_activity
1995
+ FROM conversation_turns
1996
+ WHERE status IN ('captured', 'pass01_done')
1997
+ GROUP BY agent_id
1998
+ HAVING last_activity < ?
1999
+ ORDER BY last_activity ASC
2000
+ LIMIT ?`).all(cutoff, limit);
2001
+ return rows.map((r) => r.agent_id);
2002
+ }
2003
+ }
2004
+ //# sourceMappingURL=database.js.map