@lucas_zaia/agent-voice 1.0.0 → 1.1.1

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
@@ -1,128 +1,367 @@
1
1
  # agent-voice
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@lucas_zaia/agent-voice)](https://www.npmjs.com/package/@lucas_zaia/agent-voice)
4
+ [![test](https://github.com/LucasZaia/agent-voice/actions/workflows/test.yml/badge.svg)](https://github.com/LucasZaia/agent-voice/actions/workflows/test.yml)
5
+ ![node](https://img.shields.io/node/v/@lucas_zaia/agent-voice)
6
+ ![license](https://img.shields.io/npm/l/@lucas_zaia/agent-voice)
7
+
3
8
  **Make your coding agent talk to you.** When Claude Code or Codex finishes a
4
- long task — or gets stuck waiting for your permission — a speaker says so out
5
- loud:
9
+ long task, or stops to wait for your permission, a speaker says so out loud:
6
10
 
7
- > *"Claude Code terminou na sessão Home assistant repo, depois de cerca de 4
11
+ > 🔊 *"Claude Code terminou na sessão Home assistant repo, depois de cerca de 4
8
12
  > minutos. Você tinha pedido: criar o docker compose."*
13
+ >
14
+ > 🔊 *"Codex precisa de você na sessão azul, do projeto api, para usar o Bash."*
15
+
16
+ It works on macOS, Windows and Linux. It can speak through an Amazon Echo (via
17
+ Home Assistant), your computer's own voice, or any command you choose. It has
18
+ no dependencies and needs only Node.js.
19
+
20
+ ---
21
+
22
+ ## Contents
9
23
 
10
- > *"Codex precisa de você na sessão azul, do projeto api, para usar o Bash."*
24
+ - [Quick start](#quick-start)
25
+ - [Why](#why)
26
+ - [When it speaks](#when-it-speaks)
27
+ - [Speakers](#speakers): [Echo / Alexa](#echo--alexa-via-home-assistant) · [Computer voice](#your-computers-voice) · [Any command](#any-command)
28
+ - [Agents](#agents)
29
+ - [Tuning](#tuning)
30
+ - [Changing what it says](#changing-what-it-says)
31
+ - [Commands](#commands)
32
+ - [Windows notes](#windows-notes)
33
+ - [Troubleshooting](#troubleshooting)
34
+ - [How it works](#how-it-works)
35
+ - [Development](#development)
11
36
 
12
- Works on macOS, Windows and Linux. Speaks through an Echo (via Home Assistant),
13
- your computer's own voice, or any command you like.
37
+ ---
14
38
 
15
- ## Install
39
+ ## Quick start
16
40
 
17
- Requires Node.js 20 or newer.
41
+ You need Node.js 20 or newer.
18
42
 
19
43
  ```bash
20
44
  npm install -g @lucas_zaia/agent-voice
21
45
  agent-voice setup
22
46
  ```
23
47
 
24
- `setup` finds your agents, adds its hooks to them (showing you exactly what it
25
- adds, and backing up the file first), asks how you want it to speak, and says a
26
- test sentence so you know it works.
48
+ `setup` takes about a minute:
27
49
 
28
- **Windows PowerShell** blocks npm's `.ps1` launchers by default ("execução de
29
- scripts foi desabilitada" / "running scripts is disabled"). Either allow them
30
- for your user once — `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` —
31
- or call `npm.cmd` and `agent-voice.cmd` instead. The agent hooks are not
32
- affected: they run `agent-voice.cmd`.
50
+ 1. **Finds your agents** (Claude Code, Codex) and shows the exact hooks it will
51
+ add. The config file is backed up before anything is written.
52
+ 2. **Asks how to speak**: Echo, your computer's voice, or a command.
53
+ 3. **Speaks a test sentence** and asks whether you heard it. If you didn't, it
54
+ removes that speaker and lets you try another.
33
55
 
34
- Running from a checkout instead? Either `npm link` in the checkout (so
35
- `agent-voice` is on your PATH), or tell the hooks how to call it before
36
- connecting:
56
+ To check everything afterwards:
37
57
 
38
58
  ```bash
39
- AV_HOOK_COMMAND="node /path/to/agent-voice/bin/agent-voice.js" agent-voice connect claude-code
59
+ agent-voice status
60
+ ```
61
+
62
+ ```
63
+ Agents
64
+ claude-code connected
65
+ codex not installed
66
+ Outputs (* = enabled)
67
+ * echo alexa notify.echo_dot_announce @ http://localhost:8123
68
+ Settings
69
+ minSeconds=30
70
+ cooldownSeconds=120
71
+ maxSpeechChars=90
72
+ Log (~/.local/state/agent-voice/events.log)
73
+ 2026-09-25 16:05:23 claude-code 04c02385 spoke via echo: Claude Code terminou na sessão…
74
+ 2026-09-25 16:07:41 claude-code 04c02385 silent (turn 12s < 30s)
40
75
  ```
41
76
 
42
- `AV_HOOK_COMMAND` is the command written into the agent's hooks (default
43
- `agent-voice`); it is read when you run `setup` or `connect`.
77
+ > On Windows, see [Windows notes](#windows-notes) if PowerShell refuses to run `npm`.
78
+
79
+ ---
44
80
 
45
81
  ## Why
46
82
 
47
- You ask an agent for something that takes a while and stop watching. Then
48
- either the work finishes and sits there, or — worse — it stopped thirty seconds
49
- in at a permission prompt. A popup appears in the window you stopped looking
50
- at. A voice does not. agent-voice follows two rules:
83
+ You give an agent something that takes a while and stop watching it. Then one
84
+ of two things happens: the work finishes and waits for you to notice, or, worse,
85
+ the agent stopped at a permission prompt thirty seconds in. A popup shows up in
86
+ the window you are no longer looking at. A voice reaches you anyway.
87
+
88
+ agent-voice follows two rules:
51
89
 
52
- - **Say something specific**: which session, and what you had asked for.
53
- - **Stay quiet most of the time**: short turns are never announced.
90
+ - **Say something specific**: which session it is, how long it took, and what
91
+ you had asked for.
92
+ - **Stay quiet most of the time**: short turns are never announced. A notifier
93
+ that talks after every reply gets muted on day one.
94
+
95
+ ---
54
96
 
55
97
  ## When it speaks
56
98
 
57
99
  | Situation | What happens |
58
100
  |---|---|
59
- | A turn finishes in under 30s | Silent |
60
- | A turn finishes in over 30s | **Speaks**, with how long it took and what you asked |
101
+ | A turn finishes in **under 30s** | Silent |
102
+ | A turn finishes in **over 30s** | **Speaks**, with how long it took and what you asked |
61
103
  | Background work finishes (a subagent, a task) | **Speaks**, at most once every 120s per session |
62
104
  | Background work with nothing to report | Silent |
63
- | The agent needs your permission or input | **Always speaks** |
64
-
65
- Every decision, including every silence, goes to the log with its reason —
66
- `agent-voice status` shows the latest lines.
105
+ | The agent **needs your permission or input** | **Always speaks**, even inside the cooldown |
67
106
 
68
- ## Commands
107
+ Every decision, including every silence, is written to the log with its reason:
69
108
 
70
109
  ```
71
- agent-voice setup guided setup
72
- agent-voice connect <claude-code|codex> add hooks to an agent (--yes to skip the question)
73
- agent-voice disconnect <claude-code|codex>
74
- agent-voice output add <alexa|local|command> [name]
75
- agent-voice output list | remove | enable | disable | test [name]
76
- agent-voice wrap [--name <label>] -- <command...>
77
- agent-voice status
78
- agent-voice config get [key] | set <key> <value>
79
- agent-voice test
110
+ 16:05:23 claude-code notif1 spoke via echo: Claude Code precisa de você na sessão verde.
111
+ 15:57:38 claude-code a891ee silent (turn 21s < 30s)
112
+ 15:46:12 claude-code 04c023 silent (background_done with no content)
80
113
  ```
81
114
 
115
+ When a session has no name, it gets a color ("na sessão azul, do projeto api").
116
+ Colors are easy to tell apart by ear, which random ids are not.
117
+
118
+ ---
119
+
82
120
  ## Speakers
83
121
 
84
- | Type | How | Needs |
122
+ A speaker (an *output*) is added with `agent-voice output add <type> [name]`.
123
+ You can have several; every enabled one speaks, and each gets at most 15
124
+ seconds.
125
+
126
+ ```bash
127
+ agent-voice output list # * marks the enabled ones
128
+ agent-voice output test [name] # speak the test sentence
129
+ agent-voice output disable <name> # keep it, but stop using it
130
+ agent-voice output remove <name>
131
+ ```
132
+
133
+ ### Echo / Alexa (via Home Assistant)
134
+
135
+ You need:
136
+
137
+ 1. **Home Assistant**, reachable from this machine (e.g. `http://localhost:8123`).
138
+ 2. The **[Alexa Media Player](https://github.com/alandtse/alexa_media_player)**
139
+ integration, which creates `notify.<device>_speak` and
140
+ `notify.<device>_announce` entities for each Echo.
141
+ 3. A **long-lived access token**: Home Assistant → your profile → *Security* →
142
+ *Long-lived access tokens* → *Create token*.
143
+
144
+ ```bash
145
+ agent-voice output add alexa
146
+ ```
147
+
148
+ It asks for the URL and the token, checks the token right away, lists your
149
+ `notify.*` devices (with `_announce` first; those play a chime before speaking),
150
+ and says the test sentence. The token is stored only in your config directory,
151
+ with permissions `600`.
152
+
153
+ ### Your computer's voice
154
+
155
+ ```bash
156
+ agent-voice output add local
157
+ ```
158
+
159
+ | OS | Engine | Needs |
85
160
  |---|---|---|
86
- | `alexa` | Home Assistant's `notify.send_message` on an Echo entity | Home Assistant URL, a long-lived token, and the Alexa Media Player integration |
87
- | `local` | `say` (macOS), SAPI (Windows), `spd-say`/`espeak-ng` (Linux) | Nothing on macOS/Windows; `espeak-ng` or speech-dispatcher on Linux |
88
- | `command` | Runs any command; `{text}` in it is replaced by the sentence, otherwise the sentence goes to stdin | The command |
161
+ | macOS | `say` | nothing |
162
+ | Windows | SAPI (via PowerShell) | nothing |
163
+ | Linux | `spd-say`, `espeak-ng` or `espeak` | `sudo apt install espeak-ng` |
164
+
165
+ A Brazilian Portuguese voice is selected automatically when one is installed.
166
+
167
+ ### Any command
168
+
169
+ ```bash
170
+ agent-voice output add command
171
+ ```
172
+
173
+ Enter the command line. `{text}` is replaced by the sentence; if there is no
174
+ `{text}`, the sentence is sent on stdin. Some examples:
175
+
176
+ ```bash
177
+ ~/bin/falar.sh -a {text} # your own script
178
+ curl -s -d {text} ntfy.sh/my-agent-topic # phone push notification
179
+ notify-send agent-voice {text} # desktop notification
180
+ ```
89
181
 
90
- You can have several, e.g. `alexa-sala` and `alexa-escritorio`; every enabled
91
- output speaks, each given up to 15 seconds.
182
+ The command never goes through a shell. Arguments are passed exactly as typed,
183
+ and a leading `~`, `$HOME` or `%USERPROFILE%` is expanded to your home
184
+ directory. On Windows, `.cmd`/`.bat` speakers go through `cmd.exe` with every
185
+ argument quoted and escaped, so the sentence can never run anything.
92
186
 
93
- A `command` never goes through a shell: arguments are passed as typed, and a
94
- leading `~`, `$HOME` or `%USERPROFILE%` means your home directory. On Windows a
95
- `.cmd`/`.bat` speaker is run through `cmd.exe` with every argument quoted and
96
- escaped, so the sentence can never run anything; a batch file sees it with
97
- `^` escapes, which disappear when it passes `%*` or `%1` on to another program.
187
+ ---
98
188
 
99
189
  ## Agents
100
190
 
101
- - **Claude Code** — hooks in `~/.claude/settings.json`.
102
- - **Codex CLI** — hooks in `~/.codex/hooks.json`. Codex runs a hook only after
103
- you trust it: open Codex and run `/hooks` once after connecting.
104
- - **Anything else** — `agent-voice wrap -- aider ...` announces when the
105
- command finishes (no "needs you" announcements in this mode).
191
+ | Agent | Hooks live in | Notes |
192
+ |---|---|---|
193
+ | **Claude Code** | `~/.claude/settings.json` | After connecting, open `/hooks` once so a running session reloads its config. |
194
+ | **Codex CLI** | `~/.codex/hooks.json` | Codex only runs hooks you have trusted: open Codex and run `/hooks` once after connecting. |
195
+ | **Anything else** | none | Use `agent-voice wrap`, below. |
196
+
197
+ ```bash
198
+ agent-voice connect claude-code # asks first; --yes to skip the question
199
+ agent-voice disconnect claude-code # removes only agent-voice's hooks
200
+ ```
106
201
 
107
- Adding an agent: see [`docs/adapters.md`](docs/adapters.md).
202
+ `connect` never changes hooks it did not add. Running it again changes
203
+ nothing, and it always backs up the file first (`settings.json.bak-<date>`).
204
+
205
+ **Any other CLI** (Aider, a long build, a test suite) can use `wrap`. It speaks
206
+ when the command finishes, if it ran longer than the threshold:
207
+
208
+ ```bash
209
+ agent-voice wrap -- aider --message "refactor the parser"
210
+ agent-voice wrap --name "build" -- npm run build
211
+ ```
212
+
213
+ The exit code of the wrapped command is passed through. `wrap` cannot announce
214
+ "needs you", because it cannot see inside the program.
215
+
216
+ To support a new agent natively, see [`docs/adapters.md`](docs/adapters.md).
217
+
218
+ ---
108
219
 
109
220
  ## Tuning
110
221
 
111
- Tune against your own log. If most of your turns sit below the threshold it
112
- will feel broken — it will simply never speak.
222
+ Tune the thresholds against your own log. If most of your turns take less
223
+ time than the threshold, it will seem broken, because it will simply never
224
+ speak.
113
225
 
114
226
  ```bash
227
+ agent-voice config get # everything, including the phrases
115
228
  agent-voice config set minSeconds 20
116
229
  agent-voice config set cooldownSeconds 300
230
+ agent-voice config reset minSeconds # back to the default
117
231
  ```
118
232
 
119
- | Key | Default | Env override |
233
+ | Key | Default | Meaning | Env override |
234
+ |---|---|---|---|
235
+ | `minSeconds` | 30 | A shorter turn is not announced | `AV_MIN_SECONDS` |
236
+ | `cooldownSeconds` | 120 | Minimum gap between background announcements, per session | `AV_COOLDOWN_SECONDS` |
237
+ | `maxSpeechChars` | 90 | How much of your request is quoted back | `AV_MAX_SPEECH_CHARS` |
238
+
239
+ ---
240
+
241
+ ## Changing what it says
242
+
243
+ Each sentence is a template you can rewrite:
244
+
245
+ ```bash
246
+ agent-voice config set phrases.taskDone "{agent} acabou {where}, levou {duration}.[ Pedido: {request}.]"
247
+ agent-voice config set phrases.needsInput "Ei! {agent} precisa de você {where}{notice}."
248
+ agent-voice config reset phrases.taskDone # back to the default
249
+ ```
250
+
251
+ `{name}` is a variable. A `[section]` is spoken only when every variable inside
252
+ it has a value, so an empty request drops the whole clause instead of leaving
253
+ "Pedido: ." behind.
254
+
255
+ | Key | Variables | Default |
120
256
  |---|---|---|
121
- | `minSeconds` | 30 | `AV_MIN_SECONDS` |
122
- | `cooldownSeconds` | 120 | `AV_COOLDOWN_SECONDS` |
123
- | `maxSpeechChars` | 90 | `AV_MAX_SPEECH_CHARS` |
257
+ | `phrases.taskDone` | `{agent}` `{where}` `{duration}` `{request}` | `{agent} terminou {where}, depois de {duration}.[ Você tinha pedido: {request}.]` |
258
+ | `phrases.backgroundDone` | `{agent}` `{where}` `{text}` | `{agent} terminou um trabalho em segundo plano {where}.[ Era: {text}.]` |
259
+ | `phrases.needsInput` | `{agent}` `{where}` `{notice}` | `{agent} precisa de você {where}{notice}.` |
260
+
261
+ | Variable | Example |
262
+ |---|---|
263
+ | `{agent}` | `Claude Code`, `Codex` |
264
+ | `{where}` | `na sessão Home assistant repo` / `na sessão azul, do projeto api` |
265
+ | `{duration}` | `cerca de 4 minutos` |
266
+ | `{request}` | what you asked, without links, file paths or markdown |
267
+ | `{text}` | what the background work was, e.g. `code-reviewer` |
268
+ | `{notice}` | the agent's notice, translated: `, para usar o Bash`; empty when it adds nothing |
269
+
270
+ A template with an unknown variable or unbalanced brackets is refused, so the
271
+ speaker never reads out broken text.
272
+
273
+ ---
274
+
275
+ ## Commands
276
+
277
+ ```
278
+ agent-voice setup guided setup
279
+ agent-voice status agents, outputs, settings, latest log lines
280
+ agent-voice test speak a test sentence on every enabled output
281
+
282
+ agent-voice connect <claude-code|codex> [--yes] add hooks to an agent
283
+ agent-voice disconnect <claude-code|codex> remove them
284
+
285
+ agent-voice output add <alexa|local|command> [name]
286
+ agent-voice output list | test [name] | enable <name> | disable <name> | remove <name>
287
+
288
+ agent-voice config get [key] | set <key> <value> | reset <key>
289
+ agent-voice wrap [--name <label>] -- <command...> announce when any command finishes
290
+ ```
291
+
292
+ `agent-voice notify <agent> <event>` is also available: it is the hook entry
293
+ point, called by your agent. You never need to run it yourself.
294
+
295
+ ---
296
+
297
+ ## Windows notes
298
+
299
+ **"execução de scripts foi desabilitada" / "running scripts is disabled".**
300
+ PowerShell blocks npm's `.ps1` launchers by default. You can allow them once,
301
+ for your user only:
302
+
303
+ ```powershell
304
+ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
305
+ ```
306
+
307
+ Or use `npm.cmd` and `agent-voice.cmd` instead. The agent hooks are not
308
+ affected either way, because they call `agent-voice.cmd`.
309
+
310
+ **"agent-voice não é reconhecido" / "is not recognized".** npm's global folder
311
+ is not on your PATH. Add it, then open a new terminal and restart Claude Code:
124
312
 
125
- ## Where things live
313
+ ```powershell
314
+ [Environment]::SetEnvironmentVariable("Path", $env:Path + ";$(npm prefix -g)", "User")
315
+ ```
316
+
317
+ ---
318
+
319
+ ## Troubleshooting
320
+
321
+ Run `agent-voice status` first. The reason is almost always in the log lines it
322
+ prints.
323
+
324
+ | You see | It means |
325
+ |---|---|
326
+ | `silent (turn 21s < 30s)` | Working as configured. Lower `minSeconds` if you want it to speak sooner. |
327
+ | `silent (cooldown 120s)` | A background announcement was held back so the speaker doesn't chatter. |
328
+ | `FAILED via <output> (…)` | The speaker failed; the parenthesis says why. Try `agent-voice output test <name>`. |
329
+ | `no such output: <name>` | The output is enabled but not configured. Run `agent-voice output add`. |
330
+ | Agent shows `legacy` | It still has hooks from the old bash version. Run `agent-voice connect <agent>`. |
331
+ | Nothing in the log at all | The hooks are not firing. Claude Code: open `/hooks` once. Codex: trust them in `/hooks`. Check that `agent-voice` is on the PATH your agent sees. |
332
+
333
+ **Uninstall:**
334
+
335
+ ```bash
336
+ agent-voice disconnect claude-code # and/or codex
337
+ npm rm -g @lucas_zaia/agent-voice
338
+ ```
339
+
340
+ ---
341
+
342
+ ## How it works
343
+
344
+ ```
345
+ agent hook ──▶ adapter ──▶ core ──▶ output ──▶ speaker
346
+ translate decide speak
347
+ ```
348
+
349
+ | Piece | Job |
350
+ |---|---|
351
+ | **Adapter** (`src/adapters/`) | Knows one agent. Turns its hook payload into a standard event. Decides nothing. |
352
+ | **Core** (`src/core/`) | Knows no agent. Measures turns, applies thresholds and cooldown, cleans the text (no links, paths or markdown), builds the sentence. |
353
+ | **Output** (`src/outputs/`) | Takes a finished sentence and makes a device say it. |
354
+
355
+ Some guarantees:
356
+
357
+ - **It cannot break your agent.** The hook entry point always exits 0, never
358
+ waits more than a few seconds for input, and writes nothing your agent would
359
+ read. Failures go to the log and nowhere else.
360
+ - **Parallel sessions never mix.** State is kept per agent and per session, so
361
+ turns, durations and cooldowns stay separate.
362
+ - **Language.** The code and docs are in English. The spoken sentences are in
363
+ Brazilian Portuguese, and you can reword them (see
364
+ [Changing what it says](#changing-what-it-says)).
126
365
 
127
366
  | | Config | State and `events.log` |
128
367
  |---|---|---|
@@ -130,35 +369,31 @@ agent-voice config set cooldownSeconds 300
130
369
  | macOS | `~/Library/Application Support/agent-voice` | `…/agent-voice/state` |
131
370
  | Windows | `%APPDATA%\agent-voice` | `%LOCALAPPDATA%\agent-voice` |
132
371
 
133
- ## Troubleshooting
372
+ ---
134
373
 
135
- Run `agent-voice status` first — the reason is almost always in the log lines it prints.
374
+ ## Development
136
375
 
137
- - `silent (turn 21s < 30s)` → working as configured; lower `minSeconds`.
138
- - `FAILED via <output> (…)` → the speaker failed; the parenthesis says why. Try `agent-voice output test <name>`.
139
- - `no such output: <name>` → enabled but not configured; `agent-voice output add`.
140
- - An agent shows `legacy` → it still has the old bash hooks; run `agent-voice connect <agent>`.
141
- - Nothing in the log at all → the hooks are not firing. In Claude Code, open `/hooks` once to reload; in Codex, trust them in `/hooks`.
142
-
143
- ## Upgrading from the bash version
376
+ ```bash
377
+ git clone https://github.com/LucasZaia/agent-voice.git
378
+ cd agent-voice
379
+ npm test # node:test, no dependencies
380
+ npm link # puts this checkout's agent-voice on your PATH
381
+ ```
144
382
 
145
- 1. `npm install -g @lucas_zaia/agent-voice`
146
- 2. `agent-voice setup` — for the same Echo as before, pick `command` and enter
147
- `~/softwares/home-assistant/falar.sh -a {text}`, or pick `alexa` to talk to
148
- Home Assistant directly.
149
- 3. `agent-voice connect claude-code` replaces the old `…/bin/notify` hooks.
383
+ To point the hooks at a checkout without `npm link`, set the command they call
384
+ before connecting:
150
385
 
151
- Existing state in `~/.local/state/agent-voice` is reused as-is.
386
+ ```bash
387
+ AV_HOOK_COMMAND="node /path/to/agent-voice/bin/agent-voice.js" agent-voice connect claude-code
388
+ ```
152
389
 
153
- ## Notes
390
+ CI runs the suite on Linux, macOS and Windows with Node 20 and 22.
154
391
 
155
- - **It cannot break your agent.** The hook entry point exits 0 no matter what;
156
- failures go to the log and nowhere else.
157
- - **Language.** Code and docs are English; the spoken sentences are Brazilian
158
- Portuguese and live in one file, `src/core/phrases.js`.
159
- - **Concurrency.** State is keyed per agent and per session, so parallel
160
- sessions never mix up their turns, durations or cooldowns.
392
+ **Upgrading from the old bash version:** install the package, run
393
+ `agent-voice setup` (to reuse a speaker script, choose `command`), then run
394
+ `agent-voice connect claude-code`. That replaces the old `…/bin/notify` hooks.
395
+ The existing state in `~/.local/state/agent-voice` is reused as-is.
161
396
 
162
397
  ## License
163
398
 
164
- MIT.
399
+ MIT
package/package.json CHANGED
@@ -1,14 +1,38 @@
1
1
  {
2
2
  "name": "@lucas_zaia/agent-voice",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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
- "bin": { "agent-voice": "bin/agent-voice.js" },
7
- "files": ["bin", "src", "docs/adapters.md", "README.md", "LICENSE"],
8
- "engines": { "node": ">=20" },
9
- "scripts": { "test": "node --test" },
10
- "repository": { "type": "git", "url": "git+https://github.com/LucasZaia/agent-voice.git" },
11
- "keywords": ["claude-code", "codex", "alexa", "home-assistant", "tts", "notifications"],
6
+ "bin": {
7
+ "agent-voice": "bin/agent-voice.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "docs/adapters.md",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/LucasZaia/agent-voice.git"
25
+ },
26
+ "keywords": [
27
+ "claude-code",
28
+ "codex",
29
+ "alexa",
30
+ "home-assistant",
31
+ "tts",
32
+ "notifications"
33
+ ],
12
34
  "license": "MIT",
13
- "publishConfig": { "access": "public" }
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
14
38
  }
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
  }