@mxalbert/context-mode 2.0.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 (365) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +31 -0
  3. package/.codex-plugin/hooks.json +65 -0
  4. package/.codex-plugin/mcp.json +13 -0
  5. package/.codex-plugin/plugin.json +31 -0
  6. package/.openclaw-plugin/index.ts +12 -0
  7. package/.openclaw-plugin/openclaw.plugin.json +38 -0
  8. package/.openclaw-plugin/package.json +28 -0
  9. package/LICENSE +94 -0
  10. package/README.md +1619 -0
  11. package/bin/statusline.mjs +378 -0
  12. package/build/adapters/antigravity/index.d.ts +51 -0
  13. package/build/adapters/antigravity/index.js +169 -0
  14. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  15. package/build/adapters/antigravity-cli/index.js +342 -0
  16. package/build/adapters/base.d.ts +92 -0
  17. package/build/adapters/base.js +135 -0
  18. package/build/adapters/claude-code/config.d.ts +8 -0
  19. package/build/adapters/claude-code/config.js +8 -0
  20. package/build/adapters/claude-code/hooks.d.ts +106 -0
  21. package/build/adapters/claude-code/hooks.js +183 -0
  22. package/build/adapters/claude-code/index.d.ts +80 -0
  23. package/build/adapters/claude-code/index.js +569 -0
  24. package/build/adapters/claude-code-base.d.ts +49 -0
  25. package/build/adapters/claude-code-base.js +113 -0
  26. package/build/adapters/client-map.d.ts +10 -0
  27. package/build/adapters/client-map.js +42 -0
  28. package/build/adapters/codex/config.d.ts +8 -0
  29. package/build/adapters/codex/config.js +8 -0
  30. package/build/adapters/codex/hooks.d.ts +55 -0
  31. package/build/adapters/codex/hooks.js +64 -0
  32. package/build/adapters/codex/index.d.ts +87 -0
  33. package/build/adapters/codex/index.js +995 -0
  34. package/build/adapters/codex/paths.d.ts +1 -0
  35. package/build/adapters/codex/paths.js +12 -0
  36. package/build/adapters/codex/usage.d.ts +107 -0
  37. package/build/adapters/codex/usage.js +227 -0
  38. package/build/adapters/copilot-base.d.ts +94 -0
  39. package/build/adapters/copilot-base.js +297 -0
  40. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  41. package/build/adapters/copilot-cli/hooks.js +64 -0
  42. package/build/adapters/copilot-cli/index.d.ts +48 -0
  43. package/build/adapters/copilot-cli/index.js +341 -0
  44. package/build/adapters/cursor/config.d.ts +4 -0
  45. package/build/adapters/cursor/config.js +4 -0
  46. package/build/adapters/cursor/hooks.d.ts +55 -0
  47. package/build/adapters/cursor/hooks.js +82 -0
  48. package/build/adapters/cursor/index.d.ts +64 -0
  49. package/build/adapters/cursor/index.js +517 -0
  50. package/build/adapters/detect.d.ts +136 -0
  51. package/build/adapters/detect.js +623 -0
  52. package/build/adapters/gemini-cli/config.d.ts +8 -0
  53. package/build/adapters/gemini-cli/config.js +8 -0
  54. package/build/adapters/gemini-cli/hooks.d.ts +83 -0
  55. package/build/adapters/gemini-cli/hooks.js +115 -0
  56. package/build/adapters/gemini-cli/index.d.ts +80 -0
  57. package/build/adapters/gemini-cli/index.js +483 -0
  58. package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
  59. package/build/adapters/jetbrains-copilot/config.js +8 -0
  60. package/build/adapters/jetbrains-copilot/hooks.d.ts +59 -0
  61. package/build/adapters/jetbrains-copilot/hooks.js +87 -0
  62. package/build/adapters/jetbrains-copilot/index.d.ts +31 -0
  63. package/build/adapters/jetbrains-copilot/index.js +131 -0
  64. package/build/adapters/kimi/config.d.ts +8 -0
  65. package/build/adapters/kimi/config.js +8 -0
  66. package/build/adapters/kimi/hooks.d.ts +28 -0
  67. package/build/adapters/kimi/hooks.js +34 -0
  68. package/build/adapters/kimi/index.d.ts +66 -0
  69. package/build/adapters/kimi/index.js +537 -0
  70. package/build/adapters/kimi/paths.d.ts +21 -0
  71. package/build/adapters/kimi/paths.js +52 -0
  72. package/build/adapters/kimi/usage.d.ts +82 -0
  73. package/build/adapters/kimi/usage.js +217 -0
  74. package/build/adapters/kiro/hooks.d.ts +61 -0
  75. package/build/adapters/kiro/hooks.js +94 -0
  76. package/build/adapters/kiro/index.d.ts +61 -0
  77. package/build/adapters/kiro/index.js +301 -0
  78. package/build/adapters/omp/index.d.ts +65 -0
  79. package/build/adapters/omp/index.js +182 -0
  80. package/build/adapters/omp/plugin.d.ts +81 -0
  81. package/build/adapters/omp/plugin.js +331 -0
  82. package/build/adapters/omp/usage.d.ts +49 -0
  83. package/build/adapters/omp/usage.js +110 -0
  84. package/build/adapters/openclaw/config.d.ts +8 -0
  85. package/build/adapters/openclaw/config.js +8 -0
  86. package/build/adapters/openclaw/hooks.d.ts +50 -0
  87. package/build/adapters/openclaw/hooks.js +61 -0
  88. package/build/adapters/openclaw/index.d.ts +74 -0
  89. package/build/adapters/openclaw/index.js +439 -0
  90. package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
  91. package/build/adapters/openclaw/mcp-tools.js +207 -0
  92. package/build/adapters/openclaw/plugin.d.ts +141 -0
  93. package/build/adapters/openclaw/plugin.js +818 -0
  94. package/build/adapters/openclaw/session-db.d.ts +55 -0
  95. package/build/adapters/openclaw/session-db.js +88 -0
  96. package/build/adapters/openclaw/usage.d.ts +34 -0
  97. package/build/adapters/openclaw/usage.js +52 -0
  98. package/build/adapters/openclaw/workspace-router.d.ts +29 -0
  99. package/build/adapters/openclaw/workspace-router.js +64 -0
  100. package/build/adapters/opencode/config.d.ts +8 -0
  101. package/build/adapters/opencode/config.js +8 -0
  102. package/build/adapters/opencode/hooks.d.ts +38 -0
  103. package/build/adapters/opencode/hooks.js +50 -0
  104. package/build/adapters/opencode/index.d.ts +144 -0
  105. package/build/adapters/opencode/index.js +638 -0
  106. package/build/adapters/opencode/plugin.d.ts +249 -0
  107. package/build/adapters/opencode/plugin.js +1536 -0
  108. package/build/adapters/opencode/v2.d.ts +117 -0
  109. package/build/adapters/opencode/v2.js +224 -0
  110. package/build/adapters/opencode/zod3tov4.d.ts +1 -0
  111. package/build/adapters/opencode/zod3tov4.js +132 -0
  112. package/build/adapters/pi/extension.d.ts +81 -0
  113. package/build/adapters/pi/extension.js +861 -0
  114. package/build/adapters/pi/index.d.ts +57 -0
  115. package/build/adapters/pi/index.js +173 -0
  116. package/build/adapters/pi/mcp-bridge.d.ts +268 -0
  117. package/build/adapters/pi/mcp-bridge.js +894 -0
  118. package/build/adapters/qwen-code/hooks.d.ts +26 -0
  119. package/build/adapters/qwen-code/hooks.js +29 -0
  120. package/build/adapters/qwen-code/index.d.ts +40 -0
  121. package/build/adapters/qwen-code/index.js +361 -0
  122. package/build/adapters/qwen-code/usage.d.ts +90 -0
  123. package/build/adapters/qwen-code/usage.js +222 -0
  124. package/build/adapters/types.d.ts +360 -0
  125. package/build/adapters/types.js +118 -0
  126. package/build/adapters/vscode-copilot/config.d.ts +8 -0
  127. package/build/adapters/vscode-copilot/config.js +8 -0
  128. package/build/adapters/vscode-copilot/hooks.d.ts +71 -0
  129. package/build/adapters/vscode-copilot/hooks.js +98 -0
  130. package/build/adapters/vscode-copilot/index.d.ts +32 -0
  131. package/build/adapters/vscode-copilot/index.js +222 -0
  132. package/build/adapters/zed/index.d.ts +40 -0
  133. package/build/adapters/zed/index.js +163 -0
  134. package/build/cli.d.ts +34 -0
  135. package/build/cli.js +1828 -0
  136. package/build/db-base.d.ts +250 -0
  137. package/build/db-base.js +756 -0
  138. package/build/executor.d.ts +72 -0
  139. package/build/executor.js +678 -0
  140. package/build/exit-classify.d.ts +19 -0
  141. package/build/exit-classify.js +12 -0
  142. package/build/fetch/blocks.d.ts +145 -0
  143. package/build/fetch/blocks.js +244 -0
  144. package/build/fetch/extract.d.ts +68 -0
  145. package/build/fetch/extract.js +123 -0
  146. package/build/fetch/page-store.d.ts +66 -0
  147. package/build/fetch/page-store.js +174 -0
  148. package/build/fetch-cache.d.ts +13 -0
  149. package/build/fetch-cache.js +15 -0
  150. package/build/lifecycle.d.ts +124 -0
  151. package/build/lifecycle.js +278 -0
  152. package/build/package-identity.d.ts +26 -0
  153. package/build/package-identity.js +28 -0
  154. package/build/runPool.d.ts +36 -0
  155. package/build/runPool.js +51 -0
  156. package/build/runtime.d.ts +71 -0
  157. package/build/runtime.js +659 -0
  158. package/build/search/auto-memory.d.ts +47 -0
  159. package/build/search/auto-memory.js +170 -0
  160. package/build/search/ctx-search-schema.d.ts +90 -0
  161. package/build/search/ctx-search-schema.js +135 -0
  162. package/build/search/flood-guard.d.ts +57 -0
  163. package/build/search/flood-guard.js +80 -0
  164. package/build/search/unified.d.ts +56 -0
  165. package/build/search/unified.js +104 -0
  166. package/build/security.d.ts +206 -0
  167. package/build/security.js +780 -0
  168. package/build/server.d.ts +240 -0
  169. package/build/server.js +5034 -0
  170. package/build/session/analytics.d.ts +773 -0
  171. package/build/session/analytics.js +2420 -0
  172. package/build/session/db.d.ts +468 -0
  173. package/build/session/db.js +1330 -0
  174. package/build/session/error-classifier.d.ts +87 -0
  175. package/build/session/error-classifier.js +303 -0
  176. package/build/session/event-emit.d.ts +48 -0
  177. package/build/session/event-emit.js +101 -0
  178. package/build/session/extract.d.ts +333 -0
  179. package/build/session/extract.js +2688 -0
  180. package/build/session/model-prices.json +429 -0
  181. package/build/session/persist-tool-calls.d.ts +54 -0
  182. package/build/session/persist-tool-calls.js +105 -0
  183. package/build/session/pricing.d.ts +64 -0
  184. package/build/session/pricing.js +151 -0
  185. package/build/session/project-attribution.d.ts +73 -0
  186. package/build/session/project-attribution.js +245 -0
  187. package/build/session/purge.d.ts +138 -0
  188. package/build/session/purge.js +240 -0
  189. package/build/session/retrieval-marker.d.ts +39 -0
  190. package/build/session/retrieval-marker.js +65 -0
  191. package/build/session/snapshot.d.ts +46 -0
  192. package/build/session/snapshot.js +532 -0
  193. package/build/store-directory.d.ts +56 -0
  194. package/build/store-directory.js +254 -0
  195. package/build/store.d.ts +147 -0
  196. package/build/store.js +1714 -0
  197. package/build/truncate.d.ts +57 -0
  198. package/build/truncate.js +142 -0
  199. package/build/types.d.ts +117 -0
  200. package/build/types.js +20 -0
  201. package/build/util/claude-config.d.ts +32 -0
  202. package/build/util/claude-config.js +84 -0
  203. package/build/util/hook-config.d.ts +27 -0
  204. package/build/util/hook-config.js +76 -0
  205. package/build/util/jsonc.d.ts +14 -0
  206. package/build/util/jsonc.js +104 -0
  207. package/build/util/plugin-cache-integrity.d.ts +51 -0
  208. package/build/util/plugin-cache-integrity.js +146 -0
  209. package/build/util/project-dir.d.ts +149 -0
  210. package/build/util/project-dir.js +340 -0
  211. package/build/util/sibling-mcp.d.ts +79 -0
  212. package/build/util/sibling-mcp.js +181 -0
  213. package/cli.bundle.mjs +1495 -0
  214. package/configs/antigravity/GEMINI.md +68 -0
  215. package/configs/antigravity/mcp_config.json +7 -0
  216. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  217. package/configs/antigravity-cli/hooks.json +37 -0
  218. package/configs/antigravity-cli/mcp_config.json +10 -0
  219. package/configs/antigravity-cli/plugin.json +14 -0
  220. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  221. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  222. package/configs/claude-code/CLAUDE.md +91 -0
  223. package/configs/codex/AGENTS.md +99 -0
  224. package/configs/codex/config.toml +8 -0
  225. package/configs/codex/hooks.json +47 -0
  226. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  227. package/configs/copilot-cli/.mcp.json +12 -0
  228. package/configs/copilot-cli/README.md +47 -0
  229. package/configs/copilot-cli/hooks.json +41 -0
  230. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  231. package/configs/cursor/context-mode.mdc +76 -0
  232. package/configs/cursor/hooks.json +21 -0
  233. package/configs/cursor/mcp.json +7 -0
  234. package/configs/gemini-cli/GEMINI.md +88 -0
  235. package/configs/gemini-cli/mcp.json +7 -0
  236. package/configs/gemini-cli/settings.json +60 -0
  237. package/configs/jetbrains-copilot/copilot-instructions.md +80 -0
  238. package/configs/jetbrains-copilot/hooks.json +16 -0
  239. package/configs/jetbrains-copilot/mcp.json +7 -0
  240. package/configs/kilo/AGENTS.md +84 -0
  241. package/configs/kilo/kilo.json +6 -0
  242. package/configs/kimi/hooks.json +54 -0
  243. package/configs/kiro/KIRO.md +84 -0
  244. package/configs/kiro/agent.json +18 -0
  245. package/configs/kiro/mcp.json +7 -0
  246. package/configs/omp/SYSTEM.md +85 -0
  247. package/configs/omp/mcp.json +7 -0
  248. package/configs/openclaw/AGENTS.md +87 -0
  249. package/configs/openclaw/openclaw.json +13 -0
  250. package/configs/opencode/AGENTS.md +84 -0
  251. package/configs/opencode/opencode.json +6 -0
  252. package/configs/pi/AGENTS.md +3 -0
  253. package/configs/qwen-code/QWEN.md +91 -0
  254. package/configs/vscode-copilot/copilot-instructions.md +80 -0
  255. package/configs/vscode-copilot/hooks.json +16 -0
  256. package/configs/vscode-copilot/mcp.json +7 -0
  257. package/configs/zed/AGENTS.md +68 -0
  258. package/hooks/antigravity-cli/payload.mjs +98 -0
  259. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  260. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  261. package/hooks/antigravity-cli/stop.mjs +58 -0
  262. package/hooks/auto-injection.mjs +102 -0
  263. package/hooks/cache-heal-utils.mjs +379 -0
  264. package/hooks/codex/platform.mjs +1 -0
  265. package/hooks/codex/posttooluse.mjs +66 -0
  266. package/hooks/codex/precompact.mjs +70 -0
  267. package/hooks/codex/pretooluse.mjs +42 -0
  268. package/hooks/codex/sessionstart.mjs +121 -0
  269. package/hooks/codex/stop.mjs +138 -0
  270. package/hooks/codex/userpromptsubmit.mjs +75 -0
  271. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  272. package/hooks/copilot-cli/precompact.mjs +66 -0
  273. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  274. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  275. package/hooks/copilot-cli/stop.mjs +59 -0
  276. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  277. package/hooks/core/codex-caps.mjs +112 -0
  278. package/hooks/core/formatters.mjs +356 -0
  279. package/hooks/core/mcp-ready.mjs +109 -0
  280. package/hooks/core/platform-detect.mjs +49 -0
  281. package/hooks/core/routing.mjs +1050 -0
  282. package/hooks/core/stdin.mjs +90 -0
  283. package/hooks/core/tool-naming.mjs +57 -0
  284. package/hooks/cursor/afteragentresponse.mjs +74 -0
  285. package/hooks/cursor/hooks.json +31 -0
  286. package/hooks/cursor/posttooluse.mjs +72 -0
  287. package/hooks/cursor/pretooluse.mjs +26 -0
  288. package/hooks/cursor/sessionstart.mjs +97 -0
  289. package/hooks/cursor/stop.mjs +48 -0
  290. package/hooks/ensure-deps.mjs +254 -0
  291. package/hooks/formatters/claude-code.mjs +113 -0
  292. package/hooks/formatters/cursor.mjs +37 -0
  293. package/hooks/formatters/gemini-cli.mjs +55 -0
  294. package/hooks/formatters/vscode-copilot.mjs +55 -0
  295. package/hooks/gemini-cli/aftermodel.mjs +70 -0
  296. package/hooks/gemini-cli/aftertool.mjs +61 -0
  297. package/hooks/gemini-cli/beforeagent.mjs +99 -0
  298. package/hooks/gemini-cli/beforetool.mjs +27 -0
  299. package/hooks/gemini-cli/precompress.mjs +55 -0
  300. package/hooks/gemini-cli/sessionstart.mjs +137 -0
  301. package/hooks/heal-partial-install.mjs +712 -0
  302. package/hooks/hooks.json +143 -0
  303. package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
  304. package/hooks/jetbrains-copilot/precompact.mjs +55 -0
  305. package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
  306. package/hooks/jetbrains-copilot/sessionstart.mjs +124 -0
  307. package/hooks/kimi/platform.mjs +1 -0
  308. package/hooks/kimi/posttooluse.mjs +72 -0
  309. package/hooks/kimi/precompact.mjs +80 -0
  310. package/hooks/kimi/pretooluse.mjs +42 -0
  311. package/hooks/kimi/sessionend.mjs +61 -0
  312. package/hooks/kimi/sessionstart.mjs +113 -0
  313. package/hooks/kimi/stop.mjs +132 -0
  314. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  315. package/hooks/kiro/agentspawn.mjs +97 -0
  316. package/hooks/kiro/posttooluse.mjs +51 -0
  317. package/hooks/kiro/pretooluse.mjs +64 -0
  318. package/hooks/kiro/userpromptsubmit.mjs +88 -0
  319. package/hooks/normalize-hooks.mjs +323 -0
  320. package/hooks/platform-bridge.mjs +340 -0
  321. package/hooks/posttooluse.mjs +225 -0
  322. package/hooks/precompact.mjs +100 -0
  323. package/hooks/pretooluse.mjs +227 -0
  324. package/hooks/qwen-code/platform.mjs +1 -0
  325. package/hooks/qwen-code/stop.mjs +168 -0
  326. package/hooks/routing-block.mjs +105 -0
  327. package/hooks/run-hook.mjs +95 -0
  328. package/hooks/security.bundle.mjs +2 -0
  329. package/hooks/session-attribution.bundle.mjs +1 -0
  330. package/hooks/session-db.bundle.mjs +145 -0
  331. package/hooks/session-directive.mjs +520 -0
  332. package/hooks/session-extract.bundle.mjs +3 -0
  333. package/hooks/session-helpers.mjs +428 -0
  334. package/hooks/session-loaders.mjs +448 -0
  335. package/hooks/session-snapshot.bundle.mjs +32 -0
  336. package/hooks/sessionstart.mjs +464 -0
  337. package/hooks/stop.mjs +82 -0
  338. package/hooks/suppress-stderr.mjs +25 -0
  339. package/hooks/userpromptsubmit.mjs +108 -0
  340. package/hooks/vscode-copilot/posttooluse.mjs +61 -0
  341. package/hooks/vscode-copilot/precompact.mjs +55 -0
  342. package/hooks/vscode-copilot/pretooluse.mjs +27 -0
  343. package/hooks/vscode-copilot/sessionstart.mjs +129 -0
  344. package/openclaw.plugin.json +38 -0
  345. package/package.json +132 -0
  346. package/scripts/heal-better-sqlite3.mjs +430 -0
  347. package/scripts/heal-installed-plugins.mjs +635 -0
  348. package/scripts/plugin-cache-integrity.mjs +248 -0
  349. package/scripts/postinstall.mjs +397 -0
  350. package/server.bundle.mjs +1421 -0
  351. package/server.js +5 -0
  352. package/skills/.ignore +7 -0
  353. package/skills/context-mode/SKILL.md +300 -0
  354. package/skills/context-mode/references/anti-patterns.md +283 -0
  355. package/skills/context-mode/references/patterns-javascript.md +298 -0
  356. package/skills/context-mode/references/patterns-python.md +304 -0
  357. package/skills/context-mode/references/patterns-shell.md +277 -0
  358. package/skills/ctx-doctor/SKILL.md +22 -0
  359. package/skills/ctx-index/SKILL.md +46 -0
  360. package/skills/ctx-insight/SKILL.md +24 -0
  361. package/skills/ctx-purge/SKILL.md +49 -0
  362. package/skills/ctx-search/SKILL.md +35 -0
  363. package/skills/ctx-stats/SKILL.md +26 -0
  364. package/skills/ctx-upgrade/SKILL.md +31 -0
  365. package/start.mjs +616 -0
@@ -0,0 +1,1330 @@
1
+ /**
2
+ * SessionDB — Persistent per-project SQLite database for session events.
3
+ *
4
+ * Stores raw events captured by hooks during a Claude Code session,
5
+ * session metadata, and resume snapshots. Extends SQLiteBase from
6
+ * the shared package.
7
+ */
8
+ import { SQLiteBase, defaultDBPath, logDbError } from "../db-base.js";
9
+ import { createHash } from "node:crypto";
10
+ import { execFileSync } from "node:child_process";
11
+ import { accessSync, constants, existsSync, mkdirSync, realpathSync, renameSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, isAbsolute, join, resolve } from "node:path";
14
+ // ─────────────────────────────────────────────────────────
15
+ // Storage root resolution
16
+ // ─────────────────────────────────────────────────────────
17
+ //
18
+ // This lives beside the session DB path helpers because packaged hooks and the
19
+ // statusline already consume `hooks/session-db.bundle.mjs` as their no-build
20
+ // runtime bridge. Keeping the storage resolver here avoids adding a second
21
+ // generated hook bundle just to share CONTEXT_MODE_DIR behavior.
22
+ const STORAGE_ROOT_ENV = "CONTEXT_MODE_DIR";
23
+ const STORAGE_SESSIONS_SUBDIR = "sessions";
24
+ const STORAGE_CONTENT_SUBDIR = "content";
25
+ export class StorageDirectoryError extends Error {
26
+ kind;
27
+ path;
28
+ overrideEnvVar;
29
+ ignoredEnvVar;
30
+ ignoredReason;
31
+ constructor(kind, path, overrideEnvVar = STORAGE_ROOT_ENV, cause, message, metadata = {}) {
32
+ super(message ?? storageDirectoryErrorMessage(kind, path, metadata), { cause });
33
+ this.name = "StorageDirectoryError";
34
+ this.kind = kind;
35
+ this.path = path;
36
+ this.overrideEnvVar = overrideEnvVar;
37
+ this.ignoredEnvVar = metadata.ignoredEnvVar;
38
+ this.ignoredReason = metadata.ignoredReason;
39
+ }
40
+ }
41
+ const writableStorageCache = new Map();
42
+ export function resolveDefaultSessionDir(opts) {
43
+ const env = opts.env ?? process.env;
44
+ const legacyEnvVar = opts.legacySessionDirEnv;
45
+ const legacy = legacyEnvVar ? env[legacyEnvVar]?.trim() : undefined;
46
+ if (legacy && legacyEnvVar) {
47
+ opts.onLegacySessionDir?.(legacyEnvVar, legacy);
48
+ return legacy;
49
+ }
50
+ return join(resolveConfigDirForDefaultSession(opts.configDir, opts.configDirEnv, env), "context-mode", "sessions");
51
+ }
52
+ function resolveConfigDirForDefaultSession(configDir, configDirEnv, env) {
53
+ const envValue = configDirEnv ? env[configDirEnv] : undefined;
54
+ if (envValue && envValue.trim() !== "") {
55
+ return resolveConfigDirValue(envValue.trim());
56
+ }
57
+ return resolveConfigDirValue(configDir, homedir());
58
+ }
59
+ function resolveConfigDirValue(value, baseDir) {
60
+ if (value.startsWith("~"))
61
+ return resolve(homedir(), value.replace(/^~[/\\]?/, ""));
62
+ if (isAbsolute(value))
63
+ return resolve(value);
64
+ return baseDir ? resolve(baseDir, value) : resolve(value);
65
+ }
66
+ function invalidStorageOverride(kind, path, detail) {
67
+ return new StorageDirectoryError(kind, path, STORAGE_ROOT_ENV, undefined, [`Invalid ${STORAGE_ROOT_ENV} for context-mode ${kind} directory: ${detail}`, storageDirectoryHint()].join("\n"));
68
+ }
69
+ function storageOverrideRoot(kind) {
70
+ const raw = process.env[STORAGE_ROOT_ENV];
71
+ if (raw === undefined)
72
+ return { kind: "unset" };
73
+ const trimmed = raw.trim();
74
+ if (!trimmed) {
75
+ return { kind: "ignored-empty", ignoredEnvVar: STORAGE_ROOT_ENV, ignoredReason: "empty" };
76
+ }
77
+ if (!isAbsolute(trimmed)) {
78
+ throw invalidStorageOverride(kind, trimmed, `${STORAGE_ROOT_ENV} must be an absolute path.`);
79
+ }
80
+ return { kind: "override", root: resolve(trimmed) };
81
+ }
82
+ function ignoredStorageMetadata(root) {
83
+ return root.kind === "ignored-empty"
84
+ ? { ignoredEnvVar: root.ignoredEnvVar, ignoredReason: root.ignoredReason }
85
+ : {};
86
+ }
87
+ function overrideStorageDir(kind, subdir) {
88
+ const root = storageOverrideRoot(kind);
89
+ if (root.kind !== "override")
90
+ return null;
91
+ return {
92
+ kind,
93
+ path: join(root.root, subdir),
94
+ envVar: STORAGE_ROOT_ENV,
95
+ source: "override",
96
+ };
97
+ }
98
+ function defaultStorageDir(kind, getDefaultDir, metadata) {
99
+ return {
100
+ kind,
101
+ path: resolve(getDefaultDir()),
102
+ envVar: null,
103
+ source: "default",
104
+ ...metadata,
105
+ };
106
+ }
107
+ export function resolveSessionStorageDir(getDefaultDir) {
108
+ const root = storageOverrideRoot("session");
109
+ if (root.kind === "override") {
110
+ return {
111
+ kind: "session",
112
+ path: join(root.root, STORAGE_SESSIONS_SUBDIR),
113
+ envVar: STORAGE_ROOT_ENV,
114
+ source: "override",
115
+ };
116
+ }
117
+ return defaultStorageDir("session", getDefaultDir, ignoredStorageMetadata(root));
118
+ }
119
+ export function resolveContentStorageDir(getSessionDir) {
120
+ const override = overrideStorageDir("content", STORAGE_CONTENT_SUBDIR);
121
+ if (override)
122
+ return override;
123
+ const session = resolveSessionStorageDir(getSessionDir);
124
+ return {
125
+ kind: "content",
126
+ path: join(dirname(session.path), STORAGE_CONTENT_SUBDIR),
127
+ envVar: session.envVar,
128
+ source: session.source,
129
+ ignoredEnvVar: session.ignoredEnvVar,
130
+ ignoredReason: session.ignoredReason,
131
+ };
132
+ }
133
+ export function resolveStatsStorageDir(getDefaultSessionDir) {
134
+ const override = overrideStorageDir("stats", STORAGE_SESSIONS_SUBDIR);
135
+ if (override)
136
+ return override;
137
+ const session = resolveSessionStorageDir(getDefaultSessionDir);
138
+ return {
139
+ kind: "stats",
140
+ path: session.path,
141
+ envVar: session.envVar,
142
+ source: session.source,
143
+ ignoredEnvVar: session.ignoredEnvVar,
144
+ ignoredReason: session.ignoredReason,
145
+ };
146
+ }
147
+ export function formatStorageDirectoryError(err) {
148
+ return err.message;
149
+ }
150
+ export function describeStorageDirectorySource(dir) {
151
+ if (dir.source === "override" && dir.envVar)
152
+ return `via ${dir.envVar}`;
153
+ if (dir.ignoredEnvVar && dir.ignoredReason === "empty")
154
+ return `default; ignored empty ${dir.ignoredEnvVar}`;
155
+ return "default";
156
+ }
157
+ export function clearStorageDirectoryCheckCacheForTests() {
158
+ writableStorageCache.clear();
159
+ }
160
+ export function ensureWritableStorageDir(dir) {
161
+ const key = [
162
+ dir.kind,
163
+ dir.path,
164
+ dir.source,
165
+ dir.envVar ?? "",
166
+ dir.ignoredEnvVar ?? "",
167
+ dir.ignoredReason ?? "",
168
+ ].join("\0");
169
+ const cached = writableStorageCache.get(key);
170
+ if (cached instanceof StorageDirectoryError)
171
+ throw cached;
172
+ if (cached === dir.path)
173
+ return cached;
174
+ try {
175
+ mkdirSync(dir.path, { recursive: true });
176
+ accessSync(dir.path, constants.W_OK);
177
+ writableStorageCache.set(key, dir.path);
178
+ return dir.path;
179
+ }
180
+ catch (err) {
181
+ const storageErr = new StorageDirectoryError(dir.kind, pathFromStorageError(err) ?? dir.path, STORAGE_ROOT_ENV, err, undefined, { ignoredEnvVar: dir.ignoredEnvVar, ignoredReason: dir.ignoredReason });
182
+ writableStorageCache.set(key, storageErr);
183
+ throw storageErr;
184
+ }
185
+ }
186
+ function storageDirectoryErrorMessage(kind, path, metadata = {}) {
187
+ return [
188
+ `context-mode ${kind} directory is not writable: ${path}`,
189
+ ignoredStorageOverrideHint(metadata),
190
+ storageDirectoryHint(),
191
+ ].filter(Boolean).join("\n");
192
+ }
193
+ function ignoredStorageOverrideHint(metadata) {
194
+ if (metadata.ignoredEnvVar && metadata.ignoredReason === "empty") {
195
+ return `Ignored empty ${metadata.ignoredEnvVar}; using adapter default.`;
196
+ }
197
+ return null;
198
+ }
199
+ function storageDirectoryHint() {
200
+ return `Set ${STORAGE_ROOT_ENV} to a writable absolute path.`;
201
+ }
202
+ function pathFromStorageError(err) {
203
+ if (!err || typeof err !== "object")
204
+ return null;
205
+ const path = err.path;
206
+ return typeof path === "string" && path.length > 0 ? path : null;
207
+ }
208
+ // ─────────────────────────────────────────────────────────
209
+ // Worktree isolation
210
+ // ─────────────────────────────────────────────────────────
211
+ /**
212
+ * Returns the worktree suffix to append to session identifiers.
213
+ * Returns empty string when running in the main working tree.
214
+ *
215
+ * Set CONTEXT_MODE_SESSION_SUFFIX to an explicit value to override
216
+ * (useful in CI environments or when git is unavailable).
217
+ * Set to empty string to disable isolation entirely.
218
+ */
219
+ // Memoized per (projectDir, env override) — recomputing on every tool call cost
220
+ // ~12ms (git worktree list subprocess fork) on macOS, 50ms+ on Windows.
221
+ // Key by projectDir so callers can pass the actual workspace even when the
222
+ // MCP server has chdir'd into the installed package directory.
223
+ let _wtCache;
224
+ export function normalizeWorktreePath(path) {
225
+ const normalized = path.replace(/\\/g, "/");
226
+ if (/^\/+$/.test(normalized))
227
+ return "/";
228
+ if (/^[A-Za-z]:\/+$/.test(normalized))
229
+ return `${normalized.slice(0, 2)}/`;
230
+ return normalized.replace(/\/+$/, "");
231
+ }
232
+ // Case-insensitive filesystems (macOS HFS+/APFS default, Windows NTFS default)
233
+ // can report `currentRoot` and `mainRoot` with different casing for the same
234
+ // physical directory — git itself sometimes preserves the on-disk casing while
235
+ // user-supplied paths use a different casing. Compare canonically by resolving
236
+ // symlinks via realpath and case-folding on these platforms. POSIX/Linux is
237
+ // strictly case-sensitive so this is a no-op there.
238
+ function canonicalizeForCompare(root) {
239
+ let resolved = root;
240
+ try {
241
+ resolved = realpathSync.native(root);
242
+ }
243
+ catch {
244
+ // Path may not exist (test fixtures, deleted dirs); fall back to as-given.
245
+ }
246
+ const normalized = normalizeWorktreePath(resolved);
247
+ if (process.platform === "win32" || process.platform === "darwin") {
248
+ return normalized.toLowerCase();
249
+ }
250
+ return normalized;
251
+ }
252
+ function gitOutput(projectDir, args) {
253
+ return execFileSync("git", ["-C", projectDir, ...args], {
254
+ encoding: "utf-8",
255
+ timeout: 2000,
256
+ stdio: ["ignore", "pipe", "ignore"],
257
+ }).trim();
258
+ }
259
+ function getCurrentWorktreeRoot(projectDir) {
260
+ const root = gitOutput(projectDir, ["rev-parse", "--show-toplevel"]);
261
+ return root.length > 0 ? normalizeWorktreePath(root) : null;
262
+ }
263
+ function getMainWorktreeRoot(projectDir) {
264
+ const root = gitOutput(projectDir, ["worktree", "list", "--porcelain"])
265
+ .split(/\r?\n/)
266
+ .find((line) => line.startsWith("worktree "))
267
+ ?.replace("worktree ", "")
268
+ ?.trim();
269
+ return root ? normalizeWorktreePath(root) : null;
270
+ }
271
+ export function getWorktreeSuffix(projectDir = process.cwd()) {
272
+ const envSuffix = process.env.CONTEXT_MODE_SESSION_SUFFIX;
273
+ if (_wtCache && _wtCache.projectDir === projectDir && _wtCache.envSuffix === envSuffix) {
274
+ return _wtCache.suffix;
275
+ }
276
+ let suffix = "";
277
+ if (envSuffix !== undefined) {
278
+ suffix = envSuffix ? `__${envSuffix}` : "";
279
+ }
280
+ else {
281
+ try {
282
+ const currentRoot = getCurrentWorktreeRoot(projectDir);
283
+ const mainRoot = getMainWorktreeRoot(projectDir);
284
+ if (currentRoot && mainRoot) {
285
+ // Use the canonicalized currentRoot for BOTH the comparison and the
286
+ // hash so the suffix DB filename stays stable across casing-variant
287
+ // calls on the same machine (round-5 finding). Previously the hash
288
+ // ate raw casing, so the same linked worktree could land at two
289
+ // different `__<8-hex>` files depending on which casing the caller
290
+ // passed in.
291
+ const canonicalCurrent = canonicalizeForCompare(currentRoot);
292
+ const canonicalMain = canonicalizeForCompare(mainRoot);
293
+ if (canonicalCurrent !== canonicalMain) {
294
+ suffix = `__${createHash("sha256").update(canonicalCurrent).digest("hex").slice(0, 8)}`;
295
+ }
296
+ }
297
+ }
298
+ catch {
299
+ // git not available or not a git repo — no suffix
300
+ }
301
+ }
302
+ _wtCache = { projectDir, envSuffix, suffix };
303
+ return suffix;
304
+ }
305
+ // Test-only helper: clear the memoization between cases.
306
+ export function _resetWorktreeSuffixCacheForTests() {
307
+ _wtCache = undefined;
308
+ }
309
+ // ─────────────────────────────────────────────────────────
310
+ // SessionDB path resolution + case-fold migration
311
+ // ─────────────────────────────────────────────────────────
312
+ /**
313
+ * Hash a project directory the way the deployed code (≤ v1.0.111) did:
314
+ * normalize slashes only, preserve raw casing. Kept exported so the
315
+ * migration helper can locate pre-fix DB files for one-shot rename.
316
+ *
317
+ * Do NOT call this for new code paths — use {@link hashProjectDirCanonical}.
318
+ */
319
+ export function hashProjectDirLegacy(projectDir) {
320
+ return createHash("sha256")
321
+ .update(normalizeWorktreePath(projectDir))
322
+ .digest("hex")
323
+ .slice(0, 16);
324
+ }
325
+ /**
326
+ * Hash a project directory case-stably. On case-insensitive filesystems
327
+ * (macOS HFS+/APFS, Windows NTFS) the path is lowercased so that
328
+ * `/Users/Mert/proj` and `/users/mert/proj` resolve to the same DB file.
329
+ * On Linux (case-sensitive) casing is preserved.
330
+ *
331
+ * Used as the base half of the SessionDB filename:
332
+ * <baseHash><worktreeSuffix>.db
333
+ */
334
+ export function hashProjectDirCanonical(projectDir) {
335
+ const normalized = normalizeWorktreePath(projectDir);
336
+ const folded = (process.platform === "darwin" || process.platform === "win32")
337
+ ? normalized.toLowerCase()
338
+ : normalized;
339
+ return createHash("sha256").update(folded).digest("hex").slice(0, 16);
340
+ }
341
+ /**
342
+ * Resolve the per-project FTS5 content store DB path, performing a one-shot
343
+ * migration from a legacy raw-casing filename to the canonical one when only
344
+ * the legacy file (with optional `-wal` / `-shm` SQLite sidecars) exists.
345
+ *
346
+ * Same dual-hash safety contract as {@link resolveSessionDbPath}:
347
+ * - Linux: canonical hash equals legacy hash → no migration attempted.
348
+ * - Mac/Win: rename legacy → canonical when canonical missing.
349
+ * - Both exist: leave legacy alone (data-loss safety). Caller picks
350
+ * canonical; reconciliation is a manual operation.
351
+ *
352
+ * Differs from `resolveSessionDbPath` in two ways:
353
+ * 1. No worktree suffix — the FTS5 store is per-project, not per-worktree.
354
+ * 2. The `-wal` / `-shm` sidecars travel with the main `.db` during
355
+ * migration so an active SQLite WAL checkpoint is not stranded behind.
356
+ */
357
+ export function resolveContentStorePath(opts) {
358
+ const { projectDir, contentDir } = opts;
359
+ const canonicalHash = hashProjectDirCanonical(projectDir);
360
+ const canonicalPath = join(contentDir, `${canonicalHash}.db`);
361
+ if (existsSync(canonicalPath))
362
+ return canonicalPath;
363
+ const legacyHash = hashProjectDirLegacy(projectDir);
364
+ if (legacyHash === canonicalHash)
365
+ return canonicalPath; // Linux short-circuit
366
+ const legacyPath = join(contentDir, `${legacyHash}.db`);
367
+ if (existsSync(legacyPath)) {
368
+ try {
369
+ renameSync(legacyPath, canonicalPath);
370
+ // Travel the SQLite sidecars too so an active WAL is not orphaned.
371
+ for (const suffix of ["-wal", "-shm"]) {
372
+ try {
373
+ renameSync(legacyPath + suffix, canonicalPath + suffix);
374
+ }
375
+ catch { /* sidecar may not exist */ }
376
+ }
377
+ }
378
+ catch {
379
+ // Race or permission issue — caller will create canonicalPath fresh.
380
+ }
381
+ }
382
+ return canonicalPath;
383
+ }
384
+ /**
385
+ * Resolve the SessionDB file path for a project, performing a one-shot
386
+ * migration from legacy raw-casing filenames to canonical ones when only
387
+ * the legacy file exists.
388
+ *
389
+ * Migration rules:
390
+ * - Linux: `legacyHash === canonicalHash` so the resolver short-circuits;
391
+ * no migration ever runs (case-sensitive FS, never any drift).
392
+ * - macOS / Windows: if the canonical path does not exist but a legacy
393
+ * path does, rename in place. This preserves the user's session
394
+ * history across the casing-fix upgrade.
395
+ * - When BOTH paths exist (rare — usually only if the user previously
396
+ * ran two terminals with different casing) the legacy file is left
397
+ * UNTOUCHED. The canonical path wins; manual reconciliation needed.
398
+ * Avoiding the rename here is the data-loss safety guarantee.
399
+ *
400
+ * Worktree separation is preserved: each call only ever migrates the ONE
401
+ * legacy file matching THIS projectDir's hash. Different worktrees have
402
+ * different physical paths → different hashes → different DB files; the
403
+ * migration cannot collapse worktrees.
404
+ */
405
+ export function resolveSessionDbPath(opts) {
406
+ return resolveSessionPath({ ...opts, ext: ".db" });
407
+ }
408
+ /**
409
+ * Generalized resolver: same case-fold + one-shot legacy-rename semantics
410
+ * as {@link resolveSessionDbPath}, parameterised on the file extension so
411
+ * the SAME logic powers `.db`, `-events.md`, and `.cleanup` paths.
412
+ *
413
+ * Source of truth for hooks: `hooks/session-helpers.mjs` imports this
414
+ * function from the bundled output (`hooks/session-db.bundle.mjs`) so the
415
+ * JS hooks and the TS server can never drift again on hash, suffix, or
416
+ * migration policy.
417
+ *
418
+ * Optional `suffix` lets the hook layer inject its cross-process cached
419
+ * worktree suffix (the marker-file optimisation that amortises the
420
+ * `git worktree list` cost across hook forks). When omitted, falls back
421
+ * to {@link getWorktreeSuffix} which uses an in-process cache only.
422
+ */
423
+ export function resolveSessionPath(opts) {
424
+ const { projectDir, sessionsDir, ext } = opts;
425
+ const suffix = opts.suffix ?? getWorktreeSuffix(projectDir);
426
+ const canonicalHash = hashProjectDirCanonical(projectDir);
427
+ const canonicalPath = join(sessionsDir, `${canonicalHash}${suffix}${ext}`);
428
+ if (existsSync(canonicalPath))
429
+ return canonicalPath;
430
+ const legacyHash = hashProjectDirLegacy(projectDir);
431
+ if (legacyHash === canonicalHash)
432
+ return canonicalPath; // Linux or already canonical
433
+ const legacyPath = join(sessionsDir, `${legacyHash}${suffix}${ext}`);
434
+ if (existsSync(legacyPath)) {
435
+ try {
436
+ renameSync(legacyPath, canonicalPath);
437
+ }
438
+ catch {
439
+ // Race or permission issue — caller will create canonicalPath on first
440
+ // write. Better to lose this rename than to throw and break ctx_stats.
441
+ }
442
+ }
443
+ return canonicalPath;
444
+ }
445
+ // ─────────────────────────────────────────────────────────
446
+ // Constants
447
+ // ─────────────────────────────────────────────────────────
448
+ /** Maximum events per session before FIFO eviction kicks in. */
449
+ const MAX_EVENTS_PER_SESSION = 1000;
450
+ /** Number of recent events to check for deduplication. */
451
+ const DEDUP_WINDOW = 5;
452
+ /**
453
+ * Coerce an arbitrary input to a non-negative integer suitable for
454
+ * SQLite's INTEGER column. Accepts undefined / null / NaN / floats
455
+ * and returns 0 for invalid inputs so the column never violates its
456
+ * NOT NULL DEFAULT 0 contract.
457
+ */
458
+ function clampNonNegativeInt(value) {
459
+ const n = Number(value);
460
+ if (!Number.isFinite(n) || n <= 0)
461
+ return 0;
462
+ return Math.floor(n);
463
+ }
464
+ // ─────────────────────────────────────────────────────────
465
+ // Statement keys (typed enum to avoid string typos)
466
+ // ─────────────────────────────────────────────────────────
467
+ const S = {
468
+ insertEvent: "insertEvent",
469
+ getEvents: "getEvents",
470
+ getEventsByType: "getEventsByType",
471
+ getEventsByPriority: "getEventsByPriority",
472
+ getEventsByTypeAndPriority: "getEventsByTypeAndPriority",
473
+ getEventCount: "getEventCount",
474
+ getLatestAttributedProject: "getLatestAttributedProject",
475
+ checkDuplicate: "checkDuplicate",
476
+ evictLowestPriority: "evictLowestPriority",
477
+ updateMetaLastEvent: "updateMetaLastEvent",
478
+ ensureSession: "ensureSession",
479
+ getSessionStats: "getSessionStats",
480
+ getSessionRollup: "getSessionRollup",
481
+ getMaxFileEdits: "getMaxFileEdits",
482
+ getLatestCommitMessage: "getLatestCommitMessage",
483
+ incrementCompactCount: "incrementCompactCount",
484
+ getUsageCursor: "getUsageCursor",
485
+ setUsageCursor: "setUsageCursor",
486
+ upsertResume: "upsertResume",
487
+ getResume: "getResume",
488
+ markResumeConsumed: "markResumeConsumed",
489
+ claimLatestUnconsumedResume: "claimLatestUnconsumedResume",
490
+ deleteEvents: "deleteEvents",
491
+ deleteMeta: "deleteMeta",
492
+ deleteResume: "deleteResume",
493
+ getOldSessions: "getOldSessions",
494
+ searchEvents: "searchEvents",
495
+ incrementToolCall: "incrementToolCall",
496
+ getToolCallTotals: "getToolCallTotals",
497
+ getToolCallByTool: "getToolCallByTool",
498
+ getEventBytesSummary: "getEventBytesSummary",
499
+ };
500
+ // ─────────────────────────────────────────────────────────
501
+ // Schema migration helpers (shared with the analytics aggregator)
502
+ // ─────────────────────────────────────────────────────────
503
+ /**
504
+ * Columns that the current `session_events` schema requires but earlier
505
+ * versions of context-mode did not write. Older DBs on disk are missing
506
+ * these — the analytics aggregator opens every DB it finds across all
507
+ * adapters, so without an in-place migration the SUM queries below fail
508
+ * the entire DB (the catch at the top of the read loop swallows the
509
+ * "no such column" error and the DB contributes zero to every column,
510
+ * not just the new ones). v1.0.148 hotfix.
511
+ */
512
+ const SESSION_EVENTS_REQUIRED_COLUMNS = [
513
+ ["project_dir", "TEXT NOT NULL DEFAULT ''"],
514
+ ["attribution_source", "TEXT NOT NULL DEFAULT 'unknown'"],
515
+ ["attribution_confidence", "REAL NOT NULL DEFAULT 0"],
516
+ ["bytes_avoided", "INTEGER NOT NULL DEFAULT 0"],
517
+ ["bytes_returned", "INTEGER NOT NULL DEFAULT 0"],
518
+ ];
519
+ /**
520
+ * Apply any missing post-v1.0.130 `session_events` columns to an already-
521
+ * open writable database handle. Idempotent — each ALTER is guarded by a
522
+ * PRAGMA table_xinfo check, and the project_dir index is created only
523
+ * when a migration actually ran. Returns true if any column was added.
524
+ *
525
+ * Used by both the SessionDB constructor (for the active DB) and the
526
+ * analytics aggregator (for the 100+ historical DBs that never get
527
+ * opened through SessionDB). ADR-0001 compatible: no EXCLUSIVE pragma,
528
+ * no acquireDbLock — relies on the SQLite busy_timeout + WAL semantics
529
+ * already provided by SQLiteBase.
530
+ */
531
+ export function applyMissingSessionEventsColumns(db) {
532
+ const colInfo = db.pragma("table_xinfo(session_events)");
533
+ const cols = new Set(colInfo.map((c) => c.name));
534
+ let changed = false;
535
+ for (const [name, spec] of SESSION_EVENTS_REQUIRED_COLUMNS) {
536
+ if (!cols.has(name)) {
537
+ db.exec(`ALTER TABLE session_events ADD COLUMN ${name} ${spec}`);
538
+ changed = true;
539
+ }
540
+ }
541
+ if (changed) {
542
+ db.exec("CREATE INDEX IF NOT EXISTS idx_session_events_project ON session_events(session_id, project_dir)");
543
+ }
544
+ return changed;
545
+ }
546
+ /**
547
+ * Open a session DB file briefly, run any missing schema migrations,
548
+ * and close. Best-effort: missing tables, file-locks, corrupt files,
549
+ * and any DatabaseCtor error are swallowed silently — the caller
550
+ * (analytics aggregator) handles the readonly query that follows and
551
+ * will skip the DB if it remains unreadable.
552
+ *
553
+ * Lazy migration entry point for the analytics aggregator, which would
554
+ * otherwise read 100+ historical DBs with the old (pre-v1.0.130) schema
555
+ * and lose every signal (not just bytes_avoided) because the SELECT
556
+ * statement references columns that don't exist on legacy schemas.
557
+ *
558
+ * Two open/close cycles in the worst case (one readonly probe to detect
559
+ * legacy schema, one writable to migrate). For already-migrated DBs
560
+ * (the common case after first read), this opens writable once and
561
+ * exits without writing — cheaper than always-writable.
562
+ */
563
+ export function ensureSessionEventsSchema(dbPath, DatabaseCtor) {
564
+ let db = null;
565
+ try {
566
+ db = new DatabaseCtor(dbPath);
567
+ applyMissingSessionEventsColumns(db);
568
+ }
569
+ catch (err) {
570
+ // best-effort — missing table, file lock, corrupt DB, or DatabaseCtor
571
+ // load failure. The aggregator's existing skip-on-error handles the
572
+ // downstream readonly query. Logged (rate-limited) so transient
573
+ // SQLITE_IOERR / lock storms are diagnosable instead of invisible.
574
+ logDbError("ensureSessionEventsSchema", err, dbPath);
575
+ }
576
+ finally {
577
+ try {
578
+ db?.close();
579
+ }
580
+ catch { /* ignore */ }
581
+ }
582
+ }
583
+ // ─────────────────────────────────────────────────────────
584
+ // SessionDB
585
+ // ─────────────────────────────────────────────────────────
586
+ export class SessionDB extends SQLiteBase {
587
+ constructor(opts) {
588
+ super(opts?.dbPath ?? defaultDBPath("session"));
589
+ }
590
+ /** Shorthand to retrieve a cached statement. */
591
+ stmt(key) {
592
+ return this.stmts.get(key);
593
+ }
594
+ // ── Schema ──
595
+ initSchema() {
596
+ // ── Migration: fix data_hash generated column from older schema ──
597
+ // Old schema had data_hash as GENERATED ALWAYS AS — new schema uses explicit INSERT.
598
+ // Detect and recreate table if needed (session data is ephemeral, safe to drop).
599
+ // `?? []` — bun:sqlite's adapter returns undefined for a missing table's
600
+ // table_xinfo; without the fallback this catch fired a TypeError on
601
+ // EVERY fresh DB, which would make the logging below log-storm on
602
+ // healthy opens. With it, the catch only fires on real DB failures.
603
+ try {
604
+ const colInfo = (this.db.pragma("table_xinfo(session_events)") ?? []);
605
+ const hashCol = colInfo.find((c) => c.name === "data_hash");
606
+ if (hashCol && hashCol.hidden !== 0) {
607
+ // hidden != 0 means generated column — must recreate
608
+ this.db.exec("DROP TABLE session_events");
609
+ }
610
+ }
611
+ catch (err) {
612
+ logDbError("SessionDB.initSchema.dataHashMigration", err, this.dbPath);
613
+ }
614
+ this.db.exec(`
615
+ CREATE TABLE IF NOT EXISTS session_events (
616
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
617
+ session_id TEXT NOT NULL,
618
+ type TEXT NOT NULL,
619
+ category TEXT NOT NULL,
620
+ priority INTEGER NOT NULL DEFAULT 2,
621
+ data TEXT NOT NULL,
622
+ project_dir TEXT NOT NULL DEFAULT '',
623
+ attribution_source TEXT NOT NULL DEFAULT 'unknown',
624
+ attribution_confidence REAL NOT NULL DEFAULT 0,
625
+ bytes_avoided INTEGER NOT NULL DEFAULT 0,
626
+ bytes_returned INTEGER NOT NULL DEFAULT 0,
627
+ source_hook TEXT NOT NULL,
628
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
629
+ data_hash TEXT NOT NULL DEFAULT ''
630
+ );
631
+
632
+ CREATE INDEX IF NOT EXISTS idx_session_events_session ON session_events(session_id);
633
+ CREATE INDEX IF NOT EXISTS idx_session_events_type ON session_events(session_id, type);
634
+ CREATE INDEX IF NOT EXISTS idx_session_events_priority ON session_events(session_id, priority);
635
+
636
+ CREATE TABLE IF NOT EXISTS session_meta (
637
+ session_id TEXT PRIMARY KEY,
638
+ project_dir TEXT NOT NULL,
639
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
640
+ last_event_at TEXT,
641
+ event_count INTEGER NOT NULL DEFAULT 0,
642
+ compact_count INTEGER NOT NULL DEFAULT 0
643
+ );
644
+
645
+ CREATE TABLE IF NOT EXISTS session_resume (
646
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
647
+ session_id TEXT NOT NULL UNIQUE,
648
+ snapshot TEXT NOT NULL,
649
+ event_count INTEGER NOT NULL,
650
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
651
+ consumed INTEGER NOT NULL DEFAULT 0
652
+ );
653
+
654
+ CREATE TABLE IF NOT EXISTS tool_calls (
655
+ session_id TEXT NOT NULL,
656
+ tool TEXT NOT NULL,
657
+ calls INTEGER NOT NULL DEFAULT 0,
658
+ bytes_returned INTEGER NOT NULL DEFAULT 0,
659
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
660
+ PRIMARY KEY (session_id, tool)
661
+ );
662
+
663
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
664
+ `);
665
+ // Migration: add per-event attribution columns for existing DBs.
666
+ // Shared helper — the analytics aggregator (analytics.ts) runs the
667
+ // SAME migration against every historical DB it scans, so the column
668
+ // list lives in one place at the top of this module.
669
+ try {
670
+ applyMissingSessionEventsColumns(this.db);
671
+ }
672
+ catch (err) {
673
+ // best-effort migration only — but surface it (rate-limited) so a
674
+ // repeatedly failing migration is diagnosable.
675
+ logDbError("SessionDB.initSchema.migrateColumns", err, this.dbPath);
676
+ }
677
+ // Migration: per-session usage high-water cursor for the Stop hook's
678
+ // cursor-aware main-turn capture (extractTranscriptUsageSince). Stores the
679
+ // uuid of the last assistant turn already emitted so the next Stop forwards
680
+ // only NEW spend. Idempotent — guarded by a table_xinfo column check.
681
+ try {
682
+ const metaCols = (this.db.pragma("table_xinfo(session_meta)") ?? []);
683
+ if (!metaCols.some((c) => c.name === "usage_cursor")) {
684
+ this.db.exec("ALTER TABLE session_meta ADD COLUMN usage_cursor TEXT");
685
+ }
686
+ }
687
+ catch (err) {
688
+ // best-effort migration only — surface it (rate-limited).
689
+ logDbError("SessionDB.initSchema.usageCursorMigration", err, this.dbPath);
690
+ }
691
+ }
692
+ prepareStatements() {
693
+ this.stmts = new Map();
694
+ const p = (key, sql) => {
695
+ this.stmts.set(key, this.db.prepare(sql));
696
+ };
697
+ // ── Events ──
698
+ p(S.insertEvent, `INSERT INTO session_events (
699
+ session_id, type, category, priority, data,
700
+ project_dir, attribution_source, attribution_confidence,
701
+ bytes_avoided, bytes_returned,
702
+ source_hook, data_hash
703
+ )
704
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
705
+ p(S.getEvents, `SELECT id, session_id, type, category, priority, data,
706
+ project_dir, attribution_source, attribution_confidence,
707
+ bytes_avoided, bytes_returned,
708
+ source_hook, created_at, data_hash
709
+ FROM session_events WHERE session_id = ? ORDER BY id ASC LIMIT ?`);
710
+ p(S.getEventsByType, `SELECT id, session_id, type, category, priority, data,
711
+ project_dir, attribution_source, attribution_confidence,
712
+ bytes_avoided, bytes_returned,
713
+ source_hook, created_at, data_hash
714
+ FROM session_events WHERE session_id = ? AND type = ? ORDER BY id ASC LIMIT ?`);
715
+ p(S.getEventsByPriority, `SELECT id, session_id, type, category, priority, data,
716
+ project_dir, attribution_source, attribution_confidence,
717
+ bytes_avoided, bytes_returned,
718
+ source_hook, created_at, data_hash
719
+ FROM session_events WHERE session_id = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
720
+ p(S.getEventsByTypeAndPriority, `SELECT id, session_id, type, category, priority, data,
721
+ project_dir, attribution_source, attribution_confidence,
722
+ bytes_avoided, bytes_returned,
723
+ source_hook, created_at, data_hash
724
+ FROM session_events WHERE session_id = ? AND type = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
725
+ p(S.getEventCount, `SELECT COUNT(*) AS cnt FROM session_events WHERE session_id = ?`);
726
+ p(S.getLatestAttributedProject, `SELECT project_dir
727
+ FROM session_events
728
+ WHERE session_id = ? AND project_dir != ''
729
+ ORDER BY id DESC
730
+ LIMIT 1`);
731
+ p(S.checkDuplicate, `SELECT 1 FROM (
732
+ SELECT type, data_hash FROM session_events
733
+ WHERE session_id = ? ORDER BY id DESC LIMIT ?
734
+ ) AS recent
735
+ WHERE recent.type = ? AND recent.data_hash = ?
736
+ LIMIT 1`);
737
+ p(S.evictLowestPriority, `DELETE FROM session_events WHERE id = (
738
+ SELECT id FROM session_events WHERE session_id = ?
739
+ ORDER BY priority ASC, id ASC LIMIT 1
740
+ )`);
741
+ p(S.updateMetaLastEvent, `UPDATE session_meta
742
+ SET last_event_at = datetime('now'), event_count = event_count + 1
743
+ WHERE session_id = ?`);
744
+ // ── Meta ──
745
+ p(S.ensureSession, `INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, ?)`);
746
+ p(S.getSessionStats, `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count
747
+ FROM session_meta WHERE session_id = ?`);
748
+ // ── Session rollup (seed-parity aggregator) ────────────────────────
749
+ // Single query producing 9 of the 12 platform-side session_summary +
750
+ // session_metadata fields. Computed against the local SessionDB
751
+ // session_events table at forward time so every outgoing canonical
752
+ // event carries a session-wide snapshot at that moment — matches the
753
+ // seed.ts shape where each event row has tool_calls/errors/etc. stamped.
754
+ // max_file_edits and edit_test_cycles need separate GROUP BY queries
755
+ // (below). compact_count is read from session_meta (already in getSessionStats).
756
+ p(S.getSessionRollup, `SELECT
757
+ COUNT(*) AS tool_calls,
758
+ COALESCE(SUM(CASE WHEN category = 'error' THEN 1 ELSE 0 END), 0) AS errors,
759
+ COUNT(DISTINCT type) AS unique_tools,
760
+ COUNT(DISTINCT CASE WHEN category = 'file' THEN data END) AS unique_files,
761
+ CASE WHEN SUM(CASE WHEN type = 'git_commit' THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS has_commit,
762
+ CAST(COALESCE((MAX(strftime('%s', created_at)) - MIN(strftime('%s', created_at))) / 60.0, 0) AS INTEGER) AS duration_min,
763
+ COALESCE(SUM(CASE WHEN type = 'external_ref' THEN 1 ELSE 0 END), 0) AS sources_indexed,
764
+ CAST(COALESCE(SUM(bytes_avoided) / 1024.0, 0) AS INTEGER) AS total_chunks,
765
+ COALESCE(SUM(CASE WHEN type IN ('file_search', 'file_glob') THEN 1 ELSE 0 END), 0) AS search_queries
766
+ FROM session_events
767
+ WHERE session_id = ?`);
768
+ // max_file_edits: max edits on any single file path in the session.
769
+ // Two-level aggregation — GROUP BY data first, then MAX of those counts.
770
+ p(S.getMaxFileEdits, `SELECT COALESCE(MAX(c), 0) AS max_file_edits
771
+ FROM (
772
+ SELECT COUNT(*) AS c
773
+ FROM session_events
774
+ WHERE session_id = ? AND category = 'file' AND type IN ('file_edit', 'file_write')
775
+ GROUP BY data
776
+ )`);
777
+ // v1.0.161 (Bug 2): latest commit message from session's type='git_commit'
778
+ // events. Used by rollup spread to stamp commit_message symmetric with
779
+ // has_commit on every outgoing event. Separate prepared statement (vs.
780
+ // sub-select in getSessionRollup) keeps the binding shape uniform — every
781
+ // rollup query takes a single sessionId parameter.
782
+ p(S.getLatestCommitMessage, `SELECT data
783
+ FROM session_events
784
+ WHERE session_id = ? AND type = 'git_commit'
785
+ ORDER BY id DESC
786
+ LIMIT 1`);
787
+ p(S.incrementCompactCount, `UPDATE session_meta SET compact_count = compact_count + 1 WHERE session_id = ?`);
788
+ p(S.getUsageCursor, `SELECT usage_cursor FROM session_meta WHERE session_id = ?`);
789
+ p(S.setUsageCursor, `UPDATE session_meta SET usage_cursor = ? WHERE session_id = ?`);
790
+ // ── Resume ──
791
+ p(S.upsertResume, `INSERT INTO session_resume (session_id, snapshot, event_count)
792
+ VALUES (?, ?, ?)
793
+ ON CONFLICT(session_id) DO UPDATE SET
794
+ snapshot = excluded.snapshot,
795
+ event_count = excluded.event_count,
796
+ created_at = datetime('now'),
797
+ consumed = 0`);
798
+ p(S.getResume, `SELECT snapshot, event_count, consumed FROM session_resume WHERE session_id = ?`);
799
+ p(S.markResumeConsumed, `UPDATE session_resume SET consumed = 1 WHERE session_id = ?`);
800
+ // Atomic "pick newest unconsumed snapshot AND mark it consumed in one
801
+ // statement". Required for race-safe cross-session resume injection
802
+ // (Mickey / PR #376) — two parallel chat-turn hooks must not both read
803
+ // the same row before either one writes consumed=1.
804
+ //
805
+ // The `session_id != ?` clause prevents self-injection (v1.0.106): when
806
+ // Session B compacts mid-flight and produces its own row, B's next chat
807
+ // turn must NOT claim that row back into its own prompt — that's wasted
808
+ // tokens and steals the snapshot meant for the next fresh session.
809
+ p(S.claimLatestUnconsumedResume, `UPDATE session_resume
810
+ SET consumed = 1
811
+ WHERE id = (
812
+ SELECT id FROM session_resume
813
+ WHERE consumed = 0
814
+ AND session_id != ?
815
+ ORDER BY created_at DESC, id DESC
816
+ LIMIT 1
817
+ )
818
+ RETURNING session_id, snapshot`);
819
+ // ── Delete ──
820
+ p(S.deleteEvents, `DELETE FROM session_events WHERE session_id = ?`);
821
+ p(S.deleteMeta, `DELETE FROM session_meta WHERE session_id = ?`);
822
+ p(S.deleteResume, `DELETE FROM session_resume WHERE session_id = ?`);
823
+ // ── Search ──
824
+ p(S.searchEvents, `SELECT id, session_id, category, type, data, created_at
825
+ FROM session_events
826
+ WHERE (project_dir = ? OR project_dir = '')
827
+ AND (data LIKE '%' || ? || '%' ESCAPE '\\' OR category LIKE '%' || ? || '%' ESCAPE '\\')
828
+ AND (? IS NULL OR category = ?)
829
+ ORDER BY id ASC
830
+ LIMIT ?`);
831
+ // ── Cleanup ──
832
+ p(S.getOldSessions, `SELECT session_id FROM session_meta WHERE started_at < datetime('now', ? || ' days')`);
833
+ // ── Tool calls (persistent counter) ──
834
+ p(S.incrementToolCall, `INSERT INTO tool_calls (session_id, tool, calls, bytes_returned)
835
+ VALUES (?, ?, 1, ?)
836
+ ON CONFLICT(session_id, tool) DO UPDATE SET
837
+ calls = calls + 1,
838
+ bytes_returned = bytes_returned + excluded.bytes_returned,
839
+ updated_at = datetime('now')`);
840
+ p(S.getToolCallTotals, `SELECT COALESCE(SUM(calls), 0) AS calls,
841
+ COALESCE(SUM(bytes_returned), 0) AS bytes_returned
842
+ FROM tool_calls WHERE session_id = ?`);
843
+ p(S.getToolCallByTool, `SELECT tool, calls, bytes_returned
844
+ FROM tool_calls WHERE session_id = ? ORDER BY calls DESC`);
845
+ // ── Event-level byte accounting (D2 PRD Phase 2) ──
846
+ p(S.getEventBytesSummary, `SELECT COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
847
+ COALESCE(SUM(bytes_returned), 0) AS bytes_returned
848
+ FROM session_events WHERE session_id = ?`);
849
+ }
850
+ // ═══════════════════════════════════════════
851
+ // Events
852
+ // ═══════════════════════════════════════════
853
+ /**
854
+ * Insert a session event with deduplication and FIFO eviction.
855
+ *
856
+ * Deduplication: skips if the same type + data_hash appears in the
857
+ * last DEDUP_WINDOW events for this session.
858
+ *
859
+ * Eviction: if session exceeds MAX_EVENTS_PER_SESSION, evicts the
860
+ * lowest-priority (then oldest) event.
861
+ */
862
+ insertEvent(sessionId, event, sourceHook = "PostToolUse", attribution, bytes) {
863
+ // SHA256-based dedup hash (first 16 hex chars = 8 bytes of entropy)
864
+ const dataHash = createHash("sha256")
865
+ .update(event.data)
866
+ .digest("hex")
867
+ .slice(0, 16)
868
+ .toUpperCase();
869
+ const projectDir = String(attribution?.projectDir
870
+ ?? event.project_dir
871
+ ?? this._getSessionProjectDir(sessionId)).trim();
872
+ const attributionSource = String(attribution?.source
873
+ ?? event.attribution_source
874
+ ?? "unknown");
875
+ const rawConfidence = Number(attribution?.confidence
876
+ ?? event.attribution_confidence
877
+ ?? 0);
878
+ const attributionConfidence = Number.isFinite(rawConfidence)
879
+ ? Math.max(0, Math.min(1, rawConfidence))
880
+ : 0;
881
+ const bytesAvoided = clampNonNegativeInt(bytes?.bytesAvoided);
882
+ const bytesReturned = clampNonNegativeInt(bytes?.bytesReturned);
883
+ // Atomic: dedup check + eviction + insert in a single transaction
884
+ // to prevent race conditions from concurrent hook calls.
885
+ const transaction = this.db.transaction(() => {
886
+ // Deduplication check: same type + data_hash in last N events
887
+ const dup = this.stmt(S.checkDuplicate).get(sessionId, DEDUP_WINDOW, event.type, dataHash);
888
+ if (dup)
889
+ return;
890
+ // Enforce max events with FIFO eviction of lowest priority
891
+ const countRow = this.stmt(S.getEventCount).get(sessionId);
892
+ if (countRow.cnt >= MAX_EVENTS_PER_SESSION) {
893
+ this.stmt(S.evictLowestPriority).run(sessionId);
894
+ }
895
+ // Insert the event
896
+ this.stmt(S.insertEvent).run(sessionId, event.type, event.category, event.priority, event.data, projectDir, attributionSource, attributionConfidence, bytesAvoided, bytesReturned, sourceHook, dataHash);
897
+ // Update meta if session exists
898
+ this.stmt(S.updateMetaLastEvent).run(sessionId);
899
+ });
900
+ this.withRetry(() => transaction());
901
+ }
902
+ /**
903
+ * Bulk-insert N events in a SINGLE transaction.
904
+ *
905
+ * PostToolUse hooks emit 5–15 events per tool call. Calling insertEvent()
906
+ * in a loop runs N transactions = N WAL commits = N fsync candidates,
907
+ * which is painful on Windows NTFS where commit latency dominates.
908
+ * One transaction = one commit, dedup/evict checks reuse cached statements.
909
+ *
910
+ * Cross-platform: uses the same WAL-mode transaction primitive as
911
+ * insertEvent — behavior identical on macOS / Linux / Windows.
912
+ */
913
+ bulkInsertEvents(sessionId, events, sourceHook = "PostToolUse", attributions, bytesList) {
914
+ if (!events || events.length === 0)
915
+ return;
916
+ if (events.length === 1) {
917
+ // Cheaper to fall through to insertEvent (its own dedicated transaction).
918
+ this.insertEvent(sessionId, events[0], sourceHook, attributions?.[0], bytesList?.[0]);
919
+ return;
920
+ }
921
+ // Pre-compute hashes + normalized attribution outside the transaction
922
+ // so the SQL transaction holds only DB work (shorter lock window).
923
+ const prepared = events.map((event, i) => {
924
+ const dataHash = createHash("sha256")
925
+ .update(event.data)
926
+ .digest("hex")
927
+ .slice(0, 16)
928
+ .toUpperCase();
929
+ const attribution = attributions?.[i];
930
+ // #827: store project_dir in canonical path shape so the search-time
931
+ // allow-set lookup (getSessionIdsForProject) matches regardless of the
932
+ // separator / trailing-slash form the host adapter happened to emit.
933
+ // normalizeWorktreePath is the same rule used for project-hash stability.
934
+ const rawProjectDir = String(attribution?.projectDir ?? event.project_dir ?? this._getSessionProjectDir(sessionId) ?? "").trim();
935
+ const projectDir = rawProjectDir === "" ? "" : normalizeWorktreePath(rawProjectDir);
936
+ const attributionSource = String(attribution?.source ?? event.attribution_source ?? "unknown");
937
+ const rawConfidence = Number(attribution?.confidence ?? event.attribution_confidence ?? 0);
938
+ const attributionConfidence = Number.isFinite(rawConfidence)
939
+ ? Math.max(0, Math.min(1, rawConfidence))
940
+ : 0;
941
+ const eventBytes = bytesList?.[i];
942
+ const bytesAvoided = clampNonNegativeInt(eventBytes?.bytesAvoided);
943
+ const bytesReturned = clampNonNegativeInt(eventBytes?.bytesReturned);
944
+ return {
945
+ event,
946
+ dataHash,
947
+ projectDir,
948
+ attributionSource,
949
+ attributionConfidence,
950
+ bytesAvoided,
951
+ bytesReturned,
952
+ };
953
+ });
954
+ const transaction = this.db.transaction(() => {
955
+ let cnt = this.stmt(S.getEventCount).get(sessionId).cnt;
956
+ for (const row of prepared) {
957
+ const dup = this.stmt(S.checkDuplicate).get(sessionId, DEDUP_WINDOW, row.event.type, row.dataHash);
958
+ if (dup)
959
+ continue;
960
+ if (cnt >= MAX_EVENTS_PER_SESSION) {
961
+ this.stmt(S.evictLowestPriority).run(sessionId);
962
+ }
963
+ else {
964
+ cnt++;
965
+ }
966
+ this.stmt(S.insertEvent).run(sessionId, row.event.type, row.event.category, row.event.priority, row.event.data, row.projectDir, row.attributionSource, row.attributionConfidence, row.bytesAvoided, row.bytesReturned, sourceHook, row.dataHash);
967
+ }
968
+ this.stmt(S.updateMetaLastEvent).run(sessionId);
969
+ });
970
+ this.withRetry(() => transaction());
971
+ }
972
+ /**
973
+ * Retrieve events for a session with optional filtering.
974
+ */
975
+ getEvents(sessionId, opts) {
976
+ const limit = opts?.limit ?? 1000;
977
+ const type = opts?.type;
978
+ const minPriority = opts?.minPriority;
979
+ if (type && minPriority !== undefined) {
980
+ return this.stmt(S.getEventsByTypeAndPriority).all(sessionId, type, minPriority, limit);
981
+ }
982
+ if (type) {
983
+ return this.stmt(S.getEventsByType).all(sessionId, type, limit);
984
+ }
985
+ if (minPriority !== undefined) {
986
+ return this.stmt(S.getEventsByPriority).all(sessionId, minPriority, limit);
987
+ }
988
+ return this.stmt(S.getEvents).all(sessionId, limit);
989
+ }
990
+ /**
991
+ * Get the total event count for a session.
992
+ */
993
+ getEventCount(sessionId) {
994
+ const row = this.stmt(S.getEventCount).get(sessionId);
995
+ return row.cnt;
996
+ }
997
+ /**
998
+ * Aggregate per-event byte accounting for a session.
999
+ *
1000
+ * Returns the total bytes context-mode kept OUT of the model context
1001
+ * window (`bytesAvoided`) and the total it actually returned to the
1002
+ * model (`bytesReturned`). Both default to 0 for unknown sessions.
1003
+ *
1004
+ * Used by the Insight dashboard to render the "saved vs returned"
1005
+ * panel without scanning every event row in JS.
1006
+ */
1007
+ getEventBytesSummary(sessionId) {
1008
+ const row = this.stmt(S.getEventBytesSummary).get(sessionId);
1009
+ return {
1010
+ bytesAvoided: Number(row?.bytes_avoided ?? 0),
1011
+ bytesReturned: Number(row?.bytes_returned ?? 0),
1012
+ };
1013
+ }
1014
+ /**
1015
+ * Return the most recently attributed project dir for a session.
1016
+ */
1017
+ getLatestAttributedProjectDir(sessionId) {
1018
+ const row = this.stmt(S.getLatestAttributedProject).get(sessionId);
1019
+ return row?.project_dir || null;
1020
+ }
1021
+ /**
1022
+ * Look up the project_dir from session_meta as a last-resort fallback
1023
+ * for event attribution. Prevents project_dir='' orphans when the caller
1024
+ * (e.g. pi adapter) omits the attribution parameter.
1025
+ */
1026
+ _getSessionProjectDir(sessionId) {
1027
+ try {
1028
+ const row = this.db.prepare("SELECT project_dir FROM session_meta WHERE session_id = ?").get(sessionId);
1029
+ return row?.project_dir || "";
1030
+ }
1031
+ catch (err) {
1032
+ // Best-effort fallback: still return "" but surface why (rate-limited).
1033
+ logDbError("SessionDB.getSessionProjectDir", err, this.dbPath);
1034
+ return "";
1035
+ }
1036
+ }
1037
+ /**
1038
+ * Search events by text query scoped to a project directory.
1039
+ *
1040
+ * Performs a case-insensitive LIKE search across the `data` and `category`
1041
+ * columns. An optional `source` parameter filters by exact category match.
1042
+ * Returns results ordered by monotonic id (chronological).
1043
+ *
1044
+ * Best-effort: returns empty array on any error.
1045
+ */
1046
+ searchEvents(query, limit, projectDir, source) {
1047
+ try {
1048
+ const escapedQuery = query.replace(/[%_]/g, (char) => "\\" + char);
1049
+ const sourceParam = source ?? null;
1050
+ return this.stmt(S.searchEvents).all(projectDir, escapedQuery, escapedQuery, sourceParam, sourceParam, limit);
1051
+ }
1052
+ catch (err) {
1053
+ // Best-effort: still return [] but surface why (rate-limited to
1054
+ // once per op+code+message per ~30s).
1055
+ logDbError("SessionDB.searchEvents", err, this.dbPath);
1056
+ return [];
1057
+ }
1058
+ }
1059
+ /**
1060
+ * Return the distinct list of session ids whose events were attributed
1061
+ * to a given `project_dir`. Powers the ctx_search `project:` filter
1062
+ * (#737) via the 2-step IN-clause strategy — ATTACH DATABASE is avoided
1063
+ * because SQLite's WAL + ATTACH combination has known correctness
1064
+ * trade-offs flagged in the upstream docs.
1065
+ *
1066
+ * Backed by the `idx_session_events_project(session_id, project_dir)`
1067
+ * composite index, so 1000-session lookups complete in single-digit
1068
+ * milliseconds. Best-effort: returns `[]` on any error.
1069
+ */
1070
+ getSessionIdsForProject(projectDir) {
1071
+ try {
1072
+ // #827: match by canonical path shape, not raw bytes. The host adapter
1073
+ // may store `project_dir` in a different separator / trailing-slash
1074
+ // shape than the search path resolves the scope in — most visibly on
1075
+ // Windows, where attribution often carries `C:\Users\me\proj` while the
1076
+ // server resolves `C:/Users/me/proj`. An exact `project_dir = ?` match
1077
+ // then returned an EMPTY allow-set and ctx_search reported "No results
1078
+ // found" even though the content was present. We fold BOTH sides through
1079
+ // the same canonical rule used for project-hash stability
1080
+ // (normalizeWorktreePath): backslash → forward slash, then strip the
1081
+ // trailing slash. Normalizing in SQL (RTRIM(REPLACE(...))) covers rows
1082
+ // already written un-normalized without a migration, while the JS-side
1083
+ // normalize keeps the bound parameter in the identical shape. This
1084
+ // preserves the #737 project scope — distinct directories still differ
1085
+ // after normalization, so cross-project isolation is intact.
1086
+ const normalized = normalizeWorktreePath(projectDir);
1087
+ const rows = this.db
1088
+ .prepare(`SELECT DISTINCT session_id
1089
+ FROM session_events
1090
+ WHERE RTRIM(REPLACE(project_dir, '\\', '/'), '/') = ?`)
1091
+ .all(normalized);
1092
+ return rows.map((r) => r.session_id);
1093
+ }
1094
+ catch (err) {
1095
+ // Best-effort: still return [] but surface why (rate-limited).
1096
+ logDbError("SessionDB.getSessionIdsForProject", err, this.dbPath);
1097
+ return [];
1098
+ }
1099
+ }
1100
+ // ═══════════════════════════════════════════
1101
+ // Meta
1102
+ // ═══════════════════════════════════════════
1103
+ /**
1104
+ * Ensure a session metadata entry exists. Idempotent (INSERT OR IGNORE).
1105
+ * `projectDir` is the session origin directory, not per-event attribution.
1106
+ */
1107
+ ensureSession(sessionId, projectDir) {
1108
+ this.stmt(S.ensureSession).run(sessionId, projectDir);
1109
+ }
1110
+ /**
1111
+ * Get session statistics/metadata.
1112
+ */
1113
+ getSessionStats(sessionId) {
1114
+ const row = this.stmt(S.getSessionStats).get(sessionId);
1115
+ return row ?? null;
1116
+ }
1117
+ /**
1118
+ * Session rollup snapshot — 12 aggregate fields the analytics platform
1119
+ * stamps onto every outgoing event row (seed.ts shape parity).
1120
+ *
1121
+ * Called from session-loaders BEFORE `maybeForward`; the snapshot is
1122
+ * computed against the LOCAL SessionDB and threaded into the canonical
1123
+ * event so the platform-side Zod schema receives the rich shape without
1124
+ * the bridge ever hand-mapping fields (PRD §5.4 ABI passthrough).
1125
+ *
1126
+ * Returns zeroed defaults for unknown sessions — callers MUST tolerate
1127
+ * a snapshot from an empty session (first event into a fresh DB).
1128
+ */
1129
+ getSessionRollup(sessionId) {
1130
+ const main = this.stmt(S.getSessionRollup).get(sessionId);
1131
+ const maxRow = this.stmt(S.getMaxFileEdits).get(sessionId);
1132
+ const commitRow = this.stmt(S.getLatestCommitMessage).get(sessionId);
1133
+ const meta = this.getSessionStats(sessionId);
1134
+ // edit_test_cycles: heuristic — min(file edits, errors) approximates
1135
+ // the number of edit-then-test attempts in a session. Exact pattern
1136
+ // detection (consecutive file_edit followed by error_tool) would need
1137
+ // a windowed query; this scalar pair under-counts but never overshoots.
1138
+ const fileEdits = (main?.tool_calls ?? 0) > 0
1139
+ ? (main?.unique_files ?? 0)
1140
+ : 0;
1141
+ const errors = main?.errors ?? 0;
1142
+ const editTestCycles = Math.min(fileEdits, errors);
1143
+ return {
1144
+ tool_calls: main?.tool_calls ?? 0,
1145
+ errors: main?.errors ?? 0,
1146
+ unique_tools: main?.unique_tools ?? 0,
1147
+ unique_files: main?.unique_files ?? 0,
1148
+ max_file_edits: maxRow?.max_file_edits ?? 0,
1149
+ has_commit: main?.has_commit ?? 0,
1150
+ commit_message: commitRow?.data ?? "",
1151
+ edit_test_cycles: editTestCycles,
1152
+ duration_min: main?.duration_min ?? 0,
1153
+ compact_count: meta?.compact_count ?? 0,
1154
+ sources_indexed: main?.sources_indexed ?? 0,
1155
+ total_chunks: main?.total_chunks ?? 0,
1156
+ search_queries: main?.search_queries ?? 0,
1157
+ };
1158
+ }
1159
+ /**
1160
+ * Increment the compact_count for a session (tracks snapshot rebuilds).
1161
+ */
1162
+ incrementCompactCount(sessionId) {
1163
+ this.stmt(S.incrementCompactCount).run(sessionId);
1164
+ }
1165
+ /**
1166
+ * Read the per-session usage high-water cursor — the uuid of the last
1167
+ * assistant turn already emitted by the Stop hook's main-turn capture.
1168
+ * Returns null when unset (first Stop) or the session row is absent.
1169
+ */
1170
+ getUsageCursor(sessionId) {
1171
+ const row = this.stmt(S.getUsageCursor).get(sessionId);
1172
+ return row?.usage_cursor ?? null;
1173
+ }
1174
+ /**
1175
+ * Advance the per-session usage high-water cursor to `uuid`. No-op when the
1176
+ * session_meta row does not exist yet (callers ensureSession first).
1177
+ */
1178
+ setUsageCursor(sessionId, uuid) {
1179
+ this.stmt(S.setUsageCursor).run(uuid, sessionId);
1180
+ }
1181
+ // ═══════════════════════════════════════════
1182
+ // Resume
1183
+ // ═══════════════════════════════════════════
1184
+ /**
1185
+ * Upsert a resume snapshot for a session. Resets consumed flag on update.
1186
+ */
1187
+ upsertResume(sessionId, snapshot, eventCount) {
1188
+ this.stmt(S.upsertResume).run(sessionId, snapshot, eventCount ?? 0);
1189
+ }
1190
+ /**
1191
+ * Retrieve the resume snapshot for a session.
1192
+ */
1193
+ getResume(sessionId) {
1194
+ const row = this.stmt(S.getResume).get(sessionId);
1195
+ return row ?? null;
1196
+ }
1197
+ /**
1198
+ * Mark the resume snapshot as consumed (already injected into conversation).
1199
+ */
1200
+ markResumeConsumed(sessionId) {
1201
+ this.stmt(S.markResumeConsumed).run(sessionId);
1202
+ }
1203
+ /**
1204
+ * Atomically claim the most recent unconsumed resume snapshot in this DB,
1205
+ * EXCLUDING any row that belongs to `currentSessionId`.
1206
+ *
1207
+ * `SessionDB` is sharded per project (see `resolveSessionDbPath` — SHA-256
1208
+ * of canonical project dir), so "this DB" already implies "this project".
1209
+ * The atomic
1210
+ * `UPDATE … RETURNING` ensures concurrent processes for the same project
1211
+ * cannot both inject the same snapshot (Mickey / PR #376 race).
1212
+ *
1213
+ * The `currentSessionId` parameter prevents self-injection: when a session
1214
+ * compacts mid-flight and produces its own row, that session's next chat
1215
+ * turn must NOT claim that row back (wasted tokens AND it would consume
1216
+ * the snapshot meant for the next fresh session).
1217
+ *
1218
+ * Pass an empty string to allow self-claim (legacy behaviour, only useful
1219
+ * in tests or one-off harnesses).
1220
+ *
1221
+ * Returns null when no unconsumed snapshot exists for any other session.
1222
+ */
1223
+ claimLatestUnconsumedResume(currentSessionId) {
1224
+ const row = this.stmt(S.claimLatestUnconsumedResume).get(currentSessionId);
1225
+ if (!row)
1226
+ return null;
1227
+ return { sessionId: row.session_id, snapshot: row.snapshot };
1228
+ }
1229
+ /**
1230
+ * Return the most recent session_id from session_meta, or null if none.
1231
+ * Used by the runtime to attach persistent counters to the right session
1232
+ * after a process restart.
1233
+ */
1234
+ getLatestSessionId() {
1235
+ try {
1236
+ const row = this.db.prepare("SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1").get();
1237
+ return row?.session_id ?? null;
1238
+ }
1239
+ catch (err) {
1240
+ logDbError("SessionDB.getLatestSessionId", err, this.dbPath);
1241
+ return null;
1242
+ }
1243
+ }
1244
+ // ═══════════════════════════════════════════
1245
+ // Tool call counters (Bug #1 + #2 — survive restart, --continue, upgrade)
1246
+ // ═══════════════════════════════════════════
1247
+ /**
1248
+ * Increment the persistent tool-call counter for `tool` in `sessionId`.
1249
+ * Adds `bytesReturned` to the cumulative total. Idempotent across
1250
+ * SessionDB instances — counters survive process restart.
1251
+ */
1252
+ incrementToolCall(sessionId, tool, bytesReturned = 0) {
1253
+ const safeBytes = Number.isFinite(bytesReturned) && bytesReturned > 0 ? Math.round(bytesReturned) : 0;
1254
+ try {
1255
+ this.stmt(S.incrementToolCall).run(sessionId, tool, safeBytes);
1256
+ }
1257
+ catch (err) {
1258
+ // best-effort: counter must never throw and break the parent call —
1259
+ // but surface it (rate-limited) instead of vanishing.
1260
+ logDbError("SessionDB.incrementToolCall", err, this.dbPath);
1261
+ }
1262
+ }
1263
+ /**
1264
+ * Get aggregated tool-call stats for `sessionId`. Returns zero-stats
1265
+ * when the session has no recorded calls.
1266
+ */
1267
+ getToolCallStats(sessionId) {
1268
+ try {
1269
+ const totals = this.stmt(S.getToolCallTotals).get(sessionId);
1270
+ const rows = this.stmt(S.getToolCallByTool).all(sessionId);
1271
+ const byTool = {};
1272
+ for (const row of rows) {
1273
+ byTool[row.tool] = {
1274
+ calls: row.calls,
1275
+ bytesReturned: row.bytes_returned,
1276
+ };
1277
+ }
1278
+ return {
1279
+ totalCalls: totals?.calls ?? 0,
1280
+ totalBytesReturned: totals?.bytes_returned ?? 0,
1281
+ byTool,
1282
+ };
1283
+ }
1284
+ catch (err) {
1285
+ logDbError("SessionDB.getToolCallStats", err, this.dbPath);
1286
+ return { totalCalls: 0, totalBytesReturned: 0, byTool: {} };
1287
+ }
1288
+ }
1289
+ // ═══════════════════════════════════════════
1290
+ // Lifecycle
1291
+ // ═══════════════════════════════════════════
1292
+ /**
1293
+ * Delete all data for a session (events, meta, resume).
1294
+ */
1295
+ deleteSession(sessionId) {
1296
+ this.db.transaction(() => {
1297
+ this.stmt(S.deleteEvents).run(sessionId);
1298
+ this.stmt(S.deleteResume).run(sessionId);
1299
+ this.stmt(S.deleteMeta).run(sessionId);
1300
+ })();
1301
+ }
1302
+ /**
1303
+ * Remove sessions older than maxAgeDays. Returns the count of deleted sessions.
1304
+ */
1305
+ cleanupOldSessions(maxAgeDays = 7) {
1306
+ const negDays = `-${maxAgeDays}`;
1307
+ const oldSessions = this.stmt(S.getOldSessions).all(negDays);
1308
+ for (const { session_id } of oldSessions) {
1309
+ this.deleteSession(session_id);
1310
+ }
1311
+ return oldSessions.length;
1312
+ }
1313
+ /**
1314
+ * Delete event rows whose session_id has no matching session_meta row.
1315
+ *
1316
+ * Orphaned events accumulate when meta rows were aged out by an older
1317
+ * version of `cleanupOldSessions` but the matching events were left
1318
+ * behind (or when callers wrote events without a meta upsert). The Kimi
1319
+ * Code sessionstart hook calls this on every startup as a self-healing
1320
+ * step; surfacing it as a SessionDB method keeps the SQL definition in
1321
+ * one place instead of letting hook scripts reach through to
1322
+ * `db.db.exec(...)` and re-encode schema knowledge in mjs files.
1323
+ */
1324
+ pruneOrphanedEvents() {
1325
+ const result = this.db
1326
+ .prepare(`DELETE FROM session_events WHERE session_id NOT IN (SELECT session_id FROM session_meta)`)
1327
+ .run();
1328
+ return Number(result.changes ?? 0);
1329
+ }
1330
+ }