acuvo-code 0.6.7 → 0.6.9
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/input-box.mjs +38 -48
- 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/input-box.mjs
CHANGED
|
@@ -516,7 +516,7 @@ export function pinRegion(output, { rows = 2, env = process.env } = {}) {
|
|
|
516
516
|
*/
|
|
517
517
|
const enabled = Boolean(output?.isTTY)
|
|
518
518
|
&& height > rows + 4
|
|
519
|
-
&& String(env.
|
|
519
|
+
&& String(env.ACUVO_NO_PIN ?? '') !== '1'
|
|
520
520
|
&& String(env.CI ?? '').toLowerCase() !== 'true';
|
|
521
521
|
|
|
522
522
|
if (!enabled) return { enabled: false, release() {}, rows: 0, bottom: 0 };
|
|
@@ -525,66 +525,56 @@ export function pinRegion(output, { rows = 2, env = process.env } = {}) {
|
|
|
525
525
|
let released = false;
|
|
526
526
|
|
|
527
527
|
/**
|
|
528
|
-
* ──
|
|
528
|
+
* ── ⭐⭐⭐ RESERVE THE ROWS. DO NOT CLEAR THE SCREEN. ────────────────────────
|
|
529
529
|
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
530
|
+
* ⚠️⚠️ THE CLEAR WAS THE ENTIRE BUG, THROUGH FOUR ATTEMPTS. Every previous
|
|
531
|
+
* version began `ESC[H ESC[2J ESC[3J` — home, erase screen, erase scrollback.
|
|
532
|
+
* That is what `clear` emits, and it is completely wrong here for two reasons
|
|
533
|
+
* that took far too long to separate:
|
|
532
534
|
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
535
|
+
* 1. It runs AFTER the banner has been printed, so it erases the thing the
|
|
536
|
+
* user just saw. Roman: "I see the green logo and text for a fraction of
|
|
537
|
+
* a second then I just see the prompt box, nothing else."
|
|
538
|
+
* 2. `3J` deletes the SCROLLBACK — the user's shell history, and then their
|
|
539
|
+
* own session. "Can't scroll up or it's just not there." A CLI has no
|
|
540
|
+
* business destroying the buffer it was launched into.
|
|
537
541
|
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
* ⚠️⚠️ `3J` BEFORE `2J`, AND WITHOUT IT THE PROMPT IS OFF-SCREEN. Roman:
|
|
544
|
-
* *"when you type acuvo you have to scroll down to see the prompt area."*
|
|
545
|
-
*
|
|
546
|
-
* `ESC[2J` clears the visible screen but PUSHES what was there into
|
|
547
|
-
* SCROLLBACK — so VS Code (and most terminals) leave the viewport parked up
|
|
548
|
-
* in the history, showing the old shell output with our banner and prompt
|
|
549
|
-
* below the fold. The user has to scroll to find the thing they just started.
|
|
550
|
-
*
|
|
551
|
-
* `ESC[3J` deletes the scrollback itself, so there is nothing above to be
|
|
552
|
-
* parked in and the viewport has nowhere to sit but the top of a clean
|
|
553
|
-
* screen. Both are needed and the order matters: erase the history, erase the
|
|
554
|
-
* screen, then home.
|
|
555
|
-
*/
|
|
556
|
-
/**
|
|
557
|
-
* ── ⚠️⚠️ THE CANONICAL CLEAR, IN THE CANONICAL ORDER — TWICE WRONG BEFORE ──
|
|
558
|
-
*
|
|
559
|
-
* Roman, twice: *"you have to scroll down to see the prompt area."*
|
|
560
|
-
*
|
|
561
|
-
* What `clear` itself emits is `ESC[H ESC[2J ESC[3J` — HOME FIRST, then erase
|
|
562
|
-
* the screen, then erase the scrollback. I had `3J 2J` with the home last, and
|
|
563
|
-
* the order is not cosmetic: erasing scrollback while the cursor is still
|
|
564
|
-
* parked in it leaves the viewport anchored to a region that no longer exists,
|
|
565
|
-
* so the terminal keeps showing the old shell output and our banner and prompt
|
|
566
|
-
* sit below the fold.
|
|
542
|
+
* ⭐ AND THE REFERENCE NEVER DID THIS. Claude Code does not clear your screen
|
|
543
|
+
* when it starts; your prompt, your previous commands and your scrollback are
|
|
544
|
+
* all still there afterwards. I imported a clear because I was thinking of a
|
|
545
|
+
* full-screen TUI, and then spent four builds fixing the ORDER of an operation
|
|
546
|
+
* that should not have existed.
|
|
567
547
|
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
548
|
+
* ⚠️ THE ROOM IS MADE BY SCROLLING, NOT BY ERASING. Printing `rows` newlines
|
|
549
|
+
* pushes existing content up exactly as any command would, leaving the cursor
|
|
550
|
+
* on the last line with `rows` blank lines below the content. Stepping back up
|
|
551
|
+
* to `bottom` and declaring the region there reserves those lines for the
|
|
552
|
+
* input — and everything above is untouched, still scrollable, still theirs.
|
|
570
553
|
*
|
|
571
|
-
* ⚠️
|
|
572
|
-
*
|
|
573
|
-
*
|
|
554
|
+
* ⚠️ THE CURSOR IS PLACED LAST, AND THAT IS NOT COSMETIC: DECSTBM homes the
|
|
555
|
+
* cursor to (1,1) as a side effect. Without the final move, the first line of
|
|
556
|
+
* output would land at the TOP of the screen and overwrite the banner — which
|
|
557
|
+
* is a quieter version of the same defect this rewrite removes.
|
|
574
558
|
*/
|
|
575
|
-
|
|
559
|
+
const makeRoom = '\n'.repeat(rows);
|
|
560
|
+
output.write(`${makeRoom}${CSI}${rows}A${CSI}1;${bottom}r${CSI}${bottom};1H`);
|
|
576
561
|
|
|
577
562
|
const release = () => {
|
|
578
563
|
if (released) return;
|
|
579
564
|
released = true;
|
|
580
565
|
/**
|
|
581
|
-
* ⚠️ `ESC[r` WITH NO ARGUMENTS RESETS TO THE FULL SCREEN. Then the cursor
|
|
582
|
-
*
|
|
583
|
-
* our
|
|
584
|
-
*
|
|
566
|
+
* ⚠️ `ESC[r` WITH NO ARGUMENTS RESETS TO THE FULL SCREEN. Then the cursor
|
|
567
|
+
* moves below the reserved rows so the shell prompt does not land on top of
|
|
568
|
+
* our input — an exit that is technically correct and visually broken is
|
|
569
|
+
* still a bad exit.
|
|
570
|
+
*
|
|
571
|
+
* ⭐ AND NOTHING IS ERASED ON THE WAY OUT EITHER. The session the user just
|
|
572
|
+
* had stays on screen and in scrollback, which is the whole point of not
|
|
573
|
+
* having taken over their terminal in the first place.
|
|
585
574
|
*/
|
|
586
575
|
try {
|
|
587
|
-
output.write(`${CSI}r${CSI}${height};1H
|
|
576
|
+
output.write(`${CSI}r${CSI}${height};1H
|
|
577
|
+
`);
|
|
588
578
|
} catch { /* the stream may already be gone on a hard exit */ }
|
|
589
579
|
};
|
|
590
580
|
|
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
|