supipowers 1.5.3 → 2.0.1

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 (340) hide show
  1. package/README.md +14 -8
  2. package/bin/install.mjs +20 -5
  3. package/bin/install.ts +95 -0
  4. package/package.json +8 -4
  5. package/skills/context-mode/SKILL.md +17 -10
  6. package/skills/harness/SKILL.md +94 -0
  7. package/skills/ui-design/SKILL.md +63 -0
  8. package/skills/ui-design/sub-agent-templates/component-builder.md +29 -0
  9. package/skills/ui-design/sub-agent-templates/design-critic.md +46 -0
  10. package/skills/ui-design/sub-agent-templates/pencil/component-builder.md +29 -0
  11. package/skills/ui-design/sub-agent-templates/pencil/design-critic.md +42 -0
  12. package/skills/ui-design/sub-agent-templates/pencil/section-assembler.md +27 -0
  13. package/skills/ui-design/sub-agent-templates/section-assembler.md +27 -0
  14. package/skills/ultraplan-discover/SKILL.md +96 -0
  15. package/skills/ultraplan-intake/SKILL.md +89 -0
  16. package/skills/ultraplan-research/SKILL.md +129 -0
  17. package/skills/ultraplan-review/SKILL.md +86 -0
  18. package/skills/ultraplan-review-scope/SKILL.md +111 -0
  19. package/skills/ultraplan-review-structure/SKILL.md +120 -0
  20. package/skills/ultraplan-review-tdd/SKILL.md +142 -0
  21. package/skills/ultraplan-scout/SKILL.md +110 -0
  22. package/skills/ultraplan-synthesize/SKILL.md +124 -0
  23. package/src/{quality/ai-session.ts → ai/final-message.ts} +27 -0
  24. package/src/ai/schema-text.ts +129 -0
  25. package/src/ai/structured-output.ts +274 -0
  26. package/src/ai/template.ts +27 -0
  27. package/src/bootstrap.ts +63 -28
  28. package/src/commands/agents.ts +131 -42
  29. package/src/commands/ai-review.ts +251 -30
  30. package/src/commands/clear.ts +434 -0
  31. package/src/commands/commit.ts +1 -0
  32. package/src/commands/config.ts +242 -44
  33. package/src/commands/context.ts +55 -28
  34. package/src/commands/doctor.ts +234 -6
  35. package/src/commands/fix-pr.ts +306 -131
  36. package/src/commands/generate.ts +111 -21
  37. package/src/commands/memory.ts +192 -0
  38. package/src/commands/model-picker.ts +28 -21
  39. package/src/commands/model.ts +18 -8
  40. package/src/commands/optimize-context.ts +408 -29
  41. package/src/commands/plan.ts +2 -0
  42. package/src/commands/qa.ts +312 -137
  43. package/src/commands/release.ts +259 -76
  44. package/src/commands/review.ts +293 -59
  45. package/src/commands/status.ts +200 -13
  46. package/src/commands/supi.ts +3 -35
  47. package/src/commands/ui-design.ts +394 -0
  48. package/src/commands/ultraplan.ts +1518 -0
  49. package/src/commands/update.ts +86 -0
  50. package/src/config/defaults.ts +62 -0
  51. package/src/config/loader.ts +448 -60
  52. package/src/config/schema.ts +108 -2
  53. package/src/context/optimizer.ts +25 -33
  54. package/src/context/rule-renderer.ts +223 -0
  55. package/src/context/savings.ts +258 -0
  56. package/src/context/startup-check.ts +380 -0
  57. package/src/context/startup-optimizer.ts +355 -0
  58. package/src/context/tokenignore.ts +146 -0
  59. package/src/context-mode/cache-handle.ts +49 -0
  60. package/src/context-mode/cache-preview.ts +71 -0
  61. package/src/context-mode/cache-store.ts +738 -0
  62. package/src/context-mode/compressor.ts +131 -26
  63. package/src/context-mode/dedup.ts +108 -0
  64. package/src/context-mode/detector.ts +35 -4
  65. package/src/context-mode/event-extractor.ts +14 -12
  66. package/src/context-mode/event-store.ts +91 -36
  67. package/src/context-mode/hooks.ts +798 -56
  68. package/src/context-mode/knowledge/store.ts +255 -11
  69. package/src/context-mode/memory-store.ts +325 -0
  70. package/src/context-mode/metrics-recorder.ts +158 -0
  71. package/src/context-mode/metrics-store.ts +765 -0
  72. package/src/context-mode/model.ts +24 -0
  73. package/src/context-mode/processor-keys.ts +29 -0
  74. package/src/context-mode/processors/build.ts +66 -0
  75. package/src/context-mode/processors/docker.ts +57 -0
  76. package/src/context-mode/processors/git.ts +111 -0
  77. package/src/context-mode/processors/json.ts +112 -0
  78. package/src/context-mode/processors/k8s.ts +67 -0
  79. package/src/context-mode/processors/lint.ts +67 -0
  80. package/src/context-mode/processors/log.ts +86 -0
  81. package/src/context-mode/processors/registry.ts +116 -0
  82. package/src/context-mode/processors/test-runner.ts +102 -0
  83. package/src/context-mode/processors/types.ts +20 -0
  84. package/src/context-mode/repomap.ts +400 -0
  85. package/src/context-mode/routing.ts +97 -24
  86. package/src/context-mode/sandbox/runners.ts +5 -1
  87. package/src/context-mode/snapshot-builder.ts +106 -11
  88. package/src/context-mode/source-hash.ts +173 -0
  89. package/src/context-mode/tool-name.ts +11 -0
  90. package/src/context-mode/tools.ts +654 -22
  91. package/src/context-mode/web/fetcher.ts +31 -12
  92. package/src/debug/logger.ts +2 -1
  93. package/src/deps/registry.ts +1 -1
  94. package/src/discipline/failure-summarizer.ts +170 -0
  95. package/src/discipline/failure-taxonomy.ts +131 -0
  96. package/src/discipline/workflow-invariants.ts +125 -0
  97. package/src/discovery/index.ts +31 -0
  98. package/src/discovery/lsp.ts +87 -0
  99. package/src/discovery/rank.ts +144 -0
  100. package/src/discovery/sources.ts +89 -0
  101. package/src/discovery/workflow.ts +87 -0
  102. package/src/docs/contracts.ts +39 -0
  103. package/src/docs/drift.ts +117 -87
  104. package/src/fix-pr/assessment.ts +200 -0
  105. package/src/fix-pr/contracts.ts +47 -0
  106. package/src/fix-pr/fetch-comments.ts +80 -0
  107. package/src/fix-pr/prompt-builder.ts +58 -40
  108. package/src/fix-pr/scripts/exec.ts +34 -0
  109. package/src/fix-pr/scripts/trigger-review.ts +106 -0
  110. package/src/fix-pr/scripts/wait-and-check.ts +108 -0
  111. package/src/fix-pr/types.ts +4 -0
  112. package/src/git/branch-finish.ts +5 -0
  113. package/src/git/commit-contract.ts +83 -0
  114. package/src/git/commit.ts +121 -184
  115. package/src/git/status.ts +62 -8
  116. package/src/harness/anti_slop/architecture-parser.ts +210 -0
  117. package/src/harness/anti_slop/backend-factory.ts +30 -0
  118. package/src/harness/anti_slop/backend.ts +140 -0
  119. package/src/harness/anti_slop/desloppify-adapter.ts +319 -0
  120. package/src/harness/anti_slop/fallow-adapter.ts +305 -0
  121. package/src/harness/anti_slop/installer.ts +227 -0
  122. package/src/harness/anti_slop/queue.ts +216 -0
  123. package/src/harness/anti_slop/recommend.ts +84 -0
  124. package/src/harness/anti_slop/score.ts +180 -0
  125. package/src/harness/anti_slop/synthetic-edit-test.ts +128 -0
  126. package/src/harness/artifacts/agents-md.ts +88 -0
  127. package/src/harness/artifacts/checks-wiring.ts +57 -0
  128. package/src/harness/artifacts/docs-tree.ts +79 -0
  129. package/src/harness/artifacts/lint-configs.ts +136 -0
  130. package/src/harness/artifacts/review-agents.ts +67 -0
  131. package/src/harness/bare-entry.ts +108 -0
  132. package/src/harness/command.ts +1010 -0
  133. package/src/harness/default-agents/design.md +23 -0
  134. package/src/harness/default-agents/discover.md +18 -0
  135. package/src/harness/default-agents/implement.md +24 -0
  136. package/src/harness/default-agents/plan.md +19 -0
  137. package/src/harness/default-agents/research.md +21 -0
  138. package/src/harness/default-agents/validate.md +22 -0
  139. package/src/harness/gc/reporter.ts +28 -0
  140. package/src/harness/gc/runner.ts +136 -0
  141. package/src/harness/hooks/layer-context-inject.ts +155 -0
  142. package/src/harness/hooks/post-session-sweep.ts +130 -0
  143. package/src/harness/hooks/pre-edit-dupe-probe.ts +224 -0
  144. package/src/harness/hooks/register.ts +118 -0
  145. package/src/harness/model.ts +117 -0
  146. package/src/harness/pipeline.ts +348 -0
  147. package/src/harness/project-paths.ts +235 -0
  148. package/src/harness/stage-runner.ts +107 -0
  149. package/src/harness/stages/design.ts +386 -0
  150. package/src/harness/stages/discover.ts +454 -0
  151. package/src/harness/stages/implement.ts +162 -0
  152. package/src/harness/stages/plan.ts +335 -0
  153. package/src/harness/stages/research.ts +263 -0
  154. package/src/harness/stages/validate.ts +684 -0
  155. package/src/harness/storage.ts +467 -0
  156. package/src/harness/tools.ts +426 -0
  157. package/src/lsp/bridge.ts +56 -95
  158. package/src/lsp/capabilities.ts +108 -0
  159. package/src/lsp/contracts.ts +35 -0
  160. package/src/lsp/detector.ts +8 -12
  161. package/src/markdown-frontmatter.ts +68 -0
  162. package/src/mempalace/bridge.ts +135 -0
  163. package/src/mempalace/config.ts +75 -0
  164. package/src/mempalace/format.ts +163 -0
  165. package/src/mempalace/hooks.ts +370 -0
  166. package/src/mempalace/installer-helper.ts +194 -0
  167. package/src/mempalace/python/mempalace_bridge.py +440 -0
  168. package/src/mempalace/runtime.ts +565 -0
  169. package/src/mempalace/schema.ts +268 -0
  170. package/src/mempalace/session-summary.ts +198 -0
  171. package/src/mempalace/tool.ts +186 -0
  172. package/src/mempalace/uv.ts +256 -0
  173. package/src/migrate/runner.ts +354 -0
  174. package/src/planning/approval-flow.ts +206 -9
  175. package/src/planning/plan-writer-prompt.ts +4 -3
  176. package/src/planning/planning-ask-tool.ts +39 -0
  177. package/src/planning/render-markdown.ts +74 -0
  178. package/src/planning/spec.ts +42 -0
  179. package/src/planning/system-prompt.ts +11 -8
  180. package/src/planning/validate.ts +84 -0
  181. package/src/platform/omp.ts +15 -2
  182. package/src/platform/system-prompt.ts +37 -0
  183. package/src/platform/test-utils.ts +3 -0
  184. package/src/platform/types.ts +6 -1
  185. package/src/qa/config.ts +12 -6
  186. package/src/qa/detect-app-type.ts +13 -6
  187. package/src/qa/matrix.ts +12 -6
  188. package/src/qa/prompt-builder.ts +28 -30
  189. package/src/qa/scripts/dev-server-utils.ts +72 -0
  190. package/src/qa/scripts/run-e2e-tests.ts +226 -0
  191. package/src/qa/scripts/start-dev-server.ts +138 -0
  192. package/src/qa/scripts/stop-dev-server.ts +77 -0
  193. package/src/qa/session.ts +13 -7
  194. package/src/quality/ai-setup.ts +27 -25
  195. package/src/quality/contracts.ts +34 -0
  196. package/src/quality/gates/ai-review.ts +20 -58
  197. package/src/quality/gates/command.ts +249 -46
  198. package/src/quality/review-gates.ts +18 -2
  199. package/src/quality/runner.ts +63 -22
  200. package/src/quality/schemas.ts +37 -2
  201. package/src/quality/setup.ts +96 -16
  202. package/src/release/changelog.ts +1 -1
  203. package/src/release/channels/custom.ts +13 -3
  204. package/src/release/channels/types.ts +5 -0
  205. package/src/release/contracts.ts +90 -0
  206. package/src/release/executor.ts +122 -45
  207. package/src/release/prompt.ts +18 -2
  208. package/src/release/targets.ts +86 -0
  209. package/src/release/version.ts +96 -71
  210. package/src/review/agent-loader.ts +221 -109
  211. package/src/review/fixer.ts +10 -6
  212. package/src/review/multi-agent-runner.ts +114 -13
  213. package/src/review/output.ts +12 -139
  214. package/src/review/runner.ts +12 -6
  215. package/src/review/scope.ts +144 -24
  216. package/src/review/types.ts +1 -20
  217. package/src/review/validator.ts +12 -6
  218. package/src/storage/fix-pr-sessions.ts +21 -14
  219. package/src/storage/plans.ts +14 -5
  220. package/src/storage/qa-sessions.ts +25 -19
  221. package/src/storage/reliability-metrics.ts +180 -0
  222. package/src/storage/reports.ts +8 -7
  223. package/src/storage/review-sessions.ts +55 -20
  224. package/src/tool-catalog/active-tool-controller.ts +164 -0
  225. package/src/tool-catalog/active-tool-planner.ts +212 -0
  226. package/src/tool-catalog/tool-groups.ts +102 -0
  227. package/src/types.ts +1399 -5
  228. package/src/ui-design/backend-adapter.ts +78 -0
  229. package/src/ui-design/backends/local-html.ts +82 -0
  230. package/src/ui-design/backends/pencil-mcp.ts +111 -0
  231. package/src/ui-design/components-scanner.ts +124 -0
  232. package/src/ui-design/config.ts +55 -0
  233. package/src/ui-design/pen-scanner.ts +95 -0
  234. package/src/ui-design/pen-selector.ts +72 -0
  235. package/src/ui-design/prompt-builder.ts +73 -0
  236. package/src/ui-design/scanner.ts +136 -0
  237. package/src/ui-design/session.ts +974 -0
  238. package/src/ui-design/system-prompt.ts +312 -0
  239. package/src/ui-design/tokens-scanner.ts +181 -0
  240. package/src/ui-design/types.ts +96 -0
  241. package/src/ultraplan/agent-catalog.ts +522 -0
  242. package/src/ultraplan/authoring/agent-catalog.ts +310 -0
  243. package/src/ultraplan/authoring/authoring-tools.ts +552 -0
  244. package/src/ultraplan/authoring/command-handlers.ts +339 -0
  245. package/src/ultraplan/authoring/markdown.ts +510 -0
  246. package/src/ultraplan/authoring/model.ts +162 -0
  247. package/src/ultraplan/authoring/pipeline.ts +319 -0
  248. package/src/ultraplan/authoring/stage-runner.ts +141 -0
  249. package/src/ultraplan/authoring/stages/approve.ts +249 -0
  250. package/src/ultraplan/authoring/stages/discover.ts +289 -0
  251. package/src/ultraplan/authoring/stages/intake.ts +203 -0
  252. package/src/ultraplan/authoring/stages/research.ts +399 -0
  253. package/src/ultraplan/authoring/stages/review.ts +333 -0
  254. package/src/ultraplan/authoring/stages/scout.ts +188 -0
  255. package/src/ultraplan/authoring/stages/synthesize.ts +348 -0
  256. package/src/ultraplan/authoring/storage.ts +594 -0
  257. package/src/ultraplan/authoring/synth-gate.ts +165 -0
  258. package/src/ultraplan/authoring-draft.ts +653 -0
  259. package/src/ultraplan/authoring-persist.ts +180 -0
  260. package/src/ultraplan/authoring-tool.ts +608 -0
  261. package/src/ultraplan/authoring-wizard.ts +587 -0
  262. package/src/ultraplan/batch/merge.ts +98 -0
  263. package/src/ultraplan/batch/planner.ts +150 -0
  264. package/src/ultraplan/batch/presenter.ts +97 -0
  265. package/src/ultraplan/batch/storage.ts +420 -0
  266. package/src/ultraplan/batch/supervisor.ts +317 -0
  267. package/src/ultraplan/batch/worker.ts +26 -0
  268. package/src/ultraplan/batch/worktree.ts +110 -0
  269. package/src/ultraplan/contracts.ts +1593 -0
  270. package/src/ultraplan/default-agents/authoring/discoverer.md +12 -0
  271. package/src/ultraplan/default-agents/authoring/intake.md +12 -0
  272. package/src/ultraplan/default-agents/authoring/planner.md +12 -0
  273. package/src/ultraplan/default-agents/authoring/researcher.md +12 -0
  274. package/src/ultraplan/default-agents/authoring/scope-checker.md +12 -0
  275. package/src/ultraplan/default-agents/authoring/scout.md +12 -0
  276. package/src/ultraplan/default-agents/authoring/structure-checker.md +12 -0
  277. package/src/ultraplan/default-agents/authoring/tdd-checker.md +12 -0
  278. package/src/ultraplan/default-agents/backend-domain-reviewer.md +10 -0
  279. package/src/ultraplan/default-agents/backend-executor.md +10 -0
  280. package/src/ultraplan/default-agents/backend-stack-reviewer.md +10 -0
  281. package/src/ultraplan/default-agents/backend-tester.md +10 -0
  282. package/src/ultraplan/default-agents/frontend-domain-reviewer.md +10 -0
  283. package/src/ultraplan/default-agents/frontend-executor.md +10 -0
  284. package/src/ultraplan/default-agents/frontend-stack-reviewer.md +10 -0
  285. package/src/ultraplan/default-agents/frontend-tester.md +10 -0
  286. package/src/ultraplan/default-agents/infrastructure-domain-reviewer.md +10 -0
  287. package/src/ultraplan/default-agents/infrastructure-executor.md +10 -0
  288. package/src/ultraplan/default-agents/infrastructure-stack-reviewer.md +10 -0
  289. package/src/ultraplan/default-agents/infrastructure-tester.md +10 -0
  290. package/src/ultraplan/execution/contract.ts +71 -0
  291. package/src/ultraplan/execution/policy.ts +217 -0
  292. package/src/ultraplan/execution/runtime-tools.ts +107 -0
  293. package/src/ultraplan/execution/session-runner.ts +281 -0
  294. package/src/ultraplan/next-router.ts +85 -0
  295. package/src/ultraplan/presenter.ts +359 -0
  296. package/src/ultraplan/project-paths.ts +342 -0
  297. package/src/ultraplan/runtime/active-execution.ts +72 -0
  298. package/src/ultraplan/runtime/apply-mutation.ts +416 -0
  299. package/src/ultraplan/runtime/blockers.ts +243 -0
  300. package/src/ultraplan/runtime/hook-bridge.ts +486 -0
  301. package/src/ultraplan/runtime/launch-context.ts +207 -0
  302. package/src/ultraplan/runtime/migration.ts +524 -0
  303. package/src/ultraplan/runtime/normalize.ts +281 -0
  304. package/src/ultraplan/runtime/proof.ts +260 -0
  305. package/src/ultraplan/runtime/reducer.ts +416 -0
  306. package/src/ultraplan/runtime/repair.ts +251 -0
  307. package/src/ultraplan/runtime/tracker-storage.ts +368 -0
  308. package/src/ultraplan/session-selection.ts +291 -0
  309. package/src/ultraplan/storage.ts +374 -0
  310. package/src/utils/editor.ts +38 -0
  311. package/src/utils/executable.ts +80 -0
  312. package/src/utils/paths.ts +1 -20
  313. package/src/utils/shell.ts +31 -0
  314. package/src/visual/companion.ts +2 -1
  315. package/src/visual/scripts/frame-template.html +60 -0
  316. package/src/visual/scripts/index.js +59 -13
  317. package/src/visual/scripts/package.json +3 -0
  318. package/src/visual/start-server.ts +2 -1
  319. package/src/workspace/git-scope.ts +64 -0
  320. package/src/workspace/locks.ts +23 -0
  321. package/src/workspace/package-manager.ts +117 -0
  322. package/src/workspace/path-mapping.ts +75 -0
  323. package/src/workspace/project-slug.ts +92 -0
  324. package/src/workspace/repo-root.ts +137 -0
  325. package/src/workspace/selector.ts +115 -0
  326. package/src/workspace/state-paths.ts +118 -0
  327. package/src/workspace/targets.ts +313 -0
  328. package/src/fix-pr/scripts/diff-comments.sh +0 -33
  329. package/src/fix-pr/scripts/fetch-pr-comments.sh +0 -25
  330. package/src/fix-pr/scripts/trigger-review.sh +0 -36
  331. package/src/fix-pr/scripts/wait-and-check.sh +0 -37
  332. package/src/qa/scripts/detect-app-type.sh +0 -68
  333. package/src/qa/scripts/discover-routes.sh +0 -143
  334. package/src/qa/scripts/run-e2e-tests.sh +0 -131
  335. package/src/qa/scripts/start-dev-server.sh +0 -46
  336. package/src/qa/scripts/stop-dev-server.sh +0 -36
  337. package/src/review/prompts/fix-output-schema.md +0 -18
  338. package/src/review/prompts/review-output-schema.md +0 -38
  339. package/src/review/template.ts +0 -15
  340. /package/src/{review → ai}/prompts/invalid-output-retry.md +0 -0
@@ -0,0 +1,765 @@
1
+ // src/context-mode/metrics-store.ts
2
+ //
3
+ // Sidecar SQLite store for L1 measurement. Lives alongside `events.db` under
4
+ // `<projectStateDir>/sessions/metrics.db`. Mirrors the `event-store.ts`
5
+ // conventions (DELETE journal mode, WAL sidecar cleanup, idempotent migration)
6
+ // but keeps an independent failure mode so a metrics issue cannot regress
7
+ // event tracking.
8
+ //
9
+ // Hot-path contract (see design spec §3 + plan preamble):
10
+ // - `record(row)` is sync-looking; it appends to an in-memory queue and
11
+ // arms a `queueMicrotask` flush. Tests await `flushPendingForTest()`
12
+ // before reading durable state.
13
+ // - Bursts that arrive in the same microtask coalesce into one transaction.
14
+ // - Failures are swallowed; the in-memory failure counter combined with the
15
+ // persisted column lets `/supi:doctor` surface degraded sessions.
16
+
17
+ import { constants, Database } from "bun:sqlite";
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { isDebugEnabled } from "../debug/logger.js";
21
+
22
+ export const SCHEMA_VERSION = 3;
23
+ export const MAX_ROWS_PER_SESSION = 5000;
24
+ export const RETENTION_DAYS = 7;
25
+
26
+ export type LayerKey = "L1" | "L2" | "L3" | "L4" | "L5" | "L6" | "L7";
27
+
28
+ export type ProcessorKey =
29
+ | "bash"
30
+ | "read"
31
+ | "search"
32
+ | "find"
33
+ | "passthrough"
34
+ | "omp-minimizer"
35
+ | "git"
36
+ | "test"
37
+ | "lint"
38
+ | "build"
39
+ | "k8s"
40
+ | "docker"
41
+ | "log"
42
+ | "json"
43
+ | "dedup"
44
+ | "lazy-tools"
45
+ | "startup-optimizer"
46
+ | "cache-store"
47
+ | "cache-spill"
48
+ | "cache-open"
49
+ | "cache-prune"
50
+ | "cache-clear"
51
+ | null;
52
+
53
+ /** A single metric row pending insertion or read from the metrics table. */
54
+ export interface MetricRow {
55
+ session_id: string;
56
+ ts: number;
57
+ layer: LayerKey;
58
+ tool: string;
59
+ processor: ProcessorKey;
60
+ before_bytes: number;
61
+ after_bytes: number;
62
+ cache_hit: 0 | 1;
63
+ unique_source_hash: string | null;
64
+ context_tokens: number | null;
65
+ context_window: number | null;
66
+ context_percent: number | null;
67
+ }
68
+
69
+ export interface SessionMetaMetrics {
70
+ session_id: string;
71
+ cwd: string;
72
+ started_at: number;
73
+ last_event_at: number;
74
+ row_count: number;
75
+ write_failures: number;
76
+ last_clear_at: number | null;
77
+ }
78
+
79
+ export interface ProjectMetaMetrics {
80
+ project_slug: string;
81
+ first_run_notice_shown_at: number | null;
82
+ last_prune_at: number | null;
83
+ last_clear_all_at: number | null;
84
+ }
85
+
86
+ export interface MetricsStoreOptions {
87
+ dbPath: string;
88
+ projectSlug: string;
89
+ }
90
+
91
+ const SCHEMA = `
92
+ CREATE TABLE IF NOT EXISTS metrics (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ session_id TEXT NOT NULL,
95
+ ts INTEGER NOT NULL,
96
+ layer TEXT NOT NULL,
97
+ tool TEXT NOT NULL,
98
+ processor TEXT,
99
+ before_bytes INTEGER NOT NULL,
100
+ after_bytes INTEGER NOT NULL,
101
+ cache_hit INTEGER NOT NULL DEFAULT 0,
102
+ unique_source_hash TEXT,
103
+ context_tokens INTEGER,
104
+ context_window INTEGER,
105
+ context_percent REAL
106
+ );
107
+
108
+ CREATE INDEX IF NOT EXISTS idx_metrics_session_ts ON metrics(session_id, ts);
109
+ CREATE INDEX IF NOT EXISTS idx_metrics_layer_tool ON metrics(layer, tool);
110
+
111
+ CREATE TABLE IF NOT EXISTS session_meta_metrics (
112
+ session_id TEXT PRIMARY KEY,
113
+ cwd TEXT NOT NULL,
114
+ started_at INTEGER NOT NULL,
115
+ last_event_at INTEGER NOT NULL,
116
+ row_count INTEGER NOT NULL DEFAULT 0,
117
+ write_failures INTEGER NOT NULL DEFAULT 0,
118
+ last_clear_at INTEGER
119
+ );
120
+
121
+ CREATE TABLE IF NOT EXISTS project_meta_metrics (
122
+ project_slug TEXT PRIMARY KEY,
123
+ first_run_notice_shown_at INTEGER,
124
+ last_prune_at INTEGER,
125
+ last_clear_all_at INTEGER
126
+ );
127
+ `;
128
+
129
+ function appendTraceLine(filePath: string, entry: Record<string, unknown>): void {
130
+ try {
131
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
132
+ fs.appendFileSync(
133
+ filePath,
134
+ JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n",
135
+ );
136
+ } catch {
137
+ // Trace logging must never block the primary flow.
138
+ }
139
+ }
140
+
141
+ export class MetricsStore {
142
+ readonly #dbPath: string;
143
+ readonly #projectSlug: string;
144
+ #db: Database;
145
+ #closed = false;
146
+
147
+ // Microtask-batched write queue + a per-burst flush promise so tests can await it.
148
+ #queue: MetricRow[] = [];
149
+ #flushScheduled = false;
150
+ #flushPromise: Promise<void> | null = null;
151
+ #flushResolve: (() => void) | null = null;
152
+
153
+ // Per-session in-memory write failures, combined with persisted column on read.
154
+ #inMemoryFailures = new Map<string, number>();
155
+
156
+ // Per-instance flush counter — used by tests to confirm batching works.
157
+ #flushCount = 0;
158
+
159
+ // Trace path is computed once on init; null when SUPI_DEBUG is unset.
160
+ #tracePath: string | null = null;
161
+
162
+ // Prepared statements (lazy, initialized on first use).
163
+ #insertStmt: ReturnType<Database["prepare"]> | null = null;
164
+ #upsertSessionStmt: ReturnType<Database["prepare"]> | null = null;
165
+ #incRowCountStmt: ReturnType<Database["prepare"]> | null = null;
166
+ #evictOldestStmt: ReturnType<Database["prepare"]> | null = null;
167
+ #updateRowCountStmt: ReturnType<Database["prepare"]> | null = null;
168
+ #flushTransaction: ((rows: MetricRow[]) => void) | null = null;
169
+
170
+ constructor(opts: MetricsStoreOptions) {
171
+ this.#dbPath = opts.dbPath;
172
+ this.#projectSlug = opts.projectSlug;
173
+ this.#db = new Database(opts.dbPath);
174
+ }
175
+
176
+ /** Absolute path to the on-disk SQLite file. */
177
+ get dbPath(): string {
178
+ return this.#dbPath;
179
+ }
180
+
181
+ /** Project slug supplied at construction (primary key into `project_meta_metrics`). */
182
+ get projectSlug(): string {
183
+ return this.#projectSlug;
184
+ }
185
+
186
+ /** Number of times the microtask flush function has run. Test-only. */
187
+ get flushCountForTest(): number {
188
+ return this.#flushCount;
189
+ }
190
+
191
+ /** Trace file path (null when `SUPI_DEBUG` is unset). Test-only. */
192
+ get tracePathForTest(): string | null {
193
+ return this.#tracePath;
194
+ }
195
+
196
+ init(): void {
197
+ try {
198
+ this.#ensureDeleteJournalMode();
199
+ // CREATE TABLE IF NOT EXISTS guards every statement in SCHEMA, so it is
200
+ // safe to run before #migrate(). Running the schema first means v1→vN
201
+ // data fixups can assume their target tables exist on every code path.
202
+ this.#db.exec(SCHEMA);
203
+ this.#migrate();
204
+ this.#prepareStatements();
205
+
206
+ if (isDebugEnabled()) {
207
+ this.#tracePath = path.join(path.dirname(this.#dbPath), "metrics-trace.jsonl");
208
+ }
209
+ } catch (error) {
210
+ this.close();
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ // ── Journal mode + WAL handling (mirrors event-store.ts) ────────────
216
+
217
+ #ensureDeleteJournalMode(): void {
218
+ const journalMode = this.#getJournalMode();
219
+ if (journalMode === "delete") return;
220
+
221
+ if (journalMode === "wal") {
222
+ this.#cleanupWalSidecars();
223
+ }
224
+
225
+ try {
226
+ this.#db.exec("PRAGMA journal_mode = DELETE;");
227
+ } catch {
228
+ // Older WAL-backed databases can stay on WAL for this process.
229
+ // close() still checkpoints them so teardown and the next reopen succeed.
230
+ }
231
+ }
232
+
233
+ #getJournalMode(): string {
234
+ const { journal_mode } = this.#db.prepare("PRAGMA journal_mode").get() as {
235
+ journal_mode: string;
236
+ };
237
+ return journal_mode.toLowerCase();
238
+ }
239
+
240
+ #cleanupWalSidecars(): void {
241
+ try {
242
+ this.#db.fileControl(constants.SQLITE_FCNTL_PERSIST_WAL, 0);
243
+ this.#db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
244
+ } catch {
245
+ // Best effort only: close() still releases the handle in finally.
246
+ }
247
+ }
248
+
249
+ // ── Schema migration ─────────────────────────────────────────────────
250
+
251
+ #migrate(): void {
252
+ const { user_version } = this.#db.prepare("PRAGMA user_version").get() as {
253
+ user_version: number;
254
+ };
255
+
256
+ if (user_version === SCHEMA_VERSION) return;
257
+ if (user_version > SCHEMA_VERSION) {
258
+ throw new Error(
259
+ `metrics-store: unknown schema version ${user_version} (max supported: ${SCHEMA_VERSION})`,
260
+ );
261
+ }
262
+
263
+ // v0 \u2192 v1: schema-only bump. CREATE TABLE IF NOT EXISTS already ran in
264
+ // init(); no data fixup is required.
265
+
266
+ // v1 \u2192 v2: OMP 14.5.12 renamed the canonical "grep" tool key to "search".
267
+ // Rows persisted under the old name still report `tool='grep'` and
268
+ // `processor='grep'` \u2014 values the type system no longer admits and which
269
+ // /supi:doctor / per-processor breakdowns would silently bucket under a
270
+ // typed-impossible key. Source hashes baked the legacy `grep:` prefix into
271
+ // SHA256, so they cannot collide with post-rename `search:`-prefixed
272
+ // hashes; the privacy contract forbids reconstructing the original path
273
+ // or pattern, so re-hashing is impossible. NULL is the right substitute:
274
+ // `getUniqueSourceShare` already excludes NULL hashes from numerator and
275
+ // denominator, and a NULL hash never collides with new dedup state.
276
+ if (user_version < 2) {
277
+ const tx = this.#db.transaction(() => {
278
+ this.#db.exec(
279
+ `UPDATE metrics SET unique_source_hash = NULL WHERE tool = 'grep'`,
280
+ );
281
+ this.#db.exec(
282
+ `UPDATE metrics SET tool = 'search' WHERE tool = 'grep'`,
283
+ );
284
+ this.#db.exec(
285
+ `UPDATE metrics SET processor = 'search' WHERE processor = 'grep'`,
286
+ );
287
+ });
288
+ tx();
289
+ }
290
+
291
+ // v2 → v3: OMP 14.6.0 renamed `search`/`find` tool params from
292
+ // `path: string` / `pattern: string` to `paths: string[]`. Source hashes
293
+ // computed under the old salts (`search:<single-path>:<slug>`,
294
+ // `find:<pattern>:<slug>`) cannot collide with the new salts
295
+ // (`search:<joined-paths>:<pattern>:<slug>`, `find:<joined-paths>:<slug>`)
296
+ // because the input strings differ; but the privacy contract forbids
297
+ // re-hashing, so NULL is the correct substitute. `getUniqueSourceShare`
298
+ // already excludes NULLs from numerator/denominator. Newly-recorded rows
299
+ // from this point on use the post-rename salt.
300
+ if (user_version < 3) {
301
+ this.#db.exec(
302
+ `UPDATE metrics SET unique_source_hash = NULL WHERE tool IN ('search', 'find')`,
303
+ );
304
+ }
305
+
306
+ this.#db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
307
+ }
308
+
309
+ // ── Prepared statements ─────────────────────────────────────────────
310
+
311
+ #prepareStatements(): void {
312
+ this.#insertStmt = this.#db.prepare(
313
+ `INSERT INTO metrics
314
+ (session_id, ts, layer, tool, processor, before_bytes, after_bytes,
315
+ cache_hit, unique_source_hash, context_tokens, context_window, context_percent)
316
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
317
+ );
318
+
319
+ this.#upsertSessionStmt = this.#db.prepare(
320
+ `INSERT INTO session_meta_metrics
321
+ (session_id, cwd, started_at, last_event_at, row_count, write_failures, last_clear_at)
322
+ VALUES (?, ?, ?, ?, 0, 0, NULL)
323
+ ON CONFLICT(session_id) DO UPDATE SET
324
+ last_event_at = excluded.last_event_at`,
325
+ );
326
+
327
+ this.#incRowCountStmt = this.#db.prepare(
328
+ `UPDATE session_meta_metrics
329
+ SET row_count = row_count + ?, last_event_at = ?
330
+ WHERE session_id = ?`,
331
+ );
332
+
333
+ this.#updateRowCountStmt = this.#db.prepare(
334
+ `UPDATE session_meta_metrics
335
+ SET row_count = ?
336
+ WHERE session_id = ?`,
337
+ );
338
+
339
+ this.#evictOldestStmt = this.#db.prepare(
340
+ `DELETE FROM metrics
341
+ WHERE id IN (
342
+ SELECT id FROM metrics
343
+ WHERE session_id = ?
344
+ ORDER BY id ASC
345
+ LIMIT ?
346
+ )`,
347
+ );
348
+
349
+ this.#flushTransaction = this.#db.transaction((rows: MetricRow[]) => {
350
+ const perSession = new Map<string, number>();
351
+ const lastTsBySession = new Map<string, number>();
352
+
353
+ for (const row of rows) {
354
+ this.#insertStmt!.run(
355
+ row.session_id,
356
+ row.ts,
357
+ row.layer,
358
+ row.tool,
359
+ row.processor,
360
+ row.before_bytes,
361
+ row.after_bytes,
362
+ row.cache_hit,
363
+ row.unique_source_hash,
364
+ row.context_tokens,
365
+ row.context_window,
366
+ row.context_percent,
367
+ );
368
+
369
+ perSession.set(row.session_id, (perSession.get(row.session_id) ?? 0) + 1);
370
+ const prev = lastTsBySession.get(row.session_id);
371
+ if (prev === undefined || row.ts > prev) {
372
+ lastTsBySession.set(row.session_id, row.ts);
373
+ }
374
+ }
375
+
376
+ // Flush in-memory failures opportunistically: if persistence is healthy,
377
+ // promote any pending in-memory counts into the column and clear the
378
+ // in-memory entries so the doctor surfaces a stable number.
379
+ const failures = [...this.#inMemoryFailures.entries()];
380
+ for (const [session, count] of failures) {
381
+ this.#db.prepare(
382
+ `UPDATE session_meta_metrics
383
+ SET write_failures = write_failures + ?
384
+ WHERE session_id = ?`,
385
+ ).run(count, session);
386
+ }
387
+ this.#inMemoryFailures.clear();
388
+
389
+ // Bump row_count and last_event_at per session.
390
+ for (const [session, count] of perSession) {
391
+ const ts = lastTsBySession.get(session) ?? Date.now();
392
+ this.#incRowCountStmt!.run(count, ts, session);
393
+ }
394
+
395
+ // Eviction: per-session row_count cap. Read fresh count from DB to
396
+ // accommodate inserts that arrived in a separate transaction.
397
+ for (const [session] of perSession) {
398
+ const row = this.#db.prepare(
399
+ `SELECT row_count FROM session_meta_metrics WHERE session_id = ?`,
400
+ ).get(session) as { row_count: number } | undefined;
401
+ if (!row) continue;
402
+ const overflow = row.row_count - MAX_ROWS_PER_SESSION;
403
+ if (overflow > 0) {
404
+ this.#evictOldestStmt!.run(session, overflow);
405
+ this.#updateRowCountStmt!.run(MAX_ROWS_PER_SESSION, session);
406
+ }
407
+ }
408
+ });
409
+ }
410
+
411
+ // ── Session metadata ────────────────────────────────────────────────
412
+
413
+ upsertSession(opts: { session_id: string; cwd: string; ts?: number }): void {
414
+ const ts = opts.ts ?? Date.now();
415
+ this.#upsertSessionStmt!.run(opts.session_id, opts.cwd, ts, ts);
416
+ }
417
+
418
+ getSessionMeta(sessionId: string): SessionMetaMetrics | null {
419
+ const row = this.#db.prepare(
420
+ `SELECT session_id, cwd, started_at, last_event_at, row_count, write_failures, last_clear_at
421
+ FROM session_meta_metrics
422
+ WHERE session_id = ?`,
423
+ ).get(sessionId) as SessionMetaMetrics | undefined;
424
+ return row ?? null;
425
+ }
426
+
427
+ getProjectMeta(projectSlug: string): ProjectMetaMetrics | null {
428
+ const row = this.#db.prepare(
429
+ `SELECT project_slug, first_run_notice_shown_at, last_prune_at, last_clear_all_at
430
+ FROM project_meta_metrics
431
+ WHERE project_slug = ?`,
432
+ ).get(projectSlug) as ProjectMetaMetrics | undefined;
433
+ return row ?? null;
434
+ }
435
+
436
+ setFirstRunNoticeShown(projectSlug: string, ts?: number): void {
437
+ const value = ts ?? Date.now();
438
+ this.#db.prepare(
439
+ `INSERT INTO project_meta_metrics (project_slug, first_run_notice_shown_at)
440
+ VALUES (?, ?)
441
+ ON CONFLICT(project_slug) DO UPDATE SET
442
+ first_run_notice_shown_at = excluded.first_run_notice_shown_at`,
443
+ ).run(projectSlug, value);
444
+ }
445
+
446
+ // ── Hot-path write API ───────────────────────────────────────────────
447
+
448
+ record(row: MetricRow): void {
449
+ if (this.#closed) {
450
+ this.#bumpInMemoryFailure(row.session_id);
451
+ this.#trace("metrics_record_after_close", {
452
+ session_id: row.session_id,
453
+ tool: row.tool,
454
+ });
455
+ return;
456
+ }
457
+
458
+ this.#queue.push(row);
459
+ if (!this.#flushScheduled) {
460
+ this.#flushScheduled = true;
461
+ this.#flushPromise = new Promise<void>((resolve) => {
462
+ this.#flushResolve = resolve;
463
+ });
464
+ queueMicrotask(() => this.#flush());
465
+ }
466
+ }
467
+
468
+ /** Drain the pending microtask flush. Tests await this before reading state. */
469
+ async flushPendingForTest(): Promise<void> {
470
+ while (this.#flushScheduled) {
471
+ const promise = this.#flushPromise;
472
+ if (promise) await promise;
473
+ else break;
474
+ }
475
+ }
476
+
477
+ #flush(): void {
478
+ const rows = this.#queue;
479
+ this.#queue = [];
480
+ this.#flushScheduled = false;
481
+ this.#flushCount += 1;
482
+
483
+ const resolve = this.#flushResolve;
484
+ this.#flushResolve = null;
485
+ this.#flushPromise = null;
486
+
487
+ if (rows.length === 0) {
488
+ if (resolve) resolve();
489
+ return;
490
+ }
491
+
492
+ let attempts = 0;
493
+ while (true) {
494
+ try {
495
+ this.#flushTransaction!(rows);
496
+ for (const row of rows) {
497
+ this.#trace("metrics_record_flushed", {
498
+ tool: row.tool,
499
+ layer: row.layer,
500
+ before_bytes: row.before_bytes,
501
+ after_bytes: row.after_bytes,
502
+ });
503
+ }
504
+ break;
505
+ } catch (err) {
506
+ const code = (err as NodeJS.ErrnoException)?.code;
507
+ if (attempts === 0 && code === "SQLITE_BUSY") {
508
+ attempts += 1;
509
+ // Linear backoff (sleep ~5ms) before the single retry.
510
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
511
+ continue;
512
+ }
513
+
514
+ for (const row of rows) {
515
+ this.#bumpInMemoryFailure(row.session_id);
516
+ this.#trace("metrics_record_failed", {
517
+ tool: row.tool,
518
+ error: (err as Error)?.message ?? String(err),
519
+ });
520
+ }
521
+ break;
522
+ }
523
+ }
524
+
525
+ if (resolve) resolve();
526
+ }
527
+
528
+ #bumpInMemoryFailure(sessionId: string): void {
529
+ this.#inMemoryFailures.set(sessionId, (this.#inMemoryFailures.get(sessionId) ?? 0) + 1);
530
+ }
531
+
532
+ #trace(event: string, data: Record<string, unknown>): void {
533
+ if (!this.#tracePath) return;
534
+ appendTraceLine(this.#tracePath, { event, ...data });
535
+ }
536
+
537
+ // ── Read accessors ───────────────────────────────────────────────────
538
+
539
+ getSessionTotals(sessionId: string): {
540
+ beforeBytes: number;
541
+ afterBytes: number;
542
+ saved: number;
543
+ rowCount: number;
544
+ } {
545
+ const row = this.#db.prepare(
546
+ `SELECT
547
+ COALESCE(SUM(before_bytes), 0) AS beforeBytes,
548
+ COALESCE(SUM(after_bytes), 0) AS afterBytes,
549
+ COALESCE(SUM(before_bytes - after_bytes), 0) AS saved,
550
+ COUNT(*) AS rowCount
551
+ FROM metrics
552
+ WHERE session_id = ?`,
553
+ ).get(sessionId) as {
554
+ beforeBytes: number;
555
+ afterBytes: number;
556
+ saved: number;
557
+ rowCount: number;
558
+ };
559
+ return row;
560
+ }
561
+
562
+ getTopProcessors(
563
+ sessionId: string,
564
+ limit: number,
565
+ ): Array<{ processor: string; saved: number; calls: number }> {
566
+ return this.#db.prepare(
567
+ `SELECT
568
+ COALESCE(processor, tool) AS processor,
569
+ COALESCE(SUM(before_bytes - after_bytes), 0) AS saved,
570
+ COUNT(*) AS calls
571
+ FROM metrics
572
+ WHERE session_id = ?
573
+ GROUP BY COALESCE(processor, tool)
574
+ ORDER BY saved DESC
575
+ LIMIT ?`,
576
+ ).all(sessionId, limit) as Array<{ processor: string; saved: number; calls: number }>;
577
+ }
578
+
579
+ getPerLayer(
580
+ sessionId: string,
581
+ ): Array<{ layer: string; saved: number; rows: number }> {
582
+ return this.#db.prepare(
583
+ `SELECT
584
+ layer,
585
+ COALESCE(SUM(before_bytes - after_bytes), 0) AS saved,
586
+ COUNT(*) AS rows
587
+ FROM metrics
588
+ WHERE session_id = ?
589
+ GROUP BY layer
590
+ ORDER BY layer ASC`,
591
+ ).all(sessionId) as Array<{ layer: string; saved: number; rows: number }>;
592
+ }
593
+
594
+ /**
595
+ * Unique-source share = COUNT(DISTINCT unique_source_hash) /
596
+ * COUNT(unique_source_hash).
597
+ * Null hashes are excluded from both numerator and denominator so that
598
+ * untracked sources do not skew the rot signal. Returns 0 when no rows
599
+ * have a non-null hash.
600
+ */
601
+ getUniqueSourceShare(sessionId: string): number {
602
+ const row = this.#db.prepare(
603
+ `SELECT
604
+ COUNT(DISTINCT unique_source_hash) AS distinctCount,
605
+ COUNT(unique_source_hash) AS totalCount
606
+ FROM metrics
607
+ WHERE session_id = ?`,
608
+ ).get(sessionId) as { distinctCount: number; totalCount: number };
609
+
610
+ if (row.totalCount === 0) return 0;
611
+ return row.distinctCount / row.totalCount;
612
+ }
613
+
614
+ /** Combined in-memory + persisted write-failure count for the session. */
615
+ getSessionWriteFailures(sessionId: string): number {
616
+ const inMemory = this.#inMemoryFailures.get(sessionId) ?? 0;
617
+ let persisted = 0;
618
+ try {
619
+ persisted = this.getSessionMeta(sessionId)?.write_failures ?? 0;
620
+ } catch {
621
+ // DB may be closed; in-memory counter still surfaces the failure.
622
+ }
623
+ return persisted + inMemory;
624
+ }
625
+
626
+ // ── Maintenance ─────────────────────────────────────────────────────
627
+
628
+ pruneOldSessions(retentionDays = RETENTION_DAYS, now = Date.now()): number {
629
+ const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
630
+
631
+ const oldSessions = this.#db.prepare(
632
+ `SELECT session_id FROM session_meta_metrics WHERE started_at < ?`,
633
+ ).all(cutoff) as Array<{ session_id: string }>;
634
+
635
+ if (oldSessions.length > 0) {
636
+ const placeholders = oldSessions.map(() => "?").join(",");
637
+ const ids = oldSessions.map((r) => r.session_id);
638
+ this.#db.prepare(
639
+ `DELETE FROM metrics WHERE session_id IN (${placeholders})`,
640
+ ).run(...ids);
641
+ this.#db.prepare(
642
+ `DELETE FROM session_meta_metrics WHERE session_id IN (${placeholders})`,
643
+ ).run(...ids);
644
+ }
645
+
646
+ this.#db.prepare(
647
+ `INSERT INTO project_meta_metrics (project_slug, last_prune_at)
648
+ VALUES (?, ?)
649
+ ON CONFLICT(project_slug) DO UPDATE SET
650
+ last_prune_at = excluded.last_prune_at`,
651
+ ).run(this.#projectSlug, now);
652
+
653
+ return oldSessions.length;
654
+ }
655
+
656
+ // \u2500\u2500 Clearing (used by /supi:clear) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
657
+
658
+ /** Delete metrics rows for a single session, reset row_count, stamp last_clear_at. */
659
+ clearSession(sessionId: string, now = Date.now()): void {
660
+ const tx = this.#db.transaction(() => {
661
+ this.#db.prepare(`DELETE FROM metrics WHERE session_id = ?`).run(sessionId);
662
+ this.#db.prepare(
663
+ `UPDATE session_meta_metrics
664
+ SET row_count = 0, last_clear_at = ?
665
+ WHERE session_id = ?`,
666
+ ).run(now, sessionId);
667
+ });
668
+ tx();
669
+ this.#inMemoryFailures.delete(sessionId);
670
+ }
671
+
672
+ /** Returns one row per session in this project, ordered by started_at desc. */
673
+ listSessions(
674
+ _projectSlug: string,
675
+ ): Array<{ session_id: string; row_count: number; started_at: number; cwd: string }> {
676
+ return this.#db
677
+ .prepare(
678
+ `SELECT session_id, row_count, started_at, cwd
679
+ FROM session_meta_metrics
680
+ ORDER BY started_at DESC`,
681
+ )
682
+ .all() as Array<{
683
+ session_id: string;
684
+ row_count: number;
685
+ started_at: number;
686
+ cwd: string;
687
+ }>;
688
+ }
689
+
690
+ /** Delete all metrics rows in the project, reset every session's row_count,
691
+ * preserve session metadata (`started_at`, `cwd`), and stamp
692
+ * `project_meta_metrics.last_clear_all_at`.
693
+ *
694
+ * The `projectSlug` parameter is accepted for API symmetry with the rest of
695
+ * the project-meta accessors, but the store always operates on its bound
696
+ * slug. Callers do not need to pass anything special: the store's own
697
+ * projectSlug is the source of truth.
698
+ */
699
+ clearProject(_projectSlug?: string, now = Date.now()): void {
700
+ const slug = this.#projectSlug;
701
+ const tx = this.#db.transaction(() => {
702
+ this.#db.exec(`DELETE FROM metrics`);
703
+ this.#db.exec(`UPDATE session_meta_metrics SET row_count = 0`);
704
+ this.#db.prepare(
705
+ `INSERT INTO project_meta_metrics (project_slug, last_clear_all_at)
706
+ VALUES (?, ?)
707
+ ON CONFLICT(project_slug) DO UPDATE SET
708
+ last_clear_all_at = excluded.last_clear_all_at`,
709
+ ).run(slug, now);
710
+ });
711
+ tx();
712
+ this.#inMemoryFailures.clear();
713
+ }
714
+
715
+ close(): void {
716
+ if (this.#closed) return;
717
+
718
+ // Drain any queued writes synchronously before closing the DB. `record()`
719
+ // schedules flushes via `queueMicrotask`, so a `record()` followed by an
720
+ // immediate `close()` (e.g. session_shutdown after the last tool_result)
721
+ // would otherwise lose pending rows when the microtask later runs against
722
+ // a closed handle. `#flush()` already swallows write errors and bumps the
723
+ // in-memory failure counter, so this drain cannot throw.
724
+ if (this.#queue.length > 0) {
725
+ this.#flush();
726
+ }
727
+
728
+ this.#closed = true;
729
+
730
+ try {
731
+ try {
732
+ if (this.#getJournalMode() === "wal") {
733
+ this.#cleanupWalSidecars();
734
+ }
735
+ } catch {
736
+ // The DB path may already be gone during teardown.
737
+ }
738
+ } finally {
739
+ this.#db.close();
740
+ }
741
+ }
742
+ }
743
+
744
+ // ── Module-level singleton + test seam ─────────────────────────────────
745
+
746
+ let _metricsStoreRef: MetricsStore | null = null;
747
+
748
+ /** Return the active metrics store, or `null` when context-mode is disabled. */
749
+ export function getMetricsStore(): MetricsStore | null {
750
+ return _metricsStoreRef;
751
+ }
752
+
753
+ /**
754
+ * Test-only setter. Production code wires the ref through the same path from
755
+ * inside `registerContextModeHooks`. The double-underscore prefix marks this
756
+ * as a private API; do **not** call it from product code.
757
+ */
758
+ export function __setMetricsStoreForTest(store: MetricsStore | null): void {
759
+ _metricsStoreRef = store;
760
+ }
761
+
762
+ /** Reset module-level state. Intended for `_resetCache()` in tests. */
763
+ export function _resetMetricsStoreCache(): void {
764
+ _metricsStoreRef = null;
765
+ }