Files
Maven/cmd/mavend/main.go
T
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00

234 lines
7.5 KiB
Go

// Package main is mavend — maven's daemon.
//
// "core = the only key-holder": one process holds the unlocked store + the
// trigger loop; modules are separate processes, key-free, fail-independent.
// the daemon wires Store → Gatherer → Tick → phraser → delivery, runs the 60s
// ticker, owns the cold-start unlock dance, and exposes the CoreAPI boundary
// over a unix socket for modules (router/delivery/poller/...) to call.
//
// Floor (this file): pluggable seams wired with the deterministic Stubs.
// - phraser Stub (no LLM)
// - voice sink wired via wireVoice: embedder/classifier seeded with ~10
// examples across 5 intents; stt + tts stubs in-process by default,
// remote module sockets when configured; TCP listener on voice.bind.
// The voice sink (voicesink.Sink via Sessions) is wired into the
// dispatcher — the reactive path (push-to-talk) AND proactive nudges
// (care-when-present, sev3/sev4 present) both route through the same
// stt→router→tts→client pipeline.
// - auth FloorEnrollment + nil Session — cold-start unlock assumed: today
// the store opens plain sqlite (sqlcipher deferred). the daemon runs
// "unlocked" — the locked-until-asserted dance lands with the Session
// verifier + ask-password transport (open spec item).
//
// Everything wired here is swappable at the construction seam — every module
// behind an interface — without changing the loop. Wire a real phraser, voice
// sink, or session verifier, and the daemon's main loop is unchanged.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/kami/maven/internal/auth"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/delivery/ntfysink"
"github.com/kami/maven/internal/delivery/telegramsink"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/webauthn"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "mavend:", err)
os.Exit(1)
}
}
func run(args []string) error {
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
flag.CommandLine.Parse(args)
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer stop()
// ----- store (the unlocked handle; core = the only key-holder) -----
// The cold-start unlock dance (L3 passkey → key bytes) is not yet wired;
// today the key comes from config/env. When a key is present the on-disk
// file is ciphertext and we work on a tmpfs plaintext copy; no key ⇒
// plaintext store (dev/CI). A configured-but-broken key fails closed.
key, err := cfg.DBEncryptionKey()
if err != nil {
return err
}
var st *store.Store
if key != nil {
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
} else {
st, err = store.Open(ctx, cfg.DBPath)
}
if err != nil {
return fmt.Errorf("open store: %w", err)
}
defer st.Close()
// ----- loop: rules + gatherer -----
rules := loop.DefaultRules()
gatherer := loop.NewGatherer(st, rules)
if cfg.QuietHours != nil {
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
}
// ----- phraser (LLM-backed when configured, Stub floor otherwise) -----
var phr phraser.Phraser = phraser.NewStub()
if cfg.Phraser != nil {
pc := phraser.Config{
ModelPath: cfg.Phraser.ModelPath,
BinPath: cfg.Phraser.BinPath,
Listen: cfg.Phraser.Listen,
NGpuLayers: cfg.Phraser.NGpuLayers,
NCtx: cfg.Phraser.NCtx,
Timeout: time.Duration(cfg.Phraser.Timeout),
}
if pc.BinPath == "" {
pc.BinPath = "llama-server"
}
if pc.Listen == "" {
pc.Listen = "127.0.0.1:0"
}
if pc.NCtx <= 0 {
pc.NCtx = 2048
}
if pc.Timeout <= 0 {
pc.Timeout = 30 * time.Second
}
var err error
phr, err = phraser.NewLLMPhraser(ctx, pc)
if err != nil {
return fmt.Errorf("phraser: %w", err)
}
}
defer phr.Close()
// ----- voice: reactive audio path (TCP listener + stt/router/tts) -----
voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st), phr)
if err != nil {
return fmt.Errorf("wire voice: %w", err)
}
defer voiceW.close()
// ----- delivery: sinks + dispatcher -----
var ntfy delivery.Sink
if cfg.Ntfy != nil {
s, err := ntfysink.New(*cfg.Ntfy)
if err != nil {
return fmt.Errorf("wire ntfy sink: %w", err)
}
ntfy = s
}
var telegram delivery.Sink
if cfg.Telegram != nil {
s, err := telegramsink.New(*cfg.Telegram)
if err != nil {
return fmt.Errorf("wire telegram sink: %w", err)
}
telegram = s
}
// Voice sink: nil when voice is not enabled — the dispatcher's nil-sink
// path skips ChannelVoice silently, just like the pre-voice floor).
var voiceSink delivery.Sink
if voiceW != nil {
voiceSink = voiceW.voiceSink
}
dispatcher := delivery.NewDispatcher(delivery.Config{
Ntfy: ntfy,
Telegram: telegram,
Voice: voiceSink,
// AckTracker nil ⇒ repeat-til-ack disabled in the dispatcher. We
// drive repeats from store.UnackedTelegramRules + Dispatcher.RepeatUnacked
// below, which uses the nudges table's outcome=pending row itself as
// the ack-or-not state — the production ack source. The AckTracker
// interface stays reserved for an in-memory cache if the daemon ever
// wants to drive repeats without the SQL hit; the table IS the truth.
Nudges: st, // *store.Store satisfies delivery.NudgeRecorder
Reminders: st, // *store.Store satisfies delivery.ReminderCompleter
})
// ----- the proactive loop driver (60s ticker, lives HERE per spec) -----
tickInterval := time.Duration(cfg.TickInterval)
repeatInterval := time.Duration(cfg.RepeatInterval)
autotuneInterval := time.Duration(cfg.AutotuneInterval)
tl := newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest)
// ----- IPC boundary (core ↔ modules) -----
coreAPI := &daemonAPI{
CoreAPI: ipc.NewStoreAPI(st),
getTrace: tl.trace,
}
srv, err := ipc.Listen(cfg.SocketPath, coreAPI)
if err != nil {
return fmt.Errorf("ipc listen: %w", err)
}
// auth: Enrolled callers can assert step-up via MethodAssertStepUp (calls
// Session.Assert). After a successful passkey assertion, the session bumps
// to L3 for assertionTTL, enabling AuthStepUp methods (EnableTool).
// FloorEnrollment still trusts same-uid callers; the passkey verifier
// (WebAuthn) gates the session step-up, not the enrollment.
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if err := srv.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("ipc serve: %v", err)
}
}()
log.Printf("mavend: ipc listening on %s", srv.Path())
if voiceW != nil {
wg.Add(1)
go func() {
defer wg.Done()
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("voice serve: %v", err)
}
}()
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
}
wg.Add(1)
go func() {
defer wg.Done()
tl.run(ctx)
}()
<-ctx.Done()
log.Printf("mavend: shutdown signal received")
if err := srv.Close(); err != nil {
log.Printf("ipc close: %v", err)
}
wg.Wait()
log.Printf("mavend: bye")
return nil
}