package main import ( "context" "database/sql" "fmt" "log/slog" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // defaultOwnerPath and defaultPersonasDir point at the on-disk persona/owner // definitions shipped with the repo; they are relative to the working directory. const ( defaultOwnerPath = "configs/users/owner.yaml" defaultPersonasDir = "configs/personas" defaultSQLitePath = "./data/cyrene.db" fallbackInstruction = "You are Cyrene, a warm and attentive companion." // Bound/knob constants. Compile-time so no bare magic number appears in the // runtime path (project rule §10). defaultMaxRounds = 8 defaultMaxParallel = 4 defaultAgentID = "orchestrator" defaultToolTimeout = 30 * time.Second ) // app holds every subsystem the single Cyrene binary assembles. It is built by // newApp and torn down by close on shutdown. type app struct { cfg *config.Config log *slog.Logger db *sql.DB dbPath string bus bus.Bus modelAdapter model.Adapter llm *model.RuntimeLLM personas *personareg.Registry caps *registry.Registry dispatcher *tool.Dispatcher memStore *memory.Store retriever *memory.Retriever projStore *projectindex.Store loop *loop.Loop scheduler *orchestration.Scheduler tasks *task.Manager execProv exec.Provider iot *iot.Manager voice *voice.Chain mcpRegs []*mcp.Registry plugins *pluginmgr.Manager sessions *session.SessionManager automation *automations.Service backthink *backthink.Backthink vindex vector.Index owner *user.OwnerProfile history *history.Store gw *gateway.Server channelBridge *channelbridge.Bridge channels gateway.ChannelConfigStore // capacitySink broadcasts capacity_awaken transitions to the Web UI (design/14 // §5). It is satisfied by *gateway.Server (BroadcastCapacityAwaken); held as an // interface so the composition root is unit-testable with a fake sink. capacitySink capacityEmitter // emotion applies each settled turn to the bound persona's emotion machine and // affection (design/01 §2) and hands the changes to moodSink. Nil when no // persona was registered, in which case no mood is tracked or persisted. emotion *emotion.Observer // moodSink broadcasts a mood change to the Web UI (design/07 §3). It is // satisfied by *gateway.Server (BroadcastMood); held as an interface so the // composition root is unit-testable with a fake sink. moodSink moodEmitter // traceWriter fans finished trace spans out to the JSONL stderr recorder and, // once the gateway exists, to the gateway's observability buffer behind // GET /api/v1/admin/observability/trace (design/19 §2). It is held so // wireFrontend can register the gateway sink on the already-running tracer. traceWriter *traceFanout } // newApp assembles the full Cyrene runtime from cfg and log. It opens the // database, migrates the schema, wires the persona registry, model adapter, tool // dispatcher, memory stack, agent loop, orchestration, execution, IoT, channel // sessions/adapters and the gateway, and returns the ready app. // // Fatal wiring failures (database open/migrate, required auth, gateway) are // returned as errors; optional subsystems degrade gracefully so the binary can // boot even when a model key or external channel is not configured. func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, error) { dataDir := resolveDataDir(cfg) databasePath := resolveSQLitePath(cfg, dataDir) dbCfg := cfg.DB dbCfg.SQLitePath = databasePath sqlDB, err := db.Open(ctx, databasePath, log) if err != nil { return nil, fmt.Errorf("open database: %w", err) } if err := db.MigrateUp(ctx, sqlDB, log); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("migrate database: %w", err) } eventBus := bus.New() a := &app{ cfg: cfg, log: log, db: sqlDB, dbPath: databasePath, bus: eventBus, caps: registry.New(), } // Release already-opened resources if any later wiring step fails, without // the full-service shutdown log (a partial boot is not a clean shutdown). completed := false defer func() { if completed { return } if a.vindex != nil { _ = a.vindex.Close() } if a.tasks != nil { _ = a.tasks.Close() } if a.bus != nil { _ = a.bus.Close() } if a.db != nil { _ = a.db.Close() } }() if err := a.buildPersonas(); err != nil { return a, err } if err := a.wireMemory(ctx); err != nil { return a, err } if err := a.wireRuntime(); err != nil { return a, err } if err := a.wireAuxiliary(); err != nil { return a, err } a.wirePlugins(ctx) if err := a.wireChannels(); err != nil { return a, err } if err := a.wireFrontend(); err != nil { return a, err } completed = true return a, nil }