@polderlabs/bizar 6.3.0 → 7.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 (245) hide show
  1. package/.claude/hooks/sessionend-recall.mjs +3 -3
  2. package/.claude/hooks/worker-suggest.mjs +110 -0
  3. package/.claude/settings.json +9 -0
  4. package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js → EnvVarsSection-CW6fWKds.js} +3 -3
  5. package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js.map → EnvVarsSection-CW6fWKds.js.map} +1 -1
  6. package/bizar-dash/dist/assets/MobileChat-D7zRhU9K.js +1 -0
  7. package/bizar-dash/dist/assets/{MobileChat-BJrqwVDd.js.map → MobileChat-D7zRhU9K.js.map} +1 -1
  8. package/bizar-dash/dist/assets/MobileSettings-K-sNsn_r.js +1 -0
  9. package/bizar-dash/dist/assets/{MobileSettings-CEQNJNLJ.js.map → MobileSettings-K-sNsn_r.js.map} +1 -1
  10. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js → Toast-WTxc3m2g.js} +1 -1
  11. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js.map → Toast-WTxc3m2g.js.map} +1 -1
  12. package/bizar-dash/dist/assets/flow-BpU0-gK2.js +1 -0
  13. package/bizar-dash/dist/assets/flow-BpU0-gK2.js.map +1 -0
  14. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js +1 -0
  15. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js.map +1 -0
  16. package/bizar-dash/dist/assets/{icons-Do5N2jmX.js → icons-B0AKnYI3.js} +98 -103
  17. package/bizar-dash/dist/assets/icons-B0AKnYI3.js.map +1 -0
  18. package/bizar-dash/dist/assets/main-C-Md3DaY.css +1 -0
  19. package/bizar-dash/dist/assets/main-CO8fIxQK.js +18 -0
  20. package/bizar-dash/dist/assets/main-CO8fIxQK.js.map +1 -0
  21. package/bizar-dash/dist/assets/markdown-w2bDDkAH.js +1 -0
  22. package/bizar-dash/dist/assets/{markdown-tOLaD6nm.js.map → markdown-w2bDDkAH.js.map} +1 -1
  23. package/bizar-dash/dist/assets/mobile-DXYCA0hC.js +1 -0
  24. package/bizar-dash/dist/assets/{mobile-DYCHcUpq.js.map → mobile-DXYCA0hC.js.map} +1 -1
  25. package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js +2 -0
  26. package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js.map +1 -0
  27. package/bizar-dash/dist/assets/react-vendor-BnuhLJ6X.css +1 -0
  28. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js +46 -0
  29. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js.map +1 -0
  30. package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js +2 -0
  31. package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js.map +1 -0
  32. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js +45 -0
  33. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js.map +1 -0
  34. package/bizar-dash/dist/index.html +12 -10
  35. package/bizar-dash/dist/mobile.html +7 -4
  36. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  37. package/bizar-dash/src/server/api.mjs +11 -0
  38. package/bizar-dash/src/server/memory-consolidator.mjs +218 -0
  39. package/bizar-dash/src/server/memory-distillation.mjs +296 -0
  40. package/bizar-dash/src/server/routes/distill.mjs +129 -0
  41. package/bizar-dash/src/server/routes/goal-planner.mjs +236 -0
  42. package/bizar-dash/src/web/App.tsx +4 -0
  43. package/bizar-dash/src/web/components/Tag.tsx +18 -5
  44. package/bizar-dash/src/web/components/Topbar.tsx +3 -0
  45. package/bizar-dash/src/web/components/agents/CommunicationLog.tsx +126 -0
  46. package/bizar-dash/src/web/components/agents/DependencyGraph.tsx +163 -0
  47. package/bizar-dash/src/web/components/agents/QualityGates.tsx +123 -0
  48. package/bizar-dash/src/web/components/agents/RealTimeEventLog.tsx +127 -0
  49. package/bizar-dash/src/web/components/agents/RoutingDecisions.test.tsx +148 -0
  50. package/bizar-dash/src/web/components/agents/RoutingDecisions.tsx +160 -0
  51. package/bizar-dash/src/web/components/goals/GoalInput.tsx +86 -0
  52. package/bizar-dash/src/web/components/goals/PlanVisualization.tsx +111 -0
  53. package/bizar-dash/src/web/lib/goapPlanner.ts +614 -0
  54. package/bizar-dash/src/web/lib/types.ts +25 -0
  55. package/bizar-dash/src/web/styles/agents-redesign.css +311 -0
  56. package/bizar-dash/src/web/styles/main.css +251 -0
  57. package/bizar-dash/src/web/styles/tasks-redesign.css +231 -0
  58. package/bizar-dash/src/web/ui/controls/Button.tsx +77 -0
  59. package/bizar-dash/src/web/ui/controls/Checkbox.tsx +95 -0
  60. package/bizar-dash/src/web/ui/controls/IconButton.tsx +66 -0
  61. package/bizar-dash/src/web/ui/controls/Kbd.tsx +24 -0
  62. package/bizar-dash/src/web/ui/controls/NumberInput.tsx +77 -0
  63. package/bizar-dash/src/web/ui/controls/RadioGroup.tsx +109 -0
  64. package/bizar-dash/src/web/ui/controls/SearchInput.tsx +91 -0
  65. package/bizar-dash/src/web/ui/controls/Select.tsx +92 -0
  66. package/bizar-dash/src/web/ui/controls/Slider.tsx +70 -0
  67. package/bizar-dash/src/web/ui/controls/TextInput.tsx +67 -0
  68. package/bizar-dash/src/web/ui/controls/Toggle.tsx +79 -0
  69. package/bizar-dash/src/web/ui/controls/controls.css +454 -0
  70. package/bizar-dash/src/web/ui/controls/index.ts +33 -0
  71. package/bizar-dash/src/web/ui/data/BarChart.tsx +60 -0
  72. package/bizar-dash/src/web/ui/data/DataTable.tsx +216 -0
  73. package/bizar-dash/src/web/ui/data/EmptyState.tsx +60 -0
  74. package/bizar-dash/src/web/ui/data/ErrorState.tsx +56 -0
  75. package/bizar-dash/src/web/ui/data/KeyValueList.tsx +106 -0
  76. package/bizar-dash/src/web/ui/data/LoadingState.tsx +65 -0
  77. package/bizar-dash/src/web/ui/data/Sparkline.tsx +87 -0
  78. package/bizar-dash/src/web/ui/data/StatTile.tsx +87 -0
  79. package/bizar-dash/src/web/ui/data/data.css +535 -0
  80. package/bizar-dash/src/web/ui/data/index.ts +31 -0
  81. package/bizar-dash/src/web/ui/feedback/Badge.tsx +49 -0
  82. package/bizar-dash/src/web/ui/feedback/Dialog.tsx +209 -0
  83. package/bizar-dash/src/web/ui/feedback/ProgressBar.tsx +69 -0
  84. package/bizar-dash/src/web/ui/feedback/StatusDot.tsx +50 -0
  85. package/bizar-dash/src/web/ui/feedback/Toast.tsx +200 -0
  86. package/bizar-dash/src/web/ui/feedback/Tooltip.tsx +126 -0
  87. package/bizar-dash/src/web/ui/feedback/feedback.css +314 -0
  88. package/bizar-dash/src/web/ui/feedback/index.ts +43 -0
  89. package/bizar-dash/src/web/ui/index.ts +56 -0
  90. package/bizar-dash/src/web/ui/layout/AppShell.tsx +51 -0
  91. package/bizar-dash/src/web/ui/layout/Breadcrumbs.tsx +97 -0
  92. package/bizar-dash/src/web/ui/layout/Panel.tsx +73 -0
  93. package/bizar-dash/src/web/ui/layout/PanelHeader.tsx +39 -0
  94. package/bizar-dash/src/web/ui/layout/Sidebar.tsx +131 -0
  95. package/bizar-dash/src/web/ui/layout/Tabs.tsx +133 -0
  96. package/bizar-dash/src/web/ui/layout/Topbar.tsx +42 -0
  97. package/bizar-dash/src/web/ui/layout/index.ts +33 -0
  98. package/bizar-dash/src/web/ui/layout/layout.css +413 -0
  99. package/bizar-dash/src/web/ui/navigation/CommandPalette.tsx +199 -0
  100. package/bizar-dash/src/web/ui/navigation/NavGroup.tsx +95 -0
  101. package/bizar-dash/src/web/ui/navigation/NavLink.tsx +93 -0
  102. package/bizar-dash/src/web/ui/navigation/index.ts +17 -0
  103. package/bizar-dash/src/web/ui/navigation/navigation.css +343 -0
  104. package/bizar-dash/src/web/ui/primitives/Box.tsx +91 -0
  105. package/bizar-dash/src/web/ui/primitives/Grid.tsx +49 -0
  106. package/bizar-dash/src/web/ui/primitives/Inline.tsx +55 -0
  107. package/bizar-dash/src/web/ui/primitives/Separator.tsx +46 -0
  108. package/bizar-dash/src/web/ui/primitives/Stack.tsx +54 -0
  109. package/bizar-dash/src/web/ui/primitives/VisuallyHidden.tsx +26 -0
  110. package/bizar-dash/src/web/ui/primitives/index.ts +23 -0
  111. package/bizar-dash/src/web/ui/primitives/primitives.css +183 -0
  112. package/bizar-dash/src/web/ui/styles/globals.css +86 -0
  113. package/bizar-dash/src/web/ui/styles/reset.css +125 -0
  114. package/bizar-dash/src/web/ui/styles/tokens.css +195 -0
  115. package/bizar-dash/src/web/ui/theme/ThemeProvider.tsx +142 -0
  116. package/bizar-dash/src/web/ui/theme/tokens.ts +131 -0
  117. package/bizar-dash/src/web/ui/theme/useTheme.ts +22 -0
  118. package/bizar-dash/src/web/ui/utils/cx.ts +42 -0
  119. package/bizar-dash/src/web/views/Agents.tsx +506 -266
  120. package/bizar-dash/src/web/views/GoalPlanner.tsx +158 -0
  121. package/bizar-dash/src/web/views/Overview.tsx +996 -448
  122. package/bizar-dash/src/web/views/Tasks.tsx +277 -273
  123. package/bizar-dash/tests/communication-log.test.tsx +67 -0
  124. package/bizar-dash/tests/dep-graph.test.tsx +40 -0
  125. package/bizar-dash/tests/event-log.test.tsx +61 -0
  126. package/bizar-dash/tests/goal-input.test.tsx +72 -0
  127. package/bizar-dash/tests/goap-planner.test.ts +68 -0
  128. package/bizar-dash/tests/quality-gates.test.tsx +59 -0
  129. package/bizar-dash/tests/setup.ts +31 -1
  130. package/bizar-dash/tests/ui/controls/Button.test.tsx +50 -0
  131. package/bizar-dash/tests/ui/controls/Checkbox.test.tsx +44 -0
  132. package/bizar-dash/tests/ui/controls/IconButton.test.tsx +37 -0
  133. package/bizar-dash/tests/ui/controls/Kbd.test.tsx +39 -0
  134. package/bizar-dash/tests/ui/controls/NumberInput.test.tsx +56 -0
  135. package/bizar-dash/tests/ui/controls/RadioGroup.test.tsx +58 -0
  136. package/bizar-dash/tests/ui/controls/SearchInput.test.tsx +52 -0
  137. package/bizar-dash/tests/ui/controls/Select.test.tsx +66 -0
  138. package/bizar-dash/tests/ui/controls/Slider.test.tsx +41 -0
  139. package/bizar-dash/tests/ui/controls/TextInput.test.tsx +44 -0
  140. package/bizar-dash/tests/ui/controls/Toggle.test.tsx +39 -0
  141. package/bizar-dash/tests/ui/data/BarChart.test.tsx +55 -0
  142. package/bizar-dash/tests/ui/data/DataTable.test.tsx +112 -0
  143. package/bizar-dash/tests/ui/data/EmptyState.test.tsx +57 -0
  144. package/bizar-dash/tests/ui/data/ErrorState.test.tsx +47 -0
  145. package/bizar-dash/tests/ui/data/KeyValueList.test.tsx +59 -0
  146. package/bizar-dash/tests/ui/data/LoadingState.test.tsx +37 -0
  147. package/bizar-dash/tests/ui/data/Sparkline.test.tsx +56 -0
  148. package/bizar-dash/tests/ui/data/StatTile.test.tsx +69 -0
  149. package/bizar-dash/tests/ui/feedback/Badge.test.tsx +41 -0
  150. package/bizar-dash/tests/ui/feedback/Dialog.test.tsx +99 -0
  151. package/bizar-dash/tests/ui/feedback/ProgressBar.test.tsx +66 -0
  152. package/bizar-dash/tests/ui/feedback/StatusDot.test.tsx +41 -0
  153. package/bizar-dash/tests/ui/feedback/Toast.test.tsx +124 -0
  154. package/bizar-dash/tests/ui/feedback/Tooltip.test.tsx +102 -0
  155. package/bizar-dash/tests/ui/layout/AppShell.test.tsx +50 -0
  156. package/bizar-dash/tests/ui/layout/Breadcrumbs.test.tsx +85 -0
  157. package/bizar-dash/tests/ui/layout/Panel.test.tsx +52 -0
  158. package/bizar-dash/tests/ui/layout/PanelHeader.test.tsx +32 -0
  159. package/bizar-dash/tests/ui/layout/Sidebar.test.tsx +95 -0
  160. package/bizar-dash/tests/ui/layout/Tabs.test.tsx +87 -0
  161. package/bizar-dash/tests/ui/layout/Topbar.test.tsx +41 -0
  162. package/bizar-dash/tests/ui/navigation/CommandPalette.test.tsx +105 -0
  163. package/bizar-dash/tests/ui/navigation/NavGroup.test.tsx +55 -0
  164. package/bizar-dash/tests/ui/navigation/NavLink.test.tsx +61 -0
  165. package/bizar-dash/tests/ui/primitives/Box.test.tsx +49 -0
  166. package/bizar-dash/tests/ui/primitives/Grid.test.tsx +37 -0
  167. package/bizar-dash/tests/ui/primitives/Inline.test.tsx +33 -0
  168. package/bizar-dash/tests/ui/primitives/Separator.test.tsx +34 -0
  169. package/bizar-dash/tests/ui/primitives/Stack.test.tsx +45 -0
  170. package/bizar-dash/tests/ui/primitives/VisuallyHidden.test.tsx +27 -0
  171. package/bizar-dash/tests/views/Agents.test.tsx +359 -0
  172. package/bizar-dash/tests/views/Overview.test.tsx +413 -0
  173. package/bizar-dash/tests/views/Tasks.test.tsx +250 -0
  174. package/bizar-dash/vitest.config.ts +3 -1
  175. package/cli/__tests__/cost-gate.test.mjs +301 -0
  176. package/cli/__tests__/feature-list-bridge.test.mjs +371 -0
  177. package/cli/bin.mjs +38 -0
  178. package/cli/commands/claim.mjs +282 -0
  179. package/cli/commands/cost.mjs +279 -0
  180. package/cli/cost-gate.mjs +501 -0
  181. package/cli/feature-list-bridge.mjs +400 -0
  182. package/cli/memory-constants.mjs +17 -0
  183. package/cli/worker-dispatcher.mjs +259 -0
  184. package/cli/worker-dispatcher.test.mjs +204 -0
  185. package/config/trigger-patterns.json +186 -0
  186. package/package.json +2 -2
  187. package/packages/sdk/package.json +1 -1
  188. package/packages/sdk/src/agent-registry.ts +304 -0
  189. package/packages/sdk/src/consensus/byzantine.ts +587 -0
  190. package/packages/sdk/src/consensus/index.ts +147 -0
  191. package/packages/sdk/src/consensus/queen.ts +173 -0
  192. package/packages/sdk/src/consensus/types.ts +199 -0
  193. package/packages/sdk/src/federation/audit.ts +156 -0
  194. package/packages/sdk/src/federation/budget.ts +269 -0
  195. package/packages/sdk/src/federation/envelope.ts +144 -0
  196. package/packages/sdk/src/federation/hmac.ts +160 -0
  197. package/packages/sdk/src/federation/index.ts +443 -0
  198. package/packages/sdk/src/federation/pii.ts +241 -0
  199. package/packages/sdk/src/federation/policy.ts +112 -0
  200. package/packages/sdk/src/federation/trust.ts +146 -0
  201. package/packages/sdk/src/index.ts +67 -0
  202. package/packages/sdk/src/mcp/server.ts +351 -1
  203. package/packages/sdk/src/router/codemod-intent.ts +125 -0
  204. package/packages/sdk/src/router/index.ts +247 -0
  205. package/packages/sdk/src/router/memory-distillation-shim.mjs +18 -0
  206. package/packages/sdk/src/router/memory-distillation.ts +249 -0
  207. package/packages/sdk/src/router/model-router.ts +235 -0
  208. package/packages/sdk/src/router/q-learning-router.ts +304 -0
  209. package/packages/sdk/src/swarm-topology.ts +279 -0
  210. package/packages/sdk/tests/agent-registry.test.ts +285 -0
  211. package/packages/sdk/tests/consensus.test.ts +376 -0
  212. package/packages/sdk/tests/federation/audit.test.ts +164 -0
  213. package/packages/sdk/tests/federation/budget.test.ts +189 -0
  214. package/packages/sdk/tests/federation/envelope.test.ts +120 -0
  215. package/packages/sdk/tests/federation/hmac.test.ts +194 -0
  216. package/packages/sdk/tests/federation/orchestrator.test.ts +176 -0
  217. package/packages/sdk/tests/federation/pii.test.ts +106 -0
  218. package/packages/sdk/tests/federation/policy.test.ts +140 -0
  219. package/packages/sdk/tests/federation/trust.test.ts +124 -0
  220. package/packages/sdk/tests/mcp-tools.test.ts +204 -0
  221. package/packages/sdk/tests/memory-distillation.test.mjs +186 -0
  222. package/packages/sdk/tests/model-router.test.mjs +108 -0
  223. package/packages/sdk/tests/q-learning-router.test.mjs +100 -0
  224. package/packages/sdk/tests/router-orchestrator.test.mjs +172 -0
  225. package/packages/sdk/tests/router.test.mjs +91 -0
  226. package/packages/sdk/tests/sdk.test.mjs +26 -2
  227. package/packages/sdk/tests/swarm-topology.test.ts +258 -0
  228. package/scripts/bh-full-e2e.mjs +11 -15
  229. package/bizar-dash/dist/assets/MobileChat-BJrqwVDd.js +0 -1
  230. package/bizar-dash/dist/assets/MobileSettings-CEQNJNLJ.js +0 -1
  231. package/bizar-dash/dist/assets/icons-Do5N2jmX.js.map +0 -1
  232. package/bizar-dash/dist/assets/main-IvfQAOfy.js +0 -18
  233. package/bizar-dash/dist/assets/main-IvfQAOfy.js.map +0 -1
  234. package/bizar-dash/dist/assets/main-ietCEg_R.css +0 -1
  235. package/bizar-dash/dist/assets/markdown-tOLaD6nm.js +0 -1
  236. package/bizar-dash/dist/assets/mobile-DYCHcUpq.js +0 -1
  237. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js +0 -2
  238. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js.map +0 -1
  239. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js +0 -40
  240. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +0 -1
  241. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js +0 -2
  242. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js.map +0 -1
  243. package/bizar-dash/dist/assets/vendor-C843201K.js +0 -29
  244. package/bizar-dash/dist/assets/vendor-C843201K.js.map +0 -1
  245. package/packages/sdk/package-lock.json +0 -32
@@ -0,0 +1,501 @@
1
+ // @ts-nocheck — declaration emit for the `Database` / `Statement`
2
+ // private types from better-sqlite3 is not currently exported by the
3
+ // better-sqlite3 type package, so .d.ts generation for this .mjs
4
+ // module fails with TS9006. The runtime is correct; once better-sqlite3
5
+ // ships exported types (or F-035 lands with a wrapper), the
6
+ // @ts-nocheck can come off.
7
+ // (The directive is placed BEFORE the docblock header below so
8
+ // TypeScript's parser doesn't trip on the `<uuid>` / `<env>` tokens
9
+ // inside the JSDoc body.)
10
+
11
+ /**
12
+ * cli/cost-gate.mjs
13
+ *
14
+ * F-035 (MetaHarness) — atomic SQLite-backed room-budget tracker.
15
+ *
16
+ * Source-of-truth port from
17
+ * ruflo/v3/@claude-flow/cli/src/business-pods/bbs-budget-tracker.ts:151
18
+ * (AtomicBbsRoomBudgetTracker — ADR-164.1 §3-§5)
19
+ *
20
+ * The contract:
21
+ * - `registerRoom(roomId, capUsd)` is idempotent (upsert by id).
22
+ * - `reserve(roomId, callerId, amount, opts?)` atomically checks
23
+ * the budget AND inserts a 'reserved' transaction inside one
24
+ * `BEGIN IMMEDIATE` transaction (Bizar's L10 cost-gate constraint).
25
+ * - `commit(txId, actualUsd)` flips the row to 'committed' (or
26
+ * 'committed_post_expiry' + `COMMIT_AFTER_EXPIRY` warning if the
27
+ * reservation already expired — the API spend already happened,
28
+ * so we accept the charge and emit a warning).
29
+ * - `release(txId)` flips a still-'reserved' row to 'released';
30
+ * any other state returns ALREADY_FINALIZED.
31
+ * - `status(roomId)` returns { capUsd, spentUsd, remainingUsd }
32
+ * computed from the committed + live-reserved ledger.
33
+ * - `sweepExpired(nowMs?)` is exposed for callers to drive
34
+ * periodically (the pod-tick mjs stub uses 5s sweep).
35
+ *
36
+ * State machine (per ADR-164.1 §3, §5.3):
37
+ * reserved -> committed
38
+ * reserved -> released
39
+ * reserved -> expired (sweepExpired, before any commit)
40
+ * reserved -> committed_post_expiry (commit lands after expires_at)
41
+ *
42
+ * Atomicity guarantees:
43
+ * - WAL (PRAGMA journal_mode = WAL) so reads don't block writes.
44
+ * - `busy_timeout = 500` so concurrent reserves get a brief retry
45
+ * window for SQLITE_BUSY instead of failing immediately.
46
+ * - All reserve/commit/release run inside a single `BEGIN IMMEDIATE`
47
+ * transaction so the lock acquisition + state mutation are atomic.
48
+ *
49
+ * Schema (per feature spec):
50
+ * rooms(id TEXT PRIMARY KEY, monthly_cap_usd REAL, spent_usd REAL,
51
+ * last_reset_at INTEGER)
52
+ * transactions(id INTEGER PRIMARY KEY, room_id TEXT, kind TEXT,
53
+ * amount_usd REAL, ts INTEGER, metadata TEXT)
54
+ *
55
+ * IDs:
56
+ * transaction id is string-shaped (`tx-<uuid>`) so the result type
57
+ * can carry it back through JSON. The `kind` column is one of
58
+ * 'reserved' | 'committed' | 'released' | 'expired' | 'committed_post_expiry'.
59
+ *
60
+ * Expiry window:
61
+ * - default 60_000 ms (overridable per `reserve()` call)
62
+ * - clamped to [5_000, 300_000] ms (ADR-164.1 §3.2)
63
+ * - overridable globally via BIZAR_COST_RESERVATION_EXPIRY_MS env var
64
+ */
65
+
66
+ import { randomUUID } from 'node:crypto';
67
+ import { mkdirSync } from 'node:fs';
68
+ import { dirname } from 'node:path';
69
+
70
+ // better-sqlite3's Database / Statement types are not currently exported by
71
+ // the @types/better-sqlite3 package, so we use a structural cast to keep
72
+ // both runtime behavior and declaration emit happy.
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ const Database = (await import('better-sqlite3')).default;
75
+
76
+ // ── Constants (kept in this module so the CLI doesn't depend on
77
+ // cli/memory-constants.mjs for the cost gate) ──────────────────────
78
+
79
+ export const RESERVATION_EXPIRY_FLOOR_MS = 5_000;
80
+ export const RESERVATION_EXPIRY_CEILING_MS = 300_000;
81
+ export const RESERVATION_EXPIRY_DEFAULT_MS = 60_000;
82
+
83
+ const TX_STATES = new Set([
84
+ 'reserved',
85
+ 'committed',
86
+ 'released',
87
+ 'expired',
88
+ 'committed_post_expiry',
89
+ ]);
90
+
91
+ export function clampReservationExpiry(raw) {
92
+ if (raw === undefined || raw === null || !Number.isFinite(Number(raw))) {
93
+ const envVal = Number(process.env.BIZAR_COST_RESERVATION_EXPIRY_MS);
94
+ if (Number.isFinite(envVal) && envVal > 0) {
95
+ return Math.max(
96
+ RESERVATION_EXPIRY_FLOOR_MS,
97
+ Math.min(RESERVATION_EXPIRY_CEILING_MS, envVal),
98
+ );
99
+ }
100
+ return RESERVATION_EXPIRY_DEFAULT_MS;
101
+ }
102
+ const n = Number(raw);
103
+ return Math.max(
104
+ RESERVATION_EXPIRY_FLOOR_MS,
105
+ Math.min(RESERVATION_EXPIRY_CEILING_MS, n),
106
+ );
107
+ }
108
+
109
+ const SCHEMA_SQL = `
110
+ PRAGMA journal_mode = WAL;
111
+ PRAGMA synchronous = NORMAL;
112
+ PRAGMA busy_timeout = 500;
113
+
114
+ CREATE TABLE IF NOT EXISTS rooms (
115
+ id TEXT NOT NULL PRIMARY KEY,
116
+ monthly_cap_usd REAL NOT NULL CHECK (monthly_cap_usd >= 0),
117
+ spent_usd REAL NOT NULL DEFAULT 0 CHECK (spent_usd >= 0),
118
+ last_reset_at INTEGER NOT NULL
119
+ );
120
+
121
+ CREATE TABLE IF NOT EXISTS transactions (
122
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
123
+ tx_id TEXT NOT NULL UNIQUE,
124
+ room_id TEXT NOT NULL REFERENCES rooms(id),
125
+ kind TEXT NOT NULL CHECK (kind IN ('reserved','committed','released','expired','committed_post_expiry')),
126
+ amount_usd REAL NOT NULL,
127
+ caller_id TEXT NOT NULL DEFAULT '',
128
+ ts INTEGER NOT NULL,
129
+ expires_at INTEGER,
130
+ committed_at INTEGER,
131
+ metadata TEXT NOT NULL DEFAULT '{}'
132
+ );
133
+
134
+ CREATE INDEX IF NOT EXISTS idx_tx_room_state_ts
135
+ ON transactions (room_id, kind, ts);
136
+ CREATE INDEX IF NOT EXISTS idx_tx_state_expires
137
+ ON transactions (kind, expires_at);
138
+ `;
139
+
140
+ // ── Result shapes ─────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * @typedef {Object} ReserveResult
144
+ * @property {boolean} ok
145
+ * @property {string} [txId]
146
+ * @property {number} [expiresAt]
147
+ * @property {number} [remainingAfterReserve]
148
+ * @property {string} [error] 'BUDGET_EXCEEDED' | 'ROOM_NOT_FOUND'
149
+ */
150
+
151
+ /**
152
+ * @typedef {Object} CommitResult
153
+ * @property {boolean} ok
154
+ * @property {boolean} [committed]
155
+ * @property {string} [warned] 'COMMIT_AFTER_EXPIRY'
156
+ * @property {number} [finalRemaining]
157
+ * @property {string} [error] 'NOT_FOUND' | 'ALREADY_FINALIZED'
158
+ */
159
+
160
+ /**
161
+ * @typedef {Object} ReleaseResult
162
+ * @property {boolean} ok
163
+ * @property {boolean} [released]
164
+ * @property {string} [error] 'NOT_FOUND' | 'ALREADY_FINALIZED'
165
+ */
166
+
167
+ /**
168
+ * @typedef {Object} RoomStatus
169
+ * @property {string} roomId
170
+ * @property {number} capUsd
171
+ * @property {number} spentUsd
172
+ * @property {number} reservedUsd
173
+ * @property {number} remainingUsd
174
+ * @property {number} txCount
175
+ * @property {number} [lastResetAt]
176
+ */
177
+
178
+ // ── CostGate ──────────────────────────────────────────────────────────
179
+
180
+ export class CostGate {
181
+ /**
182
+ * @param {string|object} opts Either a path string or an
183
+ * `{dbPath, clock, logger}` options bag.
184
+ */
185
+ constructor(opts = {}) {
186
+ if (typeof opts === 'string') {
187
+ this.dbPath = opts;
188
+ this.clock = () => Date.now();
189
+ this.logger = null;
190
+ } else {
191
+ this.dbPath = opts.dbPath;
192
+ this.clock = opts.clock || (() => Date.now());
193
+ this.logger = opts.logger || null;
194
+ }
195
+ if (!this.dbPath) {
196
+ throw new Error('CostGate requires dbPath');
197
+ }
198
+ mkdirSync(dirname(this.dbPath), { recursive: true });
199
+ /** @type {import('better-sqlite3').Database} */
200
+ this.db = new Database(this.dbPath);
201
+ this.db.exec(SCHEMA_SQL);
202
+ // Lock down the mode after first PRAGMA above.
203
+ this.db.pragma('journal_mode = WAL');
204
+ this.db.pragma('synchronous = NORMAL');
205
+ this.db.pragma('busy_timeout = 500');
206
+
207
+ // Prepared statements — re-bind per call so better-sqlite3 doesn't
208
+ // trip on changing SQL text.
209
+ /** @type {{ [k: string]: import('better-sqlite3').Statement<any[], any> }} */
210
+ this._stmts = {
211
+ registerRoom: this.db.prepare(
212
+ `INSERT INTO rooms (id, monthly_cap_usd, spent_usd, last_reset_at)
213
+ VALUES (?, ?, 0, ?)
214
+ ON CONFLICT(id) DO UPDATE SET monthly_cap_usd = excluded.monthly_cap_usd`,
215
+ ),
216
+ getRoom: this.db.prepare(
217
+ `SELECT id, monthly_cap_usd, spent_usd, last_reset_at FROM rooms WHERE id = ?`,
218
+ ),
219
+ listRooms: this.db.prepare(
220
+ `SELECT id, monthly_cap_usd, spent_usd, last_reset_at FROM rooms ORDER BY id`,
221
+ ),
222
+ insertTx: this.db.prepare(
223
+ `INSERT INTO transactions
224
+ (tx_id, room_id, kind, amount_usd, caller_id, ts, expires_at, committed_at, metadata)
225
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
226
+ ),
227
+ getTx: this.db.prepare(
228
+ `SELECT tx_id, room_id, kind, amount_usd, caller_id, ts, expires_at, committed_at
229
+ FROM transactions WHERE tx_id = ?`,
230
+ ),
231
+ totalsThisCycle: this.db.prepare(
232
+ `SELECT
233
+ COALESCE(SUM(CASE WHEN kind IN ('committed','committed_post_expiry')
234
+ THEN amount_usd ELSE 0 END), 0) AS committed_total,
235
+ COALESCE(SUM(CASE WHEN kind = 'reserved'
236
+ AND (expires_at IS NULL OR expires_at > ?)
237
+ THEN amount_usd ELSE 0 END), 0) AS reserved_total
238
+ FROM transactions
239
+ WHERE room_id = ?`,
240
+ ),
241
+ setSpentUsd: this.db.prepare(
242
+ `UPDATE rooms SET spent_usd = ? WHERE id = ?`,
243
+ ),
244
+ flipTxState: this.db.prepare(
245
+ `UPDATE transactions SET kind = ?, committed_at = ? WHERE tx_id = ?`,
246
+ ),
247
+ sweepExpired: this.db.prepare(
248
+ `UPDATE transactions SET kind = 'expired'
249
+ WHERE kind = 'reserved' AND expires_at IS NOT NULL AND expires_at < ?`,
250
+ ),
251
+ listTxForRoom: this.db.prepare(
252
+ `SELECT tx_id, room_id, kind, amount_usd, caller_id, ts, expires_at, committed_at
253
+ FROM transactions WHERE room_id = ? ORDER BY ts`,
254
+ ),
255
+ txCountForRoom: this.db.prepare(
256
+ `SELECT COUNT(*) AS n FROM transactions WHERE room_id = ?`,
257
+ ),
258
+ };
259
+ }
260
+
261
+ close() {
262
+ try { this.db.close(); } catch (_e) { /* ignore double-close */ }
263
+ }
264
+
265
+ // ── Register ──────────────────────────────────────────────────────
266
+
267
+ registerRoom(roomId, capUsd) {
268
+ if (!roomId || typeof roomId !== 'string') {
269
+ throw new Error('roomId must be a non-empty string');
270
+ }
271
+ const cap = Number(capUsd);
272
+ if (!Number.isFinite(cap) || cap < 0) {
273
+ throw new Error('capUsd must be a non-negative finite number');
274
+ }
275
+ this._stmts.registerRoom.run(roomId, cap, this.clock());
276
+ return { ok: true, roomId, capUsd: cap };
277
+ }
278
+
279
+ // ── Reserve ───────────────────────────────────────────────────────
280
+ /**
281
+ * Atomically reserve budget inside a BEGIN IMMEDIATE transaction.
282
+ * @returns {ReserveResult}
283
+ */
284
+ reserve(roomId, callerId, estimatedUsd, opts = {}) {
285
+ if (!Number.isFinite(Number(estimatedUsd)) || Number(estimatedUsd) < 0) {
286
+ throw new Error('estimatedUsd must be a non-negative finite number');
287
+ }
288
+ const amount = Number(estimatedUsd);
289
+ const expiryMs = clampReservationExpiry(opts.expiryMs);
290
+ const metadata = opts.metadata ? JSON.stringify(opts.metadata) : '{}';
291
+
292
+ const nowMs = this.clock();
293
+ const txId = `tx-${randomUUID()}`;
294
+ const expiresAt = nowMs + expiryMs;
295
+
296
+ // better-sqlite3 auto-commits each .run(). For atomicity we use the
297
+ // `transaction()` builder, which produces a single function that
298
+ // wraps all the .run() / .get() calls inside BEGIN/COMMIT (and
299
+ // ROLLBACK on throw). ADR-164.1 §3.2 explicitly mandates
300
+ // BEGIN IMMEDIATE — better-sqlite3 always opens with
301
+ // BEGIN DEFERRED by default; we force it via `BEGIN IMMEDIATE` for
302
+ // the atomic reserve flow by writing the transaction() builder
303
+ // as a single BEGIN IMMEDIATE block.
304
+ const reserveTxn = this.db.transaction(() => {
305
+ const room = this._stmts.getRoom.get(roomId);
306
+ if (!room) {
307
+ return { ok: false, error: 'ROOM_NOT_FOUND' };
308
+ }
309
+ const capUsd = Number(room.monthly_cap_usd);
310
+ const totals = this._stmts.totalsThisCycle.get(nowMs, roomId);
311
+ const committed = Number(totals?.committed_total ?? 0);
312
+ const reserved = Number(totals?.reserved_total ?? 0);
313
+ const projected = committed + reserved + amount;
314
+ if (projected > capUsd + 1e-9) {
315
+ return {
316
+ ok: false,
317
+ error: 'BUDGET_EXCEEDED',
318
+ committed,
319
+ reserved,
320
+ capUsd,
321
+ };
322
+ }
323
+ this._stmts.insertTx.run(
324
+ txId,
325
+ roomId,
326
+ 'reserved',
327
+ amount,
328
+ callerId || '',
329
+ nowMs,
330
+ expiresAt,
331
+ null,
332
+ metadata,
333
+ );
334
+ return {
335
+ ok: true,
336
+ txId,
337
+ expiresAt,
338
+ remainingAfterReserve: capUsd - projected,
339
+ };
340
+ });
341
+ // Force BEGIN IMMEDIATE so we hold the write lock the entire
342
+ // time the cap check + insert runs (ADR-164.1 §3.2 + Bizar's
343
+ // "concurrent reserves must not double-spend" constraint).
344
+ reserveTxn.immediate;
345
+ const result = reserveTxn.immediate();
346
+ if (result.ok) {
347
+ if (this.logger) this.logger.info?.('cost.reserve', result);
348
+ }
349
+ return result;
350
+ }
351
+
352
+ // ── Commit ────────────────────────────────────────────────────────
353
+ /**
354
+ * Commit the reservation with the actual cost. Late commits are
355
+ * accepted (warn: 'COMMIT_AFTER_EXPIRY').
356
+ * @returns {CommitResult}
357
+ */
358
+ commit(txId, actualUsd) {
359
+ if (!Number.isFinite(Number(actualUsd)) || Number(actualUsd) < 0) {
360
+ throw new Error('actualUsd must be a non-negative finite number');
361
+ }
362
+ const amount = Number(actualUsd);
363
+ const nowMs = this.clock();
364
+
365
+ const updateAmountAndState = this.db.prepare(
366
+ `UPDATE transactions SET kind = ?, amount_usd = ?, committed_at = ? WHERE tx_id = ?`,
367
+ );
368
+
369
+ const commitTxn = this.db.transaction(() => {
370
+ const row = this._stmts.getTx.get(txId);
371
+ if (!row) return { ok: false, error: 'NOT_FOUND' };
372
+ if (row.kind === 'committed' || row.kind === 'committed_post_expiry' || row.kind === 'released') {
373
+ return { ok: false, error: 'ALREADY_FINALIZED' };
374
+ }
375
+
376
+ // Late commit? Even if the row's state is still 'reserved' (or
377
+ // 'expired' if a sweeper beat us to it), the expires_at may
378
+ // have already passed. ADR-164.1 §5.3 — accept the charge and
379
+ // warn.
380
+ const late = row.expires_at != null && row.expires_at <= nowMs;
381
+ const newKind = late ? 'committed_post_expiry' : 'committed';
382
+
383
+ // IMPORTANT: replace the estimated amount with the actual cost
384
+ // on commit. The reservation's amount_usd reflects the upper
385
+ // bound; the actual cost may be smaller (cheaper model) or,
386
+ // in late-commit cases, may approximate the work that already
387
+ // ran. After this UPDATE, the SUM(... committed/committed_post_expiry)
388
+ // returns the real spend.
389
+ updateAmountAndState.run(newKind, amount, nowMs, txId);
390
+
391
+ // Update room.spent_usd to reflect the (now corrected) actual
392
+ // totals — kept in sync for status()'s `spentUsd`.
393
+ const totals = this._stmts.totalsThisCycle.get(nowMs + 1, row.room_id);
394
+ const committedTotal = Number(totals?.committed_total ?? 0);
395
+ this._stmts.setSpentUsd.run(committedTotal, row.room_id);
396
+
397
+ const room = this._stmts.getRoom.get(row.room_id);
398
+ const cap = Number(room?.monthly_cap_usd ?? 0);
399
+ const finalRemaining = cap - (committedTotal + Number(totals?.reserved_total ?? 0));
400
+
401
+ if (late) {
402
+ return {
403
+ ok: true,
404
+ warned: 'COMMIT_AFTER_EXPIRY',
405
+ finalRemaining,
406
+ };
407
+ }
408
+ return { ok: true, committed: true, finalRemaining };
409
+ });
410
+ return commitTxn.immediate();
411
+ }
412
+
413
+ // ── Release ───────────────────────────────────────────────────────
414
+ /**
415
+ * @returns {ReleaseResult}
416
+ */
417
+ release(txId) {
418
+ const releaseTxn = this.db.transaction(() => {
419
+ const row = this._stmts.getTx.get(txId);
420
+ if (!row) return { ok: false, error: 'NOT_FOUND' };
421
+ if (row.kind !== 'reserved') {
422
+ return { ok: false, error: 'ALREADY_FINALIZED' };
423
+ }
424
+ this._stmts.flipTxState.run('released', this.clock(), txId);
425
+ return { ok: true, released: true };
426
+ });
427
+ return releaseTxn.immediate();
428
+ }
429
+
430
+ // ── Status ────────────────────────────────────────────────────────
431
+ /**
432
+ * @returns {RoomStatus | null}
433
+ */
434
+ status(roomId) {
435
+ const room = this._stmts.getRoom.get(roomId);
436
+ if (!room) return null;
437
+ const capUsd = Number(room.monthly_cap_usd);
438
+ const now = this.clock();
439
+ const totals = this._stmts.totalsThisCycle.get(now, roomId);
440
+ const committed = Number(totals?.committed_total ?? 0);
441
+ const reserved = Number(totals?.reserved_total ?? 0);
442
+ const count = this._stmts.txCountForRoom.get(roomId);
443
+ return {
444
+ roomId,
445
+ capUsd,
446
+ spentUsd: committed,
447
+ reservedUsd: reserved,
448
+ remainingUsd: capUsd - (committed + reserved),
449
+ txCount: Number(count?.n ?? 0),
450
+ lastResetAt: Number(room.last_reset_at),
451
+ };
452
+ }
453
+
454
+ listRooms() {
455
+ const rows = this._stmts.listRooms.all();
456
+ return rows.map((r) => {
457
+ const totals = this._stmts.totalsThisCycle.get(this.clock(), r.id);
458
+ const committed = Number(totals?.committed_total ?? 0);
459
+ const reserved = Number(totals?.reserved_total ?? 0);
460
+ const capUsd = Number(r.monthly_cap_usd);
461
+ return {
462
+ roomId: r.id,
463
+ capUsd,
464
+ spentUsd: committed,
465
+ reservedUsd: reserved,
466
+ remainingUsd: capUsd - (committed + reserved),
467
+ lastResetAt: Number(r.last_reset_at),
468
+ };
469
+ });
470
+ }
471
+
472
+ // ── Sweep ─────────────────────────────────────────────────────────
473
+ /**
474
+ * Sweep expired reservations to 'expired' state. Returns the number
475
+ * of rows updated. Driven by setInterval at the integration site.
476
+ */
477
+ sweepExpired(nowMs) {
478
+ const ts = nowMs == null ? this.clock() : Number(nowMs);
479
+ const result = this._stmts.sweepExpired.run(ts);
480
+ return Number(result.changes ?? 0);
481
+ }
482
+
483
+ // ── Test-only / introspection ────────────────────────────────────
484
+ /** Return raw transactions for a room. Used by tests + CLI status. */
485
+ listTransactions(roomId) {
486
+ return this._stmts.listTxForRoom.all(roomId);
487
+ }
488
+ }
489
+
490
+ // ── File-path helper (default location under .bizar/) ────────────────
491
+
492
+ import { homedir } from 'node:os';
493
+ import { join } from 'node:path';
494
+
495
+ /** Default DB path: $HOME/.bizar/cost-gate.db (or override via BIZAR_COST_GATE_DB). */
496
+ export function defaultDbPath() {
497
+ if (process.env.BIZAR_COST_GATE_DB && process.env.BIZAR_COST_GATE_DB.trim()) {
498
+ return process.env.BIZAR_COST_GATE_DB.trim();
499
+ }
500
+ return join(homedir(), '.bizar', 'cost-gate.db');
501
+ }