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:
2026-08-02 03:28:49 +04:00
parent 7079a240f7
commit 93987f2dfc
38 changed files with 99 additions and 85 deletions
+260
View File
@@ -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 | 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-
```