package main import ( "context" "database/sql" "fmt" "log/slog" "os" "path/filepath" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "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/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/platform" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" "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/orchestration" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist" "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/runtime/trace" "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/gateway" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/basic" ) // 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 bus bus.Bus personas *personareg.Registry caps *registry.Registry dispatcher *tool.Dispatcher memStore *memory.Store retriever *memory.Retriever loop *loop.Loop scheduler *orchestration.Scheduler tasks *task.Manager iot *iot.Manager sessions *session.SessionManager automation *automations.Service backthink *backthink.Backthink vindex vector.Index owner *user.OwnerProfile gw *gateway.Server } // 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, 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 } if err := a.wireChannels(); err != nil { return a, err } if err := a.wireFrontend(); err != nil { return a, err } completed = true return a, nil } // buildPersonas loads the persona definitions and registers them, plus the // default character and the owner profile used by channel identity resolution. func (a *app) buildPersonas() error { a.personas = personareg.New() dirPersonas, err := loader.LoadDir(defaultPersonasDir) if err != nil { a.log.Warn("persona dir load skipped", "error", err) } else { for _, p := range dirPersonas { if rerr := a.personas.RegisterPersona(p); rerr != nil { a.log.Warn("persona register skipped", "persona", p.ID, "error", rerr) } } } if owner, oerr := user.LoadOwnerProfile(defaultOwnerPath); oerr == nil { a.owner = owner } else { a.log.Warn("owner profile load skipped", "error", oerr) } // Load the default persona explicitly if it was not under the personas dir. defaultID := "cyrene" if a.owner != nil && a.owner.DefaultPersona != "" { defaultID = a.owner.DefaultPersona } if _, ok := a.personas.Persona(defaultID); !ok { if p, lerr := loader.Load(filepath.Join(defaultPersonasDir, defaultID+".yaml")); lerr == nil { _ = a.personas.RegisterPersona(p) } else { a.log.Warn("default persona load skipped", "persona", defaultID, "error", lerr) } } if p, ok := a.personas.Persona(defaultID); ok { char := &personareg.Character{ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID} if cerr := a.personas.RegisterCharacter(char); cerr != nil { a.log.Warn("default character register skipped", "error", cerr) } } a.log.Info("personas ready", "count", a.personas.CountPersonas(), "default", defaultID) return nil } // wireMemory builds the long-term memory stack: store, embedding manager, vector // index, project index and the retriever wired into the io knowledge tool. func (a *app) wireMemory(ctx context.Context) error { store, err := memory.NewStore(ctx, a.db, a.log) if err != nil { return fmt.Errorf("memory store: %w", err) } a.memStore = store embedManager := embedding.New(a.cfg.Embedding) embedder := embedManager.Embedder() index, err := vector.New(a.dbCfgForEmbed(), embedder, a.log) if err != nil { a.log.Warn("vector index unavailable, falling back to in-memory semantic", "error", err) } else { a.vindex = index } projStore, err := projectindex.New(ctx, a.db, a.log) if err != nil { a.log.Warn("project index unavailable", "error", err) projStore = nil } opts := []memory.RetrieverOption{memory.WithEmbedder(embedder)} if a.vindex != nil { opts = append(opts, memory.WithVectorIndex(a.vindex)) } if projStore != nil { opts = append(opts, memory.WithProjectIndex(projStore)) } a.retriever = memory.NewRetriever(store, opts...) return nil } // wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent // and its loop, tracing, persistence and the orchestration scheduler. func (a *app) wireRuntime() error { adapter, aerr := model.NewOpenAIChatChain(a.cfg.LLM) if aerr != nil { a.log.Warn("llm adapter unavailable", "error", aerr) adapter = nil } router := model.NewRouter(a.cfg.LLM.Model) dispatcher, derr := tool.New(a.caps, tool.WithDefaultTimeout(defaultToolTimeout)) if derr != nil { return fmt.Errorf("tool dispatcher: %w", derr) } a.dispatcher = dispatcher if err := basic.Register(a.caps); err != nil { return fmt.Errorf("register basic tools: %w", err) } ioTools, ioErr := iotools.New(a.cfg, iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log)) if ioErr != nil { return fmt.Errorf("build io tools: %w", ioErr) } for _, t := range ioTools { if rerr := a.dispatcher.Register(t); rerr != nil { a.log.Warn("io tool register skipped", "tool", t.Name(), "error", rerr) } } guards := guardrail.New() instructions := a.defaultInstructions() ag, aerr := agent.New(instructions, a.dispatcher, guards, agent.WithModel(adapter, router), agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"})) if aerr != nil { return fmt.Errorf("agent: %w", aerr) } personaID := a.defaultPersonaID() store := a.memStore tracer := trace.New(trace.NewJSONRecorder(os.Stderr)) checkpoint, perr := persist.New(context.Background(), a.db, a.log) if perr != nil { return fmt.Errorf("checkpoint store: %w", perr) } a.loop = loop.New(loop.Options{ Agent: ag, Tools: a.dispatcher, Memory: store, Retriever: a.retriever, Extractor: memory.NewManualExtractor(personaID, "owner"), Checkpoint: checkpoint, Tracer: tracer, Guards: guards, PersonaID: personaID, MaxRounds: defaultMaxRounds, WorkingMemory: memory.NewWorkingMemory(0), Log: logger.WithSource(a.log, "runtime/loop"), }) a.scheduler = orchestration.New(orchestration.Options{ MaxParallel: defaultMaxParallel, Loop: a.loop, Bus: a.bus, Memory: store, Tracer: tracer, Approver: audit.NewApprover(a.log), Logger: logger.WithSource(a.log, "orchestration"), AgentID: defaultAgentID, }) return nil } // wireAuxiliary builds the execution provider, task manager, IoT manager, // channel session manager, automations and background thinking services. func (a *app) wireAuxiliary() error { provider, err := exec.New(a.cfg.ExecSandbox, a.log) if err != nil { return fmt.Errorf("exec provider: %w", err) } tmgr, err := task.New(a.cfg, provider, a.log, task.WithBus(a.bus)) if err != nil { return fmt.Errorf("task manager: %w", err) } a.tasks = tmgr iotMgr, err := iot.New(a.cfg.IoT, a.log) if err != nil { return fmt.Errorf("iot: %w", err) } a.iot = iotMgr sess, err := session.New(context.Background(), a.db, a.log) if err != nil { return fmt.Errorf("session manager: %w", err) } a.sessions = sess automation, err := automations.NewService(automations.Config{}, automations.Deps{ Bus: a.bus, Loop: a.loop, Log: logger.WithSource(a.log, "services/automations"), }) if err != nil { return fmt.Errorf("automations: %w", err) } a.automation = automation bt, err := backthink.New(backthink.ConfigFromBackground(a.cfg.Background), backthink.Deps{ Bus: a.bus, Thinker: backthink.NewLoopThinker(a.loop), State: backthink.NewIoTStateProvider(iotMgr, a.log), DB: a.db, Log: logger.WithSource(a.log, "services/backthink"), }) if err != nil { return fmt.Errorf("backthink: %w", err) } a.backthink = bt return nil } // wireChannels constructs and registers the four IM channel adapters so the // admin API can enumerate them. They are registered but not connected: real // platform credentials are supplied per-deployment, and connecting with empty // config would dial external networks. func (a *app) wireChannels() error { channels := []struct { name string value any policy registry.ToolPolicy }{ {"qq-obv11", qq_obv11.NewAdapter(qq_obv11.Config{Logger: a.log}), registry.ToolPolicy{}}, {"weixin-ilink", mustWeixin(a.log), registry.ToolPolicy{}}, {"telegram", telegram.New(telegram.Config{}, telegram.WithLogger(a.log)), registry.ToolPolicy{}}, {"webhook", webhook.New(webhook.WithLogger(a.log)), registry.ToolPolicy{}}, } for _, ch := range channels { capp := registry.Capability{ Name: ch.name, Kind: registry.KindChannel, Description: ch.name, Policy: ch.policy, Value: ch.value, } if err := a.caps.Register(capp); err != nil { a.log.Warn("channel register skipped", "channel", ch.name, "error", err) } } a.log.Info("channel adapters registered", "count", len(channels)) return nil } // wireFrontend builds the gateway with the embedded/external SPA filesystem and // the JWT/RBAC authenticator. func (a *app) wireFrontend() error { jwt := auth.New(a.cfg.Auth) gw, err := gateway.New(a.cfg, gateway.Deps{ Log: logger.WithSource(a.log, "gateway"), Auth: jwt, Bus: a.bus, Registry: a.caps, Loop: a.loop, IoT: a.iot, Frontend: resolveFrontendFS(a.log), }) if err != nil { return fmt.Errorf("gateway: %w", err) } a.gw = gw return nil } // dbCfgForEmbed returns the DB config used by the vector index, which opens the // same SQLite file as the primary pool. func (a *app) dbCfgForEmbed() config.DBConfig { cfg := a.cfg.DB cfg.SQLitePath = resolveSQLitePath(a.cfg, resolveDataDir(a.cfg)) return cfg } // defaultPersonaID returns the id of the default persona, falling back to // "cyrene". func (a *app) defaultPersonaID() string { if c, ok := a.personas.DefaultCharacter(); ok { return c.PersonaID } return "cyrene" } // defaultInstructions returns the default persona's system prompt, falling back // to its role text or a minimal instruction when neither is set. func (a *app) defaultInstructions() string { if c, ok := a.personas.DefaultCharacter(); ok { if p, ok := a.personas.Persona(c.PersonaID); ok { if p.Identity.SystemPrompt != "" { return p.Identity.SystemPrompt } if p.Identity.Role != "" { return p.Identity.Role } } } return fallbackInstruction } // close shuts down every subsystem in dependency order and releases resources. func (a *app) close(ctx context.Context) { if a.backthink != nil { _ = a.backthink.Stop(ctx) } if a.automation != nil { _ = a.automation.Stop(ctx) } if a.scheduler != nil { _ = a.scheduler.CancelAll(ctx) } if a.gw != nil { a.gw.Close() } if a.tasks != nil { _ = a.tasks.Close() } if a.vindex != nil { _ = a.vindex.Close() } if a.bus != nil { _ = a.bus.Close() } if a.db != nil { _ = a.db.Close() } a.log.Info("cyrene shutdown complete") } // resolveDataDir returns the configured data directory, or the OS-appropriate // default when DATA_DIR is not exported to the process environment. func resolveDataDir(cfg *config.Config) string { if _, ok := os.LookupEnv("DATA_DIR"); ok { return cfg.DataDir } return platform.DefaultDataDir() } // resolveSQLitePath returns the SQLite path to use, deriving it under the data // directory when the configured default is left untouched. func resolveSQLitePath(cfg *config.Config, dataDir string) string { p := cfg.DB.SQLitePath if strings.TrimSpace(p) == "" || p == defaultSQLitePath { return filepath.Join(dataDir, "cyrene.db") } return p } // mustWeixin builds the weixin-ilink adapter, panicking on an invalid config // (which should never happen with defaults). func mustWeixin(log *slog.Logger) *weixin_ilink.Adapter { adapter, err := weixin_ilink.New(weixin_ilink.Config{}, nil, log) if err != nil { panic(fmt.Sprintf("weixin-ilink adapter: %v", err)) } return adapter }