7c7bd8ceeb
Maven can now be told who someone is. She cannot yet tell who is speaking, and this commit is careful to say so rather than pretend otherwise. What works: profiles are enrolled from several deliberately recorded samples, listed, and deleted. They live in the existing memory_vectors table under a "speaker:" id prefix, so there is no migration; what that needed was a wider interface than memory.Store, hence memory.Catalog with ByPrefix and Delete. Delete is the load-bearing half — a voiceprint someone asked to be rid of has to actually go, and a search-only store cannot do that. InMemoryStore.Insert became an upsert by id to match what the persistent store already did. What does not work, and why it is not faked: there is no speaker-embedding model on this box. Sixteen ggufs in /mnt/hdd1/llms, all text; no ECAPA, no x-vector, no titanet, no wespeaker, no .onnx anywhere under /mnt/hdd1. So newSpeakerEmbedder returns nil, internal/speaker falls back to speaker.Disabled, Identify answers ErrDisabled, and the daemon logs which half is off at startup. The plan's "simple MFCC + GMM" floor is refused in the package comment: MFCC cosine distance detects channel and loudness as much as voice, and a biometric that is confidently wrong writes false claims about named people into his memory. A bad floor is worse than none here. Refused as well, and the reason is in enroll.go's doc comment: the plan asked for unknown speakers to be enrolled on first interaction with a TTS "кто это?". There is no request shape in the protocol that could express that. Taking a biometric of whoever walks past the microphone does it to guests who are not party to the exchange, and a synthesised question into a room is not consent from whoever answers. Authority: enrolment is AuthStepUp, because it is a deliberate sit-down act that writes a biometric of a named person and never something done by voice mid-conversation. Deletion is one rung lower at AuthWrite, deliberately inverting the usual pattern — getting rid of a biometric must never be the harder half. Listing is AuthRead and never returns the vectors themselves. Off unless configured: no speaker block means the three methods answer ErrUnknownMethod, so a default box has no wire path that takes a voiceprint. make build and make test pass. Vikunja #255 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
653 lines
21 KiB
Go
653 lines
21 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).
|
|
//
|
|
// Cold-start unlock (2026-07-06):
|
|
//
|
|
// When a passkey credential is enrolled AND no env key is set, the daemon
|
|
// starts in LOCKED mode: the IPC server runs but rejects all store methods
|
|
// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed
|
|
// by MethodUnlock (with the same credential's public key) unwraps the at-rest
|
|
// AES-256 key from a wrapped blob on disk (HKDF-SHA256 + AES-GCM) and opens
|
|
// the encrypted store. After unlock, the daemon wires voice, loop, and
|
|
// delivery and runs normally.
|
|
//
|
|
// Fallback: when db_key_env is set (or no wrapped file exists), the daemon
|
|
// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey
|
|
// while unlocked calls MethodStoreEncryptionKey to wrap the env key and
|
|
// persist the wrapped blob — enabling cold-start unlock on the next boot
|
|
// after the env key is removed.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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/persona"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/store"
|
|
"github.com/kami/maven/internal/webauthn"
|
|
)
|
|
|
|
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
|
|
|
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode.
|
|
// In locked mode, all CoreAPI methods return errLocked. The unlock path
|
|
// replaces the CoreAPI with the real store adapter and flips the flag.
|
|
type daemonLock struct {
|
|
mu sync.Mutex
|
|
locked bool
|
|
}
|
|
|
|
func newDaemonLock(locked bool) *daemonLock {
|
|
return &daemonLock{locked: locked}
|
|
}
|
|
|
|
func (l *daemonLock) isLocked() bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.locked
|
|
}
|
|
|
|
func (l *daemonLock) unlock() {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.locked = false
|
|
}
|
|
|
|
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")
|
|
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
|
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap)")
|
|
flag.CommandLine.Parse(args)
|
|
reembedOnStart = *reembed
|
|
cfg, err := config.Load(*cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
|
defer stop()
|
|
|
|
// ----- encryption key: env key → wrapped key → plaintext -----
|
|
// Priority: env key (from config/docker) > wrapped key (cold-start unlock) > plaintext (dev/CI).
|
|
// When a wrapped key file exists AND no env key is set, the daemon starts
|
|
// LOCKED and waits for a passkey assertion to unwrap it.
|
|
envKey, err := cfg.DBEncryptionKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
wrappedExists := false
|
|
if *wrappedKeyPath != "" {
|
|
if _, err := os.Stat(*wrappedKeyPath); err == nil {
|
|
wrappedExists = true
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("check wrapped key: %w", err)
|
|
}
|
|
} else {
|
|
// default path alongside the config
|
|
defaultWrapped := cfg.DefaultWrappedKeyPath()
|
|
if _, err := os.Stat(defaultWrapped); err == nil {
|
|
*wrappedKeyPath = defaultWrapped
|
|
wrappedExists = true
|
|
}
|
|
}
|
|
|
|
locked := wrappedExists && envKey == nil
|
|
dl := newDaemonLock(locked)
|
|
|
|
var st *store.Store
|
|
var envKeyBytes []byte // kept for WrapKeyFn (enrollment wraps this key)
|
|
|
|
if !locked {
|
|
// Normal boot: env key or plaintext (dev/CI)
|
|
if envKey != nil {
|
|
envKeyBytes = make([]byte, len(envKey))
|
|
copy(envKeyBytes, envKey)
|
|
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, envKey)
|
|
} else {
|
|
st, err = store.Open(ctx, cfg.DBPath)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("open store: %w", err)
|
|
}
|
|
defer st.Close()
|
|
}
|
|
|
|
// ----- daemon components (only wired when unlocked) -----
|
|
// Pre-declare so the unlock path can wire them later.
|
|
var (
|
|
gatherer *loop.Gatherer
|
|
rules []loop.Rule
|
|
phr phraser.Phraser
|
|
voiceW *voiceWiring
|
|
dispatcher *delivery.Dispatcher
|
|
tl *tickLoop
|
|
coreAPI ipc.CoreAPI
|
|
eco *ecosystemWiring
|
|
factWorker *factEnrichmentWorker
|
|
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
|
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
|
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
|
|
)
|
|
|
|
if !locked {
|
|
rules = loop.DefaultRules()
|
|
gatherer = loop.NewGatherer(st, rules)
|
|
if cfg.QuietHours != nil {
|
|
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
|
}
|
|
|
|
// phraser
|
|
phr = 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),
|
|
LLMNudges: cfg.Phraser.LLMNudges,
|
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// ecosystem — nexus + hexis + praxis (all over HTTP; no direct DB access)
|
|
eco = wireEcosystem(cfg)
|
|
|
|
// voice
|
|
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
|
if err != nil {
|
|
return fmt.Errorf("wire voice: %w", err)
|
|
}
|
|
|
|
// delivery
|
|
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
|
|
}
|
|
var voiceSink delivery.Sink
|
|
if voiceW != nil {
|
|
voiceSink = voiceW.voiceSink
|
|
}
|
|
// A crashed prior run may have left "pending" delivery attempts (send
|
|
// may have landed externally, then the process died before recording
|
|
// it) — reconcile them to "unknown" before the tick loop resumes
|
|
// sending, so nothing auto-resends into that ambiguity.
|
|
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
|
log.Printf("delivery outbox reconcile: %v", err)
|
|
}
|
|
dispatcher = delivery.NewDispatcher(delivery.Config{
|
|
Ntfy: ntfy,
|
|
Telegram: telegram,
|
|
Voice: voiceSink,
|
|
Ack: st,
|
|
Nudges: st,
|
|
Reminders: st,
|
|
Outbox: st,
|
|
})
|
|
|
|
// tick loop
|
|
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, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
|
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
|
crawlWkr = newCrawlWorker(newCrawler(cfg), ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
|
|
|
coreAPI = &daemonAPI{
|
|
CoreAPI: ipc.NewStoreAPI(st),
|
|
getTrace: tl.trace,
|
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
|
}
|
|
if voiceW != nil && voiceW.handler != nil {
|
|
api := coreAPI.(*daemonAPI)
|
|
api.chatFn = voiceW.handler.handleText
|
|
}
|
|
if voiceW != nil && voiceW.mcp != nil {
|
|
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
|
|
}
|
|
} else {
|
|
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
|
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
|
// refused before it reaches this value. This is just a safe non-nil
|
|
// placeholder: if the guard is ever bypassed by a bug, calls land
|
|
// here and fail loudly with ipc.ErrNotImplemented instead of a nil
|
|
// dereference or, worse, silently succeeding.
|
|
coreAPI = ipc.UnimplementedCoreAPI{}
|
|
}
|
|
|
|
// ----- IPC boundary (core ↔ modules) -----
|
|
srv, err := ipc.Listen(cfg.SocketPath, coreAPI)
|
|
if err != nil {
|
|
return fmt.Errorf("ipc listen: %w", err)
|
|
}
|
|
|
|
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
|
|
|
|
// Set Server.Check — the single authorization guard, run once by
|
|
// Server.dispatch before any CoreAPI method is called (see
|
|
// internal/ipc/server.go). In locked mode this is the ONLY thing
|
|
// standing between an unauthenticated caller and the store: it must
|
|
// default-deny, with an explicit allowlist for the two methods the
|
|
// unlock flow itself needs (MethodAssertStepUp, MethodUnlock — neither
|
|
// of which touches CoreAPI; dispatch handles them directly via
|
|
// srv.StepUp/srv.UnlockFn). Forgetting to allowlist a new unlock-path
|
|
// method fails safe (denied); forgetting to guard a new CoreAPI method
|
|
// is impossible because there is nothing left to forget — every method
|
|
// not in the allowlist is refused by construction.
|
|
if locked {
|
|
srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error {
|
|
switch m {
|
|
case ipc.MethodAssertStepUp, ipc.MethodUnlock:
|
|
return nil // allowed in locked mode
|
|
default:
|
|
return errLocked
|
|
}
|
|
}
|
|
} else {
|
|
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
|
}
|
|
|
|
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
|
|
|
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
|
|
// configured and there is a llama-server to extract with, in which case
|
|
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
|
if !locked {
|
|
wireMailIntake(srv, st, phr, cfg)
|
|
wireModelSwap(srv, phr, cfg)
|
|
// Vision + the media blob store (Vikunja #252). Both stay dark without a
|
|
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
|
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
|
|
// The meeting recorder (Vikunja #253) shares that blob store and its
|
|
// retention loop. Off unless a capture block enables it, in which case
|
|
// all four capture methods answer ErrUnknownMethod.
|
|
wireCapture(srv, keeper, st, voiceW, phr, cfg)
|
|
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
|
// speaker-embedding model exists on disk; off entirely without a speaker
|
|
// block, so no wire path takes a voiceprint on a default box.
|
|
wireSpeaker(srv, st, cfg)
|
|
}
|
|
|
|
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
|
// persists the wrapped blob. Only wired when the daemon has the key in
|
|
// memory (env key mode). Called by mavweb after passkey enrollment.
|
|
if envKeyBytes != nil {
|
|
srv.WrapKeyFn = func(ctx context.Context, publicKey []byte) error {
|
|
blob, err := webauthn.WrapKey(envKeyBytes, publicKey)
|
|
if err != nil {
|
|
return fmt.Errorf("wrap encryption key: %w", err)
|
|
}
|
|
wp := *wrappedKeyPath
|
|
if wp == "" {
|
|
wp = cfg.DefaultWrappedKeyPath()
|
|
}
|
|
if err := os.WriteFile(wp, blob, 0o600); err != nil {
|
|
return fmt.Errorf("write wrapped key: %w", err)
|
|
}
|
|
log.Printf("mavend: wrapped encryption key with passkey credential (%d bytes)", len(blob))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// UnlockFn — cold-start unlock: unwraps the encryption key from the wrapped
|
|
// blob using the passkey credential public key, opens the store, wires all
|
|
// daemon components, and replaces the locked API.
|
|
if locked {
|
|
srv.UnlockFn = func(ctx context.Context, publicKey []byte) error {
|
|
wp := *wrappedKeyPath
|
|
blob, err := os.ReadFile(wp)
|
|
if err != nil {
|
|
return fmt.Errorf("read wrapped key: %w", err)
|
|
}
|
|
key, err := webauthn.UnwrapKey(blob, publicKey)
|
|
if err != nil {
|
|
return fmt.Errorf("unwrap key: %w", err)
|
|
}
|
|
// Open the store with the unwrapped key.
|
|
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
|
if err != nil {
|
|
return fmt.Errorf("unlock store: %w", err)
|
|
}
|
|
|
|
// Wire everything.
|
|
rules = loop.DefaultRules()
|
|
gatherer = loop.NewGatherer(st, rules)
|
|
if cfg.QuietHours != nil {
|
|
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
|
}
|
|
|
|
phr = 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),
|
|
LLMNudges: cfg.Phraser.LLMNudges,
|
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
|
}
|
|
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
|
|
}
|
|
phr, err = phraser.NewLLMPhraser(ctx, pc)
|
|
if err != nil {
|
|
return fmt.Errorf("phraser: %w", err)
|
|
}
|
|
}
|
|
|
|
eco = wireEcosystem(cfg)
|
|
|
|
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
|
if err != nil {
|
|
return fmt.Errorf("wire voice: %w", err)
|
|
}
|
|
|
|
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
|
|
}
|
|
var voiceSink delivery.Sink
|
|
if voiceW != nil {
|
|
voiceSink = voiceW.voiceSink
|
|
}
|
|
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
|
log.Printf("delivery outbox reconcile: %v", err)
|
|
}
|
|
dispatcher = delivery.NewDispatcher(delivery.Config{
|
|
Ntfy: ntfy,
|
|
Telegram: telegram,
|
|
Voice: voiceSink,
|
|
Ack: st,
|
|
Nudges: st,
|
|
Reminders: st,
|
|
Outbox: st,
|
|
})
|
|
|
|
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, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
|
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
|
crawlWkr = newCrawlWorker(newCrawler(cfg), ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
|
|
|
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
|
newAPI := &daemonAPI{
|
|
CoreAPI: ipc.NewStoreAPI(st),
|
|
getTrace: tl.trace,
|
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
|
}
|
|
if voiceW != nil && voiceW.handler != nil {
|
|
newAPI.chatFn = voiceW.handler.handleText
|
|
}
|
|
srv.SetAPI(newAPI)
|
|
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
|
wireMailIntake(srv, st, phr, cfg)
|
|
wireModelSwap(srv, phr, cfg)
|
|
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
|
|
wireCapture(srv, keeper, st, voiceW, phr, cfg)
|
|
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
|
// speaker-embedding model exists on disk; off entirely without a speaker
|
|
// block, so no wire path takes a voiceprint on a default box.
|
|
wireSpeaker(srv, st, cfg)
|
|
|
|
// Start voice server.
|
|
if voiceW != nil {
|
|
var wg sync.WaitGroup
|
|
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())
|
|
}
|
|
|
|
// Start tick loop.
|
|
go func() {
|
|
tl.run(ctx)
|
|
}()
|
|
|
|
// Start fact-entity enrichment worker.
|
|
go func() {
|
|
factWorker.run(ctx)
|
|
}()
|
|
|
|
// Start background memory evaluation (nil unless configured).
|
|
if evalWorker != nil {
|
|
go func() {
|
|
evalWorker.run(ctx)
|
|
}()
|
|
}
|
|
|
|
// Start feed reading (nil unless configured).
|
|
if feedWkr != nil {
|
|
go func() {
|
|
feedWkr.run(ctx)
|
|
}()
|
|
}
|
|
|
|
// Start the watched-page crawls (nil unless configured).
|
|
if crawlWkr != nil {
|
|
go func() {
|
|
crawlWkr.run(ctx)
|
|
}()
|
|
}
|
|
|
|
// Keep MCP connections alive (nil unless configured).
|
|
if voiceW != nil && voiceW.mcp != nil {
|
|
go voiceW.mcp.run(ctx)
|
|
}
|
|
|
|
dl.unlock()
|
|
log.Printf("mavend: unlocked via passkey assertion")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
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 !locked && 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())
|
|
}
|
|
|
|
if !locked {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
tl.run(ctx)
|
|
}()
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
factWorker.run(ctx)
|
|
}()
|
|
if evalWorker != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
evalWorker.run(ctx)
|
|
}()
|
|
}
|
|
if feedWkr != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
feedWkr.run(ctx)
|
|
}()
|
|
}
|
|
if crawlWkr != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
crawlWkr.run(ctx)
|
|
}()
|
|
}
|
|
if voiceW != nil && voiceW.mcp != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
voiceW.mcp.run(ctx)
|
|
}()
|
|
}
|
|
}
|
|
|
|
<-ctx.Done()
|
|
log.Printf("mavend: shutdown signal received")
|
|
if err := srv.Close(); err != nil {
|
|
log.Printf("ipc close: %v", err)
|
|
}
|
|
if voiceW != nil {
|
|
voiceW.close()
|
|
}
|
|
wg.Wait()
|
|
log.Printf("mavend: bye")
|
|
return nil
|
|
}
|
|
|
|
// personaFacts reads the optional, deployment-specific facts (his name, his
|
|
// city, the free-text persona string) out of the config. Everything here may
|
|
// be empty — the context block is correct without any of it.
|
|
func personaFacts(cfg *config.Config) persona.Facts {
|
|
f := persona.Facts{
|
|
// Telegram lives outside the voice block, so it counts either way.
|
|
Telegram: cfg.Telegram != nil && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
|
}
|
|
if cfg.Voice == nil {
|
|
return f
|
|
}
|
|
f.OwnerName = cfg.Voice.OwnerName
|
|
f.City = cfg.Voice.City
|
|
f.Static = cfg.Voice.Persona
|
|
// Same test wireVoice uses to pick the real provider over the stub.
|
|
f.Weather = cfg.Voice.Weather != nil && cfg.Voice.Weather.Provider == "open-meteo"
|
|
f.Tools = len(cfg.Voice.Tools) > 0
|
|
return f
|
|
}
|
|
|
|
// contextBlockFn returns the per-turn renderer of the shared context block.
|
|
// Per turn, not once at startup, because the block states the current time.
|
|
func contextBlockFn(cfg *config.Config, now func() time.Time) func() string {
|
|
f := personaFacts(cfg)
|
|
return func() string { return f.Block(now()) }
|
|
}
|