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,179 @@
1
+ /**
2
+ * ── ⭐ THE SECOND EXECUTOR — ONE LOOP, TWO CLIENTS, FINALLY REAL ─────────────
3
+ *
4
+ * "One registry, two clients" has been the stated architecture for weeks. It was
5
+ * measured on 2026-08-09 and found to be fiction: the CLI has 13 verbs, the
6
+ * console has 153 tools, and the overlap is ZERO. Two forked registries sharing
7
+ * a doctrine paragraph.
8
+ *
9
+ * ⭐ AND IT EXPLAINS THE PEER GAP. We generate a PAGE where Manus/Replit/Base44
10
+ * generate an APPLICATION, and the cause is not the model — it is that the
11
+ * builder has no write→run→fix loop. It cannot run what it wrote, so it cannot
12
+ * fix what it wrote. The CLI has exactly that loop and it works: 7/7 on the
13
+ * bench, and it went 5/7 → 7/7 with NO MODEL CHANGE, purely on loop fixes.
14
+ *
15
+ * ⚠️ SO THE ANSWER IS NOT A SECOND LOOP. Forking `turn.mjs` for the browser is
16
+ * how we got here. This file is the other half of the seam: the SAME
17
+ * `runSession` gets an executor whose disk is a Map instead of a filesystem.
18
+ *
19
+ * CLI → createLocalExecutor(dir) → real files, child_process
20
+ * BUILDER → createMemoryExecutor({...}) → a file MAP, Modal sandbox
21
+ *
22
+ * ── ⚠️ WHY THE BUILDER'S WORKSPACE IS A MAP AND NOT A TEMP DIRECTORY ────────
23
+ * A browser session has no disk of its own, and giving each one a server-side
24
+ * temp directory would mean per-tenant cleanup, disk quotas and a new class of
25
+ * path-escape bug on a multi-tenant box. The generated files already live in
26
+ * memory (see `generated-files.ts`), and `runInSandbox` takes exactly
27
+ * `Record<string, string>` — so the natural representation is the one both ends
28
+ * already speak. Nothing is written to the server's disk, ever.
29
+ *
30
+ * ── ⚠️ THE DEPENDENCY DIRECTION IS DELIBERATE ───────────────────────────────
31
+ * This module does NOT import the Modal sandbox, or anything from console/. It
32
+ * takes a `runCommand` function. `acuvo-code/` stays zero-dependency and
33
+ * runnable on its own; the console injects the sandbox when it constructs the
34
+ * executor. Importing upward would make the CLI depend on Next.js, which is how
35
+ * a shared core stops being shareable.
36
+ */
37
+
38
+ import { normalizeRelativePath } from './workspace.mjs';
39
+
40
+ /** Mirrors the local executor's ceiling so the two agree about what is too big. */
41
+ export const MAX_MEMORY_FILE_BYTES = 512 * 1024;
42
+ /**
43
+ * ⚠️ A TOTAL CAP, WHICH THE LOCAL EXECUTOR DOES NOT NEED. A real disk pushes
44
+ * back; a Map on a shared server does not, and an agent in a loop writing
45
+ * megabytes per round is a memory leak with a plausible explanation.
46
+ */
47
+ export const MAX_MEMORY_TOTAL_BYTES = 8 * 1024 * 1024;
48
+ export const MAX_MEMORY_FILES = 400;
49
+
50
+ const bytes = (s) => Buffer.byteLength(s, 'utf8');
51
+
52
+ /**
53
+ * An executor whose filesystem is a Map<string, string>.
54
+ *
55
+ * @param {{
56
+ * files?: Record<string, string>,
57
+ * dryRun?: boolean,
58
+ * runCommand?: (command: string, files: Record<string, string>) => Promise<any>,
59
+ * }} [opts]
60
+ */
61
+ export function createMemoryExecutor({ files = {}, dryRun = false, runCommand = null } = {}) {
62
+ /** @type {Map<string, string>} */
63
+ const disk = new Map();
64
+ for (const [path, content] of Object.entries(files)) {
65
+ const norm = normalizeRelativePath(path);
66
+ // A malformed seed path is dropped rather than thrown: the caller is often
67
+ // handing over model-generated filenames, and one bad name must not take
68
+ // down a session that could still do useful work with the rest.
69
+ if (norm.ok && typeof content === 'string') disk.set(norm.path, content);
70
+ }
71
+
72
+ const totalBytes = () => [...disk.values()].reduce((a, c) => a + bytes(c), 0);
73
+
74
+ return {
75
+ /**
76
+ * ⚠️ A LABEL, NOT A PATH. The local executor's `root` is a real directory
77
+ * and several call sites `join()` with it. Nothing may do that here, so it
78
+ * is deliberately not path-shaped — a silent `join('(memory)', x)` producing
79
+ * a relative path that half-works is worse than an obvious break.
80
+ */
81
+ root: '(memory)',
82
+ dryRun,
83
+ /** ⭐ How the caller gets the work back out — the whole point of the run. */
84
+ snapshot() { return Object.fromEntries(disk); },
85
+
86
+ readFile(path) {
87
+ const r = normalizeRelativePath(path);
88
+ if (!r.ok) return { ok: false, error: r.reason };
89
+ if (!disk.has(r.path)) return { ok: false, error: `no such file: ${r.path}` };
90
+ const content = disk.get(r.path);
91
+ return { ok: true, path: r.path, content, bytes: bytes(content) };
92
+ },
93
+
94
+ writeFile(path, content) {
95
+ const r = normalizeRelativePath(path);
96
+ if (!r.ok) return { ok: false, error: r.reason };
97
+ if (typeof content !== 'string') return { ok: false, error: 'content must be a string' };
98
+ const size = bytes(content);
99
+ if (size > MAX_MEMORY_FILE_BYTES) {
100
+ return { ok: false, error: `${r.path} would be ${size} bytes, over the ${MAX_MEMORY_FILE_BYTES}-byte limit` };
101
+ }
102
+ const existed = disk.has(r.path);
103
+ const previousBytes = existed ? bytes(disk.get(r.path)) : 0;
104
+ if (!existed && disk.size >= MAX_MEMORY_FILES) {
105
+ return { ok: false, error: `this workspace already holds ${disk.size} files, the maximum` };
106
+ }
107
+ if (totalBytes() - previousBytes + size > MAX_MEMORY_TOTAL_BYTES) {
108
+ return { ok: false, error: `that write would take the workspace over its ${MAX_MEMORY_TOTAL_BYTES}-byte total` };
109
+ }
110
+ // ⚠️ A dry run must not mutate, but must still report what WOULD happen —
111
+ // same contract as the local executor, or the two clients diverge in the
112
+ // one mode where a user is deliberately being careful.
113
+ if (!dryRun) disk.set(r.path, content);
114
+ return { ok: true, path: r.path, bytes: size, created: !existed, previousBytes, dryRun };
115
+ },
116
+
117
+ deleteFile(path) {
118
+ const r = normalizeRelativePath(path);
119
+ if (!r.ok) return { ok: false, error: r.reason };
120
+ if (!disk.has(r.path)) return { ok: false, error: `no such file: ${r.path} — nothing was deleted` };
121
+ const size = bytes(disk.get(r.path));
122
+ if (!dryRun) disk.delete(r.path);
123
+ return { ok: true, path: r.path, bytes: size, dryRun };
124
+ },
125
+
126
+ /**
127
+ * A Map has no directories, so they are DERIVED from the path segments.
128
+ * ⚠️ The shape must match the local executor's exactly — `{ name, type,
129
+ * bytes }` — because `gatherWorkspaceContext` walks it and would otherwise
130
+ * silently produce an empty tree for the builder while working for the CLI.
131
+ */
132
+ listDir(path = '.') {
133
+ let prefix = '';
134
+ if (path && path !== '.' && path !== './') {
135
+ const r = normalizeRelativePath(path);
136
+ if (!r.ok) return { ok: false, error: r.reason };
137
+ prefix = `${r.path}/`;
138
+ }
139
+ const seen = new Map();
140
+ for (const [p, content] of disk) {
141
+ if (prefix && !p.startsWith(prefix)) continue;
142
+ const rest = p.slice(prefix.length);
143
+ const slash = rest.indexOf('/');
144
+ if (slash === -1) seen.set(rest, { name: rest, type: 'file', bytes: bytes(content) });
145
+ else {
146
+ const dir = rest.slice(0, slash);
147
+ if (!seen.has(dir)) seen.set(dir, { name: dir, type: 'dir' });
148
+ }
149
+ }
150
+ if (prefix && seen.size === 0) return { ok: false, error: `no such directory: ${path}` };
151
+ return {
152
+ ok: true,
153
+ path: prefix ? prefix.slice(0, -1) : '.',
154
+ entries: [...seen.values()].sort((a, b) => a.name.localeCompare(b.name)),
155
+ };
156
+ },
157
+
158
+ /**
159
+ * ⭐ THE HALF THAT MAKES IT A BUILDER AND NOT A GENERATOR.
160
+ *
161
+ * Injected, never imported — see the header. The console passes a function
162
+ * that ships `snapshot()` to the Modal sandbox; a test passes a stub; the
163
+ * CLI never constructs this executor at all.
164
+ *
165
+ * ⚠️ AND WHEN IT IS ABSENT THE ANSWER IS AN HONEST REFUSAL, not a fake pass.
166
+ * A builder whose "run" silently succeeds without running is the exact
167
+ * dead-button failure this codebase keeps re-learning: the model believes
168
+ * its code is verified, says so, and ships something nobody executed.
169
+ */
170
+ async runCommand(command) {
171
+ if (typeof runCommand !== 'function') {
172
+ return { ok: false, error: 'no sandbox is configured for this workspace, so nothing can be run or verified here' };
173
+ }
174
+ return runCommand(command, Object.fromEntries(disk));
175
+ },
176
+
177
+ hasRunner: typeof runCommand === 'function',
178
+ };
179
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * ── ⚠️⚠️ CLAMPED JSON IS UNPARSEABLE JSON ───────────────────────────────────
3
+ *
4
+ * `toolResultText` formats 24 of the 63 dispatched tools. The other 39 fall to
5
+ * `default: clampOutput(JSON.stringify(result))` — and so does **every MCP
6
+ * result**, whose names are `mcp__<server>__<tool>` and can never match a case.
7
+ *
8
+ * `clampOutput` is well built for what it was written for: it keeps 35% head and
9
+ * 65% tail and splices `… N characters omitted …` between them, so nothing is
10
+ * lost silently and trailing fields survive. On PROSE that degrades gracefully.
11
+ *
12
+ * ⚠️ On a serialised object it does not degrade — it breaks. The splice lands
13
+ * mid-object and the result is no longer JSON at all. Measured on `git_diff`
14
+ * against an ordinary 400-line refactor: an 8,030-character reply that
15
+ * `JSON.parse` rejects. The model is then reading a broken object and inferring
16
+ * its fields, which is the failure mode an earlier audit recorded as *"search
17
+ * results were arriving 19% complete, as broken JSON"*.
18
+ *
19
+ * ⭐ THE FIX IS TO SHRINK THE PAYLOAD, NOT THE SYNTAX. Cut the big string
20
+ * FIELDS inside the object until the whole thing fits, and the reply stays
21
+ * valid JSON with every flag, every count and every pagination cursor intact.
22
+ * A `diff` or a `stdout` is what is actually large; `ok`, `truncated` and
23
+ * `nextPage` are bytes that must never be the ones sacrificed.
24
+ *
25
+ * ⚠️ It shrinks the LARGEST field first and re-measures each time, rather than
26
+ * dividing a budget evenly. A result carrying one 9KB `diff` beside a 40-char
27
+ * `path` should lose only diff — an even split would mangle both.
28
+ */
29
+
30
+ /** Mirrors `clampOutput`'s split so truncation reads the same everywhere. */
31
+ function spliceMiddle(text, budget) {
32
+ const head = Math.floor(budget * 0.35);
33
+ const tail = budget - head;
34
+ const omitted = text.length - budget;
35
+ return `${text.slice(0, head)}\n\n… ${omitted} characters omitted …\n\n${text.slice(-tail)}`;
36
+ }
37
+
38
+ /**
39
+ * ⚠️ A floor, so a field is never cut to uselessness. Below this there is no
40
+ * point keeping the field's content at all — a 20-character window of a diff
41
+ * tells the model nothing and still costs it a read.
42
+ */
43
+ const MIN_FIELD_CHARS = 200;
44
+
45
+ /** Every string field big enough to be worth cutting, deepest-first by size. */
46
+ function largeStrings(value, path = [], out = []) {
47
+ if (typeof value === 'string') {
48
+ if (value.length > MIN_FIELD_CHARS) out.push({ path, length: value.length });
49
+ return out;
50
+ }
51
+ if (Array.isArray(value)) {
52
+ value.forEach((v, i) => largeStrings(v, [...path, i], out));
53
+ return out;
54
+ }
55
+ if (value && typeof value === 'object') {
56
+ for (const [k, v] of Object.entries(value)) largeStrings(v, [...path, k], out);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function getAt(root, path) {
62
+ return path.reduce((acc, key) => acc?.[key], root);
63
+ }
64
+
65
+ function setAt(root, path, next) {
66
+ const parent = path.slice(0, -1).reduce((acc, key) => acc[key], root);
67
+ parent[path[path.length - 1]] = next;
68
+ }
69
+
70
+ /**
71
+ * Serialise a tool result for the model, keeping it VALID JSON at any size.
72
+ *
73
+ * @param {unknown} result the tool's return value
74
+ * @param {number} maxChars the same ceiling the formatted branches use
75
+ * @returns {string} JSON that parses, whose large string fields may be spliced
76
+ */
77
+ export function stringifyForModel(result, maxChars) {
78
+ let json = JSON.stringify(result);
79
+ if (typeof json !== 'string') return '';
80
+ if (json.length <= maxChars) return json;
81
+
82
+ /**
83
+ * ⚠️ Deep-cloned, because this renders a LIVE result object that callers keep
84
+ * using — the transcript writer and the usage recorder both read it after we
85
+ * are done. Truncating in place would corrupt the record of what the tool
86
+ * actually returned, which is the one copy that has to stay true.
87
+ */
88
+ const clone = structuredClone(result);
89
+ const fields = largeStrings(clone).sort((a, b) => b.length - a.length);
90
+
91
+ for (const field of fields) {
92
+ if (json.length <= maxChars) break;
93
+ const current = getAt(clone, field.path);
94
+ if (typeof current !== 'string') continue;
95
+
96
+ /**
97
+ * How much this one field must give up for the whole reply to fit, with a
98
+ * little slack for the `… N characters omitted …` marker we splice in.
99
+ */
100
+ const excess = json.length - maxChars;
101
+ const budget = Math.max(MIN_FIELD_CHARS, current.length - excess - 80);
102
+ if (budget >= current.length) continue;
103
+
104
+ setAt(clone, field.path, spliceMiddle(current, budget));
105
+ json = JSON.stringify(clone);
106
+ }
107
+
108
+ /**
109
+ * ⚠️ THE HONEST FLOOR. If the size lives in structure rather than in strings —
110
+ * ten thousand tiny array entries — no amount of field-cutting reaches it, and
111
+ * cutting the JSON string would put us back where we started. Say so in a way
112
+ * that still parses, and keep the fields that describe the result over the
113
+ * ones that are the result.
114
+ */
115
+ if (json.length > maxChars) {
116
+ const note = {
117
+ ok: result?.ok,
118
+ error: result?.error,
119
+ _truncated: true,
120
+ _note: `this result was ${json.length} characters and could not be reduced to ${maxChars} `
121
+ + 'by trimming its text fields — its size is in structure, not prose. '
122
+ + 'Request a narrower slice (a smaller range, an offset, or a single path).',
123
+ };
124
+ for (const [k, v] of Object.entries(result ?? {})) {
125
+ if (typeof v === 'number' || typeof v === 'boolean') note[k] = v;
126
+ }
127
+ const fallback = JSON.stringify(note);
128
+ return fallback.length <= maxChars ? fallback : JSON.stringify({ ok: false, _truncated: true });
129
+ }
130
+
131
+ return json;
132
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * ── ⭐ THE LADDER RETRIED WITH THE SAME MODEL, WHICH IS THE WEAK VERSION ────
3
+ *
4
+ * `escalate.mjs` climbs solo → fresh context → best-of N, and every rung asked
5
+ * **the same model** the failing one used. That is the cheapest kind of "try
6
+ * harder": a fresh context helps because the old one was poisoned, and parallel
7
+ * attempts help because sampling varies — but neither adds capability the model
8
+ * did not have on the first try.
9
+ *
10
+ * ⭐ Model tiers add the missing axis. A rung can be given a STRONGER model, so
11
+ * "spend more on the hard ones" becomes true of the thing doing the work and not
12
+ * only of how many times it is asked.
13
+ *
14
+ * ── ⚠️⚠️ OFF BY DEFAULT, AND THE REASON IS ARITHMETIC, NOT CAUTION ─────────
15
+ *
16
+ * `escalate.projectTierCost` prices the next rung from **what the last attempt
17
+ * measurably cost**. That is the honest way to project when the model is
18
+ * constant, and it is WRONG the moment it is not: a rung on a model that costs
19
+ * 20x per token would be projected at 3x the price of a cheap attempt, the
20
+ * budget would wave it through, and the ceiling the user typed would be crossed
21
+ * by an order of magnitude — by the one code path whose entire job is not to do
22
+ * that.
23
+ *
24
+ * We cannot fix that by guessing prices: this package has no price table, and a
25
+ * wrong one is worse than none. So:
26
+ *
27
+ * · with no configuration, every tier is the SAME model and nothing changes;
28
+ * · configuring tiers is an explicit act (`ACUVO_MODEL_TIERS`), which is the
29
+ * user saying "I know what these cost";
30
+ * · and when a rung's model differs from the previous rung's, the caller is
31
+ * TOLD (`changed: true`) so the projection can be widened rather than
32
+ * quietly trusted.
33
+ *
34
+ * ⭐ The default therefore costs nothing and changes nothing, which is the only
35
+ * safe shape for a feature that can multiply a bill.
36
+ */
37
+
38
+ /** The variable that turns this on. Cheapest first, strongest last. */
39
+ export const TIERS_ENV = 'ACUVO_MODEL_TIERS';
40
+
41
+ /**
42
+ * ⚠️ A CEILING ON HOW FAR A SINGLE RUN CAN ESCALATE. Someone will paste a list
43
+ * of ten models; the ladder has three rungs and the rest would be dead config
44
+ * that reads as if it were doing something.
45
+ */
46
+ export const MAX_TIERS = 4;
47
+
48
+ /**
49
+ * Parse the configured ladder.
50
+ *
51
+ * ⚠️ AN EMPTY OR ABSENT VARIABLE IS NOT AN ERROR — it is the normal case, and
52
+ * it means "one tier: whatever the run is already using".
53
+ *
54
+ * @param {string} baseModel
55
+ * @param {object} env
56
+ * @returns {string[]} cheapest first, always at least one entry
57
+ */
58
+ /**
59
+ * ── ⭐⭐ THE ESCALATION MODEL, ON BY DEFAULT ────────────────────────────────
60
+ *
61
+ * This ladder climbs solo → fresh context → best-of-N, and until now every rung
62
+ * ran the SAME cheap model. So the loop got more determined and never got
63
+ * smarter, and every benchmark number this package holds is a FLOOR taken with
64
+ * one hand tied: 19 of 19 runs measured on flash, with the stronger rungs
65
+ * switched off and nobody having tried them.
66
+ *
67
+ * ⭐ THE ARITHMETIC THAT MAKES IT AFFORDABLE, and it is not what you would
68
+ * guess. Pro is 3.1x flash on a cache MISS and 3.1x on OUTPUT — but only
69
+ * **1.29x on a cache HIT** ($0.0036/M against $0.0028/M direct). At the cache
70
+ * rates this package now sustains, pro's INPUT is nearly flash's price and the
71
+ * entire gap is output.
72
+ *
73
+ * ⭐ AND ESCALATION IS ALREADY FRESH-CONTEXT, WHICH IS WHAT MAKES THE SWITCH
74
+ * CHEAP. `escalate.mjs` passes the TASK to each rung, never the transcript, so
75
+ * moving to a different model does not re-send a 41,000-token conversation at
76
+ * the dearer model's miss price. A prompt cache is per-model — switching MID-
77
+ * CONVERSATION would cost a full cold prompt every time, and nothing about this
78
+ * default does that.
79
+ *
80
+ * ⚠️ MODELLED, NOT MEASURED: at ~8% of tasks escalating (our bench passes 12 of
81
+ * 13 on flash alone), the blended cost is about $4.13 per user-month against
82
+ * $3.28 for flash-only — 78% margin instead of 83%. Five points of margin to
83
+ * put a materially better model on the tasks that already failed once. How much
84
+ * pro actually BUYS is still unmeasured; that is a bench run, not an opinion.
85
+ *
86
+ * ⚠️ THE DATED SNAPSHOT IS DELIBERATE. `deepseek-v4-pro-0813` is $0.435/M;
87
+ * the undated `deepseek-v4-pro` pointer is $1.168/M — 2.7x dearer for what is
88
+ * meant to be the same family. Pinning the snapshot is both cheaper and
89
+ * reproducible, and a moving pointer under a benchmark is how a "regression"
90
+ * appears that nobody changed.
91
+ *
92
+ * ⚠️ Set `ACUVO_MODEL_TIERS` to override, or to a single id to switch this off.
93
+ */
94
+ export const DEFAULT_ESCALATION_MODEL = 'deepseek/deepseek-v4-pro-0813';
95
+
96
+ export function parseTiers(baseModel, env = process.env) {
97
+ const raw = String(env?.[TIERS_ENV] ?? '').trim();
98
+ /**
99
+ * ⚠️ THE BASE MODEL STAYS RUNG ZERO, WHATEVER THE USER CONFIGURED. This adds a
100
+ * rung above it; it never replaces the model somebody chose. And when the base
101
+ * IS the escalation model — somebody set `OPENROUTER_CODEGEN_MODEL` to pro —
102
+ * the ladder collapses to one entry rather than listing pro twice, because
103
+ * `modelForRung` reuses the last tier and a duplicate would read as a switch
104
+ * that is not happening.
105
+ */
106
+ if (raw === '') {
107
+ return baseModel === DEFAULT_ESCALATION_MODEL
108
+ ? [baseModel]
109
+ : [baseModel, DEFAULT_ESCALATION_MODEL];
110
+ }
111
+
112
+ const seen = new Set();
113
+ const tiers = [];
114
+ for (const part of raw.split(',')) {
115
+ const id = part.trim();
116
+ if (!id || seen.has(id)) continue;
117
+ seen.add(id);
118
+ tiers.push(id);
119
+ if (tiers.length >= MAX_TIERS) break;
120
+ }
121
+ return tiers.length > 0 ? tiers : [baseModel];
122
+ }
123
+
124
+ /**
125
+ * Which model a given rung should use.
126
+ *
127
+ * ⚠️ THE LAST TIER IS REUSED, NEVER WRAPPED. With two tiers configured and three
128
+ * rungs, the third rung gets the strongest configured model — not tier[0] again.
129
+ * Wrapping would send the hardest attempt to the weakest model, which is the
130
+ * exact inverse of the feature.
131
+ *
132
+ * @param {number} rungIndex 0-based position in the ladder
133
+ * @param {string[]} tiers
134
+ * @returns {string}
135
+ */
136
+ export function modelForRung(rungIndex, tiers) {
137
+ const list = Array.isArray(tiers) && tiers.length > 0 ? tiers : [null];
138
+ const i = Math.max(0, Math.min(list.length - 1, Number.isInteger(rungIndex) ? rungIndex : 0));
139
+ return list[i];
140
+ }
141
+
142
+ /**
143
+ * What model does this rung use, and is it a different one from last time?
144
+ *
145
+ * `changed` is the load-bearing field: it is the signal that a cost projection
146
+ * derived from the previous rung no longer applies.
147
+ *
148
+ * @returns {{model: string, changed: boolean, tier: number, of: number}}
149
+ */
150
+ export function planRung(rungIndex, { baseModel, env = process.env, previousModel = null } = {}) {
151
+ const tiers = parseTiers(baseModel, env);
152
+ const model = modelForRung(rungIndex, tiers);
153
+ return {
154
+ model,
155
+ changed: previousModel !== null && previousModel !== model,
156
+ tier: Math.min(rungIndex, tiers.length - 1),
157
+ of: tiers.length,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * One line for a human when the ladder changes model mid-run.
163
+ *
164
+ * ⚠️ SAID OUT LOUD, ALWAYS. A run that silently switches to a pricier model has
165
+ * changed what it costs without telling the person paying, and "why was this
166
+ * bill different" must never be unanswerable.
167
+ */
168
+ export function describeSwitch(from, to) {
169
+ if (!from || !to || from === to) return null;
170
+ return `escalating the model as well: ${from} → ${to} (its price is not projected from the previous rung — see --budget)`;
171
+ }