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>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
# 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 | 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": "<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-
|
||||
```
|
||||
Reference in New Issue
Block a user