Files
Maven/cmd/mavend/main.go
T
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

761 lines
28 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 that credential's WebAuthn PRF output) 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. That write happens once, when no blob
// exists; replacing an existing one takes an explicit request, see
// cmd/mavend/keyfile.go.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"sync"
"sync/atomic"
"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"
)
// stepUpTTL is how long one passkey assertion keeps the session stepped up.
// Long enough for the unlock call that follows it, short enough that a walked
// away laptop does not stay authorized.
const stepUpTTL = 5 * time.Minute
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
// owns the store handle the unlock path creates.
//
// The store matters here because of who runs when. In locked mode there is no
// store at boot; one is opened inside UnlockFn, on an IPC goroutine, minutes
// or days later. Shutdown runs on the main goroutine. Without a handoff the
// main goroutine has nothing to close, and store.Close is what re-encrypts
// the tmpfs working copy back over the ciphertext file — so a daemon that
// cold-started lost every write of that session, silently, on the next boot.
type daemonLock struct {
mu sync.Mutex
locked bool
st *store.Store
}
func newDaemonLock(locked bool) *daemonLock {
return &daemonLock{locked: locked}
}
func (l *daemonLock) isLocked() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.locked
}
// unlock flips the flag and takes ownership of the store opened by UnlockFn.
func (l *daemonLock) unlock(st *store.Store) {
l.mu.Lock()
defer l.mu.Unlock()
l.locked = false
l.st = st
}
// closeStore seals the store the unlock path opened, if any. Safe to call
// when the daemon never unlocked, and safe to call twice.
func (l *daemonLock) closeStore() error {
l.mu.Lock()
st := l.st
l.st = nil
l.mu.Unlock()
if st == nil {
return nil
}
return st.Close()
}
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; the daemon does not answer until it finishes)")
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
wipe := flag.Bool("wipe", false, "print every table and its row count, then exit without serving; add -confirm-wipe to delete all of it")
confirmWipe := flag.Bool("confirm-wipe", false, "with -wipe, actually remove every piece of personal data (facts, notes, vectors, events, tasks, sessions, traces, voiceprints). config, models, passkeys and the encryption key are files and survive")
flag.CommandLine.Parse(args)
reembedOnStart = *reembed
allowSeedOnStart = *allowSeed
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)
// dbKey — the plaintext at-rest key, once the daemon has one. Set at boot
// in env-key mode and inside UnlockFn after a cold start. WrapKeyFn reads
// it from an IPC goroutine, hence the atomic: srv's function fields are
// installed before Serve and must not be reassigned afterwards.
var dbKey atomic.Pointer[[]byte]
// wrappedPath resolves the blob location the same way for both the read
// at boot and every write, so a default-path deployment cannot wrap to
// one file and unwrap from another.
wrappedPath := func() string {
if *wrappedKeyPath != "" {
return *wrappedKeyPath
}
return cfg.DefaultWrappedKeyPath()
}
if !locked {
// Normal boot: env key or plaintext (dev/CI)
if envKey != nil {
envKeyBytes = make([]byte, len(envKey))
copy(envKeyBytes, envKey)
dbKey.Store(&envKeyBytes)
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()
} else {
// Locked boot: the store does not exist yet. Seal whatever UnlockFn
// opened, at shutdown, on this goroutine.
defer func() {
if err := dl.closeStore(); err != nil {
log.Printf("mavend: seal store on shutdown: %v", err)
}
}()
}
// ----- wipe: never serves, exits when it is done (Vikunja #494) -----
if *wipe {
if locked {
return fmt.Errorf("wipe: the store is locked and there is no key to open it with")
}
return runWipe(ctx, st, os.Stdout, *confirmWipe)
}
// ----- 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)
)
// The unified intake journal (Vikunja #283). Built before anything else
// that holds a CoreAPI, because intakeAPI wraps that one interface and
// every intake path in the daemon reaches its sink through it. nil (the
// operator set intake_journal negative) means no decorator at all.
evBus := newEventBus(cfg)
// coreFor is what every in-process holder of a CoreAPI now takes, instead
// of a bare ipc.NewStoreAPI(st). Identical behaviour plus one published
// envelope per successful intake write.
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
// depsNow reads whatever the current path has wired. Both boot paths build
// the CoreAPI and start the workers from this one value, so neither can
// hold a field the other misses. See cmd/mavend/boot.go.
depsNow := func() bootDeps {
return bootDeps{
coreFor: coreFor,
tl: tl,
evBus: evBus,
voiceW: voiceW,
st: st,
factWorker: factWorker,
evalWorker: evalWorker,
feedWkr: feedWkr,
crawlWkr: crawlWkr,
}
}
if !locked {
rules = wireRules(cfg)
gatherer = wireGatherer(st, cfg, rules)
// phraser
phr, err = wirePhraser(ctx, cfg)
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, coreFor(), phr, st.VectorMemory(), st, eco)
if err != nil {
return fmt.Errorf("wire voice: %w", err)
}
// delivery
dispatcher, err = wireDispatcher(st, cfg, voiceW)
if err != nil {
return err
}
// tick loop
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
evalWorker = newMemoryEvalWorker(st, phr, cfg)
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
coreAPI = newDaemonAPI(depsNow())
} 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(stepUpTTL)
// 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, ipc.MethodPing:
// Ping is allowed for the same reason the two unlock methods
// are: it never reaches CoreAPI. It answers "she is up and
// locked", which is what mavupdate needs to tell a daemon
// waiting for a passkey apart from one that failed to start.
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{}) }
srv.LockedFn = dl.isLocked
// wg is declared here rather than next to srv.Serve because the media
// retention loop starts on this path too, and shutdown has to wait for a
// prune in flight: it deletes files.
var wg sync.WaitGroup
// 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, evBus)
wireModelSwap(srv, phr, cfg)
// Inbound telegram (V-637). Dark unless the telegram block says intake,
// and it reads one chat.
wireTelegramIntake(ctx, &wg, coreAPI, cfg)
// Vision + the media blob store (Vikunja #252). Both stay dark without a
// media block; MethodDescribeImage answers ErrUnknownMethod then.
keeper := wireVision(ctx, &wg, 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(ctx, &wg, 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 at-rest key under the passkey PRF secret and
// persists the wrapped blob. Called by mavweb after every assertion.
//
// It is wired in locked mode too, not only in env-key mode, and that is
// what makes a v1 blob recoverable. A box enrolled before Vikunja #14
// cold-starts through the legacy public-key retry in mavweb, and the
// StoreEncryptionKey that follows rewrites the blob as v2. Without this
// the only escape from a v1 blob was putting MAVEN_DB_KEY back in the
// environment, which is the thing cold-start unlock exists to avoid.
//
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
// an authenticator without PRF support produces no wrapped file at all
// rather than a file that looks protected and is not.
if envKeyBytes != nil || locked {
srv.WrapKeyFn = func(ctx context.Context, secret []byte, explicit bool) error {
kp := dbKey.Load()
if kp == nil {
return errors.New("wrap encryption key: the daemon is locked and has no key yet (unlock first)")
}
wp := wrappedPath()
// Asserting a passkey is not a request to rewrite the cold-start
// key. Without this an assertion carrying a substituted PRF value
// re-wrapped the real database key under it, and a second
// authenticator silently replaced the first one's blob.
if !explicit {
if _, err := os.Stat(wp); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("check wrapped key: %w", err)
}
}
wrote, err := wrapKeyToFile(wp, *kp, secret)
if err != nil {
return err
}
if wrote {
log.Printf("mavend: wrapped encryption key under this passkey's PRF output → %s", wp)
}
return nil
}
}
// UnlockFn — cold-start unlock: unwraps the encryption key from the
// wrapped blob using the passkey PRF secret, opens the store, wires all
// daemon components, and replaces the locked API.
if locked {
var unlockMu sync.Mutex
srv.UnlockFn = func(ctx context.Context, secret []byte) error {
// One unlock at a time, and never a second one. Without this a
// concurrent pair of Unlock calls would each open a store and
// wire a full daemon, and the loser's goroutines would run
// against a store nobody closes.
unlockMu.Lock()
defer unlockMu.Unlock()
if !dl.isLocked() {
return nil // already unlocked; the caller does not need to know
}
// Depth, not a boundary. MethodAssertStepUp is AuthRead, so
// anything that can open the same-uid socket can flip the
// session and reach MethodUnlock. What actually stops a local
// attacker is the 32-byte PRF output they do not have, and that
// was true before this check. What this check stops is an
// accidental unlock attempt from an unrelated local caller.
if !passkeySess.IsStepUp() {
return errors.New("unlock: no verified passkey assertion (assert first)")
}
wp := wrappedPath()
blob, err := os.ReadFile(wp)
if err != nil {
return fmt.Errorf("read wrapped key: %w", err)
}
key, version, err := webauthn.UnwrapKey(blob, secret)
if err != nil {
return fmt.Errorf("unwrap key: %w", err)
}
if version == webauthn.BlobV1 {
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Use the \"rewrite cold-start key\" button on /auth/webauthn with a PRF-capable authenticator to replace it with a v2 blob.", wp, version)
}
// WrapKeyFn needs the key to be able to rewrite the blob later.
keyCopy := bytes.Clone(key)
dbKey.Store(&keyCopy)
// 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 = wireRules(cfg)
gatherer = wireGatherer(st, cfg, rules)
phr, err = wirePhraser(ctx, cfg)
if err != nil {
return fmt.Errorf("phraser: %w", err)
}
eco = wireEcosystem(cfg)
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
if err != nil {
return fmt.Errorf("wire voice: %w", err)
}
dispatcher, err = wireDispatcher(st, cfg, voiceW)
if err != nil {
return err
}
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
evalWorker = newMemoryEvalWorker(st, phr, cfg)
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
// Swap the CoreAPI from the locked placeholder to the real store adapter.
newAPI := newDaemonAPI(depsNow())
srv.SetAPI(newAPI)
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
wireMailIntake(srv, st, phr, cfg, evBus)
wireModelSwap(srv, phr, cfg)
// Same on the unlock path, with the API that has just replaced the
// locked placeholder (V-637).
wireTelegramIntake(ctx, &wg, newAPI, cfg)
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
wireCapture(ctx, &wg, 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)
// The voice server and every background worker, on the outer wg
// so shutdown waits for them. This used to be nine bare
// `go func()` calls and a shadowed WaitGroup (V-639).
startBackground(ctx, &wg, depsNow())
dl.unlock(st)
log.Printf("mavend: unlocked via passkey assertion")
return nil
}
}
goWorker(&wg, func() {
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 {
startBackground(ctx, &wg, depsNow())
}
<-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()
}
// Bounded. Every worker below watches ctx, but one parked in a model call
// or an HTTP fetch can outlast the supervisor's patience, and run() has to
// return for `defer st.Close()` to seal the database. A worker abandoned
// mid-tick loses one tick; a shutdown that never returns loses every write
// since the last clean stop — which is how the deployed ciphertext went
// eleven days stale in July 2026.
if !waitWorkers(&wg, workerGrace) {
log.Printf("mavend: workers still running after %s, sealing anyway", workerGrace)
}
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.Disabled && 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
}
// cacheRAMMiB resolves phraser.cache_ram_mib into the phraser's field. Unset
// means 512 MiB and not "whatever the server does", because the server's own
// default is 8 GiB of prompt cache and that is what put 7.9 GB of RSS and half
// a gigabyte of swap on homesrv for a 1.1 GB model. A negative value is the
// deliberate opt-out: no flag is passed, the server's default applies, and the
// operator owns the consequence.
func cacheRAMMiB(configured int) int {
if configured == 0 {
return 512
}
if configured < 0 {
return 0
}
return configured
}
// 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()) }
}
// workerGrace — how long shutdown waits for the background workers before it
// goes ahead and seals without them. Comfortably inside docker's ten-second
// default so the seal still lands before SIGKILL.
const workerGrace = 4 * time.Second
// waitWorkers waits on wg for at most d. Reports whether they all finished.
func waitWorkers(wg *sync.WaitGroup, d time.Duration) bool {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}
// Phraser defaults, applied when the config block leaves a field unset. They
// are the daemon's, not the library's: phraser.Config carries no defaults of
// its own, so an empty field here would reach llama-server as an empty flag.
const (
defaultLlamaBin = "llama-server"
defaultPhraserListen = "127.0.0.1:0"
defaultPhraserNCtx = 2048
defaultPhraserTimeout = 30 * time.Second
)
// wirePhraser builds the phrasing seam. No phraser block means the
// deterministic stub, which is the floor and not an error: the daemon answers
// without a model, in fixed words.
func wirePhraser(ctx context.Context, cfg *config.Config) (phraser.Phraser, error) {
if cfg.Phraser == nil {
return phraser.NewStub(), nil
}
pc := phraser.Config{
ModelPath: cfg.Phraser.ModelPath,
BinPath: cfg.Phraser.BinPath,
Listen: cfg.Phraser.Listen,
NGpuLayers: cfg.Phraser.NGpuLayers,
NCtx: cfg.Phraser.NCtx,
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
Timeout: time.Duration(cfg.Phraser.Timeout),
LLMNudges: cfg.Phraser.LLMNudges,
ContextBlock: contextBlockFn(cfg, time.Now),
}
if pc.BinPath == "" {
pc.BinPath = defaultLlamaBin
}
if pc.Listen == "" {
pc.Listen = defaultPhraserListen
}
if pc.NCtx <= 0 {
pc.NCtx = defaultPhraserNCtx
}
if pc.Timeout <= 0 {
pc.Timeout = defaultPhraserTimeout
}
return phraser.NewLLMPhraser(ctx, pc)
}
// wireGatherer builds the nudge gatherer over the given rule set and applies
// the configured quiet hours.
func wireGatherer(st *store.Store, cfg *config.Config, rules []loop.Rule) *loop.Gatherer {
g := loop.NewGatherer(st, rules)
if cfg.QuietHours != nil {
g.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
}
return g
}
// wireDispatcher builds the delivery fan-out. Each sink stays nil unless its
// config block is present, and a sink that fails to build fails the boot
// rather than going quiet.
//
// A crashed prior run may have left "pending" delivery attempts (send may have
// landed externally, then the process died before recording it). They are
// reconciled to "unknown" here, before the tick loop resumes sending, so
// nothing auto-resends into that ambiguity.
func wireDispatcher(st *store.Store, cfg *config.Config, voiceW *voiceWiring) (*delivery.Dispatcher, error) {
ntfy, err := wireNtfySink(cfg.Ntfy)
if err != nil {
return nil, err
}
var telegram delivery.Sink
if cfg.Telegram != nil && !cfg.Telegram.Disabled {
s, err := telegramsink.New(*cfg.Telegram)
if err != nil {
return nil, 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)
}
return delivery.NewDispatcher(delivery.Config{
Ntfy: ntfy,
Telegram: telegram,
Voice: voiceSink,
Ack: st,
Nudges: st,
Reminders: st,
Outbox: st,
}), nil
}
// wireNtfySink keeps an optional reach optional without ever turning a missing
// secret into anonymous publishing. A block is live unless it says disabled;
// therefore an expanded-empty token in a live block fails startup instead of
// spending days in a permanent 403 retry loop. Disabled is an explicit
// operator choice and lets another away reach take over.
func wireNtfySink(cfg *ntfysink.Config) (delivery.Sink, error) {
if cfg == nil || cfg.Disabled {
return nil, nil
}
sink, err := ntfysink.New(*cfg)
if err != nil {
return nil, fmt.Errorf("wire ntfy sink: %w", err)
}
return sink, nil
}
// wireTickLoop reads the loop's three intervals and its schedules out of the
// config, so the two boot paths cannot disagree about them.
func wireTickLoop(st *store.Store, gatherer *loop.Gatherer, dispatcher *delivery.Dispatcher, phr phraser.Phraser, rules []loop.Rule, cfg *config.Config) *tickLoop {
return newTickLoop(st, gatherer, dispatcher, phr, rules,
time.Duration(cfg.TickInterval),
time.Duration(cfg.RepeatInterval),
time.Duration(cfg.AutotuneInterval),
cfg.Digest,
routinesFromConfig(cfg.Routines),
config.MorningRoutinesFromConfig(cfg.MorningRoutines),
cfg.PatternProposals)
}
// goWorker starts run on its own goroutine and registers it with wg, so
// shutdown can wait for it inside workerGrace.
func goWorker(wg *sync.WaitGroup, run func()) {
wg.Add(1)
go func() {
defer wg.Done()
run()
}()
}
// wireRules builds the nudge rule set, minus anything config turned off. The
// drop is logged because a rule vanishing silently is indistinguishable from a
// rule that is broken, and the next person to wonder why she stopped nudging
// should find the answer in the boot log.
func wireRules(cfg *config.Config) []loop.Rule {
rules, dropped := loop.RulesExcept(cfg.DisabledRules)
for _, name := range dropped {
log.Printf("loop: rule %q disabled by config", name)
}
return rules
}