@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 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.0.0",
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 USAGE = `usage: agent-voice config get [key] | set <key> <value> (keys: ${Object.keys(NUMERIC_KEYS).join(', ')})`;
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') out(cfg.outputs.join(' '));
15
- else if (Object.hasOwn(NUMERIC_KEYS, key)) out(String(cfg[key]));
16
- else throw new Error(`unknown key "${key}". ${USAGE}`);
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
- if (!Object.hasOwn(NUMERIC_KEYS, key ?? '')) {
21
- throw new Error(`unknown key "${key}" (settable: ${Object.keys(NUMERIC_KEYS).join(', ')}; outputs are managed with "agent-voice output enable|disable")`);
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
- const next = { ...readStoredConfig(env), ...patch };
66
- writeJson(configFile(env), next);
67
- return next;
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) {
@@ -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:
@@ -1,5 +1,6 @@
1
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.
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
- 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;
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
- const s = `${agent} terminou um trabalho em segundo plano ${place}.`;
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
- const s = `${agent} precisa de você ${place}`;
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
  }