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
pull from: overnight/senses-hearing
merge into: kami:overnight/senses-media-vision
kami:master
kami:task/725-capability-ledger-and-empirical-baseline
kami:task/692-heads-path-may-equal-model-path-and-noth
kami:task/694-staticcheck-and-deadcode-are-still-not-i
kami:task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea
kami:task/674-caveats
kami:task/673-mavgpud-serves-the-model-to-the-whole-la
kami:task/487-capture-device-doc
kami:task/487-capture-device
kami:task/487-wake-word-deploy
kami:task/487-wake-word-threshold
kami:task/487-wake-word-stage-two
kami:task/671-mavwaked-registers-as-a-voice-consumer-i
kami:task/670-cut-claude-md-to-200-lines
kami:task/515-deploy-mavwaked-workpc
kami:task/669-prune-claude-md
kami:task/668-e4b-phrasing
kami:task/668-title-capital
kami:task/668-kiwix-answers-a-question-it-cannot-answe
kami:task/666-only-a-stage-0-grammar-may-take-the-pers
kami:task/487-mavwaked-has-no-wake-word-only-an-energy
kami:task/486-deploy-the-workstation-transcriber
kami:task/486-move-stt-and-tts-to-the-workstation-wher
kami:task/665-crisperwhisper-2-russian
kami:task/664-routing-heads-in-go
kami:task/662-usage-harness-source-badge
kami:task/661-post-merge-usage-rerun
kami:task/661-routing-heads-step-3-train-the-multi-hea
kami:task/660-router-prompt-destination
kami:task/659-destination-fixture
kami:task/655-query-source-is-a-routing-decision-made
kami:task/654-a-pending-clarify-has-no-way-out-neither
kami:task/654-week-of-usage-eval-docs
kami:task/649-needs-kami-telegram-is-the-only-reach-an
kami:task/643-memorystore-search-decodes-and-unmarshal
kami:task/641-two-maps-grow-for-the-process-lifetime-w
kami:task/644-mavcaldav-is-built-documented-as-running
kami:task/642-the-store-caps-sqlite-at-one-connection
kami:task/647-factenrichmentworker-walks-the-pending-q
kami:task/646-v-637-follow-up-telegram-intake-has-no-d
kami:task/638-no-deadline-survives-the-turn-path-from
kami:task/637-inbound-telegram-turns-and-corrections-f
kami:task/636-correcting-a-turn-from-telegram-and-from
kami:task/634-an-act-alias-resolves-the-verb-but-not-t
kami:task/630-one-gesture-correction-on-chat-v-628
kami:task/629-persist-the-routing-trace-and-record-it
kami:task/631-mode-inventory-written-from-the-handlers
kami:task/586-defaultfactparser-uses-hand-written-russ
kami:task/633-reconcile-the-seed-labels-with-the-handl
kami:task/627-reminder-verbs-has-no-alarm-verb-so-an-a
kami:task/626-the-classifier-seeds-teach-an-older-inte
kami:task/546-route-with-a-fine-tuned-e5-small-instead
kami:task/586-measure-the-fact-parser
kami:fix/gofmt-ecosystem-acts
kami:task/584-media-store-a-failed-write-leaks-its-bud
kami:task/518-no-write-path-for-a-backdated-event-so-t
kami:task/287-qa-voice-session-quality-polish
kami:task/492-qa-plan-reconcile
kami:task/530-sweep-tail-four-files-the-russian-sweep
kami:task/405-score-how-often-a-real-utterance-reaches
kami:task/529-money-and-list-pick-a-mechanism
kami:task/528-sweep-tail-the-three-files-on-467
kami:task/527-embedder-open-set-phrasings-stop-being-r
kami:task/526-morphology-a-dictionary-answers-the-gram
kami:task/525-lexicons-the-finite-russian-sets-move-to
kami:task/524-entity-reference-ask-nexus-about-every-l
kami:task/523-risk-tiers-take-hexis-s-tier-for-a-hexis
kami:task/521-review-pr-111-query-strings-declension-h
kami:task/491-llama-server-core-dumps-on-every-sigterm
kami:task/479-bug-an-unconfigured-capability-does-not
kami:task/467-bug-spoken-task-capture-is-dead-the-rout
kami:task/463-deploy-mavwaked-and-mavenclient-run-nowh
kami:task/480-hearing-no-shipped-client-can-start-a-re
kami:task/432-ambient-calendar-intake-is-fragile-and-p
kami:task/431-board-surface-maven-holds-the-work-board
kami:task/433-reactivehandler-has-30-fields-and-is-pas
kami:task/371-swap-the-embedder-for-an-asymmetric-retr
kami:task/408-review-31-07-split-the-30-method-coreapi
kami:task/410-review-31-07-hand-rolled-string-enums-st
kami:task/423-review-pr50-split-internal-ipc-server-go
kami:task/422-review-pr50-split-cmd-mavend-tick-go-860
kami:task/409-review-31-07-finish-moving-mavweb-markup
kami:task/482-ambient-ingest-reads-a-notification-s-ti
kami:task/444-kuma-a-fact-per-monitor-so-she-can-name
kami:task/452-capability-model-homelab-docker-restart
kami:task/449-destructive-confirm-policy-risk-tiers-no
kami:task/453-grocery-list-items-table-fourth-append-o
kami:task/399-run-the-persona-checks-inside-the-daemon
kami:task/448-bounded-follow-up-state-pending-candidat
kami:task/455-conversation-repair-name-the-misroute-co
kami:task/454-go-mod-tidy
kami:task/458-pronunciation-dictionary-for-piper
kami:task/456-command-history-read-only-query-over-exi
kami:task/457-clarification-templates-for-the-router-s
kami:task/474-query-source-ordering-feeds-and-calendar
kami:task/469-reminders-spelled-out-times-fail-the-bod
kami:task/475-bug-the-praxis-attention-capability-is-u
kami:task/481-bug-a-transient-complaint-is-stored-as-a
kami:task/476-bug-the-router-transliterates-latin-enti
kami:task/385-decide-whether-a-parked-clarify-question
kami:task/377-backfill-routines
kami:task/421-weather-geocoder
kami:task/390-no-read-path-for-delivery-attempts
kami:task/386-recall-fixture-filler-note-ids
kami:task/473-bug-morning-item-has-no-required-flag
kami:task/465-bug-make-simulate-routes-with-an-empty
kami:task/467-bug-spoken-task-capture-is-dead
kami:task/466-bug-a-pending-clarify-is-global-so-one-u
kami:task/468-bug-pattern-detect-has-no-minimum-interv
kami:task/462-bug-checkfeminine-flags-second-person-ma
kami:task/443-safekey-drops-cyrillic-so-russian-calend
kami:task/471-bug-agendaquerygrammars-covers-today-but
kami:task/383-slottext-in-clarify-answer-would-clobber
kami:task/323-qa-phraser-coverage-is-65-3-but-the-llam
kami:task/498-bug-and-x-reach-the-model-with-no-determ
kami:task/506-strings-family-6-summaries-and-reports-i
kami:task/504-strings-family-4-act-and-smart-home-repl
kami:task/503-strings-family-3-query-answers-and-gaps
kami:task/502-strings-family-2-capture-acknowledgement
kami:task/501-strings-family-1-phrasing-fallbacks-into
kami:task/397-phrasechat-and-phrasequery-hide-model-fa
kami:task/396-the-reply-path-can-t-be-tested-llmreplie
kami:task/496-recall-a-cross-language-question-loses-i
kami:task/495-bug-x-escapes-the-personal-boundary-and
kami:task/499-llama-server-holds-7-9gb-rss-for-a-1-1gb
kami:task/470-bug-a-question-writes-invented-knowledge
kami:task/493-bug-the-memory-index-stores-the-raw-utte
kami:task/490-name-the-gap-world-questions-through-the
kami:task/485-run-the-big-model-on-the-workstation-wit
kami:task/489-workstation-deploy-mavgpud-on-workpc-and
kami:task/488-workstation-a-supervisor-that-keeps-llam
kami:task/483-docs-offload-design
kami:task/483-design-offload-ml-to-the-workstation-kee
kami:task/459-docs-refresh-the-qa-plan-against-the-liv
kami:task/446-doc-reorg-tier-the-tree-retire-the-three
kami:fix/367-voice-parks-routine-accept
kami:task/365-dialogue-slots-and-router-slots-are-hand
kami:task/364-snooze-does-nothing-at-runtime-the-gate
kami:task/447-retire-progress-md-the-backlog-and-the-f
kami:task/445-session-workflow
kami:overnight/eco-versioned-traces
kami:overnight/eco-entity-refs
kami:overnight/eco-degraded-suite
kami:overnight/netscan
kami:overnight/smarthome
kami:overnight/replay-simulator
kami:overnight/event-envelope
kami:overnight/coldstart-unlock
kami:overnight/voice-barge-in
kami:overnight/stt-golden-audio
kami:overnight/senses-speaker
kami:overnight/senses-media-vision
kami:overnight/mcp-tools
kami:overnight/mcp-client
kami:overnight/self-update
kami:overnight/model-swap
kami:overnight/web-crawler
kami:overnight/rss-feeds
kami:overnight/email-poller
kami:overnight/email-extract
kami:overnight/email-imap
kami:overnight/money-zenmoney
kami:overnight/task-priority
kami:overnight/task-capture
kami:overnight/behavior-profile
kami:overnight/day-plan
kami:overnight/ambient-calendar
kami:overnight/local-calendar
kami:overnight/memory-eval
kami:overnight/proactive-proposals
kami:overnight/split-voice-quiet
kami:overnight/nginx-maven-block
kami:overnight/stepup-chat-surface
kami:integration/small-batch
kami:docs/fix-drift
kami:fix/ru-wording
kami:integration/jul31
kami:overnight/resident-1.7b
kami:overnight/nudge-templates
kami:overnight/kiwix-rewrite
kami:overnight/eval-writeup
kami:overnight/fix-truncation
kami:overnight/kiwix-client
kami:overnight/ru-prompts
kami:overnight/external-data
kami:overnight/phrasing-grammar
kami:overnight/talk-eval
kami:overnight/prompt-context
kami:overnight/prompt-address
kami:overnight/eval-label-kill
kami:overnight/delivery-boundary
kami:overnight/address-check
kami:overnight/system-replies-pr
kami:overnight/clock-intent-pr
kami:overnight/embedder-backfill-pr
kami:overnight/embedder-marker-pr
kami:overnight/note-recall-pr
kami:overnight/thinking-off-pr
kami:overnight/dialogue-persist-pr
kami:overnight/persona-2p-pr
kami:overnight/clarify-expiry-pr
kami:overnight/clarify-rework
kami:overnight/phrasing
kami:overnight/bakeoff
kami:overnight/recall-margin
kami:overnight/router-on
kami:overnight/slot-extract
kami:overnight/embedder-e5
kami:overnight/router-refusal
kami:overnight/eval-rerun
kami:overnight/eval-harnesses
kami:overnight/eval-rerun-base
kami:overnight/fmt-gate
kami:overnight/routines-fire
kami:overnight/router-prompt
kami:overnight/away-leak
kami:overnight/recall-eval
kami:overnight/snooze-works
kami:overnight/clarify-wiring
kami:overnight/delivery-tests
kami:overnight/routine-accept
kami:overnight/llm-router-flag
kami:overnight/clarify-data-layer
kami:overnight/loop-rule-tests
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "overnight/senses-hearing"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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— acaptureblock, off by default, withmax_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 ⇒ErrUnknownMethodpattern.internal/auth/policy.go— the three write methodsAuthWrite, statusAuthRead.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.Appendis the only way audio enters and it returnsErrNoSessionunless someone explicitly started a session, so audio arriving at an idle core is dropped rather than buffered.A second STT. The plan extended the
Transcriberinterface with streaming. Not needed:internal/capturetakes thestt.Transcriberthe 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
mediablock or nocaptureblock means the methods do not exist. On an unconfigured box there is no wire path that begins a recording.max_minutes, checked on every append; the audio collected before the cap is kept.capture_stopwithdiscard: trueis what "забудь, не записывай" maps to, and it leaves nothing behind.save_transcriptsays so. The summary is.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 buildexit 0,make testexit 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
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.Appendis the only door and it rejects a frame when nosession is open. The four methods do not exist unless both a
mediablock andcapture.enabledare set. An unconfigured box has no wire path that begins arecording.
Abortleaves nothing behind.save_transcriptdefaults to false.Reusing the one STT rather than opening a second whisper context is right.
chunkAudiocutting 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
Stopstores the WAV, and on a store failure it returns:The comment sitting on that
returnsays:Nothing keeps going. The function returns before
transcriberuns.resatthat point holds a label, a start time and a duration. No transcript, no
summary, no note.
DefaultMaxDurationcompounds it:Nothing truncates anywhere.
media.Store.Putchecks the cap and returnsErrTooLarge. Walked through with the shipped defaults.DefaultMaxBytesis64 MiB, which at 32000 bytes per second is 35 minutes of audio. A 40-minute
meeting fails
Put, andcapture_stopanswers with an error and anempty 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
Putfailure rather thanreturning, so the transcript is still produced. Either raise the audio cap for
KindAudioor write the truncation thatDefaultMaxDurationpromises. And makeDefaultMaxDurationandDefaultMaxBytesagree on paper. A 120-minute capagainst 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.pcmis a[]bytegrown byappendon every frame. Nothing bounds itexcept
MaxDuration. At the default that is roughly 230 MB of live heap. It sits inside the processthat owns the database, the IPC socket and the resident model. On the deploy target that memory competes with the llama context.
Stopthen makes it worse at the moment of peak use.audio.WAVFromPCMbuildsa second buffer of the same size, so the two live together at about 460 MB. The
appendgrowth pattern is also amortised doubling, so the transient during agrow 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
Putthe path or a reader, thentranscribe by reading windows back off disk.
chunkAudioalready slices byoffset, 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:
The rung is the same, but the rung is not an owner.
CaptureStopReqcarries nosession id and
Recorderhas one global slot. So any module enrolled atAuthWrite can call
capture_stopon a session it did not start, andCaptureStopResphands it backTranscriptin full.Walked through. Kami starts a meeting from the PWA.
mavpollholds AuthWrite soit 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 inthe response body, and the PWA sees the session vanish.
Canonly doessource-scope matching for
WriteFact, so nothing else narrows this.Return a session token from
CaptureStartand require it onAppendandStop. That is a small change. It makes the "one at a time" rule enforceable against asecond 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:
Checked on every
Appendis exactly the limit. A client that stops callingAppendnever trips the cap. The session stays inr.currentwithexpiredfalse,
Statuskeeps reportingRunning: true, and every laterStartreturnsErrBusy. Nothing else touches the slot, so the recorder is wedged until mavendrestarts.
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 timeKami 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.StartedinStatusandStart, or a ticker that expires a stale session. The frame-drivencheck on its own is not a cap.
Smaller notes
Appendvalidatesa.Format.IsValid()and never compares it againsts.format, whichStartfixed ataudio.PCM16kMono. A client that switchessample rate mid-session gets its frames concatenated into the same buffer.
duration()then reads the whole thing at the original rate. The stored WAVheader lies and the cap fires at the wrong length. One equality check closes
it.
Summarizereturnsjoinedwith its error. The argument in the comment is that per-chunk summaries are real
work and should be handed over rather than lost.
Stopdoesif err != nil { return res, ... }before assigningres.Summary, so the salvaged text is discarded.Assign the summary before checking the error.
transcribereturns on thefirst 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.
CaptureStopRespsays a blob with notranscript 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 sentencedescribes work that does not exist yet.
save_transcriptis false,writeNoteswritesnothing. 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_stopholds the IPC request open for up tocaptureSummaryTimeout,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.
writeNoteembeds the summary withEmbedPassageand stores it, so whatother 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 configcomment, which currently frames the choice as disk retention only.
Summarizeis appended in Russian to the notetext. It will be embedded along with the summary. Harmless, and it does put a
fixed boilerplate string into the vector for every long meeting.
again, a format change mid-session, and
StopwhenPutfails while STTwould have worked.
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