@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.
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/bin/agent-voice.js +9 -0
- package/docs/adapters.md +56 -0
- package/package.json +14 -0
- package/src/adapters/claude-code.js +103 -0
- package/src/adapters/codex.js +43 -0
- package/src/adapters/common.js +14 -0
- package/src/adapters/hooks-json.js +158 -0
- package/src/adapters/index.js +4 -0
- package/src/cli/config.js +33 -0
- package/src/cli/connect.js +42 -0
- package/src/cli/main.js +69 -0
- package/src/cli/notify.js +72 -0
- package/src/cli/output.js +121 -0
- package/src/cli/prompt.js +75 -0
- package/src/cli/setup.js +71 -0
- package/src/cli/status.js +43 -0
- package/src/cli/wrap.js +73 -0
- package/src/config.js +98 -0
- package/src/core/context.js +14 -0
- package/src/core/handle.js +94 -0
- package/src/core/hash.js +17 -0
- package/src/core/log.js +24 -0
- package/src/core/phrases.js +54 -0
- package/src/core/speech.js +30 -0
- package/src/core/state.js +56 -0
- package/src/outputs/alexa.js +66 -0
- package/src/outputs/command.js +66 -0
- package/src/outputs/index.js +23 -0
- package/src/outputs/local.js +87 -0
- package/src/outputs/run.js +53 -0
- package/src/platform.js +54 -0
- package/src/spawn-command.js +58 -0
package/src/cli/main.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { runNotify, readStdin } from './notify.js';
|
|
2
|
+
import { createPrompter, isCancelled } from './prompt.js';
|
|
3
|
+
import { runWrap } from './wrap.js';
|
|
4
|
+
import { runOutput, testActive } from './output.js';
|
|
5
|
+
import { runConfig } from './config.js';
|
|
6
|
+
import { runConnect, runDisconnect } from './connect.js';
|
|
7
|
+
import { runStatus } from './status.js';
|
|
8
|
+
import { runSetup } from './setup.js';
|
|
9
|
+
|
|
10
|
+
const HELP = `Usage: agent-voice <command>
|
|
11
|
+
|
|
12
|
+
setup guided setup: connect agents, add a speaker, test it
|
|
13
|
+
connect <claude-code|codex> add agent-voice's hooks to the agent (asks first; --yes skips)
|
|
14
|
+
disconnect <claude-code|codex> remove agent-voice's hooks from the agent
|
|
15
|
+
output add <alexa|local|command> [name]
|
|
16
|
+
output list | remove <name> | enable <name> | disable <name> | test [name]
|
|
17
|
+
wrap [--name <label>] -- <command> [args...]
|
|
18
|
+
announce when any command finishes
|
|
19
|
+
status agents, outputs, settings and the latest log lines
|
|
20
|
+
config get [key] | set <key> <value>
|
|
21
|
+
keys: minSeconds, cooldownSeconds, maxSpeechChars
|
|
22
|
+
test speak a test sentence on every active output
|
|
23
|
+
notify <agent> <subcommand> hook entry point (called by your agent, not by you)`;
|
|
24
|
+
|
|
25
|
+
export function defaultIO(env = process.env) {
|
|
26
|
+
return {
|
|
27
|
+
env,
|
|
28
|
+
out: (s) => process.stdout.write(`${s}\n`),
|
|
29
|
+
err: (s) => process.stderr.write(`${s}\n`),
|
|
30
|
+
prompt: null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Each later task registers its command here: name → async (args, io) => exit code.
|
|
35
|
+
const COMMANDS = {
|
|
36
|
+
setup: runSetup,
|
|
37
|
+
wrap: (args, io) => runWrap(args, { env: io.env }),
|
|
38
|
+
output: runOutput,
|
|
39
|
+
config: async (args, io) => runConfig(args, io),
|
|
40
|
+
test: async (args, io) => testActive(io),
|
|
41
|
+
connect: runConnect,
|
|
42
|
+
disconnect: runDisconnect,
|
|
43
|
+
status: runStatus,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export async function main(argv, io = defaultIO()) {
|
|
47
|
+
const [cmd, ...rest] = argv;
|
|
48
|
+
if (cmd === 'notify') return runNotify(rest, { input: await readStdin(), env: io.env });
|
|
49
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
50
|
+
io.out(HELP);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
const run = Object.hasOwn(COMMANDS, cmd) ? COMMANDS[cmd] : null;
|
|
54
|
+
if (!run) {
|
|
55
|
+
io.err(`agent-voice: unknown command "${cmd}". Run "agent-voice help".`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
let prompter = null;
|
|
59
|
+
const withPrompt = { ...io, get prompt() { return io.prompt ?? (prompter ??= createPrompter()); } };
|
|
60
|
+
try {
|
|
61
|
+
return await run(rest, withPrompt);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
io.err(`agent-voice: ${e.message}`);
|
|
64
|
+
// 130 is what a shell reports for a command stopped by Ctrl+C.
|
|
65
|
+
return isCancelled(e) && e.signal === 'SIGINT' ? 130 : 1;
|
|
66
|
+
} finally {
|
|
67
|
+
prompter?.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The hook entry point. Exits 0 in every scenario, on purpose: a hook that
|
|
2
|
+
// hangs or fails is worse than one that does not exist. The log is the only
|
|
3
|
+
// place a failure is reported.
|
|
4
|
+
import { ADAPTERS } from '../adapters/index.js';
|
|
5
|
+
import { createContext } from '../core/context.js';
|
|
6
|
+
import { handle } from '../core/handle.js';
|
|
7
|
+
import { log } from '../core/log.js';
|
|
8
|
+
import { stateDir } from '../platform.js';
|
|
9
|
+
|
|
10
|
+
// Bounded: an agent that leaves stdin open without writing must not hang us.
|
|
11
|
+
export function readStdin(stream = process.stdin, timeoutMs = 3000) {
|
|
12
|
+
if (stream.isTTY) return Promise.resolve('');
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
let data = '';
|
|
15
|
+
let done = false;
|
|
16
|
+
const finish = () => {
|
|
17
|
+
if (done) return;
|
|
18
|
+
done = true;
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
stream.removeAllListeners?.('data');
|
|
21
|
+
stream.destroy?.();
|
|
22
|
+
resolve(data);
|
|
23
|
+
};
|
|
24
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
25
|
+
stream.setEncoding?.('utf8');
|
|
26
|
+
stream.on('data', (chunk) => { data += chunk; });
|
|
27
|
+
stream.on('end', finish);
|
|
28
|
+
stream.on('error', finish);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const errorLine = (e) => `error: ${String(e?.message ?? e).split(/\r?\n/)[0].slice(0, 200)}`;
|
|
33
|
+
|
|
34
|
+
export async function runNotify(args, { input = '', env = process.env, write = (s) => process.stdout.write(s) } = {}) {
|
|
35
|
+
const [agent = '', sub = ''] = args;
|
|
36
|
+
const fallbackLog = (a, s, m) => log(stateDir(env), a, s, m);
|
|
37
|
+
try {
|
|
38
|
+
if (!agent || !sub) {
|
|
39
|
+
fallbackLog(agent || '?', '-', 'ignored: usage is notify <agent> <subcommand>');
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
const adapter = Object.hasOwn(ADAPTERS, agent) ? ADAPTERS[agent] : null;
|
|
43
|
+
if (!adapter) {
|
|
44
|
+
fallbackLog(agent, '-', `no adapter for ${agent}`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
const reply = adapter.hookReply(sub);
|
|
48
|
+
if (reply) write(reply);
|
|
49
|
+
let payload = {};
|
|
50
|
+
try { payload = JSON.parse(input); } catch { /* garbage in: the core logs the incomplete event */ }
|
|
51
|
+
const event = adapter.translate(sub, payload);
|
|
52
|
+
if (!event) return 0;
|
|
53
|
+
await handle(event, createContext(env));
|
|
54
|
+
} catch (e) {
|
|
55
|
+
fallbackLog(agent || '?', '-', errorLine(e));
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The notify process must exit 0 even for failures no try/catch can see: an
|
|
61
|
+
// agent that closes our stdout before we write (async EPIPE), or a stray
|
|
62
|
+
// error from a callback. They are logged, like every other failure.
|
|
63
|
+
export function guardNotifyProcess(args, env = process.env, proc = process) {
|
|
64
|
+
const agent = args[0] || '?';
|
|
65
|
+
const report = (e) => {
|
|
66
|
+
log(stateDir(env), agent, '-', errorLine(e));
|
|
67
|
+
proc.exitCode = 0;
|
|
68
|
+
};
|
|
69
|
+
proc.stdout.on('error', () => { /* the agent stopped listening; nothing to tell it */ });
|
|
70
|
+
proc.on('uncaughtException', report);
|
|
71
|
+
proc.on('unhandledRejection', report);
|
|
72
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { OUTPUT_TYPES } from '../outputs/index.js';
|
|
2
|
+
import {
|
|
3
|
+
loadConfig, readStoredConfig, saveConfig, readOutputInstance, writeOutputInstance,
|
|
4
|
+
removeOutputInstance, listOutputInstances, isValidOutputName, outputInstanceExists,
|
|
5
|
+
} from '../config.js';
|
|
6
|
+
import { TEST_SENTENCE } from '../core/phrases.js';
|
|
7
|
+
|
|
8
|
+
const USAGE = 'usage: agent-voice output add <alexa|local|command> [name] | list | remove <name> | enable <name> | disable <name> | test [name]';
|
|
9
|
+
|
|
10
|
+
const storedOutputs = (env) => {
|
|
11
|
+
const list = readStoredConfig(env).outputs;
|
|
12
|
+
return Array.isArray(list) ? list : [];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function enableOutput(name, env) {
|
|
16
|
+
if (!readOutputInstance(name, env)) throw new Error(`no output named "${name}"`);
|
|
17
|
+
const list = storedOutputs(env);
|
|
18
|
+
if (!list.includes(name)) saveConfig({ outputs: [...list, name] }, env);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function disableOutput(name, env) {
|
|
22
|
+
saveConfig({ outputs: storedOutputs(env).filter((n) => n !== name) }, env);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function nextFreeName(type, env) {
|
|
26
|
+
let name = type;
|
|
27
|
+
for (let i = 2; readOutputInstance(name, env); i++) name = `${type}-${i}`;
|
|
28
|
+
return name;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const typeModule = (type) => {
|
|
32
|
+
if (!Object.hasOwn(OUTPUT_TYPES, type ?? '')) {
|
|
33
|
+
throw new Error(`unknown output type "${type}" (available: ${Object.keys(OUTPUT_TYPES).join(', ')})`);
|
|
34
|
+
}
|
|
35
|
+
return OUTPUT_TYPES[type];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function addOutput(type, name, { env, out, prompt, deps }) {
|
|
39
|
+
const mod = typeModule(type);
|
|
40
|
+
const finalName = name || nextFreeName(type, env);
|
|
41
|
+
if (!isValidOutputName(finalName)) throw new Error(`invalid output name "${finalName}" (use a-z, 0-9 and -)`);
|
|
42
|
+
const current = readOutputInstance(finalName, env) ?? {};
|
|
43
|
+
if (current.type && current.type !== type) throw new Error(`output "${finalName}" already exists with type ${current.type}`);
|
|
44
|
+
const conf = await mod.questions(prompt, current, deps);
|
|
45
|
+
writeOutputInstance(finalName, conf, env);
|
|
46
|
+
enableOutput(finalName, env);
|
|
47
|
+
out(`Saved output "${finalName}" (${mod.describe(conf)}) and enabled it.`);
|
|
48
|
+
return finalName;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Reports instead of throwing, so `test` goes on to the next output.
|
|
52
|
+
export async function testOutput(name, { env, out, deps }) {
|
|
53
|
+
try {
|
|
54
|
+
const conf = readOutputInstance(name, env);
|
|
55
|
+
if (!conf) throw new Error(`no output named "${name}"`);
|
|
56
|
+
if (!Object.hasOwn(OUTPUT_TYPES, conf.type ?? '')) throw new Error(`unknown output type "${conf.type}"`);
|
|
57
|
+
await OUTPUT_TYPES[conf.type].speak(TEST_SENTENCE, conf, deps);
|
|
58
|
+
out(`ok ${name}`);
|
|
59
|
+
return true;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
out(`FAIL ${name}: ${e.message}`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function testActive(io) {
|
|
67
|
+
const names = loadConfig(io.env).outputs;
|
|
68
|
+
if (names.length === 0) throw new Error('no active outputs — add one with: agent-voice output add <alexa|local|command>');
|
|
69
|
+
let ok = true;
|
|
70
|
+
for (const name of names) ok = (await testOutput(name, io)) && ok;
|
|
71
|
+
return ok ? 0 : 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runOutput(args, io) {
|
|
75
|
+
const [sub, a, b] = args;
|
|
76
|
+
const { env, out } = io;
|
|
77
|
+
switch (sub) {
|
|
78
|
+
case 'add': {
|
|
79
|
+
const name = await addOutput(a, b, io);
|
|
80
|
+
return (await testOutput(name, io)) ? 0 : 1;
|
|
81
|
+
}
|
|
82
|
+
case 'list': {
|
|
83
|
+
const active = loadConfig(env).outputs;
|
|
84
|
+
const all = listOutputInstances(env);
|
|
85
|
+
if (all.length === 0) {
|
|
86
|
+
out('No outputs yet. Add one with: agent-voice output add <alexa|local|command>');
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
for (const { name, conf, error } of all) {
|
|
90
|
+
const mark = active.includes(name) ? '*' : ' ';
|
|
91
|
+
const what = conf ? `${String(conf.type).padEnd(8)} ${Object.hasOwn(OUTPUT_TYPES, conf.type) ? OUTPUT_TYPES[conf.type].describe(conf) : '(unknown type)'}` : `broken ${error}`;
|
|
92
|
+
out(`${mark} ${name.padEnd(16)} ${what}`);
|
|
93
|
+
}
|
|
94
|
+
out('(* = enabled)');
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
case 'remove':
|
|
98
|
+
if (!a) throw new Error(USAGE);
|
|
99
|
+
if (!isValidOutputName(a)) throw new Error(`invalid output name "${a}" (use a-z, 0-9 and -)`);
|
|
100
|
+
// An enabled name with no instance ("missing" in status) can be removed too.
|
|
101
|
+
if (!outputInstanceExists(a, env) && !storedOutputs(env).includes(a)) throw new Error(`no output named "${a}"`);
|
|
102
|
+
removeOutputInstance(a, env);
|
|
103
|
+
disableOutput(a, env);
|
|
104
|
+
out(`Removed output "${a}".`);
|
|
105
|
+
return 0;
|
|
106
|
+
case 'enable':
|
|
107
|
+
if (!a) throw new Error(USAGE);
|
|
108
|
+
enableOutput(a, env);
|
|
109
|
+
out(`Enabled "${a}".`);
|
|
110
|
+
return 0;
|
|
111
|
+
case 'disable':
|
|
112
|
+
if (!a) throw new Error(USAGE);
|
|
113
|
+
disableOutput(a, env);
|
|
114
|
+
out(`Disabled "${a}".`);
|
|
115
|
+
return 0;
|
|
116
|
+
case 'test':
|
|
117
|
+
return a ? ((await testOutput(a, io)) ? 0 : 1) : testActive(io);
|
|
118
|
+
default:
|
|
119
|
+
throw new Error(USAGE);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Interactive questions over readline. Commands receive this through io.prompt,
|
|
2
|
+
// so tests can swap in a scripted prompter.
|
|
3
|
+
//
|
|
4
|
+
// Lines are queued as they arrive rather than read per question: piped input
|
|
5
|
+
// (`printf 'y\n3\n' | agent-voice setup`) delivers every line at once, before
|
|
6
|
+
// most questions are asked. When input ends (EOF, Ctrl+D) or Ctrl+C is pressed,
|
|
7
|
+
// every question still waiting — and every later one — fails with a
|
|
8
|
+
// "cancelled" error, which main turns into one line and an exit code.
|
|
9
|
+
import { createInterface } from 'node:readline';
|
|
10
|
+
|
|
11
|
+
export const cancelledError = (signal) => Object.assign(new Error('cancelled'), { code: 'CANCELLED', signal });
|
|
12
|
+
export const isCancelled = (e) => e?.code === 'CANCELLED';
|
|
13
|
+
|
|
14
|
+
export function createPrompter({ input = process.stdin, output = process.stdout, terminal } = {}) {
|
|
15
|
+
const rl = createInterface({ input, output, terminal: terminal ?? Boolean(input.isTTY && output.isTTY) });
|
|
16
|
+
const lines = [];
|
|
17
|
+
const waiting = [];
|
|
18
|
+
let ended = null;
|
|
19
|
+
let closed = false;
|
|
20
|
+
const end = (error) => {
|
|
21
|
+
ended ??= error;
|
|
22
|
+
// A question is on screen with the cursor after it; the error goes below.
|
|
23
|
+
if (waiting.length) output.write('\n');
|
|
24
|
+
for (const w of waiting.splice(0)) w.reject(ended);
|
|
25
|
+
};
|
|
26
|
+
rl.on('line', (line) => {
|
|
27
|
+
const w = waiting.shift();
|
|
28
|
+
if (w) w.resolve(line);
|
|
29
|
+
else lines.push(line);
|
|
30
|
+
});
|
|
31
|
+
rl.on('SIGINT', () => {
|
|
32
|
+
end(cancelledError('SIGINT'));
|
|
33
|
+
rl.close();
|
|
34
|
+
});
|
|
35
|
+
rl.on('close', () => {
|
|
36
|
+
closed = true;
|
|
37
|
+
end(cancelledError());
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const question = (text) => {
|
|
41
|
+
// Through readline while it is open, so redrawing the line (backspace in
|
|
42
|
+
// a terminal) keeps the question on screen.
|
|
43
|
+
if (closed) output.write(text);
|
|
44
|
+
else {
|
|
45
|
+
rl.setPrompt(text);
|
|
46
|
+
rl.prompt();
|
|
47
|
+
}
|
|
48
|
+
if (lines.length) return Promise.resolve(lines.shift());
|
|
49
|
+
if (ended) return Promise.reject(ended);
|
|
50
|
+
return new Promise((resolve, reject) => waiting.push({ resolve, reject }));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async ask(q, def = '') {
|
|
55
|
+
const answer = (await question(def ? `${q} [${def}]: ` : `${q}: `)).trim();
|
|
56
|
+
return answer || def;
|
|
57
|
+
},
|
|
58
|
+
async confirm(q, def = true) {
|
|
59
|
+
const answer = (await question(`${q} ${def ? '[Y/n]' : '[y/N]'} `)).trim().toLowerCase();
|
|
60
|
+
return answer ? answer.startsWith('y') || answer.startsWith('s') : def;
|
|
61
|
+
},
|
|
62
|
+
async choose(q, options, def = 0) {
|
|
63
|
+
output.write(`${q}\n`);
|
|
64
|
+
options.forEach((o, i) => output.write(` ${i + 1}) ${o}\n`));
|
|
65
|
+
for (;;) {
|
|
66
|
+
const answer = (await question(`Choose 1-${options.length} [${def + 1}]: `)).trim();
|
|
67
|
+
if (!answer) return def;
|
|
68
|
+
const n = Number(answer);
|
|
69
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) return n - 1;
|
|
70
|
+
output.write('Not a valid choice.\n');
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
close() { rl.close(); },
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/cli/setup.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The front door: connect the agents found on this machine, add one speaker,
|
|
2
|
+
// prove it speaks. Safe to re-run — it only offers what is still missing.
|
|
3
|
+
import { ADAPTERS } from '../adapters/index.js';
|
|
4
|
+
import { fileStatus } from '../adapters/hooks-json.js';
|
|
5
|
+
import { OUTPUT_TYPES } from '../outputs/index.js';
|
|
6
|
+
import { loadConfig, readOutputInstance, removeOutputInstance } from '../config.js';
|
|
7
|
+
import { connectAgent } from './connect.js';
|
|
8
|
+
import { addOutput, testOutput, disableOutput } from './output.js';
|
|
9
|
+
import { isCancelled } from './prompt.js';
|
|
10
|
+
|
|
11
|
+
const LABELS = {
|
|
12
|
+
alexa: 'alexa an Echo, through Home Assistant',
|
|
13
|
+
local: "local this computer's own voice",
|
|
14
|
+
command: 'command any command that speaks a sentence',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function runSetup(args, io) {
|
|
18
|
+
const { env, out, prompt } = io;
|
|
19
|
+
out('agent-voice setup');
|
|
20
|
+
out('');
|
|
21
|
+
|
|
22
|
+
const found = Object.values(ADAPTERS).filter((a) => a.detect(env));
|
|
23
|
+
if (found.length === 0) {
|
|
24
|
+
out('No supported agent found (Claude Code, Codex). Any other CLI works through: agent-voice wrap -- <command>');
|
|
25
|
+
}
|
|
26
|
+
for (const adapter of found) {
|
|
27
|
+
if (fileStatus(adapter.configFile(env), adapter.name, adapter.hooks) === 'connected') {
|
|
28
|
+
out(`${adapter.name}: already connected.`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// One agent's broken config file must not keep the others from connecting.
|
|
32
|
+
try {
|
|
33
|
+
await connectAgent(adapter, { env, out, prompt, yes: false });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (isCancelled(e)) throw e;
|
|
36
|
+
out(`${adapter.name}: could not connect — ${e.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
out('');
|
|
40
|
+
|
|
41
|
+
const active = loadConfig(env).outputs.filter((n) => readOutputInstance(n, env));
|
|
42
|
+
let wantOutput = active.length === 0 || await prompt.confirm(`Active outputs: ${active.join(', ')}. Add another?`, false);
|
|
43
|
+
const types = Object.keys(OUTPUT_TYPES);
|
|
44
|
+
|
|
45
|
+
while (wantOutput) {
|
|
46
|
+
const i = await prompt.choose('How should agent-voice speak?', types.map((t) => LABELS[t] ?? t), 0);
|
|
47
|
+
let name;
|
|
48
|
+
try {
|
|
49
|
+
name = await addOutput(types[i], undefined, io);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (isCancelled(e)) throw e;
|
|
52
|
+
out(`Could not set up ${types[i]}: ${e.message}`);
|
|
53
|
+
if (!(await prompt.confirm('Try again?', true))) break;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out('Speaking a test sentence…');
|
|
57
|
+
const spoke = await testOutput(name, io);
|
|
58
|
+
if (spoke && await prompt.confirm('Did you hear it?', true)) {
|
|
59
|
+
wantOutput = false;
|
|
60
|
+
} else {
|
|
61
|
+
removeOutputInstance(name, env);
|
|
62
|
+
disableOutput(name, env);
|
|
63
|
+
out(`Removed "${name}".`);
|
|
64
|
+
if (!(await prompt.confirm('Try again?', true))) break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
out('');
|
|
69
|
+
out('Done. Check everything with: agent-voice status');
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ADAPTERS } from '../adapters/index.js';
|
|
4
|
+
import { fileStatus } from '../adapters/hooks-json.js';
|
|
5
|
+
import { loadConfig, listOutputInstances, NUMERIC_KEYS } from '../config.js';
|
|
6
|
+
import { OUTPUT_TYPES } from '../outputs/index.js';
|
|
7
|
+
|
|
8
|
+
export async function runStatus(args, { env, out }) {
|
|
9
|
+
const cfg = loadConfig(env);
|
|
10
|
+
|
|
11
|
+
out('Agents');
|
|
12
|
+
for (const a of Object.values(ADAPTERS)) {
|
|
13
|
+
const state = a.detect(env) ? fileStatus(a.configFile(env), a.name, a.hooks) : 'not installed';
|
|
14
|
+
const hint = state === 'legacy' ? ' (old bash hooks — run "agent-voice connect ' + a.name + '" to upgrade)' : '';
|
|
15
|
+
out(` ${a.name.padEnd(12)} ${state}${hint}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
out('Outputs (* = enabled)');
|
|
19
|
+
const instances = listOutputInstances(env);
|
|
20
|
+
const known = new Set(instances.map((i) => i.name));
|
|
21
|
+
for (const { name, conf, error } of instances) {
|
|
22
|
+
const mark = cfg.outputs.includes(name) ? '*' : ' ';
|
|
23
|
+
const what = conf && Object.hasOwn(OUTPUT_TYPES, conf.type)
|
|
24
|
+
? `${conf.type.padEnd(8)} ${OUTPUT_TYPES[conf.type].describe(conf)}`
|
|
25
|
+
: `broken ${error ?? `unknown type ${conf?.type}`}`;
|
|
26
|
+
out(` ${mark} ${name.padEnd(16)} ${what}`);
|
|
27
|
+
}
|
|
28
|
+
for (const name of cfg.outputs.filter((n) => !known.has(n))) {
|
|
29
|
+
out(` * ${name.padEnd(16)} (missing — enabled but not configured)`);
|
|
30
|
+
}
|
|
31
|
+
if (instances.length === 0 && cfg.outputs.length === 0) out(' none — add one with: agent-voice output add <alexa|local|command>');
|
|
32
|
+
|
|
33
|
+
out('Settings');
|
|
34
|
+
for (const k of Object.keys(NUMERIC_KEYS)) out(` ${k}=${cfg[k]}`);
|
|
35
|
+
|
|
36
|
+
const logFile = join(cfg.stateDir, 'events.log');
|
|
37
|
+
out(`Log (${logFile})`);
|
|
38
|
+
let lines = [];
|
|
39
|
+
try { lines = readFileSync(logFile, 'utf8').trimEnd().split('\n').filter(Boolean); } catch { /* no log yet */ }
|
|
40
|
+
if (lines.length === 0) out(' (empty)');
|
|
41
|
+
for (const line of lines.slice(-10)) out(` ${line}`);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
package/src/cli/wrap.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// For agents with no hooks: run the command, marking the turn's start and end.
|
|
2
|
+
// The core applies the usual rules, so a short command stays silent.
|
|
3
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
4
|
+
import { constants } from 'node:os';
|
|
5
|
+
import { createContext } from '../core/context.js';
|
|
6
|
+
import { handle } from '../core/handle.js';
|
|
7
|
+
import { projectOf } from '../adapters/common.js';
|
|
8
|
+
import { spawnCommand, spawnErrorMessage } from '../spawn-command.js';
|
|
9
|
+
|
|
10
|
+
const USAGE = 'usage: agent-voice wrap [--name <label>] -- <command> [args...]';
|
|
11
|
+
|
|
12
|
+
export function parseWrapArgs(args) {
|
|
13
|
+
let name = '';
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < args.length && args[i] !== '--') {
|
|
16
|
+
if (args[i] === '--name') {
|
|
17
|
+
name = args[i + 1] ?? '';
|
|
18
|
+
i += 2;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`wrap: unknown option ${args[i]} (${USAGE})`);
|
|
22
|
+
}
|
|
23
|
+
const command = args.slice(i + 1);
|
|
24
|
+
if (i >= args.length || command.length === 0) throw new Error(USAGE);
|
|
25
|
+
return { name, command };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runWrap(args, {
|
|
29
|
+
env = process.env, spawn = nodeSpawn, platform = process.platform, cwd = process.cwd(), pid = process.pid, notify,
|
|
30
|
+
err = (s) => process.stderr.write(`${s}\n`),
|
|
31
|
+
} = {}) {
|
|
32
|
+
const { name, command } = parseWrapArgs(args);
|
|
33
|
+
const emit = notify ?? (async (event) => {
|
|
34
|
+
try { await handle(event, createContext(env)); } catch { /* announcing must never break the wrapped command */ }
|
|
35
|
+
});
|
|
36
|
+
const base = { agent: 'wrap', session_id: `wrap-${pid}`, session_name: name, project: projectOf(cwd) };
|
|
37
|
+
|
|
38
|
+
await emit({ ...base, type: 'turn_start', text: command.join(' ') });
|
|
39
|
+
|
|
40
|
+
const code = await new Promise((resolve) => {
|
|
41
|
+
let child;
|
|
42
|
+
let settled = false;
|
|
43
|
+
// Ctrl+C already reaches the child through the terminal; the parent just
|
|
44
|
+
// must not die first. SIGTERM is forwarded.
|
|
45
|
+
const onInt = () => {};
|
|
46
|
+
const onTerm = () => { try { child?.kill('SIGTERM'); } catch { /* already gone */ } };
|
|
47
|
+
const finish = (c) => {
|
|
48
|
+
if (settled) return;
|
|
49
|
+
settled = true;
|
|
50
|
+
process.off('SIGINT', onInt);
|
|
51
|
+
process.off('SIGTERM', onTerm);
|
|
52
|
+
resolve(c);
|
|
53
|
+
};
|
|
54
|
+
process.on('SIGINT', onInt);
|
|
55
|
+
process.on('SIGTERM', onTerm);
|
|
56
|
+
try {
|
|
57
|
+
// No shell on any OS: `git commit -m "fix a & b"` must commit, not run b.
|
|
58
|
+
child = spawnCommand(command[0], command.slice(1), { stdio: 'inherit', env }, { spawn, platform });
|
|
59
|
+
} catch (e) {
|
|
60
|
+
err(`agent-voice wrap: ${spawnErrorMessage(e, command[0], env, platform)}`);
|
|
61
|
+
finish(127);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
child.on('error', (e) => {
|
|
65
|
+
err(`agent-voice wrap: ${spawnErrorMessage(e, command[0], env, platform)}`);
|
|
66
|
+
finish(127);
|
|
67
|
+
});
|
|
68
|
+
child.on('exit', (c, signal) => finish(c ?? 128 + (constants.signals[signal] ?? 0)));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await emit({ ...base, type: 'task_done', text: '' });
|
|
72
|
+
return code;
|
|
73
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Precedence: built-in defaults < config.json < AV_* environment variables.
|
|
2
|
+
// The environment wins so tests (and one-off runs) never touch real config.
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, chmodSync, existsSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { configDir, stateDir } from './platform.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULTS = Object.freeze({ minSeconds: 30, cooldownSeconds: 120, maxSpeechChars: 90, outputs: [] });
|
|
8
|
+
|
|
9
|
+
export const NUMERIC_KEYS = Object.freeze({
|
|
10
|
+
minSeconds: 'AV_MIN_SECONDS',
|
|
11
|
+
cooldownSeconds: 'AV_COOLDOWN_SECONDS',
|
|
12
|
+
maxSpeechChars: 'AV_MAX_SPEECH_CHARS',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
16
|
+
export const isValidOutputName = (name) => typeof name === 'string' && NAME_RE.test(name);
|
|
17
|
+
|
|
18
|
+
function readJson(file) {
|
|
19
|
+
let text;
|
|
20
|
+
try {
|
|
21
|
+
text = readFileSync(file, 'utf8');
|
|
22
|
+
} catch (e) {
|
|
23
|
+
if (e.code === 'ENOENT') return null;
|
|
24
|
+
throw e;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
28
|
+
} catch (e) {
|
|
29
|
+
throw new Error(`${file}: invalid JSON (${e.message})`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeJson(file, value, mode) {
|
|
34
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
35
|
+
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, mode ? { mode } : undefined);
|
|
36
|
+
if (mode) {
|
|
37
|
+
try { chmodSync(file, mode); } catch { /* not supported on this OS */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const configFile = (env) => join(configDir(env), 'config.json');
|
|
42
|
+
const instanceFile = (name, env) => join(configDir(env), 'outputs', `${name}.json`);
|
|
43
|
+
|
|
44
|
+
export function readStoredConfig(env = process.env) {
|
|
45
|
+
const stored = readJson(configFile(env)) ?? {};
|
|
46
|
+
if (typeof stored !== 'object' || Array.isArray(stored)) {
|
|
47
|
+
throw new Error(`${configFile(env)}: expected a JSON object`);
|
|
48
|
+
}
|
|
49
|
+
return stored;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function loadConfig(env = process.env) {
|
|
53
|
+
const cfg = { ...DEFAULTS, ...readStoredConfig(env) };
|
|
54
|
+
for (const [key, envName] of Object.entries(NUMERIC_KEYS)) {
|
|
55
|
+
// A hand-edited "30s" or 1.5 must not reach the core as a threshold.
|
|
56
|
+
if (!Number.isSafeInteger(cfg[key]) || cfg[key] < 0) cfg[key] = DEFAULTS[key];
|
|
57
|
+
if (/^[0-9]+$/.test(env[envName] ?? '')) cfg[key] = Number(env[envName]);
|
|
58
|
+
}
|
|
59
|
+
if (env.AV_OUTPUTS !== undefined) cfg.outputs = env.AV_OUTPUTS.split(/\s+/).filter(Boolean);
|
|
60
|
+
cfg.outputs = Array.isArray(cfg.outputs) ? cfg.outputs.filter((n) => typeof n === 'string') : [];
|
|
61
|
+
return { ...cfg, configDir: configDir(env), stateDir: stateDir(env) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function saveConfig(patch, env = process.env) {
|
|
65
|
+
const next = { ...readStoredConfig(env), ...patch };
|
|
66
|
+
writeJson(configFile(env), next);
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readOutputInstance(name, env = process.env) {
|
|
71
|
+
if (!isValidOutputName(name)) return null;
|
|
72
|
+
return readJson(instanceFile(name, env));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function writeOutputInstance(name, conf, env = process.env) {
|
|
76
|
+
if (!isValidOutputName(name)) throw new Error(`invalid output name "${name}" (use a-z, 0-9 and -)`);
|
|
77
|
+
writeJson(instanceFile(name, env), conf, 0o600);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// True even for a file too broken to read, so it can still be removed.
|
|
81
|
+
export const outputInstanceExists = (name, env = process.env) => isValidOutputName(name) && existsSync(instanceFile(name, env));
|
|
82
|
+
|
|
83
|
+
export function removeOutputInstance(name, env = process.env) {
|
|
84
|
+
if (isValidOutputName(name)) rmSync(instanceFile(name, env), { force: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function listOutputInstances(env = process.env) {
|
|
88
|
+
let files;
|
|
89
|
+
try { files = readdirSync(join(configDir(env), 'outputs')); } catch { return []; }
|
|
90
|
+
return files
|
|
91
|
+
.filter((f) => f.endsWith('.json'))
|
|
92
|
+
.map((f) => f.slice(0, -5))
|
|
93
|
+
.filter(isValidOutputName)
|
|
94
|
+
.sort()
|
|
95
|
+
.map((name) => {
|
|
96
|
+
try { return { name, conf: readOutputInstance(name, env) }; } catch (e) { return { name, conf: null, error: e.message }; }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js';
|
|
2
|
+
import { createState } from './state.js';
|
|
3
|
+
import { log } from './log.js';
|
|
4
|
+
import { resolveOutputs } from '../outputs/index.js';
|
|
5
|
+
|
|
6
|
+
export function createContext(env = process.env) {
|
|
7
|
+
const config = loadConfig(env);
|
|
8
|
+
return {
|
|
9
|
+
config,
|
|
10
|
+
state: createState(config.stateDir),
|
|
11
|
+
log: (agent, session, message) => log(config.stateDir, agent, session, message),
|
|
12
|
+
outputs: resolveOutputs(config.outputs, env),
|
|
13
|
+
};
|
|
14
|
+
}
|