acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ TALK TO YOUR TERMINAL — the two voice directions ─────────────────────
|
|
3
|
+
*
|
|
4
|
+
* AUDIO IN: record a memo on your phone, drop the file in the repo,
|
|
5
|
+
* `acuvo --task-audio note.m4a` — it transcribes it, SHOWS you what
|
|
6
|
+
* it heard, and only then codes what you said.
|
|
7
|
+
* AUDIO OUT: `acuvo --say "<task>"` — walk away from a long run and come back
|
|
8
|
+
* to a one-sentence verdict you can hear instead of read.
|
|
9
|
+
*
|
|
10
|
+
* Both halves were proven end to end against the live Modal endpoints on
|
|
11
|
+
* 2026-08-11: `speak` produced a real 211,244-byte WAV, and `transcribe`
|
|
12
|
+
* returned the exact sentence back. This module is assembly, not research; the
|
|
13
|
+
* work is entirely in the two places assembly goes wrong.
|
|
14
|
+
*
|
|
15
|
+
* ── ⚠️⚠️ FILE IN, FILE OUT. THIS MODULE DOES NOT RECORD AND DOES NOT PLAY. ──
|
|
16
|
+
*
|
|
17
|
+
* Say it plainly because the feature invites the assumption. There is NO
|
|
18
|
+
* zero-dependency way to capture a microphone in Node — no built-in audio API
|
|
19
|
+
* exists, and every option (`node-record-lpcm16`, `naudiodon`, a bundled
|
|
20
|
+
* `sox`/`ffmpeg`) is either an npm package or a shipped binary. This package's
|
|
21
|
+
* headline enterprise property is zero dependencies forever, and that property
|
|
22
|
+
* is worth more than live capture. Playback is the same argument in reverse:
|
|
23
|
+
* we write a WAV into the workspace and hand back the ONE command that plays it
|
|
24
|
+
* on your OS (`playbackHint`), rather than spawning an audio player ourselves.
|
|
25
|
+
*
|
|
26
|
+
* ⭐ IF ANYONE REVISITS LIVE CAPTURE, this is what it costs, so the trade is
|
|
27
|
+
* argued rather than rediscovered: (a) an OS binary reached through
|
|
28
|
+
* `command.mjs`'s allowlist — `ffmpeg -f dshow` on Windows, `-f avfoundation`
|
|
29
|
+
* on macOS, `arecord` on Linux — which is three platform paths, a permissions
|
|
30
|
+
* prompt on macOS, and a dependency on software we do not ship; or (b) an
|
|
31
|
+
* optional peer dependency, which ends the zero-dependency claim the moment it
|
|
32
|
+
* is documented. The honest middle is what is built here: any recorder the user
|
|
33
|
+
* already has produces a file, and a file is a first-class input.
|
|
34
|
+
*
|
|
35
|
+
* ── ⚠️⚠️ A TRANSCRIPT IS A MODEL INSTRUCTION THAT NOBODY PROOFREAD ──────────
|
|
36
|
+
*
|
|
37
|
+
* This is the whole reason `taskFromAudio` does not simply return a string and
|
|
38
|
+
* let the caller run it. Whisper mishears — "the server" becomes "the sensor",
|
|
39
|
+
* "don't delete" becomes "do delete" — and on silence it does something worse:
|
|
40
|
+
* it INVENTS. "Thanks for watching!" is its most famous hallucination and an
|
|
41
|
+
* empty room reliably produces it. Handing that to a file-writing agent is the
|
|
42
|
+
* same class of mistake as executing a command you never read.
|
|
43
|
+
*
|
|
44
|
+
* ⭐ SO THE SEAM IS: transcribe → SHOW → decide → run. `taskFromAudio` always
|
|
45
|
+
* returns `needsConfirmation: true` and never acts. `decideTranscript` is a
|
|
46
|
+
* pure function of (what was heard, what the user answered, is this a terminal,
|
|
47
|
+
* is this --json) and returns whether to run and WITH WHAT TEXT — so the
|
|
48
|
+
* confirmation policy is one reviewable table instead of branching inside a
|
|
49
|
+
* CLI. Under `--json` or on a non-TTY there is nobody to ask, so the answer is
|
|
50
|
+
* a REFUSAL naming `--yes`, never a silent yes.
|
|
51
|
+
*
|
|
52
|
+
* ⭐⭐ AND A CORRECTION IS NOT A REFUSAL. "no, the server not the sensor" is the
|
|
53
|
+
* commonest real answer to a mis-heard task, and treating it as "n" would throw
|
|
54
|
+
* the user's own fix away and make them retype the whole thing. Answering with
|
|
55
|
+
* anything substantive amends or replaces the task in one line.
|
|
56
|
+
*
|
|
57
|
+
* ── ⚠️ AUDIO THAT PLAYS UNASKED IS HOSTILE, AND A DIFF READ ALOUD IS A
|
|
58
|
+
* PUNISHMENT ─────────────────────────────────────────────────────────────
|
|
59
|
+
* `speakSummary` is silent by default and returns `{ ok: true, spoken: false }`
|
|
60
|
+
* for it — silence is a correct outcome, not a failure. When it is asked for,
|
|
61
|
+
* it speaks the VERDICT: what was asked, how much changed, did it verify. Never
|
|
62
|
+
* the file list. Sixty seconds of paths read by a synthesiser is worse than no
|
|
63
|
+
* feature, so `summariseOutcome` is hard-capped at `MAX_SPOKEN_CHARS` and names
|
|
64
|
+
* a file only when exactly one changed.
|
|
65
|
+
*
|
|
66
|
+
* ── ⚠️ AND IT DEGRADES BY BEING ABSENT, NOT BY BEING BROKEN ────────────────
|
|
67
|
+
* No `MODAL_TRANSCRIBE_URL` → `--task-audio` reports that it is unavailable and
|
|
68
|
+
* reaches no network. No `MODAL_TTS_URL` → the same for `--say`. Nothing here
|
|
69
|
+
* is ever offered-and-broken; `mediaToolSchemas` in media.mjs settled that rule
|
|
70
|
+
* for the model-facing tools and this is the human-facing half of it.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import { speak, transcribe, mediaConfig } from './media.mjs';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The ceiling on a SPOKEN task. An hour-long podcast transcribes to tens of
|
|
77
|
+
* thousands of characters, and handing that to the model as one instruction is
|
|
78
|
+
* a real bill for a request nobody made — a voice memo that turns into a
|
|
79
|
+
* 68,000-character prompt is a mis-drop, not a task.
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ REFUSED, NOT TRUNCATED. Cutting it at 4,000 would run the first minute of
|
|
82
|
+
* what was said and silently discard the rest, which is the worst of the three
|
|
83
|
+
* options: the user believes their whole instruction was heard.
|
|
84
|
+
*/
|
|
85
|
+
export const MAX_TASK_CHARS = 4000;
|
|
86
|
+
|
|
87
|
+
/** One or two sentences. Anything longer stops being a verdict. */
|
|
88
|
+
export const MAX_SPOKEN_CHARS = 320;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What this install can hear and say.
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ READ AT CALL TIME from `env`, never captured at import — same rule as
|
|
94
|
+
* `mediaConfig`, for the same reason: a variable that changed mid-session must
|
|
95
|
+
* be seen, and a module-level snapshot is how a capability stays dark after it
|
|
96
|
+
* was fixed. Delegated to `mediaConfig` rather than re-reading the variables,
|
|
97
|
+
* so the two can never disagree about whether a service is configured.
|
|
98
|
+
*/
|
|
99
|
+
export function voiceConfig(env = process.env) {
|
|
100
|
+
const cfg = mediaConfig(env);
|
|
101
|
+
return {
|
|
102
|
+
canListen: Boolean(cfg.transcribe),
|
|
103
|
+
canSpeak: Boolean(cfg.speak),
|
|
104
|
+
listenUrl: cfg.transcribe ?? null,
|
|
105
|
+
speakUrl: cfg.speak ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Tidy a transcript into one line of instruction.
|
|
111
|
+
*
|
|
112
|
+
* ⚠️ WHITESPACE ONLY. It normalises CRLF, collapses runs of space and strips a
|
|
113
|
+
* BOM — and it does NOT touch a single word. Non-ASCII survives byte for byte,
|
|
114
|
+
* because most of the world does not dictate in English and a "cleaner" that
|
|
115
|
+
* mangles `健康チェック` or `santé` has corrupted the instruction it was meant
|
|
116
|
+
* to prepare. Emoji included: `ship it 🚀` is a real thing people say.
|
|
117
|
+
*/
|
|
118
|
+
export function cleanTranscript(raw) {
|
|
119
|
+
if (typeof raw !== 'string') return '';
|
|
120
|
+
return raw
|
|
121
|
+
.replace(/^/, '')
|
|
122
|
+
.replace(/\r\n?/g, '\n')
|
|
123
|
+
.replace(/\s+/g, ' ')
|
|
124
|
+
.trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* ── ⚠️⚠️ WHISPER INVENTS WORDS WHEN THERE IS NOTHING TO HEAR ────────────────
|
|
129
|
+
*
|
|
130
|
+
* These strings are its documented hallucinations on silence or noise — they
|
|
131
|
+
* come from the YouTube captions it was trained on, so a quiet room produces
|
|
132
|
+
* "Thanks for watching!" with full confidence. That phrase reaching a coding
|
|
133
|
+
* agent as an instruction is not a hypothetical.
|
|
134
|
+
*
|
|
135
|
+
* ⭐ FLAGGED, NEVER STRIPPED. Editing the transcript under the user is a second
|
|
136
|
+
* mis-hearing on top of the first; the honest move is to show the words AND the
|
|
137
|
+
* doubt, and let a human spend one keystroke.
|
|
138
|
+
*/
|
|
139
|
+
const HALLUCINATIONS = [
|
|
140
|
+
/^thanks? for watching/i,
|
|
141
|
+
/^thank you\.?$/i,
|
|
142
|
+
/^thanks\.?$/i,
|
|
143
|
+
/^subtitles?\b.*\b(by|provided)/i,
|
|
144
|
+
/amara\.org/i,
|
|
145
|
+
/^\s*[♪♫]/,
|
|
146
|
+
/^bye[.!]?$/i,
|
|
147
|
+
/^you\.?$/i,
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Reasons to look twice before running this. Pure — takes the text and the
|
|
152
|
+
* segments the service returned, returns sentences.
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ IT MUST NOT FIRE ON A GOOD TRANSCRIPT. A check that flags correct work
|
|
155
|
+
* trains people to press through the warning, which is strictly worse than no
|
|
156
|
+
* warning at all — this repo has been bitten by that four times in one day.
|
|
157
|
+
*/
|
|
158
|
+
export function transcriptWarnings(text, segments = []) {
|
|
159
|
+
const out = [];
|
|
160
|
+
const t = cleanTranscript(text);
|
|
161
|
+
if (!t) return ['nothing was heard — the transcript is empty'];
|
|
162
|
+
|
|
163
|
+
if (HALLUCINATIONS.some((re) => re.test(t))) {
|
|
164
|
+
out.push('⚠️ that looks like what the transcriber says when it hears SILENCE — it hallucinates stock captions on quiet audio. Check the recording before running this.');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const words = t.split(' ').filter(Boolean);
|
|
168
|
+
if (words.length < 3 || t.length < 12) {
|
|
169
|
+
out.push(`⚠️ only ${words.length} word${words.length === 1 ? '' : 's'} was heard — one mis-heard word would be the entire task`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `avg_logprob` is the model's own confidence; below about -1.0 it is
|
|
174
|
+
* guessing. `no_speech_prob` above 0.5 means it half-believes the clip is
|
|
175
|
+
* silent — while still returning words for it.
|
|
176
|
+
*/
|
|
177
|
+
const segs = Array.isArray(segments) ? segments.filter((s) => s && typeof s === 'object') : [];
|
|
178
|
+
const unsure = segs.filter((s) => (typeof s.avg_logprob === 'number' && s.avg_logprob < -1.0)
|
|
179
|
+
|| (typeof s.no_speech_prob === 'number' && s.no_speech_prob > 0.5));
|
|
180
|
+
if (unsure.length > 0) {
|
|
181
|
+
const worst = unsure.map((s) => (typeof s.text === 'string' ? s.text.trim() : '')).filter(Boolean)[0];
|
|
182
|
+
out.push(`⚠️ low confidence on ${unsure.length} segment${unsure.length === 1 ? '' : 's'}${worst ? ` (e.g. "${worst.slice(0, 60)}")` : ''} — read it before you run it`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* ── ⭐ AUDIO IN — a voice memo becomes a task ────────────────────────────────
|
|
190
|
+
*
|
|
191
|
+
* ⚠️⚠️ IT NEVER ACTS. It returns `needsConfirmation: true` and the words it
|
|
192
|
+
* heard. Running is the caller's decision, made through `decideTranscript`.
|
|
193
|
+
*
|
|
194
|
+
* ⚠️ `transcribeImpl` DEFAULTS TO THE REAL ONE and `fetchImpl` is threaded
|
|
195
|
+
* through it, on purpose: the tests exercise the genuine payload contract with
|
|
196
|
+
* an injected fetch rather than stubbing the media module. That contract is not
|
|
197
|
+
* theoretical — this endpoint reads `audio_b64`, and one underscore cost this
|
|
198
|
+
* package the entire voice loop once already.
|
|
199
|
+
*/
|
|
200
|
+
export async function taskFromAudio(root, audioPath, { env = process.env, fetchImpl = fetch, transcribeImpl = transcribe } = {}) {
|
|
201
|
+
const cfg = voiceConfig(env);
|
|
202
|
+
if (!cfg.canListen) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
error: 'no transcription service is configured (MODAL_TRANSCRIBE_URL), so --task-audio is unavailable on this machine. Set it in your .env and the flag starts working; nothing else changes.',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const heard = await transcribeImpl(root, audioPath, { env, fetchImpl });
|
|
210
|
+
if (!heard.ok) return { ok: false, error: heard.error };
|
|
211
|
+
|
|
212
|
+
const task = cleanTranscript(heard.text);
|
|
213
|
+
const where = heard.path ?? audioPath;
|
|
214
|
+
if (!task) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
error: `no speech was found in ${where} — the transcript came back empty, so there is nothing to run. Check the file actually contains audio.`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (task.length > MAX_TASK_CHARS) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
error: `${where} transcribes to ${task.length} characters, over the ${MAX_TASK_CHARS}-character limit for a spoken task. That is a recording, not an instruction — refused rather than truncated, because running the first minute and silently dropping the rest is worse than refusing.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
path: where,
|
|
230
|
+
task,
|
|
231
|
+
transcript: task,
|
|
232
|
+
segments: Array.isArray(heard.segments) ? heard.segments : [],
|
|
233
|
+
warnings: transcriptWarnings(task, heard.segments),
|
|
234
|
+
/**
|
|
235
|
+
* ⚠️ ALWAYS TRUE, never conditional on the warnings being empty. A
|
|
236
|
+
* confidently-transcribed wrong word is the dangerous case, not the
|
|
237
|
+
* flagged one — "delete the sensor cache" arrives at full confidence.
|
|
238
|
+
*/
|
|
239
|
+
needsConfirmation: true,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The lines to SHOW before acting. Pure, and total: a partial result must still
|
|
245
|
+
* render, because the one moment this is called is the moment something is
|
|
246
|
+
* about to be run.
|
|
247
|
+
*/
|
|
248
|
+
export function confirmationLines(result) {
|
|
249
|
+
const r = result ?? {};
|
|
250
|
+
const lines = [''];
|
|
251
|
+
lines.push(` heard in ${r.path ?? 'the audio file'}:`);
|
|
252
|
+
lines.push('');
|
|
253
|
+
lines.push(` "${typeof r.task === 'string' && r.task ? r.task : '(nothing)'}"`);
|
|
254
|
+
lines.push('');
|
|
255
|
+
for (const w of Array.isArray(r.warnings) ? r.warnings : []) lines.push(` ${w}`);
|
|
256
|
+
if (Array.isArray(r.warnings) && r.warnings.length > 0) lines.push('');
|
|
257
|
+
lines.push(' run it? [y = yes · Enter = no · or type a correction]');
|
|
258
|
+
return lines;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Single-word answers that mean "run it as heard". */
|
|
262
|
+
const AFFIRM = new Set(['y', 'yes', 'yeah', 'yep', 'ok', 'okay', 'sure', 'go', 'run', 'do']);
|
|
263
|
+
/** Single-word answers that mean "do not". */
|
|
264
|
+
const DENY = new Set(['n', 'no', 'nope', 'nah', 'q', 'quit', 'cancel', 'abort', 'stop']);
|
|
265
|
+
/**
|
|
266
|
+
* ⚠️ A SEPARATE, SMALLER SET, and the difference matters. A multi-word answer
|
|
267
|
+
* beginning with a pure yes/no PARTICLE is a correction to the transcript
|
|
268
|
+
* ("no, the server not the sensor"). A multi-word answer beginning with a VERB
|
|
269
|
+
* that merely happens to be in AFFIRM — "run the test suite and report the
|
|
270
|
+
* failures" — is a whole new instruction, and amending the mis-heard one onto
|
|
271
|
+
* it would smuggle the mistake back in.
|
|
272
|
+
*/
|
|
273
|
+
const AMEND_PREFIX = new Set(['y', 'yes', 'yeah', 'yep', 'n', 'no', 'nope', 'nah', 'ok', 'okay']);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* ── ⭐⭐ THE CONFIRMATION POLICY, AS ONE PURE TABLE ─────────────────────────
|
|
277
|
+
*
|
|
278
|
+
* Inputs: what was heard, what the human typed, whether there IS a human
|
|
279
|
+
* (`tty`), whether the caller promised a machine one JSON object (`json`), and
|
|
280
|
+
* whether they pre-authorised with `--yes`. Output: run or not, and with what.
|
|
281
|
+
*
|
|
282
|
+
* ⚠️⚠️ NO TTY AND NO `--yes` MEANS REFUSE. Under `--json`, in a pipe, in cron
|
|
283
|
+
* or in CI there is nobody to show the transcript to — and "nobody was there to
|
|
284
|
+
* object" is not consent. The refusal names `--yes`, so a script that genuinely
|
|
285
|
+
* wants unattended voice tasking says so once, out loud, in its own source.
|
|
286
|
+
*/
|
|
287
|
+
export function decideTranscript(input) {
|
|
288
|
+
const { task = '', answer = null, tty = false, json = false, assumeYes = false } = input ?? {};
|
|
289
|
+
const base = typeof task === 'string' ? task.trim() : '';
|
|
290
|
+
|
|
291
|
+
if (assumeYes) {
|
|
292
|
+
return { run: true, task: base, edited: false, how: 'accepted', why: '--yes was passed, so the transcript was pre-authorised' };
|
|
293
|
+
}
|
|
294
|
+
if (json) {
|
|
295
|
+
return { run: false, task: base, edited: false, how: 'refused', why: '--json emits one object and cannot stop to ask whether the transcript is right. Pass --yes if you have decided in advance, or drop --json and confirm in the terminal.' };
|
|
296
|
+
}
|
|
297
|
+
if (!tty) {
|
|
298
|
+
return { run: false, task: base, edited: false, how: 'refused', why: 'this is not an interactive terminal, so there is nobody to show the transcript to — and nobody objecting is not the same as somebody agreeing. Pass --yes to run a transcript unattended.' };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const raw = typeof answer === 'string' ? answer.trim() : '';
|
|
302
|
+
if (raw === '') {
|
|
303
|
+
return { run: false, task: base, edited: false, how: 'cancelled', why: 'no answer given — the default is not to act' };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const tokens = raw.split(/\s+/);
|
|
307
|
+
const first = tokens[0].toLowerCase().replace(/[^a-z]/g, '');
|
|
308
|
+
|
|
309
|
+
if (tokens.length === 1 && AFFIRM.has(first)) {
|
|
310
|
+
return { run: true, task: base, edited: false, how: 'accepted', why: 'confirmed as heard' };
|
|
311
|
+
}
|
|
312
|
+
if (tokens.length === 1 && DENY.has(first)) {
|
|
313
|
+
return { run: false, task: base, edited: false, how: 'cancelled', why: 'declined' };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (AMEND_PREFIX.has(first)) {
|
|
317
|
+
const rest = tokens.slice(1).join(' ').replace(/^[,;:.\-—]+\s*/, '').trim();
|
|
318
|
+
if (rest) {
|
|
319
|
+
return {
|
|
320
|
+
run: true,
|
|
321
|
+
/**
|
|
322
|
+
* ⭐ THE ORIGINAL WORDS ARE KEPT AND THE CORRECTION IS MARKED AS
|
|
323
|
+
* WINNING. Replacing the transcript outright would lose everything the
|
|
324
|
+
* user said that was heard CORRECTLY, which is nearly all of it.
|
|
325
|
+
*/
|
|
326
|
+
task: `${base}\n\nCorrection from the user — this wins wherever it disagrees with the line above: ${rest}`,
|
|
327
|
+
edited: true,
|
|
328
|
+
how: 'amended',
|
|
329
|
+
why: 'the transcript was corrected rather than rejected',
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return AFFIRM.has(first)
|
|
333
|
+
? { run: true, task: base, edited: false, how: 'accepted', why: 'confirmed as heard' }
|
|
334
|
+
: { run: false, task: base, edited: false, how: 'cancelled', why: 'declined' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { run: true, task: raw, edited: true, how: 'replaced', why: 'the user typed a different task, so the transcript was discarded' };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* ── ⚠️ MAKE A STRING SPEAKABLE ──────────────────────────────────────────────
|
|
342
|
+
* A synthesiser reads `**fix** the \`parser\`` as punctuation. Markdown, ANSI
|
|
343
|
+
* and brackets go; every letter — including non-ASCII — stays.
|
|
344
|
+
*/
|
|
345
|
+
function speakable(s) {
|
|
346
|
+
return String(s ?? '')
|
|
347
|
+
// eslint-disable-next-line no-control-regex
|
|
348
|
+
.replace(/\[[0-9;]*[A-Za-z]/g, '')
|
|
349
|
+
.replace(/[*`_#[\]<>|~]/g, ' ')
|
|
350
|
+
.replace(/\s+/g, ' ')
|
|
351
|
+
.trim();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The last path segment. A deep path read aloud is noise; the filename is the fact. */
|
|
355
|
+
function basename(p) {
|
|
356
|
+
const s = String(p ?? '').replace(/[\\/]+$/, '');
|
|
357
|
+
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
|
|
358
|
+
return i === -1 ? s : s.slice(i + 1);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* ── ⭐⭐ THE VERDICT — pure, and the only thing that gets spoken ─────────────
|
|
363
|
+
*
|
|
364
|
+
* Three facts and no more: what was asked, how much changed, whether it
|
|
365
|
+
* verified. Everything else belongs in the terminal, where it can be scrolled
|
|
366
|
+
* back and read at your own speed.
|
|
367
|
+
*
|
|
368
|
+
* ⚠️⚠️ THE FAILING CASE MUST NEVER FLATTER. `verification.ran` and
|
|
369
|
+
* `verification.passed` stay separate here exactly as they do everywhere else
|
|
370
|
+
* in this codebase — collapsing them is how a run whose suite exits 1 gets
|
|
371
|
+
* narrated as a success, and speech is the worst possible medium for that lie
|
|
372
|
+
* because nobody re-reads it.
|
|
373
|
+
*
|
|
374
|
+
* ⚠️ AND "NOTHING WAS RUN" IS SAID OUT LOUD. Omitting the verification clause
|
|
375
|
+
* when nothing was checked leaves a sentence that SOUNDS like success.
|
|
376
|
+
*/
|
|
377
|
+
export function summariseOutcome(outcome, { task = null, maxChars = MAX_SPOKEN_CHARS } = {}) {
|
|
378
|
+
if (outcome == null) return 'There is no result to report.';
|
|
379
|
+
|
|
380
|
+
if (outcome.ok === false) {
|
|
381
|
+
const why = speakable(outcome.error ?? outcome.stage ?? 'no reason was given').slice(0, 160);
|
|
382
|
+
return speakable(`The run did not finish: ${why}.`).slice(0, maxChars);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const executed = Array.isArray(outcome.executed) ? outcome.executed.filter(Boolean) : [];
|
|
386
|
+
const writes = executed.filter((e) => e && (e.mutated === true || e.name === 'write_file') && e.result?.ok === true);
|
|
387
|
+
|
|
388
|
+
let changePart;
|
|
389
|
+
if (writes.length === 0) {
|
|
390
|
+
changePart = 'No files changed.';
|
|
391
|
+
} else if (writes.length === 1) {
|
|
392
|
+
// ⭐ ONE name is worth hearing. Twelve is a punishment, so above one it is a
|
|
393
|
+
// count — the terminal already printed the list.
|
|
394
|
+
changePart = `Changed ${speakable(basename(writes[0].result?.path ?? writes[0].path ?? 'one file'))}.`;
|
|
395
|
+
} else {
|
|
396
|
+
changePart = `Changed ${writes.length} files.`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const v = outcome.verification ?? {};
|
|
400
|
+
const cmd = speakable(v.command ?? 'the check').slice(0, 40);
|
|
401
|
+
let verifyPart;
|
|
402
|
+
if (v.ran !== true) {
|
|
403
|
+
verifyPart = 'Nothing was run to check it.';
|
|
404
|
+
} else if (v.passed === true) {
|
|
405
|
+
verifyPart = `${cmd} passed.`;
|
|
406
|
+
} else {
|
|
407
|
+
verifyPart = `${cmd} still fails${typeof v.exitCode === 'number' ? ` with exit code ${v.exitCode}` : ''}.`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const tail = `${changePart} ${verifyPart}`;
|
|
411
|
+
const asked = speakable(task);
|
|
412
|
+
if (!asked) return speakable(tail).slice(0, maxChars);
|
|
413
|
+
|
|
414
|
+
// The verdict is the point, so it gets the budget first and the request is
|
|
415
|
+
// trimmed to whatever is left rather than the other way round.
|
|
416
|
+
const budget = maxChars - tail.length - 'Asked to . '.length;
|
|
417
|
+
const shortAsked = asked.length > budget && budget > 12 ? `${asked.slice(0, budget - 1).trim()}…` : asked;
|
|
418
|
+
if (budget <= 12) return speakable(tail).slice(0, maxChars);
|
|
419
|
+
return speakable(`Asked to ${shortAsked}. ${tail}`).slice(0, maxChars);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* ── ⭐ AUDIO OUT — narrate the verdict ──────────────────────────────────────
|
|
424
|
+
*
|
|
425
|
+
* ⚠️⚠️ SILENT BY DEFAULT, and that silence is `ok: true`. A run that was not
|
|
426
|
+
* asked to speak has not failed to speak. Only an explicit `enabled` (the
|
|
427
|
+
* `--say` flag) makes a request, which is also what keeps this from quietly
|
|
428
|
+
* spending money on every run.
|
|
429
|
+
*
|
|
430
|
+
* ⚠️ ASKED-BUT-UNCONFIGURED IS `ok: false`, WITH THE VARIABLE NAMED. Staying
|
|
431
|
+
* quiet there would be the "offered and broken" failure inverted: the user
|
|
432
|
+
* pressed the button and heard nothing, with no way to learn why.
|
|
433
|
+
*
|
|
434
|
+
* ⚠️ IT CAN NEVER FAIL THE RUN. The caller prints `reason` to stderr and moves
|
|
435
|
+
* on — the code is already written and the exit code is a verification verdict,
|
|
436
|
+
* not a bookkeeping one. `text` is returned even on failure so the verdict it
|
|
437
|
+
* could not say can still be printed.
|
|
438
|
+
*/
|
|
439
|
+
export async function speakSummary(root, outcome, {
|
|
440
|
+
env = process.env,
|
|
441
|
+
fetchImpl = fetch,
|
|
442
|
+
speakImpl = speak,
|
|
443
|
+
task = null,
|
|
444
|
+
enabled = false,
|
|
445
|
+
dryRun = false,
|
|
446
|
+
outPath = null,
|
|
447
|
+
now = Date.now,
|
|
448
|
+
} = {}) {
|
|
449
|
+
const text = summariseOutcome(outcome, { task });
|
|
450
|
+
|
|
451
|
+
if (!enabled) return { ok: true, spoken: false, reason: 'not asked for — pass --say to hear the verdict', text };
|
|
452
|
+
// `--help` promises a dry run touches nothing, and a WAV is something.
|
|
453
|
+
if (dryRun) return { ok: true, spoken: false, reason: 'dry run — nothing was written and nothing was spoken', text };
|
|
454
|
+
|
|
455
|
+
const cfg = voiceConfig(env);
|
|
456
|
+
if (!cfg.canSpeak) {
|
|
457
|
+
return {
|
|
458
|
+
ok: false,
|
|
459
|
+
spoken: false,
|
|
460
|
+
reason: 'no speech service is configured (MODAL_TTS_URL), so the verdict was not spoken. Set it in your .env and --say starts working.',
|
|
461
|
+
text,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const target = outPath || `.acuvo/verdict-${now()}.wav`;
|
|
466
|
+
const res = await speakImpl(root, text, target, { env, fetchImpl });
|
|
467
|
+
if (!res.ok) return { ok: false, spoken: false, reason: res.error, text };
|
|
468
|
+
|
|
469
|
+
return { ok: true, spoken: true, path: res.path, bytes: res.bytes, text, hint: playbackHint(res.path) };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* ⚠️ WE WROTE A FILE; WE DID NOT PLAY IT — see the header. This returns the one
|
|
474
|
+
* command that plays it on this OS, which is honest, zero-dependency, and
|
|
475
|
+
* copy-pasteable. It never claims the sound came out.
|
|
476
|
+
*/
|
|
477
|
+
export function playbackHint(relPath, platform = process.platform) {
|
|
478
|
+
const p = String(relPath ?? '');
|
|
479
|
+
const q = `"${p.replace(/"/g, '\\"')}"`;
|
|
480
|
+
/**
|
|
481
|
+
* ── ⚠️⚠️ THE QUOTING HAS TO NEST, AND THE FIRST VERSION DID NOT ────────────
|
|
482
|
+
*
|
|
483
|
+
* It used `JSON.stringify(p)` — a DOUBLE-quoted string — inside an argument
|
|
484
|
+
* that is itself double-quoted, producing:
|
|
485
|
+
*
|
|
486
|
+
* powershell -c "(New-Object Media.SoundPlayer ".acuvo/v.wav").PlaySync()"
|
|
487
|
+
*
|
|
488
|
+
* where the inner `"` closes the outer one. cmd then sees three arguments and
|
|
489
|
+
* PowerShell never receives a string at all. Found by RUNNING the live round
|
|
490
|
+
* trip and reading what it printed; the test that "covered" this asserted
|
|
491
|
+
* only that the word `powershell` appeared, and passed against a command that
|
|
492
|
+
* could never work.
|
|
493
|
+
*
|
|
494
|
+
* ⭐ SINGLE quotes inside, and they are also the right choice on their own
|
|
495
|
+
* merits: a PowerShell single-quoted string is a LITERAL, so a Windows path
|
|
496
|
+
* full of backslashes needs no escaping. The one character that can break out
|
|
497
|
+
* is a single quote, and PowerShell escapes it by doubling.
|
|
498
|
+
*/
|
|
499
|
+
if (platform === 'win32') return `powershell -c "(New-Object Media.SoundPlayer '${p.replace(/'/g, "''")}').PlaySync()"`;
|
|
500
|
+
if (platform === 'darwin') return `afplay ${q}`;
|
|
501
|
+
if (platform === 'linux') return `aplay ${q} (or paplay / ffplay -nodisp -autoexit)`;
|
|
502
|
+
return `play ${q} with any audio player`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* ── ⭐ THE FLAGS, LIFTED OUT OF ARGV BEFORE `parseArgv` SEES THEM ────────────
|
|
507
|
+
*
|
|
508
|
+
* Exactly the shape `bin/acuvo.mjs` already uses for its lifecycle flags, and
|
|
509
|
+
* for the same reason: `parseArgv` refuses any `--flag` it does not know, which
|
|
510
|
+
* is the right default and is why these come out first.
|
|
511
|
+
*
|
|
512
|
+
* ⚠️ TOTAL, NOT PERMISSIVE. Anything unrecognised is passed through untouched,
|
|
513
|
+
* so `parseArgv` still produces its own sentence for a typo — two parsers both
|
|
514
|
+
* guessing is how `--sayy` gets silently ignored.
|
|
515
|
+
*/
|
|
516
|
+
export function extractVoiceFlags(argv) {
|
|
517
|
+
const flags = { taskAudio: null, say: false, yes: false };
|
|
518
|
+
const rest = [];
|
|
519
|
+
const need = '--task-audio needs the path to an audio file in the workspace, e.g. --task-audio note.m4a';
|
|
520
|
+
for (let i = 0; i < (argv?.length ?? 0); i += 1) {
|
|
521
|
+
const arg = argv[i];
|
|
522
|
+
if (arg === '--say') { flags.say = true; continue; }
|
|
523
|
+
if (arg === '--yes' || arg === '-y') { flags.yes = true; continue; }
|
|
524
|
+
if (arg === '--task-audio') {
|
|
525
|
+
const value = argv[i + 1];
|
|
526
|
+
// ⚠️ A missing value must not eat the next flag: `--task-audio --json`
|
|
527
|
+
// silently transcribing a file called "--json" is a confusing failure two
|
|
528
|
+
// steps later, and refusing here is one step.
|
|
529
|
+
if (value === undefined || value.startsWith('--')) return { ok: false, error: need };
|
|
530
|
+
flags.taskAudio = value;
|
|
531
|
+
i += 1;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (arg.startsWith('--task-audio=')) {
|
|
535
|
+
const value = arg.slice('--task-audio='.length);
|
|
536
|
+
if (value === '') return { ok: false, error: need };
|
|
537
|
+
flags.taskAudio = value;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
rest.push(arg);
|
|
541
|
+
}
|
|
542
|
+
return { ok: true, flags, argv: rest };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Documented where people look. A capability only the changelog knows about is
|
|
546
|
+
* the "built but unreachable" failure this whole package exists to end. */
|
|
547
|
+
export const VOICE_USAGE = [
|
|
548
|
+
'',
|
|
549
|
+
'Voice (file in, file out — acuvo does not record and does not play):',
|
|
550
|
+
' --task-audio <file> Transcribe an audio file in the workspace and run what it says.',
|
|
551
|
+
' It SHOWS you the transcript first and waits: press Enter to',
|
|
552
|
+
' cancel, y to run it, or type a correction ("no, the server not',
|
|
553
|
+
' the sensor") to fix a mis-heard word without retyping the task.',
|
|
554
|
+
' Needs MODAL_TRANSCRIBE_URL.',
|
|
555
|
+
' --say Speak the verdict when the run ends — what was asked, how much',
|
|
556
|
+
' changed, whether it verified. Writes a .wav into .acuvo/ and',
|
|
557
|
+
' prints the command to play it. Silent unless you pass this.',
|
|
558
|
+
' Needs MODAL_TTS_URL.',
|
|
559
|
+
' --yes, -y Run a transcribed task without confirming. Required with --json',
|
|
560
|
+
' or in a pipe/cron/CI, where there is nobody to ask.',
|
|
561
|
+
].join('\n');
|