@lucas_zaia/agent-voice 1.0.0 → 1.1.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/README.md +25 -1
- package/package.json +1 -1
- package/src/cli/config.js +53 -7
- package/src/cli/main.js +3 -2
- package/src/config.js +12 -4
- package/src/core/handle.js +3 -3
- package/src/core/phrases.js +53 -10
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ agent-voice output add <alexa|local|command> [name]
|
|
|
75
75
|
agent-voice output list | remove | enable | disable | test [name]
|
|
76
76
|
agent-voice wrap [--name <label>] -- <command...>
|
|
77
77
|
agent-voice status
|
|
78
|
-
agent-voice config get [key] | set <key> <value>
|
|
78
|
+
agent-voice config get [key] | set <key> <value> | reset <key>
|
|
79
79
|
agent-voice test
|
|
80
80
|
```
|
|
81
81
|
|
|
@@ -122,6 +122,30 @@ agent-voice config set cooldownSeconds 300
|
|
|
122
122
|
| `cooldownSeconds` | 120 | `AV_COOLDOWN_SECONDS` |
|
|
123
123
|
| `maxSpeechChars` | 90 | `AV_MAX_SPEECH_CHARS` |
|
|
124
124
|
|
|
125
|
+
## Changing what it says
|
|
126
|
+
|
|
127
|
+
Each sentence is a template you can reword. `{name}` is a variable; a
|
|
128
|
+
`[section]` is spoken only when every variable inside it has a value, so an
|
|
129
|
+
empty request drops its whole clause.
|
|
130
|
+
|
|
131
|
+
| Key | Variables | Default |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `phrases.taskDone` | `{agent}` `{where}` `{duration}` `{request}` | `{agent} terminou {where}, depois de {duration}.[ Você tinha pedido: {request}.]` |
|
|
134
|
+
| `phrases.backgroundDone` | `{agent}` `{where}` `{text}` | `{agent} terminou um trabalho em segundo plano {where}.[ Era: {text}.]` |
|
|
135
|
+
| `phrases.needsInput` | `{agent}` `{where}` `{notice}` | `{agent} precisa de você {where}{notice}.` |
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
agent-voice config set phrases.taskDone "{agent} acabou {where}, levou {duration}.[ Pedido: {request}.]"
|
|
139
|
+
agent-voice config get phrases.taskDone
|
|
140
|
+
agent-voice config reset phrases.taskDone # back to the default
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`{where}` is "na sessão <name>" (or a colour plus the project when there is no
|
|
144
|
+
name), `{duration}` is "cerca de 4 minutos", and `{notice}` is the agent's
|
|
145
|
+
notice translated — ", para usar o Bash" — or empty. A template with an
|
|
146
|
+
unknown variable or unbalanced brackets is refused, so the speaker never reads
|
|
147
|
+
out broken text.
|
|
148
|
+
|
|
125
149
|
## Where things live
|
|
126
150
|
|
|
127
151
|
| | Config | State and `events.log` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lucas_zaia/agent-voice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Make your coding agent talk to you: a smart speaker says when a long task finishes or when the agent needs you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "agent-voice": "bin/agent-voice.js" },
|
package/src/cli/config.js
CHANGED
|
@@ -1,24 +1,56 @@
|
|
|
1
|
-
import { loadConfig, saveConfig, NUMERIC_KEYS } from '../config.js';
|
|
1
|
+
import { loadConfig, readStoredConfig, saveConfig, writeStoredConfig, NUMERIC_KEYS, DEFAULTS } from '../config.js';
|
|
2
|
+
import { DEFAULT_PHRASES, validateTemplate } from '../core/phrases.js';
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
+
const PHRASE_KEYS = Object.keys(DEFAULT_PHRASES).map((n) => `phrases.${n}`);
|
|
5
|
+
const KEYS = [...Object.keys(NUMERIC_KEYS), ...PHRASE_KEYS];
|
|
6
|
+
const USAGE = `usage: agent-voice config get [key] | set <key> <value> | reset <key> (keys: ${KEYS.join(', ')})`;
|
|
7
|
+
|
|
8
|
+
const phraseName = (key) => (typeof key === 'string' && key.startsWith('phrases.') ? key.slice('phrases.'.length) : null);
|
|
9
|
+
|
|
10
|
+
function checkKey(key) {
|
|
11
|
+
const name = phraseName(key);
|
|
12
|
+
if (name !== null) {
|
|
13
|
+
if (!Object.hasOwn(DEFAULT_PHRASES, name)) throw new Error(validateTemplate(name, ''));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!Object.hasOwn(NUMERIC_KEYS, key ?? '')) {
|
|
17
|
+
throw new Error(`unknown key "${key}" (settable: ${KEYS.join(', ')}; outputs are managed with "agent-voice output enable|disable")`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const storedPhrases = (env) => {
|
|
22
|
+
const p = readStoredConfig(env).phrases;
|
|
23
|
+
return p && typeof p === 'object' && !Array.isArray(p) ? p : {};
|
|
24
|
+
};
|
|
4
25
|
|
|
5
26
|
export function runConfig(args, { env, out }) {
|
|
6
27
|
const [sub, key, value] = args;
|
|
7
28
|
if (sub === 'get') {
|
|
8
29
|
const cfg = loadConfig(env);
|
|
30
|
+
const phrase = (name) => cfg.phrases[name] ?? DEFAULT_PHRASES[name];
|
|
9
31
|
if (!key) {
|
|
10
32
|
for (const k of Object.keys(NUMERIC_KEYS)) out(`${k}=${cfg[k]}`);
|
|
11
33
|
out(`outputs=${cfg.outputs.join(' ')}`);
|
|
34
|
+
for (const name of Object.keys(DEFAULT_PHRASES)) out(`phrases.${name}=${phrase(name)}`);
|
|
12
35
|
return 0;
|
|
13
36
|
}
|
|
14
|
-
if (key === 'outputs')
|
|
15
|
-
|
|
16
|
-
|
|
37
|
+
if (key === 'outputs') {
|
|
38
|
+
out(cfg.outputs.join(' '));
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
checkKey(key);
|
|
42
|
+
out(String(phraseName(key) !== null ? phrase(phraseName(key)) : cfg[key]));
|
|
17
43
|
return 0;
|
|
18
44
|
}
|
|
19
45
|
if (sub === 'set') {
|
|
20
|
-
|
|
21
|
-
|
|
46
|
+
checkKey(key);
|
|
47
|
+
const name = phraseName(key);
|
|
48
|
+
if (name !== null) {
|
|
49
|
+
const problem = validateTemplate(name, value);
|
|
50
|
+
if (problem) throw new Error(problem);
|
|
51
|
+
saveConfig({ phrases: { ...storedPhrases(env), [name]: value } }, env);
|
|
52
|
+
out(`${key}=${value}`);
|
|
53
|
+
return 0;
|
|
22
54
|
}
|
|
23
55
|
if (!/^[0-9]+$/.test(value ?? '')) {
|
|
24
56
|
throw new Error(`${key} must be a whole number of ${key === 'maxSpeechChars' ? 'characters' : 'seconds'}`);
|
|
@@ -29,5 +61,19 @@ export function runConfig(args, { env, out }) {
|
|
|
29
61
|
if (env[envName] !== undefined) out(`Note: ${envName}=${env[envName]} overrides it in this environment.`);
|
|
30
62
|
return 0;
|
|
31
63
|
}
|
|
64
|
+
if (sub === 'reset') {
|
|
65
|
+
checkKey(key);
|
|
66
|
+
const name = phraseName(key);
|
|
67
|
+
if (name !== null) {
|
|
68
|
+
const { [name]: _dropped, ...rest } = storedPhrases(env);
|
|
69
|
+
saveConfig({ phrases: rest }, env);
|
|
70
|
+
out(`${key}=${DEFAULT_PHRASES[name]}`);
|
|
71
|
+
} else {
|
|
72
|
+
const { [key]: _dropped, ...rest } = readStoredConfig(env);
|
|
73
|
+
writeStoredConfig(rest, env);
|
|
74
|
+
out(`${key}=${DEFAULTS[key]}`);
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
32
78
|
throw new Error(USAGE);
|
|
33
79
|
}
|
package/src/cli/main.js
CHANGED
|
@@ -17,8 +17,9 @@ const HELP = `Usage: agent-voice <command>
|
|
|
17
17
|
wrap [--name <label>] -- <command> [args...]
|
|
18
18
|
announce when any command finishes
|
|
19
19
|
status agents, outputs, settings and the latest log lines
|
|
20
|
-
config get [key] | set <key> <value>
|
|
21
|
-
keys: minSeconds, cooldownSeconds, maxSpeechChars
|
|
20
|
+
config get [key] | set <key> <value> | reset <key>
|
|
21
|
+
keys: minSeconds, cooldownSeconds, maxSpeechChars,
|
|
22
|
+
phrases.taskDone, phrases.backgroundDone, phrases.needsInput
|
|
22
23
|
test speak a test sentence on every active output
|
|
23
24
|
notify <agent> <subcommand> hook entry point (called by your agent, not by you)`;
|
|
24
25
|
|
package/src/config.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, chmodSync, existsSync } from 'node:fs';
|
|
4
4
|
import { join, dirname } from 'node:path';
|
|
5
5
|
import { configDir, stateDir } from './platform.js';
|
|
6
|
+
import { validateTemplate } from './core/phrases.js';
|
|
6
7
|
|
|
7
|
-
export const DEFAULTS = Object.freeze({ minSeconds: 30, cooldownSeconds: 120, maxSpeechChars: 90, outputs: [] });
|
|
8
|
+
export const DEFAULTS = Object.freeze({ minSeconds: 30, cooldownSeconds: 120, maxSpeechChars: 90, outputs: [], phrases: {} });
|
|
8
9
|
|
|
9
10
|
export const NUMERIC_KEYS = Object.freeze({
|
|
10
11
|
minSeconds: 'AV_MIN_SECONDS',
|
|
@@ -58,13 +59,20 @@ export function loadConfig(env = process.env) {
|
|
|
58
59
|
}
|
|
59
60
|
if (env.AV_OUTPUTS !== undefined) cfg.outputs = env.AV_OUTPUTS.split(/\s+/).filter(Boolean);
|
|
60
61
|
cfg.outputs = Array.isArray(cfg.outputs) ? cfg.outputs.filter((n) => typeof n === 'string') : [];
|
|
62
|
+
// A hand-edited template that would speak broken text is dropped; the default speaks instead.
|
|
63
|
+
const phrases = cfg.phrases && typeof cfg.phrases === 'object' && !Array.isArray(cfg.phrases) ? cfg.phrases : {};
|
|
64
|
+
cfg.phrases = Object.fromEntries(Object.entries(phrases).filter(([name, tpl]) => validateTemplate(name, tpl) === null));
|
|
61
65
|
return { ...cfg, configDir: configDir(env), stateDir: stateDir(env) };
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
export function saveConfig(patch, env = process.env) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
return writeStoredConfig({ ...readStoredConfig(env), ...patch }, env);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Replaces config.json wholesale — the way to remove a key.
|
|
73
|
+
export function writeStoredConfig(value, env = process.env) {
|
|
74
|
+
writeJson(configFile(env), value);
|
|
75
|
+
return value;
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
export function readOutputInstance(name, env = process.env) {
|
package/src/core/handle.js
CHANGED
|
@@ -64,7 +64,7 @@ export async function handle(event, ctx) {
|
|
|
64
64
|
ctx.log(agent, session, `silent (turn ${elapsed}s < ${ctx.config.minSeconds}s)`);
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
|
-
sentence = phraseTaskDone(who, place, durationPhrase(elapsed), clean(asked));
|
|
67
|
+
sentence = phraseTaskDone(who, place, durationPhrase(elapsed), clean(asked), ctx.config.phrases);
|
|
68
68
|
break;
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -78,11 +78,11 @@ export async function handle(event, ctx) {
|
|
|
78
78
|
ctx.log(agent, session, 'silent (background_done with no content)');
|
|
79
79
|
return;
|
|
80
80
|
}
|
|
81
|
-
sentence = phraseBackgroundDone(who, place, clean(text));
|
|
81
|
+
sentence = phraseBackgroundDone(who, place, clean(text), ctx.config.phrases);
|
|
82
82
|
break;
|
|
83
83
|
|
|
84
84
|
case 'needs_input':
|
|
85
|
-
sentence = phraseNeedsInput(who, place, clean(text));
|
|
85
|
+
sentence = phraseNeedsInput(who, place, clean(text), ctx.config.phrases);
|
|
86
86
|
break;
|
|
87
87
|
|
|
88
88
|
default:
|
package/src/core/phrases.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Everything spoken lives here, and it is the only file in Portuguese. Adding a
|
|
2
|
-
// language
|
|
2
|
+
// language touches this file and nothing else; users reword the sentences
|
|
3
|
+
// through config.json "phrases" (see DEFAULT_PHRASES).
|
|
3
4
|
import { cksum } from './hash.js';
|
|
4
5
|
import { stripTrailingPunct } from './speech.js';
|
|
5
6
|
|
|
@@ -26,14 +27,57 @@ export function durationPhrase(seconds) {
|
|
|
26
27
|
return minutes <= 1 ? 'cerca de um minuto' : `cerca de ${minutes} minutos`;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// The spoken sentences, as templates the user can override (config.json
|
|
31
|
+
// "phrases"). {name} is a variable; a [section] is spoken only when every
|
|
32
|
+
// variable inside it has a value, so "[ Era: {text}.]" vanishes with no text.
|
|
33
|
+
export const DEFAULT_PHRASES = Object.freeze({
|
|
34
|
+
taskDone: '{agent} terminou {where}, depois de {duration}.[ Você tinha pedido: {request}.]',
|
|
35
|
+
backgroundDone: '{agent} terminou um trabalho em segundo plano {where}.[ Era: {text}.]',
|
|
36
|
+
needsInput: '{agent} precisa de você {where}{notice}.',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const PHRASE_VARS = Object.freeze({
|
|
40
|
+
taskDone: ['agent', 'where', 'duration', 'request'],
|
|
41
|
+
backgroundDone: ['agent', 'where', 'text'],
|
|
42
|
+
needsInput: ['agent', 'where', 'notice'],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const MAX_TEMPLATE_CHARS = 300;
|
|
46
|
+
const VAR_RE = /\{([^{}]*)\}/g;
|
|
47
|
+
|
|
48
|
+
// null when the template is usable, otherwise the reason it is not.
|
|
49
|
+
export function validateTemplate(name, template) {
|
|
50
|
+
if (!Object.hasOwn(PHRASE_VARS, name)) return `unknown phrase "${name}" (phrases: ${Object.keys(PHRASE_VARS).join(', ')})`;
|
|
51
|
+
if (typeof template !== 'string' || !template.trim()) return `phrases.${name} is empty`;
|
|
52
|
+
if (template.length > MAX_TEMPLATE_CHARS) return `phrases.${name} is too long (max ${MAX_TEMPLATE_CHARS} characters)`;
|
|
53
|
+
let depth = 0;
|
|
54
|
+
for (const ch of template) {
|
|
55
|
+
if (ch === '[') depth += 1;
|
|
56
|
+
if (ch === ']') depth -= 1;
|
|
57
|
+
if (depth < 0 || depth > 1) return `phrases.${name}: brackets must be balanced and not nested`;
|
|
58
|
+
}
|
|
59
|
+
if (depth !== 0) return `phrases.${name}: brackets must be balanced and not nested`;
|
|
60
|
+
for (const [, v] of template.matchAll(VAR_RE)) {
|
|
61
|
+
if (!PHRASE_VARS[name].includes(v)) return `phrases.${name}: unknown variable {${v}} (use: ${PHRASE_VARS[name].join(', ')})`;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function render(name, templates, vars) {
|
|
67
|
+
const custom = templates?.[name];
|
|
68
|
+
const template = custom !== undefined && validateTemplate(name, custom) === null ? custom : DEFAULT_PHRASES[name];
|
|
69
|
+
const fill = (part) => part.replace(VAR_RE, (_, v) => vars[v] ?? '');
|
|
70
|
+
return template
|
|
71
|
+
.replace(/\[([^\]]*)\]/g, (_, inner) => ([...inner.matchAll(VAR_RE)].every(([, v]) => vars[v]) ? fill(inner) : ''))
|
|
72
|
+
.replace(VAR_RE, (_, v) => vars[v] ?? '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function phraseTaskDone(agent, place, duration, text, templates) {
|
|
76
|
+
return render('taskDone', templates, { agent, where: place, duration, request: text ? stripTrailingPunct(text) : '' });
|
|
32
77
|
}
|
|
33
78
|
|
|
34
|
-
export function phraseBackgroundDone(agent, place, text) {
|
|
35
|
-
|
|
36
|
-
return text ? `${s} Era: ${stripTrailingPunct(text)}.` : s;
|
|
79
|
+
export function phraseBackgroundDone(agent, place, text, templates) {
|
|
80
|
+
return render('backgroundDone', templates, { agent, where: place, text: text ? stripTrailingPunct(text) : '' });
|
|
37
81
|
}
|
|
38
82
|
|
|
39
83
|
// Agent notices arrive in English. Speaking one verbatim after a Portuguese
|
|
@@ -48,7 +92,6 @@ export function translateNotice(text) {
|
|
|
48
92
|
return `. ${text}`;
|
|
49
93
|
}
|
|
50
94
|
|
|
51
|
-
export function phraseNeedsInput(agent, place, text) {
|
|
52
|
-
|
|
53
|
-
return text ? `${s}${translateNotice(stripTrailingPunct(text))}.` : `${s}.`;
|
|
95
|
+
export function phraseNeedsInput(agent, place, text, templates) {
|
|
96
|
+
return render('needsInput', templates, { agent, where: place, notice: text ? translateNotice(stripTrailingPunct(text)) : '' });
|
|
54
97
|
}
|