Files
Maven/docs/protocol.md
T
claude 93987f2dfc docs: tier the tree by lifetime, so staleness shows in the path (V-446)
Seventeen markdown files at the repo root, twelve of them dated one-shot
reports sitting next to CLAUDE.md. That is why stale docs read as
current: nothing in the path said which was which.

Root now keeps CLAUDE.md and AGENTS.md. Living docs move under docs/
and carry a Last verified line. Dated measurements move to docs/evals/
ISO-prefixed, and are never edited after the day, so a newer number is
a new file. The senior review moves to docs/archive/.

Every reference was rewritten across markdown, Go comments, the Makefile
and the recall fixture. The touched Go packages still build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 03:28:49 +04:00

261 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Maven Voice Protocol
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
> 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-
```