package main import ( "context" "fmt" "io" "path/filepath" "strings" "time" "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/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/platform" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "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/task" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" ) // wireAuxiliary 组装执行提供者、任务管理器、IoT 管理器、渠道会话管理器、 // 自动化服务与后台思考服务。 func (a *app) wireAuxiliary() error { provider, err := exec.New(a.cfg.ExecSandbox, a.log) if err != nil { return fmt.Errorf("exec provider: %w", err) } a.execProv = provider 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, iot.WithBus(a.bus)) 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 hist, herr := history.NewStore(context.Background(), a.db, a.log, history.WithDocumentRefs(documents.NewRefCounter(a.log))) if herr != nil { return fmt.Errorf("history store: %w", herr) } a.history = hist 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(a.backthinkTuning(), backthink.Deps{ Bus: a.bus, Thinker: backthink.NewLoopThinker(a.loop, a.history), Recent: a.history, State: backthink.NewIoTStateProvider(iotMgr, a.log), Exporter: backthink.NewPushOutlet(a.pushOutletConfig()), DB: a.db, Log: logger.WithSource(a.log, "services/backthink"), }) if err != nil { return fmt.Errorf("backthink: %w", err) } a.backthink = bt a.wireVoice() a.wireMCP() a.wireSkills() // SK-5(design/09 §6.3):技能注册表填好之后,把技能面接到循环的模型面装配上 // —— 清单投影 + 命中才展开正文 + 技能轮次的工具面收缩。技能来源就是上面刚 // 注册进去的能力注册表(每轮实时读,因此面板热装的技能下一轮即出现在清单里)。 // 必须排在 wireSkills 之后、且在任何 Run 之前(wireAuxiliary 仍在装配期)。 a.loop.SetSkillFace(a.buildSkillFace()) a.wireTerminalTool() a.wireSubagentTool() a.wireSessionReadTool() // X1(裁决 15):把外围子系统(IoT)以 KindIoT 放进能力面,并注册只读的 // capability_list 工具。排在最后,是为了让它列出的清单已经包含前面各步 // 注册进来的工具(terminal / subagent / session_read 等)。 a.wireCapabilitySurface() // design/12 §N C.3:把「自排一次思考」暴露成能力注册表里的 request_think 工具。 // 排在 wireCapabilitySurface 之后无妨 —— 清单是调用时实时读注册表的,顺序不影响 // 它列出谁。 a.wireSelfWakeTool() return nil } // wireSelfWakeTool 把 request_think 注册进能力面(design/12 §N C.3): // `a.dispatcher.Register(backthink.NewThinkTool(a.backthink))`。 // // 时序安全:a.backthink 在 wireAuxiliary 上半段(本函数里)就建好了,而 a.loop / // a.dispatcher 在更早的 wireRuntime 已就位 ⇒ 这一步不需要新的装配顺序。 // // 与 capability_list 同口径:**失败只告警、绝不阻塞启动** —— 少一个工具不是启动失败 // 的理由(自排的其余面:判定、闸、审计都已在 backthink 内部就绪)。 func (a *app) wireSelfWakeTool() { log := logger.WithSource(a.log, "services/backthink") if a.backthink == nil { log.Warn("self wake tool register skipped: no backthink service") return } if a.dispatcher == nil { log.Warn("self wake tool register skipped: no dispatcher") return } if err := a.dispatcher.Register(backthink.NewThinkTool(a.backthink)); err != nil { log.Warn("self wake tool register skipped", "error", err) return } log.Info("self wake tool registered", "tool", backthink.ThinkToolName) } // buildSkillFace 构造技能面(SK-5,design/09 §6.3):来源是能力注册表里的 // KindSkill 条目 —— 启动时 wireSkills 载入的与面板热装的技能都在同一处,且 // **每轮实时读取**,因此热装技能下一轮就出现在模型清单里,无需重启。 // // 这里只给来源与日志,不配任何旋钮:单条清单上限(1536 字符)、清单条数上限、 // 正文注入字节上限都由代码常量给出(技能包的 listing.go 与 loop 的 // assemble_skill.go 各有带依据的注释),既不新增 `.env` 键,也不引入第二套 // 配置面(主人 2026-10-06 红线:.env 冻结为最小化引导面)。 func (a *app) buildSkillFace() *loop.SkillFace { return loop.NewSkillFace(loop.SkillFaceOptions{ Source: func() []sdk.Skill { if a.caps == nil { return nil } caps := a.caps.List(registry.KindSkill) out := make([]sdk.Skill, 0, len(caps)) for _, cap := range caps { // 注册表里不是技能的条目(理论上不会有,KindSkill 条目按契约就是 // sdk.Skill)直接跳过:宁可少一个技能,也不让类型断言失败的条目 // 把整轮装配拖垮。 if s, ok := cap.Value.(sdk.Skill); ok && s != nil { out = append(out, s) } } return out }, Log: logger.WithSource(a.log, "cmd/cyrene/skills"), }) } // backthinkTuning 组装后台思考的调优配置:env 引导面(心跳周期 / 安静时段 / 推送频次) // 由 ConfigFromBackground 映射,**自排成本闸**(design/12 §N C.5)从 // `$DATA_DIR/config/backthink.json` 读 —— 零新 env 键(主人 2026-10-06 `.env` 冻结红线)。 // // 文件缺失/全空白 ⇒ 保持零值,由 backthink 的 normalize 回落代码默认(4 轮/小时; // 「≤0 回落默认」在服务侧统一执行,这里不再造第二套默认值)。存在却解不开 ⇒ 记一条 // 带 source 的 WARN 并回落默认:坏配置**不许**静默变成「无上限」。 // // 与 QUIET_* 同口径:启动期读一次,**改文件需重启**。 func (a *app) backthinkTuning() backthink.Config { cfg := backthink.ConfigFromBackground(a.cfg.Background) tuningPath := runtimeconfig.DefaultBackthinkPath(resolveDataDir(a.cfg)) tuning, err := runtimeconfig.LoadBackgroundTuning(tuningPath) if err != nil { logger.WithSource(a.log, "services/backthink").Warn( "backthink tuning unreadable; falling back to code defaults", "path", tuningPath, "error", err) return cfg } cfg.SelfWakeLimit = tuning.SelfWakePerWindow cfg.SelfWakeWindow = time.Duration(tuning.SelfWakeWindowSec) * time.Second return cfg } // pushOutletConfig 把环境变量里的推送出口配置块映射为 backthink 的导出器配置。 // 个推(Getui)凭据只被 config 读取、并不消费:备用 SDK 尚未接入,因此出口在 // 待接入该档位时记日志,而不是伪造一次推送。token 只透传,绝不写日志。 func (a *app) pushOutletConfig() backthink.PushOutletConfig { po := a.cfg.PushOutlet return backthink.PushOutletConfig{ Enabled: po.Enabled, URL: po.URL, Token: po.Token, Timeout: time.Duration(po.TimeoutSec) * time.Second, Log: a.log, } } // close 按依赖顺序关闭每个子系统并释放资源。 func (a *app) close(ctx context.Context) { // 文档回收循环要先用连接池(每轮一次查询),因此排在关库之前。 a.docGC.stop() // 用量落库与保留期清理同样要用连接池:先把队列里剩下的样本写完,再停清理。 a.stopUsage() if a.backthink != nil { _ = a.backthink.Stop(ctx) } // 记忆整理与 backthink 同属后台定时任务:先停它,再关库(它每轮只有 // 查询与候选写入,且 Stop 会等本轮跑完)。 if a.memoryTidy != nil { _ = a.memoryTidy.Stop(ctx) } // 入库前审查与整理同属记忆后台任务:先停它(Stop 会等正在审的那一条跑完), // 再关库 —— 否则正在写 memory_items 的审查会撞上已关闭的连接池。 if a.memoryReview != nil { _ = a.memoryReview.Stop(ctx) } if a.automation != nil { _ = a.automation.Stop(ctx) } if a.channelBridge != nil { a.channelBridge.Stop(ctx) } if a.scheduler != nil { _ = a.scheduler.CancelAll(ctx) } if a.gw != nil { a.gw.Close() } // 浏览器会话(切片 X3 把 V5 留下的挂点接在这里):`browser` 工具持有**进程外** // 资源 —— 我们起的真 Chromium 子进程、一条 CDP 连接、profile 上的 SingletonLock, // 因此必须显式关掉,而不是交给进程退出。 // // 位置的三条理由(顺序在这里是有含义的): // 1. **排在渠道 / 调度器 / 网关之后**:这三步停掉的是「还会发起工具调用的入口」 // (IM → 子代理 → Web),此刻浏览器不会再从某个正在跑的回合底下被抽走; // 2. **排在 MCP / 插件 / 任务之前**:它属于能力面,与那几步同类,且不碰存储 // (db / bus 都在后面),提前关不影响任何后续关停; // 3. **绝不能拖到进程退出以后**:Windows 上父进程退出不会带走子进程,孤儿 Chrome // 会继续占着 profile 锁,下次启动就得靠「认领」绕过去 —— 早一行关掉,孤儿就 // 少一分。`Manager.Close` 自带超时(不等 shutCtx),所以这一步不会被关停预算卡住。 // // 工具对象没有单独的 app 字段(能力注册面就是它的家),因此按工具名取回:没注册 // (本机没浏览器)就是空操作,关闭链不因它存在与否改道。 if a.caps != nil { if entry, err := a.caps.Get(browsertools.ToolName); err == nil { if closer, ok := entry.Value.(io.Closer); ok { if cerr := closer.Close(); cerr != nil { logger.WithSource(a.log, "cmd/cyrene").Warn("browser tool close failed", "error", cerr) } else { // 一行可追溯的收尾记录:排障时「退出后还有没有残留的浏览器」看的 // 就是它(孤儿 Chrome 会占着 profile 锁,下一次启动要靠认领绕过去)。 logger.WithSource(a.log, "cmd/cyrene").Info("browser tool closed") } } } } for _, reg := range a.mcpRegs { _ = reg.Unregister(ctx) } if a.plugins != nil { a.plugins.Close(ctx) } 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 返回配置的数据目录(来自 .env / 环境变量,经 cfg.DataDir); // 只有在没有配置目录时才回落到与操作系统相称的默认值。 func resolveDataDir(cfg *config.Config) string { if cfg.DataDir != "" { return cfg.DataDir } return platform.DefaultDataDir() } // resolveSQLitePath 返回实际使用的 SQLite 路径:当配置里仍是默认值时, // 把它推导到数据目录下。 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 }