Commit Graph

272 Commits

Author SHA1 Message Date
c33199cc5a fix(webui,plugin): 内核控制按钮晚绑定 + 按钮布局;plugin_start 报错给候选名
1. 内核控制通道晚绑定(真机 503 实测)
   POST /api/v1/kernel/restart 一直返回 503:webui 由 plugin-host 在装配期
   拉起,而 main() 里 SetKernelControl 还没执行 ⇒ 插件 Start 拿到 nil。
   依赖图保证不了 main 里的语句顺序,所以改成让插件拿**解析器**
   (lazyKernelControl),调用那一刻再取真实通道;授权仍在每次调用时
   按 kernelControlAllowed/kernelRestartAllowed 复核,不因晚绑定放宽。

2. WebUI 重启/关机按钮布局
   原先用 .theme-btn(顶栏图标样式:pill + margin-right + 无文字)混在
   侧栏底部的文字按钮里 ⇒ 大小不一、挤在一起、关机看不出是危险操作。
   改为与「退出登录」同族的 btn-sm,带图标+文字,关机用 danger 悬停。

3. plugin_start 失败报错
   原来只说加载出错,或插件不存在,一个可用名字都不给,模型只能盲猜
   (实测把 files 写成 file)。现在给:失败原因分类(目录不存在/没清单/
   自身加载失败)、最接近的名字(Levenshtein ≤2)、已安装清单(最多 12)。

4. scripts/migrate-plugins-to-org.sh:插件迁到 GitHub 组织独立仓的脚本收口

测试:go build ./... 通过;go test ./internal/plugin ./internal/plugins/webui 全绿。
2026-10-27 01:43:19 +08:00
55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
2026-10-26 20:55:25 +08:00
ca5b1b17b7 test(pluginmgr): 判据钉住暴露给 agent 的两个更新工具
kernel_update / plugin_update 是**模型唯一能触达的更新入口**。它们被删掉、
改名、或参数对不上时,agent 就再也升不了级 —— 而源码里 grep 得到工具名
不能证明它注册成功(本仓 E-3 教训:源码有、运行时没有)。

三条判据,全部走真实 registerTools(捕获 sdk.SDKConfig.RegTool):

1. 两个工具都注册了,且 Serial=true
   更新会替换文件并重启,与 install/remove 并发会撞车。

2. kernel_update 同时暴露两种来源
   缺 pkg ⇒ 只能用 Release;缺 release ⇒ 只能用本机包。
   描述还必须说清"二选一",否则模型会都传或都不传,白烧一轮往返。

3. plugin_update 必须有显式范围
   缺 plugins 参数就无法表达范围;默认全升会让用户"只升一个"变成批量重装。
2026-10-26 13:48:39 +08:00
817f6c59ae fix(update): 重启交还 guard、OTA 支持 release/包两种来源、修三处真 bug
## 一、重启不再由 update 自己 systemctl(用户口径:应该通知 guard)

原 internal/update 直接 `systemctl restart homeagent`。三个问题:

① 杀掉整个 cgroup(unit 里 KillMode=control-group)⇒ **guard 跟着死**,
   而 guard 存在的意义就是不让自己死。
② 丢失 exit 42 语义:guard 的 failures/failbackRound/failbackDone 全丢。
③ 自毁竞态:发命令的进程正是被重启杀掉的进程 ⇒ CombinedOutput 可能报错,
   **一次成功的升级被误判失败而回滚**。

现在 internal/update 只到"产物就绪":预检 → 备份 → 写 pending → 原子替换。
重启由调用方走**既有**通道(restartCh → exit 42 → guard 用新二进制重拉):

· webui      h.kernelCtl.RequestKernelRestart(本来就有,且是白名单内)
· pluginmgr  p.sdk.KernelControl().RequestKernelRestart(见下面第三条)
· CLI        外部管理命令,restart unit 后按**版本自报**核对是否真生效

## 二、OTA 支持两种来源(release 自动更新 / 指定安装包)

新增 internal/update/source.go:

	Source{Kind: FromPackage, Path}  → 直接用本地包
	Source{Kind: FromRelease}        → 查最新 Release → 下载 → 校验 digest

ParseSource(pkg, release) 统一判空规则(pkg 优先),避免三端各写一套。
两条来源解析后进**同一条** Apply,不存在两份升级实现。

## 三、修三处已提交代码里的真 bug

① **HEAD 编译不过**:d17e261 提交了 kernelmod/update/mod.go(引用 ks.Update),
   但 kernelboot/kernelstate.go 的字段改动从未提交。干净 worktree 实测
   `ks.Update undefined` —— 推上去的仓库是坏的。本次补上。

② **kernelDataDir 算错一级**:Dir(Dir("/home/newqqagent/plugins")) = "/home",
   而第 0 层 update 模块从 ks.DataDir=/home/newqqagent 读 pending
   ⇒ **agent 发起的升级一旦启动失败就永不回滚**(静默失效)。改为 Dir 一次。

③ **pluginmgr 的 KernelControl 恒为 nil**:它不在 kernelControlAllowed 里,
   于是 `if kc := p.sdk.KernelControl(); kc != nil` 永不成立 ⇒
   换完二进制**从不重启** ⇒ pending 留着 ⇒ 下次重启被判"未确认"而回滚。

   修法**不是**把 pluginmgr 加进完整白名单 —— 那等于让 LLM 能关机整个服务
   (kernel_control_gate_test 明确拦这个)。而是拆出**仅重启**授权:

	kernelControlAllowed  = {webui}       完整(含关机)
	kernelRestartAllowed  = {pluginmgr}   仅重启(shutdown 直接报错)

   两者的危险程度差一个量级:重启几秒后 guard 自己拉回来,关机不会自己回来。

## 四、EventBus:插件请求更新不必 import internal/update

新增 events.EventUpdateRequest,pluginmgr 订阅并受理:
scope=kernel → kernelUpdateApply;scope=plugin → pluginUpdate(apply)。

它在 internal/plugins/webui 的 SSE 守卫里显式登记为**内核内部控制事件**
(不该推给浏览器:前端要的是 update_progress/update_result,不是"谁发了请求")。

## 五、顺手修掉的静默失败

· webui /kernel/update 原实现:**先**写 202 Accepted,**再**解析 body,
  之后每个错误都直接 `return` —— 用户看到"已受理"然后什么都没发生。
  改为同步 Apply(失败可如实报 HTTP 状态),成功后才请求重启。
· pluginmgr 拿不到控制通道时不再假装成功,而是回报 applied=true + 明确原因
  (否则用户以为已升级,进程却还在跑旧镜像)。
· update 事件 publishResult 的 errMsg 原先是死代码(`_ = errMsg`),
  按 payload 约定写回 error 字段。
2026-10-26 13:45:54 +08:00
5b759e80d7 feat(update): 更新进度走 EventBus + SSE(穿越进程重启的通知通道)
## 为什么必须走事件总线(用户口径:为什么不走 eventbus)

升级是**长耗时且会让进程消失**的动作。此前进度只往 log.Printf 写,
WebUI 要看得自己轮询 /kernel/update —— 而轮询在重启瞬间必然断线,
断线那一刻恰恰是用户最需要知道"升到哪一步/失败了/已回滚"的时刻。

EventBus 是内核既有的发布订阅机制(同步投递、handler panic 隔离),
用它不需要新造一条通知通道。

## 三个新事件类型

	update_check     检查结果(只读,不改动)
	update_progress  升级步骤:verify/backup/replace/restart/rollback/done/failed
	update_result    最终结果(含回滚轨迹)

## ★ 顺序约束:先发事件,再动作

升级尾部几步(replace → restart)会让进程消失。顺序一反,最后几步的
事件永远发不出去。所以每个 publish* 都紧贴在其描述的动作**之前**。

## 直接 import events,不搞接口垫层

第一版我写了 EventPublisher 接口 + map[string]interface{} payload 想
"避免依赖",那是自欺欺人:events 包**不依赖任何内部包**(已验证),
import 它不会成环;而垫层会把事件降级成无类型 map,丢掉编译期检查。
已回退该设计,改为直接传 *events.Bus。

## SDK 补 EventBus() getter

PluginSDK 原先只有 Publish/Subscribe,没有暴露总线对象。而长流程
(升级闭环)需要把总线本身传下去持续发事件 —— 只给 Publish 的话,
每个调用点都要手动转发一遍。

## SSE 通路(守卫判据逼出来的正确修法)

TestEveryPublishedEventHasAnOutwardPath 抓到三个新事件"有发布者但无对外
通路"——症状是「发了、没报错、客户端收不到」。修法不是加白名单豁免,
而是真的接进 SSE:三者都进 sseEventRing(不只是实时推送),
断线重连要能补到**最终结论**(update_result),否则用户重连后只看到
"开始升级"而没有结局。

## 调用点

· pluginmgr(kernel_update):p.sdk.EventBus() → DoUpgradeBus
· webui(/kernel/update):h.sdk.EventBus() → runKernelUpgrade(pkg, bus)
· CLI:bus=nil,退化成纯 printf(CLI 没有总线)
2026-10-26 09:23:58 +08:00
d17e261b76 feat(update): update 注册为内核标准模块(第 0 层)+ 启动失败自动回滚
## 为什么必须是第 0 层(用户口径)

update 肩负「启动失败回滚上一次升级」的职责,该职责必须**早于所有模块**。

若排在后面:升级后新内核在 memory/llm 阶段崩了,而回滚还没跑 ⇒ 机器进
崩溃循环,只能靠 guard 的 failback(受限启动)兜底。failback 是最后手段,
不该承担"换错二进制"这种**已知可回滚**的故障。

⇒ Requires() 为空 ⇒ 依赖图天然把它放 Layers[0]。

## 两段式升级确认(rollback.go)

	升级成功 ──→ 写 pending 标记("这个版本尚未被确认能启动")
	                ↓
	        重启 → 内核启动 → update 模块(第 0 层)读标记
	                ↓
	   ┌────────────┴────────────┐
	  标记还在                   标记已不在
	(上次升级没能活到清除)      (上次升级已确认成功)
	     ↓                          ↓
	  回滚到备份版本               正常继续 + 清标记

清除时机:模块跑完自检后立刻清。只依赖"进程活着且执行到第 0 层",
不依赖其他模块 ⇒ 不会误判。

**为什么不用 TTL / 重启次数**:那是 guard 已有的 failback,但判据是
"崩了几次",不是"这次升级该不该撤销"。用重启次数会在"新版本偶发崩溃"时
误回滚 —— 而那种情况该修的是崩溃,不是回退版本。

## 分层(与仓库其他模块同一惯例)

	internal/update/           业务实现(三端共用:CLI/WebAPI/agent 工具)
	internal/kernelmod/update/  适配层(读 ks → 调业务包 → 写回 ks)

参照 supervisor 模块:实现在 internal/supervisor,模块里只做装配。
分开的理由:internal/update 被三端调用,它们都没有 KernelState。

## 标记写入时机(一个真实的顺序坑)

MarkPendingUpgrade 在**替换二进制之前**调用。反过来(替换后才写)会在
"替换成功但没来得及写标记就崩了"时丢标记 ⇒ 下次启动不知道该回滚。

且写标记失败 ⇒ **放弃本次升级**(未替换任何文件):没有标记就没有回滚
能力,而不带回滚的升级等于赌运气。

## 回滚失败必须响亮

备份不存在时返回 error,调用方(模块 Init)必须中止启动 —— 继续装配等于
用一个已知起不来的版本。错误文案带备份路径与标记路径(删标记即可放弃回滚),
人能照着修。

## 判据

internal/update/rollback_test.go 10 条,2 条经变异验证:
· TestRollback_RestoresBackup —— 二进制+depgraph 同批回滚、标记清除、
  坏版本留档 homed-broken-*、版本轨迹记录
  变异:回滚后不清标记 ⇒ 红
· TestRollback_MissingBackupIsLoud —— 备份缺失时报错且可执行
  变异:静默返回 nil ⇒ 红
· TestPendingMarker_AbsentIsNotError —— 无标记返回 (nil,nil) 而非 error
  (否则每次启动都走"回滚失败"分支 ⇒ 内核永远起不来)
· TestPendingMarker_CorruptIsActionable —— 标记损坏时提示可删
· TestStateSummary_* —— 回滚信息进 healthcheck;nil 安全

## 顺带修正的既有判据

· runtime_dep_test.go:L0 设施集合从 2 个变 3 个(新增 kernel.version),
  改为断言**集合**而非顺序(注册表本就是乱序的)
· facilities.go:新增 FacVersion,并登记为 internalOnlyFacilities
  (它是启动时序事实,不是插件契约 —— facilities_sync_test 的守卫正确拦住了
  "把内部标记泄漏成对外契约")

三个 DoUpgrade 调用点补 dataDir 参数(标记落点必须与内核 --data 一致)。
2026-10-26 02:08:29 +08:00
475aac867b refactor(update): 内核与插件更新合并为 update 子系统(三端共享一个底层)
## 为什么合并(用户口径:更新部分应独立为子系统)

之前 kernelupgrade / pluginupdate 两个包,但它们共享三样东西,
分开写就要各复制一份,然后慢慢不一致:

① **版本比较** —— 合并前共 6 个函数做同一件事:
   kernel 侧 ParseSemver / CmpSemver / versionNewer / trimTagV
   plugin 侧 trimV / isNewer / semverNums(还自带一份正则)
   且**行为不一致**:kernel 侧只挡 len<2,"1.4" 漏过去被填成 1.4.0;
   plugin 侧的正则要求两段所以也漏。两份都把 "1.4" 当合法版本。

② **两阶段交互**(检查 → 确认 → 执行)—— 同一套用户口径。

③ **失败要响亮** —— 都不静默降级。

## 收敛结果

internal/update/
  doc.go     子系统头:职责边界、三端共享契约、为何不分家
  semver.go  ParseSemver / CmpSemver / Newer / TrimTag(**唯一一份**)
  kernel.go  内核升级闭环(解包→校验→备份→替换→重启→复核→回滚)
  release.go GitHub Release 查询
  plugin.go  插件升级(查仓库→拉源码→hmapdev 编译→产物)

三端共享:
  agent 工具(plugin_update / kernel_update)  ┐
  WebAPI(/plugins/update、/kernel/update)    ├─ internal/update
  CLI(homed-upgrade)                         ┘

入口层只做参数解析与结果呈现,不含业务逻辑 —— 否则会出现
"agent 说没有新版、WebUI 却升了级"的分裂状态。

## 合并时抓到的两个真实 bug

① **ParseSemver 只挡 len<2** ⇒ "1.4" 被零值填成 1.4.0,
   「1.4 → 1.4.0」被判完全相同(说"无需升级"),而两者内容未必相同。
   现在要求恰好三段。

② **不认 v 前缀** ⇒ GitHub release tag 全是 v1.4.0 形态,
   CLI 对"当前 v1.4.0 vs 包内 v1.4.0"判不可比、只能退回字符串比较。
   现在 TrimTag 去 v 前缀后再比。

## 新增入口

· plugin_update(agent 工具)+ GET/POST /plugins/update
  —— 两阶段:apply=false 只检查(列出 updatable / no_repo 两类,
     后者是"未声明仓库、无法自动更新"的内置/私有插件);
     apply=true 需显式 plugins=all 或名单(不默认全升)
· kernel_update(agent 工具)+ GET/POST /kernel/update
  —— 两阶段同构;apply 只收本机包路径(URL 下载是 CLI 职责,
     agent 不该在推理中发起长下载)

判据:
· internal/update: Newer / TrimTag / ParseSemver 的边界(含上述两个 bug 的回归)
· pluginmgr 入口层 5 条:检查形状、apply 必须显式给范围、
  kernel apply 必须给 pkg 且指向 homed-upgrade、拒绝目录当包
2026-10-26 01:05:09 +08:00
29150065aa fix(clawhub): sidecar 就绪超时 5s → 30s(慢机器上 node 冷启动超时)
CI 报红:`TestLaunchSidecarAndListTools`
  launch sidecar: sidecar echoplugin ping timeout (5.00s)
本机全绿。

## 这不是"测试机器慢所以放宽测试"

sidecar 起不来等于该 skill 整体不可用 —— 这是**产品行为**,不是测试细节。

原来的 5s 是按开发机估算的:node 冷启动 + 模块加载在快盘空载时约 0.2s。
而 CI 共享 runner(以及真实部署里的低配机器)上,一次 fork+exec+node
引导遇到 CPU 争抢就会超过 5s。

判断"真的挂了"应该由**进程退出信号**负责(readLoop 会感知),
而不是由引导耗时猜。

## 30s 的取法

· 比 systemd 的 TimeoutStopSec(25s) 宽 —— 关停时不该被就绪等待拖住
· 与用户可感知的"卡住"阈值(约 30s)持平 —— 再长就该让用户看到明确失败
· 错误文案带上超时值与可能原因(node 不可用),便于自助排查
2026-10-25 22:31:51 +08:00
7052644431 fix(core): contextoverflow 改回空中断(按原始设计)+ Python 解释器可配置
## 1. contextoverflow:L4 空中断(推翻三处偏移)

用户口径:**原始设计是注入 L4 空中断,目的是触发语义相关性裁剪,
不含任何消息,就是一个空中断。**此前的实现有三处偏移:

① **中断带正文** —— `Build` 生成「已裁剪 N 条低相关事件到文档记忆…」,
   于是 L4 变成一条要模型回应的消息,白烧一整轮推理。
② **裁剪在 raise 之前做完** —— 先 `pruneByQuery` 再 raise,中断沦为
   「裁剪已完成」的回执,晚了一步。
③ **上游 contextfull 后原地重跑** —— `StepPrepare` 重建后拿**同一份**
   超限内容再发一次,必然再超限,快速烧光 recoverBudget。
   (这条是修① ② 时暴露的连带问题。)

改动:
· `VectorContextOverflow.Build` 返回空串 ⇒ 空中断;级别仍是 L4
· 删除 `overflowNotice` 与 `vectorArgs.Pruned`(中断不需要知道裁了多少)
· `handleContextOverflow` 不再裁剪,只发空中断,如实返回 0
· 新增 `pruneForAirInterrupt`:裁剪由空中断任务**真正执行时**触发,
  query 取上下文里最后一条真实用户输入作相关性基准
· 新增 `isAirInterrupt`:只认「内核来源 + type=interrupt + 无正文」,
  插件带正文的中断不受影响(否则"插件发了个空中断"会变成无声裁剪)
· `prepareInputTask` 识别空中断 → 裁剪 → terminalSkipped
  (不进 LLM、不写上下文、不发回复)
· 上游 contextfull 后改为 `outcomeSuspended`(让位等裁剪),不再原地重跑

顺带记录偏移成因(`raiseContextOverflow` 注释):当时的**观察**是对的
(L4 看不到被打断者上下文 ⇒ 模型会把"没检索到"推断成"不存在"),
但**结论**错了——它把"补偿 L4 看不到上下文"当成中断该带正文的理由。
模型需要的不是"知道有内容被移走",而是**留下来的内容与当前问题相关**。

判据(均经变异验证):
· `TestVectors_OverflowIsAirInterrupt` —— 正文必须为空 + 级别 L4
  变异:Build 改回非空 ⇒ 红
· `TestOverflow_RaisesAirInterruptWithoutPruning` —— 发中断那步不改上下文
  变异:加回 pruneByQuery ⇒ 红
· `TestAirInterrupt_ResolvedAsValidInput` —— 空中断须被认作有效输入
· `TestAirInterrupt_RejectsOrdinaryEmptyInput` —— 普通空包/插件带正文
  中断**不得**误判
· 改写 5 条断言"发中断即裁剪"的旧测试(T1/T2/T5/大事件/自适应),
  保留它们原本守的东西(超限判定、恢复预算、终止保护),
  只把裁剪步骤显式移到 `pruneForAirInterrupt`

## 2. clawhubadapter:Python 解释器与工作目录可配置

真机事故:`bilibili-all-in-one` 声明 requirements 含 httpx,系统 Python
没装 ⇒ 启动即 `ModuleNotFoundError`。而解释器此前是**硬编码猜**的
(先 /usr/bin/python3,再 /usr/local/bin/python3)。

「猜」没有正确解:不同部署的 Python 布局完全不同(发行版 Python、
pyenv、uv、conda、venv…)。而往系统 Python 里 pip install 是**错的**:
PEP 668 明确禁止,`--break-system-packages` 会破坏 apt 包一致性。

新增两个配置项(与既有 skills_dir/simulator_dir 同一风格):
· `python_bin` —— 解释器绝对路径,最高优先
· `skill_workdir` —— skill 子进程工作目录

回退顺序:配置 → `<skill>/.venv/bin/python` → 系统 python3。
**配错必须响亮**:配置的路径不存在时返回 error,不静默退回系统解释器
(否则用户以为配好了,实际跑在缺依赖的 Python 上 —— 又一次静默降级)。

判据:`TestPickPython_ConfiguredWins`(配置优先于 venv)/
`TestPickPython_VenvWhenNotConfigured` / `TestPickPython_MissingConfiguredIsLoud`

`launchProcessIn` 是 `launchProcess` 的工作目录变体(不改既有 4 个调用点)。
2026-10-25 21:39:12 +08:00
f833897d40 fix(agentcli): 不再把自造状态当新事件注入 —— 每条都烧一整轮推理
问题(WSL2 真机诊断报告问题 4,影响最大):开终端注入「已启动」、
超时注入「已超时关闭」,每条都唤醒一整轮 LLM 推理。实测后果是
agent 反复向用户解释"终端怎么又没了",真正要做的活一件没做。

这属于本插件**自身逻辑矛盾**,不是内核缺陷:

1. 「已启动」是与 terminal_create 返回值的重复告知。返回值已含
   id/command/timeout/rows/cols/notify_mode,信息完整。
2. 「已超时关闭」在创建时已由 timeout 字段告知,agent 本可推导;
   且注入后紧接着 removeSession,agent 去 terminal_list 查必然是 0 个,
   只能盲目排查——而这是插件自己删的。
3. 同一函数里 IsExpired() 语义相反:r.err 分支把它列为**必须静默**,
   而超时分支却在注入。
4. 本插件自己的 notify=exit/interval/buffer 参数本就定义了"什么该通知"。
   两处无条件注入是不经该配置的旁路,与自身设计相矛盾。

顺带:r.err 不再以 %v 原文进 prompt(原来注入
`read /dev/ptmx: input/output error`,把 agent 引去查 devpts 挂载,
好几轮推理花在这上面)。改为给**结论**「读取结束」,原始错误只进日志。

判据(plugin_test.go):
· 新增 2 条**反向判据** TestNoStartupNotificationInjected /
  TestNoExpiredNotificationInjected —— 没有它们,后人把注入加回来时
  其它测试依然全绿,缺陷会静默复发
· 原 7 处用「等已启动通知」做时序同步的测试改为 waitReadLoopReady
  (轮询 mockTerm.readCalls(),不靠固定 sleep 也不依赖业务通知)

★ 保留不动:进程自己结束/读取错误的通知(agent 无法从工具返回推导),
  由 watch 配置控制。判据 TestProcessExit_StillNotifies 钉住这一点。
2026-10-25 20:16:06 +08:00
d65b742bd7 fix(plugin): 修两处持锁自死锁(CI 双红真因)+ tools_ready 事件声明
## 死锁(这是 CI 连续两次红的真因)

`StopAll` 与 `StopPlugin` 都在持有 `r.mu` **写锁**时调用
`RemoveBuiltinSkill` → `skillDirOf` → `r.mu.RLock()` ⇒ **自死锁**。

后果不是报错而是**卡死**:
- internal/plugins 卡 300s 超时(TestCapability_RestrictedPluginStillLoads)
- internal/plugin  卡 840s 超时(TestStopPluginDoesNotTouchPersistentState)
- CI 的 `go test ./...` 与 `go test -race` 双双红

修法:拆出**不加锁**的内部版本,持锁路径只走它:
- `skillDirFrom(dataDir, name)`(纯函数)
- `removeBuiltinSkillLocked(pluginName, p, dataDir)`(显式传 dataDir,
  避免"看起来没取锁、一改就取锁"的隐患)
- `announceSkillRemovedLocked`(持锁下直读 r.evBus)

修后:internal/plugins 300s → 12s,internal/plugin 840s → 0.7s。

## 防回归判据

`TestStopAllAndStopPluginDoNotDeadlock`:死锁**不返回**,普通断言抓不到,
所以用**超时**当判据(5 秒未返回即失败),并指明应改用 Locked 版本。

## tools_ready 事件声明

新增的 tools_ready 触发 `TestEveryPublishedEventHasAnOutwardPath`
(该判据防"事件发了、客户端收不到"的静默失效)。

tools_ready 是**内核内部**就绪信号(plugin-host → seq 等订阅者),
不是对话流程事件,前端无需感知 ⇒ 在 allowed 里显式声明理由。

## 验证

go build ./... / go vet ./... / go test -p 1 ./... / -race 全通过。
2026-10-25 18:25:37 +08:00
77f596fb07 chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。

## 范围

- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
  致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
  错误 Reason)——那是产品语言,不属于日志

## 连带修改(必须同步,否则静默失效)

| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |

## 顺带修正一处提示词超限(真实回归)

新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。

处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。

【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。

## 验证

- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
  config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用

## 运维提示

日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
  内核装配完成        -> kernel assembly complete
  插件调度完成        -> plugin scheduling done
  已建链              -> link established
  已加载 N 个         -> N loaded
2026-10-25 15:55:26 +08:00
db88c5efe5 fix(agentcli): 关闭终端后反复注入过期状态
## 现象

关闭终端后 agent 持续收到「[终端 term_6 读取结束: read /dev/ptmx:
input/output error]」「[终端 term_6 中的进程已退出]」,而 terminal_list
显示 0 个会话——它拿不到任何证据说明这些是历史遗留,只能反复解释
"这是旧回声,不用管",每条都唤醒一轮推理。

## 根因(不是一次回声,是每 30 秒一次)

    close 工具 → session.Close() → readLoop 从 <-t.stopCh 静默 return
               → 但 **sessions 条目没删**
    → cleanupLoop 每 30s 扫到它,发现 !terminalRunning
    → 又注入一条「进程已退出」

三条退出路径(主动关闭 / 进程自己结束 / 超时)里,主动关闭那条漏了删表。

## 修法

1. readLoop 的 t.stopCh 分支补 removeSession,三条路径统一走它。
2. 新增 isClosing:所有"终端结束"通知都必须区分两种成因——
   进程自己结束(是新闻,要告诉 agent)vs 我们主动关掉(是回声,不能注入)。
   旧实现只看 r.err / terminalRunning,而主动关闭恰好让两者都成立。

## 测试基础设施修正

mockTerm.Kill() 原先是空操作,于是"主动关闭"在测试里从不产生 EIO,
判据永远绿(假绿)。改为模拟真实 PTY:Kill 后 Read 立刻返
`read /dev/ptmx: input/output error`。

判据:关闭后 session 必须立刻从表里消失、关闭后不得新增注入
(连开 20 次防 select 随机性)、进程自己结束仍必须通知(不过度修复)。
变异:关闭时不删表 / isClosing 恒 false,均被抓。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 10:10:21 +08:00
2d49c23cc6 Merge remote-tracking branch 'refs/remotes/gh/main' into fix/webui-gzip-race 2026-10-24 13:25:18 +08:00
7457b052a7 fix(webui): streamOpenAI 改为 w 单写者模型,修 gzipResponseWriter 真数据竞争
根因(-race 实测,非推测):
streamOpenAI 的 handler goroutine 在收尾处直接 fmt.Fprintf(w, finish chunk)、
fmt.Fprintf(w, "data: [DONE]")、flusher.Flush(),与增量 flush goroutine
(16ms 批量合并)**并发写同一个 http.ResponseWriter**。经 gzip 中间件时,
gzipResponseWriter 的 mode / wroteHeader / buf 三个字段无锁被两方同时读写:

  Read  gzipResponseWriter.Write    gzip.go:139(读 g.mode)
        └─ streamOpenAI handler goroutine        handler_openai.go:256
  Write gzipResponseWriter.commit   gzip.go:206(写 g.mode/wroteHeader)
        └─ streamOpenAI 写 goroutine             handler_openai.go:165

修法选择:改写并发模型(单写者),不给 gzipResponseWriter 加锁。
理由两条:
1. 加锁只哄住 race detector,是治标 —— 并发写同一个 ResponseWriter 这件事
   本身就该被结构禁止,而不是靠锁逐字段兜。
2. 持锁要跨 Write / Flush 这类网络 IO,一旦客户端慢,整条 SSE 会串行卡在
   锁上(锁内做 IO)。单写者模型从结构上消灭竞争,且零锁开销。

改动:
- 新增 finishCh(chan finishItem,buf=1)+ writerExit。终止帧(finish chunk
  或超时错误 + [DONE] + Flush)也交给那个唯一的写 goroutine 发。
- 新增 sendFinish(usage, timedOut):**先投递再等 writerExit**。
  顺序不能反 —— writer goroutine 只在收到 finishCh 或 done 关闭时退出,而
  done 在 defer 里关(函数返回之后),先等后发即死锁。select 里的 <-done
  是防御路径:done 已关时 writer 可能已不在,不该再等一个永不到来的接收者。
- 超时路径与正常收尾路径都改走 sendFinish,handler goroutine 不再碰 w。
- writerExit 用 safeclose.Safe 关闭,遵守本仓 close 纪律
  (internal/safeclose/TestNoBareCloseOutsideOutsideSafeclose 禁裸 close(chan))。
- 顺带修掉一个既有逻辑缺陷:旧写法下 [DONE] 可能先于 deltaCh 里尚未发完的
  尾部 delta 到达客户端;现在先 flushPending 再发终止帧。

验证(全部 -count=1,除注明者):
- 基线(f408448 原版)TestOpenAIStreamIsActuallyStreaming + -race:
  单跑 5/5 次各 8 条 DATA RACE 且 FAIL;-count=5 时 10 / 12 / 10 条。
- 修复后同一用例 + -race:单跑 6/6 次 races=0 PASS。
- 修复后整包 go test -race ./internal/plugins/webui/:3/3 次 races=0 PASS
  (63s / 69s / 64s)。
- go build ./... 通过;go vet ./... 通过;go test ./... 58 包 ok、0 FAIL、
  0 WARNING: DATA RACE。

判据:cr-HomeAgent-6c0d1e07(stress),带变异 M1「sendFinish 改回直写 w
(并吃掉 <-writerExit,避免只靠死锁超时报红)」——实测被咬住(判据报红)。
2026-10-24 12:35:31 +08:00
3f834794c5 refactor: SDK module 路径统一改为 hologramagentsdk(59 文件 + go.mod)
## 为什么必须做

SDK 仓(独立仓库)已把 module 从 `github.com/JianFeeeee/homeagentsdk`
改名为 `hologramagentsdk`。主仓通过 `replace` 直接引用它的**物理目录**:

    replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk

一旦 SDK 侧改成新 module 名,主仓立刻炸:

    go: ./third_party/homeagent-sdk@ used for two different module paths
        (github.com/JianFeeeee/hologramagentsdk and github.com/JianFeeeee/homeagentsdk)

Go **不允许**同一目录被两个 module 路径 replace。所以不能"新旧并存",
只能把主仓 59 个文件的 import 全部改到新名,并删掉旧 replace。

## 一个被证伪的前提

我最初按"third_party/homeagent-sdk 是独立仓库,不能在此仓修改其源码"
来处理,并因此判断两个仓的改动可以隔离。实际上主仓是靠 `replace` 指向
该目录的物理副本 —— **两个仓的改动不可能真正隔离**:SDK 侧改 module 名,
主仓必然跟着改 import。

这个前提应该在做任何改动之前用 `go list -m` 验证,而不是假设。

## 连带修掉两处

| 缺陷 | 性质 |
|---|---|
| `TestLuaSDKMockSingleSource` 失败 | SDK 的 `sdk/lua/sdk.lua` 变了,主仓内嵌副本未同步(跑 `scripts/sync-lua-sdk.sh` 修复) |
| `sdkModuleNames` 分散两处 | 已在 SDK 仓合并为单一数据源(见 SDK 仓 725732b) |

第一条是**副本一致性判据抓到的**,不是我自己发现的 —— 这类判据的价值在此:
两个仓必然有副本,靠人记住"改了 A 要同步 B"是做不到的。

## 验证

- `go build ./...` / `go vet ./...` 干净
- `go test ./... -count=1`:**58 包 0 失败**
- `internal/plugin`(Lua SDK 副本一致性)+ `internal/sdk` 全绿
- 主仓 `third_party/homeagent-sdk/` 与 SDK 仓 `main` 无差异
2026-10-23 15:51:02 +08:00
b20e32d81d feat(outputch): 提示词显式声明「必须调 outputch 才送达」+ 契约值改名 Agent
## ① 提示词:只有显式调用 outputch 才会送达用户(用户明确要求)

模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了** ——
在异步通道(qq/wechat/群聊)上那等于自言自语,用户永远收不到。

改动前【输出规则】开篇是「消息不会自动发送到对话来源通道,你必须自己决定
如何回复」,语气偏描述。改为开篇总述句:

    **只有显式调用 `output_send__{通道名}` 才会把内容送达用户。**
    返回纯文本只是在这次推理里说话,用户看不到 —— 除非是下面说的同步通道。

同时补上本次内核新增能力:payload 传数组可一次发多条。

## ② 契约值改名:ProxyAuthHomeAgent="homeagent" → ProxyAuthAgent="agent"

用户指示契约值一起改。常量名与字符串值同时改,但**保留旧值读取路径**:

    ProxyAuthAgent       = "agent"      ← 新值
    ProxyAuthAgentLegacy = "homeagent"  ← 只用于兼容读取
    ProxyAuthHomeAgent   = ProxyAuthAgent  (Deprecated 别名,存量源码可编译)

为什么必须保留:`Auth` 是**写进 plugin.json 的持久化声明值**。存量配置里
写着旧值 —— 本机实测 `/home/newqqagent/plugins/huawei_smarthome/plugin.json:19`
就是 `"auth": "homeagent"`。只认新值 ⇒ 该插件加载时被判为非法声明 ⇒
反代不生效,症状是「插件好好的但页面打不开」,且无任何报错指向改名。

### 归一化是安全关键

`EffectiveProxyAuth` 把**空串与旧值都归一化为 ProxyAuthAgent**。

判断点全都写成 `auth == ProxyAuthAgent`。若不归一化,就得每点写两遍比较
—— **漏一处就是鉴权绕过**:旧值被判为「非门户鉴权」⇒ 跳过 authorizeProxy
⇒ 请求直接透传给上游。所以兼容收在一个函数里,不散落在调用点。

### 顺带修掉两处隐患

1. `webui/proxy.go` 的裸字面量 `"homeagent"`(非法行占位)改为
   `sdk.ProxyAuthAgent` —— 裸字面量在改名时会被漏掉,届时它悄悄变成
   未知值,而未知值在旧代码里等于「无鉴权」。
2. 两处鉴权判断改用 `EffectiveProxyAuth(...)`,堵住旧值绕过。

## 判据

| 判据 | 覆盖 | 变异自证 |
|---|---|---|
| `TestSystemPromptRequiresExplicitOutputCall` | 总述句必须存在、必须说明「返回文本用户看不到」、同步通道是唯一例外、payload 可传数组 | 删掉总述句 ⇒ 红 |
| `TestProxyAuthLegacyValueStillAccepted` | 旧值仍被接受 + 必须归一化为新值 | 去掉归一化分支 ⇒ 「旧值应归一化为 "agent",实际 "homeagent"」红 |

## 验证

- `go build ./...` / `go vet ./...` 干净
- webui / internal-sdk / agent-core 全绿
- SDK 仓(独立提交 acd1166):`go build` + `go test ./...` 0 失败
2026-10-22 21:17:05 +08:00
f256098c9f refactor: 全面改名为 HologramAgent + 提示词技术内容下沉到 skill/工具描述 + 修 21 处乱码
## ① 全面改名 HomeAgent → HologramAgent

仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:

- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
  plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
  目录内的 module/app 描述同步;**证书路径原样保留**
  (default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author

★ 必须保持原样的两处(改了会坏):
  · legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
    —— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
  · 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
    —— 那是 SDK 契约里的值,属独立仓库,本仓不改

## ② 修 21 处乱码(U+FFFD)

这些是历史编辑留下的编码破坏,全在注释里,例如:
  「赌注重下���遍」→「赌注重下一遍」
  「「不��滤」」  →「「不过滤」」
  「**待��退场**」→「**待退场**」

★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
  U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。

## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)

实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:

  【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
  【三层记忆】   1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)

收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":

  工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
  三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
           关系用 memory_recall / 成文知识用 knowledge_search

判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。

## 判据

prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
  黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
  向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)

变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
2026-10-22 20:49:44 +08:00
4e70b8d5d8 refactor(module): 核心仓 Go module 路径改为 github.com/JianFeeeee/HologramAgent
批 1,不含 SDK 仓与目录改名。

- go.mod 第 1 行 module 路径
- 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换
  (857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5)
- 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、
  homed/waiter 可执行名、.hmap 后缀
2026-10-21 12:17:19 +08:00
679536692f fix(pluginmgr): 升级后恢复运行状态 —— 重装不该把正在用的插件静默停掉
## 日志实证(2026-10-20 09:54)

重装 qq 插件后,agent 的工具清单里 qq_* 全消失,它只能:

    terminal_list → get_plugin_tools ×2 → plugin_start(qq) → plugin_status
    → output_send__qq_help → get_plugin_tools → 才摸到 qq_list_stickers

根因:升级路径 `installFromData` 里 StopAndUnload 停掉旧实例后,**再也没有
把它拉回来**。安装的语义应当是"换个包",不是"把正在用的插件关掉"。

## 修法

- 升级前通过 ListPluginRuntimes 记录 `wasLoaded`
- 解压成功、skill 交接完成后,若升级前在跑则 StartPlugin 拉回
- 返回体暴露 `was_running` / `running_restored`,失败不阻断安装(插件已装好)

实测:`reinstalled qq, was_running=true, running_restored=true`,装完
loaded/alive 均为 true、PID 更新。

## 测试

- TestUpgradeRestoresRunningState:升级前在跑 ⇒ StartPlugin 恰好调用一次
- TestUpgradeDoesNotStartStoppedPlugin:升级前没跑 ⇒ 不得强行拉起

## 同批日志里的另外两条(非代码问题,仅记录)

1. llmsproxy 在 09:52:21 / 09:53:14 被重启,其间 homed 收到 connection refused,
   经「stream 失败→非流式 + 2s 重试→换 provider」兜底,用户侧无失败但多耗一轮。
2. 上游返回 404「The model is not registered」与 429 限流(channels-codearts),
   属 llmsproxy 侧渠道配置/配额问题,建议在 llmsproxy 里核对该模型注册。
2026-10-20 10:01:59 +08:00
3119d48625 refactor(terminal): 终端逻辑回归 agentcli,内核不再编码终端语义
按用户指正调整架构:终端是 agentcli 的职责,内核不该有终端逻辑;插件与
agentcli 的联动应走事件总线,而不是内核内部组件。

## 删掉内核里的终端语义

`internal/agent/core/terminal_registry.go`(280 行)整体删除。它做了两件不该
内核做的事:定义「什么是终端会话」、从 EventToolCall 的 Go map 文本里解析
terminal id / command 并归并会话与命令历史。连带移除:

- Agent 的 terminalReg 字段、构造、subscribeTerminalRegistry 订阅
- StatusAPI 上的 ListTerminals / CmdHistory
- sdk.TerminalAPI + PluginSDK.Terminal() + Registry.SetTerminalAPI 及接线
- 对应测试(迁到插件侧视图包)

保留 `sdk.TerminalStatus` / `CmdExecStatus`:它们只是线上 JSON 格式,WebUI/CLI
返回给前端时口径一致。

## 插件侧订阅事件总线自攒视图

新增 `internal/pluginview`(TerminalView):把原先内核那份归并逻辑原样搬过来,
由插件自己持有。数据源仍是两条总线事件:

    EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期
    EventToolCall      (内核发)    → cmd_run 历史、terminal_create/close 归并

WebUI 在 NewHandler 订阅、Close 退订;CLI 在 Start 订阅、Stop 退订。
两边共用同一实现,避免历史上「两份推导各踩一次 Go map 文本」的坑。
视图不是权威:真值在 agentcli,插件晚于会话启动会缺早期会话(注释写明)。

## agentcli 保持内置

它现在仍需要 `Publish`(进程内事件总线写入)——外部插件没有上行事件通道
(`EventPublish` 在 withheldCapabilities 里,proc 只有 events.subscribe)。
按判定标准留内置;将来要外置,须先开一条受控的插件→内核上行事件通道,
本次刻意不开(用户决定:先保持内置)。

## 文档

中英 ARCHITECTURE 新增「终端归属」小节:说明职责边界、两条事件、视图非权威、
以及 agentcli 为何仍内置;内置判定表里 cli/webui 去掉 TerminalAPI 一项。

## 验收(本机实例实测)

- 内核编译通过,删除后 `go build ./... && go vet ./...` 干净,`go test ./internal/...` 全绿
- 实例重启后:让 agent 开终端跑 `echo term-view-test` →
  WebUI `GET /api/v1/terminals` 返回 term_1(output 正确、running=false)
  CLI `/terminals`(unix socket)返回同一份;
  两处都是从事件总线自攒,内核侧无终端代码参与
- cmd 历史分支由迁过来的单测覆盖(Go map 文本解析、生命周期、上限裁剪)
2026-10-20 09:33:08 +08:00
d35b7b7275 feat(status): ONNX 状态补 dep_parser 字段;人格改成「说人话」的说话方式
## 1. ONNX 状态补依存句法分析器(onnx.dep_parser)

此前 onnx.enabled 只反映**多模态向量空间**,解析器状态完全不可见 —— 于是
只能靠翻启动日志猜,而日志又曾把「内嵌模型已启用」错报成「规则引擎」
(见上一个提交)。现在健康快照直接给:

    "dep_parser": { "enabled": true,
                    "model_path": "/home/newqqagent/nlp/dep_parser.onnx",
                    "embedded": true }

- sdk.ONNXStatus 增加 DepParser / DepParserStatus(enabled/model_path/embedded)
- agent 侧由 nlp 包级默认解析器反查实际状态(判据是「真能 Parse 且报得出路径」,
  不是「配置里写了 onnx_model_path」)
- healthcheck_kernel 的描述同步说明该字段

## 2. 人格:从「交报告」改成「聊天」

用户反馈实例说话一股人机味。实测原文:

    第一件·文件读写:
    - 写:files_write 往 /tmp/... 写入 hello-files,回执 11 字节
    - 读:files_read 读回来,内容就是 hello-files,对上了

问题不在语气词,而在**结构**:清单体 + 小标题 + 加粗 + 把工具名/字节数/耗时
当成绩单报出来。旧人格只禁了「客服腔四件套」,没禁这些。

DefaultPersonaPrompt 的说话方式一节重写,明确:
- 短句为主;先给结果/情绪,再补解释
- 不用清单体(“-”/“1.”/“•”)、不用小标题/加粗/表格
- 不报工单式细节(不罗列工具名、参数、字节数、耗时)——说人话
- 不用客服腔,拿不准/失败直说
- 只有用户明确要清单/步骤/数据时才结构化

## 3. 本机实例人格同步优化(personal.md,优先级高于配置项)

按同一套规则重写 /home/newqqagent/personal/personal.md,并保留「照大肥鱼那股
活人味来」与形象特征。重启后实测(问「在吗?状态咋样」):

    在呢在呢 (`・ω・´)
    刚拉了个运行时快照看了眼,状态挺好——内核 1.4.0dev 跑着,29 个插件全部 loaded……
    刚忙完就赶紧来找我聊天,老大你这节奏也太充实了呗~

无清单、无小标题、无工具名报账。

验证:go test(sdk / agent core / healthcheck / config)全绿;
本机实例重启后 onnx.dep_parser 三字段正确返回。
2026-10-20 09:08:20 +08:00
16c9d4f69e fix(startup): 启动阶段两类噪声归零 —— 插件 stdout 日志与 CLI 型技能误判
用户反馈启动日志里有报错。实测定位到两处,都是真实问题:

## 1. huawei_smarthome 往 stdout 打日志(8 条「收到非法 JSON 帧」)

stdout 是 JSON-RPC **协议通道**,它在 plugin.go/handlers.go/service.go 等处
用了 fmt.Printf(31 处),内核每收到一行就把协议解析失败一次。
这不是内核的错,是插件写法错 —— 已在其源码(/home/newqqagent/plugindev/
huawei_smarthome)把 fmt.Printf/Println 全部改成 log.Printf(stderr),
重新 hmapdev 打包并安装。重启后非法帧 0 条、10 个工具正常注册。

## 2. clawhubadapter 把 CLI 型 main.py 当 JSON-RPC sidecar

判定规则原为「有 main.py 就按 pysidecar 拉起」,但 ClawHub 上大量技能的
main.py 是 **CLI 入口**(`python3 main.py <tool> '<json>'`),对它发 ping
必然超时(bilibili、gaode 各等 5 秒)。旧代码只打一行错误就完事 ——
启动日志一片红,而技能既没变成工具、也没交给 skillmgr,等于半死不活。

改为:sidecar 就绪失败即降级为「CLI 型技能」,移交 skillmgr 并给出说明,
日志措辞从「错误」改为「未就绪 → 按 CLI 型处理」。两个技能现已可经
skill_info 读到文档、再用命令执行。

## 3. 顺带修一条看着像配置泄漏的日志

`dep parser initialized (model: )` —— 空括号是「未配置 ONNX 模型、走内置规则
引擎」的正常回退。改为显式说明,避免误读成缺配置。

## 验收(本机实例重启后实录)

- 「非法 JSON 帧」0 条(此前 8 条)
- dep parser 日志:`ready (内置规则引擎;未配置 core.agent.onnx_model_path)`
- 剩余 3 条均为说明性:mail-bridge 出站通道兜底、两个 CLI 型技能降级移交
- 13 个内置 + 13 个自启外部插件全部 loaded,agent main started
2026-10-20 09:00:58 +08:00
b5f024b054 refactor(prompt): 提示词去工具化 + WebUI 插件状态按运行期真相展示
## 1. 系统提示词/人格瘦身(工具用法搬去 tooldef 与 skill)

用户指出:默认系统提示词与 personal 里有大量本该属于 skill / 工具描述的内容。
核对属实,且已造成**真实故障**:提示词教模型用 cmd_run,而 cmd 外置按需后
工具根本不在清单里 ⇒ 模型第一轮调用不存在的工具,拿到「工具 cmd_run 不存在」。

改动:
- kernelmod/agent.defaultSystemPrompt:删掉 output_* 用法、工具分类清单、
  cmd_run/terminal/files 策略、并行策略、媒体工具清单、emoji 风格要求。
  只留:身份 + 「工具描述与技能索引会注入,先读再用」+「只依据工具返回值作答」。
- config.DefaultPersonaPrompt:只留身份/称呼/语气 + 版本以运行时快照为准
  (后者由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住)。
- 命令与文件策略下沉到工具描述:cmd_run 补「完整 bash,支持管道/heredoc/重定向」,
  files_write 补「原子替换 + 覆盖受保护路径前留档」。
- 对话时序块自带语义说明(原先靠提示词解释,提示词一精简就丢语义)。
- 文档新增「提示词分层」章节(中英),写明归属判据与这次的反面教材。

## 2. WebUI:插件「启动状态」按运行期真相展示

旧实现只看内核 status 的 loaded 一个布尔,三种形态被混成同一个「未加载」:
auto_start=false 的按需待命(正常)、该起没起(异常)、plugin_stop 后的运行态停止。
用户看到一屏「未加载」以为插件全坏了。

- sdk.PluginRuntimeInfo 新增 AutoStart / RuntimeStopped;registry 从清单填充。
- pluginmgr /plugins 列表暴露 auto_start / runtime_stopped / builtin / tool_count。
- webui 新增 POST /plugins/<name>/start|stop(运行态,不动持久禁用清单)。
- dashboard 状态列改为五态:已禁用 / 进程丢失 / 已运行 / 按需待命 / 未运行,
  并额外标注「自启 / 按需」;按钮改为 启动/停止(运行态)与 禁用/启用(持久态)分列。
- 新增两条回归判据(引用 auto_start/runtime_stopped/alive 字段;startPlugin 走 /start)。

## 验收(本机实例实测)

- files 保持 auto_start=false(按需);问 agent 写文件 → 它先 plugin_start cmd+files,
  再 files_write/files_read,内容校验一致;未用 shell 代替文件操作。
- WebUI 插件页截图确认:a2a/acp/agentcli 显示「已运行 自启」,
  ai_image 显示「按需待命 按需」+「启动」按钮。
- go test(config/agent core/plugin/pluginmgr/webui/sdk)全绿。
2026-10-20 08:57:36 +08:00
2aab90c508 fix(plugins): 内置清单终审 15 → 13 —— kbtree/multimodal 翻案加回
## 翻案依据(逐方法对照公开 SDK,非方法名 grep)

初判把 kbtree/multimodal 归入"只用公开 SDK",错了。两边的
s.Knowledge() / s.LLM() **同名但接口面不同**:

- kbtree 需要 KnowledgeAPI.Subtree/CategoryCounts/SearchIn/Categories
  与 KnowledgeTreeView/KnowledgeCategoryCount 类型 —— 公开 KnowledgeAPI
  只有 Search/Add/List,树视图类型公开面根本没有
- multimodal 需要 LLMAPI.Chat + LLMMessage/LLMContentBlock ——
  公开 LLMAPI 只有 ListSources/SetSource/CurrentSource,没有 Chat

外置它们 = OCR/看图/转写、知识树浏览静默失效。

## 最终内置 13 个

  cli webui agentcli healthcheck pluginmgr seq remotedevice
  clawhubadapter kbtree multimodal cfgmgr skillmgr seemedia

外置 5 个(SDK 仓 example/ 已提供工程并已部署验证):
  cmd localuse mcp(本轮)+ ai_image files(此前)+ timer(更早)

## 文档同步(中英 6 份)

数量 11 → 13;判定标准小节补「终审翻案」与教训:
**判外置与否必须逐方法对照公开接口,同名方法不等于同面。**

验证:go build/vet/test 全绿;生产实例已部署新内核 + 新插件包,
四个按需插件经 plugin_start 拉起、工具全部注册(cmd_run /
local_screensee / mcp_* / files_edit_line 实核)。
2026-10-19 22:09:09 +08:00
835688c94a refactor(plugins): 按「内置插件判定标准」收敛内置清单 16 → 11
## 判定标准(写进 all.go 与 ARCHITECTURE)

**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在
编译期内置清单**;其余一律外置为 .hmap 插件,独立编译、独立发版。

依据是 internal/plugin/proc/capability.go 的 withheldCapabilities:
内部 SDK 的 TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI/
SelftestAPI/ToolAPI/Publish 等能力面,外部插件在类型层就不存在。
需要它们的插件外置后功能会**静默消失**(不报错),必须内置;
只要公开能力的插件,留在内核里是白付重编成本。

## 内置 11 个(all.go 单一权威清单)

  cli webui       TerminalAPI/Tracker/Status/Supervisor/Adapter
  agentcli        EventPublish(终端输出走 Publish,公开 SDK 无此方法)
  healthcheck     SelftestAPI
  pluginmgr       failback 恢复链必需
  seq             ToolAPI
  remotedevice    RegisterChannel
  clawhubadapter  InjectInputSync
  cfgmgr skillmgr seemedia(保留)

## 外置 8 个(源码保留,.hmap 由 SDK 仓 example/ 提供)

  timer(此前)ai_image files cmd kbtree localuse mcp multimodal

## 同步修正

- kbtree 归位:它一直是编译期内置插件,却长期被 main.go 单独导入,
  游离在 all.go 清单之外(文档盘点因此数错)。现移入 all.go,
  main.go 不再单独导入任何插件,并加注释禁止该做法。
- integration_test:外置插件的 7 个用例(cmd_run/terminal_*)加
  requireExternalTool 跳过保护——用例保留(行为判据仍有价值),
  在纯内置装配下 Skip,不造假失败。
- 全部文档同步 11 个的清单与判定标准(中英四份 + 两份 README)。

## 已核实不做的事

- 保留的 11 个内置插件无一在代码里硬依赖被外置的插件:
  cli 对 terminal_* 是 ExecuteTool 按名动态调用(运行时依赖);
  healthcheck/seq 只在白名单与示例文本里出现;webui 只订阅事件。
- kernelmod/(内核模块)与 plugins/(插件)严格区分:
  kernelmod/multimodal 是内核装配模块,与外置的 multimodal 插件无关。

验证:go build ./... 全绿;go test ./internal/plugins/... 全绿;
webui 新增 5 条命令历史渲染回归判据(TestCmdHistory*)全绿。
2026-10-19 21:13:23 +08:00
99914a9e9b test(plugins): 加负向断言守护 timer 不再被注册进内核
timer 已移出内置插件(717054a),但原有 TestIntegrationAllPluginsRegister
只是正向清单 —— 它断言「该有的工具都在」,对「多出来」是瞎的:
把 internal/plugins/timer 与 all.go 的 blank import 加回去,那条测试依然全绿。
于是「已移除」这件事只能靠人记住。

补一条负向断言,在三个互相独立的注册面上各断一次(任一处漏掉都能单独复活 timer):
  - 工具面:timer_set / timer_list / timer_cancel 不应存在
  - 输入面:inputch "timer" 不应存在(定时器注入中断的通道)
  - 注册表:插件元信息与编译期工厂都不应存在(IsBuiltinPlugin 为假)

已做变异验证:把 timer 插件与 blank import 恢复后,四条断言全部触发、测试 FAIL;
移除后 PASS。故该判据非空转。

(cherry picked from commit d5882ee44a4810fe9e5ee02fb329ad423b065d6e)
2026-10-19 16:09:03 +08:00
6b64d69b10 refactor(timer): 从内核内置插件迁为外部插件,移除编译期注册
老大要求「把 timer 移到外部插件,移除内核的内置 timer」。本次只做内核侧
的**摘除**,插件本体在外部工程(plugins/timer → .hmap 安装)。

timer 原先由 internal/plugins/timer 的 init() 注册进编译期工厂表。
后果是改一行定时器逻辑要重编整个 homed(87MB)并重启内核——而它恰是
最该独立演进的部件:有自己的持久化状态、触发语义、并发模型,却被绑死
在主进程的发版节奏上。

搬出来后:换实现只需重装插件 + 重启单个插件进程;插件崩了自己被拉起,
不影响 agent 主循环。

1. 删除 internal/plugins/timer(plugin.go + plugin_test.go)
2. internal/plugins/all.go 去掉该 import,并写明**不要加回去**的原因
3. internal/plugins/integration_test.go 的 expectedTools 去掉 timer_set

TestIntegrationAllPluginsRegister 的语义是「编译期内置插件是否都注册了工具」,
它走的是本地装配(setupIntegration 只加载 internal/plugins 下的内置包)。
timer 搬走后不再参与这个进程的装配,留在列表里只会造出永远绿不了的假失败。

真正不该省的是验证:外部插件的工具是否真注册成功,靠装完 .hmap 后实调
timer_set / timer_list / timer_cancel 来确认(见交付报告)。

- 配置表:外部插件同用 config_<name> ⇒ config_timer,老值 max_duration /
  pending_timers 直接读回,老大已设的定时器无需迁移
- 工具名:timer_set / timer_list / timer_cancel 不变,模型侧零感知
- 注入源与通道:source/channel 仍是 "timer"
- 注入级别:内置插件可声明 L4,外部插件被夹到 L3;本插件本就要 L3,
  故行为等价

StageHost.RegisterTool 对同名工具返回 `already registered`,而插件注册是
批量失败(新实例的工具一个都注不上)。所以必须**先装新内核**(不含内置
timer)再装外部 timer 插件,反之会撞名。

(cherry picked from commit f584776f23c3bfd8fb2892155e6018a1ec57eac0)
2026-10-19 16:09:03 +08:00
40de84facc feat(systemd,webui,security): 数据目录一致性接线 + 内嵌主密钥注入
## 1. 数据目录一致性(原为死代码)

`internal/systemd/sync.go` 能编译但**从未接线** —— 没有任何地方调用它。

内核有**两处**数据目录来源且不等价:

	-data <dir>            ← systemd 单元 → memory/graph.db、插件数据根
	core.daemon.data_dir   ← 数据库     → memory/text、documents、knowledge

不一致时数据**分裂**:图记忆在 A、文本/知识在 B,检索跨目录、备份会漏。
生产当前两者一致所以没暴露,但 WebUI 允许改 DB 里的值而**无任何提示**。

接线三处:

 · cmd/homed/main.go 启动时检查,不一致则打告警 + 内联修复命令
 · GET /api/v1/system/data-dir 返回诊断(只在真不一致时给 sync_command)
 · 判据 6 条(InSync/Clean/SyncCommand 内联 sed/备份/daemon-reload/drop-in)

★ 只告警**不改**:静默改 systemd 单元比不提示更危险 —— 改错了服务
起不来,而用户不知道是谁改的。命令里已内联 sed 替换,人复制执行即可。

## 2. 内嵌主密钥(方案 C)

build.sh 支持 `HA_EMBEDDED_KEY_HEX`(64 位 hex)经 -ldflags 注入。
从**环境变量**读而不是写进文件:本仓有两个公开远端,64 位 hex 一旦
写进被跟踪的文件就永久留在 git 历史里。

未设时不注入、不报错:开发机与 CI 本来就不该带生产密钥。
设了但格式不对则**中断构建**(`return 1`)—— 静默产出一个没有内嵌钥的
二进制,会让"防丢失"这个目的悄悄失效。

实测(真机):磁盘上**没有** security.key 时,仅靠 embedded 密钥即可
Submit + 二次加载解密成功;日志确认
`密钥来源 embedded (构建期注入,指纹 0db8e9c5)`。

## 判据第一版的两个错误(记下来)

 · 没给 UnitPath 就断言"必须含 sed -i" —— 实际 sed 行只在 UnitPath
   非空时生成,判据误报。
 · 断言 `/` 必须转义 —— 但 sed 分隔符是 `|` 不是 `/`。`/` 无需转义;
   真正必须转义的是 `&`(sed 里表示整个匹配)与 `|`(分隔符)。
2026-10-18 13:51:00 +08:00
53950e0388 test(pluginmgr): 钉住「插件必须走 plugin_install,不能手工替换二进制」
## 我犯的错(2026-10-18)

改完 commondev 后我是这么"部署"的:

	python3 -c "zipfile...extractall('/tmp/x')"
	install -m 0755 /tmp/x/plugin.bin <插件目录>/plugin.bin
	install -m 0644 /tmp/x/rawfile/runtime/pi/sidecar.mjs <插件目录>/...
	systemctl restart homeagent

用户一句「为什么不打包为 hmap 安装,而是手动替换」点破了问题。

## 手动替换丢了什么

换成标准 plugin_install 后,返回:

	action: "reinstalled",  checksum: "13a46f87161d37c8…",
	config_kept: true,  skills_registered: 1,  reload_required: true

这个 checksum 正是 .hmap 的 sha256(实测一致)。而手工 cp 拿不到的东西:

 · **整包校验和** —— 包与落盘结果之间没有任何可核对的凭据
 · **备份 + 失败回滚** —— 手工 install 半途失败会留下半新半旧的目录,
   比彻底失败更难查(插件能加载、某个功能诡异失效)
 · **全量路径校验** —— validatePackage 遍历**所有**条目拒 `..`/绝对路径
   (该循环曾因 break 漏检 rawfile,已修)
 · **rawfile 全量解包** —— 我只覆盖了 2 个 sidecar,其余(node/pi 运行时)
   仍是旧的。证据:替换后 `rawfile/` 目录 mtime 停在 10-16,
   标准安装后整个目录 mtime 才变成 10-49
 · **配置保留与 skill 注册** —— config_kept / skills_registered 在返回值里

## 判据(2 条)

代码无法禁止人手工 cp,但可以把**唯一正确入口**钉住:

 · TestInstallKeepsBackupAndRollback:installFromData 必须仍有
   「备份旧目录 → extractPackage → 失败回滚」三件事
 · TestValidatePackageChecksAllEntries:路径校验必须遍历全部条目,
   且所在循环里不能有 break(历史缺陷:manifest 之后的条目漏检)

## 变异

去掉备份            → 咬("安装不再备份旧目录 —— 失败就没法回滚")
去掉回滚            → 咬
2026-10-18 10:51:46 +08:00
ef08ebb7d3 chore(mascot): 立绘 v2 —— 真 alpha 素材 + 头部裁切头像 + 素材判据
老大给的 4 张新立绘里有 3 张是「棋盘格假透明」JPEG(像素是灰白格子,
不是 alpha)。直接进仓库会在深色面板上露出格子,所以先抠成真 alpha 再落盘。

## 为什么主图与头像要分两个文件

现役 mascot.webp 唯一的消费点是**聊天消息的 30px 圆形头像**
(dashboard.js 4 处 aiAvatar + GUI app.js 2 处),概览页并没有放立绘。
主图是坐姿全身,按现有 CSS(object-fit:cover + scale(1.5))裁成圆后
只剩裙子、看不到脸 —— 只换图不做头像版,等于把头像换成一张看不懂的图。

- assets/branding/mascot-xiaozhai.webp        1024² 主立绘(官方展示)
- assets/branding/mascot-xiaozhai-avatar.webp  512² 头部方裁(运行期源)
- internal/plugins/webui/mascot.webp           256² 头像版(原 133KB→24KB)
- cmd/gui/renderer/mascot.webp                 128² 头像版(原 133KB→9KB)

参考稿另存三张(不参与运行):sheet(设定总图)/ portrait(半身)/
turnaround(三视图)。

## 抠图怎么做的(可复现,不是手搓)

背景是 20.45px 周期的棋盘格(237/254 两色)。判据取「中性灰 且 灰度≥228」
的像素做候选,再从图像边界做连通域传播 —— 这样白裙子/白围裙因为不中性
(带蓝调)不会被误判,避免了当年 site/README 记录的 flood-fill 渗色问题。
边界再内缩 1px 抹白边,0.7px 高斯羽化去锯齿。抠完透明占比 55.8%,
深/浅两种背景上目检均无残留格子与白边。

## CSS:scale(1.5) → 1.25

原来的 1.5 是为「全身图裁圆」补的放大。换成头部方裁后,1.5 会切掉两侧
发绳与呆毛 —— 按真实 30px 渲染放大对照看过 1.0/1.15/1.25/1.4 四档。

## 新增判据(含反向验证)

internal/plugins/webui/mascot_asset_test.go 直接读 WebP 容器头,
断言 VP8X 的 Alpha 标志位与 ALPH chunk **同时存在**,外加正方形/≥128px/
≤128KB。理由:这类退化在肉眼 review 里抓不住(缩到 30px 就是一团浅色),
而用户每天都在看;且 site/README.md 记着当年正因为「白底图没法抠」
干脆不放立绘,这个坑值得一个自动化判据。

反向验证(已实跑,非声称):把素材换成 HEAD 旧图,判据报红并准确指出
「Alpha 标志位未置位 / 缺 ALPH chunk / 体积超限」;换回新图转绿。
另有一条独立用例构造「带 VP8X 但不置 Alpha 位」的最小容器,
确认解析器不会对退化素材误判为绿。

## 顺带修的两处

1. assets/docs/{zh,en}/PLUGIN_DEV.md 的 mascot 引用路径是坏的:
   `../../assets/branding/...` 从 assets/docs/zh/ 出发解析成
   `assets/assets/branding/...`,文件不存在(旧版一直是坏的)。
   已改为 `../../branding/...` 并逐条验证路径可解析。
   同时指向新的头像版(那处是 20px 圆形内联小图标)。
2. gzip.go / gzip_test.go 注释里的「mascot.webp(133KB)」改为不带死数字
   的说法 —— 体积会随素材变,注释写死数字只会变成误导。

## 验证

go build ./...            → 0
go vet ./internal/plugins/webui/ ./cmd/gui/ → 干净
gofmt -l                  → 空
go test ./internal/plugins/webui/ -count=1 → ok(15.1s,含新增 2 个用例)

## 没做的事

- 没碰 main、没开 PR、没部署、没重启服务。
- **概览页仍未放立绘**。site/README 里「素材不可用」这条已不成立(素材有
  alpha 了),但版面是否该放图属于设计取舍,不在本次范围 —— 文档已相应改写,
  不再用「素材不行」当理由。
- 抠图对发丝边缘是「够用」不是「完美」:0.7px 羽化在 1024 下看不出来,
  但若将来要印物料级大图,需要重出带原生 alpha 的源图。
2026-10-18 07:38:10 +08:00
5a3a8f484b fix(agent): 工具超时不再一刀切 60s,支持工具自声明与按参数推导
## 缺陷(2026-10-17 生产日志实测)

`toolcall.go` 里写死 `time.After(60 * time.Second)`,与工具自己的超时
**互相矛盾**且让内层形同虚设:

	23:13:38  执行 cmd_run(agent 传了 timeout="130" 级参数)
	23:14:38  ★ 内核 60s 硬超时掐断 → 「执行超时(60秒)」

同一份日志里 healthcheck 声明默认 120s ⇒ 被砍掉一半,天生跑不完。

## 改动

1. `ToolDef.Timeout` / `BuiltinToolDef.Timeout`:工具自己声明单次上限。
   留空 = 沿用 60s(存量插件**一行不改,行为逐字不变**)。

2. 内核取 `max(声明值, 调用参数 timeout)`,再夹到硬上限 10m:
   · 声明修好固定时长的工具(healthcheck 5m)
   · 参数推导修好时长可变的工具(cmd_run 的 timeout 参数)
   · 硬上限防插件声明成 0/无限把队列堵死

3. 非法声明("130" 少单位、0s、负数)**回退默认并记日志**,不静默接受。
   写错单位是最容易犯的错,默默变成 60s 会让人以为"声明没生效"却查不出。

4. healthcheck 声明 5m(它的 llm_timeout 默认就是 120s)。

★ 没有把 timeout 放进 `ToSchema()`:那份 map 是**发给模型**的 function
  schema。把内核的执行参数混进去,模型会以为需要自己传 timeout 参数。

## 判据(6 条)

未声明→60s / 声明生效 / 参数参与取 max / 参数比声明小用声明 /
非法回退 / 超上限夹住。变异全部咬住。

## 附:这条路只治「工具真的需要更久」

向开发线投递(output_send__*)的两次 60s 超时**不是这个原因** ——
那是 commondev 把"投递"实现成了"同步等任务完成",已在 SDK 仓修成异步。
这里没有给 output_send 放大超时:投递应该瞬时返回,慢是 bug 不是需求。
2026-10-18 06:38:51 +08:00
683f0126af test(webui): 内核按钮必须真的出现在**渲染后**的页面里
## 为什么需要这组判据

dashboard.html 与 dashboard.js 都是 `//go:embed` 进二进制的,再由 init
组装成 dashboardHTML(替换 {{DASHBOARD_JS}} 等占位符)。这里有两个
**各自都可能坏掉**的环节:

 1. 按钮写进了 dashboard.html,但占位符没被替换 / 替换错位
    ⇒ 页面里看不到按钮
 2. JS 函数写进了 dashboard.js,但被包在 IIFE 里(不在全局)
    ⇒ onclick="kernelRestart()" 点了报 "kernelRestart is not defined"

只查源码里有没有那个字符串,这两种情况都照不出来。

## 判据(3 条,都查组装后的 dashboardHTML / embed 源)

 · TestKernelButtonsRenderedInPage:渲染后的页面含两个按钮的 id 与 onclick
 · TestKernelFunctionsAreGlobal:4 个函数必须是**顶层**定义
   (浏览器 onclick 走全局查找,jsdom 的行为测试做不到这点)
 · TestKernelButtonsInNavFooter:两个按钮必须落在 nav-footer 内
   —— 它们作用于整个服务,切到任何标签页都该能点到

实测:渲染页面 408057 字节,六项断言全在。

顺带把 dashboard.js 的格式化差异一并提交(-w 对比为空的纯格式调整)。
2026-10-17 21:26:12 +08:00
198811253a feat(webui+guard): 内核重启/关机按钮,指令走心跳复用既有协议
## 需求

WebUI 加关机与重启按钮,内核提供对应内部接口。有些状态变更只能靠重启
生效(换 LLM 源、换主密钥),没有这条通道时用户只能 ssh 上去敲 systemctl。

## 设计:复用心跳,不新开通道(用户指出的方向)

guard 与 worker 之间已经有一条现成的、每 5s 一次的单向通报(心跳文件,
内容原本是 `t=<unix>`,只用来刷 mtime)。用户点按钮本质就是"我要你在
下一次心跳时知道这件事"——再开 unix socket / 信号 / 临时文件,只会多出
一套要防重、防丢、防竞态的机制。

    WebUI 点按钮
      → POST /api/v1/kernel/{restart,shutdown}
      → sdk.KernelControlAPI(只投递,立即返回)
      → 下一次心跳把 `cmd=restart|shutdown reason=...` 写进 <data>/heartbeat
      → 重启:worker 清理 → exit 42 → guard 重拉(**既有机制,零改动**)
        关机:guard 在 workerCleanExit 分支读到 cmd=shutdown
              → systemctl stop → 不再拉起

★ 关机为什么必须由 guard 做:
  unit 是 Restart=always,guard 自己 return 会被 systemd 立刻拉起
  (用户点了"关机"、几秒后服务又活了)。必须显式 systemctl stop。
  而不让 worker 自己停:它是被守护方,且**只有 guard 知道这次该不该重拉**。

★ 指令必须一次性消费:guard 读完立即原子重写文件清除 cmd 行,
  否则每次心跳都读到同一条 ⇒ 无限重启/关机。worker 侧还要检查
  "指令写过但文件里没了" ⇒ 已被消费 ⇒ 清本地 pending,否则又写回去。

## 顺带修的真缺陷:心跳文件无限增长

生产实测 **14.6MB**(纯 O_APPEND,约 70 天)。心跳的唯一职责是刷 mtime,
历史行没有任何读者 ⇒ 每次 consume 要整读 14.6MB(实测 27ms)。
改为 O_TRUNC 滚动写:只保留当前一行。真机验证 15,322,164 → 13 字节。
consume 也加了尺寸守卫(大文件只读尾部 8KB)。

## 安全:能力分级 + 白名单(两道)

1. proc 桥的 method 能力表:新增 `CapKernelControl` 一档。
   ★ 判据 `TestCapability_AllMethodsClassified` 当场报出
   "未登记 ⇒ 会按 CapCore 放行,绕过权限检查" —— 那意味着任何插件都能关机。
2. 内置插件走 `SDKConfig.KernelControl`,第一版我写成"有就发",
   等于全部内置插件都能关机。改为按名字白名单(目前只有 webui)。

## 判据(7 条)

心跳:解析 / 消费后必须清除 / worker 不得写回已消费指令 / 未消费时必须续写
能力:只有白名单插件拿得到 / 未装配时返回 nil 不 panic / SDK nil 安全
心跳文件:不得无限增长 / 大文件只读尾部且读后收敛

变异 6/6 咬住。全量 53 包 ok。

## UI

导航底栏两个图标按钮(与主题/语言/退出并列):重启有确认框 + 轮询等它
回来并自动刷新;关机有**二次确认且写清后果**(必须人工 systemctl start)。
两者都先把响应写回浏览器并 Flush,再让内核退出 —— 否则用户看到的是
connection reset,无法区分"正在重启"和"真出故障了"。
2026-10-17 21:18:07 +08:00
134cd860b6 fix(webui): 聊天受理超时 15s 太短,误报「已取消(用户停止)」
## 内测用户报告

发一句话,界面回「已取消(用户停止)」,而且连着出现多次。

## 实测真相(用户提供的日志)

    20:58:36  POST /api/v1/chat 开始,agent 开始干活
    20:58:48  agent 仍在跑(terminal_create / terminal_read)
    20:58:52  ★ 前端 15s ackTimer 到点 → ctrl.abort()
              → 服务端记 status=504,耗时 15.89s
    20:59:29  ★ agent 正常跑完,52.5 秒、22 个工具、返回 response

也就是说:**agent 从来没有被取消过**。它一直在正常干活并跑完了,
只是前端自己掐断了连接,回复没法送回,UI 就把"我没等到"显示成了
"用户停止"——一个用户从没做过的操作。

## 根因

`sendChat()` 里把"受理确认"的等待写死 15 秒,而 agent 的正常任务
动辄 45-55 秒(工具越多越久;同一份日志里另一次是 45262ms)。

这个 POST 本来就是**触发式**的:真正渲染靠 SSE 流式(`armTurnWatchdog`
兜 120 秒)。在受理环节抢时间没有任何意义,只会误伤正常任务。

## 改动

1. **超时 15s → 600s**:不再用固定短超时。保留上限只为兜住
   "连 TCP 都建不起来"这类真故障,避免请求永远挂着。

2. **文案改掉**:原来 abort 后提示「请求超时(可能已发送)」,
   红字 error 样式。用户看到"超时"会以为消息丢了而**重发** ——
   实测会造成同一句话产生两条任务。改为:
   「已发出,AI 正在处理(复杂任务可能要一两分钟)。请等待回复,不要重复发送。」
   并且不再用 error 样式(这不是错误)。

## 判据(2 条,源码扫描)

 · `TestChatAckTimeoutIsGenerous`:超时 ≥ 5 分钟
   (实测正常任务 45-55 秒,留足余量)
 · `TestChatAckTimeoutMessageDoesNotBlameFailure`:提示不得出现
   "失败/超时/Error/failed",必须说清"正在处理"且提醒"不要重发"

变异 2/2 咬住(改回 15s / 改回"请求超时"文案都当场变红)。

★ 为什么用源码扫描而不是行为测试:这是前端 abort 时序,没有可注入的
  时钟。扫描至少能防住"有人把 15s 改回来"。
2026-10-17 21:16:49 +08:00
a68d6a428c feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)
* feat(kernel): 模块化内核 + 编译期依赖图定序

把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。

## 为什么

原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
 · nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
   只靠 ticker 10min 才没炸(隐式依赖,无声明)
 · 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
 · 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
   ⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)

## 怎么改

- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
  nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
  scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
  lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
  all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)

## 判据

kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
 · 删掉 AddCloser 的锁 → -race 报数据竞争
 · Scheduler 同批跑去重 → 203 vs 200
 · 图 hash 不匹配 → 拒绝启动

## 顺带修的缺陷

热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。

实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。

* feat(security): 密钥防火墙 + 配置加密 + close 纪律

## 密钥防火墙

模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
 · 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
 · 入向:模型传别名,入口处填回真值(插件完全无感知)
 · 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件

模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。

## 配置加密

- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
  enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计

## close 纪律(用户明确要求:只要调 close 就要有前置检查)

internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
 · `select { case <-ch: default: close(ch) }` **不是**并发安全的
   (两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
 · `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
   症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
 · map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
   改成 *pendingRPC 包装,每通道一个 once

## 判据

security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。

* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复

## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)

- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
  "未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
  按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
  (不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
  那是进程退出用的,误用于"暂停"就是关了就死

## 数据目录迁移(伪关机 → 迁移 → 唤醒)

- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
  逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
  → 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
  恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本

★ 迁移脚本的三个实测教训(都写成了判据):
 1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
    加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
 2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
    并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)

## 发行包修复(全部经实测确认)

 1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
    ("生产构建(内嵌 hash 非空)但读不到依赖图")
 2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
 3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
    ⇒ --version 显示 unknown
 4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH

## ★ build.sh 的版本号必须校验(真实事故)

原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。

修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。

## deb 的 systemd unit 改为 guard 模式

ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。

## 其他

- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
  修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)

* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)

- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
  串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
  "出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
  白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
  mgrIsRuntimeStopped)+ settings 对齐

判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。

* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)

GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:

  internal/security/prefilter_test.go:39
  internal/security/store_test.go:177
  internal/security/survey_test.go:20

它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。

★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。

改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
  glpat-abcdefghijklmnopqrst       → glpat-FAKE-TEST-TOKEN-NOT-REAL
  sk-abcdefghij...012345           → sk-FAKE-TEST-KEY-NOT-REAL-0000000
  AKIAIOSFODNN7EXAMPLE             → AKIA0000FAKE0000TEST   (保持 20 字符)
  ASIAIOSFODNN7EXAMPLE             → ASIA0000FAKE0000TEST
  xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL

★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。

顺带核实:全仓 grep 无任何真实密钥泄漏。

* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一

上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:

  VERSION="${VERSION:-$(git describe --tags --dirty)}"

无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。

危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。

修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。

判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。

实测:不传 VERSION 跑 package-linux.sh,输出
  [PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
  Version: 1.4.0

* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)

CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:

  --- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
      bridge_sync_test.go:170: 读模板: open
      ../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
      no such file or directory

## 根因:判据依赖了一个**被 gitignore 的路径**

`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
  · 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
  · CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**

我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。

## 修法

文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。

★ 这不是"放弃检查":
  · 跨仓一致性由 **SDK 仓自己的判据**守着
    (tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
  · 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)

## 验证

 · 本机(有文件)  → PASS(判据真跑)
 · 模拟 CI(移走 tools/)→ SKIP(不再 fail)
 · 全量 53 包 ok / 0 fail

## 顺带

同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:18:24 +08:00
cf3d3769b1 perf(starmap): WebUI/GUI 星图卡顿修复 + 抽出共享「布局数学」单一源
## 症状与实测

WebUI 星图建图阻塞主线程 9339ms、draw calls 7514;GUI 建图 2466ms、
draw calls 3477。两者在空闲时仍在空转渲染。

## WebUI(internal/plugins/webui/dashboard.js)

- 力导向布局 O(n²) 两两互斥改**网格近邻**,并移入 **Web Worker**
  ⇒ 建图阻塞 **9339ms → 57ms**
- 每节点光晕 3753 个 draw call → 1 个 InstancedMesh
- 边 966 → 4 个 LineSegments
- 空闲帧跳过 + 星空时间驱动
- 结果:draw calls **7514 → 3760**,空闲阻塞 **99% → 3%**

## GUI(cmd/gui/renderer/app.js)

- draw calls **3477 → 1809**,建图 **2466ms → 943ms**
- 首屏可见性守卫(GUI 默认 chat 视图,星图不该在后台初始化)
- 空闲 12fps → 0.2fps

修 GUI 两个自引 bug:
  1. 抄 WebUI 的 `starmapMorphSet`(GUI 实际用 `state.starmapGrown`)⇒
     每帧 ReferenceError、星图 0 帧;
  2. `camKey` 读 `starmapCam.position` 无守卫(`switchChatPanel` 同步调
     `starmapAnimate` 时 cam 为 nil)。**同一缺陷 WebUI 也存在,两处均已补守卫。**

## 共享源(方案 A:只共享「布局数学」这一层)

新增 `shared/starmap/layout.js`(3 个纯函数,零 DOM/全局引用)+
生成器 `tools/starmap-sync.mjs` + 门禁 `make check-starmap-shared`(已进
make test)。三处(共享源 / dashboard.js / app.js)的标记块必须逐字节相同。

**为何只共享这一层**(实测依据):web 星图 54 个函数、gui 24 个,两者同名
函数逐字节相同数为 **0**。GUI 有设备桥/computer use/托盘/自有面板,WebUI 有
侧栏页签体系 —— 共享受众完全不同的 UI 代码是错的,能安全共享的只有布局数学。

**为何用生成器而非运行时加载**:GUI 用 file:// (main.js 的 loadFile),可能
离线;让其拉服务端 dashboard.js 会把「星图能否初始化」绑到网络与登录态上。

**为何 GUI 不用 Web Worker**:file:// 下外链与 Blob Worker 实测都能跑,但不把
「星图能否初始化」押在打包环境的 Worker 行为上。

## 判据

- `cmd/gui/starmap-perf.test.mjs`(21 条,接入 npm test)
- 扩充 `internal/plugins/webui/starmap_perf_test.go`(同源判据 +
  checkStarmapSharedBlock)
- 变异 M1/M2/M3 全部报红

## 附带

cmd/gui/renderer/app.js 同时含上一提交的 GUI `system` 事件消费(协议修复),
因同文件交织未再拆分,见提交 fix(clients)。
2026-10-13 16:21:04 +08:00
50ef27891f fix(clients): 三方客户端与内核的 SSE 协议一致性 —— 幽灵类型、发了没人收、客户端漏接
一次三方(CLI/GUI/鸿蒙)与内核的协议审计,发现四处**全部静默**的不一致
(无任何测试或日志会报),本次一并修复并把它们变成可执行判据。

## 四处不一致

1. `agent_error` 是**幽灵类型**:SSE 白名单订阅了它、鸿蒙还写了处理分支
   (显示「本轮处理失败」),但全仓**无任何发布者** —— events 包与 SDK 都
   没有这个常量。于是那段分支永远不可达,而真正的失败帧叫 `error`
   (webui/cli 都发 `{type:"error"}`),鸿蒙从未接住。

2. `system` **发了没人收**:internal/plugin/dynamic_proc.go 一直在发插件崩溃
   (注释还写着「webui/诊断插件可订阅」),却不在 SSE 白名单里 ⇒ 事件发出来
   就掉地上,插件崩溃对用户完全不可见。

3. CLI 缺 `stage`/`system`:CLI 是唯一看不到阶段进展的客户端,agent 跑长任务
   时终端长时间静默,用户分不清「在干活」还是「卡住了」。

4. 鸿蒙设备桥**静默丢弃**:只处理 bind_ack 与 cmd,服务端下行的
   push / cmd_speech_start / cmd_speech_end / hello_ack 直接 return ——
   「收到了但不支持」与「根本没收到」在日志上完全一样,无从排查。

## 修法(注意:三方客户端的通信模型不同,清单不可一刀切)

CLI 走 InjectTextSync **同步拿最终回复**(response 帧)+ 事件订阅做过程输出,
**不做 SSE 重连** ⇒ 它需要 stage/tool_call/reasoning/delta,
但**不需要** agent_output(正文来自同步回执)与 sync_required(那是 SSE
断线重连专用的补拉信号)。GUI/鸿蒙是真 SSE 客户端,两者都需要全量对话事件。
第一版判据按同一份清单要求三者,误报了 CLI —— 那是判据的错,不是代码的错。

- handler_chat.go:subTypes 删 `agent_error`、补 `system`
- dashboard.js / ChatSse.ets:补 `system` 消费(WebUI 用 toast,鸿蒙用 setStage)
- ChatSse.ets:幽灵分支 `agent_error` 改为真实类型 `error`
- cli/plugin.go:补 `stage`(过滤 `_consolidation_`)与 `system` 订阅
- cmd/waiter:补 Phase/Event/Plugin 字段与 stage/system 渲染
- DeviceBridge.ets:静默丢弃改为带 hilog 日志(区分「不支持」与「没收到」)

## 判据(internal/plugins/webui/handler_sse_protocol_test.go,7 条)

TestSSESubTypesAreRealEventTypes(幽灵类型)/
TestEveryPublishedEventHasAnOutwardPath(发了没人收,例外须显式声明理由)/
TestClientsRecognizeServerEventTypes(按客户端分档)/
TestNoPhantomFrameTypes / TestStageFrameFieldsMatchServer /
TestSseWhitelistParsesToExpectedSet(**解析器反向自检** —— 解析器失效会让整组
判据静默变成空集并全绿,是最危险的假绿)/
TestDeviceBridgeOpCoverage。已接入 `make check-client-protocol`(进 make test)。

变异 M1–M8 全部验证。其中 M5 首轮未报红:判据只查前缀 `json:"phase`,
把 tag 改成 `phaseX` 仍匹配 ⇒ 改为匹配**完整 tag** 后才咬住。

## 端到端实测(不只看测试绿)

- 真实 SSE 流收到 stage×15 / content_delta×111 / memory_access×3 /
  agent_llm_chain / tool_call×2,**agent_error 0 次**(幽灵已清)
- kill 一个 plugin.bin → SSE 收到 `event: system`
  `{"event":"plugin_crashed","plugin":"a2a"}`,日志确认「自动重启 a2a」
- WebUI 真弹出 toast「插件 acp 异常退出,内核正在重启它」(MutationObserver 捕获)
2026-10-13 16:20:28 +08:00
c7da8fde82 perf(webui): 星图力导向布局改网格近邻 + Web Worker,消除主线程 10.9s 阻塞
生产库 3754 块时,buildChatStarmapGraph 在内联脚本里同步跑 50 轮全对全
(O(n²))斥力:每轮 704 万次配对,50 轮共 3.52 亿次。CDP Profiler 实测
单次阻塞主线程 10.9 秒,页面启动首帧 11.8 秒;PerformanceObserver 在
40 秒窗口统计到累计 40,005ms 长任务。

两条独立问题叠加,本提交分别解决:

① O(n²) → 网格近邻(O(n·k))
   斥力体里本来就有 `if (d < 25)` —— 只对近邻施力,全对全是白算。
   改为 3×3×3 网格分桶,只遍历邻近格。实测 3754 节点 7016ms → ~120ms。

② 同线程 → Web Worker
   即使算量合理也不该占主线程。计算整体移入
   static/starmap-layout-worker.js,主线程零阻塞。

配套:迭代数按 n 自适应(小图跑满 50 轮,大图退到 14 轮)+ 迭代间早停
(阈值取平均间距的 0.2%,随规模自适应)。

★ 串行回退(file://、老浏览器、Worker 构造失败)必须用**同一算法**。
   若回退走旧全对全,两条路径会给出不同布局 —— 「有 Worker 和没 Worker
   看到的图不一样」极难排查。故两边各写一份逐字对应的实现。

★ 另修:renderChatStarmap 必须在容器不可见时提前返回(offsetParent)。
   starmapActiveContainer 会 fallback 到聊天面板,导致未打开星图也被迫
   建图,把聊天页签一起拖死。

判据 TestStarmapNoPerFrameFullScan 同时覆盖两处实现,经三向变异验证:
  ① 回退改回 Object.keys 全对全 → FAIL
  ② Worker 去掉 CUTOFF 网格法 → FAIL
  ③ 删除 Worker 文件 → FAIL
2026-10-13 10:12:57 +08:00
9f7d91c219 refactor(memory)!: 删除 CAS 包与第二套召回(块 union 化第四阶段:收尾)
块 union 化前三阶段已让块自带负载、回填存量字节。本提交拆掉 CAS 本身:
`internal/memory/media/`(2100 行)+ `QueryMediaScored`(第二套召回)
+ `media_refs`/`ref_count` 引用账本 + `internal/sdk/media.go`(MediaAPI)
+ `SDKConfig.Media` / `SetMediaStore` 接线。删完 CAS 才真正不存在。

## 为何可删(不是"先删了再说")

- **MediaAPI 在生产早已是死的**:`SDKConfig.Media` 自 86572d13 起从未被设置,
  `sdk.Media()` 恒为 nil,插件 digest 路径一直走"内容寻址库不可用"分支。
  删的是死代码,不是活路径。
- **第二套召回确无调用方**:`QueryMediaScored` 仅被 media 包自身的测试引用,
  跨包引用为 0(块召回走 `RecallBlocks`)。
- **heldMediaDigests 补丁**(crossmodal 靠它防召回"无处可归的媒体")
  已在 86572d13 删除 —— 两套存储才需要的那种补丁,随存储一起没了。

## 替代路径:块引用取代 digest

插件取媒体从 `sdk.Media().Get(digest)` 改为 `sdk.Memory().MediaBytes(ref)`:

- **新增 `MemoryAPI.MediaBytes(ref) (data, mime, ok, err)`**(内置接口,非 pubsdk):
  按块引用(完整块 ID 或唯一前缀)取字节;`ok=false` 而非 error ——
  "这个引用没有媒体"是调用方能处理的常态。
- **seemedia / multimodal**:`digest` 参数语义改为**媒体块引用**,工具描述同步。
  path/url 两条来源不变(生产实际只用这两条)。
- **webui 知识上传**:媒体字节不再 Put 进 CAS 再拿 digest,而是作为
  `KnowledgeMediaRef.Data` 随引用携带,由 knowledge.Write 落进条目目录
  (与 content.md 同生共灭,Remove 的 RemoveAll 天然清掉)。
- **回填命令去依赖**:cmd/homed-media-backfill 不再 import 待删的 media 包,
  改为直接读**冻结的历史布局** blobs/<d[:2]>/<d[2:]> 并自校验 sha256。
  依赖待删代码的迁移工具会在删包那刻失效 —— 迁移必须比被迁移的东西活得久。

## 判据(全部做过变异测试)

- `TestMemoryBytes_ByBlockRef`:完整 ID / 唯一前缀可取回且逐字节一致;
  不存在→ok=false;过短前缀(歧义)→ok=false;无负载块→ok=false;空引用不 panic。
  变异(不校验 ok,把不存在当有)当场被 3 条断言抓住。
- `TestE2EMediaBytesAgainstBackfilledDB`(-tags e2e,读真回填库):
  10/10 块经 MediaBytes 取回的字节与块 data 的 sha256 完全一致。
- `TestKnowledgeUploadedMediaRetrievable` 重写为**条目目录自带字节**判据
  (不再是"向存储按 digest 读回");`TestKnowledgeUploadMediaNeedsNoStore`
  改为"上传无外部存储依赖也必须成功"(旧的 503 语义已随 CAS 消失)。

## 静态确认
`QueryMediaScored` / `internal/memory/media` import / `MediaAPI` /
`sdk.Media()` / `SetMediaStore` / `heldMediaDigests` 全仓均为 0 处
(仅剩 medialoop.go 一句历史注释)。

回归:memory / sdk / agent-core / knowledge / plugin / webui / seemedia /
multimodal 八包全绿;全仓生产编译 GREEN。
2026-10-12 16:52:43 +08:00
33e1a0bfaf test(memory)!: 块 union 化判据重写(CAS 淘汰第二阶段:测试)
生产已在上一提交(86572d13)全绿;本提交把 CAS 专属测试改到块语义,
并修掉几处判据自身的缺陷(判据错 ≠ 生产错):

- sdk/memory_impl_test: media.Store→块;NewGraphMemoryWithPlugin
- core/{graphmedia,mediaref,medialive,medialoop,inputunify}_test:
  mediaStore→memory;PayloadDigest→Data/BlockData;shortDigest→shortID
- graphmedia: 无引用句子用独立块 ID(shortID 20 字符会与 blk_src_ 撞长度)
- memory/sentence_block: BlockByIDPrefix 加 len<12 下限(短引用守卫)
- core/medialoop: reembedOneBlock 按 BlockData 兜底取字节(lean 列无 data)
- knowledge: broken.png 改目录造读失败(chmod 0000 对 root 无效);
  dense/attach 测试补 Data 并先落盘后 AttachMedia
- webui: graph_pulse fixture payload_digest→has_data

注:internal/plugin/proc 的 shmVersion 不匹配(内核1/插件2)是
SDK 仓(777c3a0,v2 模板)与本分支内核(v1)的跨仓版本错位,
与块 union 无关(stash 全部改动后仍复现),属 proc-extra 特性线。
2026-10-12 14:26:13 +08:00
86572d134d refactor(memory)!: 块 union 化,彻底淘汰 CAS 第一阶段(生产代码)
块自带负载(MemoryBlock.Data),删 PayloadDigest;检索纯向量,媒体随块
同生灭。生产代码全绿;CAS 专属测试将在下一提交随判据重写。

- memory: Data BLOB + migration9;blockColumns 不含 data(召回只比向量);
  MediaBlockID 内容派生 ID;BlockByIDPrefix/BlockData 按需加载
- sdk: memory_impl 去 media.Store;MemoryBlockView PayloadDigest→HasData
- core: mediaref/graphmedia/crossmodal/medialoop 全部改块语义;
  captureBlockMedia 直接产块;resolveMediaRefs 按块 ID 解析
- knowledge: 媒体字节随条目目录自带(File+Data),删 mediaGet/mediaPut
- webui: graphBlockView/pulse payload_digest→has_data
- bootstrap/registry: 去 MediaStore 接线(SetMediaStore 空实现,待⑥删包)
2026-10-12 10:13:36 +08:00
b15994d964 fix(seemedia): hear_audio 不再承诺「你会听到」,如实说明可能不支持 (#7)
## 问题(e2e 抓出,非猜测)

端到端跑 hear_audio 时模型答「老实说,这次我没法真的听到」,转而去数 WAV
字节。查日志:

    tool hear_audio result: {"note":"音频已投给你——请在下一步直接聆听..."}
    modal fallback: 0 block(s) transcribed, 0 skipped
                    (provider=llmsproxy vision=true audio=false)

真因是**配置缺口 + 描述说谎**叠加:
- 当前部署所有 LLM 源都是 audio=false(visionllm/llmsproxy 均如此)
- core.input_processing.audio.fallback_provider 为空 ⇒ 无回退源
- 于是内核既不能直视、也无法转写,只发一条「拿不到音频」的 notice

而工具描述写死「你会直接听到它」、note 写「请在下一步直接聆听」——
模型同时看到「你会听到」与「拿不到」,两句自相矛盾(这就是它转去数
WAV 字节的原因)。

## 为何不改代码改行为

插件在调用时**无法**得知当前模型是否支持音频,SDK 未暴露能力查询接口
(只有 LLMAPI 的 Chat/ReloadFromConfig)。所以插件没有能力「先判断再决定
要不要注块」。正确做法是:内核继续做它该做的诚实降级(已在 modalfallback
里实现),插件这边**不许替内核承诺**。

## 改了什么

- hear_audio 描述:从无条件承诺改为条件式(「若当前模型支持音频或配了回退源,
  你会在下一步听到」),并明说:若都不支持,内核会明确告知拿不到(不会假装
  听到),那时改用 transcribe_audio 或配置回退源
- handler 的 note:从「请直接聆听」改为「请确认是否真的收到;没收到就改用
  transcribe_audio」

## 判据(可杀变异,3/3 实测)

- TestHearAudioDoesNotPromiseAudibility:描述不得含无条件承诺;必须指明
  transcribe_audio 出路;必须如实说「不支持」
- TestHearAudioNoteIsHonest:note 不得说「直接聆听」;必须给替代方案

★ 判据自身修了一个 bug(变异测试抓出):原先用「下一个 RegisterTool」做段
终点,但 hear_audio 是**最后一个**注册 ⇒ 一路截到文件尾,把 handler 的 note
也吞进来,于是 note 里残留的 transcribe_audio 让「删掉描述出路」的变异逃逸。
改用 "p.handleHearAudio)" 作终点后该变异被杀。

变异实测:
  F 描述改回无条件承诺      → FAIL ✓
  G 描述删掉 transcribe_audio 出路 → FAIL ✓(修判据前曾逃逸)
  H note 改回「直接聆听」     → FAIL ✓
  I 描述删掉「不支持」说明    → FAIL ✓

18/18 通过;全量 homed 构建通过;二进制内已无旧假承诺、含新的诚实措辞。

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-11 13:22:15 +08:00
16309c3307 feat(seemedia): 多模态直视插件——让模型一手看图/看视频/听音频 (#6)
* feat(seemedia): 新增多模态直视插件,让模型一手看图/看视频/听音频

## 动机

现有 multimodal(describe_image / transcribe_audio / ocr_image)走的是**二手**路:
插件自己起一次 VLM 子调用,把结果**文字**喂回主模型。省 token、能入记忆正文、
主模型非视觉也能用,但模型看到的是「别人转述的描述」,细节丢失不可逆。

缺的是**一手**路:让模型主动把原始像素/帧/音频投给**自己**去判读。本插件补上。

## 工具(三模态,用户决策 image+video+audio)

- `see_image`:path/digest/url → image_url 块,经 SetToolBlocks 直投主模型
- `see_video`:ffmpeg 抽帧(默认 6 帧,上限 10,对齐内核 modalfallback 约束)
  → 多张 image_url 块一起投;探时长成功走均匀定位抽帧,失败退单遍 thumbnail
- `hear_audio`:path/digest/url → audio_url 块直投

三者都 `ParallelSafe:false`:SetToolBlocks 写的是「当前 call」的块,并发会串台。

## 与 multimodal 并存(用户决策 coexist)

职责分工,不互相取代:
- 要省 token / 要把结论写进记忆 / 主模型不支持该模态 → describe_image(二手文字)
- 要模型亲自判读细节(看清截图、逐帧看操作) → see_*(一手原始媒体)

## 媒体来源(用户决策 path+digest+url 全支持)

- path:设备/工具回传的落盘文件(local_screensee、browser_screenshot、mc 截图…)
- digest:用户上传已入 CAS 的媒体,sdk.Media().Get 取回,不在内核↔插件间传 base64
- url:http(s) 直链;图/音频原样透传(不下载、不入 CAS),视频交 ffmpeg 原生读 http

## 直接进记忆(记忆载体已块化,天然打通)

seemedia 经 SetToolBlocks 注入的块,内核 task.go captureBlockMedia 处**无条件**
落进 CAS 并转成一等记忆块挂到本轮 ContextEvent(「无论直视还是回退,媒体本体都该
进记忆」)。所以一手看到的图/帧**同时**进了记忆,之后能按多模态向量被
doc_query / memory_recall 召回。本插件不写任何入库代码,复用既有块化链路。

★ 边界(刻意取舍):captureBlockMedia 只对 data URL 入库,http(s) URL 被跳过
(不下载、不入记忆、不算向量,规避 SSRF/超时)。即 path/digest 来源可召回,
url 来源只是模型当场看到但不进记忆。要让 http 媒体长期可召回,先落盘再传 path。

## 形态:内置插件(用户决策 builtin)

与 multimodal 并排 internal/plugins/seemedia,经 all.go init() 注册。代价是延续
「内核进程内读任意本地文件」这个 a02d8b00 本想消除的张力——但 multimodal 已是
内置且同样读 path,保持一致比单独走子进程沙箱更不易让人困惑;日后收紧应两者同搬。

## 判据(可杀变异,实测)

plugin_test.go:
- url 透传且拒 file://(去掉 isHTTPURL 校验 → FAIL)
- path 优先于 digest、url 优先于 path,且 path 命中时不碰 CAS
- 无来源报错且信息可操作(指明 path/digest/url)
- 文件错误带路径、空文件明说「空」
- clampFrames 把帧数夹到 [1,10]、非正回默认(超限不夹 → FAIL)
- 三工具不得声明 ParallelSafe:true(防 SetToolBlocks 串台)

9/9 单测通过;本机 ffmpeg/ffprobe 已就位;全插件 + homed(-tags onnxruntime)
构建通过;internal/plugins 装配冒烟通过。

* test(seemedia): 补集成/压力/变异测试(16 判据,5 变异全杀)

上一个提交只带 9 个纯逻辑单测(来源解析、clamp、MIME)。抽帧是本插件唯一
真正驱动外部进程、且会并发的部分,必须真跑 ffmpeg 压一压:

- 真视频 → 请求 N 帧就拿到 N 帧,每帧校验 PNG 魔数(1/3/6/10 四档)
- ffprobe 置空 → 走单遍兜底仍能拿到帧
- 损坏/非视频文件 → 如实报错,不 panic、不返半成品
- 已取消的 ctx → ffmpeg 必须立刻失败(取消传播真生效)
- 并发 16 路抽帧 → 不串台、不泄漏临时目录(前后计数比对)
- digest 视频 → 真 CAS 取字节落临时文件,cleanup 后必须删掉
- 静态判据:所有子进程必须 exec.CommandContext,不得裸 exec.Command

为什么超时判据从「计时」改成「已取消 ctx」:合成视频抽帧本来就快(~0.1s),
「1ns 超时」和「忽略超时」在计时上分不出——实测这种变异杀不掉,是判据设计
缺陷。改用已取消 ctx 的行为判据(CommandContext 在 ctx 已取消时 Start 即失败)
后,忽略 ctx 的变异被杀。

变异实测(5/5 全 FAIL,还原后全绿):
  A clampFrames 超限不夹        → TestClampFrames FAIL
  B url 分支去掉 isHTTPURL      → TestResolveMediaURL_URLPassthrough FAIL
  C cleanup 不删临时文件         → TestResolveVideoInput_DigestCleanup FAIL
  D grabFrameAt 忽略 ctx         → TestFrameGrabHonorsCanceledContext FAIL
  E 抽帧数写死 1                 → TestExtractFrames_RealVideoExactCount FAIL

16/16 通过;`-tags onnxruntime` 全量 homed 构建通过,三工具在册。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-11 13:06:23 +08:00
8336509bfa perf(webui): 修星图形变活跃集的泄漏 + 加回归判据
上一提交(3e201dd4)把 starmapTickMorph 从「每帧全量遍历」改成
starmapMorphSet 活跃集,但我漏了一处:buildChatStarmapGraph 重建图时
只重置了 starmapNodeMeshes / starmapEdgeLines,**没清空 starmapMorphSet**。

后果:旧 mesh 已从场景摘掉,却留在活跃集里,于是
- 集合随每次重建单调增长、永不归零 ⇒「空集时 O(1) 返回」的快速路径
  从此失效,正是这次优化想省掉的那笔开销又回来了
- 每帧遍历一批已经不在场景里的废弃 mesh

同时补 starmap_perf_test.go。为何用源码断言而非运行时测量:
dashboard.js 是嵌入的浏览器脚本(依赖 three.js / DOM),Node 跑不了;
而这几条都是**结构性**缺陷(循环扫全表、循环不停、每帧重建),
源码里能可靠判定,回归时立刻红。

判据可杀变异(逐个实测过,不是装饰):
- 节流常量改名 → FAIL「缺少节流」
- morph 早退条件删掉 → FAIL「必须提前返回」
- clear() 改成 if (false) 或整行删除 → FAIL「必须无条件清空」

第三处特意断言在**重建函数体内**且是 `(?m)^\s*if \(starmapMorphSet\) ...`
(先写成全仓搜 `starmapMorphSet.clear()`,变异测试显示 `if (false)` 能骗过
它 —— 弱判据当场修强)。
2026-10-08 12:40:43 +08:00
3e201dd4af perf(webui): pulse 端点不再全量扫描图库;修星图每帧全量开销
用户反馈「非常卡」。实测定位到两处(都在轮询/渲染的每帧路径上):

## 1. 后端:/memory/graph/pulse 名为轻量、实为全量

该端点自称轻量(注释写着「只回窗口内变动的实体,与完整图谱差两个数量级」),
但实现是**先调 GraphData() 取全量快照 → json.Marshal 约 3.3MB →
反序列化 → 才按时间过滤**。生产实测与全量端点**同价**:

    /memory/graph          135-147ms
    /memory/graph/pulse    144-157ms   ← 每 10s 被轮询一次

即每 10 秒白付一次全量序列化。生产库 3190 块 / 2692 边。

改法:新增 `GraphDB.BlocksChangedSince(since, limit)` —— 库侧 SQL 过滤
(`WHERE created_at >= ? OR updated_at >= ?` + LIMIT),响应体只含窗口内
的块。limit 上限保护:窗口内可能有很多块(批量迁移),而星图只需知道
「有新东西」。

接口走内置 `sdk.MemoryAPI`(非 pubsdk),与既有 GraphData 同理,
**不影响外部插件**。

## 2. 前端:星图渲染循环每帧全量遍历 + 永不停止

三处,都是我上一批星图改动引入的:

- **RAF 循环从不停**:切到聊天/终端页签、甚至页面不可见时,仍以 60fps
  跑着每帧 3190 节点的遍历。改用显式开关 + `starmapWake()` 重新点火
  (判据问「星图容器是否可见」,不用 document.hidden —— 星图可能在
  未激活的页签里,那时页面可见但星图看不到)。
- **starmapTickMorph 每帧扫全部节点**:3190 × 60fps = 每秒 19 万次
  userData 读取与分支,而绝大多数帧里「正在形变」的节点是 0 个。
  改用 `starmapMorphSet` 活跃集合,空集时 O(1) 返回。
- **标签层每帧重建 plan + measureText**:加 100ms 节流(文字层 10fps
  肉眼无差别,3D 图形仍 60fps)与 measureText 缓存(同步文字布局很贵,
  同文字同字体宽度不变)+ 缓存上限保护。

## 验证

- `blocks_changed_test.go`(新增):窗口语义、空窗口恒 0(否则星图会每次
  轮询重放生长动画)、limit 真的封顶
- 既有 pulse 判据全过(`OnlyRecent` / `SinceWidens` / `NoVector` /
  `IgnoresFrozenLegacyNodes`)—— 改写没破坏语义。
  测试替身 `fakeGraphMemory` 同步实现新方法,且**忠实反映过滤语义**
  (偷懒返回全部会把「窗口过滤」判据架空)
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
2026-10-08 12:35:28 +08:00
69c9a9613f feat(webui): 星图切到块体系数据源 + 全部标名/灰调/读写反馈/三种变化动画
## 根因:记忆系统已迁移,前端没跟上

`GraphData()` 同时返回两套数据,而前端**只读旧的**:

    nodes/edges                ← 旧表 entities/relations(2026-10-04 停写)
    memory_blocks/block_edges  ← 块/边体系(真实活跃的记忆)

生产实测:`entities` 停更在 2026-10-03(1294 条),而 `memory_blocks`
同期从 1294 涨到 3187。写入函数 `upsertEntity` 已成**全仓零调用的孤儿**。
⇒ 星图结构性看不到任何新记忆。

(另修的同类问题:`starmapDirty` 只写不读且未声明,赋值成隐式全局 ⇒
即使后端发事件,新块也永远不会出现在图上。见下「判据」。)

## 数据源切换

新增 `smAdaptGraph()` 把块/边归一化为星图节点/边(字段与旧格式一致,
下游建图/布局/图例/脉冲无需各自判源):
- `mention_count` 回填为**度数**(块表无此列,而它驱动节点大小/标签优先级)
- 只画 `status` 为 active/空 的边(deleted/merged 的边留着会让失效关系看着还在)
- 端点缺失的边丢弃(画不出,且会让度数统计偏大)

## 视觉(按「全部标名 + 灰调 + 悬浮提亮 + 查询提亮 + 三种变化动画」)

**全部节点名**:新增标签覆盖层 —— **一个** 2D canvas 画全部名字,而非
每节点一张 CanvasTexture(原实现 1151 张 ≈ 72MB 显存,也是它当初被删的原因)。
三级可读性:活动/悬浮=实色+底块,其余=低透明当底纹;**同屏文本去重**
(3178 块里大量同文本块,不去重会叠成十几层同样的字)。
仅在相机或脉冲集合变化时重绘。

**灰色调**:`smGreyFor()` 全图灰阶,**亮度按度数**(亮处即记忆稠密处)。
原按类型上色对真实数据只用得上一种颜色(99% 同一类型)。

**悬浮提亮**:发光 +1.4 + 放大 1.25×(只放大不亮在淡底上不明显,只亮不放大
又看不出是哪颗)。

**查询提亮**:`memory_access` → `starmapPulseByIDs()`,按**真实块 ID** 精确命中。

**三种变化动画**(三种是**排他状态机**,不与可叠加的活动 pulse 混用):

| 动画 | 曲线 | 时长 |
|---|---|---|
| 插入生长 | easeOutBack 过冲(从 0.05 起,从 0 起看不出「长」) | 1800ms |
| 删除消散 | 收缩 + 变暗 + 球面均匀粒子飞散(减速淡出) | 1200ms |
| 合入 | 源消散 → **停顿 120ms** → 目标重生 | 1200+120+900ms |

合入刻意分两段且中间停顿:同时放会读成「一个变小一个变大」,
先看不到源、再看到目标冒出,「合入」的因果才成立。
粒子用**单个** Points 承载(上限 600),而非每粒子一个 mesh ——
批量删除(Purge 上百块)时后者会瞬间建上千对象。
消散完成必须真 `scene.remove()`,否则 scale=0 的不可见 mesh 仍可被鼠标拾中。

**顺带**:
- 补选中详情面板(`starmapSelected` 此前**只赋值、无人读取**,点击节点
  没有任何信息反馈 —— 「可读性差」的核心成因之一)
- 布局半径按 √n 自适应 + 相机初始距离自适应(3178 节点挤在旧的固定
  15–80 环带里会糊成一片)
- 图例改为说明**视觉编码**(大小/亮度/活动/三种动画),不再列不存在的类型色
- `starmapDirty` 消费点加 30s 节流(`/memory/graph` 实测 3.3MB,
  密集写入时每 10s 重拉是纯浪费)

## 判据

- `graph_source_test.go`:钉住后端**必须下发 memory_blocks**
  (前端星图的真实数据源),防未来又切回停写的 nodes
- `TestDashboardJS_NoWriteOnlyGlobals`(新增静态判据):**只写不读的全局布尔标志**
  ⇒ 这类「置了标志无人消费」是静默失效,真跑 node 也抓不到(隐式全局赋值合法)。
  该判据写出来就当场抓到 `starmapDirty`,并经变异验证有效
  (删掉消费点 → 判据点名报错)
- `node --check` + webui 全部判据绿
2026-10-08 10:41:22 +08:00
6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00
b27e6142e2 fix(remotedevice): 补 screensue/camerasue/speakeruse 专用工具,修「agent 被迫手拼命令字符串」
## 问题(生产日志实证,10-06 12:46~13:04)

三个能力只能走 device_ctl_cmdrun 的 **command 字符串**,agent 于是手拼:

    homeagent-screensue 0 <!DOCTYPE html><html><head><meta charset="utf-8">…

结果是工具调用**连执行都没执行**:

    13:00:38  stream tool_call device_ctl_cmdrun argument fragments invalid JSON:
              "{\"device_id\": \"gui-JianF\", \"command\": \"homeagent-screensue 0
               <!DOCTYPE html>…<meta charset=\\\"utf-8\\\">…
    13:00:38  args unparseable (982 bytes) — surfacing to model instead of calling

三个原因叠加:
  ① 引号要穿过两层 JSON 转义(HTML 必然含引号)
  ② 时长与内容共用一个字符串靠空格切分(内容里的空格有歧义)
  ③ 长度无上限(一屏看板几十 KB)

agent 的应对是反复试参数 —— 它在猜格式,不是在做事:

    screensue shown on display 0 for 15s / 25s / 30s / 40s / 60s / 180s

## 改动

新增三个结构化工具(与 screensee 同一层):

| 工具 | 参数 |
|---|---|
| `screensue` | device_id, content 或 content_path, duration_seconds |
| `camerasue` | device_id, duration_seconds(省略/0=拍照,N=录像 N 秒) |
| `speakeruse` | device_id, text 或 text_path |

- 命令字符串由**服务端**拼装,形状与 GUI `executeHomeagentCmd` 的解析一致
- 长内容走 `*_path`(服务端读文件,上限 2MB,与 clipboardsue 对齐)
- `duration_seconds=0` **显式下发 0**(常驻),与「省略=默认 5 秒」语义区分开
- device_ctl_cmdrun 描述改向:告明它只管 shell,屏幕/摄像头/语音请用专用工具;
  homeagent-* 旧前缀仍兼容(不破坏既有调用)

## 顺带修掉一个「只提交判据没提交实现」的缺口

clipboardsue 的实现读 `args["content"]`,但**工具 schema 里只有 text** ——
参数存在却不暴露,等于没有。已补进 schema。

## 判据

capability_tools_test.go,11 条。三个变异自证会红:

- 变异 A:把 content 与时长拼回一个字符串 → 红
  (`got="screensue 三点开会"` / 含引号内容被下划线替换)
- 变异 B:duration=0 时省略 → 红(与「默认5秒」混淆)
- 变异 C:超时错误退化成一句「失败」→ 红(丢掉 req_id 与复查提示)

★ 过程中两次遇到「无输出」,都不是被杀死而是**编译失败**
(未使用的 prefix / 类型不符)。本项目已记过这个坑:
编译失败 ≠ 被杀。加 `_ = prefix` 后确认是真的被杀。

另:一条判据自己假红了一次 —— `res["size"]` 经 JSON 回来是 float64
而非 int,断言写错。判据的红也要先分清「实现错了」与「断言错了」。

## 验证

- go build / go vet / go test ./internal/... 全绿
- go test -race remotedevice 无数据竞争
- 部署后 39 插件加载、0 panic/fatal、DB integrity ok
- 快照 /home/newqqagent/memory/pre-capability-tools-20261006-211446/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-06 21:17:03 +08:00
1ff87d5122 merge: 整合 origin/feature/gui-starmap-pulse-vendor-align(GUI 星图/白屏/设备桥 keepalive 8 提交)
来自远端 GUI 线(JianFeeeee,10-05~10-06):
- 设备桥 gateway 自动发现回写主进程
- 设备桥 WebSocket keepalive(修「设备可见但命令下发不到」)
- 设备桥心跳与断线重连(兑现 liveness 门禁)
- device/push 校验 payload,杜绝「假成功」
- 修「长时间挂后台后打开白屏」:renderer/GPU 崩溃自愈
- 星图几何体共享 + 重建时 dispose(修聊天页长时间挂起后卡死)
- 补星图面板缺失的 </div>(修对话页 5 个页签空白)

与本分支三条本地提交(多模态插件化 / screensee 落盘 / scheduler 判据)
在 internal/plugins/remotedevice/plugin.go 上同文件不同函数段,无冲突。
2026-10-06 14:37:54 +08:00