package main import ( "context" "fmt" "log/slog" "sort" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // 本文件是 X1(思考轮工具面补齐 / 主人裁决 15)的组合根接线:让**外围子系统**真的 // 出现在能力面里,并给她一把只读的自查工具。它只装配,不新造注册机制、不开端口、 // 不跨进程(单二进制红线)。 // capabilitySurfaceLogSource 是本步日志的来源标记。 const capabilitySurfaceLogSource = "cmd/cyrene" // iotCapabilityName 是 IoT 子系统在能力注册表里的条目名。 const iotCapabilityName = "iot" // IoT 只读状态摘要的边界(编译期常量):台数、每台的关键状态键数、单个键值长度与 // 整段摘要字符数。整段上限必须**小于** registry 侧 statusCharLimit,否则真正生效的 // 是那边的截断,而这里以为没截断。 const ( iotStatusMaxDevices = 20 iotStatusMaxKeys = 6 iotStatusMaxValueLen = 40 iotStatusMaxChars = 380 ) // wireCapabilitySurface 把「她能看见自己有什么、外围在什么状态」这条面接进组合根 // (X1 / 裁决 15;design/12 §1「允许调用工具(查设备/心率/日历/天气等)」、 // design/20 §2「能调用工具:与主人会话同等的工具集」): // // 1. **外围子系统进能力面**:IoT 以它既有的 `KindIoT` 身份注册进能力注册表 —— // 此前全仓**无人注册 KindIoT**,于是外围子系统在能力面里根本不存在(面板与 // 清单都列不出); // 2. **只读自查工具**:`capability_list`(internal/capability/registry 的 // ListTool)注册进同一张注册表,并注入 IoT 的只读状态源,使她无论走 chat 还是 // 主动思考轮,都能列出「有哪些能力/插件 + 外围状态」。 // // 「chat 与 backthink 同一套工具面」不靠本文件成立,而靠**同一个注册表 / 分发器**: // backthink 的 LoopThinker 包的就是 `a.loop`(wire_services.go),而 `a.loop` 的工具面 // 就是 `a.dispatcher`(wire_runtime.go),它建立在 `a.caps` 之上。因此这里绝不再造 // 第二套注册机制 —— 把工具塞进 `a.caps` 就同时被两条车道看见。 // // 失败只告警、绝不阻塞启动:本步只影响「清单可见性」,其余子系统不受影响。 func (a *app) wireCapabilitySurface() { log := logger.WithSource(a.log, capabilitySurfaceLogSource) if a.caps == nil { log.Warn("capability surface skipped: no capability registry") return } a.registerIoTCapability(log) opts := make([]registry.ListOption, 0, 2) if a.iot != nil { opts = append(opts, registry.WithStatusSource(iotStatusSource{mgr: a.iot})) } opts = append(opts, registry.WithListLogger(a.log)) listTool, err := registry.NewListTool(a.caps, opts...) if err != nil { log.Warn("capability list tool build skipped", "error", err) return } if a.dispatcher == nil { log.Warn("capability list tool register skipped: no dispatcher") return } if err := a.dispatcher.Register(listTool); err != nil { log.Warn("capability list tool register skipped", "error", err) return } log.Info("capability list tool registered", "tool", registry.ToolName, "iot_status", a.iot != nil) } // registerIoTCapability 把 IoT 门面以 KindIoT 注册进能力注册表。已注册(重名)时 // 只告警:能力面里已经有一条,不是启动失败的理由。 func (a *app) registerIoTCapability(log *slog.Logger) { if a.iot == nil { return } err := a.caps.Register(registry.Capability{ Name: iotCapabilityName, Kind: registry.KindIoT, Description: "IoT peripherals (device list, status, control)", Value: a.iot, }) if err != nil { log.Warn("iot capability register skipped", "error", err) } } // iotStatusSource 把 IoT 子系统接成清单工具的**只读**状态源:它只调 Manager.List // (纯读),按固定上限渲染设备清单,绝不 Control、绝不改任何设备状态。 type iotStatusSource struct { mgr *iot.Manager } // Name 返回状态分组名。 func (s iotStatusSource) Name() string { return iotCapabilityName } // Status 渲染一份有界的只读摘要:设备/在线台数,然后是每台设备一行 // (名称/类型/在线/关键状态键值)。超过上限的台数与字符都被截断并显式标注。 func (s iotStatusSource) Status(ctx context.Context) (string, error) { if s.mgr == nil { return "", fmt.Errorf("capability surface: iot status: %w", iot.ErrProviderNotRegistered) } devices, err := s.mgr.List(ctx) if err != nil { return "", fmt.Errorf("capability surface: iot status: %w", err) } var b strings.Builder fmt.Fprintf(&b, "devices=%d online=%d", len(devices), onlineCount(devices)) rendered := 0 for _, d := range devices { if rendered >= iotStatusMaxDevices { break } line := "\n" + deviceLine(d) if b.Len()+len(line) > iotStatusMaxChars { break } b.WriteString(line) rendered++ } if rendered < len(devices) { fmt.Fprintf(&b, "\n…(另有 %d 台未列出)", len(devices)-rendered) } return b.String(), nil } // onlineCount 统计在线设备台数。 func onlineCount(devices []iot.Device) int { n := 0 for _, d := range devices { if d.Online { n++ } } return n } // deviceLine 把一台设备渲染成单行只读摘要:名称(类型,在线/离线):键=值,… // 状态键按字典序取前 iotStatusMaxKeys 个,保证同一台设备每次渲染一致。 func deviceLine(d iot.Device) string { status := "离线" if d.Online { status = "在线" } keys := make([]string, 0, len(d.State)) for k := range d.State { keys = append(keys, k) } sort.Strings(keys) pairs := make([]string, 0, len(keys)) for _, k := range keys { if len(pairs) >= iotStatusMaxKeys { break } pairs = append(pairs, k+"="+truncateValue(d.State[k])) } line := fmt.Sprintf("%s(%s,%s)", d.Name, d.Type, status) if len(pairs) > 0 { line += ":" + strings.Join(pairs, ",") } return line } // truncateValue 把设备状态值渲染成有界文本:非文本值走 %v,超长即截断。 func truncateValue(v any) string { s := fmt.Sprintf("%v", v) if len([]rune(s)) <= iotStatusMaxValueLen { return s } return string([]rune(s)[:iotStatusMaxValueLen]) + "…" }