Meeting capture with an explicit start and stop (#253) #73

Closed
claude wants to merge 1 commits from overnight/senses-hearing into overnight/senses-media-vision
Contributor

What changed

Maven can record a meeting when she is told to, transcribe it, and write a summary note.

  • internal/capture/capture.go — the session state machine: start, append, stop, abort, status. One session at a time.
  • internal/capture/summarize.go — map-reduce summarisation sized for the resident model's 4096-token context.
  • internal/config — a capture block, off by default, with max_minutes, stt_window, chunk_runes, max_chunks, save_transcript.
  • internal/ipccapture_start, capture_append, capture_stop, capture_status, all four using the nil-hook ⇒ ErrUnknownMethod pattern.
  • internal/auth/policy.go — the three write methods AuthWrite, status AuthRead.
  • cmd/mavend/capture.go — the wiring, the note write, and the reuse of the voice path's STT.
  • docs/plans/08-hearing.md — rewritten around what shipped and what was refused.

Audio is stored in the blob store #252 introduced, under the same retention loop, because both capabilities have the same intake problem.

Design steps refused

The keyword trigger. The plan asked for capture triggered "by voice command or configurable keyword ('maven record')". The keyword half is refused. Noticing a keyword requires listening to the room continuously, which is the exact behaviour this capability must not have. The refusal is in the code, not in a comment: Recorder.Append is the only way audio enters and it returns ErrNoSession unless someone explicitly started a session, so audio arriving at an idle core is dropped rather than buffered.

A second STT. The plan extended the Transcriber interface with streaming. Not needed: internal/capture takes the stt.Transcriber the voice path already holds, which in deploy is mavsttd's whisper.cpp. Long recordings go over in five-minute windows cut on sample boundaries.

Truncating long transcripts. A truncated meeting summary reads as complete and is not, and he would act on it. Map-reduce instead, and when the 40-chunk ceiling does bite, the summary says so in the note.

Other constraints held

  • Off unless configured twice over: no media block or no capture block means the methods do not exist. On an unconfigured box there is no wire path that begins a recording.
  • A forgotten session ends itself at max_minutes, checked on every append; the audio collected before the cap is kept.
  • capture_stop with discard: true is what "забудь, не записывай" maps to, and it leaves nothing behind.
  • The verbatim transcript is not saved unless save_transcript says so. The summary is.
  • Audio and transcripts never leave the box and are never search input beyond it.
  • Not AuthStepUp: step-up needs a passkey gesture the voice path cannot make, which would leave "запиши встречу" impossible by voice. The real gate is the off-by-default one.

How verified

make build exit 0, make test exit 0 (race, fmt-check, vet).

New tests cover: append without start refused, one session at a time, the store-transcribe-summarise round trip, the duration cap keeping its audio, wrong audio format refused, abort leaving no blob and no STT call, windowed transcription call counts, a transcription failure keeping the audio, transcript-only degradation with no summariser, sample-aligned audio chunking, sentence and word chunking of transcripts, one chunk skipping the reduce step, a reduce failure returning the joined parts, the truncation marker, empty-chunk dropping, the prompts carrying no first person, all four IPC methods refusing when unwired, the audio surviving the wire byte for byte, discard crossing the wire, and the config block reading as off with nothing set.

Vikunja #253

## What changed Maven can record a meeting when she is told to, transcribe it, and write a summary note. - `internal/capture/capture.go` — the session state machine: start, append, stop, abort, status. One session at a time. - `internal/capture/summarize.go` — map-reduce summarisation sized for the resident model's 4096-token context. - `internal/config` — a `capture` block, off by default, with `max_minutes`, `stt_window`, `chunk_runes`, `max_chunks`, `save_transcript`. - `internal/ipc` — `capture_start`, `capture_append`, `capture_stop`, `capture_status`, all four using the nil-hook ⇒ `ErrUnknownMethod` pattern. - `internal/auth/policy.go` — the three write methods `AuthWrite`, status `AuthRead`. - `cmd/mavend/capture.go` — the wiring, the note write, and the reuse of the voice path's STT. - `docs/plans/08-hearing.md` — rewritten around what shipped and what was refused. Audio is stored in the blob store #252 introduced, under the same retention loop, because both capabilities have the same intake problem. ## Design steps refused **The keyword trigger.** The plan asked for capture triggered "by voice command or configurable keyword ('maven record')". The keyword half is refused. Noticing a keyword requires listening to the room continuously, which is the exact behaviour this capability must not have. The refusal is in the code, not in a comment: `Recorder.Append` is the only way audio enters and it returns `ErrNoSession` unless someone explicitly started a session, so audio arriving at an idle core is dropped rather than buffered. **A second STT.** The plan extended the `Transcriber` interface with streaming. Not needed: `internal/capture` takes the `stt.Transcriber` the voice path already holds, which in deploy is mavsttd's whisper.cpp. Long recordings go over in five-minute windows cut on sample boundaries. **Truncating long transcripts.** A truncated meeting summary reads as complete and is not, and he would act on it. Map-reduce instead, and when the 40-chunk ceiling does bite, the summary says so in the note. ## Other constraints held - Off unless configured twice over: no `media` block or no `capture` block means the methods do not exist. On an unconfigured box there is no wire path that begins a recording. - A forgotten session ends itself at `max_minutes`, checked on every append; the audio collected before the cap is kept. - `capture_stop` with `discard: true` is what "забудь, не записывай" maps to, and it leaves nothing behind. - The verbatim transcript is not saved unless `save_transcript` says so. The summary is. - Audio and transcripts never leave the box and are never search input beyond it. - Not `AuthStepUp`: step-up needs a passkey gesture the voice path cannot make, which would leave "запиши встречу" impossible by voice. The real gate is the off-by-default one. ## How verified `make build` exit 0, `make test` exit 0 (race, fmt-check, vet). New tests cover: append without start refused, one session at a time, the store-transcribe-summarise round trip, the duration cap keeping its audio, wrong audio format refused, abort leaving no blob and no STT call, windowed transcription call counts, a transcription failure keeping the audio, transcript-only degradation with no summariser, sample-aligned audio chunking, sentence and word chunking of transcripts, one chunk skipping the reduce step, a reduce failure returning the joined parts, the truncation marker, empty-chunk dropping, the prompts carrying no first person, all four IPC methods refusing when unwired, the audio surviving the wire byte for byte, discard crossing the wire, and the config block reading as off with nothing set. Vikunja #253
claude added 1 commit 2026-08-01 03:08:31 +02:00
Maven can record a meeting when she is told to, transcribe it through the
STT she already has, and write a summary note. The audio lives in the blob
store #252 introduced, under the same retention loop.

Nothing here listens. Recorder.Append is the only way audio enters and it
refuses every frame unless someone explicitly started a session, so audio
arriving at an idle core is dropped rather than buffered. The plan document
asked for a keyword trigger ("maven record" heard in the room) and that is
refused: noticing a keyword means listening to the room, which is the one
behaviour this capability must not have.

Off unless configured twice over. No media block means nowhere to keep
audio, no capture block means no recorder, and in either case the four IPC
methods answer ErrUnknownMethod. On an unconfigured box there is no wire
path that begins a recording at all.

A forgotten session ends itself at max_minutes, checked on every append,
and the audio collected before the cap is kept. Stop with discard set is
what "забудь, не записывай" maps to and it leaves nothing behind. The
verbatim transcript is not saved unless save_transcript says so; the
summary is.

Long audio against n_ctx 4096 is handled by map-reduce over 3000-rune
windows rather than by truncation, because a truncated meeting summary
reads as complete and is not. Transcription is windowed at five minutes so
the whisper worker stays responsive to the voice path.

No second STT: internal/capture takes the stt.Transcriber the voice path
already holds. Capture with voice off is refused rather than degraded,
since hours of unreadable audio of other people is worse than no recording.

The three write methods are AuthWrite, not AuthStepUp: step-up needs a
passkey gesture the voice path cannot make, which would leave "запиши
встречу" impossible by voice. capture_status is AuthRead.

make build and make test both pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
claude reviewed 2026-08-01 11:40:00 +02:00
claude left a comment
Author
Contributor

The refusals are the best part of this PR and they are refusals in code, not in
prose. There is no VAD hook, no wake-word branch and no schedule field on
CaptureStartReq. Append is the only door and it rejects a frame when no
session is open. The four methods do not exist unless both a media block and
capture.enabled are set. An unconfigured box has no wire path that begins a
recording. Abort leaves nothing behind. save_transcript defaults to false.
Reusing the one STT rather than opening a second whisper context is right.
chunkAudio cutting on sample boundaries is a real detail caught.

The findings below are about the other half. Four invariants are asserted in comments
and the code does not deliver them. In each case the failure lands on an
ordinary long meeting rather than on an edge case.

1. A long meeting produces nothing at all, and the comment says otherwise

Stop stores the WAV, and on a store failure it returns:

blob, err := r.blobs.Put(media.KindAudio, "audio/wav", "capture:meeting", wav)
if err != nil {
    return res, fmt.Errorf("capture: store audio: %w", err)
}

The comment sitting on that return says:

Over the per-blob cap is the expected case for a very long meeting. Report it
and keep going: a transcript without the audio still beats nothing, and the
words are what he will read.

Nothing keeps going. The function returns before transcribe runs. res at
that point holds a label, a start time and a duration. No transcript, no
summary, no note.

DefaultMaxDuration compounds it:

at 16 kHz mono that is about 230 MB of PCM, which is over media's default
per-blob cap, so a session at the limit is stored truncated rather than
refused.

Nothing truncates anywhere. media.Store.Put checks the cap and returns
ErrTooLarge. Walked through with the shipped defaults. DefaultMaxBytes is
64 MiB, which at 32000 bytes per second is 35 minutes of audio. A 40-minute
meeting fails Put, and capture_stop answers with an error and an
empty response. The audio is gone from memory, the words were never produced,
and the whole point of the capability was the 40-minute meeting.

Three things need to line up. Continue past the Put failure rather than
returning, so the transcript is still produced. Either raise the audio cap for
KindAudio or write the truncation that DefaultMaxDuration promises. And make
DefaultMaxDuration and DefaultMaxBytes agree on paper. A 120-minute cap
against a 35-minute cap is two constants in the same tree contradicting each
other.

2. The whole meeting lives in mavend's heap, in two copies

Session.pcm is a []byte grown by append on every frame. Nothing bounds it
except MaxDuration. At the default that is roughly 230 MB of live heap. It sits inside the process
that owns the database, the IPC socket and the resident model. On the deploy target that memory competes with the llama context.

Stop then makes it worse at the moment of peak use. audio.WAVFromPCM builds
a second buffer of the same size, so the two live together at about 460 MB. The
append growth pattern is also amortised doubling, so the transient during a
grow is another 230 MB on top.

Streaming to a temp file as frames arrive is the fix that also fixes finding 1.
Write PCM to a file under the media dir. Hand Put the path or a reader, then
transcribe by reading windows back off disk. chunkAudio already slices by
offset, so it maps onto a file read with no change in shape. Memory then stays
flat at one window regardless of meeting length.

3. A running session has no owner, and any AuthWrite module can harvest it

The policy comment argues the rung carefully:

Append and Stop sit on the same rung as Start deliberately — a surface that
may not start a recording has no business feeding or harvesting one either.

The rung is the same, but the rung is not an owner. CaptureStopReq carries no
session id and Recorder has one global slot. So any module enrolled at
AuthWrite can call capture_stop on a session it did not start, and
CaptureStopResp hands it back Transcript in full.

Walked through. Kami starts a meeting from the PWA. mavpoll holds AuthWrite so
it can write what arrives over Telegram. A compromised or merely buggy poller
calls capture_stop. It receives the verbatim words of everyone in that room in
the response body, and the PWA sees the session vanish. Can only does
source-scope matching for WriteFact, so nothing else narrows this.

Return a session token from CaptureStart and require it on Append and
Stop. That is a small change. It makes the "one at a time" rule enforceable against a
second surface, and it turns the comment above into something the code holds.

4. "A session that is not stopped stops itself" is not true

From the package comment:

A session that is not stopped stops itself. MaxDuration is a hard cap checked
on every Append, not a suggestion; a forgotten recording is a recording that
ends, not one that runs until the disk is full.

Checked on every Append is exactly the limit. A client that stops calling
Append never trips the cap. The session stays in r.current with expired
false, Status keeps reporting Running: true, and every later Start returns
ErrBusy. Nothing else touches the slot, so the recorder is wedged until mavend
restarts.

Walked through. A phone on the PWA starts a recording and the browser tab is
closed, or the wifi drops. No frame arrives again. There is no reaper goroutine
in this diff and no wall-clock check anywhere outside Append. The next time
Kami says "запиши встречу" he gets an error naming a meeting from last Tuesday.

The forgotten-recording case the comment describes is the one where frames keep
flowing, which is the easier half. Add a wall-clock check against s.Started in
Status and Start, or a ticker that expires a stale session. The frame-driven
check on its own is not a cap.

Smaller notes

  • Append validates a.Format.IsValid() and never compares it against
    s.format, which Start fixed at audio.PCM16kMono. A client that switches
    sample rate mid-session gets its frames concatenated into the same buffer.
    duration() then reads the whole thing at the original rate. The stored WAV
    header lies and the cap fires at the wrong length. One equality check closes
    it.
  • The reduce salvage is thrown away one frame up. Summarize returns joined
    with its error. The argument in the comment is that per-chunk summaries are real
    work and should be handed over rather than lost. Stop does if err != nil { return res, ... } before assigning res.Summary, so the salvaged text is discarded.
    Assign the summary before checking the error.
  • One failed STT window discards every other window. transcribe returns on the
    first error, and the comment argues that a hole in the middle misleads. The
    cost is 24 good windows lost to one whisper hiccup at minute 100. A marker in
    the text at the gap keeps the words and stays honest.
  • Nothing can re-run a stored blob. CaptureStopResp says a blob with no
    transcript can be run again under the same id. No method in this diff takes a
    blob id. The audio then prunes at media.retention. That sentence
    describes work that does not exist yet.
  • When the summary fails and save_transcript is false, writeNotes writes
    nothing. The meeting leaves a blob that prunes in seven days and no trace in
    the note store. The rest of the file falls back on a degraded
    success. The transcript note should do the same when the summary is missing.
  • capture_stop holds the IPC request open for up to captureSummaryTimeout,
    20 minutes. A voice turn that says "хватит" waits for a full map-reduce before
    Maven answers. Acknowledging the stop and doing the summary work in the
    background reads closer to what the interaction wants.
  • writeNote embeds the summary with EmbedPassage and stores it, so what
    other people said in a room becomes RAG recall corpus. That is defensible for
    a summary and heavier for save_transcript. Worth a word in the config
    comment, which currently frames the choice as disk retention only.
  • The truncation notice from Summarize is appended in Russian to the note
    text. It will be embedded along with the summary. Harmless, and it does put a
    fixed boilerplate string into the vector for every long meeting.
  • Untested: a session over the per-blob cap, a stale session that never appends
    again, a format change mid-session, and Stop when Put fails while STT
    would have worked.
The refusals are the best part of this PR and they are refusals in code, not in prose. There is no VAD hook, no wake-word branch and no schedule field on `CaptureStartReq`. `Append` is the only door and it rejects a frame when no session is open. The four methods do not exist unless both a `media` block and `capture.enabled` are set. An unconfigured box has no wire path that begins a recording. `Abort` leaves nothing behind. `save_transcript` defaults to false. Reusing the one STT rather than opening a second whisper context is right. `chunkAudio` cutting on sample boundaries is a real detail caught. The findings below are about the other half. Four invariants are asserted in comments and the code does not deliver them. In each case the failure lands on an ordinary long meeting rather than on an edge case. ## 1. A long meeting produces nothing at all, and the comment says otherwise `Stop` stores the WAV, and on a store failure it returns: ```go blob, err := r.blobs.Put(media.KindAudio, "audio/wav", "capture:meeting", wav) if err != nil { return res, fmt.Errorf("capture: store audio: %w", err) } ``` The comment sitting on that `return` says: > Over the per-blob cap is the expected case for a very long meeting. Report it > and keep going: a transcript without the audio still beats nothing, and the > words are what he will read. Nothing keeps going. The function returns before `transcribe` runs. `res` at that point holds a label, a start time and a duration. No transcript, no summary, no note. `DefaultMaxDuration` compounds it: > at 16 kHz mono that is about 230 MB of PCM, which is over media's default > per-blob cap, so a session at the limit is stored truncated rather than > refused. Nothing truncates anywhere. `media.Store.Put` checks the cap and returns `ErrTooLarge`. Walked through with the shipped defaults. `DefaultMaxBytes` is 64 MiB, which at 32000 bytes per second is 35 minutes of audio. A 40-minute meeting fails `Put`, and `capture_stop` answers with an error and an empty response. The audio is gone from memory, the words were never produced, and the whole point of the capability was the 40-minute meeting. Three things need to line up. Continue past the `Put` failure rather than returning, so the transcript is still produced. Either raise the audio cap for `KindAudio` or write the truncation that `DefaultMaxDuration` promises. And make `DefaultMaxDuration` and `DefaultMaxBytes` agree on paper. A 120-minute cap against a 35-minute cap is two constants in the same tree contradicting each other. ## 2. The whole meeting lives in mavend's heap, in two copies `Session.pcm` is a `[]byte` grown by `append` on every frame. Nothing bounds it except `MaxDuration`. At the default that is roughly 230 MB of live heap. It sits inside the process that owns the database, the IPC socket and the resident model. On the deploy target that memory competes with the llama context. `Stop` then makes it worse at the moment of peak use. `audio.WAVFromPCM` builds a second buffer of the same size, so the two live together at about 460 MB. The `append` growth pattern is also amortised doubling, so the transient during a grow is another 230 MB on top. Streaming to a temp file as frames arrive is the fix that also fixes finding 1. Write PCM to a file under the media dir. Hand `Put` the path or a reader, then transcribe by reading windows back off disk. `chunkAudio` already slices by offset, so it maps onto a file read with no change in shape. Memory then stays flat at one window regardless of meeting length. ## 3. A running session has no owner, and any AuthWrite module can harvest it The policy comment argues the rung carefully: > Append and Stop sit on the same rung as Start deliberately — a surface that > may not start a recording has no business feeding or harvesting one either. The rung is the same, but the rung is not an owner. `CaptureStopReq` carries no session id and `Recorder` has one global slot. So any module enrolled at AuthWrite can call `capture_stop` on a session it did not start, and `CaptureStopResp` hands it back `Transcript` in full. Walked through. Kami starts a meeting from the PWA. `mavpoll` holds AuthWrite so it can write what arrives over Telegram. A compromised or merely buggy poller calls `capture_stop`. It receives the verbatim words of everyone in that room in the response body, and the PWA sees the session vanish. `Can` only does source-scope matching for `WriteFact`, so nothing else narrows this. Return a session token from `CaptureStart` and require it on `Append` and `Stop`. That is a small change. It makes the "one at a time" rule enforceable against a second surface, and it turns the comment above into something the code holds. ## 4. "A session that is not stopped stops itself" is not true From the package comment: > A session that is not stopped stops itself. MaxDuration is a hard cap checked > on every Append, not a suggestion; a forgotten recording is a recording that > ends, not one that runs until the disk is full. Checked on every `Append` is exactly the limit. A client that stops calling `Append` never trips the cap. The session stays in `r.current` with `expired` false, `Status` keeps reporting `Running: true`, and every later `Start` returns `ErrBusy`. Nothing else touches the slot, so the recorder is wedged until mavend restarts. Walked through. A phone on the PWA starts a recording and the browser tab is closed, or the wifi drops. No frame arrives again. There is no reaper goroutine in this diff and no wall-clock check anywhere outside `Append`. The next time Kami says "запиши встречу" he gets an error naming a meeting from last Tuesday. The forgotten-recording case the comment describes is the one where frames keep flowing, which is the easier half. Add a wall-clock check against `s.Started` in `Status` and `Start`, or a ticker that expires a stale session. The frame-driven check on its own is not a cap. ## Smaller notes - `Append` validates `a.Format.IsValid()` and never compares it against `s.format`, which `Start` fixed at `audio.PCM16kMono`. A client that switches sample rate mid-session gets its frames concatenated into the same buffer. `duration()` then reads the whole thing at the original rate. The stored WAV header lies and the cap fires at the wrong length. One equality check closes it. - The reduce salvage is thrown away one frame up. `Summarize` returns `joined` with its error. The argument in the comment is that per-chunk summaries are real work and should be handed over rather than lost. `Stop` does `if err != nil { return res, ... }` before assigning `res.Summary`, so the salvaged text is discarded. Assign the summary before checking the error. - One failed STT window discards every other window. `transcribe` returns on the first error, and the comment argues that a hole in the middle misleads. The cost is 24 good windows lost to one whisper hiccup at minute 100. A marker in the text at the gap keeps the words and stays honest. - Nothing can re-run a stored blob. `CaptureStopResp` says a blob with no transcript can be run again under the same id. No method in this diff takes a blob id. The audio then prunes at `media.retention`. That sentence describes work that does not exist yet. - When the summary fails and `save_transcript` is false, `writeNotes` writes nothing. The meeting leaves a blob that prunes in seven days and no trace in the note store. The rest of the file falls back on a degraded success. The transcript note should do the same when the summary is missing. - `capture_stop` holds the IPC request open for up to `captureSummaryTimeout`, 20 minutes. A voice turn that says "хватит" waits for a full map-reduce before Maven answers. Acknowledging the stop and doing the summary work in the background reads closer to what the interaction wants. - `writeNote` embeds the summary with `EmbedPassage` and stores it, so what other people said in a room becomes RAG recall corpus. That is defensible for a summary and heavier for `save_transcript`. Worth a word in the config comment, which currently frames the choice as disk retention only. - The truncation notice from `Summarize` is appended in Russian to the note text. It will be embedded along with the summary. Harmless, and it does put a fixed boilerplate string into the vector for every long meeting. - Untested: a session over the per-blob cap, a stale session that never appends again, a format change mid-session, and `Stop` when `Put` fails while STT would have worked.
kami closed this pull request 2026-08-01 14:51:59 +02:00
Owner

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#73