acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE TERMINAL IS A CANVAS, NOT A LOG ─────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Every coding CLI treats the terminal as append-only text. It is not. It is a
|
|
5
|
+
* grid of cells with 24-bit colour, and several modern terminals accept ACTUAL
|
|
6
|
+
* PIXELS through documented escape sequences — no dependency, no framework, no
|
|
7
|
+
* permission. Just bytes on stdout.
|
|
8
|
+
*
|
|
9
|
+
* ⭐ WHY THIS IS OURS AND NOT ANYONE ELSE'S. A coding agent has nothing to show
|
|
10
|
+
* you: it writes text, so it prints text. This one renders pages, generates
|
|
11
|
+
* images and draws faces, and until now it has been describing them in prose
|
|
12
|
+
* like a radio announcer calling a painting. `see_page` already produces a real
|
|
13
|
+
* screenshot and then tells you a FILE PATH. Printing the picture instead is a
|
|
14
|
+
* capability the competition structurally cannot copy, because they have no
|
|
15
|
+
* picture to print.
|
|
16
|
+
*
|
|
17
|
+
* ── ⚠️ WHAT THIS DELIBERATELY DOES NOT DO ───────────────────────────────────
|
|
18
|
+
* **No sixel.** Sixel would cover Windows Terminal and xterm, and it is the one
|
|
19
|
+
* I most wanted. It needs the image as RAW PIXELS, which means decoding PNG —
|
|
20
|
+
* inflate, defilter, palette quantisation — and that is a decoder we would own,
|
|
21
|
+
* test and be wrong in, in a package whose whole promise is zero dependencies.
|
|
22
|
+
* Kitty and iTerm2 both accept a PNG byte-for-byte, so they cost nothing.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ SO ON WINDOWS TERMINAL THIS PRINTS A PATH, exactly as before. That is
|
|
25
|
+
* stated here rather than discovered by a user, because a feature that silently
|
|
26
|
+
* does nothing on the author's own machine is how "it works" becomes a lie.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ AND IT MUST NEVER CORRUPT A TERMINAL THAT CANNOT READ IT. An unrecognised
|
|
29
|
+
* escape sequence does not render as nothing — it renders as garbage, and a few
|
|
30
|
+
* kilobytes of base64 vomited into a pipe is worse than any missing feature. So
|
|
31
|
+
* detection is ALLOWLIST-ONLY: a terminal we do not positively recognise gets
|
|
32
|
+
* text, forever. Guessing costs more than abstaining.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
|
|
37
|
+
/** Terminals whose support we have positively identified, and by which protocol. */
|
|
38
|
+
const KITTY_PROTOCOL = 'kitty';
|
|
39
|
+
const ITERM_PROTOCOL = 'iterm';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Which inline-image protocol this terminal speaks, or null.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ ENV IS READ AT CALL TIME, never captured at import — the same rule the
|
|
45
|
+
* media config follows, and for the same reason: a test must be able to hand in
|
|
46
|
+
* a different environment without reloading the module.
|
|
47
|
+
*/
|
|
48
|
+
export function detectImageProtocol(env = process.env, { isTTY = process.stdout.isTTY } = {}) {
|
|
49
|
+
/**
|
|
50
|
+
* ⚠️ THE OVERRIDE IS CHECKED FIRST AND IN BOTH DIRECTIONS. Someone on a
|
|
51
|
+
* terminal we have not heard of should be able to turn this on, and someone
|
|
52
|
+
* piping our output somewhere should be able to turn it off — and `0` must
|
|
53
|
+
* win over every piece of cleverness below it.
|
|
54
|
+
*/
|
|
55
|
+
const forced = env.ACUVO_INLINE_IMAGES?.trim();
|
|
56
|
+
if (forced === '0' || forced === 'false' || forced === 'off') return null;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ NOT A TTY MEANS SOMETHING IS READING THIS, not someone. `acuvo --json |
|
|
60
|
+
* jq` must never receive image bytes; it would not just look wrong, it would
|
|
61
|
+
* break the parse. This check sits above the allowlist deliberately: being
|
|
62
|
+
* ON kitty is irrelevant when stdout is a file.
|
|
63
|
+
*/
|
|
64
|
+
if (!isTTY && forced !== '1') return null;
|
|
65
|
+
|
|
66
|
+
const program = (env.TERM_PROGRAM ?? '').toLowerCase();
|
|
67
|
+
const term = (env.TERM ?? '').toLowerCase();
|
|
68
|
+
|
|
69
|
+
// Kitty itself, and the terminals that implement its protocol.
|
|
70
|
+
if (env.KITTY_WINDOW_ID) return KITTY_PROTOCOL;
|
|
71
|
+
if (term.includes('kitty')) return KITTY_PROTOCOL;
|
|
72
|
+
if (program === 'ghostty' || env.GHOSTTY_RESOURCES_DIR) return KITTY_PROTOCOL;
|
|
73
|
+
if (program === 'wezterm' || env.WEZTERM_PANE !== undefined) return KITTY_PROTOCOL;
|
|
74
|
+
if (env.KONSOLE_VERSION) return KITTY_PROTOCOL;
|
|
75
|
+
|
|
76
|
+
if (program === 'iterm.app' || env.ITERM_SESSION_ID) return ITERM_PROTOCOL;
|
|
77
|
+
|
|
78
|
+
// ⚠️ Everything else — Windows Terminal, VS Code, plain xterm, tmux, CI —
|
|
79
|
+
// gets text. See the sixel note at the top for why Windows Terminal is on
|
|
80
|
+
// this side of the line.
|
|
81
|
+
if (forced === '1') return KITTY_PROTOCOL;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Kitty graphics protocol: transmit-and-display a PNG.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ CHUNKED AT 4096 BASE64 CHARACTERS, WHICH IS THE SPEC AND NOT A PREFERENCE.
|
|
89
|
+
* Terminals drop escape sequences longer than their input buffer, and the
|
|
90
|
+
* failure is silent — no image, no error, nothing to debug. `m=1` means another
|
|
91
|
+
* chunk follows; the final chunk carries `m=0`.
|
|
92
|
+
*/
|
|
93
|
+
export function kittySequence(pngBytes) {
|
|
94
|
+
const b64 = Buffer.from(pngBytes).toString('base64');
|
|
95
|
+
const CHUNK = 4096;
|
|
96
|
+
if (b64.length <= CHUNK) return `\x1b_Ga=T,f=100;${b64}\x1b\\`;
|
|
97
|
+
|
|
98
|
+
const parts = [];
|
|
99
|
+
for (let i = 0; i < b64.length; i += CHUNK) {
|
|
100
|
+
const slice = b64.slice(i, i + CHUNK);
|
|
101
|
+
const more = i + CHUNK < b64.length ? 1 : 0;
|
|
102
|
+
// Control keys go on the FIRST chunk only; later chunks carry just `m`.
|
|
103
|
+
parts.push(i === 0
|
|
104
|
+
? `\x1b_Ga=T,f=100,m=${more};${slice}\x1b\\`
|
|
105
|
+
: `\x1b_Gm=${more};${slice}\x1b\\`);
|
|
106
|
+
}
|
|
107
|
+
return parts.join('');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* iTerm2 inline image protocol.
|
|
112
|
+
*
|
|
113
|
+
* `size` is the byte length of the DECODED image — iTerm uses it for a progress
|
|
114
|
+
* indicator, and getting it wrong shows a stalled bar on a picture that already
|
|
115
|
+
* arrived. `inline=1` displays rather than downloads.
|
|
116
|
+
*/
|
|
117
|
+
export function itermSequence(pngBytes, { name = 'acuvo.png' } = {}) {
|
|
118
|
+
const buf = Buffer.from(pngBytes);
|
|
119
|
+
const args = [
|
|
120
|
+
'inline=1',
|
|
121
|
+
`size=${buf.length}`,
|
|
122
|
+
`name=${Buffer.from(name, 'utf8').toString('base64')}`,
|
|
123
|
+
'width=60', // cells, not pixels — a full-width screenshot swamps the scrollback
|
|
124
|
+
'preserveAspectRatio=1',
|
|
125
|
+
].join(';');
|
|
126
|
+
return `\x1b]1337;File=${args}:${buf.toString('base64')}\x07`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* ⚠️ THE CEILING EXISTS BECAUSE THE TERMINAL IS NOT A GALLERY. A 4K screenshot
|
|
131
|
+
* is megabytes of base64 travelling through a pty one byte at a time; it stalls
|
|
132
|
+
* the session and scrolls everything useful off the screen. Beyond this we say
|
|
133
|
+
* so and print the path.
|
|
134
|
+
*/
|
|
135
|
+
const MAX_INLINE_BYTES = 1_500_000;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render an image file into the terminal if we can, and say what we did.
|
|
139
|
+
*
|
|
140
|
+
* Returns `{ shown, text }` — `text` is always safe to print, so the caller
|
|
141
|
+
* never needs to know which branch it took. Never throws: a picture failing to
|
|
142
|
+
* display must not end a coding session.
|
|
143
|
+
*/
|
|
144
|
+
export function renderImage(absolutePath, { env = process.env, isTTY = process.stdout.isTTY, readImpl = readFileSync } = {}) {
|
|
145
|
+
const protocol = detectImageProtocol(env, { isTTY });
|
|
146
|
+
if (!protocol) return { shown: false, text: null };
|
|
147
|
+
|
|
148
|
+
let bytes;
|
|
149
|
+
try {
|
|
150
|
+
bytes = readImpl(absolutePath);
|
|
151
|
+
} catch {
|
|
152
|
+
// The path is printed by the caller regardless; a missing file here is not
|
|
153
|
+
// this function's problem to report.
|
|
154
|
+
return { shown: false, text: null };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (bytes.length > MAX_INLINE_BYTES) {
|
|
158
|
+
return {
|
|
159
|
+
shown: false,
|
|
160
|
+
text: ` (${Math.round(bytes.length / 1024)}KB — too large to show inline; open the file)`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const seq = protocol === KITTY_PROTOCOL
|
|
165
|
+
? kittySequence(bytes)
|
|
166
|
+
: itermSequence(bytes, { name: absolutePath.split(/[\\/]/).pop() });
|
|
167
|
+
|
|
168
|
+
// A newline after the sequence, or the next line of output lands on top of
|
|
169
|
+
// the image — which looks exactly like a rendering bug and is not one.
|
|
170
|
+
return { shown: true, text: `${seq}\n` };
|
|
171
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE TOOLS BLOCK IS BYTE 0 OF EVERY REQUEST, SO ITS ORDER IS MONEY ───
|
|
3
|
+
*
|
|
4
|
+
* `PRICING.md` sizes the ladder on a cache floor, and a floor is
|
|
5
|
+
* `sharedHead ÷ typicalPrompt`. MEASURED on this repo, 2026-08-16, through the
|
|
6
|
+
* real `runSession` against three different repositories: the shared head
|
|
7
|
+
* between two tenants is **22,889 bytes, of which 21,466 — 94% — is the tool
|
|
8
|
+
* schema block**. It is the first thing on the wire and it is almost the whole
|
|
9
|
+
* of what any two requests have in common.
|
|
10
|
+
*
|
|
11
|
+
* ── ⚠️⚠️ AND HALF OF IT IS CONDITIONAL, SITTING IN THE MIDDLE ───────────────
|
|
12
|
+
*
|
|
13
|
+
* `toolNamesForRounds` decides what to OFFER from the machine's configuration —
|
|
14
|
+
* `--no-run`, `ACUVO_ALLOW_PUSH`, the `MODAL_*` endpoints, whether stdin is a
|
|
15
|
+
* TTY, whether this is a subagent. `toolSchemasFor` then filters the registry,
|
|
16
|
+
* which preserves REGISTRY order — and the optional tools are registered in the
|
|
17
|
+
* middle of it (`run_command` at index 3, `git_push` at 15, `generate_image` at
|
|
18
|
+
* 16, `speak` at 18).
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ SO ONE ABSENT TOOL DOES NOT COST ITS OWN 400 BYTES. It costs everything
|
|
21
|
+
* behind it: the rest of the tools block, the system message, the repo map and
|
|
22
|
+
* the task. Measured, same day, against a bare-machine reference of 31,354
|
|
23
|
+
* bytes:
|
|
24
|
+
*
|
|
25
|
+
* configuration difference shared prefix of the tools block
|
|
26
|
+
* --no-run 1,199 B 3.8%
|
|
27
|
+
* ACUVO_ALLOW_PUSH=1 10,693 B 32.9%
|
|
28
|
+
* MODAL_TTS_URL set 11,766 B 37.1%
|
|
29
|
+
* stdin is a TTY (ask_user) 17,386 B 53.6%
|
|
30
|
+
* running as a subagent 15,500 B 49.4%
|
|
31
|
+
*
|
|
32
|
+
* ⚠️⚠️ READ THE TTY ROW AGAIN. Same user, same machine, same repository, same
|
|
33
|
+
* task — `acuvo "…"` typed at a terminal and the same command in CI share
|
|
34
|
+
* **half** their prompt prefix, because one of them is offered `ask_user` and
|
|
35
|
+
* the schema for it is registered at index 37 instead of last.
|
|
36
|
+
*
|
|
37
|
+
* ── ⭐ THE FIX IS AN ORDERING CHANGE AND NOTHING ELSE ───────────────────────
|
|
38
|
+
*
|
|
39
|
+
* Constant first, conditional last. The model is offered exactly the same tools
|
|
40
|
+
* with exactly the same descriptions; only their position in the array moves,
|
|
41
|
+
* and a `tools` array is a SET of available functions, not a ranking. Measured
|
|
42
|
+
* gain, same variants:
|
|
43
|
+
*
|
|
44
|
+
* configuration difference before after
|
|
45
|
+
* --no-run 1,199 B 19,191 B (16.0x)
|
|
46
|
+
* ACUVO_ALLOW_PUSH=1 10,693 B 21,893 B (2.0x)
|
|
47
|
+
* MODAL_TTS_URL set 11,766 B 22,966 B (2.0x)
|
|
48
|
+
* stdin is a TTY 17,386 B 24,206 B (1.4x)
|
|
49
|
+
* running as a subagent 15,500 B 22,968 B (1.5x)
|
|
50
|
+
*
|
|
51
|
+
* ⭐ AND IT COSTS NO CONTEXT QUALITY, WHICH IS THE RULE THAT OUTRANKS THE CACHE.
|
|
52
|
+
* Nothing is dropped, summarised, withheld or reworded. Not compacting to
|
|
53
|
+
* protect a prefix causes context drift and drift is far more expensive than a
|
|
54
|
+
* miss — this is the opposite trade: the same context, in a cheaper order.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ `run_command` moves from index 3 to the tail, which is the one visible
|
|
57
|
+
* consequence worth naming. It is still offered, still described identically,
|
|
58
|
+
* and the verbs a model reaches for first — `read_file`, `write_file`,
|
|
59
|
+
* `edit_file`, `list_dir`, `search_text` — are unmoved at the head.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { toolNamesForRounds } from './tools.mjs';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* ── ⚠️⚠️ THE CORE IS DERIVED, NEVER HAND-LISTED ─────────────────────────────
|
|
66
|
+
*
|
|
67
|
+
* A written-down list of "the tools that are always offered" is a SECOND COPY of
|
|
68
|
+
* a fact that lives in `toolNamesForRounds`'s control flow, and the second copy
|
|
69
|
+
* is the one that goes stale. The failure would be silent and expensive: add a
|
|
70
|
+
* new conditional tool, forget the list, and it lands in the middle of the head
|
|
71
|
+
* again — voiding every prompt on every machine that does not have it, with
|
|
72
|
+
* nothing going red.
|
|
73
|
+
*
|
|
74
|
+
* So the core is computed by ASKING the offer function, across the configuration
|
|
75
|
+
* space, and intersecting the answers.
|
|
76
|
+
*
|
|
77
|
+
* ── ⭐⭐ AND THE DERIVATION IS MACHINE-INDEPENDENT BY CONSTRUCTION ──────────
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ THIS IS THE TRAP THAT NEARLY SHIPPED. The obvious version derives the core
|
|
80
|
+
* from the offers available on THIS machine — and then a machine with a
|
|
81
|
+
* language server present puts `check_types` in the core, a machine without it
|
|
82
|
+
* does not, the two produce different ORDERS, and the ordering meant to protect
|
|
83
|
+
* the prefix becomes the thing that destroys it. That is `localeCompare` in
|
|
84
|
+
* `lib/prefix-order.mjs` wearing a different hat: a sort whose result depends on
|
|
85
|
+
* the environment is not a sort, it is a per-machine cache pool.
|
|
86
|
+
*
|
|
87
|
+
* ⭐ So every probe runs against a root that CANNOT EXIST. Skills and the LSP
|
|
88
|
+
* verbs are gated on files under the workspace, so a non-existent root offers
|
|
89
|
+
* neither, on every machine, for ever. The derived core is therefore a pure
|
|
90
|
+
* function of this package's own code — identical in CI, on Roman's laptop and
|
|
91
|
+
* on the box the gateway runs on, which is the only way two tenants can share a
|
|
92
|
+
* byte-identical head at all.
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ AND THE ERROR DIRECTION IS SAFE. An intersection can only ever SHRINK the
|
|
95
|
+
* core, so an unsampled configuration can push a tool OUT of the head (costing a
|
|
96
|
+
* little ordering) and can never sneak one IN (which would cost the prefix). A
|
|
97
|
+
* tool this misses is a tool in the tail, and the tail is always correct.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A path no workspace uses, so `existsSync` is false everywhere. ⚠️ It is never
|
|
102
|
+
* created, read or written — only NAMED, so that the root-gated verbs answer
|
|
103
|
+
* "absent" identically on every machine.
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ ORDINARY CHARACTERS ONLY. The first draft of this constant separated its
|
|
106
|
+
* words with literal NUL bytes, which render as NOTHING in every viewer — so the
|
|
107
|
+
* source read as a normal path, the test asserting the path does not exist was
|
|
108
|
+
* checking a DIFFERENT string, and both passed. `cache-prefix-stability.test.mjs`
|
|
109
|
+
* records the same trap in its own separators: a control character in source is
|
|
110
|
+
* a value nobody can review.
|
|
111
|
+
*/
|
|
112
|
+
const NOWHERE = '/acuvo-cache-core-probe-no-such-workspace';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Which environment variables does the offer even consult? Recorded rather than
|
|
116
|
+
* guessed, so a new gate on a new variable is sampled the day it is written.
|
|
117
|
+
*
|
|
118
|
+
* ⚠️ `has` AND `ownKeys` ARE TRAPPED TOO, not just `get`. A gate written as
|
|
119
|
+
* `'MODAL_TTS_URL' in env` reads through a different trap, and a spy that only
|
|
120
|
+
* watched `get` would report the variable as unread and leave its tool in the
|
|
121
|
+
* head.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ AND THOSE TWO TRAPS ARE NOT COVERED BY A TEST, because no gate in the
|
|
124
|
+
* package reaches them today — deleting them leaves the suite green. That is
|
|
125
|
+
* stated rather than papered over: they are insurance against the next gate, and
|
|
126
|
+
* the thing that WOULD catch a miss is the brute-force re-derivation in
|
|
127
|
+
* `test/tool-prefix-order.test.mjs`, which samples the variables directly.
|
|
128
|
+
*/
|
|
129
|
+
function envKeysConsulted(maxRounds) {
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const spy = new Proxy({}, {
|
|
132
|
+
get(_t, key) { if (typeof key === 'string') seen.add(key); return undefined; },
|
|
133
|
+
has(_t, key) { if (typeof key === 'string') seen.add(key); return false; },
|
|
134
|
+
ownKeys() { return []; },
|
|
135
|
+
getOwnPropertyDescriptor() { return undefined; },
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
toolNamesForRounds(maxRounds, { allowRun: true, root: NOWHERE, env: spy });
|
|
139
|
+
} catch {
|
|
140
|
+
/* a spy that upsets a future gate must not take the ordering down with it */
|
|
141
|
+
}
|
|
142
|
+
return [...seen].sort();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* ⚠️ FOUR ENVIRONMENT POINTS, AND THE EMPTY STRING IS NOT REDUNDANT. Gates in
|
|
147
|
+
* this package are deliberately not all the same shape: `PERCHANCE_IMAGE_URL`
|
|
148
|
+
* UNSET means "use the public endpoint, offer the tool", and set-but-EMPTY means
|
|
149
|
+
* "this machine has switched it off". Sampling only unset would leave
|
|
150
|
+
* `generate_image` looking unconditional.
|
|
151
|
+
*/
|
|
152
|
+
const ENV_SAMPLES = ['', '1', 'https://example.invalid'];
|
|
153
|
+
|
|
154
|
+
/** Memoised per rounds-bucket: the derivation is pure, so it is computed once. */
|
|
155
|
+
const cache = new Map();
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The tools offered under EVERY configuration — the ones whose bytes may sit in
|
|
159
|
+
* the shared head.
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ BUCKETED ON `maxRounds <= 1`, because that is the only threshold
|
|
162
|
+
* `toolNamesForRounds` has: a single-shot turn returns `SINGLE_SHOT_TOOL_NAMES`
|
|
163
|
+
* and shares almost nothing with a multi-round offer. They are different cache
|
|
164
|
+
* pools by nature and pretending otherwise would put a multi-round tool in a
|
|
165
|
+
* single-shot head.
|
|
166
|
+
*
|
|
167
|
+
* @param {number} maxRounds
|
|
168
|
+
* @returns {Set<string>}
|
|
169
|
+
*/
|
|
170
|
+
export function alwaysOfferedNames(maxRounds) {
|
|
171
|
+
const bucket = Number(maxRounds) <= 1 ? 'single' : 'multi';
|
|
172
|
+
const memo = cache.get(bucket);
|
|
173
|
+
if (memo) return memo;
|
|
174
|
+
|
|
175
|
+
const rounds = bucket === 'single' ? 1 : 8;
|
|
176
|
+
const keys = envKeysConsulted(rounds);
|
|
177
|
+
const envs = [{}, ...ENV_SAMPLES.map((v) => Object.fromEntries(keys.map((k) => [k, v])))];
|
|
178
|
+
|
|
179
|
+
/** @type {Set<string> | null} */
|
|
180
|
+
let core = null;
|
|
181
|
+
for (const allowRun of [true, false]) {
|
|
182
|
+
for (const subagent of [true, false]) {
|
|
183
|
+
for (const interactive of [true, false]) {
|
|
184
|
+
for (const env of envs) {
|
|
185
|
+
let offered;
|
|
186
|
+
try {
|
|
187
|
+
offered = toolNamesForRounds(rounds, { allowRun, subagent, interactive, root: NOWHERE, env });
|
|
188
|
+
} catch {
|
|
189
|
+
/* a configuration the offer refuses contributes nothing to the intersection */
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const names = new Set(offered);
|
|
193
|
+
core = core === null ? names : new Set([...core].filter((n) => names.has(n)));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const result = core ?? new Set();
|
|
200
|
+
cache.set(bucket, result);
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* ── ⭐ CONSTANT FIRST, CONDITIONAL LAST, EACH HALF IN ITS ORIGINAL ORDER ────
|
|
206
|
+
*
|
|
207
|
+
* ⚠️ A STABLE PARTITION, NOT A SORT. Both halves keep the order they arrived in
|
|
208
|
+
* — registry order, which is already deterministic — so this adds no second
|
|
209
|
+
* ordering rule that could disagree with the first. Two machines offering the
|
|
210
|
+
* same tools produce byte-identical output; two machines differing by one tool
|
|
211
|
+
* differ only in the tail.
|
|
212
|
+
*
|
|
213
|
+
* ⚠️ IT NEVER ADDS, REMOVES OR EDITS A SCHEMA. The output is a permutation of
|
|
214
|
+
* the input and nothing else — that property is what makes this safe to apply on
|
|
215
|
+
* the model's door, and it is asserted directly rather than reviewed.
|
|
216
|
+
*
|
|
217
|
+
* @param {Array<{ function?: { name?: string } }>} schemas as `toolSchemasFor` returned them
|
|
218
|
+
* @param {{ maxRounds?: number }} [opts]
|
|
219
|
+
* @returns {Array<any>} the same schemas, constant ones first
|
|
220
|
+
*/
|
|
221
|
+
export function orderForCachePrefix(schemas, { maxRounds = 8 } = {}) {
|
|
222
|
+
if (!Array.isArray(schemas) || schemas.length === 0) return Array.isArray(schemas) ? [...schemas] : [];
|
|
223
|
+
const core = alwaysOfferedNames(maxRounds);
|
|
224
|
+
const isCore = (t) => core.has(t?.function?.name);
|
|
225
|
+
return [...schemas.filter(isCore), ...schemas.filter((t) => !isCore(t))];
|
|
226
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐⭐ STOP NEEDING TO SEND 63 SCHEMAS ────────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Roman, 2026-08-20, on our own 100% cache number: *"a high-90s hit rate usually
|
|
5
|
+
* means your software is sending the exact same large prompt over and over."*
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ HALF WRONG AND HALF EXACTLY RIGHT, and the right half is this file. The
|
|
8
|
+
* tool block MUST be re-sent every round — that is how chat-completions work,
|
|
9
|
+
* not redundancy anyone chose — so caching it is the correct fix. But caching
|
|
10
|
+
* makes 14,213 tokens *cheap*, not *free*: a cached read is still billed at
|
|
11
|
+
* roughly a tenth, and it still occupies the context window, which is the
|
|
12
|
+
* resource no cache refunds.
|
|
13
|
+
*
|
|
14
|
+
* MEASURED TODAY: `toolNamesForRounds` varies the offer by ROUND BUDGET and
|
|
15
|
+
* nothing else — 47 tools at every budget above one. So "fix this typo" carries
|
|
16
|
+
* the identical ~12k-token surface as "refactor the auth system".
|
|
17
|
+
*
|
|
18
|
+
* ⭐ THE EVIDENCE THIS IS WORTH DOING: adaptive shortlisting measured **93.1% vs
|
|
19
|
+
* 87.1%** overall and **76.8% vs 60.9%** on medium-difficulty queries against a
|
|
20
|
+
* fixed offer. Fewer, better-chosen tools beat more tools.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️⚠️ AND THE FAILURE MODE THAT MAKES NAIVE SHORTLISTING WORSE THAN NOTHING:
|
|
23
|
+
* withholding a tool the task actually needed. This repo has already measured
|
|
24
|
+
* that **tool search fails on PARAPHRASE, not ranking** — a user who says
|
|
25
|
+
* "commit this" and one who says "save my work to version control" are the same
|
|
26
|
+
* intent and only one matches a keyword. So the design here is deliberately not
|
|
27
|
+
* a classifier:
|
|
28
|
+
*
|
|
29
|
+
* 1. a CORE set is ALWAYS offered — the spine of any coding task;
|
|
30
|
+
* 2. optional groups are ADDED on signal, never subtracted;
|
|
31
|
+
* 3. ⭐ and the moment the model reaches for something it was not given, the
|
|
32
|
+
* next round gets EVERYTHING. Widening is automatic and permanent for the
|
|
33
|
+
* session, so the worst case is one wasted round rather than a task the
|
|
34
|
+
* agent cannot finish.
|
|
35
|
+
*
|
|
36
|
+
* That third rule is what makes this safe to ship. A shortlist you cannot escape
|
|
37
|
+
* is a capability ceiling; a shortlist that opens the moment it is wrong is an
|
|
38
|
+
* optimisation.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The spine. Every one of these is reachable from almost any coding task, and
|
|
43
|
+
* the cost of withholding one is a failed run.
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ GENEROUS ON PURPOSE. The saving comes from the groups below, which are
|
|
46
|
+
* large and specialised; shaving the core would buy little and risk much.
|
|
47
|
+
*/
|
|
48
|
+
export const CORE_TOOLS = Object.freeze([
|
|
49
|
+
'read_file', 'read_lines', 'read_around', 'list_dir', 'find_files', 'search_text',
|
|
50
|
+
'write_file', 'write_files', 'edit_file', 'move_file', 'delete_file',
|
|
51
|
+
'run_command', 'evaluate',
|
|
52
|
+
'plan_start', 'plan_step', 'plan_status',
|
|
53
|
+
'check_acceptance', 'declare_acceptance',
|
|
54
|
+
'read_skill', 'remember', 'forget', 'ask_user', 'delegate',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Specialised groups, each with the words that mean "this task is about that".
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ THE WORDS ARE A HINT, NOT A GATE. Missing one costs a single round because
|
|
61
|
+
* of the widening rule; there is no need for them to be exhaustive, and pretending
|
|
62
|
+
* they could be is how a keyword list becomes a capability ceiling.
|
|
63
|
+
*/
|
|
64
|
+
export const TOOL_GROUPS = Object.freeze({
|
|
65
|
+
vcs: {
|
|
66
|
+
tools: ['git_status', 'git_diff', 'git_log', 'git_commit', 'git_branch', 'git_push', 'gh_issue', 'gh_pr', 'gh_run'],
|
|
67
|
+
words: ['git', 'commit', 'branch', 'merge', 'rebase', 'pr', 'pull request', 'push', 'issue', 'github', 'review', 'diff', 'changelog', 'version control', 'ci', 'workflow', 'release'],
|
|
68
|
+
},
|
|
69
|
+
process: {
|
|
70
|
+
tools: ['start_process', 'stop_process', 'check_process', 'read_log', 'wait_for_output', 'summarize_log', 'call_endpoint'],
|
|
71
|
+
words: ['server', 'dev server', 'run it', 'serve', 'port', 'localhost', 'api', 'endpoint', 'daemon', 'watch', 'log', 'logs', 'background', 'start', 'boot', 'listen'],
|
|
72
|
+
},
|
|
73
|
+
intel: {
|
|
74
|
+
tools: ['check_types', 'find_definition', 'find_references', 'list_symbols', 'review_code'],
|
|
75
|
+
words: ['type', 'types', 'typescript', 'tsc', 'refactor', 'rename', 'definition', 'reference', 'symbol', 'interface', 'signature', 'review', 'audit', 'lint'],
|
|
76
|
+
},
|
|
77
|
+
db: {
|
|
78
|
+
tools: ['inspect_db', 'sample_db_rows'],
|
|
79
|
+
words: ['database', 'db', 'sql', 'table', 'schema', 'query', 'postgres', 'sqlite', 'migration', 'row', 'rows'],
|
|
80
|
+
},
|
|
81
|
+
web: {
|
|
82
|
+
tools: ['web_search', 'fetch_url', 'see_page'],
|
|
83
|
+
words: ['search', 'docs', 'documentation', 'look up', 'website', 'url', 'http', 'scrape', 'fetch', 'browse', 'page', 'screenshot', 'render'],
|
|
84
|
+
},
|
|
85
|
+
media: {
|
|
86
|
+
tools: ['generate_image', 'edit_image', 'expand_image', 'read_image', 'speak', 'transcribe', 'list_engines'],
|
|
87
|
+
words: ['image', 'picture', 'photo', 'logo', 'icon', 'illustration', 'voice', 'speak', 'audio', 'speech', 'transcribe', 'video', 'render', 'design', 'visual'],
|
|
88
|
+
},
|
|
89
|
+
docs: {
|
|
90
|
+
tools: ['make_document', 'read_document', 'read_table'],
|
|
91
|
+
words: ['document', 'pdf', 'docx', 'spreadsheet', 'csv', 'excel', 'report', 'table', 'export'],
|
|
92
|
+
},
|
|
93
|
+
repl: {
|
|
94
|
+
tools: ['repl', 'repl_reset', 'run_program'],
|
|
95
|
+
words: ['repl', 'interactive', 'experiment', 'try', 'explore', 'inspect', 'debug', 'session', 'python', 'node script'],
|
|
96
|
+
},
|
|
97
|
+
session: {
|
|
98
|
+
tools: ['list_sessions'],
|
|
99
|
+
words: ['session', 'resume', 'earlier', 'previous run', 'last time', 'history'],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const norm = (s) => String(s ?? '').toLowerCase();
|
|
104
|
+
|
|
105
|
+
/** Which groups a task's own words point at. */
|
|
106
|
+
export function groupsForTask(task) {
|
|
107
|
+
const text = norm(task);
|
|
108
|
+
if (!text.trim()) return Object.keys(TOOL_GROUPS);
|
|
109
|
+
const hit = [];
|
|
110
|
+
for (const [name, g] of Object.entries(TOOL_GROUPS)) {
|
|
111
|
+
if (g.words.some((w) => text.includes(w))) hit.push(name);
|
|
112
|
+
}
|
|
113
|
+
return hit;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The tools to offer for this task.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} task the user's brief, verbatim
|
|
120
|
+
* @param {readonly string[]} available what the environment actually allows —
|
|
121
|
+
* the shortlist may only ever be a SUBSET of this. Withdrawal (no shell, no
|
|
122
|
+
* browser, no key) has already been decided upstream and must not be undone
|
|
123
|
+
* here; a shortlist that re-offers a withdrawn tool is worse than no shortlist.
|
|
124
|
+
* @param {{ widened?: boolean }} [opts] `widened` is set once the model has
|
|
125
|
+
* reached for something it was not given, and never unset for the session.
|
|
126
|
+
*/
|
|
127
|
+
export function shortlistTools(task, available, { widened = false } = {}) {
|
|
128
|
+
const allowed = new Set(available ?? []);
|
|
129
|
+
/**
|
|
130
|
+
* ⚠️ WIDENED IS ABSOLUTE. Once the model has demonstrated the shortlist was
|
|
131
|
+
* wrong, guessing again is how a run oscillates between two wrong offers.
|
|
132
|
+
*/
|
|
133
|
+
if (widened) return [...allowed];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* ⚠️ AN EMPTY OR VERY SHORT BRIEF OFFERS EVERYTHING. "fix it" carries no
|
|
137
|
+
* signal, and a shortlist built from no evidence is a guess with consequences.
|
|
138
|
+
*/
|
|
139
|
+
const text = norm(task);
|
|
140
|
+
if (text.trim().length < 12) return [...allowed];
|
|
141
|
+
|
|
142
|
+
const keep = new Set(CORE_TOOLS.filter((t) => allowed.has(t)));
|
|
143
|
+
for (const name of groupsForTask(task)) {
|
|
144
|
+
for (const t of TOOL_GROUPS[name].tools) if (allowed.has(t)) keep.add(t);
|
|
145
|
+
}
|
|
146
|
+
// `finish` and anything else the environment offers that we do not classify
|
|
147
|
+
// stays IN — an unclassified tool is one we do not understand, and dropping
|
|
148
|
+
// what you do not understand is how capability disappears quietly.
|
|
149
|
+
for (const t of allowed) {
|
|
150
|
+
if (!Object.values(TOOL_GROUPS).some((g) => g.tools.includes(t))) keep.add(t);
|
|
151
|
+
}
|
|
152
|
+
return [...allowed].filter((t) => keep.has(t));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Did the model just reach for a tool it was not given? That is the signal to
|
|
157
|
+
* widen — and it is a FACT, not a heuristic.
|
|
158
|
+
*/
|
|
159
|
+
export function shouldWiden(calledNames, offered) {
|
|
160
|
+
const have = new Set(offered ?? []);
|
|
161
|
+
return (calledNames ?? []).some((n) => typeof n === 'string' && n && !have.has(n));
|
|
162
|
+
}
|