// 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" ) 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) ----- 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) // ----- 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)) 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) loop := newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval) // ----- IPC boundary (core ↔ modules) ----- coreAPI := ipc.NewStoreAPI(st) srv, err := ipc.Listen(cfg.SocketPath, coreAPI) if err != nil { return fmt.Errorf("ipc listen: %w", err) } // auth floor: any same-uid caller is fully trusted (FloorEnrollment + // FloorSession — L3, step-up satisfied). The cold-start unlock dance and a // real passkey Session are the open spec items; today the daemon runs // unlocked — plain sqlite, sqlcipher deferred. FloorSession keeps the floor // consistent so the authed mavweb /tools page can EnableTool (AuthStepUp) // against the local socket; the passkey verifier swaps FloorSession later. srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: auth.FloorSession{}}).Check 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() loop.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 }