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,135 @@
1
+ /**
2
+ * ── ⭐⭐⭐ THE SKILLS THAT SHIP WITH ACUVO ───────────────────────────────────
3
+ *
4
+ * Roman, 2026-08-17: *"all the Modal integrations, all free opensource tools,
5
+ * design elements and capabilities, skills, frameworks — it all needs to be in
6
+ * Acuvo dude. Make it real."*
7
+ *
8
+ * ⚠️⚠️ MEASURED THE SAME DAY, AND HE WAS RIGHT: `skills.mjs` is 28,968 bytes of
9
+ * complete infrastructure — a catalogue, frontmatter parsing, size caps, a
10
+ * prompt block, tool schemas, tests — and **ZERO SKILLS SHIPPED**. There was no
11
+ * `.acuvo/skills` directory anywhere in the repo and nothing seeded one. A user
12
+ * installing the CLI got the loader and an empty shelf.
13
+ *
14
+ * That is the house defect at its most expensive: the extensibility system that
15
+ * makes the product feel deep was finished, and there was nothing in it.
16
+ *
17
+ * ── ⭐ BUNDLED, NOT SEEDED ──────────────────────────────────────────────────
18
+ *
19
+ * The obvious fix — copy skill files into the user's project on first run — is
20
+ * the wrong one. It writes into a repo we do not own, it goes stale the moment
21
+ * we improve a skill, and it turns `git status` noisy in someone else's project.
22
+ *
23
+ * Instead these live INSIDE the package and are discovered as a second root.
24
+ * Upgrading the CLI upgrades the skills, and the user's project stays clean.
25
+ *
26
+ * ⚠️ THE PROJECT ALWAYS WINS ON A NAME COLLISION. A user who writes their own
27
+ * `nextjs-app-router` skill means it — ours is a default, not a policy. Losing
28
+ * that argument silently would make the product feel like it is fighting them.
29
+ */
30
+ import { fileURLToPath } from 'node:url';
31
+ import { dirname, join } from 'node:path';
32
+ import { discoverSkills, MAX_BUILTIN_SKILLS, loadSkill, SKILLS_DIR } from './skills.mjs';
33
+
34
+ /** Where the bundled skills live, relative to the package root. */
35
+ export const BUILTIN_SKILLS_DIR = 'skills';
36
+
37
+ /**
38
+ * The package root — the directory containing `lib/` and `skills/`.
39
+ *
40
+ * ⚠️ DERIVED FROM `import.meta.url`, NOT `process.cwd()`. The CLI runs inside
41
+ * whatever project the user is in, so cwd is THEIR repo; resolving the bundle
42
+ * against it would look for our skills inside their tree and silently find
43
+ * nothing — the exact failure this file exists to end.
44
+ */
45
+ export function builtinSkillsRoot() {
46
+ return join(dirname(fileURLToPath(import.meta.url)), '..');
47
+ }
48
+
49
+ /**
50
+ * Every skill available this run: the ones we ship, plus the ones the project
51
+ * defines, with the project taking precedence by name.
52
+ *
53
+ * ⚠️ SHAPE-COMPATIBLE WITH `discoverSkills` ON PURPOSE, so `skillsPromptBlock`
54
+ * and every existing test keep working against it unchanged.
55
+ *
56
+ * ⚠️ NEVER THROWS — the same rule `discoverSkills` follows. Assembling the
57
+ * prompt may never be the thing that kills a run, so a broken bundle degrades
58
+ * to "the project's skills only" rather than taking the session with it.
59
+ */
60
+ export function discoverAllSkills(projectRoot, opts = {}) {
61
+ let builtin = { ok: true, skills: [] };
62
+ try {
63
+ // ⚠️ `maxSkills` LAST, so a caller's project-shelf limit cannot silently
64
+ // truncate the builtin shelf — which is exactly how three shipped skills
65
+ // vanished. See MAX_BUILTIN_SKILLS in skills.mjs.
66
+ builtin = discoverSkills(builtinSkillsRoot(), { ...opts, dir: BUILTIN_SKILLS_DIR, maxSkills: MAX_BUILTIN_SKILLS });
67
+ } catch {
68
+ builtin = { ok: true, skills: [] };
69
+ }
70
+
71
+ let project = { ok: true, skills: [] };
72
+ try {
73
+ project = discoverSkills(projectRoot, opts);
74
+ } catch {
75
+ project = { ok: true, skills: [] };
76
+ }
77
+
78
+ const builtinList = Array.isArray(builtin?.skills) ? builtin.skills : [];
79
+ const projectList = Array.isArray(project?.skills) ? project.skills : [];
80
+
81
+ const overridden = new Set(projectList.map((s) => s?.name).filter(Boolean));
82
+ const merged = [
83
+ ...projectList,
84
+ ...builtinList.filter((s) => s?.name && !overridden.has(s.name)),
85
+ ];
86
+
87
+ return {
88
+ /**
89
+ * ⚠️⚠️ `ok` IS TRUE WHENEVER WE HAVE SKILLS TO OFFER, and my first version
90
+ * got this wrong in a way only a test caught. I set it to the PROJECT's
91
+ * `ok`, reasoning that a user's malformed `.acuvo/skills` should be heard
92
+ * about. But `skillsPromptBlock` returns NULL on `ok === false` — so any
93
+ * project without a readable skills directory (which is every project, by
94
+ * default) suppressed the entire BUNDLED catalogue too. The skills shipped,
95
+ * were discovered, and never reached the model.
96
+ *
97
+ * ⭐ The project's problem is still reported — it rides in `error` — but it
98
+ * no longer takes our shelf down with it.
99
+ */
100
+ ok: merged.length > 0 || project?.ok !== false,
101
+ dir: project?.dir ?? SKILLS_DIR,
102
+ skills: merged,
103
+ skipped: [...(project?.skipped ?? []), ...(builtin?.skipped ?? [])],
104
+ found: merged.length,
105
+ capped: (project?.capped ?? 0) + (builtin?.capped ?? 0),
106
+ scanTruncated: Boolean(project?.scanTruncated || builtin?.scanTruncated),
107
+ error: project?.error,
108
+ builtinCount: builtinList.length,
109
+ overrodeBuiltin: [...overridden].filter((n) => builtinList.some((b) => b.name === n)),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Load one skill by name, project first then bundled.
115
+ *
116
+ * ⚠️ THE ORDER MATCHES DISCOVERY. If the catalogue advertised the project's
117
+ * version and the loader returned ours, the model would be shown one thing and
118
+ * handed another — a drift that is invisible in every log.
119
+ */
120
+ export function loadAnySkill(projectRoot, rawName, opts = {}) {
121
+ let fromProject = null;
122
+ try {
123
+ fromProject = loadSkill(projectRoot, rawName, opts);
124
+ } catch {
125
+ fromProject = null;
126
+ }
127
+ if (fromProject && fromProject.ok) return fromProject;
128
+
129
+ try {
130
+ return loadSkill(builtinSkillsRoot(), rawName, { ...opts, dir: BUILTIN_SKILLS_DIR });
131
+ } catch {
132
+ // Fall back to the project's own refusal so the caller still gets a reason.
133
+ return fromProject ?? { ok: false, error: 'skill not found' };
134
+ }
135
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * ── ⭐⭐ THE INSTRUMENT. THE CACHE RATE IS THE MARGIN, SO IT GETS MEASURED ───
3
+ *
4
+ * `lib/plan.mjs` sizes every tier on holding a cache floor, and
5
+ * `acuvo-gateway/PRICING.md` §5 states the arithmetic behind it:
6
+ *
7
+ * cache floor = sharedHead ÷ typicalPrompt
8
+ *
9
+ * ⚠️⚠️ AND UNTIL THIS FILE EXISTED, NEITHER TERM HAD EVER BEEN MEASURED.
10
+ * PRICING.md tabulates 25k/40k and 40k/60k as illustrations and closes with the
11
+ * open item *"measure the achieved cache floor"*. A margin that leans on a
12
+ * number nobody has read off the wire is a margin nobody has.
13
+ *
14
+ * ── ⚠️⚠️ SHARED BYTES ARE NOT A SHARED PREFIX, AND THE GAP IS THE WHOLE TRAP ─
15
+ *
16
+ * A prompt cache reuses everything up to the FIRST DIFFERING BYTE and pays full
17
+ * price for everything after it. So "these two requests are 98.9% identical" is
18
+ * not a cache claim at all — two requests can share 98.9% of their bytes and
19
+ * cache at exactly 0% if the 1.1% that differs sits at the front.
20
+ *
21
+ * ⭐ THAT IS WHY THIS FILE REPORTS BOTH. A probe that reports only the shared
22
+ * fraction cannot tell "our prefix is perfect and the provider is routing us
23
+ * somewhere cold" apart from "our prefix moved at byte 200" — and those two have
24
+ * completely different fixes. `describeDivergence` names the byte, so the
25
+ * question stops being a guess.
26
+ *
27
+ * ⚠️ EVERYTHING HERE IS PURE. It takes strings and returns numbers: no fetch, no
28
+ * provider, no key. That is deliberate — the half of the cache contract we
29
+ * control is the bytes we send, and it is the half that can be tested for $0.00
30
+ * on every commit. What a provider then does with those bytes is measured with
31
+ * `acuvo --json`'s `.cache` block against a real key, and this file's numbers are
32
+ * what make that reading interpretable.
33
+ *
34
+ * ⚠️ BYTES, NOT TOKENS, AND THE DIFFERENCE IS STATED RATHER THAN HIDDEN. A
35
+ * tokeniser is provider-specific and we do not ship one; bytes are exact, ours,
36
+ * and monotone in the thing we care about. A floor computed in bytes is an
37
+ * ESTIMATE of the floor in tokens — close, because the shared head and the tail
38
+ * are the same kind of text, and never presented as anything else.
39
+ */
40
+
41
+ /**
42
+ * Length of the byte-identical run the two strings begin with.
43
+ *
44
+ * ⚠️ THIS IS THE ONLY QUANTITY A PREFIX CACHE PAYS FOR. Anything shared further
45
+ * in is shared bytes, not cache.
46
+ *
47
+ * @param {string} a
48
+ * @param {string} b
49
+ * @returns {number}
50
+ */
51
+ export function sharedPrefixBytes(a, b) {
52
+ const x = String(a ?? '');
53
+ const y = String(b ?? '');
54
+ const n = Math.min(x.length, y.length);
55
+ let i = 0;
56
+ while (i < n && x[i] === y[i]) i += 1;
57
+ return i;
58
+ }
59
+
60
+ /**
61
+ * Length of the byte-identical run the two strings END with.
62
+ *
63
+ * ⭐ WORTH NOTHING TO THE CACHE AND EVERYTHING TO THE DIAGNOSIS. A large shared
64
+ * suffix beside a small shared prefix is the signature of one varying field near
65
+ * the front — which is a fix we own. A small shared suffix beside a large shared
66
+ * prefix is the ordinary, healthy shape: the task text differs and nothing else.
67
+ *
68
+ * ⚠️ Clamped so the two runs cannot overlap and double-count on near-identical
69
+ * inputs — without the clamp two equal strings report `2 × length` shared.
70
+ *
71
+ * @param {string} a
72
+ * @param {string} b
73
+ * @returns {number}
74
+ */
75
+ export function sharedSuffixBytes(a, b) {
76
+ const x = String(a ?? '');
77
+ const y = String(b ?? '');
78
+ const limit = Math.min(x.length, y.length) - sharedPrefixBytes(x, y);
79
+ if (limit <= 0) return 0;
80
+ let i = 0;
81
+ while (i < limit && x[x.length - 1 - i] === y[y.length - 1 - i]) i += 1;
82
+ return i;
83
+ }
84
+
85
+ /**
86
+ * ── ⭐ THE FLOOR, AND WHY IT IS DIVIDED BY THE LARGER PROMPT ────────────────
87
+ *
88
+ * `sharedHead ÷ typicalPrompt`. When two prompts differ in length the honest
89
+ * denominator is the LARGER of the two: the cache pays for the head once and we
90
+ * are billed for every uncached byte of whichever request is bigger, so dividing
91
+ * by the smaller would quote a floor no request actually achieves.
92
+ *
93
+ * ⚠️ null, never 0 and never NaN, when there is no prompt behind it. A rate with
94
+ * an empty denominator is not a measurement — the same rule `aggregateCache` in
95
+ * `turn.mjs` already follows for a session with no reported rounds.
96
+ *
97
+ * @param {{ sharedHead: number, typicalPrompt: number }} sizes
98
+ * @returns {number | null} 0..1
99
+ */
100
+ export function cacheFloor({ sharedHead, typicalPrompt } = {}) {
101
+ const head = Number(sharedHead);
102
+ const prompt = Number(typicalPrompt);
103
+ if (!Number.isFinite(head) || !Number.isFinite(prompt) || prompt <= 0) return null;
104
+ if (head <= 0) return 0;
105
+ // ⚠️ A head longer than the prompt is a caller error, not a floor above 100%.
106
+ return Math.min(1, head / prompt);
107
+ }
108
+
109
+ /**
110
+ * ── ⭐⭐ THE ONE READING THAT ANSWERS "WHY DID THIS NOT CACHE" ──────────────
111
+ *
112
+ * Given two wire payloads, returns the shared prefix, the shared suffix, the
113
+ * floor, and — the part that turns a number into an action — the byte where they
114
+ * part company with a window of context from each side.
115
+ *
116
+ * ⚠️ THE WINDOW IS WHAT MAKES IT USABLE. "diverged at byte 10,693" is a fact
117
+ * nobody can act on; "diverged at byte 10,693, where A has `git_push` and B does
118
+ * not" is a fix. `context` bytes either side, clamped to the strings.
119
+ *
120
+ * @param {string} a
121
+ * @param {string} b
122
+ * @param {{ context?: number }} [opts]
123
+ */
124
+ export function describeDivergence(a, b, { context = 120 } = {}) {
125
+ const x = String(a ?? '');
126
+ const y = String(b ?? '');
127
+ const prefix = sharedPrefixBytes(x, y);
128
+ const suffix = sharedSuffixBytes(x, y);
129
+ const larger = Math.max(x.length, y.length);
130
+ const identical = x === y;
131
+ const span = Math.max(0, Math.trunc(context));
132
+ return {
133
+ identical,
134
+ bytesA: x.length,
135
+ bytesB: y.length,
136
+ sharedPrefix: prefix,
137
+ sharedSuffix: suffix,
138
+ /**
139
+ * ⚠️ THE FIGURE THAT LOOKS LIKE A CACHE RATE AND IS NOT ONE. Reported so a
140
+ * reader who has been handed "98.9% shared" can see it beside the number
141
+ * that actually bills, rather than mistaking one for the other.
142
+ */
143
+ sharedFraction: larger > 0 ? (prefix + suffix) / larger : null,
144
+ floor: cacheFloor({ sharedHead: prefix, typicalPrompt: larger }),
145
+ /** null when the strings are identical — there is no divergence to point at. */
146
+ at: identical ? null : prefix,
147
+ aroundA: identical ? null : x.slice(Math.max(0, prefix - span), prefix + span),
148
+ aroundB: identical ? null : y.slice(Math.max(0, prefix - span), prefix + span),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * ── ⭐ WHAT A PROVIDER ACTUALLY HASHES, IN THE ORDER IT SEES IT ─────────────
154
+ *
155
+ * The tool schemas are a TOP-LEVEL field on the request and are rendered into
156
+ * the prompt ahead of the messages, so a probe that serialises only
157
+ * `opts.messages` is measuring the second half of the prefix and calling it the
158
+ * whole thing. Measured on this repo 2026-08-16: the tools block is **21,466 of
159
+ * the 22,889 shared bytes** between two tenants — 94% of the entire shared head.
160
+ * A prefix probe that cannot see it is blind to the part that matters most.
161
+ *
162
+ * ⚠️ THIS IS A STABLE SERIALISATION FOR MEASUREMENT, NOT THE WIRE FORMAT.
163
+ * `callModel` builds the real body with more fields on it; every one of those is
164
+ * either constant across requests or irrelevant to the prefix. What matters is
165
+ * that this function is deterministic and puts the same things in the same
166
+ * order every time it is called, which is exactly what makes two readings
167
+ * comparable.
168
+ *
169
+ * @param {{ tools?: any, messages?: any }} request
170
+ * @returns {string}
171
+ */
172
+ /**
173
+ * ── ⭐⭐ THE SAME BYTES, SERIALISED SO THAT APPENDING EXTENDS THE STRING ─────
174
+ *
175
+ * `wireBytes` wraps everything in one JSON object, which is right for comparing
176
+ * two DIFFERENT requests — the envelope is shared, so it cancels out.
177
+ *
178
+ * ⚠️ IT IS WRONG FOR COMPARING ROUND N TO ROUND N+1, and the error looks exactly
179
+ * like a defect. A conversation is append-only, but `{"messages":[a,b]}` is NOT
180
+ * a prefix of `{"messages":[a,b,c]}` — the closing `]}` sits between them. So a
181
+ * perfectly stable loop measures 99.99% and reports drift on every single round.
182
+ *
183
+ * ⭐ MEASURED WHEN EXACTLY THAT HAPPENED: 32,272 of 32,274 bytes shared, and the
184
+ * two missing ones were `]}`. A naive reading of that says "we void our own
185
+ * cache every round", which is false, and would have sent somebody hunting a
186
+ * bug that does not exist. **The instrument has to be right before its readings
187
+ * mean anything.**
188
+ *
189
+ * Here each part is serialised separately and joined with a delimiter that
190
+ * cannot occur in JSON text, so appending a message strictly EXTENDS the string
191
+ * and an append-only round measures exactly 1.
192
+ *
193
+ * ⚠️ This is for round-over-round comparison ONLY. Across two different requests
194
+ * use `wireBytes` — this one's delimiter is not what any provider receives.
195
+ */
196
+ export function appendOnlyWireBytes({ tools = null, messages = [] } = {}) {
197
+ const parts = [JSON.stringify(tools ?? null)];
198
+ for (const m of messages) parts.push(JSON.stringify(m));
199
+ return parts.join('');
200
+ }
201
+
202
+ export function wireBytes({ tools = null, messages = [] } = {}) {
203
+ return JSON.stringify({ tools, messages });
204
+ }
package/lib/chain.mjs ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * ── ⭐⭐ NEVER SINGLE — THE PROVIDER CHAIN ───────────────────────────────────
3
+ *
4
+ * Acuvo Code called OpenRouter and only OpenRouter. If OpenRouter rate-limits or
5
+ * has a bad ten minutes, **every user of this CLI is dead at once** — not
6
+ * degraded, dead, mid-task, with whatever they were doing abandoned.
7
+ *
8
+ * The console has carried a "never single" doctrine for months and the CLI never
9
+ * inherited it. An independent review of the product flagged the same thing as
10
+ * "API redundancy" and it is the most legitimate technical point in it: a
11
+ * one-provider agent is a one-provider outage.
12
+ *
13
+ * ── ⚠️ WHAT IS RETRYABLE, AND WHY THE DISTINCTION IS THE WHOLE FILE ─────────
14
+ * Retrying the wrong failure is worse than not retrying at all. A 401 retried
15
+ * three times is three times the wait before the user learns their key is bad,
16
+ * and a 400 retried is us hammering a provider with a request we malformed.
17
+ *
18
+ * RETRY 429 (rate limit) · 5xx (their fault) · timeout · connection error
19
+ * STOP 400 401 403 404 · and anything else 4xx
20
+ *
21
+ * ⭐ 402 IS DELIBERATELY NOT RETRYABLE ACROSS MODELS BUT IS ACROSS PROVIDERS.
22
+ * "This account cannot pay" is permanent for that account this minute; trying a
23
+ * cheaper model on the same exhausted balance just fails again more slowly.
24
+ *
25
+ * ── ⚠️⚠️ AN EMPTY 200 IS A FAILURE, AND IT IS THE ONE THAT ALMOST SHIPPED ───
26
+ * `qwen3.7-flash`, `deepseek-v4-flash-0731` and `deepseek-v4-pro` all have a
27
+ * native reasoning budget ON BY DEFAULT that can consume the entire output and
28
+ * return `content: null` with HTTP 200. Measured earlier in this project: a
29
+ * bake-off where BOTH v4 models returned 0 bytes and looked like a silent
30
+ * success. A chain that treats 200 as success would fall through to nothing and
31
+ * report "the model returned an empty reply" instead of trying the next one.
32
+ *
33
+ * ── ⚠️ THE PREFIX MUST NOT MOVE ─────────────────────────────────────────────
34
+ * Every attempt sends the SAME messages, byte for byte. An identical prompt
35
+ * prefix caches at a measured 97.2% and cuts call cost 4.3x; reordering,
36
+ * re-summarising or "trimming for the fallback" would throw that away silently
37
+ * and nobody would notice except the bill.
38
+ */
39
+
40
+ import { callModel } from './model.mjs';
41
+
42
+ /** Total attempts across the whole chain. Bounded because a loop that retries
43
+ * forever is an outage that also costs money. */
44
+ export const MAX_ATTEMPTS = 4;
45
+
46
+ /**
47
+ * The order is deliberate: the configured model first (it is what the user
48
+ * asked for and what the cache is warm on), then progressively more available
49
+ * fallbacks.
50
+ *
51
+ * ⚠️ FREE MODELS LAST, NOT FIRST. They are the most rate-limited things on
52
+ * OpenRouter, so leading with one would make the common case slower and the
53
+ * failure case no better. They are here as a floor — something that answers
54
+ * when nothing else will — not as a cost optimisation.
55
+ */
56
+ export function buildChain(primary, env = process.env) {
57
+ const extra = (env.ACUVO_FALLBACK_MODELS ?? '')
58
+ .split(',').map((s) => s.trim()).filter(Boolean);
59
+ const defaults = [
60
+ 'deepseek/deepseek-chat',
61
+ 'z-ai/glm-4.6',
62
+ 'qwen/qwen3.7-flash',
63
+ ];
64
+ const seen = new Set();
65
+ return [primary, ...extra, ...defaults].filter((m) => {
66
+ if (!m || seen.has(m)) return false;
67
+ seen.add(m);
68
+ return true;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Decide what to do with a failed attempt.
74
+ *
75
+ * Pure, and separately tested — this is the function that decides whether a
76
+ * user waits four times as long for a key error.
77
+ */
78
+ /**
79
+ * @param {any} error the message, for the cases that still have to be read
80
+ * @param {'timeout' | 'network' | null} [kind] the structured fact from
81
+ * `transportErrorKind`, when the caller has it. ⭐ WHEN PRESENT IT DECIDES,
82
+ * because a fact beats a regex over English — see model.mjs's
83
+ * `transportErrorKind` for the timeout that never failed over.
84
+ */
85
+ export function isRetryable(error, kind = null) {
86
+ /**
87
+ * ⚠️ ONLY EVER ADDS RETRYABILITY. A 401 or a malformed body fails identically
88
+ * on every provider, so no kind may turn one of those into four attempts —
89
+ * and none can, because this returns only `true` and the prose rules below
90
+ * still get their say when the kind is absent.
91
+ */
92
+ if (kind === 'timeout' || kind === 'network') return true;
93
+
94
+ const e = String(error ?? '');
95
+ // Transport: no HTTP status ever arrived.
96
+ if (/timed out|could not reach|network|ECONNRESET|ECONNREFUSED|ENOTFOUND|fetch failed/i.test(e)) return true;
97
+ // Their fault.
98
+ if (/HTTP 5\d\d/.test(e)) return true;
99
+ if (/HTTP 429|rate.?limit/i.test(e)) return true;
100
+ /**
101
+ * ⚠️ The empty-200 case arrives as a normal-looking error string from
102
+ * extractReply, NOT as an HTTP failure. It must be caught by text, and it is
103
+ * the single most important line here — see the header.
104
+ */
105
+ if (/empty reply|no content|returned nothing/i.test(e)) return true;
106
+ return false;
107
+ }
108
+
109
+ /**
110
+ * ── ⭐ IS THIS FAILURE ABOUT THE REQUEST, OR ABOUT THIS ONE MODEL? ───────────
111
+ *
112
+ * `callChain` stops the moment a failure is not retryable, and the reasoning is
113
+ * right for the case it was written against: a bad key or a malformed body
114
+ * "will fail identically on every provider", so burning three more attempts
115
+ * turns a two-second error into an eight-second one.
116
+ *
117
+ * ⚠️ THAT ARGUMENT DOES NOT HOLD FOR A FAILURE ABOUT THE MODEL ITSELF. Every
118
+ * candidate in the chain sends a DIFFERENT model id, so "model not found",
119
+ * a retired id, or "no endpoints found that support tool use" is a fact about
120
+ * ONE candidate and says nothing about the next three.
121
+ *
122
+ * ⭐ WE PAID FOR THIS ONE ALREADY. The OpenCode integration sat broken because
123
+ * its configured model was an IMAGE model with no tool support, and every
124
+ * request returned `404 "No endpoints found that support tool use"`. Three
125
+ * healthy fallbacks were sitting right there, each of which would have sent a
126
+ * different id, and the chain refused to try a single one of them.
127
+ *
128
+ * ⚠️ DELIBERATELY NARROW. This must match only failures that name the MODEL. A
129
+ * pattern loose enough to catch a bad key would restore the eight-second error
130
+ * this whole branch exists to prevent, so 401/403 and body-shape 400s are
131
+ * excluded explicitly and `test/chain-failover-policy.test.mjs` pins that.
132
+ */
133
+ /**
134
+ * ── ⚠️⚠️ A PROVIDER PIN MAKES "NO ENDPOINTS FOUND" MEAN SOMETHING ELSE ───────
135
+ *
136
+ * The whole point of this function is that every candidate sends a DIFFERENT
137
+ * model id, so a failure naming the model says nothing about the next three.
138
+ * **A provider pin breaks that reasoning, because the pin is the SAME on all
139
+ * four.** `provider: { order: ['NotAProvider'] }` narrows the endpoint set to
140
+ * nothing and OpenRouter answers "No endpoints found for <model>" — the model
141
+ * id is merely what the sentence happens to name.
142
+ *
143
+ * ⭐ MEASURED 2026-08-14. With the pin invisible, the chain burned all four
144
+ * candidates re-sending the identical bad pin, and the error a human finally
145
+ * read led with "check OPENROUTER_CODEGEN_MODEL against the model catalogue"
146
+ * about a model that was never wrong. Four round trips to misdiagnose one
147
+ * environment variable.
148
+ *
149
+ * ⚠️ `pinned` DEFAULTS TO FALSE, so every existing caller behaves exactly as
150
+ * before. This can only ever REMOVE retryability, and only for the one cause
151
+ * that provably repeats — which keeps the OpenCode case intact: a genuinely
152
+ * dead model id with no pin still advances through the chain, and that is the
153
+ * failure this branch was written for in the first place.
154
+ *
155
+ * @param {any} error
156
+ * @param {{ pinned?: boolean }} [opts] `pinned` — a provider pin was sent with
157
+ * this request, so an endpoint-shaped failure is at least as likely to be
158
+ * about the pin as about the model.
159
+ */
160
+ export function isModelSpecific(error, { pinned = false } = {}) {
161
+ const e = String(error ?? '');
162
+ if (/HTTP 40[13]|invalid api key|unauthoris|unauthoriz|forbidden/i.test(e)) return false;
163
+ /**
164
+ * ⚠️ ONLY the endpoint-shaped failure is disclaimed, not the whole family. A
165
+ * retired or misspelled model id still fails per-candidate under a pin, and
166
+ * those messages ("model not found", "deprecated model") are unambiguous —
167
+ * they cannot be produced by narrowing the provider set.
168
+ */
169
+ if (pinned && /no endpoints found/i.test(e)) return false;
170
+ return /no endpoints found|model not found|does not exist|unknown model|no such model|not a valid model|unsupported model|decommission|deprecated model/i.test(e);
171
+ }
172
+
173
+ /**
174
+ * Call the chain until something answers.
175
+ *
176
+ * Returns the same shape as `callModel`, plus `attempts` and `usedFallback`.
177
+ *
178
+ * ⚠️ THE RESULT SAYS WHICH MODEL ANSWERED. A silent downgrade that returns a
179
+ * weaker model's output without saying so is the dishonest version of this
180
+ * feature: the user compares two sessions, one is worse, and nothing on screen
181
+ * explains why.
182
+ */
183
+ export async function callChain({
184
+ apiKey, model, messages, tools, timeoutMs, maxTokens, onText = null,
185
+ env = process.env, callImpl = callModel, onAttempt = null,
186
+ /**
187
+ * ⭐ THE STICKY KEY, THREADED. OpenRouter uses it to route every request in a
188
+ * conversation to the SAME upstream server, which is the half of the caching
189
+ * story no amount of prefix discipline could reach — see `sessionId` in
190
+ * model.mjs. It travels with the call rather than being derived here, because
191
+ * a conversation outlives any one chain attempt.
192
+ *
193
+ * ⚠️ IT DELIBERATELY SURVIVES A MODEL DOWNGRADE. Stickiness is tracked per
194
+ * account PER MODEL, so reusing the id on the fallback model costs nothing
195
+ * and keeps the ladder's later rounds warm if it stays there.
196
+ */
197
+ sessionId = null,
198
+ /** The observed-warm route, threaded straight through — see warm-provider.mjs. */
199
+ routeOverride = null,
200
+ /**
201
+ * ⭐ INJECTED SO THE BACKOFF IS TESTABLE. A test that really sleeps is a test
202
+ * nobody runs, and a backoff nobody tests is a backoff that quietly becomes
203
+ * zero. Production passes the real timer; the suite passes a recorder.
204
+ */
205
+ sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
206
+ }) {
207
+ const chain = buildChain(model, env);
208
+ const tried = [];
209
+ let last = null;
210
+ /**
211
+ * ⚠️ THE CHAIN HAD NO BACKOFF AT ALL. Every attempt fired immediately and
212
+ * `Retry-After` was never read, so all four candidates were spent in
213
+ * milliseconds against a rate limiter that would have served us a second
214
+ * later — a chain that burns itself out faster than the limiter's window is a
215
+ * chain only on paper. Waits grow, and an explicit Retry-After always wins
216
+ * over our guess.
217
+ */
218
+ let waitBeforeNext = 0;
219
+
220
+ for (const candidate of chain) {
221
+ if (tried.length >= MAX_ATTEMPTS) break;
222
+ if (waitBeforeNext > 0) await sleepImpl(waitBeforeNext);
223
+ tried.push(candidate);
224
+ if (onAttempt) onAttempt({ model: candidate, attempt: tried.length, of: Math.min(chain.length, MAX_ATTEMPTS) });
225
+
226
+ /**
227
+ * ⚠️ `env` IS FORWARDED, and it was not. It is destructured above precisely
228
+ * so a library caller can configure a call without touching `process.env`
229
+ * — the design comment next to the provider-pin code says exactly that —
230
+ * but it reached only `buildChain`. So a caller that passed
231
+ * `ACUVO_PROVIDER_ORDER` through `env` had its pin silently ignored while
232
+ * `callModel` read the ambient environment instead. Production was
233
+ * unaffected only because `callModel` defaults to `process.env` and the CLI
234
+ * never passes anything else, which is what kept it invisible.
235
+ */
236
+ /**
237
+ * ⚠️ THE OVERRIDE APPLIES ONLY TO THE MODEL IT WAS LEARNED FOR. A chain
238
+ * falls back to a DIFFERENT model, whose cache lives somewhere else
239
+ * entirely, so carrying the pin across would force a cold provider AND
240
+ * remove its fallback — the exact failure this exists to prevent.
241
+ */
242
+ const route = candidate === model ? routeOverride : null;
243
+ const res = await callImpl({ apiKey, model: candidate, messages, tools, timeoutMs, maxTokens, onText, env, routeOverride: route, sessionId });
244
+ if (res.ok) {
245
+ return {
246
+ ...res,
247
+ attempts: tried.length,
248
+ // ⭐ True whenever the answer did NOT come from what was asked for.
249
+ usedFallback: candidate !== model,
250
+ chainTried: tried,
251
+ };
252
+ }
253
+
254
+ last = res;
255
+
256
+ /**
257
+ * ⭐ HOW LONG BEFORE THE NEXT CANDIDATE. An explicit `Retry-After` from the
258
+ * provider always wins — guessing over an instruction is how a client gets
259
+ * itself throttled harder. Otherwise 500ms, doubling, capped, so four
260
+ * attempts span about three seconds rather than forty milliseconds.
261
+ */
262
+ waitBeforeNext = Number.isFinite(res.retryAfterMs) && res.retryAfterMs > 0
263
+ ? Math.min(res.retryAfterMs, 30_000)
264
+ : Math.min((waitBeforeNext || 250) * 2, 4_000);
265
+
266
+ /**
267
+ * ⭐ A FAILURE ABOUT THIS ONE MODEL IS NOT A FAILURE ABOUT THE CHAIN. The
268
+ * next candidate sends a DIFFERENT model id, so a 404 here proves nothing
269
+ * about it — see `isModelSpecific`. Advancing costs one more attempt;
270
+ * stopping costs the whole capability, which is precisely what happened to
271
+ * the OpenCode integration.
272
+ */
273
+ if (isModelSpecific(res.error, { pinned: Array.isArray(res.providerPin) && res.providerPin.length > 0 })) continue;
274
+
275
+ /**
276
+ * ⭐ `res.kind` is the structured transport fact when `callModel` had one
277
+ * (model.mjs `transportErrorKind`). It is absent for HTTP failures, where
278
+ * the message still decides — so this reads the fact first and the prose
279
+ * second, which is the only ordering that stops the two drifting apart.
280
+ */
281
+ if (!isRetryable(res.error, res.kind ?? null)) {
282
+ /**
283
+ * ⚠️ STOP IMMEDIATELY, AND SAY THE CHAIN STOPPED EARLY. A bad key or a
284
+ * malformed request will fail identically on every provider; burning the
285
+ * remaining attempts turns a two-second error into an eight-second one and
286
+ * teaches the user that the tool is slow rather than that their key is
287
+ * wrong.
288
+ */
289
+ return { ...res, attempts: tried.length, usedFallback: false, chainTried: tried, stoppedEarly: true };
290
+ }
291
+ }
292
+
293
+ return {
294
+ ok: false,
295
+ error: [
296
+ `every provider in the chain failed after ${tried.length} attempt${tried.length === 1 ? '' : 's'}.`,
297
+ `tried: ${tried.join(' → ')}`,
298
+ `last error: ${last?.error ?? 'unknown'}`,
299
+ ].join('\n'),
300
+ attempts: tried.length,
301
+ chainTried: tried,
302
+ };
303
+ }