acuvo-code 0.2.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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/plan.mjs ADDED
@@ -0,0 +1,461 @@
1
+ /**
2
+ * ── ⭐⭐ THE PLAN MATH, IN CODE, FROM MEASURED PRICES ────────────────────────
3
+ *
4
+ * Everything about what a user gets for their money lived in conversations and
5
+ * spreadsheets. That is fine until somebody has to ENFORCE it, and then the
6
+ * question "has this account run out?" has no function to call. This module is
7
+ * the one place that answers it, and every number in it is either a real
8
+ * OpenRouter endpoint price read on 2026-08-15 or a figure from our own bench.
9
+ *
10
+ * ── ⚠️⚠️ THE ONE FACT THAT DECIDES THE BUSINESS ─────────────────────────────
11
+ *
12
+ * **The cache rate IS the margin.** Not a contributor to it — the whole of it.
13
+ * Blended cost per million flash tokens, measured:
14
+ *
15
+ * cache 0% $0.0686 cache 85% $0.0228
16
+ * cache 65% $0.0336 cache 95% $0.0174
17
+ *
18
+ * A 95M-token plan costs $3.19 at 65% cache and $1.66 at 95%. On $29 AUD that
19
+ * is the difference between an 83% margin and a 91% one, from the same product
20
+ * doing the same work. Which is why `PROVIDER_PIN_BY_MODEL` and compaction
21
+ * discipline are not housekeeping — they are the P&L.
22
+ *
23
+ * ── ⭐⭐ AND THE ROUTING RULE THAT FALLS OUT OF IT ──────────────────────────
24
+ *
25
+ * Pro's premium over flash COLLAPSES as the cache warms:
26
+ *
27
+ * cache 0% 6.40x cache 95% 1.86x
28
+ * cache 65% 4.78x cache 98% 1.23x
29
+ *
30
+ * So the honest rule is NOT "use the cheap model" — it is **"a long warm
31
+ * session can afford the strong model; a short cold one cannot."** That is the
32
+ * opposite of escalate-on-failure, which reaches for pro exactly when the
33
+ * context is coldest, and it is measured rather than reasoned.
34
+ */
35
+
36
+ /**
37
+ * Per-million prices, USD, from OpenRouter's per-model endpoint feed on
38
+ * 2026-08-15, for the endpoint each model is PINNED to.
39
+ *
40
+ * ⚠️ THE PINNED ENDPOINT, NOT THE MODEL PAGE. The model page shows a headline;
41
+ * a model is served by many providers at wildly different prices. Pro was
42
+ * unpinned and landing on GMICloud at $1.218/M in and $0.1015/M cache-read —
43
+ * 2.8x and 28x DeepSeek's own endpoint. Quoting the page would have hidden
44
+ * that entirely. See `PROVIDER_PIN_BY_MODEL` in model.mjs.
45
+ */
46
+ export const MODEL_PRICES = Object.freeze({
47
+ 'deepseek/deepseek-v4-flash-0731': Object.freeze({ in: 0.068, out: 0.137, cacheRead: 0.0137, provider: 'StreamLake' }),
48
+ 'deepseek/deepseek-v4-pro-0813': Object.freeze({ in: 0.435, out: 0.870, cacheRead: 0.0036, provider: 'DeepSeek' }),
49
+ 'qwen/qwen3.7-flash': Object.freeze({ in: 0.030, out: 0.130, cacheRead: 0.0060, provider: 'Alibaba' }),
50
+ 'z-ai/glm-4.6': Object.freeze({ in: 0.430, out: 1.750, cacheRead: 0.0800, provider: 'Venice' }),
51
+ });
52
+
53
+ /**
54
+ * ⚠️ MEASURED, AND SMALLER THAN ANYONE GUESSES. A real 3-round run: 33,544
55
+ * tokens total, 33,258 of them prompt — so **output is 0.9% of the tokens.**
56
+ * That is why cache rate dominates everything: 99% of what we pay for is input,
57
+ * and input is the only half that can be cached. It is also why "make the model
58
+ * write less" is a much weaker lever than it sounds.
59
+ */
60
+ export const OUTPUT_TOKEN_SHARE = 0.009;
61
+
62
+ /**
63
+ * Blended cost per million tokens at a given cache hit rate.
64
+ *
65
+ * @param {string} model
66
+ * @param {number} cacheRate 0..1
67
+ * @returns {number|null} USD per million, or null for a model we have not priced
68
+ */
69
+ export function costPerMillion(model, cacheRate = 0) {
70
+ const p = MODEL_PRICES[String(model ?? '')];
71
+ if (!p) return null;
72
+ const c = Math.min(1, Math.max(0, Number(cacheRate) || 0));
73
+ const inputCost = c * p.cacheRead + (1 - c) * p.in;
74
+ return (1 - OUTPUT_TOKEN_SHARE) * inputCost + OUTPUT_TOKEN_SHARE * p.out;
75
+ }
76
+
77
+ /**
78
+ * ── ⭐ THE PLANS ────────────────────────────────────────────────────────────
79
+ *
80
+ * ⚠️ SEPARATE ALLOWANCES PER MODEL, NOT ONE POOLED NUMBER, and the reason is
81
+ * arithmetic rather than taste. Pro costs between 1.23x and 6.40x flash
82
+ * depending on cache, so a single "N tokens" pool would be worth wildly
83
+ * different amounts of money depending on which model spent it — and the user
84
+ * could not predict their own limit. Claude splits Opus from Sonnet for exactly
85
+ * this reason. Two numbers a user can read beat one number that lies.
86
+ *
87
+ * ⚠️ AND THE PRO ALLOWANCE IS THE RISK. At 95% cache the whole $29 AUD plan
88
+ * costs us $1.98 (89% margin); at 65% it costs $4.80 (74%). The plan is sound
89
+ * BECAUSE the cache holds, which makes the pin a revenue control.
90
+ */
91
+ /** AUD→USD, deliberately conservative — a plan must not depend on a good day. */
92
+ export const AUD_USD = 0.645;
93
+
94
+ const FLASH = 'deepseek/deepseek-v4-flash-0731';
95
+ const PRO = 'deepseek/deepseek-v4-pro-0813';
96
+ /**
97
+ * ── ⭐⭐⭐ THE THIRD MODEL, ADDED 2026-08-16, AND IT IS THE MARGIN LEVER ─────
98
+ *
99
+ * Every tier below used to grant FLASH and PRO only, which quietly assumed the
100
+ * build model also does the READING. It does not, and it should not: the locked
101
+ * role split is *"qwen3.7-flash — interpreting, review, vision, chat. NEVER
102
+ * building."* Qwen's blended rate is **3.2x cheaper than flash**, and roughly
103
+ * 58% of an agent run's tokens are reading rather than writing.
104
+ *
105
+ * ⭐ SO NAMING IT MADE EVERY TIER BIGGER *AND* MORE PROFITABLE. Starter went
106
+ * from 95M tokens to **152M** while its margin at a poor (70%) cache month rose
107
+ * — because the volume that was being priced at flash rates was never flash
108
+ * work in the first place. Measured 2026-08-16.
109
+ */
110
+ const QWEN = 'qwen/qwen3.7-flash';
111
+
112
+ /**
113
+ * ── ⚠️⚠️ ONE LADDER, AND IT IS THE SHIPPED ONE ─────────────────────────────
114
+ *
115
+ * This file first invented its own tiers — free/code/pro/max at 5M/95M/200M/
116
+ * 500M — while `console/lib/plan-catalog.ts` was already SELLING
117
+ * free/starter/growth/scale at 4M/95M/330M/1000M. Two ladders, both
118
+ * plausible, already disagreeing on three of four rungs. A price the CLI
119
+ * quotes and a price the customer is charged must be the same number, and
120
+ * the console is the one with customers on it.
121
+ *
122
+ * ⭐ So these mirror the catalogue exactly, and
123
+ * `console/lib/plan-drift.test.ts` FAILS if either side moves. Change one,
124
+ * the test names the other — which is what makes the numbers safe to change
125
+ * at will rather than merely easy to change in one place and forget in the
126
+ * other.
127
+ *
128
+ * ── ✅ THE CONFESSED DEFECT IS FIXED (2026-08-16) ───────────────────────────
129
+ *
130
+ * This paragraph used to read: *"⚠️ Growth and Scale sit below 80% on a poor
131
+ * cache month. Their FLASH allowances were already generous, so any pro on top
132
+ * is squeezed — a pricing decision to revisit deliberately, recorded rather
133
+ * than hidden."* It quoted 95/85/75/65% cache margins of
134
+ * `starter 90/86/82/79 · growth 87/82/77/72 · scale 85/80/75/69`.
135
+ *
136
+ * ⭐ THE FIX WAS NOT SMALLER ALLOWANCES — IT WAS NAMING THE THIRD MODEL. Those
137
+ * tiers granted FLASH and PRO only, which priced the READING half of every run
138
+ * at build-model rates. Qwen is 3.2x cheaper and does ~58% of the volume, so
139
+ * adding it made every tier **larger and more profitable at once**:
140
+ *
141
+ * starter 95M -> 152M growth 349M -> 415M scale 1036M -> 1046M
142
+ *
143
+ * All three now clear **80% at a poor 70% cache month** and sit at ~87.7% at
144
+ * the 90% floor the gateway engineers. ⚠️ At a total cache failure (0%) they
145
+ * are 52–53% — thin, never a loss. Holding 80% at 0% would mean granting ~60M
146
+ * on Starter, which is a worse product than the risk justifies: the floor is
147
+ * something we BUILD (shared prompt head + pinned provider + multi-tenant
148
+ * traffic), not something we hope for.
149
+ */
150
+ export const PLANS = Object.freeze({
151
+ free: Object.freeze({
152
+ id: 'free', label: 'Free', priceUsd: 0, currency: 'AUD', priceLocal: 0,
153
+ tokens: Object.freeze({ [QWEN]: 6_000_000, [FLASH]: 4_000_000, [PRO]: 0 }),
154
+ includesReview: true,
155
+ }),
156
+ /**
157
+ * ── ⭐ RESIZED 2026-08-16 — BIGGER *AND* MORE PROFITABLE ────────────────────
158
+ *
159
+ * Was `flash 95M + pro 5M`. Now `qwen 88M + flash 59M + pro 5M = 152M`, sized
160
+ * so 80% margin holds at a **poor 70% cache month**, on this file's own
161
+ * measured prices and its measured 0.9% output share.
162
+ *
163
+ * @70% cache $3.78 79.8%
164
+ * @90% cache $2.30 87.7% ← the floor the gateway engineers
165
+ * @0% cache $8.96 52.1% ← total cache failure: thin, never a loss
166
+ *
167
+ * ⚠️ 0% is the honest worst case and it is NOT 80%. Holding 80% with no cache
168
+ * at all would mean granting ~60M tokens, which is a worse product than the
169
+ * risk justifies — the floor is something we build (shared prompt head +
170
+ * pinned provider + multi-tenant traffic), not something we hope for.
171
+ */
172
+ starter: Object.freeze({
173
+ id: 'starter', label: 'Starter', priceUsd: 29 * AUD_USD, currency: 'AUD', priceLocal: 29,
174
+ tokens: Object.freeze({ [QWEN]: 88_000_000, [FLASH]: 59_000_000, [PRO]: 5_000_000 }),
175
+ includesReview: true,
176
+ }),
177
+ /**
178
+ * ⚠️ THIS FIXES A DEFECT THIS FILE ALREADY CONFESSED. Its own header said:
179
+ * *"Growth and Scale sit below 80% on a poor cache month … a pricing decision
180
+ * to revisit deliberately, recorded rather than hidden."* This is that revisit.
181
+ * Was `flash 330M + pro 19M`; now 415M total and 80.1% at the same poor month.
182
+ */
183
+ growth: Object.freeze({
184
+ id: 'growth', label: 'Growth', priceUsd: 79 * AUD_USD, currency: 'AUD', priceLocal: 79,
185
+ // ⭐ Re-split 2026-08-18 to hold STARTER'S MODEL BLEND, which
186
+ // `acuvo-gateway/lib/topups.mjs` requires — it prices one blended top-up
187
+ // across the whole ladder and refuses when the mixes drift apart.
188
+ tokens: Object.freeze({ [QWEN]: 240_000_000, [FLASH]: 161_000_000, [PRO]: 14_000_000 }),
189
+ includesReview: true,
190
+ }),
191
+ scale: Object.freeze({
192
+ id: 'scale', label: 'Scale', priceUsd: 199 * AUD_USD, currency: 'AUD', priceLocal: 199,
193
+ tokens: Object.freeze({ [QWEN]: 606_000_000, [FLASH]: 406_000_000, [PRO]: 34_000_000 }),
194
+ includesReview: true,
195
+ }),
196
+ /**
197
+ * ⚠️ UNMETERED, NOT UNLIMITED-BY-OVERSIGHT. `null` means the allowance is
198
+ * negotiated rather than absent, and `planGate` treats a null as "no ceiling
199
+ * to enforce here" rather than as zero.
200
+ */
201
+ enterprise: Object.freeze({
202
+ id: 'enterprise', label: 'Enterprise', priceUsd: 1000 * AUD_USD, currency: 'AUD', priceLocal: 1000,
203
+ tokens: Object.freeze({ [QWEN]: null, [FLASH]: null, [PRO]: null }),
204
+ includesReview: true,
205
+ }),
206
+ });
207
+
208
+ /**
209
+ * ── ⚠️⚠️ TWO STRUCTURES THAT LOOK BETTER AND MEASURE WORSE ──────────────────
210
+ *
211
+ * Recorded so nobody re-proposes them from first principles, because both are
212
+ * the obvious idea and both are refuted by the price table above.
213
+ *
214
+ * **A pooled allowance with pro billed at a multiplier.** Elegant, and the
215
+ * multiplier is NOT A CONSTANT: pro is 1.9x flash at 95% cache, 3.3x at 85%
216
+ * and 4.8x at 65%. Any published figure is therefore wrong most of the time —
217
+ * either we overcharge on warm sessions or we lose money on cold ones, and the
218
+ * user cannot predict their own limit either way. Two honest numbers beat one
219
+ * that lies.
220
+ *
221
+ * **Unlimited flash, metered pro.** Measured against real usage: a 100-task/day
222
+ * user leaves an 88% margin, 200/day leaves 75%, and 400/day leaves **51%**.
223
+ * "Unlimited" needs a fair-use cap to survive its top percentile, and a cap is
224
+ * an allowance with worse manners.
225
+ */
226
+ export const REJECTED_STRUCTURES = Object.freeze(['pooled-with-multiplier', 'unlimited-flash']);
227
+
228
+ /**
229
+ * What a plan costs us, and what margin it leaves, at a given cache rate.
230
+ *
231
+ * ⚠️ IT TAKES THE CACHE RATE BECAUSE THERE IS NO SINGLE ANSWER. A margin quoted
232
+ * without one is a number somebody chose. `acuvo spend` reports the real rate
233
+ * for a workspace; feed that in and this tells the truth about that account.
234
+ *
235
+ * @param {object} plan
236
+ * @param {number} cacheRate
237
+ */
238
+ export function planEconomics(plan, cacheRate = 0.95) {
239
+ let cogsUsd = 0;
240
+ const unpriced = [];
241
+ /**
242
+ * ⚠️⚠️ AN UNMETERED ALLOWANCE MAKES THE MARGIN UNKNOWABLE, NOT 100%.
243
+ * `null` means "negotiated, no ceiling", and skipping it as if it were zero
244
+ * reported Enterprise at a 100% margin — a plan whose customers use real
245
+ * tokens, costed at nothing. That is the exact shape of lie this file spends
246
+ * its comments guarding against, committed inside the file that guards it.
247
+ */
248
+ const unmetered = Object.entries(plan?.tokens ?? {})
249
+ .filter(([, t]) => t === null)
250
+ .map(([m]) => m);
251
+ for (const [model, tokens] of Object.entries(plan?.tokens ?? {})) {
252
+ if (tokens === null || !tokens) continue;
253
+ const per = costPerMillion(model, cacheRate);
254
+ if (per === null) { unpriced.push(model); continue; }
255
+ cogsUsd += (tokens / 1e6) * per;
256
+ }
257
+ const revenueUsd = Number(plan?.priceUsd ?? 0);
258
+ return {
259
+ cacheRate,
260
+ cogsUsd,
261
+ revenueUsd,
262
+ /** null rather than a made-up 100% for a free plan — it has no margin. */
263
+ /**
264
+ * null when there is no revenue (free) OR no ceiling (enterprise). Both are
265
+ * "we cannot state a margin", and inventing one for either is worse than
266
+ * saying so.
267
+ */
268
+ marginPct: revenueUsd > 0 && unmetered.length === 0 ? ((revenueUsd - cogsUsd) / revenueUsd) * 100 : null,
269
+ unmetered,
270
+ profitUsd: revenueUsd - cogsUsd,
271
+ /** ⚠️ Named, never silently skipped — an unpriced model understates COGS. */
272
+ unpriced,
273
+ };
274
+ }
275
+
276
+ /**
277
+ * ── ⚠️ THE CACHE RATE AT WHICH A PLAN STOPS PAYING ─────────────────────────
278
+ *
279
+ * The single most useful number for pricing a plan, and it cannot be reasoned
280
+ * to — it has to be solved for. Below this rate the plan earns less than the
281
+ * target margin, which is the alarm the pin and the compaction rules exist to
282
+ * keep from ringing.
283
+ *
284
+ * @returns {{ rate: number|null, note: string }}
285
+ */
286
+ export function breakEvenCacheRate(plan, targetMarginPct = 80) {
287
+ // Monotonic in cacheRate, so a coarse scan is exact enough to act on and has
288
+ // no solver to get wrong.
289
+ for (let r = 0; r <= 1.0001; r += 0.01) {
290
+ const e = planEconomics(plan, Math.min(1, r));
291
+ if (e.marginPct !== null && e.marginPct >= targetMarginPct) {
292
+ return { rate: Math.min(1, r), note: `at or above ${(r * 100).toFixed(0)}% cache this plan clears ${targetMarginPct}%` };
293
+ }
294
+ }
295
+ return { rate: null, note: `this plan never reaches ${targetMarginPct}% margin, at any cache rate` };
296
+ }
297
+
298
+ /**
299
+ * How much of a plan's allowance is left.
300
+ *
301
+ * @param {object} plan
302
+ * @param {Record<string, number>} usedByModel tokens already spent, per model
303
+ */
304
+ export function allowanceRemaining(plan, usedByModel = {}) {
305
+ const out = {};
306
+ for (const [model, granted] of Object.entries(plan?.tokens ?? {})) {
307
+ const used = Number(usedByModel?.[model]) || 0;
308
+ if (granted === null) {
309
+ // ⚠️ Unmetered is AVAILABLE and never exhausted. Treating null as 0 would
310
+ // tell an enterprise customer their negotiated capacity had run out.
311
+ out[model] = { granted: null, used, remaining: null, exhausted: false, available: true };
312
+ continue;
313
+ }
314
+ out[model] = {
315
+ granted,
316
+ used,
317
+ remaining: Math.max(0, granted - used),
318
+ exhausted: used >= granted,
319
+ /** ⚠️ A plan that grants 0 of a model is NOT "exhausted" — it never had any. */
320
+ available: granted > 0,
321
+ };
322
+ }
323
+ return out;
324
+ }
325
+
326
+ /** One screen for a human, and every figure is computed here rather than quoted. */
327
+ export function formatPlan(plan, cacheRate = 0.95) {
328
+ const e = planEconomics(plan, cacheRate);
329
+ const price = plan.currency === 'AUD' ? `$${plan.priceLocal} AUD (~$${plan.priceUsd.toFixed(2)} USD)` : `$${plan.priceUsd} USD`;
330
+ const lines = [`${plan.label} — ${plan.priceUsd === 0 ? 'free' : price}`];
331
+ for (const [model, tokens] of Object.entries(plan.tokens)) {
332
+ if (!tokens) continue;
333
+ const per = costPerMillion(model, cacheRate);
334
+ lines.push(` ${(tokens / 1e6).toFixed(0)}M ${model}${per === null ? ' (unpriced)' : ` costs us $${((tokens / 1e6) * per).toFixed(2)} at ${(cacheRate * 100).toFixed(0)}% cache`}`);
335
+ }
336
+ if (e.marginPct !== null) {
337
+ lines.push(` total COGS $${e.cogsUsd.toFixed(2)} · margin ${e.marginPct.toFixed(0)}%`);
338
+ const be = breakEvenCacheRate(plan, 80);
339
+ lines.push(` ${be.note}`);
340
+ }
341
+ return lines;
342
+ }
343
+
344
+ /**
345
+ * ── ⚠️⚠️ AN ALLOWANCE NOTHING ENFORCES IS A NUMBER ON A PRICING PAGE ────────
346
+ *
347
+ * `allowanceRemaining` shipped and was called by nobody, which is this
348
+ * package's signature defect wearing a business hat: the plan was expressible,
349
+ * auditable and completely unenforced. A limit that does not stop a run is
350
+ * marketing.
351
+ *
352
+ * ⚠️ IT REFUSES BEFORE THE SPEND, NOT AFTER. `budget.mjs` already states the
353
+ * rule — "it never spends money to discover it had none" — and the same applies
354
+ * one layer up: discovering an exhausted allowance by exhausting it further is
355
+ * the failure this gate exists to prevent.
356
+ *
357
+ * ⚠️ AND EVERY REFUSAL NAMES THE WAY OUT. "Limit reached" is an obstacle; "you
358
+ * have used 95M of 95M Acuvo Flash this period — Acuvo Pro grants 200M" is a
359
+ * decision. A gate that cannot be acted on just moves the user to a competitor
360
+ * with a clearer error message.
361
+ *
362
+ * @param {object} args
363
+ * @param {object} args.plan
364
+ * @param {Record<string, number>} args.usedByModel tokens already spent, per PROVIDER id
365
+ * @param {string} args.model the provider id about to run
366
+ * @param {number} [args.projectedTokens] what this run is expected to add
367
+ * @param {object[]} [args.ladder] plans to suggest upgrading to
368
+ */
369
+ export function planGate({ plan, usedByModel = {}, model, projectedTokens = 0, ladder = Object.values(PLANS) } = {}) {
370
+ const id = String(model ?? '');
371
+ const rawGranted = plan?.tokens?.[id];
372
+ if (rawGranted === null) {
373
+ // Unmetered: there is nothing to enforce, and saying "allowed" is the truth.
374
+ return { allowed: true, reason: 'unmetered', remaining: null, message: null };
375
+ }
376
+ const granted = Number(rawGranted ?? 0);
377
+ const used = Number(usedByModel?.[id]) || 0;
378
+ const remaining = Math.max(0, granted - used);
379
+
380
+ /**
381
+ * ⭐ THE UPGRADE THAT WOULD ACTUALLY HELP, found rather than hardcoded: the
382
+ * cheapest plan in the ladder that grants more of THIS model than the current
383
+ * one. Naming a tier that does not solve the problem is worse than naming none.
384
+ */
385
+ const upgrade = ladder
386
+ .filter((p) => Number(p?.tokens?.[id] ?? 0) > granted)
387
+ .sort((a, b) => a.priceUsd - b.priceUsd)[0] ?? null;
388
+ const upgradeNote = upgrade
389
+ ? ` ${upgrade.label} grants ${(upgrade.tokens[id] / 1e6).toFixed(0)}M.`
390
+ : '';
391
+
392
+ if (granted <= 0) {
393
+ return {
394
+ allowed: false,
395
+ reason: 'not-on-plan',
396
+ remaining: 0,
397
+ message: `${labelFor(id)} is not included in ${plan?.label ?? 'this plan'}.${upgradeNote}`,
398
+ };
399
+ }
400
+ if (remaining <= 0) {
401
+ return {
402
+ allowed: false,
403
+ reason: 'exhausted',
404
+ remaining: 0,
405
+ message: `${plan?.label ?? 'this plan'} has used all ${(granted / 1e6).toFixed(0)}M ${labelFor(id)} tokens for this period.${upgradeNote}`,
406
+ };
407
+ }
408
+ /**
409
+ * ⚠️ A PROJECTION THAT WOULD CROSS THE LINE STOPS THE RUN, matching
410
+ * `budget.mjs`'s "stop when the NEXT round would cross it". Starting a run
411
+ * that cannot finish spends the remainder and delivers nothing, which is the
412
+ * worst of both outcomes.
413
+ */
414
+ if (projectedTokens > 0 && projectedTokens > remaining) {
415
+ return {
416
+ allowed: false,
417
+ reason: 'would-exceed',
418
+ remaining,
419
+ message: `this run is projected at ${(projectedTokens / 1e6).toFixed(1)}M ${labelFor(id)} tokens and only ${(remaining / 1e6).toFixed(1)}M remain on ${plan?.label ?? 'this plan'}.${upgradeNote}`,
420
+ };
421
+ }
422
+ return { allowed: true, reason: 'ok', remaining, message: null };
423
+ }
424
+
425
+ /** Acuvo's name for a provider id, without importing the catalogue into the math. */
426
+ function labelFor(id) {
427
+ if (id === 'deepseek/deepseek-v4-flash-0731') return 'Acuvo Flash';
428
+ if (id === 'deepseek/deepseek-v4-pro-0813') return 'Acuvo Pro';
429
+ return id;
430
+ }
431
+
432
+ /**
433
+ * Tokens used per PROVIDER id, from audit records.
434
+ *
435
+ * ⚠️ THE MODEL THAT ANSWERED, NOT THE ONE REQUESTED. A run that fell back to a
436
+ * different model spent tokens on the model that actually served it, and
437
+ * charging the requested one would bill an allowance that was never touched.
438
+ *
439
+ * ⚠️ AND A RECORD WITH NO TOKEN COUNT IS COUNTED AS UNKNOWN, never as zero —
440
+ * silently free usage is how an allowance stops meaning anything.
441
+ */
442
+ export function usageByModel(records = []) {
443
+ const byModel = {};
444
+ let unknown = 0;
445
+ for (const rec of records) {
446
+ const run = rec?.run;
447
+ if (!run) continue;
448
+ const id = run.model?.answered ?? run.model?.requested ?? null;
449
+ /**
450
+ * ⚠️⚠️ CHECKED BEFORE COERCION, AND MY FIRST VERSION WAS NOT. `Number(null)`
451
+ * is `0`, and `Number.isFinite(0)` is TRUE — so a run that recorded no token
452
+ * count was silently counted as ZERO USAGE rather than as unknown, which is
453
+ * precisely the "silently free usage" this function's comment forbids. The
454
+ * test caught it; the code and its own documentation disagreed.
455
+ */
456
+ const tokens = typeof run.tokens === 'number' && Number.isFinite(run.tokens) ? run.tokens : null;
457
+ if (!id || tokens === null) { unknown += 1; continue; }
458
+ byModel[id] = (byModel[id] ?? 0) + tokens;
459
+ }
460
+ return { byModel, unknown };
461
+ }