Add meeting capture with explicit start and stop (#253)
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
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// DTOs — wire-level data. Decoupled from internal/store so the protocol is
|
||||
@@ -221,6 +223,84 @@ type DescribeImageResp struct {
|
||||
NoteID int64 `json:"note_id,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStartReq — begin recording a meeting (Vikunja #253).
|
||||
//
|
||||
// Label is what the meeting is called ("встреча с подрядчиком"); it goes into
|
||||
// the summary note so the note is findable later. Empty is allowed.
|
||||
//
|
||||
// There is no "auto", no keyword and no schedule in this request, and there will
|
||||
// not be: the only way audio enters the recorder is a client that was told to
|
||||
// start, appending frames it was told to append. All four capture methods answer
|
||||
// ErrUnknownMethod unless the operator enabled a capture block, so a surface
|
||||
// cannot start a recording by asking nicely.
|
||||
type CaptureStartReq struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStartResp — the session that opened. MaxSeconds is the hard cap after
|
||||
// which it stops itself; the caller tells him, so a forgotten recording is his
|
||||
// own informed choice rather than a surprise.
|
||||
type CaptureStartResp struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
Started time.Time `json:"started"`
|
||||
MaxSeconds int `json:"max_seconds"`
|
||||
}
|
||||
|
||||
// CaptureAppendReq — one chunk of audio for the running session. Refused with
|
||||
// "nothing is being recorded" when no session is open, which is the guard that
|
||||
// makes an ambient path impossible: audio arriving at an idle core is dropped on
|
||||
// the floor, not buffered "just in case".
|
||||
type CaptureAppendReq struct {
|
||||
Audio audio.Audio `json:"audio"`
|
||||
}
|
||||
|
||||
// CaptureAppendResp — how much has been collected, so a client can show a timer
|
||||
// and notice the cap coming. Expired means the session hit its limit and closed;
|
||||
// stop sending and call capture_stop, the audio so far is kept.
|
||||
type CaptureAppendResp struct {
|
||||
Seconds float64 `json:"seconds"`
|
||||
Expired bool `json:"expired,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStopReq — end the running session.
|
||||
//
|
||||
// Discard throws the recording away without transcribing, storing or
|
||||
// summarising anything. This is what "забудь, не записывай" maps to, and it is a
|
||||
// flag rather than a separate method so the client that says "stop" and the
|
||||
// client that says "stop and forget" take the same path to the same session.
|
||||
type CaptureStopReq struct {
|
||||
Discard bool `json:"discard,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStopResp — the finished capture. BlobID is the stored WAV, kept under
|
||||
// media.retention like any other blob and pruned with it.
|
||||
//
|
||||
// A response with a Transcript and an empty Summary is a degraded success: the
|
||||
// words exist, only the model failed. A response with a BlobID and neither is
|
||||
// the audio surviving a transcription failure — the same id can be run again.
|
||||
// Discarded is true when nothing was kept.
|
||||
type CaptureStopResp struct {
|
||||
BlobID string `json:"blob_id,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Started time.Time `json:"started,omitempty"`
|
||||
Seconds float64 `json:"seconds,omitempty"`
|
||||
Transcript string `json:"transcript,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Chunks int `json:"chunks,omitempty"`
|
||||
NoteID int64 `json:"note_id,omitempty"`
|
||||
Discarded bool `json:"discarded,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureStatusResp — what "что ты записываешь?" needs, and what /dash shows.
|
||||
// Running=false with everything else empty is the normal state.
|
||||
type CaptureStatusResp struct {
|
||||
Running bool `json:"running"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Started time.Time `json:"started,omitempty"`
|
||||
Seconds float64 `json:"seconds,omitempty"`
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// SwapModelReq — load another resident model without restarting the daemon
|
||||
// (Vikunja #250). ModelPath must be one of the paths in phraser.swap_models;
|
||||
// anything else is ErrForbidden, and an unconfigured allowlist makes the whole
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// The load-bearing default for the most invasive capability Maven has: on a core
|
||||
// that was never configured to record, there is no wire path that starts a
|
||||
// recording, feeds one, or harvests one. Every one of the four methods refuses.
|
||||
func TestCapture_OffUnlessConfigured(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := cli.CaptureStart(ctx, CaptureStartReq{Label: "встреча"}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("CaptureStart error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if _, err := cli.CaptureAppend(ctx, CaptureAppendReq{}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("CaptureAppend error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if _, err := cli.CaptureStop(ctx, CaptureStopReq{}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("CaptureStop error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if _, err := cli.CaptureStatus(ctx); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Errorf("CaptureStatus error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
}
|
||||
|
||||
// With the hooks wired, a whole session crosses the boundary intact: the label
|
||||
// out, the audio in, the summary back.
|
||||
func TestCapture_RoundTrip(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
started := time.Now().UTC().Truncate(time.Second)
|
||||
var gotLabel string
|
||||
var gotBytes int
|
||||
var gotDiscard bool
|
||||
|
||||
srv.CaptureStartFn = func(_ context.Context, req CaptureStartReq) (CaptureStartResp, error) {
|
||||
gotLabel = req.Label
|
||||
return CaptureStartResp{Label: req.Label, Started: started, MaxSeconds: 7200}, nil
|
||||
}
|
||||
srv.CaptureAppendFn = func(_ context.Context, req CaptureAppendReq) (CaptureAppendResp, error) {
|
||||
gotBytes = len(req.Audio.Bytes)
|
||||
return CaptureAppendResp{Seconds: 1.5}, nil
|
||||
}
|
||||
srv.CaptureStopFn = func(_ context.Context, req CaptureStopReq) (CaptureStopResp, error) {
|
||||
gotDiscard = req.Discard
|
||||
return CaptureStopResp{BlobID: "abc", Summary: "— решили купить насос", Chunks: 1}, nil
|
||||
}
|
||||
srv.CaptureStatusFn = func(context.Context) (CaptureStatusResp, error) {
|
||||
return CaptureStatusResp{Running: true, Label: "встреча", Seconds: 1.5}, nil
|
||||
}
|
||||
|
||||
start, err := cli.CaptureStart(ctx, CaptureStartReq{Label: "встреча с подрядчиком"})
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureStart: %v", err)
|
||||
}
|
||||
if gotLabel != "встреча с подрядчиком" || start.MaxSeconds != 7200 {
|
||||
t.Errorf("start = %+v (label seen: %q)", start, gotLabel)
|
||||
}
|
||||
if !start.Started.Equal(started) {
|
||||
t.Errorf("started = %v, want %v", start.Started, started)
|
||||
}
|
||||
|
||||
// Audio must survive the JSON round trip byte for byte — a base64 mistake
|
||||
// here would be silence in the transcript, not a visible error.
|
||||
pcm := []byte{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
ap, err := cli.CaptureAppend(ctx, CaptureAppendReq{
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureAppend: %v", err)
|
||||
}
|
||||
if gotBytes != len(pcm) {
|
||||
t.Errorf("%d bytes arrived, sent %d", gotBytes, len(pcm))
|
||||
}
|
||||
if ap.Seconds != 1.5 || ap.Expired {
|
||||
t.Errorf("append resp = %+v", ap)
|
||||
}
|
||||
|
||||
st, err := cli.CaptureStatus(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureStatus: %v", err)
|
||||
}
|
||||
if !st.Running || st.Label != "встреча" {
|
||||
t.Errorf("status = %+v", st)
|
||||
}
|
||||
|
||||
stop, err := cli.CaptureStop(ctx, CaptureStopReq{})
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureStop: %v", err)
|
||||
}
|
||||
if gotDiscard {
|
||||
t.Error("a plain stop arrived as a discard")
|
||||
}
|
||||
if stop.BlobID != "abc" || stop.Summary == "" {
|
||||
t.Errorf("stop = %+v", stop)
|
||||
}
|
||||
}
|
||||
|
||||
// "забудь, не записывай" has to reach core as a discard, not as an ordinary
|
||||
// stop that quietly keeps everything.
|
||||
func TestCapture_DiscardCrossesTheWire(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
var gotDiscard bool
|
||||
srv.CaptureStopFn = func(_ context.Context, req CaptureStopReq) (CaptureStopResp, error) {
|
||||
gotDiscard = req.Discard
|
||||
return CaptureStopResp{Discarded: req.Discard}, nil
|
||||
}
|
||||
resp, err := cli.CaptureStop(context.Background(), CaptureStopReq{Discard: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureStop: %v", err)
|
||||
}
|
||||
if !gotDiscard || !resp.Discarded {
|
||||
t.Errorf("discard lost: sent true, core saw %v, resp %+v", gotDiscard, resp)
|
||||
}
|
||||
}
|
||||
@@ -473,6 +473,48 @@ func (c *Client) DescribeImage(ctx context.Context, req DescribeImageReq) (Descr
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CaptureStart begins recording a meeting (Vikunja #253). ErrUnknownMethod
|
||||
// means the operator has not enabled capture — the caller should say so and stop
|
||||
// asking, not retry.
|
||||
func (c *Client) CaptureStart(ctx context.Context, req CaptureStartReq) (CaptureStartResp, error) {
|
||||
var r CaptureStartResp
|
||||
if err := c.call(ctx, MethodCaptureStart, req, &r); err != nil {
|
||||
return CaptureStartResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CaptureAppend hands one chunk of audio to the running session. An error means
|
||||
// the frame was not kept: either nothing is being recorded, or the session hit
|
||||
// its time limit. Either way the client stops sending.
|
||||
func (c *Client) CaptureAppend(ctx context.Context, req CaptureAppendReq) (CaptureAppendResp, error) {
|
||||
var r CaptureAppendResp
|
||||
if err := c.call(ctx, MethodCaptureAppend, req, &r); err != nil {
|
||||
return CaptureAppendResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CaptureStop ends the session. Slow — it transcribes and summarises the whole
|
||||
// recording — so pass a context with room. Set Discard to throw the recording
|
||||
// away instead.
|
||||
func (c *Client) CaptureStop(ctx context.Context, req CaptureStopReq) (CaptureStopResp, error) {
|
||||
var r CaptureStopResp
|
||||
if err := c.call(ctx, MethodCaptureStop, req, &r); err != nil {
|
||||
return CaptureStopResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CaptureStatus reports the running session, if any.
|
||||
func (c *Client) CaptureStatus(ctx context.Context) (CaptureStatusResp, error) {
|
||||
var r CaptureStatusResp
|
||||
if err := c.call(ctx, MethodCaptureStatus, nil, &r); err != nil {
|
||||
return CaptureStatusResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// SwapModel asks core to load another resident model (Vikunja #250).
|
||||
// ErrUnknownMethod means core has no phraser.swap_models allowlist configured;
|
||||
// ErrForbidden means the path is not on it, or step-up was not asserted. A
|
||||
|
||||
+78
-4
@@ -459,6 +459,20 @@ type Server struct {
|
||||
// other CoreAPI implementation should have to carry it.
|
||||
DescribeImageFn DescribeImageFunc
|
||||
|
||||
// Capture* — the meeting recorder (Vikunja #253). Set by the daemon only
|
||||
// when a media store is configured AND capture.enabled is true; nil ⇒ all
|
||||
// four methods answer ErrUnknownMethod. That is the load-bearing default for
|
||||
// this capability: on an unconfigured box there is no wire path that begins a
|
||||
// recording, so nothing can be recorded by accident, by a bug in a surface,
|
||||
// or by a model deciding it would be helpful.
|
||||
//
|
||||
// They bypass CoreAPI because a recorder needs a blob store, an STT worker
|
||||
// and a llama-server, none of which is a store operation.
|
||||
CaptureStartFn CaptureStartFunc
|
||||
CaptureAppendFn CaptureAppendFunc
|
||||
CaptureStopFn CaptureStopFunc
|
||||
CaptureStatusFn CaptureStatusFunc
|
||||
|
||||
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
||||
// the passkey credential public key, opens the encrypted store, and wires
|
||||
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
||||
@@ -489,6 +503,13 @@ type IngestMailFunc func(ctx context.Context, req IngestMailReq) (IngestMailResp
|
||||
// DescribeImageFunc — core-side image intake + description.
|
||||
type DescribeImageFunc func(ctx context.Context, req DescribeImageReq) (DescribeImageResp, error)
|
||||
|
||||
// CaptureStartFunc / CaptureAppendFunc / CaptureStopFunc / CaptureStatusFunc —
|
||||
// the four core-side halves of the meeting recorder.
|
||||
type CaptureStartFunc func(ctx context.Context, req CaptureStartReq) (CaptureStartResp, error)
|
||||
type CaptureAppendFunc func(ctx context.Context, req CaptureAppendReq) (CaptureAppendResp, error)
|
||||
type CaptureStopFunc func(ctx context.Context, req CaptureStopReq) (CaptureStopResp, error)
|
||||
type CaptureStatusFunc func(ctx context.Context) (CaptureStatusResp, error)
|
||||
|
||||
// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check
|
||||
// satisfies this); dispatch calls it once per request after param-unmarshal
|
||||
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
||||
@@ -657,10 +678,11 @@ func withoutParams[R any](fn func(ctx context.Context, api CoreAPI) (R, error))
|
||||
// is still honored on the very next request with no extra plumbing here.
|
||||
//
|
||||
// MethodAssertStepUp, MethodStoreEncryptionKey, MethodUnlock,
|
||||
// MethodIngestMail, MethodSwapModel, MethodModelStatus and
|
||||
// MethodDescribeImage are NOT in this table: they bypass CoreAPI entirely
|
||||
// (s.StepUp / s.WrapKeyFn / s.UnlockFn / s.IngestMailFn / s.DescribeImageFn),
|
||||
// so dispatch special-cases them before consulting the table.
|
||||
// MethodIngestMail, MethodSwapModel, MethodModelStatus,
|
||||
// MethodDescribeImage and the four MethodCapture* methods are NOT in this
|
||||
// table: they bypass CoreAPI entirely (s.StepUp / s.WrapKeyFn / s.UnlockFn /
|
||||
// s.IngestMailFn / s.DescribeImageFn / s.Capture*Fn), so dispatch
|
||||
// special-cases them before consulting the table.
|
||||
var methodTable = map[Method]handlerFunc{
|
||||
MethodWriteFact: withParams(func(ctx context.Context, api CoreAPI, p WriteFactReq) (idResp, error) {
|
||||
id, err := api.WriteFact(ctx, p)
|
||||
@@ -950,6 +972,58 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodCaptureStart:
|
||||
if s.CaptureStartFn != nil {
|
||||
var p CaptureStartReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.CaptureStartFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodCaptureAppend:
|
||||
if s.CaptureAppendFn != nil {
|
||||
var p CaptureAppendReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.CaptureAppendFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodCaptureStop:
|
||||
if s.CaptureStopFn != nil {
|
||||
var p CaptureStopReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.CaptureStopFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodCaptureStatus:
|
||||
if s.CaptureStatusFn != nil {
|
||||
resp, err := s.CaptureStatusFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodModelStatus:
|
||||
if s.ModelStatusFn != nil {
|
||||
resp, err := s.ModelStatusFn(ctx)
|
||||
|
||||
@@ -55,6 +55,10 @@ const (
|
||||
MethodSwapModel Method = "swap_model"
|
||||
MethodModelStatus Method = "model_status"
|
||||
MethodDescribeImage Method = "describe_image"
|
||||
MethodCaptureStart Method = "capture_start"
|
||||
MethodCaptureAppend Method = "capture_append"
|
||||
MethodCaptureStop Method = "capture_stop"
|
||||
MethodCaptureStatus Method = "capture_status"
|
||||
)
|
||||
|
||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||
|
||||
Reference in New Issue
Block a user