Commit Graph

347 Commits

Author SHA1 Message Date
3a780384d0 refactor(memory): 裁剪与召回收敛到唯一入口 memoryPass
把「踢出去(prune)」与「取进来(recall)」从两处各写一遍,收敛为
memoryPass(query, trigger, prune, recall) 单一入口,统一:

- 同一份清洗后的 query(避免噪声带偏相关性打分);
- 同一次 token 预算与召回截断;
- 同一条带 trigger 的审计日志(谁、据什么触发了哪种操作)。

落地:
- 新增 memorypass.go:memoryPass + pruneByQuery(原 pruneOnInput 的执行体);
- pruneOnInput 只解析声明,执行委托 memoryPass;
- stepToolAfter 的裁剪/召回改为一次 memoryPass 调用(去掉重复的 topK 逻辑);
- 抽出 recallText,输入侧 buildTaskMemoryContext 与工具侧 recallTextFor 共用;
- 输入侧召回 query 改用 CleanInput(清洗文本),与裁剪侧同一语义;
- QQ qq_get_history 补齐声明 ContextPolicy=prune + RecallPolicy=auto
  (内容类工具:真实聊天正文既当轮用完即裁,又据正文召回)。

测试:新增 memorypass_test.go,锁死 no-op / 两轴同时生效 / 正交不互相触发 /
输入侧用清洗 query。go build/vet 干净,internal/... 全绿,qq 插件模块测试通过。
2026-09-14 23:32:41 +08:00
b792a94b84 feat(memory): 新增可声明的召回轴 RecallPolicy(与 prune 正交)
问题:召回(把 L2/L3 相关记忆注入本轮)此前不可声明、也不受任何 SDK 字段
控制——它只在任务开始时对 f.Input 无条件跑一次。于是 qq_get_message 取回
真实正文后只触发 Prune(裁剪),从不触发召回;而中断通知的 meta 文本反而
会去召回(词不对题,命中一堆泛实体)。

改动:
- SDK 新增 RecallPolicy(none|auto) 轴,落在 InjectOptions / ChannelDef /
  ToolDef 三个声明面,与 ContextPolicy 正交(裁剪 vs 召回)。默认值与
  prune 刻意相反:输入/注入默认 auto(保持既有「每条输入都召回」),
  工具默认 none(工具输出多为噪声,按需声明)。
- 内核:recallDeclared 按 注入点 > 通道 > 默认auto 解析;输入侧用它决定
  是否注入记忆索引;工具侧 ContextPolicy/RecallPolicy 共用同一份清洗后
  query,一次相关性过程分别 prune / recall;召回以 system 消息挂到消息
  末尾(同任务内替换而非累加)。
- 管线:proc RPC(inject/register + 校验)、lua 键、io payload 全量透传。
- QQ 插件:中断与 qq 通道声明 RecallPolicy=none(meta 不是内容);
  qq_get_message 声明 RecallPolicy=auto(取回正文后据正文召回)。

测试:新增 recallpolicy_test.go(core)与 proc 校验用例;
go build ./... 通过,go test ./internal/... 全通过,SDK 模块与 qq 插件测试通过。
2026-09-14 23:14:38 +08:00
bc32fbbc98 feat(webui): 阶段管道的工具格改为滚动展示最新一条调用
「工具」是循环格:一轮里可能调几十次工具/输出通道。此前每一次都追加成
chip,这一格被撑成一长条,反而看不出「现在在调什么」。改为固定一行的
滚动视口——只留最新一条,右侧给出本轮累计次数;新调用到来时旧条向上
滚出、新条滑入(morph 就地改文本不会重放 CSS 动画,故摘类 + 强制 reflow
+ 重加类)。并给 chip 名称加 .rt-chip-t 承接省略号,窄框不再硬切半截。

配套 TestStagePipelineToolCellShowsLatestOnly 钉住该口径。
2026-09-14 20:07:29 +08:00
0faf9fb4e8 style(webui): 队列/管道列宽下限收到 100px,消掉「4 个 + 1 个」孤行
118px 时容器 540px(小窗口侧栏展开的宽度)只放得下 4 列,第 5 个框
落到第二行且后面四个位置全空 —— 正是「看着空」的那种观感。
收到 100px 后 540px 也能一行放下 5 个;配套给 .rt-qmeta 加 wrap,
窄框里「登记/抢占」两枚迷你条换行而不是撑破框。

五档实测(1400/1100/900/700/480):1400/1100/900/700 都是 5 框一行
(200/140/100/120px),480 为 3+2;均无横向溢出,框内元素无越界。
2026-09-14 18:45:36 +08:00
f89e57a732 feat(webui): 阶段管道与中断队列统一为等大表框,字体加大加粗
问题:阶段管道是「小圆点 + 一条连接线 + 9px 小字」,中断队列是五行
「名字 | 进度条 | 元数据」的扁条 —— 两块都远小于旁边的 KPI 框,中断队列四级
全为 0 时四行几乎全是空白,既占高度又难看。

改法:两块统一成同一套视觉语言 —— **等大表框**(与 KPI 同一种骨架)。

阶段管道(.rt-pipe-row / .rt-pipe-cell)
- 5 个等大框,框内 = 图标 + 阶段名 + 本阶段本轮发生的事件 chip。
- 阶段名 9px/500 → 13.5px/700;图标 12px → 15px。
- 当前阶段整框点亮(accent 描边 + 淡底 + 内阴影),不再靠一个小圆点表意。
- 删掉圆点、连接线、滑块把手那套已死的 CSS(.rt-pipe-track/.rt-pipe-knob 等)。

中断队列(.rt-queues / .rt-qcell)
- 五行扁条 → 5 个等大框(L4/L3/L2/L1 + 排队),一行排开。
- 框头级别名 16px/800、深度数字 26px/800(原来深度只是行末一个小数字)。
- 级别色同时用在框头、点亮格槽、有积压时的整框描边 —— 一处配色贯穿。
- 排队队列无级别,用虚线框与四级中断区分(另一**类别**,不是另一优先级)。
- 保留可见格槽:0 时也有形状,不会变回一片空白。

列宽自适应:两块共用 repeat(auto-fit, minmax(118px, 1fr)),
118px 而不是 150px 是为了让 640–740px 容器(窄屏侧栏收起后的宽度)也能
5 个框排一行,不出现「4 个 + 1 个」的孤行。chip 补 min-width:0 以免撑破窄框。

顺带清掉一条无用的旧 .rt-chip 规则(与新规则重名且只被阶段事件用到)。

实测(现网 CDP,1400/1100/900/700/480 五档):
- 1400/1100/700px:5 框一行(200px / 140px / 120px);900/480px:换行且框仍等大
- 五档均无横向溢出
- 字号:阶段名 13.5px/700,级别 16px/800,深度 26px/800
- 注入一轮轨迹:输入=输入|行动=思考|工具=qq_get_message x3 qq*|输出=生成|结束=完成
- 注入 L3=3/排队=2:L3 描边 rgba(255,166,87,.55)、框头与点亮槽同为琥珀色;
  排队绿框;空的 L4 保持默认描边(首次读到的默认色是 0.25s 过渡中途,非 bug)
- chip 未溢出所在框;总览/侧栏/顶栏渲染文本无 emoji
2026-09-14 18:38:08 +08:00
37924b295b feat(webui): 总览底部源码区改为独立的「开源许可」框(协议 + 全文 + 源码)
此前总览底部只在 KPI 卡里挂了一行小链接(.ov-foot),既看不出受什么许可
约束,也看不出 AGPL 网络服务场景下的义务。现在单独成一张卡:

  开销许可
    许可协议     AGPL-3.0-only   → GNU 官方全文
    源码仓库     <source_url>    → 仓库
    网络服务条款(§13):把修改后的版本作为网络服务对外提供时,
                        必须向使用者提供取得对应源码的途径。

内核侧(License 是新事实,不能只靠前端写死):
- internal/meta:新增 License(SPDX 标识)与 LicenseURL,都可 -ldflags 覆盖。
  LicenseURL 默认指向 GNU 官方 AGPL-3.0 全文页 —— 与仓库托管方、分支名、
  文件路径都无关,换仓库/换分支不会失效。
- internal/sdk/status.go 的 BuildStatus:新增 License / LicenseURL 两个
  json 字段(additive,旧消费方忽略未知字段即可)。
  注意 internal/sdk 不受公开接口冻结约束(docs/git-branching.md §六),
  本次未触碰 third_party/homeagent-sdk/sdk/。
- internal/agent/core/status.go:从 meta 填充。

前端:
- 骨架里 .ov-foot 换成独立的 <div class="card" id="ov-legal">(放在 KPI 卡之后)。
- 网络条款那一段按许可标识是否含 AGPL 决定是否渲染,不硬写协议名。
- 内容对一次构建是常量,沿用 __html 比对,填一次后不再重建(不引入闪烁)。

验证(现网 1400x920,CDP 实测):
- /api/v1/kernel 的 build 现在带 license="AGPL-3.0-only"、
  license_url="https://www.gnu.org/licenses/agpl-3.0.html"
- 卡片为真框:class=card、border 1px、radius 14px;总览结构 = rt-panel | card | ov-legal
- 两个链接均为真 <a>,target=_blank + rel=noopener noreferrer
- updateOverview() 再跑一次,卡片子节点身份不变(不重建、不闪)
- 回归:KPI 版本副行、阶段管道 5 节点/5 列/6 SVG、队列 5 行×5 格 均正常
- 无横向溢出;总览/侧栏/顶栏渲染文本无 emoji
- go vet 干净;agent/core、plugins/webui、plugins 全量测试通过
2026-09-14 17:48:54 +08:00
bead5746c3 feat(webui): 总览显示内核身份、图标全 SVG 化、队列改格槽、阶段管道下方按阶段列事件
四个问题一起改(都出在总览/内核页的展示层,不动内核逻辑):

1) 内核版本不再"看不见"
   - 36b577b 改图标 KPI 时把 kernel_name 丢了,只剩一个 "v1.4.0",分不清
     是哪个内核、哪次构建。现在 KPI 值给版本号,下面补一行副行
     「HomeAgent · <commit>」(.ov-sub)。
   - 内核页此前**完全没有构建信息**,现在补一张「构建」卡:内核名/内核版本/
     Commit/构建时间/SDK 兼容/源码链接(AGPL §13 的入口页)。

2) 任何位置都不再用 emoji/符号字符充当图标
   - 新增 RT_ICO(纯内联 SVG,24x24 / currentColor),替换:阶段节点的循环
     标记(原 ↻)、轨迹 chip 的工具/输出标记(原 ⚙/⇥)、"立即运行"(原 )、
     工具卡与思考卡的下拉箭头(原 ▾)。
   - CSS 注释里的同类字符一并去掉。

3) 队列不再"空着只有文字"
   - 原来画的是宽度百分比进度条:深度为 0 时宽度就是 0,五行只剩文字。
     改成 rtSlots 的「车位」式格槽(至少 5 格、最多 16 格,按全场最大深度
     缩放),0 时仍有可见形状,占用多少一眼可数;超出格数时给 +N。
   - 修掉一个真实的 DOM 结构错误:第五条「排队」队列被写在 .rt-levels 闭合
     **之后**,且后面多一个 </div>,多出来的闭合标签会提前关掉祖先节点、
     把整块布局撞歪。现在它回到容器内。

4) 每一步管道的事件显示在管道下方对应阶段列里
   - 原来是一条拍平的 chip 序列,看不出"这件事发生在哪个阶段"。
     现在 .rt-pipe-cols 与上面的阶段节点共用 5 等分栅格,事件按 g(阶段组)
     分列落位;实测列中心与节点中心偏差 ≤ 2px。
   - 轨迹覆盖全部阶段(输入/思考/工具/输出/完成),同阶段重复的同一条
     累加 ×N 而不是刷屏(rtTrailPush)。空列显示一个弱化的「无」。

验证(现网 1400x920,CDP 实测):
- 版本 KPI = v1.4.0+hotfix.0fd4fb1 / HomeAgent · 0fd4fb1;内核页构建卡齐全
- 注入一轮轨迹:col0=输入 col1=思考x2 col2=qq_get_message x3/qq/cmd_run
  col3=生成x2 col4=完成,active 节点=工具,×N 计数正常,3 个 chip SVG
- 队列 L3=3/5、排队=2/5 点亮,L3 取到琥珀色 rgb(255,166,87)
- 页面无横向溢出;总览/侧栏/顶栏/内核页渲染文本无 emoji
- go vet 干净,internal/plugins/webui 测试通过
2026-09-14 17:40:54 +08:00
21db84e8dc fix(webui): 拓扑 +N 提示改用输出带顶部锚点,修提示串行
无输出通道的 agent 那条带上 outTop 在渲染时才确定,而提示行仍在用
循环变量 y(已累加到别的带),所以「+N 更多」会跑到隔壁带上。
改用该带自己的 outTop,并把基线从 +13 收到 +11(紧贴最后一行)。
2026-09-14 17:40:46 +08:00
0fd4fb1f7a fix(webui): 拓扑按实测容器宽布局 + 字号/截断,修间距失衡与文字难读
三处实机问题:
1) viewBox 固定 640 而容器 ~920,浏览器按 'meet' 把内容顶到左上、右侧空出一大片
   —— 观感就是「间距不对」。改为 viewBox 宽 = 实测容器宽、width 用像素值,
   缩放恒为 1(已用 getScreenCTM().a 验证)。
2) 文字全是 9-11px + 低对比度硬编码色(#8b90a5)→ 难读。字号提到 10.5-12.5px,
   fill/font-size 改走 .tp-* 类,颜色交给 --text-primary/--text-muted 主题变量。
3) 列短的一侧原来顶在带上半、节点居中,连线又长又歪;长通道名还会溢出到邻居身上。
   现在两列在带内各自居中、节点块高度参与带高计算(单行带不再把节点名压到下一条带),
   长名按估算宽度截断加 …,完整名放 <title> 悬停可见。
另:rtSpark 用的 _rtEdgeIn/_rtEdgeOut 键与取值方式未变,光点动画照旧。
2026-09-14 16:51:41 +08:00
9eebd96ab7 fix(core): 插件拒绝工具时把 ctx.Response 的理由透给模型
before_toolcall 的 ctx.Response 是插件写的**拒绝理由**,但工具结果被写死成
「工具 X 已被插件拒绝」,理由从不到达模型——模型于是不知道能不能重试,
会反复重试被拒的调用。抽出 denialResultText 并在有理由时原样透出。
2026-09-14 16:45:04 +08:00
c252915083 feat(webui): 阶段管道改「循环 + 本轮轨迹」,区分工具/输出调用;再砍总览文字
jianf:阶段管道像无记忆的单向滑块,但一轮里会多次 toolcall、也可能多次输出;
且没区分 output_* 调用与普通工具调用;总览仍有一大坨文字。

- 阶段管道不再是单向滑块:#
  画成 输入 → 行动 ⇄(工具↻) → 输出 → 结束 的循环结构,当前阶段高亮;
  下面用一排 chip 记**本轮真实发生过的序列**(on_input 重置、before_toolcall 追加、
  after_output 收尾,最多 24 条)。工具调用会反复出现,循环因此可见。
- 区分调用类型:普通工具 chip 前缀 ⚙(青),output_* 输出通道调用前缀 ⇥(accent 色),
  两者配色与图标都不同。
- 文字再收缩:删掉「累计:入队/执行/抢占/挂起/背压」整行;队列标签由
  「L4 内核独占…」压成 L4/L3/L2/L1/排队(原描述进 title);各段标题压成
  「队列」「栈」「拓扑」;KPI 块标签压成 排队/中断/栈/子代理。

顺带(同类问题):CLI /stop 是人在终端当场下的指令,优先级由默认 L1 提到 L3。
2026-09-14 16:31:37 +08:00
36b577bff8 fix(webui): 总览改静态骨架 + 图标 KPI,彻底去掉整页重建的闪烁
jianf:仍严重闪烁;应彻底摒弃增量重建,用动态图标 + api 数据展示;主页文字太多。

- renderOverview 从「每次 innerHTML 重建整页(含运行态面板)」改成**首帧建一次
  静态骨架**,之后 renderAll(每 15s 一次)只 updateOverview —— 只写 textContent
  与类名,一个节点都不重建。实测连续两次 renderAll 后 #ov-kpis / #ov-status /
  #rt-panel 仍是同一批 DOM 节点,这是"不再闪"的直接判据。
- 主页文字大幅收缩:删掉「系统概览 / LLM 状态 / 记忆状态 / 运行时」四张 kv 文字卡,
  改成一排 8 个图标 KPI(状态/运行/插件/版本/LLM/记忆/文档/运行时),状态用彩色
  圆点表达,其余只留数字 + 两字标签。
- 运行态面板不再被 renderOverview 清空(去掉 _rtSig=null 与重建),保持连续更新。
2026-09-14 16:16:14 +08:00
97111778a9 feat(webui): 数据查询 API + 前端 keyed 对账,去掉「局部重建」的闪烁
jianf:局部重建的闪烁几乎消不掉,应暴露数据查询 api,前端轮询后增量更新视图,
聊天记录也用这套。

后端(数据查询 api):
- ChatMsg 增加 seq(服务端单调递增、随记录落盘);老记录加载时补 1..n,重启不重编号。
- /api/v1/chat/history 增加 after=<seq> 增量通道:只回 seq 更大的消息,返回 last_seq
  作下次游标;一批超 limit 时回**最旧**的一批(回最新会把被挤掉的旧消息永久漏掉)。
  普通响应也带 last_seq,客户端首次全量后据此初始化游标。
- 测试 TestChatHistoryIncrementalAfterCursor 钉住「不重不漏 + 截断停在返回的最后一条」。

前端:
- 新增通用 morph():按「子节点位置 + nodeName」递归对账 DOM,同名节点复用、只同步
  变化的属性与文本。运行态面板的 put() 由 innerHTML 重建改为 morph —— SVG 圆环、
  队列条、数字块这些未变节点不再被替换,CSS 过渡与动画不再从头播。
- 聊天列表改用 keyed commitChatList():按 data-key(服务端 seq / 本地临时 key)对账,
  未变消息节点一个字节都不动,只替换真正变化的那条。
- syncChatFromHistory 改走游标:pollChatIncremental() 用 after 拿增量 + tail=1 探尾部
  原地更新(工具调用/最终文本是原地改的,不产生新 seq);聊天页可见时 3s 轮询。
2026-09-14 15:56:27 +08:00
f722498dba feat(webui): 默认配色改黑白 + 设置页新增「外观」区
jianf:默认配色太花,且配色要能在设置页调。

- 新增 mono(黑白灰)配色并设为默认:未选过配色的 localStorage 一律
  data-color=mono。黑白下连拓扑归属配色也走灰阶,不至于只剩一张彩图。
- accent 的所有硬编码 rgba(255,127,172,x) 收敛成语义变量 --accent-rgb,
  各配色块(sakura/cyan/violet/emerald/amber/blue)各自声明自己的 rgb,
  于是换配色时阴影/描边/阴影辉光一起换,不再残留粉色。
- 设置页新增「外观」区(侧栏最前):主题(浅/深)+ 7 个配色圆点 +
  背景图 URL/模糊。原先只有侧栏底部一个调色盘图标,找不到。
- 切配色时强制重画运行态(置空 _rtSig),否则拓扑会停在旧色。
2026-09-14 15:44:17 +08:00
e4d69fa140 feat(webui): 总览改版 —— 阶段管道滑块 / per-agent 负载环 / 通道→agent 拓扑与光点
总览页此前是一堆数字与文字块,看不出「这一轮走到哪、谁忙、消息从哪进哪出」。
本次把运行态面板改成以图形为主:

- 阶段管道:七阶段滑块,由 SSE stage 事件驱动,当前阶段高亮、滑块滑过去;
  一轮结束(after_output 或 2.5s 无事件)自动回到空闲,不做假动画。
- 队列与中断栈:沿用五条进度条(L1–L4 + 排队),中断栈补一条深度进度条。
- Agent 拓扑:改成「每 agent 一条横带」——左 inputch、中 agent 节点(圆环 = 负载)、
  右 outputch,连线即路由;删掉旧的「归属框 + 单个内核盒」画法(看得出哪个子接了哪条输入)。
- 光点动画:channel_input(新增轻量 SSE 事件)沿 inputch→agent 连线跑;
  agent_output 沿 agent→outputch 连线跑。用 SMIL animateMotion,不需要 rAF 循环。
- 负载:由该 agent **自己的**调度器积压(排队 / 四级中断 / 中断栈)按级别加权折算,
  环形图展示。为此把驻留子的调度器积压透出到状态面(SDK 纯追加字段)。

后端:sdk.ResidentStatus / core.ResidentInfo 增加子 agent 调度器积压四项;
WebUI SSE 增加 channel_input 轻量事件(只带通道名与 agent id,不带正文)。

顺带收口对话区视觉(页签改分段控件、消息间距/气泡区分、输入区分隔线)。
2026-09-14 15:33:23 +08:00
0fda210e8b feat(webui): 视觉重做第一轮 —— 侧栏图标化、顶栏标题化、卡片/行/按钮收口
反馈是「丑死了」,没有具体项,所以按「哪儿在制造廉价感」逐条改:

1. **侧栏只有文字**:6 个导航项各加 24×24 stroke 图标(currentColor,随选中/hover 变色),
   10px 间距、13.5px/500 字重、圆角 10px 的药丸命中区;品牌字改 sakura→frost 渐变。
   → 空荡荡的 16rem 栏终于有了骨架。

2. **选中态把文字整体右推 + 发光文字**:原来用 `border-left: 3px` 画选中条,
   hover 时整行抖 3px;还加了 text-shadow 光晕。改成 `box-shadow: inset 2px 0 0`
   (不占布局)+ 取消光晕 + 选中加粗。这类「一像素级不稳」是廉价感的主要来源。

3. **顶栏只有一行灰字面包屑**:把当前页做成 15px/650 的标题色,面包屑碎片
   压到 12.5px 且降透明度;顶栏 48→56px。页面总算有「入口」。

4. **卡片 hover 整页上下浮**:`.card:hover` 去掉 `translateY(-1px)`(十几张卡一起
   浮,视线扫过像在抖),只提亮阴影与描边;padding 20→22、卡片间距 16→18。

5. **卡片标题没有章节信号**:`h2` 前加 3×14px 的 sakura→frost 渐变短竖。

6. **kv-row 是文字墙**:flex + 固定 180px 键列 → grid `minmax(110px,180px) 1fr`,
   行高 8px、负外边距 hover 高亮、末行去分隔线、数值 `tabular-nums`(端口/计数上下对齐)。

7. **满屏药丸按钮**:`.btn` 圆角从 999px 收到 10px(与卡片同一套圆角),
   padding/font 微调;`.btn-sm` 11→11.5px 提升可读性。

8. **内容区靠左铺满**:`.container` 居中 + `max-width: 1240px`(超宽屏摊满整个
   屏幕是「后台模板」的典型观感)。

i18n 有个坑:切语言那段是 `el.textContent = …`,所以 `data-i18n` 必须从 `<a>` 挪到
内层 `<span>`,否则切一次语言图标就被抹掉。实测 ZH→EN→ZH 图标都在。

验证:go build/测试绿(webui + sdk + agent + plugin);真机 1440×900 六页截图对比
(侧栏图标、渐变品牌、标题竖条、grid 行、居中内容区均生效)。
2026-09-14 14:29:17 +08:00
cdb2ea2207 polish(webui): 拓扑图只在容量非默认时写数字(去掉十几行「默认」文字) 2026-09-14 11:24:34 +08:00
e8d7bb4c06 feat(webui): 通道归属合并进拓扑图,整张图改 SVG(少文字、多图形)
上一版把「通道分配(按归属)」单开一段,等于把同一件事拆成两张表——
而通道属于谁是**拓扑的一部分**(左边这些输入口分别被谁接管),拆开反而
看不出关系。按用户要求合并,并整体改成图形化:

- 整张拓扑用 SVG:左侧按归属画出输入通道容器(根=青色虚线框,驻留子=彩色
  实线框并标轮次/上下文满),→ 汇集母线 → 内核 → 输出母线 → 右侧输出通道。
  **连线即路由**。
- 信息全部改用图形编码:归属=容器/配色、容量=节点内细条(默认容量不画填充)、
  输出能力=五个彩色圆点(text/file/image/audio/structured)。
- 文字降到最少:去掉四个数字块的副标题、排队队列那行只留 "FIFO"、
  通道行不再写"回程由来源决定"这类说明。
- 段名改为「通道拓扑(连线即路由;左框 = 归属)」。

验证:node --check 通过;go build ./... 干净;webui 测试全绿。
2026-09-14 11:22:20 +08:00
75f377fd4d fix(remotedevice): 心跳 pong 忘了 Flush —— 修「设备通道每 60 秒掉线重连」
真因(实测定位):服务端 writePong 只调 writeFrameHeader,**不 Flush**。
pong 只有两个字节,且设备空闲时没有任何别的写会顺带把 bufio 缓冲刷出去 ——
于是 pong 永远留在服务端缓冲里。

链路:客户端每 30s 发一个 ping(pingLoop)→ 服务端算出 pong 却没发出 →
客户端的读循环设的是「2 倍 ping 间隔」读超时(默认 60s)→ 每 60 秒准点
i/o timeout → 桥断开 → 3s 后重连 → 服务端 markOffline 注销 outputch,
重连后再注册。

生产日志就是这个指纹(online :20 → offline 下一分钟 :20 → 重连 :23,
连续数小时无一次例外);面板上表现为设备通道/工具凭空消失又出现,
/devices 列表跟着闪。

改法:writePong 复用 writeFrame(它 Flush)。另把客户端读循环退出时的
静默 return 改成带错误与 opcode 的日志 —— 此前断线真因在设备侧完全不可见,
只能靠对端日志倒推,正是这次排查一开始卡住的地方。

回归用例 TestWSPingGetsPongWhileIdle:只发一个 ping,随后什么都不发,
要求 2s 内必须收到 pong。**反向验证过**:把修复改回 writeFrameHeader,
用例即以 `read tcp ...: i/o timeout` 失败(与生产症状一致)。
2026-09-14 11:15:21 +08:00
8756f8d77f fix(webui): 通道归属把「根 agent id」与驻留子分开(根不再被标成「驻留子 main」)
实测(创建一个驻留子 uitest 并把 timer 划给它)暴露的归类错误:
inputch 的 owner 在登记表里可以是**根 agent 自己的 id**(如 "main")——
child/<id> 这条就是 owner="main"。前端只按「owner 非空」判为驻留子,
于是根自己那条被标成「驻留子 main」,而同一条通道在 residents 里根本不存在。

改法:
- /api/v1/runtime 补 agent_id(根 agent 的 id);
- 前端把 owner == 根 id 与 owner == "" 归一成同一组「根 agent / 内核默认」,
  只有既非空又非根 id 的才是子容器。
2026-09-14 11:08:27 +08:00
d502fc1bf5 fix(webui): 运行态面板逐段更新(真修「一闪一闪」)+ 补第五条排队队列
1) 上一版只做了整体签名缓存,实测仍会重建:设备通道列表本身就在来回变
   (远程设备通道 11→9 条),签名一变就整块 innerHTML,没变的段落(含条
   transition)也跟着推倒重来——视觉上仍是闪。改法:外壳只建一次,之后
   **逐段**(tiles/levels/stack/owners/topo)比较 HTML,只替换真正变了的那段。

2) 设计是「四条中断队列(L1–L4)+ 一条排队队列」= 五个队列,面板只画了四条:
   排队输入这条线在运行态里凭空消失。补第五行「排队(无级别)」,用中性色 +
   虚线分隔(它不是优先级,而是另一**类别**),并把它计入条形归一化基准。
   段标题从「中断队列(按级别)」改为「队列(四级中断 + 排队)」。

3) 顺带把累计计数(入队/执行/抢占/挂起恢复/拒绝/背压)显式列在数字块下方——
   背压是新指标,之前只能看接口看不到面板。

验证:node --check 通过;go build ./... 干净;webui/core/sdk 测试全绿。
2026-09-14 11:03:37 +08:00
43536ba326 fix(webui): 运行态面板不再闪、通道分配带归属(含驻留子)、改图形化
三个用户可见问题,逐个说明根因与改法。

1) 首页「一闪一闪」——运行态每 3s 轮询一次,renderRuntime 无条件重建
   #rt-panel 的 innerHTML:数据没变也把整块 DOM(含各级条的 transition)
   推倒重来。改法:缓存数据签名(**不含 uptime**——它每秒都变,带上等于没缓存),
   签名相同直接 return,一个字节都不动。另:renderOverview 会整块重建
   #rt-panel(面板本身是空的),所以那里必须让签名失效,否则空面板填不上。

2) 通道分配只显示内核/根 agent,看不见驻留子——根因是状态面只暴露了设备能力
   (KernelStatus.Channels,来自 iom.ListChannels),而「这条输入归谁」是
   ChannelRegistry 的属性(InputChannel.Owner/Capacity/Output),从未出过内核。
   而登记表本来就是根 agent 与驻留子**共用同一份**,所以数据一直都在,只是没画。
   改法:KernelStatus 新增 InputChannels(+ sdk.InputChannelInfo),
   /api/v1/runtime 带出 input_channels;前端把它按 owner 分进「归属容器」,
   驻留子即使一条 inputch 都没划到也照样出现在图里(否则"子存在但看不见"
   与"子不存在"无法区分),并显示其 allowed_outputs / 轮次 / 上下文满标记。

3) 「这些信息明明可以图形化」——四级中断的登记/抢占由纯文本改成并排迷你条;
   通道分配用归属容器 + 容量滑块(轨道/填充/把手/读数),并把回程通道、
   注册插件做成胶囊标签。设备能力拓扑(原有)保留。

验证:go build/vet 干净;go test ./internal/agent/... ./internal/plugin/...
./internal/sdk/... ./cmd/... 全绿;node --check dashboard.js 语法通过。
TestRuntimeEndpoint 扩展为同时钉住 input_channels 的归属与「驻留子划走的那条」。
2026-09-14 10:55:45 +08:00
c321388a21 fix(scheduler): 安全点重新求值中断队列 + 抢占/背压计数修正 + 停机补终态
对照 docs/zh/input-scheduler-design.md 原文修四处(前两处是真缺陷,后两处是
观测面与设计承诺不一致),均配回归用例:

1. §4.3/§5.2「临界区结束后的第一个安全点重新求值」此前**没有实现**:
   全仓唯一的武装点是 registerInterrupt,凡被拦成「入队」的中断只能等当前任务
   自然结束。可达症状:WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却窗内 →
   入队 → 再也不会被求值。修:runTaskSteps 的安全点先 rearmPending()——
   判据与 registerInterrupt 完全同一套(canPreempt + 冷却 + 临界区闸门)。

2. PreemptsByLevel 的语义是「进入 immediate 槽的次数」,但计数发生在
   setImmediateLocked 之前:immediate 是单槽,同一安全点前到达的两条同级中断里
   被降级的那条也被计成抢占。修:setImmediateLocked 只在真占住槽时返回 true,
   计数随之为真;同时把「降级入队」的责任收归调用方,消除同一任务被入队两次的
   隐患(实测该隐患会让中断任务执行两次、Executed 虚高)。

3. 状态面 Preempted 此前拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
   修:Preempted = Σ PreemptsByLevel[1..4]。

4. §4.4/Q4「满时阻塞发送方 + 计数并打日志」只做了阻塞:pumpInbox 满时直接返回,
   一个字都不计。修:新增 Stats.Backpressure(+DTO 字段) 与只报一次的状态翻转日志;
   同时显式处理 enqueue 返回值(静默丢弃会让同步调用方永久挂起)。

另:Stop() 停机前排空待办——给从未运行与已挂起的、带 ResponseCh 的任务补
skipped 终态,否则 cli/clawhubadapter 这类无超时同步注入方永久挂起(§7 I5、§11.3 X4)。
emitResponse 的 ResponseCh 写入改为非阻塞 + 告警,避免一行写错就卡死调度器 goroutine。

验证:go build/vet 干净;go test -count=1 ./internal/agent/... ./internal/plugin/...
./internal/sdk/... ./cmd/... 全绿;go test -race ./internal/agent/core/ ./internal/sdk/ 干净。
新增 scheduler_rearm_test.go 六个用例(冷却期满重新求值/同级降级不计数/Preempted 求和/
停机补终态/背压计数与翻转/pumpInbox 满计数)。
2026-09-14 10:39:25 +08:00
1ce3a917a5 feat(status+webui): 运行态图形化 —— 排队/四级中断队列/中断栈/驻留子/通道拓扑
需求:首页不该只有文字,要能一眼看出内核在忙什么——排队消息数、各级中断
排队与中断栈、驻留子 agent 数量;这些要向**内部 SDK 暴露接口**,供 WebUI 等应用
展示;通道划分也要能画出来。

## 一、内核状态面(internal/sdk,内部 SDK,不受公开 SDK 冻结约束)

* SchedulerStatus 补:
  - interrupt_queues[5]:**四级中断队列各自的深度**(下标即级别 1..4,下标 0 恒 0,
    这样 level 能直接当数组下标用)。此前只有 pending_interrupts 总数,
    看不出"堵在 L1 还是 L4"——四级是抢占优先级,堵在哪级是完全不同的运行状态。
  - immediate:刚抢占成功、下一个安全点立即运行的那个中断(此前完全不可见)。
  - suspend_frames:中断栈的帧(栈底→栈顶,只给任务标识),depth 之外还能看出
    "谁被谁打断"。
  - interrupts_by_level / preempts_by_level:各级累计登记数与抢占成功数。
* KernelStatus 补 residents(驻留子运行时视图:状态/轮次/上下文已满/输入通道/允许输出)。
  刻意**不带**每个驻留子的 inputch 登记明细——状态面会被反复轮询,明细会让
  每次 /status 背上几十 KB;只给表大小,要明细走专门接口。
* ChannelInfo 补 direction(in/out/io)、description、tools、output_caps、caps_text。
  此前 collectKernelStatus 只透传 Name/Type,把描述/工具/能力**全丢了**,
  前端只能画出一排光秃秃的名字。

## 二、WebUI

* 新增只读 `/api/v1/runtime`:只回运行态三件事(scheduler/residents/channels),
  实测 **1.0KB**(/kernel 是 30KB 级)——所以能 3 秒轮询做"实时"感,
  而不必反复拉全量状态。
* 首页新增「运行态」面板(纯 CSS + 内联 SVG,前端仍无构建链):
  - 四个数字块:排队任务 / 待处理中断 / 中断栈(深度/上限) / 驻留子 Agent,带占比条;
  - 四级中断队列条形图:每级"深度 · 登记/抢占",四级语义**照抄内核**
    (L4 内核独占 / L3 交互 / L2 消息 / L1 后台),不自己起名字;
  - 中断栈层叠图(栈顶在上)+ 立即运行项;
  - 通道拓扑:输入通道 → 内核 → 输出通道,双向通道两侧都出现,能力以胶囊标签显示。
* 3 秒轮询只在总览页可见时才发请求;切回总览时 renderAll 会立刻补一次。

## 验证

* 新增 TestSchedulerStatusExposesLevelsAndStack(四级队列/立即项/栈帧/各级计数映射,
  并断言"未使用的级别必须为 0"与"下标 0 恒 0")、TestChannelInfoCarriesTopology、
  TestRuntimeEndpoint(形状 + 不携带 tools/plugins + 无状态源时 503)。
* go build / vet / agent+core+sdk+plugin+webui 全量测试绿。
* 真实浏览器实测(CDP 驱动,注入运行态样本走真实渲染路径):
  数字块 [3, 5, 2/4, 2];四级条 L4=1/L3=2/L2=1/L1=1 与数据一致;
  栈帧按"栈底→栈顶"渲染且标出栈顶;通道左右分列、io 通道两侧都出现。
2026-09-14 09:05:14 +08:00
c0e9dc1818 fix(webui): 聊天记录不再"每次都发完整记录",并修掉视口跳顶
两个都是你指出的症状,都定位到了具体代码路径。

① 「每次都发完整聊天记录」
   a) API 缺省值错了:/api/v1/chat/history 的 limit 缺省是 0 = **不限制**,
      于是任何不带 limit 的调用每次都拿到整段记录。实测(126 条):
      不带 limit 635,297 字节;现在默认只回一页 180,668 字节。
      显式 limit=0 仍可整取(逃生口)。WebUI/GUI 本来都带 limit,不受影响。
   b) 前端 30s 轮询(以及每次 SSE 报错)都直接拉一页 40 条:
      浏览器实测单次 180,813 字节。现在先做"尾巴探测"(limit=1,362 字节),
      尾巴一致就直接跳过;不一致才拉整页。

② 「聊天记录会跳到顶部」——两条会导致视口丢失的路径都堵上
   a) syncChatFromHistory 在"找不到重合点"时直接 `state.messages = serverMsgs`:
      服务端只回一页,而本地可能已经向上翻了好几页;一覆盖,容器立刻变矮,
      视口被夹回顶部,用户翻过的旧消息也凭空消失。现在只在服务端页**不短于**本地时
      才整体替换。
   b) renderChat 全量重建 innerHTML 后,仅在粘底时滚到底;非粘底(用户正在向上读)
      时位置没人管。改为重建前记住 scrollTop、非粘底时原样还回去。

浏览器实测(CDP 驱动真实页面,126 条历史):
  * 15s/30s 定时器跑满 40s:聊天区滚动位置 **0 px 变化**,未跳顶;
  * 期间 chat/history 请求:limit=1 × 2(各 362 字节)+ 首屏 limit=40 一次;
  * 控制台无报错;新增消息后轮询仍能正确并进来(尾巴探测→拉整页→合并)。

测试:新增 TestChatHistoryDefaultIsPaged(默认一页 / has_more / limit=0 整取 / 显式分页)。
顺带修测试串味:迁移用例往 os.TempDir() 写共享历史文件,会让其它用例的
NewHandler 加载到脏历史(表现为条数多 1);现在各用例用自己的临时文件。
2026-09-14 08:06:16 +08:00
764939ed90 refactor(webui): dashboard.html 6637 行拆成「外壳 + 样式 + 脚本」
前端刻意没有构建链(纯 CSS + Vanilla JS,go:embed 进二进制),所以拆法是:
外壳 dashboard.html 留 {{DASHBOARD_CSS}} / {{DASHBOARD_JS}} 两个占位符,
init() 启动时把两份资产原样填回去 —— **发出的 HTML 与拆分前逐字节一致**,
但 6637 行的单文件变成三份,便于编辑与评审。

  dashboard.html   168 行   外壳(head/body 结构 + 两个占位符)
  dashboard.css   2099 行   样式
  dashboard.js    4371 行   脚本

逐字节校验(三重):
  * 组装结果 sha256 == git HEAD 里拆分前的 dashboard.html;
  * 真实实例 GET /(带 API key)返回体 sha256 同上:0ef14b49…c053;
  * 新增 TestDashboardAssetsSplit:占位符必须存在、样式/脚本不得再内联回外壳、
    组装结果不得残留占位符且必须含样式与脚本特征串。

按行号切片时踩过一次坑并已修正:`</style>`/`</script>` 两个闭合标签被切掉
(正好少 27 字节)——正是因为当时少了逐字节校验,现在把它固化成断言。
2026-09-14 07:17:23 +08:00
4cbfdc970c perf(webui)+feat(config): 聊天记录写盘节流 + 配置库空闲页回收
两条都是我上一封里点出、你说继续的问题。

① 聊天记录:每条消息都整段重写 → 节流合并写
   原来 persistChatLocked 每次变更就整段重写记录文件,而一轮对话会触发多次
   (用户消息、每个工具事件、收尾消息)。200 条上限下文件可达数 MB,单轮就能
   放大出几十 MB 写。文件里还留着一个 chatSaveThrottle=3s 常量——声明了但从未
   被使用(疑似上次 revert 的遗留),等于节流从来没生效。
   现在:persistChatLocked 只置脏 + 唤醒写盘协程;chatPersistLoop 去抖
   chatSaveThrottle(3s)、并以 chatSaveMaxDelay(10s) 兜底(持续输出也不会无限拖延);
   写盘前把快照拷出来,**不持 chatMu 做文件 IO**;写失败重新标脏下轮重试。
   插件 Stop 里调 Handler.Close():停协程 + 强制落最后一次(幂等),否则丢最后一轮。

   实测(临时实例,连发 3 条消息):3s 窗口内记录文件**尚未创建**(节流生效);
   SIGTERM 后文件出现且 6 条(3 用户 + 3 助手,无 LLM key 故为错误回复)全在
   ——关停落盘没丢。

② config.db:SQLite 的 DELETE 不缩文件 → 空闲页够多时 VACUUM
   新增 ConfigRegistry.MaybeCompact(minFreeBytes, minRatio):空闲页 >= 1MB 且
   占页数 >= 25% 才做一次 VACUUM,避免每次启动都重写整库。库里是 WAL 模式,
   VACUUM 之后必须再 wal_checkpoint(TRUNCATE),否则主库文件看着没变小。
   调用点放在插件加载**之后**(大值的搬走/删除发生在插件 Start 里,之前调没意义)。

   实测(一个刚被搬走 5MB 聊天记录的实例):
     freelist 1288 页 × 4096B;启动日志「配置库已压缩: 5394432 -> 118784 字节」
     config.db 5,394,432 → 118,784 字节;记录文件 5,279,491 字节完好未动。

测试:TestChatPersistenceIsThrottled(节流窗口内不写盘 + Close 必落盘 + Close 幂等)、
TestMaybeCompactReclaimsFreePages(删大值后文件确实变小 + 数据完好 + 阈值不达标时不白做功)。
2026-09-14 07:14:55 +08:00
642e1c39b1 refactor(webui): handler.go 2993 行按资源拆成 11 个同包文件
拆法:按「资源面」搬家,每个顶层声明(func/type/var/const)整体搬到目标文件,
声明体一字未改,各文件按实际用到的包重新生成 import。文件头加一行说明本文件负责哪一面。

  handler.go              骨架:嵌入前端资源、Handler/构造、路由表、鉴权会话日志中间件、静态页
  handler_chat.go         对话面:消息模型与内存历史、SSE 事件订阅、对话/历史接口
  handler_upload.go       上传面:handleChatFile / handleUploads / 中断对话
  handler_memory.go       记忆面:图/文档/文本记忆、知识库、LLM 源、变更追踪
  handler_agents.go       内核与代理面:状态、kernel、人格、代理/快照/回滚
  handler_settings.go     设置与插件面:配置读写、插件列表详情(含 pluginmgr 反代)
  handler_terminal.go     终端面:终端会话、终端接口、命令历史
  handler_sse.go          SSE 环形缓冲(断线重连补发)
  handler_openai.go       OpenAI 兼容面:/v1/chat/completions
  handler_device.go       设备网关反代(HTTP + WS 升级透传)
  handler_files.go        /files/ 与 /uploads/ 下载

零漂移校验:拿重构前的 handler.go 与新 11 个文件逐行比对(忽略空行、package/import 头),
**丢失行 0**;新增行恰好是 11 个文件头注释(14 行)。

顺带修掉 import 里两处假使用:handler_openai 的 sdk 只作为 Handler 字段名出现(h.sdk.),
handler_settings 的 fmt 只出现在注释里 —— 都从 import 里去掉。

验证:go build ./... / go vet / webui+config+sdk 测试全绿;
起真实实例(沿用已有 data 目录)后 /status /settings /chat/history /plugins /terminals
/kernel /memory /config /login 全部 200,设置在注入 5MB 历史的情况下仍是 33,921 字节。

最大文件从 2993 → 706 行(handler_chat.go)。
2026-09-14 07:03:47 +08:00
c4998bb102 feat(webui): 聊天记录改为独立文件存储(位置可配)+ 存量自动迁移
起因:聊天记录原先作为插件配置项 plugin.webui.chathistory 存在 config.db 里,
带来三个后果(都在生产实例上实测过):
  1. 整段记录 5,176,016 字节会被 GET /api/v1/settings 当普通配置项整块返回;
  2. 每来一条消息就把整段记录重新 marshal 后写回 config 表,而那次写要拿
     config registry 的全局写锁 —— 消息频繁时所有配置读写都被拖着排队;
  3. 位置不可配(想放独立挂载盘只能改整个 data_dir)。

改动:
* 新增 internal/plugins/webui/history.go:
  - historyStore:默认 <data>/webui_chat_history.json,写盘用同目录 tmp+rename
    原子替换,崩溃不会留半截 JSON;读失败/JSON 损坏按空历史处理并告警
    (聊天记录不是关键数据,不该让它拖垮 WebUI)。
  - resolveHistoryFile:插件设置 history_file > 默认路径;相对路径按 data 目录
    解析(可指向独立挂载盘),data 目录未知时落到系统临时目录而不是进程 CWD。
  - LoadWithMigration:文件为准;文件为空而老配置项有内容时,把记录搬到文件、
    搬成功才删配置项(删不掉就保留并告警,不丢数据);文件已有数据时顺手清掉
    上次没删干净的遗留键。
* 新增插件设置项 history_file(设置页可见可改):留空 = 默认路径。
* handler.go:chatHistory 的读/写改走 historyStore,不再碰 settings;
  顺带把「写失败静默忽略」改成告警。

存量迁移实测(拿仍持有 5,271,690 字节老记录的实例跑新二进制):
  日志:聊天记录已迁移到独立文件 .../webui_chat_history.json(1300 条),并从插件配置表移除
  迁移后:config_webui 里 chathistory 行数 = 0;记录文件 5,279,491 字节
          GET /api/v1/settings = 33,921 字节(迁移前 8,244,108)
          GET /api/v1/chat/history 正常(从文件读回 1300 条里最新的 3 条)

新增测试:TestResolveHistoryFile、TestHistoryStoreMigratesFromConfig(含二次加载
不重复迁移 + 损坏文件不 panic)、TestHistoryStoreSaveIsAtomicAndRoundTrips。
2026-09-14 07:00:07 +08:00
0beb389223 fix(webui): 设置接口不再吐内部数据;--webui 覆盖生效;端口占用不再静默成功
三处实测确认的缺陷:

① 设置接口整块吐出聊天记录
   plugin.webui.chathistory 是 webui 自己持久化的整段聊天记录(生产实例
   实测 5,176,016 字节),躺在插件配置表里被设置接口当普通配置项整块返回,
   前端还会把它渲染成一个巨大的文本框。
   修复:GET 跳过该键(按插件+键精确判定),PUT 直接 400,避免误改。

② CLI --webui 与 webui.listen_addr 一直是死配置
   内核原本在插件加载前写 settings["addr"],但那时 config_<name> 表还没建
   (表只在插件注册 def 时创建),PluginSettings.Set 的 INSERT 失败,而错误被
   "_ =" 忽略了;随后插件 Start 里 RegisterDef 才建表并写入默认 :8080。
   实测:传 "-webui 127.0.0.1:18099" 仍然监听 :8080。
   修复:覆盖值改由插件自己接收(webui.SetListenOverride,loadPlugins 前调用),
   优先级 CLI > webui.listen_addr(非默认值才算显式配置)> settings["addr"]。
   实测修复后:"-webui 127.0.0.1:18099" 正确监听 18099,与生产的 :8080 并存。

③ 端口被占时 webui 静默死亡
   Start 在后台 goroutine 里 ListenAndServe,先打印 "listening on" 再尝试绑定,
   失败只留一行日志,Start 永远返回 nil → 插件仍被当成加载成功。
   修复:net.Listen 同步做,失败即返回 error(交给加载器/守护),
   成功后才起 Serve,并打印真实绑定地址。
   A/B 实测(两个实例都撞生产的 :8080):
     修复前:"listening on :8080" + "server error: address already in use" + LOADED: webui
     修复后:"[plugin] start webui: webui: 监听 :8080 失败: ...",不再有 LOADED: webui

效果实测(同一实例,先注入 5,271,690 字节 chathistory):
  GET /api/v1/settings   8,244,108 → 28,652 字节(约 1/288)
  meta 条数              5,208 → 105,幻影键 0 条
  设置页仍正常:?prefix=plugin.webui 返回 8 条 def;普通键 PUT 落库;
  校验:GET/PUT 内部键被拒;-webui 覆盖真实生效。

新增测试:TestSettingsNoCrossPluginLeak(跨插件泄漏/幻影键/chathistory 读写)、
TestListenOverrideAndBindFailure(覆盖生效 + 端口占用必须报错)、
TestResolveListenAddrPrecedence(优先级)。
2026-09-14 06:54:16 +08:00
9e6627f0c3 fix(config): 插件 def 查询不再越界 —— ListDefs 作用域 + 新增 ListCoreDefs
两个方向相反的越界,合起来把 WebUI 设置接口的 meta 撑成 5208 条(96% 重复):

1) PluginSettings.ListDefs(prefix) 把 prefix 直接透传给全局 ListDefs,
   等于「返回全仓所有 def」——调用方以为在问某个插件,实际拿到全部。
   修复:限定到 plugin.<name>. 命名空间,并把 Key 剥回插件内局部键
   (调用方看到的键必须与 Set/Get/ListPlugin 的局部键一致)。

2) DefsCore(prefix) → reg.ListDefs(prefix) 会连插件 def 一起返回,
   于是 meta 里出现 plugin.<name>.<key> 的「核心侧副本」。
   修复:新增 ConfigRegistry.ListCoreDefs,显式排除 plugin.* 命名空间。

生产实例实测(旧代码):GET /api/v1/settings 的 meta = 5208 条,
其中 core.agent.* 等每个 def 都被复制 28 份(每个插件命名空间一份),
并派生出 plugin.<a>.plugin.<b>.<key> 这类幻影键。

⚠️ 幻影键不只是脏数据:设置接口的 PUT 走 SplitN(key, ".", 3),
对 plugin.<a>.plugin.<b>.<key> 会解出 (a, "plugin.<b>.<key>"),
即按 UI 上的幻影条目保存会**写进错误插件的配置表**。

新增 TestPluginDefsAreNamespaced 钉住两条作用域。
2026-09-14 06:53:58 +08:00
129a3aeb38 refactor(proc): coreHandler.Handle 550 行按 method 组拆成 14 个分部函数
原 Handle 是一个 550 行的巨型 switch(C ABI 51 个 case 的整块平移),
按协议面拆进同包 5 个新文件、14 个小函数:

  corehandler_register.go  handleRegister        注册面(tool/stage/output/api/input)
  corehandler_inject.go    handleInject          IO 注入 + SetToolBlocks
  corehandler_memory.go    handleGraphMemory     图记忆
                           handleDocMemory       文档记忆
                           handleKnowledge       知识库
                           handleTextMemory      文本记忆
  corehandler_settings.go  handleSettings        设置(14 个 method 共用一条实现)
                           handleLLM             LLM 源
                           handleSocial          社交图只读
                           handleLifecycle       生命周期开关
  corehandler_runtime.go   handlePluginMgr       插件管理
                           handleStageLocks      段锁仲裁
                           handleEvents          事件订阅
                           handleArena           共享槽池

Handle 保留 capability 强制检查,只做「method → 分部函数」一跳。

零漂移保证:case 标签由脚本从原文提取(不手抄常量名),case 体逐字搬迁,
逐函数比对确认 59 个标签 / 510 行 case 体与原文件完全一致(仅行首缩进经 gofmt 重排)。

验证:go build ./... / go vet / go test ./internal/plugin/... / -race 全绿。
2026-09-13 23:29:13 +08:00
dcae21b24c refactor(lua): replaceSDKReal 633 行按 SDK 子表拆分
抽 luaReg 上下文(L/t/plg/s + subTable/pushVal/pushList/pushErr/pushNil
五个助手方法),把单函数拆成 registerRegistrars / registerInjectors /
registerDataAPIs / registerSettings / registerEventsAndMgr 五个方法。

被移动的代码体**逐字保留**:各方法头部把 L/t/s/plg 与五个助手注入为局部
别名,所以内部一行未改,行为零漂移。luaSyncUnavailable 提为包级函数
(原来它在被拆到另一个方法的作用域里会 undefined)。

实测:replaceSDKReal 从 633 行消失,最大子方法 265 行;非测试函数
>=300 行的数量 3→2。go build/vet/test -race 全绿。
2026-09-13 22:44:39 +08:00
431bb5a0c9 refactor(tooldefs): buildToolDefs 614→273 行(抽 toolDef 助手,schema 形状不变)
原实现每条工具都是 4 层嵌套的 map[string]interface{} 字面量(约 20 行/条),
40+ 条堆成 614 行的巨型函数。新增 toolDef(name, desc, props, required...)
助手消除外层样板;所有 name/description/properties/required 文本**逐字保留**
(用 go/ast 定位原样搬迁,非重新键入),schema 形状与 JSON 输出不变。

验证:go build ./... / go vet / go test ./internal/agent/core/ 全绿;
AST 实测 614→273 行,非测试函数 ≥300 行数由 4 降到 3。
2026-09-13 22:17:45 +08:00
6878f0126d fix(lua): 同步注入在 Lua 中明确报不可用(避免自锁)+ 文档/mock 同步
sdk.inject_input_sync / *_opts / inject_input_media_sync* 在 Lua 里必然自锁:
Lua 代码只在 Start/工具/阶段/输出/事件回调中执行,这些路径都持有 plg.mu,
而同步注入要等本轮回复(回复路径上的回调又需要同一把锁)。原实现会挂死
直到超时;现改为立即返回明确错误,并在中英文 PLUGIN_DEV 里标注不可用 +
指向 Go 插件/异步注入。mock sdk.lua(SDK 仓为事实源)同步为同样的错误语义。
新增 TestLuaSyncInjectUnavailable 钉住不挂死。
2026-09-13 21:59:23 +08:00
52127a3323 fix(resident): CreateResident 注册入站 inputch 后加 defer 回滚
注册点与 residents 登记之间当前无可失败步骤,但缺回滚路径就是 child/<id>
残留那只 bug 的另一条入口。加 registered 标志 + defer:未走到成功返回就注销。
2026-09-13 21:59:10 +08:00
11d9038927 fix(io): ChannelRegistry 补 UnbindOutputTarget,Unregister 清理 outputTargets
outputTargets 只增不减:Unregister 一个 inputch 后,指向它的输出目标登记仍
留在表里,ResolveOutputTarget 会继续把消息路由到已不存在的 agent/inputch。
与刚修的驻留 inputch 残留同属「注册未注销」类。现补 UnbindOutputTarget
(幂等),并让 Unregister 顺手清掉显式绑定与同名回退两种目标登记。
2026-09-13 21:59:10 +08:00
4518c3eb03 fix(lua): events.subscribe 改用内部 Subscribe + 订阅生命周期(修死锁/use-after-close)
上一版 Lua 对齐引入的 sdk.events.subscribe 有两个真问题,本提交修掉:

1) 用了公共 SDK 的 Events(),但本内核从未注入 event subscriber
   (SetEventSubscriber 全仓无调用点),拿到永远是 nil ⇒ subscribe 只会
   返回 "events unavailable"。改用内部 SDK 的 s.Subscribe——内置插件走的就是
   这条路径(cli/webui/skillmgr 全用它)。

2) 自死锁:subscribe 会在 Lua 的 plugin.start(sdk) 回调里被调用,而
   luaPlugin.Start 正持有 p.mu;原实现在 subscribe 里再 lock p.mu 追加 subs,
   不可重入 ⇒ 测试实测 30s 超时。改用独立的 subsMu。

3) use-after-close:Stop 会 Close LState,但事件订阅此前无人取消,残留回调
   再触发就会碰已关的 L。现在:Stop 先(不持 p.mu,避免与 Bus.Publish
   锁序反转)取 subsMu 取消全部订阅,再置 closed 并关 L;事件回调持 p.mu 后
   先查 closed,已进入等锁的旧回调会直接返回。

4) plugin_mgr 访问补 nil 保护(部分单测构造的 SDK 不含 pluginMgr)。

回归:TestLuaEventsSubscribeAndStopCleanup——订阅后 Publish 命中、Stop 后
再 Publish 不 panic。全套 Lua 测试在 -race 下通过。
2026-09-13 20:33:13 +08:00
44cb7243c5 fix(resident): 销毁驻留子时注销其入站 inputch(child/<id>)—— 修登记表脏数据累积
根因:residentInboundChannel 在 create 时把 child/<id> 登记进共享登记表
(Plugin=resident, Owner=父),但 teardownResident 只把划入的 inputch
(如 timer)归还为未分配,从未注销这条入站登记。于是每次 create/destroy
都在登记表里留下一条脏记录,且随次数单调累积。

实测(HomeAgent 侧,HΔ-Kernel v1.3.10 / 1b49365):
resident_agents destroy 之后,input_channels by_agent 仍列出 child/<id>,
归属 main;而 HomeAgent 没有任何工具能单独注销 inputch,只能重启 homed 清。

修法:teardownResident 里用纯函数 inboundChannelName 算出名字并 Unregister。
不能复用 residentInboundChannel——它有重新登记的副作用。
该路径同时覆盖 destroy / reclaim / StopResidents(父退出)。

测试:TestResident_LifecycleAndNoOrphans 增加两条断言——销毁后与父退出后
child/<id> 都必须从登记表消失。
2026-09-13 20:19:37 +08:00
162f33f81e feat(lua): Lua 插件桥全量对齐 SDK 1.3.0(媒体/注入标志位/优先级/事件/通道注销)
内核 Lua 桥(internal/plugin/lua_plugin.go)此前停在 v0.8.0 时代能力面,
1.1/1.2/1.3 新增能力只在 Go 侧存在,而 PLUGIN_DEV.md 宣称『能力完全对齐』。
本补丁把 Lua 侧补齐到与公开 SDK 1.3.0 对齐:

- 1.1 媒体:memory.commit 支持 sentence_text/media_digests;
  doc.insert_with_media + attachments;text_memory.append attachments;
  set_tool_blocks / inject_input_media(_sync) / inject_interrupt_media。
- 1.2 注入语义:inject_input_sync(_opts)、六个 *_opts 变体
  (no_memory/context_policy/cleaner_name/priority);
  ToolDef/ChannelDef 解析 context_policy。
- 1.3 优先级与动态通道:priority 常量透传;unregister_output_channel。
- StageContext 暴露 reasoning_content/context_msgs/token_usage/memory/extra/errors。
- 新增 sdk.events.subscribe 与 sdk.plugin_mgr.*。
- sdk.lua mock 同步(单一事实源在 SDK 仓 sdk/lua/sdk.lua,内核副本由
  third_party/homeagent-sdk/scripts/sync-lua-sdk.sh 同步)。

契约测试(lua_surface_test.go):
- 守住内核内嵌 mock 与 SDK 仓事实源一致;
- 守住 mock 承诺的每个函数都有运行时 RawSetString 绑定;
- 覆盖 opts/media/attachments 解析与 context_policy 透传。

文档:中英 PLUGIN_DEV.md 的 Lua API 表补齐并改为『对齐至 SDK 1.3.0』。
2026-09-13 19:56:32 +08:00
1d4f2beeea fix(prompt): 去掉"每轮只能发一次 output_send"的凭空限制;type 缺省即 text
用户现场指出:**qq 插件的输出通道判据太严了**(那条判据在插件侧,已单独修:
`output_send__qq` 不再受"当前会话身份"限制)。同时内核提示词里还有一条**同类的凭空限制**:

  「每轮对话**通常只需调用一次** output_send__{通道名} 即可完成回复。
    仅在内容确实超过单条消息长度上限(如 >4000 字)时才拆分为多条」

可设计上输出是 agent 的**主动调用**:收到一次输入后,可以往**任意(已授权的)通道**
发**任意多次**(分段播报、先回执后结论、同时通知多个通道都合法)。这句话会让模型
自己收起合理的多次输出 —— 而且它不是任何机制的要求,只是当初为压 output-loop 写的
措辞(真正的防环机制是"回执只回 ok、不回传富结果",那条保留)。

改法:
- 提示词改为明确授权:**输出次数与目标通道由你自己决定**,没有「一轮只能发一次」的限制;
  只保留两条真话:单条长度上限(超长拆完整段落)、别反复重发**完全相同**的内容。
- `output_send__*` 的 `type` 参数改为**可选**(缺省 text):判据该拦的是"不知道发什么",
  不是"没写众所周知的默认值"——此前缺 type 会直接失败并让模型重试一次。

判据 3 条(新增 `output_rules_test.go`):提示词不得含输出次数限制且必须显式授权 /
省略 type 时按 text 发送成功且 schema 的 required 只有 payload / 空 payload 仍被拦。

(cherry picked from commit 17ea7fd5f0)
2026-09-13 16:04:16 +08:00
e2500035d5 fix(resident): 子的「轮次」一直显示 0 —— info() 根本没填 Rounds
现象(用户线上联调实录 + 我复验):父侧 `resident_agents` 列出 `输入ch=[timer] 轮次=0
处理表=2` —— **处理表已有两条记录,轮次却是 0**,自相矛盾,容易被读成"子没干活"。

根因:`residentChild.info()` 构造 `ResidentInfo` 时**从来没有填过 Rounds 字段**
(结构体里有这个字段,于是永远输出零值),不是计数漏加。

改法:`Rounds = 已执行轮次数`(调度器执行计数,单调不减)。新增 `Agent.roundsExecuted()`
并写明为什么**不能**用 inputch 处理表条数当轮次:那张表记的是"当前上下文窗口内"的轮次,
压缩会清空(设计 §8.3)—— 用它会让父看到轮次倒退。

判据:inputch 路由测试里补一条断言 —— 子处理完输入后 `info().Rounds > 0`。

(cherry picked from commit cd88b2dfe5)
2026-09-13 15:41:07 +08:00
886ba78b11 fix(scheduler): inputch 划给子后输入只流向子 —— 补上「进内核之前」的输入路由
用户指出的语义(设计稿 §4.1 早已写明):
**inputch 是可分配资源**,「路由发生在**进内核之前**」—— 划给某个 agent 后,
该通道的输入**只流向那个 agent**;outputch 不同,授权是**非独占**的,
父依旧可以通过它发送内容。

而代码里 inputch 划拨只做了**登记**,没有做**路由**:
- 插件注入输入的 io 是**根 agent 的**(`cmd/homed` 里 `pluginReg.SetIOManager(iom)`);
- 唯一消费输入的是「该 io 自己的调度器」(`scheduler.go` 读 `a.io.InputChan()`);
- `ChannelRegistry.Assign` 只把 Owner 写进登记表,**没有任何转发动作**。

⇒ 现场表现(用户线上联调):子挂 `inputch=[timer]`,**timer 的输入却打在父身上**
(日志 `[agent] interrupt from timer/timer`),子侧 `轮次=0` 永远不动。
登记表里的 Owner 于是沦为标签。

改法(按 §4.1 把路由放回"进内核之前"):
- `IOManager` 增加 `InputRouter`(`SetInputRouter`),并把**五处直接入队**收口到
  `deliverInput`:`InjectInput` / `InjectInputSync` / `InjectInputTo` /
  `InjectInputSyncTo` / `InjectInterrupt`(排队与中断两条路都过路由)。
- 内核注入路由器 `Agent.routeInputByOwner`:查 inputch 的 Owner —— 归自己/未分配 ⇒
  本内核处理;归自己的某个驻留子 ⇒ `DeliverRouted` 交给它(**不再进父的队列**);
  归一个不存在的 agent ⇒ **不吞输入**,父兜底 + 留痕(吞掉输入比多处理一条更糟)。
- `DeliverRouted` 是"已路由"的投递口,不再二次路由(避免成环)。
- 同步输入的 `ResponseCh` 随事件一起走 ⇒ 回答由持有者写回同一回程(§4.3)。

判据(新增 6 条):
- io 层:被接管时排队/中断都**不入本内核队列**(且中断确实经过路由)/ 放行与未设
  路由器时与历史行为一致 / `DeliverRouted` 不再触发路由
- 内核层:划给子的 inputch 输入进**子**(子 Executed>0)且**父 Enqueued 不变** /
  归属到不存在的 agent 时父兜底(不吞)/ 未分配的 inputch 仍归父

(cherry picked from commit 4707b05498)
2026-09-13 15:35:59 +08:00
91c4bc01f1 fix(resident): 驻留子继承父的输出通道 —— 修「子侧 childIO 空壳、子不会发消息」
现场(用户在线上跑驻留子联调,日志实录):
  父 agent 侧「通道装载完整」,子 `demo-resident` 侧 `childIO` 是**空壳**:
  子的 `output_list_channels` 为空、`output_send__<通道>` 一律被判
  「通道 [X] 不存在或不可用」,连 `output_send__*` 工具都不生成 ⇒ 子不会发消息。

根因:**输出通道在 io 层就是 Device**,而它们由插件登记在**父**的 `IOManager` 上。
`SpawnResident` 给子建的是全新 `IOManager`(它确实该有自己的输入入口与 outputCh),
却只共享了 inputch 登记表,**没有继承设备/输出通道视图**:
  - `executeOutputSendTool` → `a.io.GetChannelCapabilities(ch)` 查的是 `devices[ch]` ⇒ 0
  - 投递路径 `a.io.GetDevice(ch).Execute("output", …)` ⇒ nil
  - 工具面 `tooldefs.go` 从 `a.io.ListChannels()` 生成 `output_send__*` ⇒ 空

改法:给 `IOManager` 增加**上级回退**(`SetParentIO`)——驻留子创建时把自己的 io 挂到
父的 io 上,`GetDevice` / `GetChannelCapabilities` / `ListChannels` / `ExecuteTool`
在自己没有时回退到上级。

为什么是**实时回退**而不是创建时复制快照:设备随资源生灭(远程设备上线/掉线以分钟计,
现场日志 60 秒一个来回),复制出来的表转瞬即过期;而回退永远与父一致。
**授权不受影响**:回退只解决"看得见",能不能用仍由各自的 `AllowedOutputs` 白名单把关
(`executeOutputSendTool` 的授权闸 + 工具生成时的过滤都在白名单之后);
自己的登记优先,子可以覆盖/屏蔽同名通道。

判据(新增 5 条):
- io 层:无上级时行为与以前完全一致 / 挂上级后看得见 / **实时**(父新登记立刻可见、
  注销立刻不可见)/ 同名自己的优先且不重复列出 / `ExecuteTool` 同样回退
- 内核层:子看得见父通道 + 真能发出(父通道收到 1 次 output)/ 白名单外被拒且未送达 /
  子工具面只生成授权通道(含 `_help`)/ 父后登记的通道立刻可见 / 默认即完整授权

(cherry picked from commit 8537577123)
2026-09-13 15:09:44 +08:00
ba3ab5a7d9 chore(meta): main 的版本路牌推到 1.4.0(1.3.0 已归 release/v1.3.x 所有)
规范 §2.1 / §七.4:切出 release/v1.3.x 后,1.3.0 就归发布线所有,main 立即推进到
下一个未发布中版本。此前停在 1.3.0 属遗漏 —— 会让 main 构建出来的二进制自称已发布版本。
2026-09-13 14:34:46 +08:00
a4ebcb6e96 fix(config): 播种时不再把版本号写进人格文本 + 存量实例一次性去版本化
用户发现:agent 自报版本 **1.0.3**,内核早已 1.3.x。

根因(两层):
1. `SeedDefaults` 当年用 `fmt.Sprintf("…HΔ-Kernel v%s…", meta.Version)` **在播种时**
   就把版本写进了 `core.agent.system_prompt` —— 装完即冻住,之后每次升级都不会
   去改配置里的文本,于是实例终生自称装机那天的版本。默认模板用 meta.Version
   插值本是"不写死"的做法,但**播种 = 把插值结果固化**,等于写死。
2. `core.agent.personal_prompt`(新人格机制)本身是对的(DefaultPersonaPrompt
   无版本字面量,由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住),
   但旧键仍在系统提示词里说话,模型就照抄旧键的版本。

改动:
- **不再播种** `core.agent.system_prompt`:留空 → 组装时取 cmd/homed 的内置底座
  提示词;人格由 personal_prompt 承载。全新安装不再预置会腐坏的文本。
- 新增 `migrateSeededSystemPrompt()`(在 SeedDefaults 最前,故不被播种标记早退):
  只对"当年那段播种模板"(前缀 + `HΔ-Kernel v<数字>` 字面量双判据)做
  `v<数字>` → `v{{kernel_version}}`;用户自己写的人格卡一律不碰。
  一次性标记 `core.internal.system_prompt_deversion_v1` 守住幂等 ——
  幂等语句不等于语义幂等,重复执行会把用户后来手写的版本号也改掉。
- 与 v1.3.5 的占位符展开配套:占位符在组装系统提示词时按真实构建展开。

回归判据 4 条(`prompt_migration_test.go`):存量卡被去版本化且正文不动 /
迁移只跑一次 / 用户自写卡不动 / 全新安装不播种该键。
2026-09-13 14:34:44 +08:00
f168862eaa fix(prompt): 系统提示词支持版本占位符 —— 人格卡不再写死版本号
现象(用户发现):agent 向用户自报版本是 **1.0.3**,而内核早已 1.3.x。
根因:**人格卡是配置项**,线上 `core.agent.system_prompt` 里写死了
「HΔ-Kernel v1.0.3 型号的家政型 AI 管家助手」——那是当年装机的文本,
之后每次发版都不会去改它,模型于是照抄给用户。默认模板用 `meta.Version`
拼接(`registry.go` 的 `fmt.Sprintf`)所以一直是对的,**只要被自定义过就会漂**。

修法:在 `buildSystemPrompt` 组装处展开占位符,让这类文本跟随真实构建:

  {{kernel_version}} → meta.Version(如 1.3.5)
  {{kernel_commit}}  → 构建 commit
  {{sdk_version}}    → 所兼容 SDK 版本(如 1.3.0)

- 未知占位符**原样保留**:写错了要看得见,而不是被静默换成空串;
- 不含 `{{` 时原样返回(提示词在热路径上);
- 覆盖所有路径:主 agent 与驻留子都经 `buildSystemPrompt`,
  `persona_set` 写入的文本同样在读取时展开(存的是模板,不是渲染结果);
- 配置项描述里写明可用占位符,引导用户别再写死版本。

回归判据 `TestExpandPromptVars`:展开正确 / 内置占位符不残留 /
线上真实人格卡文本能被纠正 / 未知占位符不被吞 / 无占位符不改写。
2026-09-13 14:34:44 +08:00
937359b4df feat(pluginmgr): plugin_install 支持本机 path(配合 plugindev_build 的产物)
背景:Agent 现在能自己构建插件了(plugindev 插件封装了 hmapdev),但安装只支持
http(s) URL —— 本地刚构建出来的 `dist/*.hmap` 装不上,链路断在最后一步。
pluginmgr 的 HTTP API 本来就接受 `{path}`(installFromPath),只是工具面没暴露。

改动:`plugin_install` 增加可选 `path`(本机 .hmap 路径),与 `url` 二选一,
同时给出时以 `path` 为准;`path` 必须存在且不是目录。描述里写明
「配合 plugindev_build 的产物用这个」。

于是 Agent 的完整闭环成立:
  plugindev_init → plugindev_build → plugin_install(path) → plgreload
2026-09-13 14:17:11 +08:00
55dc6545f5 perf(memory): 静态词向量改用 float32 存储(省 ~0.65GB 常驻)
生产实测:`[static_embedder] loaded 200000 words`(zh) + `378151 words`(en) = 57.8 万词 × 300 维,
`map[string][]float64` 光向量本体就 **1.29GB**(外加 map 开销 ~0.1-0.2GB),占 homed
4.14GB RSS 的约三分之一。

源数据(fastText 文本格式)本身就是 float32 精度,用 float64 存没有任何收益:
- `words map[string][]float32` / `unkVec []float32`;
- 加载时按 `ParseFloat(..., 32)` 解析(与源精度一致);
- 相似度累加仍在 float64(`sum []float64`,读时提升),计算精度不受影响。

⇒ 向量本体 1.29GB → 0.65GB,**省 0.65GB**。(与配置侧 `#topN` 可叠加:
生产把两份 vec 各限 5 万词后,向量降到 ~0.22GB。)

防复发:`TestStaticEmbedder_VectorMemIsFloat32` 用**编译期类型断言**
(`var typed []float32 = vec`)+ 字节数断言(词数×维数×4)钉住 —— 改回 float64 会直接编译失败。

验证:`go test ./internal/memory/ ./internal/agent/core/ ./internal/nlp/` 全绿。
2026-09-13 14:09:35 +08:00
fddefc78a1 fix(plugin): "只声明出站通道"的告警改为插件加载完成后判定(此前按注册顺序误报 qq)
## 现象

生产日志(v1.3.1 启动)出现:
`[plugin] qq 只声明了输出通道 "qq",已按双向通道兜底登记 inputch;若要明确意图请显式 RegisterInputChannel`
用户据此问"qq 插件你没更新?"

## 查证:qq 没漏,是我的判据错了

- SDK 示例 `example/qq/plugin.go`:`RegisterOutputChannel("qq")` 在 368 行、
  `RegisterInputChannel("qq", {NoMemory:true, Cleaner: inputCleaner})` 在 399 行 —— **先出站后入站**;
- 生产 `plugins/qq/plugin.bin`:版本 1.4.0,且二进制里含 `inputCleaner` 痕迹 ⇒ 确实调用了入站声明;
- 我的兜底告警在 **RegisterOutputChannel 的那一刻**判"有没有入站声明" ⇒ 对"先出站后入站"
  这种完全合法的写法必然误报(a2a/acp/weather 同理)。

## 修法

告警判据从"注册时刻"改为"**插件 Start 结束后最终声明了什么**":

- `regOutput` 只保留兜底登记(功能不变),不再告警;
- 新增 `warnOutputOnlyChannels(plugin)`,在插件加载/重载成功后统一判定:
  遍历该插件**最终**声明过的出站通道,只有始终没有对应入站声明的才告警,
  且措辞改为"内核已兜底登记 inputch,若这是有意为之可忽略"。
- 判据与顺序解耦后,告警才代表真实缺口(例:weather 的 `weather_out` 与
  `weather_in` 名字不同,出站名从未被声明为入站 —— 那条告警就是真的)。

## 验证

- 新增 `TestWarnOutputOnlyChannels`:①先出站后入站(qq 写法)**不告警**;
  ②只声明出站(weather 写法)**告警且只报那一个通道**。
- `go test ./internal/plugin/ ./internal/plugins/...` 全绿。
2026-09-13 13:40:24 +08:00