diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..b2f9b59 --- /dev/null +++ b/PROTOCOL.md @@ -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": "" + }, + "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": "" + }, + "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": "" + }, + "text": "кажется, ты давно не пил воду.", + "ts": "2026-07-03T12:00:00Z" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `rule_name` | string | The proactive rule that fired | +| `severity` | int | 1–4 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": "" +} +``` + +- **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- +``` diff --git a/internal/delivery/channel.go b/internal/delivery/channel.go index 968e617..3b66d14 100644 --- a/internal/delivery/channel.go +++ b/internal/delivery/channel.go @@ -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 diff --git a/internal/delivery/dispatcher.go b/internal/delivery/dispatcher.go index a69c8db..3bc2f59 100644 --- a/internal/delivery/dispatcher.go +++ b/internal/delivery/dispatcher.go @@ -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}) diff --git a/internal/delivery/voicesink/voicesink.go b/internal/delivery/voicesink/voicesink.go index 65fdbb0..bd3c39f 100644 --- a/internal/delivery/voicesink/voicesink.go +++ b/internal/delivery/voicesink/voicesink.go @@ -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) }