@lucas_zaia/agent-voice 1.0.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.
@@ -0,0 +1,94 @@
1
+ // The only component that remembers or decides anything. Adapters hand it a
2
+ // canonical event; it completes that event from state, applies the rules,
3
+ // builds the sentence and dispatches to every active output.
4
+ import { cleanSpeech } from './speech.js';
5
+ import {
6
+ agentName, where as whereOf, durationPhrase, phraseTaskDone, phraseBackgroundDone, phraseNeedsInput,
7
+ } from './phrases.js';
8
+
9
+ const str = (v) => (typeof v === 'string' ? v : '');
10
+
11
+ export async function speakAll(agent, session, sentence, ctx) {
12
+ ctx.state.cooldownStamp(agent, session);
13
+ for (const out of ctx.outputs) {
14
+ if (!out.speak) {
15
+ ctx.log(agent, session, `no such output: ${out.name}${out.reason ? ` (${out.reason})` : ''}`);
16
+ continue;
17
+ }
18
+ try {
19
+ await out.speak(sentence);
20
+ ctx.log(agent, session, `spoke via ${out.name}: ${sentence}`);
21
+ } catch (e) {
22
+ // "container down", "token missing" and "HTTP 401" must stay distinguishable
23
+ // from each other, and from a hook that simply chose to stay quiet.
24
+ const reason = String(e?.message ?? e).split(/\r?\n/)[0].slice(0, 120) || 'failed with no reason';
25
+ ctx.log(agent, session, `FAILED via ${out.name} (${reason}): ${sentence}`);
26
+ }
27
+ }
28
+ }
29
+
30
+ export async function handle(event, ctx) {
31
+ const e = event && typeof event === 'object' ? event : {};
32
+ const type = str(e.type);
33
+ const agent = str(e.agent);
34
+ const session = str(e.session_id);
35
+ if (!type || !agent || !session) {
36
+ ctx.log(agent || '?', session || '?', 'ignored: incomplete event');
37
+ return;
38
+ }
39
+
40
+ // session_name and project come straight from the agent (an AI-written title,
41
+ // a directory name) — never speech-safe by construction, so cleaned here once.
42
+ const clean = (t) => cleanSpeech(t, ctx.config.maxSpeechChars);
43
+ const who = agentName(agent);
44
+ const place = whereOf(session, clean(str(e.session_name)), clean(str(e.project)));
45
+ const text = str(e.text);
46
+ let sentence;
47
+
48
+ switch (type) {
49
+ case 'turn_start':
50
+ ctx.state.turnStart(agent, session, text);
51
+ ctx.log(agent, session, 'turn started');
52
+ return;
53
+
54
+ case 'task_done': {
55
+ const elapsed = ctx.state.turnElapsed(agent, session);
56
+ if (elapsed === null) {
57
+ ctx.log(agent, session, 'ignored: task_done with no turn marker');
58
+ return;
59
+ }
60
+ // The request came from turn_start; the stop hook never carries it.
61
+ const asked = ctx.state.turnText(agent, session);
62
+ ctx.state.turnClear(agent, session);
63
+ if (elapsed < ctx.config.minSeconds) {
64
+ ctx.log(agent, session, `silent (turn ${elapsed}s < ${ctx.config.minSeconds}s)`);
65
+ return;
66
+ }
67
+ sentence = phraseTaskDone(who, place, durationPhrase(elapsed), clean(asked));
68
+ break;
69
+ }
70
+
71
+ case 'background_done':
72
+ if (!ctx.state.cooldownOk(agent, session, ctx.config.cooldownSeconds)) {
73
+ ctx.log(agent, session, `silent (cooldown ${ctx.config.cooldownSeconds}s)`);
74
+ return;
75
+ }
76
+ // Background events fire often; a sentence with no content is noise.
77
+ if (!text) {
78
+ ctx.log(agent, session, 'silent (background_done with no content)');
79
+ return;
80
+ }
81
+ sentence = phraseBackgroundDone(who, place, clean(text));
82
+ break;
83
+
84
+ case 'needs_input':
85
+ sentence = phraseNeedsInput(who, place, clean(text));
86
+ break;
87
+
88
+ default:
89
+ ctx.log(agent, session, `ignored: unknown type ${type}`);
90
+ return;
91
+ }
92
+
93
+ await speakAll(agent, session, sentence, ctx);
94
+ }
@@ -0,0 +1,17 @@
1
+ // POSIX `cksum` CRC, so state paths and spoken session colours stay identical
2
+ // to the bash version (which shelled out to cksum). Not a security hash.
3
+ const TABLE = new Uint32Array(256);
4
+ for (let i = 0; i < 256; i++) {
5
+ let c = i << 24;
6
+ for (let k = 0; k < 8; k++) c = c & 0x80000000 ? (c << 1) ^ 0x04c11db7 : c << 1;
7
+ TABLE[i] = c >>> 0;
8
+ }
9
+
10
+ export function cksum(str) {
11
+ const bytes = Buffer.from(String(str), 'utf8');
12
+ let crc = 0;
13
+ const feed = (b) => { crc = ((crc << 8) ^ TABLE[((crc >>> 24) ^ b) & 0xff]) >>> 0; };
14
+ for (const b of bytes) feed(b);
15
+ for (let n = bytes.length; n > 0; n = Math.floor(n / 256)) feed(n & 0xff);
16
+ return (~crc) >>> 0;
17
+ }
@@ -0,0 +1,24 @@
1
+ // One line per firing. Without this, a hook that chose to stay silent and a
2
+ // hook that died look identical from the outside.
3
+ import { mkdirSync, appendFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ const pad2 = (n) => String(n).padStart(2, '0');
7
+
8
+ export function timestamp(d = new Date()) {
9
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` +
10
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
11
+ }
12
+
13
+ export function formatLine(agent, session, message, d = new Date()) {
14
+ return `${timestamp(d)} ${String(agent).padEnd(12)} ${String(session).slice(0, 8).padEnd(10)} ${message}\n`;
15
+ }
16
+
17
+ export function log(stateDir, agent, session, message, d = new Date()) {
18
+ try {
19
+ mkdirSync(stateDir, { recursive: true });
20
+ appendFileSync(join(stateDir, 'events.log'), formatLine(agent, session, message, d));
21
+ } catch {
22
+ // The log is the last resort; there is nothing left to report a failure to.
23
+ }
24
+ }
@@ -0,0 +1,54 @@
1
+ // Everything spoken lives here, and it is the only file in Portuguese. Adding a
2
+ // language, or changing what the speaker says, touches this file and nothing else.
3
+ import { cksum } from './hash.js';
4
+ import { stripTrailingPunct } from './speech.js';
5
+
6
+ const AGENT_NAMES = { 'claude-code': 'Claude Code', codex: 'Codex', wrap: 'O comando' };
7
+
8
+ // Spoken fallback identity when an agent cannot supply a session name.
9
+ // "sessão zero quatro cê zero" is useless to hear; a colour is not.
10
+ export const LABELS = ['azul', 'verde', 'vermelha', 'amarela', 'roxa', 'laranja', 'dourada', 'prateada', 'turquesa', 'violeta'];
11
+
12
+ export const TEST_SENTENCE = 'Teste do agent voice. Se você está ouvindo, está funcionando.';
13
+
14
+ export const agentName = (agent) => (Object.hasOwn(AGENT_NAMES, agent) ? AGENT_NAMES[agent] : agent);
15
+
16
+ export const sessionLabel = (sessionId) => LABELS[cksum(sessionId) % LABELS.length];
17
+
18
+ export function where(sessionId, name, project) {
19
+ if (name) return `na sessão ${name}`;
20
+ if (project) return `na sessão ${sessionLabel(sessionId)}, do projeto ${project}`;
21
+ return `na sessão ${sessionLabel(sessionId)}`;
22
+ }
23
+
24
+ export function durationPhrase(seconds) {
25
+ const minutes = Math.floor((seconds + 30) / 60);
26
+ return minutes <= 1 ? 'cerca de um minuto' : `cerca de ${minutes} minutos`;
27
+ }
28
+
29
+ export function phraseTaskDone(agent, place, duration, text) {
30
+ const s = `${agent} terminou ${place}, depois de ${duration}.`;
31
+ return text ? `${s} Você tinha pedido: ${stripTrailingPunct(text)}.` : s;
32
+ }
33
+
34
+ export function phraseBackgroundDone(agent, place, text) {
35
+ const s = `${agent} terminou um trabalho em segundo plano ${place}.`;
36
+ return text ? `${s} Era: ${stripTrailingPunct(text)}.` : s;
37
+ }
38
+
39
+ // Agent notices arrive in English. Speaking one verbatim after a Portuguese
40
+ // sentence says the same thing twice, so known notices are translated — and the
41
+ // one that merely restates the sentence is dropped. Unknown ones are kept.
42
+ export function translateNotice(text) {
43
+ const marker = 'needs your permission to use ';
44
+ const at = text.lastIndexOf(marker);
45
+ if (at !== -1) return `, para usar o ${text.slice(at + marker.length)}`;
46
+ if (text.includes('needs your permission')) return '';
47
+ if (text.includes('waiting for your input')) return ', e está esperando sua resposta';
48
+ return `. ${text}`;
49
+ }
50
+
51
+ export function phraseNeedsInput(agent, place, text) {
52
+ const s = `${agent} precisa de você ${place}`;
53
+ return text ? `${s}${translateNotice(stripTrailingPunct(text))}.` : `${s}.`;
54
+ }
@@ -0,0 +1,30 @@
1
+ // Turns written text into something worth hearing. A speaker reading a URL or
2
+ // a card id out loud is unbearable, and markdown syntax is noise in speech.
3
+ const EMOJI = /[\u{2190}-\u{27BF}\u{2300}-\u{23FF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1FFFF}]/gu;
4
+
5
+ export function cleanSpeech(input, max) {
6
+ const text = (typeof input === 'string' ? input : '')
7
+ .replace(/\n/g, ' ')
8
+ // Tags from agent-injected markup (<task-id>, <br/>): keep the text, drop the tag.
9
+ .replace(/<\/?[A-Za-z][\w-]*(\s[^<>]*)?\/?>/g, ' ')
10
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
11
+ .replace(/https?:\/\/[^ ]*/g, '')
12
+ // File paths. Must run after the URL rule: by now every URL is gone, so this
13
+ // cannot eat half of one. Needs two slashes; a lone "/etc" is left alone.
14
+ .replace(/\/[^ ]*\/[^ ]*/g, '')
15
+ .replace(/\[#?[0-9]+\]\s*/g, '')
16
+ .replace(/[[\]]/g, '')
17
+ .replace(/[`*#>]/g, '')
18
+ .replace(/_/g, ' ')
19
+ .replace(EMOJI, '')
20
+ .replace(/\.{2,}/g, '.')
21
+ .replace(/\s+/g, ' ')
22
+ .replace(/^ /, '')
23
+ .replace(/ $/, '');
24
+ return Array.from(text).slice(0, max).join('');
25
+ }
26
+
27
+ // Used before joining two fragments, so "travados." + "." does not become "travados..".
28
+ export function stripTrailingPunct(text) {
29
+ return String(text).replace(/[\s.!:;,-]+$/u, '');
30
+ }
@@ -0,0 +1,56 @@
1
+ // Per-session state, used by the core only. Keyed by agent and session so that
2
+ // concurrent sessions — and concurrent agents — never clobber each other.
3
+ // Layout is identical to the bash version, so existing state carries over.
4
+ import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { cksum } from './hash.js';
7
+
8
+ const segment = (raw) => {
9
+ const s = String(raw);
10
+ return `${s.replace(/[^a-zA-Z0-9_-]/g, '') || 'unknown'}_${cksum(s)}`;
11
+ };
12
+
13
+ const epochNow = () => Math.floor(Date.now() / 1000);
14
+
15
+ export function createState(stateDir, now = epochNow) {
16
+ const path = (agent, session) => {
17
+ const dir = join(stateDir, segment(agent));
18
+ mkdirSync(dir, { recursive: true });
19
+ return join(dir, segment(session));
20
+ };
21
+ const read = (file) => {
22
+ try { return readFileSync(file, 'utf8'); } catch { return null; }
23
+ };
24
+ const readEpoch = (file) => {
25
+ const v = read(file)?.trim();
26
+ return v && /^[0-9]+$/.test(v) ? Number(v) : null;
27
+ };
28
+
29
+ return {
30
+ path,
31
+ turnStart(agent, session, text) {
32
+ const p = path(agent, session);
33
+ writeFileSync(`${p}.start`, String(now()));
34
+ writeFileSync(`${p}.text`, text ?? '');
35
+ },
36
+ turnElapsed(agent, session) {
37
+ const started = readEpoch(`${path(agent, session)}.start`);
38
+ return started === null ? null : Math.max(0, now() - started);
39
+ },
40
+ turnText(agent, session) {
41
+ return read(`${path(agent, session)}.text`) ?? '';
42
+ },
43
+ turnClear(agent, session) {
44
+ const p = path(agent, session);
45
+ rmSync(`${p}.start`, { force: true });
46
+ rmSync(`${p}.text`, { force: true });
47
+ },
48
+ cooldownStamp(agent, session) {
49
+ writeFileSync(`${path(agent, session)}.cooldown`, String(now()));
50
+ },
51
+ cooldownOk(agent, session, seconds) {
52
+ const last = readEpoch(`${path(agent, session)}.cooldown`);
53
+ return last === null || now() - last >= seconds;
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,66 @@
1
+ // Makes an Echo speak through Home Assistant's notify.send_message service —
2
+ // the same call the old falar.sh made, without needing that script.
3
+ import { SPEAK_TIMEOUT_MS } from './run.js';
4
+
5
+ export const type = 'alexa';
6
+
7
+ const base = (url) => String(url ?? '').replace(/\/+$/, '');
8
+
9
+ export const describe = (conf) => `${conf.entity} @ ${base(conf.url)}`;
10
+
11
+ async function call(conf, path, init = {}, deps = {}) {
12
+ const doFetch = deps.fetch ?? fetch;
13
+ const timeoutMs = deps.timeoutMs ?? SPEAK_TIMEOUT_MS;
14
+ let res;
15
+ try {
16
+ res = await doFetch(`${base(conf.url)}${path}`, {
17
+ ...init,
18
+ headers: { Authorization: `Bearer ${conf.token}`, 'Content-Type': 'application/json' },
19
+ signal: AbortSignal.timeout(timeoutMs),
20
+ });
21
+ } catch (e) {
22
+ if (e.name === 'TimeoutError' || e.name === 'AbortError') {
23
+ throw new Error(`Home Assistant did not answer within ${Math.round(timeoutMs / 1000)}s`);
24
+ }
25
+ throw new Error(`cannot reach Home Assistant at ${base(conf.url)} (${e.cause?.code ?? e.message})`);
26
+ }
27
+ if (res.status === 401) throw new Error('Home Assistant rejected the token (HTTP 401)');
28
+ if (!res.ok) throw new Error(`Home Assistant answered HTTP ${res.status}`);
29
+ return res;
30
+ }
31
+
32
+ export async function speak(sentence, conf, deps = {}) {
33
+ if (!conf.url || !conf.token || !conf.entity) throw new Error('alexa output is missing url, token or entity');
34
+ await call(conf, '/api/services/notify/send_message', {
35
+ method: 'POST',
36
+ body: JSON.stringify({ entity_id: conf.entity, message: sentence }),
37
+ }, deps);
38
+ }
39
+
40
+ export async function listNotifyEntities(conf, deps = {}) {
41
+ const res = await call(conf, '/api/states', {}, deps);
42
+ const states = await res.json();
43
+ return (Array.isArray(states) ? states : [])
44
+ .map((s) => s?.entity_id)
45
+ .filter((id) => typeof id === 'string' && id.startsWith('notify.'))
46
+ .sort();
47
+ }
48
+
49
+ export async function questions(prompt, current = {}, deps = {}) {
50
+ const url = base(await prompt.ask('Home Assistant URL', current.url ?? 'http://localhost:8123'));
51
+ // A saved token is never echoed back: Enter keeps it, the hint shows its end.
52
+ const tail = String(current.token ?? '').length >= 16 ? ` …${String(current.token).slice(-4)}` : '';
53
+ const saved = current.token ? ` [saved token${tail}, Enter keeps it]` : '';
54
+ const token = (await prompt.ask(`Long-lived access token (Home Assistant → your profile → Security)${saved}`, '')) || current.token || '';
55
+ const conf = { type, url, token, entity: '' };
56
+ await call(conf, '/api/', {}, deps);
57
+ const entities = await listNotifyEntities(conf, deps);
58
+ if (entities.length === 0) {
59
+ throw new Error('Home Assistant has no notify.* entities — is the Alexa Media Player integration set up?');
60
+ }
61
+ const ordered = [...entities.filter((e) => e.endsWith('_announce')), ...entities.filter((e) => !e.endsWith('_announce'))];
62
+ const def = Math.max(0, ordered.indexOf(current.entity));
63
+ const i = await prompt.choose('Which device should speak? (_announce plays a chime first)', ordered, def);
64
+ conf.entity = ordered[i];
65
+ return conf;
66
+ }
@@ -0,0 +1,66 @@
1
+ // Runs any command that makes noise. {text} in its arguments is replaced by the
2
+ // sentence; without {text}, the sentence goes on stdin. No shell is involved.
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { run as runProcess, SPEAK_TIMEOUT_MS } from './run.js';
6
+
7
+ export const type = 'command';
8
+
9
+ export function parseCommandLine(line) {
10
+ const out = [];
11
+ let cur = '';
12
+ let quote = null;
13
+ let started = false;
14
+ for (const ch of String(line)) {
15
+ if (quote) {
16
+ if (ch === quote) quote = null;
17
+ else cur += ch;
18
+ } else if (ch === '"' || ch === "'") {
19
+ quote = ch;
20
+ started = true;
21
+ } else if (/\s/.test(ch)) {
22
+ if (started) out.push(cur);
23
+ cur = '';
24
+ started = false;
25
+ } else {
26
+ cur += ch;
27
+ started = true;
28
+ }
29
+ }
30
+ if (quote) throw new Error('unclosed quote in command');
31
+ if (started) out.push(cur);
32
+ return out;
33
+ }
34
+
35
+ // No shell means no ~ expansion, yet `~/softwares/falar.sh -a {text}` is what
36
+ // people type. A leading ~, $HOME, ${HOME} or %USERPROFILE% in the command or
37
+ // an argument is the home directory; the sentence is never expanded.
38
+ const HOME_PREFIX = /^(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%)(?=$|[\\/])/i;
39
+
40
+ export function expandHome(arg, home = homedir()) {
41
+ const m = HOME_PREFIX.exec(arg);
42
+ if (!m) return arg;
43
+ const rest = arg.slice(m[0].length).replace(/^[\\/]+/, '');
44
+ return rest ? join(home, rest) : home;
45
+ }
46
+
47
+ export const describe = (conf) => (conf.argv ?? []).join(' ');
48
+
49
+ export async function questions(prompt, current = {}) {
50
+ const line = await prompt.ask(
51
+ 'Command that speaks a sentence ({text} marks where the sentence goes; without it, the sentence is sent on stdin)',
52
+ current.argv ? current.argv.join(' ') : '',
53
+ );
54
+ const argv = parseCommandLine(line);
55
+ if (argv.length === 0) throw new Error('a command is required');
56
+ return { type, argv };
57
+ }
58
+
59
+ export async function speak(sentence, conf, deps = {}) {
60
+ if (!Array.isArray(conf.argv) || conf.argv.length === 0) throw new Error('command output has no argv');
61
+ const [cmd, ...rest] = conf.argv.map((a) => expandHome(String(a), deps.home));
62
+ const placeholder = rest.some((a) => a.includes('{text}'));
63
+ const args = rest.map((a) => a.replaceAll('{text}', sentence));
64
+ const run = deps.run ?? runProcess;
65
+ await run(cmd, args, { input: placeholder ? '' : sentence, spawn: deps.spawn, timeoutMs: deps.timeoutMs ?? SPEAK_TIMEOUT_MS });
66
+ }
@@ -0,0 +1,23 @@
1
+ import * as alexa from './alexa.js';
2
+ import * as command from './command.js';
3
+ import * as local from './local.js';
4
+ import { readOutputInstance } from '../config.js';
5
+
6
+ export const OUTPUT_TYPES = { alexa, local, command };
7
+
8
+ // Turns the active output names into speak() closures for the core. A name with
9
+ // no usable instance gets speak: null, which the core logs as "no such output".
10
+ export function resolveOutputs(names, env = process.env) {
11
+ return names.map((name) => {
12
+ let conf;
13
+ try {
14
+ conf = readOutputInstance(name, env);
15
+ } catch (e) {
16
+ return { name, speak: null, reason: e.message };
17
+ }
18
+ if (!conf) return { name, speak: null };
19
+ const mod = Object.hasOwn(OUTPUT_TYPES, conf.type) ? OUTPUT_TYPES[conf.type] : null;
20
+ if (!mod) return { name, speak: null, reason: `unknown output type ${conf.type}` };
21
+ return { name, speak: (sentence) => mod.speak(sentence, conf) };
22
+ });
23
+ }
@@ -0,0 +1,87 @@
1
+ // The computer's own voice: `say` on macOS, SAPI through PowerShell on Windows,
2
+ // speech-dispatcher or eSpeak on Linux.
3
+ import { run as runProcess, SPEAK_TIMEOUT_MS } from './run.js';
4
+ import { findOnPath } from '../platform.js';
5
+
6
+ export const type = 'local';
7
+
8
+ // The sentence arrives on stdin, never spliced into the script, so no quoting
9
+ // of user text is ever needed.
10
+ const SAPI_SPEAK = [
11
+ '[Console]::InputEncoding = [Text.Encoding]::UTF8;',
12
+ 'Add-Type -AssemblyName System.Speech;',
13
+ '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;',
14
+ 'if ($env:AV_VOICE) { $s.SelectVoice($env:AV_VOICE) };',
15
+ '$s.Speak([Console]::In.ReadToEnd())',
16
+ ].join(' ');
17
+
18
+ const SAPI_VOICES = [
19
+ 'Add-Type -AssemblyName System.Speech;',
20
+ '(New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() |',
21
+ "ForEach-Object { $_.VoiceInfo.Culture.Name + '|' + $_.VoiceInfo.Name }",
22
+ ].join(' ');
23
+
24
+ export function detectBackend(platform = process.platform, env = process.env, find = findOnPath) {
25
+ if (platform === 'darwin') return 'say';
26
+ if (platform === 'win32') return 'sapi';
27
+ for (const backend of ['spd-say', 'espeak-ng', 'espeak']) if (find(backend, env, platform)) return backend;
28
+ return null;
29
+ }
30
+
31
+ export function commandFor(backend, sentence, voice) {
32
+ switch (backend) {
33
+ case 'say':
34
+ return { cmd: 'say', args: voice ? ['-v', voice, sentence] : [sentence], input: '' };
35
+ case 'sapi':
36
+ return {
37
+ cmd: 'powershell.exe',
38
+ args: ['-NoProfile', '-NonInteractive', '-Command', SAPI_SPEAK],
39
+ input: sentence,
40
+ env: { ...process.env, AV_VOICE: voice ?? '' },
41
+ };
42
+ case 'spd-say':
43
+ return { cmd: 'spd-say', args: ['-w', '-l', voice || 'pt', sentence], input: '' };
44
+ case 'espeak-ng':
45
+ case 'espeak':
46
+ return { cmd: backend, args: ['-v', voice || 'pt-br', sentence], input: '' };
47
+ default:
48
+ throw new Error(`unknown local voice backend: ${backend}`);
49
+ }
50
+ }
51
+
52
+ export async function defaultVoice(backend, deps = {}) {
53
+ const run = deps.run ?? runProcess;
54
+ try {
55
+ if (backend === 'say') {
56
+ const out = await run('say', ['-v', '?']);
57
+ const line = out.split('\n').find((l) => /\bpt_BR\b/.test(l));
58
+ return line ? line.trim().split(/\s{2,}/)[0] : '';
59
+ }
60
+ if (backend === 'sapi') {
61
+ const out = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', SAPI_VOICES]);
62
+ const line = out.split(/\r?\n/).find((l) => l.startsWith('pt-BR|'));
63
+ return line ? line.slice('pt-BR|'.length).trim() : '';
64
+ }
65
+ } catch {
66
+ return '';
67
+ }
68
+ return backend === 'spd-say' ? 'pt' : 'pt-br';
69
+ }
70
+
71
+ export const describe = (conf) => `${conf.backend}${conf.voice ? ` (${conf.voice})` : ''}`;
72
+
73
+ export async function questions(prompt, current = {}, deps = {}) {
74
+ const backend = detectBackend(deps.platform, deps.env, deps.find);
75
+ if (!backend) {
76
+ throw new Error('no local voice found — install espeak-ng (e.g. apt install espeak-ng) or speech-dispatcher');
77
+ }
78
+ const suggested = current.backend === backend && current.voice ? current.voice : await defaultVoice(backend, deps);
79
+ const voice = await prompt.ask(`Voice for ${backend} (Enter keeps the suggestion)`, suggested);
80
+ return { type, backend, voice };
81
+ }
82
+
83
+ export async function speak(sentence, conf, deps = {}) {
84
+ const { cmd, args, input, env } = commandFor(conf.backend, sentence, conf.voice);
85
+ const run = deps.run ?? runProcess;
86
+ await run(cmd, args, { input, env, spawn: deps.spawn, timeoutMs: deps.timeoutMs ?? SPEAK_TIMEOUT_MS });
87
+ }
@@ -0,0 +1,53 @@
1
+ // Runs a speaker process with a hard timeout. A hook that hangs is worse than
2
+ // one that fails, so every child gets killed eventually.
3
+ import { spawn as nodeSpawn } from 'node:child_process';
4
+ import { spawnCommand, spawnErrorMessage } from '../spawn-command.js';
5
+
6
+ // Every output gets at most this long. Hooks run outputs one after another and
7
+ // agents kill a hook at its timeout (60s for the async hooks), so three slow
8
+ // outputs still leave time to log why they failed.
9
+ export const SPEAK_TIMEOUT_MS = 15000;
10
+
11
+ export function run(cmd, args, { input = '', timeoutMs = SPEAK_TIMEOUT_MS, env, spawn = nodeSpawn, platform = process.platform } = {}) {
12
+ return new Promise((resolve, reject) => {
13
+ const startError = (e) => new Error(spawnErrorMessage(e, cmd, env ?? process.env, platform));
14
+ let child;
15
+ try {
16
+ child = spawnCommand(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env, windowsHide: true }, { spawn, platform });
17
+ } catch (e) {
18
+ reject(startError(e));
19
+ return;
20
+ }
21
+ let stdout = '';
22
+ let stderr = '';
23
+ let settled = false;
24
+ let grace = null;
25
+ const finish = (fn, value) => {
26
+ if (settled) return;
27
+ settled = true;
28
+ clearTimeout(timer);
29
+ clearTimeout(grace);
30
+ fn(value);
31
+ };
32
+ const timer = setTimeout(() => {
33
+ child.kill();
34
+ finish(reject, new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`));
35
+ }, timeoutMs);
36
+ child.stdout.on('data', (d) => { stdout += d; });
37
+ child.stderr.on('data', (d) => { stderr += d; });
38
+ child.on('error', (e) => finish(reject, startError(e)));
39
+ // Done when the speaker exits, not when its pipes close: one that leaves
40
+ // audio playing in the background (`sh -c "play x &"`) hands the pipes to
41
+ // that player. Output still in flight gets a moment to arrive.
42
+ const done = (code, signal) => {
43
+ child.stdout.destroy();
44
+ child.stderr.destroy();
45
+ if (code === 0) finish(resolve, stdout);
46
+ else finish(reject, new Error(stderr.trim().split(/\r?\n/)[0] || `exited ${code ?? signal} with no output`));
47
+ };
48
+ child.on('exit', (code, signal) => { grace = setTimeout(() => done(code, signal), 200); });
49
+ child.on('close', done);
50
+ child.stdin.on('error', () => {});
51
+ child.stdin.end(input);
52
+ });
53
+ }
@@ -0,0 +1,54 @@
1
+ // Everything that differs between macOS, Windows and Linux lives here.
2
+ import { homedir } from 'node:os';
3
+ import path, { join } from 'node:path';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ export function configDir(env = process.env, platform = process.platform, home = homedir()) {
7
+ if (env.AV_CONFIG_DIR) return env.AV_CONFIG_DIR;
8
+ if (platform === 'win32') return join(env.APPDATA || join(home, 'AppData', 'Roaming'), 'agent-voice');
9
+ if (platform === 'darwin') return join(home, 'Library', 'Application Support', 'agent-voice');
10
+ return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'agent-voice');
11
+ }
12
+
13
+ export function stateDir(env = process.env, platform = process.platform, home = homedir()) {
14
+ if (env.AV_STATE_DIR) return env.AV_STATE_DIR;
15
+ if (platform === 'win32') return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'agent-voice');
16
+ if (platform === 'darwin') return join(home, 'Library', 'Application Support', 'agent-voice', 'state');
17
+ return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'agent-voice');
18
+ }
19
+
20
+ // Windows environment names are case-insensitive, but a copied env object
21
+ // ({ ...process.env }) is not: PATH may arrive as "Path".
22
+ export function envValue(env, name, platform = process.platform) {
23
+ if (env[name] !== undefined || platform !== 'win32') return env[name];
24
+ const key = Object.keys(env).find((k) => k.toUpperCase() === name);
25
+ return key === undefined ? undefined : env[key];
26
+ }
27
+
28
+ // Where the OS would find `cmd`. On Windows this follows cmd.exe: a name that
29
+ // already carries a PATHEXT extension is taken as is, otherwise each PATHEXT
30
+ // extension is tried — so npm's extensionless sh script never wins over its
31
+ // .cmd twin. A name containing a separator is checked where it points.
32
+ export function findOnPath(cmd, env = process.env, platform = process.platform, exists = existsSync) {
33
+ const win = platform === 'win32';
34
+ const p = win ? path.win32 : path.posix;
35
+ const name = String(cmd ?? '');
36
+ if (!name) return null;
37
+ let exts = [''];
38
+ if (win) {
39
+ const pathext = (envValue(env, 'PATHEXT', platform) || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map((e) => e.toLowerCase());
40
+ // Windows file lookup is case-insensitive; lower-case keeps the result predictable.
41
+ if (!pathext.includes(p.extname(name).toLowerCase())) exts = pathext;
42
+ }
43
+ const hasDir = win ? /[\\/]/.test(name) : name.includes('/');
44
+ const dirs = hasDir
45
+ ? ['']
46
+ : (envValue(env, 'PATH', platform) ?? '').split(p.delimiter).map((d) => d.replace(/^"(.*)"$/, '$1')).filter(Boolean);
47
+ for (const dir of dirs) {
48
+ for (const ext of exts) {
49
+ const candidate = dir ? p.join(dir, name + ext) : name + ext;
50
+ if (exists(candidate)) return candidate;
51
+ }
52
+ }
53
+ return null;
54
+ }