context-mode 1.0.168 → 2.0.0-next.5

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 (395) hide show
  1. package/.claude-plugin/marketplace.json +4 -22
  2. package/.claude-plugin/plugin.json +9 -24
  3. package/SHA256SUMS +31 -0
  4. package/claude-code/skills/ctx-doctor/SKILL.md +6 -0
  5. package/claude-code/skills/ctx-stats/SKILL.md +6 -0
  6. package/claude-code/skills/ctx-upgrade/SKILL.md +6 -0
  7. package/cli.mjs +6744 -0
  8. package/copilot/plugin.json +14 -0
  9. package/fixtures/hook-roundtrip.json +22 -0
  10. package/gemini-extension.json +14 -0
  11. package/hook.mjs +202 -0
  12. package/hooks/claude-code.json +50 -0
  13. package/hooks/codex.json +50 -0
  14. package/hooks/copilot-cli.json +33 -0
  15. package/hooks/hooks.json +16 -111
  16. package/hooks/kiro.json +69 -0
  17. package/hooks/qwen.json +50 -0
  18. package/hooks/vscode.json +40 -0
  19. package/hooks/zcode.json +53 -0
  20. package/opencode/plugin.js +22456 -0
  21. package/package.json +29 -114
  22. package/pi/index.js +50841 -0
  23. package/pi/skills/ctx-doctor/SKILL.md +6 -0
  24. package/pi/skills/ctx-stats/SKILL.md +6 -0
  25. package/pi/skills/ctx-upgrade/SKILL.md +6 -0
  26. package/qwen-extension.json +15 -0
  27. package/run.mjs +5633 -0
  28. package/search.mjs +5633 -0
  29. package/server.mjs +48198 -0
  30. package/skills/ctx-doctor/SKILL.md +3 -19
  31. package/skills/ctx-stats/SKILL.md +3 -23
  32. package/skills/ctx-upgrade/SKILL.md +3 -28
  33. package/.codex-plugin/hooks.json +0 -65
  34. package/.codex-plugin/mcp.json +0 -13
  35. package/.codex-plugin/plugin.json +0 -31
  36. package/.openclaw-plugin/index.ts +0 -12
  37. package/.openclaw-plugin/openclaw.plugin.json +0 -38
  38. package/.openclaw-plugin/package.json +0 -28
  39. package/LICENSE +0 -94
  40. package/README.md +0 -1619
  41. package/bin/statusline.mjs +0 -378
  42. package/build/adapters/antigravity/index.d.ts +0 -51
  43. package/build/adapters/antigravity/index.js +0 -169
  44. package/build/adapters/antigravity-cli/index.d.ts +0 -51
  45. package/build/adapters/antigravity-cli/index.js +0 -342
  46. package/build/adapters/base.d.ts +0 -92
  47. package/build/adapters/base.js +0 -135
  48. package/build/adapters/claude-code/config.d.ts +0 -8
  49. package/build/adapters/claude-code/config.js +0 -8
  50. package/build/adapters/claude-code/hooks.d.ts +0 -106
  51. package/build/adapters/claude-code/hooks.js +0 -183
  52. package/build/adapters/claude-code/index.d.ts +0 -80
  53. package/build/adapters/claude-code/index.js +0 -569
  54. package/build/adapters/claude-code-base.d.ts +0 -49
  55. package/build/adapters/claude-code-base.js +0 -113
  56. package/build/adapters/client-map.d.ts +0 -10
  57. package/build/adapters/client-map.js +0 -42
  58. package/build/adapters/codex/config.d.ts +0 -8
  59. package/build/adapters/codex/config.js +0 -8
  60. package/build/adapters/codex/hooks.d.ts +0 -55
  61. package/build/adapters/codex/hooks.js +0 -64
  62. package/build/adapters/codex/index.d.ts +0 -87
  63. package/build/adapters/codex/index.js +0 -992
  64. package/build/adapters/codex/paths.d.ts +0 -1
  65. package/build/adapters/codex/paths.js +0 -12
  66. package/build/adapters/codex/usage.d.ts +0 -107
  67. package/build/adapters/codex/usage.js +0 -227
  68. package/build/adapters/copilot-base.d.ts +0 -94
  69. package/build/adapters/copilot-base.js +0 -297
  70. package/build/adapters/copilot-cli/hooks.d.ts +0 -33
  71. package/build/adapters/copilot-cli/hooks.js +0 -64
  72. package/build/adapters/copilot-cli/index.d.ts +0 -48
  73. package/build/adapters/copilot-cli/index.js +0 -341
  74. package/build/adapters/cursor/config.d.ts +0 -4
  75. package/build/adapters/cursor/config.js +0 -4
  76. package/build/adapters/cursor/hooks.d.ts +0 -55
  77. package/build/adapters/cursor/hooks.js +0 -82
  78. package/build/adapters/cursor/index.d.ts +0 -64
  79. package/build/adapters/cursor/index.js +0 -517
  80. package/build/adapters/detect.d.ts +0 -136
  81. package/build/adapters/detect.js +0 -623
  82. package/build/adapters/gemini-cli/config.d.ts +0 -8
  83. package/build/adapters/gemini-cli/config.js +0 -8
  84. package/build/adapters/gemini-cli/hooks.d.ts +0 -83
  85. package/build/adapters/gemini-cli/hooks.js +0 -115
  86. package/build/adapters/gemini-cli/index.d.ts +0 -80
  87. package/build/adapters/gemini-cli/index.js +0 -483
  88. package/build/adapters/jetbrains-copilot/config.d.ts +0 -8
  89. package/build/adapters/jetbrains-copilot/config.js +0 -8
  90. package/build/adapters/jetbrains-copilot/hooks.d.ts +0 -59
  91. package/build/adapters/jetbrains-copilot/hooks.js +0 -87
  92. package/build/adapters/jetbrains-copilot/index.d.ts +0 -31
  93. package/build/adapters/jetbrains-copilot/index.js +0 -131
  94. package/build/adapters/kimi/config.d.ts +0 -8
  95. package/build/adapters/kimi/config.js +0 -8
  96. package/build/adapters/kimi/hooks.d.ts +0 -28
  97. package/build/adapters/kimi/hooks.js +0 -34
  98. package/build/adapters/kimi/index.d.ts +0 -66
  99. package/build/adapters/kimi/index.js +0 -537
  100. package/build/adapters/kimi/paths.d.ts +0 -21
  101. package/build/adapters/kimi/paths.js +0 -52
  102. package/build/adapters/kimi/usage.d.ts +0 -82
  103. package/build/adapters/kimi/usage.js +0 -217
  104. package/build/adapters/kiro/hooks.d.ts +0 -61
  105. package/build/adapters/kiro/hooks.js +0 -94
  106. package/build/adapters/kiro/index.d.ts +0 -61
  107. package/build/adapters/kiro/index.js +0 -301
  108. package/build/adapters/omp/index.d.ts +0 -65
  109. package/build/adapters/omp/index.js +0 -182
  110. package/build/adapters/omp/plugin.d.ts +0 -81
  111. package/build/adapters/omp/plugin.js +0 -331
  112. package/build/adapters/omp/usage.d.ts +0 -49
  113. package/build/adapters/omp/usage.js +0 -110
  114. package/build/adapters/openclaw/config.d.ts +0 -8
  115. package/build/adapters/openclaw/config.js +0 -8
  116. package/build/adapters/openclaw/hooks.d.ts +0 -50
  117. package/build/adapters/openclaw/hooks.js +0 -61
  118. package/build/adapters/openclaw/index.d.ts +0 -74
  119. package/build/adapters/openclaw/index.js +0 -439
  120. package/build/adapters/openclaw/mcp-tools.d.ts +0 -54
  121. package/build/adapters/openclaw/mcp-tools.js +0 -207
  122. package/build/adapters/openclaw/plugin.d.ts +0 -141
  123. package/build/adapters/openclaw/plugin.js +0 -818
  124. package/build/adapters/openclaw/session-db.d.ts +0 -55
  125. package/build/adapters/openclaw/session-db.js +0 -88
  126. package/build/adapters/openclaw/usage.d.ts +0 -34
  127. package/build/adapters/openclaw/usage.js +0 -52
  128. package/build/adapters/openclaw/workspace-router.d.ts +0 -29
  129. package/build/adapters/openclaw/workspace-router.js +0 -64
  130. package/build/adapters/opencode/config.d.ts +0 -8
  131. package/build/adapters/opencode/config.js +0 -8
  132. package/build/adapters/opencode/hooks.d.ts +0 -38
  133. package/build/adapters/opencode/hooks.js +0 -50
  134. package/build/adapters/opencode/index.d.ts +0 -67
  135. package/build/adapters/opencode/index.js +0 -463
  136. package/build/adapters/opencode/plugin.d.ts +0 -184
  137. package/build/adapters/opencode/plugin.js +0 -558
  138. package/build/adapters/opencode/zod3tov4.d.ts +0 -1
  139. package/build/adapters/opencode/zod3tov4.js +0 -132
  140. package/build/adapters/pi/extension.d.ts +0 -81
  141. package/build/adapters/pi/extension.js +0 -861
  142. package/build/adapters/pi/index.d.ts +0 -57
  143. package/build/adapters/pi/index.js +0 -173
  144. package/build/adapters/pi/mcp-bridge.d.ts +0 -268
  145. package/build/adapters/pi/mcp-bridge.js +0 -894
  146. package/build/adapters/qwen-code/hooks.d.ts +0 -26
  147. package/build/adapters/qwen-code/hooks.js +0 -29
  148. package/build/adapters/qwen-code/index.d.ts +0 -40
  149. package/build/adapters/qwen-code/index.js +0 -361
  150. package/build/adapters/qwen-code/usage.d.ts +0 -90
  151. package/build/adapters/qwen-code/usage.js +0 -222
  152. package/build/adapters/types.d.ts +0 -360
  153. package/build/adapters/types.js +0 -118
  154. package/build/adapters/vscode-copilot/config.d.ts +0 -8
  155. package/build/adapters/vscode-copilot/config.js +0 -8
  156. package/build/adapters/vscode-copilot/hooks.d.ts +0 -71
  157. package/build/adapters/vscode-copilot/hooks.js +0 -98
  158. package/build/adapters/vscode-copilot/index.d.ts +0 -32
  159. package/build/adapters/vscode-copilot/index.js +0 -222
  160. package/build/adapters/zed/index.d.ts +0 -40
  161. package/build/adapters/zed/index.js +0 -163
  162. package/build/cache-heal.d.ts +0 -48
  163. package/build/cache-heal.js +0 -150
  164. package/build/cli.d.ts +0 -34
  165. package/build/cli.js +0 -1827
  166. package/build/concurrency/runPool.d.ts +0 -36
  167. package/build/concurrency/runPool.js +0 -51
  168. package/build/db-base.d.ts +0 -179
  169. package/build/db-base.js +0 -569
  170. package/build/executor.d.ts +0 -72
  171. package/build/executor.js +0 -678
  172. package/build/exit-classify.d.ts +0 -19
  173. package/build/exit-classify.js +0 -12
  174. package/build/fetch-cache.d.ts +0 -13
  175. package/build/fetch-cache.js +0 -15
  176. package/build/lifecycle.d.ts +0 -124
  177. package/build/lifecycle.js +0 -278
  178. package/build/openclaw/mcp-tools.d.ts +0 -54
  179. package/build/openclaw/mcp-tools.js +0 -198
  180. package/build/openclaw/workspace-router.d.ts +0 -29
  181. package/build/openclaw/workspace-router.js +0 -64
  182. package/build/openclaw-plugin.d.ts +0 -130
  183. package/build/openclaw-plugin.js +0 -626
  184. package/build/opencode-plugin.d.ts +0 -122
  185. package/build/opencode-plugin.js +0 -375
  186. package/build/pi-extension.d.ts +0 -14
  187. package/build/pi-extension.js +0 -451
  188. package/build/routing-block.d.ts +0 -8
  189. package/build/routing-block.js +0 -86
  190. package/build/runPool.d.ts +0 -36
  191. package/build/runPool.js +0 -51
  192. package/build/runtime.d.ts +0 -71
  193. package/build/runtime.js +0 -659
  194. package/build/search/auto-memory.d.ts +0 -47
  195. package/build/search/auto-memory.js +0 -170
  196. package/build/search/ctx-search-schema.d.ts +0 -90
  197. package/build/search/ctx-search-schema.js +0 -135
  198. package/build/search/flood-guard.d.ts +0 -57
  199. package/build/search/flood-guard.js +0 -80
  200. package/build/search/unified.d.ts +0 -56
  201. package/build/search/unified.js +0 -104
  202. package/build/security.d.ts +0 -206
  203. package/build/security.js +0 -780
  204. package/build/server.d.ts +0 -184
  205. package/build/server.js +0 -4542
  206. package/build/session/analytics.d.ts +0 -745
  207. package/build/session/analytics.js +0 -2369
  208. package/build/session/db.d.ts +0 -468
  209. package/build/session/db.js +0 -1308
  210. package/build/session/error-classifier.d.ts +0 -87
  211. package/build/session/error-classifier.js +0 -303
  212. package/build/session/event-emit.d.ts +0 -48
  213. package/build/session/event-emit.js +0 -101
  214. package/build/session/extract.d.ts +0 -333
  215. package/build/session/extract.js +0 -2688
  216. package/build/session/model-prices.json +0 -429
  217. package/build/session/persist-tool-calls.d.ts +0 -54
  218. package/build/session/persist-tool-calls.js +0 -105
  219. package/build/session/pricing.d.ts +0 -64
  220. package/build/session/pricing.js +0 -151
  221. package/build/session/project-attribution.d.ts +0 -73
  222. package/build/session/project-attribution.js +0 -245
  223. package/build/session/purge.d.ts +0 -138
  224. package/build/session/purge.js +0 -240
  225. package/build/session/snapshot.d.ts +0 -46
  226. package/build/session/snapshot.js +0 -532
  227. package/build/store-directory.d.ts +0 -56
  228. package/build/store-directory.js +0 -254
  229. package/build/store.d.ts +0 -147
  230. package/build/store.js +0 -1714
  231. package/build/tool-naming.d.ts +0 -4
  232. package/build/tool-naming.js +0 -24
  233. package/build/truncate.d.ts +0 -57
  234. package/build/truncate.js +0 -142
  235. package/build/types.d.ts +0 -117
  236. package/build/types.js +0 -20
  237. package/build/util/claude-config.d.ts +0 -32
  238. package/build/util/claude-config.js +0 -84
  239. package/build/util/hook-config.d.ts +0 -27
  240. package/build/util/hook-config.js +0 -76
  241. package/build/util/jsonc.d.ts +0 -14
  242. package/build/util/jsonc.js +0 -104
  243. package/build/util/plugin-cache-integrity.d.ts +0 -51
  244. package/build/util/plugin-cache-integrity.js +0 -146
  245. package/build/util/project-dir.d.ts +0 -149
  246. package/build/util/project-dir.js +0 -340
  247. package/build/util/sibling-mcp.d.ts +0 -79
  248. package/build/util/sibling-mcp.js +0 -181
  249. package/cli.bundle.mjs +0 -1235
  250. package/configs/antigravity/GEMINI.md +0 -68
  251. package/configs/antigravity/mcp_config.json +0 -7
  252. package/configs/antigravity-cli/hooks/hooks.json +0 -37
  253. package/configs/antigravity-cli/hooks.json +0 -37
  254. package/configs/antigravity-cli/mcp_config.json +0 -10
  255. package/configs/antigravity-cli/plugin.json +0 -14
  256. package/configs/antigravity-cli/rules/context-mode.md +0 -77
  257. package/configs/antigravity-cli/skills/context-mode/SKILL.md +0 -77
  258. package/configs/claude-code/CLAUDE.md +0 -91
  259. package/configs/codex/AGENTS.md +0 -99
  260. package/configs/codex/config.toml +0 -8
  261. package/configs/codex/hooks.json +0 -47
  262. package/configs/copilot-cli/.github/plugin/plugin.json +0 -23
  263. package/configs/copilot-cli/.mcp.json +0 -12
  264. package/configs/copilot-cli/README.md +0 -47
  265. package/configs/copilot-cli/hooks.json +0 -41
  266. package/configs/copilot-cli/skills/context-mode/SKILL.md +0 -38
  267. package/configs/cursor/context-mode.mdc +0 -76
  268. package/configs/cursor/hooks.json +0 -21
  269. package/configs/cursor/mcp.json +0 -7
  270. package/configs/gemini-cli/GEMINI.md +0 -88
  271. package/configs/gemini-cli/mcp.json +0 -7
  272. package/configs/gemini-cli/settings.json +0 -60
  273. package/configs/jetbrains-copilot/copilot-instructions.md +0 -80
  274. package/configs/jetbrains-copilot/hooks.json +0 -16
  275. package/configs/jetbrains-copilot/mcp.json +0 -7
  276. package/configs/kilo/AGENTS.md +0 -84
  277. package/configs/kilo/kilo.json +0 -6
  278. package/configs/kimi/hooks.json +0 -54
  279. package/configs/kiro/KIRO.md +0 -84
  280. package/configs/kiro/agent.json +0 -18
  281. package/configs/kiro/mcp.json +0 -7
  282. package/configs/omp/SYSTEM.md +0 -85
  283. package/configs/omp/mcp.json +0 -7
  284. package/configs/openclaw/AGENTS.md +0 -87
  285. package/configs/openclaw/openclaw.json +0 -13
  286. package/configs/opencode/AGENTS.md +0 -84
  287. package/configs/opencode/opencode.json +0 -6
  288. package/configs/pi/AGENTS.md +0 -3
  289. package/configs/qwen-code/QWEN.md +0 -91
  290. package/configs/vscode-copilot/copilot-instructions.md +0 -80
  291. package/configs/vscode-copilot/hooks.json +0 -16
  292. package/configs/vscode-copilot/mcp.json +0 -7
  293. package/configs/zed/AGENTS.md +0 -68
  294. package/hooks/antigravity-cli/payload.mjs +0 -98
  295. package/hooks/antigravity-cli/posttooluse.mjs +0 -138
  296. package/hooks/antigravity-cli/pretooluse.mjs +0 -78
  297. package/hooks/antigravity-cli/stop.mjs +0 -58
  298. package/hooks/auto-injection.mjs +0 -102
  299. package/hooks/cache-heal-utils.mjs +0 -379
  300. package/hooks/codex/platform.mjs +0 -1
  301. package/hooks/codex/posttooluse.mjs +0 -66
  302. package/hooks/codex/precompact.mjs +0 -70
  303. package/hooks/codex/pretooluse.mjs +0 -42
  304. package/hooks/codex/sessionstart.mjs +0 -121
  305. package/hooks/codex/stop.mjs +0 -138
  306. package/hooks/codex/userpromptsubmit.mjs +0 -75
  307. package/hooks/copilot-cli/posttooluse.mjs +0 -79
  308. package/hooks/copilot-cli/precompact.mjs +0 -66
  309. package/hooks/copilot-cli/pretooluse.mjs +0 -41
  310. package/hooks/copilot-cli/sessionstart.mjs +0 -121
  311. package/hooks/copilot-cli/stop.mjs +0 -59
  312. package/hooks/copilot-cli/userpromptsubmit.mjs +0 -77
  313. package/hooks/core/codex-caps.mjs +0 -112
  314. package/hooks/core/formatters.mjs +0 -356
  315. package/hooks/core/mcp-ready.mjs +0 -109
  316. package/hooks/core/platform-detect.mjs +0 -49
  317. package/hooks/core/routing.mjs +0 -1017
  318. package/hooks/core/stdin.mjs +0 -90
  319. package/hooks/core/tool-naming.mjs +0 -57
  320. package/hooks/cursor/afteragentresponse.mjs +0 -74
  321. package/hooks/cursor/hooks.json +0 -31
  322. package/hooks/cursor/posttooluse.mjs +0 -72
  323. package/hooks/cursor/pretooluse.mjs +0 -26
  324. package/hooks/cursor/sessionstart.mjs +0 -97
  325. package/hooks/cursor/stop.mjs +0 -48
  326. package/hooks/ensure-deps.mjs +0 -254
  327. package/hooks/formatters/claude-code.mjs +0 -113
  328. package/hooks/formatters/cursor.mjs +0 -37
  329. package/hooks/formatters/gemini-cli.mjs +0 -55
  330. package/hooks/formatters/vscode-copilot.mjs +0 -55
  331. package/hooks/gemini-cli/aftermodel.mjs +0 -70
  332. package/hooks/gemini-cli/aftertool.mjs +0 -61
  333. package/hooks/gemini-cli/beforeagent.mjs +0 -99
  334. package/hooks/gemini-cli/beforetool.mjs +0 -27
  335. package/hooks/gemini-cli/precompress.mjs +0 -55
  336. package/hooks/gemini-cli/sessionstart.mjs +0 -137
  337. package/hooks/heal-partial-install.mjs +0 -712
  338. package/hooks/jetbrains-copilot/posttooluse.mjs +0 -61
  339. package/hooks/jetbrains-copilot/precompact.mjs +0 -55
  340. package/hooks/jetbrains-copilot/pretooluse.mjs +0 -27
  341. package/hooks/jetbrains-copilot/sessionstart.mjs +0 -124
  342. package/hooks/kimi/platform.mjs +0 -1
  343. package/hooks/kimi/posttooluse.mjs +0 -72
  344. package/hooks/kimi/precompact.mjs +0 -80
  345. package/hooks/kimi/pretooluse.mjs +0 -42
  346. package/hooks/kimi/sessionend.mjs +0 -61
  347. package/hooks/kimi/sessionstart.mjs +0 -113
  348. package/hooks/kimi/stop.mjs +0 -132
  349. package/hooks/kimi/userpromptsubmit.mjs +0 -90
  350. package/hooks/kiro/agentspawn.mjs +0 -97
  351. package/hooks/kiro/posttooluse.mjs +0 -51
  352. package/hooks/kiro/pretooluse.mjs +0 -64
  353. package/hooks/kiro/userpromptsubmit.mjs +0 -88
  354. package/hooks/normalize-hooks.mjs +0 -323
  355. package/hooks/platform-bridge.mjs +0 -340
  356. package/hooks/posttooluse.mjs +0 -187
  357. package/hooks/precompact.mjs +0 -100
  358. package/hooks/pretooluse.mjs +0 -227
  359. package/hooks/qwen-code/platform.mjs +0 -1
  360. package/hooks/qwen-code/stop.mjs +0 -168
  361. package/hooks/routing-block.mjs +0 -105
  362. package/hooks/run-hook.mjs +0 -95
  363. package/hooks/security.bundle.mjs +0 -2
  364. package/hooks/session-attribution.bundle.mjs +0 -1
  365. package/hooks/session-db.bundle.mjs +0 -144
  366. package/hooks/session-directive.mjs +0 -520
  367. package/hooks/session-extract.bundle.mjs +0 -3
  368. package/hooks/session-helpers.mjs +0 -428
  369. package/hooks/session-loaders.mjs +0 -448
  370. package/hooks/session-snapshot.bundle.mjs +0 -32
  371. package/hooks/sessionstart.mjs +0 -464
  372. package/hooks/stop.mjs +0 -82
  373. package/hooks/suppress-stderr.mjs +0 -25
  374. package/hooks/userpromptsubmit.mjs +0 -108
  375. package/hooks/vscode-copilot/posttooluse.mjs +0 -61
  376. package/hooks/vscode-copilot/precompact.mjs +0 -55
  377. package/hooks/vscode-copilot/pretooluse.mjs +0 -27
  378. package/hooks/vscode-copilot/sessionstart.mjs +0 -129
  379. package/openclaw.plugin.json +0 -38
  380. package/scripts/heal-better-sqlite3.mjs +0 -430
  381. package/scripts/heal-installed-plugins.mjs +0 -598
  382. package/scripts/plugin-cache-integrity.mjs +0 -248
  383. package/scripts/postinstall.mjs +0 -384
  384. package/server.bundle.mjs +0 -1161
  385. package/skills/.ignore +0 -7
  386. package/skills/context-mode/SKILL.md +0 -300
  387. package/skills/context-mode/references/anti-patterns.md +0 -283
  388. package/skills/context-mode/references/patterns-javascript.md +0 -298
  389. package/skills/context-mode/references/patterns-python.md +0 -304
  390. package/skills/context-mode/references/patterns-shell.md +0 -277
  391. package/skills/ctx-index/SKILL.md +0 -46
  392. package/skills/ctx-insight/SKILL.md +0 -24
  393. package/skills/ctx-purge/SKILL.md +0 -49
  394. package/skills/ctx-search/SKILL.md +0 -35
  395. package/start.mjs +0 -616
@@ -1,1308 +0,0 @@
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 } 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 {
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.
573
- }
574
- finally {
575
- try {
576
- db?.close();
577
- }
578
- catch { /* ignore */ }
579
- }
580
- }
581
- // ─────────────────────────────────────────────────────────
582
- // SessionDB
583
- // ─────────────────────────────────────────────────────────
584
- export class SessionDB extends SQLiteBase {
585
- constructor(opts) {
586
- super(opts?.dbPath ?? defaultDBPath("session"));
587
- }
588
- /** Shorthand to retrieve a cached statement. */
589
- stmt(key) {
590
- return this.stmts.get(key);
591
- }
592
- // ── Schema ──
593
- initSchema() {
594
- // ── Migration: fix data_hash generated column from older schema ──
595
- // Old schema had data_hash as GENERATED ALWAYS AS — new schema uses explicit INSERT.
596
- // Detect and recreate table if needed (session data is ephemeral, safe to drop).
597
- try {
598
- const colInfo = this.db.pragma("table_xinfo(session_events)");
599
- const hashCol = colInfo.find((c) => c.name === "data_hash");
600
- if (hashCol && hashCol.hidden !== 0) {
601
- // hidden != 0 means generated column — must recreate
602
- this.db.exec("DROP TABLE session_events");
603
- }
604
- }
605
- catch { /* table doesn't exist yet — fine */ }
606
- this.db.exec(`
607
- CREATE TABLE IF NOT EXISTS session_events (
608
- id INTEGER PRIMARY KEY AUTOINCREMENT,
609
- session_id TEXT NOT NULL,
610
- type TEXT NOT NULL,
611
- category TEXT NOT NULL,
612
- priority INTEGER NOT NULL DEFAULT 2,
613
- data TEXT NOT NULL,
614
- project_dir TEXT NOT NULL DEFAULT '',
615
- attribution_source TEXT NOT NULL DEFAULT 'unknown',
616
- attribution_confidence REAL NOT NULL DEFAULT 0,
617
- bytes_avoided INTEGER NOT NULL DEFAULT 0,
618
- bytes_returned INTEGER NOT NULL DEFAULT 0,
619
- source_hook TEXT NOT NULL,
620
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
621
- data_hash TEXT NOT NULL DEFAULT ''
622
- );
623
-
624
- CREATE INDEX IF NOT EXISTS idx_session_events_session ON session_events(session_id);
625
- CREATE INDEX IF NOT EXISTS idx_session_events_type ON session_events(session_id, type);
626
- CREATE INDEX IF NOT EXISTS idx_session_events_priority ON session_events(session_id, priority);
627
-
628
- CREATE TABLE IF NOT EXISTS session_meta (
629
- session_id TEXT PRIMARY KEY,
630
- project_dir TEXT NOT NULL,
631
- started_at TEXT NOT NULL DEFAULT (datetime('now')),
632
- last_event_at TEXT,
633
- event_count INTEGER NOT NULL DEFAULT 0,
634
- compact_count INTEGER NOT NULL DEFAULT 0
635
- );
636
-
637
- CREATE TABLE IF NOT EXISTS session_resume (
638
- id INTEGER PRIMARY KEY AUTOINCREMENT,
639
- session_id TEXT NOT NULL UNIQUE,
640
- snapshot TEXT NOT NULL,
641
- event_count INTEGER NOT NULL,
642
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
643
- consumed INTEGER NOT NULL DEFAULT 0
644
- );
645
-
646
- CREATE TABLE IF NOT EXISTS tool_calls (
647
- session_id TEXT NOT NULL,
648
- tool TEXT NOT NULL,
649
- calls INTEGER NOT NULL DEFAULT 0,
650
- bytes_returned INTEGER NOT NULL DEFAULT 0,
651
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
652
- PRIMARY KEY (session_id, tool)
653
- );
654
-
655
- CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
656
- `);
657
- // Migration: add per-event attribution columns for existing DBs.
658
- // Shared helper — the analytics aggregator (analytics.ts) runs the
659
- // SAME migration against every historical DB it scans, so the column
660
- // list lives in one place at the top of this module.
661
- try {
662
- applyMissingSessionEventsColumns(this.db);
663
- }
664
- catch {
665
- // best-effort migration only
666
- }
667
- // Migration: per-session usage high-water cursor for the Stop hook's
668
- // cursor-aware main-turn capture (extractTranscriptUsageSince). Stores the
669
- // uuid of the last assistant turn already emitted so the next Stop forwards
670
- // only NEW spend. Idempotent — guarded by a table_xinfo column check.
671
- try {
672
- const metaCols = this.db.pragma("table_xinfo(session_meta)");
673
- if (!metaCols.some((c) => c.name === "usage_cursor")) {
674
- this.db.exec("ALTER TABLE session_meta ADD COLUMN usage_cursor TEXT");
675
- }
676
- }
677
- catch {
678
- // best-effort migration only
679
- }
680
- }
681
- prepareStatements() {
682
- this.stmts = new Map();
683
- const p = (key, sql) => {
684
- this.stmts.set(key, this.db.prepare(sql));
685
- };
686
- // ── Events ──
687
- p(S.insertEvent, `INSERT INTO session_events (
688
- session_id, type, category, priority, data,
689
- project_dir, attribution_source, attribution_confidence,
690
- bytes_avoided, bytes_returned,
691
- source_hook, data_hash
692
- )
693
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
694
- p(S.getEvents, `SELECT id, session_id, type, category, priority, data,
695
- project_dir, attribution_source, attribution_confidence,
696
- bytes_avoided, bytes_returned,
697
- source_hook, created_at, data_hash
698
- FROM session_events WHERE session_id = ? ORDER BY id ASC LIMIT ?`);
699
- p(S.getEventsByType, `SELECT id, session_id, type, category, priority, data,
700
- project_dir, attribution_source, attribution_confidence,
701
- bytes_avoided, bytes_returned,
702
- source_hook, created_at, data_hash
703
- FROM session_events WHERE session_id = ? AND type = ? ORDER BY id ASC LIMIT ?`);
704
- p(S.getEventsByPriority, `SELECT id, session_id, type, category, priority, data,
705
- project_dir, attribution_source, attribution_confidence,
706
- bytes_avoided, bytes_returned,
707
- source_hook, created_at, data_hash
708
- FROM session_events WHERE session_id = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
709
- p(S.getEventsByTypeAndPriority, `SELECT id, session_id, type, category, priority, data,
710
- project_dir, attribution_source, attribution_confidence,
711
- bytes_avoided, bytes_returned,
712
- source_hook, created_at, data_hash
713
- FROM session_events WHERE session_id = ? AND type = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
714
- p(S.getEventCount, `SELECT COUNT(*) AS cnt FROM session_events WHERE session_id = ?`);
715
- p(S.getLatestAttributedProject, `SELECT project_dir
716
- FROM session_events
717
- WHERE session_id = ? AND project_dir != ''
718
- ORDER BY id DESC
719
- LIMIT 1`);
720
- p(S.checkDuplicate, `SELECT 1 FROM (
721
- SELECT type, data_hash FROM session_events
722
- WHERE session_id = ? ORDER BY id DESC LIMIT ?
723
- ) AS recent
724
- WHERE recent.type = ? AND recent.data_hash = ?
725
- LIMIT 1`);
726
- p(S.evictLowestPriority, `DELETE FROM session_events WHERE id = (
727
- SELECT id FROM session_events WHERE session_id = ?
728
- ORDER BY priority ASC, id ASC LIMIT 1
729
- )`);
730
- p(S.updateMetaLastEvent, `UPDATE session_meta
731
- SET last_event_at = datetime('now'), event_count = event_count + 1
732
- WHERE session_id = ?`);
733
- // ── Meta ──
734
- p(S.ensureSession, `INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, ?)`);
735
- p(S.getSessionStats, `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count
736
- FROM session_meta WHERE session_id = ?`);
737
- // ── Session rollup (seed-parity aggregator) ────────────────────────
738
- // Single query producing 9 of the 12 platform-side session_summary +
739
- // session_metadata fields. Computed against the local SessionDB
740
- // session_events table at forward time so every outgoing canonical
741
- // event carries a session-wide snapshot at that moment — matches the
742
- // seed.ts shape where each event row has tool_calls/errors/etc. stamped.
743
- // max_file_edits and edit_test_cycles need separate GROUP BY queries
744
- // (below). compact_count is read from session_meta (already in getSessionStats).
745
- p(S.getSessionRollup, `SELECT
746
- COUNT(*) AS tool_calls,
747
- COALESCE(SUM(CASE WHEN category = 'error' THEN 1 ELSE 0 END), 0) AS errors,
748
- COUNT(DISTINCT type) AS unique_tools,
749
- COUNT(DISTINCT CASE WHEN category = 'file' THEN data END) AS unique_files,
750
- CASE WHEN SUM(CASE WHEN type = 'git_commit' THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS has_commit,
751
- CAST(COALESCE((MAX(strftime('%s', created_at)) - MIN(strftime('%s', created_at))) / 60.0, 0) AS INTEGER) AS duration_min,
752
- COALESCE(SUM(CASE WHEN type = 'external_ref' THEN 1 ELSE 0 END), 0) AS sources_indexed,
753
- CAST(COALESCE(SUM(bytes_avoided) / 1024.0, 0) AS INTEGER) AS total_chunks,
754
- COALESCE(SUM(CASE WHEN type IN ('file_search', 'file_glob') THEN 1 ELSE 0 END), 0) AS search_queries
755
- FROM session_events
756
- WHERE session_id = ?`);
757
- // max_file_edits: max edits on any single file path in the session.
758
- // Two-level aggregation — GROUP BY data first, then MAX of those counts.
759
- p(S.getMaxFileEdits, `SELECT COALESCE(MAX(c), 0) AS max_file_edits
760
- FROM (
761
- SELECT COUNT(*) AS c
762
- FROM session_events
763
- WHERE session_id = ? AND category = 'file' AND type IN ('file_edit', 'file_write')
764
- GROUP BY data
765
- )`);
766
- // v1.0.161 (Bug 2): latest commit message from session's type='git_commit'
767
- // events. Used by rollup spread to stamp commit_message symmetric with
768
- // has_commit on every outgoing event. Separate prepared statement (vs.
769
- // sub-select in getSessionRollup) keeps the binding shape uniform — every
770
- // rollup query takes a single sessionId parameter.
771
- p(S.getLatestCommitMessage, `SELECT data
772
- FROM session_events
773
- WHERE session_id = ? AND type = 'git_commit'
774
- ORDER BY id DESC
775
- LIMIT 1`);
776
- p(S.incrementCompactCount, `UPDATE session_meta SET compact_count = compact_count + 1 WHERE session_id = ?`);
777
- p(S.getUsageCursor, `SELECT usage_cursor FROM session_meta WHERE session_id = ?`);
778
- p(S.setUsageCursor, `UPDATE session_meta SET usage_cursor = ? WHERE session_id = ?`);
779
- // ── Resume ──
780
- p(S.upsertResume, `INSERT INTO session_resume (session_id, snapshot, event_count)
781
- VALUES (?, ?, ?)
782
- ON CONFLICT(session_id) DO UPDATE SET
783
- snapshot = excluded.snapshot,
784
- event_count = excluded.event_count,
785
- created_at = datetime('now'),
786
- consumed = 0`);
787
- p(S.getResume, `SELECT snapshot, event_count, consumed FROM session_resume WHERE session_id = ?`);
788
- p(S.markResumeConsumed, `UPDATE session_resume SET consumed = 1 WHERE session_id = ?`);
789
- // Atomic "pick newest unconsumed snapshot AND mark it consumed in one
790
- // statement". Required for race-safe cross-session resume injection
791
- // (Mickey / PR #376) — two parallel chat-turn hooks must not both read
792
- // the same row before either one writes consumed=1.
793
- //
794
- // The `session_id != ?` clause prevents self-injection (v1.0.106): when
795
- // Session B compacts mid-flight and produces its own row, B's next chat
796
- // turn must NOT claim that row back into its own prompt — that's wasted
797
- // tokens and steals the snapshot meant for the next fresh session.
798
- p(S.claimLatestUnconsumedResume, `UPDATE session_resume
799
- SET consumed = 1
800
- WHERE id = (
801
- SELECT id FROM session_resume
802
- WHERE consumed = 0
803
- AND session_id != ?
804
- ORDER BY created_at DESC, id DESC
805
- LIMIT 1
806
- )
807
- RETURNING session_id, snapshot`);
808
- // ── Delete ──
809
- p(S.deleteEvents, `DELETE FROM session_events WHERE session_id = ?`);
810
- p(S.deleteMeta, `DELETE FROM session_meta WHERE session_id = ?`);
811
- p(S.deleteResume, `DELETE FROM session_resume WHERE session_id = ?`);
812
- // ── Search ──
813
- p(S.searchEvents, `SELECT id, session_id, category, type, data, created_at
814
- FROM session_events
815
- WHERE (project_dir = ? OR project_dir = '')
816
- AND (data LIKE '%' || ? || '%' ESCAPE '\\' OR category LIKE '%' || ? || '%' ESCAPE '\\')
817
- AND (? IS NULL OR category = ?)
818
- ORDER BY id ASC
819
- LIMIT ?`);
820
- // ── Cleanup ──
821
- p(S.getOldSessions, `SELECT session_id FROM session_meta WHERE started_at < datetime('now', ? || ' days')`);
822
- // ── Tool calls (persistent counter) ──
823
- p(S.incrementToolCall, `INSERT INTO tool_calls (session_id, tool, calls, bytes_returned)
824
- VALUES (?, ?, 1, ?)
825
- ON CONFLICT(session_id, tool) DO UPDATE SET
826
- calls = calls + 1,
827
- bytes_returned = bytes_returned + excluded.bytes_returned,
828
- updated_at = datetime('now')`);
829
- p(S.getToolCallTotals, `SELECT COALESCE(SUM(calls), 0) AS calls,
830
- COALESCE(SUM(bytes_returned), 0) AS bytes_returned
831
- FROM tool_calls WHERE session_id = ?`);
832
- p(S.getToolCallByTool, `SELECT tool, calls, bytes_returned
833
- FROM tool_calls WHERE session_id = ? ORDER BY calls DESC`);
834
- // ── Event-level byte accounting (D2 PRD Phase 2) ──
835
- p(S.getEventBytesSummary, `SELECT COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
836
- COALESCE(SUM(bytes_returned), 0) AS bytes_returned
837
- FROM session_events WHERE session_id = ?`);
838
- }
839
- // ═══════════════════════════════════════════
840
- // Events
841
- // ═══════════════════════════════════════════
842
- /**
843
- * Insert a session event with deduplication and FIFO eviction.
844
- *
845
- * Deduplication: skips if the same type + data_hash appears in the
846
- * last DEDUP_WINDOW events for this session.
847
- *
848
- * Eviction: if session exceeds MAX_EVENTS_PER_SESSION, evicts the
849
- * lowest-priority (then oldest) event.
850
- */
851
- insertEvent(sessionId, event, sourceHook = "PostToolUse", attribution, bytes) {
852
- // SHA256-based dedup hash (first 16 hex chars = 8 bytes of entropy)
853
- const dataHash = createHash("sha256")
854
- .update(event.data)
855
- .digest("hex")
856
- .slice(0, 16)
857
- .toUpperCase();
858
- const projectDir = String(attribution?.projectDir
859
- ?? event.project_dir
860
- ?? this._getSessionProjectDir(sessionId)).trim();
861
- const attributionSource = String(attribution?.source
862
- ?? event.attribution_source
863
- ?? "unknown");
864
- const rawConfidence = Number(attribution?.confidence
865
- ?? event.attribution_confidence
866
- ?? 0);
867
- const attributionConfidence = Number.isFinite(rawConfidence)
868
- ? Math.max(0, Math.min(1, rawConfidence))
869
- : 0;
870
- const bytesAvoided = clampNonNegativeInt(bytes?.bytesAvoided);
871
- const bytesReturned = clampNonNegativeInt(bytes?.bytesReturned);
872
- // Atomic: dedup check + eviction + insert in a single transaction
873
- // to prevent race conditions from concurrent hook calls.
874
- const transaction = this.db.transaction(() => {
875
- // Deduplication check: same type + data_hash in last N events
876
- const dup = this.stmt(S.checkDuplicate).get(sessionId, DEDUP_WINDOW, event.type, dataHash);
877
- if (dup)
878
- return;
879
- // Enforce max events with FIFO eviction of lowest priority
880
- const countRow = this.stmt(S.getEventCount).get(sessionId);
881
- if (countRow.cnt >= MAX_EVENTS_PER_SESSION) {
882
- this.stmt(S.evictLowestPriority).run(sessionId);
883
- }
884
- // Insert the event
885
- this.stmt(S.insertEvent).run(sessionId, event.type, event.category, event.priority, event.data, projectDir, attributionSource, attributionConfidence, bytesAvoided, bytesReturned, sourceHook, dataHash);
886
- // Update meta if session exists
887
- this.stmt(S.updateMetaLastEvent).run(sessionId);
888
- });
889
- this.withRetry(() => transaction());
890
- }
891
- /**
892
- * Bulk-insert N events in a SINGLE transaction.
893
- *
894
- * PostToolUse hooks emit 5–15 events per tool call. Calling insertEvent()
895
- * in a loop runs N transactions = N WAL commits = N fsync candidates,
896
- * which is painful on Windows NTFS where commit latency dominates.
897
- * One transaction = one commit, dedup/evict checks reuse cached statements.
898
- *
899
- * Cross-platform: uses the same WAL-mode transaction primitive as
900
- * insertEvent — behavior identical on macOS / Linux / Windows.
901
- */
902
- bulkInsertEvents(sessionId, events, sourceHook = "PostToolUse", attributions, bytesList) {
903
- if (!events || events.length === 0)
904
- return;
905
- if (events.length === 1) {
906
- // Cheaper to fall through to insertEvent (its own dedicated transaction).
907
- this.insertEvent(sessionId, events[0], sourceHook, attributions?.[0], bytesList?.[0]);
908
- return;
909
- }
910
- // Pre-compute hashes + normalized attribution outside the transaction
911
- // so the SQL transaction holds only DB work (shorter lock window).
912
- const prepared = events.map((event, i) => {
913
- const dataHash = createHash("sha256")
914
- .update(event.data)
915
- .digest("hex")
916
- .slice(0, 16)
917
- .toUpperCase();
918
- const attribution = attributions?.[i];
919
- // #827: store project_dir in canonical path shape so the search-time
920
- // allow-set lookup (getSessionIdsForProject) matches regardless of the
921
- // separator / trailing-slash form the host adapter happened to emit.
922
- // normalizeWorktreePath is the same rule used for project-hash stability.
923
- const rawProjectDir = String(attribution?.projectDir ?? event.project_dir ?? this._getSessionProjectDir(sessionId) ?? "").trim();
924
- const projectDir = rawProjectDir === "" ? "" : normalizeWorktreePath(rawProjectDir);
925
- const attributionSource = String(attribution?.source ?? event.attribution_source ?? "unknown");
926
- const rawConfidence = Number(attribution?.confidence ?? event.attribution_confidence ?? 0);
927
- const attributionConfidence = Number.isFinite(rawConfidence)
928
- ? Math.max(0, Math.min(1, rawConfidence))
929
- : 0;
930
- const eventBytes = bytesList?.[i];
931
- const bytesAvoided = clampNonNegativeInt(eventBytes?.bytesAvoided);
932
- const bytesReturned = clampNonNegativeInt(eventBytes?.bytesReturned);
933
- return {
934
- event,
935
- dataHash,
936
- projectDir,
937
- attributionSource,
938
- attributionConfidence,
939
- bytesAvoided,
940
- bytesReturned,
941
- };
942
- });
943
- const transaction = this.db.transaction(() => {
944
- let cnt = this.stmt(S.getEventCount).get(sessionId).cnt;
945
- for (const row of prepared) {
946
- const dup = this.stmt(S.checkDuplicate).get(sessionId, DEDUP_WINDOW, row.event.type, row.dataHash);
947
- if (dup)
948
- continue;
949
- if (cnt >= MAX_EVENTS_PER_SESSION) {
950
- this.stmt(S.evictLowestPriority).run(sessionId);
951
- }
952
- else {
953
- cnt++;
954
- }
955
- 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);
956
- }
957
- this.stmt(S.updateMetaLastEvent).run(sessionId);
958
- });
959
- this.withRetry(() => transaction());
960
- }
961
- /**
962
- * Retrieve events for a session with optional filtering.
963
- */
964
- getEvents(sessionId, opts) {
965
- const limit = opts?.limit ?? 1000;
966
- const type = opts?.type;
967
- const minPriority = opts?.minPriority;
968
- if (type && minPriority !== undefined) {
969
- return this.stmt(S.getEventsByTypeAndPriority).all(sessionId, type, minPriority, limit);
970
- }
971
- if (type) {
972
- return this.stmt(S.getEventsByType).all(sessionId, type, limit);
973
- }
974
- if (minPriority !== undefined) {
975
- return this.stmt(S.getEventsByPriority).all(sessionId, minPriority, limit);
976
- }
977
- return this.stmt(S.getEvents).all(sessionId, limit);
978
- }
979
- /**
980
- * Get the total event count for a session.
981
- */
982
- getEventCount(sessionId) {
983
- const row = this.stmt(S.getEventCount).get(sessionId);
984
- return row.cnt;
985
- }
986
- /**
987
- * Aggregate per-event byte accounting for a session.
988
- *
989
- * Returns the total bytes context-mode kept OUT of the model context
990
- * window (`bytesAvoided`) and the total it actually returned to the
991
- * model (`bytesReturned`). Both default to 0 for unknown sessions.
992
- *
993
- * Used by the Insight dashboard to render the "saved vs returned"
994
- * panel without scanning every event row in JS.
995
- */
996
- getEventBytesSummary(sessionId) {
997
- const row = this.stmt(S.getEventBytesSummary).get(sessionId);
998
- return {
999
- bytesAvoided: Number(row?.bytes_avoided ?? 0),
1000
- bytesReturned: Number(row?.bytes_returned ?? 0),
1001
- };
1002
- }
1003
- /**
1004
- * Return the most recently attributed project dir for a session.
1005
- */
1006
- getLatestAttributedProjectDir(sessionId) {
1007
- const row = this.stmt(S.getLatestAttributedProject).get(sessionId);
1008
- return row?.project_dir || null;
1009
- }
1010
- /**
1011
- * Look up the project_dir from session_meta as a last-resort fallback
1012
- * for event attribution. Prevents project_dir='' orphans when the caller
1013
- * (e.g. pi adapter) omits the attribution parameter.
1014
- */
1015
- _getSessionProjectDir(sessionId) {
1016
- try {
1017
- const row = this.db.prepare("SELECT project_dir FROM session_meta WHERE session_id = ?").get(sessionId);
1018
- return row?.project_dir || "";
1019
- }
1020
- catch {
1021
- return "";
1022
- }
1023
- }
1024
- /**
1025
- * Search events by text query scoped to a project directory.
1026
- *
1027
- * Performs a case-insensitive LIKE search across the `data` and `category`
1028
- * columns. An optional `source` parameter filters by exact category match.
1029
- * Returns results ordered by monotonic id (chronological).
1030
- *
1031
- * Best-effort: returns empty array on any error.
1032
- */
1033
- searchEvents(query, limit, projectDir, source) {
1034
- try {
1035
- const escapedQuery = query.replace(/[%_]/g, (char) => "\\" + char);
1036
- const sourceParam = source ?? null;
1037
- return this.stmt(S.searchEvents).all(projectDir, escapedQuery, escapedQuery, sourceParam, sourceParam, limit);
1038
- }
1039
- catch {
1040
- return [];
1041
- }
1042
- }
1043
- /**
1044
- * Return the distinct list of session ids whose events were attributed
1045
- * to a given `project_dir`. Powers the ctx_search `project:` filter
1046
- * (#737) via the 2-step IN-clause strategy — ATTACH DATABASE is avoided
1047
- * because SQLite's WAL + ATTACH combination has known correctness
1048
- * trade-offs flagged in the upstream docs.
1049
- *
1050
- * Backed by the `idx_session_events_project(session_id, project_dir)`
1051
- * composite index, so 1000-session lookups complete in single-digit
1052
- * milliseconds. Best-effort: returns `[]` on any error.
1053
- */
1054
- getSessionIdsForProject(projectDir) {
1055
- try {
1056
- // #827: match by canonical path shape, not raw bytes. The host adapter
1057
- // may store `project_dir` in a different separator / trailing-slash
1058
- // shape than the search path resolves the scope in — most visibly on
1059
- // Windows, where attribution often carries `C:\Users\me\proj` while the
1060
- // server resolves `C:/Users/me/proj`. An exact `project_dir = ?` match
1061
- // then returned an EMPTY allow-set and ctx_search reported "No results
1062
- // found" even though the content was present. We fold BOTH sides through
1063
- // the same canonical rule used for project-hash stability
1064
- // (normalizeWorktreePath): backslash → forward slash, then strip the
1065
- // trailing slash. Normalizing in SQL (RTRIM(REPLACE(...))) covers rows
1066
- // already written un-normalized without a migration, while the JS-side
1067
- // normalize keeps the bound parameter in the identical shape. This
1068
- // preserves the #737 project scope — distinct directories still differ
1069
- // after normalization, so cross-project isolation is intact.
1070
- const normalized = normalizeWorktreePath(projectDir);
1071
- const rows = this.db
1072
- .prepare(`SELECT DISTINCT session_id
1073
- FROM session_events
1074
- WHERE RTRIM(REPLACE(project_dir, '\\', '/'), '/') = ?`)
1075
- .all(normalized);
1076
- return rows.map((r) => r.session_id);
1077
- }
1078
- catch {
1079
- return [];
1080
- }
1081
- }
1082
- // ═══════════════════════════════════════════
1083
- // Meta
1084
- // ═══════════════════════════════════════════
1085
- /**
1086
- * Ensure a session metadata entry exists. Idempotent (INSERT OR IGNORE).
1087
- * `projectDir` is the session origin directory, not per-event attribution.
1088
- */
1089
- ensureSession(sessionId, projectDir) {
1090
- this.stmt(S.ensureSession).run(sessionId, projectDir);
1091
- }
1092
- /**
1093
- * Get session statistics/metadata.
1094
- */
1095
- getSessionStats(sessionId) {
1096
- const row = this.stmt(S.getSessionStats).get(sessionId);
1097
- return row ?? null;
1098
- }
1099
- /**
1100
- * Session rollup snapshot — 12 aggregate fields the analytics platform
1101
- * stamps onto every outgoing event row (seed.ts shape parity).
1102
- *
1103
- * Called from session-loaders BEFORE `maybeForward`; the snapshot is
1104
- * computed against the LOCAL SessionDB and threaded into the canonical
1105
- * event so the platform-side Zod schema receives the rich shape without
1106
- * the bridge ever hand-mapping fields (PRD §5.4 ABI passthrough).
1107
- *
1108
- * Returns zeroed defaults for unknown sessions — callers MUST tolerate
1109
- * a snapshot from an empty session (first event into a fresh DB).
1110
- */
1111
- getSessionRollup(sessionId) {
1112
- const main = this.stmt(S.getSessionRollup).get(sessionId);
1113
- const maxRow = this.stmt(S.getMaxFileEdits).get(sessionId);
1114
- const commitRow = this.stmt(S.getLatestCommitMessage).get(sessionId);
1115
- const meta = this.getSessionStats(sessionId);
1116
- // edit_test_cycles: heuristic — min(file edits, errors) approximates
1117
- // the number of edit-then-test attempts in a session. Exact pattern
1118
- // detection (consecutive file_edit followed by error_tool) would need
1119
- // a windowed query; this scalar pair under-counts but never overshoots.
1120
- const fileEdits = (main?.tool_calls ?? 0) > 0
1121
- ? (main?.unique_files ?? 0)
1122
- : 0;
1123
- const errors = main?.errors ?? 0;
1124
- const editTestCycles = Math.min(fileEdits, errors);
1125
- return {
1126
- tool_calls: main?.tool_calls ?? 0,
1127
- errors: main?.errors ?? 0,
1128
- unique_tools: main?.unique_tools ?? 0,
1129
- unique_files: main?.unique_files ?? 0,
1130
- max_file_edits: maxRow?.max_file_edits ?? 0,
1131
- has_commit: main?.has_commit ?? 0,
1132
- commit_message: commitRow?.data ?? "",
1133
- edit_test_cycles: editTestCycles,
1134
- duration_min: main?.duration_min ?? 0,
1135
- compact_count: meta?.compact_count ?? 0,
1136
- sources_indexed: main?.sources_indexed ?? 0,
1137
- total_chunks: main?.total_chunks ?? 0,
1138
- search_queries: main?.search_queries ?? 0,
1139
- };
1140
- }
1141
- /**
1142
- * Increment the compact_count for a session (tracks snapshot rebuilds).
1143
- */
1144
- incrementCompactCount(sessionId) {
1145
- this.stmt(S.incrementCompactCount).run(sessionId);
1146
- }
1147
- /**
1148
- * Read the per-session usage high-water cursor — the uuid of the last
1149
- * assistant turn already emitted by the Stop hook's main-turn capture.
1150
- * Returns null when unset (first Stop) or the session row is absent.
1151
- */
1152
- getUsageCursor(sessionId) {
1153
- const row = this.stmt(S.getUsageCursor).get(sessionId);
1154
- return row?.usage_cursor ?? null;
1155
- }
1156
- /**
1157
- * Advance the per-session usage high-water cursor to `uuid`. No-op when the
1158
- * session_meta row does not exist yet (callers ensureSession first).
1159
- */
1160
- setUsageCursor(sessionId, uuid) {
1161
- this.stmt(S.setUsageCursor).run(uuid, sessionId);
1162
- }
1163
- // ═══════════════════════════════════════════
1164
- // Resume
1165
- // ═══════════════════════════════════════════
1166
- /**
1167
- * Upsert a resume snapshot for a session. Resets consumed flag on update.
1168
- */
1169
- upsertResume(sessionId, snapshot, eventCount) {
1170
- this.stmt(S.upsertResume).run(sessionId, snapshot, eventCount ?? 0);
1171
- }
1172
- /**
1173
- * Retrieve the resume snapshot for a session.
1174
- */
1175
- getResume(sessionId) {
1176
- const row = this.stmt(S.getResume).get(sessionId);
1177
- return row ?? null;
1178
- }
1179
- /**
1180
- * Mark the resume snapshot as consumed (already injected into conversation).
1181
- */
1182
- markResumeConsumed(sessionId) {
1183
- this.stmt(S.markResumeConsumed).run(sessionId);
1184
- }
1185
- /**
1186
- * Atomically claim the most recent unconsumed resume snapshot in this DB,
1187
- * EXCLUDING any row that belongs to `currentSessionId`.
1188
- *
1189
- * `SessionDB` is sharded per project (see `resolveSessionDbPath` — SHA-256
1190
- * of canonical project dir), so "this DB" already implies "this project".
1191
- * The atomic
1192
- * `UPDATE … RETURNING` ensures concurrent processes for the same project
1193
- * cannot both inject the same snapshot (Mickey / PR #376 race).
1194
- *
1195
- * The `currentSessionId` parameter prevents self-injection: when a session
1196
- * compacts mid-flight and produces its own row, that session's next chat
1197
- * turn must NOT claim that row back (wasted tokens AND it would consume
1198
- * the snapshot meant for the next fresh session).
1199
- *
1200
- * Pass an empty string to allow self-claim (legacy behaviour, only useful
1201
- * in tests or one-off harnesses).
1202
- *
1203
- * Returns null when no unconsumed snapshot exists for any other session.
1204
- */
1205
- claimLatestUnconsumedResume(currentSessionId) {
1206
- const row = this.stmt(S.claimLatestUnconsumedResume).get(currentSessionId);
1207
- if (!row)
1208
- return null;
1209
- return { sessionId: row.session_id, snapshot: row.snapshot };
1210
- }
1211
- /**
1212
- * Return the most recent session_id from session_meta, or null if none.
1213
- * Used by the runtime to attach persistent counters to the right session
1214
- * after a process restart.
1215
- */
1216
- getLatestSessionId() {
1217
- try {
1218
- const row = this.db.prepare("SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1").get();
1219
- return row?.session_id ?? null;
1220
- }
1221
- catch {
1222
- return null;
1223
- }
1224
- }
1225
- // ═══════════════════════════════════════════
1226
- // Tool call counters (Bug #1 + #2 — survive restart, --continue, upgrade)
1227
- // ═══════════════════════════════════════════
1228
- /**
1229
- * Increment the persistent tool-call counter for `tool` in `sessionId`.
1230
- * Adds `bytesReturned` to the cumulative total. Idempotent across
1231
- * SessionDB instances — counters survive process restart.
1232
- */
1233
- incrementToolCall(sessionId, tool, bytesReturned = 0) {
1234
- const safeBytes = Number.isFinite(bytesReturned) && bytesReturned > 0 ? Math.round(bytesReturned) : 0;
1235
- try {
1236
- this.stmt(S.incrementToolCall).run(sessionId, tool, safeBytes);
1237
- }
1238
- catch {
1239
- // best-effort: counter must never throw and break the parent call
1240
- }
1241
- }
1242
- /**
1243
- * Get aggregated tool-call stats for `sessionId`. Returns zero-stats
1244
- * when the session has no recorded calls.
1245
- */
1246
- getToolCallStats(sessionId) {
1247
- try {
1248
- const totals = this.stmt(S.getToolCallTotals).get(sessionId);
1249
- const rows = this.stmt(S.getToolCallByTool).all(sessionId);
1250
- const byTool = {};
1251
- for (const row of rows) {
1252
- byTool[row.tool] = {
1253
- calls: row.calls,
1254
- bytesReturned: row.bytes_returned,
1255
- };
1256
- }
1257
- return {
1258
- totalCalls: totals?.calls ?? 0,
1259
- totalBytesReturned: totals?.bytes_returned ?? 0,
1260
- byTool,
1261
- };
1262
- }
1263
- catch {
1264
- return { totalCalls: 0, totalBytesReturned: 0, byTool: {} };
1265
- }
1266
- }
1267
- // ═══════════════════════════════════════════
1268
- // Lifecycle
1269
- // ═══════════════════════════════════════════
1270
- /**
1271
- * Delete all data for a session (events, meta, resume).
1272
- */
1273
- deleteSession(sessionId) {
1274
- this.db.transaction(() => {
1275
- this.stmt(S.deleteEvents).run(sessionId);
1276
- this.stmt(S.deleteResume).run(sessionId);
1277
- this.stmt(S.deleteMeta).run(sessionId);
1278
- })();
1279
- }
1280
- /**
1281
- * Remove sessions older than maxAgeDays. Returns the count of deleted sessions.
1282
- */
1283
- cleanupOldSessions(maxAgeDays = 7) {
1284
- const negDays = `-${maxAgeDays}`;
1285
- const oldSessions = this.stmt(S.getOldSessions).all(negDays);
1286
- for (const { session_id } of oldSessions) {
1287
- this.deleteSession(session_id);
1288
- }
1289
- return oldSessions.length;
1290
- }
1291
- /**
1292
- * Delete event rows whose session_id has no matching session_meta row.
1293
- *
1294
- * Orphaned events accumulate when meta rows were aged out by an older
1295
- * version of `cleanupOldSessions` but the matching events were left
1296
- * behind (or when callers wrote events without a meta upsert). The Kimi
1297
- * Code sessionstart hook calls this on every startup as a self-healing
1298
- * step; surfacing it as a SessionDB method keeps the SQL definition in
1299
- * one place instead of letting hook scripts reach through to
1300
- * `db.db.exec(...)` and re-encode schema knowledge in mjs files.
1301
- */
1302
- pruneOrphanedEvents() {
1303
- const result = this.db
1304
- .prepare(`DELETE FROM session_events WHERE session_id NOT IN (SELECT session_id FROM session_meta)`)
1305
- .run();
1306
- return Number(result.changes ?? 0);
1307
- }
1308
- }