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
@@ -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
+ }