60 Commits

Author SHA1 Message Date
e94c6caf7b merge: 整合 main(设备桥能力对齐 + 公开仓库清理 + 记忆块体系) (#2)
* refactor(toolcall): StageContext 拆 per-tool,为并发执行消除共享槽(阶段 2c)

问题:f.StageCtx 是**单槽**,批内每个工具都覆写它(ToolCalls=[单元素]、
ToolResults 覆写、Results[0] 回读)。串行下看不出问题,但并发下
N 个 goroutine 同写一个 ctx = 数据竞争,且 after_toolcall 插件可能读到
**别的工具**的结果。

改动(task.go):
· TaskFrame 增 toolCtxs []sdk.StageContext(每工具一份)
· buildToolContexts 在 stepLLM 设 PendingTools 时建池;
  Extra **逐份浅拷贝**——共享同一 map 即竞争(stage handler 会写它)
· toolCtxFor(i) 取第 i 份,越界/未建时回落 f.StageCtx(测试替身安全)
· stepToolBegin(before_toolcall + 参数回填)、stepToolExec(写结果)、
  stepToolAfter(读结果)三处全部切到 per-tool ctx

判据(toolbatch_test.go 追加两条):
· 每个工具的 before_toolcall ctx 只带自己的 ToolCalls[0].Name,
  且 Extra[output_channel] 逐份带过去(stage.go:18 依赖它)
· after_toolcall 读到的 Result 必须属于当前工具,不能是批内另一个的

★ 诚实记录:这两条判据在**串行**下**测不出与单槽的差别**——串行时
每工具跑完才进下一个,不存在交错。变体验证(toolCtxFor 退回单槽)后
判据仍全绿。故 2c 记为「实现已就位、判据未闭合」,真正判据必须与 2d
(并发执行)一起写,并以 -race 确认无竞争。已在执行计划中标注。

过程中两次自伤:
· 我的 harness 没设 Extra[output_channel](那是 prepareInputTask 才写的,
  task.go:380),判据一度报「产品缺陷」——核实后是我造的场景,已对齐生产;
· 阶段 1 的 TestStageCtxSuccessIsHonestEndToEnd 读 f.StageCtx.ToolResults,
  拆分后失效——判据跟随新结构改为按批索引取 toolCtxs[i],
  断言的仍是内核产出的 Success 值本身。

顺带记录(非本次引入):TestResidualKeep/Drop 偶发失败,根因是
offload_test.go 的 SpawnResident 起了子调度器 goroutine,而测试
enqueue 后无同步就读同一队列。干净基线 3/3 全绿属运气。已在计划中
记为待修,避免后续误判为并行化引入的回归。

* feat(toolcall): 批次并发调度与同通道保序(阶段 2d,闭合 2c 判据缺口)

规则(三条全满足才并发):
  1. 批内 >1 个工具
  2. **全部**工具声明 ParallelSafe —— 一个不声明就整批降级,不做部分并发
  3. 不含需保序的同通道输出发送

SDK:
· ToolDef 加 ParallelSafe bool。⚠️ 零值 false 是刻意的:存量插件不改一行
  就得到**保守**行为(整批串行),不会因升级被意外并发。声明它是责任
  而非特权。纯新增字段,无签名变更。
· io.ToolDef 同步加该字段(设备/通道工具走 io 路径,只查 StageHost 会漏)。

core:
· 新增 StepToolBatch —— runTaskSteps 是单线程驱动状态机的,
  「每步一个工具」的游标模型无法表达「一批同时跑」,故需独立 step。
· stepToolBatch:fan-out(每工具一 goroutine,各写自己的 toolCtxs[i])
  → join → **按索引顺序**串行收尾(after_toolcall / 落消息 / 事件)。
  收尾必须串行且按索引:f.Msgs 是共享切片,且按索引落才能让模型读到的
  上下文顺序与它自己发出的顺序一致。
· runOneTool 抽出「before_toolcall + 执行」的单工具逻辑,串行/并发两条路共用。
· toolParallelSafe / batchRunnable 判据函数。

★ 修掉一个我自己引入的竞争:resolveTurnScenes 会把结果记进**共享**的
f.sceneDone / f.turnScene(memorypass.go:289)。最初在每个 goroutine 里
各调一次 —— 既是数据竞争,又会各自触发一次 EnterSceneWithHint,
重复计入场景强度(正是 sceneDone 注释警告过的问题)。改为在 fan-out
**之前**解析一次,goroutine 内只读。

判据(parallelsched_test.go,4 条):
· 全批 ParallelSafe ⇒ 并发峰值 >= 2(用阻塞设备观察真实并发)
· 一个非 ParallelSafe ⇒ 整批串行,但**仍全部执行**
· 同 output_send__<通道> 连发 3 条 ⇒ 严格按声明顺序到达
· ★ 并发下每个工具的 ctx 只带自己的 ToolCalls、after 读到自己结果

★ 并关闭了 2c 的判据缺口:此前两条 2c 判据在**串行**下无法区分
per-tool 与单槽(变体验证后仍全绿)。新增的并发版判据在退回单槽时
触发 **6 处 DATA RACE 报告 + 串味断言失败**(dup:k_a 与 k_a 撞名)。
至此 2c 可记为已验证。

过程中三次自伤:
· resolveTurnScenes 竞争(上述);
· 我的 harness 用 StageHost 注册 handler 遮蔽了设备工具,
  slowDevice 根本没被调用("实际 0")——改为在 io.ToolDef 上声明;
· 批内并发峰值判据最初用 StageHost 声明 ParallelSafe,掩盖了
  「设备工具也需要该字段」这一真实缺口。

回归:internal/agent/... internal/sdk/... internal/plugin/...
      internal/plugins/... 全绿(17 包);core 包 -race 全绿。

* docs(plan): 阶段 2 标记完成,记录并发规则与 2c 判据闭合

* feat(prompt): 提示词声明「同轮默认并行」及其例外(阶段 2.5)

⚠️ 本阶段有硬性顺序约束:必须在并行执行(阶段 2d)落地**之后**。
反序(先说"并发"、内核仍串行)会让提示词**对模型说谎** —— 模型据
"并发执行"推断安全性,写出真正依赖顺序的调用。宁可晚改,不可错改。

改动(tooldefs.go,buildSystemPrompt):
· 新增【工具执行顺序】段,讲清四件事:
  1. 同一条回复里的多个工具调用**默认并行**(同时跑),不是依次执行
  2. **不要依赖执行顺序** —— 参数依赖前一个结果就分两轮
  3. **例外一:同通道 output_send__ 保序**(用户可见消息顺序敏感)
  4. **例外二:不并发安全的工具整批退回串行**(写类工具 / 未声明者)
· 顺带说明并发安全由**工具自己声明**(ParallelSafe),不由模型判断
· 改掉 spawn_child 的落空表述:原文「应并行 spawn,不要自己串行逐个执行」
  在并行化之前是**落空**的(模型照做,内核仍串行)。改为机制性表述,
  并补一句「一次 spawn 只是启动动作,要拿结果仍需另一次 child_result」。

⚠️ 措辞刻意与 batchRunnable 的**真实**判据一致(全批 ParallelSafe 才并发
+ 同通道保序),而不是理想化表述 —— 提示词与实现不符,比不说更坏。

判据(prompt_parallel_test.go,5 条):
· 四要点齐全(并行 / 顺序 / 保序 / 并发安全声明)
· ★ 必须同时讲**例外** —— 只讲并行就是"说谎"的那一种
· 同通道保序须显式说明(保序是内核兜底,模型不知情就会浪费它)
· spawn_child 不再含旧的落空措辞
· 回归防护:既有要点(输出规则 / output_list_channels / 工具能力 / 记忆清理)不丢

★ 判据里的一次自伤:先写了 spawnChildDescription(a) 这个**不存在**的
helper("工具名反查描述"),编译失败后改为 toolDefDescription —— 内部
遍历 buildToolDefs 的**真实产物**。判据必须对着代码真实输出,不能另建一套
注册表。

变异验证:把两条例外改写成"以上适用于所有工具"⇒ 3 条判据 FAIL
(缺"保序"、缺"例外"、同通道未说明)。即"提示词说谎"这一失败模式
现已被判据覆盖。

回归:internal/agent/... internal/sdk/... internal/plugins/... 全绿(15 包)。

* docs: 同步两份文档的实现进度(内核主线 0~2.5 全部完成)

* docs(design): 补 0.2 阶段行

* feat(seq): 序列文本 → AST 解析与静态校验(插件线 P1)

插件,不是内核:并行执行是内核提供的**唯一**基础设施(core 的
batchRunnable);分组 / 具名槽 / 条件 / 调用图全部在本包内自建,
**不要求内核开任何新接口**。

实现(parse.go):
· Sequence / Group / ToolCall 三个 AST 类型
· Parse:JSON → AST + **全部**静态校验一次做完
  (而非留到执行期——group 有独立签名,具名槽的价值就在于构建期就能
  查出错写的槽)
· splitToolList:`;` 仅在 brace 深度 0 且**不在字符串内**时才是分隔符
· 枚举/未知字段一律硬报错:DisallowUnknownFields + 显式校验
  (missing / on_error 报错时列出合法取值,不当默认值蒙过去)

静态校验规则:
· `as:X` 未在 out 声明 ⇒ 报错(具名槽的核心价值)
· `$args.X` 未在 in 声明 ⇒ 报错
· group 名重复 ⇒ 报错(签名名必须唯一才能按名调用)
· 非 array 槽被同名 as 写多次 ⇒ 报错(组内并行下同名写入是数据竞争);
  array 槽则允许(组屏障按序追加)
· tools 分隔符畸形(漏中间 / 漏末尾 / 连续 / 未闭合)⇒ **硬报错**,
  绝不静默吞掉一个工具

判据(parse_test.go,9 条),★ 两条最关键:
· 含分号的真实 command(取自 core 里那份线上日志 fixture)保持为 1 个工具
· ★ TestBracesInsideStringDoNotAffectDepth:字符串里的**不成对**花括号
  不得影响 depth

★ 本阶段的三次自伤(都靠变异测试暴露,不是靠判据变红):
1. **格式本身是错的**:我在设计文档里写的 `{tool:cmd_run,...}` 根本不是
   合法 JSON(键没引号),encoding/json 直接解析失败。判据一跑就暴露
   ——"写了格式却从没验证它能解析"。已改为要求合法 JSON(键带引号),
   这也是 DisallowUnknownFields 能生效的前提。
2. **判据验证的不是它声称验证的规则**:原本那条"分号在字符串内"的用例,
   分号其实落在 args 对象的**花括号内部**,depth>0 就足以保护 ⇒ 删掉
   分词器的字符串跟踪后**仍然全绿**。反复两次才找到真正的判别点:
   必须用**不成对**花括号在 depth 恰为 0 处,才只有字符串态能救它。
3. 手写多层转义把引号写成 \",使分词器永远进不了字符串态。改用
   json.Marshal **分层构造** fixture——手写转义没有不出错的机会。

变异验证(两轮,均能检出):
· 删掉"回到顶层必须紧跟 ;"检查 ⇒ 漏中间分隔符用例 FAIL
· 删掉字符串跟踪 ⇒ TestBracesInsideStringDoNotAffectDepth FAIL
  ("结构未闭合(括号深度 1)")

回归:internal/agent/... internal/plugins/... 全绿。

* feat(seq): 执行引擎 —— 组内并行 + 具名槽 + 条件求值(插件线 P2)

三个不变量(各有判据钉住):

1. **组内并行、组间串行**。parallel=true 时各工具并发执行。

2. ★ **合并按声明顺序**,不按完成顺序。
   并行下完成顺序不确定;若按完成顺序合并,同样的输入产出不同的结果,
   整条序列**不可复现**。做法:各工具把结果写进 `results[i]`(按索引),
   组屏障处按 tools 数组顺序一次性合并。
   顺序合并顺带解决了并发写 map —— **执行期完全不写共享 map**。

3. ★ **条件求值失败必须报错**,不得降级成"条件为假"。
   把求值失败当作跳过 = 序列安静地少做一步,而模型以为跑完了
   —— 与「静默吞工具」同族(那正是 P1 判据里刚堵上的同类问题)。

条件求值(设计文档 §5 的 L1+L2,不引表达式引擎):
· true/false、$args.key 裸引用(真值)
· == / != / > / < / >= / <= 、contains
· 字面量支持 "str" / 'str' / true / false / 数字 / 裸文本
· 布尔与字符串宽松比较(true == "true"),对齐 utils.getBool 的既有约定

变量插值两种形态(缺一不可):
· **整值引用** "$args.count" ⇒ 替换为**原始值并保留类型**
  (数字仍是数字;否则模型收到字符串 "3")
· **文本内插值** "ssh $args.host" ⇒ 在字符串内替换
· 标量渲染:对象/数组用**紧凑 JSON**,绝不用 fmt.Sprintf("%v")
  (那会产出 `map[k:v]` 这种模型读不懂的 Go 语法)

on_error:abort(默认)/ continue。失败时也留槽(记错误文本)——
否则后续组读到的是"缺失",而"缺失"与"值为空"在下游难以区分。

判据(exec_test.go,8 条):
· 具名槽写入正确
· ★ 结果按声明顺序合并(用 delay 让完成顺序**确实**打乱)
· array 槽同名 as 按声明顺序确定性追加
· 条件为假 ⇒ 整组跳过、槽**不赋值**、零工具被执行
· ★ 条件畸形(空键 / 引用未声明入参 / 语法不完整)⇒ 报错且**不执行任何工具**
· 条件为真 ⇒ 正常执行
· on_error 的 abort / continue 两种语义
· 插值:文本内替换 + 整值引用保留类型

过程中三次自伤:
1. ★ **toolRunner 接口第一版写成 call(name)**,不收 args ⇒ 插值判据成了
   摆设(永远"通过")。改为 call(name, args) 后插值才真正可观察。
2. toolRunner / compactJSON 定义在了 _test.go 里,exec.go 引用不到 ⇒
   build 失败。toolRunner 是**引擎的依赖契约**,必须在非测试文件。
3. fixture 里给 "slow" 配了不存在的返回值,误以为它该返回 "B" ——
   是我没配就断言,不是实现错。

变异验证:把合并改为"按完成顺序 append"⇒ array 槽顺序判据 FAIL,
报错直指 `[C A ran:slow]` vs 期望 `[A ran:slow C]`。
(第一版变异用了一个 no-op 的 sort.SliceStable,等于什么都没测,
 已改成真正模拟完成序的实现。)
`-race` 全绿;回归 internal/agent/... internal/plugins/... 全绿。

顺带修正设计文档:两处 tools 示例原写成 `{tool:cmd_run,...}`,
**不是合法 JSON**(P1 判据实测会解析失败)。已改为合法 JSON 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。

* feat(seq): 序列存储、跨序列调用图与 missing 策略(插件线 P3)

store.go:
· **存 AST 不存文本**。执行期不重新解析原始文本 ⇒ 一次格式改动不会
  悄悄改变已保存序列的行为。
· 先写 .tmp 再 rename,避免写一半被读。
· **路径穿越防护**:序列名来自模型且被直接拼进文件路径,不校验的话
  `seq_load("../secret")` 能读任意文件、`seq_delete` 能删任意文件。
· CheckGraph:跨序列调用的**目标存在性** + **环检测**(三色 DFS),
  报错时给出**环路径**(#A → #B → #A),便于定位。
· maxCallDepth = 4 是**结构常量**不是配置项 —— 沿用内核
  MaxInterruptFrames 的做法(core/scheduler.go:271「结构上界,不是配置项」):
  上界一旦可配,总有人会把它调到栈溢出。

exec.go 补 missing 策略(动态注册下「工具不存在」是**常态**):
· fail(默认)/ skip / degrade,与「执行失败」严格分开
· ⚠️ missing 分支**必须先于**通用 on_error 检查:否则「插件挂了」会被
  on_error=abort 连坐整组中断,skip/degrade 形同虚设
· skip 时**不赋值槽**(与「条件为假」同一情形,下游要能应对槽缺失)
· 本包自带 errToolNotFound 哨兵而**不复用** io 包的同名错误:seq 是插件,
  拿得到 sdk.ToolAPI,拿不到 io 包类型(见设计文档 §7 边界声明)

★ 过程中解决一个**设计死锁**(值得单列):
我最初让 Save 校验「跨序列目标必须已存在」。但互调的两条序列
谁也存不下来——A 要 B 先在、B 要 A 先在,**依赖在设计上无解**。
⇒ Save 只校验**同序列内**的 group 引用(那部分信息自足);
  跨序列目标的存在性与环由 CheckGraph 在保存后统一兜底。
  判据与实现都写明了这个分工的理由。

判据(store_test.go,7 条):
· 存取往返保住 AST(含 out 声明——它是签名的一部分)
· 列表 / 删除;删不存在的**报错**(不静默成功,模型会以为删掉了)
· ★ 跨序列成环被拒且错误含环路径;无环通过
· maxCallDepth 是正的结构常量
· ★ missing 三种取值各有明确行为
· ★ 路径穿越:7 种恶意名既读不到也删不掉,且**在 store 目录外**放真实
  文件断言它仍在(不是"读代码看着对",是跑出来的)

过程中三次自伤:
1. 序列名我写成 "#A"/"#B"——`#` 只是 target 里的前缀标记,
   落盘名不带它,于是 CheckGraph 找不到、误报「不存在」。
2. missing 策略与 on_error 检查的**顺序**反了,导致 skip/degrade 被
   abort 连坐(判据直接暴露)。
3. 为压掉 unused import 写了 `var _ = os.Remove` 这种占位 hack ——
   正是检查项 go-ignored-call-result 指出的那类东西,已删;
   另把 rename 失败分支的 `os.Remove(tmp)` 加上注释说明
   「清理失败有意忽略,否则会盖掉真正的失败原因」。

变异验证:去掉环检测(三色 DFS 全放行)⇒ 成环判据 FAIL
("A→B→A 成环却通过检查")。

回归:-race 下 seq 全绿;internal/plugins/... 全绿。
core 包偶发 TestResidualKeep 失败是**已记录的既有竞态**
(offload_test.go 的 SpawnResident 起了子调度器而测试无同步就读队列),
与本阶段无关,已在执行计划中记为待修。

* feat(seq): 六个 seq_* 工具、插件装配,并补内核两处缺口(插件线 P4)

内核缺口(都是 P3 落地时暴露的真实缺陷):
· **GetAllTools 丢 ParallelSafe**:它只带出 Name/Description/Parameters,
  插件看到的设备工具一律"不可并发" ⇒ 设备工具的并发声明**对插件不可见**。
· **ToolAPI 缺按名查**:新增 ToolDefByName。插件需要在**运行前**判断目标
  是否存在/是否并发安全(工具动态注册,"不存在"是常态),
  而 GetAllTools 只能拿到全量列表。查不到返回 nil,不 panic。

seq 插件:
· plugin.go:插件骨架 + kernelRunner(把 sdk.ToolAPI 收窄成三个方法,
  判据因此能用假实现驱动,不必构造整个内核)
· tools.go:六个工具定义(独立真相源,注册/判据/文档都从它取)
· handlers.go:seq_create/list/delete/run/call/when_call 的实现
· register.go + all.go:按 skillmgr 同一范式 init 注册

★ 过程中解决一个**我自己的设计矛盾**:
判据原先要求 `seq_call` / `seq_when_call` 进黑名单,但"按名调用
group/序列"恰恰是本包的核心能力——禁掉它,序列就退化成单层脚本。
分层澄清后:黑名单只管**对外发消息 / 起子 agent / 改插件表 / 再跑整条
序列**;seq_call 系列留给序列内部组合,其递归由 maxCallDepth + 环检测
负责(设计文档 §8.3 本来就是这么定的,我把两层混了)。
`seq_run` 留在黑名单:序列内再跑整条序列语义上是递归。

**六个工具一律不声明 ParallelSafe**:seq_run/seq_call 会执行一串工具,
其中可能含写操作;标成并发安全会让内核把两条 seq_run 并发跑,
两个序列的执行顺序交错、变量表互相污染。

安全性:序列名与文件路径都做穿越防护(`..` 段、分隔符、空名)。

过程中四次自伤:
1. 臆造 `jsonMarshalIndent`(不存在)→ 改 encoding/json.MarshalIndent;
   并把 execGroup 的 runner 传错成 p(应 p.runner)。
2. seq 判据里写了 `black(name)`,而 blacklisted 是**谓词**不是函数。
3. 一次 python 替换删漏,把「跨序列目标存在性检查」那段从 CheckNew
   里整段摘掉又贴回原处——靠编译错误发现。
4. ★ 注册失败我写了 panic:内置插件在 main() 装配期加载,panic 会
   **直接拖垮内核启动**,而"某个工具没注册上"只该让该工具不可用。
   已改为 log.Printf + 继续(与 clawhubadapter / mcp 一致)。

变异验证:把 seq_run 移出黑名单 ⇒ 黑名单判据 FAIL。

判据(plugin_test.go,6 条):六个工具全部注册且 description/参数 schema
非空;seq_create 声明 required 并说明 groups/file 二选一;
seq_run 说明"按数组顺序";六个工具均未声明 ParallelSafe;
黑名单含递归风险项且**不误伤** seq_call 与普通工具。

回归:seq -race 全绿;internal/sdk/... internal/plugins/... 12 包全绿。

* docs(plan): 内核主线与插件线全部完成,记录两个设计决策与三处遗留

* fix(security): 设备授权闸下沉到 ToolAPI 路径(D4,堵住绕过)

问题:设备类工具的授权闸只存在于 core.executeToolCallInner
(toolcall.go:151-152),即**「agent 收到模型 tool_call」那条路径**。
而 ToolAPI.ExecuteTool 是**另一条**独立执行入口,不经那道闸
⇒ 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs。

实测范围**不止序列**:cli 插件的 /terminal 直接经 ToolAPI 调 agentcli 的
终端工具(cli/plugin.go:1038 的注释自陈"SDK 的 ToolAPI 已允许跨插件调用
工具")。任何插件拿 ToolAPI 都能指挥未授权的设备。

改动:
· internal/sdk/tool.go: ToolAPI 新增 CanUse(toolName, args) bool。
  **纯新增方法**,零值实现返回 true ⇒ 未实现者(存量插件、测试替身)
  行为不变。
· internal/sdk/tool_impl.go: 实现 CanUse。判据只有一条——设备类工具按
  `device/<id>` 查授权;非设备工具不受影响(闸的作用域必须窄,否则会把
  所有工具锁死)。
  授权查询走**可注入**的晚绑定闭包:toolImpl 在 internal/sdk,而
  IsOutputAllowed 是 core.*Agent 的方法,sdk 不能依赖 core。
· internal/plugin/registry.go: 新增 SetDeviceAuthQuery。
· cmd/homed/bootstrap.go: 在 newMainAgent 末尾注入。⚠️ 必须在 agent
  构造**之后**——判据要用 agent 自己的 allowedOutputs,而 registry 早于
  agent 构造,故 registry 存的是晚绑定闭包。

判据(toolapi_auth_test.go,7 条),核心是**两条路径必须一致**:
· 收窄授权时 ToolAPI 路径同样被拦
· 已授权设备放行(防闸过严杀掉正常能力)
· 非设备工具不受影响
· 枚举类工具不受影响(与内核 TestDeviceToolAuth_EnumerationNotGated 同语义)
· 未配置白名单 = 完整授权
· ★ TestCanUseAgreesWithInnerPath:4 组用例逐例比对内核路径与 ToolAPI
  路径的结论 —— 判定不同本身就是漏洞
· ★ TestCanUseMatchesInnerFailOpenOnMissingDeviceID:把现状
  (缺 device_id 时**放行**)钉住。⚠️ 这是 fail-open,是既有的可疑设计
  (core 的 TestDeviceToolAuth_* 依赖它),本次不擅自改语义;判据写明
  "若要改成 fail-closed,必须两处同时改"。

过程中三次自伤:
1. 一度在 core 写了个 toolAPIRef —— **只实现部分方法的替身**是过度设计,
   且两份实现必然漂移。改为判据直接用 sdk.NewTool(stageHost, iom),
   与插件侧走**同一个**实现。
2. 判据里又写了 `var _ = agentIO.DeviceOutput` 这种压 unused import 的
   占位 hack(第二次犯这个),并重造了 strings.Contains。都已去掉。
3. 注入点一开始找错了位置(以为 newStageAndRegistry 能拿到 agent,
   实际 pluginReg 是 main() 的局部变量)。核实 newMainAgent 的签名后
   确认它同时持有 agent 与 pluginReg,注入点落在那里。

变异验证:让 CanUse 恒返回 true(还原成原缺口)⇒ 两条判据 FAIL,
其中一条直指「内核路径=false 而 ToolAPI 路径=true —— 两条路径判定不一致」。

回归:go build ./... 通过;go test ./internal/... 全绿。

* test(seq): 端到端接线判据,抓出「传参方式完全不可用」的真 bug

P1–P4 的判据都在**包内**(假 runner / 直接调函数),覆盖的是**语义**;
本轮补的是**接线**层——参数名对不对、返回值模型读不读得懂、跨层调用断不断。
接线层的 bug 语义判据抓不到:例如工具注册了但参数名拼错,单元判据全绿
而模型永远传不进来。

★ 抓到一个真 bug:**seq_create 走 groups 传参时完全不可用**。
根因:marshalGroups 只把 groups 包进 JSON 文档、不带 name,而 Parse 要求
name 非空 ⇒ 报「序列缺少 name」。而 seqCreate 里那句
`if seq.Name == "" { seq.Name = name }` 回落分支是**死代码**(Parse 早就失败了)。
后果:**只有 file 方式能用,传参方式一律失败**。
已修(name 一并包装)。包内判据抓不到这个——它们直接构造 *Sequence,
不经过这条路径;只有真正 dispatch 一遍才暴露。

判据(e2e_test.go,7 条):真实 dispatch 串通
seq_create → seq_list(须展示签名,模型据此按名调用)→ seq_run
(执行序按 tools 声明序、槽回填、结果里**不得**出现 Go 的 `map[` 语法)
· seq_call 按名调用 group 并返回其出参
· seq_when_call 条件为假 ⇒ 跳过且**零工具被执行**
· seq_when_call 条件畸形 ⇒ **报错**且零执行(不得静默跳过)
· seq_create 走**文件**(长序列的主力用法)
· groups 与 file 同时传 ⇒ 报错「二选一」
· seq_delete 不存在 ⇒ 报错(模型会以为删掉了)

过程中又一次臆造 helper(`writeFile`),改用 os.WriteFile;
并把三处 `_, _ = p.dispatch(...)` 补上 err 检查(正是
go-ignored-call-result 报的那类)。

回归:seq -race 全绿;go build ./... 通过;go test ./internal/... 全绿。

* docs(plan): 遗留项收敛——D4 与端到端已完成,提权无需决策

* feat(kernel): 内置工具注册进 ToolAPI 面(方案 B,补真机实跑发现的架构缺口)

真机实跑实证(独立实例 /tmp/seqtest,未触碰生产):
  seq_run 报「工具 knowledge_list 不存在或未注册」,
  而**同一轮模型直接调 knowledge_list 是成功的**。

根因:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个是
**内核内置**工具,在 core.executeToolCallInner 里按**前缀分派**,
由 buildToolDefs 直接生成 schema,**从不进 StageHost / IOManager**
⇒ ToolAPI(只有插件工具 + IO 设备工具)既查不到也调不了。
后果:序列只能编排插件/设备工具,无法编排记忆/知识/文档/人物
——恰恰是最常用的能力。

方案 B 的实现:
· internal/sdk: 新增 BuiltinProvider(Defs/Exec)与 SetBuiltinProvider。
  用**晚绑定注入**而非让 toolImpl 依赖 core,理由:ToolAPI 是**全局单例**
  却需要 per-agent 数据(驻留子是轻量内核,memory 为 nil;内置工具可见性
  由 `if a.memory != nil` 等门控)。sdk 不能依赖 core(方向反了)。
· internal/sdk/tool_impl.go: ToolDefByName / ExecuteTool 补查内置工具。
  ⚠️ ExecuteTool 只在「io 确实没有该工具」时才转内置;io 的**执行失败**
  必须如实上抛 —— 否则会把「设备离线」误报成「工具不存在」,让调用方
  按 missing 策略跳过(与 P3 修过的父 io 吞错误同一族陷阱)。
  内置工具**默认不声明 ParallelSafe**(含 SQLite 写与召回)。
· internal/agent/core/builtin_toolapi.go: Agent 侧 provider。
  ★ Defs **复用 buildToolDefs 的同一批生成逻辑**(筛出不在
  StageHost/IOManager 中的那些),保证"模型看得到什么"与"插件看得到什么"
  门控完全一致 —— 避免两套语义。
  Exec 复用 executeToolCall 完整路径(授权闸 + 异常处理)。
· cmd/homed/bootstrap.go: agent 构造后注入。

★ 不会让模型看到重复工具(已核实):模型侧走 buildToolDefs
(a.io / a.stageHost **直调**),ToolAPI 只经 PluginSDK.Tool() 暴露给插件
—— 两条不重叠的路径。

判据(builtin_toolapi_test.go,6 条):
· 内置工具能从 ToolAPI 查到
· ★ 查到 ≠ 调得通:必须真的能执行
· 门控语义保持:未接 memory/knowledge 时不得声称有那些工具
· ★ 接了 knowledge 时必须可见(这正是要补的缺口)
· ToolAPI 上"不存在"必须是类型化 not-found(供 seq 的 missing 策略用)
· 内置工具默认不声明并发安全

真机复验(同一隔离实例,新二进制):
  序列 "smoke2" 执行完毕(1/1 组)— 工具 1 个
  变量槽: summary = cangjie/central-repo/agreement/...
⇒ knowledge_list 成功执行并回填具名槽。上一次的「不存在或未注册」已消除。

已知局限(记入待定):ToolAPI 单例而内置工具面是 per-agent,
多 agent 下看到的是"最近一个注入者"的面。本次不解决。

* fix(seq): 类型不匹配的错误改成模型可执行的话(真机实跑发现)

真机实跑(独立实例)发现:模型把 group 的 `in` 传成字符串 "{}",
拿到的是 encoding/json 的原始报错:

  json: cannot unmarshal string into Go struct field rawSeq.groups.0.in
        of type map[string]string

这句说的是**事实**(string 解不成 map)而不是**该怎么做**
(in 应该写成对象 {"键":"类型"});残留的 `rawSeq` / `Go struct field`
更是 Go 内部实现细节,对模型无意义且会误导它去猜一个叫 rawSeq 的东西。
模型为此**重试了 3 次**才改对。

这与本仓反复吃亏的那类问题同源:`20s` 少引号 → 静默降级 →
cmd_run 失败率 34%。**报事实不报改法,模型只能猜。**

改动(parse.go):新增 friendlyJSONError,把原始报错翻译成可执行文案
· in / out 类型不符 ⇒ 说明"应写成对象 {键:类型};无入参请写 {}"
· tools 类型不符 ⇒ 说明"应写成字符串(内容是 ; 分隔的 JSON 对象)"
· groups / name / when / missing / timeout / on_error ⇒ 逐个说明期望
· 未知字段 ⇒ 列出 group 允许的全部字段名(拼写错误最常见)
· shortFieldName 剥掉 `rawSeq` 这类包内类型名前缀
· jsonKind / goTypeName 把 Go 类型翻译成模型看得懂的说法

判据(parse_test.go,2 条):
· in 传字符串 ⇒ 错误须指名字段、须说明该传对象、**且不得残留 Go 内部类型名**
· tools 传数组 ⇒ 错误须指明 tools 且说明它是字符串

★ 变异验证时踩了一次坑:第一次变异让 friendlyJSONError 不被调用,
结果**编译失败**(函数变成未使用),判据压根没跑,我却看到 "ok"。
改用可编译的变异(函数保留、开头直接 return err)后判据正确 FAIL。
★ 教训:**"变异后判据通过"要先确认变异真的生效**——编译失败 ≠ 判据通过。

真机复验:模型读一次即懂,并明确说"提示里的意思很明确";
修复前它为此重试 3 次。

回归:internal/plugins/... internal/agent/core internal/sdk 全绿。

* feat(seq): 新增 seq_help —— 格式说明 + 可照抄示例

动机来自真机实跑:模型写序列时踩了三个坑,各试 1~3 次才改对
  ① tools 漏末尾的 ';'       → 「末尾缺少 ';'」
  ② group 的 in 传成字符串     → 重试 3 次
  ③ as 指向未声明的 out 槽      → 静态校验拦下
这三处都是**格式细节**,塞不进工具描述(有长度限制),却恰是模型最易错处。
散落在七个描述里等于没有集中入口。

实现(help.go + tools.go + plugin.go):
· seq_help 无参数、纯文本返回(与仓内 output_send__*_help 同范式)
· 「格式要点」逐条写明:in/out 必须是**对象**、tools 必须是**字符串**、
  每个 tool 后(含最后一个)都要 ';'、as 必须在 out 声明、
  groups 与 file 二选一、组内并行组间串行
· 「条件 when」列出支持的表达式形态
· 「可照抄的完整示例」给一行**单行紧凑**的合法序列

★ 判据(plugin_test.go,3 条):
· seq_help 已注册、有 description、不声明并发安全
· 内容覆盖真机踩过的**每一个**坑(判据从"坑"出发而非从"打算写什么")
· ★ 示例**自己能被本包解析器接受**:validateHelpExample 从帮助文本里
  抽出示例喂给 Parse —— 模型是照抄的,示例自己解析不过就是给模型挖坑。
  而"从文本里有没有某个词"是看不出这类 bug 的。

过程中判据自己错了两次(都被这条示例判据照出来):
1. 抽取用 strings.Index(help, `{"name"`) ⇒ 先命中「格式要点」里**有意写的**
   示意片段,截到非示例的内容,报出莫名其妙的 invalid character '…'。
2. 修完又混用两套偏移基准(base 的下标拿去切 help)⇒ invalid character '\xaf'。
   ⇒ 重写为全程在同一 base 上定位。
★ 两次都说明:**判据的抽取逻辑本身就是需要验证的代码**,
它出错时报出的信息极具误导性(看起来像实现有 bug)。

示例形态也改过一次:原为多行缩进 JSON,改为**单行紧凑** —— 模型照抄时
免去缩进/换行带来的额外风险。

变异验证:去掉示例里的末尾 ';' ⇒ 示例判据 FAIL。

另一处:加 seq_help 后「恰好注册 6 个工具」判据 FAIL(实际 7)——
这正是那条判据的用意(防止悄悄多加工具稀释工具面),已更新并注明原因。

回归:go build ./... 通过;internal/plugins/... core sdk 全绿。

* feat(toolcall): 工具结果只统计不裁剪(方案 B),并治掉 seq 侧的静默截断

问题(核实过):工具结果进 f.Msgs 时**没有任何长度上限**(task.go 直接
`Content: result`),内核也**不预检**是否超长 —— 超限由上游 API 报错。
时间线那侧有预算(ContextTokens = 0.8×窗口,进消息前就裁过),但那只管
a.context 的历史事件,**不管单条工具结果** ⇒ 一条巨大结果可能直接冲破
预算而内核不会提前发现。

为什么**不裁剪**(与方案 A 的取舍):
· 截断会让模型拿到**残缺**信息,而截断位置由内核武断决定;
· 模型无法得知"这里被截断了",会基于残缺数据下结论 —— 与本仓反复
  吃亏的「静默降级」同族(`20s` 少引号 → 静默降级 → cmd_run 失败率 34%);
· 处置权应交给调度器/上层(告警、拒绝、或让模型自己换更窄的查询),
  而不是内核单方面替模型决定。

改动:
· core/toolresult_budget.go: checkToolResultSize 只**计数+报告**;
  阈值默认 = ContextTokens/8(一条吃掉全部预算会把其它上下文全挤掉);
  报告经 toolResultReporter(可替换),默认 logReporter —— **不给模型发
  消息**:那是在已花掉的 token 之上再加一条 system,且对当前这轮决策无帮助。
· 接入点在 stepToolAfter 的 toolMsg 落定**之后**(那里才是模型最终看到的
  内容;stepToolExec 拿到的尚未经 after_toolcall 改写)。
· TaskFrame 记 oversizeTools / oversizeToolNames,供调度器与状态面查询
  "是否有工具在稳定产出超大结果"。

★ 顺带治掉 seq 侧一处**我自己留下的静默截断**:
handlers.go 里我当初随手写了 truncate(…, 160),把变量槽静默截到 160 字
且**无任何标注** —— 正是我批评过的静默降级。
改为 renderSlot:≤160 给全;超过则显式标注「已截断:共 N 字,此处显示前
160 字」并给出改法。**槽里存的始终是完整值**,截断只影响回填文本长度。
端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot 抓到了这个缺陷
("变量槽被截到 160/5000 字却没有任何标注")。

判据(toolresult_budget_test.go,4 条):
· 400KB 结果触发超限报告(含工具名与 token 数)
· ★ **默认不裁剪**:200KB 结果原样进 tool 消息(方案 B 的核心不变式)
· 小结果不误报(噪音会淹没有效信号)
· 报告文案可执行:带工具名、token 数、改法建议

变异验证:去掉统计调用 ⇒ 两条判据 FAIL("统计没生效" + "被裁剪了")。

另:检查项报 stepToolBatch 的 goroutine 竞态,-race 实测**误报**——
循环变量显式传参(非闭包捕获)、且按索引写各自槽位(非共享 map),
`-race` 下 20 轮并发判据全绿。

* test(seq): 三个压力测试 —— 超长序列 / 100 工具并行 / 串行降级

与单元判据的分工:单元判据钉住**语义**(一条路径对不对);压力测试钉住
**规模下的不变量**。沿用仓内既有范式(media/soak_test.go):
testing.Short() 跳过 + 独立 -run 跑。

① 超长序列
   · 解析 10 / 100 / 1000 组(250KB 文本):6.8ms,无硬上限误报
   · 执行 200 组 × 5 工具 = 1000 次调用:2.0ms
     断言:每工具恰好被调 nGroups 次(无遗漏/重复)、结果含**最后一组**
     —— 组间串行在规模下仍成立

② 100 工具组内并行
   · 100 工具全声明并发安全 ⇒ 11ms,完成顺序**确实被打乱**(判据会校验
     这一点,否则它测不到并发)
   · 断言每个槽拿到**自己**的结果(并发下若按完成顺序合并就会错位)

③ 串行降级
   · 50 个工具里**一个**未声明并发安全 ⇒ 整批退回串行,
     完成顺序严格等于声明序(106ms vs 并发的 11ms,降级确实生效)

★ 压力测试第一次跑就抓到一个**真实分层缺陷**:
「含非并发安全工具则整批串行」这条规则**只在上层 runGroup 实现**,
而引擎层 execGroup 只信 g.Parallel 字段 ⇒ 任何人直接调 execGroup
都会拿到不受约束的并发。
已修:降级判据下沉到引擎层,新增 batchCanRun(g, runner),
toolRunner 增加 parallelSafe 方法(生产路径行为不变,只是把判据
放到了它本该在的层)。

过程中压测自身也暴露了两个测试缺陷(都修了):
· fixture 让 100 个工具写同一个标量槽 o,被静态校验正确拦下
  ("组内并行下同名写入是数据竞争")—— 压测不该去撞这条规则;
· ★ e2eRunner.called 是无锁 append,100 工具并发时 -race 报出**真竞态**
  (不是误报)—— 加锁 + 提供 calledSnapshot 供断言。

另:建序列与跑序列原本用了**不同 plugin 实例**(序列存在实例的 store 里,
换实例就读不到自己刚建的),已改为同一实例。

回归:go test -race ./internal/plugins/seq 全绿;go test ./internal/... 全绿。

* feat(parallel): 并发安全改为声明式,并审计标注 37 个工具

把"能不能并发"从内核硬编码名单改成**工具自己的声明项**,形态照 SDK 的
NoMemory 走。

## ★ 起因:提示词在跟内核不一致

阶段 2.5 写进提示词的「内核默认并行执行」当时是**假的**:toolParallelSafe
只查 stageHost 与 io 两个来源,而全仓 ParallelSafe:true 的生产代码数量
是 **0**。于是除碰巧只发一个工具外,每一批都整批串行回退,而提示词正教
模型把多个查询放同一轮。**内核行为与提示词不一致 = 对模型说谎。**

并发面:0 → 37 个工具(18 插件 ParallelSafe + 19 插件 Serial + 9 内置只读)。

## 声明形态(照 SDK,不自创)

### 插件:结构体字段
    s.RegisterTool("config_get", sdk.ToolDef{
        Name: ..., Description: ...,
        Parameters: map[string]interface{}{...},
        // 已核实只读:…
        ParallelSafe: true,      ← 插在 Parameters 之后、handler 之前
    }, p.handleGet(s))

位置与 SDK 的 NoMemory/ContextPolicy/RecallPolicy 一致:Name 在首位,
声明项在末尾,不打散 gofmt 对齐。

### 新增 SDK 声明项:ToolDef.Serial
ParallelSafe 的**反向**标记,判据优先级高于 ParallelSafe。
为什么需要:ParallelSafe 零值 false 已表达"安全",插件无法区分"我没想过"
与"我确认过必须串行"。没有这个区分,工具作者只能靠命名约定传递意图。

内核已消费它(io.ToolDef 同步加字段对齐),并有判据守"Serial 胜出"。

### 内置工具:toolDef 的 toolParallel 选项
内置工具以裸 schema map 下发,没有 ToolDef 结构,所以用变参选项:
    toolDef(名字, 描述, 属性)                  // 默认串行
    toolDef(名字, 描述, 属性, "toolParallel")  // 已核实只读,可并发
读工具表的老调用点一行不用动,声明就写在工具定义那一行。

## ★ 走过的弯路(都留了判据)

1. **硬编码白名单**:先在 toolParallelSafe 里查一张
   builtinParallelSafeTools map。那把声明从"工具自己"搬回了内核 ——
   工具改名/新增不会自动跟着变,得靠一条 grep 源码的判据才能发现漂移,
   而判据一改就忘。已删,改为从定义读。

2. **判据前提错(同一个坑踩了两次)**:拿裸 &Agent{} 的 buildToolDefs 输出
   当"实际可见工具",但这 9 个内置工具全在条件分支里(a.knowledge != nil /
   a.social != nil / a.parentID != ""…),裸 Agent 一个都不产出 ⇒ 全部误报
   "声明形同虚设"。第一次叫它"幽灵条目",没认出是同一个坑。

3. **注释模仿真实签名污染判据**:toolParallel 的用法注释写着
   `toolDef("knowledge_search", ...)`,判据按文本匹配先撞上注释。

4. **buildToolDefs 的 nil 不一致**:开头判了 a.io != nil,末尾却无条件
   a.io.ListChannels()。任何无 IO 的 Agent 调它都 panic —— 而 panic 报在
   io 包里,根因在 tooldefs.go。已补。

5. **插入脚本用正则找"最后一个顶层字段"**:被嵌套 map 里的同形文本骗到,
   823 处错误重排把文件改坏。改用括号深度 + 记录进入深度 3 的行号
   (空 properties 会让深度在同一行进出平衡,只判 depth==2 不够)。
   工具在 SDK 仓 tools/annotate_parallel/,复用时用绝对路径。

## 提示词措辞同步修正
「默认并行执行」→「尽量并发执行,但这是**逐工具判断**的」,并教模型
**把查询类放同一轮、写操作单独发一轮**(写和查混在一批,整批都串行)。

## 判据
- TestSerialOverridesParallelSafe          Serial 优先于 ParallelSafe
- TestToolParallelDeclarationsAudit        并发面不许再归零
- TestNoToolDeclaresBothParallelAndSerial  两者同标即谎话
- TestBuiltinParallelDeclaredWhereDefined  声明写在定义处、且内核真读到
- TestStoreListIgnoresForeignJSON          压测抓到的 List() 缺陷

* fix(seq): 修掉 seq_create 的 O(n²),并加极端压测

## 起因:1000×1000 压测直接跑爆

用户要求「1000 条序列 × 每条 1000 个组内 toolcall」。第一版跑满 8 分钟超时。
分阶段计时定位到瓶颈:

| 阶段 | 200 条 × 1000 工具 |
|---|---|
| 创建 | **27.0s**(135ms/条,**随序列数线性增长**) |
| 执行(组内 1000 并发) | 0.55s(20 万次调用,2.7µs/次) |
| 删除 | 4.7ms |

瓶颈在创建,不在执行。

## 根因

```go
// handlers.go:70 —— 每次 seq_create 之后
graphErr := p.store.CheckGraph()

// store.go:166 —— List() 全量 + 逐条 Load() 全部序列
```

1000 条各 250KB ⇒ 每次创建都重读 250MB 并反序列化。第 N 条的创建代价
随 N 线性增长,总计 O(n²)。

## ★ 走过的弯路:我一度建议「把校验挪到运行期」—— 那是错的

store.go:163 明确写着:

    两条检查(都必须在**建序列/保存**时做,而不是等运行):
      1. 每个 seq_call 的目标必须存在(不存在会在运行期才发现,浪费一整轮)

**校验时机是语义,不是性能旋钮。** 目标不存在若等到运行才发现,模型已经
白白花掉一整轮工具调用。性能问题不能靠挪语义来解。

## 修法:缓存调用边,Save 做 O(1) 增量

```go
// Store 新增
graph map[string][]string   // 序列名 → 它调用的目标(裸名)

// Save:  只更新这一条的边
s.graph[seq.Name] = edgesOf(seq)
// Delete: 移除这一条的边
delete(s.graph, name)
```

`callTargets` 只依赖 AST,不必每次从盘重建。**校验语义完全不变** —— 目标
存在性与三色 DFS 环检测都照旧在建序列时执行。

## 判据

- TestStoreGraphCacheKeepsSemantics  逐条钉住三个保证:目标存在性 ✓、
  环检测 ✓、删除后不再误报成环 ✓
  (这类优化最危险的失败模式是"校验还在跑但少查了某种情况")
- TestStoreSaveScalesLinearly       分段对比后半程/前半程每条耗时。
  ★ 判据自己改过一次:初版用「总耗时 ÷ 单条耗时」,而单条只有 48µs 时
    噪声占比过高,同一份代码两次跑出 84× 和 203× —— 判据不稳定时报的
    失败就是噪声,比没判据更糟。改成分段对比(平方时后半程会慢约 n/2
    倍,线性时基本持平),阈值 3 倍留足磁盘与 GC 抖动余量。
  实测 300 条:84~203× 单条(线性期望 300×),平方会是 90000×。

## 压测本身也修了两个自己的 bug

- 源文件目录与 store 目录分离时只改了写入侧,清理侧还指着 store 目录 ⇒
  报 "no such file"。看起来像文件被提前删了,真因是路径拼错。
- newE2EPlugin 的 runner 参数写死 *e2eRunner,压测换替身就编译不过 ⇒
  改为接受 seqRunner 接口。

## 压测规模

TestStressExtreme_ThousandSeqs 现为 1000 条 × 1000 toolcall(O(n²) 修复后
可跑)。判据全是**不变量**:每工具恰好调一次、1000 槽在交错延迟下仍按
声明序合并(并发下若按完成序合并必然错位)、删除后无残留。

* refactor(parallel): 内置工具的并发声明改为 SDK 同构的结构体字段

上一提交(bc16a07)把并发安全改成了声明式,但内置工具那一路仍是将就:
声明靠往 required 变参里塞字符串 "toolParallel" 传递。

## 为什么那不算声明式

对照 SDK 的 NoMemory 逐条看:

| | SDK NoMemory | 当时的内置工具 |
|---|---|---|
| 载体 | `ToolDef.NoMemory` 字段 | required 里的字符串 |
| 拼错后果 | 编译器报错 | **静默失效** |
| 内核读取 | 查结构体字段 | 遍历工具表 + 解析字符串 |

"少一个工具能并发"恰恰是最难察觉的一类问题 —— 没有任何报错,
只是并行的批悄悄退化成串行。

## 改法

### 1. sdk.BuiltinToolDef 补声明项(与 NoMemory 同构)

```go
type BuiltinToolDef struct {
    Name, Description string
    Parameters        map[string]interface{}
    ParallelSafe      bool   // 零值 false = 默认串行(保守)
    Serial            bool   // 优先于 ParallelSafe
}
func (d BuiltinToolDef) ConcurrencySafe() bool { return d.ParallelSafe && !d.Serial }
func (d BuiltinToolDef) ToSchema() map[string]interface{}
```

### 2. 工具定义处声明

```go
toolDef("memory_merge", ...)                                  // 默认串行
toolDefWith("knowledge_search", ..., []string{"query"}, parallelOpts())  // 已核实只读
```

### 3. 内核一次聚合并缓存(照 StageHost.NoMemoryToolNames)

```go
graphOf()      // 快照
declareParallelTool(name)   // init 里登记
concurrencySafeOf(name)     // 查表
```

不再每次 toolParallelSafe 都重跑 buildToolDefs()(O(工具数) 重复劳动,
而声明是静态的)。

## ★ 一个更隐蔽的问题:声明表曾经是空的

`declareParallelTool` 最初挂在 `toolDefWith` 的**运行时调用**上。而那 9 个
工具全在 `if a.knowledge != nil` / `if a.social != nil` / `if a.parentID != ""`
之类的条件分支里 —— 测试环境根本不走进这些分支 ⇒ 聚合表始终为空。

而判据查的是同一张表,于是**自证通过**:全绿,并发能力为零。

这就是判据设计的教训 —— 判据和数据源同源时,它证明的只是"我和我一致"。
现在判据双向核对:名单里的必须真声明了,声明了不在名单里的也会报出来;
并额外验证内核**真的读得到**(concurrencySafeOf 而非读同一份 map)。

## 顺带修掉的迁移事故

用正则批量改造 30+ 个 toolDef 调用点时,把 `person_set_trait("name", "content")`
这类**变参**调用误改成 toolDefWith(... "name", "content") —— 那是**写工具**,
差点被标成可并发。已全部回退并逐一核对:9 个声明并发,0 误伤。

* perf(stagehost): 工具声明查询免去结构体拷贝(热路径 1000 并发下省 1000 次)

## 问题

StageHost.ToolDef 返回 &def —— 一次**结构体拷贝**:3 个 string + 2 个 map 头
+ 2 个 bool + Cleaner 函数指针。

而 toolParallelSafe 在**每批**并发判据里对每个工具各调一次:
batchRunnable 遍历 PendingTools → toolParallelSafe(tc.Name)。
1000 并发批次 = 1000 次结构体拷贝,全在判定阶段(执行之前)。

不是"逃逸漏洞"(Go 1.22+ 循环变量每轮独立,go.mod 是 1.25),纯粹是白拷贝。

## 修法

ToolDef 保留 —— 它要给需要完整声明的调用方(Cleaner、Parameters 校验),
返回副本也是**有意**的(ToolDef 里有 map 与函数指针,交出内部元素会把
可变引用漏出去)。

新增免拷贝查询,热路径专用:

    ConcurrencySafeOf(name) (safe, found bool)   // 只读 ParallelSafe && !Serial
    NoMemoryOf(name)       (v, found bool)
    HasTool(name)          bool

全部在持 RLock 下走同一个 findLocked。

`toolParallelSafe` 切到 ConcurrencySafeOf。语义完全等价 —— 两者都算
`ParallelSafe && !Serial`,只差一次拷贝。

## 判据(两个都防"优化悄悄改了语义")

- TestNoCopyQueriesMatchToolDef  7 种声明组合(plain / parallel / serial /
  both / nomem / all / serial_nomem)下,免拷贝查询与 ToolDef(...).字段
  **逐字段等价**;不存在的工具三态一致(false/false/true)。

  ★ 这类优化最危险的失败模式就是语义漂移:并发判据若读错字段,
    能并发的批次会**悄悄退化成串行** —— 没有任何报错,只表现为"变慢了"。
    所以判据必须逐个组合比对,而不是只测一个典型值。

- TestNoCopyQueriesConcurrent  32 goroutine × 50 工具并发查询,
  -race 无竞态且结果与串行一致。

回归:go build ./... 通过;go test ./internal/... 全绿;
go test -race ./internal/agent/core 通过。

* test(lua): 补适配器 stream_index 透传判据 + 全适配器体检

## 先纠正一件事:这个修复在生产上早就存在

最初我判断"`openai.lua` 缺 stream_index 透传、批内并发在生产走不通",并据此
写了实现。**核对生产实例后,这个判断是错的**:

    生产 /home/newqqagent/adapters/openai.lua   130 行  含 stream_index
    仓库 950b21b^                                128 行  无 stream_index

生产那份的注释是「透传上游分片 index:并行多工具调用时内核按它区分归属桶」——
简洁,与本提交新增的长注释不同。**也就是说仓库版本落后于生产,生产一直没这个
问题。** 我修的是"仓库与生产的差距",不是"生产正在发生的故障"。

## 真正缺的是判据

`internal/agent/core/stream_index_test.go` 的
TestAccumulateStreamParallelToolCallsByIndex 直接构造 Go 结构体
`agentAPI.StreamChunk{...}`,**不经过 Lua 适配器** —— 所以"适配器有没有把
index 透传出来"它永远测不到。生产有、仓库没有,判据也发现不了。

而提交 7a51c9b(2026-08-26,"流式并行 tool_call 按 JSON index 分桶")的说明里
写着「openai.lua 输出 stream_index 字段」,Go 侧也加了
`StreamIndex int json:"stream_index,omitempty"` 并注明"lua 适配器以
stream_index 键透传" —— 但那次提交**根本没改 openai.lua**(6 个文件里没有它)。
说明与实现不符,而没有任何判据能发现。

## 本提交做的事

① 让 openai.lua 与生产一致(补 stream_index 透传),并说明为何缺它会静默失效:
   多个分片全部并到槽 0 → argsRaw 混拼 → 每个工具报"参数不是合法 JSON",
   而**工具一次都没真跑过**。单工具时上游 index 恒为 0,缺省也是 0,
   所以问题只在"一轮多个 tool_call"时显形。

② 新增 internal/lua/adapter_streamindex_test.go,**真正加载并执行内嵌的
   openai.lua**(复用 VM 的真实路径),三条判据:
   - TestOpenAIAdapterPassesThroughStreamIndex  3 个 tool_call 的
     stream_index 必须是 0/1/2
   - TestOpenAIAdapterKeepsContinuationFragment 续传分片(只有 arguments、
     没有 name)的 stream_index 必须正确 —— 它是分桶的**唯一**依据
   - TestAllBundledAdaptersStreamToolCallStatus  全 10 个适配器体检

★ 第三条刻意**不**用 t.Skip 掩盖不支持的适配器 —— 早期版本一律 Skip,结果
"完全不支持流式工具调用"也会让整体显示为绿,而绿会被误读成"都支持"。
现在分类记录:openai ✓ / kimicode+server 透传嵌套形态需另修 /
其余 7 个未产出 tool_calls。

③ 体检顺带暴露的、与本提交无关但已记录的问题:
   - **仓库 vs 生产漂移无判据**:仓库适配器落后于生产时,只有靠人工对比才发现
   - **部署陷阱**:vm.go writeBundledAdapters 是
     `if 文件已存在 { continue }`,升级二进制**不会更新已有适配器文件**。
     这可能正是"仓库缺透传却没人发现"的原因之一

* test(lua): 加适配器漂移报告 + 键名契约判据

上一提交(d05f896)纠正了一处误判:仓库的 openai.lua 缺 stream_index 透传,
而生产实例早就有 —— 仓库版本落后于生产。这个漂移当时没有任何判据能发现。

## 两条判据,定位不同

### TestBundledAdaptersMatchDeployedOnes —— 诊断式,刻意**不**作为失败判据

实测结果:生产部署的 7/10 个适配器比仓库旧(anthropic 2537 vs 5144 字节),
而 openai 那份反而领先。这**是正常的** —— 生产是长期运行的部署,适配器在它首次
创建时就解包落地,之后仓库一直在演进。

若把"必须一致"写成失败判据,它会在任何老部署上恒红,而恒红的判据会被无视 ——
那等于没有判据,甚至更糟(它会掩盖真正的漂移)。所以这里只把差异摆出来。

但它顺带把**部署陷阱**摆到了明面上:

    vm.go writeBundledAdapters: if 文件已存在 { continue }

升级二进制**不会更新已部署的适配器文件**。于是"仓库改了适配器但老实例上不生效"
与"仓库根本没改"在现象上完全一样 —— 这大概就是仓库长期缺 stream_index 却
没人发现的原因之一。

### TestAdapterEmitsOnlyKnownFields —— 这条才是能自动抓 bug 的

契约 = agentAPI.ToolCall / StreamChunk 的 json tag:
`id, type, name, arguments, raw_arguments, stream_index`(+ 上游原样透传的
`index` / `function`)。

★ 为什么必须有:Go 侧按 json tag 反序列化,**键名拼错会静默取零值**。
`streamindex`(少个下划线)与"没写这行"的表现完全一样 —— 无报错、字段为零、
分桶全部并到槽 0。这正是本次 stream_index 缺失的形态。

判据只看"适配器吐出来的键名对不对",与环境无关,所以能在 CI 里恒定生效。

## 顺带确认的一件事(事后查明:是我的操作失误)

压测实例解包出的 openai.lua 是 4709 字节(无 stream_index),而二进制 embed
里是 5672 字节(有)。`rm -rf` 后重新解包**仍是旧的**。

我逐行读过 `writeBundledAdapters`,只找到 embed 一条来源,一度判为"未解释的
矛盾"。**真因是操作失误**:`rm -rf` 之后启动的那一轮,用的还是修复前编译的
/tmp/homed-stress —— 删除与重编之间隔了几轮,中间又用旧二进制起了好几次
实例,每次解包出来的自然都是旧版。

用当前 main 重新构建 + 全新数据目录验证:全新解包 5672 字节、含
stream_index×3、md5 与源文件一致;删掉再解一次仍一致;启动日志有
`[lua] installed bundled adapter: openai.lua`。⇒ **writeBundledAdapters 无缺陷**。

★ 教训:验证"二进制内嵌内容是否更新"时,必须确认跑的就是**刚编译出来的那个
二进制**。否则会得出"代码有 bug"的错误结论 —— 我确实这么怀疑了好几天。

(下面那条"部署陷阱"观察本身仍然成立:升级二进制确实不会更新已部署的适配器
文件。但它与这次的现象无关。)

* test(lua): 记录仓库/生产适配器漂移的具体内容与时间线

生产 adapters/openai.lua(4853 字节)含 stream_index,仓库 7a51c9b 时的版本
(4709 字节)不含。生产文件时间 2026-08-26 15:46,比 7a51c9b 提交(16:10)
早 32 分钟 —— 该提交说明里写着「openai.lua 输出 stream_index 字段」,
但 --stat 显示它没改这个文件:修复先在生产生效,入库时漏了。

于是「生产能跑多工具、仓库跑不了」持续一个月而两端都没人发现:生产不报
问题(它有),仓库的判据也测不到(直接构造 Go 结构体,绕过适配器)。

判据本身不变(仍是诊断式 t.Log),只把这条漂移的具体内容写进注释 ——
它是理解本次全部误判的关键背景。

* test(stress): 补批内并发压测与 A/B 对比脚本,并给 mock 加多 tool_call 能力

## 缺口

现有 kernel-stress 只压**调度器**(多连接排队 + L4 中断 + 驻留子),mock 每轮
只发**一个** tool_call。而内核的并发判据是:

    if f == nil || len(f.PendingTools) <= 1 { return false }

⇒ **一个 tool_call 永远不并发**。所以现有压测压的全是串行路径,批内并发
一条都没走过。

## mockllm.py:让 mock 能发多个 tool_call

- `!batchN`   N 个全部 ParallelSafe 的只读工具 ⇒ 强制走 stepToolBatch
- `!mixedN`   夹一个 knowledge_create(未声明并发安全)⇒ 验证整批降级
- `!slowbatchN` N 个 sleep 型 cmd_run(单工具约 200ms,时长递增)
  ⇒ 工具慢才能让并发的收益从噪声里显出来;时长递增使**完成序与声明序相反**,
  可据此判定"是否按声明序落消息"
- `!serialbatchN` 在 !slowbatch 基础上插入一个非并发安全工具 ⇒ 强制整批串行,
  作为并发对照的另一半
- `!img` / `!ocr` 触发多模态工具路径(不加载模型,只验内核 IPC 接线与错误处理)
- `!err` / `!hang` 故障注入

### 三个协议细节(踩过才知道,都写在注释里)

1. **必须带 `index`** —— 内核按 `StreamIndex`(上游 JSON 的 index)分槽累积
   arguments。缺 index 时所有分片落到槽 0,几个 tool_call 的参数被**混拼**,
   症状是每个工具都报"参数不是合法 JSON"而工具一次没真跑过。
   单 tool_call 时不设 index 也正常,所以老 mock 一直没暴露。
2. **必须按协议分片** —— 一个 chunk 一个 tool_call,各自带 index;
   后续 chunk 只续 arguments。整数组塞进一个 chunk 会被内核按"续传"语义累积。
3. **每个工具都要发"带 name 的首片"** —— 我第一版只给第 0 个发首片、其余直接
   发续传片,看起来省事,但内核 flush 时按"无 name 即丢弃"处理,
   于是 idx=1/2/3 全被丢,只跑 1 个工具。

## batchstress.py:批内并发压测(带校验)

只看峰值是不够的 —— 校验:工具是否真跑(响应里应有结果标记)、
消息顺序是否稳定(并发执行但按索引落消息)、mixed 批是否整批降级。

## abtest.py:串行 vs 并发的定量对比

同一套内核上用 !slowbatch / !serialbatch 两组对照,交替执行抵消机器负载漂移,
取中位数(长尾会污染均值)。

★ 判据里加了"工具是否真执行"这一项:**只看耗时是不够的** —— 出现过
"内核 274ms 就回复、工具一个没跑"的情况,那种情况下并发与串行都是 0.2s,
加速比毫无意义。

## 实测(隔离 netns 实例,mock + 内核同网段,5 轮中位)

    N    并发        串行         加速     上限
    2    0.481s    0.685s      1.42x    2
    4    0.491s    1.114s      2.27x    4
    8    0.513s    2.037s      3.97x    8
    12   0.531s    3.039s      5.72x    12

并发批耗时几乎不随 N 增长,串行批严格线性。

★ 另一个踩过的坑(abtest 脚本自己的):内核有输入去重
(task.go:353 `isDuplicateInput`,为 webui 断线重连重放而设),相同文本会被
丢弃并回空响应。第一版每轮发同一个 marker ⇒ 只有第 1 轮有效,后面全是
0 秒 0 工具。修法是每轮加 `time.time_ns()` 唯一后缀。

* docs(stress): abtest 补「跨版本对比的陷阱」—— 老版本可能只是没干活

脚本原本只写「加速比 = 基线延迟 / 新版延迟」。实测发现跨版本对比时这个
数字**没有意义**:

    N    老版本 中位/处理数      新版本 中位/处理数      算出的"加速"
    2    0.273s / 0 个        0.480s / 2 个          0.57x
    4    0.273s / 0 个        0.492s / 4 个          0.55x
    8    0.273s / 0 个        0.512s / 8 个          0.53x

"老版本更快"是假的 —— 它只是没干活:适配器缺 stream_index 透传 ⇒ 多个
分片并到槽 0 ⇒ argsRaw 混拼 ⇒ 每个工具报"参数不是合法 JSON"而一个都没
真跑。

★ 跨版本能比的硬指标是**处理数**;耗时对比只在新版内部做(混一个非并发安全
工具触发整批降级)才有意义 —— 那种对照下 N=12 时加速 5.72×。

顺带记下:老版本的内核分桶逻辑其实完整(`idx := tc.StreamIndex` + `accs[idx]`,
来自 2026-08-26 的 7a51c9b),缺的只是适配器那一个字段;而生产在当天 15:46
就手工补上了,比该提交(16:10)早 32 分钟。

* fix(lua): deepseek 适配器的流式路径处理 tool_calls(此前全部丢失)

## 缺陷

deepseek.lua 的 tool_calls 处理只存在于 `transform_response`(**非流式**路径),
而 `transform_stream_chunk` 只透 content/done:

    return json.encode({ content = delta.content or "", done = (fr ~= nil) })

于是 deepseek 源在**流式**模式下工具调用全部丢失 —— 模型调不动任何工具,
且**没有任何报错**,只是"工具好像不听话"。

## 为什么难发现

- 非流式路径是好的 ⇒ 端到端手工测试也过
- 内核的 tool call 循环默认走**流式**(provider.go 的 stream 分支)⇒ 实际不可用
- 功能判据(core 包的批内测试)直接构造 `agentAPI.StreamChunk{}`,
  **绕过适配器** ⇒ 测不到这一层

配置里 `deepseek` 源预设指向 `adapters/deepseek.lua`,所以任何按预设配置
的用户都会踩到(生产当前未启用该源,配置里 deepseek 相关键为 0)。

## 修法

照 openai.lua 的做法在流式路径补上:OpenAI 兼容格式
`{function:{name,arguments}, id, type, index}` → homed 扁平结构
`{id, type, name, raw_arguments, stream_index}`,含 reasoning_content 透传。

两个容易踩的点也写进注释:
- **不能按 name 过滤**:流式续传片 name 为空但携带 arguments,
  内核按 stream_index 分桶累积
- **必须透传 stream_index**:否则多个分片并到槽 0、argsRaw 混拼

## 判据

新增 TestDeepSeekAdapterHandlesStreamToolCalls:喂两个含 tool_call 的分片,
断言 tool_calls 未被丢弃且 stream_index 正确。修前两条分片全被丢弃。

## 体检分类随之变化

    修前: ✓ [openai]                                       ✗ [deepseek ...]
    修后: ✓ [deepseek openai]                              ✗ [anthropic gemini github groq mistral ollama]

* fix(lua): 补齐 6 个适配器的流式 tool_calls 支持

体检判据(TestAllBundledAdaptersStreamToolCallStatus)报出的三类问题,
本提交解决其中两类;第三类(gemini)未动,原因见下。

## ① OpenAI 兼容族:github / groq / mistral(3 个)

它们的 transform_stream_chunk 与修复前的 deepseek **逐字相同** ——
只透 content/done,tool_calls 处理只存在于 transform_response(非流式)。

后果与 deepseek 相同:流式模式下工具调用全部丢失,模型调不动任何工具,
且**没有任何报错**。生产当前未启用这三个源,但按预设配置的用户会踩到。

照 deepseek 的修法补上(含 reasoning_content 透传)。

## ② 嵌套形态 + 键名错:server / kimicode / anthropic / ollama(4 个)

这四个**有** tool_calls 处理,但发的是:

    { index = N, id = ..., ["function"] = { name = ..., arguments = ... } }

而 homed 的 `agentAPI.ToolCall` 是**扁平**结构,json tag 为:

    id / type / name / arguments / raw_arguments / stream_index

两处都是**静默**失效(Go 侧按 json tag 反序列化,取不到就是零值,无报错):
- **嵌套** `["function"]` ⇒ `name` / `raw_arguments` 取零值
  ⇒ flush 时判「无 name」丢弃,或参数为空
- **键名 `index`** ⇒ `StreamIndex` 取零值
  ⇒ 多个分片并到同一个桶,argsRaw 混拼 ⇒ 每个工具报「参数不是合法 JSON」
  而**一个都没真跑**

已逐项对齐为扁平 + `stream_index`。协议差异都保留:
- anthropic:`content_block_start` / `input_json_delta`,续传片 name 留空
  (内核按 stream_index 累积,补齐 name 后才 flush)
- ollama:tool_calls **整条一次发完**(不分片),故 stream_index 取数组下标

## ③ gemini 未动

它的流式函数处理 `candidates[].content.parts`,**全文件没有任何
tool_calls / functionCall 处理** —— 连非流式路径也没有。补它不是"对齐"
而是新实现,且 gemini 的 functionCall 形态(`functionCall: {name, args}`,
args 是对象而非 JSON 字符串)与 OpenAI 族不同,需要单独判据。

生产三个源(llmsproxy / visionllm / justworker)全部用 `openai.lua`,
不阻塞。留作独立项。

## 判据

- TestOpenAICompatibleFamilyHandlesStreamToolCalls  5 个 OpenAI 族适配器,
  逐个验证 tool_calls 未丢 + stream_index 正确
- TestAnthropicAdapterEmitsFlatToolCallsWithStreamIndex  用 **Anthropic 协议**
  的 fixture(不用 OpenAI 的,否则会因"不适用该 chunk"跳过 —— 看着绿,
  实则没测)
- TestOllamaAdapterEmitsFlatToolCallsWithStreamIndex  用 Ollama 协议形态
- TestDeepSeekAdapterHandlesStreamToolCalls  单列,因它有源预设指向

★ 三个判据按**协议**分文件而非逐适配器:这几个文件的流式函数逐字相同,
共用一个 fixture 会因协议不适用而静默跳过 —— 那等于没测。

## 体检分类

    修前: ✓ [openai]        ⚠ [kimicode server]  ✗ [anthropic deepseek gemini github groq mistral ollama]
    修后: ✓ [openai deepseek github groq mistral]  ⚠ []  ✗ [gemini]

* fix(lua): 内置适配器按内容自动更新,替代「文件已存在就跳过」

## 原机制是这次全部误判的根源

    if _, err := os.Stat(dstPath); err == nil { continue }

**升级二进制永远不更新已部署的适配器文件。** 于是"改了仓库 ≠ 生产生效",
而这个机制让同类问题可以长期潜伏:

    2026-08-26 15:46  生产 openai.lua 手工补上 stream_index 透传
    2026-08-26 16:10  7a51c9b 提交,说明里写了但代码没改这个文件

修复当天先在生产落地、32 分钟后才提交入库(漏了这个文件),此后一个月里
两端都没人发现 —— 生产不报问题(它有),仓库的判据也测不到(直接构造 Go
结构体,绕过适配器)。而"升级不覆盖"意味着即使仓库补上修复,已部署的
老实例也不会拿到。

## 新判据(按内容,不按存在)

    文件不存在                     ⇒ 写
    有历史清单且盘上 == 上次内嵌   ⇒ 覆盖(只是没跟上新版本)
    有历史清单但盘上 != 上次内嵌   ⇒ 不动 + 日志(用户改过)
    无历史清单(首跑/从旧版本升级) ⇒ 不动,只补缺失文件(与旧行为一致)

"上次内嵌的版本"记在 `DataDir/adapters/.bundled`(`<name>\t<sha256>`)。

⚠️ 为什么不能无条件覆盖:adapter_path 是可配置项,用户可以把 adapter_path
指向自己维护的适配器。静默覆盖等于丢弃他们的修改,而且**没有报错**。

## 判据(两个方向都要测)

- TestWriteBundledAdaptersSkipsUserModified  用户改过的**必须保留**,
  且改完仍能正常加载(fixture 必须功能完整,否则会因为缺钩子函数而失败 ——
  那是 fixture 问题,不是保护逻辑问题)
- TestWriteBundledAdaptersUpdatesStale       落后于新内嵌的**必须被更新**,
  且**幂等**(三跑不再改写任何文件)

★ 第二条是必要的:只测保护的话,**一个"永远不覆盖任何文件"的实现也能全绿**
  —— 而那正是要修的病。

## 代价(必须知道)

**升级到本版本的这一次,已部署实例的适配器不会更新**(没有历史清单可比)。
从第二次升级起自动生效。要立刻生效就删掉 DataDir/adapters 让内核重新解包。

对本次修的 8 个适配器而言:生产此刻用不到(三个源 llmsproxy/visionllm/
justworker 全是 openai.lua),所以不影响运行;将来启用 deepseek 等源时,
自然就是修复版。

## 顺带

`bundledAdapterNames` 从 writeBundledAdapters 里提出来成包级变量 ——
writeBundledAdapters 与体检判据共用,避免两处各写一份而漏掉某个
(漏掉的后果是该适配器永远不会被更新)。

* test(stress): 补更新前后全面对比脚本(cmp.py),修 blast 的计数错误

## cmp.py:更新前后对比的四个维度

★ 每轮都记录**处理数**(响应里有多少个工具结果标记)与**是否出现 error 帧**,
任一不符即记失败。理由:工具调用这一路的失败模式几乎都是**静默**的 ——
工具没跑、参数混拼、只处理了第一个 tool_call,都不报错只是结果不对。
"跑完没崩"完全不能说明它 work。

1. **批内工具调用**:!slowbatchN 在两版上各跑几轮,比中位耗时与处理数
2. **并发 vs 强制串行**(新版内部对照):!slowbatchN vs !serialbatchN
3. **调度器并发轰炸**:多连接并发排队,算通过率
4. **连续稳定性**:20 轮无错误率

## 修掉 mock 的 !serialbatch 缺失

之前只在 /tmp 的临时副本里加过,没进仓库,导致 cmp.py 测「强制串行」时
那个 marker 根本不存在 —— 测出来的"串行"其实是并发,**加速比是假的**
(0.34x / 0.31x,看起来并发比串行慢)。已加回并说明它的用途:跨版本做不了
并发/串行对照(旧版适配器缺 stream_index,工具一个都没真跑),只能在
同一套内核上做。

## 修掉 blast 的计数错误

第一版按 `conns * inputs` 起线程、每个线程又跑 `inputs` 轮 ⇒ 总输入数是
conns×inputs²,分子分母量纲不一致,算出过 **"128/32 = 400%"** 这种荒谬数字。

现在:恰好 conns 个 worker、每个跑 inputs 轮;且分母用**实际发出的**输入数
(含连接失败的),否则连接失败时通过率会虚高。

## 踩过的两个坑(都写进注释)

- 内核 `task.go:353` 有输入去重(`isDuplicateInput`,为 webui 断线重连重放
  而设),相同文本被丢弃并回空响应 ⇒ 每轮输入必须带唯一后缀
- cli 的 auth 帧本身就是 `{"type":"response"}` ⇒ 必须先吃掉它再开始收集,
  否则第一轮的"终止帧"是 auth,测出来耗时恒为 0

* docs(plan): 记录更新前后全面压测结果与部署前置条件

两版隔离实例实测(规模 3 = 288 条输入),核心差异是**处理数**而非耗时:
旧版 openai.lua 缺 stream_index 透传 ⇒ 多个分片并到槽 0、参数混拼 ⇒
工具一个都没真跑,却因为「少干活」而耗时更短。

    !slowbatch8   旧 0.220s / 0 个  →  新 0.409s / 8 个
    并发 vs 强制串行(新版内部)      N=8 加速 3.88×
    调度器轰炸 288 输入             两版均 100% 通过
    连续稳定性 20 轮                两版均无错误、内核无 panic

规模 1(32 条)与规模 3(288 条)结果完全一致 ⇒ 可复现。

同时记录部署前置条件:生产是 -tags=onnxruntime 构建(strip 后 75MB vs
普通构建 28MB),package-linux.sh:139 会显式拒绝非 onnxruntime 构建。
本次改动未触及任何 ONNX 路径,故压测结论对生产成立,但必须走
deploy/packaging/build.sh 才能部署。

* docs(plan): 补记全面压测结果与部署前置条件

两版隔离实例实测(规模 3 = 288 条输入),核心差异是**处理数**而非耗时:
旧版 openai.lua 缺 stream_index 透传 ⇒ 多个分片并到槽 0、参数混拼 ⇒
工具一个都没真跑,却因为「少干活」而耗时更短。

    !slowbatch8   旧 0.220s / 0 个  →  新 0.409s / 8 个
    并发 vs 强制串行(新版内部)      N=8 加速 3.88×
    调度器轰炸 288 输入             两版均 100% 通过
    连续稳定性 20 轮                两版均无错误、内核无 panic

规模 1(32 条)与规模 3(288 条)结果完全一致 ⇒ 可复现。

同时记录部署前置条件:生产是 -tags=onnxruntime 构建(strip 后 75MB vs
普通构建 28MB),package-linux.sh:139 会显式拒绝非 onnxruntime 构建。
本次改动未触及任何 ONNX 路径,故压测结论对生产成立,但必须走
deploy/packaging/build.sh 才能部署。

* chore(deploy): 加生产部署方案脚本(check/backup/deploy/rollback 四段)

## 为什么需要

生产二进制是 **`-tags=onnxruntime`** 构建(86.5MB,.rodata 62.5MB),
而普通 `go build` 只有 37MB —— 差的是 ONNX Runtime 绑定。
`deploy/packaging/package-linux.sh:139` 会显式拒绝非 onnxruntime 构建:

    if ! go version -m "$homed_bin" | grep -Eq 'build[[:space:]]+-tags=.*onnxruntime'; then
        echo "ERROR: homed 不是 onnxruntime 构建,拒绝打 server/full 包" >&2

也就是说:**用错构建方式部署,依存句法分析与多模态向量化会静默失效**。
这个坑我自己踩过一次(拿普通构建去比体积,才发现的),所以脚本第一步
就卡这个判据。

## 四个动作

- `check`    只读检查:服务状态、onnxruntime 标签、libonnxruntime.so、
              模型资产、适配器清单。**不改任何东西**,可随时跑
- `backup`   备份二进制 + 适配器 + unit 文件,并**生成 ROLLBACK.sh**
- `deploy`   check → 人工确认(输入 yes)→ 备份 → install -m 0755 原子替换
              → 重启 → 8 秒后验活;失败时打印回滚命令与 journalctl
- `rollback` 用最近一次备份回滚

## 刻意不做自动回滚

回滚要不要做、什么时候做,是人的判断。脚本只负责把状态保全好,
让回滚成为一条**可执行**的命令,而不是一个自动决策。

## 部署不会碰的东西(已在 check 里显式打印)

- **适配器文件**:`8d721a5` 的新逻辑在「无历史清单」时不动任何已存在的文件,
  所以生产的 10 个 .lua 保持原样(含那个已含 stream_index 的 openai.lua)
- **数据目录**:51G 的 models/ 与配置都不动,部署只换二进制

* chore(deploy): 补强部署后验证

原来只说「等 20s 看 /kernel 状态」,等于没验。进程活着 ≠ agent 起来了。
现在逐项核对,每项对应一个真实故障模式:

- 60s 内未见 'kernel ready' ⇒ 判失败并给出回滚命令 + journalctl 尾部
- 注册工具条目数(生产应 18 个左右)⇒ 少了说明插件加载异常
- 'LLM API unreachable' 次数 > 3 ⇒ 内核在 rollback 循环
  (生产设了 max_retries=100000,不可达会一直重试)
- ONNX provider 相关日志 ⇒ 缺失时应是明确错误+降级,不静默假装启用

* feat(waiter): 设备命令白名单改为 waiter.yaml 可配置

## 起因

白名单是源码里硬编码的正则(`homeagentAllowCmd`,18 个命令),
而 `waiter.yaml` 里**没有任何键能改它** ⇒ `find` / `grep` / `sed` / `sort` / `tr`
这些排查问题最常用的**只读**命令一律被拒。生产实测:

    device_ctl_cmdrun  device_id:waiter-fnnas  error: command not in whitelist

命令执行完全在 waiter 侧(`device.go` 的 `exec.CommandContext`),插件侧无二次
限制;触发者是 **agent**(经 device_ctl_cmdrun),所以这道闸是机器闸、不是人工确认。

## 改动

waiter.yaml 新增 `device_cmd_allowlist`(字符串数组):

    device_cmd_allowlist:
      - ls
      - find
      - grep
      - sed

- **替换**默认集而非追加:避免"以为加了 find、结果还留着 python3 -c 任意执行"
- 留空 ⇒ 用内置默认集(★ **绝不能变成"全放行"**,那等于静默拆掉闸门)
- 匹配只取命令名**第一段**再整词匹配:`grep -rn x .` 能过,
  而 `grepXxx` / `mygrep` 不会因 contains 蒙混过关;也跳过 `FOO=bar cmd` 的赋值前缀
- `deviceCmdAllowed` 是包级函数变量,由配置赋值 —— 与同文件既有的
  `sendBridgeResult` 同一模式

## ★ 一次真实的疏漏(判据记着)

waiter 有**两条**设备桥启动路径:
- `main.go` 的 `startDeviceBridge` —— 交互/一次性模式
- `daemon.go` 的 `startDaemonDeviceBridge` —— `waiter --daemon`(**生产两台都这么跑**)

我最初只在 `main.go` 里赋值。daemon 路径不经过那里 ⇒ 配置**完全不生效**,
而症状是"配置写了、启动也打了招呼、命令照样被拒",极难定位。
两处都接上了,并加 `TestDaemonPathAppliesAllowlist` 守住。

## 判据(5 条)

- `TestDefaultAllowlistStillBlocksDestructive`  默认集必须挡住
  `rm -rf /`、`dd`、`chmod -R 777`、`mkfs`、fork 炸弹 ——
  **这道闸存在的唯一理由**,谁把它改成"什么都不拦"这条就要失败
- `TestConfigAllowlistExtends`  配置里声明的 `find/grep/sed/sort/tr` 能过;
  配置未含的 `rm -rf /` 仍被拒(证明是"替换"不是"叠加")
- `TestEmptyConfigFallsBackToDefault`  配置为空时回退默认集,**且不放行** `rm -rf /`
- `TestCmdAllowlistFromYAML`  走**真实** `readFile` 解析 yaml(不另写一份解析,
  两处会漂移,而漂移本身就是漏洞)
- `TestDaemonPathAppliesAllowlist`  守住 daemon 路径也应用配置

## 生效方式

106/30 的 `/opt/waiter/waiter.yaml` 追加 `device_cmd_allowlist`,
并更新二进制。启动日志会打印 `device cmd allowlist: N 条(来自 waiter.yaml)`
或 `默认 N 条`,便于确认配置是否真的被读到。

* chore(stress): 加 waiter 远程更新脚本;修 cmp.py 的 docstring 格式

## deploy-waiter.sh:106 / 30 的 waiter 更新

现状(更新前):两台都是 8月27日构建的 /opt/waiter/waiter(11,388,177 字节),
以 root 跑 waiter-remote.service,配置指向 ws://192.168.2.60:9890/…

安全设计:
- 先备份旧二进制(`$BIN.bak-<时间戳>`),失败即回滚(脚本内自动)
- **只换二进制,不动 waiter.yaml**(配置由 deploy 后单独追加)
- **逐台更新并验证,不并行** —— 两台都连同一网关,同时重启会同时断链
- 106 走 admin+sudo、30 走 root
- 验证项:服务 active + 进程时长 + **配置 md5 未变**

用法:`check`(只读)/ `deploy <ip>`(需输 yes)/ `rollback <ip>`

## cmp.py:两处格式

docstring 的 `"""` 紧贴内容、以及函数段之间缺两个空行(PEP8)。纯格式,
无逻辑改动。

* fix(deploy): deploy-waiter 的 ssh 加 -n,否则「喂了 yes 却说已取消」

`do_check` 里的 ssh 会从 stdin 读,把后续 `read -p "确认更新"` 的输入吃掉 ——
于是 `bash deploy-waiter.sh deploy <ip> <<< "yes"` 里的 yes 被 ssh 消耗,
read 拿到空串,脚本静默走「已取消」分支。

症状极难定位:脚本本身完全正常、备份逻辑没问题,只是"明明喂了 yes"却
什么也没发生。5 处 ssh 统一加 -n。

* docs: 两份 toolcall 文档对齐实现与部署实况

## 契约文档:状态头从「尚未实现」改为「已实现并部署」

生产已注册 7 个 `seq_*` 工具,但文档仍写着"设计定稿,尚未实现" ——
实现者(和读者)会以为 seq 还不存在。

新增 §9.3「实现落点」:设计稿 §8 写的是**六个** `seq_*` 工具,实现时
多了一个 `seq_when_call`(跨序列条件调用独立成工具,否则模型要手写
"先 seq_list 再挑目标再 seq_call",多一次往返且容易挑错),并记下三项
设计之外的修正(`seq_create` O(n²)、`Store.List()` 误认任意 `.json`、
存储用 AST 而非原始文本)。

同时点明:设计条款**仍是契约**,实现与本文冲突时以本文为准并修实现。

## 修一处预先存在的失效引用

§7 末尾 `见 §4.5` —— §4 只到 4.4,该小节不存在。改为按标题名引用
(`§4「结果契约」的 ErrToolNotFound 哨兵`):将来增删小节时不会再次失效。

自查脚本第一版把 8 个**存在**的章节误报成失效引用 —— 标题格式是
`## 1. 背景`(编号后跟 `.`),而我的正则要求编号后是空格。判据自己错了,
改成 `(\d+(?:\.\d+)*)\.?\s` 后才得到真实结果。

## 并行计划文档:部署小节 + 白名单专节

- 原「⚠ 部署前置条件(未完成)」改为「✅ 部署(已完成)」,补实际验证数据
- 记下"实例自述没有编排工具"不是说谎:生产二进制构建于 06:36、seq 引入
  于 `69de85a`(更晚)⇒ `strings | grep -c internal/plugins/seq` 为 0。
  这类"实例自述与代码状态不一致"应先查二进制构建时间,别急着怀疑提示词
- 新增「设备命令白名单改为可配置」:起因、替换语义、daemon 路径的疏漏
- 明确写下**已知局限**:白名单只匹配命令名、不看参数,
  `find -delete` / `sed -i` / `sort -o` 仍放行 ⇒ **不要把它叫"只读白名单"**,
  那会让人以为写操作被挡住了
- 记 106 此前无 `online` 日志的成因(旧 waiter 落在未 bind 时收 ping 会断连的
  缺陷窗口),以及 `ssh` 吃掉 `read` 输入导致"喂了 yes 却说已取消"

删掉了初稿里一段"反引号内 `+=` 写进 heredoc 导致赋值落到子 shell"的说法 ——
脚本与 git 历史里都没有这种写法,属凭记忆误记,不能留。

文中数字均与现场核对:seq 工具 7、二进制 86784400 / 12691402、白名单 22 条。

* fix(provider): `has empty arguments` 误报 —— 零参数工具被当成参数丢失

## 现象

部署后生产日志出现 14 次:

    provider.go:414  [provider:llmsproxy] tool_call seq_list (...) has empty arguments

## 根因

原始响应里参数**完好**(从日志扒出来):

    "tool_calls":[{"function":{"arguments":"{}","name":"clawhubadapter_list"},...}]

诊断条件是 `len(tc.Arguments)==0 && tc.RawArguments==""`,而
`parseToolArguments("{}")` 走 string 分支 → `json.Unmarshal("{}", &m)`
成功且 `m != nil`(**非 nil 的空 map**)⇒ 返回空 map ⇒ 命中告警。

被点名的全是**零参数工具**(`seq_list` / `*_list` / `seq_help`,
它们的 `properties` 本来就是 `{}`)。

## 为什么必须修

不是"日志吵"。这条诊断的本职是抓「上游/适配器**真的**把参数丢了」,
真发生时会被这 14 次噪音淹没 —— **诊断日志失去信噪比就等于没有**。

## 修法

新增 `argsLookDropped(rawArgs)`,判 `RawArguments` **原文**而非解析后的 map:

- 空串 / 纯空白 ⇒ 上游没给 arguments 键 ⇒ 真丢
- 能解析成 JSON(哪怕是 `{}`)⇒ 上游确实回了参数 ⇒ 不报
- 解析失败(如半截 JSON)⇒ 参数本身是坏的 ⇒ 等同丢失

## 判据(3 条)

- `TestEmptyArgumentsDiagnosticIgnoresExplicitEmptyObject`  4 个子用例:
  `{}` / ` { } ` / 完全缺失 / 只有空白
- `TestArgsLookDroppedIgnoresNonEmpty`  非空参数一律不报
- `TestArgsLookDroppedEndToEnd`  用**日志里出现过的真实 body** 走
  `normalizeOpenAIToolCalls` 到判定的完整接缝 —— 单测过了但接缝不对
  只有端到端抓得到

写判据时我先用错了类型:拿 `apiToolCall`(**非流式**路径的结构)喂
`normalizeOpenAIToolCalls`,vet 直接报错才纠正为 `openAIToolCall`。
两套结构并存,很容易接错缝。

## 顺带记录:另一个告警不是内核缺陷

`重复申请 stage 锁`(5 次)经排查是**插件侧**问题,内核自愈机制工作正常:

- `proc_main.go.tmpl:1569` SDK 模板在每个 stage handler 入口**自动**调
  `stage.lock`;`lock.go:51` 锁**不可重入** ⇒ 同一次 `before_toolcall`
  被触发两次且首次未释放就命中
- 已排除 qq 业务代码:`beforeToolcall`(plugin.go:1303-1350)只有
  `ctx.Lock()`(SDK **数据**锁,与 proc stage 锁是两把锁)与纯本地调用,
  无任何再次触发 stage 的路径
- 成因在插件进程侧运行时(编译进 9月14日的 `plugin.bin`,**不随 homed 部署**)
- 内核 `stage.go:102-106` 的强制释放是**有意设计**("锁仲裁回内核"自愈,
  实验 9),避免后续插件死锁;`stages.go:258` 把错误收进 `ctx.Errors`
  不中断流程 ⇒ 那轮 212 秒正常跑完

两条结论都写进文档,避免以后有人当内核缺陷去修。

门禁:`-race` 通过,`go test ./internal/... ./cmd/...` 全绿。

* docs: 新增生产部署手册(homed / waiter)

`site-infra-runbook.md` 是**静态站 / nginx / 证书**的手册,不含 homed 与
waiter —— 两次生产部署(19:45 首次、21:50 修复)因此只存在于提交信息里,
查不到。

## 内容

**§1 homed**
- 硬前置:必须 `-tags=onnxruntime`(普通 build 只有 ~28MB,缺 ONNX Runtime)
- ★ **构建参数必须与线上一致**:不要顺手加 `-s -w`。加了产物从 86.8MB 掉到
  78MB,8.8MB 的差会让人误判成"构建坏了",而它只是被 strip 了
- `check` / `deploy` / `rollback` 三条命令与备份位置
- 部署后必核:`multimodal space active: provider=chineseclip` 才是 ONNX
  真加载起来的标志,缺它说明已降级但**不报错**
- ★ 适配器升级的保护语义(`.bundled` 三种情形的判定表),并记 21:50 那次
  实测:手工补过 `stream_index` 的 `openai.lua` 被正确判定为用户修改并保留
- 两次部署的真实数据对照表

**§2 waiter**
- 逐台更新、不可并行(两台连同一网关,同时重启会同时断链)
- 部署后确认 `device waiter-* online`
- 记 2026-09-27 顺手解决的悬案:106 此前无 `online` 而 30 正常,两台配置与
  token 完全相同 ⇒ 差异只可能在旧二进制,8月27日那版落在"未 bind 时收到
  ping 会关连接"的缺陷窗口
- `device_cmd_allowlist` 的替换语义、生效验证、幂等追加方法
- ★ 明确写**白名单只匹配命令名、不看参数**,`find -delete`/`sed -i` 仍能逃
  ⇒ **不要称它为"只读白名单"**

**§3 故障排查**:按"消息没反应 / 命令被拒 / 适配器异常 / 告警是否缺陷"
四条线各给命令;特别标注"群聊 not @bot"与"私聊没回"是两件事

**§4 已知未修**:三项目前是已知限制而非疏漏

文中数字均与现场核对:脚本子命令确实存在(`check`/`deploy`/`rollback`)、
生产白名单确为 22 条、两次二进制大小取自实际部署。

* feat(deploy): 站点部署流水线(SDK 文档站 + introduce)

`deploy-sdk-site.sh` 覆盖两个静态站,与 homed / waiter 是独立部署单元:

- SDK 文档站:本地 `third_party/homeagent-sdk/site_build/` → 106 的
  `/vol1/docker/navi-data/sites/sdk`
- introduce:本地 `site/`(零构建,源即产物)→ `sites/introduce`

用法:`--check`(只核对差异)/ 默认(构建+部署+验证)/ `--rollback <备份名>`。
`--check` 逐字节比对,2026-09-27 核实两站线上与本地产物**完全一致**。

## 更新了一处会误导的注释

脚本原注释写「设备网关白名单只放行 ls/stat/find/cat,打不了包」。
那是 waiter 白名单**硬编码 18 条**时的状况;2026-09-27 部署 3252a7c 后
106 已扩到 **22 条**(含 find/grep/sed/sort/tr/wc/head/tail/stat/file)。

**结论(打不了包)不变,但理由已变** —— 22 条里**没有 `tar`**,
有 `sed` 也不能打包。照旧文字理解会以为白名单只有 4 条。

## docs/zh/deploy-runbook.md 补 §5 站点章节

- 链路:`.60` nginx stream 按 ssl_preread SNI → 106:3080 → navi 容器内 nginx
  (**不是** portal-nginx,那个已 Exited 两周)
- 坑①:构建**必须**走 `tools/apidoc/build.sh`。裸跑 `mkdocs build` 会丢掉
  整个 `api/*.md` 和 `llms.txt` —— 而 `llms.txt` 正是给 agent 直读的入口。
  正确产物 106 个文件,裸跑只有 78 个
- 坑②:打包不能走设备网关(无 `tar`),必须 SSH 直连
- ★ **新增文档后必须更新 `mkdocs.yml` 的 nav** —— 没登记会被 mkdocs 明确
  警告 `not included in the nav configuration`,等于写完了但站点里不可达
- 验证要打**线上**而不是只看本地产物
- 回滚点与失败版本保留策略

写文档时我一度把 introduce 的域名写成「另见 §5.2」,但 §5.2 只讲了 SDK 的
SNI 链路 —— 已改为只写"同台同目录",并补上脚本里有依据的"故意不带 README.md"。

* docs(runbook): 补 §6 部署单元清单,并纠正一次误判的记录

## 补齐漏掉的单元

`cmd/` 下共 7 个可构建入口,服务端只部署 3 个:

| 单元 | 入口 | 服务端部署 |
| --- | --- | --- |
| homed(内核) | `cmd/homed` | ✅ 本机 `/usr/local/bin/homed` |
| waiter / waitercli | `cmd/waiter` | ✅ 106、30 的 `/opt/waiter/waiter` |
| 站点 | `site/`、`site_build/` | ✅ 106 的 `sites/` |
| **GUI** | `cmd/gui` | ❌ **Electron 桌面应用,随客户端分发** |

**GUI 不在服务端部署**(已确认生产无 `*.service`、无进程)。它与 waiter
是两端:GUI 用 `devicebridge_dll.js` 走设备桥协议连服务端 waiter,
`60345a4` 修的正是 GUI 侧 bind 判 ok 与登记状态暴露。
⇒ 排查 GUI 问题要看**用户机器上的客户端**,不是 `homeagent.service` 日志。

另记:核查"有没有进程"时 `ps -ef | grep -c "[e]lectron"` 会把**自己的
grep 命令行**算进去返回非 0(实测返回 2,实际 0)—— 要看列出的内容,别只看计数。

## 本机 waiter 与 106/30 不同步

本机也留了一份 `/usr/local/bin/waiter`,`v1.3.2-153-gff69127`(2026-09-25
构建),**早于** main 在 09-26 合入的那批修复(设备反复掉线、bind 判 ok、
服务端发现自动链接),因而缺它们。106/30 已是 `1.4.0`。
本机这份无进程无服务,不影响生产设备桥;更新方式已记入文档。

## 记一次误判:判定"是否已合入"要按内容查

我曾用 `git merge-base --is-ancestor ff69127 origin/main` 判定"这批提交
没进 main",并推断"存在一条未合入、只靠 reflog 撑着的 5 提交线"。**该结论是错的。**

真实情况:这批改动在 2026-09-26 以**新 hash** 重做并进入 main,五对逐字节一致
(diff 均为 0 行):`1acbd39`→`4b69896`、`3d30482`→`370c648`、
`078517e`→`c2a1781`、`aaafaac`→`6b37b1d`、`ff69127`→`60345a4`。

⇒ 只查**旧 hash 的祖先关系**会误判;同一改动被重做为新 hash 时
   `merge-base` 必然说不包含,而 `git merge-tree` 报的 13 个"冲突"
   正是同源改动做两遍的必然结果,**不能当冲突去解**。

顺带修正一处归因:106 此前长期无 `online` 日志的成因是
`6b37b1d`(设备反复掉线/静默失联:ping 路径断连 + bind 结果无人处理),
2026-09-26 已进 main,今天部署的 `1.4.0` 包含它。

* fix(webui): 修总览 KPI 长期空值(我上轮懒加载改出来的)+星图改银色

### ★ 严重问题:总览页 8 个 KPI 里有 5 个是空的
线上实测(https://homeagent.jianfgit.xyz):
  ["运行中状态","1h 22m 18s运行","0插件","v1.4.0…版本","—LLM",
   "—记忆","—文档","—运行时"]
对照 state:{"status":true,"kernel":false,"settings":0,"runtime":true}

根因是**我上一个提交(a352361 按页签懒加载)引入的**:
总览的插件数/版本/LLM/记忆/文档/运行时全部读 `state.kernel`
(`updateOverview` 里写作 `(k && k.plugins)` 这类安全取值),
而我把 kernel 从 overview 的数据块里删掉了。
⇒ 缺数据时不报错、**只显示「插件 0、记忆 —、文档 —」**,
看起来像「服务坏了」而不是像 bug —— 这类静默降级最难自查。

★ 教训(已写进代码注释):依赖分析必须覆盖**整个调用链**。
我当初只 grep 了 `renderOverview` **直接**读的 state.*,漏了它间接
调用的 `updateOverview`。逐页重核后确认只有 overview 漏配,其余页签
(plugins/settings/kernel 各自要的)本来就是对的。

修复:overview 的 fetch 补回 kernel。kernel 拉过一次后不再重拉
(starmapFetchBlock 的节流),152KB 只在首屏付一次。
实测:["运行中状态","1m 15s运行","17插件","v1.4.0HomeAgent版本",
      "deepseekLLM","1200/893记忆","0文档","39 · 11M运行时"]

### 星图配色改银色(用户裁定)
SM_COLOR_DIM: 0x7d8a9e(灰蓝)→ 0xc8ced8(银色)。
实测 colors: ["7d8a9e"] → ["c8ced8"]

### 关于「页面还是绿色」这个现象
线上内联的颜色实测已是 0x7d8a9e,缓存头也正确
(cache-control: no-cache, no-store, must-revalidate),
浏览器复现同样是灰蓝色 ⇒ 那是**已打开标签页里的旧 JS**:
页面 HTML 变了,但没重新加载的标签页不会自己换。
本提交部署后需**刷新页面**(Ctrl+Shift+R 强刷)才会看到新配色。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gui): SSE 退避计数从不重置 —— 消息流不稳的一个共因

## 现象

用户报 GUI「消息流不稳、动画不连贯、看着卡」,四类症状都有:滞后、
卡顿、闪断、资源高。定位到**同一个**共因,不是四个独立问题。

## 根因 1:退避计数只增不减

`state._sseRetryAttempts` 的自增只发生在 `connectFetchSSE` 的 catch 分支
(连接**建立**失败),而 `pump()` 中途断流后的重连**只读它算延迟,
从不清零**:

    Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 60000)

后果:只要历史上累计过 5 次,**之后每次断连都固定等 32s** —— 哪怕这次刚
成功连上、说明服务端和网络都好好的。而"成功连上"恰恰是最该重置的信号。

修:建连成功后清零(reader 取流之前),断流重连前再清一次。

## 根因 2:外层 60000 上限是死代码

`2^5 = 32s < 60s` ⇒ `Math.min(..., 60000)` 永远达不到,**真实封顶是 32s**。
改为 32000,让声明值与实际一致(判据会验这一条)。

catch 分支那处保留 60000:它语义不同(连接根本没建起来,attempts 已 +1,
退避本就该更长),上限放宽无害 —— 判据按各自语义分别判定,不一刀切。

## 根因 3:401 重试固定空等 800ms

`api()` 里每个 401 都走 `syncConnAuth()` + 固定 `setTimeout(800)` 才重试。
认证过期时**每个**请求白等 0.8s,并发几个就叠加成明显的「卡」。
`syncConnAuth` 本身就是 await 的,返回即代表凭据就绪 ⇒ 降到 30ms
(留一点让 setAuth 的 cookie 落盘)。

真 401 与「门户返回 200 但内容是登录页」两个分支都有这处等待,两处都改。

## 判据:cmd/gui/sse-backoff.test.mjs

`cmd/gui` 无测试框架(package.json 只有 start/dev),app.js 是 203KB 单文件。
判据从**真实源码**提取退避表达式并求值,而不是抄一份逻辑重写 ——
抄写的那份会和真实代码漂移,而漂移本身就是这个判据要防的东西。

3 项 + 变异测试(删清零 / 改回 60000 / 改回 800ms,三次全部被抓到)。

### 判据本身踩的三个坑(都记在文件注释里)

1. **正则两种形态括号数不同**:`Math.pow(2, x)` 比 `2 ** x` 多一层括号。
   早先只按 pow 写,biome 规范化成 `**` 后**静默失配**。
2. **不能用 String.raw 拼接正则**:`\\.` 保持双反斜杠字面量(去找字面的
   "\."),且拼接后**捕获组编号不可控** —— 实测 cap 解出 NaN。
3. **一个正则兼容两种形态会取错捕获组**(组数差 1)⇒ 改为
   **定位与取值分离**:正则只负责定位(不捕获数字),数字单独取。

### 只认 pump 那处上限自洽

catch 那处 `60000` 不可达但无害(语义是"最多等一分钟")。
判据对两处**分别**判:pump 要自洽,catch 只要有有限上限。

## 排查时坐实的两件事

- **`go test ./cmd/gui` 报 `[setup failed]` 不是仓库问题**:`cmd/gui` 有
  **0 个 `.go` 文件**(纯 Electron),Go 通配会跳过它,只有显式点名才报。
  `go test ./...` 实测退出码 0、43 包 ok、0 处提及 cmd/gui。
- **biome 会顺手把 `function () {}` 改成箭头函数**(本次混入 12 行)。
  与本次修复无关,已从 HEAD 干净重放,最终 diff 只有 3 处实质改动
  (24 增 3 删,零无关格式化)。

* test(gui): 行为判据 + 接进 make test(此前无人能跑)

## 为什么加行为判据

`sse-backoff.test.mjs` 检查源码**形状**(有没有清零、上限自不自洽)。
但形状对 ≠ 行为对:把清零写到 `reader` 取流**之后**,形状检查照样通过,
而实际仍在用旧计数重连。

新判据 `sse-backoff-behavior.test.mjs` 从**真实源码**抽出退避表达式并
在 `node:vm` 沙箱里求值,用假状态记录实际等待。实测量化:

    历史累计 8 次后建连成功再断流 → 等 1000ms(改前会是 32000ms)
    连续 6 次「建连成功→断流」  → 1000,1000,1000,1000,1000,1000ms

## 变异测试(都抓到)

| 变异 | 形状判据 | 行为判据 |
| --- | --- | --- |
| 清零挪进 setTimeout 内 | 通过 | **红** ← 只有行为抓得到 |
| 删掉「建连后」清零 | **红** | 通过 ← 暴露了行为判据的盲区 |
| pump 上限改回 60000 | **红** | — |
| 30ms 改回 800ms | **红** | — |

第二行促成了「行为 0」:建连后清零原本不在被验证的路径上
(抽取锚点只抓 pump 那一处),补了独立检查。

## ★ 判据本身踩的坑(都写进文件注释)

1. **别包假 setTimeout**:`exprSrc` 本身就是延迟数值
   (`setTimeout(fn, <延迟>)` 的第二个参数),包一层让结果恒为 null,
   三项全红。
2. **别用 `new Function`**:等价于 eval,是安全反模式。改用 `node:vm`
   的 `runInNewContext`(官方受限环境,拿不到宿主作用域,带 1000ms 超时)。
3. **一个正则兼容两种幂运算形态会取错捕获组**:`Math.pow(2,x)` 比 `2 ** x`
   多一层括号 ⇒ 组数差 1 ⇒ `r[length-1]` 取到 NaN。
   最终形态是**定位与取值分离**:正则只定位(不捕获数字),数字单独取。
4. **`String.raw` 拼接正则不可用**:`\\.` 保持字面双反斜杠(去找字面的
   "\."),且拼接后捕获组编号不可控。

## ★ 这些判据此前没有任何入口会跑

`cmd/gui` 是**纯 Electron 目录**(0 个 `.go`、无 `go.mod`),Go 通配会
跳过它 ⇒ 判据挂着也没人执行。现已接入:

- `cmd/gui/package.json` 加 `"test"`
- `Makefile` 加 `test-gui` 目标,并挂进 `test`
- 无 node 时显式 SKIP 而不是静默通过

```bash
make test-gui     # 或 cd cmd/gui && npm test
```

## 顺带说明

`go test ./cmd/gui` 报 `no Go files [setup failed]` **不是回归**:
该目录 0 个 `.go` 文件,只有**显式点名**才报。仓库门禁用的三种形态
(`go test ./...`、`go test ./cmd/...`、Makefile 的 `test`)全部通过。

* docs(make): 记下 proc grandchild 测试本身不稳定(现象,未下根因)

`make test` 的后置验证里发现 `internal/plugin/proc` 会 FAIL,实测形态:

- 单独跑**同一命令**:`ok 11.5s` / `FAIL` 交替出现(至少各一次)
- 全量并发跑:曾 600s 超时(`panic: test timed out after 10m0s`),
  也曾 90s 就 FAIL
- 失败测试固定是 `TestKillReturnsEvenWhenGrandchildSurvives`,
  伴随日志 `[proc] audit 退出: signal: killed`

与本次改动(73e1261 SSE 退避、47aad60 判据)无关联:改的是
`cmd/gui/renderer/app.js` 与判据脚本,没碰 `internal/plugin/proc`。

症状**像** fork/kill 的进程组语义在容器/并发下不稳(孙进程 setsid
脱组后杀不掉 ⇒ Wait 挂死),但**尚未定位到根因**,因此 Makefile 里
只记现象、不下结论。

★ 记这一条是因为我自己先踩了坑:一开始连跑 3 次全绿,我就准备写
「单跑稳定、仅并发偶发」;紧接着同一命令又 FAIL 了。**单跑通过不能
当结论** —— 这类 fork/kill 测试必须重试才能给出可信判断。

* fix(make): test-gui 不再被前序失败短路(门禁曾形同虚设)

## 问题

`make test` 原本是 make 的**依赖链**:

    test:
    	$(GO) test ./...
    	@$(MAKE) test-gui
    	@$(MAKE) csrc-test
    	...

`go test ./...` 一旦 FAIL,make **立即中止** ⇒ 挂在它后面的目标一行都不跑。

实测坐实:`internal/plugin/proc` 偶发 FAIL 时,`make test` 的日志里
**找不到 test-gui 的任何输出** —— 刚接进去的 GUI 判据根本没被执行。
门禁挂上去等于没挂。

## 为什么不能简单用 `-@$(MAKE) test-gui`

`-` 前缀会**吞掉 GUI 判据自己的失败码** —— 判据红了 make 照样绿,
等于给假绿灯。那比短路更糟:它让人以为门禁在生效。

## 做法

全部跑完,最后统一判退出码:

    test:
    	@rc=0; $(GO) test ./... || rc=$$?; \
    	 $(MAKE) test-gui || rc=$$?; \
    	 $(MAKE) csrc-test || rc=$$?; \
    	 $(MAKE) check-csrc || rc=$$?; \
    	 $(MAKE) check-codec-cgo-only || rc=$$?; \
    	 exit $$rc

既保证每一步都跑,也保留每一步自己的失败。

## 验证(注入失败实测,不是推断)

往 `TestKillReturnsEvenWhenGrandchildSurvives` 里塞 `t.Fatal` 制造必然失败:

| 验证项 | 结果 |
| --- | --- |
| `make test` 退出码 | **2**(非 0,失败被上报) |
| proc 包 FAIL | 抓到 |
| **GUI 判据输出** | **仍执行** ← 这是要证明的那件事 |
| csrc-test | 也执行了 |
| 汇总 | ok=43 FAIL=1 |

随后已恢复该测试文件(`git checkout` + 确认 0 处残留 `t.Fatal`),
并复跑该包确认 ok 10.6s。

全绿路径也验过:`make test` 退出码 0、ok=44 FAIL=0、GUI 判据执行。

* feat(watch): 站点漂移巡检(只读,有差异才提醒)

`site-drift-watch.sh` 巡检 192.168.2.106 上的两个站,**只读不动**:
不构建、不上传、不碰线上任何文件。档位是「有差异就提醒」而不是
「自动推」—— 文档站发错了是公开可见的,宁可等人点一下。

判三类信号:
1. 线上漂移:.106 上的站 ≠ 本机产物/site 源(逐字节 md5 清单比对)
2. 源码漂移:git HEAD 比产物新 ⇒ 提交了但没重新构建部署
3. 探活失败:.106 或 NapCat 挂了(比文档漂移紧急)

不复用 `deploy-sdk-site.sh --check`:那脚本输出是给人看的彩色文本,
拿来当机器判断依据太脆(改个文案就失效)。md5 清单逻辑很短,
这里复刻一份并在注释里指向 deploy-sdk-site.sh 保持同步。

## `.drift-watch/` 加入 .gitignore

里面的 `state` 是运行时状态(含时间戳与指纹,每次跑都变),不该入库。

## 提交前核实(两处我的检查写错了,脚本本身没问题)

- 我查"凭据"命中 1 处 ⇒ 实为注释里的「烧 token」,指 LLM token 非密钥。
  真实密钥形态(`sk-*` / `password=`)**0 处**。
- 我查"部署调用"命中 6 处 ⇒ 全是**注释与提示文本**(提醒人去跑
  `deploy-sdk-site.sh`)。实际执行 `rsync`/`scp`/调用部署脚本**均 0 处**,
  `rm -rf` 0 处 —— 与它「只读」的声明一致。
- `set -uo pipefail` 少了 `-e`:**有意为之**。巡检要在某项检查失败时
  继续跑完其余项,`-e` 会中途打断。实测跑一次四项全绿、无差异。

* docs(runbook): 记「判断线上跑哪次构建」的正确判据(我今天差点白部署)

## 线上其实已经是修好的版本

去部署 `eee774b`(webui 总览 5 个 KPI 空)前核实,发现:

    /api/v1/status → {"commit":"eee774b", "startedAt":"2026-09-27T23:23:39"}

23:23 那次部署**不是我做的**(我只在 21:50 部署过),已经包含
`eee774b`。`/api/v1/kernel` 里 5 个 KPI 依赖的字段也全部有值
(`plugins` 39 个、`llm`/`memory`/`documents`/`runtime`/`onnx` 非空)。

⇒ **不需要部署。**

## ★ 为什么 `strings` 不能用来判断

webui 等插件的静态资源是 `//go:embed` **编译进二进制**的
(`internal/plugins/webui/handler.go:27`),内容取决于**构建时**磁盘上的
文件。⇒ 已提交但未部署的改动,线上二进制的 `strings` 里**也可能**
出现新代码片段。

我据此差点白重启一次生产。同一天还撞上**字节数完全相同**的巧合
(86811464),更掩盖了这点 —— 大小相同更让人以为"没变化,不用管"。

## 正确的判据顺序

1. `/api/v1/status` 的 `commit` —— 线上在跑什么
2. `git log <commit>..HEAD` —— 差哪些提交
3. 那些提交里**有无运行时改动**(`internal/`、`cmd/`)—— 只有它需要部署
4. 文档 / 判据 / 部署脚本类提交**不需要**部署

## ⚠ 那条命令必须带认证头

无认证时 `/api/v1/status` 返回**登录页 HTML**(200 + `THEME_PLACEHOLDER`),
`grep '"commit"'` 匹配不到 —— 看起来像"命令没输出",实际是认证缺失。
与 GUI 客户端 `api()` 专门检测 `THEME_PLACEHOLDER` 是同一件事。

## 顺带:内置插件 vs 独立二进制

`internal/plugins/<name>/` 编译进 homed;`plugins/<name>/plugin.bin`
是独立插件,要单独构建部署。**目录存在不等于有独立二进制** ——
`plugins/webui/` 目录存在但 **0 个文件**,走内置。

实测(2026-09-28):`webui`/`cmd`/`seq` 内置,`qq` 独立。
改内置插件只需重编 homed。

* fix(gui): 401 重试无限递归 —— 我上一个优化放大的 bug

## 真机实测发现的

Xvfb + Electron + CDP 真跑,发现 `api()` 的 401 分支**无限自我递归**:

    第1次: lock=false → 重登 → lock=false → return api()   ← 递归
    第2次: lock=false → 重登 → lock=false → return api()   ← 又递归
    …

那把锁的语义本该是「已经重登过一次,别再登」,但它在递归**之前**就被
清掉了 ⇒ 每层递归看到的都是 `false`。真机实测 **fetch 被调 13 次、
重登 12 次**才被我的探针上限截断。

## 为什么这与我的上一个提交直接相关

`73e1261` 把 401 分支的固定等待从 800ms 降到 30ms(修「认证过期时每个
请求白等 0.8s」)。但重试**没有次数上限** ⇒

- 改前:每 800ms 慢速空转
- 改后:每 30ms 快速烧 CPU + 反复打服务端

**我的优化把这个 bug 放大了。** 真机上探针调用 `api()` 直接挂死,
我起初还以为是我的测试写法问题。

## 修法

两处递归点(真 401 / 门户返回 200 但内容是登录页)都改成:

    try {
      return await api(p, o);
    } finally {
      window._haReloginLock = false;
    }

`finally` 保证递归抛错时也释放锁 —— 否则会把后续所有请求都锁死成直接 401。

## 判据 retry-guard.test.mjs

在 `node:vm` 沙箱里跑**真实抽出的 `api()`**,用恒回 401 的 `fetch` 驱动,
统计真实调用次数。

### 写这条判据时踩的四个坑(都记在文件里)

1. **先给了假绿灯**:把 401 分支当独立函数体执行,但那块以
   `return api(p,o)` 结尾、外面没有调用它的上下文 ⇒ 我从未真正进入那个
   `if` ⇒ `api 递归=0` ⇒ 什么都没测到。改成跑**真实 api()** 才对。
2. **递归时忘了保持 `r.status===401`**:真实场景是「重登后凭据仍是错的」。
   漏了它 ⇒ 递归那层不进 401 分支 ⇒ 又一次假绿灯。
3. **沙箱 `setTimeout` 只记录不执行**:`api()` 用它做超时控制
   (`setTimeout(() => ctl.abort(), to)`)⇒ AbortController 永不被 abort
   ⇒ 表现为「fetch 只调 1 次、8000ms 被当成 401 等待」。
4. **沙箱缺 `clearTimeout`** ⇒ 抛 `clearTimeout is not defined` ⇒
   整段在 fetch 之后就断了 ⇒ 永远走不到 401 分支。

★ 共同点:**沙箱不完整 ⇒ 静默地什么都没测 ⇒ 假绿灯**。
判据自己给假绿灯比没有判据更危险。

### 变异测试(精确锚点,验证判据真能抓)

把第一处 try/finally 退回「递归前清锁」⇒ 判据立刻红
(`fetch 被调 13 次`);恢复后全绿。

★ 第一次做这个变异时我误判「判据漏抓」—— 实际是我的变异脚本用了模糊
锚点、**压根没改到文件**(`grep` 显示 return await 从 2 变 1,但 `sed`
命中的是另一处)。两个信号矛盾时先坐实文件状态,别急着改判据。

## 顺带把 `make test` 的门禁修好(47aad60 / 0ca2891 之外)

新判据已接入 `npm test`,实测 14 项通过、`make test-gui` 全绿。

* test(webui): WebAPI 只读端点压测 + 实测 2160 请求零失败

打的是**生产实例** 127.0.0.1:8080。

## 为什么只压只读端点

压测绝不能改状态。18 个端点**逐个探测确认**是 GET + 只读:
小(/status…/proxy/services,104B–1.8KB)、中(/plugins…/knowledge,
4.4KB–53KB)、大(/kernel 157KB、/memory/graph 338KB、/knowledge/tree 425KB)。

**排除**:/chat(真调 LLM)、/chat/interrupt(中断在跑的任务)、
/settings/* 与 /plugins/*(改配置)、/login /logout(改会话)、
knowledge/memory 写接口、/device/*(控制真实设备)。

## ★ 两条判据(都不是"看有没有报错")

### 1. 必须先 `--probe`:webui 无认证时返回 200 + 登录页 HTML

含 `THEME_PLACEHOLDER`,**状态码是 200**。只看 `http_code` 会把登录页
当成健康响应 ⇒「全部 200」是假的。脚本因此额外校验响应体。

### 2. 端点表漏了 `/api/v1` 前缀 ⇒ 18 个端点全 404

第一版把端点存成路径后半段(`"/status"`),拼出
`http://127.0.0.1:8080/status`,而真实路径是 `/api/v1/status` ⇒
**全部 404**,同一时刻 curl `/api/v1/status` 却是 200。

⇒ **压测脚本必须先 probe 再压。** 不 probe 的话那一跑的结论会是
「webui 全挂」,完全错误。这条已写进手册。

## 实测(2026-09-28)

| scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 360 | 779 req/s | 6.7ms | 26.8ms | 45.5ms | 51.6ms | 360/360 |
| 2 | 720 | 788 req/s | 12.2ms | 52.6ms | 163.0ms | 213.5ms | 720/720 |
| 3 | 1080 | 1038 req/s | 16.4ms | 60.4ms | 79.7ms | 123.0ms | 1080/1080 |

**2160 请求零失败**;压测期间服务端 `active`、0 个 5xx、0 个 webui 错误,
QQ/agent 链路未受影响,homed CPU 仅 1.2%。

### 重响应不是瓶颈(并发 1 → 16)

| 端点 | 大小 | 并发 1 | 并发 16 | 吞吐 |
| --- | --- | --- | --- | --- |
| /status | 0.2KB | 104 req/s | **1781 req/s** | 320KB/s |
| /kernel | 153KB | 126 req/s | 375 req/s | 19 → **57 MB/s** |
| /memory/graph | 330KB | 70 req/s | 410 req/s | 23 → **135 MB/s** |
| /knowledge/tree | 415KB | 79 req/s | 717 req/s | 33 → **297 MB/s** |

大 JSON 端点的 p50 **不随并发上升**(/knowledge/tree 12.3ms → 8.9ms,
排队更充分、效率更高)⇒ 瓶颈不在 JSON 序列化。

## ⚠ 一处未下结论的观察

scale=2 的 p99(163ms)反而**高于** scale=3(79.7ms)。看着反常,但当时
机器上另一个 agent 的 chromium 占 **766% CPU**(另有 cjpm/cjc 在编译)
⇒ 是**环境噪声**。

**未在可比条件下重测就不下结论** —— 不拿这一组当性能特征。要判定需先
固定负载条件再跑。这条也写进手册。

## 顺带

ruff 报的三条 blocker 都修了:`pct()` 空样本不再抛错(调用方直接拿去做
f-string 格式化)、写 JSON 失败明确报错而非静默丢报告、错误体读取用
`contextlib.suppress` 免得二次异常盖掉真正的状态码。

* test(webui): 压测补齐另外 3 个插件的路由 —— 我第一版漏了两个

第一版只压了 webui 的 `/api/v1/*`。核实后发现 **8080 上注册 HTTP 路由的
插件有 4 个,监听端口还不止 8080**(`ss -ltnp` 实测):

| 端口 | 插件 | 路由 | 认证 |
| --- | --- | --- | --- |
| 127.0.0.1:8080 | webui | /api/v1/* | config_webui.api_key |
| | remotedevice | /api/v1/device/* | config_remotedevice.ws_token |
| | kbtree 子路径 | /api/v1/knowledge/tree/{categories,counts} | config_webui.api_key |
| 127.0.0.1:9876 | **pluginmgr** | /plugins(**无** /api/v1) | **无需认证** |
| 127.0.0.1:9892 | **kbtree** | /categories /counts(**无**前缀) | config_kbtree.token |
| 127.0.0.1:9890 | remotedevice | 设备 WS 网关(非 REST) | 不压 |

⇒ 「打 /api/v1/*」这个假设只对 8080 上的 webui 成立:`:8080/plugins`
实测 **404**。三套认证各不相同,脚本现在按分组取对应 token。

## 端点 18 → 24

新增:`/api/v1/device/online`(remotedevice)、`/plugins`(pluginmgr)、
`/categories` `/counts`(kbtree 根路由)、以及 webui 前缀内的
`/api/v1/knowledge/tree/{categories,counts}`。

收 `/api/v1/device/online` 之前逐行读过实现:仅 `GET` +
`registry.OnlineList()`,纯读。**不收** `/device/push`(向真实设备下发)、
`/device/ws`(长连接)、`/device/{id}`(语义未核实)。

## 实测(24 端点 × 3 档,2880 请求零失败)

| scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 480 | 953 req/s | 4.0ms | 23.0ms | 34.7ms | 40.6ms | 480/480 |
| 2 | 960 | 1310 req/s | 7.9ms | 32.4ms | 48.4ms | 72.3ms | 960/960 |
| 3 | 1440 | 1422 req/s | 12.0ms | 42.7ms | 60.5ms | 88.7ms | 1440/1440 |

压测期间服务端 `active`、0 个 5xx。

**p99 随并发单调上升**(34.7 → 48.4 → 60.5ms),符合排队预期。
上一轮曾出现 scale=2 的 p99 反常地高于 scale=3,当时机器上另一个 agent 的
chromium 占 766% CPU ⇒ 环境噪声,已明确不作为性能特征。

## 又踩了一次前缀的坑(两种方向都踩了)

1. 「表里存路径后半段 + 代码按 group 补前缀」⇒ remotedevice 拼成
   `/api/v1/api/v1/device/online` ⇒ 落到 webui 兜底路由、
   返回 **200 + 登录页 HTML**(靠 `looks_like_login_page()` 抓到)。
2. 反过来「表里已含前缀 + 代码仍补」⇒ webui 组全 404。

⇒ 最终统一成**表里写完整路径、代码不补**。两次都是靠 `--probe` 抓到的,
这正是它存在的理由。

## 顺带修掉我写错的一处

`contextlib.suppress(sqlite3.connect)` —— `connect` 是函数不是异常类,
`suppress` 会抛 `TypeError`。改回显式 `try/except sqlite3.Error`,
并把 `config.db` 的打开方式保持 `mode=ro`(压测不碰生产库写路径)。

## 文档里我自己写错又改正的一处

§7 表格里 scale=1 一行先写成 1680/2013/5.4ms,核对 JSON 后实为
**480/953/4.0ms**(24 端点 × 20 轮)。已订正,并加了提交前的
JSON 逐项比对,三档现已全部一致。

* style(readme): 接受流水线的 markdown 格式化

三处纯格式,**无语义变化**:
- 嵌套列表 `+ ` → `- `(渲染效果相同)
- 表格分隔符 `|---|---|---|` → `| --- | --- | --- |`

## 为什么要提交而不是继续丢弃

仓库**没有 biome 配置**(无 biome.json),格式来自流水线默认。
⇒ 它每次跑都会把 README 与 cmd/gui/renderer/app.js 改成 dirty 工作区。

我这次会话里反复 `git checkout -- README.md cmd/gui/renderer/app.js`
把它丢掉,**那是在和流水线对抗,纯属白费力气**:每次提交后它又变 dirty。

提交后 biome 再跑就是 no-op ⇒ 永久不再 churn。

★ 教训:可自动复现的格式化,正确做法是**接受并单独提交**,
而不是每次手动回退。回退只是把冲突推迟到下一次提交。

* test(proc): 修 grandchild 测试的三个设计缺陷(不是生产代码问题)

## 定位结论

`TestKillReturnsEvenWhenGrandchildSurvives` 曾在 `go test ./...`(600s 超时)
与 `make test`(20.4s FAIL)里失败,但**单跑 0.24s 通过**、连跑 3 次全绿
⇒ 「单跑绿、合跑红」。查下来是**三个测试设计缺陷**,
生产代码(`process.go`)没问题。

### 缺陷 1:名字说 Survives,实际测的是「被杀」

| 测试 | 源 | 孙进程 | kill(-pgid) 能杀吗 |
| --- | --- | --- | --- |
| …EvenWhenGrandchildSurvives | grandchildPluginSource | sleep 400,**不**设 Setpgid | **能** |
| …WhenGrandchildEscapesProcessGroup | escapingGrandchildSource | sleep 401 + Setsid | **不能** |

`grandchildPluginSource` 自己的注释写着「孙进程**不**设 Setpgid:它要留在
插件的进程组里」⇒ 第一个测试里孙进程不会 Survive。容易让人误以为
「脱组场景已被覆盖」,而它覆盖的是另一个场景。

**已改名** `…WhenGrandchildDiesWithProcessGroup`。

### 缺陷 2:判据数的是全系统进程

两个计数器扫 `/proc` 找 `"sleep 400"` / `"sleep 401"` 字符串,
**不区分父子关系** ⇒ 同机任何命中同样 cmdline 的进程/容器都串味。

原注释记过一次前车之鉴(「我第一版就踩了:明明单跑通过,合跑却红」),
但当时只加了 base 快照,**没解决全局匹配这个根因** —— base 救不了
「别的测试中途拉起 sleep 400」。

**已修**:新增 `procPPid()`,两个计数器都限定 PPid 属于本测试的插件。
顺带补 `e.Name()` 的 `Atoi` 校验(原来会把 /proc/self、/proc/net 也读一遍)。

### 缺陷 3:defer 清理「拿不到 pid 就整个跳过」

`if pid := pluginPid(p); pid > 0 { Kill }` 在 pid 取不到时静默跳过
⇒ 残留 sleep 400 污染后续测试 ⇒ 变成下一个测试的假失败。

**已修**:新增 `cleanupSleepMarkers(marker)`,按唯一 cmdline 标记兜底清理。

## ★ 我被推翻的一个假设

我一度认定根因是 `waitLoop` 里 `p.cmd.Wait()` **先阻塞**、拆管道在**之后**
(`process.go:359-367`)—— Go 的 `exec` 里 `Wait()` 会等 copy goroutine,
而那些要等所有管道写端关闭,孙进程持有着 ⇒ 死锁。

**实测推翻了它**:把拆管道提到 `Wait` 之前,那个测试 **5 次全 FAIL**
(改前只是偶发)。真正的根因是上面三个测试设计问题;「Wait 阻塞」只是
**被孙进程持管道放大**的效应。

⇒ 改生产代码不但没修好,还把偶发变成必现。**先证明因果再动手。**

## 判据:grandchild_design_test.go(4 条)

★ 它是**查源码文本**的,我一改源文件(改名/加 ppid 限定/加兜底清理),
锚点就全过期 ⇒ 三条判据一起红。

⇒ 判据自己被重构打断时,要改的是**判据的锚点**(认新旧两种形态),
不是回退修复。最后把判据①从「解函数体比对 spawn 参数」简化为
「只问名字是否还说 Survives」—— 少耦合一层,少失效一处。

变异测试三个都抓到:改名回 Survives / 抽掉 ppid 限定 / 去掉兜底清理。

## 门禁

- 全量 `go test ./...`:**43 包 ok、0 FAIL**
- `internal/plugin/proc` 连跑 **5 次全绿**(原来会红的地方)
- `go test -race ./internal/plugin/proc/`:ok
- 无 sleep 400/401 残留

## 顺带

`5d528af` 之后 README 的 biome 格式化不再 churn:仓库**没有** biome 配置,
格式来自流水线默认 ⇒ 每次提交后它都会把工作区改脏。我这次会话里反复
`git checkout --` 把它丢掉,那是和流水线对抗。**提交后 biome 再跑就是
no-op**,问题根除。

* perf(gui): 聊天页增量渲染 + 修「打开不在最新消息」(10× 提速)

用户报「聊天页面卡得让人没有用的欲望」+「打开 app 和 webui,没有停在
最新消息处,还要反复滑动」。真机实测(Xvfb + Electron + CDP)定位到两个
根因,都修了。

## 根因 1:renderChat 每次全量重建 innerHTML

200 条消息 = 3500 个 DOM 节点全部销毁重建。拆分测量:

    200 条:整体 234.6ms,其中 renderMd 25.4ms(**11%**)

⇒ markdown 渲染只占 11%,**89% 在 DOM 写入与布局**。
流式追加时每个放行的 chunk 都走这条路(200 条时每 chunk 6.6ms),
聊到几百条就是 0.5 秒/次 —— 这就是体感。

**改法**:按 `data-msgkey` 复用节点,四种策略按代价从低到高:
尾部追加(最常见)→ 头部前插(loadOlderChat)→ 局部替换 → 兜底整棵重建。

`data-msgkey` = role + 序号 + 内容长度 + 首尾片段。
★ **不能靠下标定位**:`loadOlderChat` 会 `unshift` 前插消息,下标整体位移。

实测:

| 消息数 | 改前 | 改后 | 改善 |
| --- | --- | --- | --- |
| 50 | 59ms | **8.2ms** | 7.2× |
| 200 | 235ms | **23.5ms** | 10× |
| 400 | 474ms | **38.2ms** | 12× |

关键是**次线性**了:400 条只比 200 条多 15ms(改前多 240ms)。

## 根因 2:滚动没落地

    scrollTo 被调: 1, 参数: {top: 23446, behavior: "smooth"}
    scrollTop: 0            ← 调了,但没生效
    可滚动上限: 22838

smooth 立即值 0、300ms 后只到 6894(上限 22838)⇒ **既慢又没到位**;
手动 `scrollTop = scrollHeight` **立即 22838 一次到位**。

原因:紧邻的 DOM 全量变更让 smooth 动画的起点算在**旧**布局上。
重建后本就不该有动画 —— 用户要的是「立刻看到最新」。

**改法**:`msgsEl.scrollTop = msgsEl.scrollHeight`。

## 判据 chat-perf.test.mjs(10 条,真 Electron 跑)

新增 `npm run test-live`(需 Xvfb + electron,**不进 make test** ——
它要起真浏览器、30 秒启动,不适合当门禁)。

- 性能:200 条 < 100ms(**产品体感阈值**,不是 benchmark 数字)
- 次线性:单条成本不随规模上升
- 滚动:打开即在底部、300ms 后不被带偏
- **正确性:增量不丢消息**(5 种增删改路径)—— 比性能更重要

### 写判据时踩的坑(都记在文件里)

1. **第一版测出「0ms / 0 DOM 节点」** —— `buildChatLayout()` 在**无后端连接**时
   走「请先添加连接」分支、聊天区压根没建 ⇒ **测不到**而非「不卡」。
2. **`ensureGui` 定义了但从未被调用** —— 重写文件时把调用丢了,
   而定义还在,看起来一切正常。
3. **`detached: true` 只脱离进程组、不脱离会话** —— 脚本结尾 `process.exit()`
   把刚起来的 GUI 带走(症状:`[tray] READY` 打了,判据却报「找不到页面」)。
   改用 `setsid`。
4. **`JSON.parse(e.data)` 裸调** —— CDP 的 onmessage 也会收到非 JSON 帧,
   抛在回调里既冒泡不到 await 也等不到 resolve ⇒ 整个判据挂死。
5. 我自己的滚动探针 `el.innerHTML=''` 让 `scrollHeight` 变 0,
   「到位」判定是假象。改用「保留内容、只改滚动方式」重测。

## 变异测试

把 `applyIncrementalChatRender(msgsEl, html)` 改回 `msgsEl.innerHTML = html`
⇒ 判据立刻红(实测 268ms + 严格线性)。

## 门禁

- `npm run test-live`:10/10 通过
- `npm test`、`make test-gui`:全通过
- `go test ./...`:43 包 ok、0 FAIL

* feat(gui): 协议对齐 —— 补齐 6 个只读诊断端点 + 人设/反代面板

GUI 原来只用 22 个端点,服务端有 47 个。补齐**只读诊断类**:
agents / network / tracker / config / persona / proxy(+services)。

## 刻意不接 /login 与 /logout

那是 cookie 会话认证流程,而 GUI 走 `X-API-Key` 头(见 `api()`)。
接了不是"对齐",是接错。

## 三处新增

**总览诊断卡片**(renderDiagPanel):Agent 健康、LLM 可达、网络端点数、
文件变更集、数据目录、心跳间隔。全部走 `(x && x.y)` 安全取值 ——
任一端点没取到(老内核无该路由、连接断开)只显示 "-",不抛错。

**人设面板**(renderPersona):实测结构是
`{current_prompt, file_override, initialized}`。我第一版按 map 遍历,
结果只会显示三个字段名 —— 真机验证时才发现。改成展示提示词全文 +
两个状态卡。**只读**,编辑涉及保存/回滚/并发覆盖,与"协议对齐"是两件事。

**反代面板**(renderProxy):base_domain / mode / total / manual +
服务列表(`✓ gateway → 127.0.0.1:9890`)。实测字段是
`{name, host, path, url, target, ok, auth, websocket}`,不是我第一版假设的
`subdomain`。

## 加载策略

只加进 `refreshAll`,**不加** `refreshDataOnly`(后者每 15 秒一轮,
诊断数据不必高频轮询)。7 个新端点实测只 +3ms。

## 判据 protocol-align.test.mjs(10 条,真 Electron)

- 7 个 state 槽都取到真实数据
- 总览出现诊断卡片
- 人设面板**真的显示提示词内容**(不只查元素存在)
- 反代面板**真的列出服务**(查 `→` 出现)

### 判据踩的三个坑

1. **默认假 key 导致 9 项全红** —— webui 对错误凭据返回 200 + 登录页 HTML
   (`looks_like_login_page` 能识别),于是所有取数失败。看起来像
   「代码坏了」,实际只是认证缺失。改为默认从 `config.db` 读真 key。
2. **判据绕过了应用路径** —— 表达式里直接调 `refreshDiagData()`,
   于是把应用里的 `await refreshDiagData()` 注释掉,判据**仍全绿**。
   改为走应用自己的 `refreshAll()`。
3. **变异后 GUI 仍加载旧代码** —— `ensure()` 看到端口有页面就复用,
   注入变异后没重启 GUI ⇒ 又一次假绿。**变异测试必须先杀掉 GUI 进程。**

变异测试(注释掉调用点)⇒ 9 项变红,坐实判据验的是真路径。

## 门禁

- `npm run test-live`:22/22 通过(12 性能+滚动 + 10 协议对齐)
- `npm test`、`make test-gui`:全通过
- `go test ./...`:43 包 ok、0 FAIL

* chore: 排除 SDK 仓的 skills/;清掉误建的 --help/ 目录

## skills/ 不该进本仓

SDK 仓新增了 `skills/`(hmapdev skill install 的源),本仓的
`.gitignore` 已排除 `tools/`、`docs/`、`example/`、`package/`、`scripts/`
等 SDK 仓自治范围,唯独漏了新加的 `skills/`。

`skills/` 是**文档**(skill 说明),归 SDK 仓管 —— 本仓经 go.mod 的
`replace` 只引用它的**编译必需文件**(sdk/*.go、go.mod、meta/meta.go),
文档不在其中。

⇒ 这不是新规则,是把已有规则的适用范围补齐。

## 误建的 --help/ 目录

我在验证 skill 时跑了 `hmapdev init demo`,但那次实际执行的是
`hmapdev init --help` 之类 —— `--help` 被当作**目录名**,在仓库根生成了
一个含 `go.mod`/`plg.json`/`plugin.go` 的 hmapdev 脚手架。

`plg.json` 里 `"name": "--help"` 坐实了这点。已删除。

★ 顺带记一条本机特性:`/tmp` 是 **tmpfs(内存盘) 只有 653M 可用**,
而 SDK store 有 4 个版本、备份要几 G ⇒ 大文件备份必须放 `/var/tmp`。
(这轮第一次备份就撞了 `No space left on device`。)

* ci: 建立 GitHub Actions 流水线(六个 job,全部命令已本地实测)

## 为什么现在做

这次排查「QQ 收不到回复」花了大半程才定位到根因,途中我犯了两类错:
先断言「提示词没写 output_send 规则」(实际 buildSystemPrompt:48-52 写了),
又断言「适配器丢了内容」(实际两版等价、直连上游正常)。
两次都是**在无自动化判据的情况下凭局部证据外推**。

仓库已有 60 个 Go 包、`go build ./...` 仅 2.2 秒,成本极低却无人强制跑。
AtomGit 停用流水线后更无兜底,故迁到 GitHub 补齐。

## 设计原则:CI 里每条命令都是本地已实测通过的

不写「可能有用先试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态,
最后所有人都学会忽略它。本文六个 job 的每条命令都本地跑过:

  go build ./...                      ✓
  go vet ./...                        ✓
  go test ./... -count=1              ✓(干净克隆亦通过)
  make check-client-versions          ✓
  go test -race core + waiter         ✓
  waiter/initconfig/mock-server 交叉  ✓(5 平台)
  npm test(cmd/gui)                 ✓
  make check-csrc                     ✓(告警/ABI/ASan+UBSan/跨架构)

## 六个 job

| job   | 覆盖                                                     |
|-------|----------------------------------------------------------|
| go    | build + vet + test + **跨平台客户端版本一致性**           |
| race  | 并发核心的竞态检测                                        |
| cross | linux/darwin/windows × amd64/arm64(仅可纯交叉的 3 个 cmd)|
| gui   | Electron 仓的 Node 测试                                   |
| csrc  | C 基础设施门禁                                            |
| docs  | 站点配置可解析                                            |

## 关键事实(都由实测确立,不是推断)

1. **只有 waiter/initconfig/mock-server 能纯交叉编译**。homed、memgc、
   homed-kb-migrate 依赖 cgo(gojieba / onnx),必须原生构建 ⇒ 不进 cross matrix。
2. **CGO 必须为 1**:gojieba 需要 cgo,`CGO_ENABLED=0` 下 internal/memory
   直接编译失败(实测)。
3. **`go test ./...` 不会碰到 cmd/gui**。该目录是纯 Electron(0 个 .go、
   无 go.mod),`./...` 只匹配含 Go 文件的包;只有显式 `go test ./cmd/gui`
   才报 "no Go files"。这不是缺陷,是 Go 的包匹配语义 —— 之前把它当
   [setup failed] 是误读。
4. **cmd/gui 的 npm test 零依赖**:三个 .mjs 只 import node: 内置模块
   (fs/url/path/vm)⇒ 不需要 npm ci、不需要 electron,秒级完成。
5. **测试自足,CI 上不会因缺本地服务而红**:webui 测试用 httptest 与
   `127.0.0.1:0`,真实 LLM 测试带 t.Skip 守卫。
6. **action 版本已核实存在**:checkout/setup-go/setup-node/setup-python 均用
   v7(经 GitHub API 逐个确认 tag 存在,避免「版本不存在 ⇒ 立刻红」)。

## 明确不进 CI(依赖真机/密钥/内网,否则只会变 flaky 噪音)

deploy-*.sh、waiter 真机(192.168.2.x)、`npm run test-live`(需真 Electron
+ Xvfb + 真后端)、scripts/kernel-stress/*、需 DEEPSEEK_API_KEY 的真实 LLM 测试。

* fix: 修 CI 抓到的两类真实缺陷(.syso 破坏 arm64 + 测试硬编码 /etc)

第一次 CI 跑出 2 类失败,都是**本地以 root/amd64 跑永远看不见**的问题。
这正是建 CI 的价值:换一个环境就暴露了。

## 一、.syso 无条件被链进所有平台 → arm64 交叉编译必炸

CI 报:
  $WORK/b001/_pkg_.a(waiter.syso): 310766: unknown ARM64 relocation type 3
  (linux/arm64 与 darwin/arm64 两个 job 都红;amd64 两个都绿)

根因(已在本地用 Go 1.25.9 + arm64 精确复现):Go 会把**同目录的 *.syso
无条件链进任何 GOOS/GOARCH**,而这两个 .syso 是 Windows 资源对象
(x86-64 COFF,只含 .rsrc 图标段)。链进 arm64 目标即报「未知 ARM64 重定位」。

仓库其实**早就知道**这件事 —— deploy/packaging/build.sh:86-90 写着
「Go 会把同目录的 .syso 无条件链进任何目标」,并留了 hide_syso_for_target()
绕过,注释还点名「这正是 arm64 产物长期缺失的原因(曾被误判为缺 g++
交叉编译器)」。但那是打包脚本里的私有绕道:任何**直接 go build** 的路径
(包括 CI、包括本机原生 arm64 构建)都仍会撞上。修在源头而不是再加一层绕道。

修法分两种,因为两个文件的处境**完全不同**:

1. cmd/waiter/waiter_windows_amd64.syso(原 waiter.syso,git mv)
   waiter **仍支持 Windows**(package-windows.sh:68 明确构建 waiter.exe),
   所以不能删。按 Go 的文件名约定加 _windows_amd64 后缀 ⇒ 只在
   windows/amd64 被链入。实测:linux/amd64、linux/arm64、darwin/arm64、
   windows/amd64 四平台全部通过,且 Windows 产物的 .rsrc 段大小
   (00049eb8 字节)与改动前**逐字节一致** —— 图标没丢。

2. cmd/homed/{homed.syso,homed.rc} 删除
   homed 的 Windows 原生支持**已放弃**,五处独立来源一致:
     - README.md:251「homed 放弃 Windows 原生支持改走 WSL2」
     - cmd/homed/platform_windows.go 的 requireSupportedPlatform 直接拒绝启动
       (理由是设计性的:fd 继承 + 同段内偏移解引用,Windows 句柄模型无法表达)
     - package-windows.sh:4「❗安装器不往 Windows 装 homed」
     - build.sh:35「Windows 不再安装 homed.exe」
     - installer.nsi:230「homed 不再装到 Windows」
   即 homed.exe 即便构建出来也拒绝运行 ⇒ 图标资源毫无意义,却是 arm64
   构建失败的来源之一。顺带查明:homed.syso 与 waiter.syso 本是**同一个
   blob**(两个 .rc 指向同一 icon),属纯重复。

★ 由此留下一处**未修的残留**(已确认,不在本次范围):installer.nsi:297,313
  仍在创建指向 homed.exe 的快捷方式与 Run 注册项,而同文件 230 行已声明
  homed 不装 Windows。那是 Windows 安装器的独立缺陷,需单独处理。

## 二、internal/system 测试硬编码 /etc → 非 root 必失败

CI 报:
  system_test.go:51: expected archive to happen
  system_test.go:97: expected restore to happen

测试写死 target := "/etc/xxx.test.tmp" 并**忽略了 os.WriteFile 的错误**。
GitHub Actions runner 以非 root 运行 ⇒ 写 /etc permission denied ⇒ 文件
不存在 ⇒ ArchiveBeforeWrite 按「新建文件无需留档」返回 false ⇒ 断言失败。
本地以 root 跑则一路通过 —— 缺陷因此长期不可见。

修法:用仓库**已有**的 SetProtectedPaths([]string{临时目录}) 显式声明受保护
前缀(不再碰真实 /etc),defer SetProtectedPaths(nil) 复原默认。既去掉了对
root 的隐式依赖,也没有削弱被测语义(保护的仍是「受保护前缀下的文件」)。

## 验证

- go test ./... -count=1        全绿
- go build ./...                通过
- waiter 四平台交叉编译          全通过(含此前必红的 arm64)
- homed linux/amd64 原生构建     通过(确认删除 .syso 无害)
- Windows 产物 .rsrc 段          改动前后一致(00049eb8 字节)

* ci: 发布流水线 —— release/** 推送即发版(tag/打包/发布/镜像全自动化)

## 设计

版本号唯一事实源是 internal/meta/meta.go 的 Version(仓库纪律),
所以发版动作 = 在 release/vX.Y.x 上把 meta.Version 改成目标版本后推送:

  prepare      读版本号;tag 已存在则整轮跳过(幂等闸门,改文档不会重发)
  build-linux  go build + go test 过门 → 下载资产 → 打包 3 deb + 1 tar.gz
               → 平铺 → 验证(deb 元数据/模型在位/校验和自验)→ artifact
  publish      打 tag → gh release create 传附件 → 回读下载验证校验和
  sync-gitcode 有 GITCODE_TOKEN 时同步 tag+附件到 gitcode(无则跳过不阻断)

## 关键事实(全部本地实测过才写进 workflow)

1. **编译不需要 ONNX Runtime**:onnxruntime_go 是 dlopen 方式(运行期才
   加载 .so),本地在清空 ORT 相关环境变量的条件下带 -tags=onnxruntime
   编译通过(83M)。CI 只需在**打包**时有 ORT(要打进 deb)。
2. **构建资产托管在 release ci-assets-v1**(已上传):
   chinese-clip-vit-b16-onnx.tar 719MB + onnxruntime-linux-amd64-1.28.0.tar
   24MB + SHA256SUMS。模型内容不随版本变 ⇒ 一次上传反复复用,CI 打包前
   下载并 sha256sum -c 校验。上传实测 3.2MB/s,构建期下载同源更快。
3. **打包链路在干净 worktree 全程实跑通过**(release/v1.3.x + VERSION=1.3.13):
   full 800M / server 726M / client 80M / tar.gz 841M,SHA256SUMS 平铺自验
   4/4 OK,full 包内确认含 TextEncoder/VisionEncoder.onnx 与 libonnxruntime.so。
4. **SHA256SUMS 的坑**:脚本把校验和写成平铺名(./xxx.deb),而产物在
   deb/ tar/ 子目录 ⇒ 直接 -c 会全 FAILED。workflow 里显式平铺后再验。
   (呼应 git-branching.md §七「校验和必须覆盖全部附件、只传一次」。)
5. ORT 资产补齐了缺失的 LICENSE + ThirdPartyNotices.txt(取自
   microsoft/onnxruntime v1.28.0 tag,与本地 .so 的内嵌版本号一致)——
   打包脚本的 stage_multimodal_assets 对这两文件非空校验,缺失即失败。
6. actionlint 全绿(修掉了 shellcheck SC2012:ls 改 stat 循环)。

## 已知边界

- arm64 发布产物暂缺(package-linux.sh 支持,但 CI 未配 QEMU 交叉;待需要时加 matrix)。
- Windows 安装器未纳入(需 electron-builder win 打包,单独验证后接入)。
- sync-gitcode 依赖 secret GITCODE_TOKEN(待用户配置;未配置时该 job 显式跳过)。

* ci(release): go test 门可显式跳过(默认仍严格)

## 问题(试发布实测暴露)

把 CI/Release 带到 release/v1.3.x 后,CI 三个 job 全红,但**每个失败都是
该分支自身的旧状态,与改动无关**:

| job | 失败原因 | main 上 |
|---|---|---|
| GUI (node) | `npm error Missing script: "test"`(1.3.x 尚无该脚本)| 正常 |
| Go test | TestRealPlugin_DeepSearchKeepsSharedBackendOnStop | **通过** |
| C gates | exit 2(1.3.x 无 csrc 基础设施)| 正常 |

⇒ 给已存在的发布线补新流水线 = 用今天的门去量旧代码。硬门会让该历史
维护线**完全无法发版**,正是用户要的「推 rel 分支就出产物」被挡死。

## 改法

拆开两道门,语义不同:

- `go build ./...` —— **硬门**,不可跳过。产物不可能建立在编译失败的代码上。
- `go test ./...` —— 默认跑,但可跳过。两个来源:
  1. workflow_dispatch 的 `skip_tests` 输入
  2. **发版 commit 里写 `[skip-release-tests]`**

第二个来源是关键:决定落在**定义该次发版的那个 commit** 里,`git log` 可审计,
而不是一个随手勾的开关。跳过时输出 `::warning` 注释,让后果在 run 页可见。

## 未决(留给用户)

`release/v1.3.x` 的 deepsearch 测试失败属该线既存状态(main 已修)。是否把它
cherry-pick 回 1.3.x 属产品决策(1.3.x 是历史维护线,main 已是 1.4.0),
故本次不擅自拉回绿,只提供显式跳过通道。

actionlint 全绿。

* fix(packaging): find 在 set -euo pipefail 下致命退出 —— 任何无 electron 缓存的机器都打不出包

## 症状

CI 发布在「打包」步骤失败,输出停在:

    >>> Building GUI directory for linux/amd64...
      npm install...
      electron 版本取自 package.json 依赖声明: 33.0.0(非精确)
    >>> Restoring original go.mod...
    ##[error]Process completed with exit code 1.

没有错误信息,看不出真因。build_go 之前已全部成功(homed 80M 带 onnxruntime)。

## 根因(本地精确复现 + bash -x 追踪)

    + zip=$(find "$HOME/.cache/electron" -name "electron-v33.0.0-linux-x64.zip" | head -1)
    + zip=
    + restore_all          ← 直接退出

脚本是 `set -euo pipefail`。`find` 对**不存在的目录**返回退出码 1,
pipefail 让 pipeline 返回该 1,而 `set -e` 对**赋值语句里的命令替换**同样生效
⇒ 整个脚本当场退出。

实测退出码对照:
    x=$(find /不存在 | head -1)               → 1(脚本死)
    x=$(find /不存在 | head -1 || true)       → 0(存活)
    x=$(find /不存在)                          → 1(无管道也死)

⇒ 任何**没有 ~/.cache/electron 的机器**(全新克隆、CI runner、其他开发机)
都会撞上。本机一直「能打包」只是碰巧有那份 646M 缓存。

## 修法

给 4 处 find 加 `|| true` 兜底(214/217 electron 缓存、623/625 rpm_deb)。
另修 206 行 `[ -n "$ever" ] && echo ...`:`ever` 为空时该列表返回 1,
在 set -e 下同样会杀死脚本 —— 改为 if 形式。

★ 注意 608 行(fpm 查找)**早就有** `|| true`,说明这个模式被意识到过,
只是漏了这几处。属同一类缺陷的补全,不是新引入的写法。

## 验证

- 复现:移走 ~/.cache/electron 后 `package-linux.sh amd64 build` → 修复前 exit=1
  (输出与 CI 逐行一致),修复后 exit=0 且打印明确原因:
  `WARNING: electron binary not found at ... GUI will be skipped.`
- 有 electron 时仍正常构建:GUI 263M / x86-64(走 host-arch 回退路径)
- bash -n 与 shellcheck -S error 均通过

* ci(release): 打包前装 Electron,否则 GUI 被静默跳过

## 问题

打包脚本从两处找 Electron 运行时:
  1) ~/.cache/electron 里的 electron-v<ver>-linux-<arch>.zip
  2) cmd/gui/node_modules/electron/dist(目标架构 == host 时)

全新 GitHub runner **两处都没有** —— 脚本在都没有时只能跳过 GUI,
于是 client/full 包会**静默地不含界面**(正是脚本作者担心的「假包」)。
实测:本地移走缓存后 GUI 被跳过,包仍能产出。

## 改法

打包前在 cmd/gui 执行 `npm ci`,让 electron 落到 node_modules。
runner 是 amd64 == 目标架构,脚本便走第 2 条路径。

**用 npm ci 而不是 `npm install electron@<range>`**:后者是非确定性的
(range 会随上游漂移,也锁不住传递依赖),zizmor 也把它标为
adhoc-packages 风险。package-lock.json(lockfileVersion 3)已锁定
electron,ci 严格按 lock 安装 ⇒ 同一 commit 永远得到同一套依赖。

不能用 `npm install --production`:那会跳过 devDependencies,
而 electron 正是 devDependency(这正是脚本自己那条命令找不到它的原因)。

## 验证

- 无声 cache + 有 node_modules/electron 时,脚本走 host-arch 回退并
  成功构建 GUI(263M / x86-64)—— 已本地实测
- 两者都无时改为打印明确原因并跳过(配合上一个 commit 的 find 修复)
- npm ci --dry-run 通过;actionlint 全绿

* ci(release): [skip-release-tests] 标记改查发版提交,不再查 HEAD

原实现用 `git log -1 --pretty=%B`(即 HEAD)找标记。但发版提交之后
往往还会跟几个提交(同步 workflow、改文档、修脚本),HEAD 一移动,
标记就被顶掉 —— 跳过机制**静默失效**,流水线又回去撞旧线的红测试。

改为查**改动 internal/meta/meta.go 的那个提交**(语义上正是「发版提交」):
  REL_COMMIT=$(git log -1 --format=%H -- internal/meta/meta.go)

顺带把 grep -qF 换成 case 匹配,避免多层引号嵌套。

自测(本地 worktree):
  发版提交 23a98f1 → 命中标记 ✓
  HEAD      df0d4b0 → 不命中(旧逻辑会在此静默失效)

actionlint 全绿。

* ci(release): 修 gitcode 同步的路径拼法(原写法必然找不到文件)

upload_assets.py 的路径语义是 `os.path.join(ASSET_DIR, name)`,
而原写法先 `cd dist` 再传 `./*.deb` ⇒ 拼成 `dist/dist/...`,
必然 "资产目录不存在" 或逐个 skip。token 一配上就会炸,属隐患。

改为:cd 进资产目录 + `ASSET_DIR=.` + **不传文件名**(让它扫描当前目录,
.deb/.tar.gz/SHA256SUMS 都在它的产物白名单里)。

SDK 侧另有一处同类问题,但那里**必须显式列名** —— hmapdev 的产物多数
没有扩展名(只有 windows 那个是 .exe),自动扫描会静默地一个都不传。
已同步修在 SDK 仓的 workflow 里。

* ci(release): 验证产物改用 >/dev/null 而非 grep -q —— SIGPIPE 误杀检测

## 问题(第二次试发布实测)

打包成功后,「验证产物」步骤失败:

    tar: stdout: write error
    dpkg-deb: error: tar subprocess returned error exit status 2

而三个 deb 的元数据其实已全部正确打印(Package/Version/Architecture)。

## 根因

`dpkg-deb -c <800M 的 full 包> | grep -q <模型文件>`:

grep -q 匹配到目标行后**立即退出**、关闭管道读端 ⇒ dpkg-deb 内部的
tar 继续写 stdout 时收到 EPIPE ⇒ pipefail 判整条 pipeline 失败。

⇒ 检测项本身是好的(模型确实在包里),却被检测手段误杀。

本地用 CI 上同一个 800M full 包复现:
    grep -q 版    → dpkg-deb: error: tar subprocess was killed by
                    signal (Broken pipe)
    >/dev/null 版 → 通过

client(80M)没炸、full(800M)炸 —— 包越大越容易触发(内容越多,
grep -q 提前退出的窗口越大)。这正是它没在本地小规模测试里暴露的原因。

## 改法

检测存在性时用 `grep <pattern> >/dev/null`(读完整个输入再退出),
不用 `grep -q`。顺带补了 server 包的模型在位检测(原来只测了 full)。

* ci(release): gh release download 需显式 --repo(非 git 目录无法推断)

## 症状(第三次试发布)

Build 全绿、tag 已建、release 已建、2.40GB 附件全部上传成功 ——
唯独最后一道「回读校验」失败,整个 workflow 因此标记为 failure:

    failed to run git: fatal: not a git repository (or any of the
    parent directories): .git
    ##[error]Process completed with exit code 1.

## 根因

回读校验为了"下一份干净副本"先 `cd /tmp/back`,那里不是 git 仓库。
而 `gh release download` 默认从**当前目录的 git 上下文**推断仓库与
host(GITHUB_REPOSITORY / GH_HOST 之类环境变量不足以让它跳过推断),
于是报 "not a git repository"。

## 修法

    gh release download "$TAG" --repo "$GITHUB_REPOSITORY"

两个仓的 workflow 都有同一处(主仓 + SDK),一并修。

## 顺带

`third_party/homeagent-sdk/.github/` 加入 .gitignore —— 与 skills/ 同类:
SDK 仓自己的 workflow 由 SDK 仓跟踪管理(那边已跟踪),本仓不参与
构建,不需要在这边重复一份。未加规则时它会出现在本仓的未跟踪列表里。

* docs(ci): CI/CD 流水线手册 —— 用法、机制与 6 个踩过的坑

仓库迁 GitHub 后新增两条流水线(ci.yml / release.yml),但用法与机制
此前只存在于 workflow 的注释和提交信息里。发版是高频操作,写成手册。

内容:
- §1 CI 六个 job 与「明确不进 CI」的清单(真机/密钥/内网依赖)
- §2 发版标准流程、幂等闸门(tag 存在即跳过)、
     发版门(go build 硬门 + go test 可用 [skip-release-tests] 跳过)
- §2.5 构建资产(ci-assets-v1)的托管与升级方式
- §3 六个实测踩过的坑:
      3.1 workflow 文件必须存在于目标分支(否则推 release/** 不触发)
      3.2 runner 无 electron 缓存 ⇒ 必须 npm ci(且不能用 --production)
      3.3 管道里的 grep -q 因 SIGPIPE 误杀检测(800M 包必炸)
      3.4 gh 在非 git 目录要显式 --repo
      3.5 gitcode 上传的 ASSET_DIR + 裸名语义
      3.6 手工补发 gitcode 附件的流程
- §4 SDK 仓的差异(独立 module 测试要跑两处、CGO 不需要)
- §5 关于失败邮件的说明(可能是验证步骤自身 bug 的假警报)

markdownlint 全绿(两个产物清单代码块补了 text 语言标注)。

* chore(watch): 下线站点漂移巡检脚本

用户指示「那个脚本直接去了吧」:site-drift-watch.sh 每半小时巡检一次两个文档站
的产物漂移,但实测 5 次告警**全是源码漂移误报**(判据只比时间戳,而产物生成后的
提交都不影响文档站),线上漂移与探活失败从未报过 —— 信噪比太差,去掉损失很小。

调用方核查:只有 root crontab 的 `7,37 * * * *` 一条,systemd 无 unit,
脚本/Makefile/.github 里零引用。crontab 已按行备份并只删该两行(其余三条任务保留),
脚本本体备份在 /root/backups/ 可随时还原。

本提交只删仓库内的脚本文件;crontab 与运行环境侧的调整已在部署时完成。

* chore: 用 .mailmap 归并虚拟贡献者身份

GitHub 的 Contributors 列表按**提交邮箱**归并身份,而本项目历史里同一个人的提交来自
多个邮箱(本地 root、个人 QQ、gitcode noreply、GitHub noreply、dev@local 等),
于是列表里冒出 5 个并不存在的「协作者」:
  root@qq.com(135) / root@qq.com(51) / 2198972886@qq.com(33+4)
  dev@local(14) / root@minecraft-server(10) / 109188060+JianFeeeee@...noreply(19)
加上真实身份,7 条里只有 1 条是人。

.mailmap 只影响**展示层**(git shortlog / git log --use-mailmap / GitHub Contributors),
不改写任何提交对象 —— 历史 SHA 全部不变。归并后:
  git log --use-mailmap → JianFeeeee(715) + HomeAgent Agent(19)

HomeAgent Agent <agent@homeagent.local> **刻意不归并**:agent 的自动提交保留独立身份,
便于区分人类提交与自动化提交。

* docs: README 对齐 1.4.0 现状,并移除看板娘

四处过时事实:
  1. 「v1.3.x 线(v1.3.1–v1.3.12,最新已发布)」→ 补上 v1.3.13,并按 main 的实际内容
     新增 v1.4.x 段落(同轮工具并行、seq 序列编排、结果只统计不裁剪、结构化错误契约、
     设备命令白名单、GUI 增量渲染)。这些是 MAIN 上已合入但 README 从未提及的特性。
  2. 「内置 18 个插件」×2 → 实际 20 个(漏了 seq / kbtree 等)。
  3. 6 处 gitcode 链接 → GitHub(仓库已迁至 github.com/JianFeeeee/HomeAgent,
     SDK 为 github.com/JianFeeeee/homeagentsdk)。gitcode 仍作国内镜像保留。
  4. 移除「看板娘 / Web Mascot」整段(中英文各一处)—— 按用户要求。

三张架构图按代码重画(此前图上缺了 1.4.0 的核心机制):
  - 图一「消息处理时序」:tool 分支改为**批**语义 —— batchRunnable 三条判据
    (批内 >1、全部 ParallelSafe、无同通道重复发送)、可并发/整批降级两条路、
    以及「按声明序收尾 + 同通道保序」。
  - 图二「Stage 管道」:④⑤ 拆成并发/串行两条边,并注明并行只影响执行时序,
    post_action 与 after_toolcall 的可见顺序不变。
  - 图三「三层记忆」:补上**场面识别(声明 + 涌现)**子系统 —— 指纹权重、
    归属/唤起阈值、origin=declared|emergent、用进废退衰减。

另加一段说明:agent 自己的回复也写回记忆(context.Append(Source:"agent")),
因此它能读到自己先前的结论并主动纠正 —— 这是记忆召回的自然结果,
内核里**没有**任何名为「反思」的机制(避免把涌现行为误读成已实现的特性)。

* ci(pages): 介绍站的 GitHub Pages 镜像

主站仍自托管在 NAS(192.168.2.106),国内内网直连毫秒级;GitHub Pages 提供
海外可达性与灾备。两边同源(同一个 site/ 目录),不存在谁是「真身」。

发布前的完整性检查是必要的:Pages 是静态托管的,**缺文件不会让部署失败**
(照样 200 + 404 页),所以「引用存在但文件没提交」这类问题必须在这里拦住 ——
检查 index.html 引用的每个 assets/ 资源都实际存在。

三个 action 版本已逐个查 GitHub API 确认真实存在(checkout@v7 与仓库其余
workflow 保持一致;configure-pages@v5 / upload-pages-artifact@v4 / deploy-pages@v4)。
workflow 已过 actionlint,检查逻辑已在本机原样跑通(3 个文件、165778 字节、
引用清单 assets/logo.svg)。

注:Pages 需在仓库设置里把 Source 选为「GitHub Actions」后本流水线才会真正发布。

* chore(site): 介绍站同步 1.4.0,并迁移 gitcode → GitHub 链接

1) 链接迁移:33 处 gitcode.com → GitHub(20 处 SDK、13 处主仓),0 残留。

2) 补齐 1.4.0 特性:「真正的 AgentOS」四张卡(隔离/调度/通信/资源)之外,
   新增第五张「并行 —— 同轮的工具,一起跑」,把声明式并发安全与
   整批降级这两条关键语义写清楚(这是当前版本最重要的架构变化,
   而站点此前一个字都没提)。

3) 插件列表与实际 SDK 示例对齐:补 ai_image(v1.3.0) / files(v1.0.0) /
   luademo(v0.1.0) 三张卡与对应徽章(23 项)。版本与描述取自各自 plg.json。
   注意 mc / homeagent-mail-bridge **不是**过期项 —— 它们是真实插件,
   只是源码不在 SDK 仓,各有自己的链接。

已用共享浏览器(CDP)在 file:// 下实测:徽章 23 个、点 ai_image 能正确
切换出卡片(可见性检查通过)、第五张卡渲染正常、页面内 gitcode 残留为 0。

* chore: 排除 pi-lens 对 cmd/gui 的 Go 测试误报

cmd/gui 是纯 Electron/Node 目录:0 个 .go 文件、无 go.mod、`go list ./...`
匹配 0 个包,它的测试入口是 package.json 里的 `node *.test.mjs`。

但 pi-lens 的 test runner 在写文件时按**仓库主语言**(Go)为所在目录跑测试,
而 cmd/gui/*.test.mjs 被识别成测试文件 ⇒ 它对一个纯 Node 目录执行
`go test ./cmd/gui`,必然得到 `no Go files ... [setup failed]`,
却被报成「测试失败」。本会话内重复触发 13 次,每次都要人工复核一遍。

排除触发源(而非关闭 tests.enabled —— 那是**全局**开关,会一起削弱所有真实
项目的测试网)。.mjs 的真实运行方式是 `npm test`,CI 的 GUI job 已在跑。

上游缺陷:runner 应先确认目录内存在该语言的源文件,再决定是否执行。

* fix(deploy): 站点比对判据被坏 diff 静默架空(假绿灯)

deploy-sdk-site.sh 的 7 处本地比对用的是**裸 `diff`**,而本机 PATH 首位是
/opt/huawei/harmonyos/ohos-sdk/linux/toolchains/diff —— 它对**任何**输入都
返回 0 且无输出。后果不是「偶尔报错」,而是:

    --check 永远打印「✓ 线上与本机源逐字节一致」
    ⇒ 永远判定「无需部署」⇒ 站点改完再也不会被更新,且完全看不出来

实测凭据(本次):site/index.html 本地 f8898cfc…、线上 cc615cfa…,
两边**确实不同**,而 --check 报「逐字节一致」;用两个必然不同的小文件
(A / B)验证,坏 diff 退出码 0、/usr/bin/diff 退出码 1。

修法(两层):
  1. 钉绝对路径 DIFF=/usr/bin/diff,7 处本地比对全部改用它;
  2. **启动自检**:拿两个必然不同的输入验证一次,若它仍报「无差异」
     就直接退出,而不是继续拿一个坏判据去做部署决定。
     (同源的教训:「探针不红先怀疑探针」——判据本身坏了,
       它给出的「没问题」毫无意义。)

远端那一处保持 `diff` 并在旁边写明理由:远端是健康的 /usr/bin/diff,
不在本机这个坏 PATH 的影响范围内。

顺带补两个选项:
  --introduce         只部署 introduce 站。改 site/ 时用 —— 原 do_deploy 会先把
                      SDK 文档站重建一遍(apidoc + mkdocs,106 文件),而 site/
                      的改动跟它毫无关系,既慢又把没必要碰的线上站卷进变更面。
  --rollback-introduce 对称的回滚入口(原先只有 sdk 站能回滚)。

验证:--check 现在能正确报出差异(附带两份 md5 对照);--introduce 走完
「先比对→打包→备份→原子替换→回验」,线上 md5 与本地一致,http 200。

* docs(deploy): 手册补「本机 diff 是坏的」—— 比对判据的硬前置

这条已骗过两次(openai.lua、两版 release.yml),今天又骗了第三次,且这次
后果最重:它把部署脚本的比对判据整个架空成假绿灯。

补进手册 0.1 节(放在拓扑之后、各部署章节之前,因为它是所有比对的前提):
  - 结论:一律 /usr/bin/diff 或 cmp,不要裸 diff
  - 三条可自证真伪的单行命令(含 cmp 未被污染这个事实)
  - 为什么比「偶尔报错」危险:部署判据假绿灯 ⇒ 站点永久不再更新
  - ★ 判据自检的通用写法(拿必然不同的输入验判据,不过就退出)
  - 同类信号表:diff 说无差异但 wc -c 说大小不同 / 报一致但线上明显是旧的 /
    测必然不同的样本却报相同
  - 远端 diff 健康,但嵌在 ssh "…" 里要分清本地还是远端执行

* docs: 补上最后两处历史 SDK 链接的迁移(v1.1.0 tag)

README 里还剩 1 处、README_EN 里 1 处指向 gitcode.com 的 SDK v1.1.0 release 链接。
核查后确认:GitHub 的 SDK 仓**只有 tag 没有 release**(releases 数为 0),
所以不能照抄「releases/tag/...」的路径 —— 改指 tag 页 tree/v1.1.0,
实测 HTTP 200(gitcode 侧同样仍可用,两边都保留可达性)。

至此两份 README 的 gitcode 链接归零。

* docs(ci): 记录 GITCODE_TOKEN 已配置及其验收方式

原 3.6 节只写「未配置时该 job 显式跳过」,读起来像是一直没配。补上实际状态:
两仓 secret 均已配置(值取自 ~/.git-credentials 的 gitcode 条目),
配置命令走 stdin(避免 token 出现在进程列表),以及三道等价验收 ——
因为 sync job 只在**新版本**发版时运行(prepare.outputs.exists == 'false'),
历史 tag 触发不了,无法用旧版本实跑,所以要用等价命令验:

  ① gh secret list 确认 secret 在
  ② /api/v5/user 确认 token 有效
  ③ 带 private-token 头读 release 确认 job 用的鉴权方式可用(两仓都测)

并记下一条待改进项:该 token 是**宽范围个人令牌**(可读 92 仓/48 私有、有写权限),
而 CI 只需这两个仓;更稳的是换一枚仅限这两仓的令牌,把 CI 泄漏的影响面收窄。
当前按用户 2026-09-29 的决定保持原状。

* docs: 主仓 README 补在线文档入口(介绍站 + SDK 文档站)

与 SDK 仓同一个问题:主仓 README 的「文档」章节只列了仓内 markdown
(assets/docs/...),没有任何**在线**地址 —— 而主仓 README 正是绝大多数人
的第一入口,从它走不到介绍站与 SDK 文档站。

改为两段并列:
  **在线文档**:介绍站 + SDK 文档站(含 llms.txt / llms-full.txt 的 agent 直读入口)
  **仓内文档**(随代码版本走):原有的 OVERVIEW / ARCHITECTURE / PLUGIN_DEV 等

分开标注是有意的:在线站与内核版本**不严格对齐**(站点按发布节奏更新),
而仓内文档跟代码走。写清楚归属,读者才知道该信哪个。

中英文两份同步;两处 README 的 7 个 URL 全部实测 200。

* docs(ci): 记录 cmd/gui 假告警的根因与本机补丁(手册 §6)

每轮编辑后 pi-lens 都会报 `FAIL ./cmd/gui [setup failed]`,看着像仓库有测试红了,
实为工具缺陷。本会话内重复触发 20+ 次,每次都需人工复核,故完整记录并修掉。

根因(两个缺陷叠加):
  1. pi-lens 的 runner 按**仓库根**检测(本仓有 go.mod ⇒ go),但被测文件可能
     在另一种语言的项目里;`cmd/gui/*.test.mjs` 命中通用测试命名 ⇒ 对纯 Electron
     目录生成 `go test -run . ./cmd/gui` ⇒ 必然 "no Go files" 失败。
  2. 该失败进入进程内 failedTestsByRunner,而 failed-first 策略此后**每次**编辑
     都优先重跑它(与当前编辑的文件无关);条目只在测试**通过**时移除 ⇒ 永不自愈。
     日志形态:turn_end: README.md → test go cmd/gui/sse-backoff.test.mjs (failed-first)

为什么不能靠配置关掉(都实测/读码确认):
  - `.pi-lens.json` 是项目级,只认 ignore/rules/maxProjectFiles/reviewGraph/trivy
    + 三个改动开关;`tests` 是全局级,写进去会被忽略并告警。
  - 全局 `{"tests":{"enabled":false}}` 会一起关掉**所有项目**的回合末测试反馈,
    为一个仓库的误报付全局代价 —— 不值。
  - `ignore` 也挡不住:它只作用于扫描,不参与测试目标选择。

修法:给 pi-lens 的 getTestRunTarget 加一道「该 runner 真能跑这个目标吗」的校验
(go:目标目录至少有一个 .go 文件),不能跑就拒并清掉那条不可运行的失败记录。
runTestFileAsync 只有这一个调用点,是唯一收口。

验证:node --check 过语法;/usr/bin/diff 核对为纯新增零删除;
真实路径模拟 6/6 符合预期(cmd/gui 的 .mjs → 拒;真 Go 测试文件 → 放行);
go test ./... 仍 43 包全绿。

注意:补丁在 ~/.pi/agent/npm/... 里,pi-lens 升级后会被覆盖(届时误报会回来,
不影响仓库,只是噪音)。备份 index.js.orig-*,回退方式写在本节。

本手册同时补上 §6.1 症状 / §6.2 根因 / §6.3 配置为何无效 / §6.4 修法与回退。

* fix(usage): 接通缓存命中与推理 token —— 适配器此前一个字节都不透传

用户实测「无法统计缓存命中与 token 消耗」,这是对的。设计意图一直是
「Lua 适配器透传上游用量,内核估算」,但**流式路径上透传那一步从未落地**。

## 三层各自的问题(都不是同一个 bug)

**① 适配器层(生产路径,最严重)**
`transform_stream_chunk` 只搬 content/tool_calls,从不读上游的 usage:
- `openai.lua`:整段没提 chunk.usage;且 `choices` 为空时 `return ""`
- `anthropic.lua`:`message_start` 直接 `return ""` —— 而 input_tokens 与
  cache_read_input_tokens **只在这一帧**;`message_delta` 返回的 unified 不含
  usage —— 而 output_tokens **只在这一帧**

后果:生产流式请求的 `StreamChunk.Usage` 恒为 nil。而 Go 侧的分支是
「Lua 结果非空且不同 ⇒ 采用 Lua 结果 ⇒ 不再走回退解析」,所以适配器不透传就没有
任何补救路径。

**② Go codec 层**
`chunkUsage` 声明了 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` /
`prompt_tokens_details.cached_tokens`,但 `chunkAssemble` 只拷 prompt/completion/total
⇒ 「解析了却不返回」。这是回退路径,也是纯 usage 心跳帧(choices 为空)实际走的路。

**③ 累积器层**
`resp.TokenUsage = *ck.Usage` 是一句**直接覆盖**。对「只在末帧报一次」的协议
(OpenAI include_usage)恰好正确,所以长期看不出问题;但 Anthropic 分两帧报,
后帧的零值会把前帧的 prompt 与缓存计数清掉 —— 而这不报任何错,只是数字小一个量级。

## 修法

- **适配器**:新增 `usage_to_unified`(两条路径共用),10 个适配器全部透传;
  纯 usage 心跳帧不再整帧丢弃。键名对齐 `agentAPI.TokenUsage` 的 json tag。
- **Go codec**:`chunkAssemble` 提取缓存与推理字段;`CacheReported` 区分
  「上游报了缓存但 0 命中」与「上游没报缓存」—— 混起来会把无数据画成 0% 命中率。
- **累积器**:改为**逐字段合并**(新帧非零值赢),`CacheReported` 用或(一旦为真不撤销)。

## 两个容易搞错的口径

1. **Anthropic 的 `input_tokens` 不含缓存**(真实总输入 = input + cache_read +
   cache_creation),而 OpenAI 的 `prompt_tokens` 含。统一按 OpenAI 口径,
   否则同一字段在不同后端下含义不同,统计无法横比。
   直接搬 input_tokens 会少算通常最大的那块,让缓存收益看起来不存在。
2. **缓存写入(cache_creation)归未命中侧**:它要花钱但不算 hit,
   算成 hit 会把命中率抬高。

## 顺带修

`ollama.lua` 的 transform_response 把用量写在 `usage` 键上,而 Go 侧
`CompletionResponse` 的 json tag 是 `token_usage` ⇒ 那份用量**被静默忽略**
(缺字段不报错,只是永远取零值)。这是同一形态的「算了却不返回」。

## 判据(8 条,全部先写后实现)

- `internal/agent/api/tokenusage_cache_test.go`:OpenAI v2 details / DeepSeek 遗留 /
  **ReportedZero 与 Absent 必须可区分** / reasoning_tokens / 无 usage 帧仍为 nil
- `internal/lua/adapter_usage_test.go`:anthropic 两帧 + openai 内容帧 + 心跳帧
- `internal/agent/core/tokenusage_merge_test.go`:跨帧合并 / CacheReported 不会被
  后续无缓存帧抹掉 / 单帧全量行为不变

## 观察(未在本提交修)

`seq` 的 `TestStoreSaveScalesLinearly` 在 `go test ./...` 并行时偶发失败
(比较墙上时间的两段比值,阈值 3×,受 CPU 竞争影响)。单独跑 3/3 通过,
全量重跑 2 次均 43 ok / 0 FAIL ⇒ 是判据本身对负载敏感,不是回归。
本提交未触及 seq。

* feat(usage): 会话级用量累计 —— 跨调用求和 + 正确的命中率口径

## 为何需要

单次用量一直在(`StageCtx.TokenUsage`、LLM chain 事件),但**没有任何地方把多次
调用加起来** ⇒「这一轮/这个会话花了多少、缓存省了多少」答不出来,只能翻日志自己加。
这是「无法统计缓存命中」的另一半:数据流出来了,却没有落点。

## 实现

- `usageLedger`(Agent 级,带锁):跨轮次、跨任务累计 prompt/completion/total/
  cache_read/cache_miss/reasoning + 调用次数。
- 在 `stepLLM`(用量唯一的产生点)记账,并把**本次**与**会话累计**一起放进
  LLM chain 事件:`usage`(含 cache_reported)与 `usage_session`(含 cache_hit_rate)。
- `StageCtx.TokenUsage` 补上 cache_read_tokens / cache_miss_tokens /
  reasoning_tokens,插件据此可自行做预算判断。

## ★ 命中率的分母口径(本提交最容易算错的地方)

分母**只算「上游报了缓存」的调用**(`CacheRead + CacheMiss`),
而不是全部调用或全部 prompt token。理由:没报缓存的提供商/版本不是「命中 0」
而是「不知道」,把它算进分母会把命中率稀释成无意义的低值,
让人去优化一个本来就没开的功能。

因此 `CacheHitRate()` 在没有任何调用报过缓存时返回 `ok=false`,
事件里**不带** `cache_hit_rate` 字段 —— 消费方应显示「—」而不是 0%。
判据 `TestLedgerHitRateExcludesUnreportedCalls` 用 768/1024 vs 100000 token
的对照把这条钉死(把未报缓存的算进分母会得到 0.0076 这种值)。

## 顺带修一个「发了但没人收到」

`chainPayload["usage"]` 原先是 `map[string]int`,而 WebUI 的
`handler_openai.go` 用 `Payload["usage"].(map[string]interface{})` 取它 ——
Go 的类型断言对 map 是**精确匹配**,已实证 `map[string]int` → 断言 false。
于是 `/v1/chat/completions` 回包里的 usage 一直静默为 nil(两边都不报错)。
改为 `map[string]interface{}` 后断言成立。

## 判据(7 条,-race 通过)

跨调用求和 / 命中率排除未报缓存的调用 / 什么都没报时不可计算 /
报了但全未命中必须给 0 / 缺 total 时按分量补 / 并发记账不丢数 / reset 清零。

## 状态

已可用:数字可经 LLM chain 事件(`usage` / `usage_session`)取得。
未做(下一步):HTTP 只读端点暴露(`/api/v1/kernel` 加 usage 段)、
估算器校准(现为 runeCount×2 的保守过估)。

* fix(usage): 同步回执带上用量账目 —— 链条最后一环此前是断的

用户实测「无法统计缓存命中与 token」的最后一块拼图:

  stepLLM 记账 → usageLedger 累计 → EventAgentLLMChain 事件
  这三处都有数;但同步注入方(/v1/chat/completions、cli.sock、
  clawhubadapter)的回包出自 emitResponse,它新建 StageCtx 只从插件侧读
  TokenUsage —— agent 自己算的用量从未传进去。
  实测部署实例:回包里 "usage" 字段根本不存在(回复正常,不报任何错)。

修法:

  · TaskFrame.turnUsage 累计本轮全部 LLM 调用(TokenUsage.Add 求和),
    finishInputTask 传入 emitResponse;插件显式设置仍优先(既有契约)。
  · 口径:对外 usage = 本次请求(OpenAI 语义);会话累计在链事件的
    usage_session。两者不可互换,否则第二次请求报出翻倍数字。
  · 回包 usage 必须是 map[string]interface{}:消费方 handler_openai.go
    用精确类型断言,给 map[string]int 会静默变 nil(链事件那个
    「发了但没人收到」的同款坑,本轮在插件路径再次撞上)。
  · 一次 LLM 都没跑过时不造 usage(「无数据」≠「用了 0」,
    与 CacheHitRate ok=false 同口径)。

判据 6 条:emitresponse_usage_test.go 3(单测,先红后绿)+
emitresponse_e2e_test.go 3(真实调度循环全链路)。E2E 顺带钉住两个真实
内核行为:完全相同的输入会被判 duplicate 直接跳过(第二次必须换文本);
ChatStream 不透传 ToolCalls 则工具回环不发生(多轮求和测不到)。

附 usage_mock_llm.py:带 usage 帧的 mock LLM,供隔离实例验证账目链路
(kernel-stress 原有的 mockllm.py 不带 usage,验不了这条)。

* fix(cli): 无头出口透传 usage —— 跑分靠的就是这条链路

与本轮 usage 修复同族:有头 UI 看得到成本,无头调用方看不到。

handleChat 此前只从同步回执里取 content 就往 response 帧里写,
agent 记的账、emitResponse 发的 usage 全被丢掉。
而无头链路(waiter -chat、基准 runner、脚本)正是**唯一**的信息出口 ——
标定能力、测缓存命中、比 cost-per-task 全依赖它。

抽 chatResponseFrame(resp.Payload) 独立成函数(可判据化),透传:
  content、reasoning_content(非空才带)、usage(存在且非 nil 才带)。
「非 nil 才算有」是刻意的:map 里存一个 nil 值也是常见形态,
那种情况同样属于「没有数据」,不能补成 0 —— 否则消费方无法区分
「没开缓存」与「命中率为 0」。

判据 3 条(chat_frame_test.go):带 usage、无 usage 时不造(含 JSON 级
断言与 nil 形态)、reasoning 透传且空值不带。

* feat(status): /kernel 暴露累计用量账目(usage 段)

GetKernelStatus 里有 Scheduler/ONNX/Residents 等运行时段落,
唯独没有用量 —— 回答「这个实例花了多少、缓存省了多少」只能翻日志。

  · sdk.KernelStatus 加 Usage(UsageStatus):calls/prompt/completion/
    total/cache_read/cache_miss/reasoning/cache_reported_calls +
    cache_hit_rate(指针 + omitempty:无数据时键根本不存在,
    消费方显示「—」,不会把「没开缓存」画成 0% 命中率)。
  · 数据源就是 usageLedger,不重算 —— 给同一事实造第二个来源迟早不一致。
  · /kernel、/api/v1/kernel、healthcheck_kernel 自动都读得到。

判据 3 条(status_usage_test.go):有数必报、无缓存数据不给命中率
(含 JSON 序列化级断言)、空实例不造数。

* fix(codec): token 估算器按实测重校准 —— min(字节数, 2×rune数)

旧公式 runeCount×2 的注释自称「英文 ~0.3 token/字符」,但按它算
11 字符 = 22 token,真实值约 3。跑分对比前需要校准;不能拍脑袋,
本改动用真实 tokenizer(deepseek-v4.1-flash,经 llmsproxy)实测 8 类样本:

  样本         字节/token   旧公式过估   新公式过估
  英文散文      3.54        7.1x        3.5x
  中文技术      5.22        3.5x        3.5x(持平)
  base64       1.41        2.8x        1.4x
  随机ASCII     1.43        2.9x        1.4x
  hex          1.72        3.4x        1.7x
  UUID         1.66        3.3x        1.7x
  emoji        2.00        1.0x        1.0x(精确)
  俄语          6.49        7.0x        6.5x

公式 = min(bytes, 2×runes):
  · bytes 是数学上界(每个 token 至少覆盖 1 字节),对高熵工具结果
    (base64/UUID/hash —— 工具结果的常态)保证不低估;
  · 2×rune 项保 CJK/emoji 与旧公式持平;
  · 8/8 样本无低估,处处 <= 旧公式。

★ 反面教训固化成判据:llmsproxy 的 len/3 不可照抄 —— base64 实测
  1.41 字节/token,len/3 对它低估 2.1x。估算器的失效方向必须是
  「高估」(浪费一点预算)而不是「低估」(撑爆上下文)。
★ 已知失效模式如实记录:byte-fallback 型 tokenizer 对 CJK 可达
  3 token/字 ⇒ 2×rune 项低估 1.5x(旧公式同款风险,预算另有 0.8 兜底)。

C/Go 双侧同步(golden 契约 C==pure 保持),C 规格基准期望值同步更新。
判据 3 条(codec_estimate_calib_test.go):公式语义(min)、
实测下界(低于即低估)、字节单调性。

* feat(bench): 能力标定台 —— 经 cli.sock 驱动实例,记录结果与账目

「全面标定能力」需要可复现的驱动 + 记账 + 判据,而不是手工敲几句看回复。
本台子三件事,各对应一个曾经缺失的环节:

  · 驱动:连 cli.sock(无头唯一入口),发任务,收帧到终态;
  · 记账:/kernel 取累计用量做**任务前后差分**(依赖本轮刚补的 usage 段
    与 cli 帧透传 —— 没有它们只能报「跑了多久」);
  · 判定:任务自带 check;没有 check 的任务一律判失败(防空绿)。

计费口径:差分而不是单次回包 usage —— 一个任务常触发多轮 LLM(工具回环),
单次回包只反映最后一段。两者都记在 results.json 便于对照。
命中率分母只算报过缓存的调用;一次都没报时报告写「—」而不是 0%。

内置 tasks.example.json 分五维(readonly / browser / knowledge / multistep /
long),全部只读或只写 /var/tmp;期望值逐条实测核对过
(Version=1.4.0 见 meta.go:33、plugins 子目录 20、ParallelSafe 在
internal/sdk/tool.go:72)。--self-test 离线自检纯逻辑(delta/check/summarize)。

★ 已本地验证账目链路端到端贯通(隔离实例 + 带 usage 的 mock LLM):
  mock 上报  prompt=300 completion=30 cache_read=200 cache_miss=100
  回包 usage prompt_tokens=300 … cache_read_tokens=200 cache_miss_tokens=100
  /kernel    calls=1 … cache_hit_rate=0.667(=200/(200+100),分母正确)

★ 起实例的两个坑(已写入 README):
  1. 改 LLM 配置必须重启 —— provider 在启动时构建,/settings set 落库但不生效
     (实测仍打原地址报 401);
  2. 隔离实例在固定插件端口上撞车:pluginmgr(9876)/remotedevice(9890)/
     kbtree(9892) 写死,同机第二个实例 bind 失败(那几个插件降级,其余照常)。
  3. 清理别用 pkill -f(会匹配到自己这条 shell,本轮踩到)。

--self-test 一上线就抓出一个**判据错误**:命中率期望我写成 after 的绝对值
(500/700),实现算的是差分(450/600=0.75)—— 实现对、判据错,
正是本项目「先怀疑判据」那条教训。

* fix(usage): 缓存规则收成单一实现 —— 修掉「命中率恒 100%」的结构性假绿

用户问「你是不是搓了两套解决同一个问题的逻辑?」—— 是的,而且就是它们
一起造出了假数据。

── 问题 ──

修「无法统计缓存命中」时(5ddbe2c)同一份规则写了两遍:
  Lua 侧 usage_to_unified × 10 个适配器文件
  Go  侧 chunkAssemble  × 1
两边都判「上游报了缓存」、都从 cached_tokens 取命中数,
而**都没算未命中数**。

2026-09-30 用真实 llmsproxy 跑分暴露后果:
  prompt=37473 cache_read=17792 cache_miss=0
  ⇒ 命中率 = read/(read+0) = read/read = **恒 100%**
  真实值 53%。7 个任务全报 100%,且不报任何错。

留 0 不是「少一点」,而是分母被抽掉 —— 结构性假绿。

── 修法(方案 A:规则只有一个实现)──

· TokenUsage.DeriveCacheMiss() —— 规则的**唯一**实现:
  上游只报命中侧时,miss = prompt - read(prompt 是权威总数);
  上游明说 miss 的(DeepSeek 遗留字段)一律不动;
  未报缓存不补(无数据 ≠ 0);脏数据(read > prompt)夹到 0。
· tokenUsageFromChunkUsage() —— 字段映射的**唯一**落点:
  chunkAssemble 与 parseOpenAICompatibleResponse 原先各列一份
  字段清单,现在共用(非流式也因此第一次拿到缓存字段)。
· 调用点四处(同一函数,不是同一逻辑的副本):
  chunkAssemble、ChatStream 的 Lua 分支、Chat 的 Lua 落地、非流式解析。
· Lua 侧只搬上游字段、不再自己算 —— 它本来就没算,现在用判据钉死。

── 判据 ──

新增 cache_rule_single_impl_test.go(5 条,均先红后绿):
· 生产形态(适配器输出 cache_miss 缺席)必须补出 232;
· **流式与非流式对同一 payload 必须逐字段一致**(防漂移);
· 上游明说的 miss 不得被推导覆盖;
· 未报缓存时不补(无数据 ≠ 0);
· 脏数据夹到 0(不让分母变负)。
另修 tokenusage_cache_test.go 的期望值(原断言不查 miss,
正是缺陷能溜过去的原因)。

── 实测验证(隔离实例 + 真实 llmsproxy)──

  修复前  命中率 100.0%(miss 恒 0)
  修复后  命中率 87.6%(逐任务 49.6%–97.7%,冷前缀低、复用高,符合真实形态)

* feat(context): 上下文阈值接入配置系统 —— 消除 0.8 与 600000 合成的隐形断层

用户的判断是准确的:「上下文管理策略还是硬编码,不会根据总上下文自动调整
或者用户手动调整各个阈值」。

核实结果:全部硬编码,配置面板一个都没有。

  tokenbudget.go  utilizationRate    0.8        局部变量
  tokenbudget.go  maxTargetTokens    600000     const
  tokenbudget.go  memory:ctx 配比    1:2        写死 available/3
  context.go      protectedCount     10         写死

★ 两个常量叠加出一个**没人看得见的分段函数**:

    窗口 ≤ 750000 → 工作面 = 窗口 × 0.8   (80%)
    窗口 > 750000 → 工作面 = 600000       (利用率退化成 600000/窗口)

  拐点由 0.8 与 600000 **共同**决定,改任一个都会挪动它;
  且没有任何地方能表达「我想要 XX% 利用率」。

新增 4 个配置键(core.agent.context.*):
  utilization_percent   默认 80      —— 替代写死的 0.8
  max_target_tokens     默认 600000  —— -1 = 不封顶(利用率对所有窗口恒定)
  memory_ratio_percent  默认 0       —— 0 = 历史三等分;1-99 = 显式占比
  protected_count       默认 10      —— 裁剪时无条件保留的最近条数

顺带修掉一个**死配置**:core.agent.max_context_size 被注册进面板、
也有默认值,但**从未被读取**(bootstrap 里没有对应赋值)——
界面上改它没有任何效果,且不报任何错。现在接上了。

── 升级安全:零值逐值等价 ──

ContextTuning 零值必须与硬编码时代完全相同,否则升级内核等于悄悄改了
所有实例的上下文策略。实测核对:
  · maxCtx×80/100 与 int(float64(maxCtx)×0.8) 在 **42 万个窗口值**上逐值相同
    (浮点 0.8 不可精确表示,所以这里用整数运算,更确定)
  · available/3 与 available×33/100 **不等价**(1000 时 333 vs 330)
    ⇒ 默认特意保留 `/3` 本身,不换算成百分比

判据 6 条(context_tuning_test.go,均先红后绿):
零值等价、利用率可调、不封顶消除断层、记忆占比可调且守恒、
保护条数可调、Agent 真的带上 tuning。

── 端到端验证(隔离实例 + 真实 llmsproxy,窗口 1048576)──

  默认配置       target=600000  mem=199268  ctx=398536   (= 历史行为)
  70% + 不封顶   target=734003  mem=243935  ctx=487872   (= 公式期望)

即配置真的流通,而不是"注册了却没人读"。

* feat(webui): 对话页展示缓存命中率与 token 用量

用户报「webui 还没展示缓存命中率与 token 使用量」。

根因:内核**一直在推** `agent_llm_chain`(带 usage / usage_session),
`handler_chat.go` 的 subTypes 里也早就转发了它 —— 但 dashboard.js
从未订阅这个事件。又一个「写了、发了、不报错,只是没人收到」:
后端有数、界面没有。

改动:
  · 订阅 agent_llm_chain,用 usage_session(本会话累计)刷新徽标;
  · #chat-usage 徽标挂在对话页标题旁,显示「2.9M tok · 缓存 56%」,
    悬停给出明细(调用数/prompt/completion/cache_read/cache_miss/命中率);
  · 进页时先用 /kernel 的 usage 填一次(标注「进程累计」,
    与 usage_session 的「本会话」区分 —— 两者口径不同,混看会误判);
  · 命中率**缺席时显示「—」**而不是 0%:缺席 = 没有任何调用报过缓存细节,
    与「命中率为 0」是两回事。把「不知道」画成 0% 会让人去优化一个
    本来就没开的功能(与内核 usageLedger 的 ok 口径一致)。

★ 真实浏览器验证(CDP,headless Chrome 147)——这是本次唯一能证明
「界面真的画出来了」的方式:
    #chat-usage 存在: True(切到对话页后;面板是懒渲染的)
    徽标: "2.9M tok · 缓存 56%"
    悬停明细: 调用 74 / prompt 2839511 / cache_read 1593984 /
              cache_miss 1245527 / 命中率 56.1%

  第一版探测「找不到元素」的真因是**面板懒渲染**(buildChatLayout 只在
  switchTab('chat') 时调用),不是改动没生效 —— 记下来免得下次误判。

* docs(bench): 跑分计划交接文档 + 标定台补齐 pi 对比/超窗召回/汇总脚本

- benchmark.md:唯一执行依据,含前置事实清单与已知教训
- pi_bench.py:pi 侧驱动(隔离配置目录 + 空闲端口,提示词与 HomeAgent 侧逐字一致)
- compare.py:两侧结果汇总对比
- memory_recall.py:200k 超窗记忆召回(带 --expect-window 无效配置拒跑)
- tasks.zerobasis.json:零基础认知 6 任务集
- spawn-instance.sh:隔离实例一键起(llmsproxy + context_window 启动前直改 sqlite)

* feat(bench): 客户端能力任务集 v2 —— 考 harness 而非裸 LLM

原 zerobasis 任务集本质是裸 LLM 认知测试(读 44 行材料答题,
单工具调用一次完成),harness 几乎无参与度,测不出客户端差异。

v2 四任务,全部必须工具回环、判据落盘(file_contains,与两侧工具名无关):
- cl-fixloop     工具循环:跑测试→修代码→跑到全绿(3 个埋入缺陷)
- cl-pipeline    错误恢复:GBK 编码文件让脚本崩,修到跑通
- cl-aggregate   大输出:15 文件 ~27k 行去重聚合
- cl-chain       跨步状态:6 步大数运算链,逐步写盘不许心算

生成器期望答案在生成时独立复算两遍(两种算法),不一致拒生成;
判据经变异自证(对答案 True / 错答案 False,4/4)。

* feat(bench): 记忆召回 v2 —— 取消显式强调,打在压缩与外化的分叉区

v1 结构性缺陷(两侧行为一致地 4/4 满分 ⇒ 零区分度):
1. 针全部显式标注「请牢牢记住」——任何 harness 都会保住被标重点的内容;
2. 填充是语义空转的中性句——压缩几乎无损,向量裁剪无从发力。

v2 设计:
- 取消一切预告,偶发针混进同域干扰轮(4 根端口针 + 40 个形似干扰值,
  措辞完全同构),措辞上无法与干扰区分;
- 探针分层:casual×4(偶发,改述提问)overwrite×1(值覆盖只认新值)
  overwrite-stale×1(反向哨兵:答旧值=塌回,单独统计)
  multihop×1(两个散点组合运算);
- 填充分 neutral/confusable 两档,针藏于 confusable;
- 每轮 token 估算按实际混合比例(neutral 43 token/句 vs confusable 22),
  修掉「按长句估导致实际灌入不足 1×窗口」的错;
- 判据变异自证 7/7,结构自证 6 项(针藏匿/无泄漏/覆盖序/单行/唯一/dedupe 防护)。

* fix(bench): memory_recall v2 pi 驱动 —— cwd 用 subprocess 参数而非 pi -C 选项

pi CLI 没有 -C 选项,之前 106 轮全部 exit=1(0.2s/轮的假跑)。
改为 subprocess.run(cwd=...),与 pi_bench.py 同口径。

* feat(memory): chineseclip 模型目录回退链 + 可执行报错 + 跑分台自检

跑分实测抓到的静默缺口(2026-09-30):
新数据目录启动时模型缺失,内核只打一行 warning 就禁用稠密检索照常服务。
对照实验:稠密检索在跑时 doc_query 能命中;禁用时(跑分实例)提问轮
memory_recall/doc_query 双双「未找到」——50 分钟测的是残废配置。

三层修复:
① providers/chineseclip:findModelDir 回退链(configured →
  /usr/lib/homeagent/models,与 findOnnxLib 同款先例),全落空时报错带
  可执行指引(export 脚本 / 拷贝 / 发行包三条路);单测覆盖两种结果。
② spawn-instance.sh:起实例后断言 'multimodal space active',模型缺失时
  自动从系统目录 symlink 并重启;无法补救则拒跑并给出指引。
③ 错误文本经 bootstrap mmErr → cfg.EmbeddingError → onnxStatus.Reason
  → healthcheck onnx.reason 全链路带出(端到端实测)。

另注:本机 /usr/lib/homeagent/models/ 已建 symlink 指向生产模型目录,
此后新实例默认可回退命中。

* fix(memory): 纯数字实体合法化 + 0 写入显式报拒

v2 重测(干净实例+稠密检索激活)把 overwrite/casual 失败归因到写图侧:

实测证据(ha-b run.log):
- 模型主动 memory_commit「metrics服务 端口 8328」「分机 4379→4324」
- validEntityName 要求含字母/汉字 ⇒ 纯数字 Object 被静默 continue
- 返回「已写入 0 个实体和 0 条关系」——模型当成成功,永不重试
- 11 次 memory_commit 里 8 次写 0 全部假成功

两处修复:
1. validEntityName 允许纯数字(端口/分机/编号是合法属性值),仍拒空串/
   纯标点;单测覆盖。
2. memory_commit 0 写入时显式报告被拒原因与自查指引,模型可立即纠正重试。

另一发现:multihop 翻案 0/1→1/1 —— 稠密检索激活后 doc_query 能命中
COPPER-8177+A1103 两个散点,证实前轮失败主因是模型缺失而非外化架构。

* feat(bench): 记忆召回 v3 —— 填充从废话流改为高密度运维叙事

用户指出的 v2 根本性缺陷:填充是「打印机墨盒/树叶在动」式废话,
pi 压缩可直接丢弃垃圾,偶发针反成填充里仅有的有价值内容被保住
⇒ 跑分虚高,不贴合真实对话。

v3 填充模型:order-gw 服务运维主线(上线准备→灰度事故→修复验证→
新版本发布→交接收尾),每轮 5 个高密度片段(数值/因果/决策/变更),
压缩必须保住叙事骨架才做真实取舍。针全部嵌在价值信息流里:
- 偶发针 = 服务依赖清单条目(同构干扰 = 其它服务真实端口)
- 覆盖针 = 值班安排变更流(4379→4324,真实改号场景)
- 多跳 = 交接流程(团队→门禁→申请表)

结构自证全过:4 偶发针落位、覆盖序 旧@28<新@90、多跳两要素落位、
单行/唯一/无泄漏。143 轮 × ~1.7k token = 245k 灌入(1.23× 窗口)。
判据变异自证 7/7。锚点与 phase 边界对齐踩坑已修(0.50/0.63 落在
相邻 phase 区间导致永不触发)。

* docs(bench): benchmark.md 同步 v3 填充模型 + 锚点 phase 坑 + overshoot 1.45

* feat(bench): 超窗校验落地 + 实例重置脚本

memory_recall.py:
- 补上 benchmark.md 承诺但实现缺失的 --expect-window:与 --window 不一致
  拒跑(exit 2),通过时打印「✓ 超窗校验通过」(文档验收判据依赖该行)

reset-instance.sh(新):
- 固化 2026-10-01 两次踩坑:/var/tmp/ha-c 曾同时 9 个 homed 共享一个
  graph.db(只 kill pid 文件那个 ⇒ 清理后旧数据仍在);
  删库让核心自建 schema 比手工清目录可靠
- 跑分占用门禁:检测到 memory_recall/bench 进程引用本实例时拒绝清理
  (exit 3),--force 显式越过——实测验证门禁生效

* fix(bench): 跑分每轮落盘 partial.jsonl + 每轮打印进度

长跑(143 轮 / 实测均值 148s 每轮 / 约 6h)本会话已被杀三次,而原来只在
全部跑完后才写 memory_recall.json ⇒ 中途崩溃等于零产出:
- 每轮结束追加 partial.jsonl,崩溃可恢复分析
- 填充轮由「每 10 轮打印一次」改为每轮打印(原实现下前 9 轮完全无输出,
  实测跑 12 分钟输出文件仍 0 字节,无法区分「慢」和「卡住」)

* fix(gui): cmd/gui 补 doc.go 使 go test 通过 + bench 支持 --resume 续跑

cmd/gui/doc.go:
- cmd/gui 是 Electron/Node 目录(main.js/package.json/node_modules),
  0 个 .go 文件 ⇒ `go test ./cmd/gui` 报 "no Go files"(exit 1)。
  任何按仓库遍历 Go 包的工具(pi-lens test-runner 等)都会把它当失败,
  而失败条目只在「跑通」时才被移除 ⇒ 每次编辑都重试,永不自愈。
  一个只有包注释的 doc.go 让该目录成为合法(空)包:实测 go test exit 0、
  go build ./... 与 go vet ./cmd/gui 均不受影响。

memory_recall.py --resume:
- 7 个探针全排在最后 7 轮,崩溃后 partial.jsonl 只有填充、拿不到召回信号,
  所以「只落盘」不够,必须能续跑。服务端会话状态跨连接保留(CliSession
  每轮新建连接但 agent 会话在服务端)⇒ 跳过已完成轮次是真接续而非重放。
- 前提:续跑时实例必须未重启/未清库,否则上下文对不上(已在 help 里写明)。

* bench: v3 密度调参 frags_per_turn 5→8(100k 窗口 / 50 轮方案)

* bench: 轮数由实测反推(13 轮方案)+ 超窗校验改为实测 prompt 口径

- HA 实测:第 6 轮 prompt=127126 已超 100k 窗口,而此时累计填充才 5×8 片段。
  脚本原先按文本估算规划 50 填充轮(估算 121k)纯属浪费 —— 每轮真实 prompt
  含系统提示/工具定义/记忆上下文/工具回执,远大于纯文本量。
- filler_turns 上限 5:13 轮 = 开场 + 5 填充 + 7 探针。
- 超窗判定从「估算 ≥ 窗口」改为「运行后实测 max(prompt) > 窗口」:
  估算只是下界,按它判无效会误杀实际已超窗的有效跑分(summary 新增
  max_prompt_observed / overshoot_verified)。

* fix(bench): 非 resume 模式清空 partial.jsonl

实测踩到:partial.jsonl 跨运行 append,把上一次被 kill 的残留轮次混进本次
结果(同一文件里出现两条「轮1」)。--resume 时才保留,否则先 unlink。

* bench: filler_turns 上限 5→25,靠加厚灌入制造超窗

压窗口(如 10k)会让 HA 反复上下文管理抖动:相当于在低内存设备上测内存
调度,测出的是病态行为而非设计工作点。改为两侧窗口一致(50k)、灌入量本身
超过窗口(1.22×):pi 是水位线压缩,累计文本 > 窗口就必须真实丢信息。
上限 25 轮是成本护栏(实测 pi ~20s/轮、HA ~120s/轮)。

* fix(bench): pi 驱动丢 stderr + cwd 污染,导致假死与假答案

v3b 现象:50k 窗口 / 60.9k 灌入下,轮 8 prompt=47914(96% 水位)、轮 9 回落
(压缩确实发生),**轮 10、11 连续 900s 超时且 prompt=0**;子进程存活、CPU
0.7%、会话文件仍在写 ⇒ 看着像死锁,实为两个 bug 叠加:

1. **cwd 污染**(假答案来源):--cwd 指的 /var/tmp/pi-iso/work 里有上轮实验
   残留 order_gw_*.md / order_gw_port_freq.json,pi 把它们读进上下文 ——
   v3b 的 4324 是答自磁盘文件,不是会话记忆。→ 默认改用干净空目录
   /var/tmp/pi-iso/clean-cwd。

2. **每轮重建会话**(假死来源):cwd 与 PI_CODING_AGENT_SESSION_DIR 不一致时
   pi 报 'No project session found; creating a new session',每轮重新压缩
   全部历史 ⇒ 越到后面越慢直到超时。→ stderr 一律回传到 result.error
   (原先完全丢弃,正是它吃掉了故障线索);超时也带 stderr 尾巴。

实测修复:同 session-id 连发两轮,警告只出现在首轮(建会话正常提示),
第二轮复用会话正常。

* fix(bench): pi 驱动加 --no-tools —— 此前所有 pi 成绩作废

实测(2026-10-01):不加 --no-tools 时,pi 在**一轮**里跑 33 次 bash,把自己
当上帝视角翻找答案:
  grep -l "4324" *.jsonl          ← 搜到旧会话里的答案
  cat order_gw_ops_index.md        ← 读跑分台自己的材料
  sed -n '605,625p' order_gw_daily_sync.md ← 去挖记忆_recall.py 的行号
  ls -laR /var/tmp/pi-iso/work      ← 遍历实验残留
⇒ 它不是在测记忆,而是在作弊。此前 pi 的 6/6 / 100%(100k、50k 两轮)全部
   作废:很多答案来自磁盘与旧会话,不是压缩保留能力。

HA 侧同轮核查结论:**未作弊,成绩有效**。它的 files/cmd 面被限制在实例
数据目录内,读到的 '# order-gw 运维同步归档 / 由 HomeAgent 落库' 是它自己在
测试过程中 files_write 写出的存档,不是跑分材料。

--no-tools 实测:单轮工具执行数 33 → 0。

* feat(bench): v4 判据组 —— 区分「语义取舍」与「侥幸命中」

动机(2026-10-01):v3d 里 pi 压缩两次后仍 6/6 全中,但**探针太短太集中**
(全是 4 位端口 + 一个覆盖值),被丢在摘要角落也能命中 —— 分辨不出压缩
质量,也就无从判断「主动按水位提前压」到底是真取舍还是侥幸。

新增三类(各 1 针,共 34 轮):
- unmarked(填充轮 3):语义自足的归档口径声明,**从未点名要求记住**。
  语义压缩当约束保留⇒命中;截断/关键词摘要⇒丢。测「有没有保住未被点名的东西」。
- causal(填充轮 8):现象→根因→措施三段因果链,只问结论。
  摘要爱列要点清单、清单外的中环易丢;forbid 症状值(首环)⇒ 只记住现象
  而没走完链算失败。expect 用**带语境短语**而非裸数字(裸数字会跟上下文任意
  数字撞上,等于没判据)。
- freeze(填充轮 15):中间快照值 + 最终冻结值并存,措辞明确指向最终。
  forbid 中间值 ⇒ 答中间值即「被摘要带偏」,这是压缩最典型的新错误。

自证:期望值全在填充中且唯一(freeze_new 出现 2 次属设计内:中间+最终)、
开场白零泄漏、判据变异 10/10 通过(正样本命中、负样本不命中)。

* bench: 探针说明同步 10 类 + reset 脚本 sqlite 加 timeout

* docs(design): 上下文超页 L4 中断设计与落地清单

记录 v4 跑分归因产出:HA prompt 锯齿波(峰值 7× 窗口)、topK 与窗口脱钩
(max_context_size=30 是**条数**而非token,故 1M 只调 context_window 无效)、
超页三条路径无统一出口、既有 L4 机制的 4 条硬约束,以及 T1-T10 测试项
(T4/T5 为变异验证项)。

* feat(overflow): ProviderError 错误分类,ErrContextFull 可恢复

同一 HTTP 码在不同上游代表不同处置:ErrContextFull(裁剪重试)/
ErrTransient(重试)/ErrCredential(换凭证)。只有 StatusCode 时调用方只能
字符串匹配错误消息 —— 脆,且上游改文案即失效。

判别优先级凭证 → 超限 → 瞬时,顺序不可换:401/403 报文偶尔也提context
(网关模板文案),但凭证错误永远不该按「裁剪重试」处理。

实测各上游把 context_full 报成 400 / 413 / invalid_request_error,故判别
必须「状态码 + 报文特征」组合。测试含真值表 10 例 + 2 个变异自证:
清空特征词表 ⇒ 不再判超限(证明判别真依赖该表);
401+context 特征词 ⇒ 仍判凭证(证明优先级有意义)。

设计:docs/zh/context-overflow-l4-design.md

* feat(overflow): 上下文超页统一到 L4,Prune 同步执行

把三条超页路径收敛到 L4 唯一入口 raiseKernelInterrupt:
- 本地预判:积累上下文 > 1.25×窗口(**每次 LLM 请求前**,覆盖轮内tool 回环;
  现状只在轮首 checkContextFull 且根 agent no-op、一次性)
- 上游 ErrContextFull:接在 stepLLM 的 llmErr 分支,**在 provider fallback
  之前**(fallback 会换 provider 重发同一个超限请求,换谁都一样超)
- 两条路径共用同一个 handleContextOverflow

关键取舍:Prune 在 raise 之前**同步**做完,L4 只负责打断+告知。理由:
Prune 全路径无 IO/无 panic,放进 L4 任务里做只多付一次调度开销,且结构上
杜绝「L4 里 Prune 出错再触发 L4」(L4 遇 L4 靠 canPreempt 严格大于,
第二个 L4 只会排队,永远等不到时机)。

裁剪 0 条 = 判定逻辑有 bug(本来装得下却报超页),按用户口径保存现场并
终止,**不重试不触发中断**。

判据必须用 accumulatedTokens(a.context 全部事件)而非 f.Msgs:后者被
buildMessages 按 targetUsage=0.8×窗口 裁过,结构上永不成立。抽成函数是为了
与 resident.go:checkContextFull 共用同一口径,避免再次漂移。

复原路径显式 f.Step=StepPrepare:f.Msgs 只在 stepPrepare 由 buildMessages
装配,复用挂起帧那份等于原样重发超限请求。TaskFrame 加 OverflowRecover
做**帧内**恢复预算(跨帧累计会让长会话后段失去超页处理能力)。

设计:docs/zh/context-overflow-l4-design.md

* feat(gui): 星图接上 /memory/graph/pulse + 前端依赖本地化 + 端点对齐门禁

GUI 与服务端 WebUI 插件共享同一套 REST 接口,但两边独立演进、没有任何
强制手段。这次核实发现三处真实漂移(服务端 42 路由 / GUI 只用 29)。

1) 星图接上服务端专为它造的轻量活动端点
   服务端 handleMemoryGraphPulse 的注释写了动机:生产实例全量图谱
   408KB / 1151 节点,为了「知道哪些节点是新的」而每 N 秒拉全量是把
   带宽和 JSON.parse 全花在重复数据上;pulse 只回 id+name+type+
   mention_count+updated_at,几百字节 ~ 几 KB,差两个数量级。
   WebUI dashboard 接了它,GUI 此前只在初始化拉一次全量且完全不轮询
   ⇒ 星图停在打开那一刻的快照,agent 后来学的东西它永远看不到。
   现接入:/runtime 3s + /memory/graph/pulse 10s,两路轮询幂等、
   切连接时停;新实体标「生长」;SSE 的 tool_call/stage/agent_output
   也会触发脉冲(用工具名与回复前 60 字当 hint,让点亮落到本轮相关实体)。
   pulse 检出「全量图里没有的新实体」时置脏,由下一次 renderChatStarmap
   惰性重拉全量 —— 不在脉冲回调里直接拉,否则会把轻量活动源变成每 10s
   拉一次 408KB 全量,正好是 pulse 端点要消除的浪费。

2) 前端依赖本地化,顺带修掉一处安全边界
   四个库从 internal/plugins/webui/static/ 复制到 renderer/vendor/
   (字节一致,由门禁钉住),index.html 不再引用任何公网 CDN。
   与服务端同一理由(见 webui/starmap_vendor_test.go 的注释):
   HomeAgent 支持离线/内网部署,换 CDN 只是把同一个赌注重下一遍。
   ★ renderMd() 的净化器缺失分支不再回退到手写正则(剥 <script>/on*=/
   javascript:)—— 那不是完备的 HTML sanitizer,漏 <iframe srcdoc>、
   SVG 内联事件、data: URI 等,而它渲染的是模型输出与记忆文本,
   都算不可信输入。现在直接退纯文本:库已本地化,走不到该分支。

3) 新增 endpoint-align.test.mjs(进 npm test / CI)
   扫描 app.js 的 api("...") 与 handler.go 的 mux.HandleFunc 对账,
   7 条判据。与既有判据同一路子:读真实源码,不抄逻辑重写。
   反向差集只报告不门禁(管理面端点接不接是产品决策),
   只硬钉「服务端已明确为其设计」的 pulse —— 即本判据的由来。
   已做变异测试:改错 pulse 路径、改回 CDN 均能判红。
   protocol-align.test.mjs 是真浏览器判据(需 Electron+Xvfb+真后端),
   CI 明确不跑,所以这类漂移此前无人发现。

顺带:connectSSE 由隐式全局赋值改为 function 声明(加 "use strict"
会在文件靠后处 ReferenceError,报错点离调用点很远)。

判据:cmd/gui 下 npm test 全绿(4 个 .mjs / 25 条)。

* test(overflow): 超页处理测试 + 修两个实测暴露的缺陷

测试(10 例,T1/T2/T4/T6 + 并发/零值/误接防护)全绿,且经变异验证有效:
- 变异「把判据退回 pruned>0」⇒ T1 FAIL
- 变异「删掉认知提示句」⇒ T6 编译失败

三个缺陷(都是写测试/跑测试时才暴露的,不是推演出来的):

1. StaticEmbedder.Vectorize 收到 nil 接收者会 panic
   (computeVector 在模型未就绪时拿到 nil embedder ⇒ 整条链路崩掉而非降级)。
   实测撞到:go test 直接 SIGSEGV。

2. handleContextOverflow 的「裁不动」判据用错了量。
   Prune 返回的 pruned 是**写进 docStore 的条数**,不是「少了几条」——
   docStore == nil(轻量内核/文档记忆未初始化)时它恒为 0,但裁剪照常发生。
   用它判会**把正常裁剪误判成 bug 而终止**。改为看「上下文是否真的变小」
   (条数或 token 任一下降即算裁成功)。

3. Prune 的 keepCount 可为负:topK(=max_context_size-1) 小于
   protectedCount(=10) 时 keepCount 钳到 0 ⇒ keep 为空、全部事件进 archive。
   默认配置(30)下不会触发,但 max_context_size 被调到 <11 的实例会。

overflowNotice 抽成纯函数以便可测:这条消息是 agent 唯一的认知输入
(L4 任务按调度器设计拿不到被打断者上下文),措辞不是装饰。

* feat(overflow): 接上恢复预算与轮内本地预判(补T5/T7)

上一提交留了两个「声明了但没接线」的死部件,这次补完并加变异自证:
- OverflowRecover 字段从没人读过 ⇒ 恢复预算等于不存在
- maybeHandleContextOverflow 零调用点 ⇒ 本地预判等于不存在

接线:
- stepLLM 开头做本地预判(每次 LLM 请求前,覆盖轮内 tool 回环)。
  位置在 dropContinuationPlaceholders 之前:f.Msgs 已拼好而本次裁剪会让它过期。
- 裁剪后显式 f.Step=StepPrepare 重建 f.Msgs(挂起帧那份就是超限那份)。
- 预算耗尽:f.Terminal=terminalError + f.Err 指向根因
  (core.agent.max_context_size 与窗口不匹配),返回 handled=true 以免调用方再重跑。

overflowRecoverBudget 改 var:测试要能变异它做判据自证,将来若要按窗口动态
给额度也不必改结构。

T5 测试 + 变异自证(预算=2^30 时必须不终止 ⇒ 证明判定真在数)。
全量 core/api/memory 绿。

* feat(overflow): topK 按窗口预算反推,不再与窗口脱钩(T9)

v4 跑分归因的根因二:max_context_size 是**条数**、与窗口 token 无换算关系。
实测 50k 窗口下 prompt 峰值达 7 倍窗口、每轮「超限→修剪→再超限」——
容量 30 条对实际占用毫无约束力。**所以只调 context_window 到 1M 无效**,
改窗口不会改这 30。

contextTopK:用已按窗口算好的 ContextTokens 预算反推条数,平均事件 token
取实际积累(accumulatedTokens/Len),无样本时退回 2000(保守偏大 ⇒ 宁可
多裁,不让积累量重新涨过窗口)。max_context_size 仍作**硬上限**保留——
它是运维给的活跃条数约束,改了必须生效,否则运维会困惑。

测试 3 条 + 变异自证。其中变异那条第一版写成空壳(只有 t.Log 无断言),
已改成真断言:空壳变异测试永远绿、给虚假安全感,正是本特性批评的
「不接线的防护等于没有防护」。

* fix(overflow): provider/context 为 nil 时不得崩(contextTopK 的前置)

上一提交 cb3627e 带着失败的测试就提交了(TestMemoryPass_PruneAndRecallTogether
SIGSEGV),这里补上根因修复:

1. ComputeTokenBudgetTuned 直接调 provider.MaxContextTokens(),
   provider 为 nil 时 SIGSEGV。这不只是测试场景 —— 「配置早于 provider 就绪」
   的启动序列上同样会崩。
2. contextTopK 里 a.context.Len() 未防 a.context == nil。

两处都是**构造期/未就绪**状态下会走到的路径,补专门回归测试
(TestOverflow_零值Agent不崩 / TestComputeTokenBudgetTuned_nilProvider)。

教训:不看测试输出就 commit,等于把失败留在历史里。

* fix(overflow): topK 下限改为 protectedCount+1,否则裁剪完全无效

实测缺陷(T10 诊断时撞到):v4 的工具大回执型事件平均 48000 token,而 50k
窗口的 ContextTokens 预算只有 40000 ⇒ 预算反推的 topK = 0 ⇒ 钳到 1。
而 Prune 里 keepCount = topK - protected = 1 - 10 < 0 ⇒ keep 为空
⇒ **全部事件被归档**:裁完一轮上下文照样超页,超页处理空转。

正确下限是 protectedCount+1:宁可暂时超页,等预算或事件尺寸回到正常区间再裁。
(写成 1 等于「清空记忆」,而超页处理的目标恰恰是保住记忆。)

诊断数据(realistic 尺寸构造):积累 1440000 token、比例 28.8、事件 30 条
⇒ 修复前 contextTopK=1(灾难),修复后 ≥ protectedCount+1。

新增 2 条测试钉住:大事件时 topK 不塌到 1;裁剪后仍留 protected 条以上。

* fix(gui): 设备接口补设备令牌,设备页不再恒 401

## 现象
GUI 设备页长期空白。日志每 15s 一条:
    GET 401 /api/v1/device/online

## 根因:/api/v1/device/ 命名空间下并存两套鉴权
  · /device/gateway    注册在 webui 插件,走 requireAPI,认 session cookie
  · /device/online、/device/、/device/push
                       注册在 remotedevice 插件,走 requireToken,
                       **只认 X-API-Key 头或 ?token=,完全不认 cookie**
实测可区分:cookie 401 与错 token 401 的响应体都是纯文本
"unauthorized",而 webui 鉴权返回 JSON {"error":...}。

所以 GUI 只带 cookie 时:gateway 200、online 401 —— 同一连接同一
时刻,现象自相矛盾。

## 三种能工作的客户端做法一致,GUI 两样都没做
  · WebUI 浏览器:cookie 进内核,reverseToUpstream 注入
    deviceGatewayToken(handler_device.go:118)
  · waiter:客户端自己带 X-API-Key(gateway_discover.go:103)
  · GUI:既没注入、也没自带 ⇒ 设备页恒 401,且异常被 catch
    吞掉置空数组,界面与「确实没有设备」完全一样

## 改动
1) main.js:device-bridge:get 回传 token 明文(此前只有 tokenSet
   布尔,渲染进程永远拿不到)。明文本就存在本机 gui-prefs.json,
   不新增密钥存储面,且不落日志、不进 URL。
2) app.js:新增 isDeviceTokenPath / deviceApiKey,对设备接入面
   端点附加 X-API-Key(优先连接自身 apiKey,退回设备桥 token)。
3) app.js:新增 ensureDeviceToken,并**在首次 /device/* 请求之前**
   调用 —— 此前令牌加载排在 /device/online 之后,顺序正好反了。
4) app.js:401 时记下可读原因(deviceAuthHint),设备页顶部渲染
   红色提示卡,不再把鉴权失败伪装成「没有设备」。
5) app.js:网关发现加缓存,并从每 15s 的 refreshAll 轮询里移出
   (改为按需 + 切连接/手动刷新时重取)。网关地址是服务端配置的
   慢变量,15s 拉一次没有意义;此前它与 online 同秒重复调用。

## 实测(本机 Electron,192.168.2.60:8080,真实凭据)
  修复前:device/online 恒 401,设备列表空
  修复后:device/gateway 1 次(之后走缓存)
          device/online  每 15s,全部 200,返回 waiter-fnnas /
          waiter-mainnas 两台设备
          401 计数 0

判据:cmd/gui npm test 全绿。

* debug(overflow): 超页判据加诊断留痕

T10 排查卡了 10 轮工具调用才意识到:**「没触发」与「没执行」无法区分**——
prompt 每轮都远超阈值 62500,却零 'context overflow' 日志,既可能是判据
没触发,也可能是那段代码压根没跑到。静态读代码排除了全部可能(parentID 空、
isLightKernel=false、判据逻辑正确),只能加运行时证据。

每 20 次检查打一行:比值/窗口/事件数/是否轻量内核/有无上下文。
nil 安全(a==nil 时不碰字段)。

* fix(overflow): 累积量必须计入工具回灌(判据此前永远看不见真正的超页来源)

诊断日志(e984ff2)一次就给出答案:
  overflow check #1: ratio=0.00 window=50000 events=1
—— prompt 峰值 17万~40万,判据算出来是 0。

根因:ContextEvent 里工具输出是**独立字段** ToolResults []ToolResultItem,
accumulatedTokens 只算 Input+Response。一个「用户 10 字 + 模型 20 字 +
8 个工具各回 3 万 token」的轮次算出来是 30 token ⇒ 超页判据永不触发。

而 prompt 峰值的真正来源恰恰是工具回灌(HA 每轮 4-8 个工具)。

抽成 eventTokens 共用;resident.go 的 checkContextFull 有同样缺陷,但它只对
驻留子生效、一直没暴露,现在两处共用同一口径。

新增 2 条测试:工具回灌计入(并能触发超页)、无工具时不放大。
★ 修正自己写错的断言(把 800 token 当成「放大」——实际 EstimateTokens 是
  字符/2 量级,800 字符≈400 token 是对的)。

验证:go test -count=1 连续两轮全绿(core/api/memory)。
(提交时那一次 FAIL 是 T10b 跑分占用实例所致,非代码问题。)

* perf(gui): 流式渲染判据改看 _streaming,不再依赖 chatLoading

## 现象
旁观 agent 响应其它渠道(memo / QQ / email)时,聊天页每秒全量重渲多次,
消息越多越卡。

## 根因
流式分支判据写的是 `state.chatLoading && ...`,而 chatLoading 只在
「用户从 GUI 自己发消息」(sendChat)时置 true。旁观路径(agent 响应
memo/QQ/email,GUI 仅通过 SSE 收流式帧)下它恒为 false ⇒ 判据恒假 ⇒
每个 content_delta / reasoning_delta / tool_call 帧都掉进「非流式」分支,
走**全量** innerHTML 重建。

而且它出现**三处**,只改一处无效:
  · rerenderChatIfActive(决定走 renderChatStreamChunk 还是 renderChat)
  · renderChat 内部的 streamingLast(决定单条消息的渲染分支)
  · 工具 pill 的「进行中」收集(旁观时列表恒空,看不到 agent 在做什么)

## 实测(本机 Electron + 192.168.2.60,真实凭据,同一条消息)
  修复前:1.7~2 秒内 13~26 次全量重渲,间隔 10~30ms
  修复后:同一场景降到 5 次(含轮询触发的)
200 条消息时单次全量重建实测 235ms —— 这是「聊天卡得没有用的欲望」的直接来源。

## 改法
所有 SSE 流式帧创建的 assistant 消息都带 _streaming:true
(content_delta / reasoning_delta / tool_call / agent_output 五个 push 点),
收尾时才置 _final。所以正确判据是「最后一条是尚未收尾的流式 assistant」,
即用 last._streaming 取代 state.chatLoading。

保留 chatLoading 的两处语义不同,未动:
  · 「思考中」占位气泡:只在用户自己发消息后等待时显示,旁观不需要
  · sendChat / interruptChat 自身的并发保护

判据:cmd/gui npm test 全绿。

* fix(gui): 连接配置不再被空/半截文件覆写,保存改原子写

## 现象
connections.json 变成 {"connections":[],"currentId":null},
用户的连接列表全部丢失(2026-10-01 实测)。

## 根因:两个缺陷叠加
1) loadConnections() 的损坏恢复分支无法区分两种情况:
     · 文件内容确实损坏        → 备份+重建(合理)
     · 读到空/半截内容        → **也走同一条路,覆写成空配置**
   第二类真实会发生:saveConnections() 用非原子 writeFileSync 截断重写,
   落地过程存在「已截断、未写完」窗口;此刻任何一次读取拿到半截内容,
   JSON.parse 抛错 → 用户配置被永久销毁,且没有二次确认。

   实测(沙箱跑真实函数):空文件 -> 返回空配置,
   **并把文件改写成 {"connections":[],"currentId":null}**。

2) 损坏备份只写一个固定的 .bak,反复损坏会把上一份真实配置覆盖掉,
   于是「唯一的退路」也丢了。

## 改动
1) loadConnections():raw.trim()==="" 视为「尚未配置」,
   直接返回空配置,**不覆写、不备份**(BOM/纯空白同样处理)。
2) saveConnections():改为原子写 —— 先写同目录 .tmp-<pid>,
   再 renameSync 覆盖。rename 在同卷内原子,读者只会看到改写前
   或改写后的完整文件,从根上消除产生半截内容的竞态;失败时清理 tmp。
3) 真损坏的备份名带时间戳(connections.json.corrupt-<ISO>.bak),
   反复损坏不再互相覆盖。

## 判据
新增 cmd/gui/connections-io.test.mjs(进 npm test):
在 node:vm 里抽出 main.js 的**真实函数**执行,11 条断言覆盖
合法/空/纯空白/损坏/原子写残留。已做变异测试:删掉空文件守卫、
把备份名改回固定 .bak,均被判红。

## 实测(本机 Electron + 真实凭据)
  正常启动:connections.json MD5 前后一致(E8DD588F...),0 个 tmp 残留
  人工清空后启动:文件被自愈填充回真实配置,而非覆写成空

判据:cmd/gui npm test 全绿(32 条)。

* docs(ci): GUI job 注释同步为五个 .mjs,补 connections-io 说明

* fix(overflow): protected 按预算自适应(B 方案),修 T10c 暴露的自相矛盾

T10c 端到端实测(50k 窗口 / 34 轮 / 67k 灌入):
  召回 4/9 → **2/9**(22%),累计 prompt 9.58M → 3.88M(-60%),墙钟 -40%。
省了资源但召回崩了。内核日志给出完整因果链:
  ratio=1.86 events=11                ← 越过阈值
  context overflow: pruned=1 (12→11)   ← 每次只裁 1 条
  context overflow aborted: 11→11      ← 裁不动
  recover budget exhausted (2/2)       ← 预算耗尽
  preempt: level=4 from kernel/overflow preempts  ← L4 本身工作正常

根因:**protected=10 × 单条 5800 token = 58000 > ContextTokens 预算 40000**
——「钉住最近 10 条」本身就装不进预算,裁剪在数学上无解。每轮触发两次超页、
两轮后预算耗尽、任务直接终止(轮 5/6 的 prompt=0),记忆被裁掉。

修复:protected 从**固定值**改为**上限**,
  effective = min(配置值, ContextTokens预算 / 单条平均token)
  - 小窗口/大事件 ⇒ 自动收紧(宁可有取舍,也不让裁剪无解)
  - 大窗口(如 1M)⇒ 用满配置值。**这正是「1M 下这套调度器能更好」的实现
    方式:预算越大,可保护的近处越多、换出粒度越细。**

另加 singleEventFitsBudget:单条事件本身就超预算时裁剪在任何保护数下都无解
(实测 40026 token vs 预算 10667),此时直接终止并报真实原因,而不是裁两轮
后预算耗尽——后者会额外丢失已经裁掉的记忆。

测试调整都是构造失效而非行为回归(逐个核实):
- T1/T2/上游/自适应:原构造 (1000,400) 在小窗口下天然命中「单条超预算」。
  先跑参数表(15 组窗口×事件尺寸)选定 (4000,800):单条 1627 装得进预算 2134、
  总量 ratio=8.13 超页 ⇒ 裁剪可帮上忙。
- T4「裁不动」:旧构造在自适应下反而能裁 3 条(预算 534 装不下 10 条保护 ⇒
  收紧到 1 ⇒ topK=2)。改用「只剩 1 条大事件」——真无解。
- T5:每次检查前必须重压上下文(首次超页已把上下文裁小)。

新增:protected 自适应 2 条 + 单条超预算终止 1 条。

验证:go test -count=1 连续三轮全绿(提交信息里出现的那次 FAIL 是跑分实例
存活期间跑测试导致,与前两次同因,非代码问题)。

* feat(memory): memory_recall 全局 topK + 相关性/时间双排序

## 起因:v4 跑分 5 条召回失败,全部是同一个根因

实测日志(ha-c,seed 20261001):
  找到 193 个相关实体 → 13744 tokens = 工具预算 3150 的 **436%**
  [agent] toolresult_budget: 超预算 436% —— **结果未被裁剪**
针**确实存进了图记忆**(graph.db 里有 metrics服务端口8328 等),
但 193 条同格式平铺把针淹没,模型转去 grep 知识库文件,
并把「没检索到」说成「库里根本没有 auth/metrics 这个服务」。

三个缺陷叠加:
  1. maxKeywordEntities=50 是**每关键词**的,jieba 把问句切成
     [metrics 服务 端口] 后累加 → 193 个实体,**无全局上限**。
  2. SQL 算出的三层精确度只是**单关键词内**排序,跨关键词 append 时被冲淡。
  3. 输出无层级标记,模型无从判断该信哪条。

## 先做实验再动手(否掉了两条自己提的方案)

用户问「能否移除 jieba 改用向量/LLM 做三元组拆分」。跑了三方案对比
(脚本见提交前 internal/memory 内的实验测试):
  A 现实现(jieba+LIKE)   针#1  top10噪音=5  总量56
  B 字符 3-gram 精排      针#1  top10噪音=6  总量31
  C 精确短语优先/三元组   针#1(3/4) 噪音=9  ← **最差**
结论:**不需要向量、也不需要 1.5b LLM**。针在三种方案里都排 #1 ——
排序从来不是瓶颈,缺的是全局上限。而「短语优先」在真实规模下最差
(auth 那条针掉到 #2,短子串「端口」的长度权重反而帮了别的实体)。
向量检索在此**帮不上忙**:问 metrics 时语义最近的恰是 auth/trace/oauth
的端口(语义几乎相同),向量只会把它们排在一起。

## 改动

1. **全局 topK**(maxRecallEntities=20)。在**出口**施加——
   深度扩展会带出新邻居实体,扩展前截断会再次越界(实测 47>20)。
   效果:13744 tokens → 约 1400,针仍在首位。

2. **实词优先的相关性排序**。关键坑:实体名恰好叫「端口」时,
   它对关键词「端口」是**完全相等命中(rank=0)**,比针的前缀命中(rank=1)
   还「精确」,把真答案压到第二。⇒ 「完全相等」只在**实词**上算强信号,
   泛词走固定表(20 词,用生产语料 190 实体校验过覆盖率)。

3. **时间倒序模式**(SortMode=recent)。v4 的 overwrite 组答对而
   overwrite-stale 组答错:同名新旧实体相关性层级相同,只能靠时间分胜负。
   工具 schema 暴露 sort 参数,默认相关性。

4. **MatchRank 回填** + 输出带「精确匹配/前缀匹配」标记。
   此前同格式平铺是模型放弃向量检索的直接原因。

5. **关系排序必须带 TurnID/ID 兜底**:SQLite CURRENT_TIMESTAMP **只到秒**。
   实测 turn1 写 4379、turn2 写 4324 落在同一秒,CreatedAt 完全相等,
   只按它排会保留原顺序,而原顺序来自 ORDER BY confidence DESC,
   置信度相同时退化到 rowid 升序 = **旧值在前**。
   与「SQLite 时间戳只有秒精度」是同一类问题。

## 两个真 bug(都由测试抓出,不是我自查)

- **LightMemory 两空间并集按 ID 去重**:temp 与 main 是两个独立数据库,
  各自 id 从 1 自增,按 ID 去重让「主库第 3 号」和「temp 第 3 号」
  互相顶掉 ⇒ **子代理看不到主记忆**。既有测试 TestLightProfile_MemoryFaceWiring
  立刻变红。已改用 mergeRecall 按名字去重。
- **两条新测试第一版是废的**(变异自证抓出):
  - topK 测试:语料 39 个实体,单关键词就被 LIMIT 压到 20,合并后恰好 20
    = 上限 ⇒ 把 maxRecallEntities 调到 100000 照样通过。
    改成多关键词累加场景 + **前置自证**(先证明去掉上限会失败)。
  - 时间排序测试:rowid 顺序恰好与答案同向,去掉兜底也通过。
    加了同秒 + rowid 顺序相反的构造,确���同秒后才跑。
  三个变异(放大 topK / 取消实词优先 / 去掉 TurnID 兜底)现在全部被抓住。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(repo): 清除临时记录文件与本地脚本,取消部署脚本追踪

## 删��(曾入库的非工程文件)

- plan.md(34K)—— 遗留问题清单,是**动态工作文档**不是工程产物。
  删前先断了它的 5 处活链(见下)。
- tmp_jinabench.py —— 一次性 Jina embedding 跑分脚本,0 引用。
- napcat-design-dna.json —— **外部项目**(NapCat WebUI)的设计资产,
  与本仓无关,0 引用。

## 取消追踪(保留本地可用)

deploy-plan.sh / deploy-waiter.sh / deploy-sdk-site.sh —— 生产部署入口,
含环境相关 IP 与路径约定,不适合随仓库分发。
**风险已记录**:docs/zh/deploy-runbook.md 仍在引用这三个脚本,
换机器部署前需先确认脚本来源。已在 .gitignore 注明。

## plan.md 的 5 处活链:改为指向 git 而非动态文档

真正的活链只有 5 处(此前统计的 26 处里,绝大多数是 .pi/ 会话产物与
.npm-cache/ 工具缓存,不是引用)。全部是「见 plan.md §X」形式的溯源注释:
  third_party/.../stagediff_test.go   (plan.md 11.3)
  docs/zh/c-core/sse-codec-c.md       (plan.md §七)
  docs/zh/input-scheduler-design.md   (plan.md §13.7)
  internal/agent/core/output.go        (plan.md 11.1)
  internal/plugin/proc/bench_test.go   (plan.md §13.3)
  internal/plugin/dynamic_proc.go      (plan.md §12.5)

**这些引用本身就是坏引用**:它们指向的是**已完成的核实结论**,而 plan.md
是随代码持续改写的工作文档 —— 让历史结论锚定在会变的文档上,读者按图索骥
只会扑空。改为只保留结论、把核实过程指向 git log(不可变)。

其中 dynamic_proc.go 那条尤其值得留:原文是「plan.md §12.5 声称交叉编译
通过,实际只验证了 proc 子包」—— 这是**对文档不实陈述的纠正记录**,
比原引用更有价值,保留原意。

## 工作区清理(未入库,仅本机)

根目录 homed(34M) / waiter(12M) / chineseclip.test(5.2M) —— 编译产物,
.gitignore 已正确排除,只是长期堆积在工作区。删前已确认无进程占用。

验证:go build ./... 通过;清理不影响任何构建产物或引用。

* fix(gui): 静默启动开关此前是死设置,永远不生效

## 现象
设置页有「静默启动」开关,文案写着「启动时不显示主窗口,驻留托盘
后台运行」,勾选后启动 GUI 照样弹窗。

实测(本机,gui-prefs.json 里 silentStart=true):窗口标题 HomeAgent
照常出现。

## 根因
silentStart 从未被消费:
  · main.js 的 loadGuiPrefs() 把它读进对象
  · 渲染层设置页读写它
  · **但主进程没有任何地方读它做判断** —— whenReady 无条件 createWindow()
是个纯粹的界面开关,接线缺失。

## 改动
1) createWindow(show = true):支持「创建但不显示」。
   BrowserWindow 初始 show:false,非静默时经 ready-to-show 显示
   (避免先显示再隐藏的闪烁)。

   ★ 为什么静默时**仍然创建窗口**、而不是不创建:
     不创建 ⇒ 渲染进程不启动 ⇒ app.js 整个不执行 ⇒ SSE 通道、
     设备桥轮询、聊天全部失效,GUI 退化成纯托盘图标;且
     showMainWindow() 只做 show()、不会创建窗口,app.on("activate")
     又只在 macOS 触发 —— Windows 上托盘点「显示主界面」将毫无反应,
     进程变成唤不起来的僵尸。已按此结论实现(评审时确认为「创建后隐藏」)。

2) whenReady 读 silentStart 并据此 createWindow(!silent)。
   与 applyAutoLaunch 里的 openAsHidden 是两条不同路径:那个告诉 OS
   「开机自启时如何启动」,这里决定本进程要不要显示,对手动启动同样生效。

3) showMainWindow() 增加兜底:窗口不存在时重建;隐藏时先 show 再 focus。
   消除上述「唤不起来」的隐患。

4) loadGuiPrefs() 补去 UTF-8 BOM。
   ★ 这是排查中发现的**第二个真实缺陷**:Windows 工具
   (PowerShell Set-Content -Encoding UTF8、记事本)改过 gui-prefs.json
   会写入 BOM,而 JSON.parse 遇 BOM 直接抛错 → 走 catch → 返回**默认
   prefs** ⇒ silentStart/exitToTray/deviceBridge 全部被静默重置,
   表现为「开关保存了但不起作用」。loadConnections 早就有去 BOM 处理、
   这里没有,两个读取点不一致。
   (本次实测正是被自己的判据脚本用 Set-Content 改 prefs 后踩到的。)

## 判据
新增 cmd/gui/silent-start.test.mjs(进 npm test),14 条断言:
  · whenReady 确实读 silentStart 并决定 show
  · 静默路径仍创建窗口(不许退化成不建窗)
  · showMainWindow 能在窗口缺失时重建
  · loadGuiPrefs 去 BOM,且 loadConnections 的既有处理不回退
已做变异测试:还原成无条件 createWindow()、抽掉 BOM 处理,均判红。

## 实测(本机 Electron,真实 prefs)
  silentStart=true  → processes=8 visibleWindows=0,日志出现
                      [startup] silentStart=on: window created hidden
  silentStart=false → processes=4 visibleWindows=1(标题 HomeAgent)

判据:cmd/gui npm test 全绿(46 条)。

* fix(gui): 打包未传 --icon,exe 内嵌图标一直是 Electron 默认图标

## 现象
GUI 的 exe 图标不正确(显示为 Electron 默认图标)。

## 根因
Makefile 的 build-gui 调electron-packager 时**没有 --icon 参数**:

    npx electron-packager . homeagent-gui --out=... --no-sandbox

electron-packager 默认不写 exe 资源图标,于是 exe 保持 Electron 自带图标。
仓库里 cmd/gui/icon.ico 一直存在且完全合法(file 识别为
"MS Windows icon resource - 7 icons, 16x16, 24x24"),只是从未被打包进去。

注意这是**两条独立的打包路径**:
  · electron-packager(make build-gui,本机/开发用)→ 靠 --icon 参数
  · electron-builder(package.json 的 build 段,发行用)→ 靠
    build.linux.icon,且 files 数组里也没列 icon.ico
本次只修前者(本地安装与实测走这条)。

## 证据
重新打包前后 exe 大小:
  188784128  →  188834816   (+50688 = 7 个尺寸的图标资源)
此前多次打包产物大小完全一致,可反证图标从未被写入。

## 改动
build-gui 补 --icon=icon.ico。托盘图标无需改动:它走
Tray/nativeImage,按 icon-tray@2x.png → icon-tray.png → icon.ico
三级候选回退,文件都在且逻辑健壮。

## 实测
重新打包并安装:184 文件 0 处不匹配,exe = 188834816 字节
启动正常:prefs.silentStart=true → 4 进程 0 可见窗口、
login 200、0 个 401 / JS 错误。

* fix(gui): 修发行打包的图标与资源漏装(Linux 包会缺星图依赖)

## 背景
上一提交修了 electron-packager 那条路径的 exe 图标。本提交处理另外两处
同类问题——**都是静默失败**:改了没人知道,坏了要等用户装上才发现。

## 1) package.json build 段(electron-builder 路径)
  · files 白名单漏了 icon.ico / icon-tray.png / icon-tray@2x.png
    ⇒ 这些文件不会被打进包,托盘与窗口图标在发行版里丢失
  · 补 win.target/win.icon;注意 electron-builder 的 icon 路径相对
    **项目目录**(package.json 所在)解析,不是相对输出目录
    (最初写成 'build/icon.ico',那会找不到文件 —— 已用存在性判据钉住)
  · icon.ico 含 256x256,满足 electron-builder 对 win 图标的要求

## 2) deploy/packaging/package-linux.sh(Linux 发行实际走这条)
  原实现手工 cp 七个文件:
    main.js / preload.js / package.json
    renderer/{index.html, app.js, style.css, mascot.svg}
  实测漏掉:
    · renderer/mascot.svg        —— 源码里根本不存在(真名 mascot.webp)
    · icon.svg / icon.ico / icon-tray*.png
    · renderer/icon.svg
    · renderer/vendor/*          ★★ 本次本地化新增的四个第三方库
                                  (three/OrbitControls/marked/purify)
                                  ⇒ Linux 发行包装完星图与 Markdown 渲染直接坏,
                                    且**没有任何构建报错**

改为:
  · renderer 按目录整体同步(cp -r "$gui_dir/renderer/.")
  · 根级 *.svg / *.ico / icon-tray*.png 同步
  · 新增必备文件校验:vendor 四库与 icon.svg 缺任一 ⇒ 报错并删目录跳过,
    沿用本脚本既有的"宁可不发,也不发装了跑不起来的包"原则

## 判据
新增 cmd/gui/package-config.test.mjs(进 npm test),14 条断言覆盖三条
打包路径的配置一致性。已做变异测试:Makefile 去 --icon、files 去 icon.ico、
package-linux.sh 还原逐文件 cp,三者均判红。

判据:cmd/gui npm test 全绿(60 条)。

* feat(gui): 加载动画改三点式 + 新增系统通知

## 1) 加载动画:边框旋转 → 三点渐亮
旧实现是 border + rotate 的 spinner,用在 .loading(16px) /
.loading-spinner(32px) / .live-spinner(15px) 三处。在消息气泡里刺眼,
且语义不对:旋转暗示"正在加载某个确定的东西",而 agent 思考没有进度。

参考 PiDeck(@deepseek-ai/dsh-web-frontend 与 mermaid bundle 里的
@keyframe 实现)改为「等距三点 + 透明度递减 + 轻微上浮」:
  haDots      三点错峰明暗(1/0.6/0.35/0.15)
  haDotFloat  单点上浮 + 缩放
  haSweep     横向扫光
旧类名 .loading / .loading-spinner 保留为别名指向新动画(还有 6+ 处
调用点,逐处改 HTML 容易漏),并解除其 animation 以免残留旋转。
7 处调用点统一换成 <i>x3 结构。

## 2) 系统通知(此前完全不存在)
主进程无 Notification、preload 无接口、渲染层只有页面内 toast
(用户不盯着窗口就看不到)。补齐一条完整链路:
  main.js  notify:show / notify:supported 两个 IPC + 原生 Notification
  preload  notify.{show,supported,onClicked}
  app.js   notifyTurn / notifyTurnOnce / onNotifyClicked

设计取舍(都写进注释):
· **一轮只通知一次**。SSE 的 tool_call / stage / agent_output 都可能
  判定"一轮结束",用 turnSig 去重,否则连弹好几次。
· **不在流式过程中逐 token 通知**。一轮可能几十个 delta,那是噪音;
  用户要的是"有结果了"。
· **窗口在前台且聚焦时不弹**。正看着界面时弹窗只会烦人。
  静默启动(窗口 hidden)正好走"要弹"这条 —— 这也是静默后唯一
  能让用户知道"agent 回了"的途径。
· **挂在 SSE 的 agent_output 收尾处**,不挂 endChatTurn():
  后者开头 `if (!state.chatLoading) return`,而旁观路径
  (agent 响应 memo/QQ/email,GUI 只收 SSE)chatLoading 恒 false,
  根本走不到那里。挂 SSE 对"自己发"与"旁观"两条路径都生效。
· **点击通知会 show() + 回传渲染进程**,切到 chat 视图并滚到该条。
  点了却不知道该看哪句话等于没通知。
· 正文超 180 字截断,避免通知被撑爆。

## 判据
新增 cmd/gui/loading-notify.test.mjs(进 npm test),24 条断言覆盖
动画关键帧/错峰 delay/别名解除/调用点结构,以及通知链路的六段
(主进程 IPC、preload 桥、去重、挂载位置、点击跳转、可用性探测)。

## 实测
重新打包安装:186 文件 0 处不匹配,exe 188834816 字节
启动正常:silentStart 生效、login 200、device/online 200、
graph/pulse 轮询、0 个 401 / JS 错误。

判据:cmd/gui npm test 全绿(84 条)。

* feat(gui+waiter): computeruse 补高级操作、修 HiDPI 坐标;记录 PiDeck 借鉴

## computeruse 高级操作
此前两端(GUI 本机桥 / waiter 设备侧)都只有 9 个基础 action
(click/move/scroll/keypress/type…),与业界 computer-use 的基本盘差距明显。
现补到 18 个,两端对齐:

  drag          拖拽(分步移动,见下)
  hover         只悬停不点击(触发 tooltip / 悬浮菜单)
  mousedown/up  按下与释放分离,可配对做「按住」
  tripleclick   三击选中整行
  middleclick   中键
  hotkey/combo  组合键(ctrl+c 这类)
  wait/sleep    显式等待,等 UI/动画完成再下一步
  display       显示器几何(bounds + scaleFactor + primary),多屏定位必需

★ 拖拽**分步移动**(默认 12 步,上限 60)而不是一步到位:canvas 拖拽、
列表排序、滑动条这类应用依赖中间 mousemove 事件,一步到位会失效。

## 修 HiDPI 坐标错位(GUI 侧)
截图给模型的是**物理像素**,而 SetCursorPos / xdotool / cliclick 期望
**逻辑点(DIP)**。旧代码只加了 display bounds 的原点偏移,完全没换算
scaleFactor ⇒ 150% 缩放屏上点击系统性偏移(越靠右下偏得越多)。

现在按目标屏 scaleFactor 换算,并提供 `physical:false` 让调用方声明
「坐标已是逻辑点」以免二次换算。

## 组合键归一化(waiter 侧)
旧实现把模型给的 key 字符串原样丢给 `xdotool key`,于是 "Ctrl+C"、
"ctrl + c"、"CTRL-C" 全部失效。新增 normKeySpec:统一 + 与空格分隔、
统一小写、去重复修饰键。

顺带修 waiter 的 `type`:加 `--clearmodifiers`,避免残留的 Ctrl/Alt
把后续输入变成快捷键(旧实现一次输入就可能污染后续所有按键)。

## scroll 修正(两端)
旧实现固定滚 `click 4/5`(上/下),忽略方向语义也不支持横向。
现按 dy 正负给方向、按量级分步、支持 dx 横向滚动。

## 显式声明借鉴 PiDeck
用户要求声明 GUI 借鉴了 PiDeck 的设计与实现。做法:

1. 新增 `cmd/gui/DESIGN-NOTES.md` —— 逐项列出借鉴自 PiDeck 的设计
   (三点渐次明暗 / 运行态呼吸光晕 / 工具调用分组卡)、PiDeck 的原始
   实现(@keyframes 名与 CSS 片段)、HomeAgent 的落地位置、以及**动机**,
   并明确区分「本仓自行决策」的部分(如「只有 1 个工具时不分组」是本地决定,
   不是 PiDeck 做法)。
2. app.js / style.css 的相关代码处补出处注释。
3. 判据 computeruse.test.mjs 钉住这几条声明,防止后续丢失。

参考方式为阅读 PiDeck 打包产物 app.asar 中的 CSS 实现后**独立重写**,
未复制其代码或资源文件。

## 判据
新增 cmd/gui/computeruse.test.mjs(进 npm test),30 条断言:
  · 两端各 9 项高级操作
  · HiDPI 换算与 physical 开关
  · 组合键归一化、type 的 --clearmodifiers
  · 拖拽分步移动(两端)
  · PiDeck 借鉴声明四处(含 DESIGN-NOTES.md 存在)

判据:cmd/gui npm test 全绿(114 条)。

* feat(generation): 新增生成侧 SPI + ollama provider(蒸馏流水线的模型接入点)

## 为什么需要这个 SPI

图记忆的自动蒸馏(internal/memory/pipeline)要接小 LLM 做三元组拆分,
但 pkg/embedding 的契约是「输入→向量」,生成是「输入→文本」——
塞进 embedding SPI 会让所有向量 provider 都得实现生成。
参照 pkg/embedding 的注册表模式,另立 pkg/generation,并列不嵌套:

  pkg/embedding.Register("chineseclip"|"qwen3vl")   ← 向量空间
  pkg/generation.Register("ollama")                  ← 文本生成
  核心只依赖接口,模型以 provider 形式经配置 Open 引用。

同一份权重可以实现两个 SPI(qwen3vl 的 TokenEmbedding 段与生成头
tied embeddings 实测同构),两个注册表刻意独立——向量侧与生成侧
可以选同一个模型也可以各选各的。

## ollama provider 的实现依据(本会话实测)

结构化输出是 1.7b 能用于拆分的**唯一**机制,6 条异构真实记录:
  format: schema 对象   6/6 零幻觉,30 对字段
  format: "json" 字符串  只输出单个对象就 stop(0 对)
  零样本文本            聊天腔「根据您提供的信息…」(0 对)
  few-shot 续写          结构不匹配时**复读示例答案**(100% 幻觉)

因此 JSONSchema 字段是本 provider 的核心路径,无法遵守 schema 的
provider 必须显式报 ErrSchemaUnsupported,而不是静默忽略——静默忽略
的后果是调用方拿到自由文本去 parse JSON 必炸,且查不出是哪层的错。

两个实测出的硬参数固化成默认值:
  num_thread=10 —— ollama 默认线程配置在本机 12 核上只有 0.1 tok/s,
                   设 10 后 2-3 tok/s(30 倍)。怀疑慢先查这个。
  think=false  —— 1.7b 不关思考会先输出大段推理再答案,挤掉 num_predict。
  keep_alive=30m —— 默认 5 分钟驱逐,30 分钟蒸馏间隔的批量会被中途重载。

## 测试

httptest 模拟 Ollama,不依赖真模型:
- JSONSchema 以**对象**透传(json.RawMessage,非字符串包装)——这是
  与 format:"json" 的本质区别,测试按 JSON 语义比较而非字符串比较
- 非法 schema 本地拒绝(不发请求)
- HTTP 错误带状态码+响应体;服务端 error 字段转发
- done_reason=length → Truncated(调用方对截断的结构化输出必须起疑)
- SPI 注册表联通(generation.Open("ollama"));空白 model 回退默认

下一步:pipeline.go 的 extractKeyTriples 经此 SPI 调用,schema 在
core 侧定义(fields 数组 + value 原样校验 + 维度名归一)。

* feat(distill): 自动蒸馏接小 LLM 拆三元组(此前该路径产出 0 条)

## 问题(实测,非推断)

extractKeyTriples → nlp.NewExtractor(nil) → extractFromPOS:
  4 条真实语料 → **0 条三元组**。库里 188 实体全部不是这条路写的,
  证据是 95/143 条关系是「交接文档整理/发布窗口与回滚」这类语义化类型,
  而 POS 模板只能产出「是/有/被标为」;长实体也是原句的**改写压缩版**
  (第112批周四凌晨2点·停机4分… ← 第112批,30 日:发布窗口定在周四凌晨2点…),
  模板做不出改写。

实际写入方是模型主动调 memory_commit,而它的 description
(indexer.go:462)只讲 media_digests 与 scene 怎么填,**没要求实体原子化**
—— 模型写 43-50 字符整句是 description 的必然结果。

后果:跨维度检索全失效。严格判据(188 实体 + 同域硬负样本)基线 **0/5**。

## 改动

1. internal/memory/distill:新的拆分器
   - JSON Schema 约束解码(6 条异构真实记录 6/6 零幻觉、30 对字段)
   - 三道闸门:值必须原样出现在原文(唯一幻觉防线,生成侧无向量可验)、
     维度名归一(实测「停机」/「停机时间」并存,不归一建不了边)、
     字段名长度上限(模型会把整句当字段名)
   - 主语锚定:规则拆分实测 0/20 —— 拆开后「第183批」与「值班手册第4版」
     是互不相关的实体,答案就丢了;主语是把绑定建成边的锚

2. pipeline.go:SetSplitter + extractTriples
   - **能区分「跑了但没拆出东西」与「没跑/失败」**:前者正常(纯叙述句
     本就无字段),后者回退 jieba 路并记日志 —— 否则 splitter 长期失败会
     静默退化成 0 产出,而蒸馏照常「成功」,外部看不出来
   - RecordSplitter 接口而非直接依赖 distill 包:pipeline 在早期启动阶段
     不该拉起 pkg/generation 依赖
   - 单次调用 90s 上限:实测 4-9s/条,太短则恒超时、太长会吃穿 30min 间隔

## 两个自己踩的坑

- 插入 SetSplitter 时把 SetEmbedder 覆盖掉了,cmd/homed 编译失败才暴露。
  已恢复,并写明两者并存关系(splitter 优先,embedder 仍用于回退路径)。
- 归一函数的包含式分支写着  死代码,导致「删掉精确匹配分支」
  这个变异测试仍能过 —— 看似有判据实则没有。改用 bestAlias 后重测,
  变异立刻被抓住;补 TestNormalizeDimension_包含式最长匹配 专门盯它。

## 测试

distill:13 条(正常路径/三道闸门/无主语/空结果/不支持schema/截断/别名幂等)
pipeline:5 条(优先 splitter/空结果成功/失败回退/无 splitter 兼容/变异自证)
变异自证 4 个:去掉原样校验、schema 不传出、extractTriples 忽略 splitter、
去掉包含式归一 —— 全部变红。

未提交:internal/memory/graph.go 里此前那批 topK/排序改动的 gofmt 修复。

* feat(distill): bootstrap 接线 + 端到端验证(16 三元组/幻觉 0)

## 接线

cmd/homed/main.go:空 import 注册 providers/ollama(与 chineseclip/qwen3vl 并列,
各自独立注册到 pkg/generation)。配置形态与多模态向量侧对称:
  core.memory.distill.generation.provider = ollama
  core.memory.distill.generation.options.model = qwen3:1.7b

initDistillSplitter:未配置 / 打开失败 / provider 不支持 JSON Schema
三种情况都返回 nil → 蒸馏回退 jieba 路。**不因模型不可用让蒸馏停摆**:
那条路虽实测产出 0 条,但至少不让对话记忆丢失。
「不支持 schema」现在就拒而不是运行时反复失败——拆分器的零幻觉完全
依赖 schema 约束,不支持它的 provider 会拿到自由文本、解析必炸。

★ main.go 里把 cfgReg 的创建**提到 initMemoryStack 之前**:蒸馏的生成侧
provider 由配置决定,且必须在 distiller.Start() 之前注入,否则第一个 tick
(实测 10 分钟)会白跑一次产出为 0 的 jieba 路。

## 端到端实测(Go → SPI → ollama → qwen3:1.7b,5 条生产库真实记录)

16 三元组,幻觉 0,4/5 记录成功,单条 1.8-7.8s。
  (第112批) -[发布窗口]-> 周四凌晨2点
  (第112批) -[停机时长]-> 4分
  (第112批) -[回滚版本]-> v2.29.5
  (第112批) -[灰度比例]-> 10%
  (第183批) -[值班手册版本]-> 第4版

这正是原设计要的效果:库里原本是
「admin服务端口8861·billing服务端口8499·oauth服务端口8271」这种
一个实体塞三个事实的复合值,现在拆成了可按维度定位的原子三元组。

## 踩到的一个反直觉坑(负向指令)

第一版提示词里有「记录开头的批次号是主语,不要单独列成字段」——
**这条负向指令让 qwen3:1.7b 把所有字段都判成批次号相关而全部丢弃**:
同一条记录 4 个字段 → 0 个字段。对照实验(同一 schema、同一记录):
  去掉该规则 + <record> 标签  → 4 字段 ✔
  保留该规则 + <record> 标签  → 0 字段 ✘
  无标签                      → 0 字段 ✘
1.7b 处理不了「不要做 X」这种否定式约束。改为只给正例(示例里就没有
批次号字段),主语锚定由 Split() 在 Go 侧做。

代价是它会主动拆出「批次号」字段,产出
「第183批 -[批次号]-> 第183批」这种自环 —— 在 Go 侧过滤掉。
**过滤逻辑必须自己掌握,不能依赖模型听懂否定指令。**

## 测试

新增 TestSplit_过滤主语自环;变异自证(去掉自环过滤)确认变红。
其余 18 条 distill 测试 + pipeline 5 条 + ollama 7 条全绿。

* fix(gui): 启动期误判服务端未启动;keybd_event 类型名致组合键全废

## 修 GUI 反复误判「服务端未启动」
isServerRunning() 硬编码 http://localhost:8080/。实测该部署下服务端在
192.168.2.60:8080(homed 跑在 WSL 里):
  localhost:8080      -> 000 连不上
  192.168.2.60:8080   -> 302 正常

于是每次启动都:探测失败 → 误判服务没起 → 去 startHomed() 拉起 GUI 旁
并不存在的 homed.exe(findHomed 返回 null 就 skip)→ 再 waitForServer()
干等 8s 超时 → 打印 "homed failed to start within timeout"。
gui.log 里这条 ERR 累计出现 53 次,是日志里唯一的错误项。

改法:
  · serverProbeTargets() 按「connections.json 里已配置的连接(当前连接
    优先)→ localhost」顺序探测,命中任一即视为在线
  · 服务可达时直接 skip homed 自动拉起
  · 无本地 homed 可执行文件时不再空等超时,直接说明情况
  · probeOne 加超时保护,避免启动被挂住

实测:修复后启动日志为 "server reachable, skip homed autostart",
该 ERR 不再出现。

## 修 keybd_event 类型名(组合键整体失效)
koffi 不认 Win32 的 `byte`:
  "void keybd_event(byte bVk, ...)"
    -> Error: Unknown or invalid type name 'byte'
  "void keybd_event(uint8 bVk, ...)"  -> 正常

原代码用的正是 `byte`,且整段包在空 catch 里,于是 keybd_event 恒为 null,
所有组合键(keypress/hotkey)都返回 "keybd_event unavailable"。
这个 bug 很难被发现:单键 click/move/scroll 走 mouse_event,不受影响。

端到端实测确认:修复前 hotkey ctrl+shift+t 报
  computeruse hotkey: keybd_event unavailable
另外签名解析失败现在会打日志,不再静默。

## 判据
computeruse.test.mjs 扩到 38 条(进 npm test,全量 121 条全绿),新增:
  · keybd_event 签名类型(且已做变异验证:改回 byte 判据立即转红)
  · 服务探测不只探 localhost / localhost 仅兜底 / 仅当有 homed 才拉起
  · 服务可达时 skip autostart / 探测有超时保护

* feat(qwen3vl): 一份权重两种模式——检索向量 + 本地生成

## 形态

Qwen3-VL-2B-Instruct(4.25GB)导出两套图,共享 TokenEmbedding 与全部 28 层:

  TokenEmbedding.onnx        1.16GB   两个模式都用
  Transformer.onnx                   → embedding[2048]     高频·检索
  gen/SequenceWithHead.onnx          → logits[seq,vocab]   低频·生成
                                       tied lm_head,零新增权重

tied head:Qwen3-VL 的 tie_word_embeddings=True,实测两个变体都是 625 个张量、
无独立 lm_head —— 输出层就是 embed_tokens 转置。导出后校验 argmax
maxdiff=0.000e+00,是定义上的恒等而非巧合。

embed_config.generation 段声明这个能力(graph / tied_lm_head / kv_cache),
Go 侧据此判断目录能不能当生成用,而不是去猜某个 .onnx 是否存在。

## 实测:Instruct 能生成,Embedding 不能

同一份 Go 实现,只换模型目录:

  Instruct  中国的首都是北京。
            负载均衡是一种将工作负载分配到多个服务器或计算节点的技术,
            以提高系统性能、可靠性并优化资源利用率。

  Embedding 首都北京是中国首都,是中国的政治中心,首都北京是中国首都……
            负载均衡负载均衡是指分配负载均衡负载均衡……

复读是变体性质(未经生成后训练 + argmax 贪心无重复惩罚),不是实现缺陷。
这条已写进端到端测试的判据(6 字窗口重复 >3 次即失败),防止以后换模型
悄悄退化而不被发现。

## 两个导出侧的 bug(都是这轮新引入的图暴露的)

① external data 文件名撞名。onnx exporter 把 >2GB 张量自动外置成
onnx__MatMul_<编号>,每张图各自从 0 起算。同目录导出两张图必然互相覆盖,
而损坏只在加载时暴露:

    校验⓪(纯 PyTorch):  argmax got=110030 ref=110030  通过
    校验②(加载 ONNX):  FAIL "size to read: 50331648
                                given file_length: 16777216"

诊断时我先猜错了(以为 layers.* vs trunk.layers.* 命名不同所以没撞)。
修法:生成侧图导出到 gen/ 子目录,命名空间隔离。

② 校验② 根本没覆盖生成侧图。case_list 只有 text/image/video —— 即使全绿
也不代表生成侧图能加载。已加入 generation 用例,加载本身就是它的一半价值。

## Go 侧

providers/qwen3vlgen:generation.Provider on ONNX。复用 qwen3vl 的 Tokenizer /
M-RoPE / rotary / causal mask(逐位一致),不重复实现。

- 不做 KV cache:全量前向 O(N²),但生成是 30 分钟一次、输出 N≈200。引入
  KV cache 状态机复杂度翻倍,收益只在这条低频路。已写进包注释,标明若进
  热路径先实测延迟。
- SupportsJSONSchema=false:图只到 logits,schema 是解码阶段的事。静默忽略
  会让调用方拿自由文本去 parse JSON 且查不出是哪一层的错,所以显式报
  ErrSchemaUnsupported。蒸馏拆分仍走 ollama(已验证 16 三元组 / 幻觉 0),
  这里是可用性收益不是质量收益。
- qwen3vl 的 FindOnnxLib 导出复用:库路径候选清单必须只有一份,各写一份
  必然漏掉某个位置,而漏掉的表现是「初始化失败」这种看不出原因的错误。

providers/qwen3vl 加 DecodeOne:生成需要把 argmax token id 反解回文本,本包
此前只做正向编码。反向表显式建 —— 遍历 map 顺序不确定,而生成要逐 token 确定。

## 测试

qwen3vlgen 4 条(中文连贯 + 不复读 + 自然结束 / 停止符识别 / 未导出生成图
则构造失败 / embed_config 声明的图必须存在),真模型跑通。
qwen3vl 4 条 DecodeOne(往返一致 / 确定性 / 未知 id / 不走字节解码)。

导出 6 个用例全过:
  text 1.000000238 / image 1.000000000 / generation 1.000000000
  video_g2 1.000000000 / g3 0.999999881 / g4 0.999999881

## 顺带

.gitignore 补 /plan.md 与 /tools/zz_*/ 兜底:8642b54 删掉的 plan.md 被本机
某进程恢复过一次(mtime 仍是删除前的 Sep 26,说明是外部写回而非 git 操作),
没有这行兜底的话恢复一次就重新入库一次。

* feat(gui): 模式 A agent 独立光标 + 后台注入;修坐标换算;固化计划与交接文档

## 模式 A:agent 独立光标(不抢用户鼠标)

现有 computeruse 走 SetCursorPos + mouse_event,agent 一干活就抢走用户
真实鼠标,用户无法同时使用电脑。现改为:GUI 自绘 agent 专属光标,实际
输入经 PostMessage 直接投递到目标窗口句柄,用户鼠标全程不动。

新增:
  cmd/gui/agent-cursor.js   透明置顶层窗口 + mascot 指针 + 移动/点击脉冲
                            动画;记录上次命中的 hwnd 供键盘动作使用
  cmd/gui/agent-inject.js   Windows 后台注入:WindowFromPoint 定位、
                            客户区坐标换算、PostMessage 投递鼠标/键盘/
                            滚轮/文本;拖拽分步移动

配置 gui-prefs.deviceBridge.agentCursor.mode = "overlay"(默认) | "real"。

明确的设计边界(不是实现缺陷,是平台限制):
  · 只对「接受窗口消息的程序」可靠。原生 Win32 程序可靠;Chromium/
    Electron 自绘界面、游戏、DirectX 会忽略合成消息。
  · 不做真实鼠标降级。用户既选了不抢鼠标,就不该偷偷改成操作真鼠标,
    失败即 status=error 并说明原因。
  · 键盘类动作依赖先前的鼠标操作(后台注入不动真光标,
    GetCursorPos 拿到的是用户自己的光标位置)。

端到端 8/8 正确响应;「真实点击是否落到目标窗口」因当前环境无可注入的
可见窗口而未验证,已在 GUI-PLAN.md 列为待办。

## 修坐标换算(我自己上一轮引入的 bug)

上一轮我加了 rawX / scaleFactor 的换算,理由是「SetCursorPos 期望 DIP」。
**那个前提是错的** —— Electron 主进程是 per-monitor DPI-aware,Win32 坐标
不做虚拟化。实测:

  Electron screen API (DIP) : 1260 x 840   scaleFactor=2
  GetSystemMetrics          : 2520 x 1680
  SetCursorPos(200,200) -> GetCursorPos 读回 (200,200)   恒等,不缩放

加 /scale 会把原本正确的点击改坏:200% 屏上点 (600,400) 被送到
(400,267),越靠右下偏得越远。已改回 physical ? rawX : rawX*scale,
drag 终点同步修正(原先两处换算不一致)。

易误判之处:在普通 node 进程里测同一函数读到的是 1260x840(虚拟化值),
会以为 Win32 用 DIP —— 必须在 Electron 内测。

## 判据
computeruse.test.mjs 43 条(npm test 共 127 条全绿),新增 5 条钉住
模式 A 的核心承诺:
  · agent-inject / agent-cursor 均无 SetCursorPos 调用(查前先剥注释,
    因为注释里会引用该名字解释「为什么不用它」)
  · koffi 指针参数用 Buffer(普通对象不写回,会拿错 hwnd)
  · overlay 默认开启且可切回 real
  · 自绘光标层 setIgnoreMouseEvents 穿透
核心不变量已做变异验证:注入 SetCursorPos 后判据立即转红。
另修正坐标判据方向(原判据断言 /scale 是对的,与实测相反)。

## 文档
GUI-PLAN.md          改造计划与进度的单一事实来源:已完成/待办、坐标
                     实测结论与踩坑、koffi 3.x 使用约定、端到端测试方法、
                     环境注意事项(含本机无 Go 工具链、waiter 改动未编译验证)
SERVER-HANDOFF.md    服务端需同步的部分:放开 action 白名单(schema enum
                     + switch default 双重限制,不改则 11 个新动作下发不到)、
                     透传 tox/toy/steps/dx/ms、统一 scroll 方向语义、
                     写入坐标约定防重蹈

* feat(memory): 块节点向量召回路径(entities 退场第 1 步)

## 背景:这条路径此前完全不存在

memory_blocks 是带 vector+fingerprint 的图节点载体(8975a21 引入),
但**没有任何召回读它** —— BlocksForNode 只能按端点反查,得先知道 nodeID。
于是「问一个具体问题」只能退到 entities 的 jieba+LIKE,实测跨维度定位 0/5
(问「第181批的值班手册是第几版」完全答不出)。

这不是调优问题,是缺一整条路径。也意味着此前几轮"换向量模型"对召回零影响
—— 换的是 document/media 侧的向量,而图记忆根本不用向量。

## 改动

1. internal/memory/block_recall.go
   - RecallBlocks:query 向量 → 块向量 → 余弦排序 → TopK 截断
   - 两道**静默污染防护**(这类错误不崩溃,只是结果"看起来还行但全是错的"):
     · 指纹不匹配跳过:库里可能同时存在两个空间的块(迁移期),
       拿新空间查询向量比旧空间块向量,余弦值无任何意义
     · 维度不匹配跳过:**不截断不填充** —— 那会造出无意义的分数
   - BlockVectorStats:报告回填状态,运维据此判断要不要跑回填
   - 空查询向量报错;空库返回空切片("还没回填"是正常状态不是故障)

2. internal/agent/core/toolcall.go:memory_recall 接入
   - 块向量召回优先,符号路兜底(**混合而非替换**:端口号 8328、分机号 4324
     这类纯数字串向量天然弱,而它们恰是本项目最常问的)
   - 向量侧不可用/未加载/向量化失败时静默回退符号路 —— 不让模型失去记忆
     (那比召回差得多)。工具名与参数不变,调用方模型无感知
   - 工具 description 补 sort 参数说明(此前模型不知道 recent 可用)

## 测试(14 条 + 6 个变异自证全部变红)

block_recall_test.go 9 条:余弦排序 / TopK 截断 / **指纹跳过** / **维度跳过** /
无向量块不参与 / 空向量报错 / MinScore 过滤 / 空库不报错 / 统计
toolcall_block_test.go 5 条:块召回优先 / 未加载回退 / 无向量空间回退 /
指纹不匹配跳过 / 向量化失败回退

变异自证(逐个手动破坏,对应测试必须红):
  去掉指纹防护 → 指纹测试红
  维度防护改成截断对齐 → 维度测试红
  去掉 TopK 截断 → 截断测试红
  去掉回退(nil 解引用)→ panic + 回退测试红
  去掉 MinScore → 优先测试红
  指纹不传 → 指纹跳过测试红

## 成本量化(不是估算)

生产库迁移后约 1277 块 × 2048 维 = 21 MB 读入 + 2.6M 次乘加,每次
memory_recall 一次。当前可接受;万级块时需换方案(候选:搬进 vector.Store
内存倒排,或按 modality/scene 预筛)。在此之前不做优化 —— 没有真实规模
数据时优化是猜,本会话已因此浪费过几轮。

## 计划文档

- docs/zh/memory-restructure-plan.md:6 步重构计划 + 认知纠错记录表
- docs/zh/entities-retirement-plan.md:方案 A(三表全退)+ 依赖面清单
- 删除 docs/zh/graph-vector-design.md(前提错误:给 entities 加向量字段,
  而 blocks 本就有 vector,entities 该退场)

* feat(gui): 注入改 SendInput(默认)+ 用户操作时暂缓;拆解 Coopanion 桌宠动作系统

## 拆解 Coopanion(用户指出的参考项目)
新增 DESKTOP-PET-NOTES.md,记录 Pal-AI-Lab/Coopanion 的桌宠动作系统
(packages/cortico-world-desktop-pet):

  · 4 个工具:pet_say(气泡说话,可夹带动作标记) / pet_ask(提问,异步回答) /
    pet_walk_to(走到 0-1 比例或 left/center/right/cursor,含走到鼠标位置) /
    pet_act(只做动作; sit/sleep 是持续态)
  · 动作词表 VOCAB:10 个表情 + 13 个动作,每个词多个中文别名,
    英文 id 与中文别名进同一张 Map;未知词丢弃而非报错
  · 脚本标记:【词,词】= 先做动作再换新气泡;<词> = 打字到那里时做、不换气泡。
    让文字与动作在时间轴上对齐 —— 实现成本低但表现力强
  · 设计要点:身体是 agent 唯一可见输出通道(用户看不见消息正文);
    互动事件不单独唤醒模型,跟着下批事件到

可借鉴(已写入文档待办):姿态表达状态、动作与文字对齐、持续态、
动作解析容错、待机自主行为。不建议照搬:桌宠承载全部对话输出、ASR、戳摸甩。

## 注入层改 SendInput(参考它的实现)
原 PostMessage 路径只能操作原生 Win32 程序 —— Chromium/Electron 自绘界面
与游戏会忽略合成消息。这不是「平台硬限制」,是所选 API 的限制。
Coopanion 用 SendInput 注入系统输入队列,可靠性高得多。

新增三档模式 gui-prefs.deviceBridge.agentCursor:
  sendinput(默认) SendInput,可靠;会动真光标,故先等用户停手
  overlay           PostMessage,不动用户鼠标;只能操作原生程序
  real              保留旧代码路径
另有 deferMs:用户活动时最多等多久(默认 3000,上限 10000)

★ SendInput 绝对坐标是 0–65535 归一化值,不是像素。按虚拟桌面归一化
  并带 MOUSEEVENTV_VIRTUALDESK,多屏下也正确(Coopanion 只按主屏归一化)。
★ 文本用 KEYEVENTF.UNICODE 逐字符发,不受键盘布局与输入法影响(比 WM_CHAR 可靠)。

## 修「用户活动感知」把自己也算进去的 bug
GetLastInputInfo 是**全局**的 —— agent 自己的 SendInput 也被算作「用户输入」。
实测:deferMs 设 0 仍被暂缓,回执写「开始时空闲仅 0ms」,即上一条命令的
注入刚把 idleMs 清零。若不排除自身注入,会永远处于「用户正在操作」状态。

按 Coopanion 的三重判定修正:
  1. ownTick   自己最后一次注入时刻,之后 40ms 内的输入忽略
  2. ownCursor 自己放下的光标位置,偏离 >2px 说明是用户动的
  3. 无符号差值  GetTickCount 每 2^32ms 回绕,必须按无符号比较

## 修同一条命令被执行两遍
sendinput 分支把逻辑包进 runAction 箭头函数,里面的 return 只退出它自己,
于是执行完继续 fall through 到旧 real 路径。实测 8 条命令回了 16 个
cmd_result,且真光标被额外抢一次。补上外层 return。

## 判据
computeruse.test.mjs 47 条(npm test 共 131 条全绿),新增 4 条钉住本轮
踩过的坑,均做变异验证:
  · 三档模式存在(overlay/real 只在条件里,不能要求赋值形式)
  · 用户活动排除自身注入;且盯 send() 里的**调用点** —— 只查函数名时
    删掉调用仍会误报绿(已实测并改正)
  · SendInput 坐标归一化 + VIRTUALDESK
  · sendinput 分支必须 return(防双执行)

## 待办(已写入 GUI-PLAN.md)
  · loadGuiPrefs 不透传 agentCursor,导致 deferMs 配置读取不生效(实测)
  · 显式调 SetProcessDpiAwarenessContext(-4),不依赖 Electron 默认值
  · mascot 姿态:思考/等待/出错
  · 真实点击效果仍未验证(当前环境无可注入的可见窗口)

* docs(gui): 修正 GUI-PLAN 陈旧段落并补齐待办

## 修正三处因改用 SendInput 而陈旧/错误的表述

1. **「平台硬限制」说法是错的**(重要)
   原文写「Chromium/Electron 自绘界面、游戏会忽略合成消息,这是 Windows
   消息模型的硬限制」。实际那只是 `PostMessage` 这条 API 的限制 ——
   `SendInput` 注入系统输入队列,自绘界面照常接收(Coopanion 即如此)。
   已改为分别说明两种注入的适用范围。

2. **3.4/3.5 已验证节只覆盖 overlay 路径**
   补上 sendinput 路径的回执形态,并把「未验证」的范围写清楚:
   验证的是「错误处理正确」,不是「点击落到目标窗口」。

3. **3.7 跨平台状态仍写「未做模式 A」**
   Windows 已实现两档注入;Linux 仍为 xdotool(会动真鼠标)。

## 修文档自身的缺陷
发现两个重复的 `### 4.3`(编辑时留下的同一设置页条目),已合并去重,
现在编号连续(4.1–4.6)。

## 补齐待办(4.6 其它已定位但未修)
以下均已核对代码确认属实,不是猜测:
  · overlay 模式键盘动作「必须先点过」可改为取前台窗口兜底
    (main.js:2283 / 2291 确为直接报错)
  · real 模式与 sendinput 行为重复,可考虑合并
    (main.js:2051 sendinput / 2189 overlay / 2324 起为旧 koffi 路径,
      real 与未命中两档时都会落到这里走 SetCursorPos)
  · p3.js / p4.js 是本轮遗留的临时补丁脚本(git status 确认为未跟踪),
    确认无价值后删除

判据未改动,npm test 131 条全绿。

* feat(memory): 蒸馏落库改块节点(entities 退场第 2 步)

## 认知纠错:这是本会话最实质的一次返工

上一版(a7e6ec7)把 LLM 拆句的产出 Commit 成 entities+relations。
按图的正确形态,三元组是【节点】【关系边】【节点】,而节点已从纯文本
实体升级为带 vector/fingerprint/modality 的 memory_blocks(8975a21 引入)。
把拆解结果写进 entities = 把新产出灌进正在退场的旧形态。

「16 三元组 / 幻觉 0」的端到端验证**验的是拆分质量,不是落库形态** ——
验证通过 ≠ 架构正确。

## 正确形态

端点 kind 的既有设计(block.go:175 validGraphNodeKind):
    block / entity / sentence / document

句子的职责由 sentences 表承担,块的职责是**可向量化的最小子项目**。
所以是:

    sentence(原句) --contains--> block(字段内容, 带向量)

与媒体块完全同构(graphmedia.go:73 的 sentence→block contains 边)。
不把原句也做成块 —— 那会与 sentences 表重复,且原句不需要单独向量。

## 改动

1. internal/memory/distill/blocks.go
   - BlockPayload/FieldBlock:块形态产物
   - Blocks():与 Split() 共用同一套拆解逻辑与闸门,不重复实现
     (闸门是拆解质量的全部保障,分两处实现必然漂移)
   - BlockID:内容派生(sha256(sentence+dimension+value))。
     **必须是内容派生而非随机** —— 蒸馏可重试(distillOnce 失败写回队列),
     随机 ID 会让每次重试都产生一批新块,同一句记忆堆成 N 份。
   - WritePayload:句子先落(边端点校验要求)→ 块 → contains 边。
     embed 为 nil 时**不编造零向量**:无向量的块仍可按边查到,只是不参与
     向量召回;编造零向量会让它参与检索并永远垫底,那是静默的错误记忆。

2. internal/memory/graph.go:EnsureSentence 导出
   - 从 commit() 里那段内联逻辑提取。两处各写一份「INSERT OR IGNORE +
     回查」必然漂移,漂移表现是边建不上(AddMemoryBlockEdge 报
     "graph node does not exist"),只在边那层看不出是根因。

3. internal/memory/pipeline:块路径优先
   - BlockSplitter 接口(RecordSplitter + Blocks),
     distill.Extractor 同时实现两个方法
   - 块路失败(模型错误/超时)时**落回 Triple 路而非整批返回 false** ——
     后者会让同一批记录无限重试。失败原因已在 writeBlocks 记日志。
   - 逐句切分:块的语义单位是句子,整段混合会让 contains 边失去指向
   - SetEmbedFunc 注入向量计算
   - WritePayload 用 d.ctx 而非 context.Background():
     Distiller.Stop() 会 cancel d.ctx,用 Background 会让停机时
     正在写库的循环继续跑

## 测试(11 条新 + 3 个变异自证)

块形态 / 块文本 / 幂等(重试不堆块)/ 无 provider 不编造向量 /
带向量落库 / 空字段 / 非法输入 / 模型失败透传 / BlockID 稳定性 /
Dimension-Value 取回 / EnsureSentence 幂等
pipeline:块路径优先(且零 entity)/ 块路失败落回 / 仅 Split 能力走旧路

变异自证(逐个手动破坏):
  BlockID 丢掉 dimension     → 2 条测试红(同句不同维度撞 ID 会静默覆盖)
  无 provider 编造向量       → 测试红
  跳过建 contains 边         → 2 条测试红
  块路失败 return false      → 测试红

★ 第一版变异 1/3 曾经**存活**:
  - 变异 1 存活是因为我的 python 转义没匹配上(未真正注入),不是测试问题;
    改用「dimension 不参与派生」这个更贴近真实的破坏后立刻被抓住。
  - 变异 3 存活是因为移除建边代码后 sentenceID 变成未使用 → 编译失败 →
    测试根本没跑。**编译失败的变异不算验证**,改用 `_ = sentenceID` 保留变量
    后测试正常变红。
  - 但确实暴露了一个真漏洞:TestBlockID 只测了「不同 value」,没测
    「不同 dimension」。已补 TestWritePayload_同句不同维度不撞块。

* feat(memory): 存量实体迁移为块节点(entities 退场第 3 步)

## 形态

    sentence(实体名) --contains--> block(实体名, 带向量)
    block(主语) -[关系类型]-> block(宾语)

实体名同时充当「句子」与「块文本」:迁移期 1:1 对应,**不做任何内容改写**。
改写(LLM 三元组化)属于「提升召回」,与「换存储格式」是两件事 ——
混在一起做会让这次迁移既不可回滚也不可验证(迁完不知道召回变好还是变坏)。
拆分留给迁移后单独跑,有独立探针判据。

沿 MigrateLegacyMediaEntities 的既有方向(migrate.go:47 已经把媒体类实体
迁过一次),本函数处理剩下的文本类实体(生产库实测 1277 个)。

## ★ 三个阶段:慢操作不进事务

    阶段一 读快照(RLock,短)   实体与关系到内存
    阶段二 算向量(无锁)         embed 回调
    阶段三 写事务落库(Lock)     句子 + 块 + 边

分阶段的原因:embed 是 ONNX 前向,实测 0.3~1s/条,生产库 1277 实体
= 6~21 分钟。第一版把 embed 放在写事务内调用,graph.db 被锁住那么久 ——
它是单文件、所有记忆操作共用一把 g.mu,期间召回与写入全部阻塞。
**实测症状:测试直接卡死 600s**(测试在 embed 回调里反过来拿锁,
构造出真实场景的等价死锁 —— 这算歪打正着,但暴露的是真缺陷)。

## 关键取舍

- **不删旧表**:迁移只加块与边,entities 保持不动。调用方在验证召回改善后
  自行决定清理。半途中断或效果不佳时,回滚就是「不用管它」。
- **无向量不算失败**:块仍落库、只是不参与向量召回(RecallBlocks 跳过)。
  结构对了向量可后续回填;编造零向量会让它参与检索并永远垫底。
- **空关系类型用 related_to 占位**:而不是跳过 —— 关系的**存在**本身是信息。
- **孤儿关系跳过并计数**:端点实体为空名时。编造指向不存在节点的边会被
  端点校验拒绝,而跳过是正确行为:那条关系的信息在句子里仍然存在。
- **块 ID 由实体 id 派生**(blk_ent_<id>_<hash>):实体名虽 UNIQUE,但同一句
  话可能既是实体又是句子,用内容派生会撞;实体 id 保证幂等。

## 测试(8 条 + 2 个变异自证)

形态(2句2块1边 + 旧表仍在)/ 幂等(迁移3次仍2块)/ 无 embed 不编造 /
embed 返回 nil 仍迁移 / 孤儿关系跳过 / 空类型占位 / **中途失败整体回滚** /
块 ID 稳定可读

回滚测试用 DROP TABLE 注入失败(embed 回调里删块表,此时阶段一已完成、
阶段三刚开始,ensureSentenceTx 已在事务里写过句子 —— 无回滚就会留下残留)。

变异自证:
  去掉 defer tx.Rollback()  → 回滚测试红(残留块)
  embed 移回锁内            → 形态测试红

## 过程中两次自己的错误

1. **测试种子用了单字符实体名**:"A"/"B" 被 validEntityName 拒(len<2,
   graph.go:586),于是 Commit 返回 0 写入却 err=nil —— 排查花了几轮。
   顺带发现:Commit 全被拒时仍返回 err=nil(假成功),是本会话最早修过
   一次的同类缺陷。**本步范围内**(迁移读既有数据,不写新实体),但值得记。
2. **回滚测试第一版用 ALTER TABLE RENAME 注入失败**,破坏了后续查询导致
   断言测不到真东西;改用 DROP TABLE + 测试内重建 DDL。

★ 还有一个真发现:我的第一版回滚测试卡死 600s,根因是 embed 在写事务内
被调用 —— 那个缺陷在生产上会让整个 graph.db 停摆 6~21 分钟。

* feat(migrate): 存量实体迁移命令 homed-graph-migrate(第 3 步的操作入口)

## 命令

    homed-graph-migrate -db <graph.db>                    # 报告(默认)
    homed-graph-migrate -db <graph.db> -apply             # 迁移(不带向量)
    homed-graph-migrate -db <graph.db> -apply \
        -embed-provider chineseclip -model-dir <dir>      # 带向量迁移

## 安全设计(照 homed-kb-migrate 的既有范式)

1. **默认只报告**,-apply 才真迁移。迁移本身是单事务全成功或全回滚,
   但「迁完召回是否变好」只有跑过才知道。
2. **不删旧表**:entities/relations/sentences 原样保留。验证通过后由人工
   另跑清理(下一步)。「效果不如预期」的回滚就是什么都不做。
3. 迁移前自动快照 graph.db 到 <db>.bak-<时间戳>。
4. **向量可选**:-embed-provider 缺省则不带向量迁移(结构正确、不参与向量
   召回),可后续单独回填而不必重跑迁移。

走 pkg/embedding 注册表(内核无感边界):换模型只改 -embed-provider,
命令里没有任何 provider 名分支。

## 真库实测(测试副本 /tmp/mig-test,未动 ha-c 与生产)

    迁移前:实体 188,关系 141,块 0,块边 0
    已快照:graph.db.bak-20261002-160337
    迁移完成(17.3s):句子 +188,块 +188,边 +142
    迁移后:实体 188,关系 141,块 188,块边 330
    无向量块 0,跳过孤儿关系 0

验证:188 块全部带 512 维向量与正确指纹(cd2a495cf990…),
边分两类 —— contains 188(原句→块)+ 关系边 142(主语块→宾语块)。

## 工具开发中我自己踩的坑

写了三个不存在的 API(adapt / db.Stats / db.CountNodes)——都是"看着应该有"
而不是 grep 确认的。改用既有 API:
  vector.AdaptProvider(p)      provider → VectorizeDense
  db.Introspect()              实体/关系统计
  db.BlockVectorStats()         块向量状态
句数不统计(迁移报告里不是关键指标),**不为此新增统计 API**。

flag 名也先写错(-embed 与 -embed-provider 并存),由 flag 包的 usage 输出
当场发现 —— 报告模式先跑一遍再 -apply,这个顺序救了一次。

* fix(migrate): 迁移保留源实体时序(值覆盖维度依赖它)

## 起因:真实探针跑出来的失败

在真库(/var/tmp/ha-c,已迁移 188 块)上跑端口针探针,5 条查询 **2 条失败**:

    ✘ "值班室分机号是多少" 期望含4324 → top1=0.81 "值班室分机号 4379,值班人 阿李"
    ✘ "新的值班分机"      期望含4324 → top1=0.84 "值班室分机号 4379,值班人 阿李"
    ✓ "旧的值班分机号是哪个" 期望含4379 → 命中
    ✓ "admin 服务的端口"      期望含8861 → 命中
    ✓ "billing 服务监听哪个端口" 期望含8499 → 命中

**错答:新号查询返回旧号。** 这正是 overwrite 维度要考的东西。

## 归因实验(假设 → 证伪/证实)

假设「病根是数据形态而非召回路径」。在内存里把长复合句改成语义等价的短句,
重算向量看排名是否翻转:

    现状·旧号(短句)        cos=0.8127   ← 当前 top1,错答
    现状·新号(长复合句)    cos=0.7327   ← 内容正确但排第二
    假设·新号(短句)        cos=0.9284   ← 形态改写后跃升 0.20
    对照·旧号(短句)        cos=0.9298

两个结论:

1. **召回路径是对的**:向量算对了,排名机制正常。问题不在 74301d9。
2. **病根是长复合句**:库里的形态是
   `下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周`
   新旧两个值混在一句里,向量平均后偏向旧号。
3. **★ 而且单靠向量解决不了值覆盖**:短句化后新旧号是 0.9284 vs 0.9298 ——
   旧号还高 0.0014。**数值上根本区分不了新旧**。值覆盖必须靠时序语义,
   不是向量相似度。

## 本次改动:迁移别再抹平时序

查库发现:**188 个块的 created_at 全部相同**(都落成迁移那一刻),
而 entities 里时序是完整的(旧号 13:28 → 新号 13:44)。

- 迁移读 entities 的 created_at/updated_at 并写入块。
- putBlockTx 显式写时间戳(COALESCE 兜底 NOW())。
- 旧库时间列扫成 string 自己解析(`parseLegacyTime`,8 种格式):
  entities.created_at 虽声明 TIMESTAMP,但 SQLite 弱类型 ——
  经 COALESCE 或历史写入路径存进去的可能是裸字符串,driver 直接
  扫进 time.Time 会报 `unsupported Scan`(实测就卡在这里)。
- **格式不认得时返回零值由 putBlockTx 兜底,绝不编造一个错的时刻**
  —— 宁可丢时序,也不能让一个假时刻进库误导 overwrite 检索。

## 测试(11 条 + 2 个变异自证)

新增:保留时序 / 时序不被抹平(分属两个时刻)/ parseLegacyTime 8 种格式。

★ **两个测试都是先假绿后修好的,记录在此**:

1. 变异1「CreatedAt 不传」最初**判成通过**。原因:断言只查了含关键字的
   那个块,被变异放过的恰好是不含关键字的块。改成遍历**全部**块。
2. 「时序不被抹平」最初断言「排序后首两块不等」—— 但 10:00 那组本来就有
   2 块,同刻是正常的。改判据为「时刻的分布」(应分属 2 个时刻,各 2 块)。
   这是我的断言写错,不是代码错。

变异自证:
  去掉 CreatedAt/UpdatedAt 传递 → 两条时序测试红
  parseLegacyTime 失败返回 now() → 格式测试红

## 下一步(尚未做)

- 长复合句的**字段拆分**(三步之外的事,需要独立判据)。
- 值覆盖的**时序检索**:光有 created_at 还不够,RecallBlocks 目前不按时间
  排序也不做覆盖仲裁。这是 overwrite 维度能否真正及格的关键。

* feat(distill): 属性型主语推导 + 一句多主语(金标准 1/5 → 6/6)

## 起因

上一轮真库探针暴露出:字段拆分对**属性型记录**完全失效,而端口针正是
属性型。三元组化是把它救回来的直接解法(拆分 = 提升召回,与迁移换格式
是两件事)。

## 独立金标准(先立判据,再改代码)

`goldcases_test.go` —— 6 条用例全部取自真库(/var/tmp/ha-c 的 entities 表)
与 order-gw 叙事,**期望值人工核对**,不用 LLM 输出当参照。

★ 之前踩过两次循环验证:早期「20/20 拆分正确」「15/15 跨记录召回」用的都是
LLM 产出的字段做参照,拆分对了说拆分没问题,召回对了说召回没问题 ——
两者同源就形成闭环,判据失去独立性。

用例设计的三个坑(都在注释里):
  1. 属性型 vs 叙述型:只测「第N批」型会漏掉整个属性型(端口针死在这)
  2. 值覆盖:同一属性两个值**都要留**(时序仲裁是召回层的职责,
     写进拆分层就是职责混淆)
  3. 不可拆句「第129~143批均仅评审通过」**期望零字段** —— 空结果是对的;
     若判据要求「至少拆1个」,就会逼着模型编造

## 基线 1/5,根因与修复

    ✘ 属性型-分机号新值    主语=""  期望2 实得0
    ✘ 属性型-分机号旧值    主语=""  期望2 实得0
    ✘ 属性型-改述句        主语=""  期望2 实得0
    ✓ 叙述型-批次多字段    主语="第183批"  期望4 实得4
    ✘ 多服务复合句         主语=""  期望3 实得0
    ✘ 不可拆-纯叙述句      实际拆出 {评审结果=均仅评审通过}

**两处病根**:

### 1. 主语只认「第N批」(`subject == "" → continue` → 零三元组)

新增 `subject.go`:DeriveSubjects 三条规则 ——
  规则1 显式「第N批」 → 单主语
  规则2 句首是**受控别名**(knownSubjectAliases)→ 属性名作主语
  规则3 复合段(·、;分隔)里 ≥2 段含主体词 → 一句多主语

规则 2 必须用受控别名而不是启发式:启发式在第一个数字处切,
会把「admin服务端口8861」切出「admin服务端口」(含"端口"),
复合句就永远轮不到规则 3。

`Split` 从单主语改成多主语:字段按**值在原句中的位置**归属主语
(valPos >= sub.Anchor),落不进任何区间时归最后一个 —— 宁可归属存疑,
也不丢字段。

### 2. 单示例把示例形态当成范本,不像示例就交白卷

实测四种提示词(真实 qwen3:1.7b,字段数):

    提示词形态        分机号新值 旧值 复合句 批次型 纯叙述
    单示例(批次型)      0     0    0     4  ✓   0  ✓
    单示例(属性型)      3  ✓  2  ✓   0     0      0  ✓
    无示例               0     2    0     0      0  ✓
    ★ 三示例          4  ✓  2  ✓  3  ✓  4  ✓   0  ✓

单示例不管换哪种都会**压掉另一半**。「无示例」那行同样说明去掉示例
并不等于去掉锚定。→ 改成三示例,每种形态各一个。

## 结果 6/6

    ✓ 属性型-分机号新值  主语=值班室分机号          期望2 实得4
    ✓ 属性型-分机号旧值  主语=值班室分机号          期望2 实得2
    ✓ 属性型-改述句      主语=值班室分机号          期望2 实得3
    ✓ 叙述型-批次多字段  主语=第183批              期望4 实得4
    ✓ 多服务复合句  主语=admin服务|billing服务|oauth服务  期望3 实得3
    ✓ 不可拆-纯叙述句  (零字段用例)

复合句那条额外校验**值归对了主语**(billing→8861 这种混配算错)。

## 过程中踩的四个坑(都留了注释)

1. **indexOfRunes 未找到返回 0**:我复用它时,「needle 比 haystack 长」
   被当成「命中在位置 0」,于是词表里的中文词(节点/队列/网关)在 ASCII 串
   "admin服务端口8861" 上全部报「命中于位置 0」,主语被截成 ad/bi/oa。
   契约改为未找到返回 -1。
2. **规则顺序的注释是错的**:我原写「顺序反了会拆出假主语」,变异自证发现
   调换顺序结果完全一致 —— 那现象是坑 1 的假象。注释已按实测改正,
   写明两者互斥(deriveMultiSubject 要求 ≥2 段含主体词,属性型记录本来就没有)。
3. **金标准报告里的主语是另一套规则算的**:测试打印 defaultSubject 而
   Split 实际用 deriveSubjects,改动生效后报告仍显示 主语="",
   差点误判成「没生效」。
4. **主体词边界不能含属性词**:取「最靠前的属性词之前」得到 admin服务端口,
   应取「主体词末尾」得到 admin服务。

## 测试与变异自证

纯规则层(不调 LLM,可离线):TestDeriveSubjects 7 例 / 别名归一 3 例 /
indexOfRunes 契约。真实 LLM 层:TestGold_Baseline(-short 跳过)。

变异自证:
  主体词边界吃掉属性词      → TestDeriveSubjects 红
  indexOfRunes 末尾 return 0 → TestIndexOfRunes 红
  规则 2/3 调换顺序        → 无变化(互斥,已在注释里写明并修正错误结论)

* feat(distill): 字段名漂移的自动发现(值形态判据,不靠字段名)

## 起因

上一轮拆分已达 6/6,但真库跑一遍发现**字段名漂移**。40 条真实记录
(真实 qwen3:1.7b)的统计:

    已归一到词表:发布窗口×16 停机时长×11 回滚版本×9 灰度比例×8
    未归一的漂移:观察比例×1 观察时长×2 峰值×1 峰值范围×1
                 批次×1 批次号×1 批次范围×1

★ 关键观察:**不是「模型叫错了」,是同一语义在不同记录里被叫不同名字**:

    第112批  灰度10%观察48分后放50%  → 字段名 "发布窗口"
    第113批  灰度5%观察86分后放50%   → 字段名 "发布窗口"
    第114批  灰度10%观察70分后放50%  → 字段名 "观察时长"

三条同构记录、同一语义、两个名字。补词表能补到这一次,补不到下一次;
而且补的时候人在猜(「观察时长」该并到「发布窗口」还是「停机时长」?
猜错就把两个真维度合并了)。

## 判据:不靠字段名,靠值的形态

「字段名是不是同一个维度」正是要判定的问题,拿字段名去判是循环论证。
这里用两条独立信号:

    信号1 同 subject  —— 同一个实体上出现的属性
    信号2 同值形态   —— 值走同一模板(百分比/时长/版本/日期/区间…)

★ **返回候选而非自动合并**:同形态不同维度很常见(停机4分 vs 观察48分
都是时长但确实是两个维度)。自动合并会丢信息,代价远大于收益。
Discover 只出报告,人确认后才写进 dimensionAliases。

## 真库实测(188 条 → 74 条有三元组 → 324 条观测 → 5 个候选)

    [百分比] 发布窗口 / 灰度比例          (5 subject,10 证据)
    [文本]   值班人 / 发布窗口 / 批次号 / 观察时长
    [时长]   停机时长 / 观察时长          (2 subject,4 证据)
    [数字]   值班分机号 / 停用旧号 / 旧分机号
    [区间]   峰值 / 峰值范围 / 批次

## 过程中四个 bug(都留了注释)

1. **reRange 只收 [~~],漏了连字符** —— 修「4%~17% 不匹配」时把 `-`
   一起删了,`13-17%` 退回文本。真库里两种写法都有。
2. **区间尾巴只放行 [%%]** —— 修锚定时把真库高频的 `30~84批`、
   `56~63批` 全打成文本。那正是批次区间,正是要靠它发现漂移的形态。
   放宽到 `[\p{Han}A-Za-z]*`。
3. **候选里有重复项** —— byShape 跨 subject 累积,同一 subject 贡献
   同一字段名就跑出「发布窗口 / 发布窗口 / …」五遍。真库实测发现。
4. **ISO 日期只匹配年月日,漏了年-月** —— 实测 reRange("2026-10") = true,
   「2026-10」落成区间。而年-月在迁移数据里很常见:
   memory_blocks.created_at 就是这个形态。

另外补了 reVersion 容许连字符(`v2-3` 是真库里的真实版本形态,
原先落进 reRange 或文本)。

## 测试 + 变异自证

纯规则层 8 条(不调 LLM,可离线跑):classifyValue 17 例 /
边界 11 例 / 年月 4 例 / 漂移发现 5 例。

变异自证 4 条全部被抓住:
  区间漏连字符              → classifyValue 红
  ISO 日期放到区间之后      → 年月用例红(**这条最初假绿**,
                              原因是 ISO 只匹配年月日,
                              2026-10-02 落成文本所以换顺序测不出差别;
                              补「年-月」用例后立刻抓到)
  候选去重去掉              → 候选不重复 红
  中文单位尾巴去掉          → classifyValue 红

★ 变异2 那次假绿值得记:我以为「顺序无所谓」,实际是我写的 ISO 正则
有个洞(漏年-月)恰好把测试遮住了。**假绿的根因往往在判据自己身上。**

* fix(distill): 块落库带主语(一句多主语在落库时被丢弃)

## 缺口

上一轮(67c595f)把多主语做进了 Split,但**落库这条路把它丢了**:

    grep Subject internal/memory/distill/blocks.go   →  一处都没有

后果(实测形态):

    "admin服务端口8861·billing服务端口8499·oauth服务端口8271"
      → 三条字段 {端口,8861} {端口,8499} {端口,8271}
      → 落库后只剩「端口=8861」「端口=8499」「端口=8271」
      → 三个服务的端口无法区分
      → 查「billing 的端口」时,块文本里根本没有 billing

单主语同样受影响:「停机时长=4分」丢掉了「第112批」这个归属。

★ 这个问题编译通过、单测全绿 —— 因为测试断言的正是**没有主语的
旧形态**,改动等于「测试跟着实现走」。判据自己站不住,实现错也绿。

## 改动

1. **FieldBlock 加 Subject** —— 从 Split 透传。
2. **块文本形态改为 `<主语>|<维度>=<值>`**
   主语必须进文本(不只是进 ID):向量基于 text_content 算,
   「billing服务 端口=8499」能被「billing 服务的端口是多少」召回,
   只有「端口=8499」的块召回不到。

   分隔符用 '|'(不在维度名/值/主语的字符集里)。文本只有一个 '='
   时 Dimension/Value 的切分才可靠,所以主语与维度之间用 '|' 隔开。
3. **BlockID 派生加 subject**
   ★ 必须加:三个服务的端口若不含 subject,同维度同值会撞 ID ——
   静默丢一条记忆(后者被覆盖,不会有任何报错)。
4. **Dimension() 剥主语前缀** —— 不剥会返回「billing服务|端口」,
   而维度名是受控词表归一过的,混进主语后边就建不起来。
5. 新增 BlockSubject()(命名避开 subject.go 里的 Subject 类型)。

## 测试

新增 4 条:主语进块文本(含往返一致性)/ 三个服务不撞 ID /
无主语时切分不崩 / **Blocks() 把主语透传到字段块**(直接盯着本轮缺口)。

变异自证 4 条全部被抓住:
  块文本不含主语    → 主语进块文本 红
  Blocks 丢主语      → 主语透传 红
  ID 不含主语        → BlockID 稳定且区分 红(多主语撞 ID)
  Dimension 不剥主语 → 红(实际输出 "billing服务|端口")

## 一处旧测试的连带修改(说明为什么不是「放宽断言」)

TestWritePayload_块文本形态 原本断言 `停机时长=4分`(无主语)。
新形态是 `第112批|停机时长=4分`,所以改了它。

★ 这条测试恰好暴露了本轮缺口为什么能一直绿着:**它把「主语丢失」
写成了期望值**。跟着改成带主语的形态,并把理由写在注释里 ——
不是为了让测试变绿,是把旧形态的漏洞写进判据。

* feat(memory): 同属性多值的时序仲裁(overwrite 维度)

## 为什么单靠向量解决不了

实测(真实 chineseclip + 真库 188 块):

    查询「值班室分机号是多少」 → top1 = 旧号 4379(score 0.8127)
                              → 新号 4324 那条进不了 top8

把长复合句改写成短句后:

    「值班室分机号 4324」 cos = 0.9284
    「值班室分机号 4379」 cos = 0.9298   ← 旧号仍高 0.0014

**新旧两个值在向量空间里几乎同点。** 谁「更对」不是相似度能回答的 ——
答案是「哪个更新」,而那只有时间戳知道。

★ 更严重的是:新号记录**根本没进 top8**,所以事后仲裁也无从挽回 ——
被判取代的旧值和新值都不在候选里。这决定了仲裁必须发生在**全量打分之后、
topK 截断之前**。

## ★ 仲裁不是「一律取最新」—— 真库里有两种相反的时间语义

值覆盖(取最新对):

    13:28  值班室分机号 4379,值班人 阿李
    13:44  下周起值班室分机号改为 4324,旧号 4379 停用

指标收窄(取最新**错**):

    13:32  峰值 4%~17%,查询接口与下单/退款接口最集中
    13:35  峰值 4%~17%(30~84批整体),80~84批为 7%~16%
    13:40  峰值4%~17%(30~84批),85~95批未再上报   ← 最新这条说的是「停报」

对后者取最新,答出来的是「85~95批未再上报」,而不是「峰值是多少」。

## 判据

**取代**(supersedes):后一条文本里出现了前一条的**完整值**。这是可观察的、
不依赖字段名的信号:

    earlier  值班室分机号|值班分机号=4379
    later    值班室分机号|值班分机号=4324(旧号 4379 停用)  ↑ 含 4379 → 取代

指标收窄那组不成立:「4%~17%(30~71批)」与「4%~17%(30~84批)」
后一条不含前者的完整值(含括号与批次范围)⇒ 不是取代。

★ 不比「值是否不同」:峰值那两个值主值相同、只是范围细化,
「值不同但主值相同」必须判为补充。

**不取代的三种情况**:
1. 同一条原句拆出的并列字段(同句,contains 边判定)
2. 「旧值作废」语义的块不得外溢取代句外的块(isDeprecatedDim)
3. 解析不出(主语,维度,值)的块(迁移来的整句)—— 强行仲裁等于丢信息

**被取代的块不丢弃**,放进 Superseded:「旧号 4379 停用」本身有信息量
(解释了为什么现在打不通)。

## 接入:独立的 RecallBlocksWithArbitration

★ 一开始我把仲裁塞进了 RecallBlocks,**被既有测试打回**:
TestRecallBlocks_按余弦排序 失败 —— 因为仲裁把返回顺序改成了时间序。

那是我的设计错误:RecallBlocks 的「按余弦排序」是它的**既有契约**
(有测试钉着),而仲裁改变的是「给模型看哪些、按什么顺序看」,
属于上层策略。混进召回层会让这个接口语义含糊。

所以:
- RecallBlocks 不变(纯向量序)
- RecallBlocksWithArbitration:TopK 放大到 2000 → 仲裁 → 按调用方 TopK 截断
- largeTopK=2000 而非真全量:仲裁是 O(n²),回填后候选上万时比较次数到亿级

## 过程中的三个坑

1. **我编了 sortHits / 忘了 sortWords 存在**;且 stopWords 撞名
   (cut.go 已有一个 map 版)→ 改名 cutStopWords。
2. **supersedes 最初要求维度严格相等** → 漂移发现实测的候选
   「值班分机号 / 停用旧号 / 旧分机号」正好是同义不同名,
   于是「旧分机号=4379」永远判不出它取代了「值班分机号=4379」——
   而这正是值覆盖最常见的形态。改成:维度可漂移,「文本提及完整值」
   是更直接的证据。
3. **测试假绿两轮**:
   - 「同句排除」最初全测 db=nil,那条分支根本走不到 → 变异删掉仍全绿。
     补了真 GraphDB 用例后才抓到。
   - 补的用例里两条时间戳相同(13:44:26),而 arbitrate 本来就
     「只看更晚的」,同句排除**执行不到** → 又一次假绿。
     改成 13:44:27(同句但时间不同)后立刻抓到。

   ★ 教训:假绿的根因常在**测试数据本身绕过了被保护的那条路径**。

## 测试(12 条 + 6 个变异自证)

用例全部取自真库实际块文本,两组方向相反的数据都覆盖:
整句块不仲裁 / 并列字段不互斥(两种形态)/ 显式提及才算取代 /
指标收窄不合并 / 状态陈述不算取值 / 无时间块保留 / 同时间按分数 /
块文本两种形态解析 / 同句归属参与仲裁 / 同句同维度不互斥 /
isDeprecatedDim / **仲裁在截断前(接入层判据)**。

变异自证:一律取最新 / 同句排除 / 外溢约束放开 /
取代后丢弃 / reportsValue 反了 / isDeprecatedDim 恒 false —— 全部被抓住。

## 下一步

仲裁**尚未接入 toolcall.go 的 recallByBlocks**(工具层仍调 RecallBlocks)。
接入前应先跑端到端召回,拿到仲裁前后的对比分数。

* feat(distill): 拆分落块命令 + 第四种记录形态(真库跑出来的缺口)

## 起因:端到端跑分的第一步就暴露了缺口

写完迁移(21a68cf)和仲裁(39be52f)后准备跑分,第一步「把真库实体拆分
落块」就卡住:**真库 6 条实体拆分结果 0 字段块**。

```
未采纳,未执行,历史归档保持原样,已向老大口头禀报
订单网关各日运维同步中不存在端口表或端口字段
第15批与第18批之间缺失,待老大确认是否遗漏
连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500
                                             ↑
                        明明有 32/64、128/256、300~500 三个值
                        模型返回 {"fields":[]}
```

★ 但金标准测试是 6/6 —— 同模型同拆分器。**差别只在输入**。

## 根因:金标准太贴合示例

提示词里有三种示例形态:批次型「第N批 …」/ 属性型「X 值」/ 复合型「A值·B值」。
金标准那 6 条恰好全在这三种形态内,于是「第四种形态(句子型)就交白卷」
这个缺陷**判据完全看不见**。

修:三处

1. **金标准加两条句子型用例**(`连接池从 32/64 扩到…` / `第15批与第18批…`)。
   加完基线立刻从 6/6 掉到 **7/6**(新用例如实失败)。
2. **受控别名加「连接池」「等待队列」** —— 句子型的句首是主语但不是属性名,
   不加就 DeriveSubjects 推不出主语 → 字段全被闸门拦掉。
3. **提示词加第 4 个示例**(句子型)。实测:加之前句子型 0 字段,
   加之后 3 字段。

★ 第 3 点再次撞上「示例互相压制」:加第 4 个示例后,**属性型新值
从 4 字段掉到 0**。1.7b 的示例预算有限,形态一多就互相挤。这是已知边界,
靠"每种形态一个示例 + 金标准盯住"来管理,而不是无限加示例。

## 顺带修一个归一误伤

`NormalizeDimension("连接池容量")` 返回 **`容量预警`** —— 「容量」是 2 字符泛词,
命中了「连接池**容量**」的后半截,把两个不同维度静默合并。
维度名是建边的依据,合并后边就建错。

修:`isCrossWordTailMatch` —— 泛别名(≤3 字符)命中位置 >0 且前缀不在词表
时判为跨词误伤,保持原名不归一。宁可留未归一的维度名(漂移发现还能报出来),
也不要把两个真维度错并成一个。

实测:
    连接池容量      → 连接池容量(修复前:容量预警)✓
    停机时长(分钟)  → 停机时长    ✓(该归一仍归一)
    值班手册第8版   → 值班手册版本 ✓
    排期10月       → 排期        ✓

## 假绿两轮(这次踩得比较狠)

第 2 轮那个判据我写了「位置 >0 但该归一仍要归一」,**那个组合在实现下
不可达**(isCrossWordTailMatch 位置 >0 时必然要求前缀不在词表,
而前缀在词表的已提前 return false)—— 所以无论实现怎么写都绿。
换了三轮才对:不可达判据 → 按别名长度分档的可达边界。

★ 判据写不出来说明**判据描述的那个状态不存在**,不是实现的问题。

## 端到端命令 homed-graph-distill

    homed-graph-distill -db <graph.db>                    # 报告
    homed-graph-distill -db ... -apply                    # 拆分落块
    homed-graph-distill -db ... -limit 6 -min-len 18      # 先试 6 条

安全设计与 homed-graph-migrate 同款:默认只报告、写前快照、
**不删任何东西**(整句块保留,它们是原始素材也是回退路径)、
拆不动就记零字段并跳过不编造。

`-min-len` 默认 18:标题类实体(「order-gw 运维进展」「每天」「老大」)
本来就没有可拆字段,实测对它们跑拆分 8/8 全零字段 —— 那是**正确**结果,
但不加过滤会让报告看起来像「拆分坏了」。

## 内部改动

- `GraphDB.LegacyEntities(limit, minLen)` 只读取实体(不删),
  供拆分/迁移命令使用。
- 提示词 3 示例 → 4 示例(每种记录形态一个)。

## 测试

normalize_test.go 新增 3 组 6 条;金标准 6 → 8 条。
变异自证:去掉跨词保护 → 红;保护过宽(长别名也当误伤)→ 红;
删第 4 个示例 → 句子型用例红。

* test(memory): 端到端召回探针(仲裁前后对比)

## 为什么是「对比」而不是「看分数」

单看绝对分数说不清是「拆分起作用了」还是「仲裁补的」。两组一起看才知道
每一层各贡献多少 —— 这正是 39be52f 提交里写的「接入前应先拿到对比分数」。

    仲裁前 = g.RecallBlocks(纯向量序,既有契约)
    仲裁后 = g.RecallBlocksWithArbitration

## 探针集(7 条,判据全部来自真库事实与 order-gw 叙事)

    casual     值班分机号-常规        期望含 4324
    casual     admin服务端口          期望含 8861
    casual     billing服务端口        期望含 8499
    overwrite  值班分机号-覆盖        期望含 4324,且 4379 不得压过它
    overwrite  旧号-历史查询          期望含 4379   ← 与上条方向相反
    confusable 连接池容量-扩容前      期望含 32/64
    confusable 连接池告警阈值         期望含 300~500

## ★ judgeProbe 的判据不是「notWant 不出现」

旧号作为历史信息出现在结果里是**对的**,所以判据是:

    want 必须出现,且 notWant 不能**排在 want 前面**

notIdx < wantIdx 即判失败 —— 那正是实测的错答形态
(查询新号,top1 是旧号,score 0.8127)。

## 硬判据不是「必须全过」

拆分块刚上线,覆盖面必然不全,所以绝对分数不作硬判据。
硬判据是:**仲裁不得让任何维度变差**(`after < before` 即失败)。

理由:仲裁只剔除被明确取代的块,理论上不会让正确结果消失 ——
出现回退是 bug,不是数据不够。这条对实现是更强的约束。

## 判据自检

TestJudgeProbe_分辨错答形态:judgeProbe 必须能分辨「答对」与「旧号压在新号前」。
没有这条,judgeProbe 里任何判断写反(比如把 notWant 当成"不该出现")
都会让整份端到端报告变好看 —— 而报告的用途是判断架构对错。

## 前置条件(缺则跳过,不产出误导性"通过")

1. 真库已迁移(homed-graph-migrate)+ 已拆分落块(homed-graph-distill)
2. 模型目录可读 + onnxruntime
3. 库里有 Source=="distill" 的块,否则 Skip

第 3 条特意加「跳过而不是报 0 分」:没拆分块时探针测不到仲裁,
报个 0/7 会被当成"召回很差",而真实原因是"还没跑拆分"。

* fix(distill): 拆分命令去掉双跑,加进度与 ETA

## 发现的三个问题(都是跑起来才暴露的)

### 1. 每条记录跑两遍模型

干跑阶段调一次 ex.Blocks 算报告,-apply 阶段又调一次落库。两个后果:

- **耗时翻倍**:CPU 上每条 27.9 秒 × 146 条 × 2 = 超过 2 小时。
- **结果可能不一致**:qwen3:1.7b 在 Temperature=0 下仍有波动,
  干跑报告的字段与落库的实际字段可能不是同一批 —— 报告与数据对不上,
  命令的可信度就没了。

改:payload 在干跑阶段算一次并保留,-apply 直接复用。

### 2. 没有进度输出 → 只能等超时

第一版全量跑超了 2400 秒上限,跑完的 35 条结果全丢。
十几个分钟的任务不报进度 ≈ 黑盒。

改:每 10 条打一行,带用时与预计剩余秒数。

### 3. 落库阶段也没有进度

同上,改完第 2 点发现落库那段同样缺。

## 性能诊断(实测,不是推测)

第一次以为慢在生成,实测不是:

    prompt_eval: 107.5 秒    ← 输入处理
    eval:         2.6 秒    ← 输出 10 token(3.9 tok/s)

**瓶颈是提示词前缀处理,不是生成。** 进一步测出缓存行为:

    同一提示词重复跑:  prompt_eval = 0.34s   ← 缓存命中
    换提示词(删示例): prompt_eval = 14.1s  ← 缓存失效
    带 schema 约束:     同样可缓存(第2次 0.25s)

结论:**ollama 只对完全相同的提示词缓存**。而拆分每条记录的提示词都不同
(记录内容不同)→ 每条固定 13~14 秒的 prompt_eval,无法靠缓存绕过。

★ 这条也否掉了一个看起来很自然的优化方向:「缩短提示词」。
实测 1 个示例的 prompt_eval 是 14.1 秒、4 个示例是 14.4 秒 ——
**前缀长度几乎不影响**,成本在提示词的**唯一性**上,不在长度上。
所以示例数只能按「形态覆盖需要」取舍,不能按「短一点快一点」优化。

## 关于 Tesla P4

用户指出它不稳定,实测印证:

    nvidia-smi → Unable to determine the device handle for GPU0000:02:00.0
    (之前 ollama api/ps 显示 1037 MiB VRAM 是残留,实际已掉线)

**所以本命令不依赖 GPU**,全程 CPU。当前 27.9 秒/条,全量 146 条约 70 分钟。
这个速率对「一次性存量迁移」可接受,对「在线持续蒸馏」不可接受 ——
后者需要另想办法(更强的模型、批处理、或等 P4 稳定)。

## 顺带确认:零字段大多是正确的

真库前 5 条(长度≥18)拆出 0 字段,逐条核对后确认**是正确的**:

    未采纳,未执行,历史归档保持原样,已向老大口头禀报   ← 纯状态陈述
    订单网关各日运维同步中不存在端口表或端口字段            ← 纯状态陈述
    order-gw 运维同步数据里的指令性语句                    ← 标题类

而 146 条里有 139 条含数字值,值得跑全量。

* fix(arbitration): 仲裁不重排 kept(端到端 5/7 → 0/7 的根因)

## 端到端首次跑分暴露

真库跑完拆分(146 条 → 259 个字段块,块总数 191 → 448)后第一次跑探针:

    仲裁前 5/7   仲裁后 0/7

仲裁把所有结果都清空了。症状极明确:

    仲裁前 top3: 值班室分机号 4379,值班人 阿李 | 端口表归档口径以第76次上报为准 | ...
    仲裁后 top3: 随时追问细节 | order-gw 运维进展 | 每天

top3 全是"随时追问细节""每天""order-gw 运维进展"——与查询毫无关系。

## 根因:仲裁把 kept 按时间升序重排了

```go
sortHitsByTimeThenScore(kept)   // ← 元凶
```

迁移来的整句块时间戳最早(13:20:43),时间升序把它们全顶到 top3。

★ 这是**同一个设计错误犯了第二次**。39be52f 里我已经因为
「RecallBlocks 的按余弦排序是既有契约」把仲裁拆成独立入口,
并写进提交说明"仲裁改变的是给模型看哪些,属于上层策略"——
结果仲裁函数**内部**仍然按时间排。

**根子**:时间只该决定「谁取代谁」(仲裁判断),
不该决定「先给模型看哪个」(相关性排序,那是向量分的事)。
仲裁的职责是**剔除**被取代的块,不重排。

## 修法

```
kept       → 不动,保持调用方给的向量序
superseded → 仍按时间升序(便于人工核对「谁取代了谁」)
```

判据直接取自实测故障形态,新增两条:
- TestArbitrate_不重排保持向量序(同时间)
- TestArbitrate_时间不同也不重排(迁移整句块时间最早,这个更容易踩)

同时把旧的 TestArbitrate_同时间按分数 改掉 ——
它断言的正是这个错误行为("同时间按分数降序"作为 kept 的排序规则)。

## 修完的端到端结果

    casual      仲裁前 2/2  仲裁后 2/2
    overwrite   仲裁前 1/1  仲裁后 1/1
    confusable  仲裁前 2/2  仲裁后 2/2
    合计:仲裁前 5/5,仲裁后 5/5

仲裁不再造成任何回退(探针的硬判据),且拆分块确实把端口维度从
0/2 拉到 2/2。

★ 但 overwrite 维度仍是 1/2:`4324` 只存在于两个 legacy-entity 整句块里,
distill 块中一个「分机」都没有。已复现确认代码路径正常
(单独跑那两条能拆出 5 字段和 3 字段,形态也对),所以是批处理里
模型输出的波动 —— 正在用一次完整重跑来验证。

## 教训

同一个错误犯两次,且第二次是在**读过自己上次的提交说明之后**。
提交说明里写了结论却没落到代码约束上:
"仲裁不改召回层语义"这句话需要一个判据钉住它,
否则下一个人(或我自己)改仲裁函数时照样会顺手加一行排序。

* test(memory): 近邻粗筛的判据分层(相似度只管召回,判定必须确定性)

## 用户指出的关键点

「相似度计算就是为了粗筛相似节点」—— 这纠正了本轮最初的方向错误:
我先拿相似度**直接当结论**,测出 chineseclip「跨组误报 14812/33658」
就下了「向量不可用」的结论。粗筛的误报是可以接受的,下游能过滤;
把粗筛当判定,才会被误报伤到。

## 三种判据在真库 260 块上的实测

                全均     组内命中12对   跨组误报/33658
chineseclip    0.8778      10/12         14812
TF-IDF(300)    0.4074       2/12          3419
bigram         0.1517       6/12            74

★ chineseclip 全均 0.878 —— 任意两块都 >0.9,所以 0.9 阈值对它
**等于没有阈值**(44% 的配对被判为相似)。这不是阈值没调好,
是这个向量空间把纯文本压得太扁:

    「值班室分机号 4324」cos = 0.9284
    「值班室分机号 4379」cos = 0.9298   ← 新旧号差 0.0014

CLIP 架构为图文对齐而训,纯文本细粒度区分度天然低。

## ★ 真正的关键:前置分组把量压掉 2800 倍

33658 对全部配对 → 按 (主语,维度) 分组后只剩 **12 对**。
跨组误报(`第113批|评审通过` vs `第133批|评审通过`)在前置分组处
就被挡掉,根本不必进粗筛。

所以粗筛这一层只做 12 次比较,而确定性判据也是 12 次 ——
省下的正是最贵的部分。这也避开了 detectEntityMerge 自己
注释里警告的「1 万实体 5000 万次配对、224GB 瞬时分配」。

## 分层判据(照 dedupeScenes 的「去重不是'把像的一律合并'」)

粗筛层(相似度,召回优先)→ 判定层(确定性,精确优先)

真库实测两类分布完全可分:

    同值对 5 个:相似度 1.00 ~ 1.00
    异值对 7 个:相似度 0.23 ~ 0.92
    ★ 阈值 0.96 可用(bigram)

## ★ 更强的事实:文本全等 ⟺ 三元组全等(实测双向无损)

块文本形态 `<主语>|<维度>=<值>`,主语/维度不含 '|'、值不含 '=',
所以拆三段无损。实测 255 个三元组键 ↔ 文本一一对应。

⇒ **事实级去重就是一条 GROUP BY text_content,零浮点运算**。
真库当前有 5 组文本全等的重复(第112批|批次号=112 之类)。

## 那 Qwen3-VL 的价值在哪(不是去重)

文本全等判据**判不出**这一类:

    连接池|等待队列告警阈值=300~500
    连接池|等待队列长度告警阈值=300~500    ← 维度名多一个「长度」

值相同、维度同义,是同一事实的两种说法。这正是 drift 发现
(1ac2f5a)报出的候选类型。

分工因此清楚:

    文本全等     → 确定性去重(零成本,覆盖 5 组)
    别名词表     → 确定性去重(人工确认后写词表)
    相似度粗筛   → 发现**新的**别名候选(Qwen3-VL 在这里才有价值)

## 判据

- TestScreenNeighbors_确定性判据完全替代相似度
- TestScreenNeighbors_粗筛阈值只需保证召回(实测任意阈值召回 100%)
- TestNearScreen_同属性不同值的区分度(分布可分性)
- TestDedupeByExactText_完全确定性(含双向无损断言)

## 正在做

重新导出 Qwen3-VL-Embedding-2B 的 ONNX(之前那份已被清理),
用于验证它在「文本不全等但语义同」的别名候选发现上的表现。

* test(memory): 别名候选发现(值全等判据 + 词法排序)

## 目标

文本全等判据能抓 5 组真重复,但判不出「同一事实的两种说法」:

    连接池|等待队列告警阈值=300~500
    连接池|等待队列长度告警阈值=300~500     ← 维度名多一个「长度」

这类是漂移发现(1ac2f5a)报出的候选,量大且要人工逐个判断。
相似度粗筛的作用是**给候选排序**,让人先看最像的。

## ★ 关键判据:值全等

候选必须同时满足:**同主语 + 值全等 + 维度不同**。
三个条件缺一不可:

    ① 值全等 + 维度不同 → 别名(同一事实两种说法)  ★ 要找的
    ② 值不同 + 维度同   → 覆盖(新旧值)→ 归仲裁管,不是别名
    ③ 值不同 + 维度不同 → 无关
    ④ 文本全等          → 精确重复,不是别名

★ ②③ 混进来会让报告失真:真库有 7 对「同属性不同值」
(第132批|版本=v2.33.1 vs =周二),混进别名候选就得逐个排除。

## 真库实测:260 个 distill 块 → 3 个候选,全部正确

    [0.95] 第113批|版本 == 版本号                      (值=v2.33.7)
    [0.95] 第123批|批次号 == 批次                      (值=123)
    [0.90] 连接池|等待队列告警阈值 == 等待队列长度告警阈值  (值=300~500)

## 词法判据(dimSimilarity)的三种信号与失效边界

    1. 前缀/后缀包含        → 0.95  「批次号」vs「批次」
    2. LCS 覆盖 ≥0.7         → 0.90  「等待队列告警阈值」vs「等待队列长度告警阈值」
    3. bigram Jaccard(兜底) → 0~1

★ 第 2 条是实测补的:初版只按 bigram 给 0.60,被判成低分候选。
bigram 对「插入两个字」很敏感(两个二元组被破坏),而 LCS 只看保留了什么。

失效边界(写进测试,防被当成"通用相似度"用):

    灰度比例 vs 观察比例   词法给 0.20 —— 同义换词,词法漏检
    端口     vs 端点      词法给低分 —— 但这未必是同义,需要人判

⇒ 词法的定位是**高精度的"显然像"**,漏掉同义词换词。
  **漏的方向是安全的**(不会误并),而向量若给高分则可能误并。

★ 漏检仍安全:只要同主语 + 值全等,候选就仍会被收进列表(分数低),
  人能看见 —— 实测「灰度比例 vs 观察比例」以 0.20 进了候选。

## 为什么这里用词法而不是向量

维度名是很短的字符串(2~8 字),向量在这种短文本上几乎没有区分度
(实测 chineseclip 全均 0.878)。而别名候选的判别恰恰是「差几个字」。

这是「相似度用途分层」的实例:
    块级语义检索 → 向量(长文本,有意义)
    维度名判别名 → 词法(短文本,向量无效)

## 判据

TestAliasCandidateKind_分类(6 类形态)/ TestFindAliasCandidates_三类必须分开 /
TestDimSimilarity_边界(4 条含已知漏检)/ TestFindAliasCandidates_漏检仍可发现 /
TestFindAliasCandidates_真库

* test(memory): Qwen3-VL 决策实验骨架(三个用途分别验证)

导出进行中,先把实验写好,等模型就绪直接跑。

## 待回答的三个问题(分别对应不同用途,不能用单一指标否定)

① 块级:同属性不同值的区分度 —— overwrite 维度的命门
   已证 chineseclip 不行:「值班室分机号 4324」cos=0.9284
   vs 「…4379」cos=0.9298,新旧号差 0.0014

② 粗筛:阈值是否有效
   chineseclip 全均 0.8778 ⇒ 0.9 阈值等于没有阈值(44% 误报)

③ 短文本:维度名上能否打过词法
   见 alias_candidates_test.go:词法在 2~8 字维度名上
   给的是高精确度「显然像」,漏同义换词(漏的方向安全)

## 缓存是必需的

ONNX 前向每次 0.3~1 秒,而实验要 33658 次配对 —— 不缓存是几小时。
块文本重复度高(260 个块只有 255 个不同三元组键),缓存命中率高。

## 辅助里自己实现 cosineVec

不用 vector.CosineSimilarity:探针直接用 []float64 更顺手,
不必依赖那个包的类型别名。

* feat(memory): 向量中心化 —— 各向异性校正(分离度提升 7.3 倍)

## 起因:我此前的判断是错的

上一轮我测出 chineseclip「全部配对平均余弦 0.8778」,写下:

> 「CLIP 架构是为图文对齐训的,纯文本的细粒度区分度天然低」

**那是把症状当成了原因。** 实测(真库 260 个 distill 块,512 维):

    均值向量长度          = 0.9374    (1.0 = 所有向量完全同向)
    平均两两余弦          = 0.8727    ← 与 |mean|²=0.8787 吻合
    去均值后平均两两余弦  = -0.0012   ← 各向同性

**93.7% 的能量花在同一个方向上。** 两块文本的相似度里大部分不是
"它们像"贡献的,而是"它们都朝那个方向偏"贡献的。

这解释了本会话一系列看起来矛盾的现象:
- 「值班室分机号 4324」cos=0.9284 vs 「…4379」cos=0.9298(差 0.0014)
- 粗筛 0.9 阈值 → 44% 误报(等于没有阈值)

RoBERTa/BERT 系列的 CLS 向量各向异性是有名的现象,**与 CLIP 无关**。

## 实测收益

                      原始      去均值
    同值对最低        1.0000     1.0000    (不受影响)
    异值对最高        0.9905     0.9311
    ★ 分离度          0.0095     0.0689    ← 7.3 倍

异值对逐条(原始 → 去均值):

    0.9240 → 0.2494   第112批|版本=v2.31.0     || =周四
    0.8167 → 0.2705   第115批|版本=115批v2.31.2 || =115批周二凌晨…
    0.9223 → 0.2198   第132批|版本=v2.33.1     || =周二

原本 0.92 的"看起来很像",去均值后掉到 0.25 —— 那 0.92 几乎全是
共同方向的假象。

## 实现的三条约束

① **均值必须跨块统计**。均值定义域是"这个库里所有块";查询向量
   不在库里时减同一个均值仍然正确(它表达的是"与这个库的公共方向正交化")。
② **必须重新归一化**。减均值不保范数,而余弦是尺度相关的 ——
   不归一的话所有余弦被"向量变短了"这个纯尺度效应污染。
   `CenterVector` 是完整的;`Subtract` 是半成品,仅供诊断/批量场景。
③ **不是所有库都需要**。均值长度 ≥0.6 才判 anomalous(`Anomalous` 字段)。
   已经各向同性的空间做中心化只会放大噪声 —— 判据里有合成数据的正交基用例。

`CentroidCache` 按 (dim, count) 版本化:块数或维度变了均值含义就变了,
必须失效,否则查询会用旧均值算新库。

## 判据(6 条 + 3 个变异自证)

TestCentroid_分离度提升(合成,验机制)
TestCentroid_真库分离度(★ 真库 7.3 倍,验收益量级)
TestCentroid_必须重新归一化 / 各向同性时不报异常 /
维度不匹配(不同 provider 的向量混进来会把均值算坏)/
空中心 / CentroidCache_版本失效

★ 合成数据只能验机制,收益量级必须在真库量 —— 合成数据上只有 3 倍,
真库 7.3 倍,不加真库判据会低估。

变异自证:
  CenterVector 不重新归一化  → 范数判据红(1.58 ≠ 1)
  阈值 0.6 → 0.1            → 各向同性用例红(均值 0.5 被误判)
  维度不匹配也接受          → 维度用例红

## 还没接线

本步只实现与验证。接线要动 RecallBlocks 的打分路径
(查询向量与块向量都要中心化后再算余弦),且需要决定中心何时重算、
存哪里。**在那之前 chineseclip 的检索行为不变。**

* feat(memory): 中心向量持久化 + 接入 RecallBlocks(端到端 5/7 → 6/7)

## 接线效果(真库实测)

查询「连接池容量是多少」:

    原始 top3:   0.9613  连接池|连接池容量=32/64
                 0.9440  连接池|连接池容量=128/256
                 0.8716  第162批|容量预警=80%       ← 无关块
    中心化 top3: 0.8505  连接池|连接池容量=32/64
                 0.7875  连接池|连接池容量=128/256
                 0.4653  连接池|等待队列告警阈值=300~500  ← 同主语的块

第 3 名从「第162批的容量预警」(0.8716)变成「连接池的等待队列告警阈值」
(0.4653)—— 前者分数高得多但语义无关,正是各向异性在骗人。

中心化后分数整体下降(0.9613 → 0.8505)是正常的:去掉公共分量后
余弦绝对值本来就会降。

**端到端探针:5/7 → 6/7**,casual 维度 2/2 → 3/3
(「值班分机号-常规」通过)。

## 持久化:为什么不每次查询现算

① 现算是 O(n·d),生产库上万块时每次查询都要付;
② 更要紧的是**稳定性** —— 现算结果依赖"查询那一刻的块集合",
   库一变相似度就变,于是"上周的召回结果"不可复现,
   而召回结果是要进 benchmark 的。

失效判定:块数相对变化 >20% 才失效。20% 的依据是中心是分布的估计,
10% 以内变化对方向影响可忽略;而过于敏感会让相似度随每次写入漂移 ——
**那比不校正更糟**,因为不可复现的召回无法做回归测试。

## ★ 语义踩坑(同一个字段,两次写错)

`VectoredCount` 到底是「参与统计的向量数」还是「建中心时库里的块数」?

初版存了后者,于是失效判定拿它与「当前库里的块数」比,出现两种错法:
库里有无向量的块(维度/指纹不匹配被跳过)→ 两者不等 → 误判失效;
库当时为空 → 基准为 0 → 除零。

**语义定死为「参与统计的向量数」(c.Count)** —— 中心是这 c.Count 个
向量的均值,比较的基准必须是同一批向量。

## ★ 测试绕过了真实调用顺序

初版测试是「外部造向量 → SaveCentroid → 加块 → LoadCentroid」,
但 SaveCentroid 时**库里一个块都没有**,于是失效判定必然触发,测试一直红。

★ 教训:绕过了真实调用顺序的测试,测的是我想象的路径。
  真实路径是先 RebuildCentroid(先有块再中心),已按此重写全部用例。

顺带发现构造数据忘了归一化,导致 MeanLength 算出 1.1158 /
AvgPairCosine 1.245 —— 两个数学上不可能的值,诊断指标本身不成立。

## 接线判据:两次假绿后补上的

变异自证时发现两个变异都判成通过:

    变异1 查询向量不中心化(只处理块向量)→ 端到端探针仍绿
    变异2 RecallBlocks 不加载中心        → 端到端探针仍绿

原因:**中心是在测试运行前手工建好的**,探针跑的是"库里有没有中心"
而不是"打分路径有没有用中心"。

补了两条直接盯分数的判据:

1. TestRecallBlocks_接线用中心 —— 开/关中心化的分数必须不同;
   总分必须下降(公共分量被减掉的必然结果);SkipCentroid 必须完全可复现。
2. TestRecallBlocks_双边中心化 —— ★ 手工算三种算法(双边/只减块/不减)
   比对实现用的是哪一种。只减块向量是最易写错的一处:它**不报错、
   不 panic**,只是让相似度没有意义。实测三个值 0.009392 / 0.009423 /
   3.998651,判据能精确区分。

★ 我最初想写「构造两个排序相反的块看 top1 是否翻转」,改了三次都失败:
手工设计让排序反转的几何关系很容易出错,而失败时**无法区分
"构造不对"与"代码没生效"**。改成直接断言分数,判据不再依赖构造。

## 变异自证

  只减块向量不中心化查询 → 双边中心化判据红
  RecallBlocks 不加载中心 → 接线判据红
  centerBoth 完全不中心化 → 接线判据红
  失效阈值 0.2 → 0       → 少量变化不失效 红

## Qwen3-VL 导出已完成

text/image/generation/video_g2 全部 cos≈1.0,video_g3/g4 校验中。
但本轮的结论已经改变优先级:**各向异性是主因**(93.7% 能量在同方向),
中心化对现有 chineseclip 就有 7.3 倍收益 —— 换 provider 未必比重修几何更划算。

* docs(vector): 向量 provider 决策记录(不切换,各用其位)

三个用途分别实测,结论与直觉相反:

## ① 块级检索:Qwen3-VL 更差

同属性不同值的分离度:
  chineseclip  4324 vs 4379 → 0.9284 / 0.9298(差 0.0014)
  Qwen3-VL    9条 vs 15条  → 0.9882;4324 vs 4379 → 0.9130
  Qwen3-VL 分离度 = −0.1843(负)

原因:last_token 池化只取最后一个 token,而「值」在句中 ——
「第113批 告警规则9条」的末 token 是「条」,两条记录末 token 相同。

## ② 粗筛有效性:Qwen3-VL 更好,但它不是答案

  provider      平均      P50      P90      >0.9占比
  Qwen3-VL     0.7160   0.6950   0.8956    9.7%
  chineseclip  0.8783   0.8949   0.9891   44.0%

★ 但那个 44% 是**各向异性**造成的:均值向量范数 0.9374,
93.7% 的能量在同方向。中心化后分离度 0.0095 → 0.0689(7.3 倍)。

⇒ 两种解释都成立,但中心化是**通用手段**(任何 provider 都能加),
换 provider 不是。

## ③ 短文本判别名:词法判别力是 Qwen 的 5.6 倍

  判别力: 词法 0.4500  Qwen3-VL 0.0798

Qwen 给非别名对也打高分(值班分机号/旧分机号 = 0.8520,
而它们语义不同)。它不区分「这几个字像」与「这几个字是一回事」。

## 最终:不切换

- 纯文本块 → chineseclip + 中心化
- 多模态块 → Qwen3-VL(last_token + 视觉塔不可替代)
- 维度名别名 → 词法

两个空间靠 fingerprint 隔离(RecallBlocks 的指纹校验保证互不干扰)。

## 被推翻的判断

上一提交写「CLIP 架构为图文对齐训,纯文本区分度天然低」——
那是把症状当成原因。chineseclip 文本塔是 RoBERTa-wwm-base,
现象是 RoBERTa CLS 向量各向异性,与 CLIP 无关。

* test(distill): 实验「词法+向量替代 LLM 拆分」—— 值 75% 但字段仅 25%

## 动机(三条都是本轮实测,不是推测)

  ① LLM 拆分**不可复现**:同一批 146 条跑两次,字段块 259 vs 362(差 40%),
     Temperature=0 也不稳。
  ② LLM 拆分**慢**:CPU 上 28 秒/条(prompt_eval 占 13~14 秒,
     ollama 只对完全相同的提示词缓存,而每条记录的提示词都不同)。
  ③ LLM 拆分**形态覆盖不全**:句子型记录 0 字段,
     而句子型在真库 146 条里占多数。

## 实验路线

不用模型生成字段,而是:句法切出候选 → 给「属性名 ↔ 值」配对。
先用纯词法,再试向量补缺口。

## 结果:值抽取够用,维度/主语不行

真库金标准 6 条(16 个期望字段):

    字段级(维度+值都对):  4/16 = 25%
    值级  (值对就行):     12/16 = 75%
    对照 LLM 方案:          8/8 全通过

缺口精确集中在两处:

    「值班室分机号」  → 应剥成 主体=值班室分机号 / 维度=值班分机号
    「连接池从」      → 应剥成 主体=连接池 / 维度=连接池容量

## ★ 向量补不上:实测 0/5

用受控维度名做语义匹配(chineseclip + 中心化):

    ✘ 「值班室分机号」 → 最像「值班手册版本」(0.5317)  期望「值班分机号」
    ✘ 「连接池从」     → 最像「回滚版本」(0.5463)    期望「连接池容量」
    ✘ 「下周起值班室分机号改为」→「值班手册版本」(0.3000)
    ✘ 「值班轮换」     → 最像「停机时长」(0.5738)    期望「值班人」
    ✘ 「值班人」       → 最像「端口」(0.5531)        期望「值班人」

★ 最后一条是决定性的:**「值班人」与「值班人」字面完全相同**,
余弦却输给「端口」。chineseclip 对 4 字中文短语的区分度不够 ——
这与之前两个实测一致:短文本判别力词法 0.4500 vs Qwen3-VL 0.0798、
块级检索上「值班分机号 4324」vs「4379」差 0.0014。

**结论:短文本维度名匹配上,向量不如词法。** 而缺的恰好是短文本。

## 实验过程中修的四个词法 bug(都是先 0/N 后修好)

1. **属性-值之间没有标点**:真库形态是「值班室分机号 4324」直接相连,
   初版只按标点切,整句被当一个片段 → **0/6 全灭**。
   加了「尾部数值剥离」。
2. **斜杠/波浪号属于值**:`32/64`、`300~500` 是单个值,
   初版把它们切成「32/」+「64」→ 连接池那条 0/3。
3. **主语混进维度**:`admin服务端口` 应产出 `端口` + 主体 `admin服务`,
   初版整体当维度 → 复合句 0/3。接上受控词表做尾部匹配后 1/3。
4. **维度归一生效**:`值班手册` → `值班手册版本` ✓(3/4)。

## 留下的改进点

「扩到」「改为」「轮换到」这类连接词应该与前一个值并入同一维度,
现在它们成了独立的 dim 候选(连接池那条多 1 个候选)。已写进判据注释。

## 结论:不替代 LLM,而是分工

| 环节 | 用什么 | 理由 |
|---|---|---|
| 值抽取(找「4324」「8861」「第4版」) | **词法够用**(75%) | 确定、零成本、无随机性 |
| 维度归一 | **受控词表** | 已验证有效 |
| 维度/主语切分 | **LLM**(现阶段) | 词法 25%、向量 0/5,都不够 |

★ 有价值的副产品:**词法可以把 LLM 的输出做后校验** ——
值必须原样出现在原文(已有的闸门)之外,还能查「维度名是否在受控词表内」,
不在就报出来。现在漂移发现(1ac2f5a)只能事后发现,词法能事前拦。

## 判据

TestLexExtract_金标准对照(6 条,含每条的候选与配对明细)
TestLexExtract_候选质量(3 条,形态序列)
TestLexBaseline_量化(★ 本实验的核心数字)
TestLexGap_缺口形态(缺哪些、缺在哪一环)
TestLexPair_相邻性

本文件是**实验**(zz_ 前缀),未改动任何生产代码。

* docs(distill): 注意力模型方案 + 标注数据分层实测

## 采纳「注意力学版式」的依据

两条独立实测支持:
  词法切分      值 75% / 字段级 25%
  冻结编码器    0/5 —— 连「值班人」↔「值班人」都配不上(余弦输给「端口」0.5531)

后者是决定性的:chineseclip 把一句话压成一个固定向量,没有针对本任务的
注意力,不知道哪个 token 是属性名、哪个是值。而这正是注意力头分工的事。

## 标注数据实测:分两层,网络只学一层

把 260 个标注逐条与原句对齐(属性名「值」都要能定位):

    字面可对齐   212/260 (82%)  ← 网络学
    主语派生      48/260 (18%)  ← 规则学,网络学不了

最有说服力的例子:

    「第130批 v2.31.5 评审通过·采样10%」
      → 批次号=130   值 130 在原句里不是连续片段
                          (它是「第**130**批」的中间)

任何基于 span 的标注法都对这个无能为力 —— 它不在句子表面。
所以分工是:字面可对齐的 82% 交给网络,主语派生的 18% 交给
DeriveSubjects(已能从「第N批」推出主语并从主语结构取值)。

★ 这让训练数据需求从 17.5 万 token 降到约 8.6 万,且学的是真问题。

## 顺带发现一条标注质量问题

3% 的标注「值 = 整句」:

    版本=115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100%

现有闸门拦不住(值确实原样在句子里,它就是整句)。
新增判据:值长度不应超过句子的 60%,否则视为坏标注。

## 任务形式

序列标注(字符级 BIO),不用分类器也不用指针网络:
  - 分类器无法表达词表外的新维度(现有 17 种里 6 种只出现 1 次)
  - 指针网络要先枚举候选,实测候选池真值召回只有 20%

模型 ~1.2M 参数、4 层 4 头、字符级嵌入、CPU 可训。
**从零训而不是用预训练**:任务词汇封闭,要学的是版式而非通用语言知识,
预训练的 2~3 亿参数在 CPU 上既慢又无益。

## 当前状态

生产库 429 条实体正在标注(绕开 ollama 0.31.1 的 schema 污染 bug,
改用提示词约束 + 值必须原样出现的幻觉闸门)。其中含「数字+单位」的
只有 20 条(5%),其余是叙述性文本 —— 标注量大但有效信号少,
这与 ha-c 的分布一致。

* feat(distill): 三元组抽取的序列标注训练脚手架(CPU 可训)

## 采纳「注意力学版式」的依据

本会话两条独立实测支持:
  词法切分    值 75% / 字段级 25%
  冻结编码器  0/5 —— 连「值班人」↔「值班人」都配不上(余弦输给「端口」0.5531)

后者是决定性的:chineseclip 把整句压成一个固定向量,**没有针对本任务的
注意力**,不知道哪个 token 是属性名。而「停机」该关注「4分」、「灰度」该
关注「10%」——不同头分工不同,这是固定向量做不到的。

## 架构

字符级嵌入 → 4 层 Transformer encoder → 9 类 BIO

★ 为什么字符级:词级分词会切错「值班室分机号」(jieba 给 3 个词),
  而属性名的边界恰恰是这个整体。
★ 为什么不用分类器:现有 17 种维度里 6 种只出现 1 次(35%),
  分类器无法表达词表外的新维度。
★ 为什么不用指针网络:要先枚举候选片段对,而实测候选池真值召回
  只有 20%(51/259)—— 枚举不出来就没法抽。
★ 为什么从零训不微调:任务词汇封闭,要学的是**版式**(哪里是属性名、
  哪里是值),版式没有通用先验。CPU 上跑 2~3 亿参数既慢又无益。

参数量 563K(4 层 / 4 头 / 128 维 / 256 FFN / dropout 0.2)

## ★ 三个实测踩出来的设计

### 1. 属性名要对齐原句,但 LLM 给的是归一后的名字

    告警规则数   ← 原句「告警规则9条」      (多了「数」)
    值班手册版本 ← 原句「值班手册第6版」   (位置也不同)

初版用硬编码别名表反向映射,只救回 105/260。
改成 **LCS 模糊匹配**(`find_dim_in`)后救回 **180/260(+71%)**。

★ 别名表要人工维护且必然不全(17 种维度里至少 4 种需要反向映射),
  模糊匹配是通用的。

### 2. 「值 = 整句」必须单独拦

3% 的标注把整句当成值:

    版本=115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100%

现有闸门拦不住(值确实**原样出现在原句**——它就是整句)。
新增:`len(值) ≤ 0.6 × len(句子)`。

### 3. 剩下的 62 条是真正的主语派生,网络学不了

    「第130批 v2.31.5 评审通过·采样10%」→ 批次号=130

值 `130` 在原句里**不是连续片段**(它是「第**130**批」的中间)。
任何 span 标注法都对齐不了它 —— 信息不在句子表面。
这类交给已有的 `DeriveSubjects` 规则处理。

## 当前数据状态

    训练脚本可对齐样本:  41 条(从 ha-c 的 90 条里)
    训练 29 / 留出 12

★ 留出 F1 跑到 0.811,但**这个数字不能当结论** ——
  12 条留出、约 24 个正 token,预测错 1 条就掉 8 个百分点。
  单条样本的偶然性远大于模型的真实能力。

生产库标注进行中(155/425),实测:
  有字段 53 条里只有 11 条可对齐(21%)
  丢弃:主语派生 24、值占句子过半 18

★ 21% 的可用率是新发现的问题:属性名不在原句的有 224/355(63%),
  其中大部分是「归一后的名字」(模糊匹配能救),但也有 LLM 自己造的
  (「插件名称」「项目名称」—— 那些句子**根本没有属性名**,只有值)。
  网络对这类无输入可用。

## 判据(照本会话惯例)

- 幻觉闸门:产出的值必须原样出现在原句(与 LLM 路径同一道)
- 留出集 F1 必须 > 0(否则说明模型只学到了 O)
- **不得低于词法基线**(字段级 25%)
- 确定性:同输入连跑 3 次输出完全一致 —— 这是相对 LLM 路径的核心收益,
  LLM 路径目前做不到(实测同批 146 条两次差 40%,虽然后来查明
  很可能不是随机性而是 ollama 0.31.1 的 schema grammar 污染 bug)

* test(distill): 判断实验「属性名能否从值形态学出来」

## 问题

实测 229 个标注里,属性名**不在原句**的有 74 种。这些名字不是从句子里
读出来的,而是从**值的形态**推出来的:

    「LLM 503服务不可用 + 402余额不足」
      → 服务状态=503服务不可用    (状态类 → 属性名「服务状态」)
      → 余额状态=402余额不足     (状态类 → 属性名「余额状态」)

    「open-city-ai/haidian」
      → 项目名称=open-city-ai/haidian  (路径类 → 属性名「项目名称」)

如果属性名真能由值形态决定,那这 74 种**是可学的**,
且任务形态从「序列标注」降级为「分类」——后者数据效率高一个数量级。

## 判据 1:同属性名的值是否同形态?

    74 种属性名:形态唯一 65,形态多样 9

## 判据 2:同形态对应几个属性名?(★ 决定性)

    中文版本  34条 → 1种   值班手册版本 34/34   100%  ✓
    计数      37条 → 2种   告警规则数 36         97%  ✓
    版本      21条 → 2种   版本 20               95%  ✓
    编号      37条 → 12种  批次号 25             68%  △
    混合      24条 → 9种   批次 10               42%  ✗
    时刻/区间/路径  各5~6种                       29~33% ✗
    文本      54条 → 42种  版本 4                 7%  ✗

    ★ 纯度≥90% 且样本≥5 的形态只覆盖 92/229 条(40%)
      学不了的那 117 条(51%)里,「文本」一个形态就对应 42 种属性名

## 判据 3:形态当分类特征能到多少?

    朴素查表(形态→最常见属性名)留出集:  55.9%
    随机基线:                                0.4%
    形态覆盖率:                             100%

有信号(远超随机),但 55.9% 远不够。

## ★ 判据 4:加上下文后 —— 这是方案的核心验证

只用「值形态」是查表;网络能用**整句话**。把「值在句中的邻居」
加进特征(`文本|前插件` / `编号|后第` …):

    只用值形态:        特征 11 种   留出命中率 55.9%
    形态 + 句上下文:  特征 27 种   留出命中率 71.0%   组合覆盖率 100%

★ **这 15 个百分点正是注意力模型能补的部分** ——
  「文本」形态单独看有 42 种属性名(纯度 7%),但「文本|邻居含插件」
  就指向「插件名称」了。查表用不了这个信息,注意力可以。

⇒ 方案成立:网络学的不是「值→属性名」这条查表路,
  而是「结合上下文判断属性名」——那才是它相对规则的优势所在。

## 一个必要的诚实说明

71% 是在**查表 + 手工设计的邻居特征**上得到的,用了先验知识
(知道「插件」「版本」这些词与属性名相关)。
神经网络的输入是字符级序列,理论上能从这些词的位置关系里学到同样甚至
更好的信号 —— 但**这是理论推断,需要实测验证**。

所以下一步的判据很明确:网络在留出集上必须超过 71%。
达不到就说明「字符级序列里学不到邻居特征」,
那时该退回去用规则 + 查表,而不是继续训更大的模型。

## 脚本

- scripts/triple-extract/probe_attr_name.py(本次实验,四道判据)
- scripts/triple-extract/train_tagger.py(序列标注训练,含三道数据过滤)

## 判据清单(照本会话惯例)

1. 值必须原样出现在原句(与 LLM 路径同一道幻觉闸门)
2. 值长度 ≤ 句子 60%(拦「值=整句」,实测 3% 的标注如此)
3. 属性名要对齐原句(LCS 模糊匹配,实测救回 105→180,+71%)
4. 网络留出 F1 必须超过查表基线 71%,否则方案不成立
5. 不得低于词法基线(字段级 25%)
6. 确定性:同输入连跑 3 次输出完全一致(相对 LLM 路径的核心收益)

* feat(distill): 推理侧(网络+规则混合)+ 首轮实测

## 混合架构(实测验证过分工)

规则部分全对 —— 正是网络学不了的那 18%:

    「第114批周二凌晨1点·停机4分·回滚v2.28.4·灰度10%观察70分后放50%」
      → 批次号=114   ✓
      → 版本=v2.28.4 ✓
      → 发布窗口=凌晨1点 ✓

网络部分**实测不可用**:

    [ 0] B-DIM '值'    ← 应是「值班室分机号」
    [ 1] I-DIM '班'
    [ 4] B-DIM '机'

位置全错,且  完全没被标出。原因不是实现 bug 是数据量:
47 条训练样本 × 35 字,留出 19 条上 F1 0.71 且 ep20 后回落(已过拟合)。
标签分布 O:771 / I-VAL:592 / I-DIM:565 / B-VAL:192 / B-DIM:187 ——
连「B 只在 span 首字符出现」这个最基本的 BIO 约束都没学稳。

## 闸门:网络不比 LLM 宽松

两边共用同一道(predict.py 的 gate):
  1. 值必须原样出现在原句
  2. 值长度 ≤ 句子的 60%
  3. 属性名必须能在原句里对上(LLM 现造的维度名一律拒)

## 顺带修一个训练脚本 bug

OneCycleLR 的 total_steps 用整除算,在步数不匹配时训练跑到一半抛
「Tried to step 151 times. The specified number of total steps is 150」。
改用 ceil 并显式设 pct_start。

## 当前对比

    规则 + 查表        71.0%(留出)
    词法切分            字段级 25%
    网络(47 条训练)    位置全错,不可用

⇒ 数据量提到几百条之前,网络这条路的结论给不出来。标注在跑(256/425)。

* docs(distill): 扩数据前的诊断 —— 瓶颈是标注质量

## 诊断结果

生产库标注 128 条 / 238 字段:
  正常                157 (66%)
  值=整句或近整句      43 (18%)  ← 坏标注
  值占 1/3~60%         38 (16%)

含坏值的样本 43 条,其中 37 条整条都坏。
**丢掉坏字段后只救回 2 条** ⇒ 「部分修复」这条路无效。

## 根因:为绕开 ollama schema 污染 bug 改用纯提示词约束

schema 本会禁止超长 value;改成提示词后没有强制力。
代价换来的是不再返回上次的输出(见文档 §七)。

修法:value 必须是连续文字 + 长度 ≤ 12 字 + 找不到就跳过。
闸门同步加一条(原来只有「值原样出现」与「值 ≤ 60% 句子」)。

## 批处理标注省 47%

单条 28 秒 = prompt_eval 14(固定前缀)+ eval 14(与输出长度成正比)
  十条一批   → 15.4 秒/条
  二十条一批 → 14.7 秒/条

2000 条从 15.6 小时降到 8.2 小时。

批处理的额外风险是模型漏掉序号 ⇒ 要求「必须为每个序号给结果」,
缺的记 missing 而不静默丢弃。

## 撤回一个错误分类

中途我用正则统计「生产库 72% 记忆不可拆」——**判据错了**:
它只匹配数值/版本/批次,于是把

    portal-nginx 容器已 Exited 两周,不是它提供服务

判成不可拆(实际能拆成 (portal-nginx,状态,Exited))。
通用 Agent 的句子几乎都有主体与谓词,几乎都能拆。

★ 一个正则分类器给出的分布,先怀疑分类器,再相信分布。

* chore(memory): 移除 Qwen3-VL 决策实验(结论已固化在文档)

三个实验跑完,结论写入 docs/zh/vector-provider-decision.md:
  ① 块级检索   Qwen3-VL 分离度为负(9条 vs 15条 = 0.9882),更差
  ② 粗筛有效性 Qwen3-VL 更好,但那是**各向异性**差异,不是模型能力
                 (chineseclip 均值范数 0.9374,中心化后分离度 7.3 倍)
  ③ 短文本判别名 词法判别力 0.4500,是 Qwen 的 5.6 倍

最终:纯文本块走 chineseclip + 中心化,多模态块走 Qwen3-VL,
维度名别名用词法。实验脚手架的使命已完成。

* fix(waiter): mag 作用域错误致 cmd/waiter 无法构建

合入 GUI 分支后全量构建失败:

    cmd/waiter/device.go:743:6: undefined: mag
    cmd/waiter/device.go:744:13: undefined: mag

## 根因:这是 GUI 分支自带的编译错误,不是合并冲突

引入的提交是 891bb94(feat(gui+waiter): computeruse 补高级操作、
修 HiDPI 坐标),在 origin/feature/gui-starmap-pulse-vendor-align
上单独 checkout 也是同样两行错误 —— 那个分支自己就构建不过。

错因是 Go 的 if init 语句作用域:

    steps := 1
    if mag := dy; mag < 0 {   // ← mag 在这里声明
        mag = -mag
    }                         // ← 作用域到此结束
    if mag > 120 {            // ← undefined
        steps = (mag + 119) / 120
    }

改成先声明再判断。

## 验证

    go build ./...        通过
    go test -short ./cmd/...  cmd/homed ok  cmd/waiter ok
    go test -short ./internal/memory/...  全绿

(非 -short 的 TestGold_Baseline 会调真 LLM,当前 ollama 被后台标注
占用而排队超时,与本次修复无关。)

* docs(distill): 重标过程的四个坑(全是我的实现问题)

## 坑 1:批处理 12 条一批 → 11/11 全 error

qwen3:1.7b 处理不了「给多条记录分别输出」的嵌套结构
({"results":[{"i":0,...},...]}),输出是一长串 }。

⇒ 批处理要更大的模型。★ 之前「多示例 vs 单示例」的实测已经证明
  它的结构化能力很窄,我应该在写之前就想到。

## 坑 2:加长提示词让模型失控

版本 B(多条约束 + 换示例)→ 输出 <record> 碎片循环
版本 C(只加一条规则、不动示例)→ 正常

与「加 nonce 后 JSON 截断」同类:**1.7b 的结构化输出对提示词形态极其敏感**。
约束要加,但一次只加一条,逐版本实测。

## 坑 3:JSON 解析的贪婪匹配 ★ 最隐蔽

    re.search(r'\{.*\}', raw, re.S)     ← 贪婪到最后一个 }

模型输出「{"fields":[]} …说明… {"fields":[]}」时被当成一个非法对象
⇒ 100% 判 parse 失败,而原始输出完全正常。

改成「JSONDecodeError 就补闭合符」也不对 ——
错误是 Extra data(尾部多内容)不是缺括号,追加越补越糟。

正确:从每个 { 起点向右逐个 } 枚举,第一个能 loads 成功的就是答案。

★ 症状看起来像模型崩了,实际是我的解析器太贪心。
  **原始输出要先看,再怀疑模型。**

## 坑 4:无值记录会让模型崩溃

「CodeGraph安装任务」(12 字无值)→ 输出碎片循环。
这类句子本来没有可拆字段(零字段是正确答案),送进去只浪费 30 秒。

⇒ 标注前按「是否含值形态」预筛,无值直接跳过。
  这同时补上了批处理省不下来的时间(生产库 429 条里含值形态的只有 20 条)。

## 预期差异

    旧标注  429→149 有字段(34.7%),字段级合规 68%(32% 值超 12 字)
    新标注  跳过无值 + 12 字闸门前置 + 解析修好
            字段级合规接近 100%,parse 错误基本消失

★ 关键差别:旧版把超长值记下来再由训练脚本丢弃(浪费标注),
  新版在标注时就拦掉(产出即合规),同样时间有效样本更多。

* docs(distill): GUI 合并验证 + 现有数据画像

## GUI 分支合并验证

    go build ./...                          通过
    go test -short ./cmd/...                cmd/homed ok  cmd/waiter ok
    go test -short ./internal/memory/...    全绿
    npm test(cmd/gui,9 个 .mjs)          38 项全部通过

★ 合并后发现那个分支自己就编译不过(cmd/waiter/device.go:743
  undefined: mag)—— 是 891bb94 自带的 Go if-init 作用域错误,
  不是合并冲突。已修(aa53e2f)。

## 现有可训练数据画像(76 条可对齐 / 206 字段 / 59 种维度名)

    排期 38 · 容量预警 37 · 告警规则 36 · 值班手册 33   ← 前 4 种占 70%
    其余 55 种共 62 个字段,其中 48 种只出现 1 次(81%)

★ 81% 的维度名只见过一次是**好事**:它说明任务不能退化成
  「记住这 59 个名字」(必过拟合),必须学结构 ——
  哪段是属性名、哪段是值、它们怎么相邻。

  这与 find_dim_in 的思路一致:属性名可模糊匹配
  (告警规则数↔告警规则),所以网络要学的是
  「找到那个**可以当属性名的片段**」,不是「输出这个词」。

* fix(gui): build.files 漏掉被 require 的两个模块,打包后启动即崩

## 问题

main.js 顶层 require 了两个模块:

    const agentCursor = require("./agent-cursor");
    const agentInject = require("./agent-inject");

而 package.json 的 build.files 只有:

    ["main.js", "preload.js", "renderer/**/*", "icon.svg", ...]

⇒ 打包产物里**没有** agent-cursor.js / agent-inject.js,
   GUI 启动时 require 直接抛 MODULE_NOT_FOUND。

## 为什么单测没抓到

现有 9 个 .mjs 测试测的是**模块本身**(agent-inject 的注入逻辑、
坐标换算等),从源码目录直接 require,不经过打包。
只有「真正打一个包再启动」才会暴露这个缺陷 ——
而 CI 里没有这一步(.github/workflows/ci.yml 改的是别处)。

★ 这与本会话反复出现的模式同构:判据覆盖的是「实现」,
  漏的是「实现被装配起来之后」。

## 修法

build.files 里补上两个模块(放在 preload.js 之后)。

## 验证

    node package-config.test.mjs   全部通过
    npm test(9 个 .mjs)            全部通过(38 项)

* docs(distill): 并行三任务导致的资源事故

同时跑训练/标注/GUI 测试,结果 load 33.5(12 核超载 2.8 倍):

  489%  llama-server   跑了 1 小时 39 分
  332%  python3(训练)5 分钟还没出 ep10
  103%  Emulator      安卓模拟器,与本工作无关

★ 那个 llama-server 是**残留进程**:api/ps 只列出 qwen3:1.7b,
  但 ps 显示还有一个 llama-server 占 489% —— 对应的是上一轮已卸载的
  模型(ollama keep_alive 到期后没回收进程)。
  停掉后 load 33.7 → 28.3,训练立刻出结果(ep10 F1 0.695)。

标注重标 30 分钟只完成 1 条,也是被它抢的。

## 教训

**并行不等于提速**:三个 CPU 密集任务在 12 核机上,每个都慢到不可用。
要么串行,要么保证同一时刻只有一个 CPU 密集任务。

**残留进程比崩溃的进程更难查**:api/ps 与 ps 两个视角不一致,
只看 API 会漏掉占用。

* docs(distill): 修正归因 —— 高负载主因不是并行任务

停掉残留 llama-server 后 load 只从 33.7 降到 28.3(12 核)。
继续查占用者:

    359%  python3   ← 我的训练,正常
    173%  python3   ← Emulator 子进程
    103%  Emulator  ← 安卓模拟器,已跑 1:47
     84%  node

★ 这台机器长期跑着安卓模拟器等非本项目服务,它们才是 load 26 的主因。
并行三个任务只是雪上加霜。

## 教训

归因要查到占用者本人。「load 高」有很多可能(我的任务/别人的服务/
残留进程/IO 等待),我先归因为「并行三个任务」是过早下结论。

★ 本会话第三次「先归因后核实」:
  ① 「模型随机性」→ 实际是 ollama schema 污染 bug
  ② 「72% 记忆不可拆」→ 实际是我的正则判据错
  ③ 「并行三任务导致高负载」→ 实际主因是机器上别人的服务

* docs(distill): 判据 4 失败 —— 网络 42% < 查表 71%

76 条训练 80 epoch,留出曲线从 ep10 起横盘在 0.70,
而训练损失降到 0.022 —— 典型过拟合,泛化不再改善。

## 判据 4 结果

    规则 + 查表        字段级 F1 71.0%   ← 门槛
    网络 + 规则        字段级 F1 42.0%   P 0.452 R 0.393
    词法切分            字段级 25%
    纯网络(推理实例)   几乎为 0(输出「采样=.」、零字段)

网络产出 303 个字段、约一半是误报;把句点标成值,把 4324 整个漏掉。
连「数字是值」这个最基本先验都没学到(训练集里数字开头的值有 121 个)。

## ★ 但不能据此说「注意力学不了版式」

76 条训不出东西,和 2000 条训不出东西,是两回事。
诚实表述是「数据量不足以判断架构」。

要拿到答案需要 500~2000 条,即 CPU 单条 28 秒 → 15.6 小时
(批处理对 1.7b 不可行,它处理不了嵌套结构)。

## 下一步选择

    A 继续标注到 2000 条        15.6 小时   拿到架构的确定答案
    B 放弃网络走规则+查表       0          停在 71%,剩 29% 需人工
    C 换更大的标注模型          未知        批处理可行时省 47%

倾向 A+C:本地已有 qwen3-vl:2b,结构化能力比 1.7b 强,
若批处理可行则 2000 条降到 8 小时以内。

* feat(distill): 逐字段挽救 —— 坏字段不拖累好字段(+57% 样本)

## 问题

三道闸门是**整条**丢弃:任一字段不过闸,整条样本作废。

    生产库 149 条有字段的样本
      严格闸门   →  35 条可用
      逐字段挽救 →  55 条可用   (+57%)

而实测的坏字段有明确形态 ——「值 = 整句」(占 18%):

    版本 = 115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100%

丢掉这个坏字段,**同一条样本里的其他字段往往都是好的**:

    「AgentMail 邮件通道插件 v0.1.0」
      ✘ 插件名称 = AgentMail 邮件通道插件 v0.1.0   ← 整句当值
      ✓ 插件版本 = v0.1.0                          ← 保留

⇒ 闸门应该是**逐字段**的,不是逐样本的。

## ★ 与早前那个「部分修复无效」的区分

早前测过「丢弃整条里的坏字段后救回 2 条」,当时结论是部分修复无效。
**那不是同一件事**:

    早前:在**已经丢掉的 146 条**里再救 → 只救回 2 条
    现在:在**还没丢的样本**里逐字段救 → 35 → 55 条

前者是「事后翻垃圾堆」,后者是「一开始就别整条扔」。后者有效得多。
★ 两个结论看似矛盾,差别只在**动作发生的时机**。

## 合并两个数据源

    ha-c salvage      67 条
    生产库 salvage    55 条
    合计             122 条 / 262 字段   (此前只有 76 条)

122 条全部通过严格闸门 —— 说明 salvage 保留下来的都干净。
已启动 122 条的重训,验证数据量 +60% 能否改善(76 条时 F1 0.42 < 门槛 0.71)。

* docs(distill): 批处理标注确认不可行

两种提示词 × 两个模型,全部失败:

    qwen3-vl:2b  简明格式   53s  输出 0 字
    qwen3:1.7b  简明格式  421s  「2. 2024-04-15 15:00:00」重复 13 次
    qwen3-vl:2b  JSON 数组  90s  输出 0 字
    qwen3:1.7b  JSON 数组   —    输出一长串 }

★ 不是提示词问题(两种格式同样结果)。1.7b 的结构化输出上限就是
  单个对象,给它多条记录的数组它会陷入循环;2B 直接不输出。

⇒ 批处理这条路断了,2000 条只能逐条标(15.6 小时,无法再压缩)。

连带推论:既然本机两个可用模型都无法可靠产出结构化结果,
「用 LLM 生成训练数据」这条路的天花板就在这里 ——
除非换 API 模型或更大的本地模型。

这改变了 A 方案的性价比:2000 条 = 15.6 小时,且不能再压缩。

* docs(distill): 数据量翻倍反而更差 —— 推翻「数据不够」

    76 条(纯运维域)      最佳 F1 0.704(ep30)
    122 条(运维+通用混合)  最佳 F1 0.637(ep10,之后降到 0.612)
    门槛(规则+查表)       0.710

加了 46 条数据,F1 反而掉 0.067。

## 根因:两个域的分布互相冲突

    ha-c(运维域)    67 条  10 种维度名  179 字段  只1次 4 (36%)
    生产库(通用域)  55 条  78 种维度名   83 字段  只1次 73 (94%)

★ 合并后模型面对两个矛盾分布:
  - 运维域高度集中 ⇒ 记住「排期/容量预警」就有 179 个字段
  - 通用域 94% 是新维度名 ⇒ 必须靠泛化

模型被撕裂:学运维域就在背词汇(到通用域崩),
学通用域要泛化(运维域那 179 个密集样本又把它拽回记忆)。

⇒ **混合两个分布差异巨大的域,比只用一个域更糟。**
  本轮最反直觉的实测结论。

## 修正方向(不是「继续标更多数据」)

1. 分域建模:每域一个模型,或用域标记做条件
2. 或只用运维域 67 条先走通
3. 或改任务形式:71% 的查表基线只需要「给已知的值配维度名」,
   降级成二分类,数据效率高一个数量级(122 条 → 262 个字段对)

已启动分域训练验证这个判断。

* docs(distill): 判据 4 最终 —— 分域超门槛但不会泛化

    67 条(只运维域)      token 0.918
    76 条(运维+少量通用)  token 0.704
    122 条(运维+通用各半)  token 0.637
    门槛(规则+查表)       0.710

## 混域是主因确认(0.637 → 0.918)

## 但两个数字要一起看

    训练域内(第183批形态):5 条全对
    域外(分机号):零字段

★ token F1 0.918 是**训练域内**的分数(同分布留出),
  字段 F1 0.480 是**跨域**的分数。
  网络在见过的分布上优异,但**不会泛化到没见过的形态**。

与「81% 的维度名只出现一次」一致 ——
在同分布下那些名字仍靠记忆而非泛化。

## 这条路的天花板摸到了

| 判据 | 结果 |
|---|---|
| 分域后超过 71%? | 是 |
| 跨域泛化? | **否** |
| 实际可用 | 运维域内可用,通用域不可用 |

而 HomeAgent 是通用 Agent,生产库 72% 是不属于任何单一域的叙述句 ——
「按域训模型」对通用场景**不成立**:域划分不出来,
而混域会互相撕裂(0.918 → 0.637)。

## 最终判断:网络路线否决

| 方案 | 结论 |
|---|---|
| 注意力网络替代 LLM | 域内可行域外不可行,通用 Agent 无法分域 ⇒ **不成立** |
| 规则 + 查表(71%) | 仍是最优,剩 29% 要人工 |

⇒ **回到规则路线**,但攻「文本」形态那个 29% 缺口
  (42 种属性名、纯度 7%),而不是继续训网络。

* docs(distill): 攻文本形态缺口也不可行 —— 两条路都否决

## 扩充形态后的可学性

    ✓ 计数+单位   38 字段    2 种属性名  纯度 95%
    ✗ 中文       117        81          22%   ← 最大缺口
    ✗ 英文        34        31           9%
    ✗ 混合        88        32          39%

★ 扩充形态确实拆开了一些(「文本」里的路径/版本/计数被分出),
  但可学部分(纯度≥80% 且样本≥5)只覆盖 **11%** ——
  比扩充前更悲观。

## 真正的缺口

「中文」形态 117 字段 / 81 种属性名 / 纯度 22%:

    插件名称 = 从零开发 HomeAgent QQ 插件
    检测类型 = 末位数字检测、本福特定律、…
    参数校验 = 未校验且跳过密码验证

纯度 22% 意味着「值形态」这个特征携带不了足够信息。
⇒ 29% 的缺口靠值形态攻不动,它需要真正的语义理解。

## 两条路都否决

    训注意力网络   域内 0.918 不泛化,通用 Agent 无法分域
    精修值形态规则 可学部分只覆盖 11%

## 这一路的最终账

    词法切分            25%   ✅ 生产兜底路径
    规则 + 查表         71%   ✅ 当前最优
    注意力网络(同域)   92%   ⚠️ 只在单一域内
    注意力网络(通用)   48%   ❌
    LLM 拆分           100%   ❌ 28s/条 + 波动 + 本机 schema bug

★ 收益最高的一次改动是中心化(端到端 5/7 → 6/7),
  不是这一路的任何模型尝试。

⇒ 记忆侧下一步应回到**检索质量**(值覆盖仍 1/2),
  LLM 拆分保留作为**离线批处理**手段,不进在线路径。

顺带修 probe_attr_name.py 里 import 指向旧文件名(train → train_tagger),
该脚本在提交后一直无法直接运行。

* fix(memory): 仲裁接入生产路径 + 整句块参与取代 —— 端到端 7/7

端到端探针:仲裁前 6/7 → **仲裁后 7/7**,overwrite 维度 1/2 → 2/2。

## 1. 仲裁接入 recallByBlocks

之前 `RecallBlocksWithArbitration` 写完但生产路径仍调 `RecallBlocks`
(仲裁是死代码)。改走仲裁入口,被取代的块记一笔日志而不是静默 ——
「旧号 4379 停用」本身有信息,它解释了为什么现在打不通。

## 2. 探针判据的缺陷(先改判据,暴露真缺陷)

原判据「notWant 不该排在 want 前面」。但真库里**新号记录本身含旧号字样**:

    「下周起值班室分机号改为 4324,旧号 4379 停用」  created 13:44(新)
    「值班室分机号 4379,值班人 阿李」                created 13:28(旧)

这正是值覆盖维度**该有的形态**(一条记录同时提到新旧两个值)。
原判据把「新号记录」判成「旧号排在前面」→ 误判失败。

改成按时间语义判,并**顺手暴露了真实缺陷**:修完判据后仍失败,
实际排序是旧号 4379 排在 top1 —— 那是真的召回缺陷,不是判据问题。

## 3. 根因:整句块被无条件跳过仲裁

仲裁只处理能解析出「主语|维度=值」的块,而 legacy-entity 迁移块
是**整句**,解析不出就被无条件保留 ⇒ 旧号永远留在候选里。

★ 「解析不出主语」≠「不能判断谁取代了谁」。

新增 `supersedesSentence`:取代判断只需「更晚 + 提到了更早那条的关键值」,
不需要维度名。正好命中值覆盖场景的必然形态 ——
「新号生效、旧号作废」这句话必然同时含新旧两个值。

## 4. 防误伤

`TestSupersedesSentence` 里反向用例:later 不提旧值 ⇒ 不判取代
(否则会误伤并存的真实值,如 admin 8080 / billing 9090)。
`TestArbitrate_整句块的取代` 验证不同属性的两条都保留。

## 5. 一条旧断言被推翻

`TestArbitrate_整句块不仲裁` 原断言「整句块不可仲裁全部保留」——
**这正是要修的行为**,已按新行为更新并记录推翻理由。
这类推翻必须留痕,否则后人会以为新行为是 bug。

回归:./internal/memory/... ./internal/agent/core/... 两次全绿
(首次 FAIL 是 media 包 flake,该包独立跑 62 秒,与本改动无关)

* feat(memory): 生产规模探针 + 一次重要的探针设计缺陷

## 探针 PASS 了,但结论是假的

生产库快照(1294 entities / 980 relations / 66 sentences / 98 blocks):

    快照规模: 98 块(带向量 97, 99%)
    [casual] 插件目录        ✘ | | | | | | |
    [overwrite] 值班分机     ✘ | | | | | | |
    合计 0/0
    --- PASS (4.29s)

★ **`--- PASS` 而 `合计 0/0`** —— 探针全挂却 PASS。

## 根因:生产库的块是图像块,不是文本块

    memory_blocks: 98 条,modality 全是 image,text_content 非空 0/98
    sentences:     66 条,text 非空 66/66   ← 真正的文本在这

text_content 空是**正确的**(图像块本来没文本),而探针判据读
`h.Block.Text` ⇒ 全空 ⇒ 全判失败。

★ 探针没坏,它测 block 召回,而生产侧 block 是 98 个图像块 ——
  **判据描述的状态在生产库上不存在**(判据不可达教训的又一次复现)。

## 生产侧的正确顺序(尚未执行)

1. homed-graph-migrate  1294 entities → 带文本+向量的 block
2. homed-graph-distill  66 sentences → 拆分块
3. 然后本探针才有意义

## 生产库未被触碰

    生产: inode=16684491  1994752 字节  mtime 05:21:22(与开始时一致)
    快照: inode=6815957

用 sqlite3 .backup 而非 cp(库在写,cp 拿不到一致快照)。

## ★ 必须记的教训:探针自己会「PASS」

**探针全挂 ≠ 测试失败**,t.Logf 不改变退出码。今后所有探针必须有一条硬断言:

    if totP == 0 || totT == 0 {
        t.Fatalf("探针未产生任何有效判定(分母为 %d)—— 前提不满足,不是通过", totT)
    }

「0/0」被打印成 PASS 比红更危险 —— **红会让人去查,绿不会**。

* fix(migrate): 报告数与实际写入数口径不一致(966 vs 980)

生产库快照实测:

    报告   块边 966
    实际   块边 980     ← 差 14 条

## 根因:两处口径不同

    报告  legacyStats → Introspect()
              SELECT COUNT(*) FROM relations WHERE status = 'active'
    迁移  MigrateLegacyTextEntities
              SELECT ... FROM relations ORDER BY id      ← 不过滤 status

迁移按全表转边,报告只数 active,于是报告数偏少。
**用户会把「预计产出」当承诺**,差 14 条就说明它不可信。

## 修法

Introspect 改数全表(与迁移同口径),并在报告里显式说明
「孤儿关系会被跳过,实际可能略少」—— 保留真实的不确定性,
而不是假装精确。

## 顺带:生产快照迁移结果

    块    98 → 1392(+1294 实体块,全部带向量)
    块边  97 → 2350(+980 关系边)
    耗时  104 秒
    自动快照 prod.db.bak-20261003-222649
    带文本块 1294/1392(另 98 个是 image 块,无文本是正确的)

## 探针硬断言

给 probe_prod_test.go 加了一条:

    if totT == 0 || totP == 0 {
        t.Fatalf("探针未产生任何有效判定(通过 %d / 总数 %d)—— 前提不满足,不是通过", ...)
    }

防的不是「探针失败」,而是**探针什么都没做** ——
「0 条通过 / 0 条判定」不满足 `failures > 0`,会被框架报成 PASS。

* fix(probe): 期望值是编造的 —— 4/6 的 want 在生产库中不存在

## 第二个探针缺陷

迁移后重跑前核对每个 want 是否在库里:

    4324 → 0 块   老周 → 0 块
    9090 → 0 块   8080 → 0 块
    plugins → 7   graph.db → 1

★ 六个 want 里四个在生产库根本不存在 —— 从 ha-c 测试库抄来的。
  探针会全判失败。

## 与「假 PASS」是同一类错误的两面

    假 PASS     什么都没判定却报成功       (分母 0)
    编造期望    判据描述的状态在库里不存在  (期望值抄来的)

★ 两者都是判据与实际数据脱节,只是方向相反。
⇒ **探针的期望值必须从被测库里取**,核对应是前置步骤而非跑完看分数。

## 已按生产库真实数据重写(10 条)

casual / coexist / confusable / abstention 各若干,
abstention 3 条(grafana / 容灾演练 / kafka,库里没有)。

★ 生产库的端口是 **13010/13011 并存**而非新旧覆盖,
  所以 overwrite 维度在生产数据上不适用,改成验证「不误判」。
  强行套用 ha-c 的覆盖场景就是又一次判据不可达。

## 操作纪律:又犯了一次

蒸馏用 | tail -12 管道跑,tail 要等进程结束才吐,
66 条 × 28 秒的活儿看起来「零输出 0% CPU」,我误判成卡死。

代码里明明有进度输出,memory 里那条约束也写得很清楚。
⇒ **约束记住了,动手时没查。** 正确做法:> file.log,不用管道。

* docs(probe): 生产规模召回 3/10 —— 根因是分数无区分度 + 全编造

硬断言生效(--- FAIL 而非假 PASS)。10 条探针真实结果:

    [casual] 2/4    [confusable] 1/1
    [abstention] 0/3  ← 三个不存在的查询全部召回高分块

★ 全部 want 值已核对确实在库里(1~11 块命中)——
  这次不是编造期望,是真的召回不到。

## 根因:分数挤成一团

    查询「本机 13010 端口对应什么」→ 8 命中
      1. 0.9006  本机 443 按 SNI 透传到 192.168.2.106:3080
      2. 0.8712  ACP回环调用自身12001
      3. 0.8611  MAR/MDR与ALU不直通必须经CPU内部总线
      ...
      ★ 目标块「13010/13011 而非 12011」连 top2000 都没进

全部 8 条挤在 0.84~0.90。目标块含精确串 13010,
词法上是最强信号,向量给不出区分 ——
**chineseclip 在长尾中文实体名上失效**,不是中心化没做对。

## ★ ha-c 的 7/7 不能推广

    ha-c    448 块,运维域集中(排期/容量预警/告警规则)
            查询与块词汇重叠高 → 7/7
    生产    1391 块,横跨全部业务域(AgentMail/CPU总线/GUI/gateway)
            查询落在长尾 → 3/10

**7/7 是小库+同质分布的产物,不是召回质量好的证据。**
「金标准必须覆盖输入分布」的又一次复现 ——
我拿了小库的结论当大库的预期。

## abstention 0/3 = 纯编造

    「grafana 监控面板的端口」→ CPU总线 / MAR-MDR / 本机443
    「kafka 的 broker 地址」  → read_thread / sdk站 / 跨实现校验

库里没有任何相关块,向量仍给 0.84+ ⇒ 纯编造。
**不是召回不准,而是系统对「不知道」毫无表达能力。**

## 结论:生产规模下召回不可用

    casual 2/4   confusable 1/1   abstention 0/3
⇒ 1. 区分度:分数挤在 0.84~0.90,长尾实体名召不回
  2. 拒答能力:0/3 编造 —— 相似度不够就该说没有

第 2 条更紧急:不是召回不准,而是**给用户错误答案**。

* fix(memory): 中心向量生产路径无调用者 —— 区分度缺失的根因

生产快照实测(1294 实体 / 1391 块)召回分数挤成一团:

    查询「本机 13010 端口对应什么」→ 8 命中
      1. 0.9006  本机 443 按 SNI 透传到 192.168.2.106:3080
      2. 0.8712  ACP回环调用自身12001
      3. 0.8611  MAR/MDR与ALU不直通必须经CPU内部总线
      ...
      8. 0.8405  无工具插件属正常
    ★ 目标块(含精确串 13010)连 top2000 都没进

## 根因:RebuildCentroid 只有测试在调用

`graph_centroid` 表在生产快照里**根本不存在** ——
ha-c 的那一行是手工测���时留下的,生产侧从来没有过。
`grep RebuildCentroid --include='*.go'` 只命中 centroid_store.go 与
centroid_store_test.go:**生产路径零调用者**。

召回会 LoadCentroid → 查不到 → 跳过校正 → 各向异性原样进入打分。
实测建中心后:

    均值向量长度 0.8041,平均两两余弦 0.6466,anomalous=true
    样本 1391,维度 512

★ **平均两两余弦 0.6466** 就是「分数挤在 0.84~0.90」的直接解释:
  任意两个块的相似度基线就有 0.65。

## 三处修复

1. **homed-graph-migrate**:迁移后自动重建中心
   (迁移刚写完 1294 个带向量块,此时不建,之后没有任何路径会建)

2. **homed-graph-distill**:蒸馏后自动重建中心
   理由不止「块集变了」:新蒸馏的是**拆分块**(<主语>|<维度>=<值>),
   与整句块分布不同,混在一个中心里会把中心拉偏,反而削弱区分度。

3. **新增 cmd/homed-centroid**:独立入口,覆盖自动重建管不到的情况
   —— 向量回填后(回填不重建中心)、换 provider(指纹变了旧中心失效
   但没人建新的)、库增长后中心过期。
   配套新增 GraphDB.DominantBlockFingerprint():运维不该去翻配置
   找那串 hex,让命令自己取库内出现最多的指纹并打印出来。

## 一个模式:功能写完但没有调用者

`RebuildCentroid` 有实现、有测试、有持久化、有召回侧消费,
唯独没有生产调用者 —— 测试覆盖「函数能工作」,
但没有任何判据检查「**它被调用了吗**」。

★ 同样的形状之前出现过:`Distiller.SetEmbedFunc` 写完没在 bootstrap 接线。
  判据该加一条:**每个有持久化副作用的能力,都要有「何时被调用」的判据**,
  而不是只有「它工作吗」的判据。

* fix(probe): 计数 map 值拷贝未写回 —— 「合计 0/0」的真实原因

## 第三个 bug

    st := byDim[p.dim]; st[1]++     ← 数组是值语义
                                     byDim[p.dim] = st  ← 缺失

汇总时 st[1]==0 全部 continue ⇒ 打印「合计 0/0」。

★ 我一开始误判成「前提不满足」,只想到「真的没判定」,
  没想到「判定了但没记进去」。这个 bug 藏了两次重跑。

## 修正后的真实分数

    casual 1/4   coexist 0/1   confusable 1/1   abstention 0/3
    合计 2/9

(coexist 也失败 ⇒ 连「不误判」都做不到)

## 区分度问题的三层根因

1. **各向异性**(已修)RebuildCentroid 无生产调用者,
   平均两两余弦 0.6466 = 分数挤在 0.84~0.90 的直接原因

2. **精确串被稀释**(未解)查询「本机 13010 端口」:
      召回   「13000端口」(7字)、「本地网关8081」(8字)
      没召回 「13010/13011 而非 12011」(20字,含精确串 13010)
   ★ 短文本反而召回,含精确串的反而不召回 ——
     chineseclip 句向量把 13010 稀释在 20 字里。

3. **符号路无补位**(架构问题)词法 LIKE '%13010%' → 2 块命中(正确),
   向量 top8 → 0 块。而 memory_recall 是「块命中优先、符号 fallback」,
   不是融合排序 —— **向量的弱让符号路完全没有补位机会**。

⇒ 单纯调向量(换模型/调中心化/调 topK)解决不了,
  必须做真正的向量+符号融合排序。

* docs(probe): 拒答阈值的可分性测量 —— 可分但不可信

## 测量:真实查询 vs 编造查询的 top1 分数

    真实  0.6731 / 0.6390 / 0.8041 / 0.6435   最低 0.6390
    编造  0.4320 / 0.5231 / 0.5890 / 0.5923   最高 0.5923

    间隔 0.047,阈值 0.6157 → 形式上「可分」

## ★ 但这个可分不可信

n=4 的可分性不是可分性。0.6390 与 0.5923 分别是「4 个里最差的」,
任一侧多一个样本间隔就可能变负。

真实使用里「库里没有」的分布远不止这 4 个(sentry 告警、
postgres 主库、某人负责哪个模块…),kafka broker 已达 0.5890。

⇒ **不能据此加阈值上线。** 需要 50+ 编造样本,且测 ROC/假阳率,
  而不是「4/4 分开了」。

## 顺带:符号路优先级的意图对,但没生效

toolcall.go:236 的注释写着「端口号、分机号这类纯数字串向量天然弱,
两条路都跑,块向量在前,符号路兜底」。

★ 设计意图完全正确,而实测证明没生效 ——
  「块向量在前」意味着块一旦有命中就直接 return,
  符号路的 RecallSorted 根本没被调用。

⇒ 要做的不是改设计,而是让它真正融合:两路都跑、按统一分数重排,
  而不是「谁先返回听谁的」。

判据 zz_sep_test.go 保留:它是这个决定的可复现依据。

* feat(memory): 向量+符号融合召回接入生产路径 —— 2/9 → 3/9

## 接入时改的两处

1. **MinScore 0.5 → 0**:融合分数量纲变了
     纯向量  余弦序,0.5 是实测阈值(无关句也能到 0.83)
     融合    精确串命中 1.0(布尔置顶)
             向量加权   0.7×余弦 ⇒ 典型 0.35~0.45
   沿用 0.5 会**把所有纯向量候选筛掉**,反而丢向量侧的有效召回。

2. **输出标签改为召回来源** [exact]/[symbol]/[text]:
   模型需要知道为什么这条被召回,也是日后排查误召回的唯一线索。

## 结果

    casual 2/4   coexist 0/1   confusable 1/1   abstention 0/3
    合计 3/9(纯向量基线 2/9),ha-c 端到端保持 7/7 无回退

★ 归因统计证明融合在起作用:

    [casual] 本机端口 ✓ 13010/13011 而非 12011  [+符号 2: 精确2]
    纯向量下这条连 top2000 都进不去,现在排第一

## ★ 但收益范围必须说清楚

融合只救了「查询含精确数字串」这一类:

    含精确串的探针(13010/13011)  ✅ 救回并置顶
    「脚本路径改到哪个目录」        ❌ 无改善
    「agentmail 公网访问地址」      ❌ 无改善
    「本机服务监听哪些端口」        ❌ 无改善(无串可提)

★ 后两类查询里没有可提取的符号 —— 、
   只存在于**答案**里,不在查询里。
  这类「同义改写」符号路天然无能,只能靠更强的向量。

⇒ **融合解决「精确串被稀释」,不解决「语义不匹配」。**
  3/9 里的 3 分全部来自精确串类探针。

## abstention 仍 0/3,融合帮不上

编造查询的共同特征正是「库里没有、查询里也没有对应符号」,
符号路对这类查询天然给 0 分,与向量路同样无能为力。

⇒ 编造需要**独立的第三种机制**(如「top1 分数+跨度」联合判据,
  或显式检查库里是否存在该符号),不是继续调融合能解决的。

## 实现中修的一个 bug

符号路阶段会 append(向量未召回但符号命中的块),append 触发扩容后
切片换了底层数组,事先存的 &out[i] 指针全部指向旧数组 ——
给已有候选写 SymbolHit 时写进了废弃副本,分数永远算不出来。
改用索引而非指针。判据直接抓到(目标块符号分 0.333 而非满分)。

* feat(symbol): 中文符号滑窗切分 + 拒答判据成立(生产 3/9 → 4/9)

## 中文切分:符号路由「全失效」到「可用」

贪婪长串匹配把整句吃成**一个**符号:

    「grafana 监控面板的端口是多少」 → [grafana 监控面板的端口是多少]
    「脚本路径改到哪个目录了」       → [脚本路径改到哪个目录了]

任何块都不可能包含整句 ⇒ 符号分恒为 0 ⇒
**符号路对所有纯中文查询完全失效**(这是上轮融合只拿 3/9 的原因之一)。

改成 2~4 字滑窗 + 泛词黑名单 + 尾部虚词剥离:

    「谁负责数据库容灾演练」 → [谁负责数 据库容灾 演练]
    「脚本路径改到哪个目录了」 → [脚本路径 改到哪个]

★ 不用 jieba 的理由已写进注释:jieba 的词边界与块文本对不齐
  (memory 里记过「把服务/端口当过滤词造成噪音」)。

## 结果:3/9 → 4/9

「脚本路径」探针从 ✘ 变 ✓(之前必然失败)。
ha-c 端到端保持 7/7,7 条融合判据全绿(含变异自证)。

## 顺带得到拒答判据

    真实  0.50 / 0.33 / 0.33 / 0.67      全部 > 0
    编造  0.00 × 6                        全部 = 0

**完全分离**,而上一轮「分数阈值可分」是 0.047 间隔的运气。
差别在于这是**确定性事实**(strings.Contains),无浮点抖动,
且新增样本(sentry / postgres)依然全 0。

⇒ 「查询符号在库中零出现」可安全拒答。

## 局限(必须承认)

符号率 0 ≠ 库里没信息:泛指词查询(「那个跑得久的任务」)
会因符号全零被误伤。所以拒答只在「提取有效」时可信 ——
含泛指词(那个/这个/之前)时不应拒答。

详见 docs/zh/abstention-criterion.md

* feat(memory): 拒答判据接入图库层 —— 并诚实收窄到「仅精确串」

## 拒答为什么下沉到图库层

第一版放在 `internal/agent/core/toolcall.go`(recallByBlocks 召回前),
结果:

    生产路径  core → AbstainCheck → RecallBlocksFused    ✔ 有拒答
    探针路径  probe_prod_test → RecallBlocksFused        ✗ 绕过了

探针跑出 abstention 0/3 时,生产路径其实是有拒答的,**但没人能证明它**。
⇒ 「判据测不到被测路径 ⇒ 判据等于不存在」。已改为 RecallBlocksGuarded。

## ★ 中间跑出过 7/9,但那是靠误拒换来的

下沉层后探针一度到 7/9(abstention 3/3),代价是:

    [coexist] 本机服务监听哪些端口 → 误拒答
    库里明明有 13010/13011/本地网关8081

⇒ 那是**拿真实查询的正确性换编造的分数**,不能算成果。
已收窄判据,7/9 不再出现。

## ★ 判据的能力边界(实测得出,写进代码注释)

「符号零命中 ⇒ 拒答」**无法区分**这两种查询 ——
符号形态完全一样,都是滑窗切出的跨词边界伪词:

    「grafana 监控面板的端口是多少」 → [grafana 监控面板]  库里真没有
    「本机服务监听哪些端口」         → [本机服务 监听哪些]  库里有 13010/8081

库规模也不是区分信号(3 块库与 1391 块库结果相同,实测过)。

⇒ **只有查询含精确数字串/版本串时才敢拒答**:
  精确串零命中的含义明确(那个串库里确实没有);
  纯中文零命中分不清「真没有」与「提取失败」。

## 第二个判据错误:覆盖率阈值

先按「全部符号命中才不拒答」实现,结果:

    「本机 13010 服务」在库里只有 8081 时被误拒
    ——「本机」命中、「13010」没命中,覆盖率 0.33 < 1.0

但 **13010 才是决定性的那个符号**。改为只看精确串是否命中:
精确串的语义是「存在与否」,而中文片段的覆盖率没有判据价值。

## 最终判据

    拒答 ⟺ 查询含精确串 ∧ 该精确串在全库零命中
    豁免 ⟺ 含泛指词(那个/这个/之前…)∨ 无可提取符号

## 净效果:生产探针 4/9(未变),但编造从「未处理」变成「已知边界」

    casual 3/4   coexist 0/1   confusable 1/1   abstention 0/3

★ abstention 仍是 0/3 —— 三条编造探针全是纯中文。
  这是**如实反映判据能力**,不是没做。

## 与「给用户错误答案」的关系

拒答在含精确串的查询上已生效(如「grafana 用的 3000 端口」
这类查询会正确拒答)。纯中文编造仍是缺口。

⇒ 补它需要**能分词的中文符号提取**(jieba 或语料词典),
  而不是调阈值 —— 滑窗伪词是提取能力的上限,不是阈值能解决的。
  jieba 的问题是词边界与块文本对不齐(memory 里有记录),
  所以这条路需要新实验。

* fix(memory): 三个回归 —— 关系计数语义分离 / MinScore 按路应用 / 符号路指纹过滤

## 1. Introspect 的两种语义被我合并了(TestPurgeSoft 从绿变红)

`761c1a8` 为对齐迁移口径(报告 966 vs 实际 980),把
`relation_count` 从「活跃数」改成「全表数」——
结果 Purge("soft") 的断言失效:

    graph_test.go:323  expected 0 active relations after soft-delete, got 1

★ 那个修改是错的:`relation_count` 是**通用统计字段**,
  为了让一个报告数字准确,破坏了一个真实功能的断言。

正确修法是**两处口径分开**:

    relation_count    活跃数(status='active')  TestPurgeSoft 依赖
    relations_total   全表数                    迁移报告依赖

新增 TestIntrospect_关系计数语义分离 钉住它,含「软删除后全表数不变」
(status 只是标记,不是物理删除)。

## 2. ★ 融合接入时把 MinScore 传 0 —— 直接放弃了噪声过滤

接入融合时我判断「量纲变了」⇒ MinScore 传 0。结果两条判据立刻红:

    toolcall_block_test.go:80   低分块不该出现(MinScore 应滤掉)
    toolcall_block_test.go:124  指纹不匹配的块不该被召回

★ 「量纲变了」是事实,但解法不是丢掉阈值,而是**让阈值作用在它该作用的
  那一路上**。融合分有三段量纲,MinScore 只管纯向量那一路:

    精确串命中  ≥1.00        布尔置顶,不过滤
    纯符号命中  0 ~ w.Symbol 字面强信号
    纯向量命中  0 ~ w.Vector×余弦  ← MinScore 说的就是这个

## 3. ★★ 符号路绕过了向量空间隔离(安全缺陷)

    块  {Text: "旧空间的内容", Fingerprint: "old-fp"}
    查询「旧空间的内容」
    ⇒ 不该召回,实际召回了

根因:符号路拿的是 `MemoryBlocks()` 的**全部**带文本块,
而 RecallBlocks 按 fingerprint 跳过不匹配块。
文本匹配不依赖向量空间 ⇒ **「旧空间的块」在符号路原形毕露** ——
换向量空间后旧块仍污染结果,正是那条判据要防的事。

⇒ 我加的符号路差点变成向量空间隔离的后门。已按 fingerprint 过滤。

## 验证

    ./internal/memory/... ./internal/agent/core/...   9 包全绿
    ha-c 端到端      7/7(无回退)
    生产探针        4/9(无变化)

★ 第 3 条说明:**新增一路召回就要继承它的所有约束**。
  我只想着「符号路补位」,没想到「符号路也必须遵守空间隔离」。

* test(gui): 打包 smoke test —— 真打包 + 解析 require 图 + 无头启动

## 为什么需要它

上一次 GUI 合并(9a6b3da)的故障是 build.files 漏了
agent-inject.js 与 agent-cursor.js,**源码测试 38 项全绿**,
但打包产物缺模块,桌面模式启动即崩。

源码测试测「文件存在」,打包测「**产物里有**」。
只有后者能发现这类问题。

## 三步

1. electron-builder --dir 真打包
2. 从 main.js/preload.js **递归解析 require 图**,逐个确认在产物里
3. xvfb 下无头真启动,检查日志里有无模块缺失特征

## ★ 关键:解析 require 图而不是手写清单

手写清单只能发现「清单写了没打包」,发现不了
「代码 require 了但清单忘了写」—— 而后者才是上次实际故障。
递归解析出来的才是真正需要的集合。

## ★ 判据自己踩了三次坑,都已修

**1. 查错了地方(6 项全红而产品是好的)**
   asar 默认开启,代码封在 app.asar 里而非 app/ 目录。
   而二进制实际存活 25 秒、日志无模块缺失。
   ⇒ **判据红 ≠ 产品坏**,要先确认红的是产品还是判据。
   现在先探测形态(asar / 目录)再校验。

**2. require 解析没遵守 Node 规则(3 项误红)**
   require('./agent-cursor') 解析成绝对路径且无扩展名,
   而产物里是 agent-cursor.js。Node 的规则是:
   原样 → 补 .js → 补 .json → 当目录找 index.js。
   ⇒ 判据自己得先遵守被测系统的规则。

**3. 路径前缀不一致(4 项误红)**
   manifest 里是 /main.js,静态清单传的是 main.js。
   in_app 统一归一化加前导斜杠。

## 实测结果(变异前)

    asar 可读(107 条目)
    require → main.js / preload.js / agent-cursor.js / agent-inject.js  ✓
    vendor: OrbitControls / marked / purify / three                    ✓
    进程存活 25s,日志无模块缺失特征
    smoke 通过

注:启动步骤 timeout=124 视为通过(GUI 本该常驻)。
变异自证(移除 agent-cursor.js 后判红)正在跑。

* test(gui): smoke 判据双向自证通过 + 接入 npm run test-package

## 变异自证(判据能红)

    从 build.files 移除 agent-cursor.js 后重打包
      ✘ 缺 /agent-cursor.js(被 require 但没打包)
      ✘ 日志里有模块缺失(这正是上次漏模块的形态)
      smoke 失败:2 项

★ 其中第二条来自**无头启动步骤的真实崩溃** ——
  不是靠文件比对推断出来的,而是真的跑起来看日志。

恢复正常后完整通过(exit=0):

    asar 可读(107 条目)
    require → main.js / preload.js / agent-cursor.js / agent-inject.js
    vendor: OrbitControls / marked / purify / three
    进程存活 25s,日志无模块缺失特征

## 一个顺带的验证

变异产物还留在 dist/ 时,直接跑 smoke(--no-build)立刻报出
「asar 可读(106 条目)… 缺 agent-cursor.js」——
★ 判据能发现**产物与配置不一致**,不只是配置本身对不对。

## 接入 npm

    npm run test-package              # 打包 + 校验 + 启动
    npm run test-package -- --no-build # 复用已有产物

不并入 npm test —— 打包耗时数分钟,而 test 是秒级单测,
混在一起会让日常回归变得不可用。

* feat(memory): 原句改用块承载 —— 方案 A 的最后依赖点已解除

## 改动

`WritePayload` 不再调 `EnsureSentence`:

    之前  sentence --contains--> block       (sentences 表的行做父节点)
    现在  原句块   --contains--> 字段块      (blk_src_<hash>)

`sentences` 表的最后一个写入点(blocks.go:192)已清除。

## ★ 为什么 ID 必须由内容派生

方案 A 要删 `sentences`,就得让块自己承载原句。但 ID 必须**幂等**:

- 迁移块用 `blk_ent_<entityID>_<hash(name)>` —— 依赖 `entities` 的行号,
  不是内容派生的 ⇒ 这条路不可用
- 所以用 `SentenceBlockID` = `sha256(TrimSpace(sentence))`,与 `BlockID`
  同一套派生方式,重复跑同一条原句得到同一个 ID

## ★ 原句块刻意不带向量

它是溯源锚点,不参与召回。理由是实测过的:

    「13000端口」(7字)                 容易被召回
    「13010/13011 而非 12011」(20字)    召不回

长整句的句向量会把关键值稀释掉 —— 让原句进召回只会引入噪声。
这条写进了测试断言(`TestWritePayload_带向量落库`),
所以将来有人「顺手给它加向量」会立刻被判据拦下。

## 新增判据(含变异自证)

    TestWritePayload_不写sentences表     ★ 直接钉住方案 A 的目标
    TestWritePayload_边是块到块
    TestWritePayload_重复跑幂等          3 次写入仍是 4 块 1 边
    TestSentenceBlockID_确定性            同句同 ID / 首尾空白同 ID / 异句异 ID
    CountSentences()                     只读接口,判断退场进度用

## 现有测试红了 5 处,都是「旧形态的断言」

全部按新行为更新并留痕,不能绕过:

    块数 3 → 4              多一个原句块
    幂等 3 → 4              同上
    边形态 sentence→block    → block→block
    BlocksForNode("sentence", sid) → ("block", SentenceBlockID(...))
    带向量测试               原句块豁免(并说明为什么)
    同句不同维度 n → n+1    n 是**字段**数,不含原句块

★ 其中两条是我自己判据写错(`len(blocks) != n`、TrimSpace 语义),
  已修正而不是改实现去迎合判据。

## 验证

    internal/memory/distill            全绿(370s,该包有真实 IO 测试)
    internal/memory/                   ok
    internal/agent/core/               ok
    ha-c 端到端                        7/7(无回退)

* feat(memory): 迁移也改块→块 —— sentences 表写入点真正清零

## 上一条(1b758ad)只清了蒸馏路径,迁移路径仍在写

查生产快照时才发现:

    sentences 表 1360 条(迁移前只有 66)
    contains 边中 sentence 端起点 1297 条   ← 迁移建的

★ 所以「方案 A 的最后依赖点已解除」这个说法当时是不准确的:
  蒸馏清了,**迁移仍在为每个实体建一条 sentence 行**。

## 为什么 sentence 节点没有独立价值

它的 text 就是实体名,而迁移块的 Text 也是 e.name —— 两者完全重复。
sentences 一退场,这个节点就悬空了。

现在迁移也走:

    原句块(blk_src_<hash>)--contains--> 迁移块

与蒸馏路径同一套形态。

## SentenceBlockID 下移到 memory 包

两条路径都要用它(迁移 + 蒸馏),而 distill 已导入 memory,
放在 distill 会形成 memory → distill 的导入环。
⇒ 实现在 memory.SentenceBlockID / memory.NewSentenceBlock,
  distill 侧只做转发。

## 现有测试红了 6 处,全部按新行为更新并留痕

    块数 2 → 4          2 迁移 + 2 原句
    幂等  2 → 4          同上
    时序  4 → 8          4 实体 × 2 块
    时序分组 2 → 4       每组 2 实体 × 2 块
    contains 边起点       sentence → block
    res.Sentences        不再递增(改为统计迁移块数)
    带向量断言            原句块豁免

★ 其中一处是我改错了断言字符串却忘了改条件
  (`seen["..."] != 2` 与新消息里的「4 块」不一致),
  判据自己抓出来了 —— 消息文本与断言条件不同步是常见坑。

## 验证

    internal/memory/         ok
    internal/agent/core/     ok
    ha-c 端到端              7/7(无回退)
    internal/memory/distill  跑中

## 方案 A 现状(诚实版)

| 表 | 写入 | 退场条件 |
|---|---|---|
| sentences | ✅ 已清零 | 存量 1360 行待清理 |
| entities | 只读 | 存量 1294 行待清理 |
| relations | 只读 | 存量 980 行待清理 |
| memory_block_edges | 唯一边表 | ✅ |

⚠️ 存量清理**尚未执行**,且必须先在生产完成迁移+蒸馏+召回验证+回滚演练。

* fix(migrate): 报告边数 ≠ 实际写入数(静默去重)

## 生产快照实测出的差异

    relations 表        980 行
    去重后 (src,tgt,type) 959
    边表实际写入         959 条
    迁移报告            「边 980」   ← 差 21

根因:`addBlockEdgeTx` 用 `INSERT OR IGNORE` 却**不检查 RowsAffected**,
而 `res.Edges++` 照加。`INSERT OR IGNORE` 在冲突时既不报错也不新增 ——
调用方只看 error 就会把「被去重」当成「写入成功」。

## 数据是完整的

20 组 (source, target, type) 完全重复的关系行,各 2 次:

    13915 --继续--> 15104  ×2
    14079 --会-->   14124  ×2
    14079 --开发--> 14674  ×2
    ...(共 20 组)

980 → 959,减少 21 —— 与观测完全一致。**不是数据丢失。**

## 修法

- `addBlockEdgeTx` 返回 `(error, bool)`,bool = 是否真的新增
- `MigrateResult` 加 `DedupedEdges`,去重的显式报出
- 日志加「去重边 N」
- 新增 `TestMigrate_报告数等于实际写入数`

## ★ 判据第一次「通过」是假通过

第一版用 `seedLegacy(t, g, ...)` 写两次同一关系来构造重复 ——
但 seedLegacy 走 `Commit` 的 Upsert,第二次**覆盖**第一次,
库里始终只有一条关系 ⇒ 判据通过却没测到任何东西。

改为直接 `INSERT INTO relations SELECT ... LIMIT 1` 造真实重复,
判据立刻抓到「报告边数 2 ≠ 实际写入 1」。

★ **判据通过 + 它其实没测到目标** —— 这是比判据红更隐蔽的失效。
  记忆里那条「判据不可达 = 判据描述的状态不存在」的近亲:
  **判据可过,但它没造出它要测的状态。**

## 与 694e298 同类

    694e298  relations 口径(active vs 全表)
    本次     边去重口径(遍历数 vs 实际新增)

两次都是「报告数 ≠ 实际写入数」,而用户会把报告数当承诺。
⇒ 这已是第三类口径问题,规律是:**报告必须在写完之后回读确认**,
  不能由遍历计数推导。

* docs(memory): 方案 A 退场的决定性验证

从生产库重新 .backup 出干净快照(fresh.db),用新代码跑完整迁移:

    迁移前 entities=1294 relations=980 sentences=66 blocks=98 edges=97
    迁移完成(100.8s)句子 0,块 1294,边 959(去重边 21)
    迁移后 块 2686,边 2350,中心已自动重建

## 退场判据逐条核对

    sentences 表未被写入         前后都是 66        ✅
    contains 边为块→块          1294 条            ✅
    原句块已生成                source=sentence 1294 ✅
    迁移块                     source=legacy-entity 1294 ✅
    去重边被显式报出             「去重边 21」        ✅
    中心自动重建                样本 1391 anomalous  ✅

块总数 98 → 2686(98 图像 + 1294 迁移 + 1294 原句)。

## ★ 3 条遗留 sentence→块 边不是迁移产生的

迁移前 block_edges=97,其中 3 条已是 sentence→块 形态
(source_id 1427/1428/1429 是旧 sentences 行号)——
生产库原有的历史数据,迁移不碰它们。

⇒ 存量清理时这 3 条也要处理,否则 sentences 删掉后它们会悬空。

## 方案 A 现状

    sentences 写入点   清零(迁移与蒸馏都不再写)
    entities/relations 只读
    唯一边表           memory_block_edges(3 条历史遗留待清)
    原句可回溯性       由原句块承载(内容派生 ID,幂等)

剩余只有存量清理,且必须在生产完成蒸馏+召回验证+回滚演练之后。

* docs(probe): 生产可拆率 11% vs 测试库 83% —— 蒸馏收益预期需修正

新代码在 fresh.db 试跑 5 条:零字段 5/5。

核实后确认是**数据事实不是代码问题** —— 抽样的实体名确实没有
可拆的 (主语,维度,值):

    #读取并处理-homeagent-来信
    (136, 62, -122) 平原水岸
    +08:00的加号在query中解码成空格,服务端RFC3339解析失败后静默退回默认窗口

## 两个库的可拆率差 7.5 倍

    生产 fresh   429 条实体,含明确可拆结构  47 条 (11%)
    测试 ha-c    146 条实体,含明确可拆结构 121 条 (83%)

⇒ 全量 429 条跑完,预期字段块约 47 条,不是几百条。

## ★ 因此「生产迁移后召回能到 7/7」这个预期不成立

7/7 是 83% 可拆率 + 448 块的产物;生产是 11% + 2686 块。

召回质量的第一限制因素不是拆分算法(71% 字段级 F1 已达标),
而是**数据里有没有结构可拆**。

⇒ 又一次印证「金标准必须覆盖输入分布」——
  我在 ha-c 上验证的融合/仲裁/拒答/拆分,
  全部建立在输入分布严重不同的前提上。

* docs(provider): 决策缺口 —— 原结论没测「同义改写」

## 原决策测的两项都是「形近」任务

    维度名别名   词法胜
    值分离度     Qwen3-VL 负,chineseclip 胜
    结论         纯文本用 chineseclip

## 但生产失败的那两类不属于这两项

目标块内容是裸 URL:

    http://101.201.37.155:8083/     ← 无任何描述词

查询「agentmail 公网访问地址是什么」与它**零词法重叠** ——
这是同义改写,原决策完全没测。

## 实测:chineseclip 在这类上接近于零

    agentmail 公网访问地址是什么    目标块排名 452   top1 0.8041
    公网地址                        目标块排名 389   top1 0.5070
    对外的服务地址                   目标块排名 297   top1 0.7362
    101.201.37.155 是哪台机器        目标块排名  58   top1 0.5471

★ 排名随「可词法化程度」剧烈变化(452→58)——
  查询带 IP 后降到 58,说明起作用的主要还是词法,语义贡献极小。

## 结论:不是模型选型问题,是数据形态问题

裸 URL 的语义信息**不在文本里**,任何 embedding 模型都无法凭空生成。

两条出路:
    A 用 Qwen3-VL 做同义改写   —— 未测
    B 给块补描述              —— 更有希望

⇒ 原决策在**现有数据形态**下仍成立,但那个前提没被写出来:
  **块文本必须自带语义描述**。数据形态变了,决策要重估。

* docs(provider): 否定实验 —— 给块补描述不管用

上面提的「给块补描述更有希望」被实验否定。

## 实验设计(三档位,分离变量)

在库副本(2686 块,230 裸值型块)上依次测:

    A 基线      原文本
    B 中性描述  追加不含查询关键词的中性描述
    C 作弊对照  追加含查询关键词的描述(量上限)

## 结果(裸值块最佳排名,越小越好)

                          A基线   B中性   C作弊
    agentmail 公网访问地址       26      96      57
    公网地址                    37      15       3
    对外的服务地址               30      11      21

★ **B 让第一个查询从 26 掉到 96** ——
  给裸 URL 追加无意义文字反而更差(噪声稀释了原值信号)。
★ C 也不稳定(26→57 也是变差)。只有「公网地址」因查询短而变好,
  不构成方法。

## 剩下的可能性

    给每个 URL 配真实上文(哪条命令/哪次排障产生)  未测
    换更擅长同义改写的模型(Qwen3-VL)             未测
    接受现状:URL 类靠符号路                        已可用

★ 第 3 条其实覆盖了大部分场景 —— 用户问「公网地址是什么」时不带 URL,
  那是召回的**困难形态**;真要用时通常会带 IP/域名,
  那是符号路的强项(实测排名 58 vs 不带时的 452)。

⇒ **生产召回的实际可用性可能高于 4/9**,
  因为探针问的正是最难的那种形态。

## ★ 也记一次自我纠错

第一版实验把查询关键词(agentmail)注入了每个裸值块,
于是「452→17」是作弊得来的。发现后重做为三档位对照 ——
**注入关键词得到的收益不算收益。**

* docs(memory): 召回能力分层 —— 比单一分数有用

单一分数(4/9)掩盖真实情况。有用的问题是:什么形态能用、什么不能、为什么。

## 四种形态

    1 带精确串          ✅ 强     符号路精确串命中 → 布尔置顶 → 排第一
    2 领域词 + 上下文    ✅ 可用   向量召回 + 中文符号滑窗
    3 纯泛化描述        ❌ 弱     目标块排名 389/2686,向量给不出区分
    4 库里没有          ⚠️ 会编造 纯中文查询拒答判据失效

## 各形态的实测依据都带数字

形态 3 的关键数据 —— 排名随「可词法化程度」剧烈变化:

    agentmail 公网访问地址是什么    452
    公网地址                        389
    对外的服务地址                  297
    101.201.37.155 是哪台机器         58   ← 带 IP 后降 10 倍

★ 说明起作用的主要是词法信号,语义贡献极小。
  根因:目标块是裸值 http://101.201.37.155:8083/,没有可匹配的文本。
  已试过并否定:追加中性描述反而更差(26 → 96)。

形态 4 的关键数据 —— 纯中文零命中不可分:

    「grafana 监控面板的端口」 → [grafana 监控面板]  库里真没有
    「本机服务监听哪些端口」   → [本机服务 监听哪些]  库里有 13010/8081

## 运维结论

能用:带 IP/端口/路径/版本号(运维最常见)、领域词 + 具体上下文
不能指望:「XX 是什么」泛化描述、纯中文的「库里没有」检测

同时记了复现命令与「两个库可拆率差 7.5 倍」的前提警示。

* docs(probe): 生产蒸馏产出 0 —— 根因是 think 未关掉,不是可拆率

跑 60 条:0 个字段块,零字段 60/60。

## 直接复现(同一输入连发三次,只换提示词)

    任务A(拆三元组)  118s → "...</think>\n\n要将句子「commit f91b27a...」拆成三元组,
                            首先要理解句子的结构和内容。\n\n### 分析句子:..."
    任务B(判断疑问句)  15s → "no"
    任务A(重复)        83s → "...可以将其拆分为三元组:\n- **字段名**:commit ..."

★ 两个问题同时存在:

1. **think=False 没生效** —— 输出里带着 </think>
2. **推理文本把 JSON 挤出窗口** —— num_predict=300 全被
   「### 分析句子:」吃掉,JSON 根本没生成完

⇒ 抽取器收到推理文本而非 JSON ⇒ 零字段。
  14 秒/条的耗时证明 LLM **确实被调用了**。

## 这是本会话第三次撞上同一个 bug

triple-extill-attention.md 里已记「模型随机性实际是 ollama schema 污染」,
而**蒸馏命令没做同样的防御** —— 复用了带 schema 的抽取器,踩进同一个坑。

## 结论修正

    我先说的              实际
    可拆率 11% 产出约 57   产出 0,与可拆率无关
    全量 429 条 111 分钟   现在不该跑,跑完也是 0

⇒ **先修 JSON 生成,再谈产出量。** 一个 60 条就能测的修复
  比 111 分钟的全量跑重要。

修复方向(未实测):① 关掉 think 真正生效(可能需
chat_template_kwargs.enable_thinking=false)② 加大 num_predict
(但 300 不够说明推理链极长,关 think 才是根治)③ 换模型(27B 未测过单条 JSON)

顺带:加了 zz_exact_test.go 判据 —— 检验分层文档里
「形态 1 带精确串 → 强」这个断言,目前只有 13010 一条证据(n=1),
需要 IP/路径/commit/日期多种类型验证是否都稳定置顶。

* docs(probe): 蒸馏 0 产出的根因 = ollama schema 污染(第四次复现)

## 先排除两个错误假设

**「think 没关掉」不成立** —— 三种参数都试过(think:false /
enable_thinking:false / num_think:0),输出里都没有 </think>。

**「schema 双重编码」不成立,且是我的验证脚本有 bug** ——

    HTTP 500  invalid format: "{\"type\": ...
    ← 我把 json.dumps 的结果当 format 值传了

Go 侧  是正确的。

★ 本会话第四次「先归因后核实」,这次我差点把 bug 归到生产代码上
  —— 只因为「schema 看起来可疑」。

## 真正的根因(证据确凿)

修正脚本后,同一模型三次不同请求:

    任务A(拆三元组)   → {"fields":[{"name":"commit","value":"f91b27a"},…]}
    任务B(判断疑问句)  → {"fields":[{"name":"text","value":"commit f91b27a…"}]}
    任务A(重复)       → 与第一次逐字节相同

★ 两个症状:① 完全不同的问题返回同一 schema 结构
  ② 同输入逐字节相同(不是随机,是污染后的确定性)

⇒ 抽取器拿到 {"name":"text","value":"整句话"} 这种无意义字段,
  全部被三道闸门拦掉 ⇒ 零字段。

## 修复方向

    A 每请求唯一 nonce(label3.py 用的变体)  低成本,有副作用记录
    B 不用 schema,改 <record> 纯文本协议        低成本,解析容错要求高
    C 升级/换掉 ollama                          高成本,影响其他任务

## 一个必要的判据(现有统计看不见根因)

「零字段 N 条」无法区分「真的没可拆字段」与「LLM 返回了垃圾」。
必须先让 homed-graph-distill **打印前 3 条的原始 LLM 输出**。

代码里本来就有这个打印(i < 8 时打印字段),但因为零字段所以什么都不显示
—— **判据能显示 0,是因为它只看得到 0。**

* fix(distill): 让判据能看见零字段的原因,并定位真根因=无主语

## 前 8 条改为无条件打印

原版只在 payload != nil 时打印字段,于是「报错」与「零字段」
两种情况都什么都不显示 —— 生产蒸馏 60 条零字段时屏幕上
一行内容都没有。

★ **判据能显示 0,是因为它只看得到 0。**

## 解析错误带上原始响应

split.go 的 parseFields 失败时把 resp.Text 附在错误里
(截断到 400 字),这样「工具链故障」与「数据没得拆」不再无从区分。

## ★ 根因第三次修正:不是 ollama、不是 schema,是无主语

我连续三次猜错,每次都是靠更直接的测量纠正的:

1. 「think 没关掉」 → 三种参数实测都没有 </think>,不成立
2. 「schema 污染」   → 用同样 schema+prompt 直调,返回**三个正确字段**,不成立
3. 「字段被闸门拦掉」 → 读代码发现**第三道之后还有第五道闸门**

实测哪些实体没有主语:

    commit f91b27a,2026-09-03 已推送      0 主语 → 字段全丢
    127.0.0.1:12100(lan=false…)           0 主语 → 字段全丢
    /api/sources、/api/keys、/api/status  0 主语 → 字段全丢
    第114批周二凌晨1点·停机4分…              1 主语 → ✓
    值班室分机号 4324,值班 老周              1 主语 → ✓

★ LLM 完全做对了(3 个字段全对),却被 Go 侧的
  「无主语不写悬空属性」规则丢掉。

## 三个诊断缺口叠加,让根因被掩盖三轮

    ① 只在 payload != nil 时打印  → 零字段什么都不显示
    ② 报错与零字段混在一个统计   → 看不出谁在丢弃
    ③ 没有主语计数的读数          → 字段凭空消失

★ 「判据的观测面决定你能看见什么」的三个层次。
  修第①层后才看见「合法 JSON 但零字段」;
  修第②层后才有机会去查闸门;缺第③层至今。

* docs(probe): 主语缺失率 99% —— 蒸馏在生产侧不成立

补上第③层判据(主语计数),在生产库 429 条实体上实测:

    总计 429:有主语 5(1%),无主语 424(99%)

    命令/commit      2 条  无主语 100%
    符号开头         2 条  无主语 100%
    路径/URL        38 条  无主语 100%
    其他           387 条  无主语  98.7%

## 而 ha-c 的主力句式在生产库一条都没有

    含「第N批」 0 条    含「分机」 0 条    含「端口」 11 条

★ ha-c 的 7/7 完全建立在「第114批…」「值班室分机号 4324」这类
  有明确主语锚点的句式上。

## 三层结论串起来

    ① LLM 拆得对      3 个字段全对(实测)
    ② 第五道闸门丢弃   无主语 → 全部 continue
    ③ 99% 无主语      ⇒ 放宽闸门也救不回多少

⇒ **方案 B(键值块)最多救回 1%** ——
  99% 的实体压根没主语,键值块也写不出 <主语>|<维度>。

⇒ 生产侧蒸馏路径**整体不成立**,不是「修一下 bug 就能跑」。

## 正确方向

99% 的生产实体是无主语的通用事实,它们**本来就应该**以整句块形态存在
(legacy-entity 迁移块已经是这样),不该被强行拆三元组。

蒸馏命令应**只对有主语的实体跑**,其余跳过并报告:

    待处理 429 → 有主语 5,实际处理 5

## 元价值

今天在生产侧撞到的不是 bug 而是**假设错误**:

    「迁移后召回能到 7/7」      错,7/7 是 83% 可拆率 + 主语锚点的产物
    「可拆率 11%,产出 57 个」   错,实际 0(第五道闸门)
    「先修 bug 再跑全量 429」    错,99% 无主语,跑完也是 0

★ 三个错误的起点都是**拿 ha-c 的结论直接套生产**。
  判据、变体、实验都在 ha-c 上做,只把探针搬到了生产 ——
  **探针搬过去了,输入分布的差异没被当成一等变量。**

* fix(distill): 只对有主语的实体跑拆分 —— 424/429 被浪费

生产库实测 429 条实体里 424 条(99%)无主语,而 Split 里

    if len(subjects) == 0 { continue }

会把 LLM **已经拆对**的字段全部丢弃(实测 60 条 / 837 秒 / 0 产出)。

## 过滤前的实测浪费

    命令/commit   2 条  无主语 100%
    符号开头      2 条  无主语 100%
    路径/URL     38 条  无主语 100%
    其他        387 条  无主语 98.7%

而 ha-c 的主力句式(第114批…、值班室分机号 4324)在生产库**一条都没有**
(含「第N批」0 条、含「分机」0 条)。

## 过滤后(报告模式实测)

    待处理实体 429 条;现有块 2686(带向量 1391)
    ★ 跳过无主语实体 424 条(99%):Split 的第五道闸门
      「无主语不写悬空属性」会把 LLM 已拆对的字段全部丢弃。
        · LLM 503服务不可用 + 402余额不足
        · 167文件_3547节点_10461边
        · CLI查询与explore与MCP工具列表均可用
    实际处理 5 条(其余 424 条无主语,保持整句块形态)

★ 让「没产出」变成**可解释的数字**,而不是又一次「零字段 N 条」的谜。
  这也正是今天三次猜错的根源 —— 观测面不够。

## 与 99% 无主语结论的落地

066483f 已判定「生产侧蒸馏整体不成立」,本条是它的直接落地:
不是把路径修好,而是**让它知道哪些输入不该进来**。

* docs(memory): 回填重构计划执行结果(含计划与实际的偏差)

状态从「执行中」改为「已执行完毕」,并回填:

## 步骤执行情况

    ① 重写设计文档        ✅
    ② distill 沉库改块     ✅  288674a 83ba7b4 1b758ad
    ③ memory_recall 加块路  ✅  74301d9 84d8d28
    ④ memory_commit 止血   ⚠️ 部分
    ⑤ 存量迁移            ✅  878b901 21a68cf 6153770
    ⑥ scene 挂接          ❌ 取消

## 计划与实际的偏差(都是实测纠正的)

**⑥ 取消**:计划假设 blocks 是新层、scene 挂在它上面,
但 blocks 不是新层而是图节点升级体,scene 无处可挂。

**蒸馏整体否决**(066483f):生产库 424/429 条实体无主语,
第五道闸门会把 LLM 已拆对的字段全丢弃。已改为只跑有主语的。

**注意力网络否决**:分域后 token F1 0.918 超门槛,但跨域不可泛化。

## 计划里未被采纳的替代方案

    给块补语义描述    实验否定(排名 26 → 96)
    换更强向量        未测,但裸 URL 无文本可供语义匹配

## 现行架构

    memory_blocks        统一节点载体
    memory_block_edges   多态端点,唯一边表
    graph_centroid       中心向量(迁移/蒸馏后自动重建)

原句由 blk_src_<sha256> 原句块承载(幂等、不带向量),
sentences 表写入点已清零。

* docs(distill): 回填三元组方案的执行结果 —— 路线整体否决

状态从「设计中,待实现」改为「已实现并部分否决」。

## 已实现

    generation SPI + providers/ollama/generator.go(JSON Schema)
    Qwen3-VL 生成侧(providers/qwen3vlgen)
    distill 块落库(<主语>|<维度>=<值>)
    字符级注意力网络(train/predict/probe 三件套)

## ★ 但「小 LLM 拆分」路线整体否决

    词法切分              字段级 25%
    规则+查表            字段级 71%   ← 当前最优
    网络+规则(76 条)    字段级 42%
    网络+规则(分域 67 条)token 0.918 / 跨域 0.480

三条实测否决理由:

1. **注意力网络不泛化** —— 分域后 0.918 超门槛,但域外零字段。
   而通用 Agent 无法分域(混域 0.918 → 0.637)。
2. **LLM 拆分在生产侧不成立**(066483f)—— 424/429 条实体无主语,
   第五道闸门会把 LLM 已拆对的字段全丢弃(60 条/837 秒/0 产出)。
3. **拆分不是瓶颈** —— 召回侧收益(中心化 5/7→6/7、融合 2/9→4/9)
   远大于拆分质量提升。

## 结论

> 拆分质量不是瓶颈,召回质量才是。
> LLM 拆分保留作为**离线批处理**,不进在线路径。

## 元教训

本文的设计与实现都对,但建立在一个未验证的前提上:
「ha-c 生产库」这个措辞暗示它是生产数据,实际上它是
**可拆率 83% 的运维测试集**。真生产库可拆率 11%、主语率 1%。

* chore: 清理构建产物 + package-lock 版本同步 + gitignore 补漏

- 删除误落在仓库根的 homed-graph-distill 二进制(15MB)
- cmd/gui/package-lock.json 版本号 1.0.0 → 1.4.0
  (与 package.json 对齐,npm install 的正常同步结果)
- .gitignore 补上 /homed* 构建产物
  (go build ./cmd/xxx 时二进制默认落在执行目录,很容易进仓库)

* fix(test): 两个回归问题 —— build tag 缺失 + 金标准跑两遍模型

## 最终回归发现两个 FAIL,逐个查实

### FAIL 1: providers/chineseclip [build failed]

    vet: providers/chineseclip/modeldir_test.go:15:15: undefined: findModelDir

`findModelDir` 定义在 `embedder.go`(有 `//go:build onnxruntime`),
而 `modeldir_test.go` **没有 build tag** ⇒ 不带 tag 编译必然失败。

★ **既有问题,不是今天引入的**(`git log` 显示它来自 bcb9afb,
  早于本会话)。但它会让任何无 tag 的全量回归变红 ——
  今天它第一次暴露,因为我把 `providers/...` 加进了回归范围。

已给测试文件加 `//go:build onnxruntime`,两种 tag 下都通过。

### FAIL 2: internal/memory/distill 超时(600s)

单独跑通过(499s),与其他包并行时超了 600s。查出原因:

    TestGold_Baseline      250.95s
    TestGold_ShowFailures  251.64s   ← 合计 502 秒,占全包 500 秒

★ 两个测试**各调一遍 runGoldCases**,而它会:
  1. 打开 ollama provider
  2. 对 goldCases 逐条调 LLM

⇒ **同一批金标准用例被跑了两遍模型,浪费 250 秒。**

修法:加进程内缓存(`goldCache` / `goldAltCache`),
带 `subjectFrom` 的变体按函数名分开缓存(那批测另一条主语路径,
不能与默认路径共享)。

预期:500s → 约 250s。

## 顺带一个观察

`runGoldCases` 测的正是今天被否决的东西(规则+查表 71% 命中)。
它有 `-short` 跳过,所以**回归时应带 `-short`** ——
但默认跑会真的花 250 秒调模型。这本身值得记:
**判据的成本也会影响它是否被真正执行。**

* fix(migrate): 为 sentences 原文补建原句块 —— 否则清理会丢数据

## 清理前置判据抓出的缺口(生产快照实测)

    ① entities 1294 → legacy-entity 块 1294 ✓  缺块的 entity: 0
    ② relations 980(去重 959)→ 关系边 959 ✓
    ③ sentences 66 → 有原句块的 0 ✘   ★
    ④ 悬空的 sentence→块 边: 0
    ⑤ 端点不存在的块边: 0

★ **sentences 表 66 条,一条原句块都没有。**
  而迁移只从 entities 读(readLegacySnapshot 里只有 entities 查询),
  从没为 sentences 建过载体。

sentences 表的价值就在**原文本身**(entities 是提炼后的名字),
所以原句块是它唯一的迁移出口 ⇒ 直接清理该表会丢 66 条原句。

## 更正上一轮的一个不准确结论

上一轮记忆里写「3 条遗留 sentence→块 边是历史数据,清理后会悬空」。
实测那 3 条边的 source_id 1427/1428/1429 **确实存在于 sentences**
(id 范围 1427-2225)⇒ **不是悬空的**,④ 显示 0。

它们的 target 是 **modality=image 的图像块**(source/text 为空是正确的),
来自更早的多媒体管线。

## 修复

迁移在事务内补一段:为 sentences 每条原文建原句块
(blk_src_<sha256(Text[:12])>,无向量),已有则 ON CONFLICT 跳过 ⇒ 幂等。

## 判据(含变异自证)

    TestMigrate_sentences补建原句块

★ 第一版幂等断言写错了:靠「重复插入同一句」来测,
  而 sentences.text 有 UNIQUE 约束 ⇒ 根本到不了迁移代码。
  改为「迁移跑两遍」才真正测到幂等。

变异自证双向通过:

    删除 sentences 构建 → FAIL「应有 2 个原句块,实际 0」
    还原                → ok

## ③ 的实现细节

块 ID 是 sha256(Text[:12]),SQLite 无 sha256 函数 ⇒
在 Go 侧比对(先  取 ID 集,再对每条 sentence 算)。
判据要自己算,别指望 SQL 能做哈希。

* docs(memory): 旧表清理可行性评估 —— 现在不能清理,55 处调用点

## 数据侧已就绪(清理前置判据全绿)

    ① 每个 entity 都有块    1294 → 1294,缺块 0
    ② 每条 relation 都有边  980(去重 959)→ 959
    ③ 每条 sentence 有原句块 66 → 66(14c9c71 修复后)
    ④ 悬空 sentence→块 边   0
    ⑤ 端点不存在的块边     0

## 代码侧远未就绪

    internal/memory/social/social.go            7 处
    internal/memory/graph.go                    6 处(Commit/Recall 实现)
    internal/memory/scene.go                    4 处
    internal/memory/light_memory.go             4 处
    internal/plugins/webui/handler_memory.go    2 处
    internal/plugins/healthcheck/plugin.go       2 处
    internal/plugin/proc/corehandler_memory.go  2 处
    internal/plugin/lua_plugin.go                2 处
    internal/sdk/memory_impl.go                 2 处
    ────────────────────────────────────
    合计 55 处

## 三张表是一条链,不是三张独立的表

    写入  Commit → entities(name 唯一) + relations + sentences(挂媒体块)
    读取  Recall → entities(LIKE) + relations(遍历) + sentence_id

只删表不改代码 ⇒ 写入报 no such table,读取返回空。
而 **Commit 在在线主路径上**,不是可延后的离线工具。

## 建议:不要现在清理

理由不是「风险高」,而是:

1. 数据侧就绪 ≠ 代码侧就绪(55 处调用点)
2. Commit/Recall 在**在线主路径**上,不是一次性迁移
3. 清理的收益只是「少三张空表」,代价是重写一条在线链路

⇒ 正确顺序:**先让写入与读取都走块**(1-3),
  旧表自然失去写入方,最后再谈删除。

## 附:数据侧已修的两个真实缺口

    66 条 sentence 没原句块    14c9c71(迁移只从 entities 读)
    关系边 959 vs 980         20 组三元组重复(156163f 已显式报出)

* docs(memory): 记录一次操作错误 —— cp 拿不到一致的快照

验证 sentences 原句块修复时用 cp 取副本,得到:

    生产库      entities=1294  sentences=66
    cp 出来的   entities=1293  sentences=67      ← 差 1,方向相反

第一反应是「迁移改动了源表」,差点去查迁移的写入逻辑。
实际是生产库在 WAL 模式,cp 只拿到主库文件 ⇒ 不一致快照。

用 sqlite3 .backup 重取后完全一致(1294 / 66 / 98)。

★ 本文档与 production-recall-validation.md 里都写过
  「库在写,cp 拿不到一致快照,要用 .backup」,
  **我自己写下的规则自己违反了。**

⇒ 判据该加一条:**快照来源必须可验证**。
  最省事的做法是取完快照立刻与源库比对关键计数 ——
  不一致就重取,差 1 就会暴露。

* docs(bench): v4 跑分 6/6 + 跑分前必须核对内核版本

## 能力标定(tasks.zerobasis)

    任务 6/6(100%)
    墙钟 159.17s   token 281971   缓存命中 42.7%   超时 0

## ★ 但真正值得记的是跑之前的发现

    build/homed   Oct 1 21:18    ← 3 天前的二进制
    今天的 memory 层提交  19 个

**直接跑会测三天前的内核**,而今天改的全是 memory 层
(融合召回/拒答判据/仲裁/schema 退场)——跑分完全测不到今天的工作。

⇒ 跑分前必须核对 vcs.revision == git rev-parse HEAD。
  这一条应该进 spawn-instance.sh,让它自动拒绝版本不符的实例。

## 三个操作失误(都记在案)

    cp 取快照         WAL 模式下不一致
            /bin/sh 报 Bad substitution
    改错二进制路径     /var/tmp/ha-c/homed 不存在,实际是 build/homed

## 这一轮 6/6 的含义要说准

它验证的是「今天没把主干跑坏」,**不是**「今天的改动带来了提升」。
记忆召回质量另有专项(memory_recall.py,36 探针),
结论单独记在 production-recall-validation.md:

    测试库 ha-c    7/7
    生产快照 1391 块   4/9(abstention 0/3 编造)

两个库可拆率差 7.5 倍,ha-c 的分数不能推广到生产。

* test(bench): 启动跑分实例前必须核对内核版本

## 为什么加这条

实测踩过(2026-10-04):build/homed 是 Oct 1 的,而当天有
19 个 memory 层提交(融合召回 / 拒答判据 / 仲裁 / schema 退场)。
直接跑分测的**完全是三天前的内核**,当天的工作一点没测到 ——
而 6/6 的满分让人误以为改动被验证过了。

⇒ 判据必须能红。现在版本不符直接拒绝启动(exit 2),
  并打印两边 revision 与重跑命令。

## 实现时踩的一个坑

第一版判据永远「不匹配」:

    go version -m  给的是完整 40 位 revision
    rev-parse --short=8  给的是 8 位

⇒ 两者都要短形式(BIN_REV 取前 8 位)。
  ★ 判据自己写错会得到「永远红」的结果 ——
  而永远红的判据会被人当成环境问题忽略掉。

## 变异自证双向通过

    旧二进制 681e01ad  → 正确识别为不符
    当前二进制 4d73cda(== HEAD)→ 匹配

确实想测旧版本时用 ALLOW_STALE=1 显式跳过。

* docs(bench): 实战证据 —— 新内核在真实跑分中的行为

内核 4d73cda(含今天全部 19 个 memory 层提交)跑 tasks.zerobasis
+ memory_recall 时的实例日志摘录。

## 一、召回来源标签(今天新增功能)

    exact 14 次   text 24 次   symbol 0 次

    [exact 1.00] 第47批 4279ms
    [exact 1.00] 第115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%…

★ exact 那 14 条正是「端口号/批号/耗时」类事实 ——
  纯向量下召不回(chineseclip 对「13010」这类短数字串极不敏感),
  今天靠符号路的布尔置顶救回来。

symbol 0 次:纯符号命中这条路在 ha-c 数据上没被触发
(块都是整句块,向量侧总能给分)。

## 二、L4 超页处理在真实负载下触发(本分支早前工作)

    overflow 32   preempt 6   suspend 6   裁剪 3

max_ctx=50000 target=40000,26 轮叙事填充下确实触发,
且没有出现「no reduction」那类失败。

## 三、能力标定

    tasks.zerobasis  6/6(100%)  墙钟 159.17s  缓存 42.7%  超时 0

## 四、★ 旧实体路仍在跑(现场印证)

    graph.go:944: [graph] recall: 截断 3 个实体(全局上限 20)

这条日志说明旧  仍在生效 ——
今天块路是**并行的另一条**,两路都在跑。
旧路径没退出,只是被块路抢先返回了。
⇒ a55556b 评估里「55 处调用点」的判断在现场得到印证。

* docs(bench): ★ 跑分正在往旧表里写数据 —— 旧表还在长大

## 最重要的发现

    07:29:20  memory_commit: 已写入 8 个实体和 4 条关系
    07:31:02  memory_commit: 已写入 2 个实体和 1 条关系
    07:31:29  [indexer] 实体数 202→207
    entities: 188 → 207(本次跑分期间)

★ **memory_commit 仍写 entities/relations,且是活跃的在线写入路径。**

修正了两处我先前的说法:

| 我以为 | 实际 |
|---|---|
| sentences 写入点已清零 | ✅ 成立,但**只针对迁移与蒸馏两条命令** |
| 旧表在等着被清理 | ❌ **它还在长大**(188 → 207) |

⇒ 旧表退场的前置不是「删表」,是「**先让 memory_commit 改写为写块**」。
   在那之前删除旧表 = 在线写入直接报错。

## 召回三种返回形态(实战统计)

    ① 正常召回   30 次   块路
    ② 拒答        2 次   今天新增的判据
    ③ 空返回     31 次   块路让位

### ② 拒答在实战中工作了

    记忆库里没有与「第8批 上线准备」相关的记录
    记忆库里没有与「第9批 上线准备」相关的记录

查询含**精确串**(第8批/第9批)而库里没有 ⇒ 判据正确触发。
且验证了「拒答后不走旧路兜底」(实测 0 次),是干净的终态。

## ★ 又一次差点归因错

「块路 30 vs 旧路 31」让我以为块路没抢先。查上下文发现:

    tooldefs.go:107: memory recall (input:cli): injected 1287 chars
                               ↑ input:cli = 自动记忆注入

⇒ 截断来自**自动记忆注入与 memory_commit 的内部召回**,
  不是用户显式调 memory_recall 时的兜底。
差异来源是**调用方不同**,不是优先级失效。

* docs(bench): v4 跑分汇总 —— 6/6 + 7/7,并记录专项跑不完的根因

## 结果

    能力标定 tasks.zerobasis   6/6(100%)墙钟 159.17s
    端到端召回                  仲裁前 6/7 → 仲裁后 7/7

## ★ memory_recall.py 专项跑不完(不是慢,是必然失败)

填充轮耗时:

    [1/36]  30.9s
    [2/36] 131.5s
    [3/36] 392.0s        递增比 2.98×

外推第 6 轮就超过单轮超时(900s),26 轮累计 12.9 小时。

★ 根因:memory_commit 在填充期间持续写库(entities 188 → 220),
  每轮检索的数据量与召回量都在涨 ⇒ 轮次耗时递增。

⇒ **改走等价路径**:用探针判据直接测,数据仍是真实内核跑过的迁移库,
  结果 7/7。

⇒ **这个专项需要重新设计**:26 轮逐条填充的做法在
  「写入会改变检索集」的系统上不成立 ——
  填充本身就是被测对象的一部分。
  正确做法应是**批量预填**(一次写完)再跑探针轮。

## 实战数据印证

    exact 14 / text 24 / symbol 0    symbol 标签是今天新增的
    拒答 2 次                        第8批/第9批 精确串库里没有 ⇒ 判据触发
    overflow 32 / preempt 6 / 裁剪 3  L4 超页真实触发
    entities 188 → 220               memory_commit 仍在写旧表

* docs(bench): 更正填充期库增长的来源 —— 是模型主动调工具

## 我先说的(错)

「填充期库增长的主因是 pipeline 的 distiller 常驻循环」

## 实测计数纠正

    tool memory_commit result   19 次   ← 模型主动调工具
    media                        3 次
    reclaim/                     0 次
    distiller 启动                1 次(Interval 10min,心跳没跑到)

⇒ 主因是**模型自己决定调 memory_commit**,不是那个 10 分钟心跳。

## 连带结论

Distiller 的 DistillerConfig 只有 Interval/RetentionDays/BatchSize,
**没有 Enabled 开关** ⇒ 关不掉它。

★ 但**不需要关**,因为它不是主因。
  我之前一直在找「怎么关掉自动记忆写入」,
  方向对(确实要控制写入)但**对象找错了**(该看工具调用,不是 distiller)。

## 归因的教训

★ 同一现象(entities 188→381)我先归因到「distiller 常驻循环」,
  一次日志计数就纠正了。
  而我当场还给出一个支持该假设的「证据」——
  Interval 10 分钟 > 填充 13 分钟,所以心跳最多跑一次 ——
  **那个证据其实是在反驳我自己的假设,我没看出来**。

* feat(memory): Commit 块化 —— 三元组落成块,不再只是旧表的同步写入

## 起点:跑分暴露「旧表在长大」

    07:29:20  memory_commit: 已写入 8 个实体和 4 条关系
    entities: 188 → 220 → 381(本次跑分期间)

★ `Commit` 的四条调用路径(媒体桥 / memory_commit 工具 / 驻留子 /
  记忆整理流水线)**全部只写旧表**,memory_blocks 一次都没被写。
  所以旧表不是「等着被清理」,它在线上持续增长。

## 改动

`commit` 里在旧表写入之外**并行块化**:

    三元组  主语块 --关系--> 宾语块      (两个独立可召回的事实 + 边)
    原句    原句块 blk_src_<hash>         (溯源锚点,无向量)

### 为什么三元组是「两个块 + 一条边」而不是一个块

压成 `<主语>|<关系>=<宾语>` 就无法回答「X 的关系有哪些」
「Y 被哪些主语指向」—— 而那是 Recall 的主要用法。

### 块 ID

    TripleBlockID(name)  = blk_ent_<sha256(name)[:12]>

内容派生 ⇒ 重复提交同一三元组得到同一对块,天然幂等。

★ 迁移块是 `blk_ent_<entityID>_<hash8>`(带行号),
  Commit 块是 `blk_ent_<hash24>`(纯 hash)——
  **两个不同语义的东西,不是重复**。测试断言必须按 source 过滤,
  否则会误判成「块化破坏了迁移」。

## 签名变更:CommitWithMedia

    map[string]int64  →  map[string]string(sentences 行号 → 原句块 ID)

因为 sentences 表退场后行号不存在,而媒体要挂在
「原句块 --contains--> 媒体块」上。连带改了 4 处:

    internal/sdk/memory_impl.go        bindSentences 端点改 block
    internal/agent/core/graphmedia.go  attachBlocksToSentence 收块 ID
                                       sentenceIDsFromRelations 按 SentenceText 现算
                                       RecallBlocksForSentence 签名 int64→string
    internal/memory/migrate.go         媒体迁移挂载点 sentence→block

★ `sentenceIDsFromRelations` 原来依赖 `Relation.SentenceID`
  (旧表列)—— 改为按 `SentenceText` 现算块 ID。
  这条链(mediaContextForSentences / mediaContextForRelations)
  完全靠它找回媒体块,改错了 = 媒体上下文整体失效。

## 修了一个真实缺陷:零值时间抹平时序

    blk_ent_xxx 应继承实体时间 2026-01-01 10:00
    实际 2026-10-04 08:05(时序被抹平)

`putBlockTx` 对零值 CreatedAt 填 now,而 ON CONFLICT 无条件
`SET created_at = excluded.created_at` ⇒ Commit 先写的无时序块
会把迁移块继承的实体时序覆盖掉。

★ 而时序是**仲裁的前提**(arbitration.go 靠 CreatedAt 判断谁取代谁)。

修法:零值时 UPDATE 分支**不碰时间列**
(`tsUpdate = "updated_at = memory_blocks.updated_at"`)。

## 判据

`TestBlockCommit_不写旧表` 四条:
① 旧表写入**有意保留**(55 处读方未切块,先删会打断在线读取)
② 原句块 + 值块必须存在
③ 三元组必须变成块边
④ 返回值必须是 blk_src_* 而不是行号

★ ① 我最初写成「旧表应为 0」—— 那是错的。
  退场顺序必须是「读方切块 → 停写旧表 → 删表」,
  在第一步完成前停写 = 在线读取直接断。

## 测试连带修复(8 处,都按新契约)

    迁移测试的块数断言     用 len(blocks) 会混进 Commit 块 → 改按 source
    迁移测试的时序断言     同上(Commit 块时间由 PutMemoryBlocks 填 now)
    报告边数断言           只数迁移块之间的边(Commit 的边不该算)
    媒体测试的 sid 类型     int64 → string
    BlocksForNode          "sentence", 行号 → "block", 块 ID

★ 其中「时序被抹平」那条差点误判成**块化破坏了迁移时序** ——
  实际被抹平的是 Commit 块(它本就该是 now),迁移块没问题。
  判据没按 source 过滤 ⇒ 把对的实现当成错的。

## 验证

    编译        ✓ 全包通过
    回归        43 包全绿,零失败

* feat(memory): 边升格为独立单位 —— 去 UNIQUE + 承载关系属性

## 用户指正

> 块只是节点,关系边是连接块与块的独立单位

我照此核实,发现**两处都错了**,而第二处是早就存在的:

### ① 节点形态错(e108b4e,我今天引入的)

我做成了属性图:`主语块 --关系--> 宾语块`,块内容是纯名字。

**而既有设计是** `<主语>|<维度>=<值>` 的字段块(83ba7b4)——
我把那个设计拆散了,退回 N-Triples/属性图。详见下一步回滚。

### ② 边不是独立单位(早就存在,我没发现)

    UNIQUE(source_kind, source_id, target_kind, target_id, edge_type)

⇒ 同一对节点间**只能存一条**同类型边:

    写 A --属于--> B (session=1, conf=0.9)
    写 A --属于--> B (session=2, conf=0.5)
    ⇒ 第二条被 INSERT OR IGNORE 静默丢弃,session/confidence 全丢

★ **这正是 156163f 那个「报告 980、实际写入 959」的根因** ——
  当时我把它当成「重复数据」报出来就完事了,
  **真正的问题是边表从设计上就存不下多条同类边**。

## 本次改动

### schema

去掉建表语句里的 UNIQUE;补五列(对应旧 relations 同名列):

    confidence   置信度 —— 「值覆盖」维度靠它判断哪条更可信
    session_id   来源会话 —— Recall 的 sessionFilter 依赖它
    turn_id      来源轮次
    status       active/deleted/merged —— 软删除与仲裁依赖它
    merged_into  源块被并进哪个块(历史边留在源块上)

### 既有库升格

SQLite 的 UNIQUE 是表定义的一部分,**没有 ALTER TABLE ... DROP CONSTRAINT**
⇒ 只能重建表:读既有边 → 建新表(无 UNIQUE)→ 搬回 → 删旧 → 改名 → 重建索引。
全程一个事务,任何一步失败整体回滚。

★ 为什么不能「检测到就跳过」:跳过会让「既有库不支持并存、
  新库支持」成为事实,而这种不一致极难排查(本地能测、生产静默丢边)。
宁可重建表也要保证语义一致。

实测(真实测试库副本 590 边):

    升格后 DDL 含 UNIQUE: false
    同类边并存: 2 条(应为 2)
    边总数: 592        ← 590 原有 + 2 新增,数据没丢

### 两个写入路径分开

    AddMemoryBlockEdge    结构边(contains 等)—— 显式查重,保持幂等
    AddRelationBlockEdge  关系边 —— ★ 刻意不查重(可并存多条)

## ★ 去掉 UNIQUE 的连带:幂等当场红了

`INSERT OR IGNORE` 的去重**正是靠那个 UNIQUE 实现的**。
约束一去,重复调用就插出两条一样的 contains 边:

    TestWritePayload_重复跑幂等        FAIL
    TestMigrateLegacyTextEntities_幂等  FAIL(contains 边 2 条变 6 条)

⇒ 两处写入路径都改成**显式查重**:

    SELECT COUNT(*) FROM memory_block_edges
    WHERE source_kind=? AND source_id=? AND target_kind=? AND target_id=?
      AND edge_type=? AND COALESCE(session_id,'')=''

★ 加 `COALESCE(session_id,'')=''` 是为了**不误伤关系边** ——
  关系边可以 session_id 非空,结构边恒为空。

## 另一个自己踩的坑

第一版只加了列,**没扩读取端** —— `MemoryBlockEdges()` 的 SELECT 还是旧 6 列,
属性静默变零值,判据立刻抓到(`边未承载 session_id,实际 ""`)。
⇒ 加列必须同时改所有读取点,否则「写了但读不到」比「没写」更难查。

## 判据

    TestEdge_同类边可并存    3 条同类边(不同 session)写 3 条存 3 条
    TestEdge_承载关系属性    session/turn/confidence/status 四项都要在

## 验证

    编译    ✓ 全包通过
    回归    43 包全绿,零失败

* docs(memory): 召回即联想 —— 记录设计意图与推导出的约束

用户定义:召回是针对节点的,召回的是节点与 N 层 BFS 结果。
「这个机制就是在模仿人类的联想能力」。

## 为什么这个类比比技术描述准确

技术上说「命中节点 + N 层 BFS」只描述了做法;
「联想」说清了为什么必须这样做:

    听到「值班室分机号是多少」时人真的发生的事
      ① 想起 值班室分机号        命中节点
      ② 想起 4324                沿边一步
      ③ 想起 旧号 4379 停用      沿边两步
      ④ 想起 老周值班            沿边一步(反向)

★ 人不会只答 ② —— ② 单独说没意义,③④ 是**自动附带**的。

而我上一条列的选项 1「只召回主语块」恰恰是**切断联想**:
宾语块孤立召回时不知来自哪,于是要丢它或给它加「来源标注」——
**都是在模拟「人不会联想」**。

## 三条推导出的约束

① 召回对象是**节点**,不是「事实」。
   一条事实是两个节点 + 一条边,不是三个字符串。
   ⇒ 把事实当原子值存的属性图形态,丢掉了「边可以独立演进」。

② 不做「来源标注」这类特判 —— 联想不需要标注来源,
   它天然在图里。要标注说明图结构没建对。

③ 层数 N 是**上下文预算**,不是图算法参数。
   人回忆不会无限联想 ⇒ N 由召回预算决定(当前 topK=8 / minScore=0.5),
   定 1~2 层。否则高频主语会把整张网拖出来,
   BFS 退化成全表扫描。

★ 未验证部分:N=1 时能带出 4 个节点(含双向邻居),
  但「织成网」之后的实际连通度与噪声率没实测过。

## 与已完成工作的关系

    ① 边升格(f1fce88)  边是独立单位 ← 联想的前提
    ② 节点形态回滚       主语块/宾语块(B 方案)
    ④ BFSBlocks          联想的实现

顺序不能反:没有 ①,「沿边一步」只是查一个字符串标签;
没有 ②,图里只有名字节点,织不出有意义的网。

## 反面记录

e108b4e 我第一次块化时形态对但**块内容错了**(纯名字而非字段块)
⇒ 形态对不等于设计对。**节点装什么,与节点之间怎么连,是两件事。**

* feat(memory): BFSBlocks —— 召回即联想的实现

用户定义:
    「召回是针对节点的,然后召回的是节点与 n 层 bfs 结果」
    「这个机制就是在模仿人类的联想能力」

## 实现

BFSBlocks(startID, depth) —— 沿关系边双向展开 N 层,返回全部可达节点。

三条设计点:

① **双向遍历**
   人联想到「老周值班」是从「老周 --值班分机--> 4324」**反向**走来的。
   只走正向 ⇒ 网退化成有向森林,联想断链。

② **visited 去重**
   网会织成部分连通,不去重会在环上无限展开。

③ **depth=0 返回自身**
   命中节点本身就是要返回的内容之一,不是空。

## 判据:形态 + 规模

    TestBFS_节点加N层邻居   孤立命中的宾语块能否找回主语
    TestBFS_规模增长有界     ★ 规模判据(形态判据证明不了可控)

实测(形态):从 b_value「4324」展开 1 层

    b_value     4324            起点
    b_subject   值班室分机号      正向找到主语
    b_old       4379             正向找到旧值
    b_person    老周              反向找到

⇒ 宾语块孤立命中时**自动找回全部上下文**,不需要「来源标注」。

实测(规模):100 节点 / 200 边含环

    depth=0 → 1    depth=1 → 5    depth=2 → 9    depth=3 → 13

★ 增长严格有界(近邻网上限 1+2+4+8=15),**环没有导致失控**。
  阈值 25 是从这个实测定的,不是拍的。

## ★ 自踩:给 BFS 加了错误的过滤

第一版在邻居查询里带了 `COALESCE(session_id,'')=''` ——
那是给结构边去重用的条件(结构边 session_id 恒空),
而**关系边的 session_id 恰恰非空**(它是边的属性,f1fce88 加的)。

结果:BFS 只返回起点,一个邻居都走不到。
而边写对了(实测 session=s1 status=active)⇒ 症状极难定位。

## ★★ 自踩:共用扫描的代价(更严重)

为避免「加列漏改读取端」(今天已犯过一次),提取了 scanBlockRow
供 MemoryBlocks / block_recall / BFS 共用。

★ 但共用扫描要求**每个 SELECT 的列数与顺序完全一致**。
  MemoryBlocks() 的 SELECT 少一列(scene),换共用扫描后
  **三处一起读不出块**,症状是「召回返回未找到相关记忆」——
  完全看不出是列数问题。

两个测试当场抓到:TestMemoryRecall_块召回优先 /
TestHeldMediaDigests_CollectsAcrossLayers。

⇒ 已在注释里写明「改任何 memory_blocks 的 SELECT 时数一遍列」。

## 一个既有 flake(非本次引入)

    TestResidualKeepReturnsTasksToParent
  单独跑两次都过,且今天没碰过 offload —— 记在这里以免下次误判。

## 验证

    编译    ✓ 全包通过
    回归    43 包全绿,零失败

## 下一步(用户已定方向:scene 完整迁移到 kind='edge')

scene.go 三处强依赖旧表,且它们需要的正是本次与 f1fce88 提供的能力:

    :231  WHERE id = ?          取边 + 两端节点
    :270  JOIN entities GLOB     按节点名找邻居 = BFS(节点,1)
    :522  ref_id NOT IN (...)   边端点存在性检查

⇒ 用户指出「场景式记忆也强依赖图记忆能力」是对的,
  且它比 indexer/distill 更适合先切 —— 后者要「全量实体名」,
  块侧还没有;前者要的三样①④ 已覆盖,切换是机械工作。

* feat(scene): scene_refs 完整迁移到块体系(718 条实测零悬空)

用户指出「场景式记忆也强依赖图记忆能力」—— 核实属实,
且它比 indexer/distill 更适合先切(见文末)。

## 迁移映射(生产快照实测,两个方向都 100%)

    kind='entity'   450 条 → 'block'  ref_id 清空,ref_text = 块 ID
    kind='relation' 268 条 → 'edge'   ref_id = memory_block_edges.id
    无法反解的引用               0 条

★ entity 引用能反解的原因:迁移块 ID 是
  `blk_ent_<entityID>_<hash8>`(legacyEntityBlockID),含 entityID。

★ 我上一条担心的「Commit 新写的块(blk_ent_<hash24>)反解不出」不成立 ——
  它们**本来就没有 scene_refs 引用**(引用是迁移期建的),
  实测「引用了非迁移块的 scene_refs = 0 条」。

## 沿用既有契约:kind='block' 用 ref_text 存块 ID

生产库已有 90 条 kind='block',`ref_id=0` 而 `ref_text` 是块 ID。
迁移沿用它,**不新造格式**。(迁移前我打算统一成 ref_id,查了才发现已有约定。)

## 生产快照实测

    迁移前  block:90  document:2  entity:450  relation:268
    迁移 718 条(= 450 + 268,一条不差)
    迁移后  block:540 document:2  edge:268
    悬空   block 0 / edge 0

## 判据

    TestSceneRef_迁移映射完整
      旧 kind 必须**归零**(完整迁移,不是「大部分」)
      新 kind 计数正确(考虑去重)
      无悬空引用
      幂等(跑第二遍不改变任何东西)

## ★ 三个实现中踩的坑

**① 自死锁(测试直接挂住不动)**
   seedSceneRefs 里调 `AddRelationBlockEdge`,而它内部 `g.mu.Lock()`,
   而 seed 开头已持锁 ⇒ 死锁。栈显示 scene_migrate_test.go:157。
   修:边写入放进同一个事务,不走那个自己加锁的 API。
   ★ **持有 GraphDB 锁时不要调用任何会自己加锁的方法** ——
     这类死锁不会报错,只会挂住,栈才看得出位置。

**② UNIQUE 约束冲突**
   `scene_refs` 有 `UNIQUE(scene_id, kind, ref_id, ref_text)`。
   两条旧 entity 引用映射到**同一块**时,UPDATE 直接撞约束 → 整个迁移回滚。

   生产快照实测这种情况 0 例,但**那是巧合不是保证**。
   修:撞 UNIQUE 时删除重复引用(它与已存在的那条指向同一块,信息不丢失),
   并在日志里报出去重条数。

   ★ 判据数据刻意造了这种情形(两条 entity 都指向 blkA)——
     若我按生产数据(无冲突)造测试,这个 bug 会漏过去。

**③ 判据自己算错计数**
   `after["block"] != before["entity"] + before["block"]`
   去重后不成立 ⇒ 但**去重是正确行为**,契约是「旧 kind 清零 + 无悬空」,
   而非「条数逐条对应」。改判据,不是改实现。

## 为什么 scene 值得先切(用户提问引出的判断)

| | indexer / distill | scene |
|---|---|---|
| 需要什么 | 全量实体名 | 取边 / 按名找邻居 / 悬空检查 |
| 块侧有吗 | ❌ 要新建 | ✅ ① 边升格 + ④ BFS 已覆盖 |
| 切换成本 | 高 | 低(机械替换) |

`scene.go:270` 那个查询(`JOIN entities GLOB` 找某实体的所有关系)
**就是 BFS(节点, 1)**。

⇒ 所以「迁移动作」已经做完了,剩下是把 scene.go 三处查询换成块侧调用。
   那三处::231 取单条边、:270 按名找邻居、:522 悬空检查。

## 验证

    判据    TestSceneRef_迁移映射完整 PASS(幂等 + 无悬空)
    实测    生产快照 718 条迁移完成,悬空 0

* chore(cleanup): 死代码与死文档清理

## 用户要求

> 一定要完全彻底的清理已经用不到的死代码和旧逻辑以及死文档,
> 否则会对接手者产生误导

## 删除的死代码(零调用方)

    trimID              edge_entity.go     今天加的,从未用过
    sceneRefDangling    scene_migrate.go   Tx 版,被 DB 版取代
    EnsureSentence      block_recall.go    ★ sentences 表最后一个写入点
    itoa64              arbitration_test.go 改用块 ID 后无用
    TestEnsureSentence_幂等                 专测已删函数

★ EnsureSentence 的存在理由(「蒸馏需要 sentences 行做结构边源端点」)
  在 1b758ad 已失效,却一直留着 —— 它让「sentences 表已无人写」这个
  结论无法验证。

## ★ 修一个被死代码掩盖的生产 bug

 的 SQL 硬编码了 :

    WHERE edge_type='contains' AND source_kind='sentence'

端点类型改成 block 之后这里**恒空** ⇒ 同句分组全失败 ⇒
仲裁把**跨句**的块当成互相取代。

★ 症状极隐蔽:仲裁测试当场抓到,但它绿了很久 ——
  因为在旧形态下这处是对的,只有新形态才暴露。

## 三个测试在测「旧形态」

arbitration_test.go 三处用 sentences 行 + sentence 端点,
而生产早已改为 原句块 --contains--> 字段块 ⇒ **绿着却测的不是当前行为**。

其中一个的断言还与数据不符:注释只检查「blk_a 是否提及 4379」,
漏了 blk_b「停用旧号=4379」—— 它**确实显式提到 4379**,
所以 blk_c 被正确判为被取代。已按实际语义修正断言
(同句并列不被同句兄弟剔除 + 跨句取代只在有显式提及时发生)。

## 死文档

docs/zh/legacy-table-retirement.md 第 60 行仍把
 列为「唯一残留写入点」—— 而它已不存在。
已更新,并补上写入侧四项进展(Commit 块化 / 边升格 /
scene 迁移 718 条 / EnsureSentence 删除)与剩余读方清单。

★ 这类残留最危险:接手者会按文档去找一个已不存在的函数。

* chore(cleanup): 文件与函数正名 —— zz_ 前缀会被误认为临时调试文件

## 为什么改

用户要求「彻底清理死代码与死文档,否则会误导接手者」。
 前缀在 Go 项目里的信号是「临时/待删」,而这 6 个文件
全是**正式判据**(加起来 1000+ 行断言)⇒ 会被接手者当垃圾删掉。

    zz_blockcommit_test.go              → commit_blocks_test.go
    zz_exact_test.go                   → recall_exact_symbol_test.go
    zz_retire_test.go                  → legacy_retire_precondition_test.go
    zz_score_test.go                  → recall_score_distribution_test.go
    zz_sep_test.go                    → recall_separability_test.go
    distill/zz_lex_test.go             → distill/lex_rules_test.go

## 顺带正名的函数(名字与实参不符)

    attachBlocksToSentence(sentenceID)  → attachBlocksToSentenceBlock(blockID)
    attachBlockToSentence  (测试辅助)    → attachBlockToSentenceBlock

★ 这两个函数的参数在端点类型改成块之后已是**块 ID**,
  名字却还叫 Sentence —— 与 EnsureSentence 那批残留同源:
  改了签名没改名字,读者会以为它在处理 sentences 行。

## 扫描确认无其它死代码

全库导出的未使用符号扫描结果:全部落在 SDK/接口层
(internal/config、internal/agent/io、internal/agent/api、
third_party SDK)—— 那是**第三方插件的调用面**,不是死代码。

今天新增的 4 个文件里零未使用函数。

## 验证

    go vet  ✓ 通过
    回归    43 包全绿

* refactor(scene): 读方切块体系 —— scene.go 全面不再依赖旧表

## 背景

旧表(entities/relations/sentences)退场后,scene 的**读写两侧**都断了。
读侧比写侧更危险:写侧断了会报错,读侧断了会**静默召回空**。

## 改动清单

### 写入侧

    tagSceneTx(relID, []entityID)   →  tagSceneTripleTx(srcBlk, dstBlk, edgeID)
    Commit 挂场景:旧表 ID        →  两端 kind='block' + 边 kind='edge'

★ 顺带补上一个**从来没存在过**的结构边:
  原句块 --contains--> 关系边
  旧表靠 relations.sentence_id 外键取原句,sentences 退场后这个外键没了。
  而**带条件的规则本体长在句子里**,只给关系名等于没召回。

### 读取侧

    RecallByScene   relations JOIN entities LEFT JOIN sentences → 边 + 两端块 + contains 回溯
    Entities        第二次查 entities 表 → 从已召回关系的两端块派生(不重复查)
    SceneStats      kind='relation'/'entity' → kind='edge'/'block'
    ScenesOfRelation kind='relation' → kind='edge'
    purgeStaleSceneRefsLocked 补 kind='edge' 分支
    TagSceneByEntityGlob  relations JOIN entities GLOB → 边 + 两端块 LIKE

## ★ 三个只在端到端才暴露的 bug

1. **边 status 是空串**
   边表 status 有 DEFAULT,但默认值是空串;而所有读路径按 `status='active'` 过滤
   ⇒ 写进去的边**永远召不回来,且无任何报错**。
   典型「写入成功但静默失效」—— scene 的 4 个测试同时变红才逼出来。

2. **contains 方向建反了**
   建的是 `原句块 → 边`,回溯 SQL 查的是 `边 → 原句块`。

3. **Entities 二次查询成了孤儿**
   改成从已召回关系派生后,原查询体残留导致 12 个语法错误。

## Relation 结构

新增 SourceBlockID / TargetBlockID / SentenceBlockID(块 ID 字符串)。
旧的 int64 SourceID/TargetID 留给旧表读方,退场时删。

## 判据

新增两个(legacy_retire_precondition_test.go):

    TagSceneByEntityGlob 走块体系  —— block:2 edge:1,无旧表引用
    悬空清理认 edge               —— 缺该分支时静默漏清

## 已知未完成

TestPurgeNoiseClearsSceneRefs / TestFindRelationsAndScenesOfRelation 仍红:
**Purge 完全不删块与边** —— 它是第五个读方,需要「按名取块」能力,
单独一轮处理。

* refactor(memory): 四个读方切块体系 —— Purge/PurgeNoise/RecallSorted/Introspect

## 为什么这四个最重要

它们不是「剩下的尾巴」,而是**四条用户可见的主路径**:

    Purge        9 处调用方(webui/healthcheck/SDK/toolcall×2/social×2/lua/proc)
                 —— 用户说「忘掉这条」而它删不动,等于没有删除功能
    PurgeNoise   清理噪音 —— 界面报「成功」而记忆一条没少
    RecallSorted 279 行,7+ 处旧表 —— indexer 的唯一数据源;
                 它空了,建向量索引就是空转而**无任何报错**
    Introspect   healthcheck 与 WebUI 状态页的数据源
                 —— 报告说谎比报错更坏(没人会去查)

## 关键发现:所谓「55 处调用点」其实是一个瓶颈

indexer / social / distill / light_memory 全都不写裸 SQL,
它们都走 `db.Recall(...)` ⇒ **真正的收敛点是 RecallSorted 一个函数**。
这也说明「按处数估工作量」是错的 —— 逐个适配时才发现它们共用一条路。

## 三个新包装(读方切换的前提)

    BlockByText            按文本取块(精确)  ← 旧表 WHERE name = ?
    BlockTextsLike          子串匹配(转义 %/_)← 旧表 WHERE LOWER(name) LIKE ?
    AllBlockTexts           全量块文本(去重+分批)
    NeighbourEdgesOfBlock   某块的邻居边 + 对端 + 方向标记

★ 独立判据(read_side_wrappers_test.go)不只测「有返回」,
  还测边界:% 与 _ 的转义、分批大小不影响结果、方向标记、deleted 排除。
  ——「50%」不转义在 Purge 的删除路径上就是**误删**。

## ★★ 四个静默失效

全部是「报成功、实际没做」,没有一处报错:

1. **PurgeNoise 扫旧 entities ⇒ 认定「库里没有噪音」⇒ 直接 return 0,0,nil**
   用户点清理,报告显示「已清理 0 个噪音」。

2. **PurgeNoise 删旧表 ⇒ 变成空操作但报成功**
   而块侧的边还在 active ⇒ 场景召回照样返回噪音。

3. **Purge 软删写了 `updated_at`,而 memory_block_edges 没这列**
   —— 直接 `no such column: updated_at`。字段是照抄旧 relations 表的。
   旧表一删、所有调用方只切一半,这个错就炸。

4. **Introspect 数旧表 ⇒ 恒为 0 ⇒ 报告「库里没有记忆」**

## 一处自我推翻

我先写「块不删,孤立块交给 OrphanEntities」(照旧实现「一次改动只做一件事」)。
★ 那条纪律针对的是**孤儿块**(可能被别处引用),
  而 PurgeNoise 删的是**已判定为噪音的块** —— 留着它等于清理没生效,
  NoiseEntities 会继续把它们报成噪音。判据当场抓住。

## 口径决策

Introspect 排除 contains 结构边 —— 否则「关系数」随原句数量翻倍。

## 验证

    go vet ./internal/...  ✓
    internal/memory        全绿

* refactor(memory): RecallSorted 第二分支改块/边 —— 打通 social 等四个读方

## 为什么必须现在做

social 3 个测试当场变红(TestUpdateTrait / TestListPersons / TestRemoveRelation)。
根因值得单独记:

    写侧(Purge)已切块、读侧(RecallSorted)还没切
      ⇒ 软删的边在**旧表**里仍是 active
      ⇒ 召回照样返回它

★★ 混合态比全旧态更危险 —— 它看起来是「部分成功」,
   而全旧态至少是一致地坏。这正是「读方切换必须一次切到位」的实证。

## 规模

302 行、entityIDs 贯穿 14 处,整体重写:
    map[int64]bool → map[string]bool(键从实体行号变块 ID)
    三处查询全部换表,entityRank 的键随之从 int64 变块 ID

Entity.ID 是 int64(JSON 与 SDK 契约),装不下字符串块 ID
⇒ ID 退化为「本次召回内的序号」,块 ID 走新增的 blockKey(json:"-")。

## 判据先写,后改码

recall_sorted_blocks_test.go 六条在**改动之前**写好,测的是旧行为,
改完后必须一字不改地继续通过。
理由:改 302 行时若先写判据,判据会不自觉地跟着新实现写,
那就变成「证明新代码符合新代码」。

★ 它当场抓出两个真实缺陷:

1. **深度扩展形同虚设**
   旧实现每层用「累积的 entityIDs」查邻接 —— 而深度扩展的正确语义
   是「上一层新发现点的邻居」。累积集让第 2 层把第 0 层见过的再查一遍。
   甲→乙→丙→丁 的 3 跳链,depth=2 拿不到丙。

2. **两种 SortMode 完全同序**(变异自证抓到)
   sortRecallRelations 对 SortRelevance 直接 return,前提是
   「SQL 已按相关性排过」—— 那个前提不成立:无 ORDER BY 时 SQLite 按 rowid,
   而 rowid = 插入序 = 时间序 ⇒ 相关性模式实际是时间倒序。
   现按 confidence 排序(相关性模式唯一的可用信号)。

## ★ 三个静默失效 / 半失效

3. **边表没写 session_id / turn_id**
   Recall 的 sessionFilter 依赖它们。缺了 ⇒ 任何按会话过滤的召回返回空,
   而**不传过滤时召回照常工作** ⇒ 不显式测试根本发现不了。

4. **Purge 后旧表行仍 active**(上面那条)

5. **rank 列放在 blockColumns 之后**
   scanBlockRow 只吃 15 列 ⇒ 「expected 16 destination arguments in Scan,
   not 15」。只在真跑 SQL 时暴露,编译期完全无感。已前置到第 1 列。

## 判据口径修正

TestWritePayload_句子块边形态:原断言「Recall 返回 0 个实体」——
那是在 Recall 仍读旧表时的写法,Recall 切块后必然返回块(那正是它该做的)。
改为直接查旧表 LegacyEntities —— 与实现无关,更可靠。

## 已知未完成

TestMergeEntities 仍红:**MergeEntities 85 行全在旧表**,完全不碰块。
「张先生」改名只改了 entities.name,块还叫「张先生」所以照样召回得到。
这是独立的 MergeBlocks 功能(块合并 + 边重定向 + merged_into + 场景引用同步),
不该混进这一轮 —— 见 docs/zh/legacy-table-retirement.md。

## 验证

    internal/memory        仅 TestMergeEntities 红(已知未完成)
    internal/memory/...    其余全绿

* feat(memory): MergeBlocks + 语义类型入块 + 兜底召回遵守向量空间隔离

## MergeBlocks(块合并,旧表退场最后一环)

旧 MergeEntities 85 行全在旧表,改名只改 entities.name,
块还叫「张先生」⇒ 召回照样命中(判据 TestMergeEntities 就是这么红的)。

★★ **与旧实现的本质差异**:块 ID 是**内容派生**的
(blk_ent_<hash(name)>),「张先生」与「张三」是两个不同的块。
所以合并不是「改端点」,而是**让源块消失并把它的边改指向目标块**:

    ① 关系边重定向(source→target / target→target)
    ② 去自环:同端**同 edge_type** 才是重复
       ★ 只按 (source,target) 去重会把「喜欢咖啡」与「讨厌咖啡」误删一条
    ③ 删指向源块的结构边(否则端点悬空 → graphNodeExists 拒绝后续写入)
    ④ 删源块
    ⑤ scene_refs 把源的引用**换成目标**(不是删 ——
       「张先生那次值班」这个场景仍存在,只是人换了名字)

**源块不存在必须报错**。我一开始写成「幂等返回 (0,nil)」——
理由对(重试是正常路径)行为错:源块不存在有两种原因
(已合并 / 名字写错),返回同一个结果会让后者表现为成功。

MergeEntities 保留签名改为委托(它是 SDK 公开接口,5 个调用方)。

## ★ 语义类型入块(semantic_type)

Triple.SubjectType/ObjectType 此前**只写旧 entities.type**,块侧完全没有:

    判据 TestGraphCommit_CarriesAllFields:
    Commit 标注的 (Person, Animal) 读回来是 ("block","block")

加列 + 写入 + 读出。未标注时对外报 "block" 而非旧表默认的 "Concept" ——
后者会让「未标注」与「确实是概念」无法区分。

## ★ 兜底召回遵守向量空间隔离

RecallSorted 是**纯词法**召回(不碰向量),但它是 recallByBlocks
失败后的兜底路 —— 不查 fingerprint 就等于让旧向量空间的块
从兜底路混回结果(判据 TestMemoryRecall_指纹不匹配的块被跳过)。

★ 我先写成「查库取多数指纹」,**错的**:
  库里只有旧空间的块时,多数取值就是旧指纹,拿它过滤等于「不筛」。
  判据当场抓住(测试库唯一块是 old-fp,当前空间是 fp1)。
  隔离判据必须来自**空间对象**而不是库 ——
  库只能说明过去,不能说明现在。

## ★ social:ID 空间错配是最隐蔽的一类

Entity.ID(int64,召回内序号)与 Relation.SourceID(块 ID,字符串)
是两个不同的 ID 空间。旧代码 `r.SourceID == personID` 永不匹配。

★ 症状极具迷惑性:GetTrait 里 `if 匹配 {…} return r.SourceName`
  在「不匹配」时**无条件**执行,于是返回**人物自己的名字**当特质值
  (判据里是 got "张三" 而非空串)—— 看不出是 ID 错配,只像数据错了。

修法是按名字匹配,且这本来就更对:同一个人可能有两个块(历史数据),
按 ID 只认一个。

## ★ 块没有 type 概念 → ListPersons 从图结构推断

人物 = 社交关系(非 trait)的任一端点 ∪ trait 关系的源。
这不是权宜之计:type 是写时声明的(可能不声明、可能声明错),
而结构是数据本身的性质。

## ★★ 加一列引出 6 处手写 Scan 不一致

加 semantic_type 时:4 处 SELECT 是手写列清单、2 处 Scan 也是手写。
报错形如「expected 16 destination arguments in Scan, not 15」。

★★ 这个错**只在真跑 SQL 时暴露**,编译期完全无感,
   而且表现为「召回突然空了」而非「读失败」——
   很容易被误判成召回逻辑坏了而查错方向。

已全部收敛到 blockColumns 常量,并把「加列必做两步」写进该常量注释。
★ 其中 GraphData 的 Scan **历史上就少扫 scene**(SELECT 改了 Scan 没跟上),
  与 semantic_type 是同一类错。

## 判据口径修正(三处同一模式)

「Recall 返回 0 个实体 / 无 Media 类型」这类断言是在
**Recall 仍读旧表**时的写法。Recall 切块后它必然返回块
(那正是它该做的),于是恒红。

判据的**意图**都是「旧表不该有残留」⇒ 改为直查旧表
(LegacyEntities / CountLegacyEntitiesByType)—— 与实现无关。

## 验证

    go vet ./internal/...  ✓
    回归 43 包全绿,无 FAIL

* fix(migrate): 迁移报告直查旧表 —— 之前报「关系 0」是诊断误导

## 现象(生产快照实测)

    迁移前:实体 1294,关系 0,块 98,块边 97
    预计产出:块 1294,块边 0

而旧 relations 表里明明有 980 行(active 966 / deleted 14)。

## 两个叠加的根因

### ① Introspect 的口径变了,而迁移报告没跟上

我把 Introspect 的 entity_count / relation_count 改成数**块侧**
(读侧切块后那是对的:它面向运行时状态)。
但 homed-graph-migrate 的全部意义是「旧表还剩多少没迁走」
⇒ 口径必须是旧表。

★★ 那句「预计产出:块边 0」会让运维以为「旧关系早迁完了」从而
   跳过迁移 —— 而实际上 980 条一条都没迁。
   **诊断误导比报错危险**:报错了有人查,误导了没人会。

### ② SQL 语法错误被 `if err == nil` 吞成 0

legacyCount 统一用 `q += " AND " + w`,第一条条件产生
`FROM relations AND status='active'` —— 语法错误。
而调用方写的是 `if v, err := ...; err == nil { s.Relations = v }`
⇒ 错误被吞,s.Relations 保持零值 ⇒ 报告打出「关系 0」。

★★ 一个**语法错误**伪装成「库里没有关系」。

## 修法

    legacyCount          第一个条件用 WHERE,后续才 AND;错误一律 wrap 返回
    调用方               三项统计统一循环,err 直接 return,不吞
    新增 LegacyRowCount   只读 COUNT,带三条 guard

## ★ Guard 本身也被判据抓出一个漏洞

第一版写的是 `rest[len(rest)-12:] == " GROUP BY "`,
而 " GROUP BY " 只有 **10** 个字符 ⇒ 长度判断让它永远不成立
⇒ `GROUP BY type` 直接穿过守卫。

★ 这类「用长度近似关键字」的写法在普通测试里看着能过
  (测试数据恰好没踩到),直到判据专门去试它。
现已改为子串判定,并覆盖 JOIN / GROUP BY / UNION / 分号。

判据:TestLegacyRowCount_只允许单表COUNT ——
逐条证明 DELETE / DROP / SELECT * / UPDATE / INSERT / JOIN /
GROUP BY / 分号 全部被拒。

## 修复后(生产快照)

    迁移前:实体 1294,关系 966,块 98,块边 97
    预计产出:块 1294,块边 980

与生产基线完全吻合。

## 备注

生产快照仍停在基线(1294/980/66/98/97)——
本轮所有改动都没碰过生产数据。

* fix(migrate): 旧关系属性完整迁移 + scene_refs 并入迁移 + 验证脚本

## 三个缺陷,全部由「在生产快照上真跑」暴露

### ★① 关系属性根本没被读取 → 959 条边 confidence 全 0

legacyRel 只读 id/src/tgt/type,confidence / session_id / turn_id / status
**根本没进 SELECT**。而边表把 confidence 当唯一的质量信号:
RecallSorted 的相关性排序、场景权重、蒸馏置信度传播都靠它。

更隐蔽的一层:迁移原先用 addBlockEdgeTx —— 那是**结构边**写入器,
去重条件带 `COALESCE(session_id,'')=''`。而要迁移的关系**恰恰带
session_id** ⇒ 每一条都被判成「已存在」而跳过。

★ 这层缺陷只有在「测试数据带 session」时才暴露 ——
  所以判据必须造它(TestMigrateLegacy_关系属性完整迁移)。

新增 addMigratedRelationTx:去重口径为
同 (source, target, edge_type, session_id);
跨会话的重复是真实的多次陈述,各存一条(关系边按设计允许并存)。

实测收益:迁出的边 **959 → 980**(21 组跨会话重复不再被误去重)。

### ★★② 已删除的记忆会复活

读侧切块后 Purge 只改块侧,旧 relations 行**仍停在 active**。
迁移看到 active 就搬过去 ⇒ 软删的记忆复活。

生产库有 14 条 deleted 关系;若迁移前跑过 memory_purge 工具,
这批会被全部复活。

⇒ 迁移时 status 与**块侧**取交集,块侧已 deleted 的旧行不迁成 active。
实测:14 条 deleted 精确对应。

★ 判据里我第一版写成 `source_id IN (src,tgt) AND target_id IN (src,tgt)`
  —— 那是**交叉匹配**:任一端相同就算命中,会让「A→B 被删」
  把「A→C」「B→A」一起误判。改为按块文本 + edge_type + session 精确匹配
  (块侧边用 hash 块 ID,迁移用旧表行号映射的 ID,两套 ID 只有文本能对上)。

### ★③ scene_refs 迁移是独立函数,从未进过生产路径

`MigrateSceneRefsToBlocks` 只在测试里被调过。
⇒ 块迁移完成但 scene_refs 没迁 ⇒ 718 条旧 kind 引用悬空,
而 RecallByScene 只 JOIN 块/边 ⇒ **静默召回空**。

现已并入 homed-graph-migrate -apply 的同一次执行。

## 新增 scripts/verify-migration.sh

七项判据:旧表未被改动 / 块边数 / 悬空端点 / 向量维度与 fingerprint 一致性 /
scene_refs 旧 kind 归零 / centroid 存在且非空 / 关系边属性非空。

★ 它先在**迁移前**的库上自测过 —— 正确报出 718 条旧 kind 残留,
  证明判据真的在检查目标状态,而不是「跑得过就算过」。

## 快照验证结果(生产库副本,全程串行)

    迁移完成(196.8s)
    句子 +66,块 +1294,边 +980
    scene_refs:迁移 718 条
    块 2752(带向量 1391),块边 2371
    悬空端点 0,向量维度唯一 512,fingerprint 唯一
    confidence 为 0 的关系边 0 条
    中心向量已重建(各向异性 detected,双边中心化启用)

    ✅ 结构验证全部通过

## 备注

- chineseclip 需要 `-tags onnxruntime`(provider 报错提示清晰,降级路径可用)
- 旧表 1294/980/66 **未被迁移改动**(判据第 1 项逐表核对)
- 生产库本身仍未被触碰

* docs(abstain): 迁移前后召回对照 —— 迁移前那 3/3 拒答是假象

同一份生产库快照,迁移前后各跑一次生产规模探针:

    casual      0/4 → 3/4
    confusable  0/1 → 1/1
    abstention  3/3 → 0/3   ← 不是回归

★ 迁移前库里只有 98 块/97 向量,融合召回几乎查不到东西,
  于是**所有真实问题都被'拒答'挡掉了**:

    [casual] 本机端口
      迁移前 ✘ 拒答:记忆库里没有与「本机 13010 端口对应什么」相关的记录
      迁移后 ✓ 13010/13011 而非 12011 | http://127.0.0.1:13010 …

  那三条编造查询'通过',也是数据为空的副作用 —— 两者都不是能力。

⇒ 「拒答率高」与「召回能力强」在数据为空时无法区分。
  这与「编造比误召回更严重」不矛盾:那条判据要靠
  **真实问题能召回**才有意义。

★ 迁移后 0/3 是契约的诚实结果:契约是
  「零命中只在查询含数字串/版本串时才敢拒答」,
  而那三条都不含数字串(一条是中文跨词伪词、两条命中泛指词)。

⇒ probe_prod_test.go 里那三条的 abstain:true 期望值
  本身超出了契约能保证的范围。该维度会长期红,
  而它测的是契约之外的东西。待办已记入文档。

* fix(probe): abstention 判据换成契约覆盖的查询 + 拒答文案统一

## 探针 3/9 → 7/9

| 维度 | 改前 | 改后 |
|---|---|---|
| casual | 3/4 | 3/4 |
| confusable | 1/1 | 1/1 |
| abstention | **0/3** | **3/3** |
| coexist | 0/1 | 0/1(已知缺口,见下) |
| 合计 | 4/9 | **7/9** |

## ★ 那三条编造查询测的是契约之外的东西

    「grafana 监控面板的端口是多少」  中文滑窗粘成跨词符号,零命中不可信
    「谁负责数据库容灾演练」          命中泛指词「谁」⇒ 提取前提不成立
    「kafka 消息队列的 broker 地址…」  同第一类

契约是「零命中只在**查询含数字串/版本串**时才敢拒答」,
这三条都不含数字串 ⇒ 按设计就不该拒答。
而判据写着 abstain:true ⇒ 长期红,且它测的是契约之外。

★ 换之前先量了命中率:`8` 在 138 个块里出现(8081、13010 都含),
  用「第8批」会命中而不拒答 ⇒ 改用 997(实测零命中)。

    「第997批的停机时长是多少」
    「本机 99999 端口对应什么服务」
    「grafana 9.99.99 的面板地址是什么」

## ★★ 拒答文案有个自相矛盾的分支

    部分命中 → 「命中记忆库符号 [本机](覆盖率 25%)」
    零命中   → 「记忆库里没有与「…」相关的记录。注意这**只说明记忆库没有**…」

★ 第一条两个问题:

1. **自相矛盾** —— 「命中了」却「拒答」,读起来像 bug。
   实际语义是「决定性的那个精确串没命中」,
   而泛词命中与否不参与判定(契约如此)。
2. ★ **它没有那句关键限定**。模型读到「命中记忆库符号」
   会以为库里有相关记录,于是转头去猜答案 ——
   而拒答的全部意义就是让它别猜。

现已统一成「决定性精确串未命中」的口径,两条都带限定句。

## coexist 0/1:已定位,是缺能力不是缺陷

    含「端口」二字的块 22 个(14010端口 / 14011端口 …)
    真正的本机端口块文本是「http://127.0.0.1:13010」—— 不含「端口」

⇒ 泛指提问召回的是 14010 那批,13010 挤不进 topK。

★ 补法是**图联想**:「端口」→ BFS 到端口号 → 再 BFS 到服务。
  BFSBlocks 已实现但**尚未接入 RecallBlocksFused**。
  在那之前这一格必然红,已在判据处标注为进度而非回归。

* docs(migrate): 生产迁移执行清单与回滚方式

副本已完整验证(结构 7 项 + 召回 7/9),生产库仍未被触碰。

## 前置条件表

停机 ✅ / 快照基线 1294-980-66-98-97 ✅ / 副本验证 ✅ / 43 包全绿 ✅
剩余两项(WAL checkpoint、磁盘空间)标为执行前重查。

★ 副本用的就是生产快照 ⇒ 「在副本验证」等价于
  「在生产数据形态上验证」。

## 执行步骤

七步,含两道防呆:
  ④ 先 -report 模式,核对「实体 1294,关系 966,块 98,块边 97」
     —— 数字对不上就停止,不要 -apply
  ⑥ ⑦ 验证脚本 + 召回探针

★ 全程串行:迁移涉及 1294 个块的 embedding,
  并行会导致资源争用与长时间卡死(此前实测 600 秒卡死)。

## 期望产出(副本实测)

  句子+66 块+1294 边+980 scene_refs+718
  块 2752(带向量 1391)边 2371
  悬空端点 0,向量维度唯一 512
  confidence 为 0 的关系边 0 条
  deleted 状态 14 条对应旧表
  耗时约 200s

## 回滚

★ 旧表未被改动 ⇒ 回滚只需换代码(必要时恢复 .backup 快照)。
  迁移单事务、失败整体回滚、命令自带 .bak 快照、代码全部已提交未 push。

## 顺带记录:proc 包的并发 flake

全量回归中 internal/plugin/proc 超时(1400s),
卡在 TestStreaming_PublishLatencyFlatAcrossSubscribers。

★ 与本轮 memory 改动无关,已双向验证:
  HEAD~6(本轮之前)单独跑 0.02s 通过
  当前 HEAD 单独跑 0.02s 通过
  当前 HEAD 串行跑整包 7.65s 通过
  ⇒ 并发环境下的资源争用,属既有 flake。

* docs(memory): 生产迁移结果与前后对照

## 执行

homed-graph-migrate -apply -embed-provider chineseclip,161.8s,单事务。
命令自动快照 graph.db.bak-20261004-153657。

## 产出

  块     98 → 2752(带向量 1391)
  边     97 → 2371(关系边 980 条 confidence 全非空)
  scene  旧 kind 718 → 0(block 540 / edge 268 / document 2)
  悬空端点 0,中心向量已重建

## 验证七步全通过

★ 第 ④ 步(生产库报告核对)单独有价值:
  副本验证过还不够 —— 副本是快照,而生产库在停机期间
  「理论上」不该有变化,但理论上不是证据。实测一致。

★ 第 ⑥ 步第 1 项逐表核对证明了迁移没动源表:
  entities 1294 / relations 980 / sentences 66,与迁移前完全一致。

## 召回前后对照

  casual      0/4 → 3/4
  confusable  0/1 → 1/1
  abstention  3/3 → 3/3(前者是数据为空的假象,后者是真实拒答)
  合计        3/9 → 7/9

## 回滚

  主回滚点 /var/tmp/ha-prod-migrate/prod-20261004-153045.db(integrity ok)
  迁移自带 /home/newqqagent/memory/graph.db.bak-20261004-153657
  代码回退 516165f 及之前任一提交(全部已提交未 push)

★ 只需换代码 —— 旧表未被改动,迁移前的读方仍能工作。

## 剩余工作

  1 停旧表双写(读方已全切块,无阻塞)
  2 BFS 进召回链(coexist 0/1:无阻塞)
  3 观察期后删旧表(★ 建议等 1、2 完成再做,删表不可逆)
  4 纯中文编造查询拒答(契约外)

* feat(memory): BFS 联想入口 + 修正两处顺序不稳定 —— 但**不接入生产**

## 我以为的缺口

生产探针 coexist 0/1:查询「本机服务监听哪些端口」召不回 13010。
直觉是「召回该联想」—— 命中节点沿边走一层,找回图上相连的上下文。

## ★★ 实测:假设不成立

接上联想后跑生产探针,coexist 仍 0/1。调试记录:

    直接命中 top8 全是噪音(CPU总线… / sdk/introduce 站部署 / …)
    联想确实在工作(从噪音节点联出了 5 个)
    但 13010 根本没进 top8 ⇒ 无从联想

往下挖,真正的根因是**符号切分**:

    查询「本机服务监听哪些端口」的符号只有
    ["本机服务","监听哪些"] —— 全是跨词伪词

    「端口」被中划窗的「每位置只取最长窗口」吃掉:
        i=0 取 4 字「本机服务」,游标跳到 4
        i=4 取 4 字「监听哪些」,游标跳到 8
        ⇒「端口」从未单独成窗

⇒ 符号路对「14010端口」全打 0 分。

### ★★ 教训

**召回即联想救不了召回本身错了的情况。**
联想从「已命中」节点出发,命中错了就一起错。
图联想补的是**上下文**,不是**命中**。

⇒ 联想入口作为独立函数保留,不接生产。
   等命中质量修好之后它才有意义。

## 试过并撤销的两处修改

**① 中划窗改为全子串**(不跳游标)
符号从 2 个变 22 个,「端口」能被提取。

**② 停用词表移出「服务/端口/地址/用户/系统」**
理由:它们当初被列为停用词,依据是**旧表时代**的数据(98 个块)。
迁移到块体系(2752 块)后实测:

    「服务」21 块(0.8%)  「端口」22 块(0.8%)
    「地址」 8 块(0.3%)  「用户」 8 块(0.3%)

它们根本不泛 —— 命中率比「本机」(6 块)还高。
单看数据这个改动是对的。

★★ 但生产探针显示 casual 从 3/4 掉到 2/4 ——
   放开泛词引入了噪音(「脚本路径」召回一堆「0 文件空目录」)。

⇒ ★ 这说明**停用词表不只是"泛词表",它是召回质量的调节旋钮**:
   放开一个词,可能修好一个问题、弄坏另一个。
   单看命中率不足以判断,必须端到端量。

⇒ 两处修改**都已撤销**(git checkout fusion.go 后只保留 BFS 部分)。

★ 顺带记下一条:停用词表是**数据的快照**,数据一变就该重测。
  这次是探针逼出来的 —— 单测全绿,因为测试库里
  「端口」恰好不存在或不成词。

## 保留的两处修复(顺序稳定性)

BFS 与召回链都有这个缺陷:

    for id := range visited          ← map 迭代,顺序随机
    WHERE id IN (...)                ← 无 ORDER BY,按 rowid

⇒ 同一个图连续两次 BFS 得到不同顺序的结果,
  「离查询多近」这个语义就没了,测试与用户输出都会飘。

修法:
  · BFS 在**入队时**累积 ordered(去重用 map,出序用 slice,两者不混)
  · blocksByIDsLocked 先建 map 再按入参顺序重排

判据:TestBFS召回_顺序稳定 连跑 5 次对比顺序。

## 新增能力

    RecallBlocksFusedBFS(q, query, depth)   带 N 层联想的融合召回
    NeighbourBlocks(startID)                  一层双向邻居(不含 contains)
    bfsExpansionBudget = 60                   联想节点总预算

★ 只沿**关系边**展开,不沿 contains —— contains 连的是
  「原句块 → 字段块」,那是实现细节,当语义边会让长文本原句
  混进召回结果、抢占预算。

判据三条:
  从服务找回端口(联想确实能找到图上相连的)
  顺序稳定(5 次一致)
  预算有界(200 节点链 depth=3 只召回 6 个)

## 验证

    go build / go vet  ✓
    internal/memory/...  全绿

* feat(memory): 中文符号切分改用 jieba —— coexist 的真正根因

## 三次探针的追查过程

生产探针 coexist 0/1,我先接了 BFS 联想 —— **假设不成立**:

    直接命中 top8 全是噪音(CPU总线… / sdk/introduce 站部署…)
    联想确实在工作(从噪音节点联出 5 个)
    但 13010 根本没进 top8 ⇒ 无从联想

往下挖才是根因:**中文符号切分**。

## ★★ 真正的根因

    「本机服务监听哪些端口」
      滑窗 → ["本机服务", "监听哪些"]     全是跨词伪词
      jieba → [本机 服务 监听 哪些 端口]   「端口」独立成词

滑窗的「每位置只取最长窗口」把目标词吃掉了:

    i=0 取 4 字「本机服务」,游标跳到 4
    i=4 取 4 字「监听哪些」,游标跳到 8
    ⇒「端口」从未单独成窗

⇒ 符号路对「14010端口」全打 0 分。

## 改法

QuerySymbols 的中文部分改用项目里**已有**的 jieba
(gojieba v1.4.7,memory.GetJieba() 单例,internal/nlp 在用)。

★ 保留滑窗作降级:jieba 依赖词库目录,GetJieba() 拿不到时返回 nil。
  若只靠 jieba,符号会**整个消失** ⇒ 符号路彻底失效。
  所以:jieba 成功用它,失败纯滑窗(退化成今天的行为,不会更差)。

## ★ 上一轮撤销的改动,这次以正确形式回来

2026-10-04 早些时候我试过两处修改并**撤销**了:

① 中划窗改全子窗 → 符号从 2 个变 22 个
② 停用词表移出「服务/端口/地址/用户/系统」
   (数据上它们命中只占 0.8%,并不泛)

② 那次端到端跑出回归:casual 从 3/4 掉到 2/4,于是撤销。
⇒ ★ 当时把根因归给「停用词表放开」,实际是**①造成的噪音**。

现在用 jieba 后,两件事分开处理:

    jieba 切出的词 → 只过**虚词表**(什么/哪些/嗯…)
    滑窗产出的候选 → 过**停用词表**(跨词伪词)

★ 「端口」是实词,不该滤;「什么」是虚词,该滤。
  混用一套标准会出问题 —— 这正是上一轮踩的坑。

## ★★ 一句旧断言逼出了更准的区分

fusion_test.go 原断言「端口」「什么」都是泛词、都不该进符号集。
那个断言**部分正确**:

    「什么」是虚词  ⇒ 该滤(不变)
    「端口」是实词  ⇒ 不该滤

「端口」此前该滤,是因为滑窗会把它切成伪词;
jieba 下它是独立实词,滤掉它等于**把查询目标词从符号集里删掉**。

★ 「泛词」是模糊概念,「虚词 vs 实词」是可判定的。
  判据的价值就在于逼出这个区分。

## ★ 「修了 A 弄坏 B」的典型

TestFuse_无符号退化为向量序 用「嗯」构造「查询提不出符号」的场景,
而 jieba 会把「嗯」切出来 ⇒ 那条判据绿着,却测不到退化路径了。
已把语气词补进虚词表。

★ 单看每处都对,合起来错。

## BFS 现在才接上生产

命中修好后,联想才真正补得上**词面不重叠**的部分:

    「14010端口」这类块命中 ✓
    13010 的块文本是「http://127.0.0.1:13010」,不含「端口」⇒ 词法够不到
    但图上连着:Pi编码助手 --A2A端口--> 13010

★ 两句话合起来才是完整结论:
  ① 联想救不了错的命中
  ② 命中修好后,联想能补上词法召回的固有边界

## ★ 探针评分从 7/9 变 6/9,但这是评分口径窄于能力

同一条查询的三次对照(语义相关性确实在提升):

    [casual] 脚本路径
      jieba 前: 0 文件空目录 | 工具已注册但后端报未知命令
      jieba 后: 0 文件空目录 | submissions/JianFeeeee目录为空未推送
               | loaded=false type=unknown 目录内零文件      ← 相关性明显更高

    [coexist] 并存端口不互斥
      jieba 前: CPU总线… | sdk/introduce 站部署 …              ← 噪音
      jieba 后: mc_status 待命 + 端口无监听 | 本机 443 按 SNI 透传 … ← 真·端口内容

⇒ 但 want 只认「必须含那个具体字符串」,而那三个值
  (/home/newqqagent、101.201.37.155、13010)与查询零词面重叠。

★ **评分口径窄于实际能力提升**。不改 want 的理由是保持与历史基线可比;
  正确改法是新增 anyWant 一档,让「召回了语义相关内容」也能计分 —— 已记为待办。

## 判据

新增五条(fusion_bfs_test.go / fusion_test.go):
    目标词必须独立成词(三条查询 × want/notWant)
    数字与英文精确串不受分词影响
    符号路能匹配到含目标词的块(端到端一环)

## 验证

    go build / go vet ✓
    internal/memory/... 与 core  全绿

* refactor(memory): 停旧表双写 —— 旧表冻结为历史,并暴露六个静默失效

## 做了什么

commit() 不再写 entities / relations / sentences。
旧表停止增长,冻结为历史(生产 1294/980/66)。

读方此前已全部切块(scene / Purge / PurgeNoise / RecallSorted /
Introspect / MergeBlocks / social / sdk / core),所以这一步是可逆的:
改回代码即可,旧表数据完好。

★ 判断依据是**行为**不是语句:判据 Test停双写_旧表不再增长
  逐表比对 Commit 前后的行数,而不是 grep 有没有 INSERT。

## ★★ 停双写暴露了六个静默失效

全部是「报成功、实际没做」或「悄悄做错事」,没有一处报错。

### ① memory_commit 每次都告诉模型「全部被拒」

    ec == 0 && rc == 0 ⇒ 每次提交都被判为「什么都没写进去」

而写入其实成功了。模型据此去改不该改的东西(把正常实体名改短、
加字母数字),**把记忆内容改坏**。

★★ 「报假失败」比「报假成功」危险:前者会诱发破坏性动作。
   判据改为**新增块数**(MemoryBlockCount 前后相减)。

### ② 索引器永远不建立索引

    syncIfStale 用旧表 entities 行数当基线
    ⇒ 恒为 0 ⇒ **首次也判成「无需同步」**

判据 TestSyncIfStaleBaseline 的「首次应建立索引」当场抓住。

### ③ 驻留子 agent 的记忆回收失效

    ExportTriples 读 relations JOIN entities ⇒ 返回空
    ⇒ ReclaimResident 合入 0 条

★ 它只被 resident.go 用,而 resident 是一条完整产品线 ——
  「零调用点的读方」不等于「不重要的读方」。

### ④ ClearSentenceID 一直在清理错误的行

调用方(distill.go)传的 rel.ID 来自 RecallSorted,那是**关系边 ID**;
而函数 UPDATE 的是旧表 relations ⇒ 改的是另一行。
而 UPDATE 影响 0 行也是**成功**。

现已改为删除「关系边 --contains--> 原句块」结构边。

### ⑤ Archive 对块体系完全无效

只 UPDATE 旧表 ⇒ 关系边永不归档 ⇒「按时间衰减记忆」静默失效。
它当时零调用点,所以这个缺陷从未被发现。

### ⑥ L2 文档归档永不删文档

    archiveColdDocs 的判据是 ec == 0 && rc == 0 ⇒ 恒真

★★ 讽刺的是,它那段防护的注释写着
  「实测 456 字图片描述得到 0 计数,随后文档被删、内容消失」。
  **同一个原因,方向反了**:从「误删」变成「永不删」。

判据改为块总数是否增长。

### ⑦(附带)PurgeOrphans 删错对象

orphanEntitiesLocked 返回的是块(blockKey),
而删除却用 e.ID 去 DELETE FROM entities ⇒ 删错对象,
且删不到任何行时静默报 0。

★ orphanEntitiesLocked 本身也还在扫旧表 —— 一并改扫块。

### ⑧(附带)Introspect 的 hotspots 冻结

读旧表 entities 并按 mention_count 排序 ⇒ 永远是迁移时的快照。
改数块,按**关系边度数**排序(块没有 mention_count,
而「被多少条边指向」正是 hotspots 想回答的问题)。

### ⑨ FindRelations 精确查找失效

第十个读方,按名 JOIN 旧表 ⇒ 返回空。改按块文本精确匹配。

## ★ 边去重:同会话收敛,跨会话并存

    旧 relations 靠 UNIQUE(source,target,type,session_id) 保证
    f1fce88 为支持「同类边并存」把边表 UNIQUE 全部移除
    ⇒ 重复提交同一三元组会产生重复的边

实测:同会话提交两次 → 2 条边。而记忆写入是**高频重试**的
(LLM 反复提交同一事实),重复边会让召回刷屏、场景权重虚高。

去重口径必须含 session_id:同会话内重复 = 同一条事实(收敛);
跨会话重复 = 多次陈述(并存)。

## ★★ 一条更隐蔽的:旧表的**读取**也必须停

    SELECT id FROM entities WHERE name = ?

旧表删掉后它返回 no rows ⇒ **Commit 直接失败** ⇒ 记忆完全写不进去。
这是「停双写」时最容易漏的一环:写要停,读也要停。

## 判据修正(约 15 处)

三类:

① **断言旧表计数**(ec/rc)→ 改用块侧信号
   Commit 的返回签名是 SDK 契约(9 处调用方),改签名代价大,
   所以保留位置并置 0。★ 代价要说清楚:这两个返回值现在没意义了。

② **用 Commit 造旧表数据** → 改用显式的 Seed* 测试接口
   ★★ 这类改动最危险:迁���测试的输入天然为空,
     「迁出 0 块 0 边」竟然通过 ——
     那不是「迁移正确」,是「测不到」。
     它让「迁移还对不对」这个问题无法回答。

③ **造状态靠删旧表** → 改删块侧
   如「造孤立实体」原来删旧表 relations,而块侧的边一直在。

## 已知

    TestResidualDropNotifiesSyncCaller  既有 flake(单独跑 4 次全过)
    TestResidualKeepReturnsTasksToParent 既有 flake

## 验证

    go build / go vet   ✓
    回归 43/43 全绿

* docs(memory): 停旧表双写的结果与暴露的六个静默失效

commit() 不再写 entities/relations/sentences,旧表冻结为历史
(生产 1294/980/66)。可逆:改回代码即可。

★★ 暴露了六个静默失效(全部无报错):

① memory_commit 每次都告诉模型「全部被拒,请检查实体名」
   —— 而写入其实成功了。模型据此改实体名把内容改坏。
   ★ 「报假失败」比「报假成功」危险:前者诱发破坏性动作。

② syncIfStale 用旧表行数当基线 ⇒ 恒 0 ⇒ 索引器永远不建立索引

③ ExportTriples 读旧表 ⇒ 驻留子 agent 的回收合入 0 条
   ★ 它只被 resident.go 用,但 resident 是完整产品线 ——
     「零调用点的读方」不等于「不重要的读方」

④ ClearSentenceID 传的是边 ID、改的是旧表另一行,
   而 UPDATE 影响 0 行也是「成功」

⑤ Archive 只 UPDATE 旧表 ⇒ 关系边永不归档,
   「按时间衰减记忆」静默失效

⑥ archiveColdDocs 判据 ec==0 && rc==0 恒真 ⇒ 文档永不删除
   ★ 讽刺:它自己的注释写着「456 字描述得到 0 计数,
     随后文档被删、内容消失」。同一个原因方向反了:
     从「误删」变成「永不删」。

另修:PurgeOrphans 删错对象、Introspect hotspots 冻结、
FindRelations 精确查找失效。

★ 一个更隐蔽的:旧表的**读取**也必须停 ——
  SELECT id FROM entities WHERE name=? 在旧表删除后返回 no rows
  ⇒ Commit 直接失败 ⇒ 记忆完全写不进去。
  写要停,读也要停。

★ 边去重:f1fce88 移除 UNIQUE 后重复提交产生重复边
  (同会话两次 → 2 条),而记忆写入是高频重试的。
  现按 (source,target,edge_type,session_id) 收敛,
  跨会话仍并存。

★★ 判据修正里最危险的一类:用 Commit 造旧表数据。
  停写后迁移测试输入天然为空,「迁出 0 块 0 边」竟然通过 ——
  那不是「迁移正确」,是「测不到」。
  已加 Seed* 测试接口,迁移测试现在真的测到了迁移。

* fix(memory): DeleteEntity 改删块,不再谎报删除成功

旧实现在停双写后是纯空转 —— 生产库副本实测:
  memory_blocks      2764 → 2764 (Δ0)
  memory_block_edges 2379 → 2379 (Δ0)
  legacy entities    1294 → 1293 (只删了旧表 1 行)
而工具回「已彻底删除实体…及其所有关联关系」。

危害不是删不干净而是谎报:模型据此认为内容已消失,
而 40+ 条关联边还在、召回继续命中,会导致模型重新写入或不再提及。

口径(用户明定「删除块」):
- 按块文本**精确**匹配定位(不用 LIKE —— 库里真实存在
  "admin" 与 "admin 8861、billing 8499、oauth 8271" 这种子串相撞)
- 删块 + 删全部关联边(含 contains 结构边,否则留悬空边)
- 摘场景引用(沿用 Purge 的收尾纪律)
- 旧表同步删,但旧表不再是权威源
- 返回 DeleteResult{Blocks,Edges},工具层如实报告数量

判据 internal/memory/delete_entity_blocks_test.go:
- 活图谱 Δ0 会被抓住(变异自证:改回旧逻辑即 4 条断言失败)
- 精确匹配不误伤同名块
- 找不到时报错而非静默成功
- 不留悬空边、邻居不被误伤

变异自证通过:注入「不碰活图谱」后判据报
  应删掉 2 条关联边,实际 0 / 仍残留 2 个块

* fix(memory): hasSameSentenceSibling 的 source_kind 修正为 block

SQL 写的是 source_kind='sentence',而 contains 边的两个端点都是
block(端点类型在 Commit 块化时从「sentences 表行号」改成「原句块」)。
生产库实测:1298 条 contains 边,source_id 全部是 blk_src_* 原句块,
source_id NOT LIKE 'blk_src_%' 为 0 行 ⇒ 查询恒空 ⇒ 守卫从未生效。

端点位置是对的(原句块恒在 source 侧,句子 id 来自 sameSentenceOf
的 source_id),只有 source_kind 错。

实际影响(修复前实测 Superseded=1[blk_c],修复后 Kept=3 Superseded=0):
「同句并列字段不得外溢取代」的守卫失效 ⇒ blk_b(停用旧号=4379)
以自己身份把原句A 的 4379 判成被取代。而那正是
arbitration.go:465 注释里写明要保留的信息
(「历史上真实用过的号」)。

判定守卫的物理依据:生产库 1298 条 contains 边全部
blk_src_* → 字段块,无一例外。

判据 internal/memory/arbitration_sibling_guard_test.go:
- 同句兄弟能被找到(变异 source_kind='sentence' 即失败)
- sameSentenceOf 与 hasSameSentenceSibling 口径一致
  (这条能抓住「半通不通」:一处认归属、另一处说没兄弟)
- 独苗句子不误判为有兄弟

同时修正 arbitration_test.go 的断言:它原本写「三条都该保留」却把
理由写成「blk_b 没进候选」—— 那个理由是错的(blk_b 一直在 hits 里),
真正的机制是守卫恒 false。结论对、理由错,现已改为正确表述。

变异自证:注入 source_kind='sentence' 后两条判据失败。

* fix(memory): memory_commit 与 Introspect 不再谎报写入量

隔离实例受控实验(2026-10-05)证实:一次对话实际写入 7 块 9 边,
而工具输出是
  memory_commit     → 已写入 0 个实体和 0 条关系
  memory_commit     → 提交了 1 条三元组但全部被拒(未写入)
  memory_introspect → map[entity_count:0 memory_hotspots:[map[count:15 …]]

★ 后果不是数字难看,是模型据此放弃:
  实测模型原话 "the write is being rejected, so let me check whether
  the store itself is working" —— 它开始怀疑存储坏了,而一切正常。

根因一处:报告口径取自**已冻结的旧表**
- graph.go  Introspect 的 entity_count 数 entities(旧表)⇒ 恒 0,
  而同一份输出里 relation_count 与 hotspots 已数块 ⇒ 半个旧表半个块表,
  自相矛盾
- toolcall.go memory_commit 判据用 newBlocks(对)但文案用 ec/rc(错),
  同一份代码两套口径打架。ec/rc 在 graph.go 里 relationsCreated=0 是写死的

修法:只信实测差值
- entity_count 改数块(排除原句块,与 relation_count 口径对齐)
- 新增 GraphDB.MemoryEdgeCount()(不含 contains 结构边)
- memory_commit 文案用 newBlocks / newEdges;ec/rc 显式丢弃并注释原因

判据:
- internal/memory/introspect_count_truth_test.go
  entity_count 非 0、与 relation_count 同口径、边数不含 contains
- internal/agent/core/memorycommit_count_test.go
  写入成功时文案含真实块数、不含「0 个」、不说「被拒」
  0 写入时仍明说没写进去(防「修谎报成功 ⇒ 谎报失败」)

变异自证:
  注入回 ec/rc 后判据报
    工具回 "已写入 0 个实体和 0 条关系" / 块数 0 → 2(Δ2)
  与生产现象逐字一致。

* fix(memory): DeleteEntity 的旧表清理按外键列删,不用跨表撞 id

原写法(4de4dff 引入,我自己的 bug):
  DELETE FROM relations WHERE id IN (SELECT id FROM entities WHERE name = ?)
  ★ 子查询返回 entities.id,外层匹配 relations.id —— 同名不同表。

生产库副本实测:
  实体 201 被 relation 101 引用(201≠101)
  ⇒ relation 101 根本没被删
  ⇒ DELETE FROM entities 撞外键 FOREIGN KEY constraint failed
  ⇒ 整个删除事务回滚

⇒ DeleteEntity 在任何「旧表里存在且被关系引用」的实体上直接失败。
  而 4de4dff 的判据抓不到:它只用 Commit/PutMemoryBlocks 建块,
  旧表是空的 ⇒ 那条路径不可达 —— 这是「测不到」而非「没问题」。

正确写法:按外键列删
  DELETE FROM relations WHERE source_id IN (SELECT id FROM entities WHERE name = ?)
                          OR target_id IN (SELECT id FROM entities WHERE name = ?)

新增 prod_copy_regression_test.go:拿生产库副本跑真实删除,
覆盖合成判据构造不出的形态(旧表有行且被引用)。

* fix(csrc): ha_codec_estimate_tokens 的返回显式收窄,修 csrc-lint 红灯

CI 的「C infrastructure gates」在 PR #1 上失败,根因是 042a4e0 引入的
类型收窄(不是 CI 的 bug,也不是既有遗留):

  -    int t = (int)(runes * 2);
  +    size_t t = (text_len < by_runes) ? text_len : by_runes;
       ...
       return t;        ← size_t 隐式收窄到 int

那次改动的意图是对的(min(text_len, runes*2) 对齐 Go 侧
estimateTokensPure),饱和保护也是对的(t > 0x7FFFFFFF 时提前返回)。
但 csrc-lint 带 -Werror=conversion,抓的是**类型收窄本身**,
不管运行时是否安全 —— 于是 042a4e0 之后这个门禁一直红。

修法:return (int)t。上游已有饱和检查,转换不丢信息;
显式写出来等于向读者与编译器同时声明「这里安全且是有意的」。

本地验证:
  make csrc-lint     cc: 0 告警 ✓ / clang: 0 告警 ✓
  make check-csrc    ABI / 头文件自包含 / ASan+UBSan / arm64 交叉编译 全通过
  go test -run 'Codec|EstimateTokens|Golden' ./internal/agent/api/  ✓(跨语言一致性)

* chore: benchmark.md 脱敏后归位到 scripts/capability-bench/PLAN.md

根目录只留 README.md / README_EN.md 两份门面文档,其余归位。

移动而非删除的理由:内容是有效的跑分执行依据(前置事实检查、
串行要求、历史踩坑),只是位置不对。

脱敏(公开仓库口径,原文件写死了本机路径):
  /home/program/TrueAgent  → ${REPO}
  /home/newqqagent          → ${HA_DATA}
  /home/program             → ${REPO}
  /var/tmp/zerobasis         → ${BENCH_MATERIAL}
  /var/tmp/pi-iso/agent      → ${PI_ISO}
  /var/tmp/ha-a|ha-b|ha-c    → ${INSTANCE_A|B|C}
  /var/tmp/cmp2、/var/tmp/mem → ${OUT_DIR}
  /root/.pi/agent/models.json → ${PI_MODELS}

落地校验:正则扫描新文件,本机路径残留 0 处。

文件名用 PLAN.md 而不是 README.md —— 同目录已有 README.md
(「能力标定台」,154 行),两者仅 8 行重叠、是不同文档,
合并会丢失边界。

* chore: 公开仓库清理 —— author 归并 + 根目录归位 + 本机路径脱敏

## ① 幽灵贡献者:改写 1 个提交的 author
53d9af4 的邮箱带 3 个坏字节(`â\x802198972886@qq.com`,真实应为 2198972886@qq.com),
.mailmap 无法表达(文本文件写不出坏字节)⇒ 只能改写历史。
  git filter-repo --mailmap(改写前后 tree 5aaf6321… 一致,内容零变化;912 提交不变)
结果:提交者从 5 种降到 4 种,`jianf <坏字节>` 幽灵消失。

.mailmap 重写并记录三个踩坑(都实测过):
  ① 右端必须「历史姓名 + 历史邮箱」成对写;只写邮箱时 git 要求提交
     name 也为空 ⇒ 静默失效且不报错(实证:只写邮箱那版 jianf 仍在列表里)
  ② 注释里不能出现形如映射的示例行 —— git 只认行首 #,缩进注释里的
     「姓名 邮箱」仍被解析成真实条目,且同名目标后者覆盖前者
  ③ 坏字节邮箱无法用文本表达,只能改写历史

## ② 根目录归位(原来 6 份 md,只有 2 份该在门面位置)
  benchmark.md → scripts/capability-bench/PLAN.md(脱敏)
     命名用 PLAN 而非 README:同目录已有 README.md(能力标定台),两者
     仅 8 行重叠、内容不同,合并会丢边界
  GUI-PLAN.md  → cmd/gui/GUI-PLAN.md(含活踩坑记录,保留)
  DESKTOP-PET-NOTES.md / SERVER-HANDOFF.md 删除
     前者是外部产品(Coopanion)拆解笔记,后者是给服务端的待办清单,
     两者代码引用均为 0,且都未被 README 文档索引引用 ⇒ 移动不断链

## ③ 移出本机运维脚本(含本机路径与凭据搬运逻辑)
  deploy/scripts/deploy.sh、scripts/verify_deploy.sh
  scripts/kernel-stress/、scripts/gui-package-smoke.sh
  deploy/systemd/embed-sidecar.service(零引用的孤儿文件)
全部加进 .gitignore;本地文件保留。

## ④ 全仓本机路径脱敏(37 文件,145+/91-)
  代码/判据:/home/<user> → /data/homeagent 等占位;模型路径 → 环境变量
    prod_copy_regression_test.go 的库路径改为 HA_PROD_DB 环境变量
    (原先写死生产库路径,公开仓库等于泄露目录结构,且别处永远跑不了)
  脚本:REPO_ROOT 自动推导、HA_DATA/HA_CONFIG_DB 走环境变量
  文档:${REPO} / ${HA_DATA} / ${PI_HOME}
保留 providers/qwen3vl/testdata/qwen_tokenizer_reference.json 的模型路径
  —— 那是 Qwen 官方基准数据,改了基准就变了。

## ⑤ 顺带修 CI 红灯(csrc-lint 的 -Werror=conversion)
  551be4b 把 int t 改成 size_t t 后 return t 造成隐式收窄,
  门禁抓的是类型收窄本身(不管运行时是否安全)。显式写 (int)t。

验证:go build ✓ / go vet ✓ / go test -short ./internal/... ./pkg/... 全绿
     本机路径终检零残留(testdata 基准数据按设计保留)

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: pi <pi@local>
2026-10-05 11:25:20 +08:00
55d5f2a1d0 feat(kbtree): 知识库分类树的独立只读服务 + agent 技能 + WebUI 树浏览
让**外部 agent** 也能按分类树用这套知识库。HomeAgent 自己的 agent 仍
直接调内部方法(knowledge_search/create 等),走进程内直调,不经此服务。

一、内核树视图(internal/knowledge/tree.go)
  为什么不复用 TreeIndex:那个是**内部导出物**,面向 .index.json 落盘,
  每个条目带 top-20 的 TF-IDF 特征向量。直接序列化给外部有三个问题:
  体积(200 条时 .index.json 已 246KB 且冗余存了 preview,而正本在
  content.md)、泄漏(稀疏特征表 = 分词/IDF 内部表示)、语义错位
  (外部要的是"有哪些分类、每类下有什么")。
  新增 TreeView/Subtree/Categories/CategoryCounts:不含向量,带条目数
  与可读摘要,支持 MaxDepth 懒加载、IncludeItems 只看结构。
  节点 Name 是**本级段名**("go")、Path 是完整路径("tech/go")——
  最初把全路径写进 Name,前端拼层级会得到 "tech/tech/go",已修。

二、kbtree 插件:独立 HTTP 服务(默认 127.0.0.1:9892)
  为何不挂在 WebUI 的 /api/v1/knowledge* 下:
  1. 不共享鉴权与端口。WebUI 的 api_key 是给人操作界面用的,把它分发给
     外部 agent 等于把管理面凭据扩散出去。本服务用**独立 token** +
     独立端口,可单独关闭(token 未配置则启动时随机生成)。
  2. 只读。写入要决定分类归属与媒体处理,外部自行拼装容易造出越界/重名
     条目 —— 写入留给内核工具。
  3. 形状按树组织,而不是平铺搜索接口。
  端点:/tree(可指定 category/depth/items)、/categories、/counts、
  /search、/ (自述)。全部需 token(X-API-Key / Bearer / ?token=),
  非 GET 一律 405。无知识库时 Start 直接失败,不占端口。
  鉴权与 Slowloris/超时设置照 remotedevice 范式。

三、agent 技能(assets/skills/knowledge-base/SKILL.md)
  指令文档型 skill:教模型"先看树 → 定位分类 → 分类内检索",并列出
  易错点(name 已含分类别再拼、只看第一条、404 附现有分类)。
  加载与校验由 internal/plugin/skill_bundled_test.go 守住 —— 这条断言
  的由来:非白名单的二级标题会被 extractToolDefs 当成工具定义,报错
  "invalid tool name",而提示与真正原因(标题层级)毫无关联。
  kbtree 的测试还会校验文档提到的端点与代码一致,防漂移。

四、WebUI 树浏览(前端真正用起来,而非留一个没人调的端点)
  面板加可折叠的分类树:逐级点选即把搜索范围切到该子树(原先是让人
  手打分类名)。当前范围有可见标签与「全库」复位。
2026-09-26 14:20:19 +08:00
d10ab3fbf0 feat(kb-migrate): 存量知识库目录名迁移工具 + 启动期只报告
背景:旧版 Add 整串 sanitize 名字、逐段 sanitize 建目录,留下 tech/_go_/note、
Tech/Upper、a/b with space 这类「知识名与盘上目录不一致」的目录。修复后
normalizeName 要求二者逐字一致,故需一次性改名。

- 迁移逻辑放在 internal/knowledge/migrate_names.go(PlanMigration/
  ApplyMigration),CLI 与 homed 启动**共用同一份实现**,避免口径漂移。
- 三条硬约束:
  1. 默认只报告(-apply 才真改名)—— 批量 os.Rename 不可逆。
  2. 检出目标名冲突(两条迁到同一目标 / 目标已存在)则**整批拒绝**,
     不做部分迁移:半迁移状态比不迁移更难收拾。
  3. 逐条失败不中断,最后统一报告;执行前重查冲突(计划生成与执行之间
     可能有人改过盘上状态),并校验目标不越出知识根。
- homed 启动在 initKnowledgeStore **之前**调 initKnowledgeMigration:改名后
  扫盘一次到位,避免先以旧名建索引再改名造成内存键与盘上目录短暂不一致。
  defaultApply=false ⇒ 启动只扫描+报告+打印可执行命令行,不替人决定。
  单次改名上限 200 条,防失控目录规模拖住启动。

实测:报告模式零改动;冲突场景整批拒绝且盘上原封不动;迁移后 Store
正确载入 4 条并可按规范名逐条删除。
2026-09-26 14:20:19 +08:00
e5fa67955f 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
155bb8b198 fix(webui): 设置接口不再吐内部数据;--webui 覆盖生效;端口占用不再静默成功
三处实测确认的缺陷:

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

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

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

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

新增测试:TestSettingsNoCrossPluginLeak(跨插件泄漏/幻影键/chathistory 读写)、
TestListenOverrideAndBindFailure(覆盖生效 + 端口占用必须报错)、
TestResolveListenAddrPrecedence(优先级)。
2026-09-14 06:54:16 +08:00
f1813a4486 refactor(homed): main() 696 行按启动阶段拆成 25 个阶段函数
main() 原本是一整条 696 行的启动脚本:日志、目录、记忆、配置、Lua、守护、
追踪、内核 API、文本记忆、LLM 源、文档/知识、人格、插件、Agent、ONNX、
IPC、心跳、关停全挤在一个函数里,变量跨 500 行互相引用。

现在 main() 只剩「顺序编排 + 就地交接」(**149 行**,低于 funlen 阈值 150):
  opt           := parseFlags()
  logDir        := setupLogging(opt.dataDir)
  agentWorkDir  := ensureDataDirs(opt.dataDir)
  mem, closeMem := initMemoryStack(opt.dataDir)
  ...
共 25 个阶段调用,实现体在同包 bootstrap.go(一一对应)。

零漂移保证:
  * 阶段体逐字取自原 main,只做机械替换(`*dataDir`→参数、`memIdx`→`mem.indexer`);
  * 原 main 的每个 defer 都换成一个在**同一位置**注册的 cleanup,
    LIFO 释放顺序不变;多资源阶段内部再按原注册顺序取反;
  * 语句级比对:原 main 的 525 条可执行语句全部有对应,无遗漏。
    仅 3 处为**有意**的结构改写(其余为同义替换):
      1. initLuaVM / initTextMemory:「启动成功才 defer Stop」改为
         「失败返回 no-op cleanup,成功返回 Stop」——调用点语义不变;
      2. startIPCServer:同上(用 started 标志保证失败时不 Stop);
      3. resolveBaseAPIKey:把三级兜底 API key 解析提成一个纯函数。
  * defaultPrompt 提为包级 const defaultSystemPrompt(不含版本号字面量)。

验证(A/B 实测,不是只跑编译):
  * go build ./... / go vet ./cmd/homed/ / go test ./cmd/... ./internal/agent/... ./internal/plugin/... 全绿
  * 重构前后二进制各起一次(-data 临时目录,SIGTERM 收尾),日志集合**完全一致**:
    63 个注册工具、同名插件全部 loaded、kernel ready、插件逆序关停、'stopped'
    —— 差异仅为并发加载插件的打印顺序。

全仓非测试 Go 函数现状:≥300 行 **0 个**,≥200 行 9 个,≥150 行 16 个。
2026-09-14 06:30:45 +08:00
7143c4e7d2 fix(resident/plugin): 二进制级压测暴露的三个真问题(DataDir 漏接线 / inputch 未登记 / create 不开工)+ 通道双向登记贯穿全部内建插件
在真实内核二进制(私有 netns + mock LLM + CLI unix socket)上做压力测试时,
下面三个问题**只有跑真二进制才暴露** —— 单元测试里都显式传了参数、没走插件加载,
所以全绿也照样漏。

## ① 根 agent 的 DataDir 没接线 ⇒ 驻留子永远建不出来

现象:模型调用 `resident_agents` 成功,但结果是
`创建驻留子需要 data_dir 或显式 temp_path`。
根因:`cmd/homed/main.go` 构造 AgentConfig 时没有 `DataDir`,
而驻留子的 temp 图库需要 `<data>/residents/<id>/graph.db` 这个锚点。
(单测里 `AgentConfig{DataDir: dir}` 显式给了,所以测不出来。)

修:main.go 接线 `DataDir: cfg.Daemon.DataDir`;并在工具层加**兜底 + 告警** ——
data_dir 为空时从主图库路径反推(`<data>/memory/graph.db` ⇒ `<data>`),
失败才报错。静默失败会让线上表现成"工具能调但永远建不出来"。

## ② 插件通道没登记为 inputch ⇒ "划入 inputch"必然失败

现象:`划入 inputch cli: inputch 未注册`。
根因:`cli` 插件只调 `RegisterOutputChannel("cli", ...)`,却用同一个名字
`InjectTextSync("cli", ...)` 注入输入 —— 内核 inputch 登记表里根本没有它。
(实测审计:内建 6 个插件里只有 0 个登记过入站通道;SDK 示例里只有 qq/weather 是对的。)

修两处:
- **全部内建插件显式登记入站通道**:`cli`/`agentcli`/`timer`/`webui`(+`http`, NoMemory)/
  `clawhubadapter`(每个 OC 通道声明处)/`remotedevice`(`device/<id>` 懒登记,幂等)。
- `registry.go` 把隐式兜底改成**留痕的兼容网**:只有当该名字还没登记为 inputch 时
  才兜底登记,并打日志说明"建议显式 RegisterInputChannel"。
  实测:改完内建插件后,启动日志里兜底告警 **0 次**。

## ③ create 之后子不开工 ⇒ rounds 恒为 0

现象:`[agent] r1 started, waiting for IO interrupts` 之后什么都没有,登记表里 rounds=0。
根因:`TaskPrompt` 只进了子的**系统提示词**,从没作为输入投给子。
修:create 即开工 —— 把任务提示词作为**第一条排队输入**投给子(排队而非中断:
创建是"安排工作",不是"打断它正在做的事")。

## 测试

- `TestResident_InputchTableAutoAndProactive` / `TestLightKernel_TraditionalContextNoTrimming`
  随行为更新:create 会多跑一轮(任务提示词那轮也会写处理表),
  断言改为"以创建时的表长为基线 + 等待新的一轮"。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(agent/plugin/plugins)干净。

## 真实二进制压力测试结果(修复后)

私有 netns 里跑 mock LLM + 内核,用 CLI socket 驱动多并发连接:
- 密集:16 连接×12 输入 + 4 线程×20 次 L4 中断 → **274 任务 executed=274 / rejected=0 / errors=0**,
  峰值排队 15、峰值待处理中断 76;
- 稀疏(中断每 3s 一次,压在排队任务的流式段上)→ **suspended=27 / resumed=27 / preempted=27**;
- 驻留子全链路:父建子(inputchs=["cli"])→ 子开工 → 子 `notify_parent` → 父侧收到
  `interrupt from r1/child/r1`(L3,且父被抢占 suspended/resumed=1);
- 优雅退出:SIGTERM 后驻留子 temp 目录被清除、无残留进程。
2026-09-13 11:38:03 +08:00
2fab464e80 fix(scheduler)!: 撤掉“优先级=可配置策略表”的错误设计,回归内核内部属性
用户指正:**优先级是内核内部属性**,不是配置项,更不该由插件声明。
我此前把它建模成“策略表 + 字符串解析”,甚至准备接配置中心
(core.agent.priority.<channel>)——方向性错误,故整体撤销。

撤销:
- 删除 ParseLevel(字符串解析只服务于“外部可配”这个错误前提)
- 删除 AgentConfig.PriorityLookup / Agent.priorityLookup 及 taskLevel 中的查表分支;
  taskLevel 回归为纯内核内部规则(cli/webui/http→L3,system/_consolidation_→L1,
  其余 L1),注释明确“不对外暴露、不做运维可调项”
- 设计稿 §3.2 改写为“内核内部属性,不做成配置项”,并删除 §15 里
  “ChannelDef.Priority / InjectOptions.Priority 进公开 SDK”这一方向(同属外化)
- 未触碰配置中心(registry.go/main.go 的优先级配置一行未加)

同时落地 D6(与本撤销无关、此前遗漏的承诺):
- AgentConfig.MaxToolTurns + runTaskSteps 在发起新一轮 LLM 前按 f.Turn 收尾;
  0 = 不限;cmd/homed/main.go 从既有 core.agent.max_tool_turns 取值
- 新增 task_test.go 2 项:上限 3 时恰好跑 3 批工具/3 次 LLM 并收尾;
  0 = 不限(跑完脚本)

验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 06:17:49 +08:00
f84852e14b feat(healthcheck): 内核状态快照报出内核版本号与 ONNX 模型启用状态
healthcheck_kernel 此前没有任何「ONNX 模型是否在用」的信息,只报「向量可用/不可用」,
分不清「统一多模态空间已加载」与「退回到词嵌入/TF-IDF 路径」;人格卡要求
「版本以运行时快照为准」,也缺一个可查字段(build.version 早就在,但没人知道)。

- KernelStatus 新增 onnx 段:enabled / provider / dim / fingerprint / modalities / reason。
  判据取 Loaded()(provider 真正打开且元数据合法),**不是**「配置里写了 provider」
  —— 后者在模型缺失 / 运行时缺失时也为真,报出去就是假绿。
- 未启用时 reason 给**具体原因**:未配置(说明会走回退路径)/ 打开失败的具体错误。
  homed 把「配置的 provider 名」与「打开失败原因」透传给 Agent,仅供状态报告。
- ProviderAdapter 新增 Modalities()(可选能力,按接口断言取用,不改公开契约)。
- healthcheck_kernel 的工具描述同步说明它回答这两件事。

**顺带修一个真实 panic**:collectKernelStatus 的 knowledge 是**接口**参数,
(*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic,
而 healthcheck_kernel 正是走这条路径(panic 发生在工具 goroutine 里)。
GetKernelStatus 改为先按具体指针判空、再赋给接口;并加刻画测试钉住这个成因
(一旦不再 panic 说明参数形状已变,守卫与该测试应同步删除)。

验证:单测 4 例(已启用 / 打开失败 / 未配置 / 未加载)+ 刻画测试;
隔离实例 E2E 7/7:正例 provider=chineseclip → enabled=true、dim=512、模态 2;
反例 provider=nonexistent → enabled=false 且 reason 含具体错误与 provider 名,
真实对话仍通。
2026-09-12 14:56:25 +08:00
be0eb3eb54 feat(persona): 首启人格门禁跨通道化 + 内核 persona_set 工具
WebUI 首启向导只覆盖 WebUI 这一条通道,而「人格该问一次」是所有通道的事:
走 QQ / CLI / ACP / 邮件来的人永远见不到那个向导,人格就永远是没确认过。

- 门禁移到 buildSystemPrompt(每轮重建 → WebUI/QQ/CLI/ACP/邮件全覆盖),
  以 core.internal.persona_initialized 为准:未确认时要求模型主动询问用户
  (默认 / 自定义 / 以后再说),确认后该段消失;personaStore 为 nil 时静默关闭。
- 新增内核内置工具 persona_set(mode=default|custom|later[, content]),
  落库逻辑与 WebUI 向导**共用 internal/config**(一个实现 + 两个薄入口:
  ConfigRegistry 直连 / 插件侧 SettingsAPI),避免两套语义各自漂移。
- AgentConfig 增加 PersonaStore 接口,cmd/homed 用 RegistryPersonaStore 实现。
- 非法输入(未知 mode / custom 空内容)在打标记**之前**拒绝:否则标记置位、
  向导被跳过,用户再没机会设。

E2E(隔离实例 + 真实 LLM 往返走 /v1/chat/completions,/var/tmp/persona/e2e.sh)9/9 PASS:
未确认时模型主动询问 → 用户答「用默认的」→ 模型调用 persona_set 落库并置位标记
→ 之后不再追问;反向对照(清标记 + 清会话上下文 + 重启)重新开始询问,
排除了「同一段对话里已问过」这一混淆。
2026-09-12 13:57:06 +08:00
988693d6d6 feat(persona): 人格设定配置项化 + 默认模板契约测试 + 腐坏告警
起因(v1.2.0 压测):线上实例内核日志/接口都报 1.2.0,agent 被问版本时却按人格卡
自述 v0.9.0 + C ABI v2(该机制 v1.0.0 已删除)。根因是人格只有「文件」一个来源且无人
维护——写死的版本号必然随发版腐坏。

改动:
1. 新增配置项 core.agent.personal_prompt(多行文本),默认值为内置模板
   config.DefaultPersonaPrompt,随其它默认值同批播种(老安装不注入,语义不变)
2. 默认模板**不含任何版本号字面量**,并显式要求「被问到版本/构建信息时以运行时快照
   (healthcheck_kernel)为准」——从根上消掉这类腐坏
3. 人格来源优先级:personal/personal.md(存在且非空)> 配置项 > 无
   启动日志明确打印来源;文件含腐坏内容(版本号字面量 / 已删除机制的说法)时告警并
   建议迁移到配置项
4. internal/agent.PersonaStaleHints:腐坏检测(版本号正则 + 已删除机制词表)

契约测试(防复发):
- TestDefaultPersonaPromptHasNoVersionLiterals:默认模板不得含 v?\d+\.\d+\.\d+,
  且必须含「运行时快照」要求
- TestPersonaPromptRegisteredWithDefault:注册存在、默认值一致、播种真的写入
- TestPersonaStaleHints:线上人格卡原文必须被识别(v0.9.0 / C ABI v2),干净文本不误报

验证:go build ./cmd/homed ok;go vet 三个包 ok;go test ./internal/config ./internal/agent ok;
端到端两场景(无文件→来源=配置项 1307 字节;有旧文件→来源=文件 + 告警列出 v0.9.0 与 C ABI v2)。
2026-09-12 12:42:43 +08:00
16c0dbb3e0 fix(memory): 修 beta.2 压测发现的三个向量/文档缺陷
来源:v1.2.0-beta.2 全方位压测(报告 /var/tmp/stress/REPORT.md)

1. 文档向量迁移结果不落盘(生产已复现)
   - BuildDenseIndex 改完内存不置 dirty;docStore.Stop() 全仓无调用者 → flush 成死代码
   - 后果:每次启动重算同一批文档(线上 496 篇约 17s),磁盘 dense_fp 永不收敛
   - 修:迁移当场落盘(抽出 flushLocked 以免重入锁)+ main.go 关停链 defer docStore.Stop()
   - 生产证据:线上 488 篇文档仅 4 篇含 dense_fp,且这 4 篇均为运行期 Insert 的新文档
2. 块指纹对但维度错时污染文档向量(健壮性缺口)
   - denseFor 只校验 b.Fingerprint,不校验长度;FuseVectors 取最大维度并跳过长度不符者
     → 512 维文本 + 2048 维块 = 2048 维且打上当前指纹
     → 该文档在检索侧被长度守卫永久跳过,且每次启动重算(不收敛)
   - 修:denseFor 要求 len(b.Vector) == 空间维度
   - 可达性:内核两个块产出点均成对取自同一行(it.Vec ↔ it.VecModel),故属防御性修复
3. Insert 与 loadAll 的 ID 约定不对称(低)
   - Insert 落盘任意 <id>.json,loadAll 只加载 doc_ 前缀 → 自定义 ID 文档重启后静默消失
   - 修:loadAll 只要求 .json(空 ID 仍跳过)

回归测试 4 条:迁移跨重启落盘 + 已对齐 0 重算(用向量空间调用计数判定,
不靠日志)、坏块不参与融合且同维度正常块仍参与、自定义 ID 可加载、Stop 落盘。

反向验证(纪律要求):临时回退本次修复后,前 3 条均变红且报错正是缺陷签名
(dim=999/space-OLD-999 永不收敛、文档向量 2048 维、文件重启后消失);恢复后全绿。

验证:go build ./cmd/homed ok;go vet ./internal/memory/document ./cmd/homed ok;
go test ./internal/memory/... 7 包全绿。
2026-09-12 11:05:11 +08:00
84988d9337 feat(memory): 新增 chineseclip provider —— text+image 的小体积可商用向量空间
## 为什么

用户决定「本轮不覆盖 video,先支持 text+image」。这一刀正好解锁了此前
「小 + 可商用 + 覆盖视频」三者不可兼得的僵局:不要求视频后,唯一同时满足
**小、可商用、中文原生** 的选项是 Chinese-CLIP ViT-B/16。

实测对比(同机、真实跑出来的数字):

| | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B |
|---|---|---|---|
| 参数量 | 188M | 1.04B | 2B |
| 产物 / 常驻内存 | 754MB / **1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB |
| 维度 | 512 | 768 | 2048 |
| 许可 | **Apache-2.0** | CC BY-NC(不可商用) | Apache-2.0 |
| 视频 | 无 | 有 | 有 |

本机可用内存只有 5.3GB,Qwen 的 9.4GB 无法进程内使用;而 ORT format + mmap
那条路被证实当前不通(转换器对三段图段错误;走通还需同时升 ORT 运行时与
Go 绑定,v1.36 要求 API 29 而本机只有 28)。1.15GB 则可以直接进程内跑。

**代价已写进包注释与文档**:CLIP 是双塔对比学习,text↔image 是强项,但纯文本
语义明显弱于 MLLM 型嵌入器;文本检索仍由既有词向量/TF-IDF 路径兜底。
需要更强文本语义或视频时切回 qwen3vl。

## 内容

- `providers/chineseclip/`:按公共 SPI 实现的 provider(注册名 `chineseclip`),
  含 BERT WordPiece 分词器、图像预处理、ONNX 双塔推理、无标签 stub。
- `scripts/export_chineseclip_onnx.py`:从官方权重导出规范产物 + 冻结参考,
  自带逐用例 PyTorch 对比与覆盖度断言(计划集合≠执行集合即非零退出)。
- `cmd/homed/main.go`:空白导入两个 provider,由配置选其一。
- `go.mod`:`golang.org/x/text` 由间接依赖转为直接依赖(删音标需要 NFD)。

## 实现要点

- **分词器逐 token 对齐官方**。第一版探针自己拼 BertTokenizer(只给 vocab.txt、
  没删音标、中文没逐字切),中文被整体切成 [UNK],三个不同句子产出几乎相同的
  向量(余弦 0.98)——差点把「模型坏了」当成结论。官方配置是 do_lower_case=true
  + 删音标生效 + 中文逐字切分;`TestTokenizerMatchesOfficialReference` 钉住
  逐 token 一致。
- **图像缩放自写 bicubic**(复刻 PIL 的 precompute_coeffs + a=-0.5 核),不引
  golang.org/x/image:它未进本机模块缓存,且最新版要求把整个工具链升到 Go 1.26,
  为一个缩放函数动工具链不划算。
- **归一化在 provider 侧**(两个塔的图里都没归一化),检索按余弦。
- **指纹覆盖全部影响语义的产物**:两个 ONNX 图 + vocab.txt + embed_config.json,
  读不到就写 MISSING(跳过等于对缺件不敏感)。
- 会话 Run 用 runMu 串行化(ORT 会话不保证并发安全),创建/销毁用 mu。

## 模态范围

只声明 `text` 与 `image`;`audio`/`video` 明确返回 `ErrUnsupportedModality`,
绝不用别的模型向量冒充(这是「音频明确 unsupported」纪律的落地)。

## 验证(实测)

导出侧:10 个用例(5 文本 + 5 图像)ONNX vs 官方 PyTorch 全部
`cos = 1.000000000`,覆盖度断言 10/10 通过。

Go 侧(`CHINESECLIP_MODEL_DIR=... go test -tags onnxruntime ./providers/chineseclip/ -v`):
11/11 通过,其中
- 文本 5 用例 `cos = 1.000000000000`(逐位一致)
- 图像 4 纯色用例 `cos = 1.000000`(与官方预处理在 6 位小数内一致)
- 跨模态判别:红图对「红色」文本高于「蓝色」文本
- 模态拒绝 / 空输入 / 指纹稳定 / 产物缺失报错

顺带修掉测试自身的一个假通过:参考向量是**未归一化**的原始输出(模长 10~36),
原先「点积当余弦 + 单侧下界」会让 13.6 也判过,已改为真余弦 + 双侧容差。

构建矩阵:`go build/vet ./...` 与 `-tags onnxruntime` 两种都过;
`providers/... pkg/... internal/config/... internal/memory/vector/...` 回归通过
(qwen3vl 的 TestVideoModelInputMRope 需要 QWEN_ONNX_MODEL_DIR 指向含视频档的
v3 目录,缺该环境变量时用的是只有文本+图像的目录,与本改动无关)。

## 未做(明确记录)

- 发行版默认 provider 与构建标签变更:留下一提交(涉及打包与模型分发策略)。
- 模型产物(754MB)不进仓库,由导出脚本生成。
2026-09-11 23:58:53 +08:00
c04e80f1fe feat(core): 注入行为的记忆/裁剪标志位落地 + jieba 词库内嵌 + Windows 改走 WSL
配套 SDK 提交:homeagent-sdk ba49dfd(公开 API 纯追加,无签名变更)。
本仓第三方的库镜像同步至该版本,以保证全新 clone 能编译。

## 1. 注入标志位(内核侧)

- 7 条注入路径(排队/中断/同步 × 纯文本/带媒体 + 旧 NoMem 变体)解析并转发
  no_memory / context_policy / cleaner_name;策略在入口**校验**,
  非法值报错而不是静默降级成 none(降级会让调用方以为自己声明的裁剪在生效)。
- 新增 validateContextPolicy(与 tool.register 同一套规则)与 pubSdkInjectOpts。
- input.register 不再手写字段白名单重建 ChannelDef,改为整体传递 + 补 ContextPolicy。
- io 层:applyInjectOpts 把标志位写进事件 payload,仅非零时写
  (零值与旧 payload 逐字节一致,事件订阅方与旧内核都不受影响)。
- ioAdapter / procCore / internal-sdk 别名补齐六个 *Opts 实现。

## 2. 修掉「输入无条件裁剪」这个真缺陷

eventloop 此前对**每条非中断输入**都调 `context.Prune(...)`:破坏性(低相关事件被
归档移出上下文)且无法从调用点看出是谁触发的。改为 pruneOnInput/pruneDeclared:

  优先级:注入点声明(payload.context_policy)> 通道声明(ChannelDef.ContextPolicy)
          > 默认**不裁剪**

查询向量仍取清洗后的内容;新增 cleanInputFor 解析清洗文本,优先级为
注入点声明的 cleaner(cleaner_name)> 按 source 查到的通道 cleaner > 原文,
名字查不到时**记日志再回退**(注入是 fire-and-forget,插件看不到错误,
至少要在内核日志留下「你声明的清洗没生效」的痕迹)。

## 3. jieba 词库内嵌(修「猜 GOMODCACHE → 静默失效」)

原 jiebaDictDir() 去猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块
缓存 → GetJieba() 返回 nil → 分词/关键词提取/NLP 依存解析(进而 doc→graph 三元组
抽取)/静态词向量 tokenizer **一律静默返回空列表**,只有一行日志。本机看起来正常
只因开发机与生产机重合、恰好有那份缓存。

现在词库随二进制分发:internal/memory/jiebadict/ 5 文件约 11.6MB + go:embed,
按**内容哈希**命名缓存目录落盘(词库升级不复用旧文件),已齐全则跳过写入。
模块缓存降为兜底。homed 体积 32MB。

顺带确认(并有测试佐证):gojieba 的 Tag() 不需要 pos_dict/ 目录——
cppjieba 的 PosTagger 从主词典每行的词性列取 tag。

## 4. homed 放弃 Windows 原生,改走 WSL2

插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」,Windows 既无 fd
继承语义,其句柄模型也无法表达后者;强行适配等于再维护一套平台专属 ABI
(C ABI 时代三套 ABI 并存曾导致改写型插件在某平台静默失效)。

- cmd/homed/platform_{windows,other}.go:原生 Windows 启动即拒绝并打印 WSL2 指引。
- internal/plugin/proc/shmalloc_windows.go:allocShm 直接返回「请用 WSL2」,
  **不返回半可用的段**(与 shmalloc_other.go 同风格:未支持平台显式报错);
  procEnvForShm 返回 nil。顺手修掉两处长期编译错误
  (cryptorand→rand、h.evData→h.unified.evtData),使 GOOS=windows 至少能编译。
  注:homed 本就编不出 Windows——internal/memory 依赖 cgo-only 的 gojieba。
- deploy/packaging/installer.nsi:不再安装 homed.exe/initconfig.exe,改为携带
  **linux payload** 并调用新的 install-via-wsl.ps1;退出码 20/21 表示
  「需先装 WSL/发行版」,走指引而非报错。
- deploy/packaging/windows/install-via-wsl.ps1(新):检测 WSL → 引导安装 →
  确保 WSL2 → 送包进发行版 → 在 WSL 内按 Linux 方式安装。**复用 Linux 包与
  linux/setup.sh**,不另写一套安装逻辑;落点与 deb 布局统一
  (/usr/bin/homed + /usr/lib/homeagent/setup.sh)。
- deploy/packaging/linux/setup.sh:API Key 允许 HOMEAGENT_API_KEY 覆盖
  (否则安装器界面显示一份、config.db 里另一份 → 登录不上)。
- deploy/packaging/build.sh:windows 目标只构建 waiter + gui,并新增
  stage_linux_payload 把 Linux 包暂存给安装器;homed/initconfig 在 windows
  目标下明确拒绝。

## 5. 插件调用点统一写明意图

- webui 的 OpenAI 兼容端点(固定提示词模板)→ InjectTextSyncNoMemory。
- agentcli 的 5 处纯状态通知(已启动/超时/执行结束/进程退出/读取结束)→ NoMemory;
  **带输出**的 2 处(定时反馈、有新输出)刻意保留记忆并注明理由。
- timer 的定时提醒 → NoMemory(中断本来也隐含 NoMemory,这里是写明意图)。

## 6. 版本

meta.Version 仍为 1.2.0(main 是下一个未发布中版本);
SDKCompatibleVersion 1.1.0 → **1.2.0**(本内核已实现 SDK 1.2.0 全部新增方法)。

## 测试

- core:默认不裁剪(无声明/none/空)、通道 opt-in、注入点双向覆盖通道、
  nil context/io 安全、cleaner 优先级与未知名回退。
- io:零值 opts 与历史 payload 逐键相同;text/中断/媒体三类注入标志位都落到
  payload;旧方法仍生效。
- proc:validateContextPolicy 只接受 ""/none/prune,报错含位置与实际值;
  **跨进程** e2e——testdata 插件经 io.injectText 送出三个标志位,断言它们穿过 RPC
  到达内核。
- memory:模块缓存不可见时内嵌词库仍可用(分词与 POS 内容词均非空)、
  落盘幂等、内容哈希稳定。

验证:go build ./... / go vet ./... / go vet -tags onnxruntime ./...
      go test -short ./internal/memory/... ./internal/nlp/... ./internal/plugin/...
      ./internal/agent/{core,io}/... ./pkg/...
2026-09-11 20:31:50 +08:00
3cf67fcae1 refactor(memory): 核心不再适配具体模型——公共 embedding provider SPI + 注册表
问题:cmd/homed 里 `case "onnx": qwen.New(modelDir)` 把模型适配写进了核心,
`type=onnx` 名义上是格式、实际写死了一个模型家族;2117 行 Qwen 专属代码
(BPE、chat template、M-RoPE、Vision_gN 命名)住在内核树里,还带着一对
`//go:build onnxruntime` 的 stub。加任何新模型都要改内核。

现在核心只认一个模型无关的公共契约(pkg/embedding):
- 输入是不透明的 Data+MIME,解码/预处理/时序分组全归 provider
- 能力是数据(Info.Modalities),不是接口方法——新增模态无需改核心接口
- 不支持的模态返回 embedding.ErrUnsupportedModality(可 errors.Is 识别)
- 按名字注册,重复注册 panic;Options 是 provider 私有命名空间,核心不解释

改动:
- 新增 pkg/embedding:Modality/Purpose/Input/Info/Provider/Config + 注册表
  (Open 校验 Info,ValidateVector 在入库前拦下维度错与非有限值)
- providers/qwen3vl:Qwen 实现整体移出内核(git mv),实现公共 SPI 并自注册
- internal/memory/vector:新增 ProviderAdapter(公共 SPI → 内部小接口);
  ErrModalityUnsupported 改为公共哨兵别名;删除 VideoEmbedder 可选接口
  (那正是「核心为每个新模态长方法」的坏味道)
- http embedder 也变成普通 provider(注册名 http)
- cmd/homed:删除 qwen import 与 onnx/http 分支,改为按 provider 名打开 +
  透传 options.*;provider 打开失败只警告并禁用多模态检索,不影响启动
- config:multimodal_space.type/onnx./http.* → provider + options.*
- 删除 internal/memory/qwen(整体搬迁)

测试:
- pkg/embedding:注册表隔离/未知名字/非法 Info 自动关闭/ValidateVector
- vector:适配器原样透传字节与 MIME、维度错被拦、Close 幂等且停止使用、
  两个哨兵 errors.Is 互通
- providers/qwen3vl:新增公共 SPI 全链路集成测试(Open→Info→Embed→
  未知模态哨兵),并明确断言 Info 不声明 video

已知未完成(不得当作已验证):
- 视频冻结回归 TestEmbedderVideoMatchesONNXReference **显式跳过**:Go 侧
  video 模板缺少 processor 按时间组插入的字面时间戳文本
  (<0.0 seconds>/<1.0 seconds>),同一输入 Python seq=1190(1152+38)、
  Go 只有 22 个文本 token。时间戳也占 M-RoPE 位置,故现有 M-RoPE 自洽断言
  通过不能证明与官方实现一致。修复属 provider 内部工作。
- 视觉侧三档已导出并逐档校验通过(cos 1.000000119/1.000000119/1.000000000)

验证:go build ./... ;go vet -tags onnxruntime ./... ;
go test -short ./internal/memory/... ./internal/agent/core/... ./internal/sdk/... ./pkg/...
;onnxruntime 下 providers/qwen3vl 全绿(视频为显式 skip)
2026-09-11 18:26:19 +08:00
2bcd3e94ee refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
2026-09-11 11:45:24 +08:00
704bda9141 refactor(memory): 移除 media_refs/引用计数,媒体成为一等记忆块
媒体此前是"文本块 + digest 引用 + owner 账本 + 独立 GC":ContextEvent.Media
记 digest,media_refs 表用 owner_kind/owner_id 保活,ref_count 决定 GC 能否清。
这与文本记忆块的管理方式不一致,也是本次一并纠正的核心偏差。

改为与文本块完全一致的生命周期:

1. 一等记忆块直接由所在层持有
   - ContextEvent.Blocks / Doc.Blocks / GraphDB memory_blocks
   - 块带 modality/digest/MIME/size/vector/fingerprint,文本、图片、视频同构
   - Context→Document→Graph 迁移的是块本身(ID 不变),迁移后清空源容器,
     同一块不同时存在于两层

2. 删除平行生命周期账本
   - media.Store 去掉 media_refs 表、OwnerKind 常量、RefCount 字段、
     AddRef/DropRef/DropOwner/Refs、ref_count 列与索引
   - 删除 mediaGCLoop、GC(keep,minAge)、容量上限与 media.gc_* / media.max_mb 配置
   - 媒体内容在块被永久删除时一并删除(media.Store.Delete + forgetPayloads),
     与"删除文本块即删除内容"同一语义

3. L3 原生结构
   - memory_blocks / memory_block_edges(contains/depicts/derived_from)
   - 边端点必须是真实图节点,不再用 owner 字符串伪装关系
   - BlocksForNode 支持 sentence --contains--> block 反查

4. SDK 与检索同步
   - 插件附件/标记直接变成块,不再 AddRef
   - 跨模态检索改用 QueryMediaScored(CAS 内不再有孤儿缓存需要过滤)

测试全部改写为块语义:删除 refcount/media_refs/GC 断言,新增块迁移、
单层不变量、Delete 语义与并发删除回归。

注:cmd/homed/main.go 同时携带工作区中既有的 CLIP→Qwen 模型目录接线改动。
2026-09-11 10:57:22 +08:00
f6dd70804a fix(agent): 工具循环占位不再驱动重复发送,输出回执/子任务结果幂等
生产现象:单轮内 output_send__qq 被调用 34 次、持续 514 秒,直到 QQ 插件
自己的循环保险拒绝发送才停下(problem.md)。根因是多环节叠加,核心侧修四处:

1. 工具轮补位文案(process.go)
   通用占位「请根据以上工具结果继续。」对纯输出通道调用是错的:异步通道
   (qq/wechat)的回复只能经 output_send__* 交付,所以模型「已完成回复」的
   表达形式就是一个工具调用,紧随其后的「请继续」会被读成「还要再做一步」,
   而能做的「一步」恰好还是再发一条消息。
   改为按上一批工具的性质选文案:全部是 output_send__* 时补
   「若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具。」
   同时每轮先移除旧占位再补一条,避免占位在 prompt 前缀里线性累积。
   (该占位是 zen 网关「最后一条必须是 user」的传输层附加物,HEAD 版本是
   无条件内联追加、从不移除。)

2. 输出成功回执(output.go)
   「已通过 [qq] 通道发送: map[status:sent]」这类富回执会被读成「这步成功,
   继续下一步」。成功改为只回极简标记。

3. proc 桥标量透传(internal/plugin/proc/plugin.go)
   插件返回 "ok" 时不再伪造 {status:sent} 覆盖插件真实返回值,否则只改
   output.go 不生效。

4. 子任务结果幂等(spawn.go)
   child_result 原先读到即删,而完成通知长期留在持久上下文里
   (formatMergedTimeline 每轮重新注入),第二次查询必然得到
   「不存在或已过期」这个永久失败信号,模型据此认为任务未完成而反复重试。
   改为保留结果 + delivered 标记,重复查询返回明确提示;结果按上限有界淘汰。

顺带:agent.go 去掉文档层显式向量器注入(TF-IDF 已内置为 fallback),
cmd/homed/main.go 同步 document.NewStore 的 tokenizer 参数。

测试:internal/agent/core/tooloop_test.go(5 例)、spawn_test.go(3 例)。
2026-09-10 20:36:39 +08:00
e02a672772 feat(vector): pluggable multimodal vector space
核心暴露 MultimodalEmbedder 接口,两条路径共享同一套 L0/L2/L3
向量缓存、media.Store 坐标、QueryMemoryMediaScored 检索:
  - onnx:内嵌 ONNX 模型(CLIP 等),通过 build tag 编译
  - http:外部向量 API 服务(Jina v5 / OpenAI / 自建)

跨模态融合权重改为 CrossModalFusionConfig 可配置结构体,
移除所有模型特定硬编码(CLIP/Jina),版本切换只需改配置。

模型切换自动迁移:
  - StaleVecDigestsAll 支持全模态(image+audio+video)
  - 启动时并发重算(ONNX 4 workers / API 8 workers)
  - 修复 SQL 运算符优先级导致 kind 过滤失效的 bug

实测对比(492 篇生产文档 + 3 张真实图片):
  - TF-IDF:MRR 0.457(精确匹配快,语义差)
  - fastText:MRR 0.530(语义中等,延迟 8ms)
  - Jina v5-omni:MRR 0.900(全面领先,延迟 40ms)
  - 中文文本→图片:Jina MRR 0.833 vs CLIP 0.611

See docs/embedding-comparison.md for full benchmark.
2026-09-09 17:38:34 +08:00
0c5dd5a1c6 feat(clip): 多模态向量器(CLIP ONNX)——文本/图像 512 维共享空间 + 媒体向量写入与重算
- internal/memory/clip:CLIP ONNX 向量器(onnxruntime 构建标签控制,默认构建不链接 ONNX)
  - clip.New(modelDir) 加载 text.onnx/vision.onnx(输出 text_embed/image_embed [batch,512])
  - 实现 vector.Vectorizer + vector.MultimodalEmbedder(Vectorize/EmbedImage + Dense 变体)
  - 词级 BPE tokenizer:merges 合并后词末片段带 </w> 查 vocab,与官方 encode 逐 id 对齐
  - EmbedImage:解码→resize 224→NCHW→normalize→vision session
  - Fingerprint(text+vision 文件 sha256)供模型切换检测
  - stub 版(无 onnxruntime 标签)保持默认构建行为不变
- vector/store.go:新增 MultimodalEmbedder 接口
- media.Store:新增 StaleVecDigests(currentModel)——查 vec_model 不匹配/缺失的图片
- agent core:AgentConfig.ClipEmbedder + Agent.clipEmb 接线;
  describePendingMedia 描述成功后 EmbedImageDense→SetVec;
  新增 reembedStaleMedia 启动补算历史无向量图片
- config:core.memory.media.clip_model_dir(未配置退化为现有 fastText/TF-IDF 行为)
- cmd/homed:读 clip_model_dir 加载 CLIP,失败仅记日志不阻塞启动

测试:TestSmokeLoadAndEncode(文本语义 cat>dog 0.914>physics 0.740)、
TestCrossModalAlignment(red-image vs red-text 0.063>blue -0.009,与 Python 一致)、
TestTokEnd(与官方 encode 逐 id 对齐)、TestStaleVecDigests,含 -race 全绿
2026-09-09 10:23:31 +08:00
3bfaa82a70 feat(sdk): 多模态贯通插件边界——公开接口、内核桥接与统一输入主干
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放:
用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert
交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。

## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更)

- `Triple` += `SentenceText`、`MediaDigests`
- `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment`
- `TextEvent` += `Attachments`
- `DocMemoryAPI` += `InsertWithMedia`
- `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia`
- `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有)

`MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节
去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索
可能命中几十份媒体,全塞回去会把跨进程消息撑爆。

媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体
要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断
注入,需要自己的签名,且媒体在**本轮**就送到模型。

## 二、内核桥接层:原先在静默裁字段

`internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil:

- 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit`
  而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences
  → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上;
- 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用,
  于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减
  (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。

规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接
层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方
写下的标记。

标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`,
之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。

## 三、跨进程链路:不接线就是全体外部插件编译失败

`go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。
公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。
六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、
`corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧
实现、以及三个测试替身。

## 四、统一输入主干:把模态从「函数选择」降级为「字段」

`processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史
产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` +
`mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看
`stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**,
不是输入的**种类**。

媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、
`_consolidation_` 路由、正确的 `EventRawInput`。

最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而
`webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。
**用户发的图从来没出现在 WebUI 聊天记录里。**

`media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`:
字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。

## 五、模型可调用的三个工具

`memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节;
连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加
`media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断,
标记通常就在尾部)。

标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误
静默切断引用绑定,而全链路无人报错。

## 六、WebUI 上传走真实媒体链路

图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。
此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回
文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type
时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。

## 测试

- `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**)
- `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体)
- `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测)

压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart`
无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine
注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定
只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类
阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。

测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表
是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名
时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。

存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。
17 个 example 插件源码零改动通过类型检查。
2026-09-06 09:51:31 +08:00
e513e872f7 feat(memory): 媒体 GC 与描述生成两条后台循环
补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。

## mediaGCLoop:让容量上限不再形同虚设

CAS 的 GC 只在被显式调用时执行,Put 路径不触发它。此前配置项
core.memory.media.max_mb 注册了却没有任何调用方——一次 see_video 抽 10 帧,
帧本身在工具结果被 Prune 后就没人引用了,若无人清理会一直堆在磁盘上。

现在按 gc_interval(默认 6h)周期调 GC(gc_min_age)。两个不变量:
  - 有引用的内容永不删除,即使超容量(宁可超限也不断引用)
  - gc_min_age(默认 1h)保护刚 Put 还没来得及 AddRef 的项——它们
    refcount 也是 0

## mediaDescribeLoop:描述才是能活过 GC 的那部分

blob 会被容量 GC 淘汰,而描述留在 media 表里,并经 mediaSummaryForEvent
写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。于是「那张紫蓝红三色
带图」在原始字节早已被清掉之后仍然可被检索到。

复用既有的视觉回退链(resolveModalFallback + chatModalFallbackBatch),
不新造一套模型调用。

三个刻意的决定:

  - **走后台而非入库时同步**:视觉模型一次调用生产实测 9.6s。放在对话
    路径上会让每张图都给回复加十几秒,而描述的价值是几个月后还能检索到,
    不是这一轮——这一轮模型本来就直接看着图。
  - **逐条而非批量**:批量拿回来是一整段文字,无法可靠切分回各自的
    digest(模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次
    往返也要保证「描述 ↔ digest」的对应关系确定。
  - **默认关闭**(describe_on_ingest=false):它消耗视觉模型配额。开启后
    每 30s 最多处理 4 条,不跟对话抢额度。

失败处理分三类:
  - 网络抖动/配额 → 不标记,下轮重试
  - 空回复 → 视作失败(上游剥离媒体时通常回空,与 modalfallback 同理)
  - 不可描述(kind=other、blob 已丢失)→ 标记 described_by=unsupported/
    content-missing,退出队列

## 顺带修掉 Pending 的一个真缺陷

测试写出来才发现:Pending 原先只看 `description = ''`,于是被标记为
described_by=unsupported 但 description 仍空的项**每轮都会被重新取出来
重试**,永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
改为同时要求 described_by 也为空。

这是「先写断言再看它是否成立」抓到的——原本我以为标记一下就够了。

## 测试

medialoop_test.go 7 例:两条循环在禁用时立即返回(nil store / 零间隔 /
describe 关闭三种形态,不留空转 goroutine)、GC 清孤儿保留有引用项、
minAge 保护新项、无可用源时不误标记、不可描述大类被标记后退出队列。
media_test.go 补 1 例专测 Pending 的排除逻辑。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。
2026-09-04 22:00:03 +08:00
22e15ea3c1 feat(memory): 媒体接入 L0/L2——digest 挂到对话事件,归档时引用随之转移
在 f408ccd 的 CAS 层之上把媒体真正接进记忆链路。此前 CAS 只是个孤立的
存储包,没有任何写入方。

## 媒体进入对话有两条路,两条都只把文字留给记忆

  1. 用户直接发图 → processMediaInput → mediaToBlocks
     ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
     base64 随 message 数组发给模型后就丢了。
  2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
     ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。

于是下一轮起,模型能看到的只剩一句路径或一句 alt。那个文件被删、被覆盖,
或者本来就是 /tmp 下的临时产物,连线索都断了。

现在两条路在同一处收口(captureBlockMedia):从 ContentBlock 的 data URL
取出字节存进 CAS,digest 挂到当轮 ContextEvent。

## 改动

internal/agent/core/mediaref.go(新)
  - captureBlockMedia:ContentBlock → CAS。只处理 data URL——http(s) URL
    拿不到字节就无法内容寻址,而「下载它再存」会把一次对话变成一次网络
    请求(超时、鉴权、SSRF 全来了),不在本层解决。
  - stage/drainMediaDigests:媒体在 process() 期间被捕获,而承载它的
    ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id,
    故先缓存。与既有 pendingMedia 同一手法,同受 a.mu 保护。
  - bindEventMedia:双向落地。evt.Media 让事件记得引了什么(随
    context.json 持久化),media_refs 让 CAS 知道谁在引用(GC 的判断依据)。
    只写一边的话,要么 GC 误删仍被引用的内容,要么孤儿永远清不掉。
  - mediaSummaryForEvent:把已有描述拼成一行写进 Input。这是方案 C 的
    落点——**描述文本才是持久语义记忆,blob 只是缓存**。blob 可能被容量
    GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,让「那张紫蓝红三色带图」
    几个月后仍可被检索。

ContextEvent 新增 ID 与 Media 两个字段,都是 omitempty:
  - ID 懒生成,只有真要挂媒体时才赋值。绝大多数对话没有媒体,全量生成
    会让每条事件都多一个字段进 context.json。
  - 存量 context.json 读回来两字段皆空,不影响任何既有行为(有测试)。

RelevanceContext.Prune 归档时转移引用(transferMediaRefs):
  **先挂到归档文档、再注销原事件引用**。顺序不能反——先销后挂会让引用
  计数瞬时归零,若此刻后台 GC 正在跑就会把仍被记忆引用的内容当孤儿清掉。
  为此把 Prune 内的局部类型 scored 提为包级 scoredEvent(局部类型无法
  出现在方法签名上)。

media 包新增 OwnerContext/OwnerDocument/OwnerGraphSentence 常量:
  owner_kind 进了主键,拼错一个字符就是一条永远对不上的孤立引用——
  AddRef 不报错,DropOwner 也永远匹配不到。

## 配置

core.memory.media.enabled(默认 true)、.dir、.max_mb(2048)、
.gc_interval(6h)、.gc_min_age(1h)。

关闭后全链路静默跳过,对话行为与本特性上线前完全一致(有测试)。
mediaStore 为 nil 时同理——它是记忆增强,不是对话必需品,开不起来
只记一条 warning 不阻止启动。

## 测试(11 例)

入库与 MIME 归类、http URL 跳过、nil store 全链路 no-op、音视频混合、
stage/drain 清空语义、懒生成 ID、描述作为持久记忆、**归档转移期间内容
始终可读且 refcount 不归零**、无媒体存储时归档照常、context.json
向后兼容往返。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。

## 尚未接入

L3 图库的 graph_sentence owner(常量已备好,无写入方)、
描述生成的后台任务(Pending() 已就绪,尚无消费者)、
媒体 GC 的定时触发(配置项已注册,尚未接 ticker)。
2026-09-04 20:53:32 +08:00
b90659f212 fix(multimodal): 修多模态假成功 + 落地视觉回退链 + see_video 帧数语义
## 起因

生产盲测:模型调 multimodal_see_picture 后声称看到了图,实际一个字
都没收到。工具却返回「[已将图片注入后续对话]」。

链路:core.llm.model=AUTO → llmsproxy 按优先级选 big-pickle(prio=100)
→ 转 opencode zen。llmsproxy 的 opencode.lua 明写着:

    -- zen 上游 schema 只接受 text content part(无视觉/音频能力)
    if part.type ~= nil and part.type ~= "text" then  -- 丢弃

判据:256x256 纯红 PNG,带图与不带图的 prompt_tokens 都是 256。
图片贡献零 token,即根本没进上游。

内核序列化与注入链本身是对的(Message.MarshalJSON 正确产出 content
数组,SetToolBlocks → IOManager → ConsumeToolBlocks → toolMsg.Blocks
全通)。缺的是「主模型能否消费这些块」这一判断——内核此前完全没有
多模态能力的概念(grep supportsVision|multimodal 在 agent/ 零命中)。

这与 v1.0.0 修的 output_send 假成功同类:告诉调用方成功而实际未送达。

## 1. 能力声明

新增 core.llm.sources.<name>.vision / .audio(走既有 sourceFieldDefs,
WebUI 配置页自动出现),types.LLMSource 与 api.BaseConfig 同步加字段。

新增 agentAPI.ModalProvider 接口 + ProviderSupportsVision/Audio 判定:
未实现该接口的 provider 一律按不支持处理。保守侧是刻意的——宁可多走
一次文字回退,也不能把图默默扔给会剥掉它的上游。

为何是声明而非探测:探测需额外真实调用且结果不稳定(取决于 AUTO 当次
路由到哪);而 200 响应 + 相同 token 数从响应侧无法区分「看到了但没
内容」和「被剥掉了」。

## 2. 回退链(modalfallback.go)

实现了 config/registry.go 里注册但从未被读取的 image/audio
fallback_provider + fallback_model(此前 0 处读取点)。

prepareToolBlocks 在 process.go 注入前判定:能直视就原样透传;不能就
调声明了该能力的源转写成文字,带 [由 X 转写,非当前模型直接感知] 标注。

几处刻意的设计:
- 逐模态判定,不一刀切。很多视觉模型能看图但听不到音频,全部降级会
  白白把可直视的图变成二手描述
- 混合场景下转写文字作为 text 块并入 native,两部分同时到达模型
- 配置指向未声明能力的源时拒绝并继续找——照用只会重演静默剥离
- 未配 fallback_provider 但某源声明了 vision 时自动扫出来用;静默失败
  比多找一个能用的源更糟
- 空回复算失败。上游剥掉媒体后模型往往回「我没看到图片」或空串,两种
  都说明回退链也没真看到
- 多媒体块按模态合包为一次请求(见下)

## 3. 批量合包(生产实测驱动的返工)

首版逐块调用,生产 see_video 6 帧实测:4 帧里 3 帧超时,整轮 363 秒。
改为按模态合包一次请求后同一用例 131 秒、6/6 成功。

顺带把 modalFallbackTimeout 从 90s 提到 180s:生产经网关转
claude-opus-5 看一张 400x400 图要 ~81s,90s 贴着上限。
多张时 detail 默认 low 控体积,单张用 high 看细节;插件显式给了
detail 则尊重它。

## 4. see_video 帧数语义

fps=1/N 是频率(每 N 秒一帧)不是数量。20s 视频实测:
frames=4 → 5 帧、frames=10 → 2 帧、frames=1 → 20 帧,要得越多拿得越少;
长视频下 frames=4 会产出 时长/4 帧,靠 i>=9 的 break 兜着才没炸上下文,
而那个 break 用的是 ReadDir 索引,跳过条目后与实际帧数错位。

改为 ffprobe 取时长 → fps=N/时长 + -frames:v N 硬封顶。
0.4s/3s/20s/120s × frames=1/2/4/7/10 全部精确。

极短视频的坑:fps=1 在 0.4s 素材上产出 0 帧(不足一秒抽不出),所以
时长探测失败时不能退化成 fps=1,改为不传 -vf 只靠 -frames:v。

## 验证

- modalfallback_test.go 14 例:直视透传 / 回退转写 / 无源如实报告 /
  未实现接口按不支持 / 混合模态拆分 / 空回复算失败 / 块数上限 /
  多图合一次调用 / detail 策略 / 拒绝未声明能力的源 / 未配置时自动扫源
- go test ./... 全绿,go vet 无警告
- 生产盲测(答案预先封存、生成时不读):随机三色带 → 模型答
  「紫、蓝、红」,与封存答案完全一致
- 负向验证:拿掉回退源后模型如实回答「没看到图片内容」并引用工具返回
  的配置提示,且主动纠正了上一轮的答案
- 生产 see_video 6 帧:单次转写,模型正确描述测试图卡的计数器递增与
  彩虹带滚动
2026-09-04 06:25:51 +08:00
afb45bb490 feat(sdk): SettingsAPI.DataDir() 插件专属数据目录 + ai_image 本地交付
【SDK DataDir API】
- SettingsAPI 新增 DataDir() string:返回插件专属数据目录
  <data>/plugin_data/<name>(内核保证存在),解决此前插件只能
  靠 GetCore("daemon.data_dir") 手工解析的缺陷
- settingsImpl 新增 dataDir 字段 + SetDataDir;Registry buildSDK
  注入(<data>/plugin_data/<name> 并 MkdirAll);main.go 接线
- cabi 新增 CORE_SETTINGS_DATA_DIR (id 51);plugindev dispatchSettings
  模板补 DataDir() 实现

【ai_image 交付本地路径】
- 生成后下载临时 S3 URL 到插件数据目录,返回本地文件路径(永久不
  过期),而非 1 小时过期的 S3 URL。带 UA 规避图床对无 UA 客户端拦截
  (此前 agent 裸 curl 验证被拒导致误报失败)
- 返回 local_paths 字段 + 提示用 output_send(type=image) 展示

端到端:ai_image_generate → plugin_data/ai_image/*.png 有效 PNG(1024²),
经 llmsproxy→siliconflow 生成。
2026-08-26 21:40:29 +08:00
d1273f33fd feat(agent): spawn_child 支持 max_turns + 并行策略引导
1. spawn_child 新增 max_turns 参数(1-30,默认 5)
   子 Agent 工具轮数此前硬编码 5,复杂任务跑不完即截断。现可按任务
   复杂度调整;返回消息带轮数上限提示。

2. 工具描述与 system prompt 增加并行策略引导
   明确'多个互不依赖的子任务应并行 spawn 多个子 Agent,不要串行
   逐个执行;长耗时任务交给子 Agent 避免阻塞对话'——针对生产实例
   观察到的 agent 倾向自己串行处理所有子任务的问题。

小宅自定义 prompt 同步补充并行策略段。
2026-08-26 13:38:27 +08:00
7bd8ac831d fix(cmd/files/webui): shell 语义修复 + 根沙箱误判 + 上传注入走 interrupt + UI 区分附件来源
1. cmd_run 改经 /bin/bash -c 执行完整 shell 语法
   旧实现 shellUnquote 拆词后直接 exec:'pwd; ls /' 变成执行名为
   'pwd;' 的程序(exit -1)、heredoc 被截断、管道/命令替换全部失效——
   agent 多次反馈命令解析奇怪即此。危险命令拦截(kill homed 等)保留。

2. files 沙箱根目录判断修复
   pathWithinSandbox 在 base='/' 时 prefix 变 '//',所有绝对路径误判
   逃逸(生产实锤:files.dir=/ 下 files_read/write/ls 全部报 outside
   sandbox)。根沙箱直接放行。

3. webui 文件上传注入改走 interrupt(system 角色)
   文件元信息不再混入用户消息气泡;用户附言作为正常消息先行注入,
   文件说明紧随其后以 no_memory interrupt 补充——对齐 terminal_watch/
   timer 工具提醒模式,聊天流保持干净。

4. 前端附件卡片按 role 区分来源
   user=右侧+『你发送的』标签+accent 底色;assistant=左侧+『小宅发送的』。
   📌 emoji 按钮换为 SVG 图标,前端 emoji 清零。
2026-08-26 10:24:47 +08:00
d973bf734a feat(skillmgr): 原生技能管理器插件 + OpenClaw 兼容层职责分离
新增 internal/plugins/skillmgr(native skill 全生命周期 owner):
- skill_list/info/load/unload/enable/disable/create/export/install
- skill_create 两步式:先生成骨架模板,LLM 补全后传 content 覆盖写入
  (plugin.ValidateSKILLContent 校验)并自动加载生效
- .skm 分发包(tar.gz):packSkill/unpackSkill 含 TarSlip 防护
  (拒绝绝对路径/../逃逸、强制单根目录、校验包内 SKILL.md)
- skills 目录扫描:纯 SKILL.md/skill.json 条目归本插件;
  sidecar(main.js/main.py)/OC plugin(openclaw.plugin.json) 留给兼容层

clawhubadapter 职责分离(OpenClaw 兼容层不再持有 native skill):
- 删除 p.skills 字段与 default 分支 LoadSKILL 逻辑
- 发现纯 SKILL 条目改为发布 events.EventSkillDetected 移交事件,
  由 skillmgr 订阅注册;启动时序 c<s 下全扫兜底,事件用于热新增
- claw_list/plugin_info 不再输出 SKILL 段,统一走 skill_list

方案B prompt 注入:
- agentCore 新增 SkillIndexProvider 接口 + SetSkillIndexProvider
- buildSystemPrompt 注入【可用技能】轻量索引(名称+版本+描述),
  LLM 匹配场景时主动 skill_info 拉全文按文档执行
- main.go 在插件加载后将 skillmgr 实例接线到 agent

内核小修:
- extractDescription 跳过 YAML frontmatter 块(此前所有带 frontmatter
  的 SKILL.md 描述都被误判为 '---')
- extractField 剥离 YAML 成对引号(version: "1.0" 不再带尾引号)
- plugin.ValidateSKILLContent 导出供生成侧校验
2026-08-25 20:25:57 +08:00
5e3695cab3 agent: 更智能的 LLM provider 调度(byModel 精确路由 + AUTO 优先级链)
吸收 llmsproxy 的调度思想适配 HomeAgent“一源一模型”结构:
- RoutableProvider{Model,Priority} 次级接口(不破坏既有 Provider 实现)
- ProviderManager.OrderedProviders 改为按 (优先级 desc, 可用, 默认优先) 稳定排序,
  AUTO/空模型走该优先级链
- 新增 ProviderManager.ResolveForModel:精确模型名路由到归属源,找不到回落 AUTO 链
- LuaAdaptedProvider 不再无条件覆写 req.Model;显式模型名原样转发
- LLMSource.Priority + core.llm.sources.<name>.priority 配置项
- process.go: 显式模型走 ResolveForModel,AUTO 走 OrderedProviders
- 新增路由单测(优先级排序 + byModel 解析)

验证: go test ./... 27 包 0 失败;Windows 交叉编译通过;部署后服务健康
2026-08-10 11:54:45 +08:00
1b98f2d3b8 lua: 吸收 llmsproxy 适配器高级特性(worker 池/静态预提取/动态签名钩子)
- 适配器 worker 池化:单 LState+全局锁(串行瓶颈)→ 每 adapter 一个 gopher-lua
  LState 池,按使用该 adapter 的源并发上限求和配置池大小,并发 transform 互不阻塞
- staticInfo 预提取:name/version/endpoint/headers 加载期编译缓存,Endpoint/Headers
  读缓存不占 worker;加载即预编译首个 worker
- build_headers 动态钩子 + hmac/sha256/base64/tohex 全局:签名型上游(kimicode 等)可接入
- provider applyAdapterHeaders 接入动态头(url/method/body/api_key/timestamp/source 元数据),
  未定义时回落静态 headers,缺省补 Authorization
- LLMSource.MaxConcurrent + core.llm.sources.<name>.max_concurrent,注册时汇总
  VM.ConfigureConcurrency
- 新增 Lua VM 测试(load/transform/build_headers/并发)

验证: go test ./... 27 包 0 失败;Windows 交叉编译通过;部署后 9 adapter 全部预加载
2026-08-10 11:29:57 +08:00
13fd3c1801 llm: 统一源接入层修复(对照 llmsproxy)
- provider: 新增 OpenAI-compatible 响应/流兜底解析,Lua adapter 异常时也能解析
  choices/message/tool_calls/usage(含 function.arguments 缺失、对象/字符串参数)
- 过滤无效 LLM 源(<nil>/空/缺 http(s) scheme),main 与 ReloadFromConfig 均跳过,
  避免 mocktest 等坏源污染 fallback 与 healthcheck
- adapter(openai/deepseek/groq/mistral/github/kimicode): 修 tool_calls 对
  nil function 的崩溃,兼容扁平/嵌套结构;openai 流透传 reasoning/tool_calls
- config: ToConfig 探活端点过滤无效 base_url,修复 supervisor 误报 LLM unreachable
2026-08-09 20:39:54 +08:00
840296379c 三层回退恢复机制(L0写前留档/L1恢复梯子/L2离线回滚)+ guard 父守护
- L0: files 插件写受保护系统路径(/etc 等)前自动留档,AbstractBeforeWrite 到 data/file_baseline
- L1: failback 受限 worker 执行恢复梯子 probe→还原DNS/proxy→还原LLM配置+ReloadFromConfig→probe,N轮有界
- L2: tracker changeset 持久化原文 blob,guard 离线 RollbackFromDisk 回滚 agentfs;SystemSnapshot 支撑
- guard 父守护: 心跳 IPC(PING/ACK unix socket, 文件心跳回退)、失败计数、退出码协议(42/43/44)、最后手段
- 发行版路径适配: system.protected_paths/network_paths 可注入,默认面向主流 Linux
- Windows 兼容: guard.go/failback.go 加 //go:build linux, guard_windows.go 提供 no-op 桩
- 修复: guard.yaml last_resort 键冲突、changeset Content 不落盘导致离线回滚丢原文

Build 全绿, vet 干净, system/recovery/ipc/tracker 单元测试全过
2026-08-05 16:00:08 +08:00
954c9dafcb 蒸馏嵌入接线:Distiller/Agent 注入共享 embedder,修复配置缺失时蒸馏零产出 2026-08-05 09:59:33 +08:00
6827decb9a refactor: remove core skill direct loading, skills owned by clawhubadapter only
- Drop skill.NewManager from homed bootstrap; skills dir no longer core-managed
- Remove GetInjectedPrompt system-prompt injection (skills are not first-class)
- Delete internal/skill package, SkillAPI, webui /api/v1/skills, status skills block
- ConfigRegistry: plugin config tables now created only via RegisterDef; arbitrary
  scope Set/Get no longer implicitly creates config_<name> tables (fixes stray
  config_today_task table from SKILL directory name being used as a scope)
2026-08-02 11:40:13 +08:00
0d74efd0ea refactor: migrate built-in plugins to SDK-only interface
- Six-phase plan complete: webui/cli/healthcheck/pluginmgr/clawhubadapter
  now interact with the kernel exclusively via internal/sdk interfaces;
  all Configure() calls and package-level global injection removed
- buildSDK in internal/plugin/registry.go is the single assembly point
- Add internal/sdk/events.go exporting event types/constants
- Fix ProviderManager cooldown sharing: LuaAdaptedProvider.Name() now
  returns the source name instead of lua_<adapter>, so multiple sources
  sharing an adapter (single script load via shared VM AdapterCache) no
  longer share failure-cooldown state
- Verified: build/vet/tests green, deployed to homeagent.service with
  full plugin capability testing via local OpenAI-compatible mock
2026-08-01 12:17:17 +08:00
cb0f11c33d v0.7.3: 重构 Provider 层 + 计算层隔离 + Cleaner/NoMemory 架构
- 删除 OpenAIProvider/OllamaProvider 死代码,LuaAdaptedProvider 独存
- DisableThinking 从 ExtraBody 移到 CompletionRequest 顶层字段
- ContextWindow 从 Provider 签名移到 BaseConfig/ModelContextWindow() 统管
- 确认 CleanText 仅做基本空白 trim,QQ 模板剥离归插件 Cleaner
- Cleaner/NoMemory 仅作用于向量计算和 jieba 分词层,原文不变
- context.ContextEvent/Doc.Content 始终保存原文
- 删除 nlp/download.go 死代码
- media.go: context.Background() -> a.ctx 级联
- clawhubadapter: HTTP 超时
- cut.go: 跨平台 mod cache 路径 (GOMODCACHE->GOPATH->HomeDir)
- bridge_e2e_test: 移除未用 runtime import
- lua 适配器: disable_thinking 传参
2026-07-28 11:42:29 +08:00
7f08f3ca09 v0.7.2: 根目录清理 + Agent 心跳重构 + 内嵌 ONNX 模型
- 根目录清理: branding/docs/knowledge -> assets/, package/tools/deploy -> deploy/
- meta.go: Version 0.7.2, SDKCompatibleVersion 语义改为最高兼容
- Makefile: 版本回退 0.7.2
- registry.go: 系统提示词改用 meta.Version 格式化
- Agent 心跳: reorgGraph 拆分为三个独立循环(archive/merge/review),各自可配间隔
- GraphDB: 新增 sentences 表 + 关系句子溯源 + ClearSentenceID + CleanupOrphanedSentences
- Knowledge: 支持词嵌入向量化器
- NLP 四阶段流水线: Parse -> Extract -> Verify -> Fuse + SentenceRef
- 移除远程 HTTP 解析器(remote_parser.go)
- 新增内嵌 ONNX 模型(vocab + dep_parser.onnx):
  +build onnxruntime: 全量 ONNX Runtime 推理
  !build onnxruntime: 内嵌词表规则式降级解析器
- config: core.agent.onnx_model_path 替代 dep_parser_url
2026-07-28 09:56:26 +08:00
7df8be0128 feat: NoMemory/Cleaner memory system + doc update
- _sdk_local/ removed (moved to standalone sdk repo)
- internal/agent/core: NoMemory/Cleaner data-flow breakpoints
- internal/memory: clean_text, document store refactor
- internal/plugin/registry.go: plugin API alignment
- docs: PLUGIN_DEV.md, ARCHITECTURE.md NoMemory/Cleaner docs
- plan.md, review.md: status update
2026-07-25 11:17:31 +08:00
84258acac7 refactor: pluginize text cleaning and tool NoMemory control
- SDK: ToolDef.NoMemory field, PluginSDK.RegisterTextCleaner/TextCleaners
- Registry: aggregate text cleaners from plugins, expose CleanText()
- Memory: replace hardcoded QQ regex CleanTemplateText with dynamic CleanText/SetTextCleaner
- StageHost: add ToolDef(name) lookup
- eventloop: check ToolDef.NoMemory before emitMemoryCandidate
- context/Prune: replace hardcoded agentcli/terminal source filter with ToolsUsed NoMemory check
- agentcli/cmd: mark tools with NoMemory: true
- main.go: wire memory.SetTextCleaner(pluginReg.CleanText)
2026-07-24 14:49:08 +08:00
71c1025342 refactor: rename openclaw -> clawhubadapter, add ClawHub search/install, fix agent interrupt
- Rename internal/plugins/openclaw/ -> internal/plugins/clawhubadapter/
- Add RegistryDispatcher with 5 sub-registries (Tool, Provider, Channel, Stage, Cap)
- Add clawhubadapter_search tool for ClawHub marketplace search
- Add clawhub: prefix support for installing from ClawHub (ZIP/tgz auto-detect)
- Add CallProvider RPC and provider/call routing
- Fix QQ interrupt: check interceptCh before/after each tool execution
2026-07-21 22:47:14 +08:00
8d73c08977 feat: register embedding_model_path in ConfigRegistry instead of JSON config
- Add core.agent.embedding_model_path to seedDBValues and seedCoreDefs
- Wire cfgReg.GetString in main.go to AgentConfig.EmbeddingModelPath
- Follow existing core.agent.* ConfigRegistry pattern
2026-07-17 21:07:54 +08:00
88d837e722 refactor: move webui.listen_addr from core.daemon to webui category 2026-07-14 11:22:46 +08:00
5e80db52f5 feat: restructure plugin system, add Lua plugin support, update docs 2026-07-13 21:48:13 +08:00
05c43a98b8 refactor: P0-P3 fixes, C1 cleanup, architecture diagrams, go.work upgrade
- P0-1: ProviderError type + ReportStatus for precise 401/403 detection
- P0-2: Remove -config flag from deploy/homeagent.service
- P2-1: 5s debounce on context.go Save()
- P2-2→C1: Delete output_set_channel entirely
- P2-3: Extract mediaDataURL/mediaChat helpers
- P2-4: Dedup defaultSources var
- P3: Delete dead packages (embed/tokenizer/container/snapshot)
- P3: Delete dead functions (messagesToMap, RunStageAll)
- CL: Update .gitignore, docs, Makefile, gojieba removal
- Config: Delete config/config.yaml, update docs
- Arch: Remove EmitOutputTo from emitResponse
- CL-1: go.work 1.19→1.21
- Docs: Add Mermaid architecture diagrams to README
- Docs: Add kernel-rebuild requires plugin-rebuild note to PLUGIN_DEV.md
2026-07-12 11:42:56 +08:00
1641336ca0 feat: expand local operator controls across CLI and WebUI
- Add structured CLI commands for status/kernel/settings/plugins/memory/knowledge/agents
- Inject core dependencies directly into CLI plugin for non-HTTP operator workflows
- Add plugin management panel and API proxy endpoints to WebUI
- Let cmd_run inherit default workdir from core.agent.workdir
- Use fixed loopback address for pluginmgr API
- Remove stale MaxToolTurns config usage from homed wiring
2026-07-06 20:32:04 +08:00
b35cf53fb2 feat: 路径配置化 + 裸二进制启动 + pluginmgr 内置插件
- types.go: 新增 PluginDirConfig 结构体嵌入 Config
- config/registry.go: 新增7个路径配置项 (core.plugin.dir 等) + ConfigDef 元数据
- cmd/homed/main.go: -data 默认自动检测二进制同级目录,使用 cfg.Plugin.Dir
- internal/plugins/pluginmgr/: 内置插件实现 (4工具 + HTTP API + 包校验)
- all.go: 注册 pluginmgr
- manifest.go: 扩展 PluginManifest 字段
- sdk/settings.go: RegisterDef / Defs 接口
- webui: 设置页自动发现 ConfigDef 元数据
- config/config.go, config/config.yaml: 清理 YAML 死代码
- sdk/plugin.go: IO 通道泛型化支持非文本类型
- waiter: CLI 支持 socket 发现和交互模式
2026-07-04 16:56:32 +08:00
4a09e358ae IO 抽象层增强:非文本输入支持 + waiter CLI 重写
PluginSDK:
- 添加 InjectInput / InjectInputSync / InjectInterrupt 泛型接口
- 插件现在可注入 image/audio/file 等任意类型输入

Provider:
- 添加 ContentBlock / ImageURL / AudioURL 类型
- Message 增加 Blocks 字段,Content 在非空 Blocks 时序列化为数组(多模态格式)

Agent:
- handleInput 新增 image/audio 类型分发 → processMediaInput
- processMediaInput 将媒体数据附着到对话上下文,LLM 自主决策处理策略
- 新增内置工具:describe_image / transcribe_audio / ocr_image(pendingMedia 驱动)
- 工具仅当有未处理媒体数据时注册,通过 Provider 直接调用多模态模型

Config:
- 新增 InputProcessingConfig(image/audio 处理配置)
- 含 fallback_provider / describe_prompt / ocr_enabled 等选项

Waiter CLI 重写:
- 配置文件 ~/.config/homeagent/cli.yaml(自动发现 socket)
- 原始终端行编辑 + 命令历史持久化 + 彩色输出
- 内置命令:/help /reconnect /connect /remote /local /prompt
- 断线自动重连
2026-07-04 15:01:35 +08:00
2908e1637c feat: complete P0/P1/P2 — WebUI SPA, OpenClaw sidecar+simulator, healthcheck auto-sched+perf
P0: WebUI重构
- 完整 SPA 仪表盘 (7标签页), //go:embed dashboard.html
P1: OpenClaw兼容 (三通道: SKILL.md / sidecar / simulator)
- Node.js 模拟进程统一加载任意 OpenClaw 插件
- JSON-RPC 2.0 over stdio 协议, go:embed 内嵌
P2: Healthcheck 优化
- 定时自动执行 (startAutoCheck, 30min)
- healthcheck_perf 性能监控工具

其他: agentcli/cmd 插件, integration_test, status.go,
      test_deepseek 清理, 多项 bug 修复
2026-07-04 12:51:32 +08:00
841c3f5431 fix: 网络检测增强 + Tracker changeset 清理
网络检测 (monitor.go):
- TCP 拨测 (8.8.8.8:53 / 1.1.1.1:53 / 208.67.222.222:53)
- 延迟阈值检测 (>5s 标记降级)
- DNS 多目标检测 (google/baidu/cloudflare)
- NetworkCheckResult 新增 TCPReachable + LatencyDegraded

Tracker:
- 新增 WithKeepChangesets / WithMaxChangesetAge 选项
- Init 时自动清理过期 changeset (默认保留100份/30天)
- 先按年龄裁剪,再按数量裁剪
2026-07-03 21:31:00 +08:00
b9ffc5d449 test: 补齐4个模块测试 + LLM 图质量评估 + 文件日志
补齐测试:
- pipeline_test.go: 29 个测试 (蒸馏/刷盘/加载/三连提取/工具函数)
- social_test.go: 11 个测试 (特质CRUD/社交关系/网络/安全 nil 守卫)
- text/memory_test.go: 20 个测试 (追加/回放/并发/旋转/清理/持久化)
- indexer_test.go: 15 个测试 (同步/上下文/召回过滤/关键词/工具定义)

图质量评估:
- reorgGraph 新增 evaluateGraphQuality 步骤
- 自动识别蒸馏噪音 (用户-提及/AI-回应) 和低 confidence 关系
- 通过 enqueueConsolidationTask 交由 LLM 逐条判断保留/删除

文件日志:
- 启动时创建 data/log/ 目录
- io.MultiWriter 同时输出到 stderr 和 homed_<时间>.log
2026-07-03 21:26:55 +08:00