PROTOCOL.md + away-channel fallthrough

PROTOCOL.md: generated from internal/voice/wire.go — documents the
voice wire format (TCP, length-prefixed JSON, methods, pushes, errors)
for multi-client implementors. Must stay in sync with wire.go.

dispatcher: when voice sink returns ErrVoiceNoSession, skip voice and
continue to remaining channels instead of aborting. sev4-present
already has ntfy in the routing table (continues naturally). sev1-3
present have only voice — the loop ends with no dispatches, which
matches the spec (care/ops-soft drop on no-voice).

voicesink: maps voice.ErrNoSession to delivery.ErrVoiceNoSession so
the dispatcher can detect it without importing the voice package.
This commit is contained in:
kami
2026-07-03 13:18:38 +02:00
parent 877136aa61
commit bc0e3a5a89
4 changed files with 280 additions and 2 deletions
+258
View File
@@ -0,0 +1,258 @@
# Maven Voice Protocol
> Auto-generated from `internal/voice/wire.go`, `internal/voice/errors.go`,
> `internal/voice/frame.go`, `internal/voice/client.go`. If this file and
> those files disagree, the code wins.
## Transport
TCP, length-prefixed JSON. Each frame is:
```
[4 bytes big-endian uint32 length][JSON payload]
```
The length is the number of **bytes** of the JSON payload that follows
(excludes the 4-byte length prefix itself). Maximum frame size is **64 MiB**
(`maxFrame = 64 << 20`), enough for ~33 minutes of 16k mono int16 PCM audio.
The server binds a TCP address inside the WireGuard tunnel (production) or
`127.0.0.1:9100` (local smoke test). The reference server port is configured
via `voice.bind` in `mavend.json`.
## Frame types
Three frame shapes share the same length-prefixed envelope. A reader
distinguishes them by shape rather than a type tag:
| Frame | Has `id`? | Has `kind`? | Direction |
|-------|-----------|-------------|-----------|
| Request | yes (`id` + `m`) | no | client → server |
| Response | yes (`id`) | no | server → client |
| Push | no | yes (`kind`) | server → client (async) |
### Request (client → server)
```json
{
"id": 1,
"m": "push_to_talk",
"p": { ... }
}
```
| Field | Type | Description |
|-------|------|-------------|
| `id` | uint64 | Chosen by client, monotonically increasing per connection. Server echoes it back in the matching Response |
| `m` | string | Method name (see Methods below) |
| `p` | object | Method-specific params (omitempty) |
### Response (server → client)
```json
{
"id": 1,
"r": { ... },
"e": null
}
```
| Field | Type | Description |
|-------|------|-------------|
| `id` | uint64 | Matches the Request this replies to |
| `r` | object | Result payload (omitempty). Set exactly when `e` is null |
| `e` | object | Error payload (omitempty). See Error codes |
A Response always matches a prior Request. It is written on the same
connection immediately after handling. The client can block reading — there
is exactly one Response per Request for today's synchronous methods.
### Push (server → client, async)
```json
{
"kind": "audio_nudge",
"p": { ... }
}
```
| Field | Type | Description |
|-------|------|-------------|
| `kind` | string | Push kind (see Push kinds below) |
| `p` | object | Push-specific params (omitempty) |
A Push is server-initiated. It can arrive on a connection that is also
awaiting a Response. The client distinguishes them by checking whether `id`
is present (Response) or `kind` is present (Push).
## Methods
### `push_to_talk`
The reactive round-trip: client sends captured audio, server replies with
synthesised audio + reply text.
**Request params** (`PushToTalkReq`):
```json
{
"audio": {
"fmt": "pcm_16k_mono",
"data": "<base64-encoded PCM bytes>"
},
"lang": "ru",
"surface": "pc_client"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `audio` | Audio | Captured PCM. `fmt` is one of `pcm_16k_mono`, `pcm_44k_stereo`, etc. (see `internal/audio`). `data` is base64-encoded raw PCM bytes |
| `lang` | string | Recognition language hint: `"ru"`, `"en"`, `"mixed"`, or unset for daemon default |
| `surface` | string | Client's auth surface. Today the floor always sets `"pc_client"`; future mTLS/passkey handshakes populate this |
**Response result** (`PushToTalkResp`):
```json
{
"reply_audio": {
"fmt": "pcm_16k_mono",
"data": "<base64>"
},
"reply_text": "тихий режим включён. буду реже напоминать.",
"transcript": "тихий режим",
"routed_channels": null
}
```
| Field | Type | Description |
|-------|------|-------------|
| `reply_audio` | Audio | TTS-synthesised reply audio (always present on success) |
| `reply_text` | string | Same reply in plain text |
| `transcript` | string | STT transcription of the input audio (omitempty) |
| `routed_channels` | [string] | Away channels the dispatcher also delivered to (empty for today's reactive-only path) |
### `pong`
Liveness response. The client sends this in reply to a `ping` Push to
refresh its last-active timestamp on the server.
**Request params:** none (empty `"p": null` or omitted).
**Response result:** `null`.
## Push kinds
### `audio_nudge`
Server has a proactive nudge to deliver. The client should play the audio.
**Params** (`AudioNudgePush`):
```json
{
"rule_name": "water",
"severity": 2,
"audio": {
"fmt": "pcm_16k_mono",
"data": "<base64>"
},
"text": "кажется, ты давно не пил воду.",
"ts": "2026-07-03T12:00:00Z"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `rule_name` | string | The proactive rule that fired |
| `severity` | int | 14 where 4 = most urgent |
| `audio` | Audio | TTS-synthesised nudge body |
| `text` | string | Same body as plain text |
| `ts` | datetime | When the nudge was sent (RFC3339) |
### `ping`
Liveness probe from the server. The client should respond with a `pong`
Request to keep its session alive.
**Params:** none (`"p": null` or omitted).
## Audio format
The `Audio` type is always:
```json
{
"fmt": "pcm_16k_mono",
"data": "<base64>"
}
```
- **Sample rate:** 16000 Hz
- **Channels:** 1 (mono)
- **Sample format:** signed 16-bit little-endian int (int16)
- **Encoding:** raw PCM, no header
- **Wire encoding:** base64 inside the JSON frame
The reference client wraps/unwraps WAV headers at the file edge
(`audio.WAVFromPCM`, `audio.PCMFromWAV`). The wire never carries WAV.
## Error codes
Errors are returned as an object in the Response `e` field:
```json
{
"id": 1,
"r": null,
"e": {
"c": "unknown_method",
"m": "optional diagnostic"
}
}
```
| Code | Meaning | Has message? |
|------|---------|-------------|
| `unknown_method` | The method name is not recognised | yes (the method name) |
| `bad_params` | Params failed to parse / validate | yes (parse error text) |
| `forbidden` | The surface is not authorised for this method | no |
| `internal` | Server-side error (transient, retryable) | yes (diagnostic only) |
The `message` field is never authority-bearing. Auth refusals carry
`forbidden` with no message.
## Auth surface
The server enforces capability scopes per surface. Today's floor assigns
`SurfacePCClient` to every connection (full L3 access). Future passkey
handshakes will set the surface from `mTLS` metadata / `WebAuthn`
enrollment.
Known surface values:
- `voice` — voice/chat channel (structurally capped below destructive acts)
- `pc_client` — reference desktop client
- `authed_page` — mavweb /tools page
- `telegram` — Telegram inbound
## Session lifecycle
1. Client connects via TCP to the voice address.
2. Server registers a Session (assigns an opaque ID, records `lastActive`).
3. Client sends Requests and receives Responses + Pushes on the same conn.
4. Proactive delivery routes to the most-recently-active session by
`lastActive` timestamp. If no session is live, the dispatcher falls
through to away channels.
5. On disconnect (EOF / read error / shutdown), the session is removed.
## Reference client
`cmd/mavenclient` implements this protocol. Use it to smoke-test:
```shell
# one-shot: send audio.wav → get reply.wav
mavenclient -addr 127.0.0.1:9100 -in audio.wav -out reply.wav
# listen mode: stay connected, write incoming Pushes to disk
mavenclient -addr 127.0.0.1:9100 -listen -out-prefix /tmp/nudge-
```
+10
View File
@@ -27,10 +27,20 @@
package delivery
import (
"errors"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// ErrVoiceNoSession — sentinel returned by the voice sink when no live
// client session exists at push time. The dispatcher treats this as a
// skip-continue: the voice channel is unavailable for this delivery, but
// remaining channels (ntfy, telegram) should still fire. The voicesink
// maps voice.ErrNoSession to this sentinel so the dispatcher doesn't need
// to import the voice package.
var ErrVoiceNoSession = errors.New("delivery: voice: no live session")
// Channel — one delivery transport. Drop is an explicit no-op (the routing
// table chose to suppress, which is a decision, not a failure — "a missed
// water nudge is noise"). a nil Sink for a wired channel is a daemon config
+10
View File
@@ -2,7 +2,9 @@ package delivery
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/kami/maven/internal/loop"
@@ -104,6 +106,10 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
continue
}
if err := sink.Send(ctx, s); err != nil {
if errors.Is(err, ErrVoiceNoSession) {
log.Printf("dispatcher: no live voice session for %s, falling through", c.Rule.Name)
continue
}
return out, fmt.Errorf("send %s: %w", ch, err)
}
// record AFTER successful send — a failed send must not pollute the
@@ -151,6 +157,10 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
continue
}
if err := sink.Send(ctx, s); err != nil {
if errors.Is(err, ErrVoiceNoSession) {
log.Printf("dispatcher: no live voice session for reminder %d, falling through", rd.Reminder.ID)
continue
}
return out, fmt.Errorf("send %s: %w", ch, err)
}
out = append(out, Dispatch{Sendable: s})
+2 -2
View File
@@ -94,8 +94,8 @@ func (s *Sink) Send(ctx context.Context, send delivery.Sendable) error {
}
if err := s.sess.PushToMostRecent(ctx, push); err != nil {
if errors.Is(err, voice.ErrNoSession) {
log.Printf("voicesink: no live voice session for %s — rerouting deferred, dropping", send.RuleName)
return err
log.Printf("voicesink: no live voice session for %s, falling through to away channels", send.RuleName)
return delivery.ErrVoiceNoSession
}
return fmt.Errorf("voicesink: push: %w", err)
}