Files
Maven/cmd/mavend/capture.go
kami 612ca8cf1b grammar: the last two model calls that were still free text
The replier and the meeting summariser were the two call sites without a
GBNF. Both are exactly the shape that makes a Thinking variant answer with
its reasoning as prose, and neither had anything downstream that could
remove it.

The replier already parses {"response","mood"}, so it now sends the phraser's
grammar for that contract, exported once as phraser.ResponseGrammar so the
two definitions cannot drift.

The summariser stays text-in/text-out. The JSON wrapper is attached and
unwrapped in the daemon's Completer, so internal/capture is unchanged and a
Completer without a grammar still works.

The simulator told routing from phrasing by "has a grammar", which stopped
being true here; it now looks for the intent enum.

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

365 lines
14 KiB
Go

// mavend/capture.go — core's half of the meeting recorder (Vikunja #253,
// docs/plans/08-hearing.md).
//
// The split: a client that has a microphone (mavenclient, or a phone on the PWA)
// is told to start, streams frames over ipc.MethodCaptureAppend, and is told to
// stop. Core keeps the PCM, stores it as a WAV blob under the same media store
// and the same retention as images, transcribes it through the ONE STT Maven has
// (mavsttd's whisper.cpp, reused — not a second engine), and summarises the
// transcript on the resident model in windows that fit n_ctx 4096.
//
// # Off unless configured, twice over
//
// No `media` block ⇒ nowhere to keep audio ⇒ the four capture methods do not
// exist. No `capture` block with enabled ⇒ they still do not exist. On an
// unconfigured box there is no wire path that starts a recording, which is the
// only guarantee worth making about a capability like this one.
//
// # What this file refuses to do
//
// - Nothing listens. There is no VAD hook here, no wake-word branch, no
// "start when you hear a meeting". The plan document's keyword-triggered
// recorder is refused in internal/capture's package comment for the reason
// that applies here too: noticing a keyword requires listening, which is
// the behaviour this capability must not have.
// - No transcript note by default. The summary is written where he will read
// it; the verbatim record of what other people said takes a deliberate
// capture.save_transcript.
// - The transcript is never search input beyond this box, and the audio never
// leaves it at all.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/capture"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// captureSummaryTimeout — the budget for one summary, which is a map-reduce over
// the whole meeting: one model call per transcript window plus a reduce, each of
// which is seconds on this box. Forty windows is the configured ceiling, so the
// budget has to be minutes, not the 60s the reply path uses. It is spent on a
// background goroutine, never inside the capture_stop request: a client that
// asks Maven to stop recording gets the transcript back in seconds.
const captureSummaryTimeout = 20 * time.Minute
// summaryGrammar — GBNF pinning a summarisation call to one JSON object holding
// the summary and nothing else. Same reasoning as responseGrammar and memeval's
// evalGrammar: the resident model is a Thinking variant, and a summarisation
// prompt is exactly the shape that invites it to answer with its reasoning as
// plain text. Demanding JSON leaves the reasoning nowhere to go.
//
// The bound is 2000 characters, twice the phraser's, because a reduce step over
// a two-hour meeting is a paragraph and not a sentence. Newlines are escaped by
// the escape rule, so the bullet list the prompt asks for survives the wrapper.
const summaryGrammar = `
root ::= "{" ws "\"summary\"" ws ":" ws string ws "}"
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,2000} "\""
ws ::= [ \t\n]*
`
// llmCompleter adapts *llm.Client to capture.Completer. The pure package names
// the two strings it needs and stays free of the llm request struct; the client
// itself is the swap-aware one from llmClientFor, so a model swap re-points it.
//
// The JSON wrapper lives here, not in internal/capture: that package is
// text-in/text-out by design, and the map/reduce steps still see plain prose.
type llmCompleter struct {
c *llm.Client
maxTokens int
}
func (l llmCompleter) Complete(ctx context.Context, system, user string) (string, error) {
out, err := l.c.Complete(ctx, llm.Req{
System: system,
User: user,
Grammar: summaryGrammar,
MaxTokens: l.maxTokens,
})
if err != nil {
return "", err
}
return unwrapSummary(out), nil
}
// unwrapSummary takes the summary out of the JSON object the grammar produced.
// Anything that does not parse is returned as-is: an operator running without a
// grammar, or a llama-server too old to honour one, gets the plain text it used
// to get rather than an empty meeting summary.
func unwrapSummary(raw string) string {
s := stripThink(strings.TrimSpace(raw))
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end <= start {
return s
}
var parsed struct {
Summary string `json:"summary"`
}
if err := json.Unmarshal([]byte(s[start:end+1]), &parsed); err != nil {
return s
}
// An empty field is the model saying nothing, so hand back nothing. Returning
// the raw object here would write `{"summary":""}` into his notes.
return strings.TrimSpace(parsed.Summary)
}
// captureWiring — the recorder plus what it needs to write the result down.
type captureWiring struct {
rec *capture.Recorder
st *store.Store
emb router.Embedder
cfg *config.CaptureConfig
now func() time.Time
// ctx and wg belong to the daemon, not to the request. Summarising happens
// after the reply has gone out, so it needs a lifetime that outlives the
// call and a shutdown that waits for it.
ctx context.Context
wg *sync.WaitGroup
}
// newCaptureWiring returns nil when the recorder should not exist: no media
// store, no capture block, capture disabled, or no STT to transcribe with.
//
// A missing llama-server is NOT a reason to return nil. Without one the
// recording is still made, stored and transcribed, and the summary is simply
// absent — the honest degradation, and much better than refusing to record a
// meeting that is happening now.
func newCaptureWiring(ctx context.Context, wg *sync.WaitGroup, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
if keeper == nil || !cfg.Capture.Records() {
return nil
}
tr := transcriberOf(voiceW)
if tr == nil {
// Voice off ⇒ no STT client ⇒ nothing could turn the audio into words.
// Storing hours of unreadable audio of other people is worse than not
// recording, so this is a refusal, not a degradation.
log.Printf("capture: enabled but voice/stt is not wired — meeting capture disabled")
return nil
}
cc := cfg.Capture
var sum *capture.Summarizer
if lp, ok := phr.(*phraser.LLMPhraser); ok {
client := llmClientFor(lp, captureSummaryTimeout)
sum = capture.NewSummarizer(
llmCompleter{c: client, maxTokens: 512},
cc.ChunkRunes, cc.MaxChunks, contextBlockFn(cfg, time.Now),
)
} else {
log.Printf("capture: no llama-server phraser — meetings are transcribed, not summarised")
}
rec, err := capture.New(keeper.store, tr, sum, capture.Config{
MaxDuration: cc.MaxDuration(),
STTWindow: time.Duration(cc.STTWindow),
})
if err != nil {
log.Printf("capture: %v — meeting capture disabled", err)
return nil
}
log.Printf("capture: enabled, sessions capped at %s", rec.MaxDuration())
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now, ctx: ctx, wg: wg}
}
// start handles ipc.MethodCaptureStart.
func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.CaptureStartResp, error) {
s, err := c.rec.Start(req.Label)
if err != nil {
return ipc.CaptureStartResp{}, err
}
// The label is logged; nothing that was said ever is.
log.Printf("capture: started %q", s.Label)
return ipc.CaptureStartResp{
Label: s.Label,
Started: s.Started,
Token: s.Token,
MaxSeconds: int(c.rec.MaxDuration().Seconds()),
}, nil
}
// append handles ipc.MethodCaptureAppend. ErrExpired is reported as a successful
// response with Expired set rather than an error: the cap firing is the designed
// behaviour, and the client needs the flag to stop sending and call stop.
func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc.CaptureAppendResp, error) {
err := c.rec.Append(req.Token, req.Audio)
st := c.rec.Status()
if errors.Is(err, capture.ErrExpired) {
log.Printf("capture: %q hit the %s cap — stopping", st.Label, c.rec.MaxDuration())
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds(), Expired: true}, nil
}
if err != nil {
return ipc.CaptureAppendResp{}, err
}
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds()}, nil
}
// stop handles ipc.MethodCaptureStop.
//
// The error handling here mirrors vision's, and for the same reason: the audio is
// stored first, so a transcription failure returns what exists rather than
// nothing. A response can carry a blob id with no transcript (STT failed,
// re-runnable) — a degraded success, not an error to the caller.
//
// Summarising is NOT done here. A two-hour meeting is forty model calls, which
// on this box is minutes, and holding the IPC request open for them means the
// client that said "стоп" sits there with no answer while its own deadline runs
// out. Stop returns the transcript, and the summary note is written by a
// goroutine in the daemon's WaitGroup afterwards.
func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.CaptureStopResp, error) {
if req.Discard {
// "забудь, не записывай" — nothing is stored, transcribed or noted.
if !c.rec.Abort(req.Token) {
return ipc.CaptureStopResp{}, capture.ErrNoSession
}
log.Printf("capture: session discarded on request")
return ipc.CaptureStopResp{Discarded: true}, nil
}
res, err := c.rec.Stop(ctx, req.Token)
resp := ipc.CaptureStopResp{
BlobID: res.BlobID,
Label: res.Label,
Started: res.Started,
Seconds: res.Duration.Seconds(),
Transcript: res.Transcript,
Summary: res.Summary,
Chunks: res.Chunks,
}
if err != nil {
if res.BlobID == "" && res.Transcript == "" {
// Nothing survived: no session, or an empty recording. There is
// nothing to hand back, so this is a real error.
return ipc.CaptureStopResp{}, err
}
log.Printf("capture: %q partially finished: %v", res.Label, err)
}
c.summarizeLater(res)
log.Printf("capture: finished %q — %s of audio, %d bytes of transcript",
res.Label, res.Duration.Round(time.Second), len(res.Transcript))
return resp, nil
}
// summarizeLater runs the map-reduce and writes the notes after stop replied.
// The context is the daemon's, not the request's: the request is already
// answered, and cancelling the summary because the client hung up would throw
// away the only readable record of the meeting.
func (c *captureWiring) summarizeLater(res capture.Result) {
if res.Transcript == "" {
return
}
c.wg.Add(1)
go func() {
defer c.wg.Done()
ctx, cancel := context.WithTimeout(c.ctx, captureSummaryTimeout)
defer cancel()
if err := c.rec.Summarize(ctx, &res); err != nil {
// Not fatal: writeNotes falls back to the transcript, so a dead
// llama-server costs the summary and not the meeting.
log.Printf("capture: summary for %q failed: %v", res.Label, err)
}
if _, err := c.writeNotes(ctx, res); err != nil {
log.Printf("capture: note write for %q failed: %v", res.Label, err)
return
}
log.Printf("capture: summarised %q in %d chunk(s)", res.Label, res.Chunks)
}()
}
// writeNotes stores the summary as a note, and the transcript too when
// capture.save_transcript is set. Returns the id of the note that carries the
// meeting.
//
// With no summary the transcript is written instead, whatever save_transcript
// says. That flag is about keeping the verbatim record IN ADDITION to a summary,
// not about whether the meeting is remembered at all. Without this fallback a
// llama-server that was down at stop time meant an hour of recorded meeting left
// no note behind and nothing recalled it later.
//
// The note source carries the blob id, which is the only link back to the audio.
// When retention prunes the blob the note remains — words about a meeting are a
// far lighter thing to keep than a recording of it.
func (c *captureWiring) writeNotes(ctx context.Context, res capture.Result) (int64, error) {
source := "capture:meeting"
if res.BlobID != "" {
source = "capture:meeting:" + res.BlobID[:12]
}
var id int64
if text := res.Summary; text != "" {
var err error
id, err = c.writeNote(ctx, text, source)
if err != nil {
return 0, fmt.Errorf("summary note: %w", err)
}
} else if res.Transcript != "" {
var err error
id, err = c.writeNote(ctx, res.Transcript, source+":transcript")
if err != nil {
return 0, fmt.Errorf("transcript note: %w", err)
}
return id, nil
}
if c.cfg.SaveTranscript && res.Transcript != "" {
if _, err := c.writeNote(ctx, res.Transcript, source+":transcript"); err != nil {
return id, fmt.Errorf("transcript note: %w", err)
}
}
return id, nil
}
func (c *captureWiring) writeNote(ctx context.Context, text, source string) (int64, error) {
var vec []float32
if c.emb != nil {
// EmbedPassage, not Embed: this is text being searched FOR, and the e5
// embedder is asymmetric. Backwards here makes the meeting unfindable by
// the question that should have matched it.
var err error
vec, err = router.EmbedPassage(ctx, c.emb, text)
if err != nil {
return 0, fmt.Errorf("embed: %w", err)
}
}
return c.st.WriteNote(ctx, c.now(), text, vec, source)
}
// status handles ipc.MethodCaptureStatus.
func (c *captureWiring) status(_ context.Context) (ipc.CaptureStatusResp, error) {
st := c.rec.Status()
return ipc.CaptureStatusResp{
Running: st.Running,
Label: st.Label,
Started: st.Started,
Seconds: st.Duration.Seconds(),
Bytes: st.Bytes,
}, nil
}
// wireCapture installs the four IPC hooks, or leaves them nil so every capture
// method reports ErrUnknownMethod. Takes the media keeper wireVision already
// opened: one blob store, one retention loop, images and audio side by side.
func wireCapture(ctx context.Context, wg *sync.WaitGroup, srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
cw := newCaptureWiring(ctx, wg, keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
if cw == nil {
return
}
srv.CaptureStartFn = cw.start
srv.CaptureAppendFn = cw.append
srv.CaptureStopFn = cw.stop
srv.CaptureStatusFn = cw.status
}