Commit Graph

369 Commits

Author SHA1 Message Date
895948b24e fix(stop): 配额须计入停在输入 channel 的待处理消息
实测:停止后排队消息仍逐条跑完。原因是 armStop 只数 sched.queue,
而用户按下停止时调度器正忙于当前任务,其余消息大多还没被 pumpInbox
搬进队列、仍停在 inputCh ⇒ queued=0、配额归零。

- IOManager.PendingInputs():暴露 channel 中待处理条数。
- armStop(pending int):queued = len(s.queue) + pending。
- 补 TestStop_ArmCountsPendingChannelInputs 锁死该口径。
2026-09-18 11:39:04 +08:00
698ff7ddd5 fix(stop): 修自伤——interceptLoop 里 takeStop() 被 || 短路提前消费
上一版把 stop 标记在 interceptLoop 里消费掉了一部分:
`if n := armStop(); n > 0 || takeStop() { ... }`,queued=0 时短路到
takeStop(),标记先被吃掉,stepLLM 永远看不到 → 取消后照样重跑一轮。
实测:日志正确打出 stop requested ... queued=0,但生成仍跑到自然结束(5000+ 字全文落库)。

改为只 arm 不 take(takeStop 只由 stepLLM 消费),并补一条**经真实
interceptLoop** 的用例:它必须 a.Start()(第一版测试只调 New(),
interceptLoop 根本没跑,假绿)。已验证该用例在注入此 bug 时失败、修复后通过。
2026-09-18 11:32:20 +08:00
ccc2ac2d4d fix(stop): 停止按钮真正生效——停止 ≠ 空中断;鸿蒙 screensue 支持 HTML
两处鸿蒙端缺陷 + 一个跨端(WebUI/GUI/鸿蒙)的停止语义缺陷。

## 症状(实测取证)

1. **鸿蒙终止按钮按下没反应**。POST /chat/interrupt 带空 body,接口回 200
   `{"status":"interrupted"}`,但 journalctl 零中断日志、生成继续跑到自然结束。
2. **鸿蒙 screensue 不解析 HTML**,把标签当普通字符串显示。

## 根因

停止按钮走的是「空内容中断」,而 interceptLoop 有一行
`if text == "" { continue }` —— 空内容被判为「无事发生」直接丢弃。
所以停止指令从未到达调度器;接口那个 200 是不诚实的。

另查明两条会放大症状的既有问题(停止后仍在跑):
- `chatStreamWithFallback`:流式连接失败时无条件回退非流式 `Chat`。
  上下文已取消时这等于**再发一次完整请求**(停止后模型继续生成)。
- `stepLLM`:`context.Canceled` 一律 `outcomeContinue` 重跑本步。
  这是给「被更高中断抢占」用的(现场要交出去、稍后继续),
  但用户按停止是「不要了」,重跑就是停止没生效。

## 修法(按用户明确的设计)

停止 = ①立即结束当前 LLM 推理(不重试、不恢复);
②对**停止那一刻已排队**的 x 条消息,后续在 pre-action 阶段依次短路。

- scheduler:新增 `armStop`(登记快照配额并返回当时排队深度)/`takeStop`/
  `consumeCancel`。配额取快照值(停止后新到的输入不受影响),
  重复按停止取 max 不累加(两个客户端同时按不该翻倍)。
- `interceptLoop`:读 `stop` 标记。停止时 armStop + cancelCurrentLLM;
  **纯停止不再进中断队列**(旧实现把它当空中断入队,所以停完还会活)。
  带注释的停止(`/stop 换个话题`)仍走中断路径。
- `stepLLM`:取消 + `takeStop()` → 直接 `outcomeDone`(不再重跑)。
- `stepPrepare`:`consumeCancel()` 命中即在 pre-action 短路收尾。
- `chatStreamWithFallback`:以 **ctx.Err()** 为判据拒绝回退(不是「错误是不是
  Canceled」——很多 provider 用 Canceled 表示「不支持流式」,那种必须继续回退,
  否则会把探测误判成取消;这条区分是跑全量测试时才暴露的)。
- WebUI handler / CLI `/stop`:空消息时带 `stop:true`。

## 鸿蒙端

- `BridgeCaps.ets`:新增 `looksLikeHtml`(首字符 '<' + 字母开头标签名,
  避免误判 "<3" 这类文本)、`screensueHtml`、`escapeHtmlText`。
- `ScreensuePage.ets`:HTML 走 **RichText**(只解析 HTML 子集、无脚本无网络),
  纯文本仍走 Text。不用 Web 组件:agent 下发的是第三方内容,
  Web 默认带 javaScriptAccess/fileAccess,等于让远端内容在客户端执行脚本。
  注入主题前景色,避免 RichText 用系统默认色导致深色主题下黑字不可见。
- `ChatSession.ets`:`interruptChat` 改发 `{stop:true}`(含类型声明,
  ArkTS 禁止无类型对象字面量),并在本地即时复位忙态 + 提示「已停止」。

## 验证

- 新增 `stop_semantics_test.go`:停止终结任务不重试(provider 调用次数恒为 1)、
  配额是快照(x 条短路、随后新到的不受影响)、重复 arm 取 max。
- `go test ./internal/... ./cmd/...` 全绿。
- 鸿蒙 HAP 构建通过;unsigned 包已装进模拟器(signed 包受
  READ_PASTEBOARD 授权限制装不上,与既有记录一致)。
2026-09-18 11:26:12 +08:00
1f5af1dccf feat(cli,ohos): 补齐两处能力缺口——CLI /memory context|tools、鸿蒙 camerasue 录像回传
核对「CLI 与 WebUI 插件能力对齐」时发现两个此前遗漏的缺口,一并补齐。

1) CLI /memory 缺 context 与 tools(internal/plugins/cli/plugin.go)
   WebUI 有 GET /api/v1/memory/context 与 /api/v1/memory/tools,CLI 只有
   query/graph/text。IndexerAPI 本就对内部插件开放(BuildContext/
   FormatContext/GetToolDefinitions/BuildToolPrompt),不是 SDK 缺口。
   补上后与 WebUI 同源:context 打印「实际会注入什么上下文」,
   tools 打印工具定义 + 工具提示词。
   waiter 同步接上两条远端路由与 help 文案。

2) 鸿蒙 camerasue 录像(BridgeCaps/BridgeRouter/DeviceBridge.ets)
   此前 `camerasue <N秒>` 直接返回「暂不支持录像回传」。查 SDK 后发现
   cameraPicker 本身就有 PickerMediaType.VIDEO 与 PickerProfile.videoDuration
   —— 录像完全可行,只是回传通道没接。
   现改为:VIDEO 模式取回 mp4,经 DeviceBridge.sendDataChunked 按
   cmd_data_start/分块/cmd_data_end 回传(与 GUI/CLI 录像路径一致),
   网关聚合后落盘成文件、agent 拿路径;照片仍走小体积 base64 内联。
   上限 64MB、时长 1~300s,超限明确报错而不是把 WS/上下文撑爆。

   依赖方向处理:BridgeCaps 需要「往本请求回传字节」,但 DeviceBridge 为取
   CapResult 已 import BridgeCaps,反向 import 会成环。改为 BridgeRouter
   注入 DataChunkSender 回调(它同时持有 deviceBridge 与 reqId),
   BridgeCaps 不碰 socket。新增 CapResult.chunked 标记「结果已由能力分块
   发完」,DeviceBridge 据此不再回 cmd_result,避免网关把已完成请求与后续
   分块错配。

验证:hvigor assembleHap BUILD SUCCESSFUL(ArkTS 编译通过,改动文件零告警);
make check-client-versions 一致;go vet 干净;全量 go test ./internal/... ./cmd/... 零失败。
2026-09-18 10:04:53 +08:00
9de3b365a6 fix(agentcli): 修 4 个真实缺陷——停机死锁、超时泄漏、僵尸堆积、孙进程逃逸
jianf 提示 agentcli 可能有问题,系统性审了一遍(含 -race 与线上实证),
确认并修复 4 个互相叠加的真实缺陷,每个都配了「去掉修复即失败」的回归测试。

1) 停机/热重载死锁(plugin_stop_test.go)
   Stop() 先 p.wg.Wait() 再 Close 终端,而 readLoop 自己也记在 p.wg 上、
   只监 t.stopCh 不监 p.stopCh。只要有一个终端开着,wg.Wait() 就永不返回。
   后果:插件卸载/热重载(StopAndUnload/ReloadOne)与停机全挂死,且
   registry 持锁时是整个内核一起挂。
   修:先关活跃终端(move 出 map 后在锁外 Close),再 wg.Wait();
   readLoop 顶部加 p.stopCh 探测;Stop() 用 sync.Once 保证幂等。

2) 终端超时后资源全泄漏(plugin_lifecycle_test.go)
   readLoop 的 IsExpired 分支只 delete(sessions) 后 return,既不 Kill 也不
   Close。终端已被移出 sessions,cleanupLoop 也再看不到它,进程/PTY fd/
   reader 协程无人回收。实测:timeout=1s 的 sleep 300 超时后进程仍在跑。
   修:readLoop 加 defer releaseResources(),保证「只要退出就释放」。

3) 子进程从不回收 → <defunct> 僵尸堆积(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 只 Start 从不 Wait。线上实测 homed 名下已有一个
   [sh] <defunct> 僵尸子进程。
   修:linuxPty 加 Wait()(sync.Once 保证只 Wait 一次),
   releaseResources 通过可选接口 Wait() error 调用(Windows ConPTY 不实现则跳过)。

4) Kill 只杀直接子进程,孙进程逃逸(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 用 Setsid,sh 是新进程组领头,真正的命令(sleep/vim)是
   其孙进程且同组。只 Kill(sh) 会留下孤儿继续跑。实测:`sleep 300; echo done`
   只杀 leader 后 sleep 仍在(被 init 收养)。
   修:改为 syscall.Kill(-pid, SIGKILL) 杀整个进程组,失败再回落单进程 Kill。

测试设计要点:回归用例必须让「sh 保留为父进程 + 孙进程显式 trap "" HUP」,
否则单个 sleep 会被 sh exec 掉、关 PTY 的 SIGHUP 又会顺手带走孙进程,
两个缺陷都测不出来(这两种情况都实际踩过并修正了用例)。

全量 go test ./internal/... ./cmd/... 通过,agentcli 单包 -race 通过。
2026-09-17 20:24:03 +08:00
ec13eb391a fix(agentcli): 终端退出前补推残留输出,短命令输出不再丢失
线上验证「内核开、两个插件接」时发现的真实缺陷:`echo`、`ls` 这类在首个
200ms ticker 之前就结束的短命令,readLoop 走到 `!terminalRunning(t)` 分支
直接 return,残留在 stream 里的输出从未 flush。

症状:输出只留在 session.buf 里——agent 用 terminal_read 能看到,但
terminal_output 事件永远发不出去,于是内核权威视图(以及 WebUI/CLI 的
/terminals)的 output 恒为空。实测 term_2(echo HELLO_KERNEL_REGISTRY)
在 /terminals 里 output="" 而 agent 同期 terminal_read 拿到了正文。

修法:
- 抽出 flushTermStream(s, t),ticker 与所有退出路径共用同一条推送路径
  (避免以后再出现「某条退出路径忘了 flush」)。
- readLoop 顶部加 defer:defer flushTermStream 后于 defer emitTermState
  声明 → LIFO 下先 flush 再报停止,保证「最后一段输出」先于 running=false
  到达订阅者。

测试:plugin_flush_test.go 新增 TestReadLoopFlushesOutputOnExit——走
EventBus 捕获事件,推入输出后立即让进程退出(远早于 ticker),断言输出
已补推且末态 running=false。已验证去掉修复即 FAIL、加回即 PASS。

注:handleRead(clear=true) 会主动 Reset stream(避免与读取结果重复),
属既有设计;本修复针对的是「未被读取就退出」的路径。
2026-09-17 20:00:47 +08:00
e70d2171ee refactor(terminal): 内核开终端/命令历史权威视图,WebUI 与 CLI 都改接内核
按「内核开,两个插件接」重构终端与命令历史的数据归属。

背景:此前 WebUI 与 CLI 各订 EventToolCall/EventTerminalOutput 攅一份状态,
同一件事两份推导,还各自踩过同一个坑——工具 result 是 Go 的 map 文本
(map[cols:80 ... id:term_2 ...]),断言成 map[string]interface{} 永远失败,
terminal_create 的 id 回填不生效,/terminals 因此恒空(WebUI 也一样)。
实测确认:WebUI 自己的 /api/v1/terminals 与 /api/v1/cmd/history 同样是空的。

内核开(权威唯一真相):
- internal/agent/core/terminal_registry.go:TerminalRegistry 归并两类事件——
  EventToolCall(terminal_create/close、cmd_run,id/command 从 args 或 Go map
  文本回填)与 EventTerminalOutput(agentcli 生命周期 + 输出,含 64KB 缓冲上限、
  100 条命令历史、50 个终端上限)。
- internal/sdk/terminal.go:新增 TerminalAPI(ListTerminals/CmdHistory)与
  TerminalStatus/CmdExecStatus DTO。**不塞进 KernelStatus**:那是全量快照,
  前端每 3 秒轮询 /kernel,背上每终端最多 64KB 输出会让轮询成本爆炸;
  终端输出是按需拉取的明细,另开接口。
- Agent 订阅自己的事件总线(subscribeTerminalRegistry),且**只根 agent 建**
  (驻留子共用同一总线,每个子都建会 N+1 份重复记账)。
- SDKConfig/Registry/bootstrap 接线:pluginReg.SetTerminalAPI(agent)。

生产者补全(agentcli):终端无输出时 ticker 不发事件,内核就无从知道终端
存在。新增 emitTermState,在 handleCreate/handleClose/readLoop 退出(超时/
进程结束/读取错误/stopCh)显式上报 running 状态,并给输出事件补 command 字段。
handleClose 改为接收 *sdk.PluginSDK 以便上报。

两个插件接(消费方):
- WebUI:删掉本地 termStates/cmdHistory/subscribeTerminalStream/handleToolEvent
  及不再使用的 getStr;/terminals 与 /cmd/history 直接读 s.Terminal()。
- CLI:删掉上一轮刚加的 subscribeToolEvents 与 cliTermState/cliCmdExec;
  /terminals 与 /cmd/history 直接读 s.Terminal()。两条路(local/remote)都通。

测试:新增 terminal_registry_test.go,锁死 Go map 文本解析(旧缺陷根因)、
生命周期、CLI 直调路径(无 EventToolCall 仅凭 output 事件建条目)、历史与
终端数量上限。全量 go test ./internal/... ./cmd/... 通过。
2026-09-17 18:56:15 +08:00
3cce605722 feat(cli): /terminals、/cmd/history、/terminal 对齐 WebUI(事件面 + ToolAPI,无需新接口)
去看了一遍源码,纠正上轮判断:终端与命令历史也不是 WebUI 插件私有。
- 终端会话:agentcli 插件持有,发 EventTerminalOutput;WebUI 只是订阅该事件
  自己攒视图。命令历史:WebUI 订阅 EventToolCall 的 cmd_run 攒的。
- 于是 CLI 插件订阅同样两个事件即可同口径:/terminals、/cmd/history。
- 开/写/读/关终端:SDK 的 ToolAPI.ExecuteTool 已允许跨插件调用工具,
  CLI 直接调 agentcli 的 terminal_create/write/read/close,新增 /terminal 子命令。

waiter 侧同步:/terminals、/cmd/history 两条路(local/remote)都接;/terminal
仅在 local 可用(远端 WebUI 无对应 REST 端点,明确提示而不是当聊天发出去)。
2026-09-15 15:31:32 +08:00
0227fc2d4d feat(cli): /persona 与 /agents 对齐 WebUI(复用已开放的 SDK 面)
- /persona:读写 core.agent.personal_prompt / core.internal.persona_initialized;
  插件 SDK 的 Settings() 满足 internal/config.PersonaKV,与 WebUI 同一实现。
  GET 等价返回 initialized/current_prompt/file_override;/persona set <mode> [内容] 写。
- /agents:改用 supervisor.ListAgents()(WebUI /agents 同源),此前只回一个
  agent_id、驻留子信息全丢。
- waiter 侧 /persona 在 local/remote 两条路都接上,/help 补齐。
2026-09-15 15:25:20 +08:00
91c25fa536 feat(cli): /runtime 接上(runtime 本就经 KernelStatus 对内部插件开放)
纠正上一轮的判断:runtime 不是“SDK 未暴露”。s.Status().GetKernelStatus()
里的 Scheduler / Residents / Channels / InputChannels 就是 WebUI /runtime
的数据源,内部插件同样拿得到——CLI 只是漏接了这条命令。

- CLI 插件新增 /runtime,输出与 GET /api/v1/runtime 同口径。
- waiter 侧 /runtime 在 local(发 CLI 插件)与 remote(走 REST)两条路都接上,/help 补齐。
2026-09-15 14:29:29 +08:00
bcaa8f3f31 feat(cli): /plugin install 真正可用(直连 pluginmgr 回环端点,与 WebUI 同实现)
原来 CLI 的 /plugin install 只打印“请去 WebUI”。插件安装逻辑在 pluginmgr
插件里(回环 HTTP,默认 127.0.0.1:9876,无鉴权),WebUI 也是转发到它;
CLI 插件改为直连同一端点,能力对齐。
2026-09-15 12:17:34 +08:00
5333a33e20 feat(cli): CLI 插件能力对齐 WebUI(memory/knowledge/config/tracker/adapters/network)
原来 CLI 插件只覆盖 WebUI 的一小部分:/memory 只有 query、/knowledge 只有
list、没有 config/tracker/adapters/network,结构化输出还各拼一套文本格式。

按 WebUI 的 REST 面对齐:
- /memory query|graph|text [n]     (对应 /memory、/memory/graph、/memory/text)
- /knowledge | delete <name> | stats(对应 GET/DELETE /knowledge)
- /config                          (对应 GET /config)
- /tracker | rollback              (对应 GET /tracker、POST /tracker/rollback)
- /adapters | remove <name>        (对应 GET/DELETE /adapters)
- /network                         (对应 GET /network)
- 统一 writeJSONContent:结构化数据一律缩进 JSON,与 WebUI 同口径。

waiter 侧同步:把上述命令在 local(发 CLI 插件)与 remote(走 REST)两条路
都接上,/help 补齐;两条路语义一致。
2026-09-15 12:14:25 +08:00
acc94723fd fix(memory): 打通场景/索引/召回残余矛盾点,清理死代码与冗余索引
- DecaySceneRefs 真半衰期:新增 scene_refs.decayed_at 作计时起点,
  每个引用至多每 halfLife 衰减一次。此前只按 created_at 判龄 + 每次
  心跳对半砍,30 天阈值配 60 分钟心跳会在几小时内清空老关联(不是半
  衰期是骤死);时间基准改走 SQLite datetime('now'),不再与 Go 本地
  时间混用。
- Indexer.syncIfStale 基线口径与 Sync 对齐(min(实体数, 全量召回上限)):
  实体数超过上限时原实现永远不相等,每 retrainInterval 全量重训一次。
- EnsureScene 去掉从不使用的 Situation 参数;修正 EnterSceneWithHint /
  resolveTurnScenes / 测试里「声明场景会学整轮指纹」的过时注释(实际
  刻意不学,否则会吃死被动路)。
- SituationFeature.Weight 补 peer_group → wFeatPeer:此前落到 default
  话题级 0.4,群聊身份被降级成软信号。
- RecallBySituation 注释改为与实现一致(相似度只决定命中哪些场景,
  不参与每条关系排序)。
- 移除只被测试使用的 EmergentScenes(SceneStats 已含 origin/strength/
  features,完全覆盖)。
- 清理与 UNIQUE 隐含索引重复的 idx_entity_name / idx_sentences_text。
- 新增 TestSyncIfStaleBaseline / TestPeerGroupWeight,衰减测试补「同一
  半衰期内不重复衰减」用例。

go build/vet 干净,internal/... 全绿。
2026-09-15 10:20:40 +08:00
49695c38f3 feat(memory): 场景双通道——主动声明与被动涌现并存,且互不吞噬
按「声明式的也要支持,相当于主动被动两条路」落实。此前两者只是恰好并存,
没有边界,实测会互相吃掉(下面的坑就是)。

- Triple.Scenes []string(多值):一轮写下的记忆**两条路都挂**。
  只挂一条会丢东西——只挂声明则细粒度唤起丢失,只挂涌现则首次交互
  (场景还没长出来)没有兜底。单值 Scene 保留兼容。
- TurnScene:Primary 用于写(优先涌现场景,首次退到声明场景兜底),
  Keys 是两条路的并集,用于召回(声明+涌动的场景一起进 RecallByScene)。
- EnterSceneWithHint:主动路 EnsureScene(声明即建场景,不等第二次),
  被动路 EnterScene(指纹聚类)。写侧由 executeToolCall 把本轮场景集合
  传给 memory_commit,模型不需要知道"场景"这回事。

踩到并修掉的坑(两条路互相吞噬):
  最初让声明场景也吸收**整轮指纹**,于是 chan:qq 的相似度永远是 1.0,
  把后续所有同类轮次全部吃掉 → 被动路再也长不出更细的场面,
  实测 turn2.Emergent=true 但 Primary 仍是 chan:qq、没有 auto: 场景。
  修法:给场景加 origin(declared/emergent):
  - 被动聚类只认 origin='emergent' 的场景(声明场景不进相似度空间);
  - 声明场景的特征**只从键自身解析**(chan:qq/peer:group_1 → {chan:qq, peer:group_1}),
    白名单 kind(chan/peer/peer_group/tool/topic/part),不猜——
    「老大2026-09-04_12:27_qq私聊图片」里的 12:27 也是 kind:value 形态,
    放进特征空间就是往相似度里灌垃圾(有测试钉住)。
  - 声明路的泛化靠**层级键前缀**(chan:qq 覆盖 chan:qq/peer:x),机制各归各。
- memgc -scene-stats 增加 [declared|emergent] 与 strength/features 两栏,
  可直接观察两条路各自在长什么。

新增/改写用例:
- TestDeclaredAndEmergentBothLearn:首次交互兜底到声明场景 → 第 2 轮长出
  细粒度涌现场景且**优先用于写入** → 声明场景不进相似度空间(防止压死被动路)
  但仍走声明键取回 → 两条路都进召回集合 → 声明键特征解析与白名单。
- TestEffectiveScenes:多值+单值合并去重保序。

go build/vet 干净,go test -count=1 ./... 全绿。
2026-09-15 09:37:29 +08:00
bb7e7979ae fix(memory): SceneStats 的 strength/features 不再说谎
- 旧行经 ALTER 加列后 strength 为 NULL,直接 SELECT 会显示成 0(实际是 1 次);
  改为 COALESCE(strength,1),并补上 features 计数(场景长出了几个特征)。
- 两栏一起看才能判断「场景是不是真在涌现」,而不是被一次性写出来的。
2026-09-15 09:29:46 +08:00
d4f9a12db0 feat(memory): 场景从「声明」改为「涌现」——场面指纹自己长成场景
上一版场景是声明/派生的:调用方写 scene="chan:qq",或由通道机械派生。
那不是涌现,是贴标签——标签谁定、怎么定全靠人。按「像人一样:干了什么事,
后续类似场面自动唤起对应记忆」的要求重做。

机制(全部取自运行时可观察量,无需模型配合、无需人工标注):

- **场面指纹 Situation**:每轮采集 `chan:xx / peer:xx / peer_group:xx /
  tool:xx / topic:xx / part:xx`。权重按种类:通道与对象最强(1.0),
  工具次之(0.8),话题是软信号(0.4),时段最弱(0.2)。
- **归属判定用加权 Jaccard**(不是字符串相等):共享特征权重和 / 并集权重和。
  加权是必须的——`chan:qq` 与 `topic:排班` 的证据力差 2.5 倍,不加权会让
  一次偶然的话题重合把两个不同场面并成一个。
- **涌现**:同类指纹重复到 minSceneEvidence=2 次才长出场景
  (首次只登记 situation_evidence 足迹)。一次性的交互不是「场面」,
  给它建场景会让库被一次性事件撑满、之后每次路过都召回一堆只发生过一次的事。
- **强化**:场景每次重现 strength+1、并入新特征。
- **唤起**:RecallBySituation 按**相似度**取回(阈值 0.35,比归属阈值 0.5 低
  ——想不起来是损失,多想起一条只是多几行上下文),与措辞无关。
- **遗忘**:DecaySceneRefs 按半衰期让久未重现的关联淡出,低于 floor 直接删;
  已接进 archive 心跳(半衰期 30 天,比「这个月没做过这类事」更久)。

三个必须讲清的边界:
1. 一轮只解析一次场景(TaskFrame 缓存)——多解析一次就多记一次强度,
   「工具调得多」会被误读成「这个场面更常出现」。
2. 声明与涌现**并存**:声明是「我知道这是哪个场面」(插件注入点最清楚),
   涌现是「这轮看起来像哪个场面」。两者都进召回。
3. 记忆挂载全自动:memory_commit 没写 scene 时落到本轮涌现场景,
   模型不需要知道场景这回事。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例(核心证据):
- TestSceneEmergesFromRepetition:首次不建场景 → 第 2 次同类场面长出场景 →
  同场面**不同话题**仍并入同一场景 → 换通道的场面自己长出独立场景(共 2 个)→
  强度随重现增长、特征多条。全程没有任何人声明过场景键。
- TestSceneRecallsBySituationNotWording:场面里写下的规则,换措辞后仍被
  自动唤起(含原句),无关场面不唤起。
- TestSceneRefDecay:一个半衰期权重减半、第二个半衰期低于 floor 被清掉,
  仍在重现的场景不受影响。
- TestSituationFeaturesFor:指纹维度齐全、归一化、数值 group_id 转换、nil 安全。
2026-09-15 09:27:39 +08:00
b7e47a1b1f feat(rel): 文档层接入场景(document 节点挂场景)
场景贯穿流水线的 doc 层补完:
- 新增 TagSceneDocument,linkBlocksToDocument 里建文档节点时一并挂场景;
- RecallByScene 返回该场景下的文档 id(可枚举「这个场面有哪些文档」);
- 文档场景由**来源派生**(chan:<source>),不存冗余 Doc.Scene 字段——
  存一份会随来源改名而说谎,是同一事实的第二份真相。

go build/vet 干净,go test -count=1 ./internal/memory/ ./internal/agent/core/ 全绿。
2026-09-15 09:16:22 +08:00
9d45cd0277 fix(rel): 场景贯穿流水线到块层 + 编辑不再丢置信度/场景 + 构建默认带 onnxruntime
三件事,前两件是上一轮热部署暴露/遗留的真缺陷。

1) 热部署差点静默降级(已修)
   `make build` 之前**不带任何 tags**,而发行构建(deploy/packaging/build.sh)
   默认 HOMED_TAGS=onnxruntime,package-linux.sh 还会直接拒收非 onnxruntime 二进制。
   实测差异:33MB vs 84MB;启动日志里
   「multimodal space active: provider=chineseclip dim=512」整行消失、
   少加载一个插件(chinese-clip/qwen3vl provider 降级)、
   静态词向量退回 fallback。即「随手 make build」与「发行构建」不是同一个东西,
   而部署时无从察觉。
   修:Makefile 的 build 默认 HOMED_TAGS ?= onnxruntime(与打包脚本一致),
   构建后自动校验二进制里有没有 onnxruntime,缺了就打 WARN。
   生产已按此重新构建部署(v1.4.0+hotfix.d98bf51,已核实 provider 行回归)。

2) memory_edit 每跑一次就静默降级一次(新)
   memory_edit 是「按包含匹配 Purge + 写新三元组」,中间那一步把旧关系的
   置信度、原句、**场景引用**全丢了:置信度被重置成默认 1.0,场景钉死的记忆
   被打散成无场景。而关系复审心跳(reviewLoop)走的正是这条路——每轮复审都
   在无声地削记忆质量。
   修:编辑前用 FindRelations 精确取回旧关系,把置信度/原句/场景带到新三元组;
   新增 ScenesOfRelation。Purge(hard/soft)与 PurgeNoise/PurgeOrphans 之后
   统一清理悬空 scene_refs,SceneStats 不再说谎。

3) 场景贯穿流水线到块层(按「rel 应贯穿整条流水线」的设计)
   此前场景只到 relation/entity:块(L0/L3 一等记忆块)没有场景,于是
   「那场 QQ 对话里发过来的那张图」在场面重现时永远取不回来。
   - MemoryBlock.Scene + memory_blocks.scene 列(幂等 ALTER 迁移)。
   - scene_refs 增加 ref_text 承载字符串主键(块/文档 id 不是数值)。
     **不能只 ALTER ADD COLUMN**:唯一约束要从 (scene_id,kind,ref_id) 变成
     含 ref_text 的四元组,而 ALTER 改不了约束——旧约束会让「同场景第 2 个块」
     直接冲突(只在多块场景暴露)。改为按列探测后整表重建并搬运旧数据。
   - PutMemoryBlocks 同事务挂 scene_refs(kind='block');无场景重写不覆盖已有场景
     (否则一次无场景重写就静默抹掉挂载)。
   - RecallByScene 返回块;FormatContext 增「场景素材」段(模态 + 文本/短 digest),
     上限 3 条。
   - 生产者接线:attachBlocksToSentence 让块继承承载它的三元组的场景;
     linkBlocksToDocument 让文档的块继承文档来源场景(QQ 归档的图挂 chan:qq)。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景块(取回/同场景多块/无场景重写不抹场景/悬空引用清理)、
**旧表结构迁移**(降级成旧 scene_refs 后重开,旧数据保留且多块可写)、
场景素材注入、FindRelations+ScenesOfRelation 编辑搬运闭环。

生产:已重建(-tags onnxruntime)并原子替换 /usr/local/bin/homed + 重启,
35 插件全加载、panic/fatal=0、chineseclip 空间 active。
2026-09-15 09:03:36 +08:00
d98bf512e1 feat(memory): 场景式关联召回——给记忆节点赋场景引用,场面重现即取回
背景(实测):带条件的记忆召不回来。生产库里明明有
「QQ回复禁用Markdown格式 --规定--> 纯文本不用Markdown」「老大 --偏好--> 同左」,
但输入「QQ回复格式」时命中 148 个实体、规则排第 32,注入只取前 5——规则根本没进去;
输入「在吗」这种零内容词的短消息,向量路反而灌进 17 个毫不相关的实体。

根因:词法/向量召回都建立在「字面或语义相似」上,而条件式记忆(在什么场合该怎么做)
约束的是**场面**不是话题。用户措辞不重合时它天然召不回;措辞太宽("QQ")时又被同形
命中淹没。另一处:自动注入只给实体名索引,而规则本体长在关系上(relation_type + object),
即使命中名字也拿不到「纯文本不用Markdown」这句正文。

改动:把「触发条件」升成一等索引维度。

- schema:新增 scenes(key) + scene_refs(scene_id, kind, ref_id, weight),
  kind ∈ relation|entity。刻意不建外键:节点可能先于引用被清理,
  悬空引用由读取侧 JOIN 过滤,级联删除会把清理变成跨表事务。
- 场景键是分层字符串(`/` 分隔,由宽到窄):chan:qq、chan:qq/peer:group_123、
  tool:qq_get_message。NormalizeSceneKey 归一(小写、空白/标点→_、按 `/` 分层),
  空白不算层级——否则「老大2026-09-04 12:27 QQ私聊图片」这种来源名会被拆成伪层级。
- 写入即挂场景:Triple 新增 Scene 字段,commit() 在同一事务里把「关系 + 两端实体」
  挂到场景上(同事务是必须的:关系进库但引用丢了 = 这条记忆永远无声地召不回来)。
- 召回:RecallByScene 前缀匹配(chan:qq 取回 chan:qq 及所有更窄场景;用 `/` 兜底
  防止 chan:qq 吞掉 chan:qq2),按 weight(=写入置信度)降序,返回**关系全文 + 原句**。
- 注入:BuildContextInScene 在词法/向量之外叠加场景路,FormatContext 把场景块排在
  最前(规则对行为的约束强于话题相关的实体名),上限 8 条 + 原句截断 60 字;
  场景实体不在【记忆索引】里重复占位。BuildContext(input) 保持原语义(无场景)。
- 当前场景推导:payload.scene 显式声明 > 通道(chan:qq)> 工具(tool:qq_get_message),
  并列命中不取交集。qq 通道本身 RecallPolicy=none(到达的是中断元文本),
  真正召回在 qq_get_message 工具上——现在那一步同时带上 chan:qq 与 tool:qq_get_message。
- 写入侧:memory_commit 新增 scene 参数(逐条 triples[].scene 优先,顶层 scene 作批次默认);
  docToTriples 按文档来源自动带 chan:<source>(QQ 归档的知识天然属于 QQ 场面)。
  不做自动猜测:猜错的场景会把无关记忆钉死,之后每次进入该场面都被注入。
- 存量引导:memgc -tag-scene <键> -entity-glob <GLOB>。用 GLOB 而非 LIKE——
  LIKE 对 ASCII 不区分大小写,`%QQ%` 会把对象带 /home/newqqagent 的路径类记忆
  (生产数据目录、email-mcp、dify-ops 路径…实测 7 条)一起卷进 QQ 场景。
- 清理对齐:PurgeNoise/PurgeOrphans 之后顺带删悬空场景引用,并提供
  PurgeStaleSceneRefs;memgc -scene-stats 看场景规模。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景键归一(含超长/分层/空白)、写入即挂场景(两端实体进、未标的实体不进)、
前缀语义(含 chan:qq2 反例)、weight 排序与 limit、GLOB 存量引导(dry-run 不写库)、
清理后无悬空引用、场景注入面(关系全文+原句+不在索引重复占位)、
agent 侧 sceneKeysFor 优先级(显式声明 > 通道 > 工具、数组形式、nil 安全)。

生产库实测(先 sqlite3 .backup 到 graph.db.bak-20260915-081043 再写):
把 22 条 QQ 相关关系标进 chan:qq(GLOB *QQ* 19 条 + *qq_* 3 条)。同一批输入前后对比:
- 「在吗」:改前注入 17 个无关实体;改后场景块直接给出「QQ回复禁用Markdown格式
  --规定--> 纯文本不用Markdown」等规则正文(零字面重合也能召回)。
- 「QQ回复格式」:改前规则排第 32 被截掉;改后排在场景块首位。
- 「帮我发个语音」:场景规则置顶,词法路的 qq通道语音输入 等仍在其后。
2026-09-15 08:13:52 +08:00
b37141f3f5 fix(memory): doc→graph 补回常用词闸门 + 存量噪音/孤立节点清理
问题:图记忆里堆着「结果(192) / 什么(59) / 哪个(99) / 待命(176) / 报告(174) /
context_archived(43)」这类节点,mention_count 冲到几百、度数只有 1~2——
占着热实体位、挤满召回预算,却不带任何结构。

根因:这层过滤原本存在,后来被换掉没补回。1cb3e87(NLP 三元组提取系统)
把 doc→graph 从「CutExact 滑窗词链」换成依存句法提取器时,CutExact
(去停用词 324 条 + validEntityName + 去重,注释至今还写着「用于 doc→graph
蒸馏」)失去了唯一生产调用点,只剩 cut_test.go 在调它。此后落库闸门只剩
validEntityName——它挡的是「不像名字的字符串」(2–50 字符、含字母),
完全不挡「像名字的常用词」。

改动:
- 新增 internal/memory/noise.go:IsNoiseEntity 收敛判定(停用词 /
  context_archived / 模板摘要回声),FilterNoiseTriples 给自动填充路用,
  PurgeNoise + PurgeOrphans + cmd/memgc 供存量清理(默认 dry-run)。
  判定刻意保守:只拦三类客观噪音,开放类词(报告/对话/处理)不拦——
  它们挡不挡是领域决策,见函数注释。
- docToTriples 接上闸门(用户点名的「文档常用词」路)。
  对话蒸馏路 extractKeyTriples **不接**:CutExact 当年也只挂 doc→graph,
  且 pipeline_test 明确断言「我 --读书--> 杭州」必须抽出(代词主语是该路
  既定行为),是否拦属行为决策,已在代码注释里写明并留给使用者定夺。
- cut.go 补全封闭类常用词:咱俩/咱们(我们/你们/他们 早有)、任何/此/本/
  其中/以及/那么/这样/那样/一样/还有/还要/只是/老是/全部/所有/有些/一些/
  别的/其他/其余/各自/本身/方位词/部分/方面。
  「不能/不会」试过又撤回:它们是 embedder tokenize 的实词路径,
  加进停用词会让 static_embedder 的领域聚类用例翻转(今天天气句与股票句
  的相似度大小关系反了),属真回归,不留。
- graph.go:CleanupOrphanedSentences 拆出 Locked 版供 PurgeNoise 复用。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:IsNoiseEntity 判定表、FilterNoiseTriples 顺序与边界、
PurgeNoise(统计/幂等/dry-run 不写库/不误删仍被媒体块边引用的句子)、
PurgeOrphans(识别/不误判有边实体/幂等)、docToTriples 噪音闸门与
模板锚点不被误杀。

存量清理(生产库 /home/newqqagent/memory/graph.db,先用 sqlite3 .backup 备份
到 graph.db.bak-20260915-073210):
- 噪音实体 29 个 + 其关系 59 条
- 零关系孤立实体 25 个
- 结果:实体 778→724,关系 639→580;sentences 3 条与 block_edges 3 条原样保留
  (媒体块引用不被误删),PRAGMA integrity_check=ok、无悬空关系/块边。
- 运行中的 homed 无需重启:下一个 archive 心跳会 Indexer.Sync 重建实体名向量索引。
2026-09-15 07:47:16 +08:00
94995eaa64 fix(memory): 修图记忆召回的两处能力缺失(关系重复 + 原句不回显)
针对 memory_recall / 自动注入这条图记忆召回链路的实测复核:

- Recall(depth>1) 跨层不去重:每层都用已累积的 entityIDs 查邻接,
  上一层刚产出的关系会在下一层被反复查回并再次 append。实测
  小明→小红 在 depth=2 出现两次,memory_recall 的 10 条关系预算被
  同一句话刷屏、真正的新关系(小红→小刚)被截断。改为按 relation ID
  跨层去重(实体本就已去重)。
- memory_recall 从不回显 sentence_text:工具 schema 明写「填了才能日后
  从图谱回到原文」、Recall 也已 JOIN 出句子,但输出只给实体名与关系类型,
  该字段形同虚设。抽出 formatRecallRelations,对非空原句截断 60 字附在
  关系行后;超过 10 条仍截断并提示。

测试:TestRecallWithDepth 增补去重与精确条数断言;
新增 TestFormatRecallRelations_SurfacesSentence / _Truncates。
go build/vet 干净,go test -race ./internal/memory/ ./internal/agent/core/... 全绿。
2026-09-15 06:48:36 +08:00
6afe361804 fix(memory): 记忆层启动接线/并发/落盘一致性整备
按设计方案整顿记忆系统,收敛一批"单测照不出、只在长跑生产里暴露"的缺陷:

- 启动接线:initMemoryStack 残留 `defer distiller.Stop()`,规则蒸馏
  10min 心跳启动即死。改为由调用点 cleanup 停机,并补 Stopped() 探针 +
  TestInitMemoryStackKeepsDistillerRunning / TestStartKeepsLoopRunningUntilStop。
- L0 相关性上下文:SetDenseSpace 注入稠密空间时回填已有事件的稠密向量,
  否则旧事件走稀疏余弦、新事件走稠密余弦,同一次 Prune 里两种尺度混排。
- 文档检索:QueryScored 访问计数从读锁内写移出(-race 竞争),更新后置脏,
  优雅关停可落盘、FindColdDocs 冷度判据跨重启不再失真。
- 蒸馏管线:只 flush 未落盘记录(persisted 标记)、蒸馏成功后从 raw 文件
  删除对应行、原子写文件,修重启重复蒸馏导致 mention_count 膨胀。
- 图库:全量 Recall 加实体上限(内部整备路径,防大图整表进内存);
  ClearSentenceID 补写锁;Commit/upsertEntity 计数语义注释澄清。
- 索引器:recalled 去重集加 FIFO 上限,防长跑进程自动注入越来越沉默。
- 文档/媒体注释修正;README 记忆层流程对齐跨模态召回。

验证:go build ./...、go vet、go test -race
./internal/memory/... ./internal/agent/core/... ./cmd/homed/... 全绿。
2026-09-15 06:32:36 +08:00
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