acuvo-code 0.6.8 → 0.6.10
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/ENTERPRISE.md +6 -6
- package/README.md +2 -2
- package/lib/avatar-run.mjs +208 -0
- package/lib/avatar.mjs +260 -0
- package/lib/banner.mjs +131 -146
- package/lib/input-box.mjs +26 -1
- package/lib/mcp-server.mjs +13 -0
- package/lib/tools.mjs +58 -0
- package/package.json +1 -1
package/ENTERPRISE.md
CHANGED
|
@@ -188,13 +188,13 @@ copy, but it *is* a place a process starts, and this is a list of those. Six and
|
|
|
188
188
|
are the numbers to quote. Counting is the first thing a reviewer does.
|
|
189
189
|
|
|
190
190
|
⚠️ **This said "18 shipped files", then "41", then "90", then "101", then "108", and every
|
|
191
|
-
one went stale in turn.** The package ships **
|
|
192
|
-
`bin/` — about
|
|
191
|
+
one went stale in turn.** The package ships **124 files — 122 in `lib/`, 2 in
|
|
192
|
+
`bin/` — about 80127 lines**, with **238 test files** beside them (counted 2026-08-22).
|
|
193
193
|
|
|
194
194
|
⭐ **AND THE 108 WENT STALE IN THE MOST INSTRUCTIVE WAY POSSIBLE: THREE OF THE FILES IT
|
|
195
195
|
MISSED WERE REACHABLE FROM NOTHING.** `wiring-reach.test.mjs` was naming
|
|
196
196
|
`lib/python.mjs`, `lib/cache-floor.mjs` and `lib/plan-coherence.mjs` as importable by no
|
|
197
|
-
entry point —
|
|
197
|
+
entry point — 80127 lines of complete, tested capability that had not shipped in any sense
|
|
198
198
|
a customer would recognise. A file count that rises while the reachable surface does not is
|
|
199
199
|
exactly the kind of number this section warns about.
|
|
200
200
|
|
|
@@ -203,7 +203,7 @@ exactly the kind of number this section warns about.
|
|
|
203
203
|
allowlist also carries **`test/`, `ENTERPRISE.md` and `ROADMAP.md`**, and the published
|
|
204
204
|
tarball is **265 files, 5.1 MB unpacked — 182 of them tests against 98 of `lib/`.** That is
|
|
205
205
|
deliberate, not drift (commit `ed08f2710`, *"ship the tests, and add CI that would have
|
|
206
|
-
caught the false green"*): a document that invites you to audit
|
|
206
|
+
caught the false green"*): a document that invites you to audit 80127 lines and then ships
|
|
207
207
|
you the source without the tests is asking to be taken on trust, which is the one thing this
|
|
208
208
|
file refuses to ask for. ⭐ **Run them yourself: `npm test` inside the installed package.**
|
|
209
209
|
The stale sentence is the more interesting failure — it under-claimed, so nobody would ever
|
|
@@ -217,7 +217,7 @@ everything else, and they would be right to.
|
|
|
217
217
|
|
|
218
218
|
⚠️⚠️ **AND THE LINE COUNT IS THE HALF THAT KEPT SLIPPING.** `docs-truth` failed the
|
|
219
219
|
build on the FILE count and never on the line count, so "41 files" was caught within a
|
|
220
|
-
day while "
|
|
220
|
+
day while "80127 lines" sat wrong for four days and "49,578" for one — in a paragraph
|
|
221
221
|
whose whole purpose is to tell a reviewer the numbers are checked. The guard now covers
|
|
222
222
|
both, with a stated **2% tolerance** on lines: an exact pin would go red on every commit
|
|
223
223
|
and become a nag, and a nag is a guard people learn to edit rather than read.
|
|
@@ -892,7 +892,7 @@ For completeness, the properties none of them offers:
|
|
|
892
892
|
(`lib/media.mjs`), and generates imagery with no configuration and no account
|
|
893
893
|
(`lib/imagegen.mjs`) — critiqued before it is accepted, and reported as unreviewed when
|
|
894
894
|
no critic is available.
|
|
895
|
-
- ⭐ **Zero dependencies.** The entire auditable surface is
|
|
895
|
+
- ⭐ **Zero dependencies.** The entire auditable surface is 124 files and 80127 lines,
|
|
896
896
|
and there is no `node_modules` behind it. (Counted 2026-08-22 from
|
|
897
897
|
`lib/*.mjs` + `bin/*.mjs`; `test/docs-truth.test.mjs` fails the build if this number
|
|
898
898
|
drifts, which is why it went 18 → 41 → 46 → 52 → 53 → 57 → 60 → 61 → 62 → 65 → 66 → 69 → 70 → 71 → 72 → 73 → 80 → 84 → 90 → 100 → 101 → 102 → 103 → 107 → 108 → 111 as modules landed (111 = the three that were WRITTEN and imported by nothing — `python.mjs`, `cache-floor.mjs`, `plan-coherence.mjs`; 108 = `warm-provider.mjs`, which keeps a session on the upstream that holds its prompt cache; 107 = `login.mjs`, the command that stores an Acuvo credential — until it existed, `writeAccount` was called by nothing and every user fell through to BYOK). ⚠️ Two of those three landed on this count while remaining UNREACHABLE, which is the sharpest illustration this document has that a file count is a claim about bytes, never about capability. ⭐ A
|
package/README.md
CHANGED
|
@@ -783,7 +783,7 @@ workspace containing a hostile `.mcp.json` under each flag.
|
|
|
783
783
|
|
|
784
784
|
## The rest of the verbs
|
|
785
785
|
|
|
786
|
-
The registry holds **
|
|
786
|
+
The registry holds **68 tools** (`TOOL_SCHEMAS`, `lib/tools.mjs` — count it yourself, and
|
|
787
787
|
`acuvo --doctor` prints which of them would be offered on your machine). The obvious ones
|
|
788
788
|
are above; **the rest** reach the model in any multi-round run (`--max-rounds` above 1,
|
|
789
789
|
which is the default). You never name them — the model picks. They are listed because a
|
|
@@ -813,7 +813,7 @@ useless here, and four dead buttons. The gate is now the intersection of *instal
|
|
|
813
813
|
|
|
814
814
|
### Integrations — MCP, and the curated set
|
|
815
815
|
|
|
816
|
-
The
|
|
816
|
+
The 68 tools above are the ones we built, and that is a real ceiling: work that lives in
|
|
817
817
|
your database, your issue tracker or your browser needs an adapter nobody is going to
|
|
818
818
|
write. **Model Context Protocol** is the escape — an MCP server is just a process, so
|
|
819
819
|
breadth here is nearly free.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ THE EXECUTION HALF OF THE MOAT VERBS ─────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* `avatar.mjs` decides; this runs. Everything that makes a JUDGEMENT — is this
|
|
5
|
+
* configured, is `running` a failure, is a `done` with no artifact a success —
|
|
6
|
+
* lives next door and is tested without a GPU. What is here is transport, files
|
|
7
|
+
* and a clock.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ THE ONE RULE BOTH HALVES SHARE: a failure is a RETURNED VALUE, never a
|
|
10
|
+
* throw. A GPU that is down must read as a tool result the model can reason
|
|
11
|
+
* about and recover from, not a stack trace that ends somebody's session.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
17
|
+
import {
|
|
18
|
+
avatarConfig, readStart, readPoll, keepPolling, whyUnavailable,
|
|
19
|
+
DEFAULT_JOB_BUDGET_MS, POLL_INTERVAL_MS,
|
|
20
|
+
} from './avatar.mjs';
|
|
21
|
+
|
|
22
|
+
/** Inputs are voice samples and portraits, not archives. */
|
|
23
|
+
export const MAX_INPUT_BYTES = 25 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
function loadAsset(root, path, what) {
|
|
26
|
+
if (typeof path !== 'string' || !path.trim()) return { ok: false, error: `${what} is required` };
|
|
27
|
+
const target = resolveInWorkspace(root, path, 'read');
|
|
28
|
+
if (!target.ok) return { ok: false, error: target.reason };
|
|
29
|
+
let buf;
|
|
30
|
+
try { buf = readFileSync(target.absolute); } catch (err) {
|
|
31
|
+
return { ok: false, error: `could not read ${target.relative}: ${err?.message ?? err}` };
|
|
32
|
+
}
|
|
33
|
+
if (buf.length > MAX_INPUT_BYTES) {
|
|
34
|
+
return { ok: false, error: `${target.relative} is ${(buf.length / 1e6).toFixed(1)} MB, over the ${MAX_INPUT_BYTES / 1e6} MB input limit` };
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, relative: target.relative, b64: buf.toString('base64') };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeArtifact(root, rawPath, base64, dryRun) {
|
|
40
|
+
const target = resolveInWorkspace(root, rawPath, 'write');
|
|
41
|
+
if (!target.ok) return { ok: false, error: target.reason };
|
|
42
|
+
const buf = Buffer.from(base64, 'base64');
|
|
43
|
+
if (!dryRun) {
|
|
44
|
+
mkdirSync(dirname(target.absolute), { recursive: true });
|
|
45
|
+
writeFileSync(target.absolute, buf);
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, path: target.relative, bytes: buf.length, dryRun };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* POST JSON. Never throws.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ THE TIMEOUT IS PER REQUEST, NOT PER JOB. A start call answers in seconds
|
|
54
|
+
* even when the render behind it runs for minutes; giving this the whole job
|
|
55
|
+
* budget would turn an unreachable endpoint into a twelve-minute hang.
|
|
56
|
+
*/
|
|
57
|
+
async function postJson(url, body, { secret, fetchImpl = fetch, timeoutMs = 30_000 } = {}) {
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetchImpl(url, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json', ...(secret ? { authorization: `Bearer ${secret}` } : {}) },
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
});
|
|
67
|
+
const text = await res.text();
|
|
68
|
+
let parsed = null;
|
|
69
|
+
try { parsed = JSON.parse(text); } catch { /* handled below */ }
|
|
70
|
+
if (!res.ok) return { ok: false, error: `${res.status} ${String(text).slice(0, 200)}` };
|
|
71
|
+
if (!parsed) return { ok: false, error: `the endpoint returned a non-JSON body: ${String(text).slice(0, 120)}` };
|
|
72
|
+
return parsed;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: err?.name === 'AbortError' ? `no answer within ${timeoutMs / 1000}s` : String(err?.message ?? err),
|
|
77
|
+
};
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const nap = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Start a job and wait for it, on a wall-clock budget.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ A TIMEOUT REPORTS THE `callId`. The render usually carries on running on
|
|
89
|
+
* Modal after we stop waiting, so the honest message is "it may yet finish" plus
|
|
90
|
+
* the handle — not silence that reads as money vanishing.
|
|
91
|
+
*/
|
|
92
|
+
export async function runJob({
|
|
93
|
+
startUrl, resultUrl, payload, secret, field,
|
|
94
|
+
fetchImpl = fetch, budgetMs = DEFAULT_JOB_BUDGET_MS,
|
|
95
|
+
now = () => Date.now(), sleep = nap, pollMs = POLL_INTERVAL_MS,
|
|
96
|
+
}) {
|
|
97
|
+
const started = readStart(await postJson(startUrl, payload, { secret, fetchImpl }));
|
|
98
|
+
if (!started.ok) return { ok: false, error: started.error };
|
|
99
|
+
|
|
100
|
+
const startedAt = now();
|
|
101
|
+
for (;;) {
|
|
102
|
+
if (!keepPolling({ startedAt, now: now(), budgetMs })) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `still rendering after ${Math.round(budgetMs / 60000)} minutes — it may yet finish on the GPU`,
|
|
106
|
+
callId: started.callId,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
await sleep(pollMs);
|
|
110
|
+
const poll = readPoll(await postJson(resultUrl, { callId: started.callId }, { secret, fetchImpl }), { field });
|
|
111
|
+
if (poll.status === 'running') continue;
|
|
112
|
+
if (poll.status === 'failed') return { ok: false, error: poll.error, callId: started.callId };
|
|
113
|
+
return { ok: true, ...poll, callId: started.callId };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Turn a finished job into a written file, with what it cost attached. */
|
|
118
|
+
function deliver(root, out, job, dryRun) {
|
|
119
|
+
if (!job.ok) return job;
|
|
120
|
+
const written = writeArtifact(root, out, job.base64, dryRun);
|
|
121
|
+
if (!written.ok) return written;
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
path: written.path,
|
|
125
|
+
bytes: written.bytes,
|
|
126
|
+
dryRun: written.dryRun,
|
|
127
|
+
seconds: job.seconds || null,
|
|
128
|
+
/**
|
|
129
|
+
* ⚠️ THE COST IS RETURNED, NEVER SWALLOWED. These are the only verbs in the
|
|
130
|
+
* CLI that spend real money per call, and a user learning the number from a
|
|
131
|
+
* bill instead of from the tool result is exactly what this field prevents.
|
|
132
|
+
*/
|
|
133
|
+
usd: Number.isFinite(job.usd) && job.usd > 0 ? job.usd : null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function cloneVoice(root, { sample, text, path } = {}, opts = {}) {
|
|
138
|
+
const { env = process.env, fetchImpl = fetch, dryRun = false, now, sleep } = opts;
|
|
139
|
+
const cfg = avatarConfig(env);
|
|
140
|
+
if (!cfg.voiceClone) return { ok: false, error: whyUnavailable('clone_voice', env) };
|
|
141
|
+
if (typeof text !== 'string' || !text.trim()) return { ok: false, error: 'say what the cloned voice should read' };
|
|
142
|
+
|
|
143
|
+
const ref = loadAsset(root, sample, 'a reference audio sample');
|
|
144
|
+
if (!ref.ok) return ref;
|
|
145
|
+
|
|
146
|
+
const job = await runJob({
|
|
147
|
+
startUrl: cfg.voiceClone, resultUrl: cfg.voiceClone,
|
|
148
|
+
payload: { reference_b64: ref.b64, text: text.trim() },
|
|
149
|
+
secret: cfg.secret, field: 'audio_b64', fetchImpl, now, sleep,
|
|
150
|
+
});
|
|
151
|
+
return deliver(root, path || 'audio/cloned.wav', job, dryRun);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function designVoice(root, { description, text, path } = {}, opts = {}) {
|
|
155
|
+
const { env = process.env, fetchImpl = fetch, dryRun = false, now, sleep } = opts;
|
|
156
|
+
const cfg = avatarConfig(env);
|
|
157
|
+
if (!cfg.voiceDesign) return { ok: false, error: whyUnavailable('design_voice', env) };
|
|
158
|
+
if (typeof description !== 'string' || !description.trim()) return { ok: false, error: 'describe the voice you want' };
|
|
159
|
+
if (typeof text !== 'string' || !text.trim()) return { ok: false, error: 'say what it should read' };
|
|
160
|
+
|
|
161
|
+
const job = await runJob({
|
|
162
|
+
startUrl: cfg.voiceDesign, resultUrl: cfg.voiceDesign,
|
|
163
|
+
payload: { description: description.trim(), text: text.trim() },
|
|
164
|
+
secret: cfg.secret, field: 'audio_b64', fetchImpl, now, sleep,
|
|
165
|
+
});
|
|
166
|
+
return deliver(root, path || 'audio/designed.wav', job, dryRun);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function talkingHead(root, { image, audio, path } = {}, opts = {}) {
|
|
170
|
+
const { env = process.env, fetchImpl = fetch, dryRun = false, now, sleep } = opts;
|
|
171
|
+
const cfg = avatarConfig(env);
|
|
172
|
+
if (!cfg.face) return { ok: false, error: whyUnavailable('talking_head', env) };
|
|
173
|
+
|
|
174
|
+
const portrait = loadAsset(root, image, 'a portrait image');
|
|
175
|
+
if (!portrait.ok) return portrait;
|
|
176
|
+
const speech = loadAsset(root, audio, 'the speech audio');
|
|
177
|
+
if (!speech.ok) return speech;
|
|
178
|
+
|
|
179
|
+
const job = await runJob({
|
|
180
|
+
startUrl: cfg.face, resultUrl: cfg.faceResult,
|
|
181
|
+
payload: { image_b64: portrait.b64, audio_b64: speech.b64, image_suffix: '.png', audio_suffix: '.wav' },
|
|
182
|
+
secret: cfg.secret, field: 'video_b64', fetchImpl, now, sleep,
|
|
183
|
+
});
|
|
184
|
+
return deliver(root, path || 'video/talking-head.mp4', job, dryRun);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function generateVideo(root, { prompt, image, seconds, path } = {}, opts = {}) {
|
|
188
|
+
const { env = process.env, fetchImpl = fetch, dryRun = false, now, sleep } = opts;
|
|
189
|
+
const cfg = avatarConfig(env);
|
|
190
|
+
if (!cfg.video) return { ok: false, error: whyUnavailable('generate_video', env) };
|
|
191
|
+
if (typeof prompt !== 'string' || !prompt.trim()) {
|
|
192
|
+
return { ok: false, error: 'describe the clip — subject, motion, camera, mood' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let still = null;
|
|
196
|
+
if (image) {
|
|
197
|
+
const loaded = loadAsset(root, image, 'the still to animate');
|
|
198
|
+
if (!loaded.ok) return loaded;
|
|
199
|
+
still = loaded.b64;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const job = await runJob({
|
|
203
|
+
startUrl: cfg.video, resultUrl: cfg.videoResult,
|
|
204
|
+
payload: { prompt: prompt.trim(), ...(still ? { image_b64: still } : {}), ...(seconds ? { seconds } : {}) },
|
|
205
|
+
secret: cfg.secret, field: 'video_b64', fetchImpl, now, sleep,
|
|
206
|
+
});
|
|
207
|
+
return deliver(root, path || 'video/clip.mp4', job, dryRun);
|
|
208
|
+
}
|
package/lib/avatar.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐⭐ THE MOAT, REACHABLE FROM THE TERMINAL ─────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Roman, 2026-08-22: *"does it have all our GPU Modal integrations, the 4
|
|
5
|
+
* related to face and voice… even if it's not paid just wire in the endpoints
|
|
6
|
+
* to CLI."*
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ MEASURED THE SAME DAY: the CLI had **9 of the console's 20** Modal
|
|
9
|
+
* endpoints, and every one of the missing eleven was a moat capability —
|
|
10
|
+
* `MODAL_AVATAR_URL` (the face), `MODAL_CHARACTER_LOCK_URL` (identity),
|
|
11
|
+
* `MODAL_VOICE_CLONE_URL`, `MODAL_VOICE_DESIGN_URL`, `MODAL_VIDEO_URL`. The CLI
|
|
12
|
+
* shipped generic TTS and transcription: the two things anybody can rent. The
|
|
13
|
+
* cloned voice and the talking head — the parts no competitor can draw, running
|
|
14
|
+
* on GPUs we already pay for — were console-only.
|
|
15
|
+
*
|
|
16
|
+
* `lib/media.mjs`'s own `speak` tool said it out loud: *"voice cloning
|
|
17
|
+
* ('acuvo-voice') is not reachable from the CLI and will be refused."* That was
|
|
18
|
+
* an honest description of a gap, not a design.
|
|
19
|
+
*
|
|
20
|
+
* ── ⚠️ THE RULES THIS FILE INHERITS AND MUST NOT BREAK ──────────────────────
|
|
21
|
+
*
|
|
22
|
+
* · **Zero dependencies, and never import from `console/`.** Plain HTTPS to
|
|
23
|
+
* endpoints named by the environment. The moment this imports upward the CLI
|
|
24
|
+
* stops being installable on its own.
|
|
25
|
+
* · **A tool is OFFERED only when its endpoint is configured.** Advertising a
|
|
26
|
+
* verb that cannot run teaches the model to promise things the product then
|
|
27
|
+
* refuses — the most expensive kind of lie a tool schema can tell.
|
|
28
|
+
* · **These are LONG jobs.** A talking head is minutes, not seconds; the API is
|
|
29
|
+
* start-then-poll, and `running` is the healthy answer for most of a render's
|
|
30
|
+
* life. Treating it as failure is what made our own working GPU look dead for
|
|
31
|
+
* days.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Renders are minutes long; this is the ceiling on the whole job, not one poll. */
|
|
35
|
+
export const DEFAULT_JOB_BUDGET_MS = 12 * 60 * 1000;
|
|
36
|
+
/** How often to ask. Modal charges per container-second, not per poll. */
|
|
37
|
+
export const POLL_INTERVAL_MS = 5_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which of the four are reachable here.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ EVERY ONE NEEDS THE SECRET AS WELL AS THE URL. A URL with no secret is a
|
|
43
|
+
* 401 wearing the costume of a working integration, and it would be reported as
|
|
44
|
+
* "configured" by any check that only looked for the address.
|
|
45
|
+
*/
|
|
46
|
+
export function avatarConfig(env = process.env) {
|
|
47
|
+
const url = (k) => env[k]?.trim() || null;
|
|
48
|
+
const secret = env.ACUVO_MEDIA_SECRET?.trim() || env.MODAL_VIDEO_SECRET?.trim() || null;
|
|
49
|
+
const gated = (k) => (secret ? url(k) : null);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
/** The talking head — an image plus audio becomes a person speaking. */
|
|
53
|
+
face: gated('MODAL_AVATAR_URL'),
|
|
54
|
+
faceResult: gated('MODAL_AVATAR_RESULT_URL') || gated('MODAL_AVATAR_URL'),
|
|
55
|
+
/** Clone a voice from a sample, then speak in it. */
|
|
56
|
+
voiceClone: gated('MODAL_VOICE_CLONE_URL'),
|
|
57
|
+
/** Design a voice from a description rather than a sample. */
|
|
58
|
+
voiceDesign: gated('MODAL_VOICE_DESIGN_URL'),
|
|
59
|
+
/** Text or image to video. */
|
|
60
|
+
video: gated('MODAL_VIDEO_URL'),
|
|
61
|
+
videoResult: gated('MODAL_VIDEO_RESULT_URL') || gated('MODAL_VIDEO_URL'),
|
|
62
|
+
secret,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read a start-job response.
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ PURE, SO THE CONTRACT IS TESTABLE WITHOUT A GPU. Everything that decides
|
|
70
|
+
* whether a job succeeded lives in these two functions; the network is only a
|
|
71
|
+
* transport around them. The console's client got this shape right and the
|
|
72
|
+
* reasoning is worth restating rather than re-deriving.
|
|
73
|
+
*/
|
|
74
|
+
export function readStart(body) {
|
|
75
|
+
const callId = body?.callId ?? body?.call_id;
|
|
76
|
+
if (body?.ok !== true || typeof callId !== 'string' || !callId) {
|
|
77
|
+
return { ok: false, error: String(body?.error ?? 'the endpoint returned no callId') };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, callId };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read a poll response.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️⚠️ `running` IS SUCCESS-IN-PROGRESS, NOT FAILURE, and it is the answer for
|
|
86
|
+
* most of a render's life. This distinction is why our own working GPU was
|
|
87
|
+
* reported dead for days.
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ AND A `done` CARRYING NO ARTIFACT IS A FAILURE, not an empty success. An
|
|
90
|
+
* empty string is a valid field and silence is a valid WAV; this repo has
|
|
91
|
+
* shipped both mistakes, and each time it surfaced as a file the user could not
|
|
92
|
+
* play rather than as an error they could act on.
|
|
93
|
+
*/
|
|
94
|
+
export function readPoll(body, { field = 'video_b64', minBytes = 1000 } = {}) {
|
|
95
|
+
if (body?.ok !== true) return { status: 'failed', error: String(body?.error ?? 'poll failed') };
|
|
96
|
+
const status = String(body?.status ?? '');
|
|
97
|
+
if (status === 'running' || status === 'pending' || status === 'queued') return { status: 'running' };
|
|
98
|
+
if (status !== 'done') return { status: 'failed', error: String(body?.error ?? `unexpected status ${status}`) };
|
|
99
|
+
|
|
100
|
+
const artifact = body?.[field];
|
|
101
|
+
if (typeof artifact !== 'string' || artifact.length < minBytes) {
|
|
102
|
+
return { status: 'failed', error: `the job reported done but returned no ${field}` };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
status: 'done',
|
|
106
|
+
base64: artifact,
|
|
107
|
+
bytes: Number(body?.bytes ?? 0),
|
|
108
|
+
seconds: Number(body?.seconds ?? 0),
|
|
109
|
+
usd: Number(body?.usd ?? 0),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Should we keep waiting?
|
|
115
|
+
*
|
|
116
|
+
* ⚠️ THE BUDGET IS ON WALL CLOCK, NOT ON POLL COUNT. A poll count silently
|
|
117
|
+
* becomes a different timeout the moment the interval changes, and the number
|
|
118
|
+
* that matters to a user — and to a Modal bill — is minutes.
|
|
119
|
+
*/
|
|
120
|
+
export function keepPolling({ startedAt, now, budgetMs = DEFAULT_JOB_BUDGET_MS }) {
|
|
121
|
+
return now - startedAt < budgetMs;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The tool schemas, offered only for endpoints that exist.
|
|
126
|
+
*
|
|
127
|
+
* ⚠️ THE DESCRIPTIONS SAY WHAT THESE COST AND HOW LONG THEY TAKE. A model with
|
|
128
|
+
* no sense of price will call a 7-minute GPU render to put a talking head on a
|
|
129
|
+
* placeholder page, and the user finds out when the bill arrives. Naming the
|
|
130
|
+
* cost in the schema is the cheapest guardrail available.
|
|
131
|
+
*/
|
|
132
|
+
export function avatarToolSchemas(env = process.env) {
|
|
133
|
+
const cfg = avatarConfig(env);
|
|
134
|
+
const out = [];
|
|
135
|
+
|
|
136
|
+
if (cfg.voiceClone) {
|
|
137
|
+
out.push({
|
|
138
|
+
type: 'function',
|
|
139
|
+
function: {
|
|
140
|
+
name: 'clone_voice',
|
|
141
|
+
description: [
|
|
142
|
+
'Clone a voice from a short audio sample, then speak arbitrary text in it.',
|
|
143
|
+
'Runs on our own GPU. A 10-30 second clean sample is enough.',
|
|
144
|
+
'⚠️ Only use a voice the user owns or has permission to use — ask if it is not obviously theirs.',
|
|
145
|
+
].join(' '),
|
|
146
|
+
parameters: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
sample: { type: 'string', description: 'Workspace-relative path to the reference audio (wav/mp3).' },
|
|
150
|
+
text: { type: 'string', description: 'What the cloned voice should say.' },
|
|
151
|
+
path: { type: 'string', description: 'Optional output path, e.g. "audio/vo.wav".' },
|
|
152
|
+
},
|
|
153
|
+
required: ['sample', 'text'],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (cfg.voiceDesign) {
|
|
160
|
+
out.push({
|
|
161
|
+
type: 'function',
|
|
162
|
+
function: {
|
|
163
|
+
name: 'design_voice',
|
|
164
|
+
description: [
|
|
165
|
+
'Create a NEW voice from a description ("warm Australian woman, mid 30s, unhurried")',
|
|
166
|
+
'without needing a sample, then speak text in it. Use when there is nobody to clone.',
|
|
167
|
+
].join(' '),
|
|
168
|
+
parameters: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
properties: {
|
|
171
|
+
description: { type: 'string', description: 'What the voice should sound like.' },
|
|
172
|
+
text: { type: 'string', description: 'What it should say.' },
|
|
173
|
+
path: { type: 'string', description: 'Optional output path.' },
|
|
174
|
+
},
|
|
175
|
+
required: ['description', 'text'],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (cfg.face) {
|
|
182
|
+
out.push({
|
|
183
|
+
type: 'function',
|
|
184
|
+
function: {
|
|
185
|
+
name: 'talking_head',
|
|
186
|
+
description: [
|
|
187
|
+
'Turn a photo plus an audio file into a video of that person speaking — our own GPU,',
|
|
188
|
+
'measured at about a third of what HeyGen charges.',
|
|
189
|
+
'⚠️ SLOW AND NOT FREE: minutes per render, priced per video-second. Do not use it for a',
|
|
190
|
+
'placeholder; generate the final audio first and render once.',
|
|
191
|
+
'⚠️ Only for a face the user has the right to use.',
|
|
192
|
+
].join(' '),
|
|
193
|
+
parameters: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
properties: {
|
|
196
|
+
image: { type: 'string', description: 'Workspace-relative path to a portrait image.' },
|
|
197
|
+
audio: { type: 'string', description: 'Workspace-relative path to the speech audio.' },
|
|
198
|
+
path: { type: 'string', description: 'Optional output path, e.g. "video/intro.mp4".' },
|
|
199
|
+
},
|
|
200
|
+
required: ['image', 'audio'],
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (cfg.video) {
|
|
207
|
+
out.push({
|
|
208
|
+
type: 'function',
|
|
209
|
+
function: {
|
|
210
|
+
name: 'generate_video',
|
|
211
|
+
description: [
|
|
212
|
+
'Generate a short video from a text prompt, or animate a still image.',
|
|
213
|
+
'⚠️ THE MOST EXPENSIVE THING HERE — minutes of GPU per clip. Ask before using it on a',
|
|
214
|
+
'plan that does not include video, and never call it speculatively.',
|
|
215
|
+
].join(' '),
|
|
216
|
+
parameters: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
properties: {
|
|
219
|
+
prompt: { type: 'string', description: 'What should happen in the clip — subject, motion, camera, mood.' },
|
|
220
|
+
image: { type: 'string', description: 'Optional workspace-relative still to animate instead of generating from scratch.' },
|
|
221
|
+
seconds: { type: 'number', description: 'Clip length. Keep it short; cost is per second.' },
|
|
222
|
+
path: { type: 'string', description: 'Optional output path, e.g. "video/hero.mp4".' },
|
|
223
|
+
},
|
|
224
|
+
required: ['prompt'],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function avatarToolNames(env = process.env) {
|
|
234
|
+
return avatarToolSchemas(env).map((t) => t.function.name);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Why a verb is missing, in words a user can act on.
|
|
239
|
+
*
|
|
240
|
+
* ⚠️ "UNKNOWN TOOL" IS THE WRONG ANSWER AND WE HAVE SHIPPED IT. When the model
|
|
241
|
+
* reaches for a capability the product genuinely has but this deployment has not
|
|
242
|
+
* configured, the user needs to know WHICH variable is missing — not that the
|
|
243
|
+
* feature does not exist. One says "set this"; the other says "we cannot do
|
|
244
|
+
* that", and only one of them is true.
|
|
245
|
+
*/
|
|
246
|
+
export function whyUnavailable(name, env = process.env) {
|
|
247
|
+
const cfg = avatarConfig(env);
|
|
248
|
+
const needs = {
|
|
249
|
+
clone_voice: 'MODAL_VOICE_CLONE_URL',
|
|
250
|
+
design_voice: 'MODAL_VOICE_DESIGN_URL',
|
|
251
|
+
talking_head: 'MODAL_AVATAR_URL',
|
|
252
|
+
generate_video: 'MODAL_VIDEO_URL',
|
|
253
|
+
}[name];
|
|
254
|
+
if (!needs) return null;
|
|
255
|
+
if (!cfg.secret) {
|
|
256
|
+
return `${name} needs ACUVO_MEDIA_SECRET (or MODAL_VIDEO_SECRET) as well as ${needs}. `
|
|
257
|
+
+ 'The endpoint address alone would just return 401.';
|
|
258
|
+
}
|
|
259
|
+
return `${name} is not configured here — set ${needs}. The capability exists; this install cannot reach it.`;
|
|
260
|
+
}
|
package/lib/banner.mjs
CHANGED
|
@@ -1,90 +1,128 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ── ⭐⭐⭐ WHAT YOU SEE WHEN YOU TYPE `acuvo` ────────────────────────────────
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* with acuvo logo top left and details up top etc, like how claude code opens.
|
|
6
|
-
* have you actually designed the page?"* — and then: *"no we want OUR logo"*.
|
|
4
|
+
* ── ⚠️⚠️ REWRITTEN AGAINST A SCREENSHOT OF THE REFERENCE, NOT A DESCRIPTION ──
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Roman, 2026-08-22, having put the two side by side: *"look at the sizing of
|
|
7
|
+
* logo and text near it, and what it says, the structure, the wording like
|
|
8
|
+
* theirs, the model version, the repo folder."*
|
|
11
9
|
*
|
|
12
|
-
*
|
|
10
|
+
* The reference, read off the image rather than remembered:
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* [mark] Claude Code v2.1.219
|
|
13
|
+
* Opus 5 (1M context) with high effort · Claude Max
|
|
14
|
+
* C:\Projects\claude-build
|
|
15
|
+
*
|
|
16
|
+
* Three things that were wrong here, each a decision made without looking:
|
|
17
|
+
*
|
|
18
|
+
* 1. **The mark was SEVEN rows.** Theirs is three. A logo taller than the
|
|
19
|
+
* information beside it turns the opening screen into a title card for
|
|
20
|
+
* something you run forty times a day.
|
|
21
|
+
* 2. **The facts were a LABELLED TABLE** — `workspace / model / billing /
|
|
22
|
+
* can run` down a column. Theirs is three prose lines with no labels,
|
|
23
|
+
* because the values are self-describing: a version looks like a version,
|
|
24
|
+
* a path looks like a path. Labels are what you add when the reader cannot
|
|
25
|
+
* tell what they are looking at.
|
|
26
|
+
* 3. **The model line said only the model.** Theirs carries model, effort AND
|
|
27
|
+
* plan on one line separated by `·` — everything about "what am I talking
|
|
28
|
+
* to and who is paying" in one glance.
|
|
19
29
|
*
|
|
20
30
|
* ── ⚠️ THE CONSTRAINTS, WHICH ARE NOT PREFERENCES ───────────────────────────
|
|
21
31
|
*
|
|
22
|
-
* · **Half-block glyphs only** (▀ ▄ █)
|
|
23
|
-
*
|
|
24
|
-
*
|
|
32
|
+
* · **Half-block glyphs only** (▀ ▄ █) for the art, and only when the terminal
|
|
33
|
+
* measures them at ONE cell — see `lib/glyph-width.mjs`. They are East Asian
|
|
34
|
+
* Ambiguous, so where they render double-width the padding spaces stay narrow
|
|
35
|
+
* and the mark tears.
|
|
25
36
|
* · **No colour escapes here.** The caller owns colour and honours NO_COLOR; a
|
|
26
37
|
* module that hard-codes them emits garbage the moment output is piped.
|
|
27
|
-
* · **
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* ⭐ A NOTE ON DOING BETTER: `lib/terminal-graphics.mjs` can send a real PNG
|
|
31
|
-
* inline on Kitty and iTerm2. It is deliberately NOT used here — Windows
|
|
32
|
-
* Terminal speaks neither protocol, so the block art is what most users would
|
|
33
|
-
* see anyway, and one rendering that is the same everywhere beats two that
|
|
34
|
-
* disagree.
|
|
38
|
+
* · **Never wider than the terminal.** Not 80 — the actual width, which a split
|
|
39
|
+
* pane makes much smaller.
|
|
35
40
|
*/
|
|
36
41
|
|
|
37
42
|
/**
|
|
38
|
-
* The Acuvo mark,
|
|
39
|
-
*
|
|
40
|
-
*
|
|
43
|
+
* The Acuvo mark, in half-blocks. FOUR rows, down from seven.
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ THE HEIGHT IS THE POINT. It has to sit beside three lines of text without
|
|
46
|
+
* dominating them — the proportion the reference uses, and the reason their
|
|
47
|
+
* opening screen reads as a status line rather than a splash.
|
|
41
48
|
*/
|
|
42
49
|
const MARK = [
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
' ▄█▀ ██ ▀██',
|
|
48
|
-
' ▄████▀▀█████',
|
|
49
|
-
'█▀ █▀ ▀█',
|
|
50
|
+
' ▄█▄',
|
|
51
|
+
' ▄█▀█▄',
|
|
52
|
+
' ▄█▄▄▄█▄',
|
|
53
|
+
'█▀ ▀█',
|
|
50
54
|
];
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* Every glyph here is ASCII, so it is width-1 by definition in every terminal,
|
|
56
|
-
* every font, every locale. No ambiguity class, nothing to measure, nothing to
|
|
57
|
-
* get wrong.
|
|
57
|
+
* The same mark in characters nothing can stretch.
|
|
58
58
|
*
|
|
59
|
-
* ⚠️ THIS IS NOT "NO LOGO" —
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* A degraded logo is a logo; a text substitute is a missing one.
|
|
59
|
+
* ⚠️ THIS IS NOT "NO LOGO" — the distinction matters. Roman asked for OUR mark
|
|
60
|
+
* and asked again when he got letters spelling the name. A terminal that cannot
|
|
61
|
+
* draw half-blocks must still get the angular A, drawn another way. A degraded
|
|
62
|
+
* logo is a logo; a wordmark standing in for one is a missing logo.
|
|
64
63
|
*/
|
|
65
64
|
const MARK_ASCII = [
|
|
66
|
-
'
|
|
67
|
-
'
|
|
68
|
-
'
|
|
69
|
-
'
|
|
70
|
-
' / /____\\ \\',
|
|
71
|
-
'/_/ \\_\\',
|
|
65
|
+
' /\\',
|
|
66
|
+
' / \\',
|
|
67
|
+
' /----\\',
|
|
68
|
+
'/ \\',
|
|
72
69
|
];
|
|
73
70
|
|
|
74
71
|
const GUTTER = 2;
|
|
75
72
|
|
|
76
73
|
/**
|
|
77
|
-
* The widest the banner may
|
|
78
|
-
*
|
|
79
|
-
* banner built to it fits everywhere.
|
|
74
|
+
* The widest the banner may be when the terminal will not say how wide it is.
|
|
75
|
+
* 80, because that is the narrowest terminal in real use.
|
|
80
76
|
*/
|
|
81
77
|
export const MAX_BANNER_COLUMNS = 80;
|
|
82
78
|
|
|
83
|
-
/** Below this the two-column layout stops being a layout
|
|
79
|
+
/** Below this the two-column layout stops being a layout. */
|
|
84
80
|
const MIN_TEXT_COLUMNS = 30;
|
|
85
81
|
|
|
82
|
+
/** Visible length, ignoring ANSI escapes — they occupy no cells. */
|
|
83
|
+
function visibleLength(s) {
|
|
84
|
+
return String(s).replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
85
|
+
}
|
|
86
|
+
|
|
86
87
|
/**
|
|
87
|
-
*
|
|
88
|
+
* Cut a possibly-coloured string to `width` visible cells, keeping escapes
|
|
89
|
+
* balanced so a truncation cannot leak colour into the rest of the screen.
|
|
90
|
+
*/
|
|
91
|
+
function clampToWidth(s, width) {
|
|
92
|
+
if (visibleLength(s) <= width) return s;
|
|
93
|
+
let out = '';
|
|
94
|
+
let seen = 0;
|
|
95
|
+
const re = /(\x1b\[[0-9;]*m)|([\s\S])/g;
|
|
96
|
+
let m;
|
|
97
|
+
while ((m = re.exec(s)) !== null) {
|
|
98
|
+
if (m[1]) { out += m[1]; continue; }
|
|
99
|
+
if (seen >= width) break;
|
|
100
|
+
out += m[2];
|
|
101
|
+
seen += 1;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* ⚠️ ONLY RE-CLOSE A STRING THAT WAS ACTUALLY COLOURED. Appending a reset
|
|
105
|
+
* unconditionally puts four bytes on the end of every truncated PLAIN line —
|
|
106
|
+
* harmless on a terminal, garbage the moment output is piped to a file.
|
|
107
|
+
*/
|
|
108
|
+
return out.includes('\x1b') ? `${out}\x1b[0m` : out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Elide in the MIDDLE, never the end.
|
|
113
|
+
*
|
|
114
|
+
* The informative parts of a path are the drive and the leaf; chopping the tail
|
|
115
|
+
* leaves `C:\Users\somebody\Projects\a-`, which identifies nothing.
|
|
116
|
+
*/
|
|
117
|
+
function fit(value, room) {
|
|
118
|
+
const s = String(value ?? '');
|
|
119
|
+
if (s.length <= room) return s;
|
|
120
|
+
const head = Math.ceil((room - 1) / 2);
|
|
121
|
+
return `${s.slice(0, head)}…${s.slice(s.length - (room - 1 - head))}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the opening screen.
|
|
88
126
|
*
|
|
89
127
|
* @param {object} o
|
|
90
128
|
* @param {string} o.version
|
|
@@ -104,63 +142,39 @@ export function openingScreen({
|
|
|
104
142
|
const brand = paint?.brand ?? ((s) => s);
|
|
105
143
|
|
|
106
144
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* produced was under 80 and it looked immaculate — in an 80-column terminal.
|
|
111
|
-
* In a narrower one, which is what a side panel or a split pane is, every
|
|
112
|
-
* single row wraps, and a wrapped banner does not read as dense. It reads as
|
|
113
|
-
* broken software, which is the first thing a new user sees.
|
|
114
|
-
*
|
|
115
|
-
* `- 1` because a line that exactly fills the width wraps on some terminals
|
|
116
|
-
* and not others, and the difference is not worth one column.
|
|
145
|
+
* ⚠️ THE WIDTH IS THE TERMINAL'S, NOT A CONSTANT. This built to a fixed 80 and
|
|
146
|
+
* never asked; every line was under 80 and it looked immaculate — in an
|
|
147
|
+
* 80-column terminal. In a split pane every row wrapped.
|
|
117
148
|
*/
|
|
118
149
|
const width = Math.max(20, Math.min(MAX_BANNER_COLUMNS, (columns ?? MAX_BANNER_COLUMNS) - 1));
|
|
119
150
|
|
|
120
151
|
const mark = style === 'text' ? MARK_ASCII : MARK;
|
|
121
152
|
const markWidth = Math.max(...mark.map((l) => l.length));
|
|
122
153
|
|
|
123
|
-
/**
|
|
124
|
-
* ⭐ THREE LAYOUTS, CHOSEN BY WHAT ACTUALLY FITS — never by a platform guess.
|
|
125
|
-
* Side by side when there is room for both; mark above the facts when there
|
|
126
|
-
* is room for the mark alone; facts only when there is not. The last one is
|
|
127
|
-
* rare and it still has to be right, because a 24-column terminal is somebody
|
|
128
|
-
* on a phone over SSH and they deserve a legible screen, not a torn one.
|
|
129
|
-
*/
|
|
130
154
|
const sideBySide = width >= markWidth + GUTTER + MIN_TEXT_COLUMNS;
|
|
131
155
|
const textColumns = sideBySide ? width - markWidth - GUTTER : width;
|
|
156
|
+
const room = Math.max(12, textColumns);
|
|
132
157
|
|
|
133
158
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
159
|
+
* ── ⭐ THREE LINES, NO LABELS, IN THE REFERENCE'S ORDER ────────────────────
|
|
160
|
+
*
|
|
161
|
+
* Name and version · what you are talking to and who pays · where you are.
|
|
162
|
+
*
|
|
163
|
+
* ⚠️ `billing` JOINS THE MODEL LINE rather than taking a row of its own, which
|
|
164
|
+
* is how the reference does it ("… with high effort · Claude Max"). It matters
|
|
165
|
+
* more here than it does for them: our billing line is the one that says
|
|
166
|
+
* **"YOUR OWN OpenRouter key (not your Acuvo plan)"**, and somebody needs to
|
|
167
|
+
* see that beside the model, not three rows away from it.
|
|
138
168
|
*/
|
|
139
|
-
const room = Math.max(8, textColumns - 11);
|
|
140
|
-
const fit = (v) => {
|
|
141
|
-
const s = String(v ?? '');
|
|
142
|
-
if (s.length <= room) return s;
|
|
143
|
-
const head = Math.ceil((room - 1) / 2);
|
|
144
|
-
return `${s.slice(0, head)}…${s.slice(s.length - (room - 1 - head))}`;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
169
|
const right = [
|
|
148
|
-
`
|
|
149
|
-
'',
|
|
150
|
-
|
|
151
|
-
`model ${fit(model)}`,
|
|
152
|
-
`billing ${fit(billing)}`,
|
|
153
|
-
`can run ${fit(canRun)}`,
|
|
154
|
-
'',
|
|
170
|
+
`Acuvo Code${version ? ` v${version}` : ''}`,
|
|
171
|
+
fit([model, billing].filter(Boolean).join(' · '), room),
|
|
172
|
+
fit(workspace, room),
|
|
155
173
|
];
|
|
156
174
|
|
|
157
175
|
const lines = [''];
|
|
158
176
|
|
|
159
177
|
if (sideBySide) {
|
|
160
|
-
/**
|
|
161
|
-
* ⚠️ THE TWO COLUMNS ARE ZIPPED, NOT CONCATENATED, and the row counts are
|
|
162
|
-
* allowed to differ — whichever is shorter simply runs out.
|
|
163
|
-
*/
|
|
164
178
|
const rows = Math.max(mark.length, right.length);
|
|
165
179
|
for (let i = 0; i < rows; i += 1) {
|
|
166
180
|
const text = right[i] ?? '';
|
|
@@ -170,39 +184,42 @@ export function openingScreen({
|
|
|
170
184
|
* right-hand column drifts.
|
|
171
185
|
*
|
|
172
186
|
* ⚠️⚠️ AND PADDED ONLY WHEN SOMETHING FOLLOWS. On a mark-only row the
|
|
173
|
-
* padding sits INSIDE the colour, before the reset, where `trimEnd`
|
|
174
|
-
*
|
|
175
|
-
*
|
|
187
|
+
* padding sits INSIDE the colour, before the reset, where `trimEnd` cannot
|
|
188
|
+
* reach it — so the coloured banner carried trailing whitespace the plain
|
|
189
|
+
* one did not, which means colour changed the layout.
|
|
176
190
|
*/
|
|
177
191
|
const raw = mark[i] ?? '';
|
|
178
192
|
const padded = text ? raw.padEnd(markWidth + GUTTER) : raw;
|
|
179
193
|
lines.push(`${mark[i] ? brand(padded) : padded}${text}`.trimEnd());
|
|
180
194
|
}
|
|
181
195
|
} else {
|
|
182
|
-
|
|
183
|
-
if (roomForMark) {
|
|
196
|
+
if (width >= markWidth) {
|
|
184
197
|
for (const row of mark) lines.push(brand(row));
|
|
185
198
|
lines.push('');
|
|
186
199
|
}
|
|
187
200
|
for (const text of right) lines.push(text.trimEnd());
|
|
188
201
|
}
|
|
189
202
|
|
|
190
|
-
lines.push('');
|
|
191
|
-
|
|
192
203
|
/**
|
|
193
|
-
* ⚠️
|
|
194
|
-
* above a one-shot run that has already been given its task is an instruction
|
|
195
|
-
* for something the user cannot do.
|
|
204
|
+
* ── ⚠️ WHAT IT MAY RUN IS A SEPARATE, QUIETER LINE ─────────────────────────
|
|
196
205
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
206
|
+
* The reference puts this at the very bottom of the screen ("bypass
|
|
207
|
+
* permissions on (shift+tab to cycle)"), away from the identity block, because
|
|
208
|
+
* it is a MODE rather than a fact about the session. Keeping it out of the
|
|
209
|
+
* three-line lockup is what stops that block growing back into the table it
|
|
210
|
+
* used to be.
|
|
199
211
|
*/
|
|
212
|
+
if (canRun) {
|
|
213
|
+
lines.push('', clampToWidth(fit(canRun, width), width));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
lines.push('');
|
|
217
|
+
|
|
200
218
|
if (interactive) {
|
|
201
219
|
/**
|
|
202
220
|
* ⭐ SHORTENED IN STAGES, NEVER TRUNCATED. A clamp would cut "leave" to
|
|
203
|
-
* "leav", and a hint with a word chopped in half
|
|
204
|
-
*
|
|
205
|
-
* impression this screen keeps making.
|
|
221
|
+
* "leav", and a hint with a word chopped in half reads as a rendering bug —
|
|
222
|
+
* precisely the impression this screen keeps making.
|
|
206
223
|
*/
|
|
207
224
|
const hints = [
|
|
208
225
|
' Type what you want done. /help for commands · exit to leave',
|
|
@@ -213,43 +230,11 @@ export function openingScreen({
|
|
|
213
230
|
}
|
|
214
231
|
|
|
215
232
|
/**
|
|
216
|
-
* ⚠️⚠️ THE LAST WORD ON WIDTH
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* truncated rather than allowed to wrap and tear the layout.
|
|
233
|
+
* ⚠️⚠️ THE LAST WORD ON WIDTH. Everything above reasons about `.length`, which
|
|
234
|
+
* counts CODE UNITS, while a terminal counts CELLS — and the whole reason this
|
|
235
|
+
* file was rewritten is that those two disagree. Measured on the painted
|
|
236
|
+
* string with escapes discounted, so a mark wider than advertised is truncated
|
|
237
|
+
* rather than allowed to wrap and tear the layout.
|
|
222
238
|
*/
|
|
223
239
|
return lines.map((l) => clampToWidth(l, width)).join('\n');
|
|
224
240
|
}
|
|
225
|
-
|
|
226
|
-
/** Visible length, ignoring ANSI escapes — they occupy no cells. */
|
|
227
|
-
function visibleLength(s) {
|
|
228
|
-
return String(s).replace(/\[[0-9;]*m/g, '').length;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Cut a possibly-coloured string to `width` visible cells, keeping the escapes
|
|
233
|
-
* balanced so a truncation cannot leak colour into the rest of the screen.
|
|
234
|
-
*/
|
|
235
|
-
function clampToWidth(s, width) {
|
|
236
|
-
if (visibleLength(s) <= width) return s;
|
|
237
|
-
let out = '';
|
|
238
|
-
let seen = 0;
|
|
239
|
-
const re = /(\[[0-9;]*m)|([\s\S])/g;
|
|
240
|
-
let m;
|
|
241
|
-
while ((m = re.exec(s)) !== null) {
|
|
242
|
-
if (m[1]) { out += m[1]; continue; }
|
|
243
|
-
if (seen >= width) break;
|
|
244
|
-
out += m[2];
|
|
245
|
-
seen += 1;
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* ⚠️ ONLY RE-CLOSE A STRING THAT WAS ACTUALLY COLOURED. Appending a reset
|
|
249
|
-
* unconditionally puts four bytes on the end of every truncated PLAIN line —
|
|
250
|
-
* harmless on a terminal, and garbage the moment output is piped to a file or
|
|
251
|
-
* a CI log, which is the one place this module has already been told never to
|
|
252
|
-
* write escapes.
|
|
253
|
-
*/
|
|
254
|
-
return out.includes('') ? `${out}[0m` : out;
|
|
255
|
-
}
|
package/lib/input-box.mjs
CHANGED
|
@@ -557,7 +557,32 @@ export function pinRegion(output, { rows = 2, env = process.env } = {}) {
|
|
|
557
557
|
* is a quieter version of the same defect this rewrite removes.
|
|
558
558
|
*/
|
|
559
559
|
const makeRoom = '\n'.repeat(rows);
|
|
560
|
-
|
|
560
|
+
/**
|
|
561
|
+
* ── ⚠️⚠️ THE CURSOR GOES BACK WHERE THE CONTENT ENDED, NOT TO THE BOTTOM ──
|
|
562
|
+
*
|
|
563
|
+
* Roman, from a screenshot: the banner sat at the top, then forty blank
|
|
564
|
+
* rows, then the invitation alone near the bottom — "the prompt box is too
|
|
565
|
+
* low and you have to scroll down to see everything."
|
|
566
|
+
*
|
|
567
|
+
* The cause was the last move in the old sequence, `ESC[{bottom};1H`. It
|
|
568
|
+
* parked the cursor on the LAST row of the scrolling region, so the first
|
|
569
|
+
* thing printed after pinning landed at the bottom of the screen and the
|
|
570
|
+
* whole gap opened up behind it. A terminal fills DOWNWARD: after reserving
|
|
571
|
+
* rows, output has to carry on from exactly where the banner stopped.
|
|
572
|
+
*
|
|
573
|
+
* ⭐ DECSC/DECRC (`ESC7`/`ESC8`) rather than an absolute move, because the
|
|
574
|
+
* right row is "wherever the content happens to end" and only the terminal
|
|
575
|
+
* knows that. Setting a scroll region homes the cursor to (1,1) as a side
|
|
576
|
+
* effect — that is the reason a restore is needed at all, and the reason the
|
|
577
|
+
* old code moved the cursor somewhere explicit in the first place. It just
|
|
578
|
+
* moved it to the wrong somewhere.
|
|
579
|
+
*
|
|
580
|
+
* ⚠️ The save happens AFTER the newlines and the step back up, so the
|
|
581
|
+
* position being restored is already inside the region — a restore to a row
|
|
582
|
+
* below the region would put the cursor outside it, which is undefined
|
|
583
|
+
* across terminals.
|
|
584
|
+
*/
|
|
585
|
+
output.write(`${makeRoom}${CSI}${rows}A\x1b7${CSI}1;${bottom}r\x1b8`);
|
|
561
586
|
|
|
562
587
|
const release = () => {
|
|
563
588
|
if (released) return;
|
package/lib/mcp-server.mjs
CHANGED
|
@@ -420,6 +420,19 @@ export const MEDIA_READ_TOOLS = Object.freeze(['read_document', 'read_table']);
|
|
|
420
420
|
* fact rather than a preference.
|
|
421
421
|
*/
|
|
422
422
|
export const REFUSED_TOOL_REASONS = Object.freeze({
|
|
423
|
+
/**
|
|
424
|
+
* ── ⭐ THE FOUR MOAT VERBS ARE REFUSED OVER MCP, DELIBERATELY ────────────
|
|
425
|
+
*
|
|
426
|
+
* Each is minutes of GPU billed per video-second, started by a caller we
|
|
427
|
+
* cannot see and cannot ask. Every other expensive verb in this product is
|
|
428
|
+
* bounded by a human in the loop; an MCP client is not one. Serving these
|
|
429
|
+
* would let a third-party agent spend real money in a loop with nobody
|
|
430
|
+
* watching, and the first anyone would know is the invoice.
|
|
431
|
+
*/
|
|
432
|
+
clone_voice: 'minutes of GPU billed per second, and it impersonates a real person — an MCP caller cannot be asked whether they have the right to that voice',
|
|
433
|
+
design_voice: 'minutes of GPU billed per second, started by a caller with no spending limit we can see',
|
|
434
|
+
talking_head: 'the most expensive verb here: minutes of GPU priced per video-second, and it puts words in a real face',
|
|
435
|
+
generate_video: 'minutes of GPU per clip, and nothing on the MCP side bounds how many times it is called',
|
|
423
436
|
// ── Rule 1a: nothing starts a process, and there is no flag for it. ──
|
|
424
437
|
run_command: 'starts a process; combined with write_file that is arbitrary code execution, and an MCP config is not a per-run human decision',
|
|
425
438
|
run_program: 'starts a process — same reason as run_command, with an argv instead of a string',
|
package/lib/tools.mjs
CHANGED
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
} from './git.mjs';
|
|
47
47
|
import { speak, transcribe, makeDocument, readDocument, readTable, mediaToolSchemas, mediaToolNames } from './media.mjs';
|
|
48
48
|
import { editImage, expandImage, imageEditToolSchemas, imageEditToolNames } from './image-edit.mjs';
|
|
49
|
+
import { avatarToolSchemas, avatarToolNames } from './avatar.mjs';
|
|
50
|
+
import { cloneVoice, designVoice, talkingHead, generateVideo } from './avatar-run.mjs';
|
|
49
51
|
/**
|
|
50
52
|
* ⭐ `designPass` IS A STRICT SUPERSET OF `seePage`, deliberately, so wiring it
|
|
51
53
|
* is a SWAP rather than a migration: `ok`, `path`, `screenshot`,
|
|
@@ -294,6 +296,26 @@ TOOL_SCHEMAS.push(...imageEditToolSchemas({
|
|
|
294
296
|
MODAL_SELECT_URL: 'declared', MODAL_FLUX_URL: 'declared',
|
|
295
297
|
}));
|
|
296
298
|
|
|
299
|
+
/**
|
|
300
|
+
* ── ⭐⭐⭐ THE MOAT HALF — cloned voice · designed voice · the face · video ──
|
|
301
|
+
*
|
|
302
|
+
* ⚠️ MEASURED 2026-08-22: the CLI carried 9 of the console's 20 Modal
|
|
303
|
+
* endpoints, and every missing one was a moat capability. It shipped generic
|
|
304
|
+
* TTS and transcription — the two things anybody can rent — while the cloned
|
|
305
|
+
* voice and the talking head, running on GPUs we already pay for, were
|
|
306
|
+
* reachable only from the web builder. `media.mjs` said so in its own schema:
|
|
307
|
+
* "voice cloning is not reachable from the CLI and will be refused."
|
|
308
|
+
*
|
|
309
|
+
* Declared unconditionally like the rest, for the same reason: the drift guard
|
|
310
|
+
* must not see a different tool list on a machine with different env. The
|
|
311
|
+
* per-turn OFFER below is what gates on real configuration.
|
|
312
|
+
*/
|
|
313
|
+
TOOL_SCHEMAS.push(...avatarToolSchemas({
|
|
314
|
+
MODAL_VIDEO_SECRET: 'declared',
|
|
315
|
+
MODAL_AVATAR_URL: 'declared', MODAL_VOICE_CLONE_URL: 'declared',
|
|
316
|
+
MODAL_VOICE_DESIGN_URL: 'declared', MODAL_VIDEO_URL: 'declared',
|
|
317
|
+
}));
|
|
318
|
+
|
|
297
319
|
/**
|
|
298
320
|
* ── ⭐ THE MODULES THAT WERE BUILT FOR THIS SEAM AND NEVER PLUGGED INTO IT ──
|
|
299
321
|
*
|
|
@@ -818,6 +840,14 @@ export function toolNamesForRounds(maxRounds, { allowRun = true, env = process.e
|
|
|
818
840
|
// the model then LOOKS at the result. Offering it on a single-shot turn buys
|
|
819
841
|
// the render and throws away the check.
|
|
820
842
|
names.push(...imageEditToolNames(env));
|
|
843
|
+
/**
|
|
844
|
+
* ⚠️ MULTI-ROUND ONLY, AND THE STRONGEST CASE OF THE THREE. A talking head
|
|
845
|
+
* is MINUTES of GPU and is priced per video-second. On a single-shot turn
|
|
846
|
+
* the model would buy the render and then have no round left to look at it,
|
|
847
|
+
* which is paying for the expensive half of a loop and skipping the half
|
|
848
|
+
* that makes it correct.
|
|
849
|
+
*/
|
|
850
|
+
names.push(...avatarToolNames(env));
|
|
821
851
|
names.push('git_status', 'git_diff', 'git_log');
|
|
822
852
|
if (allowRun) names.push('git_commit');
|
|
823
853
|
/**
|
|
@@ -1516,6 +1546,34 @@ export async function executeToolCall(call, executor, {
|
|
|
1516
1546
|
});
|
|
1517
1547
|
return { id, name, args, result, mutated: result.ok === true, mutatedPath: result.path ?? null };
|
|
1518
1548
|
}
|
|
1549
|
+
/**
|
|
1550
|
+
* ── ⭐⭐⭐ THE FOUR MOAT VERBS ────────────────────────────────────────
|
|
1551
|
+
*
|
|
1552
|
+
* ⚠️ ALL FOUR ARE `mutated: true`. Each writes a real file into the
|
|
1553
|
+
* workspace, so the run summary must count it — otherwise a user ends up
|
|
1554
|
+
* with a video on disk that the session never mentioned, which is how a
|
|
1555
|
+
* generated asset becomes a mystery file nobody trusts.
|
|
1556
|
+
*
|
|
1557
|
+
* ⚠️ AND THE EXECUTOR RETURNS `usd`. These are the only verbs here that
|
|
1558
|
+
* spend money per call; the cost travels back with the result rather than
|
|
1559
|
+
* arriving later on a bill.
|
|
1560
|
+
*/
|
|
1561
|
+
case 'clone_voice': {
|
|
1562
|
+
const result = await cloneVoice(executor.root, args, { dryRun: executor.dryRun });
|
|
1563
|
+
return { id, name, args, result, mutated: result.ok === true, mutatedPath: result.path ?? null };
|
|
1564
|
+
}
|
|
1565
|
+
case 'design_voice': {
|
|
1566
|
+
const result = await designVoice(executor.root, args, { dryRun: executor.dryRun });
|
|
1567
|
+
return { id, name, args, result, mutated: result.ok === true, mutatedPath: result.path ?? null };
|
|
1568
|
+
}
|
|
1569
|
+
case 'talking_head': {
|
|
1570
|
+
const result = await talkingHead(executor.root, args, { dryRun: executor.dryRun });
|
|
1571
|
+
return { id, name, args, result, mutated: result.ok === true, mutatedPath: result.path ?? null };
|
|
1572
|
+
}
|
|
1573
|
+
case 'generate_video': {
|
|
1574
|
+
const result = await generateVideo(executor.root, args, { dryRun: executor.dryRun });
|
|
1575
|
+
return { id, name, args, result, mutated: result.ok === true, mutatedPath: result.path ?? null };
|
|
1576
|
+
}
|
|
1519
1577
|
case 'generate_image': {
|
|
1520
1578
|
/**
|
|
1521
1579
|
* ⚠️ `mutated: true` — this DOES write a file into the workspace, unlike
|