Commit Graph

132 Commits

Author SHA1 Message Date
e773f156fb 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 部署 7193446 后
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"。
2026-09-27 23:00:04 +08:00
175fa9dd62 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 条、两次二进制大小取自实际部署。
2026-09-27 22:25:58 +08:00
bcd75d25fd 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/...` 全绿。
2026-09-27 21:18:32 +08:00
20d357328b 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 引入
  于 `71c894c`(更晚)⇒ `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 条。
2026-09-27 20:00:33 +08:00
07feecba11 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 才能部署。
2026-09-27 19:00:53 +08:00
56fe10d3a4 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 才能部署。
2026-09-27 19:00:19 +08:00
4fd18a2e83 docs(plan): 遗留项收敛——D4 与端到端已完成,提权无需决策 2026-09-27 13:18:28 +08:00
532500e3c9 docs(plan): 内核主线与插件线全部完成,记录两个设计决策与三处遗留 2026-09-27 13:06:10 +08:00
7532af7e9b 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 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
2026-09-27 12:29:56 +08:00
9746538a00 docs(design): 补 0.2 阶段行 2026-09-27 11:56:19 +08:00
ae3cdaa1a0 docs: 同步两份文档的实现进度(内核主线 0~2.5 全部完成) 2026-09-27 11:56:01 +08:00
446645d0a0 docs(plan): 阶段 2 标记完成,记录并发规则与 2c 判据闭合 2026-09-27 11:27:53 +08:00
3d12e82f65 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 全绿属运气。已在计划中
记为待修,避免后续误判为并行化引入的回归。
2026-09-27 11:17:06 +08:00
4ba72977d2 test(toolcall): 补批内路径的三条缺失判据(阶段 0.5)
同一批多个 tool_call 的循环(StepToolBegin→Exec→After)此前只被
scheduler_critical_test.go:125 一条用例覆盖「按序执行」,缺的三条正是
阶段 2(并行执行层)要改的地方:

· tool_call_id 配对完整性 —— 阶段 2 改消息落法(一个 assistant 带全部
  tool_calls + N 条 tool)时,配对断裂上游会直接报错
· ContentOnce 批内语义 —— 同一段 assistant 文本在批内重复 N 次,撑爆上下文
· denied 后继续批内 —— 改成 abort 会丢掉本可执行的后续调用

判据 toolbatch_test.go(4 条),全部确定性断言:阶段 0 已消除 map
迭代随机性,同批工具的落序与配对可稳定断言。

变异验证:令 stepToolBegin 跳过批内最后一个工具后,4 条判据同时 FAIL
(既有那条也 FAIL),报错直指 ToolsUsed=[tool_alpha]、
tool_call_id "c2" 被声明 0 次。

更正一处此前的不准确表述:我曾说「无任何测试直接驱动批内路径」——
不准确。scheduler_critical_test.go:125 已驱动「同批两工具按序执行」;
漏查是因为只 grep 了 PendingTools/ToolIdx 字段名,没查断言内容。
真正缺的是上表三条。

过程中三次自伤(均由「判据先写」暴露):臆造不存在的 helper;
stageHost 置 nil 后又使用;给 newTaskFrame 传 nil 导致 stepPrepare 于
task.go:519 nil 解引用 panic(改用仓内既有 a.stageCtxFromInput)。
顺带记录:生产两处 newTaskFrame 调用都传真实 ctx,但 stepPrepare 对
f.StageCtx 无 nil 兜底——本次不修(无生产触发路径),记为潜在缺口。

回归:internal/agent/... 与 internal/plugins/... 全绿。
2026-09-27 08:59:30 +08:00
7a566d50b7 fix(toolcall): 工具「不存在」类型化 + 修父 io 兜底吞错误 + 修并行 tool_call 落序随机
主线:工具调用并行化改造(阶段 0 与 0.2)。

① flush 顺序随机(process.go)
   flushToolCall 由 `for idx := range accs` 驱动,Go map 迭代顺序随机化
   ⇒ 同一批并行 tool_call 进入 resp.ToolCalls 的顺序每次运行都可能不同。
   对 output_send__ 这类用户可见通道,分段消息到达顺序不可复现。
   改为收集 index 后 sort.Ints 再 flush(两个调用点统一走 flushAll)。
   判据 stream_flush_order_test.go(8 工具 × 200 轮),已变异验证可检测。

② 工具「不存在」类型化(io/channel.go、core/stages.go、core/toolcall.go)
   工具是动态注册的,「不存在」是运行期常态而非异常。原先内核用
   strings.Contains(err, "not found in any plugin") 判别——约定而非契约,
   插件文案含该子串即被误判。改用哨兵 ErrToolNotFound + errors.Is
   (沿用仓内 ErrInputChannelUnknown 的先例)。

   ⚠️ 顺带修一个静默 bug:IOManager 向父兜底时吞掉父的执行失败,
   误报为「工具不存在」。后果是设备离线这类本该 retry 的失败被判为
   「工具没了」⇒ 整组被跳过,与「插件真没加载」无法区分。改为只传递
   「确实不存在」,其余如实上抛。

   「不存在」的文案改为可执行指引(get_plugin_tools / output_list_channels),
   而非含糊的「执行失败」——后者会让模型反复重试同一个不存在的名字。

判据:toolcall_error_test.go(类型化 vs 诱饵子串、%w 穿透、执行期文案)、
channel_error_test.go(父失败不吞、真的不存在仍可判别)。
两者均经变异验证。回归:internal/agent/... 与 internal/plugins/... 全绿(14 包)。

设计文档:docs/zh/toolcall-contract-and-sequence-design.md
执行计划:docs/zh/toolcall-parallel-execution-plan.md
2026-09-27 08:55:25 +08:00
13a070fbb2 docs(memory): 标记步骤 3/5 完成,补步骤 4 部署前基线与硬约束
步骤 4 加了硬约束提醒:必须先部署含 R1 修复的二进制再清理,否则
重新涌现出来的还是带 +/# 的旧键。并记录部署前实测基线(PID 92115、
子进程插件 25、近 24h 异常日志 0、场景 65),以及 homed --version
这个 flag 并不存在(纪律清单那条要用 status 接口或 ps 核对)。
2026-09-26 20:17:17 +08:00
758ec11832 fix(memory): 图整备覆盖 scenes + 证据桶按桶清(R3/R5)
R3:图整理心跳只查 entities/relations,scenes 完全没有整备路径。
Recall(nil,nil,1,"") 的全量路径只 SELECT 这两张表
(graph.go:609/631),于是同一场面的双胞胎键从建库起无人发现:
auto:chan:qq+part:morning 累积到 strength=271 / 6 features / 0 refs,
孪生的 auto:chan:qq_part:morning 持有 210 refs 却 0 features
(聚类只读 scene_features,所以它永远不被看见)。

新增 GraphDB.DedupeScenes:归一化后同名的场景合成一个——强度相加、
特征取并集(权重取大)、引用全部重定向,存活者保留 id 最小行,
跨 origin 也合。接到 mergeLoop 尾部。

为什么不塞进 detectEntityMerge 的双重循环:
- 实体是全库两两 bigram + LLM 裁决(1 万实体实测 5000 万次配对、
  ~224GB 瞬时分配每轮,是独立问题);
- 场景的判重口径是**归一化后是否同名**——同名即同一场面,键相同
  本身就是证据,不需要 LLM 裁决。而「像不像」是 EnterScene 聚类的
  职责,不是这里的事。
只做同键合并、不做相似度合并:把 chan:qq 与 chan:webui 合并是危险
的,去重不是「把像的一律合并」。

生产库副本实测(sqlite3 备份式复制到 /tmp,未碰生产):
65 个场景 → 合并 8 组 → 57 个;
auto:chan:qq_part:morning 的 refs/rel/ent 一条没丢,strength 1 → 272。

R5:createSceneLocked 新场景成立时执行的是
`DELETE FROM situation_evidence`(全表清),而证据表是多通道共用的
计数桶。后果不是「多清一点」:qq 的场景一长出来,就把 mc/webui/cli
尚未攒够 minSceneEvidence=2 的证据抹掉,它们的计数被反复清零,
于是**永远**攒不到 2 次。判据实测:6 个通道各来 3 次,只长出 2 个场景。
改为 `DELETE ... WHERE label = ?`,只清本指纹那个桶。

判据:scene_dedupe_test.go 8 例(含「不同场面不得被合并」与幂等)、
scene_evidence_test.go 3 例。均先红后绿。修 R5 时差点栽:桶键是
sig.Label(2) 本身、不带 auto: 前缀(base 才是带前缀的场景键),
第一版删错对象会「一条没删却看起来通过」,用探针实测真实桶键后改正。

记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
2026-09-26 20:16:11 +08:00
f441574b80 docs(memory): 补 R6/R7 与 SDK 声明项方案,重排为 7 步
R6:ChannelDef 的记忆声明已有三件套(NoMemory/ContextPolicy/
RecallPolicy),唯独没有「这条通道是否参与场面识别」,现状是无条件
参与 ⇒ chan:system/kernel/timer 等内部信噪通道也在场面聚类里。
穷举确认非查漏:go.mod replace 指向 third_party/homeagent-sdk,
plugin.go 中 scene 出现 0 次,SDK 自身 git 历史 -S'Scene' 为空。

R7:payload[scene] 的层级键(chan:qq/peer:group_1)已支持前缀
召回(RecallByScene scene.go:351),但因 R6 无人使用而闲置。

步骤重排为 7 步:SDK 声明项提到步骤 2(用户已授权动公开 SDK,
开发阶段非 release 阶段),并把「peer 覆盖面」拆到步骤 6 单列,
先只读调查插件手上有什么再动。
2026-09-26 20:03:51 +08:00
6e9420965d docs(memory): 补 R4 覆盖面与 R5 证据桶两条根因
R4:现网 65 个场景键里 peer 主导 0 个、topic 主导 0 个,36 个
auto:chan + 26 个 chan: 全部锚在输入通道。两个独立原因:
采集侧全仓无插件在 InjectInput 填 peer/group_id/user_id/chat_id
(日志中 peer 出现 0 次);排序侧 chan 与 peer 权重同为 1.0 而
NewSituation 稳定排序让 chan 恒在前,即使采集到也进不了 Label(2)。
这与 R1/R2 是不同层面:R1 修完只会得到「正确的单一维度」。

R5:createSceneLocked 新场景成立即 DELETE FROM situation_evidence
(全表清),会连带清掉别的场景尚未攒够门槛的证据。
2026-09-26 19:58:00 +08:00
1fa9ef68a4 fix(memory): 场景键归一化 + 排除最弱维度,修双胞胎与空转
现网实测:scenes 表 65 行里有 6 组是同一场面的双胞胎键,最严重的
auto:chan:qq+part:morning 累积到 strength=270、6 个 features、
0 条记忆,日志里被"命中"179 次;孪生的 auto:chan:qq_part:morning
则持有 201 条记忆却有 0 个 features(不参与聚类)。两套特征体系
各活各的,谁也发现不了谁。

病因:键构造不唯一。
- Label() 直接用 "+" 拼接且不过 NormalizeSceneKey,而
  EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。
  "+" 会被 normalizeSceneSegment 归一成 "_",于是两个字符串都合法,
  key UNIQUE 约束拦不住。
- Label(2) 会把权重仅 0.2 的 part(时段)挤进场景身份。实测
  morning 场景吞掉 evening 指纹:共享 chan:qq 权重 1.0、并集含
  part 0.2×2,相似度 1.0/1.4 = 0.714 > joinSceneThreshold 0.5。
  这本就是加权 Jaccard 的正常行为,但键名不该写进时段。
- createSceneLocked 的 "#N" 冲突后缀同样会被归一成 "_",
  造成 auto:chan:mc:event+topic:mc#2 在库、而写侧归一化后去找
  auto:chan:mc:event_topic:mc_2 —— 又一对匹配不上的双胞胎。

修法:
- Label 只取权重 ≥ 0.5 的主导特征,结果过 NormalizeSceneKey;
  全部特征都弱于门槛时退回最强的一批(宁可名字信息量低,也不能
  没有名字 —— 没名字就没有键,场景根本长不出来)。
- createSceneLocked 对 base 再做一次防御性归一化,并在 label 为空
  时拒建无名场景。
- 冲突后缀由 "#" 改为 "."('#' 会被归一化,'.' 是白名单字符)。

判据:新增 scene_key_test.go 6 例,参照物在生产代码之外("同一场面
⇒ 同一个键"这条不变量 + 直查 scenes 表复算行数)。先跑红确认判据
在跑(4 红 1 绿,失败的正是键唯一性/时段污染/证据桶),再改实现。
其中 TestWrittenRefReachableFromItsScene 一开始就是绿的——写侧到读侧
那条路本身是通的,坏的只是键的构造。

记忆 8 包 + agent/core 全绿。
2026-09-26 19:56:28 +08:00
7a1322c97f perf(api): SSE 导航层返工 —— 5+ 次边界压成 1 次(三项赢,仍默认关闭)
按 sse-codec-c.md §6.4 的架构改造方向返工。**部分成功**:从「五项全输」
变成「三项赢 / 一项持平 / 一项输」,且所有场景分配数都下降。

## 改造内容
| 项 | 前 | 后 |
|---|---|---|
| cgo 边界次数 | 5+(每字段一次 findKey) | 1(ha_sse_chunk_locate) |
| 键查找 | 每键各扫一遍对象(6 趟) | 单趟分派(遍历成员表一次即分发) |
| 解码 | 每字段一次往返 + 各自 decBuf | 同一趟内写进一块 sbuf(1 次分配) |
| 成员表遍历 | 6 趟 | 2 趟(顶层 + delta) |

顺带修掉两处自造的浪费(都是「先扫一遍拿个数、再扫第二遍拿首元素」):
choices 数组的「数个数 + 取首元素」合一趟;choice0 内的 delta/finish_reason
合一趟。成员遍历实测 107ns/趟,省一趟就是省 107ns。

新增 ha_sse_chunk_locate:一次调用完成根校验 + 顶层分派 + choices[0] +
delta 分派 + content/reasoning/finish 解码,输出写调用方持有的 C 结构体
(C 结构体无 Go 指针 ⇒ 可安全传指针,消除 out-param 逃逸)。
choices_count>1 时直接回退(Go 侧 Unmarshal 会解析全部元素,本层只认 [0],
其余元素可能类型不符而让 Go 整块作废 ⇒ 无法保证等价)。

## 实测(50000 次 × 3 轮取中位)
| 场景 | Entry | GoOnly | 判定 |
|---|---|---|---|
| content_zh | 1540ns / 5allocs | 1871ns / 13allocs | 快 18%,分配 -62% |
| content_ascii | 1250ns / 5allocs | 1304ns / 13allocs | 持平,分配 -62% |
| finish | 820ns / 6allocs | 921ns / 12allocs | 快 11% |
| usage | 2530ns / 9allocs | 2591ns / 12allocs | 持平偏快 |
| toolcall | 3450ns / 20allocs | 3000ns / 21allocs | 慢 15% |

## toolcall 仍输的根因(已定位,非猜测)
分解测量:C 侧纯 C 零边界 = 766ns;Go 侧 []openAIToolCall unmarshal =
1305ns/15allocs;对照 Go 整块 unmarshal ≈ 2980ns。
问题在第二行:tool_calls 元素是对象,Arguments interface{} 需要真实的
map[string]interface{},必须走 encoding/json 的反射建树。
而为了定位已先做了一遍 C 扫描 ⇒ 同一份数据被解析了两次。
⇒ 不是 C 慢,是「扫两遍 vs 扫一遍」。
标量字段(content/reasoning/finish)C 能一次到位 ⇒ 那些场景赢;
需要建树的字段(tool_calls/usage)C 的定位是纯开销。

## 为什么仍默认关闭(理由充分,不是保守)
1. toolcall 是真实负载最常见的一类块(任何一次工具调用流),仍慢 15%
2. 18% 收益不足以抵消「与 encoding/json 语义并存的第二实现」的风险
3. 本刀原始动机在 toolcall 场景没有兑现:分配数 20 vs 21 几乎没降
⇒ 前提是先做「按字段类型决定是否 C 化」,让 toolcall 也不输,再重测。

## 正确性
6 万+ 差分用例(协议形态/真实负载/随机 JSON 3 万/随机字节 3 万)全过。
基准测量也修了:先前 C 基准脚本用 CLOCK_MONOTONIC 却只取 tv_nsec,
算出 -4201ns 的负值 —— 测量工具本身出错会直接毁掉结论。

## 验证
ASan+UBSan PASS;gcc+clang 零告警;arm64 交叉 0 告警;
libFuzzer 66 万次零崩溃;全量 go test 38 包 ok / 0 FAIL
2026-09-26 10:41:52 +08:00
7748ec450e perf(api): SSE 分块 C 导航层 + 差分等价验收(实测更慢 ⇒ 默认关闭)
第三刀:把 ha_json_scan 接进 parseOpenAICompatibleStreamChunkFull。
**结论是否定的** —— 实测比原实现慢,故默认关闭并如实记录。这条提交的
价值在于「已钉死的正确性 + 已定位的根因 + 一条防静默回退的断言」。

## 设计:只做「结构导航」,序列化留在 Go

接线前实测出两条 wire 语义,它们让「整条解析全 C 化」不成立:
  §5.1 重复键是**字段级合并**,不是替换:
       {"choices":[{content:a}],"choices":[{reasoning:r}]} → 两个都保留。
       机制:json.Unmarshal 的 object() 收尾做 v.SetIndex(i, subv.v),
       而 subv 拿到的是**已存在元素的指针** ⇒ 第二次是叠加。
  §5.2 stringifyContent 的 default 分支 = json.Marshal(interface{}),
       即**重新序列化**:{"b":1,"a":2}→{"a":2,"b":1}(键排序)、
       1e2→100、<→\u003c、大 int 先舍入成 float64。
       逐值一致 = 复刻 Ryu 最短浮点 + map 键排序 + HTML 转义 + int 舍入。
两条都只在**取值**阶段需要,故 C 只回答「值在哪里」(零分配零解码),
类型检查靠「用相同的 Go 类型 unmarshal 相同形状的子树」保证,不靠 C 复刻规则。

## 实测:新路径比原实现慢(20000 次迭代)

| 场景 | 新路径 | 原实现 |
|---|---|---|
| content_ascii | 2016ns / 20allocs | 1325ns / 13allocs |
| toolcall      | 5854ns / 33allocs | 3270ns / 21allocs |
| usage         | 3170ns / 24allocs | 2832ns / 12allocs |

分配数**也变多**(20 vs 13),与「消除 GC 抖动」的初衷相反。

根因(逐项测量,非猜测):裸 cgo 调用 168ns;**每次带 out-param 的键查找
205ns + 2 allocs**(out-param 逃逸到堆);一次解析需要 5+ 次查找
⇒ 边界与分配成本约 1µs,恰好吃掉全部收益。Go 侧只需**一次** Unmarshal。
一句话:**用很多次廉价调用换一次昂贵调用,在这个尺寸上不划算。**

## 天花板实验:方向对,但当前实现没到

假设拿到 span 完全免费,只测设计中必须由 Go 做的部分:
  我的 Go 侧 505ns/7allocs  vs  原实现 1239ns/13allocs
⇒ 边界归零后仍有 2.4× 时间、46% 分配的空间。故问题在**逐字段往返**
这个交互方式,不在 C 本身。正确改造:一次调用返回全部字段 span +
结果写调用方栈结构体 + 仅在确需重新编码时回退。

## 正确性:6 万+ 差分用例全过

同一批输入跑两条路径逐字段比对(Content/Reasoning/Done/Finish/ToolCalls/
Usage + bool),5 组:协议形态(含全部回退触发条件)、真实负载、随机 JSON
30000 例、随机字节 30000 例、优化有效性。

★ 差分测试当场抓出 4 个真实缺陷(其中一个正是「优化压根没生效」):
 1. ha_sse_arr_first 里「重新 init 到 sc.s+sc.i」使 base 变了 ⇒ start 恒 0
    ⇒ 返回的是**数组本身**而非首元素。症状是**快速路径永远不生效**——
    而若只看「结果与 Go 一致」,这个 bug 会**完全隐形**(回退总是对的)。
    ⇒ 这就是必须单独断言「优化确实被走到」的原因。
 2. chunkAssemble 的 bool 被丢弃 ⇒ 空对象被判 true(原实现 false)
 3. 键匹配层级搞错:delta 是 **struct**(字段名 CI),不是 map。
    我一度「推理」成 CS 并以为差分测试会通过——错的。
    教教训:哪层是 struct、哪层是 map 要**回原实现读类型**,不能凭字段名推断。
 4. cgo 边界:out-param 逃逸到堆

另修:C 代码从 cgo 前言移进 csrc/ ——前言里的 C **逃出全部 C 门禁**
(告警/sanitizer/交叉/模糊测试),而它恰是本刀最易出错处。

## 防静默回退

TestChunkFast_BenchGate 断言 chunkFastEnabled 必须为 false。
后来者看到「快速路径写得全 + 差分测试全过」,很自然会以为它已生效并打开它
—— 而实测更慢。断言把这个事实钉住,改动即判红。

## 实测汇总
- C 契约 119 项断言、黄金对照 5 组、差分 6 万+ 例:全过
- ASan+UBSan PASS;gcc+clang 零告警;arm64 交叉 0 告警(3 个源文件)
- 全量 go test -count=1 ./... 38 包 ok / 0 FAIL
- libFuzzer 4948 万次零崩溃(上一刀)

教训(与第一刀同源):**「C 比 Go 快」不是前提,是待验证的假设。**
第一刀被 C.CString 的 82% 自找开销推翻一次,这一刀被逐字段往返推翻一次。
两次都是测量推翻直觉。
2026-09-26 10:32:20 +08:00
4d3962a845 feat(csrc): 第二刀 —— 零分配 JSON 扫描/取值层 ha_json_scan(含黄金对照)
C 化第二刀:为协议编解码层铺 JSON 底座。**本刀只交付库 + 验收,
未改 Go 生产路径**(接线是独立一步,库先验完再换产线)。

为什么是它:SSE 单块解析(parseOpenAICompatibleStreamChunkFull)是每个流式
chunk 都要跑的最热路径,实测 1937ns/13allocs(content 块)、3122ns/21allocs
(toolcall 块),而纯字节扫描理论下限 133ns/1alloc —— 差距 15~23×。
一次 1 万块的会话 = 1~2 万次堆分配,正是 GC 抖动的来源。

为什么不复用 SDK 的 remotedevice/ha_json.c(实测三缺陷,不可直接复用):
  ① 无 \u 解码:\u4f60\u597d → ?0?d?d?0(非 ASCII 全靠转义时内容直接损坏)
  ② 只有 _get_int 无浮点:temperature:0.7 静默变 0
  ③ null 与「键缺失」不可区分
  外加它是 DOM + malloc,与本层「不 malloc / 零拷贝 / 纯函数」正交。

设计:scan(结构,零分配零解码)+ extract(取值,按需解码)两段分离。
content 可能是很大的多模态数组,而 stringifyContent 只需要 text 字段拼起来;
若 scan 就解码并分配缓冲,等于把成本付给不需要它的调用方。

★ 被测试抓出 7 个真实缺陷(写 C 时同一逻辑我读三遍都认为正确):
  1 代理对合成成功后未跳过 unconditionally 的 U+FFFD 发射(😀 → 两个 FFFD)
  2 过长编码检查用了只含首字节位的 cp(「你」→ 6 个 FFFD)
  3 members_next 只报值起点不消费值 → 游标停在值前(模糊测试第一轮抓到)
  4 扫描阶段不校验转义字符合法性({"a":"\q"} C 判合法、json.Valid=false)
  5 扫描阶段不校验 \u 后四位十六进制(同上)
  6 get_int 接受前导零(007 / 00)
  7 cgo 桥接把 C 结构体声明为 Go 局部变量 → 运行时 panic
     (cgo argument has Go pointer to unpinned Go pointer)
  其中 4 个是「静默分叉」——不崩、不报错,生产里表现为「内容少一个字符」
  或「某些块被静默丢弃」,极难归因。这正是黄金对照不可省的理由。

★ 另纠正我自己两次错误的「真值」(比代码 bug 更危险,会变成错误规格):
  第一版真值表里 content:{} 的花括号少了一层,把「我写错 JSON」误读成
  「Go 对 content 严格」。修正后实测发现一对方向相反的语义:
  content 走 interface{} 宽松({}→"{}"、true→"true"),
  reasoning_content/usage/finish_reason 强类型严格(123 ⇒ 整块作废)。
  照错误表写 C 会产出「比 Go 更严格」的实现,静默丢弃本该生效的块。

两个由缺陷倒逼的设计决定:
  - members_next 返回**完整值 span** 并内部跳过 ⇒ 「返回 1」蕴含「成员良构」。
    要求调用方自己推进游标的 API 是错的:忘一次就解析到上一个值且不报错。
  - members_complete() 区分「正常扫到 }」与「输入畸形」,否则无法复刻 Go 严格性。

同时修两个基础设施目标对「多源文件/多测试」的适配:
  - csrc-sanitize:每个契约测试各自链接(多个 main 合链会 multiple definition,
    而报错被吞后会被误报成「本机无 sanitizer」——一个假的 SKIP)
  - csrc-cross:多源文件改用 -fsyntax-only 逐文件(gcc 不支持多源单 -o)

实测(全部当场可复现):
  - C 契约测试 119 项断言全过;黄金对照 5 组全过(语法/成员/解码/整数/随机字节)
  - libFuzzer 4948 万次运行零崩溃(121s)
  - ASan+UBSan PASS(两个契约测试各跑);gcc+clang 零告警;arm64 交叉编译 0 告警
  - 全量 go test -count=1 ./... 0 FAIL;make build-linux-arm64 → ELF aarch64
  - 纪律检查 SDK 公开接口 diff = 0 行(未触碰 SDK)

决策关闭(jianf 本轮裁决):C 实现留主仓 csrc/(它本就是替换内核 Go 实现,
SDK 从未被触碰,跨端复用才需进 SDK 而它们不调用本层);ha_json.c 不复用;
下一刀即协议编解码层。
2026-09-26 10:07:27 +08:00
7799ca4559 perf(c-core): 完全 C 化 + 消灭初版的 malloc/拷贝开销(C 从「更慢」变「明显更快」)
上一提交的基准结论是错的:"C 比 Go 慢" 不成立 —— 那是我把自己的
malloc/拷贝开销误当成了 cgo 的固有成本。本提交先拆解成本、再逐项消灭。

## 成本拆解(同机、百万次 benchtime)

| 场景 | ns/op |
|---|---:|
| cgo 边界(零拷贝传指针 + 空函数体)| 31.9  ← cgo 真实固有成本 |
| + 一次 C.CString + C 侧 strlen | 105-111(多出 ~75ns)|
| 初版 ModelContextWindow(另加 lower_dup malloc + 16×strstr)| 175 |

即 82% 开销是自找的。而初版还违反了自己写在设计文档 §四 的原则第 1 条
「C 接口只吃 const char* + 长度」——它没传长度,让 C 侧 strlen 再扫一遍。

## 逐项修复

1. C.CString(malloc+整串拷贝)→ unsafe.StringData 传指针+长度,零拷贝
2. C 侧 strlen 再扫一遍 → 长度由调用方传入,不扫
3. truncate 的 malloc 输出缓冲 + GoStringN 拷回 → C 只返回**字节数**
   (结果必然是输入前缀),Go 侧 s[:n] 完成切片,全程零分配
4. lower_dup 每次 malloc 模型名 → 栈缓冲折叠,超长走零分配回退
5. 逐字节 utf8_next 函数调用 → 字级(8 字节)ASCII 检测
6. truncate 扫完整串才判断 → 数满 keep 个 rune 立即返回(提前短路)
7. 纯 Go 侧 len([]rune(s))/[]rune(s)(1KB 分配 4KB)→
   utf8.RuneCountInString / DecodeRuneInString 游走,零分配

## 结果

| 基准 | 初版 C | 优化后 C | 纯 Go | 提升 |
|---|---:|---:|---:|---:|
| ModelContextWindow | 175 | 76.5 | 46.8 | 2.3× |
| EstimateTokens / 1KB ASCII | 2318 | 80.8 | 326 | 28.7× |
| TruncateByTokens / 1KB ASCII | 2594 | 71.7 | 411 | 36× |
| TruncateByTokens / 1KB 中文 | 3923 | 70.1 | 3097 | 56× |

## 完全 C 化(jianf 裁定)

撤掉我一度加的「短串 <32B 走回 Go」按长度分派:那会同时存在两份语义
可能分叉的实现。C 是唯一实现。

代价如实记录:EstimateTokens("qq") 这类极短串上 C 约 47ns(几乎全是
31ns 边界成本)vs 纯 Go 约 3ns,慢约一个数量级;绝对值纳秒级
(0.000047ms),单次请求尺度可忽略。若某循环对极短串高频调用,
正确应对是**把该循环 C 化(批量传一次)**,而不是按长度分派回 Go。

## 顺带补的正确性缺口(初版是真错的)

初版 C 的 UTF-8 解码只按首字节推断长度、**不校验后续字节**,
因此对畸形序列会与 Go 分叉:例 "\xE4\x41\x41",Go 判 3 rune,
初版判 1 rune ⇒ rune 计数偏差 ⇒ token 预算与截断点偏移。
这类偏差**只影响计数、不会崩**,不测发现不了。

现在 C 侧做与 utf8.DecodeRuneInString 等价的完整校验(含过长编码、
代理对、超 U+10FFFF、截断序列),语义边界逐条注释。
代价:中文密集输入比初版慢(1467 vs 840)—— 这是刻意的正确性代价,
且仍比纯 Go 快 2×。

新增测试:
- TestGolden_InvalidUTF8:3000 组**任意字节**(含畸形序列)对拍,
  覆盖初版会分叉的输入类别
- TestGolden_TruncateAlwaysPrefix:截断结果必为原串前缀且不超长
- C 契约测试从 21 项扩到 40 项(含非 NUL 结尾、超长名、畸形 UTF-8)

## 包现在要求 cgo 才能编译

删除 codec_nocgo.go:CGO_ENABLED=0 下整包构建失败(错误直指缺失符号)。
不保留回退的理由:只验证过一条路,就不该存在第二条。
实测这不影响任何构建 —— go list -deps 证明只有 cmd/homed 依赖本包,
而 waiter/initconfig/memgc/mock-server 均不依赖(逐个验过),
且 homed 本就强制 cgo(sqlite3 + gojieba)。仓库无 CI。

Makefile 把「不许有第二条路」变成可执行断言:check-codec-cgo-only
(断言 cgo 下全绿 **且** CGO_ENABLED=0 下必须失败)。

## 验证

- C 契约测试 40/40(gcc -Wall -Wextra 零警告)
- 黄金对照 6 个测试全绿(含 2000 组随机 + 3000 组畸形字节对拍)
- 变异测试:改 C 侧返回值后 go test 立即 FAIL(确认真的走 C)
- make check-codec-cgo-only 两项断言通过
- go vet ./... 干净;全量 go test -count=1 ./... → 38 ok / 0 FAIL

## 已知既有 flaky(与本改动无关,单独记录)

internal/plugins 在全量并发下偶发一次 SIGSEGV,栈在
internal/plugin/proc/{unified.go:234,arena.go:197}(arena 的 getU32)。
该两文件最后修改于 09-10,本提交 0 处触及;随后连跑 5 次单包 +
2 次全量均通过。初步判断是 arena/shared-region 的既有竞态,需单独排查。
2026-09-25 15:40:20 +08:00
5ccdf24f18 test(c-core): 补跨语言开销基线 —— 数据反驳「C 比 Go 快」的直觉
codec.go 的注释写着「EstimateTokens 是否该留在 C 侧由 codec_bench_test.go
的实测数据决定,不要凭直觉断言」,但那个文件此前并不存在(悬空引用)。
plan.md 与设计文档也都在说「需先有真实延迟基线(当前没有)」。本提交把它补上。

## 数据(ns/op,benchmem)

| 基准 | C(经 cgo)| 纯 Go | 谁快 |
|---|---:|---:|---|
| ModelContextWindow(短 ASCII)| 175 | 38 | Go 快 4.6× |
| EstimateTokens / 空串 | 100 | 0.43 | Go 快 230× |
| EstimateTokens / 短 ASCII | 115 | 6.5 | Go 快 17× |
| EstimateTokens / 短中文 | 100 | 29 | Go 快 3.4× |
| EstimateTokens / 中 200 字 | 229 | 509 | C 快 2.2× |
| EstimateTokens / 1KB 中文 | 840 | 2870 | C 快 3.4× |
| EstimateTokens / 1KB ASCII | 2318 | 332 | Go 快 7× |
| TruncateByTokens / 短中文 | 233 | 54 | Go 快 4.3× |
| TruncateByTokens / 1KB 中文 | 3923 | 6918 | C 快 1.8× |

(已用 -count 复测确认稳定;ascii_1k 的异常已单独隔离复测 3 次)

## 三条结论

1. **cgo 固定开销约 95–100 ns/次**,小输入下完全压倒算法差异。
2. C 只在**长中文**(UTF-8 步进重)上领先;长 ASCII 反而 Go 快 7×
   (Go 的 utf8.RuneCountInString 对 ASCII 有快路径,C 侧逐字节跑)。
3. ⇒ 判据应是「哪个在**真实输入分布**下真能变快」,不是「哪个看起来更底层」。

## 对后续 C 化的影响(已写进 plan.md §七 与设计文档 §7.1)

- EstimateTokens 的真高频点在 process.go:476 的逐事件循环与 resident.go:552。
  字段分布不单一:Source 是短标签(Go 快 17× 那一档),
  Input/Response 是对话文本(长中文 C 快、短文本与长 ASCII Go 快)。
  ⇒ 当前一刀切走 C 会让短串净亏;正确做法是按长度分派,
  但须先用真实长度分布复测,不要凭推测动手。
- ModelContextWindow(provider.go:333)与 TruncateByTokens(tooldefs.go:38)
  调用点单一、非热路径,开销在单次请求尺度上无关痛痒。

这不否定 C 化方向:协议编解码(JSON 解析、SSE 分片)处理长文本,
才是 C 的主场,也是比「把短函数搬过去」更合理的下一步。
2026-09-25 14:51:10 +08:00
7351c6ca2e feat(c-core): 内核编解码层 C 化第一刀 —— L1 纯函数层落地并打通构建链
第一刀只做三个纯函数(窗口推断 / token 估算 / token 截断),
价值不在功能(Go 版没问题),而在打通「Go → cgo → C」全链路并
建立可复现的对照范式,后面每扩一个函数都复用它。

## 为什么是这三个

按「无状态 → 有状态」分层,L1 协议编解码最安全:纯 string in → struct out,
不碰网络、不碰 Lua、不碰 goroutine。三个函数更是同一组纯算术,最小可验证切片。

## 关键设计:包内符号链接,不链接静态库、不 include 包外源

三种做法都实测过,只有一种同时满足「可构建 + 可交叉编译 + 缓存可跟踪」:

1. ❌ 链接 `csrc/build/libha_codec.a`(原方案)
   - .a 是构建产物、不入库(.gitignore 的 build/ 命中 csrc/build/),
     而发布脚本原先并不产出它 ⇒「不入库 + 不生成」两头空,
     实测报 `cannot find .../libha_codec.a`
   - 交叉编译 linux/arm64(homed 真实发布目标)时,宿主 x86-64 的 .a
     被链进目标产物,实测报 `file in wrong format`

2. ❌ `#include "../../../csrc/src/ha_codec.c"`(包外相对包含)
   ★ Go 构建缓存**不跟踪包外被 #include 的 C 文件**。实测:包外源把返回值
   7→8,`go test` 依然通过(缓存命中、静默沿用旧代码);同样改动落在包内
   文件时立即判红。对「逐步推进 C 化」这是致命的——改 C 源码不生效且无报错。
   (包内 shim `#include` 包外源同样漏跟踪,已实测排除。)

3. ✅ 包内符号链接 `internal/agent/api/ha_codec.{c,h}` → `csrc/`
   文件在包目录内 ⇒ 缓存按内容正确跟踪;只有一份权威源 ⇒ 无副本漂移,
   也不需要「同步 C 源」的 make 目标。

## 不需要额外 build tag

ha_codec 是零依赖纯 C99 源码内联编译,不需要外部库或工具链前提;
而 homed 本就强制 cgo(sqlite3 + gojieba),故 C 路径自然生效。
只用 `cgo` / `!cgo` 一组约束(对比 onnxruntime:那个需运行期 .so,故必须显式 tag)。

## 顺带修掉的既有缺陷(非 C 化引入,但一直缺覆盖)

- Makefile 的 arm64 目标缺 CC/CXX:cgo 回退到宿主 g++,报
  `gcc_arm64.S: no such instruction: 'stp x29,x30,[sp,'`
  (deploy/packaging/build.sh:49 一直是对的,Makefile 漏了)
- Makefile 的 arm64 目标缺 .syso 隔离:cmd/{homed,waiter}/*.syso 是 Windows
  COFF 资源对象,Go 会把同目录 .syso 无条件链进任何目标,交叉到非 Windows
  平台报 `file format not recognized`(build.sh 有 hide_syso_for_target)

## 验证(每条可复现)

- `make build-linux-arm64` → ELF 64-bit LSB executable, ARM aarch64(82MB)
- `bash deploy/packaging/build.sh linux/arm64 homed` → ELF aarch64(79MB)
- 移走 csrc/build/ 后 homed(cgo)与 waiter(CGO=0)均能构建
- 变异 C 源(131072→777)后**同一缓存**下 go test 立即 FAIL(改前:仍报 ok)
- `make check-codec-paths` 两条路径 OK;`make csrc-test` C 契约测试 100%
- 黄金对照:手写用例 + 2000 次随机对拍,C 与纯 Go 逐值相等
- 全量 `go test -count=1 ./...` → 57 包:38 ok + 19 无测试 + 0 FAIL

新增 `make check-codec-paths` 防回归:只测一条路径时,另一条的破坏不会被发现。

## 文档

- `docs/zh/c-core/llm-orchestration-c.md` 同步为「已落地」,并更正因
  「Windows 原生已放弃」而过时的 §2.3(回退路径的理由需重述)
- plan.md 的 P0-1 标记为已修复,附实测证据
2026-09-25 14:45:08 +08:00
39a0c626b6 docs: 补站点基础设施运维手册(不含任何敏感信息)
本项目的生产部署是「多站点 + 统一入口 + 两层 TLS 终止」,此前只存在于
人和 agent 的临时记忆里 —— 换个人接手要重新摸索一遍,我这次就因此在
同一类问题上反复踩坑(误报「已修好」两次、并造成一次全站 TLS 故障)。
把机制写下来。

## 内容

- §0 拓扑:两层 TLS 终止(公网入口机 + 内网网关机),并指出「改一层 ≠ 改完」
- §1 部署一个静态站的完整流程(内网 drop-in → 证书 → 公网 drop-in)
  含若干踩过的坑:为何用 drop-in 而非改生成的主配置、
  为何静态站必须 `try_files ... =404`(回落 index.html 会让不存在的路径
  返回 200,监控与链接检查全都「通过」)、反代为何要关缓冲攒包
- §2 验收:**必须用 `--resolve` / `--host-resolver-rules` 走真实公网路径**。
  内网 DNS 会把域名解析到内网机,直连 curl 测的是另一条路 ——
  这正是我此前误报「已修好」的根因。附 Playwright 片段
  (强调 `ignoreHTTPSErrors` 必须为 false,否则等于没测)
- §3 证书续期:cron → 续期 → 钩子(内网 reload + 推公网),全自动;
  以及**推送脚本必须保留的四道防线**(见下)
- §4 管理后台接口:先取 schema 别猜字段名;路由表是**整表替换**;
  写完要轮询验证(apply 是异步的);系统管理的分组不能手工加条目
- §5 已知陷阱与事故复盘(6 条,均来自本项目真实故障)
- §6 给 agent 的直读入口(llms.txt / 每页 .md,含两个必踩的坑)
- §7 脱敏约定

## 关于事故复盘

§5.1 记录了我这次造成全站 TLS 故障的根因:把 acme.sh 的证书目录名
(**字面含 `*`**)交给了 glob,glob 展开后误匹配到别的证书并写进全局默认
证书。由此提炼两条硬规则:字面 `*` 绝不用 glob;写生产前必须断言
「读到的是什么」。§5.3 记录了「删证书记录前先查全盘引用」——
我漏了 /opt/ 导致推送脚本失效。

## 脱敏

全文无真实域名、IP、token、云 AK/SK、实例 ID 与内部组件名,
一律占位符(`<PUBLIC_IP>` / `<GATEWAY_DATA>` 等),
取值指向本机私有笔记与基础设施面板。已用脚本对 8 类模式做终审扫描:
仅 `127.0.0.1` / `0.0.0.0` / `example.com` 这类结构性取值保留。

技术论断均与线上实际配置或上游源码逐条核对(drop-in 指令、钩子行为、
后台接口语义),文档本身不构成新的未验证声明。
2026-09-24 15:04:02 +08:00
3374e7dbd9 docs: 更正三处无实测支撑的性能断言
用户指出现有文档里的性能数字可疑。逐个实测后发现三类问题,都不加改原文地
标注更正(历史条目保留原文,仓内已有此惯例)。

## ① 「崩溃到恢复 <1s」——从未成立

写于 v1.0.0 发版说明。但**当时的退避代码就已是 1s**(查 v1.0.0 tag 的
`procRestartBackoff = time.Second`),首次重启就要等 1s。

实测(新增临时测试测量 scheduleProcRestart 延迟):

    第 1 次崩溃 → 1s      第 2 次 → 2.001s      第 3 次 → 3.002s

顺带纠正我自己刚在站点写错的阈值:并非「崩 3 次停下」。实测第 **4** 次
才停(`procMaxRestarts=3`,判定为 `n > 3`),前 3 次都会重启。

## ② 「RPC 往返 p50 24.1µs」——量级对、数字不符

实测 `BenchmarkToolInvoke`:inline/small **30.4µs**、frame/small 51.5µs、
inline/large 767µs、frame/large 398µs。原文与实测同为几十微秒量级,
但具体值对不上,且未注明测的是哪种 payload。

## ③ 「CLIP 实测常驻 1.15GB」——采样点不对(6 处)

实测加载 chineseclip 两塔,RSS 会**自己降下来**:

    加载前      0.00 GB
    两塔加载后  1.59 GB   ← 峰值
    GC + 静置     0.89 GB   ← 稳态(内核回收未用页)

1.15GB 落在两者之间,既不代表峰值也不代表稳态。线上稳态实测 0.39~0.58GB
(更长时间静置后更低)。同源问题:qwen3vl 的「常驻 9.4GB」实为**峰值**,
其视觉塔本就是按需加载(源码注释:每张图约 1.6GB,故按需)。

6 处全部改为「稳态 X(峰值 Y)」双值,消除口径歧义:README 中英、
docs/zh/multimodal-space.md、config/registry.go(2 处 + 1 处注释)、
providers/chineseclip/tokenizer.go。

## 验证

- `go build`(含 `-tags onnxruntime` 与不带)与 `go vet` 均通过
- 全仓 `grep 1.15GB` 已清零
- 测量用的临时测试文件已删除,无残留
2026-09-20 19:56:49 +08:00
c0274b71d5 docs: 删除迁移期临时文档,现行内容搬进正式文档
用户指出迁移评估那批是**过程性临时文档**,迁移已完成就该退场。

## 删除(38 个文件)
- docs/zh/架构迁移评估.md(1621 行)—— 评估稿。开头的「❗现网正在发生的问题」
  (output_send 永远成功 / cgo 超时泄漏 26 次 / stage 污染)**全部已修复**,
  留着是误导性告警。其 §三「目标架构」已被 ARCHITECTURE.md 完整覆盖
  (且后者更细,含子进程生命周期管理)。
- docs/zh/plugin-interface-matrix.md(428 行)—— 迁移基线矩阵。
- docs/zh/experiments/(36 文件)—— 18 项可行性实验,验证的是"该不该迁移",
  迁移早已完成;实测无任何构建/测试依赖它。

## 现行内容先搬走(不能随临时文档一起丢)
- plugin-interface-matrix §九「接口扩展规则」→ 搬进 docs/git-branching.md 新增 §八
  (只增不减/签名不改、新增必须"插件调用内核实现"方向、hmapdev 模板必须同步接线
  否则全体插件编译失败、"接口纯追加"≠"无需重编"、合回 main 的同步清单)。
- git-branching §六 原写「接口冻结是合回门禁」—— 冻结是**迁移期**约束,v1.1.x 起
  已到期,改为标注失效并指向 §八。

## 引用清理
8 处引用全部改指现行文档:plan.md ×3、两篇设计文档各 ×1、
4 处源码注释(proc/shm.go、proc/process.go、dynamic_proc.go、entry_dispatch_test.go、
proc/bench_test.go)。仅 third_party(SDK 独立仓)保留 1 处,不动。

## 验证
- `go build ./...` 通过;`go test ./internal/plugin/...` 两个包全绿
- 本项目文档**断链 0**(另 2 处断链在 oh_modules 第三方依赖内)
2026-09-19 19:22:48 +08:00
7213edd181 docs: 全面按当前源码更新文档 + 删除已过时文档
## 删除(内容已落地/已被替换,保留只会误导)
- demo.md ................... failback 与 recoverydiag 均已实现,0 引用
- docs/defect-qq-output-send-loop.md .. 已修复(本身也标了「已修复」),0 引用
- docs/embedding-comparison.md ....... 一次性选型报告,仅被 agent 产物引用
- docs/zh/plan.md ............ 描述的旧 nav 布局已重写、死配置已清,全部完成
- docs/zh/plugin-migration-plan.md ... 迁移已上生产,纯过程稿(Part 0~6 全完成)

## 更新(按当前源码核对)
- assets/docs/{zh,en}/ARCHITECTURE.md(README 指向的用户文档,最重要):
  把只讲 cancel/intercept 的旧「中断机制」章节重写为「输入调度器与中断机制」——
  补上两类别 + 四级中断(L1~L4,默认 L1、外部插件 L4 夹到 L3)+ 抢占/挂起/中断栈
  + 饥饿防护(PreemptCount 提升,封顶 L4)+ 抢占冷却(2s)+ 停止语义(cancelBudget)
  + 驻留子/分诊助手/残余任务;新增「上下文预算」章节(窗口 ≠ 工作面,600K 封顶,
  预算是上限非填充目标)。中英章节数现已对齐(各 13 节)。
- assets/docs/{zh,en}/PLUGIN_DEV.md:插件示例表补 6 个缺失项
  (acp/deepsearch/plugindev/recoverydiag/vanblog/vikunja);qq 工具数 17 → 20(实测)。
- README.md / README_EN.md:补 v1.3.x 线(此前只到 v1.2.0,而 1.3.x 已发布 12 个 patch)——
  驻留式子 agent、输出通道寻址、输入调度器、轻量内核 profile、积压及时反馈。
- plan.md:开头两个「⚠️ 紧急/正在持续污染」是过期告警(实测  残留 = 0),
  改为「已解决」并加文档定位说明;§13 仍是活跃路线图故保留。
- docs/zh/plugin-interface-matrix.md + 两处源码注释:清理指向已删文档的断链。

全仓 md 断链检查:仅剩 1 处,位于 third_party 的 oh_modules(第三方依赖,非本项目)。
2026-09-19 19:14:55 +08:00
943eef01cf feat(resident): 分诊助手定位 + 残余任务由父显式决定
用户澄清(重要定性):这不是「内核替父决定」,而是**及时反馈** ——
主 agent 忙时不该让用户干等十几分钟。子 agent 是**分诊助手**:
简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」、不勉强作答。

三处补齐:

1. 分诊助手的职责提示词(之前完全没给 ⇒ 子不知道自己为什么存在):
   两条路(直接办 / 报忙碌)、拿不准时报忙碌、必须 output_send 到原通道。
2. 驻留子继承父的 SystemPrompt(之前没传 ⇒ 子只用一句兜底文案,
   拿不到「异步通道必须显式 output_send,否则回复被静默丢弃」这条铁律。
   webui 这类同步通道能回是因为走 ResponseCh,掩盖了这个缺陷)。
3. 残余任务由父显式决定(用户要求):reclaim/destroy 时子手头未处理的消息
   不再由内核悄悄处置 —— 内核只负责列清楚,父用 residual=keep/drop 决定。
   之前 pendingEvents 只收带 ResponseCh 的,异步(qq)残余任务完全不在内,
   被销毁时静默消失、用户零反馈且日志无痕。

配套:
- scheduler.takeAllPendingEvents:取走全部未执行事件(不筛通道)
- ApplyResidual(keep|drop):keep 转回父队列(保留 ResponseCh),
  drop 逐条记日志 + 给同步调用方补终态(否则 cli/a2a 永久挂起)
- 状态面暴露 offload_owned,让父分清「我建的子」与「内核临时拉的助手」
- 工具 schema 加 residual 参数并说明 drop 的代价

这也是用户观察到的「机制很自然」的落点:分诊助手就在同一张登记表里,
父能 inspect/send/compress/reclaim/destroy,控制面 6 动作按 id 生效不区分来源。

测试 +7(残余 keep 转回且保留 ResponseCh / drop 通知同步调用方 /
空残余如实报告 / 分诊提示词 / 继承 SystemPrompt),
其中 drop 那条已实测「对着静默丢弃的旧实现会失败」。全套绿。
2026-09-19 17:43:16 +08:00
69446a2649 feat(scheduler): 主 agent 忙时把积压任务自动转投给驻留子
问题(2026-09-19 线上实测):主 agent 被长任务占住时(现场:12 分 8 秒、69 次
工具调用),后来到达的消息全部以 level insufficient 排进中断队列干等 —— 同级
中断不能抢占同级运行任务(canPreempt),只能等前一个跑完。而内核本有驻留子
(独立 agent + 独立调度器)可并行干活。

行为(用户 2026-09-19 明确要求):
- 触发:运行任务持续 > offload_busy_after(5m) 且积压 >= offload_min_pending(3)
- 拉起/复用「转投专用」驻留子,把积压的纯排队输入转投过去
- 在原队列位置留下说明「[系统] N 条积压任务已转投给驻留子 agent X 处理…」

通道配置(按用户口径,与人工创建的子刻意不同):
- 不配 inputch(内核的干活 agent,不接收插件用户输入)
- 持有全部输出通道(结果要能发回 qq/webui 等正确通道)

三个设计要点(都是实测撞出来的,写进代码注释与设计文档 §7.1):
1. 检查必须在**独立 goroutine**:schedulerLoop 同步执行任务,放它里面在
   「正忙」期间根本回不到循环顶部 ⇒ 永不触发(我第一版就写错了,测试才发现)。
2. 只转投 TaskQueued 纯排队输入:中断任务带级别语义、self 任务与父的记忆面绑定。
3. 转投失败/关闭时必须把任务**放回队列前端**:吞一条输入比多处理一条更糟。

这是设计 §7「决策在父的模型手里」的**刻意例外**(父正忙、物理上无法决策,
而积压任务本来就是空的),已在文档中显式记录,且默认关闭、由部署方显式打开。

测试 11 条:只取排队输入 / 不足量不取 / 放回不丢任务 / 说明自解释 / 默认关闭 /
空闲不触发 / 端到端转投 / 上限不增殖 / 独立 goroutine 确实会触发。
2026-09-19 16:59:47 +08:00
c151d391ee docs(release): §四 补客户端版本必须与内核同步 + make 门禁 2026-09-15 11:04:31 +08:00
27a7a3b439 chore(docs): 收编 QQ output_send 循环缺陷记录,标记已由 max_tool_turns 修复
仓库根目录的 problem.md(未跟踪)是一份 QQ `output_send` 回声/无限循环的
定位记录,状态写着「待修复」,但核心早已有轮次上限(core.agent.max_tool_turns,
默认 10,task.go 到达即强制收尾,测试 TestMaxToolTurns_CapsRunawayLoop)。
把它移进 docs/ 并更新状态,避免一份过期结论长期挂在根目录;同时删掉根目录的
临时基准脚本 tmp_fusion.py。
2026-09-14 15:35:31 +08:00
180b96e21a docs: 记下 gitcode 附件的"同名只写一次"硬约束(校验和首次没传全就永远补不回来)
实测证据:同一个名字(ZZprobe.txt)传两次不同内容,下载端始终返回第一次那份;
资产列表里该名字只有一项。删除接口走不通 —— release JSON 不含 `id`,附件列表接口 404,
`DELETE .../releases/<tag>/attach_files/<name>` 返回 400「参数类型错误」(要数字 id)。

后果(1.3.1–1.3.10 全都踩了):首次上传 `SHA256SUMS` 时只有 linux/amd64 四个产物,
之后补 arm64/darwin/win 时"合并后重传"**全部无效** —— 线上那份至今仍是 4 项、带 `./` 前缀,
arm64/darwin/win 的产物没有校验依据。

⇒ 纪律:**打包全部平台后才第一次上传校验和**;分批上传时先传产物、最后传校验和,
校验和只传一次。补救只能换名(`SHA256SUMS.complete`)或重建 release(需重传全部产物)。
2026-09-13 18:50:06 +08:00
8910c8c454 docs: 补两条流水线纪律(脚本必须 set -e;tag worktree 里的驱动脚本)
今天连踩两次,都是"脚本本身"的问题而不是打包逻辑的问题:

1. **没 `set -e`**:`package-windows.sh` 在 tag worktree 里找不到(新脚本只在 main),
   bash 报 No such file or directory 之后**流程照旧往下走**,把只含 4 项的校验和
   传上去覆盖了原本覆盖 10 项的那份 ⇒ 只能把产物下回来重建。
2. **驱动脚本不在 tag 里**:发布件在 tag 的干净 worktree 里构建,而刚补的脚本还没进 tag。
   ⇒ 让脚本支持 `DIST_LINUX` / `BUILD_DIR` / `DIST_RELEASE` 覆盖,用"主仓脚本 + 产物目录
   指向 worktree"来解;文档写清这条约束。

同步进发布技能的同名小节(这两条和"分批上传要全量重算校验和"是同一类:**校验和的完整性
比产物本身更容易被流程吃掉**)。
2026-09-13 18:23:22 +08:00
594496a225 feat(packaging): 补 Windows(WSL) 安装器的驱动脚本,并按变体定向 payload
用户要求:**Windows 的 homed 安装包应当是往 WSL 里安装**。口径本身早已落地
(installer.nsi 注释 + install-via-wsl.ps1 + build.sh 的 WSL 分支),但缺两样东西:

1. **没有驱动脚本**:`build.sh` 里没有 `makensis`,仓库里也没有任何脚本调用它 ——
   build/ 下那几个历史 .exe 是手工打的。新增 `deploy/packaging/package-windows.sh
   <server|client|full> [arch]`:按变体准备 payload、必要时编 waiter.exe、调 makensis、
   把产物落到 dist/release。
2. **payload 不分变体**:`build.sh` 的 `stage_linux_payload` 把 dist/linux 下所有 deb+tar
   全塞进 payload ⇒ 现在 server/full 的 deb 各带 ~719MB 模型,任何变体的安装器都会
   膨胀到 ~2.4GB。而 WSL 侧脚本只取 payload 里的**第一个** `.deb`
   (install-via-wsl.ps1:141)⇒ 按变体只放对应的那一个包。

同时:client/full 需要 Windows GUI payload(HAS_GUI=1),本机无 electron-builder 时
**明确失败并给出命令**,不产出"装完没有界面"的半残包。

docs/git-branching.md §七.5 补上这条口径与三条命令。
2026-09-13 18:13:04 +08:00
56ba1332f3 docs: 补「分批上传时后一轮必须全量重算 SHA256SUMS」
实测踩到:v1.3.10 先传 amd64 的 9 个资产(含只覆盖 amd64 的 SHA256SUMS),
后补 arm64 时按 arm64 那 4 个文件重算 ⇒ 同名附件覆盖 ⇒ amd64 的校验和消失。
校验和是附件的唯一完整性依据,丢了等于没有校验。已同步到技能的同名小节。
2026-09-13 17:35:02 +08:00
d0997f6279 docs(resident): 补写「子的 io 通道视图 = 对父的实时回退」(N3 落地口径)
输出通道在 io 层就是 Device,由插件登记在父的 IOManager 上;驻留子只共享了 inputch
登记表 ⇒ 子侧 childIO 空壳(现场:子调 output_send__cli 被判「通道不存在或不可用」)。
文档写清:继承方式(SetParentIO)、四个受影响的方法、为什么是实时回退而非快照、
以及回退只解决看得见、授权仍在白名单之后。附线上实测结果(result: ok)。
2026-09-13 15:16:58 +08:00
fb2db2c304 docs: 两仓文档对齐当前版本状态 + 补发版产物清单;同步 SDK meta 路牌
用户指出:SDK 版本又带 patch 位、agent 自称 1.0.3、两个仓库文档都没更新。

docs/git-branching.md:
1. **§三 状态表**重写(停在 2026-09-12:main 还写 1.3.0、release/v1.2.x 还被当成"本条发布线、
   尚无 tag"、SDK main 还写 1.2.0、完全没有 release/v1.3.x)。现按事实更新,并写明
   `v1.3.0` 是已撤回的坏 tag。
2. **§2.3** 增补:发布线的 `meta.Version` 必须跟着该线已发的最后一个 patch 走;
   只用 `-ldflags -X` 打版本而不改源码会让二进制与源码对不上账(1.3.1–1.3.4 就是这么打的)。
3. **反例表**补两行:人格文本在**播种时**固化版本(生产实例自称 v1.0.3)、
   发布线路牌不随 patch 推进 / 给 SDK 误发 patch tag。并澄清"插值"必须在**渲染时**,
   把算好的结果固化进配置库与写死没有区别。
4. **§七.5 新增"发版产物清单(可复现)"**:核心仓(tar.gz + 3 个 deb + SHA256SUMS,
   校验和必须在全部产物生成后统一算)、SDK 仓(5 平台 hmapdev + SHA256SUMS)、
   上传脚本的用法与两个坑(release 条目必须先存在;`hmapdev_*` 无扩展名不会被自动识别)。
   起因就是本次"推了 tag 却没建 release、没打包"——推 tag ≠ 完成发版。

third_party/homeagent-sdk/meta/meta.go:镜像 SDK 仓 main 的路牌(1.3.0 → 1.4.0)与
版本语义注释更新(1.3.0 已定版 ⇒ 该号归发布线,main 推进)。
2026-09-13 14:41:24 +08:00
aba1770701 fix(lightkernel): 传统上下文落到实处 —— 子不做策略性裁剪(并修掉裁剪估算的字节/rune 单位混用)
用户指出实现不自洽:"子 agent 是传统上下文,没有裁剪"。查证属实:子仍走
`formatMergedTimeline` 的预算裁剪、也可能走 `pruneOnInput`(既裁剪又向 doc 记忆归档)。
即"动态上下文"(父专属能力)漏进了轻量内核。

## 三处闸门(按 isLightKernel() 判)

1. **拼装预算**:新增 `contextTokenBudget(b)` —— 父用动态上下文的 `ContextTokens`,
   子用**整个窗口** `MaxContext`。两个调用点都改(`stepPrepare`、`rebaseFramePrefix`;
   漏掉后者时 prepare 之后的重建仍会裁,实测就是这么被抓出来的)。
2. **pruneOnInput**:轻量内核前置返回 —— 不做按相关度裁剪、不向 doc 记忆归档。
3. doc 记忆 / 整理流水线:早已由 `a.memory == nil` 关闭(N2c)。

## "不裁"的准确含义

不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个硬上限约束;
且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ⇒
**丢事件的决定权在父**(压缩/回收/销毁),不在内核。文档 §16.0.0 记录三处闸门表。

## 顺带修掉的既有 bug(单位混用)

`formatMergedTimeline` 逐事件估算原来是 `len(e.Source)+len(e.Input)+40` 再 ×2 ——
`len()` 是**字节**,而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍:
实测 2384 字的中文事件被估成 14398 token > 8192,于是窗口还有余量也提前 break、
把更早的事件整段丢掉。改为统一走 `EstimateTokens`(对父同样生效:中文长会话不再被过早裁剪)。

## 测试

- 新增 `TestLightKernel_TraditionalContextNoTrimming`:用**记录模型实收消息**的 provider
  断言"更早的事件仍在"(为什么不看 TaskFrame:`prepareInputTask` 只做前半段,
  消息在 `runTaskSteps`/`stepPrepare` 才拼出来 —— 我第一版断言就打在了空帧上);
  填充量取"超过动态份额、但仍在窗口内",从而能区分父/子两种行为。
- 新增 `TestFullKernel_StillUsesDynamicContext` 作对照(父仍用动态份额)。

全量 go test ./... 37 包 ok / 0 FAIL;-race 干净。
2026-09-13 10:36:04 +08:00
2ebbdadcd6 feat(resident): N3–N7 驻留式子 agent 全量落地(生命周期/双向投递/处理表/contextfull/e2e+压力)
设计:docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。

## N3 生命周期(resident.go)

- `SpawnResident`:主库**受限句柄** + 自己的 temp 实例(`LightMemory`)⇒ 子的轻量内核;
  划入 inputch(登记归属)、授权输出通道、注入任务提示词;为父登记 `child/<id>` 入站 inputch;
  建独立 `IOManager`(共享通道登记表);启动子。
- `DestroyResident`:停子内核、归还划入的 inputch(回到未分配)、丢弃 temp 目录、出登记表。
- `Stop()` → `StopResidents()`:**父退出必须销毁全部子、不留孤儿**(设计 §10 硬约束)。
- `Residents()` 登记表快照;`ResidentTable(id)` 父 pull 子的处理表(不打断)。

## N4 跨 agent 投递

- 子→父:`notify_parent` → 投进父的 `child/<id>` inputch,优先级 **L3**。
- 父→子:`SendToResident` → 投进子的 inputch,优先级 **L4**;
  `isKernelLevelSource` 泛化为"该 agent 的上级"(`AgentConfig.KernelSource`)⇒
  只有父能在子的阶梯上产生 L4(子内部一律 ≤L3)。
- 子的 contextfull → 父侧 `raiseKernelInterrupt`(内核级事件,带子标识,父侧 L4)。

## N5 inputch 处理表

- 子持有;`inputch_note` 主动写**优先**,轮末 `autoRecordInputch` 兜底 ⇒ 每轮必有记录。
- 压缩时清表(表记的是被压掉那段窗口的逐轮处理)。

## N6 contextfull(判据修正)

❗初版判据是"拼好的 `f.Msgs` 估算 > 90% 窗口",**结构上永不成立**:
`buildMessages` 拿到的 `budget.ContextTokens` 由 `targetUsage = 0.8 × 窗口` 推出,
时间线**在拼进消息之前就被预算裁过**,`f.Msgs` 封顶在 ~80% 窗口。
(初版测试用一个比系统提示词还小的窗口才勉强越线 —— 那等于什么都没测。)
现判据 = **未裁剪的积累上下文**(`a.context.Recent(0)`)超过窗口 90%:
它超过就说明下一轮必须丢事件,这正是"上下文满"。

三处置:`CompressResident`(保留语义:`TrimKeepRecent` 保留最近 N 条 + 清表)/
`ReclaimResident`(取消语义:`ExportTriples` 读 temp → 父选出要保留的 → `Commit` 进 main → 取消该子)/
`DestroyResident`(立刻销毁并移除)。

## 工具面

`resident_agents`(父,单工具多动作:list/create/send/inspect/compress/reclaim/destroy)、
`notify_parent` + `inputch_note`(子)。声明条件式:父才有前者,子才有后两者。

## 验收

`resident_test.go` 五项:生命周期与不留孤儿、双向投递(含"子的主动消息不得以 L4 出现")、
处理表(自动写 vs 主动写优先)、contextfull + 三处置、
**压力 8 子 × 12 轮(父→子 L4 与普通输入各半)+ 双向汇报 + 父退出清理**。
全仓 go test ./... 37 包 ok / 0 FAIL;`-race`(agent/memory/plugin)干净;
压力 `-race -count=3` 通过。
2026-09-13 10:20:06 +08:00
48cfa8fb6c feat(lightkernel): N2c —— 轻量内核 profile(窄接口 GraphMemory + nil 即禁用整理面)
按用户指出的关键点(a.memory 多数使用点属"主 agent 整理记忆"与"记忆整理流水线",
子不该有那些路径)实现,方案见设计 §16.0。

## 窄接口:只有 Recall + Commit

新增 `GraphMemory` 接口(memoryface.go)—— 按调用方实测分类后,真正"根与子都要"的只有这两个:
- `Recall`:上下文检索 / memory_recall
- `Commit`:自动写入路径的图部分 / memory_commit

新增 `Agent.graph`(共同面)与 `Agent.graphMem()` 访问器:
- `graph` 显式为 nil 时**回落**到 `memory` ⇒ 既有"只用 Agent 字面量设 memory"的测试无需改动
  (原本会出现"必须同时设两个字段"的脚坑,实测踩到后去掉了)
- 轻量内核:`graph = *memory.LightMemory`,`memory = nil`

## nil 即禁用:整理面自动消失,不需要受限包装

子的 `a.memory == nil` ⇒ 既有的 22 处 `if a.memory != nil` 关卡自动禁掉全部整理面:
- 记忆整理流水线(distill.go 的 archive/review/merge 循环)
- 记忆块 + 媒体桥(graphmedia.go / medialoop.go)
- 记忆整理工具(memory_merge / memory_delete_entity / memory_block_merge /
  memory_purge / memory_edit / memory_introspect)—— 它们本就在 `if a.memory != nil` 块内

唯一拆开的一处是自动写入 `commitTriplesWithMedia`:
图部分走 `graphMem().Commit`(父落 main、子落 temp),块/媒体部分仍由 `a.memory != nil` 守卫。
`executeMemoryTool` 的读路径改走 `graphMem().Recall`;整理类 case 加 `requireFull()` 闸门,
被直调时明确报"本 agent 是轻量内核:记忆整理不可用",不静默降级。

## 验收(3 项新测试)

- `TestLightProfile_MemoryFaceWiring`:整理面为 nil、写入只落 temp(主库无子痕迹)、
  读是并集(主库实体 + temp 实体都看得到)
- `TestLightProfile_OrganizeToolsAbsentAndRefused`:整理类工具**不进工具表**;
  即便被直调也明确报"轻量内核不支持"
- `TestFullProfile_KeepsOrganizeFace`:对照,根 agent 仍保留整理面与整理工具

全仓 go test ./... 37 包 ok / 0 FAIL;-race(agent/memory)干净;gofmt 干净。
2026-09-13 10:02:02 +08:00
c5b1242980 docs(resident-subagent): 更正 N2c 方案 —— 窄接口(只剩 Recall/Commit)+ nil 即禁用
用户指出:a.memory 的多数使用点属于**主 agent 整理记忆**与**记忆整理流水线**,子根本不该有那些
代码路径。据此把上一版"约 20 方法的接口 + 受限包装"改成**实测分类 + 窄接口**。

按调用方实测分类(42 处):
- A 记忆整理流水线(distill.go 10 处:archive/review/merge 循环)→ root-only
- B 记忆块 + 媒体桥(graphmedia.go 18 + medialoop.go 4)→ root-only
- C 记忆整理工具(executeMemoryTool 8 处:merge/delete/block_merge/purge/edit/stats)→ root-only
- D 共同面:**只有 Recall + Commit**(自动写入路径 + memory_recall 工具)
- E 22 处 if a.memory != nil 既有关卡 + 状态/工具表判空

更正后的方案:
- `GraphMemory` 接口**只含 Recall + Commit**
- 根:a.graph = a.memory = 同一个 *GraphDB
- 子:a.graph = *LightMemory,**a.memory = nil** ⇒ 既有 22 处 nil 关卡自动禁掉全部 root-only 路径
  (executeMemoryTool 开头本来就是 `if a.memory == nil { return "图记忆系统不可用" }`)
- 唯一要拆的:自动写入 commitTriplesWithMedia(图部分走 a.graph.Commit;块/媒体部分用 a.memory != nil 守卫)
- 整理类工具**不进子的工具表**(而不是进去再报不可用)

⇒ 不必写"18 个方法都返回错误"的受限包装 —— 子压根没有那些路径。
2026-09-13 09:42:43 +08:00
818ce2698f feat(memory): N2a 第一块砖 —— 主图记忆的受限句柄(query_only),子是"读得到写不进"
按用户确认的形态:**独立存储实例**(不是给共享记忆层加 space 列)。

## 结构性保证

新增 `OpenGraphDBReadOnly(path)`:以**受限句柄**打开图库 ——
连接保持正常打开能力(可读、可恢复 WAL),但 `PRAGMA query_only=1` 让
任何 INSERT/UPDATE/DELETE 被 SQLite **直接拒绝**。

为什么不用 DSN 的 `mode=ro`:只读连接在 WAL 库上无法自行恢复 -wal,
而主库在父 agent 手里是持续写入的。query_only 只堵写、不堵读,语义正好。

⇒ "子改不了主记忆"是**结构性**的,不靠调用方自觉;也不建表、不迁移
(库由父建好,受限句柄不会凭空造出一个空主库)。

## 设计文档

新增 §5.6「实现形态:独立存储实例(不做 space 列)」,写明轻量内核的记忆装配:

    子的轻量内核
    ├─ temp 图记忆实例(独立存储,读写)  ← 与子同生共死
    └─ 主图记忆的受限句柄(只读)
    子的查询 = 两个实例各查一次 + 应用层合并(并集)
    回收时由父读 temp、选记录、写进主图记忆

并记录用户给的备选简化:`AllowTempGraphWrite` 开关(默认开);设为 false 时
子对图记忆完全只读,没有 temp 实例、没有合入。

## 验收

`internal/memory/graph_readonly_test.go`(2 项):
- 受限句柄读得到、写被拒(Commit/Purge 双双报错),且**主库不留痕迹**
- 库不存在时受限句柄的查询报错,而不是凭空建表后返回空结果

全仓 go test ./... 37 包 ok / 0 FAIL。
2026-09-13 09:32:29 +08:00
36e7556a02 docs(resident-subagent): 子的记忆面收窄为「传统上下文 + 图记忆」
用户澄清:**doc 记忆**与 **context 动态上下文**是内核独立设计的记忆能力(父专属),
子 agent 的记忆面只有 **图记忆**。据此更正设计:

- §5 开头加适用范围界定:两级空间(读 temp∪main / 写 temp)**只针对图记忆**
- 新增 §5.5「子的记忆面」对照表:
    | 能力 | 根 | 驻留子 |
    | 图记忆(含向量检索) | ✅ main 读写 | ✅ 作用域化 |
    | doc 记忆 / context 动态上下文 / 蒸馏 / 归档 / consolidation / 文本 / 知识库 / 媒体 / 社交 | ✅ | ❌ 父专属 |
- 修正「轻量内核」的表述:不是"记忆变轻",而是**记忆面裁到只剩图记忆 + 图记忆被作用域化**
- §16.1 记忆面清单加「子可用?」列;**v1 只给图记忆(含其向量检索)加 space 维度**
  —— 其余面子根本够不到,加 space 是白工
- 里程碑:N2a = 图记忆 space 维度;N2b = 图记忆的向量检索接入同一过滤
- 决策表补 R3b

纯文档更正;全仓 go test ./... 37 包 ok。
2026-09-13 09:24:09 +08:00
069552e921 docs(resident-subagent): 把 N2 拆成 N2a–N2d(记忆作用域逐面铺开)+ 记忆面清单
N2(轻量内核 + 记忆作用域)是目前最大的一块:记忆子系统有 7 个面
(图记忆/向量索引/文档/知识库/文本/媒体/社交),每个面都要加 space 维度。

拆成可独立验收的四步:
- N2a 作用域对象 + **图记忆** space 维度(先做这一条纵切)
- N2b 其余记忆面加 space(逐面验收)
- N2c Agent 级作用域接线(根 = {main,[main]};驻留子 = {sub/<id>,[sub/<id>,main]})
- N2d 晋升与丢弃(回收时父把选中的 temp promote 进 main;销毁/回收丢弃 temp)

文档新增 §16.1 记忆面清单(面 → 载体 → 表),说明为何先做图记忆这条纵切。
2026-09-13 09:15:58 +08:00
f7c3a4e81d feat(channel): N1b —— 输出通道授权集合(三处过滤一致)+ 输出通道→目标 agent 的 inputch 解析
设计:docs/zh/resident-subagent-design.md §4.5(里程碑 N1b)。

## 输出通道授权集合(默认完整授权,父可收窄)

`AgentConfig.AllowedOutputs`(nil/空 = 完整授权)。三处过滤点必须一致,
否则会出现「列表里看不到、按名字还能调」的裂缝:

1. **工具表**:不为未授权的通道生成 output_send__X(模型看不到就不会调)
2. **列表工具**:output_list_channels 只列授权的
3. **调用点**:凭名字直调未授权的输出门必须被拒(纵深防御)

## 输出通道 → 目标 agent 的 inputch 解析

`ChannelRegistry.BindOutputTarget / ResolveOutputTarget`:
把输出通道解析成「目标 agent + 目标 inputch」,这是"输出可寻址到具体 agent"
(子→父、父→指定子)的**数据面**;真正的跨 agent 投递在里程碑 N4。
未登记的输出通道 ok=false —— 表示由传输层 device 自行处理(qq/webui 这类)。
已登记目标的输出通道,在 output_list_channels 里会标出「目标: <agent> / inputch <名字>」。

## 验收

`internal/agent/core/output_grant_test.go`(3 项):
- 默认完整授权:全部输出门生成 + 列表含全部
- 白名单收窄:三个过滤点同时生效(工具表 / 列表 / 直调被拒)
- 目标解析:绑定/解析、未登记由传输层处理、列表标出目标、空名报错

全仓 go test ./... 37 包 ok / 0 FAIL。
2026-09-13 09:13:19 +08:00
3c961b7bd7 feat(channel): N1a —— inputch 一等化(归属插件/归属 agent/容量/共享登记表)+ 单工具多视图总览
设计:docs/zh/resident-subagent-design.md §4.6(里程碑 N1a)。

## 用户要求

「父 agent 可以看到所有已注册的 inputch 以及 inputch 的划分情况,用**单工具多视图**方式构筑」

## 登记层(internal/agent/io/inputch.go)

inputch 是**最基本的输入路由单位**(由插件注册,一个插件可注册多个),
所以登记表以 inputch 为键,每条记录:

    名字 / 归属插件 / 归属 agent(被划给谁) / 容量 / 默认回程 outputch / 记忆策略(ChannelDef)

- 新增 `ChannelRegistry`,设计成**可共享对象**(`*ChannelRegistry`):
  根 agent 与驻留子共用同一份,"划入/授权"才有意义;`SetChannelRegistry` 注入。
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
  保留已有 Owner/Capacity/Output(否则一次 reload 就把父做的划分清空)。
- `Assign` 语义按设计 R6 默认:**读写授权,不转移所有权**(Plugin 与 Owner 分别记录)。
- 原 `inputChannels map[string]ChannelDef` 被登记表取代;`GetInputChannelDef` 保持兼容。
- `plugin.Registry` 注册时带上**归属插件名**(此前完全无归属信息)。

## 总览工具(internal/agent/core/inputch.go)

单工具 **`input_channels`** + `view` 参数(不是一堆小工具):

    all(默认)= 全部已注册(带归属插件)
    mine       = 划给本 agent 的
    unassigned = 尚未划出的
    by_agent   = 划分情况总览(按归属分组)
    detail     = 单个 inputch 全字段(需 name)

未知 view **报错并列出可用值**(拼错不得被静默当成默认视图);登记表为空时明确说明。

## 验收

- `internal/agent/io/inputch_test.go`:归属记录、重载保划分、Assign/视图数据面、
  **跨 manager 共享登记表**、策略查询向后兼容 —— 5 项
- `internal/agent/core/inputch_test.go`:单工具多视图逐视图断言(含未知 view 与空表)—— 2 项
- 全仓 `go test ./...` 37 包 ok / 0 FAIL;`-race ./internal/agent/... ./internal/plugin/...` 干净
2026-09-13 09:06:50 +08:00
3956610134 docs(resident-subagent): inputch 是最基本的输入路由单位(插件可注册多个)
按用户补充收窄定义:
- inputch 是**最基本的输入路由单位** —— 路由粒度到此为止(比「插件」细、比「通道名字符串」实)
- **由插件注册,且一个插件可注册多个**(登记接口即现有 RegisterInputChannel,
  调 N 次就是 N 个 inputch;同一插件的多个 inputch 彼此独立,可绑给不同 agent、可分别限额)
- 「划入输入通道」的单位 = **inputch**(不是插件、不是通道组)
- 通道注册层以 inputch 为键(一个插件 → N 个 inputch),每个 inputch 带
  归属/被划给的 agent · 容量 · 可接收类别 · 输出目标解析

新增测试点 S21(同一插件两个 inputch 分别划给父与子,互不串台)。
2026-09-13 08:40:04 +08:00