supipowers 1.5.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +129 -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 +264 -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,738 @@
1
+ // src/context-mode/cache-store.ts
2
+ //
3
+ // Durable L3 cache metadata and payload store. Metadata lives in cache.db;
4
+ // compressed payload bytes live under cache-payloads/ so large blobs stay out
5
+ // of SQLite hot paths.
6
+
7
+ import { constants, Database } from "bun:sqlite";
8
+ import { createHash } from "node:crypto";
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { brotliCompressSync, brotliDecompressSync } from "node:zlib";
12
+ import { parseCacheHandle, renderCacheHandle } from "./cache-handle.js";
13
+ import { buildCachePreview } from "./cache-preview.js";
14
+ import type { MetricRow } from "./metrics-store.js";
15
+
16
+ export const SCHEMA_VERSION = 1;
17
+ type CacheMetricRecorder = Pick<{ record(row: MetricRow): void }, "record">;
18
+
19
+ export interface CacheStoreOptions {
20
+ dbPath: string;
21
+ payloadRoot: string;
22
+ projectSlug: string;
23
+ metricsStore?: CacheMetricRecorder | null;
24
+ metricsSessionId?: string;
25
+ }
26
+
27
+ export interface CacheStats {
28
+ entryCount: number;
29
+ refCount: number;
30
+ uncompressedBytes: number;
31
+ compressedBytes: number;
32
+ payloadBytes: number;
33
+ }
34
+
35
+ export interface CacheClearResult {
36
+ deletedRefs: number;
37
+ deletedEntries: number;
38
+ deletedPayloadBytes: number;
39
+ retainedPayloadBytes: number;
40
+ }
41
+
42
+ export interface CacheSessionStats extends CacheStats {
43
+ reclaimablePayloadBytes: number;
44
+ retainedPayloadBytes: number;
45
+ }
46
+
47
+
48
+ export interface PutCacheTextInput {
49
+ sessionId: string;
50
+ text: string;
51
+ sourceTool?: string | null;
52
+ sourceHash?: string | null;
53
+ now?: number;
54
+ previewBytes?: number;
55
+ recordMetric?: boolean;
56
+ }
57
+
58
+ export interface CacheEntryMeta {
59
+ handle: string;
60
+ sha256: string;
61
+ sizeBytes: number;
62
+ compressedBytes: number;
63
+ preview: string;
64
+ payloadRelpath: string;
65
+ createdAt: number;
66
+ lastAccessedAt: number;
67
+ openCount: number;
68
+ }
69
+
70
+ export interface PutCacheTextResult extends CacheEntryMeta {}
71
+
72
+ export type OpenCacheTextResult =
73
+ | { ok: true; handle: string; text: string; meta: CacheEntryMeta }
74
+ | { ok: false; reason: "invalid_handle" | "not_found" | "missing_payload" | "corrupt_payload"; handle: string | null; message: string };
75
+
76
+ export interface RequestCachePutInput {
77
+ tool: string;
78
+ argsHash: string;
79
+ cwd: string;
80
+ fingerprint: string;
81
+ handle: string;
82
+ ttlMs: number;
83
+ now?: number;
84
+ }
85
+
86
+ export interface RequestCacheLookupInput {
87
+ tool: string;
88
+ argsHash: string;
89
+ cwd: string;
90
+ fingerprint: string;
91
+ now?: number;
92
+ }
93
+
94
+ export type RequestCacheLookupResult =
95
+ | { hit: true; handle: string; expiresAt: number }
96
+ | { hit: false; reason: "miss" | "expired" };
97
+ const SCHEMA = `
98
+ CREATE TABLE IF NOT EXISTS cache_entries (
99
+ handle TEXT PRIMARY KEY,
100
+ sha256 TEXT NOT NULL UNIQUE,
101
+ size_bytes INTEGER NOT NULL,
102
+ compressed_bytes INTEGER NOT NULL,
103
+ preview TEXT NOT NULL,
104
+ payload_relpath TEXT NOT NULL,
105
+ created_at INTEGER NOT NULL,
106
+ last_accessed_at INTEGER NOT NULL,
107
+ open_count INTEGER NOT NULL DEFAULT 0
108
+ );
109
+
110
+ CREATE TABLE IF NOT EXISTS cache_refs (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ handle TEXT NOT NULL REFERENCES cache_entries(handle) ON DELETE CASCADE,
113
+ session_id TEXT NOT NULL,
114
+ source_tool TEXT,
115
+ source_hash TEXT,
116
+ created_at INTEGER NOT NULL,
117
+ UNIQUE(handle, session_id, source_tool, source_hash)
118
+ );
119
+
120
+ CREATE INDEX IF NOT EXISTS idx_cache_refs_session ON cache_refs(session_id);
121
+ CREATE INDEX IF NOT EXISTS idx_cache_refs_handle ON cache_refs(handle);
122
+ CREATE INDEX IF NOT EXISTS idx_cache_entries_last_accessed ON cache_entries(last_accessed_at);
123
+
124
+ CREATE TABLE IF NOT EXISTS request_cache (
125
+ tool TEXT NOT NULL,
126
+ args_hash TEXT NOT NULL,
127
+ project_slug TEXT NOT NULL,
128
+ cwd TEXT NOT NULL,
129
+ fingerprint TEXT NOT NULL,
130
+ handle TEXT NOT NULL REFERENCES cache_entries(handle) ON DELETE CASCADE,
131
+ created_at INTEGER NOT NULL,
132
+ expires_at INTEGER NOT NULL,
133
+ PRIMARY KEY (tool, args_hash, project_slug, cwd, fingerprint)
134
+ );
135
+
136
+ CREATE INDEX IF NOT EXISTS idx_request_cache_expiry ON request_cache(expires_at);
137
+ `;
138
+
139
+ export class CacheStore {
140
+ readonly #dbPath: string;
141
+ readonly #payloadRoot: string;
142
+ readonly #projectSlug: string;
143
+ #db: Database;
144
+ #closed = false;
145
+ #metricsStore: CacheMetricRecorder | null;
146
+ #metricsSessionId: string;
147
+
148
+ constructor(opts: CacheStoreOptions) {
149
+ this.#dbPath = opts.dbPath;
150
+ this.#payloadRoot = opts.payloadRoot;
151
+ this.#projectSlug = opts.projectSlug;
152
+ this.#metricsStore = opts.metricsStore ?? null;
153
+ this.#metricsSessionId = opts.metricsSessionId ?? "(system)";
154
+
155
+ fs.mkdirSync(path.dirname(opts.dbPath), { recursive: true });
156
+ this.#db = new Database(opts.dbPath);
157
+ }
158
+
159
+ get dbPath(): string {
160
+ return this.#dbPath;
161
+ }
162
+
163
+ get payloadRoot(): string {
164
+ return this.#payloadRoot;
165
+ }
166
+
167
+ get projectSlug(): string {
168
+ return this.#projectSlug;
169
+ }
170
+
171
+ init(): void {
172
+ fs.mkdirSync(this.#payloadRoot, { recursive: true });
173
+ this.#ensureDeleteJournalMode();
174
+ this.#db.exec("PRAGMA foreign_keys = ON;");
175
+ this.#db.exec(SCHEMA);
176
+ this.#migrate();
177
+ }
178
+
179
+ setMetricsRecorder(metricsStore: CacheMetricRecorder | null, metricsSessionId = "(system)"): void {
180
+ this.#metricsStore = metricsStore;
181
+ this.#metricsSessionId = metricsSessionId;
182
+ }
183
+
184
+ putText(input: PutCacheTextInput): PutCacheTextResult {
185
+ this.#assertOpen();
186
+
187
+ const now = input.now ?? Date.now();
188
+ const bytes = Buffer.from(input.text, "utf8");
189
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
190
+ const handle = renderCacheHandle(sha256);
191
+ const preview = buildCachePreview(input.text, input.previewBytes);
192
+ const payloadRelpath = path.join(sha256.slice(0, 2), `${sha256}.br`);
193
+ const payloadPath = path.join(this.#payloadRoot, payloadRelpath);
194
+ const compressed = brotliCompressSync(bytes);
195
+
196
+ writePayloadAtomically(payloadPath, compressed);
197
+
198
+ const tx = this.#db.transaction(() => {
199
+ this.#db.prepare(
200
+ `INSERT INTO cache_entries
201
+ (handle, sha256, size_bytes, compressed_bytes, preview, payload_relpath, created_at, last_accessed_at, open_count)
202
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
203
+ ON CONFLICT(handle) DO UPDATE SET
204
+ size_bytes = excluded.size_bytes,
205
+ compressed_bytes = excluded.compressed_bytes,
206
+ preview = excluded.preview,
207
+ payload_relpath = excluded.payload_relpath,
208
+ last_accessed_at = excluded.last_accessed_at`,
209
+ ).run(
210
+ handle,
211
+ sha256,
212
+ bytes.length,
213
+ compressed.length,
214
+ preview,
215
+ payloadRelpath,
216
+ now,
217
+ now,
218
+ );
219
+
220
+ this.#db.prepare(
221
+ `INSERT INTO cache_refs (handle, session_id, source_tool, source_hash, created_at)
222
+ VALUES (?, ?, ?, ?, ?)
223
+ ON CONFLICT(handle, session_id, source_tool, source_hash) DO UPDATE SET
224
+ created_at = excluded.created_at`,
225
+ ).run(
226
+ handle,
227
+ input.sessionId,
228
+ input.sourceTool ?? "",
229
+ input.sourceHash ?? "",
230
+ now,
231
+ );
232
+ });
233
+ tx();
234
+
235
+ const meta = this.getEntryMeta(handle);
236
+ if (!meta) {
237
+ throw new Error("cache-store: failed to read metadata after cache write");
238
+ }
239
+ if (input.recordMetric !== false) {
240
+ this.#recordCacheMetric({
241
+ sessionId: input.sessionId,
242
+ processor: "cache-store",
243
+ beforeBytes: bytes.length,
244
+ afterBytes: 0,
245
+ cacheHit: 0,
246
+ });
247
+ }
248
+
249
+ return meta;
250
+ }
251
+
252
+ openText(handle: string): OpenCacheTextResult {
253
+ this.#assertOpen();
254
+
255
+ const parsed = parseCacheHandle(handle);
256
+ if (!parsed.ok) {
257
+ return {
258
+ ok: false,
259
+ reason: "invalid_handle",
260
+ handle: null,
261
+ message: `Cannot open cached content: ${parsed.message}.`,
262
+ };
263
+ }
264
+
265
+ const metaBefore = this.#readEntryMeta(parsed.handle);
266
+ if (!metaBefore) {
267
+ return {
268
+ ok: false,
269
+ reason: "not_found",
270
+ handle: parsed.handle,
271
+ message: `Cannot open cached content: handle was not found: ${parsed.handle}.`,
272
+ };
273
+ }
274
+
275
+ const payloadPath = path.join(this.#payloadRoot, metaBefore.payloadRelpath);
276
+ if (!fs.existsSync(payloadPath)) {
277
+ return {
278
+ ok: false,
279
+ reason: "missing_payload",
280
+ handle: parsed.handle,
281
+ message: `Cannot open cached content: payload file is missing for ${parsed.handle}.`,
282
+ };
283
+ }
284
+
285
+ let text: string;
286
+ try {
287
+ text = brotliDecompressSync(fs.readFileSync(payloadPath)).toString("utf8");
288
+ } catch {
289
+ return {
290
+ ok: false,
291
+ reason: "corrupt_payload",
292
+ handle: parsed.handle,
293
+ message: `Cannot open cached content: payload is corrupt for ${parsed.handle}.`,
294
+ };
295
+ }
296
+
297
+ const now = Date.now();
298
+ this.#db.prepare(
299
+ `UPDATE cache_entries
300
+ SET last_accessed_at = ?, open_count = open_count + 1
301
+ WHERE handle = ?`,
302
+ ).run(now, parsed.handle);
303
+
304
+ const meta = this.#readEntryMeta(parsed.handle) ?? metaBefore;
305
+ return { ok: true, handle: parsed.handle, text, meta };
306
+ }
307
+
308
+ putRequestCache(input: RequestCachePutInput): void {
309
+ this.#assertOpen();
310
+ const now = input.now ?? Date.now();
311
+ this.#db.prepare(
312
+ `INSERT INTO request_cache
313
+ (tool, args_hash, project_slug, cwd, fingerprint, handle, created_at, expires_at)
314
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
315
+ ON CONFLICT(tool, args_hash, project_slug, cwd, fingerprint) DO UPDATE SET
316
+ handle = excluded.handle,
317
+ created_at = excluded.created_at,
318
+ expires_at = excluded.expires_at`,
319
+ ).run(
320
+ input.tool,
321
+ input.argsHash,
322
+ this.#projectSlug,
323
+ input.cwd,
324
+ input.fingerprint,
325
+ input.handle,
326
+ now,
327
+ now + Math.max(0, Math.floor(input.ttlMs)),
328
+ );
329
+ }
330
+
331
+ getRequestCache(input: RequestCacheLookupInput): RequestCacheLookupResult {
332
+ this.#assertOpen();
333
+ const now = input.now ?? Date.now();
334
+ const row = this.#db.prepare(
335
+ `SELECT handle, expires_at AS expiresAt
336
+ FROM request_cache
337
+ WHERE tool = ? AND args_hash = ? AND project_slug = ? AND cwd = ? AND fingerprint = ?`,
338
+ ).get(input.tool, input.argsHash, this.#projectSlug, input.cwd, input.fingerprint) as
339
+ | { handle: string; expiresAt: number }
340
+ | undefined;
341
+ if (!row) return { hit: false, reason: "miss" };
342
+ if (row.expiresAt <= now) {
343
+ this.#db.prepare(
344
+ `DELETE FROM request_cache
345
+ WHERE tool = ? AND args_hash = ? AND project_slug = ? AND cwd = ? AND fingerprint = ?`,
346
+ ).run(input.tool, input.argsHash, this.#projectSlug, input.cwd, input.fingerprint);
347
+ return { hit: false, reason: "expired" };
348
+ }
349
+ return { hit: true, handle: row.handle, expiresAt: row.expiresAt };
350
+ }
351
+
352
+ getEntryMeta(handle: string): CacheEntryMeta | null {
353
+ this.#assertOpen();
354
+ const parsed = parseCacheHandle(handle);
355
+ if (!parsed.ok) return null;
356
+ return this.#readEntryMeta(parsed.handle);
357
+ }
358
+
359
+ /**
360
+ * List sessions that own at least one cache ref. Used by `/supi:clear` so
361
+ * the project-wide confirmation is truthful when cache refs exist for
362
+ * sessions that have no metrics rows.
363
+ */
364
+ listSessions(): { session_id: string; ref_count: number }[] {
365
+ this.#assertOpen();
366
+ return this.#db.prepare(
367
+ `SELECT session_id, COUNT(*) AS ref_count
368
+ FROM cache_refs
369
+ GROUP BY session_id
370
+ ORDER BY session_id`,
371
+ ).all() as { session_id: string; ref_count: number }[];
372
+ }
373
+
374
+ clearSession(sessionId: string, _now = Date.now()): CacheClearResult {
375
+ this.#assertOpen();
376
+ const candidates = this.#db.prepare(
377
+ `SELECT DISTINCT handle FROM cache_refs WHERE session_id = ?`,
378
+ ).all(sessionId) as Array<{ handle: string }>;
379
+ const handles = candidates.map((row) => row.handle);
380
+
381
+ const deletedRefs = (this.#db.prepare(
382
+ `DELETE FROM cache_refs WHERE session_id = ?`,
383
+ ).run(sessionId).changes) as number;
384
+
385
+ const gc = this.#garbageCollectHandles(handles);
386
+ return {
387
+ deletedRefs,
388
+ deletedEntries: gc.deletedEntries,
389
+ deletedPayloadBytes: gc.deletedPayloadBytes,
390
+ retainedPayloadBytes: gc.retainedPayloadBytes,
391
+ };
392
+ }
393
+
394
+ clearProject(_now = Date.now()): CacheClearResult {
395
+ this.#assertOpen();
396
+ const stats = this.getStats();
397
+ const tx = this.#db.transaction(() => {
398
+ this.#db.exec(`DELETE FROM cache_refs`);
399
+ this.#db.exec(`DELETE FROM cache_entries`);
400
+ });
401
+ tx();
402
+
403
+ fs.rmSync(this.#payloadRoot, { recursive: true, force: true });
404
+ fs.mkdirSync(this.#payloadRoot, { recursive: true });
405
+
406
+ return {
407
+ deletedRefs: stats.refCount,
408
+ deletedEntries: stats.entryCount,
409
+ deletedPayloadBytes: stats.payloadBytes,
410
+ retainedPayloadBytes: 0,
411
+ };
412
+ }
413
+
414
+ pruneOldSessions(retentionDays: number, now = Date.now()): CacheClearResult {
415
+ this.#assertOpen();
416
+ const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
417
+ const candidates = this.#db.prepare(
418
+ `SELECT DISTINCT handle FROM cache_refs WHERE created_at < ?`,
419
+ ).all(cutoff) as Array<{ handle: string }>;
420
+ const handles = candidates.map((row) => row.handle);
421
+
422
+ const deletedRefs = (this.#db.prepare(
423
+ `DELETE FROM cache_refs WHERE created_at < ?`,
424
+ ).run(cutoff).changes) as number;
425
+
426
+ const gc = this.#garbageCollectHandles(handles);
427
+ this.#recordCacheMetric({
428
+ sessionId: this.#metricsSessionId,
429
+ processor: "cache-prune",
430
+ beforeBytes: gc.deletedPayloadBytes + gc.retainedPayloadBytes,
431
+ afterBytes: gc.retainedPayloadBytes,
432
+ cacheHit: 0,
433
+ });
434
+
435
+ return {
436
+ deletedRefs,
437
+ deletedEntries: gc.deletedEntries,
438
+ deletedPayloadBytes: gc.deletedPayloadBytes,
439
+ retainedPayloadBytes: gc.retainedPayloadBytes,
440
+ };
441
+ }
442
+
443
+ getSessionStats(sessionId: string): CacheSessionStats {
444
+ this.#assertOpen();
445
+ const refs = this.#db.prepare(
446
+ `SELECT COUNT(*) AS refCount FROM cache_refs WHERE session_id = ?`,
447
+ ).get(sessionId) as { refCount: number };
448
+
449
+ const rows = this.#db.prepare(
450
+ `SELECT
451
+ e.handle,
452
+ e.size_bytes AS sizeBytes,
453
+ e.compressed_bytes AS compressedBytes,
454
+ e.payload_relpath AS payloadRelpath,
455
+ (SELECT COUNT(*) FROM cache_refs r WHERE r.handle = e.handle) AS totalRefs,
456
+ (SELECT COUNT(*) FROM cache_refs r WHERE r.handle = e.handle AND r.session_id = ?) AS sessionRefs
457
+ FROM cache_entries e
458
+ WHERE e.handle IN (SELECT DISTINCT handle FROM cache_refs WHERE session_id = ?)`,
459
+ ).all(sessionId, sessionId) as Array<{
460
+ handle: string;
461
+ sizeBytes: number;
462
+ compressedBytes: number;
463
+ payloadRelpath: string;
464
+ totalRefs: number;
465
+ sessionRefs: number;
466
+ }>;
467
+
468
+ let uncompressedBytes = 0;
469
+ let compressedBytes = 0;
470
+ let payloadBytes = 0;
471
+ let reclaimablePayloadBytes = 0;
472
+ let retainedPayloadBytes = 0;
473
+
474
+ for (const row of rows) {
475
+ uncompressedBytes += row.sizeBytes;
476
+ compressedBytes += row.compressedBytes;
477
+ const payloadSize = payloadBytesFor(path.join(this.#payloadRoot, row.payloadRelpath), row.compressedBytes);
478
+ payloadBytes += payloadSize;
479
+ if (row.totalRefs - row.sessionRefs <= 0) {
480
+ reclaimablePayloadBytes += payloadSize;
481
+ } else {
482
+ retainedPayloadBytes += payloadSize;
483
+ }
484
+ }
485
+
486
+ return {
487
+ entryCount: rows.length,
488
+ refCount: refs.refCount,
489
+ uncompressedBytes,
490
+ compressedBytes,
491
+ payloadBytes,
492
+ reclaimablePayloadBytes,
493
+ retainedPayloadBytes,
494
+ };
495
+ }
496
+
497
+ getStats(): CacheStats {
498
+ this.#assertOpen();
499
+ const entries = this.#db.prepare(
500
+ `SELECT
501
+ COUNT(*) AS entryCount,
502
+ COALESCE(SUM(size_bytes), 0) AS uncompressedBytes,
503
+ COALESCE(SUM(compressed_bytes), 0) AS compressedBytes
504
+ FROM cache_entries`,
505
+ ).get() as {
506
+ entryCount: number;
507
+ uncompressedBytes: number;
508
+ compressedBytes: number;
509
+ };
510
+ const refs = this.#db.prepare(
511
+ `SELECT COUNT(*) AS refCount FROM cache_refs`,
512
+ ).get() as { refCount: number };
513
+
514
+ return {
515
+ entryCount: entries.entryCount,
516
+ refCount: refs.refCount,
517
+ uncompressedBytes: entries.uncompressedBytes,
518
+ compressedBytes: entries.compressedBytes,
519
+ payloadBytes: sumPayloadBytes(this.#payloadRoot),
520
+ };
521
+ }
522
+
523
+ close(): void {
524
+ if (this.#closed) return;
525
+ this.#closed = true;
526
+
527
+ try {
528
+ try {
529
+ if (this.#getJournalMode() === "wal") {
530
+ this.#cleanupWalSidecars();
531
+ }
532
+ } catch {
533
+ // The DB path may already be gone during teardown.
534
+ }
535
+ } finally {
536
+ this.#db.close();
537
+ }
538
+ }
539
+
540
+ #ensureDeleteJournalMode(): void {
541
+ const journalMode = this.#getJournalMode();
542
+ if (journalMode === "delete") return;
543
+
544
+ if (journalMode === "wal") {
545
+ this.#cleanupWalSidecars();
546
+ }
547
+
548
+ try {
549
+ this.#db.exec("PRAGMA journal_mode = DELETE;");
550
+ } catch {
551
+ // Best effort only. close() still checkpoints WAL stores when possible.
552
+ }
553
+ }
554
+
555
+ #getJournalMode(): string {
556
+ const { journal_mode } = this.#db.prepare("PRAGMA journal_mode").get() as {
557
+ journal_mode: string;
558
+ };
559
+ return journal_mode.toLowerCase();
560
+ }
561
+
562
+ #cleanupWalSidecars(): void {
563
+ try {
564
+ this.#db.fileControl(constants.SQLITE_FCNTL_PERSIST_WAL, 0);
565
+ this.#db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
566
+ } catch {
567
+ // Best effort only.
568
+ }
569
+ }
570
+
571
+ #migrate(): void {
572
+ const { user_version } = this.#db.prepare("PRAGMA user_version").get() as {
573
+ user_version: number;
574
+ };
575
+
576
+ if (user_version === SCHEMA_VERSION) return;
577
+ if (user_version > SCHEMA_VERSION) {
578
+ throw new Error(
579
+ `cache-store: unknown schema version ${user_version} (max supported: ${SCHEMA_VERSION})`,
580
+ );
581
+ }
582
+
583
+ this.#db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
584
+ }
585
+
586
+ #readEntryMeta(handle: string): CacheEntryMeta | null {
587
+ const row = this.#db.prepare(
588
+ `SELECT
589
+ handle,
590
+ sha256,
591
+ size_bytes AS sizeBytes,
592
+ compressed_bytes AS compressedBytes,
593
+ preview,
594
+ payload_relpath AS payloadRelpath,
595
+ created_at AS createdAt,
596
+ last_accessed_at AS lastAccessedAt,
597
+ open_count AS openCount
598
+ FROM cache_entries
599
+ WHERE handle = ?`,
600
+ ).get(handle) as CacheEntryMeta | undefined;
601
+ return row ?? null;
602
+ }
603
+
604
+ #garbageCollectHandles(handles: string[]): Omit<CacheClearResult, "deletedRefs"> {
605
+ let deletedEntries = 0;
606
+ let deletedPayloadBytes = 0;
607
+ let retainedPayloadBytes = 0;
608
+
609
+ const uniqueHandles = [...new Set(handles)];
610
+ for (const handle of uniqueHandles) {
611
+ const meta = this.#readEntryMeta(handle);
612
+ if (!meta) continue;
613
+
614
+ const remaining = this.#db.prepare(
615
+ `SELECT COUNT(*) AS count FROM cache_refs WHERE handle = ?`,
616
+ ).get(handle) as { count: number };
617
+
618
+ if (remaining.count > 0) {
619
+ retainedPayloadBytes += meta.compressedBytes;
620
+ continue;
621
+ }
622
+
623
+ this.#db.prepare(`DELETE FROM cache_entries WHERE handle = ?`).run(handle);
624
+ deletedEntries += 1;
625
+
626
+ const payloadPath = path.join(this.#payloadRoot, meta.payloadRelpath);
627
+ if (fs.existsSync(payloadPath)) {
628
+ const size = fs.statSync(payloadPath).size;
629
+ fs.rmSync(payloadPath, { force: true });
630
+ deletedPayloadBytes += size;
631
+ } else {
632
+ deletedPayloadBytes += meta.compressedBytes;
633
+ }
634
+ removeEmptyParentDirectory(path.dirname(payloadPath), this.#payloadRoot);
635
+ }
636
+
637
+ return { deletedEntries, deletedPayloadBytes, retainedPayloadBytes };
638
+ }
639
+
640
+ #recordCacheMetric(opts: {
641
+ sessionId: string;
642
+ processor: "cache-store" | "cache-prune";
643
+ beforeBytes: number;
644
+ afterBytes: number;
645
+ cacheHit: 0 | 1;
646
+ }): void {
647
+ const metrics = this.#metricsStore;
648
+ if (!metrics) return;
649
+
650
+ try {
651
+ metrics.record({
652
+ session_id: opts.sessionId,
653
+ ts: Date.now(),
654
+ layer: "L3",
655
+ tool: "(system)",
656
+ processor: opts.processor,
657
+ before_bytes: Math.max(0, Math.floor(opts.beforeBytes)),
658
+ after_bytes: Math.max(0, Math.floor(opts.afterBytes)),
659
+ cache_hit: opts.cacheHit,
660
+ unique_source_hash: null,
661
+ context_tokens: null,
662
+ context_window: null,
663
+ context_percent: null,
664
+ });
665
+ } catch {
666
+ // Cache operations must not depend on metrics health.
667
+ }
668
+ }
669
+
670
+ #assertOpen(): void {
671
+ if (this.#closed) {
672
+ throw new Error("cache-store: operation attempted after close");
673
+ }
674
+ }
675
+ }
676
+
677
+ function payloadBytesFor(payloadPath: string, fallbackBytes: number): number {
678
+ try {
679
+ return fs.statSync(payloadPath).size;
680
+ } catch {
681
+ return fallbackBytes;
682
+ }
683
+ }
684
+
685
+ function sumPayloadBytes(root: string): number {
686
+ if (!fs.existsSync(root)) return 0;
687
+
688
+ let total = 0;
689
+ for (const dirent of fs.readdirSync(root, { withFileTypes: true })) {
690
+ const fullPath = path.join(root, dirent.name);
691
+ if (dirent.isDirectory()) {
692
+ total += sumPayloadBytes(fullPath);
693
+ } else if (dirent.isFile()) {
694
+ total += fs.statSync(fullPath).size;
695
+ }
696
+ }
697
+ return total;
698
+ }
699
+
700
+
701
+ function writePayloadAtomically(payloadPath: string, bytes: Buffer): void {
702
+ if (fs.existsSync(payloadPath)) return;
703
+
704
+ fs.mkdirSync(path.dirname(payloadPath), { recursive: true });
705
+ const tempPath = `${payloadPath}.${process.pid}.${Date.now()}.tmp`;
706
+ try {
707
+ fs.writeFileSync(tempPath, bytes, { flag: "wx" });
708
+ try {
709
+ fs.renameSync(tempPath, payloadPath);
710
+ } catch (error) {
711
+ if (fs.existsSync(payloadPath)) {
712
+ fs.rmSync(tempPath, { force: true });
713
+ return;
714
+ }
715
+ throw error;
716
+ }
717
+ } catch (error) {
718
+ try {
719
+ fs.rmSync(tempPath, { force: true });
720
+ } catch {
721
+ // Best-effort cleanup only.
722
+ }
723
+ throw error;
724
+ }
725
+ }
726
+
727
+ function removeEmptyParentDirectory(candidate: string, root: string): void {
728
+ const resolvedRoot = path.resolve(root);
729
+ let current = path.resolve(candidate);
730
+ while (current.startsWith(resolvedRoot) && current !== resolvedRoot) {
731
+ try {
732
+ fs.rmdirSync(current);
733
+ } catch {
734
+ return;
735
+ }
736
+ current = path.dirname(current);
737
+ }
738
+ }