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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐⭐ WHICH ENGINE DREW IT, AND WHO DECIDED — THE CLI HALF ──────────────
|
|
3
|
+
*
|
|
4
|
+
* Roman, 2026-08-16: *"in the CLI and in the builder the AI will ask which
|
|
5
|
+
* Acuvo image model or video model they want, basic or premium, because how
|
|
6
|
+
* else are we going to do this."* And on the people paying the most:
|
|
7
|
+
* *"they might not always want that."*
|
|
8
|
+
*
|
|
9
|
+
* ── ⚠️ THE RULE, AND IT IS THE WHOLE FILE: UNLOCKED, NEVER DEFAULTED ────────
|
|
10
|
+
*
|
|
11
|
+
* Every medium's default is its CORE engine. An Ultra engine runs only when a
|
|
12
|
+
* human named it. A premium engine that fires on its own spends somebody's
|
|
13
|
+
* credits on a decision they did not make — and at **585 credits a clip against
|
|
14
|
+
* Starter's 2,000**, one silent escalation is a quarter of their month.
|
|
15
|
+
* `defaultEngineFor` therefore has no path that can return an `ultra` engine,
|
|
16
|
+
* the same way `console/lib/creative-engines.ts` has none.
|
|
17
|
+
*
|
|
18
|
+
* ── ⭐⭐⭐ AND THE ARCHITECTURAL CONSTRAINT THAT SHAPES EVERYTHING BELOW ─────
|
|
19
|
+
*
|
|
20
|
+
* **THIS PACKAGE MUST NOT HOLD THE PRICE LIST.** Two reasons, and both are
|
|
21
|
+
* fatal rather than untidy:
|
|
22
|
+
*
|
|
23
|
+
* 1. ⚠️ IT IS PUBLISHED. `acuvo-code` is an npm package on somebody else's
|
|
24
|
+
* disk. A price compiled into it is the price that shipped on the day they
|
|
25
|
+
* installed — so the day we re-price a rented model, every un-upgraded
|
|
26
|
+
* copy quotes the old number and we either honour a price we no longer
|
|
27
|
+
* have, or we quote one figure and charge another. Neither is survivable
|
|
28
|
+
* and both are silent.
|
|
29
|
+
* 2. ⚠️⚠️ IT IS EDITABLE. `node_modules/acuvo-code/lib/creative-engines.mjs`
|
|
30
|
+
* is a text file the customer owns. A credit cost held here is a number
|
|
31
|
+
* the person being billed can change. Entitlement and price are facts
|
|
32
|
+
* about an ACCOUNT and they live on the server, next to the balance they
|
|
33
|
+
* are subtracted from.
|
|
34
|
+
*
|
|
35
|
+
* ⭐ SO THIS FILE HOLDS IDENTITY AND NOTHING ELSE: ids, display names, which
|
|
36
|
+
* medium, core-or-ultra, and what one unit is called. Those are LABELS — a
|
|
37
|
+
* forged label buys nothing, because the gateway is the thing that charges.
|
|
38
|
+
* Every number comes from `GET …/engines`, is cached under HOME, and is
|
|
39
|
+
* reported with its age. When there is no answer the honest output is
|
|
40
|
+
* "prices unavailable", never a plausible guess: a made-up price is worse than
|
|
41
|
+
* no price, because a user acts on it.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ THE IDS MIRROR `console/lib/creative-engines.ts` EXACTLY. That file is the
|
|
44
|
+
* catalogue of record (it owns `costMicros`, `minTier` and the upstream model);
|
|
45
|
+
* this one is a client that has to be able to name what it is asking for. If an
|
|
46
|
+
* id here has no counterpart there, the server answers with an engine this CLI
|
|
47
|
+
* has never heard of and the picker silently loses a row — which is why
|
|
48
|
+
* `mirrorsConsoleCatalogue` in the test compares the two lists by hand.
|
|
49
|
+
*
|
|
50
|
+
* ── ⚠️ BYOK = NEVER, AND THERE IS NO CREATIVE PATH THAT TAKES A USER KEY ────
|
|
51
|
+
*
|
|
52
|
+
* Roman, twice. Nothing in this file reads a provider key, and no engine here
|
|
53
|
+
* can be pointed at one. `ACUVO_IMAGE_SECRET` (imagegen.mjs) is OUR shared
|
|
54
|
+
* secret for OUR Modal GPU — the opposite of BYOK — and is not a way to buy
|
|
55
|
+
* capacity with your own OpenAI account. If a future engine needs a vendor key,
|
|
56
|
+
* that key belongs behind the gateway with all the others.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
60
|
+
import { homedir } from 'node:os';
|
|
61
|
+
import { join } from 'node:path';
|
|
62
|
+
|
|
63
|
+
import { accountDir, readAccount } from './account.mjs';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ── IDENTITY ONLY. NO MONEY. ────────────────────────────────────────────────
|
|
67
|
+
*
|
|
68
|
+
* `unit` is here because a refusal has to say "585 credits a CLIP" rather than
|
|
69
|
+
* "585 credits" — a number without its unit is the second half of a sentence.
|
|
70
|
+
* It is a noun, not a price.
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ `localReach` IS THE FIELD THAT KEEPS THIS FILE HONEST. It says whether
|
|
73
|
+
* THIS BINARY can run the engine at all, and it is `false` for four of the six
|
|
74
|
+
* because it genuinely is: there is no video module and no face module in this
|
|
75
|
+
* package (measured 2026-08-16 — `ls lib/` has neither), and no image path to
|
|
76
|
+
* gpt-image-2. Offering a picker that lists four engines nothing can run would
|
|
77
|
+
* be the dead-button failure this repo has already paid for twice.
|
|
78
|
+
*/
|
|
79
|
+
export const CREATIVE_ENGINES = Object.freeze([
|
|
80
|
+
Object.freeze({
|
|
81
|
+
id: 'acuvo-image',
|
|
82
|
+
name: 'Acuvo Image',
|
|
83
|
+
medium: 'image',
|
|
84
|
+
grade: 'core',
|
|
85
|
+
unit: 'image',
|
|
86
|
+
/**
|
|
87
|
+
* ⭐ REACHABLE, and it is the chain `generate_image` already runs: our own
|
|
88
|
+
* A10G first, then Perchance, then Pollinations.
|
|
89
|
+
*
|
|
90
|
+
* ⚠️ WHICH MEANS "Acuvo Image" IS SOMETIMES THE FREE FALLBACK. When our GPU
|
|
91
|
+
* is dark the picture comes from Pollinations — a shared free service — and
|
|
92
|
+
* `generateImage` already says so in its `note`. The brand names the SLOT a
|
|
93
|
+
* user chose, not a guarantee about which machine answered, and that
|
|
94
|
+
* distinction has to survive into the report or the note stops being true.
|
|
95
|
+
*/
|
|
96
|
+
localReach: true,
|
|
97
|
+
}),
|
|
98
|
+
Object.freeze({
|
|
99
|
+
id: 'acuvo-image-ultra',
|
|
100
|
+
name: 'Acuvo Image Ultra',
|
|
101
|
+
medium: 'image',
|
|
102
|
+
grade: 'ultra',
|
|
103
|
+
unit: 'image',
|
|
104
|
+
/**
|
|
105
|
+
* ⚠️ NOT REACHABLE FROM HERE, and it is not a config problem. gpt-image-2
|
|
106
|
+
* is a rented model behind OUR account; reaching it means a gateway render
|
|
107
|
+
* endpoint, and there is none (`acuvo-gateway/lib/handler.mjs` is a chat
|
|
108
|
+
* completions proxy and nothing else — read 2026-08-16). Writing a client
|
|
109
|
+
* for an endpoint that cannot be called once is how this package grows
|
|
110
|
+
* another capability that is built and unproven, which imagegen.mjs's own
|
|
111
|
+
* header already refuses to do for the flux studio.
|
|
112
|
+
*/
|
|
113
|
+
localReach: false,
|
|
114
|
+
}),
|
|
115
|
+
Object.freeze({ id: 'acuvo-video', name: 'Acuvo Video', medium: 'video', grade: 'core', unit: 'clip', localReach: false }),
|
|
116
|
+
Object.freeze({ id: 'acuvo-video-ultra', name: 'Acuvo Video Ultra', medium: 'video', grade: 'ultra', unit: 'clip', localReach: false }),
|
|
117
|
+
Object.freeze({
|
|
118
|
+
id: 'acuvo-voice',
|
|
119
|
+
name: 'Acuvo Voice',
|
|
120
|
+
medium: 'voice',
|
|
121
|
+
grade: 'sole',
|
|
122
|
+
unit: 'line',
|
|
123
|
+
/**
|
|
124
|
+
* ── ⚠️⚠️ `speak` IS NOT THIS ENGINE, AND ASSUMING IT WAS WOULD MISPRICE IT ─
|
|
125
|
+
*
|
|
126
|
+
* The obvious wiring is `speak` → "Acuvo Voice", and it is wrong. The
|
|
127
|
+
* catalogue's Acuvo Voice is **chatterbox-tts**: clone a voice from a few
|
|
128
|
+
* seconds of audio, then speak in it, measured at $0.00167 a line. What
|
|
129
|
+
* `lib/media.mjs` actually calls is `MODAL_TTS_URL` running **Kokoro**
|
|
130
|
+
* (Apache-2.0) — a fixed-voice reader that clones nothing.
|
|
131
|
+
*
|
|
132
|
+
* They are different models doing different jobs, so mapping one onto the
|
|
133
|
+
* other would have quoted a cloning price for a capability that cannot
|
|
134
|
+
* clone. `speak` therefore keeps its own unbranded default and naming
|
|
135
|
+
* `acuvo-voice` is refused as unreachable, with the difference spelled out.
|
|
136
|
+
* ⭐ This is the one finding in the lane I would not have got from reading
|
|
137
|
+
* either catalogue alone — it needed both files open at once.
|
|
138
|
+
*/
|
|
139
|
+
localReach: false,
|
|
140
|
+
}),
|
|
141
|
+
Object.freeze({ id: 'acuvo-face', name: 'Acuvo Face', medium: 'face', grade: 'sole', unit: 'second', localReach: false }),
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
export const CREATIVE_MEDIA = Object.freeze(['image', 'video', 'voice', 'face']);
|
|
145
|
+
|
|
146
|
+
export function engineById(id) {
|
|
147
|
+
return CREATIVE_ENGINES.find((e) => e.id === id) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function enginesFor(medium) {
|
|
151
|
+
return CREATIVE_ENGINES.filter((e) => e.medium === medium);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* ⭐ THE ENFORCEMENT OF "UNLOCKED, NEVER DEFAULTED": there is deliberately no
|
|
156
|
+
* argument that makes this return an `ultra` engine. If you want Ultra you name
|
|
157
|
+
* Ultra, everywhere, always.
|
|
158
|
+
*/
|
|
159
|
+
export function defaultEngineFor(medium) {
|
|
160
|
+
return CREATIVE_ENGINES.find((e) => e.medium === medium && e.grade !== 'ultra') ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* ── ⭐ WHAT A HUMAN AND A MODEL ARE BOTH ALLOWED TO TYPE ────────────────────
|
|
165
|
+
*
|
|
166
|
+
* A model writes `"engine": "ultra"` and a person types `--engine premium`. Both
|
|
167
|
+
* mean the same thing and neither is the id. Accepting the shorthands costs one
|
|
168
|
+
* lookup table and saves a refusal that reads like a spelling test.
|
|
169
|
+
*
|
|
170
|
+
* ⚠️ `basic` IS ACCEPTED BECAUSE ROMAN USES THAT WORD — *"basic or premium"* —
|
|
171
|
+
* and the product word is `core`. A vocabulary the person who specified the
|
|
172
|
+
* feature would fail is not a vocabulary.
|
|
173
|
+
*/
|
|
174
|
+
const SHORTHAND = Object.freeze({
|
|
175
|
+
core: 'core', basic: 'core', standard: 'core', default: 'core',
|
|
176
|
+
ultra: 'ultra', premium: 'ultra', best: 'ultra', pro: 'ultra',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve what the caller asked for into exactly one engine.
|
|
181
|
+
*
|
|
182
|
+
* @returns {{ ok: true, engine: object, named: boolean }
|
|
183
|
+
* |{ ok: false, code: 'unknown_engine', error: string }}
|
|
184
|
+
* `named` says whether a HUMAN OR MODEL CHOSE IT, as opposed to it being the
|
|
185
|
+
* default. Every spend decision downstream needs to know that, because
|
|
186
|
+
* "the user asked for the expensive one" and "we picked the expensive one"
|
|
187
|
+
* are the two sides of the rule this file exists to enforce.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveEngineChoice(medium, requested) {
|
|
190
|
+
const options = enginesFor(medium);
|
|
191
|
+
if (options.length === 0) {
|
|
192
|
+
return { ok: false, code: 'unknown_engine', error: `there are no ${medium} engines` };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const raw = typeof requested === 'string' ? requested.trim() : '';
|
|
196
|
+
if (!raw) {
|
|
197
|
+
const engine = defaultEngineFor(medium);
|
|
198
|
+
return { ok: true, engine, named: false };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const lower = raw.toLowerCase();
|
|
202
|
+
const grade = SHORTHAND[lower];
|
|
203
|
+
if (grade) {
|
|
204
|
+
const byGrade = options.find((e) => e.grade === grade)
|
|
205
|
+
// ⭐ A medium with ONE engine (voice, face) answers `--engine basic` with
|
|
206
|
+
// that engine rather than a refusal. "core" on a single-engine medium is
|
|
207
|
+
// not ambiguous, it is emphatic.
|
|
208
|
+
?? (options.length === 1 ? options[0] : null);
|
|
209
|
+
if (byGrade) return { ok: true, engine: byGrade, named: true };
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
code: 'unknown_engine',
|
|
213
|
+
error: `there is no ${grade} ${medium} engine. ${namesSentence(options)}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const byId = options.find((e) => e.id.toLowerCase() === lower)
|
|
218
|
+
?? options.find((e) => e.name.toLowerCase() === lower);
|
|
219
|
+
if (byId) return { ok: true, engine: byId, named: true };
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* ⚠️ NAMING THE OTHER MEDIUM'S ENGINE IS ITS OWN MISTAKE AND GETS ITS OWN
|
|
223
|
+
* SENTENCE. `speak` with `engine: "acuvo-image"` is a wiring bug in whatever
|
|
224
|
+
* called it, and "unknown engine" would send the reader looking for a typo
|
|
225
|
+
* that is not there.
|
|
226
|
+
*/
|
|
227
|
+
const elsewhere = engineById(lower) ?? CREATIVE_ENGINES.find((e) => e.name.toLowerCase() === lower);
|
|
228
|
+
if (elsewhere) {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
code: 'unknown_engine',
|
|
232
|
+
error: `${elsewhere.name} is a ${elsewhere.medium} engine and this is a ${medium} verb. ${namesSentence(options)}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
code: 'unknown_engine',
|
|
239
|
+
error: `"${raw}" is not an Acuvo ${medium} engine. ${namesSentence(options)}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function namesSentence(options) {
|
|
244
|
+
return `The ${options.length === 1 ? 'only one is' : 'choices are'}: ${options.map((e) => e.id).join(', ')}.`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* ───────────────────────────── PRICES: ASK, DO NOT KNOW ─────────────────── */
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* ── ⭐ WHERE THE ANSWER COMES FROM, AND WHERE IT IS KEPT ────────────────────
|
|
251
|
+
*
|
|
252
|
+
* The gateway URL already lives on the account (`account.mjs`), and it is the
|
|
253
|
+
* chat-completions endpoint. The engines endpoint is its sibling. Deriving it
|
|
254
|
+
* rather than adding a second configurable URL means there is exactly one host
|
|
255
|
+
* an account can point at — a second one would be a second thing to get wrong,
|
|
256
|
+
* and `account.mjs` argues at length about why that host is a deployment knob
|
|
257
|
+
* and not a free-form target.
|
|
258
|
+
*/
|
|
259
|
+
export function enginesEndpoint(gatewayUrl) {
|
|
260
|
+
const base = String(gatewayUrl ?? '').trim();
|
|
261
|
+
if (!base) return null;
|
|
262
|
+
if (base.endsWith('/chat/completions')) return `${base.slice(0, -'/chat/completions'.length)}/engines`;
|
|
263
|
+
return `${base.replace(/\/$/, '')}/engines`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* ⚠️ THE CACHE LIVES UNDER HOME WITH THE CREDENTIAL, NOT IN THE WORKSPACE, AND
|
|
268
|
+
* THE REASON IS THE SAME ONE `account.mjs` gives: `WRITE_FORBIDDEN_ROOTS` does
|
|
269
|
+
* not cover `.acuvo/`, so an agent CAN write a workspace file. A price list the
|
|
270
|
+
* agent can write is a price list the agent can lower — and this one is used to
|
|
271
|
+
* decide whether a render may proceed. Nothing in `lib/tools.mjs` can reach a
|
|
272
|
+
* path outside the workspace root, by construction and already tested, so under
|
|
273
|
+
* HOME it is out of the agent's hands.
|
|
274
|
+
*/
|
|
275
|
+
export function cataloguePath(env = process.env, home = homedir()) {
|
|
276
|
+
return join(accountDir(env, home), 'engines.json');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* ── ⚠️⚠️ HOW LONG A PRICE MAY BE USED TO *REFUSE* SOMEBODY ─────────────────
|
|
281
|
+
*
|
|
282
|
+
* Not how long it may be SHOWN — a shown price is stamped with its age and the
|
|
283
|
+
* reader can judge it. This is how stale an answer may be and still be allowed
|
|
284
|
+
* to stop a render.
|
|
285
|
+
*
|
|
286
|
+
* ⭐ FIVE MINUTES, AND THE ASYMMETRY IS DELIBERATE. This repo's own rule is
|
|
287
|
+
* that **a check which fails correct work is worse than no check** (four
|
|
288
|
+
* instances in one day, 2026-08-16). A user who tops up and is refused from a
|
|
289
|
+
* cached zero balance is exactly that failure. Five minutes is short enough
|
|
290
|
+
* that a top-up is never blocked for long, and long enough that a session of
|
|
291
|
+
* renders does not re-ask on every call.
|
|
292
|
+
*
|
|
293
|
+
* ⚠️ AND AN OLDER ANSWER NEVER REFUSES — it does not "probably" refuse or
|
|
294
|
+
* "warn and refuse". It is shown with its age and the render proceeds, because
|
|
295
|
+
* the gateway is the thing that actually charges and it is the authority. This
|
|
296
|
+
* client's job is to save a wasted round, not to be the enforcement.
|
|
297
|
+
*/
|
|
298
|
+
export const CATALOGUE_REFUSAL_TTL_MS = 5 * 60 * 1000;
|
|
299
|
+
|
|
300
|
+
/** How long the fetch may take before the answer is simply "unavailable". */
|
|
301
|
+
export const CATALOGUE_TIMEOUT_MS = 8_000;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Read whatever the last successful ask returned.
|
|
305
|
+
*
|
|
306
|
+
* ⚠️ NEVER THROWS, for the reason `readAccount` never throws: a corrupt cache
|
|
307
|
+
* must degrade to "prices unavailable" and let the run continue. Crashing a
|
|
308
|
+
* render because a JSON file has a stray byte would be a worse failure than the
|
|
309
|
+
* one it is reporting, and one the user cannot diagnose.
|
|
310
|
+
*/
|
|
311
|
+
export function readCachedCatalogue(env = process.env, home = homedir()) {
|
|
312
|
+
let parsed;
|
|
313
|
+
try {
|
|
314
|
+
parsed = JSON.parse(readFileSync(cataloguePath(env, home), 'utf8'));
|
|
315
|
+
} catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.engines)) return null;
|
|
319
|
+
const fetchedAt = typeof parsed.fetchedAt === 'number' ? parsed.fetchedAt : null;
|
|
320
|
+
if (fetchedAt === null) return null;
|
|
321
|
+
return {
|
|
322
|
+
fetchedAt,
|
|
323
|
+
tier: typeof parsed.tier === 'string' ? parsed.tier : null,
|
|
324
|
+
creditsRemaining: Number.isFinite(parsed.creditsRemaining) ? parsed.creditsRemaining : null,
|
|
325
|
+
engines: parsed.engines,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function writeCachedCatalogue(payload, env = process.env, home = homedir()) {
|
|
330
|
+
try {
|
|
331
|
+
mkdirSync(accountDir(env, home), { recursive: true });
|
|
332
|
+
writeFileSync(cataloguePath(env, home), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
333
|
+
return { ok: true };
|
|
334
|
+
} catch (err) {
|
|
335
|
+
// ⚠️ A cache that cannot be written is not an error the user needs to act
|
|
336
|
+
// on — the next call simply asks again. Reporting it would train people to
|
|
337
|
+
// ignore the messages that matter.
|
|
338
|
+
return { ok: false, error: String(err?.message || err) };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* ── ⭐⭐ THE ASK ────────────────────────────────────────────────────────────
|
|
344
|
+
*
|
|
345
|
+
* `GET <gateway>/engines`, `Authorization: Bearer <acuvo account token>`.
|
|
346
|
+
* Expected shape, and every field of it is the SERVER's to decide:
|
|
347
|
+
*
|
|
348
|
+
* { tier: 'starter', creditsRemaining: 1840,
|
|
349
|
+
* engines: [ { id, credits, reachable, minTier } ] }
|
|
350
|
+
*
|
|
351
|
+
* ⚠️⚠️ AND THE ENDPOINT DOES NOT EXIST YET — SAY IT OUT LOUD RATHER THAN
|
|
352
|
+
* IMPLY IT. Measured 2026-08-16: `acuvo-gateway/lib/handler.mjs` exports one
|
|
353
|
+
* `createHandler` that proxies chat completions and does no path routing at
|
|
354
|
+
* all, and `console/app/api/cli/v1/` contains only `chat/`. So today this
|
|
355
|
+
* function returns `unavailable` for everybody, the CLI prints "prices
|
|
356
|
+
* unavailable", and no verb is refused on entitlement.
|
|
357
|
+
*
|
|
358
|
+
* ⭐ THAT IS THE CORRECT FAILURE AND IT IS WHY THE CLIENT IS SHAPED THIS WAY.
|
|
359
|
+
* The alternative — shipping the numbers inside the package so the picker looks
|
|
360
|
+
* finished — is precisely the thing the header forbids, and it would have to be
|
|
361
|
+
* unshipped from every installed copy later. A client that degrades honestly is
|
|
362
|
+
* finished work; the endpoint is a separate lane's file and is named here so
|
|
363
|
+
* nobody has to go looking for what is missing.
|
|
364
|
+
*/
|
|
365
|
+
export async function fetchCatalogue({
|
|
366
|
+
env = process.env,
|
|
367
|
+
home = homedir(),
|
|
368
|
+
fetchImpl = fetch,
|
|
369
|
+
now = () => Date.now(),
|
|
370
|
+
cache = true,
|
|
371
|
+
} = {}) {
|
|
372
|
+
const account = readAccount(env, home);
|
|
373
|
+
/**
|
|
374
|
+
* ⚠️ NO ACCOUNT MEANS NO QUESTION TO ASK, not a failed request. There is
|
|
375
|
+
* nothing to authenticate with and no tenant whose prices could be returned —
|
|
376
|
+
* and firing an unauthenticated request would produce a 401 that reads like a
|
|
377
|
+
* broken service rather than "you are not signed in".
|
|
378
|
+
*/
|
|
379
|
+
if (!account) {
|
|
380
|
+
return { ok: false, reason: 'no-account', error: 'not signed in to an Acuvo account, so there is nothing to price against' };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const url = enginesEndpoint(account.gatewayUrl);
|
|
384
|
+
if (!url) return { ok: false, reason: 'no-endpoint', error: 'this account has no gateway URL' };
|
|
385
|
+
|
|
386
|
+
let res;
|
|
387
|
+
try {
|
|
388
|
+
res = await fetchImpl(url, {
|
|
389
|
+
method: 'GET',
|
|
390
|
+
headers: { authorization: `Bearer ${account.token}`, accept: 'application/json' },
|
|
391
|
+
signal: AbortSignal.timeout(CATALOGUE_TIMEOUT_MS),
|
|
392
|
+
});
|
|
393
|
+
} catch (err) {
|
|
394
|
+
const why = err?.name === 'TimeoutError' ? `no answer in ${CATALOGUE_TIMEOUT_MS / 1000}s` : String(err?.message || err);
|
|
395
|
+
return { ok: false, reason: 'unreachable', error: `could not reach ${url}: ${why}` };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!res?.ok) {
|
|
399
|
+
return { ok: false, reason: 'http', error: `${url} answered HTTP ${res?.status ?? '?'}` };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let json;
|
|
403
|
+
try {
|
|
404
|
+
json = await res.json();
|
|
405
|
+
} catch {
|
|
406
|
+
return { ok: false, reason: 'not-json', error: `${url} answered with something that was not JSON` };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* ⚠️ A 200 THAT IS NOT A CATALOGUE IS A FAILURE, NOT AN EMPTY CATALOGUE.
|
|
411
|
+
* `res.ok` answers a question about the HTTP conversation and never about
|
|
412
|
+
* whether the work happened — the lesson imagegen.mjs learned by writing a
|
|
413
|
+
* zero-byte PNG and calling it a render. An empty engine list here would
|
|
414
|
+
* present as "you can reach nothing", which is a refusal we would have
|
|
415
|
+
* invented ourselves.
|
|
416
|
+
*/
|
|
417
|
+
if (!Array.isArray(json?.engines) || json.engines.length === 0) {
|
|
418
|
+
return { ok: false, reason: 'empty', error: `${url} returned no engines` };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const payload = {
|
|
422
|
+
fetchedAt: now(),
|
|
423
|
+
tier: typeof json.tier === 'string' ? json.tier : null,
|
|
424
|
+
creditsRemaining: Number.isFinite(json.creditsRemaining) ? json.creditsRemaining : null,
|
|
425
|
+
engines: json.engines,
|
|
426
|
+
};
|
|
427
|
+
if (cache) writeCachedCatalogue(payload, env, home);
|
|
428
|
+
return { ok: true, catalogue: payload };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* ── ⭐ THE THREE HONEST STATES, AND THERE IS NO FOURTH ──────────────────────
|
|
433
|
+
*
|
|
434
|
+
* live — asked just now, this is the truth
|
|
435
|
+
* cache — a previous answer, and its AGE travels with it
|
|
436
|
+
* unknown — nobody has ever answered; prices are unavailable and we say so
|
|
437
|
+
*
|
|
438
|
+
* ⚠️ `fetchImpl` DEFAULTS TO `null`, WHICH MEANS "DO NOT GO TO THE NETWORK".
|
|
439
|
+
* That default is what keeps a render verb from adding a round trip before
|
|
440
|
+
* every picture: `generate_image` reads the cache and never asks, while
|
|
441
|
+
* `list_engines` — the verb whose entire job is answering "what will this cost
|
|
442
|
+
* me" — passes a real `fetch`. The question is asked by the verb that exists to
|
|
443
|
+
* ask it, and the expensive verbs read the answer.
|
|
444
|
+
*/
|
|
445
|
+
export function loadCatalogue({ env = process.env, home = homedir(), now = () => Date.now() } = {}) {
|
|
446
|
+
const cached = readCachedCatalogue(env, home);
|
|
447
|
+
if (!cached) return { source: 'unknown', ageMs: null, catalogue: null };
|
|
448
|
+
return { source: 'cache', ageMs: Math.max(0, now() - cached.fetchedAt), catalogue: cached };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The server's row for one engine, or null when we have no catalogue at all. */
|
|
452
|
+
export function rowFor(catalogue, engineId) {
|
|
453
|
+
if (!catalogue) return null;
|
|
454
|
+
return catalogue.engines.find((e) => e?.id === engineId) ?? null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/* ─────────────────────────────── THE REFUSALS ───────────────────────────── */
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* ── ⚠️⚠️ "NOT ON YOUR PLAN" AND "OUT OF CREDITS" ARE OPPOSITE MESSAGES ─────
|
|
461
|
+
*
|
|
462
|
+
* They must never share a sentence, a remedy or a code, because the two things
|
|
463
|
+
* the reader should do next are different and one of them costs money:
|
|
464
|
+
*
|
|
465
|
+
* entitlement — the engine is not sold on this tier. Buying credits changes
|
|
466
|
+
* NOTHING. The remedy is a different plan, or the core engine.
|
|
467
|
+
* balance — the engine IS yours. You simply have none left this month.
|
|
468
|
+
* The remedy is a top-up, or waiting for the reset.
|
|
469
|
+
*
|
|
470
|
+
* ⭐ Telling someone to top up when the answer is a plan gate takes their money
|
|
471
|
+
* and leaves them exactly as blocked — which is the failure worth writing a
|
|
472
|
+
* test against, and `refusals-do-not-share-a-remedy` is that test.
|
|
473
|
+
*
|
|
474
|
+
* Each refusal carries a machine `code` so a caller never has to match on
|
|
475
|
+
* English, and a `remedy` so the sentence a human reads cannot drift from the
|
|
476
|
+
* action a program would take.
|
|
477
|
+
*/
|
|
478
|
+
export function refuseNotOnPlan({ engine, tier, minTier, alternative = null }) {
|
|
479
|
+
const alt = alternative
|
|
480
|
+
? ` ${alternative.name} is on your plan and does its job.`
|
|
481
|
+
: '';
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
code: 'engine_not_on_plan',
|
|
485
|
+
remedy: 'change-plan',
|
|
486
|
+
error: `${engine.name} is not included in the ${tier ?? 'current'} plan — it starts at ${minTier ?? 'a higher tier'}.`
|
|
487
|
+
+ ` Nothing was generated and nothing was charged.${alt}`
|
|
488
|
+
+ ' This is what your subscription includes, so buying more will not unlock it.',
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function refuseOutOfCredits({ engine, credits, remaining, ageMs = null, alternative = null }) {
|
|
493
|
+
const alt = alternative
|
|
494
|
+
? ` ${alternative.name} costs less per ${alternative.unit}.`
|
|
495
|
+
: '';
|
|
496
|
+
/**
|
|
497
|
+
* ⚠️ THE BALANCE IS STAMPED WITH ITS AGE. A number read from a cache is a
|
|
498
|
+
* number that was true a moment ago, and somebody who topped up ten seconds
|
|
499
|
+
* ago has to be able to see why we still said no.
|
|
500
|
+
*/
|
|
501
|
+
const asOf = ageMs === null ? '' : ` (balance as of ${describeAge(ageMs)} — run \`acuvo engines\` to re-check)`;
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
code: 'insufficient_credits',
|
|
505
|
+
remedy: 'add-credits',
|
|
506
|
+
error: `${engine.name} costs ${credits} credits per ${engine.unit} and this account has ${remaining}${asOf}.`
|
|
507
|
+
+ ` Nothing was generated and nothing was charged. Your subscription includes this engine — you have run the balance down.${alt}`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* ── ⭐ THE THIRD REFUSAL, AND IT IS NOT A BUSINESS ONE ──────────────────────
|
|
513
|
+
*
|
|
514
|
+
* "This binary has no path to that engine." It outranks the other two and is
|
|
515
|
+
* checked FIRST, which is the opposite of what I first wrote and the reason is
|
|
516
|
+
* worth keeping: telling a Growth customer "not on your plan" for an engine
|
|
517
|
+
* that runs for NOBODY is a false explanation, and they would go and buy an
|
|
518
|
+
* upgrade that changes nothing. A fact about the software must not be dressed
|
|
519
|
+
* up as a fact about the account.
|
|
520
|
+
*
|
|
521
|
+
* ⚠️ It also costs no network call, so the cheapest check is also the one that
|
|
522
|
+
* cannot be wrong.
|
|
523
|
+
*/
|
|
524
|
+
export function refuseUnreachableHere({ engine, detail }) {
|
|
525
|
+
return {
|
|
526
|
+
ok: false,
|
|
527
|
+
code: 'engine_unreachable_here',
|
|
528
|
+
remedy: 'use-another-surface',
|
|
529
|
+
error: `${engine.name} cannot be run from Acuvo Code yet: ${detail}`
|
|
530
|
+
+ ' Nothing was generated and nothing was charged. This is a gap in this tool, not in your account.',
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Why each unreachable engine is unreachable, in one sentence each. */
|
|
535
|
+
const UNREACHABLE_DETAIL = Object.freeze({
|
|
536
|
+
'acuvo-image-ultra': 'it is a rented model behind the Acuvo gateway, and this package has no render endpoint to call — only the browser Studio reaches it today.',
|
|
537
|
+
'acuvo-video': 'this package has no video module at all (there is no lib/video.mjs) — video lives in the browser Studio.',
|
|
538
|
+
'acuvo-video-ultra': 'this package has no video module at all (there is no lib/video.mjs) — video lives in the browser Studio.',
|
|
539
|
+
'acuvo-voice': 'it is voice CLONING (chatterbox), and `speak` here runs the plain fixed-voice TTS endpoint (Kokoro) instead — a different model, so it is not offered under this name.',
|
|
540
|
+
'acuvo-face': 'this package has no face module — a talking head is rendered in the browser Studio.',
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* ── ⭐⭐ THE ONE GATE EVERY CREATIVE VERB GOES THROUGH ──────────────────────
|
|
545
|
+
*
|
|
546
|
+
* Order, and every step of it is argued above:
|
|
547
|
+
* 1. is it an engine at all — free, local, cannot be wrong
|
|
548
|
+
* 2. can this binary run it — free, local, outranks the account
|
|
549
|
+
* 3. does the plan include it — needs a FRESH answer, else skipped
|
|
550
|
+
* 4. is there a balance for it — needs a FRESH answer, else skipped
|
|
551
|
+
*
|
|
552
|
+
* ⚠️ STEPS 3 AND 4 ARE SKIPPED WHEN THE ANSWER IS OLD OR ABSENT, and that is
|
|
553
|
+
* the fail-SAFE direction: the gateway charges, so it refuses. A client that
|
|
554
|
+
* blocks a paid-up customer from a stale cache has failed correct work, which
|
|
555
|
+
* this repo holds to be worse than not checking at all.
|
|
556
|
+
*
|
|
557
|
+
* @returns {{ ok: true, engine, named: boolean, credits: number|null,
|
|
558
|
+
* priceKnown: boolean, source: string, ageMs: number|null }
|
|
559
|
+
* |{ ok: false, code: string, remedy?: string, error: string }}
|
|
560
|
+
*/
|
|
561
|
+
export function checkEngine(medium, requested, { env = process.env, home = homedir(), now = () => Date.now() } = {}) {
|
|
562
|
+
const choice = resolveEngineChoice(medium, requested);
|
|
563
|
+
if (!choice.ok) return choice;
|
|
564
|
+
const { engine, named } = choice;
|
|
565
|
+
|
|
566
|
+
if (!engine.localReach) {
|
|
567
|
+
return refuseUnreachableHere({ engine, detail: UNREACHABLE_DETAIL[engine.id] ?? 'it is not wired into this package.' });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const { source, ageMs, catalogue } = loadCatalogue({ env, home, now });
|
|
571
|
+
const row = rowFor(catalogue, engine.id);
|
|
572
|
+
const fresh = source === 'cache' && ageMs !== null && ageMs <= CATALOGUE_REFUSAL_TTL_MS && row !== null;
|
|
573
|
+
const credits = Number.isFinite(row?.credits) ? row.credits : null;
|
|
574
|
+
|
|
575
|
+
if (fresh) {
|
|
576
|
+
const alternative = CREATIVE_ENGINES.find((e) => e.medium === medium && e.grade === 'core' && e.id !== engine.id) ?? null;
|
|
577
|
+
if (row.reachable === false) {
|
|
578
|
+
return refuseNotOnPlan({ engine, tier: catalogue.tier, minTier: row.minTier ?? null, alternative });
|
|
579
|
+
}
|
|
580
|
+
if (credits !== null && Number.isFinite(catalogue.creditsRemaining) && catalogue.creditsRemaining < credits) {
|
|
581
|
+
return refuseOutOfCredits({ engine, credits, remaining: catalogue.creditsRemaining, ageMs, alternative });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return { ok: true, engine, named, credits, priceKnown: credits !== null, source, ageMs };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* ─────────────────────────────── THE LISTING ────────────────────────────── */
|
|
589
|
+
|
|
590
|
+
export function describeAge(ms) {
|
|
591
|
+
if (!Number.isFinite(ms)) return 'an unknown time ago';
|
|
592
|
+
const s = Math.round(ms / 1000);
|
|
593
|
+
if (s < 90) return `${s}s ago`;
|
|
594
|
+
const m = Math.round(s / 60);
|
|
595
|
+
if (m < 90) return `${m}m ago`;
|
|
596
|
+
const h = Math.round(m / 60);
|
|
597
|
+
if (h < 48) return `${h}h ago`;
|
|
598
|
+
return `${Math.round(h / 24)}d ago`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* ── ⭐⭐ "WHAT WILL THIS COST ME", ANSWERED BEFORE ANY MONEY MOVES ──────────
|
|
603
|
+
*
|
|
604
|
+
* ⚠️ EVERY QUANTITY IS "OR", NEVER "AND" — Roman: *"when you say 5 videos, and
|
|
605
|
+
* however many images, users need to know their credits cover that amount, not
|
|
606
|
+
* give both."* Each row says what the WHOLE balance buys if it all went on that
|
|
607
|
+
* one engine, so the lines are alternatives. A comma between them would read as
|
|
608
|
+
* "and" and promise several allowances that do not exist, which is why the word
|
|
609
|
+
* "or" is printed in the header rather than left to the reader.
|
|
610
|
+
*
|
|
611
|
+
* ⚠️ AND A MISSING PRICE PRINTS AS `—`, NOT AS A GUESS. "prices unavailable" is
|
|
612
|
+
* a state a user can act on; an invented number is one they cannot detect.
|
|
613
|
+
*/
|
|
614
|
+
export function formatEngineList({ source, ageMs, catalogue, fetchError = null } = {}) {
|
|
615
|
+
const lines = [];
|
|
616
|
+
|
|
617
|
+
if (source === 'live') lines.push('Engines — prices from your account, just now.');
|
|
618
|
+
else if (source === 'cache') lines.push(`Engines — prices from your account, cached ${describeAge(ageMs)}.`);
|
|
619
|
+
else {
|
|
620
|
+
lines.push('Engines — ⚠ PRICES UNAVAILABLE.');
|
|
621
|
+
lines.push(` ${fetchError ?? 'nothing has ever answered, so no credit cost can be shown'}.`);
|
|
622
|
+
lines.push(' The prices are your account\'s, not this package\'s — it asks the gateway rather than shipping a price list,');
|
|
623
|
+
lines.push(' so that a re-price reaches you without an upgrade and nobody can edit their own bill.');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (catalogue?.tier) {
|
|
627
|
+
const bal = Number.isFinite(catalogue.creditsRemaining) ? `${catalogue.creditsRemaining} credits left` : 'balance unknown';
|
|
628
|
+
lines.push(` plan: ${catalogue.tier} · ${bal}`);
|
|
629
|
+
}
|
|
630
|
+
lines.push('');
|
|
631
|
+
|
|
632
|
+
for (const medium of CREATIVE_MEDIA) {
|
|
633
|
+
const options = enginesFor(medium);
|
|
634
|
+
if (options.length === 0) continue;
|
|
635
|
+
lines.push(` ${medium}`);
|
|
636
|
+
for (const engine of options) {
|
|
637
|
+
const row = rowFor(catalogue, engine.id);
|
|
638
|
+
const credits = Number.isFinite(row?.credits) ? row.credits : null;
|
|
639
|
+
const price = credits === null ? '—' : `${credits} cr/${engine.unit}`;
|
|
640
|
+
/**
|
|
641
|
+
* ⚠️ THE STATUS COLUMN ANSWERS "WOULD THIS RUN", and it distinguishes the
|
|
642
|
+
* three reasons it might not — plan, package, and default-vs-named —
|
|
643
|
+
* because they are three different next actions.
|
|
644
|
+
*/
|
|
645
|
+
let status;
|
|
646
|
+
if (!engine.localReach) status = 'not in the CLI';
|
|
647
|
+
else if (row && row.reachable === false) status = `needs ${row.minTier ?? 'a higher plan'}`;
|
|
648
|
+
else if (engine.grade === 'ultra') status = 'opt in by name';
|
|
649
|
+
else status = 'default';
|
|
650
|
+
|
|
651
|
+
const many = credits !== null && Number.isFinite(catalogue?.creditsRemaining)
|
|
652
|
+
? ` (or ${Math.floor(catalogue.creditsRemaining / credits)} of these with the whole balance)`
|
|
653
|
+
: '';
|
|
654
|
+
lines.push(` ${engine.id.padEnd(18)} ${price.padEnd(14)} ${status}${many}`);
|
|
655
|
+
}
|
|
656
|
+
lines.push('');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
lines.push(' Every quantity above is ONE WAY to spend the whole balance — read them as "or", never "and".');
|
|
660
|
+
lines.push(' An Ultra engine is never chosen for you: pass --engine (CLI) or engine (tool) to ask for one.');
|
|
661
|
+
return lines;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* ── ⭐ THE VERB, SO A MODEL CAN ASK THE SAME QUESTION A HUMAN CAN ───────────
|
|
666
|
+
*
|
|
667
|
+
* Without it the model's only way to find out what an engine costs is to spend
|
|
668
|
+
* it, which is the one thing a budgeted agent must never have to do.
|
|
669
|
+
*/
|
|
670
|
+
export function listEnginesToolSchema() {
|
|
671
|
+
return {
|
|
672
|
+
type: 'function',
|
|
673
|
+
function: {
|
|
674
|
+
name: 'list_engines',
|
|
675
|
+
description: [
|
|
676
|
+
'List the Acuvo creative engines this account can reach and what each one COSTS IN CREDITS,',
|
|
677
|
+
'before you spend anything. Use it when the user asks what an image or a video will cost,',
|
|
678
|
+
'when they ask for "the best" or "premium" quality, or before you choose a non-default engine.',
|
|
679
|
+
'The prices come from the account, not from this package, so they may be unavailable — if they are,',
|
|
680
|
+
'say so plainly rather than guessing a number.',
|
|
681
|
+
'An Ultra engine is NEVER used unless the user asked for it by name.',
|
|
682
|
+
].join(' '),
|
|
683
|
+
parameters: {
|
|
684
|
+
type: 'object',
|
|
685
|
+
properties: {
|
|
686
|
+
medium: {
|
|
687
|
+
type: 'string',
|
|
688
|
+
enum: ['image', 'video', 'voice', 'face', 'all'],
|
|
689
|
+
description: 'Which kind of engine to list. Default "all".',
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
required: [],
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The `list_engines` implementation. Asks the gateway (this is the verb whose
|
|
700
|
+
* job is asking), falls back to the cache, and says "unavailable" when there is
|
|
701
|
+
* neither.
|
|
702
|
+
*/
|
|
703
|
+
export async function listEngines({
|
|
704
|
+
medium = 'all',
|
|
705
|
+
env = process.env,
|
|
706
|
+
home = homedir(),
|
|
707
|
+
fetchImpl = fetch,
|
|
708
|
+
now = () => Date.now(),
|
|
709
|
+
} = {}) {
|
|
710
|
+
const live = await fetchCatalogue({ env, home, fetchImpl, now });
|
|
711
|
+
let state;
|
|
712
|
+
if (live.ok) {
|
|
713
|
+
state = { source: 'live', ageMs: 0, catalogue: live.catalogue, fetchError: null };
|
|
714
|
+
} else {
|
|
715
|
+
const loaded = loadCatalogue({ env, home, now });
|
|
716
|
+
state = { ...loaded, fetchError: live.error };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const wanted = medium === 'all' || !CREATIVE_MEDIA.includes(medium) ? CREATIVE_MEDIA : [medium];
|
|
720
|
+
const engines = CREATIVE_ENGINES.filter((e) => wanted.includes(e.medium)).map((e) => {
|
|
721
|
+
const row = rowFor(state.catalogue, e.id);
|
|
722
|
+
return {
|
|
723
|
+
id: e.id,
|
|
724
|
+
name: e.name,
|
|
725
|
+
medium: e.medium,
|
|
726
|
+
grade: e.grade,
|
|
727
|
+
unit: e.unit,
|
|
728
|
+
credits: Number.isFinite(row?.credits) ? row.credits : null,
|
|
729
|
+
runsFromTheCli: e.localReach,
|
|
730
|
+
onYourPlan: row ? row.reachable !== false : null,
|
|
731
|
+
};
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
ok: true,
|
|
736
|
+
pricesFrom: state.source,
|
|
737
|
+
pricesKnown: state.source !== 'unknown',
|
|
738
|
+
...(state.source === 'cache' ? { pricedAt: describeAge(state.ageMs) } : {}),
|
|
739
|
+
...(state.source === 'unknown' ? { whyNoPrices: state.fetchError ?? 'no account has ever answered' } : {}),
|
|
740
|
+
tier: state.catalogue?.tier ?? null,
|
|
741
|
+
creditsRemaining: Number.isFinite(state.catalogue?.creditsRemaining) ? state.catalogue.creditsRemaining : null,
|
|
742
|
+
engines,
|
|
743
|
+
note: 'Quantities are alternatives, not a combined allowance. An Ultra engine runs only when it is named.',
|
|
744
|
+
text: formatEngineList(state).join('\n'),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/* ───────────────────── THE RUN-LEVEL CHOICE (`--engine`) ─────────────────── */
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* ── ⭐ `--engine` IS A DEFAULT FOR THE RUN, PER MEDIUM ──────────────────────
|
|
752
|
+
*
|
|
753
|
+
* `acuvo --engine acuvo-image-ultra "build the landing page"` says: when you
|
|
754
|
+
* make an image this run, make it with that one. It is set per MEDIUM, so
|
|
755
|
+
* choosing an image engine cannot quietly change what `speak` does.
|
|
756
|
+
*
|
|
757
|
+
* ⚠️ MODULE STATE, LIKE `imagesThisProcess` — and like it, with a reset seam,
|
|
758
|
+
* because a per-run choice that leaks between test files is a test that passes
|
|
759
|
+
* for the wrong reason.
|
|
760
|
+
*
|
|
761
|
+
* ⚠️ AND IT IS STILL "NAMED BY A HUMAN". A flag the user typed is exactly the
|
|
762
|
+
* consent this file requires; what is forbidden is the SOFTWARE choosing Ultra.
|
|
763
|
+
*/
|
|
764
|
+
const runEngines = new Map();
|
|
765
|
+
|
|
766
|
+
export function setRunEngine(engineId) {
|
|
767
|
+
const engine = engineById(String(engineId ?? '').trim().toLowerCase());
|
|
768
|
+
if (!engine) return { ok: false, error: `"${engineId}" is not an Acuvo engine. Choices: ${CREATIVE_ENGINES.map((e) => e.id).join(', ')}.` };
|
|
769
|
+
runEngines.set(engine.medium, engine.id);
|
|
770
|
+
return { ok: true, engine };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export function runEngineFor(medium) {
|
|
774
|
+
return runEngines.get(medium) ?? null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export function resetRunEngines() {
|
|
778
|
+
runEngines.clear();
|
|
779
|
+
}
|