Commit Graph

158 Commits

Author SHA1 Message Date
61a871cf88 feat(qq): 在线状态随内核阶段自动切换 + 手动设置工具
## 用户要的两件事

1. 「在不同内核工作阶段自动切换 QQ 状态,比如 toolcall 阶段切忙碌中、
   生成文字阶段切思考中」
2. 失败不阻塞、不重试、只写日志(完全非致命错误)

## 取值以 NapCat 源码为准(不是我猜的)

我最初拿 `status=11` 试探,NapCat 返回 retcode=0 **但那是无效状态**
(它不校验)。拉了源码 `SetOnlineStatus.ts` 的 actionDescription 才拿到
权威取值表:

	在线      status=10  ext=0        忙碌   status=50  ext=0
	离开      status=30  ext=0        请勿打扰 status=70  ext=0
	隐身      status=40  ext=0        搬砖中  status=10  ext=2023
	学习中    status=10  ext=1018     听歌中  status=10  ext=1028

## 阶段映射

	StageBeforeToolcall → 搬砖中(status=10, ext=2023)
	StageBeforeOutput   → 学习中(status=10, ext=1018)★ QQ 无"思考中"预置项
	StageAfterOutput    → 在线  (status=10, ext=0)

`after_toolcall` 不切:工具刚结束还要继续,频繁切换只会闪。

## 失败处理(用户明确要求的设计)

三个钩子**永远 return nil**,不重试:
 · 不返回 error ⇒ 内核不会因状态失败而中断工具调用或改写回复
 · 不重试 ⇒ NapCat 掉线时不会每轮打 HTTP + 打日志
 · 失败只在 applyStatus 记日志(首次 + 每 10 次),不刷屏

## 其它

 · 默认**关闭**(`status_auto`):改 QQ 状态对外可见,属于"内核替用户
   做决定",与 offload 同类的显式特权。
 · 手动工具两个:`qq_set_status`(预置状态,收中英文名)、
   `qq_set_diy_status`(自定义 face_id + wording)。
 · 防抖:同状态跳过、最短间隔 2s —— stage 每轮都触发,不防抖会打满 NapCat。
 · `Stop()` 里恢复在线:否则停机时停在"搬砖中",用户看到一个永久错位
   且不知道是谁改的状态。★ 恢复判据用**实际应用过什么**(applied),
   不能用 status_auto —— 手动工具设的状态在开关关闭时也要能恢复。

## 判据(5 条,变异 2/2 咬)

 · 指向必拒绝的地址时三个钩子仍返回 nil(不阻塞)
 · 开关关闭时完全不发请求
 · 状态名归一化(含中文别名)
 · 阶段映射用**权威取值**(忙碌必须是 50,不是 11)
 · 未知状态名被拒且不发请求

## 实机验证(生产 NapCat 192.168.2.106:25570)

	qq_set_status busy    → map[ext_status:0    status:忙碌   status_num:50]
	qq_set_status working → map[ext_status:2023 status:搬砖中 status_num:10]
	qq_set_status online  → map[ext_status:0    status:在线   status_num:10]
	qq_set_status 乱写的   → 未知状态名: 乱写的(拒绝,未发请求)

注:`get_status` 只返回 online/good,不回显具体状态 —— 那是该接口的
特性,不是设置失败。
2026-10-18 14:34:17 +08:00
74e2c654ed feat(sdk): 22 个示例插件配齐 skill + 三态启动声明 + 打包期校验
## 用户的三条要求

1. 「所有插件都默认自启会占启动时间,还在 llms 上下文塞大量工具」
   ⇒ 纯工具型改为按需加载,靠 skill 让 agent 知道要先拉起。
2. 「会向 agent 注入输入的部分插件不能这样做」
   ⇒ 9 个必须常驻,其余 13 个按需。
3. 「sdk 是不是没有暴露插件声明自身行为的方案」
   ⇒ 对:auto_start 只在 internal/plugin/manifest.go 有,SDK 零命中。

## 定级依据(代码证据,不是猜描述)

注入 / 钩子 ⇒ 必须常驻(9 个):

	qq         HTTP 监听 NapCat 推送,消息到达要立刻注入
	rss        轮询订阅源,新文章主动推送
	calendar   30s ticker 检查提醒
	memo       5min ticker 检查待办到期
	a2a        TCP 监听外部 Agent 请求
	acp        TCP 监听 ACP 客户端
	browser    ticker + 长命令完成回调
	sanitizer  注册 stage 钩子(无工具,要每轮生效)
	luademo    注册 stage 钩子做示范

纯工具型 ⇒ 按需(13 个):
	weather ocr music bili ai_image files editdoc deepsearch
	plugindev recoverydiag vanblog vikunja commondev

★ commondev 值得单独说:它虽然有 6 处 Inject 调用,但**注入源是自己的
sidecar 完成回投**,而 sidecar 只在 commondev_agent_up 时才拉起
(Start() 里不启 sidecar,通道也在 agent_up 时才注册)。所以没有 agent
调用它时,它不需要做任何事 —— 可以按需。44 个工具 ≈726 tokens 省下来可观。

## 改动

 · 22 个插件各配一个 skill,写明:何时用、先 plugin_start、
   工具清单、**用 plugin_stop 而不是 plugin_disable**
 · 13 个 plg.json 加 `auto_start: false`
 · `sdk/autostart.go`:把三态语义与"按需插件必须有 skill"的理由写进公开 SDK
 · hmapdev 打包期校验:`auto_start=false` 但无 skill/ 目录 ⇒ 直接 exit 1
   (理由:缺 skill 的按需插件**等价于没装**,比没装更糟 —— 占着磁盘让人以为可用)
 · 修 Lua 打包路径:它**完全不走** rawfile/skill 收集,luademo 的 skill 永远打不进去
 · 修 build-examples.sh:原来 `[ ! -x ]` 判"二进制在不在",改了 hmapdev 源码后
   重新打包**仍用旧工具链**(实测:luademo 的 skill 一直是 0)
 · 补 mocksdk.ToolDef.Timeout(上一轮加 ToolDef.Timeout 时漏了镜像)

## 判据

 · TestHasSkillDirRequiresRealSkillMd(空目录/.gitkeep/无 SKILL.md 都不算)
 · TestBuildRejectsOnDemandWithoutSkill(打包期必须真拦住 + exit 1)
 · TestLuaPackagingCollectsSkillAndRawfiles(**验代码行**,不验注释 —— 第一版
   只查符号存在,把 createHmapWithRaw 换回 createHmap 判据照样通过)

## 踩过的坑

 · Lua 分支的结束边界原是 `// Resolve build config`,但那个注释在文件里
   **出现多次**(Go 路径里也有),用它截取会把 Go 分支一起包进来 ⇒
   变异测不出。改成截到 Lua 分支自己的 `return` + `}`。
2026-10-18 13:21:47 +08:00
e1b458a710 fix(commondev): interrupt 的"是否真中断"判定改用 pi 的 isIdle + 真机 e2e
## 找不到的缺陷(变异测出来的)

静态判据全绿、真机也报 interrupted=true —— 但我把 sidecar 里的
`await session.abort()` **删掉**后,判据照样通过。原因:

	const hadInflight = inflight > 0;
	...
	interrupted: hadInflight,      // ← 只看"中断前有没有在跑"

任务还在跑 ⇒ inflight 仍 >0 ⇒ 照样回报"已中断"。**那正是最坏的情形:
用户以为停了,任务还在改代码、推分支。**

## 第二版又反向错了

改用"中断前后 inflight 的变化":

	interrupted: before > 0 && inflight < before

实测 before=1 / inflight=1 ⇒ 误报"未中断",而任务其实已经停了。
原因:inflight 是**包装层**计数,只在后台 promise 的 finally 里递减,
而 abort() 返回时那个 promise 还没收尾。

反向的谎报同样有害:用户以为没停,重复发一遍任务。

## 最终:用 pi 自己的 isIdle

	session.isIdle 是 pi 的权威视图("当前没有在跑的任务")
	interrupted: !wasIdle && session.isIdle

实测应答:{"ok":true,"interrupted":true,"was_idle":false,"now_idle":true}

## 真机 e2e(3 条,HA_PI_E2E / HA_ZCODE_E2E 控制)

 · TestRealPiInterruptStopsRunningTask
   真 pi 会话跑 60 步任务,12s 后中断 → interrupted=true,且会话仍可用
 · TestRealPiSameTaskWithoutInterruptStillRunning ★对照组
   同样任务不中断,15s 时仍在跑 ⇒ 证明"已中断"不是"任务碰巧跑完了"
   实测不中断时 >100s 仍未完成
 · TestRealZcodeInterruptKillsChildAndClearsHandle
   假 CLI(持续输出的 node 脚本)→ interrupt 真的 killed_pid,
   第二次 interrupt 回 no_running_turn(句柄已清,不误杀下一轮)

## 变异(这次全咬)

 删掉 await session.abort()             → 咬(now_idle=false 暴露任务仍在跑)
 把 interrupted 改回 hadInflight(代码行) → 咬

★ 静态判据里查 hadInflight 时**只查代码行、跳过注释** —— 全文 Contains
  会把解释性注释当成缺陷(实测误报一次)。

## 顺带

两份 pi sidecar(工程根 = 单测/SSH e2e 用,rawfile/ = 部署用)必须
逐字一致:既有判据 TestSidecarScriptShipsWithPackage 会比对。
2026-10-18 10:52:02 +08:00
330c0c9246 test(commondev): 守住 rawfile/ 不进库却决定包能否运行的隐患
## 背景

`example/commondev/rawfile/.gitignore` 写着 `*`:rawfile 里是随包资源
(可能是上百兆 node/pi 二进制),进版本库会让克隆变慢、每次升级产生
巨型 diff。**这是设计意图**,不是疏忽。

但代价很具体:

	本地打包  → 包里 571 个 runtime 条目(工作区已备好资源)→ 冒烟通过
	CI  打包  → 包里 0 个 runtime 条目(干净克隆没有资源)  → 插件起不来

而两次构建的**日志都写着"成功"**。这类"本地绿、别处坏"我们吃过一次:
TestHmapdevTemplateImplementsAllPluginMgrMethods 读了 gitignore 的文件,
本地绿、CI 红。

本次尤其危险:interrupt 的实现**同时**落在 sidecar.mjs(已进库)和
rawfile/runtime/*/sidecar.mjs(未进库)两处。只改前者,判据全绿、
包里的 sidecar 却还是旧的 —— 取消能力在发布版里根本不存在。

## 判据做什么

不能要求 rawfile/ 必须有内容(干净克隆本来就没有)。但可以:

 · 有内容时,每个 CLI 目录必须有 sidecar.mjs 或 driver.json
 · pi/zcode 的 sidecar **必须**支持 interrupt
 · 没有内容时,用 t.Logf **大声说明**"此状态下打出的包不含 sidecar",
   让这件事出现在测试输出里,而不是静默

## 变异

删掉 rawfile/runtime/pi/sidecar.mjs 的 interrupt case →
判据报"打包后 commondev_agent_interrupt 会失效(源码改了但资源没同步)"
2026-10-18 10:18:40 +08:00
28c162cf81 feat(commondev): 支持取消正在运行的任务(commondev_agent_interrupt)
## 缺陷(用户实测反馈)

「commondev 无法取消正在运行的 session,agent 反馈它无法取消错误的
pi session,只能等它跑完。」

日志佐证:agent 在 09:07 收到 interrupt 请求后只能如实回答无法取消。

## 根因不是"没实现取消",而是取消能力从来没被暴露

pi 的 AgentSession **本身就有** abort()(agent-session.d.ts:598,
"Abort current operation and wait for agent to become idle")。
但 commondev 注册的 30 个工具里一个 cancel/stop 都没有,
sidecar 也只有 ping/prompt/state/close 四个 method。

这是异步投递(2026-10-17)带来的新缺口:改动之前 prompt 是同步的,
调用方阻塞到任务结束,没有"中途要停"这个需求;改成异步投递后,
任务在后台长跑,用户却失去了停止它的手段。

## 改动

**sidecar 三个平台各有各的取消方式:**

	pi    → session.abort()(协作式中断,必须 await:不 await 会立刻
	        回"已中断"而实际还在跑 = 谎报成功)
	zcode → 整组杀掉当前回合的子进程(没有 abort API)
	        spawn 时 detached=true 建了独立进程组,killTree 整组杀

**interrupt 语义如实回报:**

	interrupted=true   确实有任务在跑并被终止
	interrupted=false  当时没有在跑的任务(不算错误)
	ok=false + error   该 CLI 没实现 / 调用失败

**三个收尾分支都清 currentChild**(timeout / error / close):
不清的话 interrupt 会拿到已死的子进程去杀,或更糟 —— 误杀下一轮。

## 判据(4 条 + 「不谎报」)

 · pi 的两个 sidecar 都有 interrupt 且用 await session.abort()
 · zcode 的 interrupt 真的 killTree 且用 currentChild
 · 三个 sidecar 无重复声明(我写的时候真撞了:zcode 已有 currentChild)
 · **不谎报成功**:sidecar 没跑 / CLI 没实现时必须 interrupted=false

## 变异验证

 pi 删 interrupt case → 咬
 pi 去掉 await         → 咬("会谎报已中断")
 zcode 不 killTree     → 咬("只回 ok 不真杀")

## 顺带

既有判据 TestEveryRegisteredToolHasHandler 立刻抓到我加了 RegisterTool
但没在 handlerFor 映射里登记 —— 它比我先发现漏了接线。
2026-10-18 10:17:08 +08:00
e82d21d633 fix(ci): 发版前校验分支名与 meta.Version 一致(防开发阶段误发版)
## 事故(2026-10-17)

`release.yml` 的触发条件是 `push: branches: ['release/**']`,
而 tag 名 = `v` + `meta.Version`。于是:

	2026-10-17 08:35  github-actions[bot] 打出 v1.4.0 并发布 Release

而 1.4.0 当时是**开发阶段**。起因只是有人(我)为了走 PR 流程建了一个
release/v1.4.x 分支并推送 —— 那一刻就自动发版了。

更麻烦的是它钉在旧提交 c2474f5 上:之后又合了三个修复
(异步投递、台账回填、示例 go.mod 修复),已发布的 1.4.0 里**没有**
这些修复,而 Release 页面看起来是"最新"。已按要求撤掉该 tag 与 Release。

## 改动

`prepare` 阶段在读出 meta.Version 之后,校验分支名:

	release/vX.Y.x  → meta 必须是 X.Y.*   (中版本长跑线)
	release/vX.Y.Z  → meta 必须恰好 X.Y.Z

不一致直接 exit 1,并提示"开发阶段请勿推 release/**"。

## 判据(2 条)

 · `TestReleaseWorkflowGuardsVersionMismatch`:逐形态断言两个分支形态
   都有拒绝逻辑 + 有 exit 1
 · `TestNoStaleReleaseTagForDevVersion`:记录当前 meta.Version 是开发版本

★ 判据写砸过一次并修好:第一版用
    `if !contains(A) && !contains(B) { fail }`
  —— 删掉其中任意一处描述文案,另一处还在,判据仍通过 ⇒ 变异不咬。
  改为**逐形态断言**后,两个变异都准确报出是哪个形态缺保护。
2026-10-18 08:05:32 +08:00
c9b7a6534c fix(commondev): 完成通知回填投递台账(原先永远停在 accepted)
## 缺陷(2026-10-18 本机实例日志实测)

异步投递上线后,台账里的记录全是 accepted、result 永远为空:

	07:39:09 accepted result=None
	07:40:09 accepted result=None
	07:41:18 accepted result=None     ← 13 条全是

而回投本身**是正常的**([plugin] 日志有 5 次 `回投任务结果…event=prompt_done`)。
缺的是:完成通知到达后没人把它写回台账。

实测后果(同一天日志,agent 自己起的轮询):

	[timer] firing: 【轮询轮次 2/6】…检查是否有新 result 回填…

agent 为此安排了 6 轮 timer 去等一个**永远不会出现**的回填,白等 30 分钟。
更根本的是:看板与任何查询方都拿不到任务结果。

## 修法

`completeDelivery`:把完成/失败结果回填到该 branch 下**最新一条**
未回填的 accepted。`recordDelivery` 改为返回 key(追溯用)。

匹配用"最新未完成"而不是按 key:完成通知里没有 key(sidecar 不知道它),
而同一 branch 的投递是串行投给同一个 pi 会话的,最新未完成那条就是它。

## 判据(6 条)

回填最新 / 不串 branch / 不覆盖已回填 / 无匹配不 panic / 返回 key /
**完成通知真的调用了回填**(接线判据)

## ★ 写判据时踩的两个坑(都记在注释里)

1. **判据没覆盖接线 → 变异不咬**:前 5 条测的是 completeDelivery 自身行为。
   把 sidecar.go 里**那一行调用删掉**,它们依然全绿 —— 而"函数写得对但
   没人调"正是原始缺陷的形态。补了第 6 条读源码验接线。

2. **偏移错位导致假绿**:分段用 `seg := block[k:]` 后找 "case ",会命中
   k 自己(n=0)⇒ seg 不截断、含整个函数剩余部分 ⇒ done 分支删掉回填也照样
   "找到" error 分支那一行。改成跳过 `case "x":` 本身的长度再找。
2026-10-18 07:53:45 +08:00
4387e12ca8 fix(commondev): 投递任务改成真异步,不再同步等 pi 跑完
## 缺陷(用户质疑"投递不就是发一条消息"后实测确认)

`deliverToPi` 的注释写着"把给 pi 发消息包装一下",但实际是:

	await s.prompt(text)   // 同步等 pi 把**整个任务**跑完

而 pi/zcode 开工程线干活(克隆/改代码/跑测试)要几十秒到 20 分钟。
生产日志实测两次:

	23:04:03  executing tool: output_send__commondev_br-...
	23:05:03  ★ timed out after 60s

通道的语义是"**投递**任务"(见 outputch 描述),不是"等完成"。
把它实现成同步等待,就必然撞上内核的 60s 工具超时。

★ 我一开始的诊断是错的:以为"超时值太小",给 output_send 声明了 3m
  —— 那是掩盖症状。投递本该瞬时返回,慢是 bug 不是需求。已撤回。

## 改动

	投递 → 立即回 status=accepted(瞬时)
	     → pi 在后台跑
	     → 跑完推**主动通知**(id=0)
	     → Go 侧 onNotify 经 inputch 注入回 agent

`sidecar.go` 的 readLoop 里早就留好了这个扩展位:
    if resp.ID == 0 { // 主动通知(暂未使用,保留扩展位)
现在接上了 `makePromptNotifier`。

pi 与 zcode 两份 sidecar.mjs 都改了(rawfile/ 里那两份同步更新)。

## 判据抓到我三次(都是真问题)

1. `TestSidecarScriptShipsWithPackage`:rawfile/ 与工程根不一致
   —— 我只改了根那份
2. `TestZcodeMissingCLIErrorIsActionable`:`inflight is not defined`
   —— zcode 版用了未声明变量
3. 同一条判据随后**卡死 2 分钟**:CLI 不存在本该立刻报,异步后错误
   被吞进后台通知 ⇒ 用户先收到"已受理"、几秒后才知道配置错。
   改成**受理阶段就校验 CLI**(配置错是立刻可判定的,不该推迟)。

判据还逼我保住语义:原 `TestZcodeSidecarArgvBehavior` 断言首轮拿到
真实 `sess_` id,异步后要到完成通知里才有 —— 断言保留、改从通知取,
没有削弱。

## 真机 e2e(三条全过,都要求真改工程文件)

	TestZcodeFullPipeline              42.1s
	TestZcodeOverSSH                   72.4s
	TestZcodeOverSSHWithPerHostEndpoints 75.1s

受理现在是瞬时的 ⇒ 内核工具不会再 60s 超时。
2026-10-18 06:39:24 +08:00
81a3efa4df feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)
* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:44:15 +08:00
65d2bd9e61 fix(qq): NapCat 确认超时提示「不要盲目重发」,版本 1.4.2 → 1.4.3
生产实测(2026-10-13):一次回复发出 4 条相同消息,日志中 3 次
output_send__qq 的调用 id 各不相同 —— 是模型看到「发送失败」后自己重试。

根因(在 NapCat 运行代码 /app/napcat/napcat.mjs 中确认):
sendMsg 把消息交给 QQ 内核后,等 onMsgInfoListUpdate 事件匹配
guildId + KSEND_STATUS_SUCCESS,内部固定等待 10s(附件更长)。
主机 IO 饱和(iowait 94–96%)时主线程被 10–30s 长任务阻塞,回调排不上队,
于是 NapCat 抛 "Timeout: NTEvent ... sendMsg" → 非零 retcode(实测 200)。
但消息**已被 QQ 送达**:同一窗口内确有 16 条「发送 -> 私聊」成功记录。

这与已修的 HTTP 客户端超时是两条不同路径:
- HTTP 超时:client.Post 返回 err(bd26c2a 已告警)
- NapCat 超时:HTTP 200 + 响应体带 NTEvent 字样(本次补)
只报「失败」不说「可能已送出」,模型必重发。

改动:
- napcat() 对发送类动作(napcatSendActions)的 NTEvent 超时包装
  errNapcatTimeout,并附「很可能已送出、不要盲目重发」的告警;
  查询类动作保持普通失败(可安全重试)。
- 不按 retcode 判定:该超时来自 callNormalEventV2 的普通 Error,
  retcode 本身不携带「超时」语义。
- 不以 serviceAndMethod == sendMsg 为条件:调用方已限定发送动作,
  该收紧既无生产证据支持,又会把同类超时退回普通失败重新诱发重发。

测试(全部穿过 napcat() 真实调用链):
- TestNapcatSendTimeoutWarnsAgainstBlindResend(生产原文 retcode=200)
- TestNapcatQueryTimeoutIsNotSendTimeout
- TestNapcatSendActionTimeoutRegardlessOfServiceName

变异验证(3 项,均按预期失败后退回):
① 删告警分支 → 发送用例失败 ② 加回 sendMsg 限定 → 不限服务名用例失败
③ 去掉发送动作门禁 → 查询用例失败
2026-10-13 12:52:55 +08:00
b73a95bb31 chore(qq): 版本 1.4.1 → 1.4.2(长超时客户端 + OneBot retcode 校验)
对应插件侧修复 bd26c2a:发送类端点用 120s 超时客户端并校验 OneBot
retcode/status,消除「消息其实已送达、却报发送失败」进而导致模型重复发送。
2026-10-13 10:22:34 +08:00
b2872f3702 refactor(sdk)!: 废除自定协议号,版本身份只由版本号本身承载
兼容性判据原有三处自造版本号,全部删除:
  - procProtocolVersion(模板侧握手常量)
  - shmVersion / shmOffVersion(共享段 header)
  - unifiedVersion(SuperBlock)

为什么废除:语义版本变动频繁(修 bug、加字段),再要求人去升一个额外的号,
那个号必然漏升而腐烂。实证:ProtocolVersion 常量在多次布局变更里只升过一次,
而对应的守卫测试(TestProcTemplate_ShmLayoutMatchesKernel)长期处于失败状态
无人察觉 —— 它当时期望 shmStageFieldCount=18/shmVersion=1,实际已是 19/2。

取而代之的两个判据都是**结构的派生事实**,不需要谁记得去同步:
  1. 主版本号:内核 meta.Version ↔ 插件 sdk.SDKVersion 的主版本段比对。
     主版本跃迁正是破坏性变更的定义(1.0.0 从 C ABI 改子进程即属此列),
     所以它天然是正确分界。
  2. field_count:StageContext 描述符个数,决定标志位基址。

顺带修正了 shmFlagsOff:原实现用本插件的编译期常量推算标志位基址,
内核加字段时会静默错位。现改为从 header 读取内核下发的描述符个数。
SuperBlock 的 version 槽位保留(不动其余偏移),值不再写入也不再校验。

判据:TestProcTemplate_RejectsIncompatiblePeer(含 banned 列表,
防这些号被重新引入)。
2026-10-13 09:39:15 +08:00
bd26c2a402 fix(qq): 发送类端点用长超时客户端 + 校验 OneBot retcode,消除「已发出却报失败」
## 真实故障(2026-10-13,生产日志实证)

从 `<data>/log/week_2026-W41.tar.gz` 解出的历史日志 + `memory/context.json`
拿到完整错误文本:

    napcat send_private_msg: Post "http://192.168.2.106:25570/send_private_msg":
      context deadline exceeded (Client.Timeout exceeded while awaiting headers)

对应日志时序(~30s 恰好撞上客户端超时):

    17:50:07 executing tool: output_send__qq
    17:50:47 result: 通过 [qq] 通道发送失败: ... Post "...": ...   ← 40s
    21:47:15 → 21:47:47                                            ← 32s
    08:17:39 → 08:18:09                                            ← 30s

**消息其实发出去了**,证据有二:
  ① agent 自己在上下文里总结(memory/context.json 原文):
     「其余几次 send 是 napcat 的 25570 接口超时重试,用户只收到一份,不会刷屏」
     「napcat 这会儿接口有点不稳(好几次 context deadline exceeded),
       不过关键的那一发是成功的」
  ② 同一内容重试后用户只收到一份 —— 那是 qq 循环保险拦住的,属侥幸而非正确行为。

根因:`httpClient.Timeout = 30s` 被**查询与发送共用**。而 QQ 发送要经
NapCat 的 `NodeIKernelMsgService/sendMsg`,慢时响应头 30s 回不来 ⇒
把已送达的消息报成失败 ⇒ 模型重试。

## 另一个独立缺陷:不校验 OneBot retcode

`napcat()` 原来只看 HTTP 通不通、JSON 能不能解,**从不看响应体里的
成败字段**。而 OneBot v11 的成败在 body 里:

    成功 {"status":"ok","retcode":0,"data":{"message_id":...}}
    失败 {"status":"failed","retcode":100,"message":"..."}
        或 {"data":null,"message":"Timeout: NTEvent ...sendMsg","status":"failed"}

历史日志里实测到 NapCat 返回 `data:null` + `sendMsg Timeout`(真失败)
却被当作发送成功。两个方向都错,必须靠 retcode 判定。

## 改动

- 新增 `sendClient`(120s + Transport 显式配 `IdleConnTimeout`/`ResponseHeaderTimeout`)。
  发送类端点(send_private_msg / send_group_msg / send_msg)走它;查询类
  保持 30s(幂等,快速失败更利于排查)。
- `Transport.IdleConnTimeout=30s`:默认 Transport 的空闲连接无上限等待,
  复用对端已单向关闭的 keep-alive 连接会得到"请求已写入/对端已处理,
  本地读响应 EOF"——又一种「已发送却报失败」。
- `napcat()` 校验 OneBot `retcode`/`status`,失败转 error。
- 超时单独包 `errNapcatTimeout`,错误文案明写「**可能已送达并已发出,
  不要盲目重发**」——只说"失败"会诱发模型重发 → 重复消息。

## 判据(4 条,两条经变异测试修正)

- `TestOneBotError_FailedResponses`:7 组 retcode/status 组合的判定
- `TestNapcatConvertsOneBotFailureToError`:**端到端**——napcat() 必须把
  OneBot 失败转 error。★ 变异(删掉 napcat() 里的 oneBotError 调用)
  实测发现:只测 oneBotError 的判据照样全绿(判据没穿过调用链)
  ⇒ 补这条才咬住。
- `TestNapcatSendUsesSendClient`:**真的验证路由**(用 Transport 重写 host
  观测哪个 client 实际发了请求)。★ 变异(删掉路由分支)实测发现:
  只断言两个 client 的 Timeout 字段值照样全绿 ⇒ 重写为观测实际请求。
- `TestNapcatTimeoutErrorIsActionable`:超时错误必须含「可能已送达」
  与「不要盲目重发」

27 条判据全绿(含既有 23 条)。
2026-10-13 08:02:45 +08:00
777c3a0ad3 fix(proc,qq)!: shm 模板 v2 —— fExtraBag 透传插件自建 Extra 键;qq 身份可序列化绑帧
内核 shm 桥 v2 前(见主仓同步提交)只透传 4 个具名 Extra 键,插件自建键
(qq 的 qq_auth 绑帧身份)在 stage 之间静默丢失 → before_toolcall 永远回落
可变全局身份 → 权限门绑帧在 proc 插件下整体失效(owner 被误拒/非 owner
可能被误放,2026-10-12 生产实证)。

模板(proc_main.go.tmpl):
- shmStageFieldCount 18→19、shmVersion 1→2(与内核镜像同步)
- fExtraBag:具名 4 键之外的自建键整体 JSON 装袋,read/snapshot/dirty 全链接入
- ReadInto:bag 先打平、具名键后覆盖(具名键永远赢,防御袋污染)

qq 插件:
- qqAuthContext 字段导出 + json tag:非导出字段过 JSON 桥会静默丢空
  (原根因链一环)
- authOnFrame 双形态:值形态(进程内/单测)+ map 形态(proc 桥往返,
  数字 float64);半截身份拒绝 —— 宁回落全局也不猜(owner 误判即提权)
- authFromMap 按 json tag 重建,逐字段校验类型

判据(plugin_test.go):往返无损/降权保持/半截拒绝(逐键)/帧身份优先于
全局(含 owner 被误拒的原始故障场景反向)。变异 Q1-Q4 全杀。
2026-10-12 08:28:41 +08:00
0b497f4876 fix(browser)!: 升级 chromedp v0.9.5→v0.20.1,修视口截图必挂 (#1)
用户报告截图不对(此前已改为落盘返回路径)。查下去发现**两个叠加的问题**,
其中一个是既有的、与上次改动无关的严重缺陷。

## ① 协议解析失败:chromedp v0.9.5 不认 Chrome 154

```
ERROR: could not unmarshal event: parse error: expected string near offset 411 of 'cookiePart...'
```

cdproto 把 `partitionKey` 定义为 `string`,而 Chrome 154 已改成对象
(`cookiePartitionKey`)。新版 cdproto 定义了 `CookiePartitionKey` 结构体,
且 `UnmarshalJSON` **双向兼容**(字符串 → 结构体,旧浏览器也不坏)。

副作用:`Navigate` 从 2.5s 降到 85ms(解析错误消失,不再反复重试)。

## ② 视口截图在 headless 下**永远挂住**(既有缺陷,不是升级引入)

`Screenshot(sel)` 内部是 `QueryAfter` + `NodeVisible`,而 NodeVisible 在
headless Chromium 上永远不满足 —— 两步单独看都成功,但它一直等下去:

```
Do(Query body)          ✓        Do(WaitReady body)      ✓
Do(WaitVisible body)    ✗挂住     Do(WaitVisible html)    ✗挂住
Run(Screenshot html)    ✗挂住     Run(FullScreenshot)     ✓
Run(CaptureScreenshot)  ✓(70KB)
```

**决定性对照:旧版 chromedp v0.9.5 在同一个后端上同样挂。**
所以这不是升级引入的。生产日志也佐证:

- 成功的 8 次 —— agent 都传了 `full:true`(走 FullScreenshot)
- 超时的 5 次 —— 都是默认分支(走 Screenshot("body"))

且 `NodeVisible` 的两步实测都正常(`dom.GetBoxModel` 300µs 成功、
`visibleJS` 返回 true / offsetWidth=480),就是等待机制本身不返回。
没有深挖到 chromedp 内部的根因,但**换用 `CaptureScreenshot` 完全绕开这条链**,
且语义更对:「不传 full 就只截当前视口」本来就是 API 文档的含义。

## 迁移方式:兼容层收敛,业务代码最小改动

v0.20 是**泛型重写**(`Action[T]` 返回值而不填指针),25 处调用点受影响。
新增 `chromedp_compat.go` 把新 API 收敛成几个小助手
(`chromedpDo` / `newRemoteAllocator` / `chromedpCaptureScreenshot` …),
业务代码保持旧写法。

为何不逐处改:20+ 处机械改写会把 diff 淹没,真正要审的
「行为变了没有」反而看不清。

`NewRemoteAllocator` 在 v0.20 移到了独立子模块 `remote`(核心模块现在
只用标准库 + cdproto,需要 websocket 的东西全部搬出去)。

## 顺带修掉 go.mod 的重复 replace

`go mod tidy` 清掉了 4 条重复的 `replace ... => ../../`(hmapdev 往已有
go.mod 追加却不去重导致的)。Go 容忍它,但 `go mod tidy` 会报错。

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

`chromedp_compat_test.go`:
- 视口截图必须走 `CaptureScreenshot`,不得退回 `Screenshot(sel)`
  (改回挂住的写法 → FAIL ×2)
- 兼容层关键助手都在(去掉 CaptureScreenshot → build FAIL)
- 无旧式「传指针接收截图」残留

全部 20 个 example `go vet` 通过;SDK 根 module build/test 通过。
生产实测:agent 做视口截图返回
`/home/newqqagent/browser_screenshots/shot-20261011-104225.610-browser_1.png`
(1280×713 PNG / 70518 字节),不再超时。

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-11 10:45:41 +08:00
25bd243662 fix(browser)!: 截图落盘返回路径,不再回 base64
用户报告:截图直接返回二进制(base64)对 agent 十分不友好。确认属实,且
生产日志里有 agent 的**绕行实证**(2026-10-10):

    browser_screenshot → cmd_run(自己 base64 解码写盘)→ describe_image

那一轮跑了 920 秒。

## 为什么 base64 是错的

工具结果是 JSON 文本,base64 PNG 会**整段进模型上下文**,而模型看不见
这些字符里的图 —— 纯粹浪费:

    视口截图 100-500KB ⇒ base64 后 137-685KB(约 3-17 万 token)

更关键的是它把「解码 + 写文件」这件重复劳动推给了每个消费方,而模型做不到。
内核早有对应约定:`describe_image` 的 path 参数描述写着
「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传 file 路径)」。
本插件返回路径才是合上这个约定。

## 改动

- 截图落盘到 `<data_dir>/browser_screenshots/`,返回**绝对路径**
- 结果里带 `hint` 直接告诉模型「调 describe_image(path=...)」——
  工具描述模型可能漏看,结果里的提示更近
- `inline=true` 才额外带 base64/data_uri(只有确实拿不到文件系统的场景才用)
- 落盘失败**明确报错**,不静默退回 base64:那会让「上下文被灌爆」
  在磁盘满/权限错时重新出现且没有迹象
- 文件名带毫秒时间戳 + 会话 id:同一轮内多次截图是常态
  (生产日志里一轮截了 3 次),固定名会静默覆盖
- 会话 id 过滤路径分隔符(它来自模型且被拼进路径,否则 `../` 能写出目录外)
- 未注入 data_dir 时降级到临时目录,而不是让截图整条失败

## 判据(5 条,可杀变异,逐个实测)

`screenshot_test.go`:
- 落盘 + 返回绝对路径 + 内容逐字节一致
- 连续截图不互相覆盖(文件名去时间戳 → FAIL)
- 会话 id 路径穿越被挡(`../../etc/evil` 仍在目录内)
- 未注入目录时降级而非报错
- 工具描述必须提 path 与 describe_image(描述退回 base64 → FAIL)
- handleScreenshot 返回结构必须是 path 而非 base64
  (整段替换为旧实现 → FAIL;保留 path 但无条件加 base64 → FAIL)

**判据本身修过两次(都是变异测试发现的)**:
1. 第一版只看 `res["base64"] = b64`,把 base64 写成 map 字面量的键就绕过了
2. 第二版按整段文本查 "inline",而**注释里也有 "inline"** ⇒ 把保护开关
   改成 `if false` 仍能通过。改为先 `stripLineComments` 再查真实代码行

全 20 个 example 编译通过;browser 插件 `go test` + plugin 模式构建通过。
2026-10-10 23:26:19 +08:00
e0ee0c43bb fix(hmapdev): SDK 模块识别要认新旧两个 module 名(迁移漏改的逻辑判定)
上一提交(b6a6168)只替换了 import 路径,**漏了用旧 module 名做逻辑判定
的五处**。CI 当场抓到一处(`TestPrintVersionReportsInjectedVersion`),
但那是测试断言;另外三处是**产品逻辑**,CI 抓不到,是静默失效。

## 漏改的地方

| 位置 | 原判定 | 失效后果 |
|---|---|---|
| `cmd_build.go` | `Contains(mod,"homeagent-sdk")` | 认不出 SDK 依赖 ⇒ **不写 replace** |
| `cmd_skill.go` | `Contains(gm,"homeagent-sdk")` | 认不出 SDK 仓 ⇒ 不安装 skills |
| `yaegi/interp.go` ×2 | `Contains(line,"homeagent-sdk")` | `findSDKGoPath` 找不到 SDK ⇒ 退化到兜底 |

迁移后这些 `Contains` **恒为假**(新名是 `homeagentsdk`,没有连字符)。
三者都**不报错**,只是行为悄悄变成另一条分支——比直接崩更难发现。

## 关键:不能只认新名

本机实测存量插件工作区的 go.mod **全部仍是旧名**:

```
/home/program/qq-plugin            (无 go.mod)
/home/program/vanblog-plugin       require gitcode.com/JianFeeeee/homeagent-sdk
/home/program/mcquery-plugin       require gitcode.com/JianFeeeee/homeagent-sdk
/home/program/fileproc-plugin      require gitcode.com/JianFeeeee/homeagent-sdk
/home/program/agentmail/plugins/   require gitcode.com/JianFeeeee/homeagent-sdk
```

用户已创建的项目不会因为我们改仓就自动更新。所以判定必须
**两种都接受**,而不是把旧名换成新名(那只是把 bug 从"认不出新"换成"认不出旧")。

## 改动

- `meta` 补 `SDKModule` / `LegacySDKModule` 常量(原注释引用了 `SDKModule`
  但常量并不存在——注释先于实现)
- 新增 `tools/hmapdev/sdkmodule.go`:`isSDKModule` / `sdkModulePath` /
  `containsSDKModule`
- 新增 `tools/hmapdev/yaegi/sdkmodule.go`:同语义的最小副本
  (yaegi 是独立子包,不能 import 父包)
- 三处逻辑判定改用上述助手

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

`sdkmodule_test.go` + `yaegi/sdkmodule_test.go`:覆盖新名/旧名/带版本号的
旧名/空串/核心仓(`HomeAgent`,不是 SDK)/无关模块。

这三个判定点**此前零测试覆盖**——这正是它们能静默失效的原因。

变异验证:
- 只认新名 → FAIL「旧名漏认会让存量插件静默走错分支」
- `containsSDKModule` 只认新名 → FAIL「必须认旧名」

## 验证

- SDK 根 module + hmapdev module:`go build` / `go test` 全通过
- 20 个 example `go vet` 全通过
- `go mod verify` → all modules verified
2026-10-09 07:32:00 +08:00
d685789988 docs: 文档与站点链接随托管地迁移到 GitHub
go module 路径已迁到 `github.com/JianFeeeee/homeagentsdk`(见上一提交),
文档里的示例 import、Releases 下载地址、模块路径引用同步更新。

涉及:README.md、README_EN.md、docs/guide/{getting-started,first-plugin,
capability-boundary}.md、mkdocs.yml。

## 有意保留的 gitcode 提及

**不是漏改**,删了反而让文档说谎:

- README.md:「二进制的实际分发地址目前是 gitcode」——两个仓的 release
  附件不同步,只有二进制暂时还得去 gitcode 取
- mkdocs.yml:gitcode 镜像链接(国内直连更快,海外看 GitHub)
- docs/guide/getting-started.md:注明「gitcode 仅作国内镜像」

判据:**能照做的指引**优先于「链接好看」。读者照 README 下载 hmapdev
时,走 gitcode 是真能拿到东西的那条路。
2026-10-09 07:25:35 +08:00
b6a6168e65 chore(module)!: module 路径随托管地迁移 gitcode → github
SDK 仓已迁到 GitHub 作为权威源,go module 路径同步:
`gitcode.com/JianFeeeee/homeagent-sdk` → `github.com/JianFeeeee/homeagentsdk`

## 范围

74 个文件:71 个 Go 源码 + go.mod/go.sum + hmapdev 模板 + CI 与打包脚本。

主仓(HomeAgent)侧对应改动是 6d38a164。本仓这些文件当时**不在工作树上**
(见下),所以那次的批量替换扫不到它们——这里是补偿。

## 为何这批文件当时不在工作树上(事故与恢复)

10-08 07:58 主仓做 checkout/cherry-pick 序列时,把本仓工作树的 20 个文件
误清理了(`example/*/plugin.go` + `plg.json`,10 个示例)。

根因是**结构误判**:本仓是「一目录两仓库」结构下主仓 `third_party/` 里的
**独立 git 仓库**,而主仓 .gitignore 忽略了 `example/`,于是它在主仓
`git status` 里不可见 —— 看着像「不受跟踪的构建产物」,实际是本仓受跟踪的源码。

已全部恢复(`git checkout -- <D 文件>`,只恢复删除、不动迁移改动),
20 个文件与 HEAD 逐字节一致。

**破坏的连锁后果**:恢复出来的 `plugin.go` 仍写着旧 module 路径,
因为迁移时它们已不在工作树上。所以这次补偿是必须的,不是可选优化。

## 保留 gitcode 引用

以下位置的 `gitcode` **有意保留**,不要"顺手清掉":
- README.md / docs 里「二进制实际分发地址目前是 gitcode」的说明
- mkdocs.yml 的 gitcode 镜像链接(国内直连更快)
- `tools/hmapdev/templates.go` 里「SDK 已从 gitcode 迁到 GitHub」的注释

gitcode 仍是**只读镜像**(源码同步;release 附件暂时仍在那边)。

## 验证

- 本仓根 module:`go build ./...` + `go test ./...` 全通过
- 全部 20 个 example:`go vet` 通过(补偿前 10 个因旧 module 路径失败)
- example/qq:`go test` 通过,且能从源码 build 出可用 plugin 产物
2026-10-09 07:25:25 +08:00
c7e5d2a446 docs: README 补文档站入口,并把「SDK 源码」链接迁到 GitHub
SDK 仓此前**没有任何地方指向文档站** —— README 不写、GitHub 仓的
homepage 字段也是空的,于是从仓首页根本走不到 sdk.homeagent.jianfgit.xyz。
文档站本身一直在线(106 个文件的 mkdocs 产物),只是没人从仓里指过去。

本次补上:
  - README.md / README_EN.md 顶部加文档站入口(快速开始 / API 参考 / 能力边界
    / llms.txt + llms-full.txt 的 agent 直读入口),并在末尾加「文档」章节
    列出全部页面的直链。
  - mkdocs.yml 的社交链接由 gitcode 改指 GitHub,并**新增** gitcode 镜像入口与
    介绍站入口(国内直连 gitcode 更快,海外/权威源看 GitHub,两个都留)。
  - docs/guide/getting-started.md 的 `git clone` 地址改为 GitHub 仓名
    (homeagentsdk),并写明「release 附件暂时仍在 gitcode」这个事实。

★ 刻意**不**改的东西:Go 模块路径 `gitcode.com/JianFeeeee/homeagent-sdk`。
改模块路径会让所有现有插件的 go.mod 全面失效,属于破坏性变更,
与「仓库托管位置迁移」是两件事。

关于 GitHub 侧没有 release 条目(因此不能写 releases/latest/download/...,
那会 404):已在 README 里如实写明 —— 二进制目前只在 gitcode 的 release 附件里,
GitHub 从下一个 SDK 版本(v1.4.0)起才会同步发布。

验证:两份 README 里 28 个 URL 全部实测 200;文档站重建(106 文件)并部署,
线上 `git clone` 已是 GitHub 地址、旧 gitcode clone 地址 0 次、
三个社交入口名称正确。
2026-09-29 20:43:27 +08:00
c16411a77d ci: 修 CI 自身的三处错误(本地能过 ≠ CI 能过)
首次跑出来的 8 个失败全是本 workflow 自己的问题,不是代码缺陷。
三条都属同一类:**命令本地验证过,但环境前提不同**。

## 1. hmapdev 交叉编译:在错误的目录跑(5 个平台全红)

     no Go files in /home/runner/work/homeagentsdk/homeagentsdk

`go build .` 缺 `working-directory: tools/hmapdev`,于是在仓库根执行 ——
根目录没有 Go 文件(包在 sdk/ 子目录)。加 working-directory 后本地实测
linux/arm64、darwin/arm64、windows/amd64 均产出 27–30MB 二进制。

## 2. Examples:装了 Go 1.21,而 examples 要求 1.25

     go: go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)

`go-version-file: go.mod` 读的是**根** go.mod —— 它写 `go 1.21.0`,
而 20 个 `example/*/go.mod` 都要求 `go 1.25.0`(只有根与 tools/hmapdev 是 1.21)。
于是 CI 装 1.21,examples 构建必失败。

★ 为什么本地测不出来:本机 Go 1.27 且 GOTOOLCHAIN 默认可自动取更高工具链,
  更高版本能满足 1.25 的下限,所以一路通过。而 GitHub runner 上
  **GOTOOLCHAIN=local**,Go 拒绝自动下载工具链,低版本直接报错。
  本地「能过」在这里完全不构成证据。

改法:SDK CI 全部钉 `go-version: '1.25'`(同时满足 1.21 的下限与
examples 的 1.25 要求)。

## 3. 技能检查:hmapdev 建到了 /tmp

     error: no active SDK version set

`resolveSkillsSource()` 先用**可执行文件位置**向上找仓库
(tools/hmapdev → ../../skills),落空才回退到 SDK store 的活跃版本。
我把二进制建到 /tmp/hmapdev ⇒ 推出仓库失败 ⇒ 回退 ⇒ CI 里没有 store ⇒ 报错。

改法:建到仓库内 `<repo>/build/hmapdev_ci`。本地实测(构建在仓库内时,
即便从 /tmp 调用也能正确识别仓库源——解析依据是 exe 位置而非 cwd)。

## 附带记录

- 根 go.mod(1.21) 与 example/*/go.mod(1.25) 的版本不一致本身值得关注:
  用 Go 1.22–1.24 的用户按示例走会失败。是否统一属产品决策,本次不动。
- actionlint 全绿。
2026-09-29 15:29:16 +08:00
5759e58bad ci: 建立 CI —— 此前 main 的推送与 PR 完全没有检查
## 缺口

本仓此前**只有 release.yml**(只在 release/** 推送时跑)。也就是说
推 main、开 PR 一律无检查,而 SDK 正是外部插件开发者直接依赖的契约面
(sdk/plugin.go 接口一破,所有外部插件编译失败)。

## 四个 job(每条命令都本地实测过)

| job | 内容 | 实测耗时 |
|---|---|---|
| go | 根模块 build + vet + test | <1s |
| hmapdev | 嵌套 module 测试 + 五平台交叉编译(矩阵) | ~2s + 编译 |
| examples | 构建全部 20 个示例(linux/amd64、darwin/arm64) | 9s / 21s |
| consistency | Lua SDK 三副本一致 + skills 可识别 + mkdocs --strict | ~2s |

## 三处「不能想当然」的地方(都有实测依据)

1. **示例不能用 `go build` 验**。它们是插件(只有 plugin.go、没有 func main),
   必须由 hmapdev 注入 main 包装;直接 go build 得到
   "function main is undeclared in the main package" —— 那不是缺陷,是方式不对。
   故与发版走**同一个脚本**(package/build-examples.sh),避免 CI 与发版路径分叉。
2. **tools/hmapdev 是独立 module**,根模块的 `go test ./...` 不会进入它 ——
   必须单独跑,否则它的测试永远不在 CI 里执行(Go 的模块边界)。
3. **不能用 `yaml.safe_load` 校验 mkdocs.yml**:它含 mkdocs-material 的
   `!!python/name:` 标签(配置 emoji 的官方写法),safe_load 报
   ConstructorError —— 是校验方式不对。改用 `mkdocs build --strict`
   (本地实测 2s、0 warning)。

## 明确不进 CI

- `hmapdev skill install` —— 它会写开发者本机的 ~/.claude、~/.codex 等目录
- 需要内核仓在场的检查(本仓独立可测)
- 任何网络/真机依赖

CGO_ENABLED=0(SDK 纯 Go,与主仓相反)。

actionlint 全绿(修掉一处 shellcheck SC2012)。
2026-09-29 15:18:58 +08:00
275a054287 fix(packaging): build-examples.sh 用新变量名 HMAPDEV 会 unbound variable
## 症状

按注释声称的「兼容」用法传新名,脚本立刻死:

    HMAPDEV=... bash package/build-examples.sh linux/amd64 /tmp/out
    → package/build-examples.sh: line 72: PLUGINDEV: unbound variable   (exit 1)

而传旧名一切正常:

    PLUGINDEV=... bash package/build-examples.sh linux/amd64 /tmp/out
    → 成功 20 / 失败 0

## 根因

第 71 行写的是:

    HMAPDEV="${HMAPDEV:-${PLUGINDEV:-$SDK_ROOT/build/hmapdev}}"

这**只定义了 HMAPDEV**;PLUGINDEV 若未设置就仍是未定义变量。
而紧接着的 72/77/100 三行读的却是 `$PLUGINDEV`:

    if [ ! -x "$PLUGINDEV" ]; then        # ← 未定义 → set -u 下致命
    out=$( cd "$dir" && "$PLUGINDEV" build ... )

于是只有当调用方**恰好传旧名**时 PLUGINDEV 才有值、才工作。
注释说「旧变量名 PLUGINDEV 仍兼容」——兼容的方向反了:是旧名恰好能跑,
新名不能。

## 为何一直没暴露

发版路径传的正是旧名(build.sh:92 `PLUGINDEV="$dev" ...`),
所以 release 流程一路是绿的。任何人按新名(或根本不传)调用都会撞上。

## 修法

三处改读 `$HMAPDEV`(第 71 行刚解析出的规范名)。

## 验证(三种调用方式都要能工作)

    A) HMAPDEV=...    → exit 0,20/20,20 个 .hmap(修复前 exit 1)
    B) PLUGINDEV=...  → exit 0,20/20(向后兼容保持)
    C) 都不传          → 自动先构建 hmapdev,再 20/20

跨平台也验过:linux/arm64 20/20(19s)、darwin/amd64 20/20(21s);
windows 被有意拒绝并给出明确原因(协议 2 的统一共享内存区未移植)。
2026-09-29 15:18:43 +08:00
56c694cd7c ci: gh release download 需显式 --repo
回读校验先 cd /tmp/back(非 git 目录),而 gh 默认从当前目录的
git 上下文推断仓库,于是报 "not a git repository" —— 发布本身
成功(tag/release/附件齐全),却被这道校验误判为失败。

改为 gh release download "$TAG" --repo "$GITHUB_REPOSITORY"。
2026-09-29 14:39:12 +08:00
dc9e7d0495 ci: SDK 仓发布流水线 —— release/** 推送即出 hmapdev 五平台产物
与主仓 Release 流水线同构,差异只在产物与打包命令:

  prepare  读 meta.Version;tag 已存在则整轮跳过;go test 门可显式跳过
           (改 meta 的提交里写 [skip-release-tests],查该提交而非 HEAD)
  build    go build(硬门)→ go test → build.sh all hmapdev → 验证 5 平台齐全
           → 生成 SHA256SUMS → artifact
  publish  打 tag → gh release create 传附件 → 回读校验
  sync-gitcode  有 GITCODE_TOKEN 时同步(无则跳过)

产物清单不是猜的,依 gitcode 上 v1.2.0/v1.3.0 的实际附件(各 6 个):
  hmapdev_{linux,darwin}_{amd64,arm64} + hmapdev_windows_amd64.exe + SHA256SUMS

两个易错点都有实测依据:

1. **测试要分两处跑**:tools/hmapdev 是**独立 module**,根模块的
   `go test ./...` 不会进入它(Go 的模块边界,不是配置问题)。
2. **gitcode 上传必须显式列文件名**:上传脚本的路径语义是
   `os.path.join(ASSET_DIR, name)`,所以要 cd 进目录 + `ASSET_DIR=.` + 裸名;
   而它按扩展名识别产物的默认扫描对 hmapdev **无效** —— 五个产物里只有
   windows 那个有扩展名,自动扫描会静默地一个都不传。
   故把主仓的 upload_assets.py 一并纳入本仓 scripts/(两仓各自独立可取)。

本地已验证:`VERSION=1.4.0 bash package/build.sh all hmapdev` 产出 5 个
二进制(各 27–29M),根模块 go test 通过,actionlint 全绿。
2026-09-29 14:09:35 +08:00
29c61f0e07 feat(hmapdev): skill 子命令 —— 插件开发知识随 SDK 分发
## 为什么需要

HomeAgent 插件开发的知识(hmapdev 工具链、SDK 版本坑、plg.json、
plugin.bin 部署、内置 vs 独立二进制)此前只存在于**某个 agent 的对话历史**里。
本机四个 agent(pi / claude / codex / .agents)都不会开发插件,
因为它们**没有任何渠道**拿到这些知识。

知识属于 SDK(与工具链同源、随 SDK 版本走),所以:
- 源:`skills/`(随 SDK 仓分发)
- 装:`hmapdev skill install`

## 命令

    hmapdev skill list              列出活跃 SDK 里的 skills
    hmapdev skill install [name...] 装到各 agent 的 skills 目录
    hmapdev skill path               显示源目录与来源

安装目标(**只装这些**,不认识的目录不创建):

    ~/.pi/agent/skills   ~/.claude/skills
    ~/.codex/skills       ~/.agents/skills

## 两条策略,与别处**故意相反**

1. **总是覆盖**。skill 是**工具生成物**,不是用户数据。保留用户改过的版本
   会让它与工具链脱节 —— 而工具链的命令面会随版本变。
   (对比:`hmapdev build` 写出的适配器更新会**保护**用户修改,
   因为那是运行时代码;skill 只是说明文档。)
2. **不扫 glob 自动发现**。不认识的目录建出来也没用,
   还会让用户以为装上了。

## 源目录:仓库优先,store 回退

store(`~/.homeagent/hmapdev/sdk/<v>/`)是 `sdk install` 复制的**副本**。
而 skill 是纯文档、加它不需要动 SDK 的编译产物 ——
「改了 skill 却要重装 SDK 才能生效」对日常维护不合理。

故优先用 hmapdev **自身所在仓库**的 `skills/`(靠可执行文件位置反推,
并校验 go.mod 的 module 是 homeagent-sdk),落空才回退 store。

## 判据 9 条(cmd_skill_test.go)

覆盖:目标无重复/不逃出 home、**真实仓库**里 skills/ 的布局合规、
隐藏目录与普通文件不算 skill、幂等、覆盖用户修改、跳过未知目标、
真实入口 `cmdSkillInstall` 覆盖用户修改、未知 skill 名不静默。

### 写判据时踩的三个坑

1. **`TestSkillSourceHasValidLayout` 原用 `t.TempDir()`** ⇒ 那个目录是空的,
   判据永远红,且红得毫无意义("文件不存在"是真的,但真实文件在 SDK 仓里)。
   改为指向**真实仓库**。
2. **fixture 造错**:我 `MkdirAll` 出一个叫 `README.md` 的**目录**,
   于是实现"正确地"把它当 skill,判据却报「把 README.md 当成了 skill」。
   是 fixture 错,不是实现错。
3. ★ **`TestSkillInstallOverwritesUserEdit` 只验 `copyDir`、没走真实入口**
   ⇒ 我把 `cmdSkillInstall` 里的 copyDir 换成「已存在就跳过」,
   **判据依然全绿**。变异测试抓出来的。补 `TestCmdSkillInstallOverwritesUserEdit`
   走真实入口后,变异立刻变红。

## 实测

删掉三处 skill 后 `hmapdev skill install` 一条命令装回四处,
四处 md5 与源一致,二次安装结果不变(幂等)。

## 门禁

- `go test ./...`(hmapdev 独立 module):ok,0 FAIL
- `go build ./...`:ok
- 9 条判据全通过,变异测试确认能抓回归
2026-09-28 11:23:58 +08:00
82e8d9dbac docs: 补两篇指南 —— 工具并发声明、流式多 tool_call
`ParallelSafe` / `Serial` / `stream_index` 三个新能力此前**零文档**:
README 提到 `Serial` 的那处是 UART 串口,与并发声明无关。插件作者只能
读源码注释才能知道这些字段存在及其优先级。

## docs/guide/parallel-tool-declaration.md

- 保守 opt-in 的理由:存量插件不改一行就得串行,不会被升级意外并发
- 声明 `ParallelSafe` 的三个条件(线程安全 / 不争抢资源 / 顺序无关)
- `Serial` 存在的意义:让"我确认过**必须**串行"与"我没想过"可区分
- **`Serial` 胜出**,不允许被 `ParallelSafe` 或默认值覆盖
- 声明字段放在 `ToolDef` 末尾,遵循既有 `NoMemory` 风格
- 压测效果 N=2/4/8 → 1.41×/2.22×/3.88×,并附"必须同时统计实际执行数"
  的理由(旧适配器耗时更短但实际处理 0 个工具)

## docs/guide/stream-tool-call-index.md

面向写 Lua 适配器的人:

- 上游 `index` 字段的用途:分桶累积 `id`/`name`/`arguments`
- **键名是 `stream_index` 不是 `index`** —— 写错会被 Go 解码器静默丢弃
- 不透传的实际后果:name 互相覆盖、args 碎片混拼、工具被当空参数调用
- 顺带记两个易踩点:不能按 name 过滤分片;扁平结构的协议族同样要带
- 自检命令;并注明 `gemini.lua` 不涉及(协议是 `functionCall`)

## 其它

- `mkdocs.yml` nav 登记两篇 —— 之前它们会被 mkdocs 明确警告
  "not included in the nav configuration",等于在站点里不可达
- 重跑 `tools/apidoc/build.sh` 同步 `docs/api/*`、`llms.txt`(生成物)

核实过的事实,避免臆造:
- 内置工具声明是**核心仓**的 `toolDefOptions`/`parallelOpts()`
  (`internal/agent/core/tooldefs.go`),不是 `sdk.BuiltinToolDef` —— 初稿写错
- `gemini.lua` 对 `functionCall|tool_calls` 匹配数为 0,确认无流式实现
- `server/kimicode/anthropic/ollama` 四个适配器确有 `stream_index`(2~5 处)
- 跨仓相对链接不可解析,故改为纯文本路径指路

`docs/api/tools.md` 属生成物(首行注明"请勿手改"),其 `ToolDef` 签名被截断、
不展示字段 —— 这是生成器既有行为,本次 diff 只是行号漂移,未改它。
2026-09-27 22:25:38 +08:00
1b8218afa9 fix(tools): 审计器补齐 recv/selfCalls 索引与只读白名单;标注器定位修正
## audit_parallel 的四个误判来源

**① indexMethods 没设 recv 和 selfCalls ⇒ 递归跟进空转**
`scanWritesDeep` 靠 recv 组候选键、靠 selfCalls 才知道跟进谁,缺任一个
就静默漏判。handleRestart 只调 p.stopServer/startServer 两个方法,
因此被判成"只读" —— 而 stopServer 里有 `p.server = nil`。
(我先补了闭包那条分支的 selfCalls,误以为问题在闭包上,其实是
 indexMethods 整体缺字段。)

**② writePatterns 写了 ".Set(" 而 callString 收集的是方法名**
`p.sdk.Settings().Set(...)` 是三段链,callString 返回 "Settings.Set",
永远匹配不上 ".Set("。漏判比误判更难发现:结果看着仍然合理。
→ 模式改为不带括号。

**③ looksLikeExternal 用黑名单 ⇒ 纯读标准库调用全被判"可能写"**
Marshal / ReadAll / NewRequest / NewReader 都不带写操作迹象,却被判写,
120 个工具里 119 个判成 SERIAL —— 等于工具没在工作,却**看上去在工作**
(保守方向不会引起怀疑)。
→ 改为 knownReadOnlyCalls 白名单。判定原则:**默认怀疑,明确信任** ——
   写不动的东西要逐个列出来。

**④ 工具名提取打印 AST 内部结构**
`fmt.Sprintf("%v", a.Y)` 输出 `tp&{10500 10515 STRING "manage_social"}`,
名字里混着指针地址,人没法核对。
→ 改用 BasicLit.Value + 归一化去掉运行期前缀。

## annotate_parallel 的插入点定位

改文本匹配改结构体字面量这条路走了三次弯:
- 正则找"最后一个顶层字段"被嵌套 map 里的同形文本骗到,823 处重排改坏文件
- 变量前缀匹配没要求 RegisterTool( 在**同一行**,命中函数体里散落的字面量,
  起点错到 switch case 中间,报错指向一处看起来完全无辜的分支
- 只按行末**净**深度判断:Parameters 写在单行时(进出同一行,净变化 0)
  永远察觉不到曾进入深度 3,追踪一路跑到 1305 行才"收敛"
→ 最终按**行内峰值深度** + 记录进入深度 3 的行号判定。

## 幂等

未标注过的工具重复跑会插入第二份 ParallelSafe(duplicate field 编译错误)。
→ 加 blockHasDecl 预检。
2026-09-27 16:17:04 +08:00
deeda22650 tools: 工具并发安全审计器(audit_parallel + annotate_parallel)
两个工具,都是为了让「这个工具能不能并发」有**可复现的依据**,而不是靠人眼扫。

## tools/audit_parallel.go —— 审计

为什么需要它:正则扫 `ToolDef` 字面量**不可靠**。用它审计主仓 27 个工具时,
把 `config_set` 判成"无共享写",而它的 handler 其实在 `p.handleSet` 里且无锁 ——
原因:RegisterTool 第三个参数是方法名/闭包,正则看不到执行体。

本工具用 go/ast 从**注册点跟进到 handler 实现**,递归 3 层(带环检测),
输出三态:SAFE / SERIAL / UNKNOWN。
★ UNKNOWN 一律不声明并发安全 —— 追不到实现就不能声称安全。

### 开发它时踩的坑(都写在代码注释里)

- **通用 HTTP 包装器**:vanblog 的 manage_social / manage_settings 全走
  `p.do("GET"|"POST"|"PUT", ...)`,方法名毫无写操作迹象,纯靠名字匹配
  **全判成 SAFE** —— 而它们明确含 POST/PUT。审计工具给出与代码相反的结论,
  比不给结论更危险(人会信它)。改为按 HTTP 动词判定后,
  统计从 72/37 变为 46/63。
- **链式调用丢方法**:`p.sdk.Settings().Set(...)` 是三段链,只看最外层只得到
  "Settings",`.Set` 整个丢失 ⇒ handleConfigure(写配置 + 启停服务)被判只读。
  改为收集整条链的所有方法名。
- **变量名 ≠ 类型名**:调用点写 `p.handleRead()`,定义处是
  `func (p *Plugin) handleRead()`。拿变量名去查类型索引**永远匹配不上**,
  143 个工具全报 UNKNOWN。须建"变量名 → 接收者类型"索引。
- **方法接收者不是局部变量**:example 里根本没有 `p := &Plugin{}`,
  p 是 Start 的接收者,局部变量索引全空。须把接收者变量名也纳入索引。
- **枚举被字符串污染**:verdict = "Serial:true" 而 report 只认三个枚举值
  ⇒ 全部落进 UNKNOWN,输出"共 13:UNKNOWN 13",看着像工具没在工作。
- **作者声明必须优先**:我把"已声明"当 finding 记录后照常跑写入检测并
  **覆盖** verdict,于是 plugin_install(已标 Serial:true)被判 SAFE。

## tools/annotate_parallel/ —— 标注

按 SDK 风格插入声明项:Name 在首位,声明项在末尾(Parameters 之后、
handler 之前),不打散 gofmt 对齐。

插入点必须用**括号深度 + 记录进入深度 3 的行号**定位:
- `RegisterTool( =1, ToolDef{ =2, Parameters{ =3`
- 只判 depth==2 会在 `Name:` 行就返回(那行本来就是深度 2),
  插入点跑到 RegisterTool 之前,编译报 "expected 1 expression"
- 空 `properties: map[string]interface{}{}` 让深度**在同一行**进出平衡,
  所以"曾触及深度 3"也不能作门控,必须记行号
- 试过用正则找"最后一个顶层字段",被嵌套 map 里的同形文本骗到,
  823 处错误重排把文件改坏 —— 文本匹配改结构体字面量就是这条路

用法:go build -o /tmp/annotate ./tools/annotate_parallel
     /tmp/annotate <file> <tool:parallel|serial:说明> ...
2026-09-27 16:17:04 +08:00
1d7c330cfc fix(hmapdev): mocksdk 补齐 ToolDef 声明项,并加字段/类型一致性判据
## 问题

mocksdk(yaegi 解释执行时的替身 SDK)的 ToolDef 只有 6 个字段,
公共 SDK 已有 10 个 —— 缺 RecallPolicy / ParallelSafe / Serial。

★ 危害不在编译期,而在**调试期**:插件作者用 hmapdev 在本地解释执行时,
写了 ParallelSafe:true 照常跑、不报错;直到编译安装后才发现声明根本没
被内核读到。这类不一致不会让任何现有测试失败。

这正是"黑名单不会自动跟上新执行能力"的又一次复现:主 SDK 每加一个声明项,
替身不会自动跟上。

## 修复

① mocksdk/plugin.go 补齐 RecallPolicy / ParallelSafe / Serial 三个字段,
   注释写明"主 SDK 先加、mocksdk 没跟上"这段历史,避免后人再漂移。

② 新增 yaegi/mocksdk/parity_test.go:
   - TestMockSDKToolDefMatchesSDK  比对**字段名**集合,双向都查
     (少字段 = 声明静默失效;多字段 = 替身比本体还多,必有一方理解错了)
   - TestMockSDKToolDefTypesMatch 比对**字段类型**,逐个断言
     ★ 归一化必须抹掉所有空白:gofmt 打印 "interface{}" 而反射给
       "interface {}",这是打印格式差异。我第一版没抹空白,
       结果每个复合类型都被误报成"类型不符" —— 判据自己制造假警报。

判据放在 tools/hmapdev 模块内(它是独立 module,主模块不包含它),
跑法:cd tools/hmapdev && go test ./yaegi/mocksdk/
2026-09-27 16:17:04 +08:00
bd73a9b241 feat(sdk): 通用反代声明项(DeclareProxy)+ ToolDef.Serial 串行标记 + 场面策略文档
本次一并提交工作区此前累积的改动(均已验证),并接入工具并发调度所需的
声明项。

把「谁来反代谁」从内核硬编码变成插件可声明。设备网关(remotedevice)
这类**编译进内核、没有独立插件目录与 plugin.json** 的服务,静态扫描扫不到,
此前只能靠约定。新增 DeclareProxy 让它们能自己声明反代路由。

ParallelSafe 的**反向**声明项。判据优先级:Serial 胜出,显式声明不允许被
ParallelSafe 或任何默认值覆盖。

为什么需要它:ParallelSafe 零值 false 已表达「安全/串行」,插件无法区分
「我没想过」和「我确认过必须串行」。没有这个区分,工具作者只能靠命名约定
传递意图,那不是契约。

ParallelSafe 本身也补齐了注释,明确其零值语义(默认串行、保守)与理由
(新语义下并发会改变工具的行为前提,让存量插件意外并发比慢一点危险得多)。

配套 ScenePolicy 声明项的使用说明。

remotedevice/ 整目录(C 实现的设备网关,已由 Go 侧 DeclareProxy 路径取代)。

- sdk/knowledge.go:随场面策略配套调整
- docs/api/*、docs/assets/api-index.json、docs/llms.txt、mkdocs.yml:
  由 tools/apidoc/build.sh 从源码重新生成(行号随 plugin.go 变动漂移)
2026-09-27 16:17:04 +08:00
dev
e417c69fc8 fix(example/bili): 进程组隔离 + 可取消的下载 —— 修拖死内核关停
## 现象

线上关停必超时:systemd 报 `State 'stop-sigterm' timed out. Killing.`,
其中只有 bili 报 `[proc] bili SIGKILL 后 2s 仍未被收割`,之后近 90 秒无日志。

## 根因

本插件用 `exec.Command("yt-dlp", ...)` + `cmd.Run()`:
- 无 CommandContext ⇒ Stop 无法取消
- 无 Setpgid      ⇒ yt-dlp 与本插件同进程组
- Stop() 是 `return nil` ⇒ 内核 Kill 插件本体时,yt-dlp 变孤儿

yt-dlp 还会再 fork ffmpeg,**孙进程继承本插件的 stdout 管道写端**。
插件被 SIGKILL 后孙进程仍持有写端 ⇒ 内核 readLoop 永远等不到 EOF
⇒ `Kill()` 末尾的 readerWG.Wait 永不返回 ⇒ 整个关停挂死。

## 改法

1. 两处 `exec.Command` → `exec.CommandContext`,Stop 里 cancel 能掐掉
2. `setPgid` 让命令自成进程组:yt-dlp 拉起的 ffmpeg 也在组内,
   kill(-pgid) 能一次带走整棵子进程树,不留孤儿
3. `Stop()` 不再是空实现:cancel 之后**必须 wait**。
   只 cancel 不 wait 的话内核会先释放共享段,而 yt-dlp 还在写 stdout ——
   那正是内核 readLoop 挂死的成因。`runWG` 等它真正退出。

顺带:取消时返回明确文案("已取消(插件停止)")而不是含糊的 exec 错误。

内核侧的三处修法(Setpgid / 杀进程组 / readerWG 超时)在主仓 ceeef0b,
对所有 8 个同样 exec.Command 且无进程组隔离的插件都有效。

验证:hmapdev build 通过;主仓部署后实测关停 90s → 1s、
`[homed] stopped` 打出、孙进程无残留。
2026-09-26 17:08:34 +08:00
a176cc3e20 feat(sdk): 反代声明(ProxyDef / RegisterProxy)—— 插件声明服务,HomeAgent 反代出去
配套核心仓「webui 通用反向代理」。SDK 1.4.0 尚未发布,接口未冻结,
本次按开发期自由变更处理(正式发版时并入版本号推进)。

## 声明契约

plugin.json 的 proxies 字段(声明式,静态可发现)或 RegisterProxy
(运行期,供没有 plugin.json 的内置插件用):

    {"name":"ui","host":"myapp","path":"/p/myapp","strip_path":true,
     "target":"127.0.0.1:12100","auth":"homeagent"}

命名与既有能力对齐(ToolDef / ChannelDef / ConfigDef / RegisterTool /
ToolRegistrar)——第一版写成 ProxyDecl / DeclareProxy / ProxyDeclarer
被评审指出「跟 SDK 其他接口不是一个风格」,已全面改名。

## strip_path:Path 的两种语义

Path 不能一刀切成「原样保留」,真实需求有两种且**不能自动判定**
(同一个 path 在两种语义下都说得通,猜错即全部 404 且像上游故障):

  strip_path 缺省/false(别名模式)—— path 是上游真实路径的一部分
    /api/v1/device/ws + path=/api/v1/device → 上游收到原样
    适用:客户端**已硬编码**路径的机器接口(设备网关即如此)

  strip_path=true(前缀模式)—— path 只是门户上的挂载点
    /p/myapp/api/status + path=/p/myapp → 上游收到 /api/status
    适用:自带 UI 的服务(前端用相对路径)

非法组合(strip_path 而无 path)被 ValidateProxyDef 挡住。

## 单一入口原则(契约级要求)

一个声明 = 一个入口。两种挂载形态对「根路径」处理截然不同:
Host 形态下根路径是插件的根(fetch('/api/x') 天然正确);
Path 形态下根路径**属于门户**,同样代码会打到门户自己身上
(静默错路由:页面能开、功能全坏)。

故被反代的插件必须**一律使用相对路径**,绝不硬编码以 / 开头的绝对路径。
这样同一份前端在两种形态下都正确,插件不必知道自己被挂在哪,
反代层也能按外部条件(子域是否有证书/放行)自由选择形态。

## 判据

sdk/proxy_test.go:两种语义的映射、非法组合、单一入口原则的契约存在性。
hmapdev proxy_config_test.go:schema 漂移保护(新增字段忘了同步就判红)、
非法声明在**打包时**就被拒(不必装到 HomeAgent 才看到)。

两模块 go test 全绿;文档站已重新生成(ProxyDef 与单一入口原则进入
docs/api/misc.md 与 llms-full.txt)。

## 顺带修回的一处(此前随工作树丢失)

writePluginJSON 漏写 proxies 键 —— 漏写的话插件装得上、启动正常、
就是不出现,没有任何报错。该 bug 曾在核心仓侧出现过(判据抓到过),
这次移植时由 TestWritePluginJSONPreservesProxies 再次判红并修复。
2026-09-26 14:08:30 +08:00
255d6479ad feat(docs): 换品牌图标 + 补备案号 + 给 agent 的直读入口
三件事,都是「站点上线后看出来的」问题。

## ① 图标用的是 Material 默认,不是我们的

站上 favicon 是 mkdocs-material 自带的那张(`assets/images/favicon.png`),
与主站 introduce 不一致。改为引入主站同一份 logo:

- `docs/assets/logo.svg` —— 原样(浅色底,适合 favicon)
- `docs/assets/logo-mark.svg` —— 去掉底色矩形(否则在靛蓝页头上是个白方块)

`theme.favicon` / `theme.logo` 分别指向它们。实测子页面路径也正确
(mkdocs 生成 `../../assets/logo.svg`,不是错误的相对路径)。

## ② 缺备案号

`introduce` 底部有 ICP + 公安备案,文档站没有。Material 的 footer 只渲染
`config.copyright` 一个字符串,塞不进「两条带链接的备案」——所以覆盖了
`overrides/partials/footer.html`(`theme.custom_dir`),并顺带把许可也写进底部,
读者在任何一页都能看到,不必翻到首页。

## ③ 没有给 agent 的入口

站点是给人看的(HTML + 主题 + JS 搜索),但越来越多读者是 agent。让它们爬
HTML 既浪费 token(样板占大头)又容易漏内容。

新增 `tools/apidoc/gensite/agent.go`,随构建产出:

| 路径 | 内容 |
|---|---|
| `/llms.txt` | 站点目录:每页一行,带 URL 与一句话说明(4.3KB)|
| `/llms-full.txt` | 全部文档正文拼成一份,可一次读完(115KB)|
| `/<page>.md` | 每个页面的 Markdown 原文,含生成的 API 页(text/markdown)|

沿用 llms.txt 社区约定:`llms.txt` 读目录、`llms-full.txt` 一次读全。

**一个必须处理的坑**:mkdocs 只把 `.md` **渲染**成 HTML,不会把 Markdown
放进产物目录 —— 那样 `llms.txt` 里的链接会全部 404。所以 `build.sh` 增加了
第 4 步 `copy_agent_files`,在构建后把 23 个 Markdown 复制进 `site_build/`。

`docs/llms-full.txt` 已 gitignore:它是派生件,改任何一页都会整份重写,
进版本库只产生噪声(`llms.txt` 索引小且稳定,仍提交)。

验证:`mkdocs build --strict` 零告警;favicon/logo 可取(200,naturalWidth=400);
三页底部均含两个备案号;4 个 agent 入口均可访问且 content-type 正确。
2026-09-24 13:27:58 +08:00
9b6abe1b73 fix(docs): 中文搜不到英文注释的 API —— 补关键词层
## 问题(实测)

SDK 里 100 个有摘要的符号中 **66 个是英文注释**,例如:

    RegisterTool registers a tool that the LLM can call.

于是搜「注册工具」——中文受众最自然的问法——**RegisterTool 得分 0,一条都搜不到**。
更糟的是逐字匹配把噪声顶上来了:搜「注册工具」返回 24 条,排第一的是
`SetToolBlocks`(描述里有「工具」二字),`RegisterTool` 根本不在列表里。

## 修法

不改源码注释(那会让代码与文档脱节),而是在检索索引上加一层**人工标注的
中文功能词**:`tools/apidoc/keywords.json`。

- `rules`:按符号名前缀/子串批量覆盖(`Register*` 全带「注册」,`*Memory*` 带「记忆」)
- `symbols`:逐符号补充(重点 API、或规则覆盖不到的)

词只进 `api-index.json` 的 `g` 字段,**不影响页面展示**;检索结果里会显示
(「为何命中」),读者能理解排序依据。

同时把中文逐字匹配从主信号降为**弱信号**(要求 60% 以上字符命中)——
它正是噪声来源:凡是含「工具」二字的说明都会被「注册工具」匹上。

## 结果

| 查询 | 修改前 | 修改后 |
|---|---|---|
| 注册工具 | 24 条,RegisterTool 缺席 | **7 条,RegisterTool 第一** |
| 崩溃 | 1 条 | 2 条(SetAutoRestart + AutoRestart)|
| InjectText | 7 条(含重复)| 6 条 |
| memory.recall | 1 条 | 1 条(不变)|

顺带修掉索引重复:接口会同时作为 `type` 符号与接口本身被加两次
(`MemoryAPI` 等 11 个各重复一条)。现在接口只走接口那条路径,索引 200 → 189 条。

keywords.json 是**可选**的:读不到只警告不中断,检索退化为原行为。
2026-09-24 13:00:32 +08:00
0a6e2b7dc4 docs: 插件 SDK 文档站(API 参考从源码生成 + 自建检索)
为插件作者建一个文档站,重点是**能按描述搜到 API**,以及**明确能力边界**。

## 为什么 API 参考要生成而不是手写

公开 API 面有 115 个符号、11 个接口。手抄必然与代码漂移——这是文档站最常见的
死法(本仓 README 里已经有过几处「文档说一套、代码是另一套」)。

所以 `tools/apidoc` 直接从 `sdk/*.go` 提取签名、文档注释与代码块示例,渲染成
`docs/api/*.md`。发现文档不对时改的是**源码注释**,不是生成物。生成页首行带
「勿手改」标记,防止有人改了下次构建白改。

- `extract.go`:go/ast + go/doc 提取(只用标准库,离线可跑,不引入依赖)
- `gensite/`:渲染 Markdown + 检索索引
- `gensite/usages.go`:从 `example/` 21 个示例插件里反查**真实调用点**,
  贴在每个 API 下(源码注释里几乎没有可运行示例,但示例插件都是能编译跑的真代码)

## 能力边界:写这个站时查出的三处文档错误

这是本次最有价值的部分。原以为「公开 SDK 里有的 API 外部插件都能用」,
实测对照桥接模板后发现三处不符,站内已更正:

1. **`PluginMgr()` 被写成「仅内置可用」——错的。** 桥接模板第 692 行显式
   `base.SetPluginMgrAPI(procPluginMgr{})`,公开 `PluginMgrAPI` 注释也写「外部插件可调用」。
   真正的区别是**方法数**:公开面 3 个(ReloadOne/ListLoadedPlugins/IsPluginDisabled),
   内部面 9 个。容易混淆是因为两个包里有同名但不同的接口。
2. **`Events()` 外部插件恒为 nil。** `SetEventSubscriber` 全仓只有定义、无调用点,
   故 subscriber 从未被注入。外部插件的事件订阅实际由生成的运行时走
   `events.subscribe` RPC 完成——旧文档把它当成可用入口,会让人写出必然失效的代码。
3. **`UnregisterOutputChannel` 是静默无效,不是报错。** 桥接只注入 registrar、
   不注入 unregistrar,于是 `regOutputUnreg == nil`,函数命中 else 分支**直接返回 nil**
   (sdk/plugin.go:539-549)——不报错、通道也没注销。

每条裁定的依据写进 `tools/apidoc/tiers.json`(文件:行号 或 grep 结论),
站上以告警框呈现,读者可自行核对。判断依据三源:桥接模板的 `base.Set*` 注入点、
`internal/sdk` 完整面、`internal/plugin/proc/protocol.go` 的 RPC 表。

## 检索(用户的核心诉求)

两套互补:

- **MkDocs 内置搜索**:全文,中文走 jieba 分词。
- **自建 API 检索**(`docs/javascripts/api-search.js` + `assets/api-index.json`):
  支持四类查询——按名称、**按功能描述**(「注册工具」→ RegisterTool、
  「崩溃」→ SetAutoRestart)、按 `限定符.方法`(`memory.recall` → MemoryAPI.Recall)、
  按签名片段(`(string) error`)。并标出「仅内置」,避免外部插件作者踩空。

自建的理由:Material 内置搜索按整页文本索引,搜 `InjectText` 会列出所有提到它的
页面,但分不清哪条是它的定义;而且它要等 mkdocs build 才更新。

## 文档结构

- `docs/guide/`:快速开始、Go/Lua 首个插件、能力边界、打包发布、多平台、受限 SDK 与安全
- `docs/api/`:10 个按「你想做什么」划分的章节(工具/阶段/记忆/通道/配置/生命周期/
  事件/LLM/常量/桥接)+ 仅内置汇总页
- `docs/versions.md`:SDK 版本语义(跟随内核中版本、patch 恒为 .0)、
  1.0.0 是唯一破坏性变更、RPC 协议版本

## 验证

- `mkdocs build --strict` 零告警
- 23 个页面的全部站内链接与锚点可达(自动校验)
- 1440 / 768 / 390px 三视口:无横向溢出、无控制台错误
- 四种检索模式实测有结果且跳转锚点正确
- 构建产物 `site_build/` 已 gitignore

用法:`tools/apidoc/build.sh`(生成+构建)、`tools/apidoc/build.sh serve`(预览)。
2026-09-24 12:08:37 +08:00
e97cafc8de license: SDK 改用 MIT —— 让第三方插件不被 AGPL 传染
原 LICENSE 是 2026-09-12(93ab794)引入的 AGPL-3.0-only 全文,README 据此
声明「插件静态链接 SDK,故必须以相同许可发布,闭源只能另取商业授权」。
这堵死了闭源插件,与第三方插件生态的目标相反。

## 为什么 MIT 才自洽

SDK 会随插件一起静态链接(源码进入插件二进制)。用传染许可,插件作者就被
强制开源;用 MIT,插件作者可自由选择许可(闭源 / 商业 / 私有均可),无需
回馈、无需任何例外或商业授权。这是刻意的宽松,也是插件生态安全的基础。

MIT 在这里**不会与任何许可冲突**:SDK 完全自包含 —— go.mod 零外部依赖,
sdk/ 只 import Go 标准库(sync),不引用核心仓任何代码(已实测确认)。

## 改动

- LICENSE:AGPL-3.0-only 全文(661 行)→ MIT 正文(21 行,版权人 JianFeeeee)
- README.md / README_EN.md:许可章节重写,把「必须同许可」改为「可自选许可」,
  并写明自包含这一前提

内核仓仍为 AGPL-3.0-only(其 README 的对应论述与本提交同批更新)。

验证:go build ./sdk/... ./meta/... 通过。
2026-09-24 10:55:17 +08:00
5af2a86816 docs(sdk): 补全自动重启的真实约束(退避 / 上限 / 非无感)
原文只说「插件崩溃时平台自动拉起,保障服务可用性」,读起来像
无感瞬时恢复。实测与源码都不是这样:

## 补齐的默认参数(内核 internal/plugin/registry.go)

| 参数 | 值 | 含义 |
|---|---|---|
| procRestartBackoff | 1s | 第 n 次重启前等 n × 1s(线性退避) |
| procMaxRestarts | 3 | 窗口内重启次数上限 |
| procCrashWindow | 5min | 窗口内无新崩溃则计数归零 |

即实际序列 1s → 2s → 3s;同一 5 分钟窗口内第 4 次崩溃(n > 3)不再
自动拉起,交人工介入。首次重启就要等 1s,期间该插件的工具是缺席的、
调用会报错 —— 需要秒级就位的插件应在 OnStart 里自建重连与状态重建。

## 顺带改准一处混淆

`SetAutoRestart` 的文档写「崩溃后自动重载」—— 把「重启」说成了
「重载」。重载是换 plugin.bin 后重新加载那条路径(ReloadOne),
与崩溃自愈不是一回事。已在文档里写清,并附上退避与上限。

这个混淆与内核侧 HEAD 修正的 README/官网是同一处(源码注释里
「足够快到用户感知不到工具缺席」也是同一类无实测支撑的主观断言,
已在主仓同批改掉)。

中英双版同步更新。
2026-09-21 10:34:27 +08:00
7717bf5ca5 feat(sdk): RecallPolicy —— 声明工具输出是否触发记忆召回
与 ContextPolicy **正交**,但默认值刻意相反:

| | 管什么 | 默认 |
|---|---|---|
| ContextPolicy | **裁剪**:把低相关 L0 事件归档 | 关(剪裁是破坏性的,须显式声明) |
| RecallPolicy | **召回**:把 L2/L3 相关记忆注入本轮 | 输入/注入 `auto`,**工具 `none`** |

工具默认 none 的理由:多数工具输出是噪声,据它召回会把无关记忆拉进来
(源码注释原文)。需要「取回真实内容后据它召回」的工具才显式声明 auto。

## qq 的落地(本改动想解决的具体问题)

qq 通道到达的是**中断通知(meta)**而不是用户正文。原先用这条 meta 文本
去触发召回 —— 那是无关词,召不回真正相关的东西。改为:

- 通道声明 `RecallPolicy: none`(meta 不该据它召回)
- `qq_get_message` 声明 `auto`:消息正文取回后**由正文**触发召回
- `qq_get_history` 同样 `auto` + `prune`:拉回的历史消息既用完即裁、
  又据正文召回(否则有「记忆里有、但拉历史时不注入」的盲区)

注:本实现已先随主仓 vendored 副本进入 main(两份经 diff 校验字节一致,
0 行差异),此处是把 SDK 仓自身补齐,使两仓 HEAD 对齐。
2026-09-20 09:15:34 +08:00
db207fd9b7 fix(scripts): 默认产物目录落到内核仓 dist/plugins
原默认 `$SDK_DIR/../dist/plugins`,在 SDK 仓位于 third_party/homeagent-sdk 时
会解析成 **third_party/dist/plugins** —— 既不在内核仓的发布产物目录
(upload_assets.py 认 dist/release 那套),也不在 SDK 仓内,等于丢在夹缝里,
必须每次显式传 OUT 才不会错。

改为向上找「含 go.mod 与 internal/ 的目录」即内核仓根,取其 dist/plugins。
不在内核仓内(SDK 被单独 clone)时回退到 SDK 仓自己的 dist/plugins。

不写死 ../../ 的理由:SDK 仓既可作 submodule 位于主仓内,也可被单独 clone,
写死相对路径会把产物丢到仓外。
2026-09-20 09:07:22 +08:00
953fbb2f50 feat(scripts): 批量打插件包(.hmap)供 release 发布
## 问题

release 此前**只发 homed/waiter 二进制与 hmapdev 工具链,不发插件包**(主仓 20 个
release、本仓的 release 都核实过,0 个 .hmap)。用户要用任何一个插件,都得:

1. 装 Go 1.25 + 网络拉依赖
2. 装 hmapdev 工具链
3. 进 example/<插件>/ 逐个 `hmapdev build`

而 `hmapdev build` **不是可选项**:5 个 example(deepsearch / luademo / vanblog /
vikunja / weather)连 `main.go` 都没有,直接 `go build` 会死在
「function main is undeclared in the main package」——入口是 hmapdev 现生成的。

所以「开箱可用」名不副实。本脚本把这一步前置到发布流程里。

## 用法

```bash
./scripts/build_plugin_bundles.sh                # 全部 21 个
./scripts/build_plugin_bundles.sh weather qq     # 指定
OUT=../dist/plugins ./scripts/build_plugin_bundles.sh
```

产出 `.hmap` + `SHA256SUMS.plugins`,可直接作为 release 附件。

## 实现要点(三处是踩过才写对的)

1. **产物有三种形态**,不能只认 `_bundle.hmap`:
   - `<name>_bundle.hmap` 多平台 bundle(`plg.json` 的 `bundle: true`)
   - `<name>_<os>_<arch>.hmap` 单平台(**qq** 的 plg.json 是 `bundle: false`,
     与其余 20 个不一致)
   - `<name>_lua.hmap` Lua 插件(**luademo**,不编译 Go)
   脚本按形态标注,发布时能一眼看出是哪种。

2. **`set -e` 下不能用 `[ -z "$x" ] && x=$(ls ...)` 做兜底**:
   一次 `ls` 无匹配(退出码 2)就会让整个子 shell 直接退出,后面的兜底根本走不到。
   实测表现是「只有 qq 和 luademo 失败」——因为正是它俩没有 `_bundle.hmap`。
   改成 `for` 循环 + `|| true`。

3. **参数拼错要报错,不能静默跳过**(否则以为打了实际没打)。

## 验证

- 21/21 构建成功,合计约 150 MB(bundle 含 linux/amd64 + darwin/amd64;
  不含 windows 是**策略**——homed 已放弃 Windows 原生,源码注释有说明)
- 每个包内部结构核对:含 plugin.json、含内核认得的入口、当前平台可得
2026-09-20 09:02:31 +08:00
7ef9bc2ad3 docs(example): 为每个插件补 README
此前 example/ 下 21 个插件里,13 个完全没有 README,另 4 个是
`hmapdev init` 生成的脚手架样板(`# <name>` + `plugin build` + `Install` 三行,
等于从没被写过)。只有 deepsearch / vikunja / plugindev / luademo 是真实文档。

本次为 **17 个**插件写了真文档(13 个缺失 + 4 个样板),现在 21 个全部有内容。

## 写法

每个 README 覆盖:能力一句话 → 为什么需要 → 工具表 → 配置项表 →
通道与钩子(有才写)→ 构建 → 已知边界。

**事实全部从源码读出来,不推测**:
- 工具名核对到注册点(含 `tp+"x"` / `p.name+"_x"` 前缀拼接,展开成最终名)
- 配置键与默认值取自 `RegisterDef` / getStr 默认值
- 通道名、钩子名、依赖命令逐条 grep 确认
- 版本号与已部署实例交叉核对,17 个里 16 个一致

## 几处按源码写、与直觉不同的点

- **rss**:订阅时会把抓到的历史条目一次性标为 seen,所以订阅一个源
  **不会**把历史文章全推一遍 —— 这是避免刷屏的关键,写进了文档。
- **files**:路径校验是**两道**(规范化后判断 + 解析符号链接后再判断),
  只做前者的话沙箱里的软链接就能逃逸。两种情况报错文案不同。
- **qq**:身份必须**绑帧**而非存插件全局,源码注释记录了由此产生的两个真实故障
  (中断抢占恢复后权限门整体失效、运行中到达的消息改写正在跑那一轮的身份)。
  多来源合并时权限取**交集**。硬私有工具按前缀一律拒绝。这些是安全关键,
  单独成节写清楚。
- **memo**:待办与备忘录**刻意分两类**(一提醒一不提醒),提醒注入带 NoMemory。
- **sanitizer**:不注册任何工具,只挂三个阶段钩子;依赖 ABI v2 的 stage 写回能力。
- **editdoc**:本目录是 v1.0.0(单工具),而线上跑 v2.0.0(全能版,源码未公开)——
  在文档开头显式标注,**不按 v2 描述**,避免读者以为这里就是线上那份。

## 验证

- 21/21 文件非空且非样板(最小 913B,最大 6845B)
- 逐个核对 README 中出现的工具名能在源码找到依据;5 处报警经复核**全是误报**
  (`ai_image_generate`/`music_*` 前缀来自 metadata 的 name,`on_input` 等是钩子不是工具)
- README 版本号 vs 线上 plugin.json:16/17 一致,editdoc 的差异已显式说明

注:本仓既有未提交改动(example/qq/plugin.go、sdk/plugin.go)**未纳入本次提交**。
2026-09-20 00:43:06 +08:00
cfa72df3e9 fix(qq): 权限身份改为绑帧,修中断抢占/运行中到达导致的串权与失效
问题(都是插件全局 p.auth 一份状态引起):
- 中断抢占当前轮并把现场压栈,中断轮收尾 afterOutput 清空全局身份;外层
  恢复(resumeTask 复用同帧、不重跑 StageOnInput)后 auth.active=false,
  beforeToolcall 在 !active 处直接返回 —— 该轮剩余工具调用**完全不受门**。
- 运行中到达的新消息会调 activateAuthContext 改写全局身份,把正在跑的那一轮
  换成另一方的身份:换高即越权,换低即误拒。

改法:身份在 StageOnInput 绑定到本帧的 StageContext.Extra 上,beforeToolcall
以帧上身份为准(无绑定时才回退插件全局,兼容单测)。帧随中断栈一起压栈/恢复,
身份自然跟着走。

顺带:合并中断正文里的整批 message_id 现在全部消费(原来只清第一个,其余要等
generation 回收),新增 qqMessageIDsRe 支持 message_id=100,101,102 连写。
新增 4 条测试覆盖:中断恢复、运行中到达、整批 id 消费、非 QQ 轮不受门。
2026-09-14 16:45:09 +08:00
fe1c4cdb09 feat(qq): Bot 所有者消息升到 L2;普通消息保持 L1
jianf:所有者的话不该被普通人的消息打断/挤到队尾。

- 新增 interruptLevel(owner):owner → PriorityL2(一般提醒),其余 → PriorityL1(后台)。
  一批里只要有一条来自 owner,整批按 L2 投递。
- 为什么不是 L3:L3 是时钟/终端那类"实时",QQ 是异步消息,抬到 L3 会打断真正实时的工作。
- 新增测试 TestOwnerMessagesGetHigherInterruptLevel 钉住 owner=L2 / 普通人=L1。
2026-09-14 16:31:27 +08:00
a01fe21ab1 feat(qq): 同一会话连续消息合并为一次中断 + 示例 SDK 指回仓库源码
需求(jianf):同一个人连发的数条消息应打包成一次中断,别逐条唤醒 Agent。

- debounce 合并:同一会话 + 同一发送者(群聊按 群号+QQ、私聊按 QQ)在
  batch_window_ms(默认 1500)内的连续消息合成一批,每来一条重置计时;
  整批不超过 batch_max_ms(默认 30000),避免对方持续刷屏时一直不投。
- n>1 时中断说明「短时间连续发来 N 条」并列出 message_id,建议一次
  get_history 拿全上下文;n==1 沿用原文,行为与合并前逐字一致。
- 可配置 batch_window_ms / batch_max_ms,0 = 关闭合并(逐条投递)。
- Stop 时 flush 未到点批次,别把对方消息吞掉。
- 新增 4 条测试(同发送者合并 / 不同发送者不合并 / 窗口 0 逐条 / 单条沿用原文)。

顺带:deepsearch / vikunja 的 go.mod 与 plg.json 此前指向本地安装的 SDK 1.2.0,
导致无法用当前 SDK 重编(缺 InjectOptions.Priority)。改回 ../../ 仓库源码,
与其余示例一致。
2026-09-14 16:09:45 +08:00
f09891f054 fix(lua-sdk): 同步注入在 Lua 中明确标记为不可用(避免自锁)
sdk.inject_input_sync / *_sync_opts / inject_input_media_sync* 要等本轮回复,
而 Lua 代码只在持有插件锁的回调里执行 ⇒ 必然自锁。mock 不再假装返回
(reply,nil),改为与内核一致的明确错误,避免离线测试误以为可用。
2026-09-13 21:58:58 +08:00
efb396d7b3 feat(lua): Lua SDK 全量对齐 1.3.0 + hmapdev 单一 mock 源/版本标记/语法预检
内核侧 Lua 桥此前停在 v0.8.0 时代能力面,而 1.1/1.2/1.3 新增的
媒体、注入标志位、中断优先级、事件订阅、动态通道注销只在 Go 侧存在,
文档却宣称『能力完全对齐』——属于静默漂移。

本仓(事实源):
- 新增 sdk/lua/sdk.lua:Lua mock 的单一事实源,补齐全部新 API
  (*_opts / inject_input_sync / inject_*_media / set_tool_blocks /
  unregister_output_channel / events / plugin_mgr / insert_with_media /
  sentence_text+media_digests / attachments / context_policy)。
- scripts/sync-lua-sdk.sh:把事实源同步到 hmapdev assets、luademo、
  以及被 vendored 时的内核副本;三份 sdk.lua 不再各自漂移。
- hmapdev init --lua:优先从激活 SDK 拷权威 mock,内嵌模板降级为
  assets/sdk.lua 回退,不再内联手写副本。
- hmapdev build(Lua):plugin.json 写入 SDK 版本(能力可追溯),
  打包前用 luac -p / lua loadfile 做语法预检,失败以非零码退出。
- hmapdev debug --lua:优先用激活 SDK 的权威 mock(HMAPDEV_SDK_LUA)。
- luademo 升级为全能力示例(新增 luademo_probe_v2)。
2026-09-13 19:56:12 +08:00
4852d70d77 fix(qq): 输出工具不再受"当前会话身份"限制(修「可信 QQ 会话身份不完整」误拒)
现场(用户线上,驻留子联调回执原文):
  被**子的中断**唤醒的一轮里,父 agent 调用 output_send__qq(meta 带齐 user_id)被拒:
  「QQ 权限策略拒绝工具 output_send__qq:可信 QQ 会话身份不完整;请不要改用其他会话 ID 重试」

根因:`sessionToolArgsAllowed` 对**所有**工具都先要求"本轮能精确匹配可信 OneBot 事件"。
`onInputAuthContext` 在来源是 QQ 但匹配不到可信事件时会降权成
`auth = qqAuthContext{active: true}`(无 peer、非 owner)⇒ `currentPeer == 0`
⇒ 连**输出**也一并被拒 ✗。

但输出是 agent 的**主动调用**:发到哪个会话由它自己给的 meta(group_id / user_id)决定,
`handleChannelOutput` 已经强制要求该字段存在(缺了给明确报错)。再要求"当前会话身份"
是多余的门,而且会把合法发送一起挡掉 —— 设计上收到输入后可以往任意(已授权)通道
发任意多次。

改法:`output_send__qq` 在身份判据**之前**直接放行;「只能访问当前会话」这类限制
保留给**读取类**工具(get_history / mark_read / get_message)—— 那才真的不能跨会话读。

判据 `TestDowngradedAuthStillAllowsQQOutput`:
降权态下输出放行、读取类仍被当前会话限制挡住。
扰动验证:去掉放行分支 ⇒ 该判据报出与现场**一字不差**的那句拒绝。
线上实测:CLI 发起的轮次里 output_send__qq 返回 ok,插件日志 handleChannelOutput 确认送达。
2026-09-13 16:03:24 +08:00
63b6eafaf0 docs(sdk): 补 1.3.0 的新增能力与发版口径;main 路牌推到 1.4.0
用户指出:两个仓库的文档都没跟着更新。本仓的缺口:

1. `README.md` / `README_EN.md` 头部仍写「当前:SDK 1.2.0(需内核 1.2.0+)」,
   版本表停在 1.2.0 —— 1.3.0 的两项新增能力(`InjectOptions.Priority`/`PriorityL1`–`PriorityL4`、
   `UnregisterOutputChannel` 一族)在 README 里**一个字都没有**,而它们正是这一版
   插件作者最需要知道的东西。
2. 发版口径没写清"**SDK 仓不发 patch tag**":核心 1.3.x 的后续 patch 不伴随 SDK 发版,
   patch 位恒为 `.0`(§七.1)。这条以前只在规范里,README 没提,结果我自己在
   2026-09-13 误发了 `v1.3.1`(已撤回);`v1.2.1` 是同一类历史遗留。
3. `meta/meta.go` 的版本语义注释还停在「现为 1.2.0:核心的 1.2.x 线正在发布中」,
   与事实相反。1.3.0 既已随核心正式 tag 定版,该号归发布线所有 ⇒ main 推进到 **1.4.0**。

补写内容:1.3.0 能力小节(四级中断优先级、动态输出通道、通道名约束与那起
`device/<id>` 生产事故)、发版口径两段、版本表补 1.3.0 行、meta 注释与路牌。
2026-09-13 14:40:21 +08:00
21221f20c5 feat(example): plugindev 插件 —— 把 hmapdev 工具链封装成 Agent 可调用的工具
用户要求:把 SDK 的 hmapdev 额外封装为 HomeAgent 插件并装上。

## 为什么

hmapdev 原本是"给人/CI 用"的命令行。做成插件后,Agent 能自己完成
「新建插件 → 构建 → 安装 → 重载」全流程(配合已有的 plugin_install / plgreload):

  plugindev_init → plugindev_build → plugin_install(path) → plgreload

## 工具面(5 个)

`plugindev_status`(可用性/版本/当前 SDK/工作区,排障首选)、
`plugindev_init`(脚手架,插件名约束 `[a-zA-Z0-9_-]{1,64}`)、
`plugindev_build`(在工程目录构建打包,返回产物路径与下一步提示)、
`plugindev_sdk`(SDK 版本 list/current/path/latest/install/use,`from` 支持本地源码)、
`plugindev_projects`(列出工作区已有工程与产物)。

## 安全边界(实现里落实)

- 只 exec **hmapdev 一个可执行文件**,不做 shell 拼接;
- `plugindev_build` 只接受含 `plg.json` 的目录 —— 这个工具不会变成"对任意目录跑构建";
- 子进程全部带超时;输出截断(6000 字符,保留首尾)后才返回,避免几百 KB 构建日志灌爆模型上下文;
- 工作区默认落在 `<data_dir>/plugindev`,配置可改。

## 现场验证

已在本机生产装上(`plugin_install` 带 path):5 个工具注册成功、
启动日志 `[plugindev] 就绪:hmapdev=/usr/local/bin/hmapdev 工作区=/home/newqqagent/plugindev`,
`hmapdev` 已装到 `/usr/local/bin/hmapdev`(版本 1.3.0)。
2026-09-13 14:16:46 +08:00