@pcircle/footprint 1.3.0 → 1.6.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 (349) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +215 -137
  3. package/SKILL.md +77 -33
  4. package/bin/footprint.js +16 -0
  5. package/dist/src/adapters/claude.d.ts +2 -0
  6. package/dist/src/adapters/claude.d.ts.map +1 -0
  7. package/dist/src/adapters/claude.js +7 -0
  8. package/dist/src/adapters/claude.js.map +1 -0
  9. package/dist/src/adapters/codex.d.ts +2 -0
  10. package/dist/src/adapters/codex.d.ts.map +1 -0
  11. package/dist/src/adapters/codex.js +7 -0
  12. package/dist/src/adapters/codex.js.map +1 -0
  13. package/dist/src/adapters/gemini.d.ts +2 -0
  14. package/dist/src/adapters/gemini.d.ts.map +1 -0
  15. package/dist/src/adapters/gemini.js +7 -0
  16. package/dist/src/adapters/gemini.js.map +1 -0
  17. package/dist/src/adapters/index.d.ts +5 -0
  18. package/dist/src/adapters/index.d.ts.map +1 -0
  19. package/dist/src/adapters/index.js +12 -0
  20. package/dist/src/adapters/index.js.map +1 -0
  21. package/dist/src/adapters/structured-prefix.d.ts +10 -0
  22. package/dist/src/adapters/structured-prefix.d.ts.map +1 -0
  23. package/dist/src/adapters/structured-prefix.js +59 -0
  24. package/dist/src/adapters/structured-prefix.js.map +1 -0
  25. package/dist/src/adapters/types.d.ts +32 -0
  26. package/dist/src/adapters/types.d.ts.map +1 -0
  27. package/dist/src/adapters/types.js +2 -0
  28. package/dist/src/adapters/types.js.map +1 -0
  29. package/dist/src/analyzers/content-analyzer.d.ts.map +1 -1
  30. package/dist/src/analyzers/content-analyzer.js +20 -4
  31. package/dist/src/analyzers/content-analyzer.js.map +1 -1
  32. package/dist/src/cli/context-flow.d.ts +92 -0
  33. package/dist/src/cli/context-flow.d.ts.map +1 -0
  34. package/dist/src/cli/context-flow.js +724 -0
  35. package/dist/src/cli/context-flow.js.map +1 -0
  36. package/dist/src/cli/history-display.d.ts +27 -0
  37. package/dist/src/cli/history-display.d.ts.map +1 -0
  38. package/dist/src/cli/history-display.js +167 -0
  39. package/dist/src/cli/history-display.js.map +1 -0
  40. package/dist/src/cli/index.js +924 -0
  41. package/dist/src/cli/index.js.map +1 -1
  42. package/dist/src/cli/launch-spec.d.ts +31 -0
  43. package/dist/src/cli/launch-spec.d.ts.map +1 -0
  44. package/dist/src/cli/launch-spec.js +182 -0
  45. package/dist/src/cli/launch-spec.js.map +1 -0
  46. package/dist/src/cli/live-demo.d.ts +34 -0
  47. package/dist/src/cli/live-demo.d.ts.map +1 -0
  48. package/dist/src/cli/live-demo.js +254 -0
  49. package/dist/src/cli/live-demo.js.map +1 -0
  50. package/dist/src/cli/pty-transcript.d.ts +34 -0
  51. package/dist/src/cli/pty-transcript.d.ts.map +1 -0
  52. package/dist/src/cli/pty-transcript.js +174 -0
  53. package/dist/src/cli/pty-transcript.js.map +1 -0
  54. package/dist/src/cli/session-display.d.ts +74 -0
  55. package/dist/src/cli/session-display.d.ts.map +1 -0
  56. package/dist/src/cli/session-display.js +922 -0
  57. package/dist/src/cli/session-display.js.map +1 -0
  58. package/dist/src/cli/session-execution.d.ts +55 -0
  59. package/dist/src/cli/session-execution.d.ts.map +1 -0
  60. package/dist/src/cli/session-execution.js +817 -0
  61. package/dist/src/cli/session-execution.js.map +1 -0
  62. package/dist/src/cli/session-runtime.d.ts +5 -0
  63. package/dist/src/cli/session-runtime.d.ts.map +1 -0
  64. package/dist/src/cli/session-runtime.js +11 -0
  65. package/dist/src/cli/session-runtime.js.map +1 -0
  66. package/dist/src/cli/setup.d.ts.map +1 -1
  67. package/dist/src/cli/setup.js +36 -12
  68. package/dist/src/cli/setup.js.map +1 -1
  69. package/dist/src/cli/utils/env.d.ts +7 -2
  70. package/dist/src/cli/utils/env.d.ts.map +1 -1
  71. package/dist/src/cli/utils/env.js +37 -6
  72. package/dist/src/cli/utils/env.js.map +1 -1
  73. package/dist/src/index.d.ts +4 -1
  74. package/dist/src/index.d.ts.map +1 -1
  75. package/dist/src/index.js +187 -33
  76. package/dist/src/index.js.map +1 -1
  77. package/dist/src/ingestion/deterministic.d.ts +3 -0
  78. package/dist/src/ingestion/deterministic.d.ts.map +1 -0
  79. package/dist/src/ingestion/deterministic.js +862 -0
  80. package/dist/src/ingestion/deterministic.js.map +1 -0
  81. package/dist/src/ingestion/index.d.ts +5 -0
  82. package/dist/src/ingestion/index.d.ts.map +1 -0
  83. package/dist/src/ingestion/index.js +27 -0
  84. package/dist/src/ingestion/index.js.map +1 -0
  85. package/dist/src/ingestion/semantic.d.ts +6 -0
  86. package/dist/src/ingestion/semantic.d.ts.map +1 -0
  87. package/dist/src/ingestion/semantic.js +627 -0
  88. package/dist/src/ingestion/semantic.js.map +1 -0
  89. package/dist/src/ingestion/types.d.ts +10 -0
  90. package/dist/src/ingestion/types.d.ts.map +1 -0
  91. package/dist/src/ingestion/types.js +2 -0
  92. package/dist/src/ingestion/types.js.map +1 -0
  93. package/dist/src/lib/context-memory.d.ts +140 -0
  94. package/dist/src/lib/context-memory.d.ts.map +1 -0
  95. package/dist/src/lib/context-memory.js +974 -0
  96. package/dist/src/lib/context-memory.js.map +1 -0
  97. package/dist/src/lib/crypto/decrypt.d.ts.map +1 -1
  98. package/dist/src/lib/crypto/decrypt.js +12 -8
  99. package/dist/src/lib/crypto/decrypt.js.map +1 -1
  100. package/dist/src/lib/crypto/encrypt.d.ts.map +1 -1
  101. package/dist/src/lib/crypto/encrypt.js +6 -3
  102. package/dist/src/lib/crypto/encrypt.js.map +1 -1
  103. package/dist/src/lib/crypto/key-derivation.d.ts +1 -1
  104. package/dist/src/lib/crypto/key-derivation.d.ts.map +1 -1
  105. package/dist/src/lib/crypto/key-derivation.js +11 -11
  106. package/dist/src/lib/crypto/key-derivation.js.map +1 -1
  107. package/dist/src/lib/history-handoff.d.ts +43 -0
  108. package/dist/src/lib/history-handoff.d.ts.map +1 -0
  109. package/dist/src/lib/history-handoff.js +179 -0
  110. package/dist/src/lib/history-handoff.js.map +1 -0
  111. package/dist/src/lib/observability.d.ts +3 -0
  112. package/dist/src/lib/observability.d.ts.map +1 -0
  113. package/dist/src/lib/observability.js +63 -0
  114. package/dist/src/lib/observability.js.map +1 -0
  115. package/dist/src/lib/session-artifacts.d.ts +51 -0
  116. package/dist/src/lib/session-artifacts.d.ts.map +1 -0
  117. package/dist/src/lib/session-artifacts.js +132 -0
  118. package/dist/src/lib/session-artifacts.js.map +1 -0
  119. package/dist/src/lib/session-filters.d.ts +11 -0
  120. package/dist/src/lib/session-filters.d.ts.map +1 -0
  121. package/dist/src/lib/session-filters.js +16 -0
  122. package/dist/src/lib/session-filters.js.map +1 -0
  123. package/dist/src/lib/session-history.d.ts +50 -0
  124. package/dist/src/lib/session-history.d.ts.map +1 -0
  125. package/dist/src/lib/session-history.js +73 -0
  126. package/dist/src/lib/session-history.js.map +1 -0
  127. package/dist/src/lib/session-trends.d.ts +129 -0
  128. package/dist/src/lib/session-trends.d.ts.map +1 -0
  129. package/dist/src/lib/session-trends.js +361 -0
  130. package/dist/src/lib/session-trends.js.map +1 -0
  131. package/dist/src/lib/storage/database.d.ts +257 -3
  132. package/dist/src/lib/storage/database.d.ts.map +1 -1
  133. package/dist/src/lib/storage/database.js +1836 -161
  134. package/dist/src/lib/storage/database.js.map +1 -1
  135. package/dist/src/lib/storage/export-sessions.d.ts +33 -0
  136. package/dist/src/lib/storage/export-sessions.d.ts.map +1 -0
  137. package/dist/src/lib/storage/export-sessions.js +525 -0
  138. package/dist/src/lib/storage/export-sessions.js.map +1 -0
  139. package/dist/src/lib/storage/export.d.ts +1 -2
  140. package/dist/src/lib/storage/export.d.ts.map +1 -1
  141. package/dist/src/lib/storage/export.js +46 -33
  142. package/dist/src/lib/storage/export.js.map +1 -1
  143. package/dist/src/lib/storage/index.d.ts +7 -6
  144. package/dist/src/lib/storage/index.d.ts.map +1 -1
  145. package/dist/src/lib/storage/index.js +6 -5
  146. package/dist/src/lib/storage/index.js.map +1 -1
  147. package/dist/src/lib/storage/salt-storage.d.ts +1 -1
  148. package/dist/src/lib/storage/salt-storage.d.ts.map +1 -1
  149. package/dist/src/lib/storage/salt-storage.js +26 -18
  150. package/dist/src/lib/storage/salt-storage.js.map +1 -1
  151. package/dist/src/lib/storage/schema.d.ts +7 -2
  152. package/dist/src/lib/storage/schema.d.ts.map +1 -1
  153. package/dist/src/lib/storage/schema.js +357 -40
  154. package/dist/src/lib/storage/schema.js.map +1 -1
  155. package/dist/src/lib/storage/types.d.ts +122 -0
  156. package/dist/src/lib/storage/types.d.ts.map +1 -1
  157. package/dist/src/lib/tool-wrapper.d.ts.map +1 -1
  158. package/dist/src/lib/tool-wrapper.js +2 -2
  159. package/dist/src/lib/tool-wrapper.js.map +1 -1
  160. package/dist/src/prompts/skill-prompt.d.ts +6 -0
  161. package/dist/src/prompts/skill-prompt.d.ts.map +1 -0
  162. package/dist/src/prompts/skill-prompt.js +138 -0
  163. package/dist/src/prompts/skill-prompt.js.map +1 -0
  164. package/dist/src/tools/capture-footprint.d.ts +2 -2
  165. package/dist/src/tools/capture-footprint.d.ts.map +1 -1
  166. package/dist/src/tools/capture-footprint.js +52 -11
  167. package/dist/src/tools/capture-footprint.js.map +1 -1
  168. package/dist/src/tools/confirm-context-link.d.ts +62 -0
  169. package/dist/src/tools/confirm-context-link.d.ts.map +1 -0
  170. package/dist/src/tools/confirm-context-link.js +36 -0
  171. package/dist/src/tools/confirm-context-link.js.map +1 -0
  172. package/dist/src/tools/context-schemas.d.ts +694 -0
  173. package/dist/src/tools/context-schemas.d.ts.map +1 -0
  174. package/dist/src/tools/context-schemas.js +171 -0
  175. package/dist/src/tools/context-schemas.js.map +1 -0
  176. package/dist/src/tools/delete-footprints.d.ts +18 -1
  177. package/dist/src/tools/delete-footprints.d.ts.map +1 -1
  178. package/dist/src/tools/delete-footprints.js +53 -5
  179. package/dist/src/tools/delete-footprints.js.map +1 -1
  180. package/dist/src/tools/export-footprints.d.ts +11 -3
  181. package/dist/src/tools/export-footprints.d.ts.map +1 -1
  182. package/dist/src/tools/export-footprints.js +48 -9
  183. package/dist/src/tools/export-footprints.js.map +1 -1
  184. package/dist/src/tools/export-sessions.d.ts +111 -0
  185. package/dist/src/tools/export-sessions.d.ts.map +1 -0
  186. package/dist/src/tools/export-sessions.js +136 -0
  187. package/dist/src/tools/export-sessions.js.map +1 -0
  188. package/dist/src/tools/get-context.d.ts +208 -0
  189. package/dist/src/tools/get-context.d.ts.map +1 -0
  190. package/dist/src/tools/get-context.js +27 -0
  191. package/dist/src/tools/get-context.js.map +1 -0
  192. package/dist/src/tools/get-footprint.d.ts +1 -7
  193. package/dist/src/tools/get-footprint.d.ts.map +1 -1
  194. package/dist/src/tools/get-footprint.js +7 -3
  195. package/dist/src/tools/get-footprint.js.map +1 -1
  196. package/dist/src/tools/get-history-handoff.d.ts +109 -0
  197. package/dist/src/tools/get-history-handoff.d.ts.map +1 -0
  198. package/dist/src/tools/get-history-handoff.js +85 -0
  199. package/dist/src/tools/get-history-handoff.js.map +1 -0
  200. package/dist/src/tools/get-history-trends.d.ts +155 -0
  201. package/dist/src/tools/get-history-trends.d.ts.map +1 -0
  202. package/dist/src/tools/get-history-trends.js +123 -0
  203. package/dist/src/tools/get-history-trends.js.map +1 -0
  204. package/dist/src/tools/get-session-artifacts.d.ts +151 -0
  205. package/dist/src/tools/get-session-artifacts.d.ts.map +1 -0
  206. package/dist/src/tools/get-session-artifacts.js +184 -0
  207. package/dist/src/tools/get-session-artifacts.js.map +1 -0
  208. package/dist/src/tools/get-session-decisions.d.ts +69 -0
  209. package/dist/src/tools/get-session-decisions.d.ts.map +1 -0
  210. package/dist/src/tools/get-session-decisions.js +99 -0
  211. package/dist/src/tools/get-session-decisions.js.map +1 -0
  212. package/dist/src/tools/get-session-messages.d.ts +55 -0
  213. package/dist/src/tools/get-session-messages.d.ts.map +1 -0
  214. package/dist/src/tools/get-session-messages.js +89 -0
  215. package/dist/src/tools/get-session-messages.js.map +1 -0
  216. package/dist/src/tools/get-session-narrative.d.ts +72 -0
  217. package/dist/src/tools/get-session-narrative.d.ts.map +1 -0
  218. package/dist/src/tools/get-session-narrative.js +106 -0
  219. package/dist/src/tools/get-session-narrative.js.map +1 -0
  220. package/dist/src/tools/get-session-timeline.d.ts +55 -0
  221. package/dist/src/tools/get-session-timeline.d.ts.map +1 -0
  222. package/dist/src/tools/get-session-timeline.js +93 -0
  223. package/dist/src/tools/get-session-timeline.js.map +1 -0
  224. package/dist/src/tools/get-session-trends.d.ts +108 -0
  225. package/dist/src/tools/get-session-trends.d.ts.map +1 -0
  226. package/dist/src/tools/get-session-trends.js +130 -0
  227. package/dist/src/tools/get-session-trends.js.map +1 -0
  228. package/dist/src/tools/get-session.d.ts +251 -0
  229. package/dist/src/tools/get-session.d.ts.map +1 -0
  230. package/dist/src/tools/get-session.js +290 -0
  231. package/dist/src/tools/get-session.js.map +1 -0
  232. package/dist/src/tools/index.d.ts +23 -3
  233. package/dist/src/tools/index.d.ts.map +1 -1
  234. package/dist/src/tools/index.js +23 -3
  235. package/dist/src/tools/index.js.map +1 -1
  236. package/dist/src/tools/list-contexts.d.ts +50 -0
  237. package/dist/src/tools/list-contexts.d.ts.map +1 -0
  238. package/dist/src/tools/list-contexts.js +28 -0
  239. package/dist/src/tools/list-contexts.js.map +1 -0
  240. package/dist/src/tools/list-footprints.d.ts +1 -15
  241. package/dist/src/tools/list-footprints.d.ts.map +1 -1
  242. package/dist/src/tools/list-footprints.js +17 -6
  243. package/dist/src/tools/list-footprints.js.map +1 -1
  244. package/dist/src/tools/list-sessions.d.ts +86 -0
  245. package/dist/src/tools/list-sessions.d.ts.map +1 -0
  246. package/dist/src/tools/list-sessions.js +97 -0
  247. package/dist/src/tools/list-sessions.js.map +1 -0
  248. package/dist/src/tools/manage-tags.d.ts +47 -0
  249. package/dist/src/tools/manage-tags.d.ts.map +1 -0
  250. package/dist/src/tools/manage-tags.js +109 -0
  251. package/dist/src/tools/manage-tags.js.map +1 -0
  252. package/dist/src/tools/merge-contexts.d.ts +58 -0
  253. package/dist/src/tools/merge-contexts.d.ts.map +1 -0
  254. package/dist/src/tools/merge-contexts.js +27 -0
  255. package/dist/src/tools/merge-contexts.js.map +1 -0
  256. package/dist/src/tools/move-session-context.d.ts +62 -0
  257. package/dist/src/tools/move-session-context.d.ts.map +1 -0
  258. package/dist/src/tools/move-session-context.js +33 -0
  259. package/dist/src/tools/move-session-context.js.map +1 -0
  260. package/dist/src/tools/reingest-session.d.ts +31 -0
  261. package/dist/src/tools/reingest-session.d.ts.map +1 -0
  262. package/dist/src/tools/reingest-session.js +43 -0
  263. package/dist/src/tools/reingest-session.js.map +1 -0
  264. package/dist/src/tools/reject-context-link.d.ts +58 -0
  265. package/dist/src/tools/reject-context-link.d.ts.map +1 -0
  266. package/dist/src/tools/reject-context-link.js +26 -0
  267. package/dist/src/tools/reject-context-link.js.map +1 -0
  268. package/dist/src/tools/resolve-context.d.ts +287 -0
  269. package/dist/src/tools/resolve-context.d.ts.map +1 -0
  270. package/dist/src/tools/resolve-context.js +35 -0
  271. package/dist/src/tools/resolve-context.js.map +1 -0
  272. package/dist/src/tools/search-footprints.d.ts +2 -16
  273. package/dist/src/tools/search-footprints.d.ts.map +1 -1
  274. package/dist/src/tools/search-footprints.js +23 -7
  275. package/dist/src/tools/search-footprints.js.map +1 -1
  276. package/dist/src/tools/search-history.d.ts +86 -0
  277. package/dist/src/tools/search-history.d.ts.map +1 -0
  278. package/dist/src/tools/search-history.js +103 -0
  279. package/dist/src/tools/search-history.js.map +1 -0
  280. package/dist/src/tools/session-ui-metadata.d.ts +15 -0
  281. package/dist/src/tools/session-ui-metadata.d.ts.map +1 -0
  282. package/dist/src/tools/session-ui-metadata.js +15 -0
  283. package/dist/src/tools/session-ui-metadata.js.map +1 -0
  284. package/dist/src/tools/set-active-context.d.ts +58 -0
  285. package/dist/src/tools/set-active-context.d.ts.map +1 -0
  286. package/dist/src/tools/set-active-context.js +26 -0
  287. package/dist/src/tools/set-active-context.js.map +1 -0
  288. package/dist/src/tools/split-context.d.ts +62 -0
  289. package/dist/src/tools/split-context.d.ts.map +1 -0
  290. package/dist/src/tools/split-context.js +36 -0
  291. package/dist/src/tools/split-context.js.map +1 -0
  292. package/dist/src/tools/suggest-capture.d.ts +1 -1
  293. package/dist/src/tools/suggest-capture.d.ts.map +1 -1
  294. package/dist/src/tools/suggest-capture.js +6 -2
  295. package/dist/src/tools/suggest-capture.js.map +1 -1
  296. package/dist/src/tools/verify-footprint.d.ts +7 -54
  297. package/dist/src/tools/verify-footprint.d.ts.map +1 -1
  298. package/dist/src/tools/verify-footprint.js +11 -8
  299. package/dist/src/tools/verify-footprint.js.map +1 -1
  300. package/dist/src/types.d.ts +6 -4
  301. package/dist/src/types.d.ts.map +1 -1
  302. package/dist/src/ui/register.d.ts +6 -1
  303. package/dist/src/ui/register.d.ts.map +1 -1
  304. package/dist/src/ui/register.js +60 -16
  305. package/dist/src/ui/register.js.map +1 -1
  306. package/dist/ui/dashboard.html +259 -875
  307. package/dist/ui/detail.html +124 -252
  308. package/dist/ui/export.html +133 -303
  309. package/dist/ui/session-dashboard-live.html +264 -0
  310. package/dist/ui/session-dashboard.html +329 -0
  311. package/dist/ui/session-detail-live.html +336 -0
  312. package/dist/ui/session-detail.html +355 -0
  313. package/package.json +61 -16
  314. package/dist/src/lib/errors/base-error.d.ts +0 -15
  315. package/dist/src/lib/errors/base-error.d.ts.map +0 -1
  316. package/dist/src/lib/errors/base-error.js +0 -34
  317. package/dist/src/lib/errors/base-error.js.map +0 -1
  318. package/dist/src/lib/errors/crypto-error.d.ts +0 -29
  319. package/dist/src/lib/errors/crypto-error.d.ts.map +0 -1
  320. package/dist/src/lib/errors/crypto-error.js +0 -43
  321. package/dist/src/lib/errors/crypto-error.js.map +0 -1
  322. package/dist/src/lib/errors/index.d.ts +0 -26
  323. package/dist/src/lib/errors/index.d.ts.map +0 -1
  324. package/dist/src/lib/errors/index.js +0 -26
  325. package/dist/src/lib/errors/index.js.map +0 -1
  326. package/dist/src/lib/errors/storage-error.d.ts +0 -25
  327. package/dist/src/lib/errors/storage-error.d.ts.map +0 -1
  328. package/dist/src/lib/errors/storage-error.js +0 -38
  329. package/dist/src/lib/errors/storage-error.js.map +0 -1
  330. package/dist/src/lib/errors/validation-error.d.ts +0 -21
  331. package/dist/src/lib/errors/validation-error.d.ts.map +0 -1
  332. package/dist/src/lib/errors/validation-error.js +0 -29
  333. package/dist/src/lib/errors/validation-error.js.map +0 -1
  334. package/dist/src/test-helpers.d.ts +0 -33
  335. package/dist/src/test-helpers.d.ts.map +0 -1
  336. package/dist/src/test-helpers.js +0 -108
  337. package/dist/src/test-helpers.js.map +0 -1
  338. package/dist/src/tools/get-tag-stats.d.ts +0 -30
  339. package/dist/src/tools/get-tag-stats.d.ts.map +0 -1
  340. package/dist/src/tools/get-tag-stats.js +0 -33
  341. package/dist/src/tools/get-tag-stats.js.map +0 -1
  342. package/dist/src/tools/remove-tag.d.ts +0 -22
  343. package/dist/src/tools/remove-tag.d.ts.map +0 -1
  344. package/dist/src/tools/remove-tag.js +0 -30
  345. package/dist/src/tools/remove-tag.js.map +0 -1
  346. package/dist/src/tools/rename-tag.d.ts +0 -24
  347. package/dist/src/tools/rename-tag.d.ts.map +0 -1
  348. package/dist/src/tools/rename-tag.js +0 -34
  349. package/dist/src/tools/rename-tag.js.map +0 -1
@@ -1,11 +1,66 @@
1
- import Database from 'better-sqlite3';
2
- import { createSchema } from './schema.js';
1
+ /* global Buffer, crypto */
2
+ import Database from "better-sqlite3";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { getArtifactSearchableText, parseArtifactMetadata, } from "../session-artifacts.js";
6
+ import { traceSyncOperation } from "../observability.js";
7
+ import { createSchema } from "./schema.js";
8
+ function escapeLikePattern(pattern) {
9
+ return pattern.replace(/[%_\\]/g, "\\$&");
10
+ }
11
+ function normalizeWorkspaceKeyForMatching(value) {
12
+ const resolved = path.resolve(value);
13
+ try {
14
+ return fs.realpathSync.native(resolved);
15
+ }
16
+ catch {
17
+ return resolved;
18
+ }
19
+ }
20
+ function matchesWorkspaceKey(session, workspaceKey) {
21
+ const normalizedWorkspaceKey = normalizeWorkspaceKeyForMatching(workspaceKey);
22
+ return (normalizeWorkspaceKeyForMatching(session.projectRoot) ===
23
+ normalizedWorkspaceKey ||
24
+ normalizeWorkspaceKeyForMatching(session.cwd) === normalizedWorkspaceKey);
25
+ }
26
+ /**
27
+ * Append LIMIT/OFFSET clause to SQL query string.
28
+ * Mutates the params array by pushing limit/offset values.
29
+ * @returns The query string with pagination appended.
30
+ */
31
+ function appendPaginationClause(query, params, limit, offset) {
32
+ const off = offset ?? 0;
33
+ if (limit !== undefined) {
34
+ query += " LIMIT ?";
35
+ params.push(limit);
36
+ if (off > 0) {
37
+ query += " OFFSET ?";
38
+ params.push(off);
39
+ }
40
+ }
41
+ else if (off > 0) {
42
+ query += " LIMIT -1 OFFSET ?";
43
+ params.push(off);
44
+ }
45
+ return query;
46
+ }
47
+ function formatDbError(action, error) {
48
+ return new Error(`Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
49
+ }
50
+ const TREND_FAILED_OUTCOME_PATTERN = /\b(?:fail|failed|error|timeout|timed-out|interrupted|non-zero)\b/i;
51
+ const TREND_SUCCEEDED_OUTCOME_PATTERN = /\b(?:success|succeeded|passed|completed|captured|ok)\b/i;
52
+ const SESSION_HISTORY_CACHE_VERSION_KEY = "session_history_cache_version";
53
+ const SESSION_TREND_CACHE_VERSION_KEY = "session_trend_cache_version";
54
+ const CURRENT_SESSION_HISTORY_CACHE_VERSION = 1;
55
+ const CURRENT_SESSION_TREND_CACHE_VERSION = 1;
3
56
  /**
4
57
  * Evidence database with CRUD operations
5
58
  * Manages encrypted evidence storage with SQLite backend
6
59
  */
7
60
  export class EvidenceDatabase {
8
61
  db;
62
+ sessionHistoryCacheBackfilled = false;
63
+ sessionTrendAttemptsBackfilled = false;
9
64
  /**
10
65
  * Creates or opens an evidence database
11
66
  * @param dbPath - Path to SQLite database file
@@ -15,12 +70,101 @@ export class EvidenceDatabase {
15
70
  this.db = new Database(dbPath);
16
71
  try {
17
72
  createSchema(this.db);
73
+ this.initializeMaterializedCaches();
18
74
  }
19
75
  catch (error) {
20
76
  // Clean up database connection on any initialization failure
21
77
  this.db.close();
22
- throw new Error(`Failed to initialize database: ${error instanceof Error ? error.message : String(error)}`);
78
+ throw formatDbError("initialize database", error);
79
+ }
80
+ }
81
+ getMetadataValue(key) {
82
+ const row = this.db
83
+ .prepare(`
84
+ SELECT value
85
+ FROM metadata
86
+ WHERE key = ?
87
+ `)
88
+ .get(key);
89
+ return row?.value ?? null;
90
+ }
91
+ getMetadataVersion(key) {
92
+ const value = this.getMetadataValue(key);
93
+ const parsed = Number.parseInt(value ?? "", 10);
94
+ return Number.isFinite(parsed) ? parsed : 0;
95
+ }
96
+ setMetadataValue(key, value) {
97
+ this.db
98
+ .prepare(`
99
+ INSERT INTO metadata (key, value)
100
+ VALUES (?, ?)
101
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
102
+ `)
103
+ .run(key, value);
104
+ }
105
+ getAllSessionIds() {
106
+ return this.db
107
+ .prepare(`
108
+ SELECT id
109
+ FROM sessions
110
+ ORDER BY startedAt ASC, id ASC
111
+ `)
112
+ .all().map((row) => row.id);
113
+ }
114
+ initializeMaterializedCaches() {
115
+ if (this.getMetadataVersion(SESSION_HISTORY_CACHE_VERSION_KEY) <
116
+ CURRENT_SESSION_HISTORY_CACHE_VERSION) {
117
+ this.rebuildAllSessionHistoryCaches();
118
+ this.setMetadataValue(SESSION_HISTORY_CACHE_VERSION_KEY, String(CURRENT_SESSION_HISTORY_CACHE_VERSION));
119
+ this.sessionHistoryCacheBackfilled = true;
120
+ }
121
+ else {
122
+ this.sessionHistoryCacheBackfilled = false;
123
+ this.ensureSessionHistoryCacheBackfilled();
124
+ }
125
+ if (this.getMetadataVersion(SESSION_TREND_CACHE_VERSION_KEY) <
126
+ CURRENT_SESSION_TREND_CACHE_VERSION) {
127
+ this.rebuildAllSessionTrendAttempts();
128
+ this.setMetadataValue(SESSION_TREND_CACHE_VERSION_KEY, String(CURRENT_SESSION_TREND_CACHE_VERSION));
129
+ this.sessionTrendAttemptsBackfilled = true;
130
+ }
131
+ else {
132
+ this.sessionTrendAttemptsBackfilled = false;
133
+ this.ensureSessionTrendAttemptsBackfilled();
134
+ }
135
+ }
136
+ dbOp(action, fn) {
137
+ try {
138
+ return fn();
139
+ }
140
+ catch (error) {
141
+ throw formatDbError(action, error);
142
+ }
143
+ }
144
+ resolveActiveContextIdOrThrow(contextId) {
145
+ let currentId = contextId.trim();
146
+ const visited = new Set();
147
+ while (currentId) {
148
+ if (visited.has(currentId)) {
149
+ throw new Error(`Context merge loop detected for ${contextId}`);
150
+ }
151
+ visited.add(currentId);
152
+ const row = this.db
153
+ .prepare(`
154
+ SELECT id, status, mergedIntoContextId
155
+ FROM contexts
156
+ WHERE id = ?
157
+ `)
158
+ .get(currentId);
159
+ if (!row) {
160
+ throw new Error(`Context not found: ${contextId}`);
161
+ }
162
+ if (row.status !== "merged" || !row.mergedIntoContextId) {
163
+ return row.id;
164
+ }
165
+ currentId = row.mergedIntoContextId;
23
166
  }
167
+ throw new Error(`Context not found: ${contextId}`);
24
168
  }
25
169
  /**
26
170
  * Creates a new evidence record
@@ -30,7 +174,7 @@ export class EvidenceDatabase {
30
174
  create(evidence) {
31
175
  const id = crypto.randomUUID();
32
176
  const now = new Date().toISOString();
33
- try {
177
+ return this.dbOp("create evidence", () => {
34
178
  const stmt = this.db.prepare(`
35
179
  INSERT INTO evidences (
36
180
  id, timestamp, conversationId, llmProvider,
@@ -40,10 +184,7 @@ export class EvidenceDatabase {
40
184
  `);
41
185
  stmt.run(id, evidence.timestamp, evidence.conversationId, evidence.llmProvider, Buffer.from(evidence.encryptedContent), Buffer.from(evidence.nonce), evidence.contentHash, evidence.messageCount, evidence.gitCommitHash, evidence.gitTimestamp, evidence.tags, now, now);
42
186
  return id;
43
- }
44
- catch (error) {
45
- throw new Error(`Failed to create evidence: ${error instanceof Error ? error.message : String(error)}`);
46
- }
187
+ });
47
188
  }
48
189
  /**
49
190
  * Finds evidence by ID
@@ -51,7 +192,7 @@ export class EvidenceDatabase {
51
192
  * @returns Evidence or null if not found
52
193
  */
53
194
  findById(id) {
54
- try {
195
+ return this.dbOp("find evidence by ID", () => {
55
196
  const stmt = this.db.prepare(`
56
197
  SELECT * FROM evidences WHERE id = ?
57
198
  `);
@@ -60,11 +201,7 @@ export class EvidenceDatabase {
60
201
  return null;
61
202
  }
62
203
  return this.rowToEvidence(row);
63
- }
64
- catch (error) {
65
- // Don't swallow database errors - re-throw with context
66
- throw new Error(`Failed to find evidence by ID: ${error instanceof Error ? error.message : String(error)}`);
67
- }
204
+ });
68
205
  }
69
206
  /**
70
207
  * Finds all evidences for a conversation
@@ -72,7 +209,7 @@ export class EvidenceDatabase {
72
209
  * @returns Array of evidences (empty if none found)
73
210
  */
74
211
  findByConversationId(conversationId) {
75
- try {
212
+ return this.dbOp("find evidences by conversationId", () => {
76
213
  const stmt = this.db.prepare(`
77
214
  SELECT * FROM evidences
78
215
  WHERE conversationId = ?
@@ -80,10 +217,7 @@ export class EvidenceDatabase {
80
217
  `);
81
218
  const rows = stmt.all(conversationId);
82
219
  return rows.map((row) => this.rowToEvidence(row));
83
- }
84
- catch (error) {
85
- throw new Error(`Failed to find evidences by conversationId: ${error instanceof Error ? error.message : String(error)}`);
86
- }
220
+ });
87
221
  }
88
222
  /**
89
223
  * Lists evidences with pagination
@@ -91,31 +225,1407 @@ export class EvidenceDatabase {
91
225
  * @returns Array of evidences
92
226
  */
93
227
  list(options) {
228
+ return this.dbOp("list evidences", () => {
229
+ const params = [];
230
+ const query = appendPaginationClause("SELECT * FROM evidences ORDER BY timestamp DESC", params, options?.limit, options?.offset);
231
+ const stmt = this.db.prepare(query);
232
+ const rows = stmt.all(...params);
233
+ return rows.map((row) => this.rowToEvidence(row));
234
+ });
235
+ }
236
+ static joinSearchParts(parts) {
237
+ return parts
238
+ .map((part) => part.trim())
239
+ .filter(Boolean)
240
+ .join("\n");
241
+ }
242
+ static appendSearchPart(existing, addition) {
243
+ const normalizedAddition = addition.trim();
244
+ if (!normalizedAddition) {
245
+ return existing;
246
+ }
247
+ return existing.trim()
248
+ ? `${existing}\n${normalizedAddition}`
249
+ : normalizedAddition;
250
+ }
251
+ static normalizeTrendOutcome(value) {
252
+ if (!value) {
253
+ return "other";
254
+ }
255
+ if (TREND_FAILED_OUTCOME_PATTERN.test(value)) {
256
+ return "failed";
257
+ }
258
+ if (TREND_SUCCEEDED_OUTCOME_PATTERN.test(value)) {
259
+ return "succeeded";
260
+ }
261
+ return "other";
262
+ }
263
+ buildArtifactHistoryCache(artifacts) {
264
+ const issueKeys = new Set();
265
+ const text = EvidenceDatabase.joinSearchParts(artifacts.flatMap((artifact) => {
266
+ const metadata = parseArtifactMetadata(artifact.metadata);
267
+ if (metadata.issueKey) {
268
+ issueKeys.add(metadata.issueKey);
269
+ }
270
+ return getArtifactSearchableText(artifact);
271
+ }));
272
+ return {
273
+ text,
274
+ issueKeys: Array.from(issueKeys).sort(),
275
+ };
276
+ }
277
+ buildNarrativeHistoryCache(narratives) {
278
+ return EvidenceDatabase.joinSearchParts(narratives.map((narrative) => narrative.content));
279
+ }
280
+ buildDecisionHistoryCache(decisions) {
281
+ return EvidenceDatabase.joinSearchParts(decisions.map((decision) => decision.summary));
282
+ }
283
+ ensureSessionHistoryCacheRow(sessionId) {
284
+ this.db
285
+ .prepare(`
286
+ INSERT INTO session_history_cache (
287
+ sessionId, titleText, metadataText, messagesText,
288
+ artifactsText, narrativesText, decisionsText, updatedAt
289
+ )
290
+ SELECT
291
+ id,
292
+ COALESCE(title, ''),
293
+ COALESCE(metadata, ''),
294
+ '',
295
+ '',
296
+ '',
297
+ '',
298
+ ?
299
+ FROM sessions
300
+ WHERE id = ?
301
+ ON CONFLICT(sessionId) DO NOTHING
302
+ `)
303
+ .run(new Date().toISOString(), sessionId);
304
+ }
305
+ updateSessionHistoryCache(sessionId, updates) {
306
+ this.ensureSessionHistoryCacheRow(sessionId);
307
+ const current = this.db
308
+ .prepare(`
309
+ SELECT * FROM session_history_cache
310
+ WHERE sessionId = ?
311
+ `)
312
+ .get(sessionId);
313
+ if (!current) {
314
+ return;
315
+ }
316
+ const next = {
317
+ ...current,
318
+ ...updates,
319
+ sessionId,
320
+ updatedAt: new Date().toISOString(),
321
+ };
322
+ this.db
323
+ .prepare(`
324
+ UPDATE session_history_cache
325
+ SET titleText = ?,
326
+ metadataText = ?,
327
+ messagesText = ?,
328
+ artifactsText = ?,
329
+ narrativesText = ?,
330
+ decisionsText = ?,
331
+ updatedAt = ?
332
+ WHERE sessionId = ?
333
+ `)
334
+ .run(next.titleText, next.metadataText, next.messagesText, next.artifactsText, next.narrativesText, next.decisionsText, next.updatedAt, sessionId);
335
+ }
336
+ replaceSessionIssueKeys(sessionId, issueKeys) {
337
+ this.db
338
+ .prepare(`DELETE FROM session_issue_keys WHERE sessionId = ?`)
339
+ .run(sessionId);
340
+ const insertIssueKey = this.db.prepare(`
341
+ INSERT INTO session_issue_keys (sessionId, issueKey)
342
+ VALUES (?, ?)
343
+ `);
344
+ for (const issueKey of issueKeys) {
345
+ insertIssueKey.run(sessionId, issueKey);
346
+ }
347
+ }
348
+ rebuildSessionHistoryCache(sessionId) {
349
+ const session = this.findSessionById(sessionId);
350
+ if (!session) {
351
+ return;
352
+ }
353
+ const messagesText = EvidenceDatabase.joinSearchParts(this.getSessionMessages(sessionId).map((message) => message.content));
354
+ const artifacts = this.getSessionArtifacts(sessionId);
355
+ const artifactCache = this.buildArtifactHistoryCache(artifacts);
356
+ const narrativesText = this.buildNarrativeHistoryCache(this.getSessionNarratives(sessionId));
357
+ const decisionsText = this.buildDecisionHistoryCache(this.getSessionDecisions(sessionId));
358
+ this.updateSessionHistoryCache(sessionId, {
359
+ titleText: session.title ?? "",
360
+ metadataText: session.metadata ?? "",
361
+ messagesText,
362
+ artifactsText: artifactCache.text,
363
+ narrativesText,
364
+ decisionsText,
365
+ });
366
+ this.replaceSessionIssueKeys(sessionId, artifactCache.issueKeys);
367
+ }
368
+ ensureSessionHistoryCacheBackfilled() {
369
+ if (this.sessionHistoryCacheBackfilled) {
370
+ return;
371
+ }
372
+ const missingRows = this.db
373
+ .prepare(`
374
+ SELECT s.id
375
+ FROM sessions s
376
+ LEFT JOIN session_history_cache cache ON cache.sessionId = s.id
377
+ WHERE cache.sessionId IS NULL
378
+ ORDER BY s.startedAt ASC, s.id ASC
379
+ `)
380
+ .all();
381
+ for (const row of missingRows) {
382
+ this.rebuildSessionHistoryCache(row.id);
383
+ }
384
+ this.sessionHistoryCacheBackfilled = true;
385
+ }
386
+ rebuildAllSessionHistoryCaches() {
387
+ for (const sessionId of this.getAllSessionIds()) {
388
+ this.rebuildSessionHistoryCache(sessionId);
389
+ }
390
+ }
391
+ fetchTrendSeenAtByEventIds(sessionId, eventIds) {
392
+ if (eventIds.length === 0) {
393
+ return new Map();
394
+ }
395
+ const placeholders = eventIds.map(() => "?").join(", ");
396
+ const rows = this.db
397
+ .prepare(`
398
+ SELECT id, COALESCE(endedAt, startedAt) as seenAt
399
+ FROM timeline_events
400
+ WHERE sessionId = ? AND id IN (${placeholders})
401
+ `)
402
+ .all(sessionId, ...eventIds);
403
+ return new Map(rows.map((row) => [row.id, row.seenAt]));
404
+ }
405
+ buildSessionTrendAttempts(sessionId, artifacts) {
406
+ const eventIds = [
407
+ ...new Set(artifacts
408
+ .map((artifact) => artifact.eventId)
409
+ .filter((eventId) => Boolean(eventId))),
410
+ ];
411
+ const seenAtByEventId = this.fetchTrendSeenAtByEventIds(sessionId, eventIds);
412
+ return artifacts.flatMap((artifact) => {
413
+ const metadata = parseArtifactMetadata(artifact.metadata);
414
+ const eventType = typeof metadata.details.eventType === "string"
415
+ ? metadata.details.eventType
416
+ : null;
417
+ const outcome = metadata.outcome ?? metadata.status ?? "captured";
418
+ const outcomeCategory = EvidenceDatabase.normalizeTrendOutcome(outcome);
419
+ if (!artifact.eventId || !metadata.issueKey) {
420
+ return [];
421
+ }
422
+ if (!eventType ||
423
+ (!eventType.startsWith("command.") && !eventType.startsWith("test."))) {
424
+ return [];
425
+ }
426
+ if (outcomeCategory === "other") {
427
+ return [];
428
+ }
429
+ return [
430
+ {
431
+ artifactId: artifact.id,
432
+ sessionId,
433
+ issueKey: metadata.issueKey,
434
+ issueLabel: metadata.issueLabel ?? metadata.issueKey,
435
+ kind: metadata.intent ?? metadata.category,
436
+ issueFamilyKey: metadata.issueFamilyKey,
437
+ issueFamilyLabel: metadata.issueFamilyLabel ??
438
+ metadata.issueFamilyKey ??
439
+ metadata.issueLabel,
440
+ outcome,
441
+ outcomeCategory,
442
+ seenAt: seenAtByEventId.get(artifact.eventId) ?? artifact.createdAt,
443
+ createdAt: artifact.createdAt,
444
+ },
445
+ ];
446
+ });
447
+ }
448
+ markSessionTrendAttemptsFresh(sessionId) {
449
+ this.db
450
+ .prepare(`
451
+ INSERT INTO session_trend_cache_state (sessionId, updatedAt)
452
+ VALUES (?, ?)
453
+ ON CONFLICT(sessionId) DO UPDATE SET updatedAt = excluded.updatedAt
454
+ `)
455
+ .run(sessionId, new Date().toISOString());
456
+ }
457
+ replaceSessionTrendAttempts(sessionId, artifacts) {
458
+ this.db
459
+ .prepare(`DELETE FROM session_trend_attempts WHERE sessionId = ?`)
460
+ .run(sessionId);
461
+ const attempts = this.buildSessionTrendAttempts(sessionId, artifacts);
462
+ if (attempts.length === 0) {
463
+ this.markSessionTrendAttemptsFresh(sessionId);
464
+ return;
465
+ }
466
+ const insertAttempt = this.db.prepare(`
467
+ INSERT INTO session_trend_attempts (
468
+ artifactId, sessionId, issueKey, issueLabel, kind,
469
+ issueFamilyKey, issueFamilyLabel, outcome, outcomeCategory,
470
+ seenAt, createdAt
471
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
472
+ `);
473
+ for (const attempt of attempts) {
474
+ insertAttempt.run(attempt.artifactId, attempt.sessionId, attempt.issueKey, attempt.issueLabel, attempt.kind, attempt.issueFamilyKey, attempt.issueFamilyLabel, attempt.outcome, attempt.outcomeCategory, attempt.seenAt, attempt.createdAt);
475
+ }
476
+ this.markSessionTrendAttemptsFresh(sessionId);
477
+ }
478
+ insertSessionTrendAttempts(sessionId, artifacts) {
479
+ const attempts = this.buildSessionTrendAttempts(sessionId, artifacts);
480
+ if (attempts.length === 0) {
481
+ this.markSessionTrendAttemptsFresh(sessionId);
482
+ return;
483
+ }
484
+ const insertAttempt = this.db.prepare(`
485
+ INSERT INTO session_trend_attempts (
486
+ artifactId, sessionId, issueKey, issueLabel, kind,
487
+ issueFamilyKey, issueFamilyLabel, outcome, outcomeCategory,
488
+ seenAt, createdAt
489
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
490
+ ON CONFLICT(artifactId) DO UPDATE SET
491
+ sessionId = excluded.sessionId,
492
+ issueKey = excluded.issueKey,
493
+ issueLabel = excluded.issueLabel,
494
+ kind = excluded.kind,
495
+ issueFamilyKey = excluded.issueFamilyKey,
496
+ issueFamilyLabel = excluded.issueFamilyLabel,
497
+ outcome = excluded.outcome,
498
+ outcomeCategory = excluded.outcomeCategory,
499
+ seenAt = excluded.seenAt,
500
+ createdAt = excluded.createdAt
501
+ `);
502
+ for (const attempt of attempts) {
503
+ insertAttempt.run(attempt.artifactId, attempt.sessionId, attempt.issueKey, attempt.issueLabel, attempt.kind, attempt.issueFamilyKey, attempt.issueFamilyLabel, attempt.outcome, attempt.outcomeCategory, attempt.seenAt, attempt.createdAt);
504
+ }
505
+ this.markSessionTrendAttemptsFresh(sessionId);
506
+ }
507
+ rebuildSessionTrendAttempts(sessionId) {
508
+ const session = this.findSessionById(sessionId);
509
+ if (!session) {
510
+ return;
511
+ }
512
+ this.replaceSessionTrendAttempts(sessionId, this.getSessionArtifacts(sessionId));
513
+ }
514
+ ensureSessionTrendAttemptsBackfilled() {
515
+ if (this.sessionTrendAttemptsBackfilled) {
516
+ return;
517
+ }
518
+ const missingRows = this.db
519
+ .prepare(`
520
+ SELECT s.id
521
+ FROM sessions s
522
+ LEFT JOIN session_trend_cache_state state ON state.sessionId = s.id
523
+ WHERE state.sessionId IS NULL
524
+ ORDER BY s.startedAt ASC, s.id ASC
525
+ `)
526
+ .all();
527
+ for (const row of missingRows) {
528
+ this.rebuildSessionTrendAttempts(row.id);
529
+ }
530
+ this.sessionTrendAttemptsBackfilled = true;
531
+ }
532
+ rebuildAllSessionTrendAttempts() {
533
+ for (const sessionId of this.getAllSessionIds()) {
534
+ this.rebuildSessionTrendAttempts(sessionId);
535
+ }
536
+ }
537
+ querySessionsByHistory(options) {
538
+ return traceSyncOperation("db.query-sessions-by-history", {
539
+ host: options.host,
540
+ status: options.status,
541
+ hasQuery: Boolean(options.query?.trim()),
542
+ issueKey: options.issueKey?.trim() || undefined,
543
+ sessionIds: options.sessionIds?.length ?? 0,
544
+ limit: options.limit,
545
+ offset: options.offset,
546
+ }, () => this.dbOp("query sessions by history", () => {
547
+ this.ensureSessionHistoryCacheBackfilled();
548
+ const conditions = [];
549
+ const params = [];
550
+ if (options.host) {
551
+ conditions.push(`s.host = ?`);
552
+ params.push(options.host);
553
+ }
554
+ if (options.status) {
555
+ conditions.push(`s.status = ?`);
556
+ params.push(options.status);
557
+ }
558
+ if (options.query && options.query.trim()) {
559
+ const pattern = `%${escapeLikePattern(options.query.trim())}%`;
560
+ conditions.push(`
561
+ (
562
+ cache.titleText LIKE ? ESCAPE '\\'
563
+ OR cache.metadataText LIKE ? ESCAPE '\\'
564
+ OR cache.messagesText LIKE ? ESCAPE '\\'
565
+ OR cache.artifactsText LIKE ? ESCAPE '\\'
566
+ OR cache.narrativesText LIKE ? ESCAPE '\\'
567
+ OR cache.decisionsText LIKE ? ESCAPE '\\'
568
+ )
569
+ `);
570
+ params.push(pattern, pattern, pattern, pattern, pattern, pattern);
571
+ }
572
+ if (options.issueKey && options.issueKey.trim()) {
573
+ conditions.push(`
574
+ EXISTS (
575
+ SELECT 1
576
+ FROM session_issue_keys issue_keys
577
+ WHERE issue_keys.sessionId = s.id
578
+ AND issue_keys.issueKey = ?
579
+ )
580
+ `);
581
+ params.push(options.issueKey.trim());
582
+ }
583
+ if (options.sessionIds && options.sessionIds.length > 0) {
584
+ const normalizedIds = options.sessionIds
585
+ .map((sessionId) => sessionId.trim())
586
+ .filter(Boolean);
587
+ if (normalizedIds.length === 0) {
588
+ return {
589
+ sessions: [],
590
+ total: 0,
591
+ };
592
+ }
593
+ conditions.push(`s.id IN (${normalizedIds.map(() => "?").join(", ")})`);
594
+ params.push(...normalizedIds);
595
+ }
596
+ const whereSql = conditions.length
597
+ ? `WHERE ${conditions.join(" AND ")}`
598
+ : "";
599
+ const fromSql = `
600
+ FROM sessions s
601
+ LEFT JOIN session_history_cache cache ON cache.sessionId = s.id
602
+ ${whereSql}
603
+ `;
604
+ const total = this.db
605
+ .prepare(`SELECT COUNT(*) as total ${fromSql}`)
606
+ .get(...params)?.total ?? 0;
607
+ let query = `
608
+ SELECT s.*
609
+ ${fromSql}
610
+ ORDER BY s.startedAt DESC, s.id DESC
611
+ `;
612
+ const pageParams = [...params];
613
+ query = appendPaginationClause(query, pageParams, options.limit, options.offset);
614
+ const rows = this.db
615
+ .prepare(query)
616
+ .all(...pageParams);
617
+ return {
618
+ sessions: rows.map((row) => this.rowToSession(row)),
619
+ total,
620
+ };
621
+ }));
622
+ }
623
+ querySessionTrendAttempts(options) {
624
+ return traceSyncOperation("db.query-session-trend-attempts", {
625
+ host: options?.host,
626
+ status: options?.status,
627
+ sessionIds: options?.sessionIds?.length ?? 0,
628
+ }, () => this.dbOp("query session trend attempts", () => {
629
+ this.ensureSessionTrendAttemptsBackfilled();
630
+ const conditions = [];
631
+ const params = [];
632
+ if (options?.host) {
633
+ conditions.push(`s.host = ?`);
634
+ params.push(options.host);
635
+ }
636
+ if (options?.status) {
637
+ conditions.push(`s.status = ?`);
638
+ params.push(options.status);
639
+ }
640
+ if (options?.sessionIds && options.sessionIds.length > 0) {
641
+ const normalizedIds = options.sessionIds
642
+ .map((sessionId) => sessionId.trim())
643
+ .filter(Boolean);
644
+ if (normalizedIds.length === 0) {
645
+ return [];
646
+ }
647
+ conditions.push(`ta.sessionId IN (${normalizedIds.map(() => "?").join(", ")})`);
648
+ params.push(...normalizedIds);
649
+ }
650
+ const whereSql = conditions.length
651
+ ? `WHERE ${conditions.join(" AND ")}`
652
+ : "";
653
+ return this.db
654
+ .prepare(`
655
+ SELECT
656
+ ta.*,
657
+ s.host,
658
+ s.status,
659
+ s.cwd,
660
+ s.startedAt,
661
+ s.endedAt,
662
+ s.title
663
+ FROM session_trend_attempts ta
664
+ INNER JOIN sessions s ON s.id = ta.sessionId
665
+ ${whereSql}
666
+ ORDER BY ta.seenAt DESC, ta.artifactId DESC
667
+ `)
668
+ .all(...params);
669
+ }));
670
+ }
671
+ querySessionTrendContextAttempts(sessionId) {
672
+ return this.dbOp("query session trend context attempts", () => {
673
+ const normalizedSessionId = sessionId.trim();
674
+ if (!normalizedSessionId) {
675
+ return [];
676
+ }
677
+ this.ensureSessionTrendAttemptsBackfilled();
678
+ return this.db
679
+ .prepare(`
680
+ WITH target_issue_keys AS (
681
+ SELECT DISTINCT issueKey
682
+ FROM session_trend_attempts
683
+ WHERE sessionId = ?
684
+ )
685
+ SELECT
686
+ ta.*,
687
+ s.host,
688
+ s.status,
689
+ s.cwd,
690
+ s.startedAt,
691
+ s.endedAt,
692
+ s.title
693
+ FROM session_trend_attempts ta
694
+ INNER JOIN target_issue_keys tik ON tik.issueKey = ta.issueKey
695
+ INNER JOIN sessions s ON s.id = ta.sessionId
696
+ ORDER BY ta.issueKey ASC, ta.seenAt DESC, ta.artifactId DESC
697
+ `)
698
+ .all(normalizedSessionId);
699
+ });
700
+ }
701
+ getSessionFollowUpMessages(sessionIds, options) {
702
+ return traceSyncOperation("db.get-session-follow-up-messages", {
703
+ sessionIds: sessionIds.length,
704
+ limit: options?.limit,
705
+ offset: options?.offset,
706
+ }, () => this.dbOp("get session follow-up messages", () => {
707
+ const normalizedIds = sessionIds
708
+ .map((sessionId) => sessionId.trim())
709
+ .filter(Boolean);
710
+ if (normalizedIds.length === 0) {
711
+ return [];
712
+ }
713
+ const params = [...normalizedIds];
714
+ let query = `
715
+ SELECT id, sessionId, content, capturedAt, seq
716
+ FROM messages
717
+ WHERE sessionId IN (${normalizedIds.map(() => "?").join(", ")})
718
+ AND (
719
+ content LIKE '%?%'
720
+ OR lower(content) LIKE 'next:%'
721
+ )
722
+ ORDER BY capturedAt DESC, sessionId DESC, seq DESC, id DESC
723
+ `;
724
+ query = appendPaginationClause(query, params, options?.limit, options?.offset);
725
+ const rows = this.db
726
+ .prepare(query)
727
+ .all(...params);
728
+ return rows.map((row) => ({
729
+ sessionId: row.sessionId,
730
+ content: row.content,
731
+ capturedAt: row.capturedAt,
732
+ }));
733
+ }));
734
+ }
735
+ createSession(session) {
736
+ const id = crypto.randomUUID();
737
+ const now = new Date().toISOString();
738
+ return this.dbOp("create session", () => {
739
+ const stmt = this.db.prepare(`
740
+ INSERT INTO sessions (
741
+ id, host, projectRoot, cwd, title, status,
742
+ startedAt, endedAt, metadata, createdAt, updatedAt
743
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
744
+ `);
745
+ stmt.run(id, session.host, session.projectRoot, session.cwd, session.title, session.status, session.startedAt, session.endedAt, session.metadata, now, now);
746
+ this.db
747
+ .prepare(`
748
+ INSERT INTO session_history_cache (
749
+ sessionId, titleText, metadataText, messagesText,
750
+ artifactsText, narrativesText, decisionsText, updatedAt
751
+ ) VALUES (?, ?, ?, '', '', '', '', ?)
752
+ `)
753
+ .run(id, session.title ?? "", session.metadata ?? "", now);
754
+ this.markSessionTrendAttemptsFresh(id);
755
+ return id;
756
+ });
757
+ }
758
+ appendMessage(message) {
759
+ const id = crypto.randomUUID();
760
+ return this.dbOp("append message", () => {
761
+ const stmt = this.db.prepare(`
762
+ INSERT INTO messages (
763
+ id, sessionId, seq, role, source, content, capturedAt, metadata
764
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
765
+ `);
766
+ stmt.run(id, message.sessionId, message.seq, message.role, message.source, message.content, message.capturedAt, message.metadata);
767
+ const currentCache = this.db
768
+ .prepare(`
769
+ SELECT messagesText FROM session_history_cache
770
+ WHERE sessionId = ?
771
+ `)
772
+ .get(message.sessionId);
773
+ this.updateSessionHistoryCache(message.sessionId, {
774
+ messagesText: EvidenceDatabase.appendSearchPart(currentCache?.messagesText ?? "", message.content),
775
+ });
776
+ return id;
777
+ });
778
+ }
779
+ appendTimelineEvent(event) {
780
+ const id = crypto.randomUUID();
781
+ return this.dbOp("append timeline event", () => {
782
+ const stmt = this.db.prepare(`
783
+ INSERT INTO timeline_events (
784
+ id, sessionId, seq, eventType, eventSubType, source,
785
+ summary, payload, startedAt, endedAt, status, relatedMessageId
786
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
787
+ `);
788
+ stmt.run(id, event.sessionId, event.seq, event.eventType, event.eventSubType, event.source, event.summary, event.payload, event.startedAt, event.endedAt, event.status, event.relatedMessageId);
789
+ return id;
790
+ });
791
+ }
792
+ updateSessionTitle(id, title) {
793
+ this.dbOp("update session title", () => {
794
+ const stmt = this.db.prepare(`
795
+ UPDATE sessions
796
+ SET title = ?,
797
+ updatedAt = ?
798
+ WHERE id = ?
799
+ `);
800
+ const result = stmt.run(title, new Date().toISOString(), id);
801
+ if (result.changes === 0) {
802
+ throw new Error(`Session with id ${id} not found`);
803
+ }
804
+ this.updateSessionHistoryCache(id, {
805
+ titleText: title,
806
+ });
807
+ });
808
+ }
809
+ finalizeSession(id, updates) {
810
+ this.dbOp("finalize session", () => {
811
+ const stmt = this.db.prepare(`
812
+ UPDATE sessions
813
+ SET status = ?,
814
+ endedAt = ?,
815
+ title = COALESCE(?, title),
816
+ updatedAt = ?
817
+ WHERE id = ?
818
+ `);
819
+ const result = stmt.run(updates.status, updates.endedAt, updates.title, new Date().toISOString(), id);
820
+ if (result.changes === 0) {
821
+ throw new Error(`Session with id ${id} not found`);
822
+ }
823
+ });
824
+ }
825
+ findSessionById(id) {
826
+ return this.dbOp("find session by ID", () => {
827
+ const row = this.db
828
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
829
+ .get(id);
830
+ return row ? this.rowToSession(row) : null;
831
+ });
832
+ }
833
+ listSessions(options) {
834
+ return this.dbOp("list sessions", () => {
835
+ const params = [];
836
+ const query = appendPaginationClause("SELECT * FROM sessions ORDER BY startedAt DESC", params, options?.limit, options?.offset);
837
+ const rows = this.db.prepare(query).all(...params);
838
+ return rows.map((row) => this.rowToSession(row));
839
+ });
840
+ }
841
+ createContext(context) {
842
+ const id = crypto.randomUUID();
843
+ const now = new Date().toISOString();
844
+ return this.dbOp("create context", () => {
845
+ this.db
846
+ .prepare(`
847
+ INSERT INTO contexts (
848
+ id, label, workspaceKey, status, mergedIntoContextId,
849
+ metadata, createdAt, updatedAt
850
+ ) VALUES (?, ?, ?, 'active', NULL, ?, ?, ?)
851
+ `)
852
+ .run(id, context.label, context.workspaceKey, context.metadata ?? null, now, now);
853
+ return {
854
+ id,
855
+ label: context.label,
856
+ workspaceKey: context.workspaceKey,
857
+ status: "active",
858
+ mergedIntoContextId: null,
859
+ metadata: context.metadata ?? null,
860
+ createdAt: now,
861
+ updatedAt: now,
862
+ };
863
+ });
864
+ }
865
+ findContextById(id) {
866
+ return this.dbOp("find context by ID", () => {
867
+ const row = this.db
868
+ .prepare(`SELECT * FROM contexts WHERE id = ?`)
869
+ .get(id);
870
+ return row ? this.rowToContext(row) : null;
871
+ });
872
+ }
873
+ resolveContextById(id) {
94
874
  try {
95
- const limit = options?.limit;
96
- const offset = options?.offset ?? 0;
97
- let query = 'SELECT * FROM evidences ORDER BY timestamp DESC';
875
+ const activeId = this.resolveActiveContextIdOrThrow(id);
876
+ return this.findContextById(activeId);
877
+ }
878
+ catch {
879
+ return null;
880
+ }
881
+ }
882
+ listContexts(options) {
883
+ return this.dbOp("list contexts", () => {
884
+ const conditions = [];
98
885
  const params = [];
99
- if (limit !== undefined) {
100
- query += ' LIMIT ?';
101
- params.push(limit);
102
- if (offset > 0) {
103
- query += ' OFFSET ?';
104
- params.push(offset);
886
+ if (options?.workspaceKey) {
887
+ conditions.push("workspaceKey = ?");
888
+ params.push(options.workspaceKey);
889
+ }
890
+ if (options?.status) {
891
+ conditions.push("status = ?");
892
+ params.push(options.status);
893
+ }
894
+ else if (!options?.includeMerged) {
895
+ conditions.push("status = 'active'");
896
+ }
897
+ const whereSql = conditions.length
898
+ ? `WHERE ${conditions.join(" AND ")}`
899
+ : "";
900
+ const query = appendPaginationClause(`
901
+ SELECT *
902
+ FROM contexts
903
+ ${whereSql}
904
+ ORDER BY updatedAt DESC, createdAt DESC, id DESC
905
+ `, params, options?.limit, options?.offset);
906
+ const rows = this.db.prepare(query).all(...params);
907
+ return rows.map((row) => this.rowToContext(row));
908
+ });
909
+ }
910
+ getContextCount(options) {
911
+ return this.dbOp("count contexts", () => {
912
+ const conditions = [];
913
+ const params = [];
914
+ if (options?.workspaceKey) {
915
+ conditions.push("workspaceKey = ?");
916
+ params.push(options.workspaceKey);
917
+ }
918
+ if (options?.status) {
919
+ conditions.push("status = ?");
920
+ params.push(options.status);
921
+ }
922
+ else if (!options?.includeMerged) {
923
+ conditions.push("status = 'active'");
924
+ }
925
+ const whereSql = conditions.length
926
+ ? `WHERE ${conditions.join(" AND ")}`
927
+ : "";
928
+ const row = this.db
929
+ .prepare(`SELECT COUNT(*) as count FROM contexts ${whereSql}`)
930
+ .get(...params);
931
+ return row?.count ?? 0;
932
+ });
933
+ }
934
+ listSessionsForContext(contextId) {
935
+ return this.dbOp("list sessions for context", () => {
936
+ const activeId = this.resolveActiveContextIdOrThrow(contextId);
937
+ const rows = this.db
938
+ .prepare(`
939
+ SELECT s.*
940
+ FROM context_session_links links
941
+ INNER JOIN sessions s ON s.id = links.sessionId
942
+ WHERE links.contextId = ?
943
+ ORDER BY s.startedAt ASC, s.id ASC
944
+ `)
945
+ .all(activeId);
946
+ return rows.map((row) => this.rowToSession(row));
947
+ });
948
+ }
949
+ listUnlinkedSessions(options) {
950
+ return this.dbOp("list unlinked sessions", () => {
951
+ const conditions = [
952
+ "NOT EXISTS (SELECT 1 FROM context_session_links links WHERE links.sessionId = s.id)",
953
+ ];
954
+ const params = [];
955
+ let query = `
956
+ SELECT s.*
957
+ FROM sessions s
958
+ WHERE ${conditions.join(" AND ")}
959
+ ORDER BY s.startedAt ASC, s.id ASC
960
+ `;
961
+ if (!options?.workspaceKey) {
962
+ query = appendPaginationClause(query, params, options?.limit, options?.offset);
963
+ }
964
+ const rows = this.db.prepare(query).all(...params);
965
+ let sessions = rows.map((row) => this.rowToSession(row));
966
+ if (options?.workspaceKey) {
967
+ sessions = sessions.filter((session) => matchesWorkspaceKey(session, options.workspaceKey));
968
+ const off = options.offset ?? 0;
969
+ if (off > 0) {
970
+ sessions = sessions.slice(off);
971
+ }
972
+ if (options.limit !== undefined) {
973
+ sessions = sessions.slice(0, options.limit);
105
974
  }
106
975
  }
107
- else if (offset > 0) {
108
- query += ' LIMIT -1 OFFSET ?';
109
- params.push(offset);
976
+ return sessions;
977
+ });
978
+ }
979
+ findContextLinkForSession(sessionId) {
980
+ return this.dbOp("find context link for session", () => {
981
+ const row = this.db
982
+ .prepare(`
983
+ SELECT *
984
+ FROM context_session_links
985
+ WHERE sessionId = ?
986
+ `)
987
+ .get(sessionId);
988
+ return row ? this.rowToContextSessionLink(row) : null;
989
+ });
990
+ }
991
+ listContextSessionLinks(contextId) {
992
+ return this.dbOp("list context session links", () => {
993
+ const activeId = this.resolveActiveContextIdOrThrow(contextId);
994
+ const rows = this.db
995
+ .prepare(`
996
+ SELECT *
997
+ FROM context_session_links
998
+ WHERE contextId = ?
999
+ ORDER BY updatedAt DESC, sessionId DESC
1000
+ `)
1001
+ .all(activeId);
1002
+ return rows.map((row) => this.rowToContextSessionLink(row));
1003
+ });
1004
+ }
1005
+ assignSessionToContext(input) {
1006
+ const now = new Date().toISOString();
1007
+ return this.dbOp("assign session to context", () => {
1008
+ const session = this.findSessionById(input.sessionId);
1009
+ if (!session) {
1010
+ throw new Error(`Session not found: ${input.sessionId}`);
1011
+ }
1012
+ const activeContextId = this.resolveActiveContextIdOrThrow(input.contextId);
1013
+ this.db
1014
+ .prepare(`
1015
+ INSERT INTO context_session_links (
1016
+ sessionId, contextId, linkSource, createdAt, updatedAt
1017
+ ) VALUES (?, ?, ?, ?, ?)
1018
+ ON CONFLICT(sessionId) DO UPDATE SET
1019
+ contextId = excluded.contextId,
1020
+ linkSource = excluded.linkSource,
1021
+ updatedAt = excluded.updatedAt
1022
+ `)
1023
+ .run(input.sessionId, activeContextId, input.linkSource, now, now);
1024
+ this.db
1025
+ .prepare(`
1026
+ DELETE FROM context_link_rejections
1027
+ WHERE sessionId = ? AND contextId = ?
1028
+ `)
1029
+ .run(input.sessionId, activeContextId);
1030
+ this.db
1031
+ .prepare(`
1032
+ UPDATE contexts
1033
+ SET updatedAt = ?
1034
+ WHERE id = ?
1035
+ `)
1036
+ .run(now, activeContextId);
1037
+ return {
1038
+ sessionId: input.sessionId,
1039
+ contextId: activeContextId,
1040
+ linkSource: input.linkSource,
1041
+ createdAt: now,
1042
+ updatedAt: now,
1043
+ };
1044
+ });
1045
+ }
1046
+ rejectContextForSession(sessionId, contextId) {
1047
+ const now = new Date().toISOString();
1048
+ return this.dbOp("reject context for session", () => {
1049
+ if (!this.findSessionById(sessionId)) {
1050
+ throw new Error(`Session not found: ${sessionId}`);
1051
+ }
1052
+ const activeContextId = this.resolveActiveContextIdOrThrow(contextId);
1053
+ this.db
1054
+ .prepare(`
1055
+ INSERT INTO context_link_rejections (sessionId, contextId, createdAt)
1056
+ VALUES (?, ?, ?)
1057
+ ON CONFLICT(sessionId, contextId) DO NOTHING
1058
+ `)
1059
+ .run(sessionId, activeContextId, now);
1060
+ return {
1061
+ sessionId,
1062
+ contextId: activeContextId,
1063
+ createdAt: now,
1064
+ };
1065
+ });
1066
+ }
1067
+ listContextRejectionsForSession(sessionId) {
1068
+ return this.dbOp("list context rejections", () => {
1069
+ const rows = this.db
1070
+ .prepare(`
1071
+ SELECT *
1072
+ FROM context_link_rejections
1073
+ WHERE sessionId = ?
1074
+ ORDER BY createdAt DESC, contextId DESC
1075
+ `)
1076
+ .all(sessionId);
1077
+ return rows.map((row) => this.rowToContextLinkRejection(row));
1078
+ });
1079
+ }
1080
+ setWorkspacePreferredContext(workspaceKey, contextId) {
1081
+ const now = new Date().toISOString();
1082
+ return this.dbOp("set preferred context", () => {
1083
+ const activeContextId = this.resolveActiveContextIdOrThrow(contextId);
1084
+ this.db
1085
+ .prepare(`
1086
+ INSERT INTO context_workspace_preferences (
1087
+ workspaceKey, contextId, createdAt, updatedAt
1088
+ ) VALUES (?, ?, ?, ?)
1089
+ ON CONFLICT(workspaceKey) DO UPDATE SET
1090
+ contextId = excluded.contextId,
1091
+ updatedAt = excluded.updatedAt
1092
+ `)
1093
+ .run(workspaceKey, activeContextId, now, now);
1094
+ return {
1095
+ workspaceKey,
1096
+ contextId: activeContextId,
1097
+ createdAt: now,
1098
+ updatedAt: now,
1099
+ };
1100
+ });
1101
+ }
1102
+ getWorkspacePreferredContext(workspaceKey) {
1103
+ return this.dbOp("get preferred context", () => {
1104
+ const row = this.db
1105
+ .prepare(`
1106
+ SELECT *
1107
+ FROM context_workspace_preferences
1108
+ WHERE workspaceKey = ?
1109
+ `)
1110
+ .get(workspaceKey);
1111
+ if (!row) {
1112
+ return null;
1113
+ }
1114
+ try {
1115
+ const activeContextId = this.resolveActiveContextIdOrThrow(row.contextId);
1116
+ if (activeContextId !== row.contextId) {
1117
+ const now = new Date().toISOString();
1118
+ this.db
1119
+ .prepare(`
1120
+ UPDATE context_workspace_preferences
1121
+ SET contextId = ?, updatedAt = ?
1122
+ WHERE workspaceKey = ?
1123
+ `)
1124
+ .run(activeContextId, now, workspaceKey);
1125
+ return this.rowToContextWorkspacePreference({
1126
+ ...row,
1127
+ contextId: activeContextId,
1128
+ updatedAt: now,
1129
+ });
1130
+ }
1131
+ }
1132
+ catch {
1133
+ return this.rowToContextWorkspacePreference(row);
1134
+ }
1135
+ return this.rowToContextWorkspacePreference(row);
1136
+ });
1137
+ }
1138
+ mergeContexts(sourceContextId, targetContextId) {
1139
+ this.dbOp("merge contexts", () => {
1140
+ const sourceId = this.resolveActiveContextIdOrThrow(sourceContextId);
1141
+ const targetId = this.resolveActiveContextIdOrThrow(targetContextId);
1142
+ if (sourceId === targetId) {
1143
+ throw new Error("Cannot merge a context into itself");
1144
+ }
1145
+ const now = new Date().toISOString();
1146
+ const transaction = this.db.transaction(() => {
1147
+ this.db
1148
+ .prepare(`
1149
+ UPDATE context_session_links
1150
+ SET contextId = ?, linkSource = 'merge', updatedAt = ?
1151
+ WHERE contextId = ?
1152
+ `)
1153
+ .run(targetId, now, sourceId);
1154
+ this.db
1155
+ .prepare(`
1156
+ UPDATE context_workspace_preferences
1157
+ SET contextId = ?, updatedAt = ?
1158
+ WHERE contextId = ?
1159
+ `)
1160
+ .run(targetId, now, sourceId);
1161
+ this.db
1162
+ .prepare(`
1163
+ UPDATE contexts
1164
+ SET status = 'merged',
1165
+ mergedIntoContextId = ?,
1166
+ updatedAt = ?
1167
+ WHERE id = ?
1168
+ `)
1169
+ .run(targetId, now, sourceId);
1170
+ this.db
1171
+ .prepare(`
1172
+ UPDATE contexts
1173
+ SET updatedAt = ?
1174
+ WHERE id = ?
1175
+ `)
1176
+ .run(now, targetId);
1177
+ });
1178
+ transaction();
1179
+ });
1180
+ }
1181
+ countSessionMessages(sessionId) {
1182
+ return this.dbOp("count session messages", () => {
1183
+ const row = this.db
1184
+ .prepare(`SELECT COUNT(*) as total FROM messages WHERE sessionId = ?`)
1185
+ .get(sessionId);
1186
+ return row?.total ?? 0;
1187
+ });
1188
+ }
1189
+ getSessionMessageStats(sessionId) {
1190
+ return this.dbOp("summarize session messages", () => {
1191
+ const summaryRow = this.db
1192
+ .prepare(`
1193
+ SELECT
1194
+ COUNT(*) as total,
1195
+ COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) as userCount,
1196
+ COALESCE(SUM(CASE WHEN role = 'assistant' THEN 1 ELSE 0 END), 0) as assistantCount,
1197
+ COALESCE(SUM(CASE WHEN role = 'system' THEN 1 ELSE 0 END), 0) as systemCount,
1198
+ MIN(capturedAt) as firstCapturedAt,
1199
+ MAX(capturedAt) as lastCapturedAt
1200
+ FROM messages
1201
+ WHERE sessionId = ?
1202
+ `)
1203
+ .get(sessionId);
1204
+ const previewRow = this.db
1205
+ .prepare(`
1206
+ SELECT content FROM messages
1207
+ WHERE sessionId = ?
1208
+ ORDER BY seq ASC, capturedAt ASC
1209
+ LIMIT 1
1210
+ `)
1211
+ .get(sessionId);
1212
+ return {
1213
+ total: summaryRow?.total ?? 0,
1214
+ byRole: {
1215
+ user: summaryRow?.userCount ?? 0,
1216
+ assistant: summaryRow?.assistantCount ?? 0,
1217
+ system: summaryRow?.systemCount ?? 0,
1218
+ },
1219
+ firstCapturedAt: summaryRow?.firstCapturedAt ?? null,
1220
+ lastCapturedAt: summaryRow?.lastCapturedAt ?? null,
1221
+ previewContent: previewRow?.content ?? null,
1222
+ };
1223
+ });
1224
+ }
1225
+ getSessionMessages(sessionId, options) {
1226
+ return this.dbOp("get session messages", () => {
1227
+ const params = [sessionId];
1228
+ const query = appendPaginationClause("SELECT * FROM messages WHERE sessionId = ? ORDER BY seq ASC, capturedAt ASC", params, options?.limit, options?.offset);
1229
+ const rows = this.db.prepare(query).all(...params);
1230
+ return rows.map((row) => this.rowToMessage(row));
1231
+ });
1232
+ }
1233
+ countSessionTimeline(sessionId) {
1234
+ return this.dbOp("count session timeline", () => {
1235
+ const row = this.db
1236
+ .prepare(`SELECT COUNT(*) as total FROM timeline_events WHERE sessionId = ?`)
1237
+ .get(sessionId);
1238
+ return row?.total ?? 0;
1239
+ });
1240
+ }
1241
+ getSessionTimelineStats(sessionId) {
1242
+ return this.dbOp("summarize session timeline", () => {
1243
+ const summaryRow = this.db
1244
+ .prepare(`
1245
+ SELECT
1246
+ COUNT(*) as total,
1247
+ MIN(startedAt) as firstStartedAt,
1248
+ MAX(COALESCE(endedAt, startedAt)) as lastEndedAt
1249
+ FROM timeline_events
1250
+ WHERE sessionId = ?
1251
+ `)
1252
+ .get(sessionId);
1253
+ const eventTypeRows = this.db
1254
+ .prepare(`
1255
+ SELECT eventType FROM timeline_events
1256
+ WHERE sessionId = ?
1257
+ GROUP BY eventType
1258
+ ORDER BY MIN(seq) ASC, MIN(startedAt) ASC
1259
+ `)
1260
+ .all(sessionId);
1261
+ const statusRows = this.db
1262
+ .prepare(`
1263
+ SELECT status FROM timeline_events
1264
+ WHERE sessionId = ? AND status IS NOT NULL
1265
+ GROUP BY status
1266
+ ORDER BY MIN(seq) ASC, MIN(startedAt) ASC
1267
+ `)
1268
+ .all(sessionId);
1269
+ return {
1270
+ total: summaryRow?.total ?? 0,
1271
+ eventTypes: eventTypeRows.map((row) => row.eventType),
1272
+ statuses: statusRows.map((row) => row.status),
1273
+ firstStartedAt: summaryRow?.firstStartedAt ?? null,
1274
+ lastEndedAt: summaryRow?.lastEndedAt ?? null,
1275
+ };
1276
+ });
1277
+ }
1278
+ getSessionTimeline(sessionId, options) {
1279
+ return this.dbOp("get session timeline", () => {
1280
+ const params = [sessionId];
1281
+ const query = appendPaginationClause(`
1282
+ SELECT * FROM timeline_events
1283
+ WHERE sessionId = ?
1284
+ ORDER BY seq ASC, startedAt ASC
1285
+ `, params, options?.limit, options?.offset);
1286
+ const rows = this.db.prepare(query).all(...params);
1287
+ return rows.map((row) => this.rowToTimelineEvent(row));
1288
+ });
1289
+ }
1290
+ createArtifact(artifact) {
1291
+ const id = crypto.randomUUID();
1292
+ const createdAt = new Date().toISOString();
1293
+ return this.dbOp("create artifact", () => {
1294
+ this.db
1295
+ .prepare(`
1296
+ INSERT INTO artifacts (
1297
+ id, sessionId, eventId, artifactType, path, metadata, createdAt
1298
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1299
+ `)
1300
+ .run(id, artifact.sessionId, artifact.eventId, artifact.artifactType, artifact.path, artifact.metadata, createdAt);
1301
+ const artifactRecord = {
1302
+ id,
1303
+ sessionId: artifact.sessionId,
1304
+ eventId: artifact.eventId,
1305
+ artifactType: artifact.artifactType,
1306
+ path: artifact.path,
1307
+ metadata: artifact.metadata,
1308
+ createdAt,
1309
+ };
1310
+ const artifactCache = this.buildArtifactHistoryCache([artifactRecord]);
1311
+ const currentCache = this.db
1312
+ .prepare(`
1313
+ SELECT artifactsText FROM session_history_cache
1314
+ WHERE sessionId = ?
1315
+ `)
1316
+ .get(artifact.sessionId);
1317
+ this.updateSessionHistoryCache(artifact.sessionId, {
1318
+ artifactsText: EvidenceDatabase.appendSearchPart(currentCache?.artifactsText ?? "", artifactCache.text),
1319
+ });
1320
+ for (const issueKey of artifactCache.issueKeys) {
1321
+ this.db
1322
+ .prepare(`
1323
+ INSERT INTO session_issue_keys (sessionId, issueKey)
1324
+ VALUES (?, ?)
1325
+ ON CONFLICT(sessionId, issueKey) DO NOTHING
1326
+ `)
1327
+ .run(artifact.sessionId, issueKey);
1328
+ }
1329
+ this.insertSessionTrendAttempts(artifact.sessionId, [artifactRecord]);
1330
+ return artifactRecord;
1331
+ });
1332
+ }
1333
+ replaceArtifactsForSession(sessionId, artifacts) {
1334
+ return this.dbOp("replace artifacts", () => {
1335
+ const transaction = this.db.transaction(() => {
1336
+ this.db
1337
+ .prepare(`DELETE FROM artifacts WHERE sessionId = ?`)
1338
+ .run(sessionId);
1339
+ const insertArtifact = this.db.prepare(`
1340
+ INSERT INTO artifacts (
1341
+ id, sessionId, eventId, artifactType, path, metadata, createdAt
1342
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1343
+ `);
1344
+ const createdArtifacts = artifacts.map((artifact) => {
1345
+ const id = crypto.randomUUID();
1346
+ const createdAt = new Date().toISOString();
1347
+ insertArtifact.run(id, sessionId, artifact.eventId, artifact.artifactType, artifact.path, artifact.metadata, createdAt);
1348
+ return {
1349
+ id,
1350
+ sessionId,
1351
+ eventId: artifact.eventId,
1352
+ artifactType: artifact.artifactType,
1353
+ path: artifact.path,
1354
+ metadata: artifact.metadata,
1355
+ createdAt,
1356
+ };
1357
+ });
1358
+ const artifactCache = this.buildArtifactHistoryCache(createdArtifacts);
1359
+ this.updateSessionHistoryCache(sessionId, {
1360
+ artifactsText: artifactCache.text,
1361
+ });
1362
+ this.replaceSessionIssueKeys(sessionId, artifactCache.issueKeys);
1363
+ this.replaceSessionTrendAttempts(sessionId, createdArtifacts);
1364
+ return createdArtifacts;
1365
+ });
1366
+ return transaction();
1367
+ });
1368
+ }
1369
+ countSessionArtifacts(sessionId, options) {
1370
+ return this.dbOp("count session artifacts", () => {
1371
+ const row = (options?.artifactType
1372
+ ? this.db
1373
+ .prepare(`SELECT COUNT(*) as count FROM artifacts WHERE sessionId = ? AND artifactType = ?`)
1374
+ .get(sessionId, options.artifactType)
1375
+ : this.db
1376
+ .prepare(`SELECT COUNT(*) as count FROM artifacts WHERE sessionId = ?`)
1377
+ .get(sessionId));
1378
+ return row?.count ?? 0;
1379
+ });
1380
+ }
1381
+ getSessionArtifactSummary(sessionId) {
1382
+ return this.dbOp("summarize session artifacts", () => {
1383
+ const row = this.db
1384
+ .prepare(`
1385
+ SELECT
1386
+ COUNT(*) as total,
1387
+ COALESCE(SUM(CASE WHEN artifactType = 'file-change' THEN 1 ELSE 0 END), 0) as fileChange,
1388
+ COALESCE(SUM(CASE WHEN artifactType = 'command-output' THEN 1 ELSE 0 END), 0) as commandOutput,
1389
+ COALESCE(SUM(CASE WHEN artifactType = 'test-result' THEN 1 ELSE 0 END), 0) as testResult,
1390
+ COALESCE(SUM(CASE WHEN artifactType = 'git-commit' THEN 1 ELSE 0 END), 0) as gitCommit
1391
+ FROM artifacts
1392
+ WHERE sessionId = ?
1393
+ `)
1394
+ .get(sessionId);
1395
+ return {
1396
+ total: row?.total ?? 0,
1397
+ byType: {
1398
+ fileChange: row?.fileChange ?? 0,
1399
+ commandOutput: row?.commandOutput ?? 0,
1400
+ testResult: row?.testResult ?? 0,
1401
+ gitCommit: row?.gitCommit ?? 0,
1402
+ },
1403
+ };
1404
+ });
1405
+ }
1406
+ getSessionArtifacts(sessionId, options) {
1407
+ return this.dbOp("get session artifacts", () => {
1408
+ const conditions = ["sessionId = ?"];
1409
+ const parameters = [sessionId];
1410
+ if (options?.artifactType) {
1411
+ conditions.push("artifactType = ?");
1412
+ parameters.push(options.artifactType);
1413
+ }
1414
+ const query = appendPaginationClause(`SELECT * FROM artifacts WHERE ${conditions.join(" AND ")} ORDER BY createdAt ASC, id ASC`, parameters, options?.limit, options?.offset);
1415
+ const rows = this.db.prepare(query).all(...parameters);
1416
+ return rows.map((row) => this.rowToArtifact(row));
1417
+ });
1418
+ }
1419
+ replaceDecisionsForSession(sessionId, decisions) {
1420
+ return this.dbOp("replace decisions", () => {
1421
+ const transaction = this.db.transaction(() => {
1422
+ this.db
1423
+ .prepare(`DELETE FROM decisions WHERE sessionId = ?`)
1424
+ .run(sessionId);
1425
+ const createdDecisions = decisions.map((decision) => {
1426
+ const id = crypto.randomUUID();
1427
+ const createdAt = new Date().toISOString();
1428
+ this.db
1429
+ .prepare(`
1430
+ INSERT INTO decisions (
1431
+ id, sessionId, title, summary, rationale, status, sourceRefs, createdAt
1432
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1433
+ `)
1434
+ .run(id, sessionId, decision.title, decision.summary, decision.rationale, decision.status, decision.sourceRefs, createdAt);
1435
+ return {
1436
+ id,
1437
+ sessionId,
1438
+ title: decision.title,
1439
+ summary: decision.summary,
1440
+ rationale: decision.rationale,
1441
+ status: decision.status,
1442
+ sourceRefs: decision.sourceRefs,
1443
+ createdAt,
1444
+ };
1445
+ });
1446
+ this.updateSessionHistoryCache(sessionId, {
1447
+ decisionsText: this.buildDecisionHistoryCache(createdDecisions),
1448
+ });
1449
+ return createdDecisions;
1450
+ });
1451
+ return transaction();
1452
+ });
1453
+ }
1454
+ countSessionDecisions(sessionId) {
1455
+ return this.dbOp("count session decisions", () => {
1456
+ const row = this.db
1457
+ .prepare(`SELECT COUNT(*) as count FROM decisions WHERE sessionId = ?`)
1458
+ .get(sessionId);
1459
+ return row?.count ?? 0;
1460
+ });
1461
+ }
1462
+ getSessionDecisions(sessionId, options) {
1463
+ return this.dbOp("get session decisions", () => {
1464
+ const params = [sessionId];
1465
+ const query = appendPaginationClause(`
1466
+ SELECT * FROM decisions
1467
+ WHERE sessionId = ?
1468
+ ORDER BY createdAt ASC, id ASC
1469
+ `, params, options?.limit, options?.offset);
1470
+ const rows = this.db.prepare(query).all(...params);
1471
+ return rows.map((row) => this.rowToDecision(row));
1472
+ });
1473
+ }
1474
+ replaceNarrativesForSession(sessionId, narratives) {
1475
+ return this.dbOp("replace narratives", () => {
1476
+ const transaction = this.db.transaction(() => {
1477
+ this.db
1478
+ .prepare(`DELETE FROM narratives WHERE sessionId = ?`)
1479
+ .run(sessionId);
1480
+ const createdNarratives = narratives.map((narrative) => {
1481
+ const id = crypto.randomUUID();
1482
+ const now = new Date().toISOString();
1483
+ this.db
1484
+ .prepare(`
1485
+ INSERT INTO narratives (
1486
+ id, sessionId, kind, content, sourceRefs, createdAt, updatedAt
1487
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1488
+ `)
1489
+ .run(id, sessionId, narrative.kind, narrative.content, narrative.sourceRefs, now, now);
1490
+ return {
1491
+ id,
1492
+ sessionId,
1493
+ kind: narrative.kind,
1494
+ content: narrative.content,
1495
+ sourceRefs: narrative.sourceRefs,
1496
+ createdAt: now,
1497
+ updatedAt: now,
1498
+ };
1499
+ });
1500
+ this.updateSessionHistoryCache(sessionId, {
1501
+ narrativesText: this.buildNarrativeHistoryCache(createdNarratives),
1502
+ });
1503
+ return createdNarratives;
1504
+ });
1505
+ return transaction();
1506
+ });
1507
+ }
1508
+ countSessionNarratives(sessionId, options) {
1509
+ return this.dbOp("count session narratives", () => {
1510
+ const row = (options?.kind
1511
+ ? this.db
1512
+ .prepare(`SELECT COUNT(*) as count FROM narratives WHERE sessionId = ? AND kind = ?`)
1513
+ .get(sessionId, options.kind)
1514
+ : this.db
1515
+ .prepare(`SELECT COUNT(*) as count FROM narratives WHERE sessionId = ?`)
1516
+ .get(sessionId));
1517
+ return row?.count ?? 0;
1518
+ });
1519
+ }
1520
+ getSessionNarratives(sessionId, options) {
1521
+ return this.dbOp("get session narratives", () => {
1522
+ const conditions = ["sessionId = ?"];
1523
+ const params = [sessionId];
1524
+ if (options?.kind) {
1525
+ conditions.push("kind = ?");
1526
+ params.push(options.kind);
1527
+ }
1528
+ let query = `
1529
+ SELECT * FROM narratives
1530
+ WHERE ${conditions.join(" AND ")}
1531
+ ORDER BY CASE kind
1532
+ WHEN 'journal' THEN 1
1533
+ WHEN 'project-summary' THEN 2
1534
+ WHEN 'handoff' THEN 3
1535
+ ELSE 99
1536
+ END, createdAt ASC, id ASC
1537
+ `;
1538
+ query = appendPaginationClause(query, params, options?.limit, options?.offset);
1539
+ const rows = this.db.prepare(query).all(...params);
1540
+ return rows.map((row) => this.rowToNarrative(row));
1541
+ });
1542
+ }
1543
+ hasNarrativesForSession(sessionId) {
1544
+ return this.dbOp("check session narratives", () => {
1545
+ const row = this.db
1546
+ .prepare(`SELECT EXISTS(SELECT 1 FROM narratives WHERE sessionId = ?) as present`)
1547
+ .get(sessionId);
1548
+ return Boolean(row?.present);
1549
+ });
1550
+ }
1551
+ createIngestionRun(run) {
1552
+ const id = crypto.randomUUID();
1553
+ const startedAt = new Date().toISOString();
1554
+ return this.dbOp("create ingestion run", () => {
1555
+ this.db
1556
+ .prepare(`
1557
+ INSERT INTO ingestion_runs (
1558
+ id, sessionId, stage, status, error, startedAt, endedAt
1559
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1560
+ `)
1561
+ .run(id, run.sessionId, run.stage, run.status, run.error ?? null, startedAt, null);
1562
+ return {
1563
+ id,
1564
+ sessionId: run.sessionId,
1565
+ stage: run.stage,
1566
+ status: run.status,
1567
+ error: run.error ?? null,
1568
+ startedAt,
1569
+ endedAt: null,
1570
+ };
1571
+ });
1572
+ }
1573
+ completeIngestionRun(id, status, error = null) {
1574
+ try {
1575
+ const endedAt = new Date().toISOString();
1576
+ const result = this.db
1577
+ .prepare(`
1578
+ UPDATE ingestion_runs
1579
+ SET status = ?, error = ?, endedAt = ?
1580
+ WHERE id = ?
1581
+ `)
1582
+ .run(status, error, endedAt, id);
1583
+ if (result.changes === 0) {
1584
+ throw new Error(`Ingestion run with id ${id} not found`);
110
1585
  }
111
- const stmt = this.db.prepare(query);
112
- const rows = stmt.all(...params);
113
- return rows.map((row) => this.rowToEvidence(row));
114
1586
  }
115
- catch (error) {
116
- throw new Error(`Failed to list evidences: ${error instanceof Error ? error.message : String(error)}`);
1587
+ catch (cause) {
1588
+ throw new Error(`Failed to complete ingestion run: ${cause instanceof Error ? cause.message : String(cause)}`);
117
1589
  }
118
1590
  }
1591
+ getSessionIngestionRuns(sessionId) {
1592
+ return this.dbOp("get ingestion runs", () => {
1593
+ const rows = this.db
1594
+ .prepare(`
1595
+ SELECT * FROM ingestion_runs
1596
+ WHERE sessionId = ?
1597
+ ORDER BY startedAt ASC
1598
+ `)
1599
+ .all(sessionId);
1600
+ return rows.map((row) => this.rowToIngestionRun(row));
1601
+ });
1602
+ }
1603
+ getSessionDetail(sessionId) {
1604
+ // Wrap all reads in a single transaction for atomic snapshot
1605
+ const readAll = this.db.transaction(() => {
1606
+ const session = this.findSessionById(sessionId);
1607
+ if (!session) {
1608
+ return null;
1609
+ }
1610
+ const messages = this.getSessionMessages(sessionId);
1611
+ const timeline = this.getSessionTimeline(sessionId);
1612
+ const artifacts = this.getSessionArtifacts(sessionId);
1613
+ const narratives = this.getSessionNarratives(sessionId);
1614
+ const decisions = this.getSessionDecisions(sessionId);
1615
+ const ingestionRuns = this.getSessionIngestionRuns(sessionId);
1616
+ return {
1617
+ session,
1618
+ messages,
1619
+ timeline,
1620
+ artifacts,
1621
+ narratives,
1622
+ decisions,
1623
+ ingestionRuns,
1624
+ hasNarratives: narratives.length > 0,
1625
+ };
1626
+ });
1627
+ return readAll();
1628
+ }
119
1629
  /**
120
1630
  * Updates git commit information for an evidence
121
1631
  * @param id - Evidence UUID
@@ -123,7 +1633,7 @@ export class EvidenceDatabase {
123
1633
  * @param gitTimestamp - Git commit timestamp (ISO 8601)
124
1634
  */
125
1635
  updateGitInfo(id, gitCommitHash, gitTimestamp) {
126
- try {
1636
+ this.dbOp("update git info", () => {
127
1637
  const stmt = this.db.prepare(`
128
1638
  UPDATE evidences
129
1639
  SET gitCommitHash = ?,
@@ -135,10 +1645,7 @@ export class EvidenceDatabase {
135
1645
  if (result.changes === 0) {
136
1646
  throw new Error(`Evidence with id ${id} not found`);
137
1647
  }
138
- }
139
- catch (error) {
140
- throw new Error(`Failed to update git info: ${error instanceof Error ? error.message : String(error)}`);
141
- }
1648
+ });
142
1649
  }
143
1650
  /**
144
1651
  * Adds tags to an evidence (appends to existing tags)
@@ -147,38 +1654,112 @@ export class EvidenceDatabase {
147
1654
  * @throws Error if tags array is empty or all tags are whitespace
148
1655
  */
149
1656
  addTags(id, tags) {
150
- try {
1657
+ this.dbOp("add tags", () => {
151
1658
  if (tags.length === 0) {
152
- throw new Error('Tags array cannot be empty');
1659
+ throw new Error("Tags array cannot be empty");
153
1660
  }
154
1661
  // Filter out empty/whitespace-only tags
155
- const validTags = tags.map(t => t.trim()).filter(t => t.length > 0);
1662
+ const validTags = tags.map((t) => t.trim()).filter((t) => t.length > 0);
156
1663
  if (validTags.length === 0) {
157
- throw new Error('All provided tags are empty or whitespace');
1664
+ throw new Error("All provided tags are empty or whitespace");
158
1665
  }
159
- // First, get existing tags
160
- const evidence = this.findById(id);
161
- if (!evidence) {
162
- throw new Error(`Evidence with id ${id} not found`);
1666
+ // Wrap in transaction to prevent read-modify-write race condition
1667
+ const transaction = this.db.transaction(() => {
1668
+ const evidence = this.findById(id);
1669
+ if (!evidence) {
1670
+ throw new Error(`Evidence with id ${id} not found`);
1671
+ }
1672
+ // Parse existing tags (comma-separated) or create empty array
1673
+ const existingTags = evidence.tags
1674
+ ? evidence.tags
1675
+ .split(",")
1676
+ .map((t) => t.trim())
1677
+ .filter((t) => t)
1678
+ : [];
1679
+ // Merge tags (deduplicate) using validTags instead of raw tags
1680
+ const mergedTags = [...new Set([...existingTags, ...validTags])];
1681
+ // Update database with comma-separated format
1682
+ const stmt = this.db.prepare(`
1683
+ UPDATE evidences
1684
+ SET tags = ?,
1685
+ updatedAt = ?
1686
+ WHERE id = ?
1687
+ `);
1688
+ stmt.run(mergedTags.join(","), new Date().toISOString(), id);
1689
+ });
1690
+ transaction();
1691
+ });
1692
+ }
1693
+ /**
1694
+ * Builds a WHERE clause from search/filter options
1695
+ * @param options - Filter criteria
1696
+ * @returns SQL WHERE clause string and parameter values
1697
+ */
1698
+ buildWhereClause(options) {
1699
+ const conditions = [];
1700
+ const params = [];
1701
+ if (options.query && options.query.trim()) {
1702
+ conditions.push(`(conversationId LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')`);
1703
+ const searchPattern = `%${escapeLikePattern(options.query.trim())}%`;
1704
+ params.push(searchPattern, searchPattern);
1705
+ }
1706
+ if (options.tags && options.tags.length > 0) {
1707
+ for (const tag of options.tags) {
1708
+ conditions.push(`(tags LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR tags = ?)`);
1709
+ const trimmedTag = escapeLikePattern(tag.trim());
1710
+ params.push(`${trimmedTag},%`, `%,${trimmedTag},%`, `%,${trimmedTag}`, tag.trim());
163
1711
  }
164
- // Parse existing tags (comma-separated) or create empty array
165
- const existingTags = evidence.tags
166
- ? evidence.tags.split(',').map(t => t.trim()).filter(t => t)
167
- : [];
168
- // Merge tags (deduplicate) using validTags instead of raw tags
169
- const mergedTags = [...new Set([...existingTags, ...validTags])];
170
- // Update database with comma-separated format
171
- const stmt = this.db.prepare(`
172
- UPDATE evidences
173
- SET tags = ?,
174
- updatedAt = ?
175
- WHERE id = ?
176
- `);
177
- stmt.run(mergedTags.join(','), new Date().toISOString(), id);
178
1712
  }
179
- catch (error) {
180
- throw new Error(`Failed to add tags: ${error instanceof Error ? error.message : String(error)}`);
1713
+ if (options.dateFrom) {
1714
+ conditions.push(`timestamp >= ?`);
1715
+ params.push(options.dateFrom);
1716
+ }
1717
+ if (options.dateTo) {
1718
+ conditions.push(`timestamp <= ?`);
1719
+ params.push(options.dateTo);
181
1720
  }
1721
+ const sql = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
1722
+ return { sql, params };
1723
+ }
1724
+ /**
1725
+ * Gets count of evidences matching filter criteria
1726
+ * @param options - Filter criteria (query, tags, date range)
1727
+ * @returns Number of matching evidences
1728
+ */
1729
+ getFilteredCount(options) {
1730
+ return this.dbOp("get filtered count", () => {
1731
+ const { sql: whereClause, params } = this.buildWhereClause(options);
1732
+ const row = this.db
1733
+ .prepare(`SELECT COUNT(*) as count FROM evidences${whereClause}`)
1734
+ .get(...params);
1735
+ return row.count;
1736
+ });
1737
+ }
1738
+ /**
1739
+ * Search evidences and return both paginated results and total matching count
1740
+ * in a single pass (builds WHERE clause once instead of twice)
1741
+ */
1742
+ searchWithCount(options) {
1743
+ return this.dbOp("search evidences", () => {
1744
+ const { limit, offset = 0 } = options;
1745
+ const { sql: whereClause, params: baseParams } = this.buildWhereClause(options);
1746
+ // Wrap both queries in a transaction for consistent snapshot
1747
+ const query = this.db.transaction(() => {
1748
+ // Get total count with same WHERE clause
1749
+ const countRow = this.db
1750
+ .prepare(`SELECT COUNT(*) as count FROM evidences${whereClause}`)
1751
+ .get(...baseParams);
1752
+ // Build paginated query (clone params since we append to it)
1753
+ const searchParams = [...baseParams];
1754
+ const sql = appendPaginationClause(`SELECT * FROM evidences${whereClause} ORDER BY timestamp DESC`, searchParams, limit, offset);
1755
+ const rows = this.db.prepare(sql).all(...searchParams);
1756
+ return {
1757
+ evidences: rows.map((row) => this.rowToEvidence(row)),
1758
+ total: countRow.count,
1759
+ };
1760
+ });
1761
+ return query();
1762
+ });
182
1763
  }
183
1764
  /**
184
1765
  * Search and filter evidences by various criteria
@@ -186,66 +1767,17 @@ export class EvidenceDatabase {
186
1767
  * @returns Array of matching evidences
187
1768
  */
188
1769
  search(options) {
189
- try {
190
- const { query, tags, dateFrom, dateTo, limit, offset = 0 } = options;
191
- // Build WHERE conditions
192
- const conditions = [];
193
- const params = [];
194
- // Text search in conversationId and tags
195
- if (query && query.trim()) {
196
- conditions.push(`(conversationId LIKE ? OR tags LIKE ?)`);
197
- const searchPattern = `%${query.trim()}%`;
198
- params.push(searchPattern, searchPattern);
199
- }
200
- // Tag filtering (AND logic - all specified tags must be present)
201
- // Tags are stored as comma-separated strings: "tag1,tag2,tag3"
202
- if (tags && tags.length > 0) {
203
- for (const tag of tags) {
204
- // Match tag at start, middle, or end of comma-separated list
205
- conditions.push(`(tags LIKE ? OR tags LIKE ? OR tags LIKE ? OR tags = ?)`);
206
- const trimmedTag = tag.trim();
207
- params.push(`${trimmedTag},%`, // tag at start: "tag1,..."
208
- `%,${trimmedTag},%`, // tag in middle: "...,tag1,..."
209
- `%,${trimmedTag}`, // tag at end: "...,tag1"
210
- trimmedTag // exact match (single tag)
211
- );
212
- }
213
- }
214
- // Date range filtering
215
- if (dateFrom) {
216
- conditions.push(`timestamp >= ?`);
217
- params.push(dateFrom);
218
- }
219
- if (dateTo) {
220
- conditions.push(`timestamp <= ?`);
221
- params.push(dateTo);
222
- }
1770
+ return this.dbOp("search evidences", () => {
1771
+ const { limit, offset = 0 } = options;
1772
+ const { sql: whereClause, params } = this.buildWhereClause(options);
223
1773
  // Build final query
224
- let sql = 'SELECT * FROM evidences';
225
- if (conditions.length > 0) {
226
- sql += ' WHERE ' + conditions.join(' AND ');
227
- }
228
- sql += ' ORDER BY timestamp DESC';
1774
+ let sql = `SELECT * FROM evidences${whereClause} ORDER BY timestamp DESC`;
229
1775
  // Add pagination
230
- if (limit !== undefined) {
231
- sql += ' LIMIT ?';
232
- params.push(limit);
233
- if (offset > 0) {
234
- sql += ' OFFSET ?';
235
- params.push(offset);
236
- }
237
- }
238
- else if (offset > 0) {
239
- sql += ' LIMIT -1 OFFSET ?';
240
- params.push(offset);
241
- }
1776
+ sql = appendPaginationClause(sql, params, limit, offset);
242
1777
  const stmt = this.db.prepare(sql);
243
1778
  const rows = stmt.all(...params);
244
1779
  return rows.map((row) => this.rowToEvidence(row));
245
- }
246
- catch (error) {
247
- throw new Error(`Failed to search evidences: ${error instanceof Error ? error.message : String(error)}`);
248
- }
1780
+ });
249
1781
  }
250
1782
  /**
251
1783
  * Deletes evidence by ID
@@ -253,14 +1785,11 @@ export class EvidenceDatabase {
253
1785
  * @returns true if deleted, false if not found
254
1786
  */
255
1787
  delete(id) {
256
- try {
1788
+ return this.dbOp("delete evidence", () => {
257
1789
  const stmt = this.db.prepare(`DELETE FROM evidences WHERE id = ?`);
258
1790
  const result = stmt.run(id);
259
1791
  return result.changes > 0;
260
- }
261
- catch (error) {
262
- throw new Error(`Failed to delete evidence: ${error instanceof Error ? error.message : String(error)}`);
263
- }
1792
+ });
264
1793
  }
265
1794
  /**
266
1795
  * Deletes multiple evidences by IDs
@@ -270,15 +1799,19 @@ export class EvidenceDatabase {
270
1799
  deleteMany(ids) {
271
1800
  if (ids.length === 0)
272
1801
  return 0;
273
- try {
274
- const placeholders = ids.map(() => '?').join(',');
275
- const stmt = this.db.prepare(`DELETE FROM evidences WHERE id IN (${placeholders})`);
276
- const result = stmt.run(...ids);
277
- return result.changes;
278
- }
279
- catch (error) {
280
- throw new Error(`Failed to delete evidences: ${error instanceof Error ? error.message : String(error)}`);
281
- }
1802
+ return this.dbOp("delete evidences", () => {
1803
+ // Batch deletions to stay under SQLite's 999 parameter limit
1804
+ const BATCH_SIZE = 999;
1805
+ let totalDeleted = 0;
1806
+ for (let i = 0; i < ids.length; i += BATCH_SIZE) {
1807
+ const batch = ids.slice(i, i + BATCH_SIZE);
1808
+ const placeholders = batch.map(() => "?").join(",");
1809
+ const stmt = this.db.prepare(`DELETE FROM evidences WHERE id IN (${placeholders})`);
1810
+ const result = stmt.run(...batch);
1811
+ totalDeleted += result.changes;
1812
+ }
1813
+ return totalDeleted;
1814
+ });
282
1815
  }
283
1816
  /**
284
1817
  * Updates tags for an evidence (replaces existing tags)
@@ -287,7 +1820,7 @@ export class EvidenceDatabase {
287
1820
  * @returns true if updated, false if not found
288
1821
  */
289
1822
  updateTags(id, tags) {
290
- try {
1823
+ return this.dbOp("update tags", () => {
291
1824
  const stmt = this.db.prepare(`
292
1825
  UPDATE evidences
293
1826
  SET tags = ?,
@@ -296,10 +1829,7 @@ export class EvidenceDatabase {
296
1829
  `);
297
1830
  const result = stmt.run(tags, new Date().toISOString(), id);
298
1831
  return result.changes > 0;
299
- }
300
- catch (error) {
301
- throw new Error(`Failed to update tags: ${error instanceof Error ? error.message : String(error)}`);
302
- }
1832
+ });
303
1833
  }
304
1834
  /**
305
1835
  * Renames a tag across all evidences atomically using transaction
@@ -309,7 +1839,7 @@ export class EvidenceDatabase {
309
1839
  * @throws Error if transaction fails (no partial updates)
310
1840
  */
311
1841
  renameTag(oldTag, newTag) {
312
- try {
1842
+ return this.dbOp("rename tag", () => {
313
1843
  // Wrap in transaction for atomic updates
314
1844
  const transaction = this.db.transaction(() => {
315
1845
  // Get all evidences with this tag
@@ -318,19 +1848,16 @@ export class EvidenceDatabase {
318
1848
  for (const evidence of evidences) {
319
1849
  if (!evidence.tags)
320
1850
  continue;
321
- const tags = evidence.tags.split(',').map(t => t.trim());
322
- const newTags = tags.map(t => t === oldTag ? newTag : t);
323
- if (this.updateTags(evidence.id, newTags.join(','))) {
1851
+ const tags = evidence.tags.split(",").map((t) => t.trim());
1852
+ const newTags = tags.map((t) => (t === oldTag ? newTag : t));
1853
+ if (this.updateTags(evidence.id, newTags.join(","))) {
324
1854
  updatedCount++;
325
1855
  }
326
1856
  }
327
1857
  return updatedCount;
328
1858
  });
329
1859
  return transaction();
330
- }
331
- catch (error) {
332
- throw new Error(`Failed to rename tag: ${error instanceof Error ? error.message : String(error)}`);
333
- }
1860
+ });
334
1861
  }
335
1862
  /**
336
1863
  * Removes a tag from all evidences atomically using transaction
@@ -339,7 +1866,7 @@ export class EvidenceDatabase {
339
1866
  * @throws Error if transaction fails (no partial updates)
340
1867
  */
341
1868
  removeTag(tag) {
342
- try {
1869
+ return this.dbOp("remove tag", () => {
343
1870
  // Wrap in transaction for atomic updates
344
1871
  const transaction = this.db.transaction(() => {
345
1872
  // Get all evidences with this tag
@@ -348,8 +1875,11 @@ export class EvidenceDatabase {
348
1875
  for (const evidence of evidences) {
349
1876
  if (!evidence.tags)
350
1877
  continue;
351
- const tags = evidence.tags.split(',').map(t => t.trim()).filter(t => t !== tag);
352
- const newTags = tags.length > 0 ? tags.join(',') : null;
1878
+ const tags = evidence.tags
1879
+ .split(",")
1880
+ .map((t) => t.trim())
1881
+ .filter((t) => t !== tag);
1882
+ const newTags = tags.length > 0 ? tags.join(",") : null;
353
1883
  if (this.updateTags(evidence.id, newTags)) {
354
1884
  updatedCount++;
355
1885
  }
@@ -357,31 +1887,52 @@ export class EvidenceDatabase {
357
1887
  return updatedCount;
358
1888
  });
359
1889
  return transaction();
360
- }
361
- catch (error) {
362
- throw new Error(`Failed to remove tag: ${error instanceof Error ? error.message : String(error)}`);
363
- }
1890
+ });
364
1891
  }
365
1892
  /**
366
1893
  * Gets all unique tags with their counts
367
1894
  * @returns Map of tag to count
368
1895
  */
369
1896
  getTagCounts() {
370
- try {
1897
+ return this.dbOp("get tag counts", () => {
371
1898
  const stmt = this.db.prepare(`SELECT tags FROM evidences WHERE tags IS NOT NULL AND tags != ''`);
372
1899
  const rows = stmt.all();
373
1900
  const tagCounts = new Map();
374
1901
  for (const row of rows) {
375
- const tags = row.tags.split(',').map(t => t.trim()).filter(t => t);
1902
+ const tags = row.tags
1903
+ .split(",")
1904
+ .map((t) => t.trim())
1905
+ .filter((t) => t);
376
1906
  for (const tag of tags) {
377
1907
  tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
378
1908
  }
379
1909
  }
380
1910
  return tagCounts;
381
- }
382
- catch (error) {
383
- throw new Error(`Failed to get tag counts: ${error instanceof Error ? error.message : String(error)}`);
384
- }
1911
+ });
1912
+ }
1913
+ /**
1914
+ * Gets total count of all evidence records
1915
+ * @returns Total number of evidences in the database
1916
+ */
1917
+ getTotalCount() {
1918
+ const row = this.db
1919
+ .prepare("SELECT COUNT(*) as count FROM evidences")
1920
+ .get();
1921
+ return row.count;
1922
+ }
1923
+ getSessionCount() {
1924
+ const row = this.db
1925
+ .prepare("SELECT COUNT(*) as count FROM sessions")
1926
+ .get();
1927
+ return row.count;
1928
+ }
1929
+ /**
1930
+ * Gets count of evidence records matching a search query
1931
+ * @param query - Search text to match against conversationId and tags
1932
+ * @returns Number of matching evidences
1933
+ */
1934
+ getSearchCount(query) {
1935
+ return this.getFilteredCount({ query });
385
1936
  }
386
1937
  /**
387
1938
  * Get the underlying database instance
@@ -419,7 +1970,131 @@ export class EvidenceDatabase {
419
1970
  gitTimestamp: row.gitTimestamp,
420
1971
  tags: row.tags,
421
1972
  createdAt: row.createdAt,
422
- updatedAt: row.updatedAt
1973
+ updatedAt: row.updatedAt,
1974
+ };
1975
+ }
1976
+ rowToSession(row) {
1977
+ return {
1978
+ id: row.id,
1979
+ host: row.host,
1980
+ projectRoot: row.projectRoot,
1981
+ cwd: row.cwd,
1982
+ title: row.title,
1983
+ status: row.status,
1984
+ startedAt: row.startedAt,
1985
+ endedAt: row.endedAt,
1986
+ metadata: row.metadata,
1987
+ createdAt: row.createdAt,
1988
+ updatedAt: row.updatedAt,
1989
+ };
1990
+ }
1991
+ rowToContext(row) {
1992
+ return {
1993
+ id: row.id,
1994
+ label: row.label,
1995
+ workspaceKey: row.workspaceKey,
1996
+ status: row.status,
1997
+ mergedIntoContextId: row.mergedIntoContextId,
1998
+ metadata: row.metadata,
1999
+ createdAt: row.createdAt,
2000
+ updatedAt: row.updatedAt,
2001
+ };
2002
+ }
2003
+ rowToContextSessionLink(row) {
2004
+ return {
2005
+ sessionId: row.sessionId,
2006
+ contextId: row.contextId,
2007
+ linkSource: row.linkSource,
2008
+ createdAt: row.createdAt,
2009
+ updatedAt: row.updatedAt,
2010
+ };
2011
+ }
2012
+ rowToContextLinkRejection(row) {
2013
+ return {
2014
+ sessionId: row.sessionId,
2015
+ contextId: row.contextId,
2016
+ createdAt: row.createdAt,
2017
+ };
2018
+ }
2019
+ rowToContextWorkspacePreference(row) {
2020
+ return {
2021
+ workspaceKey: row.workspaceKey,
2022
+ contextId: row.contextId,
2023
+ createdAt: row.createdAt,
2024
+ updatedAt: row.updatedAt,
2025
+ };
2026
+ }
2027
+ rowToMessage(row) {
2028
+ return {
2029
+ id: row.id,
2030
+ sessionId: row.sessionId,
2031
+ seq: row.seq,
2032
+ role: row.role,
2033
+ source: row.source,
2034
+ content: row.content,
2035
+ capturedAt: row.capturedAt,
2036
+ metadata: row.metadata,
2037
+ };
2038
+ }
2039
+ rowToTimelineEvent(row) {
2040
+ return {
2041
+ id: row.id,
2042
+ sessionId: row.sessionId,
2043
+ seq: row.seq,
2044
+ eventType: row.eventType,
2045
+ eventSubType: row.eventSubType,
2046
+ source: row.source,
2047
+ summary: row.summary,
2048
+ payload: row.payload,
2049
+ startedAt: row.startedAt,
2050
+ endedAt: row.endedAt,
2051
+ status: row.status,
2052
+ relatedMessageId: row.relatedMessageId,
2053
+ };
2054
+ }
2055
+ rowToArtifact(row) {
2056
+ return {
2057
+ id: row.id,
2058
+ sessionId: row.sessionId,
2059
+ eventId: row.eventId,
2060
+ artifactType: row.artifactType,
2061
+ path: row.path,
2062
+ metadata: row.metadata,
2063
+ createdAt: row.createdAt,
2064
+ };
2065
+ }
2066
+ rowToDecision(row) {
2067
+ return {
2068
+ id: row.id,
2069
+ sessionId: row.sessionId,
2070
+ title: row.title,
2071
+ summary: row.summary,
2072
+ rationale: row.rationale,
2073
+ status: row.status,
2074
+ sourceRefs: row.sourceRefs,
2075
+ createdAt: row.createdAt,
2076
+ };
2077
+ }
2078
+ rowToNarrative(row) {
2079
+ return {
2080
+ id: row.id,
2081
+ sessionId: row.sessionId,
2082
+ kind: row.kind,
2083
+ content: row.content,
2084
+ sourceRefs: row.sourceRefs,
2085
+ createdAt: row.createdAt,
2086
+ updatedAt: row.updatedAt,
2087
+ };
2088
+ }
2089
+ rowToIngestionRun(row) {
2090
+ return {
2091
+ id: row.id,
2092
+ sessionId: row.sessionId,
2093
+ stage: row.stage,
2094
+ status: row.status,
2095
+ error: row.error,
2096
+ startedAt: row.startedAt,
2097
+ endedAt: row.endedAt,
423
2098
  };
424
2099
  }
425
2100
  }