Every playing announcement now shows a desktop notification with its text and, on Linux, a Cancel button (Abbrechen for German) that stops exactly that announcement; the notification is closed when the announcement ends. This brings back what the old talk.sh had with notify-send --action, as a default: a long announcement can be cut short without a terminal. Implementation: the freedesktop Notifications service on the session bus via godbus (pure Go): one connection per process, Notify with the action list, ActionInvoked dispatched to the announcement, CloseNotification at the end, expire_timeout 0 so the button stays for the whole announcement. Without a session bus the notify-send fallback shows a plain notification; macOS and Windows show one without a button for now. queue.CancelItem(id) cancels only the playing item. Config [notify] on_speak (default true, env TALK_NOTIFY). `talk test --toast` now shows the real thing and waits for a click; `talk doctor` names the service and whether it supports the button. Config hygiene found on the way: a key written below a [table] header belongs to that table in TOML (lang under [voice] was silently ignored). config.Load now collects unknown keys (undecoded ones and non-language keys under [voice.<provider>]) and `talk doctor` warns with the hint. Tests: process test on a private dbus-daemon with a fake org.freedesktop.Notifications service (Notify payload, cancel click aborts the synthesis, CloseNotification), queue.CancelItem, config keys. All test binaries point DBUS_SESSION_BUS_ADDRESS at a dead socket so no test ever pops a notification on the developer's desktop. flake vendorHash updated for the new dependency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
443 lines
22 KiB
Markdown
443 lines
22 KiB
Markdown
# talk
|
|
|
|
> **Status: WIP**, under active development. Specification with the decision
|
|
> addendum: [`docs/spec.md`](docs/spec.md)
|
|
|
|
**talk** gives scripts and AI agents a voice: one call, `talk "Build done"`,
|
|
speaks a short announcement through a chain of TTS providers
|
|
(ElevenLabs → OpenAI → Edge TTS → OS voice), serialized by a background
|
|
daemon that talk starts itself on first use. One static binary,
|
|
cross-platform (Windows, macOS, Linux; amd64 and arm64), no runtime
|
|
dependencies.
|
|
|
|
## Commands
|
|
|
|
| Command | Purpose |
|
|
| --- | --- |
|
|
| `talk "text"` | announce; shorthand for `talk say`, non-blocking |
|
|
| `talk say "text"` | announce (text as argument or from stdin) |
|
|
| `talk wrap -- <command>` | run a command, announce success/failure and duration |
|
|
| `talk mute` / `talk unmute` | mute all announcements persistently / turn them back on |
|
|
| `talk status` | daemon status (queue, mute, provider, laws, version) |
|
|
| `talk cancel` | cancel the playing and all queued announcements |
|
|
| `talk daemon run\|start\|stop` | run the daemon in the foreground / start it in the background / stop it (normally not needed, see below) |
|
|
| `talk voices` | known voices per provider and language, default marked (`--provider`, `--lang`, `--json`) |
|
|
| `talk test` | speak a test sentence; shows provider and timings (`--lang`, `--provider`, `--voice`); `--toast` sends a test notification instead |
|
|
| `talk doctor` | self-diagnosis: config, daemon, audio, providers, notifications, mute, laws (`--probe`, `--json`) |
|
|
| `talk setup` | first-time setup in a few questions: language, voices with preview, quiet hours, config.toml |
|
|
|
|
Say flags (also for `talk "text"`): `--wait`, `--now`, `--priority`,
|
|
`--voice`, `--lang`, `--provider`.
|
|
|
|
## Installation
|
|
|
|
**Release binary** (Linux, macOS, Windows; amd64/arm64): download the
|
|
matching `talk_<os>_<arch>` from the
|
|
[releases](https://code.m3ta.dev/m3tam3re/talk/releases), verify the
|
|
checksum against `checksums.txt`, put it in your PATH as `talk`. No other
|
|
dependencies.
|
|
|
|
```console
|
|
curl -LO https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/talk_linux_amd64
|
|
sha256sum -c --ignore-missing <(curl -sL https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/checksums.txt)
|
|
install -m 755 talk_linux_amd64 ~/.local/bin/talk
|
|
```
|
|
|
|
**Nix** (flake): `nix run git+https://code.m3ta.dev/m3tam3re/talk -- doctor`,
|
|
or in your configuration `inputs.talk.url = "git+https://code.m3ta.dev/m3tam3re/talk";`
|
|
and `inputs.talk.packages.${system}.talk` or `inputs.talk.overlays.default`.
|
|
The package brings espeak-ng (OS voice) and libnotify (toast) along.
|
|
|
|
**From source:** `go install code.m3ta.dev/m3tam3re/talk/cmd/talk@latest`.
|
|
|
|
## Getting started
|
|
|
|
```console
|
|
talk doctor # What is there, what is missing? One line per item.
|
|
talk test # Do you hear something? Which voice was it?
|
|
talk setup # Pick a language and voices, set quiet hours, done.
|
|
talk "Hello world" # From now on scripts and agents call exactly this.
|
|
```
|
|
|
|
talk works without any key: it uses Microsoft's free Edge voice (needs
|
|
internet) and otherwise your operating system's voice. For a premium voice,
|
|
set `TALK_ELEVENLABS_KEY` or `TALK_OPENAI_KEY` as an environment variable.
|
|
Keys never go into config.toml.
|
|
|
|
### Diagnosis and setup
|
|
|
|
- **`talk doctor`** reports one line per item with ✔ (ok), ⚠ (note), ✘
|
|
(error): config file, daemon (running / starts on the next speak command
|
|
/ foreign service on the port), audio output (can oto initialize? `null`
|
|
= no sound), every provider tier (key present, endpoint, default voice;
|
|
OS voice: which mechanism was found), notifications, mute, laws
|
|
including an active quiet window. Exit 0 without ✘, else 1; a daemon that
|
|
is not running is ⚠. `--probe` runs real synthesis probes (network),
|
|
`--json` returns the list for machines. Windows consoles get
|
|
`[OK]`/`[WARN]`/`[FAIL]` instead of the symbols.
|
|
- **`talk test`** speaks a fixed test sentence directly in this process (not
|
|
via the daemon; mute and quiet hours do not apply here, an active mute is
|
|
mentioned) and shows which tier spoke and how long synthesis and playback
|
|
took. `talk test --toast` shows a test notification with the Cancel button
|
|
and waits for a click, so you can check the notification setup without
|
|
speaking anything.
|
|
- **`talk voices`** lists the curated voice catalog (ElevenLabs premade,
|
|
OpenAI, Edge neural voices per locale) as a table; `*` marks the voice
|
|
talk uses without `--voice` (config `voice.<provider>.<lang>` or the
|
|
built-in default). Config entries outside the catalog appear as their own
|
|
row. Any voice ID the provider knows works without a catalog entry.
|
|
- **`talk setup`** asks in the terminal: keys (only a hint; without a key
|
|
the Edge voice speaks), the fallback language, a voice for each language
|
|
of the active tier (number from the catalog or a voice ID, preview,
|
|
accept), quiet hours. Then it writes config.toml, merging an existing
|
|
file (other settings stay) and backing it up as `.bak`, and speaks a
|
|
test sentence. `q` aborts at any point; the file is written only at the
|
|
very end. Without a terminal (pipe, agent) setup asks nothing and only
|
|
shows the effective configuration.
|
|
|
|
### Notifications with a Cancel button
|
|
|
|
Every announcement that plays also shows a desktop notification with its
|
|
text and, on Linux, a **Cancel** button (German: Abbrechen) that stops that
|
|
announcement without a terminal. The notification disappears when the
|
|
announcement ends. talk talks to the freedesktop notification service on the
|
|
session bus directly (any daemon with the "actions" capability: mako, dunst,
|
|
GNOME Shell, KDE, quickshell); without a session bus it falls back to
|
|
notify-send, on macOS to Notification Center and on Windows to a toast, all
|
|
without a button. Turn it off with:
|
|
|
|
```toml
|
|
[notify]
|
|
on_speak = false # env TALK_NOTIFY=false
|
|
```
|
|
|
|
`talk test --toast` shows one such notification, waits a few seconds for a
|
|
click on Cancel and reports what happened; `talk doctor` names the service
|
|
and whether it supports the button.
|
|
|
|
### Language
|
|
|
|
talk detects German and English per announcement (word lists plus umlauts;
|
|
no ML) and picks the voice accordingly. When it cannot tell (numbers,
|
|
single words, ties) it uses the configured fallback language, which is also
|
|
the language of talk's own announcements such as the `wrap` result:
|
|
|
|
```toml
|
|
lang = "en" # de | en; default en (env TALK_LANG)
|
|
```
|
|
|
|
## Daemon
|
|
|
|
There is exactly one binary. The daemon is `talk daemon run`: the same code,
|
|
in the background. It owns the announcement queue and plays serially, exactly
|
|
one announcement at a time, in the order `now` > `high` > `normal` > `low`
|
|
(FIFO per level). The HTTP server listens on `127.0.0.1` only; port: default
|
|
`7800`, config `[server] port`, env `TALK_PORT`.
|
|
|
|
**Autostart:** when no daemon runs, `talk say` (and `talk wrap`) start it on
|
|
the first speak command, detached from the terminal (on Windows also broken
|
|
away from the caller's job object, so a WinRM, SSH or CI session does not
|
|
take it down), logging to `daemon.log` with its PID in `daemon.pid` in the
|
|
config directory; stderr says "daemon started" once. The daemon then keeps running (idle costs next to nothing)
|
|
until `talk daemon stop`, logout or reboot. Parallel first calls are
|
|
harmless: whoever loses the port race uses the other one's daemon. If you
|
|
manage the daemon yourself (systemd, launchd, Task Scheduler with `talk
|
|
daemon run`), turn autostart off:
|
|
|
|
```toml
|
|
[daemon]
|
|
autostart = false # env TALK_AUTOSTART=false
|
|
```
|
|
|
|
Control commands (`mute`, `unmute`, `status`, `cancel`) deliberately do not
|
|
start the daemon: `talk status` then says "not running" (exit 1), `talk
|
|
mute` writes the mute file directly.
|
|
|
|
| Endpoint | Effect |
|
|
| --- | --- |
|
|
| `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}` (+ `"truncated":true` when cut to `max_text`); muted: `202 {"id":"","muted":true}`; law discard: `202 {"id":"","discarded":"rate_limit"\|"quiet_hours"}` (+ `"toast":true` in toast mode); documented behaviour, not an error |
|
|
| `OPTIONS /speak` | CORS preflight → `204` with allow headers when the `Origin` is listed in `server.allowed_origins` (see below); otherwise `204` without CORS headers |
|
|
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "limits": {"max_text", "max_age"}, "mcp": true, "cors": [origins]}` |
|
|
| `POST /cancel` | aborts the playing announcement and empties the queue → `200 {"cancelled":n}` |
|
|
| `POST /mute` / `POST /unmute` | persistent mute state → `200 {"muted":bool}` |
|
|
| `POST /shutdown` | shuts the daemon down (`talk daemon stop`) → `200 {"ok":true}` |
|
|
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`; one tool `speak` (see below) |
|
|
| `GET /mcp` | `405`; the daemon offers no server-initiated streams |
|
|
|
|
- **CLI behaviour:** `talk say` (and `talk "text"`) detects the daemon via
|
|
`GET /status`. If it runs: enqueue and return at once (non-blocking,
|
|
stdout gets the item ID); `--wait` polls until spoken; `--now` jumps the
|
|
queue; when muted the CLI prints `muted`, on a law discard
|
|
`discarded: <reason>`. `talk mute`/`unmute`/`status`/`cancel` are daemon
|
|
calls with plain output.
|
|
- **Mute is persistent** (file `mute` in the config directory, content
|
|
`true`/`false`) and survives daemon restarts. `talk mute` without a
|
|
running daemon writes the file directly; the daemon re-reads it on every
|
|
announcement, so the state applies offline too.
|
|
- **Direct path:** if no daemon is reachable and none can be started
|
|
(autostart off, port taken by something else, start failed), `talk`
|
|
speaks directly in its own process: blocking, without a queue (parallel
|
|
calls may overlap), with a note and the reason on stderr. Mute is
|
|
honoured here too. Nothing fails silently.
|
|
|
|
### Rate limit, quiet hours, length and age
|
|
|
|
The daemon enforces the anti-spam laws as a backstop under whatever a
|
|
prompt does. **Rate limit:** every *started* announcement books a time
|
|
window, default **1 announcement / 30 s**. Until the window elapses:
|
|
|
|
- `priority=low` is **discarded** (already on admission; the 202 response
|
|
says `discarded: "rate_limit"`); if the window was open on admission, the
|
|
worker decides later.
|
|
- `normal` **parks** in the queue and plays at the next open slot (visible
|
|
in `/status` as `rate_limit.next_slot`).
|
|
- `priority=high` may **deliberately break** the limit with
|
|
`queue.allow_high_burst = true` (default); without burst rights high
|
|
waits like normal.
|
|
- `now:true` **bypasses the rate limit too**; the documented escape for
|
|
urgent matters (it still books a new window).
|
|
|
|
**Quiet hours:** `quiet_hours = "18:00-08:00"` (local time, may span
|
|
midnight; empty/`off` = disabled; deliberately no default window, quiet
|
|
hours are opt-in like mute). During quiet hours audio is dropped, also for
|
|
high (burst is a rate right, not a quiet right):
|
|
|
|
- `quiet_mode = "discard"` (default): drop silently.
|
|
- `quiet_mode = "toast"`: a desktop notification instead of audio (the
|
|
notification service or the system's own tool, see "Notifications"), best
|
|
effort: if nothing is available, only the log line remains and
|
|
`talk doctor` shows it under "Toast".
|
|
- `now:true` **breaks out of quiet hours too** (a deliberate interruption).
|
|
|
|
**Length and age:** two more laws against what prompts like to do, reading
|
|
out the long answer and piling up announcements:
|
|
|
|
- `max_text` (default **400 characters**, two to three sentences): longer
|
|
texts are **cut** at a word boundary on admission and spoken like that;
|
|
the response reports `"truncated": true`, the CLI warns on stderr, the
|
|
MCP result says so in its text. The user hears the beginning instead of a
|
|
ten-minute lecture, and the API budget stays bounded.
|
|
- `max_age` (default **2 minutes**): whatever parks longer in the queue
|
|
(rate limit, backlog) is no longer news and is **discarded** when it
|
|
comes due (`discarded: "expired"` in the log; the 202 on admission stays a
|
|
promise, because nothing is stale on admission). `now:true` never waits
|
|
long and is exempt.
|
|
|
|
**Precedence:** manual mute always wins > `now` > stale (`max_age`) > quiet
|
|
hours > rate limit; `max_text` applies on admission to everything.
|
|
Configuration (config.toml; env beats the file):
|
|
|
|
```toml
|
|
[queue]
|
|
rate_limit = "30s" # duration; "0s" = unlimited (TALK_RATE_LIMIT)
|
|
allow_high_burst = true # high may break the limit (TALK_ALLOW_HIGH_BURST)
|
|
max_text = 400 # characters; 0 = unlimited (TALK_MAX_TEXT)
|
|
max_age = "2m" # longest wait; "0s" = unlimited (TALK_MAX_AGE)
|
|
|
|
quiet_hours = "18:00-08:00" # empty = off; env TALK_QUIET_HOURS ("off" = off)
|
|
quiet_mode = "discard" # discard | toast (TALK_QUIET_MODE)
|
|
```
|
|
|
|
`talk status` shows the law state: `rate-limit: 30s (high burst allowed),
|
|
next slot …`, `quiet-hours: 18:00-08:00 (mode discard, active until 08:00)`
|
|
and `limits: max 400 characters, max wait 2m0s`.
|
|
|
|
### CORS door and MCP endpoint
|
|
|
|
**CORS** applies to `POST /speak` only, the one door a later browser
|
|
integration (for example a web chat UI) needs; control endpoints stay out of
|
|
reach of browsers. The allow-list holds exact origins; `"*"` is allowed but
|
|
not the default; **an empty list (default) means no CORS headers at all**:
|
|
|
|
```toml
|
|
[server]
|
|
allowed_origins = ["https://chat.example.com"] # env TALK_ALLOWED_ORIGINS
|
|
```
|
|
|
|
- `OPTIONS /speak` (preflight) from a listed origin → `204` with
|
|
`Access-Control-Allow-Origin` (the origin or `*`), `-Methods: POST,
|
|
OPTIONS`, `-Headers: Content-Type`, `Max-Age: 600`.
|
|
- A real `POST /speak` with a listed `Origin` carries the same header.
|
|
- **The door is shut server-side, not only browser-side.** CORS alone only
|
|
prevents *reading* the response; any web page can send a POST with a
|
|
`text/plain` body or a form POST without a preflight. Therefore the
|
|
daemon rejects every request with a foreign `Origin` header with `403`
|
|
unless it is POST /speak from a listed origin: unlisted origins on
|
|
/speak, and any browser access to `/status`, `/mute`, `/unmute`,
|
|
`/cancel` and `/mcp`, even from listed origins. Only the preflight
|
|
`OPTIONS /speak` stays a `204` without CORS headers for unlisted origins
|
|
(it has no effect; the browser drops the planned POST). CLI, curl and MCP
|
|
clients send no `Origin` header and are unaffected.
|
|
|
|
**MCP** (`POST /mcp`, Streamable HTTP): a lean own implementation of the
|
|
JSON-RPC subset, no SDK: `initialize` (the requested `protocolVersion` is
|
|
echoed; result `protocolVersion`, `capabilities: {tools:{}}`,
|
|
`serverInfo`), `notifications/initialized` (notification → `202` without
|
|
body), `ping`, `tools/list`, `tools/call`. The server is stateless (no
|
|
session IDs) and pushes nothing (hence `GET /mcp` → `405`). One tool:
|
|
|
|
```json
|
|
{"name": "speak",
|
|
"inputSchema": {"type": "object",
|
|
"properties": {"text": {"type": "string"},
|
|
"priority": {"enum": ["low","normal","high"]},
|
|
"provider": {"type": "string"},
|
|
"lang": {"enum": ["de","en"]},
|
|
"now": {"type": "boolean"}},
|
|
"required": ["text"]}}
|
|
```
|
|
|
|
`tools/call` takes exactly the same admission path as `POST /speak`
|
|
(including mute, rate limit, quiet hours, truncation). The result is always
|
|
text content: `"queued: <id>"` on admission; `muted`/`discarded: <reason>`
|
|
with an explanation, deliberately `isError: false`, because a discard is
|
|
the correct documented result, not a protocol error. Validation errors
|
|
(missing text, invalid priority, unknown tool) are JSON-RPC errors
|
|
(`-32602`). Handshake as a curl sequence:
|
|
|
|
```sh
|
|
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
|
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
|
|
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
|
|
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
|
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build done"}}}'
|
|
```
|
|
|
|
### Provider chain
|
|
|
|
Without a pin, the four-tier fallback chain runs
|
|
**ElevenLabs → OpenAI TTS → Edge TTS → OS built-in**: on a missing key, an
|
|
HTTP error, a timeout or a network problem the chain falls through to the
|
|
next tier automatically; every fallback is logged to stderr (never silent).
|
|
Pin with `--provider=<name>` or `provider = "<name>"` in config.toml.
|
|
|
|
| Tier | Key (env) | Default endpoint | Audio |
|
|
| --- | --- | --- | --- |
|
|
| `elevenlabs` | `TALK_ELEVENLABS_KEY` | `https://api.elevenlabs.io` | mp3 |
|
|
| `openai` | `TALK_OPENAI_KEY` | `https://api.openai.com` | mp3 |
|
|
| `edge` | none (free) | `wss://speech.platform.bing.com/...` | mp3 |
|
|
| `osbuiltin` | none | local OS TTS | WAV/PCM |
|
|
|
|
Keys come exclusively from environment variables (never config.toml).
|
|
Endpoints are configurable: file `[providers.<name>] endpoint = "…"` or env
|
|
`TALK_ELEVENLABS_ENDPOINT` / `TALK_OPENAI_ENDPOINT` / `TALK_EDGE_ENDPOINT`
|
|
(env beats the file; this is how tests and CI point the chain at fake
|
|
servers). The OpenAI model is configurable (`[providers.openai] model`,
|
|
default `tts-1`).
|
|
|
|
Voices: the neutral built-in defaults per provider and language (ElevenLabs
|
|
"Rachel", OpenAI "alloy", Edge `de-DE-KatjaNeural`/`en-US-AriaNeural`) can
|
|
be overridden per language in config.toml:
|
|
|
|
```toml
|
|
[voice.elevenlabs]
|
|
de = "21m00Tcm4TlvDq8ikWAM" # your own voice per language
|
|
en = "21m00Tcm4TlvDq8ikWAM"
|
|
|
|
[voice.edge]
|
|
de = "de-DE-KatjaNeural"
|
|
```
|
|
|
|
Precedence: `--voice` flag > `voice.<provider>.<lang>` > built-in default.
|
|
MP3 is decoded by the binary itself (pure Go decoder, no ffmpeg, CGO-free).
|
|
|
|
Edge TTS is an undocumented Microsoft endpoint with a DRM token scheme that
|
|
Microsoft changes now and then. When that happens, the edge tier fails with
|
|
a clear "protocol changed?" error and the chain falls through to the OS
|
|
voice; `talk doctor --probe` makes it visible.
|
|
|
|
### OS TTS on Linux
|
|
|
|
The `osbuiltin` provider uses two routes on Linux:
|
|
|
|
- **Audio bytes** (playback through the built-in oto sink): needs
|
|
`espeak-ng` (or `espeak`). On NixOS as a dev dependency:
|
|
`nix shell nixpkgs#espeak-ng`.
|
|
- **Direct speech** without espeak: `spd-say` (speech-dispatcher) plays by
|
|
itself through its daemon but cannot deliver bytes to a file or pipe.
|
|
`talk` takes this route automatically when espeak is missing.
|
|
|
|
`TALK_AUDIO_SINK=null` (or `audio.sink = "null"` in config.toml) turns all
|
|
sound off, for CI and servers.
|
|
|
|
## `talk wrap`: announce commands
|
|
|
|
`talk wrap -- <command> [args …]` runs the command and makes the result
|
|
audible: stdin/stdout/stderr are passed through; stdout belongs to the
|
|
command alone, talk's information lines (for example the announcement's
|
|
item ID) go to stderr so pipes and redirects stay clean. Wall time is
|
|
measured and announced after the command ends:
|
|
|
|
- success: "sleep succeeded. Duration: 2 seconds."
|
|
- failure: "sh failed, code 2." (without duration)
|
|
|
|
```console
|
|
talk wrap -- sleep 2
|
|
talk wrap --wait -- make test
|
|
```
|
|
|
|
- **`--` shields the command completely:** the first standalone `--` ends
|
|
talk's flag parsing; everything after it goes to the command unchanged.
|
|
`talk wrap -- foo --bar` passes `--bar` to foo instead of misreading it
|
|
as a talk flag. talk's flags for the announcement therefore go before it
|
|
(`talk wrap --wait -- …`); without `--` the usual rules apply (flags
|
|
anywhere, also after positional arguments).
|
|
- **Label** of the announcement: `wrap.label` in config.toml (env
|
|
`TALK_WRAP_LABEL` beats the file) or the command's base name
|
|
(`/usr/bin/make` → "make"). Language detection runs on the label (de/en,
|
|
falling back to the configured `lang`); `--lang` overrides.
|
|
- **Exit code policy:** wrap always exits with the command's code; if the
|
|
announcement itself fails, the command's code still stands (only a
|
|
warning on stderr; talk errors are not command errors). Commands that
|
|
cannot start (binary not found) count as 127 (shell convention), signal
|
|
deaths portably as 1. Usage errors of talk itself (no command, invalid
|
|
flags) fail before the command starts.
|
|
- **Announcement path:** the same as say: daemon (enqueue, non-blocking;
|
|
`--wait` honoured; autostart), without a daemon the direct path. wrap
|
|
inherits the say flags (`--wait`, `--now`, `--priority`, `--voice`,
|
|
`--lang`, `--provider`) and passes them on.
|
|
|
|
```toml
|
|
[wrap]
|
|
label = "Deployment" # announcement label instead of the command's base name (TALK_WRAP_LABEL)
|
|
```
|
|
|
|
## For agents: skill and standing order
|
|
|
|
[`skills/talk/SKILL.md`](skills/talk/SKILL.md) is the etiquette as a skill
|
|
artifact: **strict opt-in** (speak only when asked in the session or by a
|
|
standing order in the project's AGENTS.md, session-sticky until revoked),
|
|
content = status · next step · what the user is needed for, never the long
|
|
answer, honour `muted`/`discarded`. The three-line block for project
|
|
AGENTS.md files is in
|
|
[`skills/talk/AGENTS-snippet.md`](skills/talk/AGENTS-snippet.md). The daemon
|
|
enforces rate limit, length and age limits, mute and quiet hours regardless
|
|
of what the prompt says.
|
|
|
|
## Development
|
|
|
|
```console
|
|
go build ./... # build
|
|
go test ./... # tests
|
|
go vet ./... # static analysis
|
|
gofmt -l . # formatting check (empty output = ok)
|
|
nix develop # devShell with go, gopls, espeak-ng, libnotify
|
|
```
|
|
|
|
CI: `.gitea/workflows/test.yml` (Gitea Actions) runs vet, the gofmt check,
|
|
tests and build on every push and pull request.
|
|
|
|
**Release:** push a `v*` tag → `.gitea/workflows/release.yml` tests, builds
|
|
the six targets with `scripts/release.sh` (static, `CGO_ENABLED=0`, version
|
|
via ldflags) and creates a Gitea release with `talk_<os>_<arch>[.exe]` and
|
|
`checksums.txt`. Locally: `scripts/release.sh 1.2.3` → `dist/`. Deliberately
|
|
without GoReleaser: a shell script around `go build` yields the same
|
|
artifact locally and in CI without another tool. `nix build .#talk` builds
|
|
the Nix package (after changes to go.mod, update the `vendorHash` in
|
|
flake.nix; `nix build` prints the expected value).
|
|
|
|
## License
|
|
|
|
MIT, see [LICENSE](LICENSE).
|