83 Commits

Author SHA1 Message Date
72011bc8bf 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。
2026-10-17 19:42:27 +08:00
0111ac3a7a 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` +§协议通道纪律(正误代码对比)
2026-10-17 16:34:29 +08:00
c2474f57a2 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 条「判据本身的判据」(防止判据假绿)。
2026-10-17 16:16:04 +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
11303e3ee4 chore(example): a2a 1.3.0→1.3.1、acp 1.2.0→1.2.1(补 RegisterInputChannel 后升 patch)
上一提交(4cb3a0b)给这两个示例补了显式 `RegisterInputChannel`,但没有升版本号,
于是生产上"重装"出来的包与实际安装版本同名(1.3.0/1.2.0),既不好追溯也没法用版本比较升级。

本次只改版本号(`plg.json` 是仓库里的真源;`plugin.json` 由 `hmapdev build` 从它同步,不入库)。

实测:升级并 reload 后,内核启动日志里这两个插件的"只声明了出站通道"告警消失
(它们此前是真实缺口 —— 部署件是 9-11 的旧构建)。
2026-09-13 14:09:07 +08:00
f4f6968987 docs(sdk): 通道名会拼进 LLM 函数名,写明命名约束([A-Za-z0-9_-]{1,64})
生产事故(v1.3.0 部署后 agent 完全不应答)的根因之一就是这个约束没写清:
远程设备通道名 `device/<id>` 里的 `/` 让 `output_send__device/<id>` 无法通过上游的
函数名校验,上游对**整条请求**回 400(`Invalid 'tools[N].function.name'`),
网关 auto tier 全链条失败,内核只能报"所有 provider 都失败"。

这不是"某个工具不可用",而是**整个 agent 哑掉** —— 所以这条约束必须出现在
插件作者会看的地方(`RegisterOutputChannel` 文档 + README 的通道一节):
- 通道名只允许 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13) 的余量;
- 名字若来自外部输入(设备自报 id 等),请在插件侧派生合规且唯一的名字。

内核侧**不做**净化/反解:通道名是插件自己的声明,就该由插件遵守契约。
2026-09-13 13:10:40 +08:00
da046b2520 feat(sdk): UnregisterOutputChannel —— 动态输出通道(远程设备)随资源生灭
背景:输出通道不止有"启动时注册一次"的静态通道。远程设备是动态的:
`device/<id>` 只在设备在线期间存在,设备掉线后必须注销 —— 不注销,
`output_list_channels` 会一直列着死通道,模型会往它发消息并拿到"发送已提交"式假回执。

- 新增 `OutputChannelUnregistrar` 类型 + `UnregisterOutputChannel(name)` +
  `SetOutputChannelUnregistrar`(用 setter 而不是给公开的 `New(...)` 加参数,避免破坏调用方)。
- 与既有 `SetInputChannelRegistrar` 同一套注入模式:内核在装载插件时注入。
2026-09-13 12:23:15 +08:00
4cb3a0bda4 feat(sdk): 通道方向契约落地 + 模板工程/示例插件显式登记 inputch + 生成器两处修正
## 背景:内核侧发现的真问题

在真实二进制压力测试里发现:插件只调 `RegisterOutputChannel("cli", ...)`,
却用同一个通道名 `InjectTextSync("cli", ...)` 注入输入 ⇒ 内核 inputch 登记表里
**没有**这个通道,"把 inputch 划给驻留子"直接失败(`划入 inputch cli: inputch 未注册`)。

根因是**契约没有落到插件与 SDK 面上**:inputch 是内核最基本的**输入路由单位**,
"谁会往这个通道注入输入"必须显式声明,而 SDK 文档没说清它与 RegisterOutputChannel
的分工,示例与模板工程也没有示范。

## SDK 面

- `RegisterInputChannel` / `RegisterOutputChannel` 的文档补齐**方向契约**:
  入站(谁会注入)与出站(output_send__<name> 的回复发给谁)是分开登记的两件事;
  凡是用 `InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)` 注入的
  通道名都要 RegisterInputChannel。README 同步补了一段契约说明。

## 示例插件(全部补齐,之前只有 qq/weather 是对的)

`a2a`、`acp`、`browser`、`memo`:注入用 `p.name` ⇒ 登记 `p.name`;
`calendar`、`rss`:注入用字面量通道名 ⇒ 登记同名通道。
(这些插件此前是"能注入、但通道不在登记表里",与 cli 同类问题。)

## 模板工程(生成器 templates.go)

- `tmplPluginGo`:示范入站+出站两个方向(含 ChannelDef/NoMemory 说明与 `inputch 未注册` 的成因)。
- `tmplMainLua`:同样两个方向(`register_input_channel` / `register_output_channel`)。
- `tmplReadme`:新增 "Channels" 一节(方向对照表 + 兜底告警说明)。
- 实测:`hmapdev init` 生成的 Go/Lua 工程都含通道代码,Go 工程可构建打包出 `.hmap`;
  `--lua` 工程同样生成通道代码。

## 生成器两处修正(都是实测踩出来的)

1. `sdk install --from <dir>`:install 原本只能从 Release 归档下载,而 SDK 开发期的新能力
   (如 proc 桥要透传的 `InjectOptions.Priority`)还没发版 ⇒ 生成的工程必然编译失败
   (`z_proc_gen.go: opts.Priority undefined`)。现在可用本地源码装一个版本并激活。
   实测:`hmapdev sdk install --from <local sdk>` → 装成 v1.3.0 并激活 → 工程构建通过。
2. 构建前置校验 `sdkHasInjectPriority`:proc 桥模板需要 `InjectOptions.Priority`,
   旧 SDK 没有时应给出**可执行**的报错(升级 SDK 或用 `--from`),
   而不是把两条 `opts.Priority undefined` 编译错误甩给用户(那些错误指向生成物,
   完全看不出是 SDK 版本问题)。实测:声明 sdk=1.2.0 的工程构建时正确命中该提示。

## 未决(发布期事项)

`InjectOptions.Priority` 属本特性线新增能力,**已发布的 SDK v1.2.0 不含它**;
发版时 SDK 版本需随之内含该能力(当前源码 meta 已是 1.3.0),否则外部开发者
按文档生成的工程会撞上上面那条守卫。
2026-09-13 11:37:36 +08:00
e50bffa34f fix(deepsearch): 只关自己拉起的 SearXNG;条数改由插件侧截断
两个都由「真调用/真测试」暴露,且都会让线上搜索表现为「后端不可用」。

一、归属:接管不等于拥有(例:E2E 测试把生产后端带走)
  旧实现只要探活成功就认领关闭责任 → 同机第二个实例(测试拉起的插件、另一个 daemon)
  退出时就 docker compose stop 掉**线上正在用的**后端。实测:跑一次
  `go test ./internal/plugins/ -run TestRealPlugin_DeepSearch`,teardown 即关停
  127.0.0.1:8888,用户看到的就是「搜索后端起不来」。
  修:只有真正执行过 `docker compose up -d` 的实例才算「我们起的」;探到已在运行只接管。

二、条数:SearXNG 不认 count/limit(count/max_results 形同虚设)
  实测 ?count=3、?limit=3、不带参数返回**完全相同的 35 条**,所以截断必须在插件里做。
  旧实现把 count 当 limit 参数发给 SearXNG 就以为生效了 → 模型每次吞 35~58 条带摘要结果,
  还会把「命中 N 条」当成「拿到了 N 条」报给用户(实测发生过)。
  修:新增 limitResults(默认取 max_results,上限 20);输出改成
  「命中 N 条,返回前 M 条」;不再发无意义的 limit 参数。

验证:22 项单测全过、-race 干净、vet/gofmt 干净;两条归属测试做过扰动(把旧语义放回
去后必红,并如实打出它执行的 `docker compose stop -t 2`);内核 E2E 三条通过且
**跑完 healthz 仍 200、容器未重启**;线上 1.1.2 实测 count=3 → 「命中 40 条,返回前 3 条」。

版本 1.1.0 → 1.1.2。
2026-09-13 09:55:27 +08:00
934eb4da7d feat(sdk): 导出 PriorityL4 —— 内核级插件的“立即打断”能力
L4 的归属此前写成“只有内核(panic/selfip)”,这是不完整的:**内核级插件**
(编译期内置插件,如 webui/cli/timer)也需要它来实现中断能力——最典型的例子
就是 WebUI 的终止按钮:用户按下时必须有一条能立刻打断当前任务的中断。

- 新增 `PriorityL4 = "L4"`,注释写明“仅内核级(内置)插件可用”。
- `InjectOptions.Priority` 的注释同步更正:取值 L1..L4,L4 属内核级插件,
  外部插件声明 L4 会被内核夹到 L3。

为什么不能给外部插件:否则任何第三方插件都能随时打断用户的一切工作。
夹取有**两道闸**(纵深防御):
  1. proc 桥(外部进程唯一入口)一律把 L4 夹到 L3——在这里夹是因为
     `source` 是插件自报字段、可以冒名;
  2. 内核侧再按 `IsBuiltinPlugin(source)` 判一次。
`source` 约定为 `插件名` 或 `插件名/实例`(webui/<deviceID>),判据取第一段。

兼容性:纯追加,零值仍等价于 L1。
2026-09-13 07:18:03 +08:00
4f4a03d368 feat(sdk): InjectOptions.Priority —— 插件声明自己中断的级别(L1-L3)
内核的输入调度器区分两类别:中断输入(可抢占)与排队输入(可被任何中断打断)。
中断的级别是“这项工作有多不能等”的声明,由插件在注入时给出:

    p.sdk.InjectInterruptTextOpts(src, ch, text, sdk.InjectOptions{
        NoMemory: true,
        Priority: sdk.PriorityL2,   // L1 完全可等 / L2 一般提醒 / L3 需及时
    })

- 新增 `InjectOptions.Priority string` 与 `PriorityL1/L2/L3` 常量(纯追加)。
- 空/非法值一律降级为 L1(默认级)——拼写错误不会被静默当成别的级别。
- **L4 由内核独占**(panic 中断、内核事件中断 selfip),插件声明 L4 会被内核
  夹到 L3,远端常量的取值域里也不提供 L4。
- 排队注入(InjectText*/InjectInputSync*)没有级别:它们本就是“不需及时处理”
  的那一类,可被任何中断打断;传了 Priority 也不会生效。
- 贯通链路:sdk.InjectOptions -> proc RPC 参数(priority)-> 内核 payload;
  tools/hmapdev 模板同步透传(三个注入的 6 个 Opts 变体共用 applyInjectOpts)。
- example/qq 显式声明 L1:QQ 消息既不是时钟那样的实时工作,也不是紧急工作。

兼容性:零值等价于旧行为(L1),既有插件无需改动。
2026-09-13 07:00:59 +08:00
4482235312 fix(vikunja): body 里的 ID 必须是 JSON 数字(建任务/指派在 v2 下必定 422)
线上真调用暴露:vikunja_task_create 把 project_id 当字符串发出 →
422 validation failed: expected integer at body.project_id。同类的还有指派,
而且 v2 的 assignees **根本不接受 username 字段**(422 unexpected property),
两个分支都是坏的。单测没盖到,因为从未发过真实请求体。

实测(vikunja v2.6.0,2026-09-12):
  {"project_id":"1"}    → 422 expected integer
  {"user_id":"1"}       → 422 expected integer
  {"username":"jianf"}  → 422 unexpected property
  {"user_id":1}          → 201 ✓
  {"label_id":1}         → 201 ✓(插件本来就 Atoi,无需改)

修法:
- taskBody:project_id 走 parseID(数字)
- 新增 resolveUserID:用户名 → 数字 id,查 GET /users?q=(v1 用 ?s=);
  **只认精确匹配**,不做「只有一条就用它」的模糊兜底 —— 指派是写别人任务的动作,猜错人更贵
- assigneeBody:v2 只发 {"user_id":N},不再带 username
- task_assignees remove:路径也用解析后的数字 id

新增 5 项回归测试钉住请求体形状(数字 project_id / user_id、无 username 字段、
数字 ID 不查用户表、移除走数字路径、未知用户给可读错误)。
版本 1.0.0 → 1.0.1。线上复验:create(project_id=1, assignees=jianf) 不再报错、
add→list 显示 jianf、标签 add/remove 正常,测试数据已清理(任务/标签残留 0)。
2026-09-12 23:43:08 +08:00
4cf2df5be6 feat(vikunja): Vikunja 任务管理插件(28 工具)
token 走 password+Secret 配置项、每次调用前 ensure() 重读(换 token 无需重启);
v1/v2 差异在插件内处理(建任务 PUT/POST、改任务整对象/PATCH、搜索 ?s=/?q=、
标签对象/{label_id}、TimeEntry 无 seconds 语义);16 项单测 + 沙盒 homed 实测 28 工具全注册。
2026-09-12 23:16:33 +08:00
ebd700eaf9 fix(browser): browser_search 解析现代 Bing 版式,并把解析失败显式报错
旧实现三处叠加,模型只拿到「标题=来源行 URL 串、无摘要」,表现为反复换词重搜:
- www.bing.com 对程序化请求常回 302,拿不到结果块 → 改 cn.bing.com
- 块内第一个 <a> 当标题 → 抓到来源行 `deepin.orghttps://www.deepin.org`;改取 h2 > a,
  并解开 /ck/a?...&u=a1<base64url> 跳转包装
- 摘要正则 <div class="b_caption">.*?<p> 对现代版式 0 命中(已迁到 p.b_lineclamp*)
- 分块不再用 (.*?)</li>:块内可能嵌套 <li>(deep links)会在错误位置截断
- 解析不出结果时明确报错,不再伪装成 "No results found."

夹具 testdata/bing_cn.html 为真实 cn.bing.com 响应裁剪;新增 5 项单测。
版本 2.4.0 → 2.4.1(2.4.0 = 交互式 timeout 改必填,此前已提交)。

验证:go test -race 全过、vet/gofmt 干净;线上实测返回真实标题+摘要+规整链接。
2026-09-12 23:16:23 +08:00
7c0b7a1fb0 feat(deepsearch): 联网检索插件 + SearXNG 生命周期托管
把原 websearch 示例改名为 deepsearch(目录/go.mod/plg.json/工具前缀/README 全量对齐,
工具名 websearch_* → deepsearch_*)。

新增 searxng.go:插件自己托管搜索后端
- 启动探 healthz:已在跑则直接接管(不重启),没跑就 docker compose up -d 并等就绪
- 关闭动作注册为 stop handler(幂等、限时 4s < 内核 5s 宽限期)
- 配置 manage_searxng / searxng_dir / stop_searxng_on_exit
- 崩溃/被 kill 时不关后端(下次启动接管):安全失败方向
- 可注入 cmdRunner + 时间预算,8 项单测离线覆盖接管/拉起/失败/幂等/保留

契约依据(internal/plugin/proc):停止插件 = plugin.stop → RunStopHandlers(LIFO、
幂等)→ Stop() → exit(0),宽限期 5s;stdin 关闭同路径。

验证:19 项单测(18 通过 + 1 联调跳过)、-race 干净、vet/gofmt 干净;内核 E2E 真调用
返回 58 条/37 条、58/58 带摘要;线上 daemon 实测 stop handler 与冷启动(约 3s)。
2026-09-12 23:16:23 +08:00
8c10b7ecc7 feat(browser): 交互式会话的 timeout 改为必填,并补参数校验与测试
此前 `timeout` 默认 10m:Agent 不传也能开会话,于是"忘记设时长"会静默拿到一个
10 分钟就自己消失的浏览器会话,排查起来像是浏览器不稳。

改为**必填**:
- 新增 `parseBrowserSessionTimeout`(空值 → "timeout is required;创建浏览器会话时必须
  明确指定关闭时长,如 15m 或 2h";非法或 ≤0 → 明确报错),工具 schema 的
  `required` 加上 `timeout` 并同步描述;
- 缺参时返回可读错误结果(而不是静默套默认值);
- 新增 `plugin_test.go`(62 行)钉住「不传 timeout 必须报错」等边界;
- 示例版本 2.3.0 → 2.4.0,顺带对齐结构体字段(gofmt)。

验证:`go vet ./...` 干净、`go test ./...` → ok(browser 模块自带 go.mod)。
2026-09-12 20:17:18 +08:00
fcb7490f63 feat(vscode): 插件工程调试扩展(plg.json 诊断 / SDK 版本 / 构建运行 / 内核日志跟随)
插件的真实形态是「独立子进程 + 内核侧握手」,所以插件问题几乎都在 IDE 之外发生:
编不出来(多半是没声明用哪版 SDK,工具链拿了存储里的 current)、编出来起不来
(产物与内核协议绑定)、起来了行为不对(真因只在内核日志里)。这个扩展把这三件事
拉进 IDE。

- `tools/vscode-hmapdev`:TypeScript 扩展(零运行时依赖,仅 devDeps: typescript + @types/vscode)
  - **plg.json 诊断**:必需字段;`sdk` 必须是完整版本号(区间写法 `1.2` 报错并说明
    「patch 位恒为 .0」,与工具链 ResolveSDKForProject 同一套规矩);声明的 SDK 未安装时
    直接给 `hmapdev sdk install vX.Y.Z`;
  - **状态栏**:`插件 · SDK <声明> · hmapdev <版本>`,工具链缺失/工程有错时变色,tooltip 列已装 SDK;
  - **命令**:build / build --target all / clean / debug(解释执行)/ 工具链版本 / SDK 列表-安装-切换 /
    跟随内核日志 / 停止跟随 / 刷新;
  - **任务**:同一批动作注册为 `hmapdev` 任务 + Go 问题匹配器(编译错误进 Problems);
  - **内核日志跟随**:读 `<dataDir>/log` 最新 `homed_*.log`,按插件名过滤持续输出;
  - schema 校验 + plg.json 骨架片段。
- 诚实边界(写进 README):**不是源码级调试器**——没有断点/单步,没有 DAP 会话;
  做的是构建、运行、看内核日志、清单校验。

验证:`tsc` 零错误;`node --test` 13/13(版本规则、诊断分级、SDK 列表解析、状态栏文本、
以及**反向核对抓到的真缺陷**:`hmapdev 未找到` 这类输出曾被解析成版本号 → 已要求版本
token 以数字开头,否则「工具链不在」会被显示成「工具链 <垃圾词>」并让 SDK 诊断失真);
与真实工具链输出的集成核对 PASS(`hmapdev version` / `sdk list` 的真输出解析正确)。

README 同时补两节:plg.json 的 `sdk` 字段语义(含「为什么必须有」与「为什么拒区间写法」)、
本扩展的用法与边界。
2026-09-12 16:07:34 +08:00
9206353858 feat(hmapdev): 项目声明 SDK 版本,工具链据此自动选(plg.json 的 sdk 字段)
此前项目里没有任何「我要哪版 SDK」的声明:go.mod 的 require 是个 Go 模块版本,
而工具链实际用的是存储里的 current——谁改过 current 就拿谁的版本编,出错时
表现为莫名其妙的编译错误(本轮就踩过:存储里只有陈旧的 v0.8.0,模板项目
首次构建报 undefined: sdk.InjectOptions)。

- `plg.json` 新增 `sdk` 字段:本插件针对的 SDK 版本。`hmapdev init` 生成时写入
  **完整版本号**(如 "1.2.0")。
- `hmapdev build` 按声明的版本在本地 SDK 存储里定位:命中则用它并把 go.mod 的
  require/replace 同步到该版本;未命中则报**可执行**的错误(列出已装版本 +
  `hmapdev sdk install vX.Y.Z`),绝不静默退化成 current。
- **区间写法("1.2")被拒绝**并说明规矩:SDK 版本跟随内核中版本、patch 位恒为 .0,
  一条内核线只有一个 SDK 版本(写区间会让人误以为同一条线里还能挑不同 SDK)。
- 产物 `plugin.json` 记录实际选中的版本(`sdk`),便于追溯「这个 .hmap 是哪版编的」。
- 显式 `--sdk-path` / `plg.json sdk_path` 优先(本机改 SDK 联调的路径),此路径下也尽力记录版本。
- 存量项目(plg.json 无 sdk 字段)行为不变,向后兼容。

顺带修一处自相矛盾:解析出 1.2.0 之外的版本时,原先只改 replace 而 require 保持旧版本,
一旦有人删掉 replace 就会静默用回旧 SDK 编译(`go list -m` 报的也是假版本)。

验证:单测 10 例(精确命中/带 v 前缀目录/区间写法被拒并说明规矩/未命中给可执行命令/
空存储给安装指引/非法值拒绝/杂项目录不干扰)+ 反向验证(把版本比较退化成字典序,
「1.2.x 取最新」用例立刻变红,证明判据能发现缺陷)。
E2E:init → plg.json `"sdk": "1.2.0"`;build → 精确解析、go.mod require/replace 一致、
产物 plugin.json 记录 sdk;声明不存在的版本 → 可执行报错;无 sdk 字段 → 照旧构建。
2026-09-12 15:51:23 +08:00
83a54f321e fix(hmapdev): 工具链能报出版本(此前 -ldflags -X meta.Version 静默无效)
两个缺口叠加:既没有 `version` 子命令,构建时的
`-ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=…"` 也因为
**meta 包根本没被工具链引用**而完全不生效(链接器不会保留未被引用的符号,
rodata 里连那个字符串都没有)——于是「手里是哪一版 hmapdev」无从判断,
而插件产物与内核是协议绑定的,这恰恰是最需要判断的一件事(v0.8.0 SDK 陈旧目录
导致模板编译失败那次就是靠猜)。

- main.go 引用 meta 包并新增 `version`(以及 `-v/--version`)子命令,
  输出工具链版本 / SDK 模块 / 构建提交 / 构建时间 / 构建用 Go / 可执行文件路径;
- 未注入(源码默认值)时也照常报,unknown 字段不打印(避免噪声);
- usage 里补上 `hmapdev version`;
- 加测试钉住两件事:注入值必须出现在输出里(-X 一旦失效立刻变红)、
  未注入时也要能报出源码默认版本。

验证:`go build` 默认输出 1.3.0(主干默认值);`-X …meta.Version=1.2.1
-X …meta.Commit=abc1234` 后输出 1.2.1 + 提交号,且二进制内精确匹配到该串
(说明注入真的进了镜像);`go test -run PrintVersion` 2/2 PASS。
2026-09-12 15:08:56 +08:00
b93fe6b878 chore(version): main 路牌推到 1.3.0(v1.2.0 已从 release/v1.2.x 发出)
按版本纪律:release/v1.2.x 停在 v1.2.0 的发布提交(140cd34),主干 meta.Version
永远是「下一个未发布中版本」。
2026-09-12 14:16:46 +08:00
140cd34b56 fix(package): 示例构建改用宿主工具链,跨平台不再 Exec format error
`build.sh all all` 此前必然失败:示例的跨平台是由 hmapdev 的 `--target` 完成的,
被执行的进程必须在当前机器上跑,而 build_examples 传的是**目标平台**那把工具链
→ darwin 目标下拿 darwin 二进制在 linux 上跑,报
"cannot execute binary file: Exec format error"(linux/amd64 之后的所有目标全灭)。

- 宿主平台在脚本顶部、**export GOOS/GOARCH 之前**取定(`go env GOOS` 在 export
  之后会返回目标平台,这正是原 bug 的成因),并用 `env -u GOOS -u GOARCH` 兜底;
- `all` 的第一个目标可能不是宿主平台 → 缺宿主工具链时先补建一次;
- 宿主工具链仍缺失则**显式报错并给出该跑哪条命令**,不再静默退化成 Exec format error。

验证(VERSION=1.2.0 bash package/build.sh all all):
linux/amd64、linux/arm64、darwin/amd64、darwin/arm64 四个目标示例产物均 17/17 成功
(修复前 darwin 两个目标 0/17);windows 目标仍按设计显式拒绝
(协议 2 的统一共享内存区未移植 Windows,走 WSL)。
2026-09-12 14:15:20 +08:00
b237787c90 refactor(toolchain)!: 工具链 plugindev 更名为 hmapdev,module path 改回 gitcode
- 目录 tools/plugindev → tools/hmapdev,可执行文件名/平台产物名同步
  (hmapdev_linux_amd64 等;包格式仍叫 .hmap)
- module path github.com/JianFeeeee/homeagent-sdk/tools/... → gitcode.com/...
  (与仓库实际托管一致;核心仓不依赖该 path,改动无外部影响)
- SDK 存储目录 ~/.homeagent/plugindev/sdk → ~/.homeagent/hmapdev/sdk
  新目录不存在而旧目录存在时沿用旧目录 → 已装 SDK 版本不会丢失
- 命令表/usage/--help/生成项目 README/示例 README/NSIS 安装器/
  package/build.sh/build-examples.sh 全部同步;PLUGINDEV 环境变量保留兼容
- sdk/ 目录零改动(公开接口不变)

验证:
- go build ./... ok;go test ./tools/hmapdev/ ok(含模板接线守卫 TestProcTemplate_CoversAllCoreMethods)
- bash -n package/{build,build-examples}.sh ok
- 端到端:hmapdev init demo && hmapdev build → dist/demo_bundle.hmap(linux+darwin)
- 本机安装 /usr/local/bin/hmapdev,旧名以软链保留;sdk list/current 正常
2026-09-12 12:30:20 +08:00
69ff3089a4 fix(mocksdk): 补上漏掉的 InjectInputSync,恢复与公共 SDK 的同构
mocksdk 自己的注释立了规矩:「插件在 yaegi 下调得通的方法,编成 plugin.bin 后
必须也调得通,否则调试期与真实运行行为不一致」。但这个 mock 一直没有旧的三参数
`InjectInputSync`(`git log -S` 可证并非本次引入),而那正是通道类插件
(qq / a2a)完成「收到入站 → agent 处理 → 回复取回」闭环要调的方法:

- 编译成 plugin.bin:能用(实现在模板里)
- 在 yaegi 下调试:方法根本不存在

§九 早就点过 mocksdk 是最容易悄悄漂的一处(它没有任何代码对着编译,编译器抓不到;
上次漂的是 `Triple.Predicate` vs 公共 SDK 的 `Relation`)。

本次做的是**机械比对**而不是凭印象:抽出公共 SDK `IOInjector` 的 14 个方法名
与 mock 的方法集求差,差异恰好只有 `InjectInputSync` 一个,补上后差集为空。
mock 仍可编译,plugindev 测试全绿。
2026-09-12 09:34:51 +08:00
e839eb8220 test(plugindev): 模板接线守卫区分「漏接线」与「刻意保留的兼容面」
TestProcTemplate_CoversAllCoreMethods 红了:它要求模板出现内核提供的**每一个**
method id,而注入标志位落地后模板不再发 "io.injectTextNoMem"。

这不是漏接线,是刻意的向后兼容面:

- **旧模板确实发过它**(可复查:ba49dfd 之前的 templates/proc_main.go.tmpl 里,
  InjectTextNoMemory 拼的就是 "io.injectTextNoMem"),所以内核必须继续接受
  那时编出的插件二进制;内核侧的注释也写明「旧 RPC 语义就是『不进记忆』」。
- 当前模板改成走 "io.injectText" + `InjectOptions{NoMemory: true}`,两条路径
  语义等价,没有理由再发旧 id。

若把「内核有、模板就必须发」当不变量,这条守卫会常驻误报——常驻误报的守卫
迟早被习惯性忽略,那时**真**漏接线就没人看见了。故:

- 从 required 移出该 id,改为显式的 `deprecated` 表(每条写出保留原因);
- 加**反向保护**:allowlist 里的 id 一旦重新出现在模板里就报错,提示该条目已过期,
  避免这个表退化成「永久豁免」的垃圾抽屉。

实测:模板接线测试由红转绿;把 id 临时塞回模板,反向保护如期报出
「条目已过期,请从 deprecated 移除」,恢复后再次全绿。
2026-09-12 09:33:00 +08:00
d893bfa76f docs: README 更新到 SDK 1.2.0(注入行为 / ContextPolicy / 重编要求)
README 此前停在 SDK 1.1.0,而且 1.2.0 的新增接口**一处都没写**。本次补齐:

- 「版本与兼容性」:当前版本改为 1.2.0(需内核 1.2.0+),并新增
  **1.1.x → 1.2.x 必须重编**的说明——接口是纯追加,但插件运行协议升到 2
  (统一共享内存区 fd3 布局改变,不支持滚动升级),旧 plugin.bin 会因协议
  版本不匹配被拒绝(错误明确提示用配套 plugindev 重编,不静默降级)。
  这与 1.0.x→1.1.x「不需重编」形成对照,必须写清楚。
- 新增「注入行为与上下文裁剪(1.2.0)」章节:`InjectOptions{NoMemory,
  ContextPolicy, CleanerName}`、六个 `*Opts` 变体、`ContextPolicyNone`/`Prune`
  取值、以及三条要点(零值等价于旧三参数方法 / 裁剪必须显式声明 /
  裁剪先经注册的 Cleaner)。签名逐个从 sdk/plugin.go 抄录,未凭记忆书写。
- 「示例插件」补一句:release 附带预编译示例 `.hmap` + SHA256SUMS/MANIFEST,
  理由是插件二进制与内核协议绑定,只发工具链容易让人拿旧产物去装而握手失败。

中英双份同步更新。
2026-09-12 09:27:15 +08:00
93ab794a82 docs(license): SDK 采用 AGPL-3.0-only,并补许可章节
本仓此前**没有任何许可文件**,README 里也没有许可声明。现补:

- `LICENSE`:GNU Affero 通用公共许可证第 3 版官方全文(gnu.org 正本,
  661 行 / 34523 字节 / sha256 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0)
- README.md / README_EN.md:新增许可章节

选 AGPL-3.0-only 的理由:GPL 家族里传染性最强的一档,且不允许选后续版本。
对插件开发者的实际含义已写进 README:SDK 随插件静态链接(源码进入插件二进制),
插件因此是本 SDK 的衍生作品,必须以相同许可发布;AGPL §13 也覆盖网络交互,
通过 HTTP/WebSocket 提供服务的插件同样要向使用者提供源码。

第三方(Go 依赖 go-sqlite3 / gojieba / bubbletea 等,MIT / BSD-3 / Apache-2.0)
保持各自许可;平台侧模型与运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
不属于本 SDK。
2026-09-12 09:16:20 +08:00
12cabcb290 fix(meta): SDK main 的路牌回到 1.2.0 —— beta 不发 SDK
我上一提交(44bd915)把 SDK main 从 1.2.0 推到 1.3.0,判据用错了。

当时照搬的是 核心仓 e4be966 的先例(§七.4「两仓 main 都是下一个未发布中版本」),
但那个先例的前提是**核心那一版已经正式发布过**:当时 v1.1.0 / v1.1.1 都已打 tag,
main 才推进到 1.2.0。

而按 §七.2,**beta 不伴随 SDK 发版**:SDK 1.2.0 要等核心的**正式** tag 才定版、
建 release/v1.2.x、打 tag(§七.3)。在此之前 1.2.0 仍然是 SDK **尚未发布**的中版本,
所以「下一个未发布中版本」就是 1.2.0 —— 推到 1.3.0 等于宣称 1.2.0 已经存在。

核心 main 是 1.3.0 并没有错:核心切出 release/v1.2.x 后,1.2.0 就归发布线所有。
**两仓在这个阶段故意不对称**,已在 meta.go 注释里写明,避免再被「对齐」回去。
2026-09-12 09:00:19 +08:00
44bd915fbf chore(meta): SDK main 的版本路牌推到 1.3.0
按 核心仓 docs/git-branching.md §七.4,两仓 main 都遵守 §2.1:meta.Version 是
**下一个未发布中版本**。核心 1.2.x 线已开(release/v1.2.x 承载 1.2.0),所以
两仓 main 一起指向 1.3.0——核心仓同一时刻也做了同样的推进(chore(meta))。

它标记「main 正在积攒 1.3 的东西」,不表示 1.3.0 已经存在。
SDK 1.2.0 的定版与 tag 不在现在做:按 §七.3,SDK 的 release/v1.2.x、meta.Version
定为 1.2.0、tag v1.2.0 都与核心的**正式** tag 一起执行(beta 阶段不发 SDK)。
2026-09-12 08:24:44 +08:00
ba49dfda44 feat(sdk): 注入行为的记忆/裁剪标志位(纯追加)+ plugindev 退出码修复 + 示例 hmap 随发版
## 1. 注入标志位(公开 API 纯追加,无签名变更)

给注入行为补上工具早已有的两类声明位,并让通道定义也带上:
- `InjectOptions{NoMemory, ContextPolicy, CleanerName}`
- `IOInjector` 新增六个 `*Opts` 变体(排队/中断/同步 × 纯文本/带媒体)
- `ChannelDef.ContextPolicy`,并**补上 JSON tag**(Cleaner 标 `json:"-"`)

零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 + 不裁剪),
存量插件不需要改一行、也不需要重编;旧方法保留为转发到零值 opts 的语法糖。

三条设计要点:
- **默认不裁剪**:裁剪会归档丢弃低相关事件,必须显式声明(ContextPolicy=prune)。
- **中断也允许声明 prune**(已确认):中断同样携带内容进上下文。
- `CleanerName`:注入的 source 未必是注册过的输入通道名,而注入内容常带
  ANSI/JSON 包装;允许显式指定用哪个已注册 cleaner 清洗。

顺带修掉一个易静默丢字段的坑:`ChannelDef` 原来没有 JSON tag,只能手写字段
白名单跨进程传(`{"NoMemory": ...}`),新增字段会被丢掉。现在模板整体传 `def`。

## 2. 示例调用点统一写明意图
rss/memo/calendar/qq 的中断注入显式 `NoMemory: true`(行为等价,写清语义)。

## 3. plugindev 出错却 exit 0(真缺陷)
`buildBundle`/`buildTarget` 遇错只 Printf 后 return,`cmdBuild` 返回 void,
于是**构建失败也退 0**。实测中一个示例的 windows 目标编译失败,批量脚本却报
「17/17 全绿」,并因此少产出 16 个 .hmap。现在累计 `buildFailed` 并以非零退出。

## 4. 平台策略:插件目标去掉 windows
homed 已放弃 Windows 原生(见核心仓 cmd/homed/platform_windows.go:插件体系依赖
fd 继承 + 统一共享内存区的段内偏移解引用,Windows 句柄模型无法表达),
插件只运行在 homed 能跑的平台上,故 `allBundleTargets` 去掉 windows,
并对 windows 目标给出**可执行的报错**(指引 WSL2),而不是让它死在一句
`undefined: attachUnifiedShm` 上。

## 5. 发版附带各示例插件的 .hmap
新增 `package/build-examples.sh` 并接入 `package/build.sh`(组件 all|plugindev|examples):
- 用**刚构建出来的**那把工具链构建示例,保证与本次发版同源
- 逐平台 `--no-bundle --target <os/arch>`(bundle 会连 windows 一起编)
- 判成功同时看**退出码 + 产物存在**
- 全部产物齐了才 `sha256sum`(边打边算会漏掉后生成的包)
- 有任一失败即整体失败,不生成 SHA256SUMS

## 6. 版本
SDK 仍为 1.2.0(main 是下一个未发布中版本);1.2.0 条目补记本次新增接口,
并注明新标志位需核心 1.2.0+(旧核心会忽略这些字段,不报错但不生效)。
2026-09-11 20:29:30 +08:00
b2eafdf885 refactor(sdk): 移除 MediaAttachment.Description,媒体不再以文本描述参与索引
描述式索引是把图片将就成文本的机制,已在内核侧彻底拆除:

- MediaAttachment 不再携带 Description:媒体只按自己的原生向量被
  检索与召回,不生成、不检索、不持久化任何描述文本;
- InsertWithMedia 的语义随之收敛为「媒体成为文档直接持有的一等块」,
  文档向量融合这些块的原生向量,图片按图本身被召回;
- 往返测试同步去掉描述字段,只断言 digest/MIME/name/data 不损坏。

注意:这是公开 SDK 的破坏性字段删除(有意为之,媒体描述链路整体废弃),
不是加法式变更。
2026-09-11 11:43:35 +08:00
8c397ecf65 feat(plugindev): doc/knowledge 大正文走共享内存 + 协议版本 bump 到 2(§13.13)
- doc.insert / doc.insertWithMedia:doc_ref / attachments_ref
- knowledge.add:content_ref
- procProtocolVersion 1 → 2

bump 的理由:这两处改了内核→插件的 payload 承载方式,两种错配都静默失效
(v1 插件遇 v2 内核拿到空参数;v2 插件发 blocks_ref 给 v1 内核被静默忽略)。
双方都是等值校验,bump 后 v1 插件遇上 v2 内核会在建链时显式报错并带出
“请用配套 plugindev 重编”。

配套内核侧 50ba4a6。
2026-09-10 23:26:21 +08:00
632f6743d3 feat(plugindev): 媒体块经共享内存传递(§13.13)
SetToolBlocks / InjectInputMedia / InjectInputMediaSync /
InjectInterruptMedia 原先把 blocks 内联在 RPC 报文里。本地生成的图/音频是
base64 data URL(一张图可达数 MB),内联时整份要在报文里再编码再拷贝一遍;
更关键的是内容本体不在共享段里,插件回调无法就地改写。

改为经 putValueInArena 传 blocks_ref,内核 resolveBlocks 读回;小 payload
仍内联。

同步调用用 mediaArgsOwned 返回的释放函数延迟释放:injectMediaSync 要等
应答,槽不能在应答到达前回收,否则内核读到已释放内存。

配套内核侧 15d912e(实现 setToolBlocks 桩 + resolveBlocks)。
2026-09-10 22:46:41 +08:00
9d930db4ea feat(plugindev): output.invoke 模板从共享帧读参数(§13.6)
内核侧 OutputInvokeParams 新增 Frame/ArgsLen,payload 不再内联在 RPC 报文里。
模板必须跟着读帧,否则 frameInput 拿不到参数、输出通道收到的 payload 为空——
生产插件(如 qq)走的正是模板,模板不改这个改动就等于没做。

无帧时回退内联 args,保持对直连 RPC 调用方的兼容。
2026-09-10 21:28:56 +08:00
0a164fe4b9 feat(qq): qq_get_message 声明 ContextPolicy=prune(§13.8 验证项)
消息正文只在当轮需要(决定怎么回复),用完即裁剪。不裁的后果是每条 QQ
消息的完整正文都留在 L0 上下文里,长会话下持续挤占 token 预算。

内核侧 §13.8 早已就位:StageAfterToolcall 之后按 ToolDef.ContextPolicy
调 RelevanceContext.Prune(772a494),但 QQ 侧一直没声明,等于功能空转。
2026-09-10 21:18:16 +08:00
fd5a291df1 feat(qq): 权限门 + 单轮循环保险 + 极简发送回执 (v1.4.0)
回应 problem.md:核心把「发送成功」的富回执喂给模型,模型读成
「这步成功,继续下一步」而重复调用 output_send__qq。

- handleChannelOutput 成功只返回 "ok",不回传 NapCat 原始响应(含 message_id)
- 全局权限门 on_input/before_toolcall/post_action:工具白名单、私人资源边界、
  高风险命令 confirm 校验;before_toolcall 的 Response 只用于拒绝单个工具,
  post_action 负责清除以免被内核误当作结束推理的最终响应
- 单轮循环保险:max_qq_tool_calls=200 / max_qq_output_calls=20 /
  max_duplicate_qq_send=1,只拦参数完全相同的重复调用,不误杀必需的多次调用
- plg.json 1.2.0 → 1.4.0
2026-09-10 20:36:43 +08:00
5175e7d6e0 refactor(plugindev): 模板适配 funccall 调用帧
工具调用/清洗由内核发起,内核标定一块内存帧交给插件(callee),
插件在帧内工作;只有结果超出内核预留预算时才向内核申请扩容块。

- 新增 frameInput/frameOutput/arenaPut:读写调用帧、按需扩容
- tool.invoke:从 frame[0,args_len) 读参数;结果优先写帧结果区,
  放不下才 arena.alloc 扩容并打 sharedRefFlagExpand
- cleaner.invoke:同一帧模型(frame + input_len)
- SharedRef.Flags 语义位与内核对齐(JSON / EXPAND)
- 防漂移测试更新为 frame/args_len/result_ref
2026-09-10 18:54:40 +08:00
71e3325439 refactor(plugindev): 模板改用内核独占共享槽池 + ContextPolicy 字段
共享内存是内核内部实现,不对插件开发者暴露。模板不再维护任何分配
游标(历史上 bump 游标 / 模板内位图 CAS 两版都因把可变分配状态放在
共享内存里而出竞态),改为通过内核 RPC 申请/归还:

- 删除 arenaWrite 本地 bump 分配器与 arenaOff/arenaUsed 全局变量
- 新增 arenaAlloc/arenaFree:走 arena.alloc / arena.free RPC
- 新增 putInArena/callWithText:按 payload 大小自动选择共享槽或内联
  JSON(inlinePayloadLimit=512),SDK 公开 API 仍是普通字符串/Map,
  插件开发者无感
- cleaner.invoke 改为读 TextRef/内联 Text、写 RespRef/内联 Text;
  插件不做任何分配(内核预分配请求槽 + 响应槽)
- 同步 handshake:不再解析 arena 槽池布局(偏移与大小由 arena.alloc
  的应答下发)

测试:
- 新增 TestProcTemplate_UsesKernelArenaRPC:模板必须调用 arena.alloc/
  arena.free,且不得再出现 arenaUsed/arenaWrite(回归保护)
- TestProcTemplate_RejectsVersionMismatch 更新为 §13.1 后的「统一区域
  魔数不匹配」文案
- 修正模板与 sdk/plugin.go 的 gofmt 对齐(含补上 ContextPolicy 字段)
2026-09-10 17:58:06 +08:00
18fec9b003 feat(shm): Cleaner SharedRef + ContextPolicy (§13.4/§13.8)
- cleaner.invoke 协议: TextRef SharedRef 替代内联 text
- 插件侧 arenaWrite/SharedRef 读写辅助
- ToolDef.ContextPolicy 字段(默认 none / 可设 prune)
- 握手解析统一区域 SuperBlock + arena 元数据
2026-09-10 16:04:55 +08:00
fc236120e3 feat(shm): 统一共享内存区域(§13.1) — 子进程侧模板适配
- proc_shm_unix.go.tmpl: fd 3 = 统一区域,fd 4 = eventfd
- proc_main.go.tmpl: 握手解析 SuperBlock,从 ctxOff/evtOff 定位两段
- 新增 unified region 常量(magic/version/offset) + StageContext 内部布局常量
2026-09-10 11:46:12 +08:00
a66739e59b docs: README 顶部补版本兼容性表与并发约定,下载链接升到 v1.1.0
两件事此前没写进 README,会让读者拿到错的现状:

## 版本兼容性表

README 开头没有版本号、没有兼容性说明,读者无从判断「我这个版本能不能用新接口」。
补一张「内核版本 ↔ SDK 版本」表,说清 patch 位恒为 .0 的语义,以及
1.0.x 升 1.1.x 不需要改代码也不需要重编(新增是「插件调用、内核实现」方向,
不调就不受影响;实测用 SDK 0.9.2 编的旧 plugin.bin 在新内核上直接建链通过)。

## 并发约定

PluginSDK 是被多个 goroutine 同时使用的共享对象,这一点此前没有写在明处。
列出 SDK 已保证的(访问器/注入/注册/handler 幂等)与开发者必须自己保证的
(StageContext 字段全导出,并发读写要自己持锁;Extra 的 map 并发写是直接 fatal)。

并顺手把下载链接从 v1.0.0 升到 v1.1.0——照旧链接去 release 页面会找不到
v1.1.0 的产物,因为那条 curl 用的是 v1.0.0。
2026-09-06 11:35:09 +08:00
da01af1ad7 chore(meta): main 的版本路牌推到 1.2.0
按 核心仓 docs/git-branching.md §2.1,两仓 main 的 meta.Version 都是
**下一个未发布中版本**。1.1.x 线正在发布中,所以 main 指向 1.2.0。

SDK 版本跟随核心的中版本(§七.1):release/v1.1.x 上定版 1.1.0,
整条核心 1.1.x 线共用它。

**此 commit 不 cherry-pick 到发布分支**(§五)。
2026-09-06 10:04:26 +08:00
741e284cd4 Merge branch 'feature/sdk-multimodal' — 多模态贯通插件边界
公开 SDK 新增媒体字段与媒体注入接口(全部新增,无签名变更),
修掉 PluginSDK 的 API 字段与 autoRestart 两处并发竞态,
工具链(proc 模板、mocksdk、方法清单断言)同步接线,README 中英双语补文档。

存量插件零改动零重编。
2026-09-06 10:04:26 +08:00
ce5bff9275 feat(sdk): 多模态贯通插件边界——媒体字段、媒体注入接口与并发修复
记忆系统在核心 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放:
插件把 Triple / Doc 交进来,媒体一律无处安放,且**不报错**。本版补上公开接口
侧缺失的表达能力。

## 一、类型与接口(全部新增,无签名变更)

- `Triple` += `SentenceText`、`MediaDigests`
- `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment`
- `TextEvent` += `Attachments`
- `DocMemoryAPI` += `InsertWithMedia`
- `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia`
- `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有,
  插件只能自己去拿 injector)

`MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(内核按字节
去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索
可能命中几十份媒体,把字节全塞回来会撑爆跨进程消息。

媒体注入为什么不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,
且媒体要等**下一条** tool message 才到模型手上。插件主动发起一轮带媒体的对话、
以及中断注入,需要各自的签名,且媒体在**本轮**就随消息发出。

`Triple.MediaDigests` 非空而 `SentenceText` 为空时,内核会用媒体标记本身充当句子
——媒体引用挂在句子上,没有句子就无处挂起。插件只需填 digest,标记由内核拼:
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。

## 二、修掉两处并发竞态

`sdk/stress_test.go` 的 `-race` 实测报 11 处 DATA RACE,收敛到两个字段:

1. **`PluginSDK` 的 API 字段无锁**。写方是内核(加载/重载插件时依次注入
   injector、memory、doc、llm…),读方是插件在 `Start()` 里起的后台 goroutine
   ——轮询、监听、定时器都要拿 injector 往管道注消息。生产表现是插件重载瞬间
   偶发崩溃:读到半个接口值就 nil 解引用。
2. **`autoRestart` 标志无锁**。`SetAutoRestart` 的文档用法本身就是「外部连接建好
   后再决定能否自动重启」,而连接建立通常在后台 goroutine;内核 registry 在另一个
   goroutine 读 `AutoRestart()` 决定崩溃后重启策略。这对读写天然跨 goroutine。

加 `apiMu sync.RWMutex`。关键约定写进注释:**只在持锁期间取字段值,取完立刻
释放再调用**。持锁调用会把 `InjectInputSync`(阻塞到 agent 回复,可达数分钟)
与 `SetIOInjector` 串到一起,让插件重载卡死。

## 三、压测(sdk/stress_test.go,13 例)

SDK 是被多个 goroutine 同时使用的共享对象,单线程单测全绿不代表并发路径成立。
断言的是不变量而非吞吐:

- 媒体注入高并发不丢不串——每次调用带唯一 tag,逐条校验文本与图片 URL 配对。
  「不串」是重点:若实现里出现任何共享中间状态(把 blocks 暂存到字段再读出),
  高并发下会出现 A 的文本配 B 的图,而两者单独看都「成功」了;
- injector 热替换(含替换成 nil,即内核卸载 API 的真实状态);
- stop / onRemove handler 恰好一次——契约是「执行后清空,幂等」,执行两次的后果
  从重复写文件到 close 已关闭 channel 直接 panic;
- `StageContext` 并发读改写无 lost update(媒体链路让 Extra 成为新热点,
  而 map 并发写在 Go 里是直接 fatal,recover 接不住);
- `OwnTools` scope 不跨插件泄漏;
- 媒体类型 JSON 往返字节级一致(9 种长度,含 0/1/2/3 与 base64 分组边界)
  ——`[]byte` 在 JSON 里是 base64,往返不一致意味着图片静默损坏,
  要到 CAS 校验 digest 时才发现,那时已无从追查;
- `omitempty` 真的生效(读路径不能出现 `"data"` 键);
- nil 依赖全部静默降级不 panic。

## 四、工具链同步

- `proc_main.go.tmpl`:`procIO` 三个媒体方法、`procDocMemory.InsertWithMedia`。
  模板不跟上的后果是**每个外部插件都编不过**(接口未实现),是硬失败;
- `proc_runtime_test.go`:方法清单补 `io.injectMedia*` 与 `doc.insertWithMedia`。
  漏接线时插件调 `InjectInputMedia` 会静默无效果——模板不发这个 RPC,内核也就
  收不到,两边都不报错;
- `yaegi/mocksdk`:与公开 SDK 对齐。它此前漂移严重且**没有任何代码对着它编译**,
  所以漂移不会被编译器抓到:`Triple` 用的是 `Predicate`,而公开 SDK 一直叫
  `Relation` —— 插件在 yaegi 调试期写 `Relation:` 报未知字段,写 `Predicate:` 则
  编成 plugin.bin 时报错,两边都不对。
- README 中英双语补媒体接口文档与用法示例。

## 兼容性

存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,不调就不
受影响。17 个 example 插件源码零改动通过类型检查;用 SDK 0.9.2 编的旧 plugin.bin
在新内核上直接建链通过(握手校验的是 ProtocolVersion=1,不是 SDK 版本)。

媒体接口需要核心 1.1.1+(更早的核心没有对应 RPC,调用返回 unknown method)。
`CoreVersion` 保持 1.0.0:它是「SDK 能在其上运行」的下限,媒体是可选能力。
2026-09-06 10:03:36 +08:00
297 changed files with 51686 additions and 4642 deletions

251
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,251 @@
# HomeAgent SDK 仓 CI。
#
# 与 release.yml 分工:本文件管「推送到 main / PR 时的检查」,release.yml 管
# 「release/** 推送时的发版」。此前本仓**只有** release.yml —— main 的推送与
# PR 完全没有检查,而 SDK 是被外部插件开发者直接依赖的契约面(sdk/plugin.go
# 的接口一旦破,所有外部插件编译失败),必须有门。
#
# 设计原则与主仓一致:**CI 里每条命令都是本地已实测通过的**。
# 不写「应该有用来试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态。
#
# 明确不进 CI 的:
# - `hmapdev skill install`(会写 ~/.claude、~/.codex 等**开发者本机目录**)
# - 需要内核仓在场的检查(本仓独立可测;sync-lua-sdk.sh 会在缺内核时
# 明确 skip 而不是假装成功)
# - 任何网络/真机依赖
name: CI
on:
push:
branches: [main, 'release/**']
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# SDK 本身不需要 cgo(hmapdev 与 sdk 包都是纯 Go)—— 与主仓相反。
CGO_ENABLED: 0
GOFLAGS: -buildvcs=false
jobs:
# ── 根模块:SDK 对外接口所在 ──
go:
name: Go build / vet / test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
# 读根 go.mod 会装 1.21,examples 构建必然失败
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
# 所以本地「能过」不代表 CI 能过。
- uses: actions/setup-go@v7
with:
go-version: '1.25'
cache: true
- name: go build ./...
run: go build ./...
- name: go vet ./...
run: go vet ./...
- name: go test ./...
run: go test ./... -count=1 -timeout 10m
# ── hmapdev 工具链(嵌套 module,根模块的 ./... 不会进入它)──
hmapdev:
name: hmapdev (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
ext: ''
- goos: linux
goarch: arm64
ext: ''
- goos: darwin
goarch: amd64
ext: ''
- goos: darwin
goarch: arm64
ext: ''
- goos: windows
goarch: amd64
ext: '.exe'
steps:
- uses: actions/checkout@v7
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
# 读根 go.mod 会装 1.21,examples 构建必然失败
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
# 所以本地「能过」不代表 CI 能过。
- uses: actions/setup-go@v7
with:
go-version: '1.25'
cache: true
# tools/hmapdev 是**独立 module**(有自己的 go.mod)——
# 根模块的 `go test ./...` 不会进入它,必须单独跑,否则它的测试
# 永远不在 CI 里执行。这是 Go 的模块边界,不是配置问题。
- name: go test(嵌套 module)
working-directory: tools/hmapdev
run: go test ./... -count=1 -timeout 10m
# main 包在 tools/hmapdev —— 不指定工作目录会在仓库根跑 `go build .`,
# 而根目录没有 Go 文件(包在 sdk/ 子目录),报 "no Go files in ..."。
- name: 交叉编译
working-directory: tools/hmapdev
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
set -euo pipefail
out="hmapdev_${{ matrix.goos }}_${{ matrix.goarch }}${{ matrix.ext }}"
go build -trimpath \
-ldflags "-X github.com/JianFeeeee/homeagentsdk/meta.Version=0.0.0-ci" \
-o "/tmp/$out" .
test -s "/tmp/$out" || { echo "ERROR: 未产出 $out"; exit 1; }
printf " ✓ %-28s %s 字节\n" "$out" "$(stat -c %s "/tmp/$out")"
file "/tmp/$out"
# ── 示例插件:发版产物里最容易被使用者直接安装的东西 ──
#
# 为什么必须验:示例是使用者的模板(拷了就改),且**发版会连同 .hmap 一起发**
# —— 插件二进制与内核是协议绑定的(ProtocolVersion + 统一共享内存区魔数),
# 示例编不出来就意味着这次发版发不出配套产物。
#
# ★ 示例**不能**用 `go build` 验:它们是插件(只有 plugin.go、没有 func main),
# 必须由 hmapdev 注入 main 包装。直接 go build 会得到
# "function main is undeclared in the main package" —— 那不是缺陷,
# 是构建方式不对(本地实测确认)。
examples:
name: Examples (${{ matrix.target }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
# windows 不在列:build-examples.sh 会明确拒绝(协议 2 的统一共享
# 内存区未移植到 Windows),那是有意的设计而不是待修缺陷。
target: ['linux/amd64', 'darwin/arm64']
steps:
- uses: actions/checkout@v7
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
# 读根 go.mod 会装 1.21,examples 构建必然失败
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
# 所以本地「能过」不代表 CI 能过。
- uses: actions/setup-go@v7
with:
go-version: '1.25'
cache: true
- name: 构建 hmapdev(宿主)
run: |
set -euo pipefail
cd tools/hmapdev
go build -o "$GITHUB_WORKSPACE/build/hmapdev_host" .
# 用发版同一个脚本(package/build-examples.sh),避免 CI 与发版路径分叉。
- name: 构建全部示例
env:
HMAPDEV: ${{ github.workspace }}/build/hmapdev_host
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
bash package/build-examples.sh "$TARGET" /tmp/examples
echo
n=$(find /tmp/examples -name '*.hmap' | wc -l)
echo " .hmap 产物数: $n"
# 示例目录数应与产物数一致(少一个都要红)
want=$(find example -mindepth 2 -maxdepth 2 -name plugin.go | wc -l)
echo " 示例源码数: $want"
[ "$n" -ge "$want" ] || { echo "ERROR: 产物少于示例数"; exit 1; }
for f in /tmp/examples/*.hmap; do
printf " %9.1fKB %s\n" \
"$(stat -c %s "$f" | awk '{print $1/1024}')" "$(basename "$f")"
done
# ── 一致性与文档 ──
consistency:
name: Consistency & docs
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
# sdk.lua 有**四份**副本(本仓三份 + 内核仓一份),历史上漂移过,
# 出现「mock 有、内核没有」的静默失配。此处钉住本仓内的三份。
- name: Lua SDK 副本一致性
run: |
set -euo pipefail
canon=sdk/lua/sdk.lua
[ -f "$canon" ] || { echo "ERROR: 缺 $canon"; exit 1; }
want=$(sha256sum "$canon" | cut -d' ' -f1)
fail=0
for f in tools/hmapdev/assets/sdk.lua example/luademo/sdk.lua; do
have=$(sha256sum "$f" | cut -d' ' -f1)
if [ "$have" = "$want" ]; then
echo " ✓ $f"
else
echo " ✗ $f 与 $canon 不一致" >&2
echo " canon=$want" >&2
echo " this =$have" >&2
echo " 解法:bash scripts/sync-lua-sdk.sh" >&2
fail=1
fi
done
[ "$fail" = "0" ] || exit 1
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
# 读根 go.mod 会装 1.21,examples 构建必然失败
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
# 所以本地「能过」不代表 CI 能过。
- uses: actions/setup-go@v7
with:
go-version: '1.25'
cache: true
# skills/ 会随 SDK store 分发给所有 agent(hmapdev skill install)。
# skill list 会读 skills/ 目录并报告可用项 —— 目录结构坏掉会在这里红。
- name: 技能目录可被 hmapdev 识别
run: |
set -euo pipefail
# ★ 必须把 hmapdev 建在**仓库内**:resolveSkillsSource() 先从
# 可执行文件位置向上找仓库(tools/hmapdev → ../../skills),
# 落空才回退到 SDK store 的活跃版本 —— 而 CI 里没有 store,
# 于是报 "no active SDK version set"。建到 /tmp 就会落空。
mkdir -p build
( cd tools/hmapdev && go build -o "$GITHUB_WORKSPACE/build/hmapdev_ci" . )
./build/hmapdev_ci skill list
./build/hmapdev_ci skill path
- uses: actions/setup-python@v7
with:
python-version: '3.12'
# 文档站会被部署(deploy-sdk-site.sh)——站点构建坏了此前无从发现。
# --strict 把 warning 升级为 error:断链、缺失引用都会红。
#
# ★ 不能用 yaml.safe_load 校验 mkdocs.yml:它含 mkdocs-material 的
# `!!python/name:` 标签(配置 emoji 的官方写法),safe_load 处理不了,
# 会报 ConstructorError —— 那是校验方式不对,不是配置有问题。
- name: 文档站构建(mkdocs --strict)
run: |
set -euo pipefail
python3 -m pip install --quiet mkdocs-material
mkdocs build --strict --site-dir /tmp/site

314
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,314 @@
# HomeAgent SDK 仓发布流水线:release/** 推送即发版。
#
# 与主仓的 Release 流水线同构(见主仓 .github/workflows/release.yml),
# 差异只在产物与打包命令:
#
# 主仓 → 3 个 deb + 1 个 tar.gz(含 719MB 向量模型)
# SDK → hmapdev_{linux,darwin}_{amd64,arm64} + hmapdev_windows_amd64.exe
# + SHA256SUMS(约 140MB)
#
# 产物清单依据:gitcode 上 v1.2.0/v1.3.0 的实际附件(各 6 个),
# 以及 docs/git-branching.md §七 的「发版产物清单」。
name: Release
on:
push:
branches: ['release/**']
workflow_dispatch:
inputs:
skip_tests:
description: '跳过发版前的 go test 门(仅用于已知红的历史维护线)'
type: boolean
default: false
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CGO_ENABLED: 0
GOFLAGS: -buildvcs=false
jobs:
prepare:
name: Prepare
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }}
prerelease: ${{ steps.ver.outputs.prerelease }}
exists: ${{ steps.ver.outputs.exists }}
skip_tests: ${{ steps.ver.outputs.skip_tests }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# 版本事实源是 meta/meta.go 的 Version,故发版动作 =
# 在 release/vX.Y.x 上把它改成目标版本后推送。
#
# 幂等闸门:tag 已存在 ⇒ 整轮跳过(改文档不会重发版)。
#
# go test 门可跳过:新旧发布线的测试健康状况不同,硬门会让历史
# 维护线完全无法发版。发版人可在**改动 meta 的那个提交**里写
# [skip-release-tests] 显式跳过 —— 决定因此可被 git 历史审计。
# (标记查在改 meta 的提交上而不是 HEAD:发版提交之后常还会跟
# 几个提交,只看 HEAD 会让标记被顶掉、静默失效。)
- id: ver
name: 读取 meta.Version 并检查 tag
run: |
set -euo pipefail
V=$(sed -n 's/^[[:space:]]*Version = "\(.*\)"/\1/p' \
meta/meta.go | head -1)
if [ -z "$V" ]; then
echo "ERROR: 无法从 meta/meta.go 读出 Version"
exit 1
fi
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "tag=v$V" >> "$GITHUB_OUTPUT"
case "$V" in
*-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;;
*) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;;
esac
if git ls-remote --exit-code --tags origin "refs/tags/v$V" \
>/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo " tag v$V 已存在 —— 跳过发版"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo " 将为 v$V 发版"
fi
SKIP="${{ inputs.skip_tests }}"
REL_COMMIT=$(git log -1 --format=%H -- meta/meta.go)
REL_MSG=$(git log -1 --pretty=%B "$REL_COMMIT")
case "$REL_MSG" in
*'[skip-release-tests]'*) MARKER=1 ;;
*) MARKER=0 ;;
esac
echo " 发版提交: ${REL_COMMIT:0:12}"
if [ "$SKIP" = "true" ] || [ "$MARKER" = "1" ]; then
echo "skip_tests=true" >> "$GITHUB_OUTPUT"
echo " ⚠️ **已请求跳过发版前的 go test 门**"
else
echo "skip_tests=false" >> "$GITHUB_OUTPUT"
echo " 发版前会跑 go test 门"
fi
build:
name: Build hmapdev
needs: prepare
if: needs.prepare.outputs.exists == 'false'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
# 门:go build 是硬门;go test 可在发版 commit 里显式跳过。
#
# 测试分两处跑 —— tools/hmapdev 是**独立 module**,根模块的
# `go test ./...` 不会进入它(这是 Go 的模块边界,不是配置问题)。
- name: go build(硬门)
run: |
set -euo pipefail
go build ./...
- name: go test(根模块)
if: needs.prepare.outputs.skip_tests != 'true'
run: go test ./... -count=1 -timeout 15m
- name: go test(tools/hmapdev 独立 module)
if: needs.prepare.outputs.skip_tests != 'true'
working-directory: tools/hmapdev
run: go test ./... -count=1 -timeout 15m
- name: go test 被跳过(显式声明的后果)
if: needs.prepare.outputs.skip_tests == 'true'
run: |
echo "::warning title=go test 门已跳过::本次发版未跑 go test,产物可能建立在单元测试失败的代码上。"
- name: 打包 hmapdev(5 个平台)
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
rm -rf build
bash package/build.sh all hmapdev
echo
echo " 产物:"
for f in build/*; do
printf " %8.1fMB %s\n" \
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$(basename "$f")"
done
# 逐个确认 5 个平台都产出了 —— 只查目录非空会漏掉"少了一个平台"。
- name: 验证产物齐全
run: |
set -euo pipefail
missing=0
for f in hmapdev_linux_amd64 hmapdev_linux_arm64 \
hmapdev_darwin_amd64 hmapdev_darwin_arm64 \
hmapdev_windows_amd64.exe; do
if [ -s "build/$f" ]; then
echo " ✓ $f"
else
echo " ✗ 缺 $f" >&2
missing=1
fi
done
[ "$missing" = "0" ] || exit 1
# 校验和必须**全部产物齐全之后**一次算完(边打边算会漏包);
# 且只覆盖本批产物 —— build/ 可能残留上次的,故先清干净再建。
- name: 生成 SHA256SUMS
run: |
set -euo pipefail
mkdir -p /tmp/out
cp build/hmapdev_linux_amd64 build/hmapdev_linux_arm64 \
build/hmapdev_darwin_amd64 build/hmapdev_darwin_arm64 \
build/hmapdev_windows_amd64.exe /tmp/out/
cd /tmp/out
sha256sum hmapdev_* > SHA256SUMS
echo " SHA256SUMS:"
sed 's/^/ /' SHA256SUMS
sha256sum -c SHA256SUMS
- uses: actions/upload-artifact@v7
with:
name: hmapdev
path: /tmp/out/*
retention-days: 7
if-no-files-found: error
publish:
name: Publish
needs: [prepare, build]
if: needs.prepare.outputs.exists == 'false'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
name: hmapdev
path: dist
- name: 打 tag
env:
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
- name: 建 release 并上传附件
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
PRE: ${{ needs.prepare.outputs.prerelease }}
run: |
set -euo pipefail
cd dist
FLAGS=()
[ "$PRE" = "true" ] && FLAGS+=(--prerelease)
gh release create "$TAG" \
--title "HomeAgent SDK $VERSION" \
--notes "HomeAgent 插件 SDK $VERSION
\`hmapdev\` 工具链(Linux / macOS / Windows,amd64 + arm64)。
校验见 SHA256SUMS。
内核版本需与 SDK 的中版本对齐;协议不配套时插件握手会失败
(魔数不匹配),此时升级内核或改用对应版本的 SDK。" \
"${FLAGS[@]}" \
./hmapdev_* ./SHA256SUMS
echo "=== release 内容 ==="
gh release view "$TAG" --json assets \
--jq '.assets[] | " \(.name) \(.size) 字节"'
- name: 回读校验
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
mkdir -p /tmp/back && cd /tmp/back
# 同主仓:/tmp/back 不是 git 仓库,gh 无法从上下文推断仓库,
# 必须显式 --repo。
gh release download "$TAG" --repo "$GITHUB_REPOSITORY"
for f in *; do
printf " %8.1fMB %s\n" \
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$f"
done
sha256sum -c SHA256SUMS
echo " ✓ 回读校验通过"
sync-gitcode:
name: Sync to gitcode
needs: [prepare, publish]
if: needs.prepare.outputs.exists == 'false'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- id: tok
name: 检查 gitcode 凭据
run: |
if [ -n "${{ secrets.GITCODE_TOKEN }}" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo " 未配置 GITCODE_TOKEN —— 跳过 gitcode 同步"
fi
- uses: actions/download-artifact@v8
if: steps.tok.outputs.ok == 'true'
with:
name: hmapdev
path: dist
- name: 推 tag 与附件到 gitcode
if: steps.tok.outputs.ok == 'true'
env:
GC_TOKEN: ${{ secrets.GITCODE_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "$TAG" 2>/dev/null || true
GC_URL="https://JianFeeeee:${GC_TOKEN}@gitcode.com"
git push "${GC_URL}/JianFeeeee/homeagent-sdk.git" "$TAG"
curl -sS --max-time 60 -X POST \
-H "private-token: ${GC_TOKEN}" \
-H "Content-Type: application/json" \
"https://gitcode.com/api/v5/repos/JianFeeeee/homeagent-sdk/releases" \
-d "{\"tag_name\":\"$TAG\",\"body\":\"同步自 GitHub\"}" \
-o /tmp/.gcrel -w " 建 release → %{http_code}\n"
cd dist
# 脚本的路径语义是 os.path.join(ASSET_DIR, name) ⇒
# 必须 cd 进资产目录、ASSET_DIR=.、并传**裸文件名**。
# 传 "./x" 或 "dist/x" 都会拼成 dist/dist/x 而找不到文件。
#
# 为何显式列名而不是让它自动扫描:自动扫描只认 ARTIFACT_SUFFIXES
# 里的扩展名,而 hmapdev 的产物多数**没有扩展名**(只有 windows
# 那个是 .exe)⇒ 自动扫描会静默地一个都不传。
ASSET_DIR=. GITCODE_REPO=JianFeeeee/homeagent-sdk \
python3 ../scripts/upload-assets.py "$TAG" "$GC_TOKEN" \
hmapdev_linux_amd64 hmapdev_linux_arm64 \
hmapdev_darwin_amd64 hmapdev_darwin_arm64 \
hmapdev_windows_amd64.exe SHA256SUMS

14
.gitignore vendored
View File

@ -35,3 +35,17 @@ z_entry.c
# plugindev binary in tools/
tools/plugindev/plugindev
# 文档站构建产物(由 tools/apidoc/build.sh 生成)
site_build/
# mkdocs 缓存
.cache/
# agent 入口的「全文汇总」是派生件:由 gensite 把 docs/ 下所有 Markdown 拼成一份,
# 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出,
# llms.txt(索引,小且稳定)仍提交。
docs/llms-full.txt
# ★ rawfile/ 的内容是随包资源(可能内嵌 node/pi 等上百兆二进制),
# 由构建方自行准备,不进版本库。忽略规则放在 rawfile/.gitignore 内
# (自包含,避免依赖上层规则的作用域与否定式语义)。

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JianFeeeee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

378
README.md
View File

@ -2,6 +2,117 @@
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
> 📖 **完整文档**:<https://sdk.homeagent.jianfgit.xyz/>
>
> 快速开始 / API 参考 / 指南 / 示例插件都在那里,**本 README 只是速览**,
> 接口细节以文档站为准。直接进入:[快速开始](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/)
> · [API 参考](https://sdk.homeagent.jianfgit.xyz/api/) · [能力边界](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/)
>
> 面向 agent 的纯文本入口:[`llms.txt`](https://sdk.homeagent.jianfgit.xyz/llms.txt)
> (含全部 API 的 [`llms-full.txt`](https://sdk.homeagent.jianfgit.xyz/llms-full.txt))。
## 版本与兼容性
当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
| 内核版本 | 对应 SDK |
|---|---|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 起 | **1.3.0** |
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
因此 **SDK 仓在一个中版本里只发一次**(`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
(2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
实测验证:在新内核上直接建链通过,因为握手校验的是主版本号、不是 SDK 全等版本)。
想用新字段时重编即可。
**1.1.x 插件升到 1.2.x:接口纯追加;若内核共享区布局改了则需重编。** 公开接口没有签名变更
(新增 `InjectOptions` 与六个 `*Opts` 变体、`ChannelDef.ContextPolicy`),不调新能力就不受影响。
1.2.0 同时把 fd3 换成了统一共享区(SuperBlock + StageContext + EvtRing),
这类**布局**变化由握手时的结构判据拦下(统一区魔数、`field_count` 描述符个数),
而不是靠某个版本号——它们都是二进制里的派生事实,不会「忘同步」。
## 1.3.0 新增:注入优先级与动态输出通道
### 注入优先级(`InjectOptions.Priority`)
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
| 级别 | 常量 | 谁用 |
|---|---|---|
| L1–L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选(L1 最低) |
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
⇒ 存量插件不需要改一行、也不需要重编。
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
(现场保存/恢复对插件透明)。
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
### 动态输出通道(`UnregisterOutputChannel`)
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
1.3.0 起成对提供:
| API | 用途 |
|---|---|
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`,
违规会让**整条 LLM 请求**被上游拒绝(2026-09-13 生产事故:`device/<id>` 导致全量对话 403)。
派生规则与约束见下方「输出通道」一节。
## 注入行为与上下文裁剪(1.2.0)
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
1.2.0 起**注入侧也能声明**,并且二者共用同一套语义与取值。
```go
type InjectOptions struct {
NoMemory bool // true = 不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
ContextPolicy string // ""/none = 不裁剪(默认);prune = 据此裁剪上下文
CleanerName string // 计算层过滤函数名:先经 Cleaner 得到实际有效内容,再计算/裁剪
}
const (
ContextPolicyNone = "none"
ContextPolicyPrune = "prune"
)
// 六个变体,与旧的三参数方法一一对应,只多一个 opts
InjectTextOpts(source, channel, text string, opts InjectOptions)
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
要点:
- **零值 `InjectOptions{}` 与旧的三参数方法逐键等价**(记入记忆 + 不裁剪)。旧方法保留为
零值糖(`InjectText` / `InjectInterruptText` / `InjectTextNoMemory` …),存量插件不改一行、
不需重编即可继续调用。
- **裁剪(`prune`)必须显式声明**:它会归档丢弃低相关事件,是有副作用的行为,故默认关闭。
内核只放行 `""` / `none` / `prune`(`ValidContextPolicy`),未声明的取值会被拒。
- 裁剪前先经该插件注册的 **`Cleaner`**(由 `CleanerName` 指定)拿到实际有效内容,
避开「按原文裁剪、按清洗后计算」这种不一致。
- `ChannelDef` 也有同名 `context_policy`(并且 1.2.0 给它补上了 JSON tag——通道定义要跨进程
传给内核,而 `Cleaner` 是函数必须忽略;无 tag 时新增字段会被静默丢掉)。
## SDK API 接口
### Plugin 接口
@ -20,11 +131,15 @@ type Plugin interface {
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
> **通道的方向契约**:入站与出站是分开登记的两件事。凡是用 `InjectText*/InjectInput*/InjectInterrupt*`
> 注入的通道名都要 `RegisterInputChannel` —— inputch 是内核最基本的**输入路由单位**,
> 只有登记过的通道才能被"划给驻留子";只登记出站通道时内核会兜底登记同名 inputch 并告警(兼容老插件)。
| 分类 | 方法 | 说明 |
|------|------|------|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道,def 为 `ChannelDef`(NoMemory/Cleaner) |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道,def 为 `ChannelDef`,caps 为能力位掩码 |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
@ -36,6 +151,7 @@ type Plugin interface {
| 设置 | `Settings()` | 访问设置 API |
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
| 多模态注入 | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | 注入带图片/音频的输入(1.1.0 新增) |
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
### 阶段钩子
@ -70,6 +186,14 @@ sdk.RegisterInputChannel("qq", ChannelDef{
### 输出通道
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
> "这个工具不可用",而是**整条请求被 400 拒绝**(`Invalid 'tools[N].function.name'`),
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`(13 字符)的余量。
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
> 内核**不会**替你净化。
```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
```
@ -106,6 +230,30 @@ type 枚举值:
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
### 多模态注入(1.1.0 新增)
| 方法 | 说明 |
|------|------|
| `InjectInputMedia(source, channel, text, blocks)` | 注入带媒体的输入,异步 |
| `InjectInputMediaSync(source, channel, text, blocks)` | 注入带媒体的输入并同步等待回复文本 |
| `InjectInterruptMedia(source, channel, text, blocks)` | 注入带媒体的中断,可抢占当前处理 |
`blocks` 是 `[]sdk.ContentBlock`,与 `SetToolBlocks` 用同一类型:
```go
s.InjectInputMedia("myplugin", "webui", "帮我看看这张图", []sdk.ContentBlock{{
Type: "image_url",
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
}})
```
与 `SetToolBlocks` 的区别:`SetToolBlocks` 只能在工具处理函数内部调用,媒体要等到
下一条 tool message 才到模型手上;这三个方法是插件**主动发起一轮带媒体的对话**,
媒体在本轮就随消息发给模型,并自动落进媒体存储、挂上媒体记忆引用。
媒体块里的 `data:` URL 会被内核落盘去重;`http(s)` URL 只透传给模型,不入库
(入库需要内核发起网络请求,涉及超时、鉴权与 SSRF)。
`source` 标识来源,`channel` 指定目标输出通道。
### Triple 扩展字段
@ -115,6 +263,61 @@ Triple 数据结构新增字段:
- `Confidence` — 置信度(0.0~1.0)
- `SubjectType` — 主体类型
- `ObjectType` — 客体类型
- `SentenceText` — 原始句子文本(1.1.0 新增),写入 `sentences` 表;媒体引用挂在句子上
- `MediaDigests` — 关联的媒体 digest 列表(1.1.0 新增)
### 记忆里的媒体(1.1.0 新增)
媒体在纯文本记忆里以**标记**形式存在,格式 `[<mime> <短digest>] <描述>`:
```
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
```
描述文本是持久的语义记忆(检索靠它),digest 是回到字节的钥匙(反查靠它)。
标记由内核生成,插件不必自己拼——**填 digest 就够**。
#### 图记忆
```go
s.Memory().Commit([]sdk.Triple{{
Subject: "配色图", Relation: "包含", Object: "三色带",
MediaDigests: []string{"a1b2c3d4e5f6"}, // 短 digest 即可,内核补全
}})
```
没给 `SentenceText` 时内核会用标记本身充当句子——媒体必须有句子落点,
否则引用无从挂起。
#### 知识库
```go
s.DocMemory().InsertWithMedia(&sdk.Doc{
Title: "带图笔记",
Content: "正文",
}, []sdk.MediaAttachment{
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // 新内容,落盘去重
{Digest: "a1b2c3d4e5f6"}, // 引用已有内容
})
```
`Insert` 保持原签名不变,正文里已有的标记同样会被挂成文档级引用。
`Query` 返回的 `Doc` 带 `MediaDigests` 与 `Attachments`(mime + 描述,
**不含字节**——一次检索可能命中几十份媒体)。删除文档时引用自动释放。
#### 文本记忆
```go
s.TextMemory().Append(sdk.TextEvent{
Role: "user", Content: "看这张图",
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
})
```
`RecentEvents` 读回时正文里的标记会被反解成 `Attachments`。
媒体存储可在内核侧关闭(`core.memory.media.enabled=false`),此时以上接口
全部退化为纯文本行为:不报错、不 panic,与本特性上线前一致。
### ToolDef 字段说明
@ -140,18 +343,31 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
## plugindev 工具链
## hmapdev 工具链
`plugindev` 提供插件开发全流程支持。预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),
下载后加入 PATH 即可:
> ⚠️ **二进制的实际分发地址目前是 gitcode**(两个仓的 release 附件不同步):
> `hmapdev_linux_amd64` 等 5 个平台二进制 + `SHA256SUMS` 在
> <https://github.com/JianFeeeee/homeagentsdk/releases>。
> GitHub 侧(<https://github.com/JianFeeeee/homeagentsdk/releases>)从
> 下一个 SDK 版本(v1.4.0)起才会同步发布 —— 因为 SDK 仓**移仓后还没发过版**。
> 源码与文档一律以 GitHub 为准,**只有二进制暂时还得到 gitcode 取**。
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
> (旧目录会被自动沿用,不会丢已装版本)。
```bash
# 从 release 附件下载(以 v1.0.0 / linux amd64 为例)
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
chmod +x plugindev
# 从 release 附件下载(以 linux amd64 为例,<版本> 如 v1.3.0)
# 当前实际分发地址是 gitcode(见下方说明):
curl -Lo hmapdev https://github.com/JianFeeeee/homeagentsdk/releases/download/<版本>/hmapdev_linux_amd64
chmod +x hmapdev
# 或从源码自己编
cd tools/plugindev && go build -o plugindev .
cd tools/hmapdev && go build -o hmapdev .
```
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用):5 个平台各 26-28MB,
@ -159,11 +375,11 @@ cd tools/plugindev && go build -o plugindev .
| 命令 | 说明 |
|------|------|
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
| `plugindev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
| `plugindev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
| `plugindev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
| `plugindev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
| `hmapdev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
| `hmapdev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
| `hmapdev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
| `hmapdev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
| `hmapdev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
支持 **Go** 和 **Lua** 两种插件语言。
@ -270,7 +486,7 @@ return plugin
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`plugindev` 模板含 onRemove 演示。
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`hmapdev` 模板含 onRemove 演示。
```go
sdk.RegisterOnRemoveHandler(func() {
@ -288,6 +504,43 @@ enabled := sdk.AutoRestart()
插件崩溃时平台自动拉起,保障服务可用性。
重启是**有节制的**,默认参数(内核 `internal/plugin/registry.go`):
| 参数 | 值 | 含义 |
|---|---|---|
| `procRestartBackoff` | `1s` | 第 n 次重启前等 `n × 1s`(线性退避,非立即拉起) |
| `procMaxRestarts` | `3` | 窗口内允许的重启次数上限 |
| `procCrashWindow` | `5min` | 窗口内无新崩溃则计数归零 |
即崩溃后的实际序列是 **1s → 2s → 3s**;同一 5 分钟窗口内第 **4** 次崩溃
(`n > 3`)**不再自动拉起**,交人工介入。这不是「立即无感恢复」——
如果插件需要秒级就位,请自己在 `OnStart` 里做好重连与重建。
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
> 生产表现是插件重载瞬间偶发 nil 解引用崩溃)。早于 1.1.0 的版本建议升级。
## 插件开发者的并发约定
`PluginSDK` 是**被多个 goroutine 同时使用的共享对象**:你在 `Start()` 里起的轮询、
监听、定时器都拿着同一份 `*PluginSDK` 往里注消息,而内核会在加载/重载时写它的
API 字段。因此:
- **SDK 侧已保证的**:全部 API 访问器(`Memory()`/`DocMemory()`/…)、全部注入方法、
`SetAutoRestart`/`AutoRestart`、`RegisterTool`/`RegisterStage`、
`RunStopHandlers`/`RunOnRemoveHandlers`(幂等,并发调也只执行一次)。
- **你需要自己保证的**:`StageContext` 的字段全部导出,并发读写必须自己持
`ctx.Lock()`/`ctx.RLock()`。尤其是 `ctx.Extra`——**map 的并发写在 Go 里是直接 fatal,
`recover` 接不住**。
```go
ctx.Lock()
ctx.Extra["mykey"] = value
ctx.FinalText += "补充说明"
ctx.Unlock()
```
## 受限 SDK vs 完整 SDK
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
@ -299,6 +552,57 @@ enabled := sdk.AutoRestart()
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
## 项目声明 SDK 版本(plg.json 的 `sdk` 字段)
`hmapdev init` 生成的工程里,`plg.json` 会带一个 `sdk` 字段:
```json
{
"name": "MyPlugin",
"version": "0.1.0",
"entry": "plugin.bin",
"sdk": "1.2.0"
}
```
它的语义是**本插件针对的 SDK 版本**,工具链据此在本地 SDK 存储里选择版本:
命中就用它,并把 `go.mod` 的 `require`/`replace` 同步到该版本;未命中则**明确报错**
(列出已装版本 + `hmapdev sdk install vX.Y.Z`),**绝不静默退化成 `current`**。
```bash
$ hmapdev build
[hmapdev] SDK 1.2.0(项目声明 sdk=1.2.0)
```
为什么要这个字段:以前项目里没有任何「我要哪版 SDK」的声明,工具链只能用存储里的
`current`——谁改过 `current` 就拿谁的版本编,出错时表现为一堆看不懂的编译错误
(例如存储里只有陈旧的 `v0.8.0` 时,模板项目首次构建会报 `undefined: sdk.InjectOptions`)。
**写法必须是完整版本号(`1.2.0`),不接受区间写法(`1.2`)。** 原因见上文的版本纪律:
SDK 版本跟随内核中版本、patch 位恒为 `.0`,一条内核线只对应一个 SDK 版本;
写区间会让人误以为同一条线里还能挑不同 SDK(工具链会直接拒绝并说明这条规矩)。
- 显式 `--sdk-path` 或 `plg.json` 的 `sdk_path` 优先(本机改 SDK 联调时用);
- 存量工程(`plg.json` 没有 `sdk` 字段)行为不变,仍按 `current` 构建;
- 产物 `.hmap` 里的 `plugin.json` 会记录**实际选中的 SDK 版本**,便于事后追溯。
## IDE 支持:VSCode 扩展(`tools/vscode-hmapdev`)
调试插件的实操回路是「构建 → 运行 → 看内核日志」,这三步都在 IDE 之外很别扭,
所以仓库里带了一个 VSCode 扩展([tools/vscode-hmapdev](tools/vscode-hmapdev)):
- **plg.json 诊断**:必需字段、`sdk` 是否是完整版本号、声明的 SDK 是否已安装(直接给安装命令);
- **状态栏**:`插件 · SDK <声明> · hmapdev <版本>`,工具链缺失或工程有错时变色;
- **命令 / 任务**:build / build(全部目标)/ clean / debug(解释执行),编译错误进 Problems;
- **跟随内核日志**:读 `<dataDir>/log` 下最新的 `homed_*.log` 并按插件名过滤。
```bash
cd tools/vscode-hmapdev && npm install && npm run compile # 然后在 VSCode 里按 F5
```
它不是源码级调试器(没有断点/单步):插件要么编译成产物在内核里跑、要么用
`hmapdev debug` 解释执行,两条路都没有 DAP 会话;扩展做的是构建、运行、看日志与清单校验。
## 示例插件
| 插件 | 类型 | 说明 |
@ -319,6 +623,11 @@ enabled := sdk.AutoRestart()
| [rss](example/rss) | Go | RSS 订阅 |
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
**发版时附带预编译示例产物**:SDK 的 release 除 5 平台 `hmapdev` 外,还包含各示例插件的
`.hmap` 与 `SHA256SUMS`/`MANIFEST.txt`。原因是插件二进制与内核在**布局上绑定**(主版本号
+ 统一共享区魔数 + `field_count`),只发工具链不发示例产物,很容易拿旧产物去装而握手失败
——那看起来像「插件坏了」而不是「版本不配套」。
## Remote Device SDK
用于开发**远程设备接入适配器**的 C 语言 SDK,零外部依赖,兼容嵌入式平台。
@ -420,10 +729,10 @@ ha_transport_t my_transport = {
### 使用方式
通过 `plugindev` 工具链初始化项目:
通过 `hmapdev` 工具链初始化项目:
```bash
plugindev init my-adapter --type remotedevice
hmapdev init my-adapter --type remotedevice
```
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
@ -635,17 +944,17 @@ curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
### 位置
- **SDK 源码**: `remotedevice/`
- **plugindev 模板**: `plugindev init --type remotedevice`
- **hmapdev 模板**: `hmapdev init --type remotedevice`
## 构建与安装
### 构建
```bash
plugindev build
hmapdev build
```
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `plugindev build --no-bundle`)。
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `hmapdev build --no-bundle`)。
### 安装
@ -663,3 +972,32 @@ curl -X POST http://127.0.0.1:9876/plugins \
```
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
## 文档
- 文档站:**<https://sdk.homeagent.jianfgit.xyz/>**
- 快速开始:[环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/) · [第一个 Go 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-plugin/) · [第一个 Lua 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-lua-plugin/)
- API 参考:[总览](https://sdk.homeagent.jianfgit.xyz/api/) · [工具](https://sdk.homeagent.jianfgit.xyz/api/tools/) · [阶段钩子](https://sdk.homeagent.jianfgit.xyz/api/stages/) · [记忆](https://sdk.homeagent.jianfgit.xyz/api/memory/) · [输入/输出通道](https://sdk.homeagent.jianfgit.xyz/api/channels/)
- 指南:[能力边界(哪些 API 外部可用)](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/) · [工具并发声明](https://sdk.homeagent.jianfgit.xyz/guide/parallel-tool-declaration/) · [流式多 tool_call](https://sdk.homeagent.jianfgit.xyz/guide/stream-tool-call-index/) · [场景记忆](https://sdk.homeagent.jianfgit.xyz/guide/scene-memory/) · [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging/)
- 版本与兼容:<https://sdk.homeagent.jianfgit.xyz/versions/>
- 核心仓(内核 / 记忆 / 调度):<https://github.com/JianFeeeee/HomeAgent>
- 介绍站:<https://introduce.homeagent.jianfgit.xyz/>
> 文档站为自托管(国内直连,不必挂代理)。构建与部署见核心仓的
> `deploy-sdk-site.sh`(`--check` 只比对、`--rollback` 回滚)。
## 许可
SDK 以 **MIT** 发布,全文见 [LICENSE](LICENSE)。
**这是刻意的宽松**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
若用 AGPL 之类的传染许可,插件作者就会被强制以其对外开源。选 MIT 就是为了
让插件作者**自由选择自己的许可**——闭源、商业、私有均可,无需向本项目回馈,
也无需取得任何例外或商业授权。第三方插件生态的安全与活跃正建立在这条之上。
前提是 SDK 本身**完全自包含**:`go.mod` 零外部依赖,`sdk/` 只依赖 Go 标准库
(`sync`),不引用核心仓的任何代码,因此 MIT 授权不与其他许可冲突。
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
不属于本 SDK,其许可全文随发行包放在 `/usr/share/doc/homeagent/licenses/`。

View File

@ -2,6 +2,113 @@
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
> 📖 **Full documentation**: <https://sdk.homeagent.jianfgit.xyz/>
>
> Quick start / API reference / guides / example plugins all live there. **This README is a
> summary only** — the docs site is authoritative for interface details.
> Jump to: [Getting started](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/)
> · [API reference](https://sdk.homeagent.jianfgit.xyz/api/) · [Capability boundary](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/)
>
> Agent-friendly plain-text entry points: [`llms.txt`](https://sdk.homeagent.jianfgit.xyz/llms.txt)
> and [`llms-full.txt`](https://sdk.homeagent.jianfgit.xyz/llms-full.txt) (the whole docs in one file).
## Version and Compatibility
Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
| Kernel version | Matching SDK |
|---|---|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 onward | **1.3.0** |
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
public interface, so the SDK version has no reason to move with it — otherwise you would either be
forced to chase releases or suspect your version is stale, when not one character of the interface
has changed.
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
## New in 1.3.0: Injection Priority and Dynamic Output Channels
- **`InjectOptions.Priority` / `PriorityL1`–`PriorityL4`** — a plugin declares the interrupt level of
its own injection; the kernel schedules L1–L4, where **L4 is reserved for the kernel and
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
Queued input has no level: anything can jump ahead of it.
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
channels that die with their resource (one channel per remote device) can now be unregistered;
previously they lingered and the model kept "successfully" sending into a dead channel.
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
external IDs — never use the raw ID.
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
because the handshake validates the **major** version, not the exact SDK version). Rebuild only when you want
the new fields.
**Upgrading a 1.1.x plugin to 1.2.x: the interface is purely additive, but a rebuild is required.**
No public signature changed (the SDK adds `InjectOptions`, six `*Opts` variants and
`ChannelDef.ContextPolicy`), so not calling the new capabilities means not being affected — but the
kernel's **plugin protocol went to 2** (the fd3 layout of the unified shared-memory region changed,
and **rolling upgrades are not supported**). `plugin.bin` must therefore be rebuilt with the matching
`hmapdev` and installed **together with** the kernel; otherwise the handshake fails on protocol
version mismatch (the error says explicitly to rebuild with the matching hmapdev — it never
degrades silently).
## Injection Behaviour and Context Pruning (1.2.0)
"Should this go into memory" and "should the context be pruned based on this" used to be
something only `ToolDef` could declare. Since 1.2.0 **injections can declare them too**, sharing
the same semantics and values.
```go
type InjectOptions struct {
NoMemory bool // true = excluded from memory computation (vectorize/keywords/distill); the
// original text still stays in context
ContextPolicy string // ""/none = do not prune (default); prune = prune context based on this
CleanerName string // name of the compute-layer cleaner: run it first to get the effective
// content, then compute/prune on that
}
const (
ContextPolicyNone = "none"
ContextPolicyPrune = "prune"
)
// Six variants, one-to-one with the older three-argument methods, plus opts
InjectTextOpts(source, channel, text string, opts InjectOptions)
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
Key points:
- **A zero-valued `InjectOptions{}` is key-for-key equivalent to the older three-argument methods**
(recorded in memory, not pruned). The old methods remain as zero-value sugar (`InjectText`,
`InjectInterruptText`, `InjectTextNoMemory`, …), so existing plugins keep working without a single
line changed *or* a rebuild.
- **Pruning (`prune`) must be declared explicitly**: it archives/drops low-relevance events, which
is a side effect, so it is off by default. The kernel only accepts `""` / `none` / `prune`
(`ValidContextPolicy`); anything else is rejected.
- Pruning first goes through the plugin's registered **`Cleaner`** (named by `CleanerName`) to get
the effective content, avoiding the inconsistency of "prune on the raw text, compute on the
cleaned text".
- `ChannelDef` carries the same `context_policy` (1.2.0 also gave `ChannelDef` JSON tags — the
definition crosses the process boundary, while `Cleaner` is a function that must be ignored; with
no tags, newly added fields would be silently dropped).
## SDK API Surface
### Plugin Interface
@ -36,6 +143,7 @@ The SDK instance injected via `Start(sdk *PluginSDK)` provides:
| Settings | `Settings()` | Access settings API |
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
| Media inject | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | Inject input carrying images/audio (added in 1.1.0) |
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
### Stage Hooks
@ -106,6 +214,32 @@ Type enum values:
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
### Multimodal Injection (added in 1.1.0)
| Method | Description |
|--------|-------------|
| `InjectInputMedia(source, channel, text, blocks)` | Inject media-bearing input, asynchronous |
| `InjectInputMediaSync(source, channel, text, blocks)` | Inject media-bearing input and wait for the reply text |
| `InjectInterruptMedia(source, channel, text, blocks)` | Inject a media-bearing interrupt that can preempt current processing |
`blocks` is `[]sdk.ContentBlock`, the same type `SetToolBlocks` takes:
```go
s.InjectInputMedia("myplugin", "webui", "take a look at this", []sdk.ContentBlock{{
Type: "image_url",
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
}})
```
How this differs from `SetToolBlocks`: that one is only callable inside a tool handler and
its media reaches the model with the *next* tool message. These three let a plugin
**initiate a turn that carries media** — the media goes out with this turn's message and is
automatically stored in the media store with a memory reference attached.
`data:` URLs in the blocks are stored and deduplicated by the kernel; `http(s)` URLs are
passed to the model only and never stored (storing them would require the kernel to make
network requests, bringing timeouts, auth and SSRF into scope).
`source` identifies the origin, `channel` specifies the target output channel.
### Triple Extended Fields
@ -115,6 +249,65 @@ The Triple data structure includes additional fields:
- `Confidence` — confidence score (0.0–1.0)
- `SubjectType` — subject type
- `ObjectType` — object type
- `SentenceText` — the original sentence (added in 1.1.0), written to the `sentences` table; media references hang off the sentence
- `MediaDigests` — associated media digests (added in 1.1.0)
### Media in Memory (added in 1.1.0)
Inside plain-text memory, media is represented as a **marker** of the form
`[<mime> <short digest>] <description>`:
```
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
```
The description is the durable semantic memory (retrieval uses it); the digest is the key
back to the bytes (reverse lookup uses it). Markers are generated by the kernel — a plugin
never has to assemble one, it just **supplies the digest**.
#### Graph memory
```go
s.Memory().Commit([]sdk.Triple{{
Subject: "palette", Relation: "contains", Object: "three-band",
MediaDigests: []string{"a1b2c3d4e5f6"}, // short digest is fine, the kernel resolves it
}})
```
With no `SentenceText`, the kernel uses the marker itself as the sentence — media must have
a sentence to hang off, otherwise the reference has nowhere to attach.
#### Knowledge base
```go
s.DocMemory().InsertWithMedia(&sdk.Doc{
Title: "illustrated note",
Content: "body",
}, []sdk.MediaAttachment{
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // new content, stored and deduped
{Digest: "a1b2c3d4e5f6"}, // reference existing content
})
```
`Insert` keeps its original signature; markers already present in the body are bound as
document-level references too. `Query` fills `MediaDigests` and `Attachments` (mime plus
description, **no bytes** — one query can match dozens of media items). Removing a document
releases its references.
#### Text memory
```go
s.TextMemory().Append(sdk.TextEvent{
Role: "user", Content: "look at this",
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
})
```
`RecentEvents` decodes markers in the body back into `Attachments`.
The media store can be disabled kernel-side (`core.memory.media.enabled=false`); all of the
above then degrades to plain-text behaviour — no errors, no panics, identical to how it
behaved before this feature shipped.
### ToolDef Field Reference
@ -140,19 +333,24 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
## plugindev Toolchain
## hmapdev Toolchain
`plugindev` provides full development workflow support. Prebuilt binaries ship as **release assets**
`hmapdev` provides full development workflow support and produces `.hmap` plugin bundles (the tool is
named after that package format). Prebuilt binaries ship as **release assets**
(linux/darwin/windows × amd64/arm64); download from
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
[Releases](https://github.com/JianFeeeee/homeagentsdk/releases) and put it on your PATH:
> Rename note: the toolchain was called `plugindev` and is `hmapdev` since 1.2.0.
> The SDK store moved from `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk`
> (the old directory is still honored, so installed versions are not lost).
```bash
# From release assets (v1.0.0 / linux amd64 shown)
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
chmod +x plugindev
# From release assets (latest SDK release / linux amd64 shown)
curl -Lo hmapdev https://github.com/JianFeeeee/homeagentsdk/releases/download/<version>/hmapdev_linux_amd64
chmod +x hmapdev
# Or build from source
cd tools/plugindev && go build -o plugindev .
cd tools/hmapdev && go build -o hmapdev .
```
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
@ -161,11 +359,11 @@ cd tools/plugindev && go build -o plugindev .
| Command | Description |
|---------|-------------|
| `plugindev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
| `plugindev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
| `plugindev clean` | Clean `build/` and `dist/` plus generated files |
| `plugindev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
| `plugindev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
| `hmapdev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
| `hmapdev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
| `hmapdev clean` | Clean `build/` and `dist/` plus generated files |
| `hmapdev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
| `hmapdev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
Supports both **Go** and **Lua** plugin languages.
@ -237,7 +435,7 @@ Supports both **Go** and **Lua** plugin languages.
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `plugindev` template includes an onRemove demo.
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `hmapdev` template includes an onRemove demo.
```go
sdk.RegisterOnRemoveHandler(func() {
@ -255,6 +453,46 @@ enabled := sdk.AutoRestart()
The platform automatically restarts the plugin on crash, ensuring service availability.
Restarts are **rate-limited**. Defaults (kernel `internal/plugin/registry.go`):
| Parameter | Value | Meaning |
|---|---|---|
| `procRestartBackoff` | `1s` | Before restart #n, wait `n × 1s` (linear backoff, not immediate) |
| `procMaxRestarts` | `3` | Max restarts within the window |
| `procCrashWindow` | `5min` | No new crash within the window resets the count |
So the actual sequence is **1s → 2s → 3s**; the **4th** crash in the same 5-minute
window (`n > 3`) is **not** restarted automatically and needs manual intervention.
This is not instant, invisible recovery — if your plugin must be back in seconds,
reconnect and rebuild your own state in `OnStart`.
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
> external connection has been established, and that connection setup usually happens in a
> background goroutine while the kernel reads the flag from another one — which is inherently
> concurrent. **SDK 1.1.0 locks this flag and all API fields** (`-race` reported 11 data races;
> in production this showed up as sporadic nil-dereference crashes during plugin reload). Upgrade
> if you are on anything earlier.
## Concurrency Contract for Plugin Developers
`PluginSDK` is a **shared object used by multiple goroutines**: the polling, listening and timer
callbacks you start in `Start()` all hold the same `*PluginSDK` and push messages into it, while
the kernel writes its API fields during load/reload. So:
- **Guaranteed by the SDK**: all API accessors (`Memory()`/`DocMemory()`/…), all injection methods,
`SetAutoRestart`/`AutoRestart`, `RegisterTool`/`RegisterStage`, and
`RunStopHandlers`/`RunOnRemoveHandlers` (idempotent; concurrent calls still run it once).
- **Your responsibility**: every field of `StageContext` is exported, and concurrent read/write
must hold `ctx.Lock()`/`ctx.RLock()`. Especially `ctx.Extra` — **concurrent map writes are a
fatal in Go, and `recover` cannot catch it**.
```go
ctx.Lock()
ctx.Extra["mykey"] = value
ctx.FinalText += "supplementary note"
ctx.Unlock()
```
## Restricted SDK vs Full SDK
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
@ -286,6 +524,13 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
| [rss](example/rss) | Go | RSS subscriptions |
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
**Prebuilt example artifacts ship with every release**: besides the 5-platform `hmapdev`, an SDK
release contains the example plugins' `.hmap` files plus `SHA256SUMS`/`MANIFEST.txt`. The reason is
that plugin binaries are **layout-bound** to the kernel (major version + unified-region magic +
`field_count`), so shipping the toolchain without matching artifacts invites installing an old artifact —
which fails the handshake and looks like "the plugin is broken" rather than "the versions don't
match".
## Remote Device SDK
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
@ -370,10 +615,10 @@ ha_transport_t my_transport = {
### Usage
Initialize a project via the `plugindev` toolchain:
Initialize a project via the `hmapdev` toolchain:
```bash
plugindev init my-adapter --type remotedevice
hmapdev init my-adapter --type remotedevice
```
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
@ -583,17 +828,17 @@ curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
### Location
- **SDK Source**: `remotedevice/`
- **plugindev template**: `plugindev init --type remotedevice`
- **hmapdev template**: `hmapdev init --type remotedevice`
## Building & Installing
### Build
```bash
plugindev build
hmapdev build
```
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `plugindev build --no-bundle` for a single-target build).
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `hmapdev build --no-bundle` for a single-target build).
### Install
@ -611,3 +856,23 @@ curl -X POST http://127.0.0.1:9876/plugins \
```
Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.
## License
The SDK is released under the **MIT license** — see [LICENSE](LICENSE).
**This permissiveness is deliberate**: the SDK is **statically linked** into your plugin
(its source ends up in the plugin binary). Under a copyleft license such as AGPL that would
force plugin authors to open-source their work; MIT exists precisely so that plugin authors
can **pick their own license** — closed-source, commercial or private — with no obligation to
contribute back and no need for any exception or commercial grant. The safety and vitality of
the third-party plugin ecosystem rest on this.
This is sound because the SDK is **fully self-contained**: `go.mod` has zero external
dependencies and `sdk/` imports only the Go standard library (`sync`), never any code from the
core repository — so the MIT grant conflicts with nothing.
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
(Chinese-CLIP Apache-2.0, ONNX Runtime MIT) are not part of this SDK; their full license texts
ship with the release packages under `/usr/share/doc/homeagent/licenses/`.

204
docs/api/bridge.md Normal file
View File

@ -0,0 +1,204 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 桥接装配点(Bridge)
以下方法**不是给插件业务代码调的**——它们由 `hmapdev` 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例。列在这里是为了让「公开 API 面」完整,并说明每个注入点对应什么能力。
### `APIRegistrar`
```go
type APIRegistrar func(name string) error
```
APIRegistrar registers a plugin API for external access.
<small>`plugin.go:410`</small>
### `InputChannelRegistrar`
```go
type InputChannelRegistrar func(name string, def ChannelDef) error
```
InputChannelRegistrar registers an input channel with its memory behavior.
<small>`plugin.go:413`</small>
### `OutputChannelRegistrar`
```go
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
```
OutputChannelRegistrar registers an output channel that the output_send tool can use.
<small>`plugin.go:416`</small>
### `OutputChannelUnregistrar`
```go
type OutputChannelUnregistrar func(name string) error
```
OutputChannelUnregistrar 注销一个输出通道。
为什么需要它:输出通道不止有"启动时注册一次"的静态通道,还有**随外部资源生灭**的
动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后
必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。
<small>`plugin.go:423`</small>
### `PluginSDK.SetDocMemoryAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI)
```
<small>`plugin.go:718`</small>
### `PluginSDK.SetEventSubscriber`
!!! warning "仅内核内置插件可用"
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
```go
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
```
<small>`plugin.go:742`</small>
### `PluginSDK.SetIOInjector`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetIOInjector(io IOInjector)
```
SetIOInjector sets the IO injector (called by the core at startup).
<small>`plugin.go:699`</small>
### `PluginSDK.SetInputChannelRegistrar`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar)
```
SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
<small>`plugin.go:692`</small>
### `PluginSDK.SetKnowledgeAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI)
```
<small>`plugin.go:724`</small>
### `PluginSDK.SetLLMAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetLLMAPI(llm LLMAPI)
```
<small>`plugin.go:730`</small>
### `PluginSDK.SetMemoryAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI)
```
SetMemoryAPI sets the memory API (called by the core at startup).
<small>`plugin.go:706`</small>
### `PluginSDK.SetOutputChannelRegistrar`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar)
```
SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
<small>`plugin.go:678`</small>
### `PluginSDK.SetOutputChannelUnregistrar`
!!! warning "仅内核内置插件可用"
同上,桥接模板不注入。
```go
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
```
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
<small>`plugin.go:685`</small>
### `PluginSDK.SetPluginMgrAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI)
```
SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
<small>`plugin.go:749`</small>
### `PluginSDK.SetSocialAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetSocialAPI(social SocialAPI)
```
<small>`plugin.go:736`</small>
### `PluginSDK.SetTextMemoryAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI)
```
<small>`plugin.go:712`</small>
### `ToolRegistrar`
```go
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
```
ToolRegistrar registers a tool dynamically.
<small>`plugin.go:404`</small>

79
docs/api/builtin-only.md Normal file
View File

@ -0,0 +1,79 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 仅内置插件可用的 API
这些 API **存在于公开 SDK 包里**,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级。列在这里是为了让边界显式——而不是让你在运行时才发现拿不到。
判断依据全部来自源码与 `hmapdev` 桥接模板,逐条记在每条说明里。
### `PriorityL4`
!!! warning "仅内核内置插件可用"
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
```go
const PriorityL4
```
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
<small>`plugin.go:165`</small>
### `PluginSDK.Events`
!!! warning "仅内核内置插件可用"
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
```go
func (s *PluginSDK) Events() EventSubscriber
```
Events returns the event subscriber for listening to kernel events (may be nil if not available).
<small>`plugin.go:550`</small>
### `PluginSDK.SetEventSubscriber`
!!! warning "仅内核内置插件可用"
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
```go
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
```
<small>`plugin.go:742`</small>
### `PluginSDK.SetOutputChannelUnregistrar`
!!! warning "仅内核内置插件可用"
同上,桥接模板不注入。
```go
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
```
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
<small>`plugin.go:685`</small>
### `PluginSDK.UnregisterOutputChannel`
!!! warning "仅内核内置插件可用"
外部插件的桥接模板只注入 SetOutputChannelRegistrar(proc_main.go.tmpl:693-705),**未**注入 SetOutputChannelUnregistrar(grep Unregistrar 在 templates/ 下无命中)。因此外部插件的 regOutputUnreg 为 nil,UnregisterOutputChannel 会命中 `if reg != nil` 的 else 分支**直接返回 nil**(sdk/plugin.go:539-549)——即**静默无效**:不报错、通道也没注销。内置插件由 internal/sdk/plugin.go:330 注入 cfg.RegOutputUnreg,真正生效。外部插件要让通道下线,只能重载插件。
```go
func (s *PluginSDK) UnregisterOutputChannel(name string) error
```
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
<small>`plugin.go:643`</small>
### `EventSubscriber.Subscribe`
!!! warning "仅内核内置插件可用"
```go
Subscribe(eventType EventType, handler EventHandler) func()
```

650
docs/api/channels.md Normal file
View File

@ -0,0 +1,650 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 输入 / 输出通道
通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口。**输入通道**接收外部消息,**输出通道**把消息投递出去。
## `IOInjector`
IOInjector provides methods for injecting input and interrupts into the agent pipeline.
All methods accept (source, channel) where channel is the target output channel
for routing the agent's response.
| 方法 | 说明 |
|---|---|
| [`InjectInputMedia`](#ioinjectorinjectinputmedia) | |
| [`InjectInputMediaOpts`](#ioinjectorinjectinputmediaopts) | |
| [`InjectInputMediaSync`](#ioinjectorinjectinputmediasync) | |
| [`InjectInputMediaSyncOpts`](#ioinjectorinjectinputmediasyncopts) | |
| [`InjectInputSync`](#ioinjectorinjectinputsync) | InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 |
| [`InjectInputSyncOpts`](#ioinjectorinjectinputsyncopts) | |
| [`InjectInterruptMedia`](#ioinjectorinjectinterruptmedia) | |
| [`InjectInterruptMediaOpts`](#ioinjectorinjectinterruptmediaopts) | |
| [`InjectInterruptText`](#ioinjectorinjectinterrupttext) | |
| [`InjectInterruptTextOpts`](#ioinjectorinjectinterrupttextopts) | |
| [`InjectText`](#ioinjectorinjecttext) | |
| [`InjectTextNoMemory`](#ioinjectorinjecttextnomemory) | |
| [`InjectTextOpts`](#ioinjectorinjecttextopts) | 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 |
| [`SetToolBlocks`](#ioinjectorsettoolblocks) | SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 |
### `IOInjector.InjectInputMedia`
```go
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
```
<small>`plugin.go:329`</small>
### `IOInjector.InjectInputMediaOpts`
```go
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
<small>`plugin.go:340`</small>
### `IOInjector.InjectInputMediaSync`
```go
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
```
<small>`plugin.go:330`</small>
### `IOInjector.InjectInputMediaSyncOpts`
```go
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
```
<small>`plugin.go:341`</small>
### `IOInjector.InjectInputSync`
```go
InjectInputSync(source, channel, text string) string
```
InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
<small>`plugin.go:325`</small>
### `IOInjector.InjectInputSyncOpts`
```go
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
```
<small>`plugin.go:339`</small>
### `IOInjector.InjectInterruptMedia`
```go
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
```
<small>`plugin.go:331`</small>
### `IOInjector.InjectInterruptMediaOpts`
```go
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
<small>`plugin.go:342`</small>
### `IOInjector.InjectInterruptText`
```go
InjectInterruptText(source, channel, text string)
```
<small>`plugin.go:320`</small>
### `IOInjector.InjectInterruptTextOpts`
```go
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
<small>`plugin.go:338`</small>
### `IOInjector.InjectText`
```go
InjectText(source, channel, text string)
```
<small>`plugin.go:321`</small>
### `IOInjector.InjectTextNoMemory`
```go
InjectTextNoMemory(source, channel, text string)
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
<small>`plugin.go:322`</small>
### `IOInjector.InjectTextOpts`
```go
InjectTextOpts(source, channel, text string, opts InjectOptions)
```
以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
<small>`plugin.go:337`</small>
### `IOInjector.SetToolBlocks`
```go
SetToolBlocks(blocks []ContentBlock)
```
SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
<small>`plugin.go:328`</small>
### `CapAudio`
```go
const CapAudio
```
Output capability flags
<small>`plugin.go:430`</small>
### `CapFile`
```go
const CapFile
```
Output capability flags
<small>`plugin.go:428`</small>
### `CapImage`
```go
const CapImage
```
Output capability flags
<small>`plugin.go:429`</small>
### `CapStructured`
```go
const CapStructured
```
Output capability flags
<small>`plugin.go:431`</small>
### `CapText`
```go
const CapText
```
Output capability flags
<small>`plugin.go:427`</small>
### `ChannelDef`
```go
type ChannelDef struct { NoMemory bool `json:"no_memory,omitempty"` Cleaner func(string) string `json:"-"` ContextPolicy string `json: …
```
ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
那样新增字段会被静默丢掉。
<small>`plugin.go:178`</small>
### `ContextPolicyNone`
```go
const ContextPolicyNone
```
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
插件会在背后把别人的内容挤掉,且看不出是谁干的。
<small>`plugin.go:44`</small>
### `ContextPolicyPrune`
```go
const ContextPolicyPrune
```
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
插件会在背后把别人的内容挤掉,且看不出是谁干的。
<small>`plugin.go:45`</small>
### `PluginSDK.InjectInputMedia`
```go
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock)
```
InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
的「下一轮 tool message」语义。
等价于 InjectInputMediaOpts(..., InjectOptions{})。
<small>`plugin.go:805`</small>
### `PluginSDK.InjectInputMediaOpts`
```go
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
<small>`plugin.go:844`</small>
### `PluginSDK.InjectInputMediaSync`
```go
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
```
InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
<small>`plugin.go:811`</small>
### `PluginSDK.InjectInputMediaSyncOpts`
```go
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
```
InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
<small>`plugin.go:851`</small>
### `PluginSDK.InjectInputSync`
```go
func (s *PluginSDK) InjectInputSync(source, channel, text string) string
```
InjectInputSync injects a text message and synchronously waits for the agent reply,
returning the reply text (empty string if none). Replies must be dispatched back
to the source channel by the caller.
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
<small>`plugin.go:796`</small>
### `PluginSDK.InjectInputSyncOpts`
```go
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
```
InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
<small>`plugin.go:835`</small>
### `PluginSDK.InjectInterruptMedia`
```go
func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
```
InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
blocks 随中断消息一起发给模型。
<small>`plugin.go:868`</small>
### `PluginSDK.InjectInterruptMediaOpts`
```go
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
<small>`plugin.go:860`</small>
### `PluginSDK.InjectInterruptText`
```go
func (s *PluginSDK) InjectInterruptText(source, channel, text string)
```
InjectInterruptText injects a text interrupt that can preempt current LLM processing.
等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
<small>`plugin.go:777`</small>
### `PluginSDK.InjectInterruptTextOpts`
```go
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
```
InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文,
是否需要据此裁剪由调用方决定(默认不裁剪)。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
<small>`plugin.go:828`</small>
### `InjectOptions`
```go
type InjectOptions struct { NoMemory bool ContextPolicy string // RecallPolicy 声明此次注入是否据其内容召回相关记忆。 // 空串 = 默认(输入/注<><E6B3A8> …
```
InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
因此调用方只有在确实需要改变行为时才需要填它。
为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。
中断注入也允许声明 prune——它同样会携带内容进入上下文。
CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
空串 = 按注入的 source 查通道定义(既有行为)。
为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
不指定就只能退到「按 source 查不到就不清洗」。
<small>`plugin.go:132`</small>
### `PluginSDK.InjectText`
```go
func (s *PluginSDK) InjectText(source, channel, text string)
```
InjectText injects a text message into the agent pipeline.
等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
<small>`plugin.go:783`</small>
### `PluginSDK.InjectTextNoMemory`
```go
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string)
```
InjectTextNoMemory injects a text message without generating memory.
等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
<small>`plugin.go:789`</small>
### `PluginSDK.InjectTextOpts`
```go
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions)
```
InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
<small>`plugin.go:818`</small>
### `PriorityL1`
```go
const PriorityL1
```
中断优先级取值。
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
<small>`plugin.go:161`</small>
### `PriorityL2`
```go
const PriorityL2
```
中断优先级取值。
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
<small>`plugin.go:162`</small>
### `PriorityL3`
```go
const PriorityL3
```
中断优先级取值。
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
<small>`plugin.go:163`</small>
### `PriorityL4`
!!! warning "仅内核内置插件可用"
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
```go
const PriorityL4
```
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
<small>`plugin.go:165`</small>
### `RecallPolicyAuto`
```go
const RecallPolicyAuto
```
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
<small>`plugin.go:65`</small>
### `RecallPolicyNone`
```go
const RecallPolicyNone
```
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
<small>`plugin.go:64`</small>
### `PluginSDK.RegisterInputChannel`
```go
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error
```
RegisterInputChannel registers an input channel with its memory behavior.
契约:**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)
注入的通道名,都应当在这里登记**。inputch 是内核里最基本的**输入路由单位**:
只有登记过的通道才能在 inputch 登记表里出现,父 agent 才能"把某个 inputch 划给驻留子";
没登记就划分会直接失败(`inputch 未注册`)。
只登记输出通道(RegisterOutputChannel)而没登记输入通道时,内核会兜底登记同名
inputch 并打告警日志 —— 兜底只为兼容老插件,新插件请显式登记。
def.NoMemory: 此通道输入不参与记忆计算
def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:209` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:281` | `_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})` |
<small>`plugin.go:665`</small>
### `PluginSDK.RegisterOutputChannel`
```go
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
```
RegisterOutputChannel registers an output channel that the output_send tool can route to.
与 RegisterInputChannel 的分工:本函数声明**出站**(output_send__<name> 的回复发给谁);
入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
若该通道同时也是你的注入入口,两个都要登记。
name: channel name (e.g. "qq", "webui")。
❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`),
而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用",
而是**整条请求被上游 400 拒绝**(`Invalid 'tools[N].function.name'`),
网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13 字符)的余量。
若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**,
而不是把原始值直接当通道名。
caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
desc: description of the channel, expected meta format, and type enum
def: 通道在记忆计算层的行为(NoMemory/Cleaner)
handler: receives args map with keys: payload (string), type (string), meta (string|optional)
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:57` | `if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args…` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:57` | `s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[strin…` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:411` | `s.RegisterOutputChannel("qq", sdk.CapText\|sdk.CapFile\|sdk.CapImage\|sdk.CapAudio,` |
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:104` | `if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{` |
<small>`plugin.go:632`</small>
### `PluginSDK.SetToolBlocks`
```go
func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock)
```
SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
<small>`plugin.go:876`</small>
### `ValidContextPolicy`
```go
func ValidContextPolicy(policy string) bool
```
ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
<small>`plugin.go:49`</small>
### `ValidRecallPolicy`
```go
func ValidRecallPolicy(policy string) bool
```
ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。
<small>`plugin.go:69`</small>

86
docs/api/constants.md Normal file
View File

@ -0,0 +1,86 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 常量与枚举
SDK 里的取值枚举。其中带「仅内置」标注的取值在内核侧会被夹到较低级别。
## StageOnInput 等
| 名称 | 说明 |
|---|---|
| `StageOnInput` | |
| `StagePreAction` | |
| `StagePostAction` | |
| `StageBeforeToolcall` | |
| `StageAfterToolcall` | |
| `StageBeforeOutput` | |
| `StageAfterOutput` | |
## ContextPolicyNone 等
| 名称 | 说明 |
|---|---|
| `ContextPolicyNone` | |
| `ContextPolicyPrune` | |
## RecallPolicyNone 等
| 名称 | 说明 |
|---|---|
| `RecallPolicyNone` | |
| `RecallPolicyAuto` | |
## ScenePolicyAuto 等
| 名称 | 说明 |
|---|---|
| `ScenePolicyAuto` | |
| `ScenePolicyNone` | |
## PriorityL1 等
| 名称 | 说明 |
|---|---|
| `PriorityL1` | |
| `PriorityL2` | |
| `PriorityL3` | |
| `PriorityL4` | PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 |
## EventRawInput 等
| 名称 | 说明 |
|---|---|
| `EventRawInput` | |
| `EventAgentOutput` | |
| `EventAgentLLMChain` | |
| `EventToolCall` | |
| `EventReasoning` | |
| `EventStage` | |
| `EventSystem` | |
| `EventReasoningDelta` | 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。 |
| `EventContentDelta` | |
## StageScopeGlobal 等
| 名称 | 说明 |
|---|---|
| `StageScopeGlobal` | StageScopeGlobal receives all stage events (default). |
| `StageScopeOwnTools` | StageScopeOwnTools only receives events for this plugin's own tool calls |
## CapText 等
| 名称 | 说明 |
|---|---|
| `CapText` | |
| `CapFile` | |
| `CapImage` | |
| `CapAudio` | |
| `CapStructured` | |
## ProxyAuthHomeAgent 等
| 名称 | 说明 |
|---|---|
| `ProxyAuthHomeAgent` | ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话 |
| `ProxyAuthNone` | ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。 |

69
docs/api/events.md Normal file
View File

@ -0,0 +1,69 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 事件(Events)
订阅内核事件。**注意**:外部分布式插件的事件订阅不走 `Events()`(该接口在外部插件路径上未被注入,恒为 nil),而是由 `hmapdev` 生成的运行时通过 `events.subscribe` 完成。详见下方说明。
## `EventSubscriber`
EventSubscriber allows plugins to subscribe to kernel events.
This is a restricted interface: plugins can subscribe but the kernel
controls which events are delivered.
| 方法 | 说明 |
|---|---|
| [`Subscribe`](#eventsubscribersubscribe) | |
### `EventSubscriber.Subscribe`
!!! warning "仅内核内置插件可用"
```go
Subscribe(eventType EventType, handler EventHandler) func()
```
<small>`plugin.go:378`</small>
### `Event`
```go
type Event struct { Type EventType `json:"type"` Source string `json:"source"` Payload map[string]interface{} `json:"payload"` T …
```
Event represents a system event published by the kernel.
<small>`plugin.go:364`</small>
### `EventHandler`
```go
type EventHandler func(evt *Event)
```
EventHandler processes a system event.
<small>`plugin.go:372`</small>
### `EventType`
```go
type EventType string
```
EventType identifies the kind of system event.
<small>`plugin.go:346`</small>
### `PluginSDK.Events`
!!! warning "仅内核内置插件可用"
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
```go
func (s *PluginSDK) Events() EventSubscriber
```
Events returns the event subscriber for listening to kernel events (may be nil if not available).
<small>`plugin.go:550`</small>

39
docs/api/index.md Normal file
View File

@ -0,0 +1,39 @@
# API 参考
本页所有内容**从源码生成**(`tools/apidoc`),签名与说明直接取自 `sdk/*.go` 的
文档注释。因此不存在「文档写了一套、代码是另一套」的情况——发现不一致时,
改的是源码注释,不是这里。
## 怎么找 API
<div id="api-search"></div>
用上面的搜索框可以:
- **按名称搜**:`InjectText`、`RegisterTool`、`memory.recall`
- **按描述搜**:`注册工具`、`注入`、`重载`、`崩溃`
- **按签名搜**:`(string) error`、`[]ContentBlock`
- 带 <span class="api-badge api-badge-builtin">仅内置</span>
标记的条目在**外部插件里拿不到**,多数情况下你不需要它
## 章节划分
按「你想做什么」组织,不是按 Go 的符号类别:
| 章节 | 内容 |
|---|---|
| [工具(Tools)](tools.md) | 注册 LLM 可调用的工具——插件最常用的能力形态 |
| [阶段钩子(Stages)](stages.md) | 在处理管道的固定点位插入逻辑 |
| [记忆(Memory)](memory.md) | 三层记忆的读写:图 / 文档 / 文本,以及知识库 |
| [输入/输出通道](channels.md) | 与外界交换消息,以及往流水线里注入内容 |
| [配置(Settings)](settings.md) | 声明插件配置项,内核渲染到 WebUI |
| [生命周期(Lifecycle)](lifecycle.md) | 启动、停止、卸载、自动重启 |
| [事件(Events)](events.md) | 订阅内核事件 |
| [LLM 调用](llm.md) | 插件主动调用模型 |
| [常量与枚举](constants.md) | 取值枚举 |
| [桥接装配点](bridge.md) | 由 `hmapdev` 生成的运行时调用,插件业务代码不碰 |
| [仅内置插件可用](builtin-only.md) | 边界汇总——外部插件拿不到的 API 全在这里 |
!!! tip "先看「能力边界」能省很多时间"
如果你正在设计插件,先读 [能力边界](../guide/capability-boundary.md):
它说明哪些能力外部插件有、哪些没有,以及**为什么**。

209
docs/api/lifecycle.md Normal file
View File

@ -0,0 +1,209 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 生命周期(Lifecycle)
插件的启动、停止与卸载回调。停止与卸载是两件事:**停止**是进程/加载状态变化,**卸载**(onRemove)是插件被删除前的清理机会。
## `Plugin`
Plugin is the interface every plugin must implement.
| 方法 | 说明 |
|---|---|
| [`Name`](#pluginname) | |
| [`Start`](#pluginstart) | |
| [`Stop`](#pluginstop) | |
### `Plugin.Name`
```go
Name() string
```
<small>`plugin.go:14`</small>
### `Plugin.Start`
```go
Start(sdk *PluginSDK) error
```
<small>`plugin.go:15`</small>
### `Plugin.Stop`
```go
Stop() error
```
<small>`plugin.go:16`</small>
## `PluginMgrAPI`
PluginMgrAPI 提供插件管理能力(外部插件可调用)。
由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
| 方法 | 说明 |
|---|---|
| [`IsPluginDisabled`](#pluginmgrapiisplugindisabled) | IsPluginDisabled 查询插件是否被禁用。 |
| [`ListLoadedPlugins`](#pluginmgrapilistloadedplugins) | ListLoadedPlugins 列出已加载插件。 |
| [`ReloadOne`](#pluginmgrapireloadone) | ReloadOne 重载单个插件(停止后重新加载)。 |
### `PluginMgrAPI.IsPluginDisabled`
```go
IsPluginDisabled(name string) bool
```
IsPluginDisabled 查询插件是否被禁用。
<small>`plugin.go:389`</small>
### `PluginMgrAPI.ListLoadedPlugins`
```go
ListLoadedPlugins() []string
```
ListLoadedPlugins 列出已加载插件。
<small>`plugin.go:387`</small>
### `PluginMgrAPI.ReloadOne`
```go
ReloadOne(name string) error
```
ReloadOne 重载单个插件(停止后重新加载)。
<small>`plugin.go:385`</small>
### `PluginSDK.AutoRestart`
```go
func (s *PluginSDK) AutoRestart() bool
```
AutoRestart 返回插件是否允许自动重启。
<small>`plugin.go:895`</small>
### `PluginSDK.PluginMgr`
```go
func (s *PluginSDK) PluginMgr() PluginMgrAPI
```
PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
May be nil if the host did not wire it.
<small>`plugin.go:757`</small>
### `PluginSDK.PluginName`
```go
func (s *PluginSDK) PluginName() string
```
PluginName returns the name of the plugin.
<small>`plugin.go:501`</small>
### `PluginSDK.RegisterOnRemoveHandler`
```go
func (s *PluginSDK) RegisterOnRemoveHandler(fn func())
```
RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用,
适用于清理外部资源、删除配置表、下线状态等删除后处理。
可注册多个;执行后清空(一次删除只执行一次)。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:296` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:69` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
| [`rss`](../examples/index.md#rss) | `example/rss/plugin.go:127` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
<small>`plugin.go:930`</small>
### `PluginSDK.RegisterPluginAPI`
```go
func (s *PluginSDK) RegisterPluginAPI(name string) error
```
RegisterPluginAPI registers this plugin's API for access by other plugins.
<small>`plugin.go:606`</small>
### `PluginSDK.RegisterStopHandler`
```go
func (s *PluginSDK) RegisterStopHandler(fn func())
```
RegisterStopHandler 注册插件停止阶段的清理回调。
注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
适用于释放资源、落盘状态、关闭子进程等停止时清理操作。
可注册多个;执行后清空(进程停止前只执行一次)。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:294` | `s.RegisterStopHandler(p.saveEvents)` |
| [`deepsearch`](../examples/index.md#deepsearch) | `example/deepsearch/plugin.go:695` | `s.RegisterStopHandler(func() { p.shutdownSearxng() })` |
<small>`plugin.go:905`</small>
### `PluginSDK.RunOnRemoveHandlers`
```go
func (s *PluginSDK) RunOnRemoveHandlers()
```
RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。
由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。
<small>`plugin.go:941`</small>
### `PluginSDK.RunStopHandlers`
```go
func (s *PluginSDK) RunStopHandlers()
```
RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。
由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。
<small>`plugin.go:916`</small>
### `PluginSDK.SetAutoRestart`
```go
func (s *PluginSDK) SetAutoRestart(enabled bool)
```
SetAutoRestart 设置插件崩溃后内核是否自动重启它。
默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
重启是有限度的:线性退避(第 n 次等 n×1s,即 1s→2s→3s),
且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README)。
注意这与「重载」(换 plugin.bin 后重新加载)是两回事。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:47` | `s.SetAutoRestart(true)` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:47` | `s.SetAutoRestart(true)` |
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:110` | `s.SetAutoRestart(true)` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:63` | `s.SetAutoRestart(true)` |
<small>`plugin.go:888`</small>

50
docs/api/llm.md Normal file
View File

@ -0,0 +1,50 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# LLM 调用
让插件自己调用模型(而不是只等模型来调你)。
## `LLMAPI`
LLMAPI provides access to the LLM provider manager.
| 方法 | 说明 |
|---|---|
| [`CurrentSource`](#llmapicurrentsource) | |
| [`ListSources`](#llmapilistsources) | |
| [`SetSource`](#llmapisetsource) | |
### `LLMAPI.CurrentSource`
```go
CurrentSource() string
```
<small>`llm.go:7`</small>
### `LLMAPI.ListSources`
```go
ListSources() []string
```
<small>`llm.go:5`</small>
### `LLMAPI.SetSource`
```go
SetSource(name string) error
```
<small>`llm.go:6`</small>
### `PluginSDK.LLM`
```go
func (s *PluginSDK) LLM() LLMAPI
```
LLM returns the LLM provider API (may be nil if not available).
<small>`plugin.go:536`</small>

383
docs/api/memory.md Normal file
View File

@ -0,0 +1,383 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 记忆(Memory)
三层记忆的读写接口:**图记忆**(三元组关系)、**文档记忆**(带元数据的文档)、**文本记忆**(事件流水)。以及知识库。
## `DocMemoryAPI`
DocMemoryAPI provides access to the document vector store.
| 方法 | 说明 |
|---|---|
| [`Insert`](#docmemoryapiinsert) | |
| [`InsertWithMedia`](#docmemoryapiinsertwithmedia) | InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 |
| [`Query`](#docmemoryapiquery) | |
| [`Remove`](#docmemoryapiremove) | |
| [`Stats`](#docmemoryapistats) | |
### `DocMemoryAPI.Insert`
```go
Insert(doc *Doc) error
```
<small>`memory.go:76`</small>
### `DocMemoryAPI.InsertWithMedia`
```go
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
```
InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
因此图片按自己的向量被召回,不依赖任何生成的描述文本。
<small>`memory.go:81`</small>
### `DocMemoryAPI.Query`
```go
Query(text string, topK int) []*Doc
```
<small>`memory.go:75`</small>
### `DocMemoryAPI.Remove`
```go
Remove(id string)
```
<small>`memory.go:82`</small>
### `DocMemoryAPI.Stats`
```go
Stats() map[string]interface{}
```
<small>`memory.go:83`</small>
## `KnowledgeAPI`
KnowledgeAPI provides access to the knowledge store.
| 方法 | 说明 |
|---|---|
| [`Add`](#knowledgeapiadd) | |
| [`List`](#knowledgeapilist) | |
| [`Search`](#knowledgeapisearch) | |
### `KnowledgeAPI.Add`
```go
Add(name, content string) error
```
<small>`knowledge.go:6`</small>
### `KnowledgeAPI.List`
```go
List() ([]string, error)
```
<small>`knowledge.go:7`</small>
### `KnowledgeAPI.Search`
```go
Search(query string, topK int) ([]*Knowledge, error)
```
<small>`knowledge.go:5`</small>
## `MemoryAPI`
MemoryAPI provides access to the graph memory (entity-relation store).
| 方法 | 说明 |
|---|---|
| [`Commit`](#memoryapicommit) | |
| [`Introspect`](#memoryapiintrospect) | |
| [`MergeEntities`](#memoryapimergeentities) | |
| [`Purge`](#memoryapipurge) | |
| [`Recall`](#memoryapirecall) | |
### `MemoryAPI.Commit`
```go
Commit(triples []Triple) error
```
<small>`memory.go:6`</small>
### `MemoryAPI.Introspect`
```go
Introspect() (map[string]interface{}, error)
```
<small>`memory.go:7`</small>
### `MemoryAPI.MergeEntities`
```go
MergeEntities(source, target string) (int, error)
```
<small>`memory.go:8`</small>
### `MemoryAPI.Purge`
```go
Purge(criteria map[string]string, mode string) (int, error)
```
<small>`memory.go:9`</small>
### `MemoryAPI.Recall`
```go
Recall(query []string, depth int) ([]Entity, []Relation, error)
```
<small>`memory.go:5`</small>
## `SocialAPI`
SocialAPI provides read-only access to the social graph (person profiles and relationships).
External plugins can query person traits and social networks but cannot modify them.
| 方法 | 说明 |
|---|---|
| [`GetNetwork`](#socialapigetnetwork) | |
| [`GetPerson`](#socialapigetperson) | |
| [`GetRelations`](#socialapigetrelations) | |
| [`GetTrait`](#socialapigettrait) | |
| [`ListPersons`](#socialapilistpersons) | |
### `SocialAPI.GetNetwork`
```go
GetNetwork(name string, depth int) ([]*PersonProfile, error)
```
<small>`memory.go:104`</small>
### `SocialAPI.GetPerson`
```go
GetPerson(name string) (*PersonProfile, error)
```
<small>`memory.go:101`</small>
### `SocialAPI.GetRelations`
```go
GetRelations(name string) ([]SocialRelation, error)
```
<small>`memory.go:103`</small>
### `SocialAPI.GetTrait`
```go
GetTrait(name, trait string) (string, bool)
```
<small>`memory.go:102`</small>
### `SocialAPI.ListPersons`
```go
ListPersons() ([]string, error)
```
<small>`memory.go:105`</small>
## `TextMemoryAPI`
TextMemoryAPI provides access to chronological text event storage.
| 方法 | 说明 |
|---|---|
| [`Append`](#textmemoryapiappend) | |
### `TextMemoryAPI.Append`
```go
Append(evt TextEvent) error
```
<small>`memory.go:44`</small>
### `Doc`
```go
type Doc struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` Score …
```
Doc represents a document in the document store.
MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
<small>`memory.go:89`</small>
### `PluginSDK.DocMemory`
```go
func (s *PluginSDK) DocMemory() DocMemoryAPI
```
DocMemory returns the document memory API (may be nil if not available).
<small>`plugin.go:522`</small>
### `Entity`
```go
type Entity struct { Name string `json:"name"` Type string `json:"type"` MentionCount int `json:"mention_count"` }
```
Entity represents a named entity in the knowledge graph.
<small>`memory.go:13`</small>
### `Knowledge`
```go
type Knowledge struct { Name string `json:"name"` // Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。 // // 为何加这个字段:对<EFBC9A><E5AFB9> …
```
Knowledge represents a knowledge entry.
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
<small>`knowledge.go:11`</small>
### `PluginSDK.Knowledge`
```go
func (s *PluginSDK) Knowledge() KnowledgeAPI
```
Knowledge returns the knowledge store API (may be nil if not available).
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
<small>`plugin.go:529`</small>
### `MediaAttachment`
```go
type MediaAttachment struct { Digest string `json:"digest,omitempty"` MIME string `json:"mime,omitempty"` Data []byte `json:"data,omitempty"` Name string `json:"name,omite …
```
TextEvent represents a single text memory event.
MediaAttachment 描述一份与记忆关联的媒体。
两个方向共用一个类型:
- 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
- 读出(Query):内核只填 Digest/MIME,**不回 Data**——
一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
需要字节时拿 Digest 单独取。
刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。
它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。
<small>`memory.go:58`</small>
### `PluginSDK.Memory`
```go
func (s *PluginSDK) Memory() MemoryAPI
```
Memory returns the graph memory API (may be nil if not available).
<small>`plugin.go:508`</small>
### `PersonProfile`
```go
type PersonProfile struct { Name string `json:"name"` Traits map[string]string `json:"traits,omitempty"` Relations []SocialRelation `json:"relations,omitemp …
```
PersonProfile represents a person's complete profile (traits + social relations).
<small>`memory.go:109`</small>
### `Relation`
```go
type Relation struct { SourceName string `json:"source_name"` TargetName string `json:"target_name"` RelationType string `json:"relation_type"` Confidence float6 …
```
Relation represents a relationship between two entities.
<small>`memory.go:20`</small>
### `PluginSDK.Social`
```go
func (s *PluginSDK) Social() SocialAPI
```
Social returns the social graph API (may be nil if not available).
<small>`plugin.go:543`</small>
### `SocialRelation`
```go
type SocialRelation struct { Person string `json:"person"` Relation string `json:"relation"` }
```
SocialRelation represents a social relationship between two persons.
<small>`memory.go:116`</small>
### `TextEvent`
```go
type TextEvent struct { Role string `json:"role"` Content string `json:"content"` Timestamp int64 `json:"timestamp"` Channel …
```
<small>`memory.go:65`</small>
### `PluginSDK.TextMemory`
```go
func (s *PluginSDK) TextMemory() TextMemoryAPI
```
TextMemory returns the text memory API (may be nil if not available).
<small>`plugin.go:515`</small>
### `Triple`
```go
type Triple struct { Subject string `json:"subject"` Relation string `json:"relation"` Object string `json:"object"` Confidence float64 `json:"c …
```
Triple represents a subject-relation-object triple for the knowledge graph.
SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
<small>`memory.go:31`</small>

1044
docs/api/misc.md Normal file

File diff suppressed because it is too large Load Diff

197
docs/api/settings.md Normal file
View File

@ -0,0 +1,197 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 配置(Settings)
声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表。
## `SettingsAPI`
| 方法 | 说明 |
|---|---|
| [`DataDir`](#settingsapidatadir) | DataDir returns the plugin-specific data directory (guaranteed to exist): |
| [`Defs`](#settingsapidefs) | Defs returns config definitions matching the prefix. |
| [`Dump`](#settingsapidump) | Dump returns all config values. |
| [`Get`](#settingsapiget) | Get reads the plugin's own config value (config_<name> table). |
| [`GetCore`](#settingsapigetcore) | GetCore reads the core config table. |
| [`GetPlugin`](#settingsapigetplugin) | GetPlugin reads another plugin's config table. |
| [`List`](#settingsapilist) | List returns all keys matching the given prefix. |
| [`ListCore`](#settingsapilistcore) | ListCore lists core config keys matching the prefix. |
| [`ListPlugin`](#settingsapilistplugin) | ListPlugin lists another plugin's config keys matching the prefix. |
| [`Plugins`](#settingsapiplugins) | Plugins returns a list of all plugin config namespaces. |
| [`RegisterDef`](#settingsapiregisterdef) | RegisterDef registers a config definition for UI display. |
| [`Set`](#settingsapiset) | Set writes a config value to the plugin's own config table. |
| [`SetCore`](#settingsapisetcore) | SetCore writes to the core config table. |
| [`SetPlugin`](#settingsapisetplugin) | SetPlugin writes to another plugin's config table. |
### `SettingsAPI.DataDir`
```go
DataDir() string
```
DataDir returns the plugin-specific data directory (guaranteed to exist):
<daemon data>/plugin_data/<plugin_name>. Plugins should persist any
runtime files (generated images, caches, downloads) here.
<small>`settings.go:25`</small>
### `SettingsAPI.Defs`
```go
Defs(prefix string) []*ConfigDef
```
Defs returns config definitions matching the prefix.
<small>`settings.go:40`</small>
### `SettingsAPI.Dump`
```go
Dump() map[string]interface{}
```
Dump returns all config values.
<small>`settings.go:43`</small>
### `SettingsAPI.Get`
```go
Get(key string) (interface{}, error)
```
Get reads the plugin's own config value (config_<name> table).
<small>`settings.go:5`</small>
### `SettingsAPI.GetCore`
```go
GetCore(key string) (interface{}, error)
```
GetCore reads the core config table.
<small>`settings.go:14`</small>
### `SettingsAPI.GetPlugin`
```go
GetPlugin(plugin, key string) (interface{}, error)
```
GetPlugin reads another plugin's config table.
<small>`settings.go:28`</small>
### `SettingsAPI.List`
```go
List(prefix string) ([]string, error)
```
List returns all keys matching the given prefix.
<small>`settings.go:11`</small>
### `SettingsAPI.ListCore`
```go
ListCore(prefix string) ([]string, error)
```
ListCore lists core config keys matching the prefix.
<small>`settings.go:20`</small>
### `SettingsAPI.ListPlugin`
```go
ListPlugin(plugin, prefix string) ([]string, error)
```
ListPlugin lists another plugin's config keys matching the prefix.
<small>`settings.go:34`</small>
### `SettingsAPI.Plugins`
```go
Plugins() []string
```
Plugins returns a list of all plugin config namespaces.
<small>`settings.go:46`</small>
### `SettingsAPI.RegisterDef`
```go
RegisterDef(def ConfigDef)
```
RegisterDef registers a config definition for UI display.
<small>`settings.go:37`</small>
### `SettingsAPI.Set`
```go
Set(key string, value interface{}) error
```
Set writes a config value to the plugin's own config table.
<small>`settings.go:8`</small>
### `SettingsAPI.SetCore`
```go
SetCore(key string, value interface{}) error
```
SetCore writes to the core config table.
<small>`settings.go:17`</small>
### `SettingsAPI.SetPlugin`
```go
SetPlugin(plugin, key string, value interface{}) error
```
SetPlugin writes to another plugin's config table.
<small>`settings.go:31`</small>
### `ConfigDef`
```go
type ConfigDef struct { Key string `json:"key"` Default interface{} `json:"default,omitempty"` Type string `json:"type"` DisplayName string …
```
ConfigDef describes a configuration field for the WebUI.
<small>`settings.go:50`</small>
### `PluginSDK.Settings`
```go
func (s *PluginSDK) Settings() SettingsAPI
```
Settings returns the settings API for reading/writing plugin configuration.
sett 在 New 时一次性写入且无 setter,故不需要加锁。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:68` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:63` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:114` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:67` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
<small>`plugin.go:505`</small>

154
docs/api/stages.md Normal file
View File

@ -0,0 +1,154 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 阶段钩子(Stages)
在消息处理管道的固定点位插入自己的逻辑。阶段比工具更底层:工具是模型主动调用的,阶段是流程经过时必然触发的。
### `StageContext.IsResponded`
```go
func (c *StageContext) IsResponded() bool
```
<small>`plugin.go:213`</small>
### `StageContext.Lock`
```go
func (c *StageContext) Lock()
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:158` | `p.sessMu.Lock()` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:128` | `p.srvMu.Lock()` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:37` | `p.runMu.Lock()` |
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:427` | `p.mu.Lock()` |
<small>`plugin.go:211`</small>
### `StageContext.RLock`
```go
func (c *StageContext) RLock()
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:267` | `p.mu.RLock()` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:649` | `p.mu.RLock()` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:224` | `p.mu.RLock()` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1152` | `ctx.RLock()` |
<small>`plugin.go:209`</small>
### `StageContext.RUnlock`
```go
func (c *StageContext) RUnlock()
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:273` | `p.mu.RUnlock()` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:650` | `defer p.mu.RUnlock()` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:229` | `p.mu.RUnlock()` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1155` | `ctx.RUnlock()` |
<small>`plugin.go:210`</small>
### `PluginSDK.RegisterStage`
```go
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope)
```
RegisterStage registers a handler for a pipeline stage.
scope: StageScopeGlobal (default) — receives all stage events.
StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:152` | `s.RegisterStage(sdk.StagePreAction, p.stagePreAction)` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:719` | `s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)` |
| [`sanitizer`](../examples/index.md#sanitizer) | `example/sanitizer/plugin.go:52` | `s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {` |
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:94` | `s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {` |
<small>`plugin.go:571`</small>
### `Stage`
```go
type Stage string
```
Stage represents a point in the message processing pipeline.
<small>`plugin.go:26`</small>
### `StageContext`
```go
type StageContext struct { mu sync.RWMutex RawMessage string UserID string GroupID string ContextMsgs []map[string]interface{} L …
```
StageContext provides context for stage handlers.
<small>`plugin.go:189`</small>
### `StageHandler`
```go
type StageHandler func(ctx *StageContext) error
```
StageHandler is a function that handles a pipeline stage event.
<small>`plugin.go:23`</small>
### `StageRegistrar`
```go
type StageRegistrar func(stage Stage, handler StageHandler)
```
StageRegistrar registers a stage handler.
<small>`plugin.go:407`</small>
### `StageScope`
```go
type StageScope int
```
StageScope controls which events a stage handler receives.
<small>`plugin.go:393`</small>
### `StageContext.Unlock`
```go
func (c *StageContext) Unlock()
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:164` | `p.sessMu.Unlock()` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:129` | `defer p.srvMu.Unlock()` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:40` | `p.runMu.Unlock()` |
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:432` | `p.mu.Unlock()` |
<small>`plugin.go:212`</small>

77
docs/api/tools.md Normal file
View File

@ -0,0 +1,77 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 工具(Tools)
注册 LLM 可调用的工具。工具是插件最主要的能力形态:模型看到 `ToolDef` 的说明后决定是否调用,调用时执行你的 `ToolHandler`。
### `ContentBlock`
```go
type ContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL *ImageURL `json:"image_url,omitempty"` AudioURL *AudioURL `jso …
```
ContentBlock 是多模态内容块(OpenAI 格式:text/image_url/audio_url)。
插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
<small>`plugin.go:954`</small>
### `PluginSDK.RegisterTool`
```go
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error
```
RegisterTool registers a tool that the LLM can call.
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:76` | `s.RegisterTool(tp+"a2a_query", sdk.ToolDef{` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:70` | `s.RegisterTool(tp+"acp_query", sdk.ToolDef{` |
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:159` | `s.RegisterTool(tp+"generate", sdk.ToolDef{` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:85` | `s.RegisterTool(tp+"video", sdk.ToolDef{` |
<small>`plugin.go:557`</small>
### `ToolCall`
```go
type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty …
```
ToolCall represents a model's request to call a tool.
<small>`plugin.go:227`</small>
### `ToolDef`
```go
type ToolDef struct { Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Description string …
```
ToolDef describes a tool that the plugin exposes.
<small>`plugin.go:279`</small>
### `ToolHandler`
```go
type ToolHandler func(args map[string]interface{}) (interface{}, error)
```
ToolHandler is a function that handles a tool call.
<small>`plugin.go:20`</small>
### `ToolResult`
```go
type ToolResult struct { CallID string `json:"call_id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Success bool `json:"suc …
```
ToolResult represents the result of a tool call.
<small>`plugin.go:235`</small>

3080
docs/assets/api-index.json Normal file

File diff suppressed because it is too large Load Diff

27
docs/assets/logo-mark.svg Normal file
View File

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
<g transform="translate(200,200)">
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

28
docs/assets/logo.svg Normal file
View File

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
<rect width="400" height="400" fill="#F8FAFC"/>
<g transform="translate(200,200)">
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

60
docs/examples/index.md Normal file
View File

@ -0,0 +1,60 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 示例插件
SDK 仓 `example/` 下有多个**真实可编译**的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态。
每个示例都能用 `hmapdev build` 打成 `.hmap` 装进内核直接跑。
## `a2a`
用到的 API:`Error` · `InjectInputSync` · `Lock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
## `acp`
用到的 API:`Error` · `InjectInputSync` · `Lock` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
## `ai_image`
用到的 API:`Error` · `RegisterTool` · `SetAutoRestart` · `Settings`
## `bili`
用到的 API:`Lock` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
## `browser`
用到的 API:`Error` · `InjectInterruptTextOpts` · `InjectTextNoMemory` · `Lock` · `RegisterInputChannel` · `Unlock`
## `calendar`
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOnRemoveHandler` · `RegisterStopHandler`
## `deepsearch`
用到的 API:`RegisterStopHandler`
## `memo`
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOnRemoveHandler` · `RegisterStage`
## `qq`
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOutputChannel` · `RegisterStage`
## `recoverydiag`
用到的 API:`Knowledge`
## `rss`
用到的 API:`RegisterOnRemoveHandler`
## `sanitizer`
用到的 API:`RegisterStage`
## `weather`
用到的 API:`RegisterOutputChannel` · `RegisterStage`

View File

@ -0,0 +1,75 @@
# 能力边界:哪些 API 外部插件能用
HomeAgent 有两类插件:
| 类型 | 说明 | 分发 |
|---|---|---|
| **外部插件** | 第三方开发,编译成 `.hmap` 后安装 | 独立分发,**可闭源** |
| **内置插件** | 编译进内核,`init()` 自注册 | 随内核发行,需合入主仓 |
SDK 包是**同一个** `github.com/JianFeeeee/homeagentsdk/sdk`,但两类插件拿到的
**能力不同**:外部插件跑在独立进程里,由内核通过桥接注入能力(IPC,不是共享内存里的直接调用)。
本页说明边界在哪、为什么,以及**怎么在写代码前就知道某个 API 是否可用**。
## 一句话规则
> **公开 SDK 包里声明的符号,不等于外部插件拿得到。**
原因是:有些能力只有进程内的内置插件才可能拥有(比如直接读事件发布通道、
直接注入到内核 IO 层)。外部插件通过桥接运行时拿到的是一份**受注入的能力集合**。
## 外部插件**不可用**的 API
这些 API 在公开包里存在,但在外部插件路径上拿不到。文档里每条都带
<span class="api-badge api-badge-builtin">仅内置</span> 标记,
完整清单见 [仅内置插件可用](../api/builtin-only.md)。
| API | 外部插件的实际情况 | 该用什么 |
|---|---|---|
| `sdk.PluginSDK.Events()` | **恒为 nil**。桥接运行时不注入 event subscriber(`SetEventSubscriber` 全仓无调用点) | 桥接运行时已按你的声明完成 `events.subscribe`;Lua 插件用 `sdk.events.subscribe` |
| `sdk.PluginSDK.SetEventSubscriber` | 无人调用 | 同上 |
| `UnregisterOutputChannel` | 桥接只注入 registrar、**不注入 unregistrar**,调用是**静默无效**(返回 nil,不报错也不注销) | `RegisterOutputChannel` 可用;注销需重载插件 |
| `SocialAPI` 的写操作 | 公开接口只有 6 个**只读**方法 | 读用 `s.GetPerson` 等;写需内置插件 |
| `EventSubscriber.Publish` | 公开接口**刻意只有 Subscribe**,没有 Publish | 只订阅 |
| `PriorityL4` | 声明会被内核**夹到 L3** | 用 L1–L3 |
| `RegisterChannel` / `ListChannels` / `OutputChan` / `InjectInput` / `InjectInterrupt` | 只存在于内核内部 SDK | `RegisterInputChannel` / `RegisterOutputChannel` / `InjectText` 等公开方法 |
| `PluginMgr()` 的完整能力 | 公开 `PluginMgrAPI` **只有 3 个方法**(`ReloadOne` / `ListLoadedPlugins` / `IsPluginDisabled`) | 就这 3 个;`ReloadPlugins`/`Disable`/`Remove` 属内部接口 |
!!! warning "两处常见的文档错误(本站已更正)"
1. **`PluginMgr()` 不是「仅内置可用」**。桥接模板显式注入了它
(`base.SetPluginMgrAPI(procPluginMgr{})`),公开 `PluginMgrAPI` 也注明
「外部插件可调用」。真正的区别是**方法数量**:公开面 3 个,内部面 9 个。
容易混淆是因为两个包里有**同名但不同**的接口:
`sdk.PluginMgrAPI`(3 方法)与 `internal/sdk.PluginManager`(9 方法)。
2. **`Events()` 恒为 nil 这件事以前没写清**。旧文档把 `Events()` 当作
可用的订阅入口,但桥接运行时不注入 subscriber。外部插件的事件订阅
实际由生成的运行时通过 `events.subscribe` 完成。
## 判定依据来自哪里
本站的「仅内置」标记不是猜的,逐条来自:
1. **`tools/hmapdev/templates/proc_main.go.tmpl`** —— 外部插件运行时**实际注入**
哪些能力,看 `buildPluginSDK()` 里的 `base.Set*` 调用。
2. **`internal/sdk`** —— 内置插件用的完整接口,与公开包对照。
3. **内核 RPC 协议表**(`internal/plugin/proc/protocol.go`)—— 外部插件**能发哪些请求**。
每条裁定的具体依据写在该 API 的告警框里,可以直接核对。
## 怎么快速确认
- 用 [API 搜索](../api/index.md) 搜 API 名或功能描述,带
<span class="api-badge api-badge-builtin">仅内置</span> 的就是外部不可用
- 直接看 [仅内置插件可用](../api/builtin-only.md) 汇总页
- 拿不准时,**读 `example/` 下的示例插件** —— 它们全是外部插件,
能被它们编译通过的写法,外部就一定可用
## 为什么这样设计
不是为了限制,而是**IPC 边界决定了能力边界**:外部插件跑在独立进程里,
内核只能通过显式的注入点把能力交过去。凡是需要「持有内核内部数据结构」
的能力(事件发布通道、IO 通道、插件注册表全量操作),进程外都无法安全暴露。
这套边界同时带来好处:插件崩溃不会带崩内核(进程隔离),
以及**插件可以闭源**(SDK 是 MIT,见[首页](../index.md#_3))。

View File

@ -0,0 +1,111 @@
# 第一个 Lua 插件
Lua 插件适合**轻量、快速原型**:不需要 Go 编译环境,改完重启内核即可生效。
但它有一个必须理解的限制 —— 执行模型是**被动回调**。
## 执行模型(先读这段)
Lua 插件跑在内核进程内的 gopher-lua 解释器里(单 Lua 状态 + 互斥锁):
- **被动回调**:`main.lua` 只在加载时执行一次。此后工具、阶段钩子、
输入输出通道全部由内核事件驱动回调你的 Lua 函数。**插件不能自己启动后台任务。**
- **没有并发**:Lua 侧没有 goroutine、协程调度,也没有 `os` / `io` 库和 socket 监听。
唯一主动出站通道是 `sdk.http.get/post`(同步请求)。
- **任何阻塞循环都会持锁卡死该插件的全部调用。**
!!! warning "要常驻服务就用 Go 插件"
需要监听端口、后台轮询、定时任务的,请用 [Go 插件](first-plugin.md)
(可自行启动 goroutine)。Lua 侧的等价做法是**事件驱动**:把逻辑挂在
工具、阶段钩子或通道回调上。
## 生成工程
```bash
hmapdev init myluaplugin --lua
cd myluaplugin
```
结构:
```
myluaplugin/
├── plg.json — entry: "main.lua", targets: "lua"
├── main.lua — 插件实现
├── sdk.lua — SDK 模拟层(支持独立测试)
└── README.md
```
## 一个完整的插件
```lua
-- main.lua
local plugin = {
name = "myluaplugin"
}
function plugin.start(sdk)
sdk.log("info", "myluaplugin starting...")
sdk.register_tool("myluaplugin_hello", {
description = "向指定的人打招呼",
parameters = {
type = "object",
properties = {
who = { type = "string", description = "要打招呼的对象" }
},
required = { "who" }
}
}, function(args)
return { content = "hello, " .. (args.who or "world") .. "!" }
end)
sdk.log("info", "myluaplugin started")
end
function plugin.stop()
sdk.log("info", "myluaplugin stopped")
end
return plugin
```
## 本地测试
`sdk.lua` 是纯 Lua 的 SDK 模拟实现,可以直接用解释器跑:
```bash
lua main.lua
# [lua-plugin] info: myluaplugin starting...
# [lua-plugin] register_tool: myluaplugin_hello
# [lua-plugin] info: myluaplugin started
```
在内核里运行时,`sdk.*` 由 Go 层注入,`sdk.lua` 里所有 `-- !impl` 标记的函数
会被替换成真实实现。
## API 约定的两点
- **注册类函数调用即时报错**(抛 Lua error)—— 注册失败不会静默。
- **数据类函数统一返回 `(result, err)`**,`err` 为 nil 表示成功。
核心未装配的子系统(如 SocialAPI)返回空值而非报错。
Lua 侧的 `sdk.*` 能力与外部 Go 插件对齐至 SDK 1.3.0(需内核 1.4.0+)。
!!! note "历史提醒"
1.1–1.3 期间,媒体 / 注入标志位 / 优先级能力只在 Go 侧有,Lua 侧静默缺失。
现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住
「`sdk.lua` 承诺的每个函数都有运行时绑定」。
## 构建
```bash
hmapdev build # → dist/myluaplugin_lua.hmap
```
Lua 插件直接打包源码,不经过编译。
## 下一步
- [能力边界](capability-boundary.md) —— Lua 与 Go 外部插件的能力面一致
- [打包与发布](packaging.md)
- [示例](../examples/index.md) —— `example/luademo` 是 Lua 版参考实现

162
docs/guide/first-plugin.md Normal file
View File

@ -0,0 +1,162 @@
# 第一个 Go 插件
以下是一个**能直接跑起来**的最小插件:注册一个工具、声明一项配置、处理停止与卸载。
## 1. 生成工程
```bash
hmapdev init myplugin
cd myplugin
```
生成的结构:
```
myplugin/
├── plg.json — 插件元信息(名称、版本、入口、目标平台)
├── plugin.go — 插件实现
├── go.mod — 模块定义
├── README.md
└── thirdpart/ — 外部源码存放目录(可选)
```
`hmapdev build` 时会在构建目录自动生成子进程运行时(`z_proc_gen.go` 等),
**不需要手工创建,也不要提交**。
## 2. 插件实现
插件的全部契约是一个 `Plugin` 接口([API 参考](../api/lifecycle.md#plugin)):
| 方法 | 何时调用 |
|---|---|
| `Name() string` | 内核需要标识这个插件时 |
| `Start(*sdk.PluginSDK) error` | 插件加载后。**在这里注册工具、通道、配置** |
| `Stop() error` | 插件停止时(重载、禁用、内核退出都会触发) |
再加一个工厂函数。**名字必须是 `NewPluginFactory`** —— 生成的运行时按这个名字调用:
```go
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
```
!!! warning "不要写成 `NewPlugin`"
生成的子进程运行时调用的入口是 `NewPluginFactory`。仓库里有 3 个早期示例
同时保留了两个名字(`NewPlugin` 只是遗留别名),但新插件只写
`NewPluginFactory` 即可。写错名字的后果是**编译能过、加载时找不到入口**。
## 3. 一个完整的例子
这是一个「打招呼」工具,带一项配置:
```go
package main
import (
"fmt"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
// ① 声明配置项:内核会把它渲染到 WebUI 设置页
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.myplugin.greeting",
Default: "hello",
Type: "string",
DisplayName: "问候语",
Description: "打招呼时使用的前缀",
Category: "myplugin",
})
// ② 注册工具:模型看到 Description 后决定是否调用
tp := p.name + "_"
s.RegisterTool(tp+"hello", sdk.ToolDef{
Name: tp + "hello",
Description: "向指定的人打招呼",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"who": map[string]interface{}{
"type": "string",
"description": "要打招呼的对象",
},
},
"required": []string{"who"},
},
}, p.handleHello)
// ③ 卸载(插件被删除)前清理自己产生的数据。
// 注意与 Stop 的区别:Stop 在每次重载时也会触发。
s.RegisterOnRemoveHandler(func() {
fmt.Printf("[%s] 清理数据\n", p.name)
})
return nil
}
func (p *Plugin) Stop() error { return nil }
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
who, _ := args["who"].(string)
greeting := "hello"
if v, err := p.sdk.Settings().Get("plugin.myplugin.greeting"); err == nil && v != "" {
greeting = v
}
return map[string]interface{}{
"content": fmt.Sprintf("%s, %s!", greeting, who),
}, nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
```
## 4. 工具返回值的两条约定
`ToolHandler` 返回 `(interface{}, error)`,模型侧看到的是一条 tool message:
- **正常结果**:返回一个 map,把要展示给模型的文本放在 `content` 字段。
未识别的字段也会一并传给模型,可以放结构化数据。
- **业务失败**:返回 `map[string]interface{}{"isError": true, "content": "原因"}`
**并返回 nil error**。这样模型能看到失败原因并自行调整;
若返回 Go 的 `error`,那是**工具调用本身出错**,语义不同。
```go
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{"isError": true, "content": msg}
}
```
## 5. 构建与安装
```bash
hmapdev build # 默认产出多平台 bundle
# → dist/myplugin_bundle.hmap
hmapdev build --no-bundle # 只构建当前平台
# → dist/myplugin_linux_amd64.hmap
```
安装到内核:在 WebUI 的插件管理页上传 `.hmap`,或从 URL / 本地路径安装。
详见 [打包与发布](packaging.md)。
## 下一步
- [能力边界](capability-boundary.md) —— 哪些 API 外部插件能用
- [工具(Tools)](../api/tools.md) —— `ToolDef` 的完整字段
- [记忆(Memory)](../api/memory.md) —— 让插件读写长期记忆
- [示例插件](../examples/index.md) —— `example/memo` 是个完整的可读实现

View File

@ -0,0 +1,68 @@
# 环境与工具链
`hmapdev` 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
最终产出 `.hmap` 插件包(工具名即取自这个包格式)。
!!! note "改名说明"
1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
`~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
(旧目录会自动继续沿用)。
## 安装
从源码构建:
```bash
git clone https://github.com/JianFeeeee/homeagentsdk
cd homeagentsdk/tools/hmapdev
go build -o hmapdev
# 把 hmapdev 放进 PATH,或直接用 ./hmapdev
```
也可以从 SDK 的 release 附件下载预编译二进制(`hmapdev_linux_amd64` 等,
共 5 个平台:linux/darwin/windows × amd64/arm64)。
> 仓已迁到 GitHub;gitcode 仅作国内镜像(源码同步,**release 附件暂时仍在那里**):
> `https://github.com/JianFeeeee/homeagentsdk/releases`。
> Go 模块路径仍是 `github.com/JianFeeeee/homeagentsdk` —— 这是有意保留的,
> 改模块路径会让现有插件的 `go.mod` 全面失效。
## SDK 版本管理
`hmapdev` 会维护一份本地 SDK 存储,`init` 时按 `plg.json` 里的 `sdk` 字段
选择版本。两者**必须**一致,否则编译出的插件与内核协议可能错配。
```bash
hmapdev sdk list # 已安装的 SDK 版本
hmapdev sdk current # 当前使用的版本
hmapdev sdk latest # 最新可用版本
hmapdev sdk install v1.2.0 # 安装指定版本
hmapdev sdk use v1.2.0 # 切换版本
hmapdev sdk path # 当前 SDK 路径
```
存储在 `~/.homeagent/hmapdev/sdk/<version>/`。
!!! warning "版本未命中会**明确报错**"
`plg.json` 声明的 `sdk` 版本若不在本地存储里,`hmapdev` 不会退回某个默认版本,
而是报错并让你先 `hmapdev sdk install`。这是有意的:静默降级会产出与内核
协议不匹配的插件,那种失败要到运行时才暴露。
## 源码调试
不编译直接跑插件源码,输出调用轨迹:
```bash
hmapdev debug [dir] # dir 默认当前目录
```
写 Lua 插件时更简单——`sdk.lua` 是 SDK 模拟层,可以直接用解释器跑:
```bash
lua main.lua
```
## 下一步
- [第一个 Go 插件](first-plugin.md)
- [第一个 Lua 插件](first-lua-plugin.md)

View File

@ -0,0 +1,56 @@
# 多平台构建
## 默认就是多平台
`hmapdev build` 默认 bundle 模式,一次产出含三个平台的单个 `.hmap`:
```
dist/myplugin_bundle.hmap
└── plugin.bin.linux.amd64
└── plugin.bin.darwin.amd64
└── plugin.bin.windows.amd64
```
安装时内核挑当前平台那份,重命名为 `plugin.bin`。
## 逐平台构建
```bash
hmapdev build --no-bundle # 按 plg.json 的 targets 构建
hmapdev build --target linux/arm64 # 追加一个目标
```
`plg.json` 里声明目标:
```json
{
"name": "myplugin",
"version": "1.0.0",
"targets": "linux/amd64,windows/amd64"
}
```
单平台输出文件名:`{name}_{os}_{arch}.hmap`。
## 交叉编译
子进程插件**不再需要 cgo**,所以交叉编译不需要目标平台的 C 工具链 ——
这是 v1.0.0 的收益之一。
!!! note "bundle 模式忽略 `targets`"
固定构建 linux/amd64、darwin/amd64、windows/amd64。如果你只需要其中一个,
用 `--no-bundle` 更快。
## 平台能力差异
历史上有过一处真实的平台断层,现已消除:
- **v1.0.0 之前**:Windows 上插件只看到 **3 个 stage 字段、且无法写回**。
- **v1.0.0 起**:Windows 与其他平台**共用同一套 RPC 实现**,16 字段全可见 + 写回。
因此**不必**为 Windows 写条件分支 —— 除非你的插件自己用了平台专有的外部命令。
## 下一步
- [打包与发布](packaging.md)
- [环境与工具链](getting-started.md)

114
docs/guide/packaging.md Normal file
View File

@ -0,0 +1,114 @@
# 打包与发布
`hmapdev build` 一次完成编译与打包,产出 `.hmap` 分发包(zip 格式,内含
`plugin.json` 清单 + 二进制)。
## 命令
```bash
hmapdev build # 默认 bundle(多平台合集)
hmapdev build --no-bundle # 只构建 plg.json targets 里的平台
hmapdev build --target linux/arm64 # 在 targets 基础上追加目标
hmapdev build --outdir out # 指定输出目录(默认 dist)
hmapdev build --sdk-path <path> # 覆盖 go.mod 的 replace 指向的 SDK
hmapdev build --replace <mod@path> # 追加 go.mod replace(可多次)
```
执行流程:
1. 读 `plg.json` 的 `targets` / `bundle` 决定构建目标
2. 生成子进程运行时代码(`z_proc_gen.go`、`z_proc_shm_*.go`)
3. **Go 插件**:`go build`(普通可执行文件,`CGO_ENABLED=0`)
**Lua 插件**:直接打包源码,不编译
4. 生成 `plugin.json` 输出清单
5. 打成 `.hmap`
## 两个 JSON 的区别
这一点经常混淆:
| 文件 | 谁维护 | 作用 | 关键字段 |
|---|---|---|---|
| `plg.json` | **你** | 项目元信息,构建输入 | `targets`、`bundle` |
| `plugin.json` | `hmapdev` 自动生成 | 构建产物清单 | `entry`、`platforms` |
`plg.json` 里的 `sdk` 字段声明**本插件针对的 SDK 版本**;未命中本地 SDK 存储
会明确报错(见[环境与工具链](getting-started.md))。
## 多平台(bundle)
`build` 默认就是 bundle 模式:一次编译 linux/amd64、darwin/amd64、windows/amd64,
产出一个含全部平台二进制的 `.hmap`;安装时内核挑当前平台那份。
```bash
hmapdev build # → dist/myplugin_bundle.hmap
hmapdev build --no-bundle # → dist/myplugin_linux_amd64.hmap 等
```
!!! note "bundle 模式会忽略 `plg.json` 的 `targets`"
固定构建上述三个平台。交叉编译需要对应工具链(如 Linux 上构建 darwin 需要
clang / macOS SDK),缺工具链时会失败 —— 此时用 `--no-bundle` 只构建当前平台。
bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分,安装时重命名为 `plugin.bin`。
## 产物形态
子进程插件是**普通可执行文件**,不分平台后缀:
| 平台 | 二进制 |
|---|---|
| Linux / macOS / Windows | `plugin.bin` |
!!! warning "v1.0.0 破坏性变更:不再加载 `.so` / `.dll`"
外部插件从 C ABI 动态库改为**子进程 + 共享内存**。
- `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。
新内核遇到旧产物会跳过并报可操作错误,不崩溃。
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev`
(原 `plugindev`)重编即可。
- `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写 `plugin.so` 也无妨),
现在只用于区分 Lua 插件。
- 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
## 安装
三种方式(`9876` 是 pluginmgr 的本地端口,默认只监听 `127.0.0.1`、无鉴权):
```bash
# 从 URL 安装(仅 http/https,流式下载不落盘)
curl -X POST http://127.0.0.1:9876/plugins \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/myplugin.hmap"}'
# 从本地路径安装(读取文件,不移动原文件)
curl -X POST http://127.0.0.1:9876/plugins \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/myplugin.hmap"}'
# 直接上传二进制
curl -X POST http://127.0.0.1:9876/plugins \
--data-binary @dist/myplugin_bundle.hmap
```
安装后调用 `/api/v1/plugins/reload` 或重启内核生效。
走 WebUI 的 HTTP API(默认 `8080`,需 `api_key` 鉴权,内部代理到 pluginmgr):
```bash
curl -X POST http://127.0.0.1:8080/api/v1/plugins \
-H "Authorization: Bearer <api_key>" \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/myplugin.hmap"}'
```
也可以在 WebUI 的插件管理页面上传。
## 发布前自查
- [ ] `plg.json` 的 `sdk` 版本与目标内核匹配
- [ ] `version` 已递增(内核按版本判断是否需要重装)
- [ ] 若插件有外部状态,`SetAutoRestart(false)` 或在 `Start` 里重建连接
(崩溃重启是**线性退避** 1s→2s→3s,5 分钟内第 4 次崩溃即停止,
见[生命周期](../api/lifecycle.md#pluginsdksetautorestart))
- [ ] `RegisterOnRemoveHandler` 里清理自己写下的数据文件
- [ ] 在 `-race` 下跑一遍:插件的 `Start` 与工具的并发访问是最常见的竞态来源

View File

@ -0,0 +1,112 @@
# 工具并发声明:`ParallelSafe` / `Serial`
> 对应 `sdk.ToolDef` 的两个字段。内核在**同一轮**收到多个 `tool_call` 时,
> 依据它们决定并发还是整批串行。
>
> 状态:已随 2026-09 的并行内核落地并在生产启用。
## 1. 为什么是"保守 opt-in"
**默认整批串行。** 只有当**批内每一个**工具都显式声明 `ParallelSafe: true`
时,那一批才并发;**只要有一个不声明,整批退回串行**。
这不是"漏了声明导致退化"的将就,而是刻意的设计:
- 存量插件**不改一行**就得到保守行为(整批串行),不会被升级意外并发
- 声明是**责任**而非特权 —— 声明者必须自己确认线程安全
- 宁可慢,不可错:一次错误的并发可能让两个工具抢同一个 SQLite 写、
同一台设备、或同一个输出通道
```go
// 同批全是安全工具 → 并发
tools: [a(ParallelSafe), b(ParallelSafe)] ⇒ 并发
// 只要有一个没声明 → 整批串行
tools: [a(ParallelSafe), b(默认)] ⇒ 串行
```
## 2. 三个条件都满足才可以声明 `ParallelSafe`
1. **handler 自身线程安全** —— 不持有跨调用的可变状态
2. **不与同批其它工具争抢同一资源** —— SQLite 写、设备、同一输出通道
3. **执行顺序无关** —— 顺序敏感的工具应留 `false`,由内核保序
第 3 条常被忽略:内核能保证**用户可见的消息**按声明顺序落盘,但**工具
之间的实际执行先后**在并发模式下不确定。有顺序依赖就留 `false`。
## 3. `Serial`:显式的反向标记
```go
Serial bool `json:"serial,omitempty"`
```
`ParallelSafe` 的零值 `false` 已经表达"串行",插件**无法区分**:
- "我没想过"
- "我确认过**必须**串行,且有原因"
一旦工具作者需要把"这里**故意**串行,是有原因的"写进代码(而不只是没填),
这个区分就是必需的 —— 否则只能靠命名约定传递意图。
适用场景:读操作但有隐含顺序约束(终端 `read`/`resize` 这类共享会话
状态);写操作虽已加锁但需要串行以获得可预测的交错顺序。
**优先级:`Serial` 胜出。** 即使同时写了 `ParallelSafe: true`,`Serial`
仍然生效 —— 显式声明"必须串行"不允许被 `ParallelSafe` 或任何默认值覆盖。
## 4. 写法
声明字段放在 `ToolDef` 结构体的**末尾**,遵循既有 `NoMemory` 的风格:
```go
sdk.RegisterTool(sdk.ToolDef{
Name: "my_readonly_query",
Description: "……",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Handler: h.query,
ParallelSafe: true, // 声明在末尾
}, s)
```
需要"故意串行"时:
```go
sdk.RegisterTool(sdk.ToolDef{
Name: "my_terminal_input",
Handler: h.input,
Serial: true, // 胜出,忽略 ParallelSafe
}, s)
```
## 5. 内置工具
核心仓的内置工具用 `toolDefOptions` / `parallelOpts()` 声明
(`internal/agent/core/tooldefs.go`),最终由 `buildToolDefs` 汇总成
`ToolDef.ParallelSafe`。
内核只提供并行调度基础设施,**不硬编码任何工具名的安全状态表** ——
状态由每个工具在自己的声明结构里给出,查询时走聚合表。
## 6. 效果(实测)
同批 N 个各约 250ms 的工具,新版内核"内部并发"对"强制串行":
| N | 加速比 |
| --- | --- |
| 2 | ~1.41× |
| 4 | ~2.22× |
| 8 | ~3.88× |
批耗时几乎不随 N 增长,串行批严格线性。
> 压测时**必须同时记录实际执行的工具数**,不能只看耗时。
> 某次对照中旧版本耗时更短、但因适配器缺 `stream_index` 导致
> **实际处理 0 个工具** —— 那不是性能提升,是全失败。
## 相关
设计背景与踩坑见**核心仓**文档(不在本仓):
- `docs/zh/toolcall-contract-and-sequence-design.md` —— 契约与序列设计
- `docs/zh/toolcall-parallel-execution-plan.md` —— 阶段、实测压测数据
- `docs/zh/deploy-runbook.md` —— 生产部署(含适配器 `stream_index` 相关)

113
docs/guide/scene-memory.md Normal file
View File

@ -0,0 +1,113 @@
# 场景记忆(Scene Memory)
> 场景式记忆是内核 v1.3 起的能力。它不新增 API 面,只影响**你的输入被怎样记住与取回**。
> 与 `NoMemory` / `ContextPolicy` / `RecallPolicy` 并列为第四项声明:`ScenePolicy`。
## 它解决什么问题
三层记忆按**字面相关性**召回:你得说出相近的词,记忆才会被取回来。
场景记忆补上另一半:按**场合**召回。
同一场合再次出现时,当时挂在这个场合上的约定、偏好、人物关系会自动回来——
与这次说了什么措辞无关。
```
你:以后在群里回消息简短点
└─ 这条记忆挂到场面「chan:qq + peer:group_xxx」上
一周后,同一个群里有人问「上次说的格式是什么」
└─ 场面重现(还没等你提到「格式」),那条约定已经被取回
```
## 场面是自己长出来的
场景**不需要声明**。每轮交互,内核采集一组可观察信号当这轮<E8BF99><E8BDAE><EFBFBD>「场面指纹」:
| 特征 | 来源 | 权重 | 说明 |
|---|---|---|---|
| `chan` | 输入通道名 | 1.0 | 最强的同一性信号 |
| `peer` / `peer_group` | 注入点给的 `payload` 里的 `group_id`/`user_id`/`chat_id` 等 | 1.0 | 群与私聊分开,避免互相命中 |
| `tool` | 触发这一步的工具名 | 0.8 | 行为信号 |
| `topic` | 清洗后输入的内容词 | 0.4 | 软信号,同场面的不同话题不该被拆开 |
| `part` | 时段(夜间/上午/下午/晚间) | 0.2 | 最弱,只做辅助 |
指纹反复重合时,一场场面就成形了。相似度按**加权 Jaccard** 算
(共享特征的权重和 ÷ 并集的权重和)——不加权的话,一次偶然的话题重合
会把两个不同场面并成一个。
**同类场面出现第二次才被认定。** 一次性的交互不建场面:
那不是「场面」,建了只会让图库被一次性事件撑满。
## 声明你的参与姿态
```go
sdk.ChannelDef{
ScenePolicy: sdk.ScenePolicyNone, // 这条通道不参与场面识别
}
```
或单次注入覆盖:
```go
sdk.InjectOptions{
ScenePolicy: sdk.ScenePolicyNone,
}
```
| 取值 | 含义 |
|---|---|
| `""`(空)/ `ScenePolicyAuto` | **参与**(默认,保持既有行为) |
| `ScenePolicyNone` | **不参与**:不产任何场面指纹,也不派生场景键 |
**默认是参与而不是不参与**,与 `ContextPolicy` 刻意相反。原因是场景只
**附加**检索路径、不改记忆本体,默认关会让存量通道突然失去场景召回;
而「关」是少数意图(纯内部信号)。
声明 `none` 之后连时段特征都不产——一个不参与的门面不该在场面索引里
留下任何足迹。
### 谁该考虑关掉
内核自循环(`system`)、心跳(`timer`)、内部状态汇报(`kernel`)这类
纯内部信号。它们每次触发都在撑一个场面,会把不相干的交互聚到一起。
反过来说,**多标一个通道通常没有代价**:一个没人往上面写记忆的场面,
召回时返回空。关不关都不影响正确性——所以拿不准时,默认参与就好。
## 怎么给场面命名
场景键有两种来源:
**通道派生(默认)**——`evt.Source` 派生出 `chan:qq` 这类键。你不用管。
**显式声明(进阶)**——在注入时给出更有语义的键:
```go
p.sdk.InjectInterruptTextOpts("qq", "qq", text, sdk.InjectOptions{
ScenePolicy: sdk.ScenePolicyAuto,
})
```
也可以通过 `payload["scene"]` 传层级键(支持 `string` / `[]string` /
`[]interface{}` 三种形态):
```go
"chan:qq/peer:group_1027"
```
召回走**前缀匹配**(`chan:qq` 能覆盖 `chan:qq/peer:xxx`),用 `/` 兜底
以免 `chan:qq` 误吞 `chan:qq2` 这种同前缀但不同层的场景。
## 场面记忆不改变什么
- **不改记忆本体**:场景是记忆的**附加索引**,删掉场景不删记忆。
- **不让模型负责**:`memory_commit` 的 `scene` 留空即可,内核会挂到本轮
解析出的场面上。留空是安全的一侧——猜错的场面会把无关记忆钉死。
- **不影响同步通道**:`webui` / `cli` / 终端走 `ResponseCh`,不经
`output_send__*`,与场面无关。
## 相关 API
- `ChannelDef.ScenePolicy` —— 通道级声明(见 [输入/输出通道](../api/channels.md))
- `InjectOptions.ScenePolicy` —— 单次注入覆盖(见 [其他类型](../api/misc.md))
- `ScenePolicyAuto` / `ScenePolicyNone` / `ValidScenePolicy` —— 常量与校验

81
docs/guide/security.md Normal file
View File

@ -0,0 +1,81 @@
# 受限 SDK 与安全
外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层**安全边界**:
外部插件的进程不共享内核地址空间,能力通过显式注入点交过去。
## 三层隔离
| 层 | 机制 | 防住了什么 |
|---|---|---|
| **进程** | 插件跑在独立子进程 | 插件 panic / 内存越界**不会带崩内核** |
| **能力** | 只注入显式声明的接口 | 插件拿不到未授权的内核内部结构 |
| **权限** | 公开接口是内部接口的**只读子集** | 插件无法改写他人数据 |
第一种是 v1.0.0 从 C ABI 动态库改为子进程 + 共享内存的直接收益:
在此之前,插件 panic 会带崩 `homed`。
## 受限接口是怎么实现的
**按接口裁剪,而不是按方法裁剪。** 同一个概念在公开包与内部包里是**两个不同的
接口声明**,公开的那个只保留安全子集:
```go
// 公开 SDK:6 个只读方法
type SocialAPI interface {
GetPerson(name string) (*PersonProfile, error)
GetTrait(name, trait string) (string, bool)
GetRelations(name string) ([]SocialRelation, error)
GetNetwork(name string, depth int) ([]*PersonProfile, error)
ListPersons() ([]string, error)
}
```
写操作只在内核内部接口里。这样外部插件**在类型层面就调不到**,
不是靠运行时检查拦截。
同理,`EventSubscriber` 公开版**刻意只有 `Subscribe`,没有 `Publish`**:
```go
// 插件可以订阅,但由内核决定投递哪些事件
type EventSubscriber interface {
Subscribe(eventType EventType, handler EventHandler) func()
}
```
## 进程边界带来的约束
事件订阅是理解这层边界的典型例子。公开包里有一个 `Events() EventSubscriber`,
但**外部插件拿到的恒为 nil** —— 桥接运行时不注入它(`SetEventSubscriber`
在全仓没有调用点)。外部插件的事件订阅由生成的运行时通过 `events.subscribe`
RPC 完成,Lua 插件走内部 SDK 的 `Subscribe`。
这不是缺陷,而是进程边界的结果:跨进程无法共享内核的事件发布通道。
详见[能力边界](capability-boundary.md)。
## 共享内存中的数据面
工具调用帧、Cleaner、输入输出通道、媒体块、文档与知识正文**都走共享内存**,
RPC 只传偏移描述符。因此:
- 大对象不经 JSON 序列化,避免了大 payload 的性能与内存放大;
- StageContext 在同一份状态上读改写,消除了副本模型的 lost update
(实测由 35.8~36.8% 降到 0)。
`SharedRef`(共享内存描述符)是**内部实现细节**,插件开发者看不到它 ——
公开 SDK 只暴露普通字符串与 map。
## 插件作者的实践建议
- **不要在 `Start` 里长时间阻塞** —— 内核在等待它返回。
- **工具处理器要可并发**:模型可能并发发起多个调用;共享状态用锁保护
(`example/memo` 用 `sync.RWMutex`)。
- **写文件用原子替换**(临时文件 + rename),避免进程被强杀时截断数据。
- **声明 `NoMemory`**:定时提醒、连接状态这类不是对话内容的东西,
别让它们污染记忆(`InjectOptions{NoMemory: true}`)。
- **在 `-race` 下测**:插件重载瞬间的并发访问是历史高发缺陷。
## 许可与分发
SDK 是 **MIT**,插件可以**闭源分发**,可商用、可私有,无需回馈。
这是刻意的:SDK 随插件静态链接(源码进入插件二进制),用传染性许可会
强迫插件开源。内核本身是 AGPL-3.0-only,但那是内核的许可,与外部插件无关。

View File

@ -0,0 +1,108 @@
# 流式多 `tool_call`:适配器必须透传 `index`
> 面向在 Lua 里写适配器(`transform_stream_chunk`)的插件作者。
>
> 状态:已随 2026-09 的并行内核落地;生产 `openai.lua` 等适配器已修复。
## 1. 问题
OpenAI 兼容的流式响应里,同一轮的多个 `tool_call` 以**分片**形式到达,
靠 `index` 字段区分归属:
```
data: {"choices":[{"delta":{"tool_calls":[
{"index":0,"id":"call_a","function":{"name":"alpha","arguments":""}}]}}]}
data: {"choices":[{"delta":{"tool_calls":[
{"index":1,"id":"call_b","function":{"name":"beta","arguments":""}}]}}]}
data: {"choices":[{"delta":{"tool_calls":[
{"index":0,"function":{"arguments":"{\"x\":1}"}}]}}]}
data: {"choices":[{"delta":{"tool_calls":[
{"index":1,"function":{"arguments":"{\"y\":2}"}}]}}]}
```
**每个 SSE chunk 通常只含一个 `tool_call` 元素。** 内核按 `index` 分桶累积
`id` / `name` / `arguments`。
## 2. 适配器必须做的事
`transform_stream_chunk` 的输出 JSON 里,每个 tool call 分片都要带
**`stream_index`**(值取上游的 `index`):
```lua
table.insert(tcs, {
id = tc.id or "",
type = tc.type or "function",
name = name,
raw_arguments = raw_args,
-- ★ 必须透传上游 index(键名是 stream_index,不是 index)。
-- 内核按 stream_index 分桶累积同一轮多个 tool_call 的分片。
stream_index = tc.index or 0
})
```
### ★ 键名是 `stream_index`,不是 `index`
内核的 `ToolCall.StreamIndex` 标签是 `json:"stream_index"`:
```go
StreamIndex int `json:"stream_index,omitempty"`
```
写成 `index` 会被 Go 的解码器**静默丢弃**(无匹配字段),
`StreamIndex` 恒为 0 ⇒ 全部落进 `accs[0]`。
## 3. 不透传的实际后果
不是"少个字段",而是**多工具并行调用整体失效**:
| 现象 | 原因 |
| --- | --- |
| `name` 相互覆盖 | 全进 `accs[0]`,后写的赢 |
| `arguments` 碎片混拼 | 两个工具的 JSON 片段交错拼接 |
| 报"参数不是合法 JSON" | 上面拼接的产物解析失败 |
| 工具被当成**空参数**调用 | 同上 |
2026-09-27 的对照压测里,旧适配器耗时**更短**但**实际处理 0 个工具** ——
每个工具都因参数非法失败。⇒ 压测**必须同时统计实际执行数**,不能只看耗时。
## 4. 还有两个容易踩的点
**① 不能按 `name` 过滤分片**
```lua
-- ✗ 错:后续块的 name 为空但携带 arguments
if tc.function and tc.function.name then ... end
-- ✓ 对:无 name 但有 arguments 的分片也要收,累积时再校验 name
```
**② 扁平结构 + `stream_index`**
部分协议族(`server` / `kimicode` / `anthropic` / `ollama`)的流式 tool call
是**扁平**结构(`name`/`arguments` 直接在 `tc` 上,不在 `tc.function` 里),
同样要带 `stream_index`。
## 5. 自检
```bash
# 1) 适配器是否透传
grep -n "stream_index" /home/newqqagent/adapters/<你的>.lua
# 2) 实测:发一个同轮多工具的请求,看是否两个都真被执行
# 内核日志里 executing tool 应出现两次(可能并发)
journalctl -u homeagent.service --since "-2 min" | grep "executing tool"
# 3) 有没有参数解析失败
journalctl -u homeagent.service --since "-2 min" | grep -E "参数|合法 JSON"
```
> `gemini.lua` 目前**没有**流式 tool call 实现,因此不涉及本条。
> Gemini 协议是 `functionCall` 而非 `tool_calls`,不能照搬 OpenAI 的做法。
## 相关
- `docs/guide/parallel-tool-declaration.md` —— 并发声明 `ParallelSafe`/`Serial`
- 核心仓 `docs/zh/toolcall-parallel-execution-plan.md` —— 压测数据与协议族清单

49
docs/index.md Normal file
View File

@ -0,0 +1,49 @@
# HomeAgent 插件 SDK
用 **Go** 或 **Lua** 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
<div id="api-search"></div>
## 从这里开始
<div class="grid cards" markdown>
- :material-rocket-launch: **第一次写插件**
装工具链、生成工程、写一个工具、打包成 `.hmap` 装进内核跑起来。
[:octicons-arrow-right-24: 快速开始](guide/getting-started.md)
- :material-book-open-variant: **API 参考**
逐个符号的签名与说明,直接取自源码注释。附示例插件里的真实调用点。
[:octicons-arrow-right-24: 工具(Tools)](api/tools.md)
- :material-shield-lock: **能力边界**
哪些 API 外部插件能用、哪些仅内置插件可用,以及为什么。**先看这个能省很多时间。**
[:octicons-arrow-right-24: 能力边界](guide/capability-boundary.md)
- :material-code-braces: **示例插件**
`example/` 下有多个真实可编译的插件,覆盖常见形态。
[:octicons-arrow-right-24: 示例总览](examples/index.md)
</div>
## 许可
SDK 以 **MIT** 发布 —— 插件作者可**自由选择自己的许可**(闭源、商业、私有均可),
不必同许可、也不必回馈。原因:SDK 会随插件一起静态链接(源码进入插件二进制),
若用传染性许可,插件作者就被强制开源;MIT 让第三方插件生态不必承担这个代价。
内核本身是 **AGPL-3.0-only**,但那是内核的许可,与外部插件无关 ——
SDK 完全自包含(`go.mod` 零外部依赖,只依赖 Go 标准库),不引用内核任何代码。
## 版本
本文档站的 API 参考从源码生成,对应 SDK 版本见 [版本与兼容](versions.md)。

View File

@ -0,0 +1,264 @@
/*
* API 即时检索。
*
* 为什么要自建:Material 内置搜索按「整页文本」建索引,搜 `InjectText`
* 会把所有提到它的页面都列出来,但**分不清哪一条是它的定义**;而且内置
* 索引要等 mkdocs build 才生成,改一行 API 也得重建。
*
* 这里读的是 `assets/api-index.json`——由 tools/apidoc/gensite 直接产出,
* 每条记录带 名称/签名/描述/类别/所属页面/是否仅内置/源文件:行号。
* 因此可以做到:
* - 按名称搜(精确/前缀优先)
* - 按描述搜(中文按字、英文按词,都对 API 的文档注释做匹配)
* - 按签名搜(如 "(string) error")
* - 过滤「仅内置」——外部插件作者最容易被这个绊住
*
* 设计取舍:纯前端、零依赖、不阻塞页面。索引 ~130 条、约 40KB,一次拉取足够。
*/
(function () {
"use strict";
var INDEX_URL = (function () {
// 文档站可能部署在子路径下,按当前页面深度回推到站点根。
var path = window.location.pathname;
var marker = "/api/";
var i = path.indexOf(marker);
if (i >= 0) return path.slice(0, i) + "/assets/api-index.json";
// guide/ 等目录同样回退一层。
var lastSlash = path.lastIndexOf("/");
return path.slice(0, lastSlash) + "/assets/api-index.json";
})();
var state = { all: [], loaded: false, loading: false };
function load() {
if (state.loaded || state.loading) return Promise.resolve(state.all);
state.loading = true;
return fetch(INDEX_URL)
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (data) {
state.all = data || [];
state.loaded = true;
return state.all;
})
.catch(function () {
state.all = [];
return [];
});
}
/* ---------- 打分 ---------- */
//
// 三级优先级:名称命中 > 描述命中 > 签名命中。
// 名称命中里再分「完全相等 / 前缀 / 子串」,因为用户敲 `InjectText` 时
// 想要的是那个符号,不是所有名字里含它的。
function score(item, q) {
var name = (item.n || "").toLowerCase();
var ql = q.toLowerCase();
var s = 0;
if (name === ql) s += 1000;
else if (name.indexOf(ql) === 0) s += 600;
else if (name.indexOf(ql) > 0) s += 350;
// 中文检索关键词(keywords.json 产出,字段 g)。
// 为什么需要:SDK 里 66/100 个符号是英文注释(`RegisterTool registers a
// tool that the LLM can call.`),懂中文的人搜「注册工具」会一条都找不到。
// 关键词命中给较高权重(仅次于名称精确命中),因为它就是为「按功能找」准备的。
var kws = item.g || [];
for (var ki = 0; ki < kws.length; ki++) {
var kw = String(kws[ki]).toLowerCase();
if (kw === ql) { s += 480; break; }
if (kw.indexOf(ql) >= 0) { s += 300; break; }
}
// 限定符:PluginSDK.RegisterTool / IOInjector.InjectText。
// 额外支持「去掉 API/SDK 后缀」与「去掉点号」两种写法,
// 因为读者习惯写 `memory.recall`(RPC 名),而 Go 名是 `MemoryAPI.Recall`。
var qual = ((item.r || "") + "." + name).toLowerCase();
if (item.r && qual.indexOf(ql) >= 0) s += 200;
if (item.r) {
var flat = qual.replace(/[._]/g, "").replace(/apis?dk|sdk|api/g, "");
var qflat = ql.replace(/[._\s]/g, "");
if (qflat && flat.indexOf(qflat) >= 0) s += 180;
}
var desc = (item.d || "").toLowerCase();
if (desc.indexOf(ql) >= 0) s += 120;
// 签名按 token 匹配:把查询拆词(去掉括号/逗号等标点),全部命中才算。
// 这样 `(string) error`、`ContentBlock 媒体` 这类片段都能搜到。
// 注意必须先去标点:否则 token `(string)` 永远匹配不到签名里的 `string`。
var sig = (item.s || "").toLowerCase();
if (sig.indexOf(ql) >= 0) s += 60;
var toks = ql
.replace(/[()\[\]{},;:]/g, " ")
.split(/\s+/)
.filter(function (t) { return t.length > 1; });
if (toks.length && sig.length) {
var allSig = toks.every(function (t) { return sig.indexOf(t) >= 0; });
if (allSig) s += 55;
}
// 中文按字匹配:中文没有词边界,逐字命中比整串更实用。
// 注意只把它当作**弱信号**:光靠逐字会把「注册工具」匹到凡是含「工具」
// 字样的任何东西(实测 ContextPolicyNone 的说明里有「工具调用」也会命中)。
// 所以阈值卡在 60% 以上才算有效命中。
if (/[\u4e00-\u9fa5]/.test(q)) {
var hit = 0;
var hay = desc + " " + kws.join(" ");
for (var i = 0; i < q.length; i++) {
if (hay.indexOf(q[i]) >= 0) hit++;
}
var ratio = hit / q.length;
if (ratio >= 0.6) s += Math.round(hit * 6);
}
// 公开 API 略优先于「仅内置」——后者通常是噪声。
if (s > 0 && !item.b) s += 15;
return s;
}
function search(q) {
var qq = (q || "").trim();
if (!qq) return [];
var out = [];
for (var i = 0; i < state.all.length; i++) {
var sc = score(state.all[i], qq);
if (sc > 0) out.push({ item: state.all[i], score: sc });
}
out.sort(function (a, b) {
if (b.score !== a.score) return b.score - a.score;
return (a.item.n || "").length - (b.item.n || "").length;
});
return out;
}
/* ---------- 渲染 ---------- */
//
// 挂在 Material 首页/目录页的一个容器上:#api-search。
// 没找到容器就不做任何事——这样同一份 JS 可以安全地全站引入。
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function render(mount, q) {
mount.innerHTML = "";
if (!q.trim()) {
mount.appendChild(el("p", "api-hint",
"输入 API 名称、描述或签名片段。例:InjectText、注册工具、崩溃、memory.recall、ContentBlock"));
return;
}
var results = search(q);
if (!results.length) {
mount.appendChild(el("p", "api-hint", "没有匹配的 API。试试更短的词,或按功能描述搜(如「注入」「重载」)。"));
return;
}
var head = el("p", "api-count", "命中 " + results.length + " 个 API");
mount.appendChild(head);
var list = el("ul", "api-results");
results.slice(0, 40).forEach(function (r) {
var it = r.item;
var li = el("li", "api-result");
var title = el("a", "api-name", (it.r ? it.r + "." : "") + it.n);
// 锚点必须用**完整标题文本**(`PluginSDK.InjectText`,点号被 slug 丢掉),
// 不是裸方法名 —— 否则跳到页面顶部而到不了那一条。
title.href = pageURL(it.p) + "#" + anchorOf((it.r ? it.r + "." : "") + it.n);
li.appendChild(title);
if (it.b) {
var badge = el("span", "api-badge api-badge-builtin", "仅内置");
badge.title = "外部(第三方)插件运行时拿不到这个 API";
li.appendChild(badge);
}
li.appendChild(el("code", "api-sig", it.s || ""));
if (it.d) {
var d = el("span", "api-desc", it.d);
li.appendChild(d);
}
// 关键词是给检索用的;显示出来能让读者明白“为什么这条被匹配到”。
if (it.g && it.g.length) {
li.appendChild(el("span", "api-kw", it.g.slice(0, 6).join(" · ")));
}
if (it.f) {
li.appendChild(el("span", "api-loc", it.f + (it.l ? ":" + it.l : "")));
}
list.appendChild(li);
});
mount.appendChild(list);
}
function pageURL(page) {
if (!page) return "#";
// 所有 API 章节都在 /api/ 下(生成物),示例页在 /examples/。
// 从当前 URL 回推到站点根,保证部署在子路径下也能用。
var path = window.location.pathname;
var i = path.indexOf("/api/");
var root;
if (i >= 0) {
root = path.slice(0, i + 1);
} else {
var j = path.indexOf("/guide/");
if (j >= 0) root = path.slice(0, j + 1);
else if (path.indexOf("/examples/") >= 0) root = path.slice(0, path.indexOf("/examples/") + 1);
else root = path.slice(0, path.lastIndexOf("/") + 1);
}
var dir = page === "examples" ? "examples" : "api";
return root + dir + "/" + page + "/";
}
// anchorOf 复现 MkDocs 的 slug:小写、去掉非 [a-z0-9_-] 的字符(点号被去掉)、
// 下划线保留、空格转连字符。
function anchorOf(name) {
return String(name)
.toLowerCase()
.replace(/[^a-z0-9_ -]/g, "")
.replace(/\s+/g, "-");
}
function mount() {
var box = document.getElementById("api-search");
if (!box) return;
var input = el("input", "api-input");
input.type = "search";
input.placeholder = "搜索 API:名称、描述、签名…";
input.setAttribute("autocomplete", "off");
input.setAttribute("spellcheck", "false");
var out = el("div", "api-output");
box.appendChild(input);
box.appendChild(out);
load().then(function () {
render(out, "");
input.addEventListener("input", function () {
render(out, input.value);
});
});
// 支持 ?q= 直达(可从别处链接到一次检索)。
var m = /[?&]q=([^&]+)/.exec(window.location.search);
if (m) {
input.value = decodeURIComponent(m[1].replace(/\+/g, " "));
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mount);
} else {
mount();
}
})();

36
docs/llms.txt Normal file
View File

@ -0,0 +1,36 @@
# HomeAgent 插件 SDK
> 用 Go 或 Lua 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
> 注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
>
> SDK 以 MIT 发布(插件可闭源、可商用,无需回馈)。内核本身是 AGPL-3.0-only。
>
> 本文件是给 agent 的入口。下列每个链接都是**纯 Markdown 正文**,可直接读,
> 不含 HTML 样板;也可以直接取 https://sdk.homeagent.jianfgit.xyz/llms-full.txt 一次读完全部文档。
- [HomeAgent 插件 SDK](https://sdk.homeagent.jianfgit.xyz/index.md): 用 Go 或 Lua 为 HomeAgent 编写插件
- [桥接装配点(Bridge)](https://sdk.homeagent.jianfgit.xyz/api/bridge.md): 以下方法不是给插件业务代码调的——它们由 hmapdev 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例
- [仅内置插件可用的 API](https://sdk.homeagent.jianfgit.xyz/api/builtin-only.md): 这些 API 存在于公开 SDK 包里,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级
- [输入 / 输出通道](https://sdk.homeagent.jianfgit.xyz/api/channels.md): 通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口
- [常量与枚举](https://sdk.homeagent.jianfgit.xyz/api/constants.md): SDK 里的取值枚举
- [事件(Events)](https://sdk.homeagent.jianfgit.xyz/api/events.md): 订阅内核事件
- [API 参考](https://sdk.homeagent.jianfgit.xyz/api/index.md): 本页所有内容从源码生成(tools/apidoc),签名与说明直接取自 sdk/*
- [生命周期(Lifecycle)](https://sdk.homeagent.jianfgit.xyz/api/lifecycle.md): 插件的启动、停止与卸载回调
- [LLM 调用](https://sdk.homeagent.jianfgit.xyz/api/llm.md): 让插件自己调用模型(而不是只等模型来调你)
- [记忆(Memory)](https://sdk.homeagent.jianfgit.xyz/api/memory.md): 三层记忆的读写接口:图记忆(三元组关系)、文档记忆(带元数据的文档)、文本记忆(事件流水)
- [其他类型](https://sdk.homeagent.jianfgit.xyz/api/misc.md): 剩余的类型与方法:PluginSDK 本体的访问器、StageContext 的并发控制,以及多模态辅助类型
- [配置(Settings)](https://sdk.homeagent.jianfgit.xyz/api/settings.md): 声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表
- [阶段钩子(Stages)](https://sdk.homeagent.jianfgit.xyz/api/stages.md): 在消息处理管道的固定点位插入自己的逻辑
- [工具(Tools)](https://sdk.homeagent.jianfgit.xyz/api/tools.md): 注册 LLM 可调用的工具
- [示例插件](https://sdk.homeagent.jianfgit.xyz/examples/index.md): SDK 仓 example/ 下有多个真实可编译的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态
- [能力边界:哪些 API 外部插件能用](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary.md): HomeAgent 有两类插件:
- [第一个 Lua 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-lua-plugin.md): Lua 插件适合轻量、快速原型:不需要 Go 编译环境,改完重启内核即可生效
- [第一个 Go 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-plugin.md): 以下是一个能直接跑起来的最小插件:注册一个工具、声明一项配置、处理停止与卸载
- [环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started.md): hmapdev 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
- [多平台构建](https://sdk.homeagent.jianfgit.xyz/guide/multi-platform.md): hmapdev build 默认 bundle 模式,一次产出含三个平台的单个
- [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging.md): hmapdev build 一次完成编译与打包,产出
- [工具并发声明:`ParallelSafe` / `Serial`](https://sdk.homeagent.jianfgit.xyz/guide/parallel-tool-declaration.md): > 对应 sdk
- [场景记忆(Scene Memory)](https://sdk.homeagent.jianfgit.xyz/guide/scene-memory.md): > 场景式记忆是内核 v1
- [受限 SDK 与安全](https://sdk.homeagent.jianfgit.xyz/guide/security.md): 外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层安全边界:
- [流式多 `tool_call`:适配器必须透传 `index`](https://sdk.homeagent.jianfgit.xyz/guide/stream-tool-call-index.md): > 面向在 Lua 里写适配器(transform_stream_chunk)的插件作者
- [版本与兼容](https://sdk.homeagent.jianfgit.xyz/versions.md): SDK 版本跟随内核的中版本,patch 位恒为

123
docs/stylesheets/extra.css Normal file
View File

@ -0,0 +1,123 @@
/* API 即时检索与文档站的少量本地样式。
只补 Material 没覆盖的部分,不覆盖主题变量(保持深浅色自动适配)。 */
#api-search {
margin: 1.2rem 0 2rem;
}
.api-input {
width: 100%;
padding: 0.7rem 0.9rem;
font-size: 1rem;
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.3rem;
background: var(--md-default-bg-color);
color: var(--md-default-fg-color);
}
.api-input:focus {
outline: 2px solid var(--md-accent-fg-color);
outline-offset: 1px;
border-color: transparent;
}
.api-hint {
color: var(--md-default-fg-color--light);
font-size: 0.8rem;
margin: 0.6rem 0 0;
}
.api-count {
color: var(--md-default-fg-color--light);
font-size: 0.75rem;
margin: 0.8rem 0 0.4rem;
}
.api-results {
list-style: none;
margin: 0;
padding: 0;
}
.api-result {
padding: 0.55rem 0.6rem;
border-left: 3px solid var(--md-primary-fg-color);
margin-bottom: 0.4rem;
background: var(--md-code-bg-color);
border-radius: 0 0.2rem 0.2rem 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.45rem;
}
.api-name {
font-family: var(--md-code-font-family, monospace);
font-weight: 700;
font-size: 0.85rem;
}
.api-sig {
font-size: 0.72rem;
color: var(--md-default-fg-color--light);
background: none;
padding: 0;
overflow-wrap: anywhere;
}
.api-desc {
flex-basis: 100%;
font-size: 0.78rem;
color: var(--md-default-fg-color--light);
}
/* 「为何命中」的中文关键词(keywords.json 产出)。 */
.api-kw {
flex-basis: 100%;
font-size: 0.7rem;
color: var(--md-accent-fg-color);
opacity: 0.9;
}
.api-loc {
flex-basis: 100%;
font-size: 0.68rem;
color: var(--md-default-fg-color--lighter, var(--md-default-fg-color--light));
font-family: var(--md-code-font-family, monospace);
}
.api-badge {
font-size: 0.62rem;
padding: 0.08rem 0.34rem;
border-radius: 0.6rem;
font-weight: 600;
white-space: nowrap;
}
.api-badge-builtin {
background: rgba(245, 158, 11, 0.18);
color: #b45309;
border: 1px solid rgba(245, 158, 11, 0.5);
}
[data-md-color-scheme="slate"] .api-badge-builtin {
color: #fbbf24;
}
/* 「仅内置」告警块里的依据说明通常很长,窄屏下允许更小字号。 */
@media screen and (max-width: 44.98em) {
.api-sig { font-size: 0.68rem; }
}
/* 签名与长类型定义可能超出正文宽度(如 ContentBlock 的字段列表)。
highlight 代码块默认不换行,靠横向滚动;把默认改为换行显示,
因为文档读者更希望一眼看全签名而不是拖滚动条。 */
.md-typeset pre > code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* 接口方法表里的签名可能很长,允许在任意位置折行。 */
.md-typeset table code {
overflow-wrap: anywhere;
}

285
docs/versions.md Normal file
View File

@ -0,0 +1,285 @@
# 版本与兼容
## SDK 版本语义
**SDK 版本跟随内核的中版本,patch 位恒为 `.0`。**
整条内核 `1.1.x` 线(1.1.0、1.1.1、1.1.7…)共用 **SDK 1.1.0**;
只有内核进入 `1.2.0` 这种中版本跃迁时,SDK 才升到 1.2.0。
这样插件作者只需关心「我在为哪个中版本写插件」,不必跟着内核的每个 bugfix 换依赖。
当前内核声明的兼容上限是 **SDK 1.3.0**。
## 版本历史
| SDK | 内核 | 变化 | 需要重编? |
|---|---|---|---|
| **1.3.0** | 1.4.0+ | 驻留子 agent、`RecallPolicy` 等 | 想用新 API 才需要 |
| **1.2.0** | 1.2.0 / 1.3.x | `InjectOptions{NoMemory, ContextPolicy}`、六个 `*Opts` 变体、`ChannelDef.ContextPolicy` | 不需要 |
| **1.1.0** | 1.1.x | 多模态贯通:`Triple.SentenceText`、`Doc.Attachments`、`MediaAttachment`、`InsertWithMedia`、媒体注入方法 | 不需要 |
| **1.0.0** | 1.0.0+ | **运行模型变更**:C ABI 动态库 → 子进程 + 共享内存 | **需要** |
### 1.0.0 是唯一一次破坏性变更
- `.so` / `.dylib` / `.dll` **不再被加载**,遇到旧产物会跳过并报可操作错误(不崩溃)。
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev` 重编即可。
- 产物从 `plugin.so` 变为 `plugin.bin`;不再需要 cgo。
### 1.1.0 / 1.2.0 是纯追加
两次都是**新增方法由插件调用、内核实现**,不调就不受影响。
零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**;
想用新字段的重编即可。
### SDK 1.4.0 → 本轮新增(PluginMgr 运行态启停)⚠️ 有一个例外
本轮给 `PluginMgrAPI` 新增 3 个方法(`StopPlugin` / `StartPlugin` /
`IsPluginRuntimeStopped`,见 `docs/zh/kernel-modularization-design.md` §2.6.7)。
按上面的规则,这属于"**插件调用、内核实现**"的纯追加 ⇒ 无需重编。**但有一个例外**:
| 对象 | 是否需要动 | 原因 |
|---|---|---|
| **已编译的存量插件**(`plugin.bin`) | ❌ 不需要 | 不调新方法就不受影响(与 1.1/1.2 一致) |
| **存量插件的源码要重编** | ⚠️ **需要同步 hmapdev** | `proc_main.go.tmpl` 里的 `procPluginMgr` 必须实现新方法,否则**编译不过** |
| **新插件** | ✅ 自动 | 模板已同步(`hmapdev` 已更新) |
!!! warning "为什么这次和 1.1/1.2 不一样"
1.1/1.2 只改了 `sdk/`(插件**调用**的一侧)与内核实现。
本轮还改了 **`tools/hmapdev/templates/proc_main.go.tmpl`** ——
那是**生成插件代码**的模板,属于"插件**被编译**的一侧"。
`plugin.bin` 里的 `procPluginMgr` 是**编译期**由模板生成的具体类型,
它必须满足 `PluginMgrAPI` 的**全部**方法(Go 接口是精确匹配)。
模板不同步 ⇒ 生成物不满足接口 ⇒ **编译失败**(不是运行期降级)。
⇒ 判据 `internal/plugin/proc/bridge_sync_test.go` 已有 3 条自动守这个
(SDK 方法集 ⇒ 能力表 / Handle 分派 / hmapdev 模板,三处必须同步)。
## 脚手架模板的协议通道纪律(`hmapdev init` 生成物)
子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道**
(SDK 侧 `log.SetOutput(os.Stderr)`)。这条纪律**必须写进脚手架**,
否则每个新插件作者都会踩。
### 曾踩的坑(2026-10-16 实测)
`hmapdev init` 生成的 `plugin.go` 原先用 `fmt.Printf` 打日志 ⇒ 写进 stdout
⇒ 内核 readLoop 收到非 JSON 文本:
```
[proc] final_probe 收到非法 JSON 帧(21 字节): invalid character 'i' in literal false
```
两个后果叠加、都很难定位:
1. 那句日志**丢了**(内核把它当坏帧丢弃);
2. 多了一条看起来像**协议故障**的报错 —— 而真因是"插件自己 printf"。
**A/B 实证**(同一内核,两个插件):
| 插件 | 脚手架 | 非法 JSON 帧 | 日志可见 |
|---|---|---|---|
| `final_probe` | 旧(`fmt.Printf`) | **1 次** | ❌ 丢失 |
| `fixed_probe` | 新(`log.Printf`) | **0 次** | ✅ `[fixed_probe] started` |
### 修法
模板改成 `log.Printf`,并把这条纪律写进生成物的**头部注释**
(让每个插件作者第一眼就看到)。
### 判据(`tools/hmapdev/scaffold_stdout_test.go`,3 条)
1. `TestScaffoldTemplateDoesNotWriteStdout` —— **代码行**里不得有
`fmt.Printf/Println/Print/os.Stdout`
2. `TestScaffoldTemplateUsesStderrLogging` —— 反向钉住:光禁 stdout 不够,
把日志全删也能过第一条 ⇒ 必须**真的**用 `log.Printf`(并 import "log")
3. `TestScaffoldLuaTemplateDoesNotWriteStdout` —— Lua 脚手架同理
**变异 2/2 咬住**:改回 `fmt.Printf` ⇒ 红;删光日志 ⇒ 红。
!!! warning "判据自身踩的坑(值得记住)"
第一版判据直接扫描模板字符串 ⇒ **被模板里的注释判红**(注释**故意**
提到 `fmt.Printf` 来讲这个坑)。必须**先剥注释**再扫代码行。
这是"源码扫描判据"的固有脆弱性:本仓规范要求把坑写进注释,
而注释里必然出现那些符号名 ⇒ **任何时候用字符串扫描判据,
都要先想"注释里会不会也有这个串"**(同类事故见
`docs/zh/kernel-modularization-design.md` §8.6,那里连续骗过三次)。
## 示例插件的协议通道纪律(已修)
上面的脚手架纪律同样适用于**示例插件** —— 它们是**子进程插件**
(`package main` + `plugin.bin`),stdout 即协议通道。
### 生产环境实测的缺陷(不是理论问题)
`journalctl -u homeagent | grep -c "非法 JSON 帧"` ⇒ **1038 条**,来源:
| 插件 | 次数 | 本仓? |
|---|---|---|
| `huawei_smarthome` | 720 | 第三方(内核侧报错就是为它准备的) |
| **`weather`** | 113 | ✅ 示例 |
| **`rss`** | 103 | ✅ 示例 |
| **`calendar`** | 102 | ✅ 示例 |
**根因**:这三个示例插件(以及 `ai_image`)用了 `fmt.Printf` 打日志 ⇒
裸文本进入 stdout 协议流 ⇒ 内核报非法帧并**丢弃该日志**。
### 修法(已完成)
4 个插件共 **14 处** `fmt.Printf` → `log.Printf`,并补 `"log"` import
(`calendar`/`rss`/`weather` 原先没 import log;`ai_image` 已有)。
★ 注意:`internal/plugins/cli` 里也有一处 `fmt.Println`,但它是
**进程内插件**(`plugin.RegisterFactory`,编译进内核),
写的是内核的真实终端 ⇒ **不是缺陷**。判据只针对"子进程插件"
(`package main`)。
### 判据(`tools/hmapdev/example_stdout_test.go`,2 条)
1. `TestExamplePluginsDoNotWriteStdout` —— 扫描全部**子进程**示例插件
(实测 20 个),剥注释后不得含 `fmt.Printf/Println/Print/os.Stdout`;
报错时给出**文件:行号**
2. `TestExamplePluginsUseStderrLogging` —— 反向钉住:至少 weather/rss/calendar
真的在用 `log.Printf`(否则"删光日志"也能过第一条)
### 内核侧:让非法帧报错**可定位**(这是对第三方插件的通用修复)
原报错只有 `收到非法 JSON 帧(N 字节): invalid character 'x'`:
不显示内容(不知是哪句话)、不提原因(看起来像内核故障)。
1038 条报错的定位花了一整轮排查 —— 就因为缺这两样。
现在报错含**三要素**:
```
[proc] bad_probe 收到非法 JSON 帧(19 字节): invalid character 'b' looking for beginning of value
↑ 内容摘要: [bad_probe] started
可能原因:插件往 **stdout** 打印了日志,而 stdout 是JSON-RPC **协议通道**(stderr 才是日志通道)。
修法:把插件里的 fmt.Printf/fmt.Println 改成 log.Printf(脚手架模板 1.4.0 起已修正)。
```
(`summarizeFrame` 做控制字符替换 + 截断,保证单行且不刷屏。)
判据在 `internal/plugin/proc/process_test.go`:
`TestSummarizeFrameIsReadable`、`TestIllegalFrameErrorIsActionable`。
**变异 2/2 咬住**(去掉 summarizeFrame 调用 ⇒ 红;报错不提原因 ⇒ 红)。
## 升级已装的 SDK store 与 hmapdev 二进制(操作步骤)
!!! danger "顺序不能反 —— 否则会装出「二进制新、store 旧」的组合"
**模板是 `//go:embed` 进 hmapdev 二进制的**(`tools/hmapdev/proc_runtime.go`),
而 **SDK 接口(`sdk/`)来自 store**。二者必须**同代**:
| 组合 | 结果 |
|---|---|
| 旧二进制 + 旧 store | 自洽(但拿不到新能力) |
| 新二进制 + 新 store | ✅ 自洽 |
| **新二进制 + 旧 store** | ⚠️ 模板的 `procPluginMgr` 有 6 个方法,旧接口只有 3 个 ⇒ 生成物**多出 3 个方法**(Go 允许,但新方法调用的 `plugin.mgrStop` 内核可能不认) |
| **旧二进制 + 新 store** | ❌ 模板 3 个方法 vs 接口 6 个 ⇒ 生成物**不满足接口** ⇒ **编译失败** |
⇒ **先升级 store,再升级二进制**(或两者同一批做完)。
### 步骤(本机,用源码)
```bash
# 1) 用**当前**hmapdev 把本机源码装成 store(--from 在旧版也支持)
hmapdev sdk install --from <SDK 源码目录> # 例:third_party/homeagent-sdk
# 2) 重编 hmapdev 二进制(模板 embed 进去了,必须重编)
cd <SDK 源码>/tools/hmapdev
CGO_ENABLED=1 go build -tags onnxruntime -o /tmp/hmapdev-new . # TAG_ARGS 视内核而定
# 3) 原子替换(先备份!)
cp /usr/local/bin/hmapdev /usr/local/bin/hmapdev.bak.$(date +%Y%m%d-%H%M%S)
install -m 0755 /tmp/hmapdev-new /usr/local/bin/hmapdev
```
### 验证(三步都要过)
```bash
hmapdev --version # 版本号
strings /usr/local/bin/hmapdev | grep -c mgrStop # ≥1 ⇒ 新模板已 embed
diff <源码>/tools/hmapdev/templates/proc_main.go.tmpl \
~/.homeagent/hmapdev/sdk/<ver>/tools/hmapdev/templates/proc_main.go.tmpl # 必须无差异
```
★ **`diff` 陷阱**:本机 PATH 上的 `diff` 是 HarmonyOS 工具链里的一个
**坏二进制**(`/opt/huawei/harmonyos/ohos-sdk/linux/toolchains/diff`),
对**不同**的文件也返回 **exit 0 且无输出** ⇒ 会给出"已同步"的**假结论**。
用 `cmp` 或 Python 比对,或显式用 `/usr/bin/diff`。
### 判据(自动守"三处同步")
`internal/plugin/proc/bridge_sync_test.go` 有 3 条,任一不同步即红:
1. `TestBridgeCoversEveryPublishedPluginMgrMethod` —— SDK 方法集 ↔ 能力表
2. `TestBridgeDispatchesEveryPluginMethod` —— 内核 Handle 分派
3. `TestHmapdevTemplateImplementsAllPluginMgrMethods` —— **hmapdev 模板**
第 3 条直接读 `third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl`,
所以**源码模板**始终被守;store 里的副本靠上面的 `diff` 验证。
!!! tip "什么时候必须重编"
只有一种情况:**内核跨了主版本**(如 1.x → 2.x)。
主版本跃迁是破坏性变更的定义,此后的帧格式与共享段布局都不兼容,
必须用配套 `hmapdev` 重编。
**中版本跃迁(如 1.1 → 1.2)不需要重编**:1.x 内的新增全部是
「插件调用、内核实现」,不调新面就不受影响。
## 协议兼容性:只认主版本号
**没有独立的协议版本号。** 兼容性由双方版本号的**主版本段**表态:
内核的 `meta.Version` 与插件的 `sdk_version`(插件包里的 `sdk_version` 字段,
即编译时链接的 SDK 版本)。
| 内核 | 插件 SDK | 结果 |
|---|---|---|
| 1.4.0 | 1.0.0 / 1.1.0 / 1.3.0 | ✅ 建链(同主版本) |
| 1.4.0 | 2.0.0 | ❌ 拒绝(跨主版本) |
**为什么不设独立的协议号**:语义版本变动频繁(修 bug、加字段),如果每个
这类改动都要人去升一个额外的号,那个号必然会被漏升而腐烂(历史实证:
曾有一个 `ProtocolVersion` 常量,多次布局变更里只升过一次,而对应的守卫
测试长期处于失败状态、无人察觉)。让兼容性只挂在**始终会被发的那个号**上,
就没有「忘同步」这个失效模式。
跨主版本错配**不会静默失效**——握手时会显式拦下,并提示用配套 `hmapdev` 重编。
此外握手还会比对 `field_count`(StageContext 描述符个数):它决定标志位基址,
不一致会让插件读到错位字段,因此也在握手上显式拒绝。
## 怎么确认自己在用什么
装的 SDK 版本:
```bash
hmapdev sdk current
hmapdev sdk list
```
插件声明的目标版本在 `plg.json` 的 `sdk` 字段。若该版本不在本地存储里,
`hmapdev` 会**明确报错**,不静默降级 —— 静默降级会产出与内核协议不匹配的包,
那种失败要到运行时才暴露。
## 文档站对应的版本
本页与 [API 参考](api/index.md) 由 `tools/apidoc` 从源码生成,
内容随源码一起演进。发现文档与代码不一致时,**改的是源码注释**,
`go run ./tools/apidoc` 重新生成即可(见下方「维护」)。
## 维护(给 SDK 维护者)
```bash
cd homeagent-sdk
go run ./tools/apidoc -pkgdir ./sdk -out /tmp/api.json
go run ./tools/apidoc/gensite -api /tmp/api.json -out ./docs -examples ./example
mkdocs serve # 本地预览
mkdocs build # 产出 site_build/
```
API 面的**能力分层**(哪些 API 仅内置可用)记在 `tools/apidoc/tiers.json`,
每条裁定都附源码依据 —— 改这里而不是改生成物。

47
example/a2a/README.md Normal file
View File

@ -0,0 +1,47 @@
# a2a · Agent-to-Agent 通信
让本 Agent 与其他 Agent **双向互调**:既能对外暴露自己的能力,也能去问别的 Agent。
## 两个方向
| 方向 | 怎么实现 |
|---|---|
| **入站**(别人问我) | 插件起一个 HTTP 服务端,暴露 `/agent-card`(能力描述)与 `/a2a`(JSON-RPC 入口) |
| **出站**(我问别人) | 提供 `a2a_query` / `a2a_discover` 工具,主动向远端 A2A Agent 发起请求 |
## HTTP 端点
| 路径 | 作用 |
|---|---|
| `GET /agent-card` | 返回 Agent Card:本 Agent 的能力描述,供对方发现 |
| `POST /a2a` | JSON-RPC 2.0 入口,接收对方的任务请求 |
## 工具
| 工具 | 说明 |
|---|---|
| `a2a_a2a_query` | 向另一个 A2A Agent 发查询并取回复 |
| `a2a_a2a_discover` | 取对方的 Agent Card(能力描述) |
| `a2a_a2a_status` | 看本插件运行状态(监听地址、当前配置) |
| `a2a_a2a_configure` | 改配置并自动重启服务(可动态改监听地址) |
| `a2a_a2a_restart` | 重启 HTTP 服务端(连接异常或改配置后用) |
> 工具名前缀取自插件名(`tp`),按默认 `a2a_` 列出。
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `listen` | `127.0.0.1:12000` | 服务端监听地址。**设为空可禁用 HTTP 服务**(只出站、不入站) |
## 典型用法
1. **先发现再调用**:`a2a_discover` 拿对方能力 → 决定要不要发、发什么 → `a2a_query`。
跳过 discovery 直接问,容易问出对方不支持的东西。
2. **只出站**:把 `listen` 设为空,本 Agent 不外露端口,但仍能主动联系别人。
## 构建
```bash
hmapdev build
```

View File

@ -2,6 +2,6 @@ module a2a
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
require github.com/JianFeeeee/homeagentsdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../

View File

@ -1,2 +1,2 @@
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiwm/Ozrm9AOg=
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo=
github.com/JianFeeeee/homeagentsdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiwm/Ozrm9AOg=
github.com/JianFeeeee/homeagentsdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo=

View File

@ -3,7 +3,7 @@
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -2,7 +2,7 @@
"name": "a2a",
"name_zh": "A2A 代理通信",
"name_en": "A2A Agent Communication",
"version": "1.3.0",
"version": "1.3.1",
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
"author": "HomeAgent",
"entry": "plugin.so",

View File

@ -12,7 +12,7 @@ import (
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
type Plugin struct {
@ -24,8 +24,8 @@ type Plugin struct {
// 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
sessMu sync.Mutex
sessions map[string]*a2aSession
sessMu sync.Mutex
sessions map[string]*a2aSession
}
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*a2aSession)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
tp := p.name + "_"
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
@ -66,7 +69,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Key: "listen", Default: "127.0.0.1:12000",
Type: "string", DisplayName: "监听地址",
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
Category: p.name,
Category: p.name,
})
// Outbound: query + discover
@ -75,10 +78,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
},
"required": []string{"agent_url", "query"},
},
@ -287,7 +290,7 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
Query string `json:"query,omitempty"`
SessionID string `json:"session_id,omitempty"`
Limit int `json:"limit,omitempty"`
Message *struct {
Message *struct {
Role string `json:"role"`
Parts []struct {
Text string `json:"text,omitempty"`
@ -347,7 +350,7 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
if sess := p.sessions[sessionID]; sess != nil {
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
if len(sess.History) > maxSessionTurns*2 {
sess.History = sess.History[len(sess.History)-maxSessionTurns*2 :]
sess.History = sess.History[len(sess.History)-maxSessionTurns*2:]
}
sess.LastUsed = time.Now()
}
@ -357,11 +360,11 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]interface{}{
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
"status": "completed",
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
"status": "completed",
"session_id": sessionID,
"message": map[string]interface{}{
"role": "agent",
"role": "agent",
"parts": []map[string]string{{"type": "text", "text": reply}},
},
},
@ -457,10 +460,10 @@ type A2AResponse struct {
}
type A2AResult struct {
TaskID string `json:"id,omitempty"`
Status string `json:"status,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message *A2AMessage `json:"message,omitempty"`
TaskID string `json:"id,omitempty"`
Status string `json:"status,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message *A2AMessage `json:"message,omitempty"`
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
}

51
example/acp/README.md Normal file
View File

@ -0,0 +1,51 @@
# acp · Agent Client Protocol 通信
[ACP](https://agentclientprotocol.com/) 桥接:本 Agent 既能**当服务端**接别人的任务,也能**当客户端**去调别的 ACP Agent。
## 两个方向
| 角色 | 行为 |
|---|---|
| **服务端** | 在本机起 HTTP 服务,处理 `session/new` / `session/update`,接受其他 Agent 的任务请求 |
| **客户端** | 通过 `acp_query` 向远程 ACP Agent 发 `session/new` 并读回复 |
## 协议端点
- `POST /api/session` —— JSON-RPC,支持 `session/new` 与 `session/update`
- 客户端侧同时兼容**两种服务端**:SSE 型(流式 `session/reply`)与同步 JSON 型
## 工具
| 工具 | 说明 |
|---|---|
| `acp_acp_query` | 向远程 ACP Agent 发起会话并等待回复,返回最终回答文本 |
| `acp_acp_status` | 查看运行状态与**当前活跃会话数** |
| `acp_acp_configure` | 改监听配置并重启 HTTP 服务 |
> 工具名前缀取自插件名(`tp`),按默认 `acp_` 列出。
`acp_query` 可指向的远端举例(源码注释给的):
- opencode:`http://127.0.0.1:13000`
- pi bridge:`http://127.0.0.1:12011`
- 回环到自身:`http://127.0.0.1:12001`
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `listen` | `127.0.0.1:12001` | 服务端监听地址。**设为空可禁用 HTTP 服务**(只出站) |
## 与 a2a 的区别
| | a2a | acp |
|---|---|---|
| 面向 | Agent ↔ Agent 对等通信 | 客户端 → Agent 会话(每次一个 session) |
| 会话 | 一问一答 | 有 session 生命周期,可续 |
| 发现 | `/agent-card` | 无(需已知地址) |
## 构建
```bash
hmapdev build
```

View File

@ -2,6 +2,6 @@ module acp
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
require github.com/JianFeeeee/homeagentsdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../

View File

@ -3,7 +3,7 @@
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -2,7 +2,7 @@
"name": "acp",
"name_zh": "ACP 代理通信",
"name_en": "ACP Agent Client Protocol",
"version": "1.2.0",
"version": "1.2.1",
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
"author": "HomeAgent",
"entry": "plugin.so",

View File

@ -13,7 +13,7 @@ import (
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*sessionState)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
tp := p.name + "_"
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点。

View File

@ -1,13 +1,34 @@
# ai_image
# ai_image · 文生图
ai_image plugin
按文字提示生成图片,下载到本地并返回**文件路径**。
## Build
## 工具
| 工具 | 说明 |
|---|---|
| `ai_image_generate` | 按 prompt 生成图片 |
返回值是**本地文件路径**(永久,不过期)。要把图给用户看,再用导出的通道
以 `type=image`、`payload=<该路径>` 发送。
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `api_key` | 空 | OpenAI / Stable Diffusion 的 API Key |
| `base_url` | 空 | 自定义 OpenAI 兼容网关(**不带 `/v1` 尾缀**,如 `http://127.0.0.1:8081`)。留空走官方 `https://api.openai.com` |
| `provider` | `openai` | 服务方:`openai` / `stability` |
| `model` | `dall-e-3` | 模型名(如 `dall-e-3`、`sd-xl`) |
| `size` | `1024x1024` | 默认尺寸,也可 `1024x1792` / `1792x1024` |
## 实现要点
- **返回本地路径而不是远端 URL**:远端图床链接会过期,写进记忆就成了悬空指针。
下载到本地后路径稳定,可交给媒体存储做内容寻址。
- 配了 `base_url` 就能指向自建/兼容网关,不必依赖官方接口。
## 构建
```bash
plugindev build
hmapdev build
```
## Install
Upload the .hmap file through the Plugin Manager API.

View File

@ -2,6 +2,6 @@ module ai_image
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
require github.com/JianFeeeee/homeagentsdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../

View File

@ -3,7 +3,7 @@
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -13,19 +13,19 @@ import (
"strings"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
client *http.Client
apiKey string
name string
sdk *sdk.PluginSDK
client *http.Client
apiKey string
provider string
model string
size string
baseURL string
dataDir string // <data>/ai_images:生成本地图片存放目录
model string
size string
baseURL string
dataDir string // <data>/ai_images:生成本地图片存放目录
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -119,12 +119,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "base_url", Default: "", Type: "string",
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
Category: "ai_image",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "provider", Default: "openai", Type: "string",
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
Category: "ai_image",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "model", Default: "dall-e-3", Type: "string",
@ -143,17 +143,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.size = getSetting(s.Settings(), "size", "1024x1024")
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
if p.sdk != nil {
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
if p.sdk != nil {
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
}
}
}
if p.dataDir == "" {
// 旧版内核无 DataDir API 时退到 /tmp
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
}
os.MkdirAll(p.dataDir, 0755)
if p.dataDir == "" {
// 旧版内核无 DataDir API 时退到 /tmp
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
}
os.MkdirAll(p.dataDir, 0755)
tp := p.name + "_"
s.RegisterTool(tp+"generate", sdk.ToolDef{
@ -170,12 +170,12 @@ os.MkdirAll(p.dataDir, 0755)
},
}, p.handleGenerate)
fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model)
log.Printf("[%s] started (provider=%s, model=%s)", p.name, p.provider, p.model)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}

43
example/bili/README.md Normal file
View File

@ -0,0 +1,43 @@
# bili · B站视频下载
用 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 把 B 站视频下载到本地。
## 前置依赖
需要系统里装有 `yt-dlp`:
```bash
pip install -U yt-dlp # 或 apt install yt-dlp
```
## 工具
| 工具 | 说明 |
|---|---|
| `bili_video` | 下载 B 站视频;不指定 `format` 时先返回可用清晰度列表,指定后真正下载并返回文件路径 |
参数:
| 参数 | 说明 |
|---|---|
| `url` | 视频地址 |
| `format` | 格式 ID。常用:`30112`/`30080`=1080P、`30064`=720P、`30032`=480P、`30016`=360P。不指定则自动选最优 |
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `output_dir` | `/tmp/bili_videos` | 下载目录 |
| `proxy` | 空 | yt-dlp 使用的 HTTP 代理(如 `http://127.0.0.1:7890`)。留空则不设代理 |
## 实现要点
- **`output_dir` 有安全校验**:它是配置项,但会拒绝被配成系统目录,避免 yt-dlp 往任意位置写文件。
- 两阶段用法:先不传 `format` 拿到清晰度清单(`format_id` + `format_note`),再带上选定的 ID 下载。这样模型不会盲选一个不存在的格式。
- B 站在部分网络环境下需要代理,见上面的 `proxy`。
## 构建
```bash
hmapdev build
```

View File

@ -2,6 +2,6 @@ module bili
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
require github.com/JianFeeeee/homeagentsdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../

View File

@ -3,7 +3,7 @@
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -2,21 +2,59 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
proxy string
// runCancel 取消**正在跑**的 yt-dlp;runWG 等它真正退出。
//
// 为何需要:下载是分钟级操作,而 Stop() 必须能把它掐掉。
// 只 cancel 不 wait 的话内核会在插件死后立刻释放共享段,
// 而 yt-dlp 还在往插件的 stdout 写 —— 那正是内核 readLoop 挂死的成因。
runMu sync.Mutex
runCancel context.CancelFunc
runWG sync.WaitGroup
}
// trackRun 登记一次外部命令运行,返回完成时调用 untrack。
func (p *Plugin) trackRun() (context.Context, func()) {
ctx, cancel := context.WithCancel(context.Background())
p.runMu.Lock()
p.runCancel = cancel
p.runWG.Add(1)
p.runMu.Unlock()
return ctx, func() {
p.runWG.Done()
p.runMu.Lock()
p.runCancel = nil
p.runMu.Unlock()
}
}
// killRunGroup 掐掉正在跑的 yt-dlp 及其子进程(ffmpeg 等)。
func (p *Plugin) killRunGroup(pid int) {
if pid <= 0 {
return
}
// 负 pid = 整个进程组(yt-dlp 拉起的 ffmpeg 也在内)
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
}
func (p *Plugin) Name() string { return p.name }
@ -30,13 +68,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Key: "output_dir", Default: "/tmp/bili_videos",
Type: "string", DisplayName: "下载目录",
Description: "B站视频下载后的保存目录",
Category: p.name,
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "proxy", Default: "",
Type: "string", DisplayName: "HTTP 代理",
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置",
Category: p.name,
Category: p.name,
})
if v, _ := s.Settings().Get("proxy"); v != nil {
if str, ok := v.(string); ok {
@ -67,7 +105,24 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
return nil
}
func (p *Plugin) Stop() error { return nil }
// Stop 取消并等待正在跑的下载。
//
// 空实现的代价(实测):yt-dlp 是分钟级操作,Stop 时它还在跑,
// 而它**继承本插件的 stdout**。内核 Kill 掉本插件后,yt-dlp 变孤儿
// 且继续持有管道写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 整个关停挂死
// 直到 systemd 90 秒超时 SIGKILL(线上症状:只有 bili 报
// "SIGKILL 后 2s 仍未被收割",之后近 90 秒无日志)。
func (p *Plugin) Stop() error {
p.runMu.Lock()
cancel := p.runCancel
p.runMu.Unlock()
if cancel != nil {
cancel()
}
// 等它真的退出:不等的话内核会先释放共享段,孙进程仍在写 stdout。
p.runWG.Wait()
return nil
}
type ytdlpFormat struct {
FormatID string `json:"format_id"`
@ -84,11 +139,11 @@ type ytdlpFormat struct {
}
type ytdlpInfo struct {
Title string `json:"title"`
Duration float64 `json:"duration"`
WebpageURL string `json:"webpage_url"`
Filename string `json:"_filename"`
Formats []ytdlpFormat `json:"formats"`
Title string `json:"title"`
Duration float64 `json:"duration"`
WebpageURL string `json:"webpage_url"`
Filename string `json:"_filename"`
Formats []ytdlpFormat `json:"formats"`
}
func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) {
@ -119,11 +174,20 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
var out bytes.Buffer
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
cmd := exec.Command("yt-dlp", ytdlpArgs...)
ctx, done := p.trackRun()
defer done()
// CommandContext:Stop 里的 cancel 能直接掐掉它。
// Setpgid:让 yt-dlp 自成进程组,它再拉的 ffmpeg 也在组内,
// killRunGroup 能一次带走整棵子进程树。
cmd := exec.CommandContext(ctx, "yt-dlp", ytdlpArgs...)
cmd.Stdout = &out
cmd.Stderr = &out
cmd.Env = proxyEnv(p.proxy)
setPgid(cmd)
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("yt-dlp info 已取消(插件停止)")
}
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
}
@ -209,12 +273,16 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
dlArgs = append(dlArgs, "-f", format)
}
dlArgs = append(dlArgs, url)
cmd2 := exec.Command("yt-dlp", dlArgs...)
cmd2 := exec.CommandContext(ctx, "yt-dlp", dlArgs...)
cmd2.Env = proxyEnv(p.proxy)
var dlOut bytes.Buffer
cmd2.Stdout = &dlOut
cmd2.Stderr = &dlOut
setPgid(cmd2)
if err := cmd2.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("下载已取消(插件停止)")
}
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
}
@ -256,6 +324,15 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
}, nil
}
// setPgid 让命令自成进程组:它自己拉的子进程(yt-dlp → ffmpeg)
// 都在同一组里,kill(-pgid) 能一次带走,避免孤儿持有 stdout 管道。
func setPgid(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
func proxyEnv(proxy string) []string {
env := os.Environ()
if proxy != "" {

66
example/browser/README.md Normal file
View File

@ -0,0 +1,66 @@
# browser · 统一浏览器
一个插件覆盖三种"访问网页"的能力,从最轻到最重。**按需选层**是这个插件的重点 ——
绝大多数抓取用 HTTP 就够,不该为了一句话启动 Chromium。
## 三种能力层
| 层 | 工具 | 何时用 |
|---|---|---|
| **搜索** | `browser_search` | 要的是"找到哪些页面",不是页面本身 |
| **quick(纯 HTTP)** | `browser_fetch`(`mode=quick`) | 静态页、API、能直接拿到 HTML |
| **normal(无头渲染)** | `browser_render` / `browser_fetch`(`mode=render`) | JS 渲染的页面,HTTP 拿不到内容 |
| **interactive(CDP)** | `browser_start` + `navigate`/`click`/`type`/`scroll`/`html`/`screenshot` | 需要交互:登录、点按、翻页 |
`browser_fetch` 的 `mode`:
- `auto`(默认):先试 HTTP,**遇 403/429 才降级**用 Chromium 渲染
- `render`:强制 Chromium
- `quick`:纯 HTTP,不降级
## 工具
| 工具 | 说明 |
|---|---|
| `browser_search` | 网页搜索 |
| `browser_fetch` | 抓取 URL 内容,三种 mode 见上 |
| `browser_render` | 无头 Chromium 渲染并提取文本(normal) |
| `browser_start` | 启动交互式浏览器会话(CDP) |
| `browser_navigate` | 导航到指定 URL |
| `browser_click` | 点击元素 |
| `browser_type` | 输入文本 |
| `browser_scroll` | 滚动页面 |
| `browser_html` | 取当前页 HTML |
| `browser_screenshot` | 截图,**保存为 PNG 并返回路径**(拿 path 调 `describe_image` / `ocr_image`) |
| `browser_install` | 安装 systemd 托管的共享浏览器后端 |
| `browser_close` | 关闭会话 |
## 共享浏览器后端
`browser_install` 安装 `homeagent-browser.service`(systemd 托管)。
装上之后**所有 agent 共享同一个 Chromium 实例与登录态**,各自占独立标签页互不干扰
(同 source 复用自己的标签页)。
前提:本机已有 chromium 二进制,没有会提示先装(`apt install chromium` 或等价)。
## 实现要点
- **搜索用 `cn.bing.com` 而不是 `www.bing.com`**:后者对程序化请求常回 302(同意/重定向页),
根本拿不到结果块。
- **标题取 `<h2>` 里的 `<a>`**:直接抓结果块里第一个 `<a>` 会拿到来源行而非标题。
- **摘要认 `b_lineclamp`**:旧版 Bing 用 `b_caption`,新版已迁走,两套都匹配。
- **有 SSRF 防护**:见源码 `SSRF` 段,抓取前校验目标地址,避免被诱导访问内网。
## 测试
```bash
go test -count=1 ./...
```
`testdata/bing_cn.html` 是搜索解析的固定样本,用它做离线断言,避免测试依赖真实网络。
## 构建
```bash
hmapdev build
```

View File

@ -0,0 +1,71 @@
package main
import (
"context"
"github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/remote"
)
// chromedp v0.20 是**泛型重写**(Action[T] 返回值而不填指针),
// 本文件把旧写法收敛成几个小助手,让业务代码的改动面最小。
//
// 为何不直接在每个调用点改:插件里有 20+ 处 `chromedp.Run(ctx, 步骤...)`,
// 逐处改成「Run 取一个值 / Do 跑多步」会让 diff 淹没在机械改写里,
// 真正要审的「行为变了没有」反而看不清。收敛到助手后,业务代码保持原样。
// chromedpDo 依次跑多个不返回值的步骤(旧 Run(ctx, steps...) 的等价物)。
//
// 对应 v0.20 的 chromedp.Do。放在这里是为了让调用点看起来与旧代码一致。
func chromedpDo(ctx context.Context, steps ...chromedp.Action[chromedp.Void]) error {
return chromedp.Do(ctx, steps...)
}
// newRemoteAllocator 连接一个已在运行的 CDP 端点(如共享浏览器后端)。
//
// v0.20 把它移到了独立子模块:chromedp.NewRemoteAllocator → remote.NewAllocator。
// 子模块存在的理由:核心模块现在只用标准库 + cdproto,需要 websocket 的东西
// 全部搬出去(见 chromedp docs/decisions/2026-10-04-...)。
func newRemoteAllocator(parent context.Context, url string) (context.Context, context.CancelFunc) {
return remote.NewAllocator(parent, url)
}
// chromedpTitle 取当前页标题。
func chromedpTitle(sel ...chromedp.QueryOption) chromedp.Action[string] {
return chromedp.Title()
}
// chromedpLocation 取当前页 URL。
func chromedpLocation() chromedp.Action[string] {
return chromedp.Location()
}
// chromedpOuterHTML 取元素的外部 HTML(旧写法传 &html 接收,现在直接返回)。
func chromedpOuterHTML(sel string, opts ...chromedp.QueryOption) chromedp.Action[string] {
return chromedp.OuterHTML(sel, opts...)
}
// chromedpScreenshot 截图(旧写法传 &buf 接收,现在直接返回)。
func chromedpScreenshot(sel string, opts ...chromedp.QueryOption) chromedp.Action[[]byte] {
return chromedp.Screenshot(sel, opts...)
}
// chromedpFullScreenshot 整页截图。
func chromedpFullScreenshot(quality int) chromedp.Action[[]byte] {
return chromedp.FullScreenshot(quality)
}
// chromedpCaptureScreenshot 截当前**视口**。
//
// ★ 为何不用 chromedp.Screenshot("body")(2026-10-11):后者内部是
// QueryAfter + NodeVisible,而 NodeVisible 在本机的 headless Chromium 上
// 永远不会满足 —— 实测会一直挂到调用方超时(生产表现为 60s 工具超时)。
// 详见 plugin.go 里 handleScreenshot 的注释(含完整对照表)。
func chromedpCaptureScreenshot() chromedp.Action[[]byte] {
return chromedp.CaptureScreenshot()
}
// chromedpEvaluate 执行 JS(返回 Void 表示不关心返回值)。
func chromedpEvaluateVoid(expr string) chromedp.Action[chromedp.Void] {
return chromedp.Evaluate[chromedp.Void](expr, nil)
}

View File

@ -0,0 +1,107 @@
package main
import (
"os"
"strings"
"testing"
)
// 视口截图**必须**走 CaptureScreenshot,不能用 Screenshot(sel)。
//
// # 为什么这条判据存在(2026-10-11 实测)
//
// chromedp 的 Screenshot(sel) 内部是 QueryAfter + **NodeVisible**,
// 而 NodeVisible 在本机的 headless Chromium 上**永远不会满足** —— 它先
// 用 dom.GetBoxModel 拿盒子(实测 300µs 就成功),再用 visibleJS 判定
// (实测返回 true,offsetWidth=480),两步都对,但它仍一直等下去。
//
// 同一后端上的完整对照(实测):
//
// Do(Query body) ✓ Do(WaitReady body) ✓
// Do(WaitVisible body) ✗挂住 Do(WaitVisible html) ✗挂住
// Run(Screenshot html) ✗挂住 Run(FullScreenshot) ✓
// Run(CaptureScreenshot) ✓
//
// 且**旧版 chromedp v0.9.5 同样挂** —— 这不是升级引入的,是既有缺陷。
// 生产日志佐证:成功的都是 agent 传了 full:true(走 FullScreenshot),
// 超时的都是默认分支(走 Screenshot("body"))。
//
// 所以这条判据守的是:不许把 CaptureScreenshot 改回 Screenshot(sel)。
func TestViewportScreenshotUsesCaptureScreenshot(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "func (p *Plugin) handleScreenshot(")
if i < 0 {
t.Fatal("找不到 handleScreenshot")
}
seg := s[i:]
if j := strings.Index(seg, "\nfunc (p *Plugin) saveScreenshot("); j > 0 {
seg = seg[:j]
}
// ① 视口分支必须用 CaptureScreenshot
if !strings.Contains(seg, "chromedpCaptureScreenshot()") {
t.Error("视口截图必须走 chromedpCaptureScreenshot()(=CaptureScreenshot):\n" +
" 用 Screenshot(\"body\") 会因 NodeVisible 在 headless 下永不满足而挂到超时")
}
// ② 不得再出现挂住的那种写法
if strings.Contains(seg, `chromedpScreenshot(`) {
t.Error("handleScreenshot 里出现了 chromedpScreenshot(sel) —— " +
"该动作在 headless 下必挂(实测),改用 chromedpCaptureScreenshot()")
}
// ③ full 分支仍走 FullScreenshot(那条一直是好的)
if !strings.Contains(seg, "chromedpFullScreenshot(") {
t.Error("full=true 应走 chromedpFullScreenshot(FullScreenshot 实测正常)")
}
}
// TestCompatLayerExists 确认兼容层还在(迁移收敛点)。
//
// 这一层是为了让 20+ 处调用点的迁移不淹没 diff。若有人删掉它而没把
// 调用点改对,编译会红;但若有人把它改成直接透传错误 API,就只会
// 在运行时挂住 —— 所以这里钉住关键几个。
func TestCompatLayerExists(t *testing.T) {
src, err := os.ReadFile("chromedp_compat.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
for _, need := range []string{
"func chromedpDo(",
"func newRemoteAllocator(",
"func chromedpCaptureScreenshot(",
"func chromedpFullScreenshot(",
"func chromedpTitle(",
} {
if !strings.Contains(s, need) {
t.Errorf("chromedp 兼容层缺少 %s", need)
}
}
// 必须用 remote 子模块的分配器(v0.20 把它移出去了)
if !strings.Contains(s, "remote.NewAllocator") {
t.Error("newRemoteAllocator 必须用 remote.NewAllocator(v0.20 移出核心模块)")
}
}
// TestNoLegacyPointerScreenshot 确认没有残留的旧式「传指针接收」写法。
//
// v0.20 的截图动作是**返回值**,传 &buf 会直接编译失败 —— 但这条判据
// 仍值得留着:它锁的是「值语义」这个契约,而不是某次编译结果。
func TestNoLegacyPointerScreenshot(t *testing.T) {
for _, f := range []string{"plugin.go", "chromedp_compat.go"} {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
s := string(b)
if strings.Contains(s, "Screenshot(&") || strings.Contains(s, "FullScreenshot(&") {
t.Errorf("%s 仍用旧式传指针接收截图(v0.20 已改为返回值)", f)
}
}
}

View File

@ -3,24 +3,21 @@ module browser
go 1.25.0
require (
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
github.com/chromedp/chromedp v0.9.5
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732
github.com/JianFeeeee/homeagentsdk v0.0.0
github.com/chromedp/cdproto v0.157.8
github.com/chromedp/chromedp v0.20.1
github.com/chromedp/chromedp/remote v0.2.0
github.com/chromedp/sysutil v1.0.0
github.com/gobwas/httphead v0.1.0
github.com/gobwas/pool v0.2.1
github.com/gobwas/ws v1.3.2
github.com/gobwas/ws v1.4.0
github.com/josharian/intern v1.0.0
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80
github.com/mailru/easyjson v0.7.7
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde
golang.org/x/sys v0.16.0
golang.org/x/sys v0.47.0
)
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
require github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 // indirect
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../

View File

@ -1,15 +1,25 @@
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8=
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.157.8 h1:Tkq0AjsUlC3Z4sJfHBzV/uAAxnTF/AZyuSTjfV/1gvk=
github.com/chromedp/cdproto v0.157.8/go.mod h1:h68JaIuFr9o3yiayxEYhBdvaxgYO3shDrPSYhNlyfu0=
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
github.com/chromedp/chromedp v0.20.1 h1:983T9wmyM09JtROWlxhLQwljKOQovqt8CVE89W3U0NA=
github.com/chromedp/chromedp v0.20.1/go.mod h1:6z1Y5FdKUxRzxx2qcC5h1xjaBJrSWrZp1mjgITfNZig=
github.com/chromedp/chromedp/remote v0.2.0 h1:Jo5OuOEXMuH3IAOiY5RhXO5JvOpfKMFCZ+cPv6qIksM=
github.com/chromedp/chromedp/remote v0.2.0/go.mod h1:XW8uWIvN8Z4EUxHzYCb6p0lmeTYheYRrEEQpv5FHOH8=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 h1:XGPtjxqp0FS8jj1Z7QGcSOSgyq1fUPjNyJXvi3KfI4A=
github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
@ -21,3 +31,5 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzb
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

View File

@ -3,7 +3,7 @@
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -2,7 +2,7 @@
"name": "browser",
"name_zh": "浏览器",
"name_en": "Browser",
"version": "2.3.0",
"version": "2.4.1",
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
"author": "HomeAgent",
"entry": "plugin.so",

View File

@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net"
@ -21,7 +22,7 @@ import (
"time"
"unicode"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
"github.com/chromedp/chromedp"
)
@ -41,24 +42,42 @@ type Plugin struct {
stopOnce sync.Once
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用
// screenshotsDir 是截图落盘目录(<data>/browser_screenshots)。
//
// ★ 为何要落盘而不是直接回 base64(2026-10-10 修):
//
// 工具结果是 JSON 文本,base64 PNG 会整段进模型上下文。
// 实测一次视口截图 100-500KB ⇒ base64 后 137-685KB
// (约 3-17 万 token),而模型**看不见**这些字符里的图。
//
// 更糟的是它为每个消费方制造了同一道重复劳动:生产日志里 2026-10-10
// 那轮 agent 调 browser_screenshot 后紧跟着 cmd_run(自己 base64 解码
// 写文件),最后才 describe_image —— 它在**绕开**这个设计,
// 而那一轮跑了 920 秒。
//
// 内核早已有约定:describe_image 的 path 参数描述写着
// 「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传
// file 路径)」。本插件返回路径才是合上这个约定。
screenshotsDir string
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir,
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
sharedAllocCtx context.Context
sharedAllocCtx context.Context
sharedAllocCancel context.CancelFunc
sharedMu sync.Mutex
sharedMu sync.Mutex
}
type BrowserSession struct {
id string
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
cancel context.CancelFunc
ctx context.Context // 本会话的 Target 上下文(一个标签页)
createdAt time.Time
timeout time.Duration
closed bool
mu sync.Mutex
id string
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
cancel context.CancelFunc
ctx context.Context // 本会话的 Target 上下文(一个标签页)
createdAt time.Time
timeout time.Duration
closed bool
mu sync.Mutex
currentURL string
shared bool // true=共享浏览器的一个标签页;false=独占浏览器实例
profileDir string // 非空表示使用持久化 profile(关闭时不删目录)
@ -159,6 +178,18 @@ func errResult(msg string) map[string]interface{} {
return map[string]interface{}{"isError": true, "content": msg}
}
func parseBrowserSessionTimeout(args map[string]interface{}) (time.Duration, error) {
raw := strings.TrimSpace(readArg(args, "timeout", ""))
if raw == "" {
return 0, fmt.Errorf("timeout is required;创建浏览器会话时必须明确指定关闭时长,如 15m 或 2h")
}
timeout, err := time.ParseDuration(raw)
if err != nil || timeout <= 0 {
return 0, fmt.Errorf("invalid timeout %q;请使用大于 0 的时长,如 15m 或 2h", raw)
}
return timeout, nil
}
func newHTTPClient(timeout int, proxyURL string) *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
@ -191,6 +222,9 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client {
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.SetAutoRestart(true)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout", Default: "30", Type: "int",
@ -215,9 +249,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.client = newHTTPClient(p.timeout, p.proxy)
// 持久化 profile 根目录:<data>/browser_profiles
// 截图落盘目录:<data>/browser_screenshots
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
if s2, ok := dd.(string); ok && s2 != "" {
p.profilesDir = filepath.Join(s2, "browser_profiles")
p.screenshotsDir = filepath.Join(s2, "browser_screenshots")
}
}
@ -276,14 +312,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"start", sdk.ToolDef{
Name: tp + "start",
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。",
Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout;到期后插件关闭标签页。同来源复用已有标签页时,也按本次 timeout 重新设定关闭时间。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
"timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,如 15m、2h;必须大于 0"},
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
},
"required": []string{"timeout"},
},
}, p.handleBrowserStart)
@ -302,14 +339,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}, p.handleNavigate)
s.RegisterTool(tp+"screenshot", sdk.ToolDef{
Name: tp + "screenshot",
Description: "对交互式浏览器当前页面截图。返回 base64 编码的 PNG 图片。",
Name: tp + "screenshot",
Description: "对交互式浏览器当前页面截图,**保存为 PNG 文件并返回绝对路径**。" +
"要看图请拿返回的 path 调 describe_image(path=...);要提取文字调 ocr_image(path=...)。" +
"(不再直接返回 base64——那样会把十几万 token 灌进上下文而你看不见图。)",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"},
"format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"},
"inline": map[string]interface{}{"type": "boolean", "description": "可选:额外返回 base64/data_uri(默认 false)。仅当调用方拿不到文件系统时才需要;带上会把大段 base64 灌进上下文"},
},
"required": []string{"id"},
},
@ -471,7 +511,9 @@ type searchResult struct {
}
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(query), count)
// 用 cn.bing.com:www.bing.com 对程序化请求常回 302(同意/重定向页),拿不到结果块。
// 另:Bing 忽略 count 参数,翻页靠 first=,这里保留 count 只为兼容旧调用语义。
u := fmt.Sprintf("https://cn.bing.com/search?q=%s&first=1&count=%d&setlang=zh-CN", url.QueryEscape(query), count)
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
@ -481,38 +523,112 @@ func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return parseBingResults(string(body), count), nil
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Bing 返回 HTTP %d(%d 字节)", resp.StatusCode, len(body))
}
results := parseBingResults(string(body), count)
if len(results) == 0 {
// 关键:把「解析不出来」与「真的没结果」区分开。
// 以前两者都变成 "No results found.",版式一变就静默退化成「搜不到」。
return nil, fmt.Errorf("Bing 返回 %d 字节但未解析出结果(可能被反爬或版式变更,可改用 deepsearch 插件)", len(body))
}
return results, nil
}
func parseBingResults(html string, count int) []searchResult {
var (
bingBlockRe = regexp.MustCompile(`<li class="b_algo"`)
bingTitleRe = regexp.MustCompile(`(?s)<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
bingAnyLinkRe = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
bingSnipRe = regexp.MustCompile(`(?s)<p class="b_lineclamp[^"]*"[^>]*>(.*?)</p>`)
bingCaptionRe = regexp.MustCompile(`(?s)<div class="b_caption"[^>]*>(.*?)</div>`)
)
// splitBingBlocks 按块标记切分,每块内容延伸到下一个块标记为止。
//
// 不用 `<li class="b_algo"(?s)(.*?)</li>`:结果块内部可能嵌套 <li>(deep links),
// 非贪婪匹配会在错误位置截断;而且块内第一个 <a> 往往是 Bing 的「来源行」,
// 取到的是 `deepin.orghttps://www.deepin.org` 这种垃圾标题。
func splitBingBlocks(pageHTML string) []string {
locs := bingBlockRe.FindAllStringIndex(pageHTML, -1)
if len(locs) == 0 {
return nil
}
blocks := make([]string, 0, len(locs))
for i, loc := range locs {
end := len(pageHTML)
if i+1 < len(locs) {
end = locs[i+1][0]
}
blocks = append(blocks, pageHTML[loc[1]:end])
}
return blocks
}
func parseBingResults(pageHTML string, count int) []searchResult {
if count <= 0 {
count = 5
}
var results []searchResult
re := regexp.MustCompile(`<li class="b_algo"(?s)(.*?)</li>`)
matches := re.FindAllStringSubmatch(html, -1)
for _, m := range matches {
for _, block := range splitBingBlocks(pageHTML) {
if len(results) >= count {
break
}
block := m[1]
var r searchResult
hrefRe := regexp.MustCompile(`<a[^>]+href="([^"]+)"[^>]*>`)
if hm := hrefRe.FindStringSubmatch(block); len(hm) > 1 {
r.URL = hm[1]
// 标题:现代 Bing 是 <h2><a href=...>标题</a></h2>;没有 h2 时才退回到块内第一个链接。
var href, title string
if m := bingTitleRe.FindStringSubmatch(block); m != nil {
href, title = m[1], html.UnescapeString(stripTags(m[2]))
} else if m := bingAnyLinkRe.FindStringSubmatch(block); m != nil {
href, title = m[1], html.UnescapeString(stripTags(m[2]))
}
titleRe := regexp.MustCompile(`<a[^>]+href="[^"]+"[^>]*>(.*?)</a>`)
if tm := titleRe.FindStringSubmatch(block); len(tm) > 1 {
r.Title = stripTags(tm[1])
href = bingRealURL(html.UnescapeString(href))
// 摘要:新版在 p.b_lineclamp*,旧版在 div.b_caption > p
var snippet string
if m := bingSnipRe.FindStringSubmatch(block); m != nil {
snippet = html.UnescapeString(stripTags(m[1]))
} else if m := bingCaptionRe.FindStringSubmatch(block); m != nil {
snippet = html.UnescapeString(stripTags(m[1]))
}
snipRe := regexp.MustCompile(`<div class="b_caption">.*?<p>(.*?)</p>`)
if sm := snipRe.FindStringSubmatch(block); len(sm) > 1 {
r.Snippet = stripTags(sm[1])
}
if r.URL != "" && r.Title != "" {
results = append(results, r)
title, snippet = strings.TrimSpace(title), strings.TrimSpace(snippet)
if href == "" || title == "" || !strings.HasPrefix(href, "http") {
continue
}
results = append(results, searchResult{Title: title, URL: href, Snippet: snippet})
}
return results
}
// bingRealURL 解开 Bing 的跳转包装:/ck/a?...&u=a1<base64url>&... → 真实 URL。
// 不解的话模型拿到的是 `https://cn.bing.com/ck/a?...` 这种不可读地址。
func bingRealURL(href string) string {
href = strings.TrimSpace(href)
if href == "" {
return ""
}
if !strings.Contains(href, "/ck/a") && !strings.Contains(href, "u=a1") {
return href
}
u, err := url.Parse(href)
if err != nil {
return href
}
raw := u.Query().Get("u")
if !strings.HasPrefix(raw, "a1") {
return href
}
b64 := raw[2:]
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding} {
if dec, err := enc.DecodeString(b64); err == nil {
s := string(dec)
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
return s
}
}
}
return href
}
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
query := readArg(args, "query", "")
if query == "" {
@ -725,25 +841,30 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
ok, needInstall, _ := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
remoteCtx, remoteCancel := newRemoteAllocator(context.Background(), cdpEndpoint)
defer remoteCancel()
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
defer tabCancel()
actions := []chromedp.Action{
steps := []chromedp.Action[chromedp.Void]{
chromedp.Navigate(rawURL),
chromedp.WaitReady("body"),
}
if waitSec > 0 {
actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second))
steps = append(steps, chromedp.Sleep(time.Duration(waitSec)*time.Second))
}
actions = append(actions,
chromedp.Title(&title),
chromedp.OuterHTML("html", &html),
)
// 整体限时 30s,防慢页拖死工具
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
defer rcancel()
if err := chromedp.Run(rctx, actions...); err == nil {
// 标题与 HTML 现在是**返回值**,不再传指针接收。
// 顺序仍是「先跑完交互步骤,再取值」——取值动作本身也会被 Do 依次执行。
steps = append(steps, chromedp.WaitReady("html"))
if err := chromedpDo(rctx, steps...); err == nil {
if t, terr := chromedp.Run(rctx, chromedpTitle()); terr == nil {
title = t
}
if h, herr := chromedp.Run(rctx, chromedpOuterHTML("html")); herr == nil {
html = h
}
rendered = true
} else {
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
@ -852,7 +973,7 @@ func (p *Plugin) ensureBackend() (bool, bool, error) {
// sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
if err := chromedp.Run(tabCtx); err != nil {
if err := chromedpDo(tabCtx); err != nil {
tabCancel()
return nil, nil, err
}
@ -873,7 +994,7 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
}
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, _ := chromedp.NewContext(allocCtx)
if err := chromedp.Run(ctx); err != nil {
if err := chromedpDo(ctx); err != nil {
cancelAlloc()
return nil, nil, nil, err
}
@ -881,10 +1002,9 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
}
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
timeoutStr := readArg(args, "timeout", "10m")
timeout, err := time.ParseDuration(timeoutStr)
timeout, err := parseBrowserSessionTimeout(args)
if err != nil {
timeout = 10 * time.Minute
return errResult(err.Error()), nil
}
source := readArg(args, "source", "")
@ -899,13 +1019,19 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
s.mu.Lock()
id := s.id
cur := s.currentURL
s.createdAt = time.Now()
s.timeout = timeout
closesAt := s.createdAt.Add(timeout)
s.mu.Unlock()
p.mu.Unlock()
log.Printf("[%s] reused browser session %s: timeout=%v closes_at=%s source=%s", p.name, id, timeout, closesAt.Format(time.RFC3339), source)
return map[string]interface{}{
"id": id,
"status": "reused",
"url": cur,
"note": "已复用本来源的现有标签页(登录态全机共享)",
"id": id,
"status": "reused",
"url": cur,
"timeout": timeout.String(),
"closes_at": closesAt.Format(time.RFC3339),
"note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间",
}, nil
}
}
@ -916,14 +1042,14 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
// 路径一:systemd 托管的共享后端(主路径)
ok, needInstall, berr := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
remoteCtx, remoteCancel := newRemoteAllocator(context.Background(), cdpEndpoint)
probe, _ := chromedp.NewContext(remoteCtx)
if err := chromedp.Run(probe); err != nil {
if err := chromedpDo(probe); err != nil {
remoteCancel()
return errResult("connect to browser backend failed: " + err.Error()), nil
}
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
if err := chromedp.Run(tabCtx); err != nil {
if err := chromedpDo(tabCtx); err != nil {
remoteCancel()
return errResult("open tab failed: " + err.Error()), nil
}
@ -942,7 +1068,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
"插件会注册 homeagent-browser.service 并启动。" +
"若本机无法联网安装 chromium,可继续用本地临时模式(重试 browser_start 即自动降级)。"
return map[string]interface{}{
"error": "backend not installed",
"error": "backend not installed",
"need_install": true,
"guide": guide,
}, nil
@ -959,7 +1085,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
initURL := readArg(args, "url", "")
if initURL != "" {
if err := chromedp.Run(session.ctx,
if err := chromedpDo(session.ctx,
chromedp.Navigate(initURL),
chromedp.WaitReady("body"),
); err != nil {
@ -972,13 +1098,15 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
session.currentURL = initURL
}
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
closesAt := session.createdAt.Add(timeout)
log.Printf("[%s] created browser session %s: url=%s timeout=%v closes_at=%s source=%s", p.name, id, initURL, timeout, closesAt.Format(time.RFC3339), source)
return map[string]interface{}{
"id": id,
"status": "created",
"mode": "shared-backend",
"url": initURL,
"timeout": timeout.String(),
"id": id,
"status": "created",
"mode": "shared-backend",
"url": initURL,
"timeout": timeout.String(),
"closes_at": closesAt.Format(time.RFC3339),
}, nil
}
@ -1003,7 +1131,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error
return errResult(err.Error()), nil
}
waitSec := int64(readArg(args, "wait", float64(2)))
if err := chromedp.Run(s.ctx,
if err := chromedpDo(s.ctx,
chromedp.Navigate(rawURL),
chromedp.WaitReady("body"),
chromedp.Sleep(time.Duration(waitSec)*time.Second),
@ -1035,21 +1163,98 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
var buf []byte
var err error
if full {
err = chromedp.Run(s.ctx, chromedp.FullScreenshot(&buf, 90))
buf, err = chromedp.Run(s.ctx, chromedpFullScreenshot(90))
} else {
err = chromedp.Run(s.ctx, chromedp.Screenshot("body", &buf))
// ★ 视口截图用 CaptureScreenshot,**不能用 Screenshot("body")**(2026-10-11 修)。
//
// Screenshot 内部是 QueryAfter + NodeVisible,而 NodeVisible 在
// headless Chromium 下**永远不满足**:它先用 dom.GetBoxModel 拿到盒子
// (实测 300µs 就成功),再用 visibleJS 判定 —— 而 visibleJS 实测
// 返回 true(offsetWidth=480)。两者都对,但它仍会一直等下去。
//
// 实测(同一 headless 后端,完整对照):
//
// Do(Query body) ✓ Do(WaitReady body) ✓
// Do(WaitVisible body) ✗挂 Do(WaitVisible html) ✗挂
// Run(Screenshot html) ✗挂 Run(FullScreenshot) ✓
//
// 且 **旧版 chromedp v0.9.5 同样挂** —— 这不是升级引入的,是早就存在的
// 缺陷。生产日志佐证:成功的 8 次都是 agent 传了 full:true(走
// FullScreenshot),超时的 5 次都是默认分支。
//
// CaptureScreenshot 截整个视口,不经过 NodeVisible 那条链,实测 70KB / 正常返回。
// 语义也更对:「不传 full 就只截当前视口」本来就是 API 文档的含义。
buf, err = chromedp.Run(s.ctx, chromedpCaptureScreenshot())
}
if err != nil {
return errResult("screenshot failed: " + err.Error()), nil
}
b64 := base64.StdEncoding.EncodeToString(buf)
return map[string]interface{}{
"status": "ok",
"format": format,
"size": len(buf),
"base64": b64,
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
}, nil
// ★ 默认落盘并返回**路径**(2026-10-10 改)。
//
// 为何不改回直接回 base64:那是把「解码 + 写文件」这件重复劳动
// 推给每一个消费方,而模型自己做不到(它看不见 base64 里的图)。
// 生产日志实证过 agent 的绕行链路:browser_screenshot → cmd_run 自己
// 解码写盘 → describe_image。
path, saveErr := p.saveScreenshot(buf, format, id)
if saveErr != nil {
// 落盘失败时不静默退回 base64:那会让「上下文被灌爆」这件事
// 在磁盘满/权限错时重新出现,而且没有迹象。
return errResult("screenshot saved failed: " + saveErr.Error()), nil
}
res := map[string]interface{}{
"status": "ok",
"format": format,
"size": len(buf),
// path 是**主要返回值**:模型应拿它去调 describe_image / ocr_image,
// 或交给 files_* / cmd_run 做后续处理。
"path": path,
// hint 直接写在结果里:工具描述模型可能漏看,结果里的提示更近。
"hint": "截图已保存。要看图请调 describe_image(path=...);" +
"要 OCR 请调 ocr_image(path=...)。不要再自己解码 base64。",
}
// inline=true 时才额外带 base64:只有确实拿不到文件系统
// (如前端要把图直接嵌进消息)的少数场景才该用。
if v, ok := args["inline"].(bool); ok && v {
b64 := base64.StdEncoding.EncodeToString(buf)
res["base64"] = b64
res["data_uri"] = fmt.Sprintf("data:image/png;base64,%s", b64)
}
return res, nil
}
// saveScreenshot 把截图写入 <data>/browser_screenshots/,返回**绝对路径**。
//
// 为何文件名带时间戳与会话 id:同一会话连续截图是常态(生产日志里一轮
// 内截了 3 次),固定文件名会互相覆盖,而 agent 很可能还想回看前一张。
// 时间戳用 `20060102-150405.000`(毫秒):同秒内多次截图也不撞名。
func (p *Plugin) saveScreenshot(buf []byte, format, sessionID string) (string, error) {
dir := p.screenshotsDir
if dir == "" {
// 未注入 data_dir(如测试环境)时退到临时目录,而不是报错:
// 截图本身是成功的,只该降级存储位置。
dir = filepath.Join(os.TempDir(), "homeagent-browser-screenshots")
}
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("创建截图目录 %s: %w", dir, err)
}
// 会话 id 由调用方传入,可能含路径分隔符 —— 过滤掉,
// 否则 `../` 能把截图写出目录外。
safeID := strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == 0 {
return '_'
}
return r
}, sessionID)
name := fmt.Sprintf("shot-%s-%s.%s",
time.Now().Format("20060102-150405.000"), safeID, format)
full := filepath.Join(dir, name)
if err := os.WriteFile(full, buf, 0644); err != nil {
return "", fmt.Errorf("写入 %s: %w", full, err)
}
return full, nil
}
func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
@ -1065,25 +1270,27 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
if v, ok := args["max_chars"].(float64); ok && v > 0 {
maxChars = int(v)
}
var html string
if err := chromedp.Run(s.ctx, chromedp.OuterHTML("html", &html)); err != nil {
html, err := chromedp.Run(s.ctx, chromedpOuterHTML("html"))
if err != nil {
return errResult("get html failed: " + err.Error()), nil
}
var title, currentURL string
chromedp.Run(s.ctx,
chromedp.Title(&title),
chromedp.Location(&currentURL),
)
if t, terr := chromedp.Run(s.ctx, chromedpTitle()); terr == nil {
title = t
}
if u, uerr := chromedp.Run(s.ctx, chromedpLocation()); uerr == nil {
currentURL = u
}
truncated := len(html) > maxChars
if truncated {
html = html[:maxChars] + "\n\n[HTML truncated]"
}
return map[string]interface{}{
"status": "ok",
"title": title,
"url": currentURL,
"html": html,
"length": len(html),
"status": "ok",
"title": title,
"url": currentURL,
"html": html,
"length": len(html),
}, nil
}
@ -1097,7 +1304,7 @@ func (p *Plugin) handleClick(args map[string]interface{}) (interface{}, error) {
if err != nil {
return errResult(err.Error()), nil
}
if err := chromedp.Run(s.ctx,
if err := chromedpDo(s.ctx,
chromedp.WaitVisible(selector),
chromedp.Click(selector),
); err != nil {
@ -1117,7 +1324,7 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) {
if err != nil {
return errResult(err.Error()), nil
}
actions := []chromedp.Action{
actions := []chromedp.Action[chromedp.Void]{
chromedp.WaitVisible(selector),
chromedp.Click(selector, chromedp.NodeVisible),
chromedp.Clear(selector),
@ -1130,7 +1337,7 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) {
if submit {
actions = append(actions, chromedp.SendKeys(selector, "\r"))
}
if err := chromedp.Run(s.ctx, actions...); err != nil {
if err := chromedpDo(s.ctx, actions...); err != nil {
return errResult("type failed (element may not exist or page blocking): " + err.Error()), nil
}
return map[string]interface{}{"status": "ok", "selector": selector}, nil
@ -1160,7 +1367,7 @@ func (p *Plugin) handleScroll(args map[string]interface{}) (interface{}, error)
default:
return errResult("dir 必须是 up/down/left/right"), nil
}
if err := chromedp.Run(s.ctx, chromedp.Evaluate(scrollJS, nil)); err != nil {
if err := chromedpDo(s.ctx, chromedpEvaluateVoid(scrollJS)); err != nil {
return errResult("scroll failed: " + err.Error()), nil
}
return map[string]interface{}{"status": "ok", "dir": dir, "amount": amount}, nil
@ -1204,13 +1411,20 @@ func (p *Plugin) cleanupLoop() {
case <-p.stopCh:
return
case <-ticker.C:
now := time.Now()
p.mu.Lock()
for id, s := range p.sessions {
if time.Since(s.createdAt) >= s.timeout {
log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
delete(p.sessions, id)
s.Close()
p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
s.mu.Lock()
closesAt := s.createdAt.Add(s.timeout)
expired := !now.Before(closesAt)
s.mu.Unlock()
if expired {
log.Printf("[%s] cleanup: browser session %s reached agent-specified close time %s", p.name, id, closesAt.Format(time.RFC3339))
delete(p.sessions, id)
s.Close()
// NoMemory:会话生命周期通知,不是记忆内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true})
}
}
p.mu.Unlock()
@ -1310,7 +1524,7 @@ WantedBy=multi-user.target
return map[string]interface{}{
"status": "installed",
"endpoint": cdpEndpoint,
"chrome": chromePath,
"chrome": chromePath,
"profile": profileDir,
"guide": guide,
}, nil

View File

@ -0,0 +1,213 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
)
func TestParseBrowserSessionTimeoutRequiresExplicitValue(t *testing.T) {
_, err := parseBrowserSessionTimeout(map[string]interface{}{})
if err == nil || !strings.Contains(err.Error(), "timeout is required") {
t.Fatalf("expected required timeout error, got %v", err)
}
}
func TestParseBrowserSessionTimeoutAcceptsPositiveDuration(t *testing.T) {
got, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": "2h30m"})
if err != nil {
t.Fatal(err)
}
if got != 2*time.Hour+30*time.Minute {
t.Fatalf("timeout=%v", got)
}
}
func TestParseBrowserSessionTimeoutRejectsInvalidOrNonPositive(t *testing.T) {
for _, value := range []string{"invalid", "0s", "-1m"} {
if _, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": value}); err == nil {
t.Errorf("timeout %q should be rejected", value)
}
}
}
func TestBrowserStartReuseResetsExplicitCloseTime(t *testing.T) {
p := &Plugin{
name: "browser",
sessions: map[string]*BrowserSession{
"browser_1": {
id: "browser_1",
shared: true,
sessionKey: "qq",
createdAt: time.Now().Add(-time.Hour),
timeout: time.Minute,
currentURL: "https://example.com",
},
},
}
before := time.Now()
result, err := p.handleBrowserStart(map[string]interface{}{"source": "qq", "timeout": "3h"})
if err != nil {
t.Fatal(err)
}
out := result.(map[string]interface{})
if out["status"] != "reused" || out["timeout"] != "3h0m0s" {
t.Fatalf("unexpected result: %#v", out)
}
s := p.sessions["browser_1"]
if s.timeout != 3*time.Hour || s.createdAt.Before(before) {
t.Fatalf("deadline not reset: createdAt=%v timeout=%v", s.createdAt, s.timeout)
}
}
// ── Bing 解析器(2026-09 版式)─────────────────────────────
//
// 背景:旧实现把块内**第一个 <a>** 当标题 —— 拿到的是 Bing 的「来源行」
// `deepin.orghttps://www.deepin.org`;摘要正则 `<div class="b_caption">.*?<p>`
// 对现代 Bing 命中 0/N(摘要已迁到 p.b_lineclamp*),于是结果「有标题没摘要」,
// 模型只好反复换词重搜。夹具 testdata/bing_cn.html 是真实 cn.bing.com 响应裁剪。
func TestParseBingResultsRealBingHTML(t *testing.T) {
page, err := os.ReadFile("testdata/bing_cn.html")
if err != nil {
t.Fatalf("读取夹具失败: %v", err)
}
results := parseBingResults(string(page), 3)
if len(results) != 3 {
t.Fatalf("应解析出 3 条,实际 %d 条: %+v", len(results), results)
}
for i, r := range results {
if !strings.HasPrefix(r.URL, "http") {
t.Errorf("第 %d 条 URL 不是真实地址: %q", i+1, r.URL)
}
if strings.Contains(r.Title, "http") || strings.Contains(r.Title, "://") {
t.Errorf("第 %d 条标题混入了 URL(旧 bug 的典型症状): %q", i+1, r.Title)
}
if r.Snippet == "" {
t.Errorf("第 %d 条没有摘要(旧 bug 的典型症状): %+v", i+1, r)
}
}
// 第一条必须与样本里的真实结果一致
if results[0].URL != "https://www.deepin.org/" {
t.Errorf("第一条 URL 应为 https://www.deepin.org/,实际 %q", results[0].URL)
}
if !strings.Contains(results[0].Title, "deepin") {
t.Errorf("第一条标题不对: %q", results[0].Title)
}
if len(results[0].Snippet) < 10 || strings.Contains(results[0].Snippet, "://") {
t.Errorf("第一条摘要不对(应是有内容的文本): %q", results[0].Snippet)
}
}
// 块内嵌套 <li>(deep links)时不能截断 —— 旧的 `<li class="b_algo"(?s)(.*?)</li>` 会在此翻车
func TestParseBingResultsNestedLiKeepsResult(t *testing.T) {
page := `<ol id="b_results"><li class="b_algo" data-id iid=SERP.1>` +
`<h2><a href="https://a.example/x" h="ID=SERP,1">真标题</a></h2>` +
`<div class="b_caption"><p class="b_lineclamp2">真摘要</p></div>` +
`<div><ul><li><a href="https://sub.example/deeplink">子链接</a></li></ul></div>` +
`</li><li class="b_algo"><h2><a href="https://b.example/y">第二条</a></h2>` +
`<p class="b_lineclamp3">摘要二</p></li></ol>`
rs := parseBingResults(page, 5)
if len(rs) != 2 {
t.Fatalf("应解析 2 条,实际 %d 条: %+v", len(rs), rs)
}
if rs[0].URL != "https://a.example/x" || rs[0].Title != "真标题" || rs[0].Snippet != "真摘要" {
t.Errorf("第一条解析错误: %+v", rs[0])
}
if rs[1].Title != "第二条" || rs[1].Snippet != "摘要二" {
t.Errorf("第二条(无 b_caption,摘要走 b_lineclamp3)解析错误: %+v", rs[1])
}
}
func TestBingRealURLDecodesRedirectWrapper(t *testing.T) {
// Bing 跳转包装:/ck/a?...&u=a1<base64url>
wrapped := "/ck/a?!&&p=abc&u=a1aHR0cHM6Ly93d3cuZGVlcGluLm9yZy96aC9EZWVwaW4v&ntb=1"
if got := bingRealURL(wrapped); got != "https://www.deepin.org/zh/Deepin/" {
t.Errorf("未解开跳转包装: %q", got)
}
if got := bingRealURL("https://direct.example/p"); got != "https://direct.example/p" {
t.Errorf("直链不应被改动: %q", got)
}
// 解不开时保守返回原值,不能返回空
bad := "/ck/a?u=a1!!!!"
if got := bingRealURL(bad); got == "" {
t.Errorf("解不开时应保留原值,实际返回空")
}
}
// roundTripFunc 把任意请求转给本地测试服务器,从而离线测 bingSearch 的完整路径
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestBingSearchReportsParseFailureInsteadOfEmptyResult(t *testing.T) {
var seenURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("<html><body>no result blocks here</body></html>"))
}))
defer srv.Close()
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
seenURL = r.URL.String()
return srv.Client().Transport.RoundTrip(&http.Request{
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
})
})}}
if _, err := p.bingSearch("任意查询", 5); err == nil {
t.Fatal("解析不出结果时必须报错,而不是伪装成「没有结果」")
} else if !strings.Contains(err.Error(), "未解析出结果") {
t.Errorf("错误信息应说明是解析失败: %v", err)
}
// 数据源必须是 cn.bing.com(www.bing.com 对程序化请求回 302,拿不到结果块)
if !strings.Contains(seenURL, "cn.bing.com") {
t.Errorf("应请求 cn.bing.com,实际 %q", seenURL)
}
if strings.Contains(seenURL, "www.bing.com") {
t.Errorf("不应再请求 www.bing.com: %q", seenURL)
}
}
// 正常路径:能解析出结果时返回结果且不报错
func TestBingSearchParsesFixtureThroughClient(t *testing.T) {
page, err := os.ReadFile("testdata/bing_cn.html")
if err != nil {
t.Fatalf("读取夹具失败: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(page)
}))
defer srv.Close()
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return srv.Client().Transport.RoundTrip(&http.Request{
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
})
})}}
results, err := p.bingSearch("deepin", 2)
if err != nil {
t.Fatalf("应成功,实际 %v", err)
}
if len(results) != 2 {
t.Fatalf("应返回 2 条(count 生效),实际 %d", len(results))
}
if results[0].Snippet == "" {
t.Errorf("摘要不应为空: %+v", results[0])
}
}
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("解析测试 URL 失败: %v", err)
}
return u
}

View File

@ -0,0 +1,235 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 截图必须**落盘并返回路径**,而不是把 base64 塞进工具结果。
//
// # 为何这条判据重要(2026-10-10)
//
// 工具结果是 JSON 文本,base64 PNG 会整段进模型上下文,而模型**看不见**
// 这些字符里的图。实测一次视口截图 100-500KB ⇒ base64 后 137-685KB
// (约 3-17 万 token),纯粹是上下文浪费。
//
// 更关键的是它把「解码 + 写文件」这件重复劳动推给了每个消费方,
// 而模型做不到。生产日志实证过 agent 的绕行链路:
//
// browser_screenshot → cmd_run(自己 base64 解码写盘)→ describe_image
//
// 那一轮跑了 920 秒。内核早有对应约定:describe_image 的 path 参数描述写着
// 「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传 file 路径)」。
func TestSaveScreenshotWritesFileAndReturnsPath(t *testing.T) {
dir := t.TempDir()
p := &Plugin{name: "browser", screenshotsDir: dir}
// 造一个最小 PNG 头(内容不重要,测的是落盘行为)
data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3}
path, err := p.saveScreenshot(data, "png", "sess-1")
if err != nil {
t.Fatalf("saveScreenshot: %v", err)
}
// ① 返回的必须是**绝对路径**且指向真实存在的文件
if !filepath.IsAbs(path) {
t.Errorf("返回的应是绝对路径(模型要拿它调 describe_image),实为 %q", path)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("截图的路径不可读(%s): %v", path, err)
}
if string(got) != string(data) {
t.Errorf("落盘内容与截图字节不一致:%d vs %d", len(got), len(data))
}
// ② 路径必须在 screenshotsDir 内
if !strings.HasPrefix(path, dir+string(filepath.Separator)) {
t.Errorf("截图应落在 %s 内,实为 %q", dir, path)
}
// ③ 文件名要能看出是截图且带 png 后缀
if !strings.HasSuffix(path, ".png") {
t.Errorf("文件名应带 .png 后缀,实为 %q", path)
}
if !strings.Contains(filepath.Base(path), "shot-") {
t.Errorf("文件名应可识别为截图,实为 %q", filepath.Base(path))
}
}
// 同一会话连续截图不能互相覆盖。
//
// 为何专门测:生产日志里**一轮之内**就调了 3 次 browser_screenshot,
// 而 agent 很可能还想回看前一张。固定文件名会静默覆盖。
func TestSaveScreenshotDoesNotOverwrite(t *testing.T) {
dir := t.TempDir()
p := &Plugin{name: "browser", screenshotsDir: dir}
first, err := p.saveScreenshot([]byte("AAA"), "png", "sess-1")
if err != nil {
t.Fatal(err)
}
// 确保时间戳至少差 1ms(文件名精度是毫秒)
time.Sleep(2 * time.Millisecond)
second, err := p.saveScreenshot([]byte("BBB"), "png", "sess-1")
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatalf("两次截图写到了同一个文件(%s)—— 前一张被覆盖", first)
}
b1, _ := os.ReadFile(first)
if string(b1) != "AAA" {
t.Errorf("第一次截图被覆盖了:%q", b1)
}
}
// 会话 id 不能把截图写出目录外(它来自模型,且被拼进路径)。
func TestSaveScreenshotRejectsPathTraversalInSessionID(t *testing.T) {
dir := t.TempDir()
p := &Plugin{name: "browser", screenshotsDir: dir}
for _, evil := range []string{"../../etc/evil", "a/../../b", `win\..\..\x`} {
path, err := p.saveScreenshot([]byte("X"), "png", evil)
if err != nil {
t.Fatalf("saveScreenshot(%q): %v", evil, err)
}
// 关键:解析后仍必须在 dir 内
abs, _ := filepath.Abs(path)
base, _ := filepath.Abs(dir)
if !strings.HasPrefix(abs, base+string(filepath.Separator)) {
t.Errorf("会话 id %q 让截图写到了目录外:%s(应在 %s 内)", evil, abs, base)
}
}
}
// 未注入 data_dir 时应降级到临时目录,而不是让截图整条失败。
//
// 理由:截图本身是成功的,只有存储位置该降级。
// 直接报错会把「环境没配」变成「功能不可用」。
func TestSaveScreenshotFallsBackWhenDirUnset(t *testing.T) {
p := &Plugin{name: "browser"} // screenshotsDir 为空
path, err := p.saveScreenshot([]byte("X"), "png", "s1")
if err != nil {
t.Fatalf("未注入目录时应降级到临时目录,实为报错: %v", err)
}
defer os.Remove(path)
if !filepath.IsAbs(path) {
t.Errorf("降级路径也应是绝对路径,实为 %q", path)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("降级路径的文件不存在: %v", err)
}
}
// 工具描述必须告诉模型「返回路径 + 拿 path 去 describe_image」。
//
// 为何钉描述而不只钉实现:模型是靠描述决定怎么用工具的。
// 描述若退回「返回 base64」,模型就会又去自己解码。
func TestScreenshotToolDescriptionMentionsPath(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, `tp+"screenshot"`)
if i < 0 {
t.Fatal("找不到 screenshot 工具注册")
}
seg := s[i:]
if end := strings.Index(seg, "}, p.handleScreenshot)"); end > 0 {
seg = seg[:end]
}
if !strings.Contains(seg, "path") {
t.Error("工具描述必须提到返回 path(模型靠描述决定怎么用工具)")
}
if !strings.Contains(seg, "describe_image") {
t.Error("工具描述应指明拿 path 去调 describe_image —— " +
"不写的话模型不知道下一步该做什么")
}
if strings.Contains(seg, "返回 base64 编码的 PNG 图片") {
t.Error("工具描述退回了「返回 base64」,与实现不符(实现已改为落盘 + 返回路径)")
}
}
// handleScreenshot 的**返回结构**必须是 path 而不是 base64。
//
// # 为何用源码断言而不是调用它
//
// handleScreenshot 需要真实的 chromedp 会话(真 Chromium + CDP),
// 单测跑不动。而「结果里返回什么字段」是**静态可判定**的结构事实,
// 源码断言能可靠守住,回归时立刻红。
//
// 为何需要这条额外判据:saveScreenshot 的单测只覆盖落盘函数本身,
// 把 handleScreenshot 改回「返回 base64」**不会**让它变红
// (变异测试实测过 —— 这是一个真实的判据漏洞)。
func TestHandleScreenshotReturnsPathNotBase64(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "func (p *Plugin) handleScreenshot(")
if i < 0 {
t.Fatal("找不到 handleScreenshot")
}
seg := s[i:]
if end := strings.Index(seg, "\nfunc (p *Plugin) saveScreenshot("); end > 0 {
seg = seg[:end]
}
// ① 必须调用 saveScreenshot 并把它返回的路径带进结果
if !strings.Contains(seg, "p.saveScreenshot(") {
t.Error("handleScreenshot 必须调用 saveScreenshot 落盘(而不是直接编 base64)")
}
if !strings.Contains(seg, `"path":`) {
t.Error("handleScreenshot 的结果必须含 \"path\" 字段 —— " +
"模型拿它去调 describe_image / ocr_image")
}
// ② base64 只能是**可选**(受 inline 开关控制),不能无条件出现。
//
// 注意:这条要挡住的是「无条件把 base64 塞进返回值」,因为那会让
// 上下文被灌爆——正是本次要修的问题。变异测试发现单看
// `res["base64"] = b64` 是否存在不够:把 base64 写成 map 字面量的
// 一个键(`"base64": ...`)同样绕过它,所以两种写法都要查。
// ⚠️ 这条必须查**真实代码行**而不是整段文本:注释里也出现过 "inline"
// 和 "base64",按整段 Contains 判会把注释算成保护开关
// (变异测试实测:把 `if ...inline...` 改成 `if false` 仍能通过)。
code := stripLineComments(seg)
if strings.Contains(code, `"base64"`) || strings.Contains(code, `data_uri`) {
if !strings.Contains(code, "args[\"inline\"]") {
t.Error("返回结构里出现了 base64/data_uri,但没有真实的 inline 开关保护:" +
"无条件带上就等于把十几万 token 又灌回上下文(本次修复要消掉的问题)")
}
}
// ③ 落盘失败时不得静默退回 base64
if strings.Contains(code, "saveErr") && !strings.Contains(code, `return errResult("screenshot saved failed`) {
t.Error("screenshot saved 失败时必须明确报错(return errResult)," +
"不得静默退回 base64 —— 那会让上下文被灌爆这件事没有迹象")
}
}
// stripLineComments 去掉整行注释与行尾注释,只留可执行代码。
//
// 为何需要:源码断言若把注释也算进去,就会出现「注释里的词冒充保护逻辑」
// 这种假通过。变异测试实测过这个坑。
func stripLineComments(src string) string {
var out []string
for _, line := range strings.Split(src, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "//") {
continue
}
if i := strings.Index(line, "//"); i >= 0 {
line = line[:i]
}
out = append(out, line)
}
return strings.Join(out, "\n")
}

1
example/browser/testdata/bing_cn.html vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,13 +1,57 @@
# calendar
# calendar · 日历事件
calendar plugin
事件管理:支持**重复事件**与**多档提醒**。
## Build
## 工具
```bash
plugindev build
| 工具 | 说明 |
|---|---|
| `calendar_event_add` | 添加事件 |
| `calendar_event_list` | 列出即将到来的事件(含日期、时间、重复规则) |
| `calendar_event_update` | 更新事件(**只改传入的字段**;会重置提醒状态) |
| `calendar_event_delete` | 删除事件(连带**该事件及之后的所有重复实例**) |
| `calendar_today` | 今日事件 + 倒计时 |
| `calendar_week` | 本周事件,按天分组 |
| `calendar_month` | 月历网格,带事件标记点 |
| `calendar_search` | 按关键词搜标题 / 地点 / 备注 |
`calendar_event_add` 的时间格式:`YYYY-MM-DD HH:MM`;只给 `YYYY-MM-DD` 表示全天事件。
## 重复规则
`repeat` 取值:
| 值 | 含义 |
|---|---|
| `none` | 不重复 |
| `daily` | 每天 |
| `weekday` | 每个工作日 |
| `weekly` | 每周 |
| `biweekly` | 每两周 |
| `monthly` | 每月 |
| `yearly` | 每年 |
| `lunar_yearly` | **按农历年**(生日、传统节日用) |
`lunar_yearly` 是刻意加的:农历节日按公历写死会逐年偏移。
## 提醒
`remind_before` 单位是**分钟**,可给多个、逗号分隔:
```
15,60,1440 # 提前 15 分钟 + 1 小时 + 1 天
0 或留空 # 不提醒
```
## Install
到点通过 `InjectInterruptText` 注入提醒,带 `NoMemory: true` ——
提醒是瞬时信号,不是记忆内容。通道 `calendar` 同样声明为 NoMemory。
Upload the .hmap file through the Plugin Manager API.
## 存储
事件存为 JSON,插件重启后保留。
## 构建
```bash
hmapdev build
```

View File

@ -2,6 +2,6 @@ module calendar
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
require github.com/JianFeeeee/homeagentsdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../

View File

@ -3,7 +3,7 @@
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@ -11,35 +12,35 @@ import (
"sync"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
const (
RepeatNone = "none"
RepeatDaily = "daily"
RepeatWeekday = "weekday"
RepeatWeekly = "weekly"
RepeatBiweekly = "biweekly"
RepeatMonthly = "monthly"
RepeatYearly = "yearly"
RepeatNone = "none"
RepeatDaily = "daily"
RepeatWeekday = "weekday"
RepeatWeekly = "weekly"
RepeatBiweekly = "biweekly"
RepeatMonthly = "monthly"
RepeatYearly = "yearly"
RepeatLunarYearly = "lunar_yearly"
)
type CalendarEvent struct {
ID string `json:"id"`
Title string `json:"title"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time,omitempty"`
AllDay bool `json:"all_day,omitempty"`
Location string `json:"location,omitempty"`
Note string `json:"note,omitempty"`
Reminds []int `json:"reminds,omitempty"`
RemindAt []int64 `json:"remind_at,omitempty"`
Repeat string `json:"repeat,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Lunar bool `json:"lunar,omitempty"`
LunarMonth int `json:"lunar_month,omitempty"`
LunarDay int `json:"lunar_day,omitempty"`
ID string `json:"id"`
Title string `json:"title"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time,omitempty"`
AllDay bool `json:"all_day,omitempty"`
Location string `json:"location,omitempty"`
Note string `json:"note,omitempty"`
Reminds []int `json:"reminds,omitempty"`
RemindAt []int64 `json:"remind_at,omitempty"`
Repeat string `json:"repeat,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Lunar bool `json:"lunar,omitempty"`
LunarMonth int `json:"lunar_month,omitempty"`
LunarDay int `json:"lunar_day,omitempty"`
}
type Plugin struct {
@ -276,13 +277,16 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
// 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err)
}
p.loadEvents()
@ -359,7 +363,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"today", sdk.ToolDef{
Name: tp + "today", Description: "Show today's events with countdown.",
Parameters: map[string]interface{}{
"type": "object",
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleToday)
@ -367,7 +371,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"week", sdk.ToolDef{
Name: tp + "week", Description: "Show this week's events grouped by day.",
Parameters: map[string]interface{}{
"type": "object",
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleWeek)
@ -398,7 +402,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.wg.Add(1)
go p.remindLoop()
fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events))
log.Printf("[%s] started (%d events)", p.name, len(p.events))
return nil
}
@ -406,7 +410,7 @@ func (p *Plugin) Stop() error {
p.remindTicker.Stop()
close(p.stopCh)
p.wg.Wait()
fmt.Printf("[%s] stopped\n", p.name)
log.Printf("[%s] stopped", p.name)
return nil
}
@ -520,7 +524,8 @@ func (p *Plugin) checkReminders() {
p.mu.Unlock()
for _, msg := range injectMsgs {
p.sdk.InjectInterruptText("calendar", "calendar", msg)
// NoMemory:日程到点提醒,不是记忆内容。
p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})
}
}
@ -607,9 +612,9 @@ func (p *Plugin) cleanupData() {
p.mu.Lock()
defer p.mu.Unlock()
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
fmt.Printf("[calendar] onRemove cleanup: %v\n", err)
log.Printf("[calendar] onRemove cleanup: %v", err)
} else {
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
log.Printf("[calendar] onRemove removed %s", p.eventsFile())
}
}

View File

@ -0,0 +1,28 @@
# commondev
commondev plugin
## Build
```bash
hmapdev build
```
## Channels
入站与出站是分开登记的两件事:
| 方向 | API | 用途 |
|---|---|---|
| 入站 inputch | RegisterInputChannel(name, def) | 声明「谁会往这个通道注入输入」。**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名都要登记** |
| 出站 output | RegisterOutputChannel(name, caps, desc, def, handler) | 声明 output_send__<name> 的回复发给谁;handler 收到 {payload,type,meta} |
def(ChannelDef)描述该通道在记忆计算层的行为:NoMemory: true = 该通道输入不进记忆;
Cleaner = 计算层清洗后再向量化/提关键词(原文不改)。
> 只登记出站通道、却用同名通道注入输入时,内核会兜底登记同名 inputch 并在日志里告警。
> 兜底只为兼容老插件 —— 请显式登记,让「这是入站通道」成为插件的明确意图。
## Install
Upload the .hmap file through the Plugin Manager API.

View File

@ -0,0 +1,222 @@
package main
// 「投到指定目录的指定 session」的判据。
//
// 用户问的是这句话能不能成立。成立的前提是**地址唯一确定一条线**:
// branch_id → 工作目录(program/<hash>-<name>)
// → pi session(SessionID)
// → 一对通道(InputCh / OutputCh)
//
// 这四者必须一一对应,且**不能被两条线共享**。共享的任何一环都会让
// "投到指定 session"变成"投到某一条(不确定)"。
//
// 最危险的一环是唯一性:独立 clone 没有 worktree 的
// 「同分支不可双挂」保护(git 会拒绝),所以插件必须自己补闸。
// 设计 §4.3 明确写了这条,但**此前没有判据钉住它**。
import (
"os"
"path/filepath"
"strings"
"testing"
)
// newTestPluginForAddr 造一个只做台账的 Plugin(不碰网络/进程)。
//
// ★ ledger.json 放在 t.TempDir() **之外**的独立临时目录:
// branch_open 会把 clone 转后台 goroutine(runPrepareWorkdir),
// 而它会在 programRoot() 下建目录 —— 与 t.TempDir() 的清理构成竞态
// (实测报 "TempDir RemoveAll cleanup: directory not empty")。
// 用 os.MkdirTemp + 手动清理可以让清理发生在 goroutine 之后。
func newTestPluginForAddr(t *testing.T) *Plugin {
t.Helper()
dir, err := os.MkdirTemp("", "cdaddr")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
p := newTestPluginForShell(t)
p.path = filepath.Join(dir, "ledger.json")
p.ledger = &Ledger{
Projects: map[string]*Project{},
Branches: map[string]*Branch{},
Jobs: map[string]*Job{},
Criteria: map[string]*Criteria{},
}
return p
}
// ★★★ 判据:同名分支不能开两条未结的工程对话(唯一性闸)。
//
// 这是"投到指定 session"的**前提**:若同一个 branch_id 能对应两条线,
// 或者两条线共用同一个地址,寻址就不再唯一。
//
// ★ 独立 clone 丢掉了 git 的天然保护:worktree 模式下同一分支被两棵
// worktree 挂载时 git 直接 `fatal: 'feat/a' is already used`,
// 而独立 clone 完全没有这层拦截 —— 必须由插件补,否则两条线会
// 各自改各自的目录然后互相覆盖。
func TestDuplicateBranchIsRejected(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
// 第一次开:应成功
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/a"})
m1, _ := r1.(map[string]interface{})
if m1["status"] != "ok" {
t.Fatalf("首次开分支应成功: %v", m1)
}
id1, _ := m1["branch_id"].(string)
if id1 == "" {
t.Fatal("应返回 branch_id")
}
// 第二次开同一个分支:应被唯一性闸拒绝
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/a"})
m2, _ := r2.(map[string]interface{})
if _, isErr := m2["error"]; !isErr {
t.Fatalf("★ 同名分支被允许开第二条(唯一性闸失效): %v", m2)
}
if !strings.Contains(m2["error"].(string), "唯一性闸") {
t.Errorf("拒绝理由应指明唯一性闸: %v", m2["error"])
}
// 台账里必须只有一条
if n := len(p.ledger.Branches); n != 1 {
t.Errorf("★ 台账里应只有 1 条分支,实际 %d", n)
}
}
// ★★ 判据:**已结**的分支可以重开(闸只挡"未结"的)。
//
// 闸太严会让"合入后想再改同一个分支名"变成不可能 ——
// 而那是正常做法(分支已 merged,重开一条新的同名线是合理的)。
func TestClosedBranchCanBeReopened(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/b"})
id1 := r1.(map[string]interface{})["branch_id"].(string)
// 标记为已合入
p.ledger.Branches[id1].Status = "merged"
// 重开应成功
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/b"})
m2, _ := r2.(map[string]interface{})
if m2["status"] != "ok" {
t.Fatalf("已结分支应可重开: %v", m2)
}
// 但 id 相同 ⇒ 会覆盖旧台账项;这本身是设计取舍,只断言不报错
}
// ★★ 判据:review 状态同样算"未结"(不能趁复核期再开一条同类线)。
func TestReviewStatusStillBlocksDuplicate(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c",
"work_desc": "x"})
id1 := r1.(map[string]interface{})["branch_id"].(string)
p.ledger.Branches[id1].Status = "review"
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c"})
if _, isErr := r2.(map[string]interface{})["error"]; !isErr {
t.Errorf("★ review 状态应算未结,但被允许重开")
}
}
// ★★★ 判据:地址四元组**一一对应**且互不共享。
//
// branch_id 必须唯一映射到 一个目录 + 一个 session + 一对通道。
// 任意两条线共用其中任何一项,寻址就不唯一("投到指定 session"退化为
// "投到某一条")。
func TestAddressTupleIsUniquePerBranch(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
// 造两条不同的线
for _, br := range []string{"feat/x", "feat/y"} {
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": br})
if _, isErr := r.(map[string]interface{})["error"]; isErr {
t.Fatalf("开 %s 失败: %v", br, r)
}
}
if len(p.ledger.Branches) != 2 {
t.Fatalf("应有 2 条线,实际 %d", len(p.ledger.Branches))
}
seenDir := map[string]string{}
seenID := map[string]bool{}
for id, b := range p.ledger.Branches {
if seenID[id] {
t.Errorf("★ branch_id 重复: %s", id)
}
seenID[id] = true
if b.Workdir == "" {
t.Errorf("★ %s 没有工作目录", id)
}
// 目录不能共享
if prev, dup := seenDir[b.Workdir]; dup {
t.Errorf("★ 两条线共用同一目录: %s(%s 与 %s)—— 投到目录无法区分线", b.Workdir, prev, id)
}
seenDir[b.Workdir] = id
}
// ★ 关键:不同分支名必须得到不同目录(设计 §4.3 的目录映射不变式)
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat.z"})
_ = r1
// feat.z 与 feat/z 派生后 slug 相同,但目录 hash 含原始分支名 ⇒ 目录不同
d1 := branchDir(p.programRoot(), "proj", "feat/z")
d2 := branchDir(p.programRoot(), "proj", "feat.z")
if d1 == d2 {
t.Errorf("★ feat/z 与 feat.z 得到了同一目录 %s —— 两条线会互相覆盖", d1)
}
if filepath.Base(d1) == filepath.Base(d2) {
t.Errorf("★ 目录基名相同 %s,说明 hash 没把分支名纳入", filepath.Base(d1))
}
}
// ★★ 判据:投递必须打到**该 id 自己的**通道。
//
// deliverToPi 从台账取 SessionID;通道名由 id 派生。
// 若两条线的通道名撞了,内核路由就会把任务投到错的那条 pi。
func TestChannelNamesAreUniquePerBranch(t *testing.T) {
names := map[string]string{}
for _, id := range []string{
"br-proj-feat_a", "br-proj-feat_b", "br-other-feat_a",
} {
in := inputChannelName(id)
out := outputChannelName(id)
for _, n := range []string{in, out} {
if prev, dup := names[n]; dup {
t.Errorf("★ 通道名冲突 %q(%s 与 %s)—— 投递会打到错的后端", n, prev, id)
}
names[n] = id
}
// outputch 不能含斜杠(会拼成 output_send__<name>)
if strings.Contains(out, "/") {
t.Errorf("★ outputch 含斜杠: %q(output_send__ 名不允许)", out)
}
// 上游要求 ^[a-zA-Z0-9_-]{1,64}$
if !validNamePattern.MatchString(out) || len(out) > 64 {
t.Errorf("outputch %q 不合规(须匹配 ^[a-zA-Z0-9_-]{1,64}$)", out)
}
}
}
// ★★ 判据:**会话漂移必须响亮失败**(不静默改投新会话)。
//
// 这是"投到指定 session"的守门人:如果 pi 返回了不同的 session_id
// 而插件默默接受,任务就投到了另一条会话 —— 而调用方以为投到了指定的那条。
func TestSessionDriftIsRejectedNotSilentlyAccepted(t *testing.T) {
b, err := os.ReadFile("plugin.go")
if err != nil {
t.Skip("读不到 plugin.go")
}
src := string(b)
if !strings.Contains(src, "会话漂移") {
t.Fatal("★ deliverToPi 缺少会话漂移检查")
}
// 必须**拒绝**而不是记录后继续
if !strings.Contains(src, "拒绝继续") {
t.Error("★ 会话漂移应明确拒绝,而非仅记录")
}
}

View File

@ -0,0 +1,338 @@
package main
// 对齐审计:**逐个调用**已注册工具,检查它们真的能工作。
//
// 为何要这个:注册了名字 ≠ 能用。上一轮补了工具,但「能注册」只证明
// 编译通过。本文件对每个工具做一次最小真实调用,把「写了但没接上」
// (空返回、nil handler、路径写死错、参数名不匹配)暴露出来。
//
// 判定原则:
// - 返回 error ≠ 失败(很多工具对缺参数就是返回 error,那是正确的响亮行为)
// - 真正的失败是:panic、handler 为 nil、或**参数齐全却依然拒绝**
// - 这里的目标是发现"连正确调用都走不通"的工具
import (
"os"
"path/filepath"
"strings"
"testing"
)
// listRegisteredTools 从 plugin.go 里抽出注册的工具名。
// 用源码而非运行期 sdk,因为 SDK 的 mock 不暴露注册表。
func listRegisteredTools(t *testing.T) []string {
t.Helper()
b, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
var out []string
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
const p = `p.sdk.RegisterTool("`
if i := strings.Index(line, p); i >= 0 {
rest := line[i+len(p):]
if j := strings.Index(rest, `"`); j > 0 {
out = append(out, rest[:j])
}
}
}
return out
}
// handlerFor 通过名字映射到实际 handler 函数。
//
// ★ 这里刻意**手写映射**而不是反射:如果新增了工具却忘了在这里登记,
// 下面的覆盖率判据会报出来 —— 这正好是"新工具没接 handler"的探测。
func handlerFor(p *Plugin, name string) func(map[string]interface{}) (interface{}, error) {
switch name {
case "commondev_project_add", "commondev_project_open":
return p.handleProjectAdd
case "commondev_project_list":
return p.handleProjectList
case "commondev_branch_open":
return p.handleBranchOpen
case "commondev_branch_status":
return p.handleBranchStatus
case "commondev_branch_close":
return p.handleBranchClose
case "commondev_job_status":
return p.handleJobStatus
case "commondev_agent_up":
return p.handleAgentUp
case "commondev_agent_state":
return p.handleAgentState
case "commondev_agent_down":
return p.handleAgentDown
case "commondev_workdir_prepare":
return p.handleWorkdirPrepare
case "commondev_workdir_clean":
return p.handleWorkdirClean
case "commondev_test_define":
return p.handleTestDefine
case "commondev_test_list":
return p.handleTestList
case "commondev_test_run":
return p.handleTestRun
case "commondev_test_mutate":
return p.handleTestMutate
case "commondev_review_submit":
return p.handleReviewSubmit
case "commondev_review_result":
return p.handleReviewResult
case "commondev_conflict_check":
return p.handleConflictCheck
case "commondev_parallel_status":
return p.handleParallelStatus
case "commondev_pi_config_get":
return p.handlePiConfigGet
case "commondev_pi_config_set":
return p.handlePiConfigSet
case "commondev_pi_source_add":
return p.handlePiSourceAdd
case "commondev_pi_source_list":
return p.handlePiSourceList
case "commondev_pi_source_remove":
return p.handlePiSourceRemove
case "commondev_pi_source_default":
return p.handlePiSourceDefault
case "commondev_pi_source_import":
return p.handlePiSourceImport
case "commondev_pi_package_install", "commondev_pi_plugin_install":
return p.handlePiPackageInstall
case "commondev_pi_plugin_list":
return p.handlePiPluginList
case "commondev_pi_plugin_remove":
return p.handlePiPluginRemove
case "commondev_pi_plugin_update":
return p.handlePiPluginUpdate
case "commondev_pi_mcp_list":
return p.handlePiMCPList
case "commondev_pi_mcp_add":
return p.handlePiMCPAdd
case "commondev_pi_mcp_remove":
return p.handlePiMCPRemove
case "enc_store":
return p.handleEncStore
case "enc_list":
return p.handleEncList
case "enc_show":
return p.handleEncShow
case "enc_rm":
return p.handleEncRm
case "enc_shell":
return p.handleEncryptedShell
case "commondev_endpoint_set":
return p.handleEndpointSet
case "commondev_endpoint_list":
return p.handleEndpointList
case "commondev_endpoint_remove":
return p.handleEndpointRemove
}
return nil
}
// ★★ 判据:**每个注册的工具都必须有对应 handler**。
//
// 这条能抓出「加了 RegisterTool 却指向不存在的 handler」或
// 「加了工具但这里没同步登记」(后者是需要人补映射,属于审计信号)。
func TestEveryRegisteredToolHasHandler(t *testing.T) {
p := newAuditPlugin(t)
tools := listRegisteredTools(t)
if len(tools) < 40 {
t.Fatalf("注册工具数异常偏少(%d),插件文件可能没被正确解析", len(tools))
}
var missing []string
for _, name := range tools {
if handlerFor(p, name) == nil {
missing = append(missing, name)
}
}
if len(missing) > 0 {
t.Fatalf("★ 这些工具没有可用 handler(注册了但没接上): %v", missing)
}
t.Logf("已注册 %d 个工具,全部有 handler", len(tools))
}
// ★★ 判据:**无参数调用不得 panic**。
//
// 工具会遇到模型乱传参数(缺字段、类型错)。正确行为是返回 error,
// 不是崩掉整个插件进程。
func TestNoToolPanicsOnEmptyArgs(t *testing.T) {
p := newAuditPlugin(t)
for _, name := range listRegisteredTools(t) {
h := handlerFor(p, name)
if h == nil {
continue
}
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("★ %s 空参数 panic: %v", name, r)
}
}()
// 空 map + nil map 都要试:模型可能传 null
h(map[string]interface{}{})
h(nil)
}()
}
}
// ★★★ 判据:**本轮补的工具必须真的能完成一次正常调用**。
//
// 与前两条不同:这里给足参数,检查业务是否真走通。
// 「注册 + 不 panic」还不够 —— 一条写死的错误路径也能满足那两条。
func TestNewlyAddedToolsActuallyWork(t *testing.T) {
p := newAuditPlugin(t)
p.ledger.Projects["proj"] = &Project{
Name: "proj", RemoteURL: "https://example.invalid/o/r.git", DefaultBranch: "main"}
dir := t.TempDir()
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "proj", Name: "feat/x", Workdir: dir, Status: "open"}
// ① agent_state 应报出真实状态(此前完全不存在)
res, err := p.handleAgentState(map[string]interface{}{"branch_id": "br-1"})
if err != nil {
t.Fatal(err)
}
m := res.(map[string]interface{})
if m["branch_id"] != "br-1" || m["agent_alive"] != false {
t.Errorf("agent_state 返回不对: %v", m)
}
// ② agent_down 应成功停掉(无进程时也幂等成功)
res2, err := p.handleAgentDown(map[string]interface{}{"branch_id": "br-1"})
if err != nil {
t.Fatal(err)
}
if res2.(map[string]interface{})["status"] != "ok" {
t.Errorf("agent_down 未成功: %v", res2)
}
// ③ workdir_clean 默认 dry_run,且能识别孤儿
root := p.programRoot()
os.MkdirAll(filepath.Join(root, "deadbeef-feat_x"), 0o755)
res3, err := p.handleWorkdirClean(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
m3 := res3.(map[string]interface{})
if m3["status"] != "dry_run" {
t.Errorf("workdir_clean 默认应为 dry_run: %v", m3)
}
if n, _ := m3["count"].(int); n != 1 {
t.Errorf("应识别 1 个孤儿目录,实际 %v", m3["orphans"])
}
// ④ pi_config_get 必须能读到结构(即使没有任何 provider)
res4, err := p.handlePiConfigGet(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
if _, ok := res4.(map[string]interface{})["providers"]; !ok {
t.Errorf("pi_config_get 缺少 providers 字段: %v", res4)
}
}
// ★★ 判据:**设计文档点名的工具必须真的存在**。
//
// 这条把「文档说做了」与「代码里有」绑起来 —— 上一轮就是靠它
// 发现 pr_open / agent_state 等工具根本不存在。
//
// ⚠ 2026-10-15 起:**git 托管平台专有的工具已不在清单里**
// (account_* / repo_search / pr_* / protect_branch)—— 那些能力进 skill,
// 由 agent 用 safeshell + curl 完成。
func TestDesignNamedToolsExist(t *testing.T) {
tools := listRegisteredTools(t)
have := map[string]bool{}
for _, n := range tools {
have[n] = true
}
want := []string{
"commondev_project_add", "commondev_project_list",
"commondev_branch_open", "commondev_branch_status", "commondev_branch_close",
"commondev_workdir_prepare", "commondev_workdir_clean",
"commondev_agent_up", "commondev_agent_state", "commondev_agent_down",
"commondev_pi_plugin_install", "commondev_pi_plugin_remove", "commondev_pi_plugin_list",
"commondev_pi_source_import", "commondev_pi_source_add", "commondev_pi_source_list",
"commondev_pi_source_default", "commondev_pi_config_get",
"commondev_test_define", "commondev_test_run", "commondev_test_mutate",
"commondev_review_submit",
}
var missing []string
for _, w := range want {
if !have[w] {
missing = append(missing, w)
}
}
if len(missing) > 0 {
t.Fatalf("设计点名的工具缺失: %v", missing)
}
}
// ★ 判据:**git 平台专有的工具已全部移出插件**。
//
// 这条钉住 2026-10-15 的架构决定:平台知识进 skill。若有人又把
// account_* / pr_* / protect_branch 加回插件,这里会报红。
func TestPlatformToolsAreNotInPlugin(t *testing.T) {
tools := listRegisteredTools(t)
have := map[string]bool{}
for _, n := range tools {
have[n] = true
}
for _, forbidden := range []string{
"commondev_account_add", "commondev_account_list", "commondev_account_test",
"commondev_account_remove", "commondev_repo_search",
"commondev_pr_open", "commondev_pr_status", "commondev_pr_approve",
"commondev_pr_merge", "commondev_protect_branch",
} {
if have[forbidden] {
t.Errorf("★ %s 属 git 平台专有能力,不应再注册在插件里(应进 skill + safeshell)", forbidden)
}
}
}
// ★★ 判据:**平台专有代码不得回到插件里**。
//
// 钉住 2026-10-15 的架构决定(用户拍板):git 托管平台的知识进 skill,
// 插件只保留与平台无关的机制。若有人把 platformSpec / 按平台构造认证头
// 又加回来,这里会报红。
//
// 为什么要用判据而不只是文档:文档说了还会有人(包括将来的我)图省事加回去,
// 而那种回退在**功能上看起来完全正常** —— 只是把可移植性换成了硬编码。
func TestNoPlatformSpecificCodeInPlugin(t *testing.T) {
// 逐文件扫源码(排除测试与注释性提及由人工判断)
files := []string{"plugin.go", "credentials.go", "pi_tools.go", "criteria.go",
"encstore.go", "encshell.go", "conflict.go", "workdir.go", "workdir_security.go",
"util.go", "llmsource.go", "sidecar.go"}
forbidden := map[string]string{
"platformSpec(": "按平台构造请求(认证头/端点差异)应进 skill",
"plaftformSpec": "平台适配结构体",
"DefaultAPI": "硬编码平台 API 前缀",
"AuthHeader:": "硬编码平台认证头",
"RoleExecutor": "平台角色语义",
"handleAccountAdd": "平台凭据工具",
"handleRepoSearch": "平台搜索工具",
"handlePROpen": "平台 PR 工具",
"handleProtectBranch": "平台分支保护工具",
}
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
continue
}
src := string(b)
for sym, why := range forbidden {
// 只认代码出现,不认注释里的解释性提及
for i, line := range strings.Split(src, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
continue // 注释里说明"已移出"是允许的
}
if strings.Contains(line, sym) {
t.Errorf("★ %s:%d 含平台专有代码 %q(%s)", f, i+1, sym, why)
}
}
}
}
}

View File

@ -0,0 +1,103 @@
package main
import (
"os"
"strings"
"testing"
)
// ★ 判据:慢操作(clone/fetch/测试)**绝不能**出现在工具 handler 的同步路径里。
//
// 依据(实测踩到):内核把每次工具调用硬限制在 **60 秒**
// (internal/agent/core/toolcall.go:62,硬编码无配置项)。同步 clone 的后果是
// 工具报超时、但 mirror 其实已建好 —— 调用方只看到失败,状态却是"做了一半"。
//
// 这条判据用**源码文本**做检查(同本仓既有做法:判据类检查必须先剔除注释)。
func TestNoSyncCloneInToolHandlers(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
// 剔除注释,避免注释里提到 runGit 也会命中
body := stripComments(string(src))
// 工具 handler 的区间:从 handleProjectAdd 到 handleJobStatus 之前
start := strings.Index(body, "func (p *Plugin) handleProjectAdd")
end := strings.Index(body, "func (p *Plugin) handleJobStatus")
if start < 0 || end < 0 || end <= start {
t.Fatal("找不到 handler 区间(函数名可能已改)")
}
handlers := body[start:end]
// handleBranchOpen 的**后台函数**是允许慢操作的,单独排除
if i := strings.Index(handlers, "func (p *Plugin) runPrepareWorkdir"); i >= 0 {
handlers = handlers[:i]
}
for _, bad := range []string{"runGit(", "prepareWorkdir("} {
if strings.Contains(handlers, bad) {
t.Errorf("工具 handler 里出现慢操作 %s —— 会撞 60s 工具上限;应转后台(见 runPrepareWorkdir)", bad)
}
}
// 但后台函数自己必须用到它(否则重构把功能删了)
if !strings.Contains(body, "func (p *Plugin) runPrepareWorkdir") ||
!strings.Contains(body, "prepareWorkdir(pr, branch, workdir)") {
t.Error("runPrepareWorkdir 未实现或未调用 prepareWorkdir —— 目录准备功能丢了")
}
// ★ 且必须切到目标分支(“每个目录下是独立仓库的独立分支”——用户原话)
//
// 判据必须检查**两条路径都切**:新建路径(clone 后)与复用路径(目录已存在)。
// 为何要查两处:初版只查字符串存在,而 ensureOnBranch 的**定义**本身
// (func … ensureOnBranch(branch, workdir string))就包含该子串 —— 于是
// 只删调用点、定义还在,判据仍然全绿(变异实验当场发现了这个漏洞)。
// ⇒ 改为数**调用点**个数(排除定义行)。
calls := 0
for _, ln := range strings.Split(body, "\n") {
t := strings.TrimSpace(ln)
if strings.HasPrefix(t, "func ") {
continue // 定义行不算
}
if strings.Contains(t, "p.ensureOnBranch(branch, workdir)") {
calls++
}
}
if calls < 2 {
t.Errorf("prepareWorkdir 切分支的调用点只有 %d 处(=new建路径+复用路径);"+
"缺一处就会让目录停在默认分支(main)", calls)
}
}
// stripComments 去掉 // 行注释与 /* */ 块注释。
//
// 为何必须先剔除:本仓既有教训 —— 注释里模仿真实签名会污染一切按文本匹配的判据。
func stripComments(s string) string {
var out strings.Builder
lines := strings.Split(s, "\n")
inBlock := false
for _, ln := range lines {
trimmed := strings.TrimSpace(ln)
if inBlock {
if idx := strings.Index(ln, "*/"); idx >= 0 {
inBlock = false
ln = ln[idx+2:]
} else {
out.WriteString("\n")
continue
}
}
if strings.HasPrefix(trimmed, "/*") {
if idx := strings.Index(ln, "*/"); idx >= 0 {
ln = ln[:strings.Index(ln, "/*")] + ln[idx+2:]
} else {
inBlock = true
out.WriteString("\n")
continue
}
}
if i := strings.Index(ln, "//"); i >= 0 {
ln = ln[:i]
}
out.WriteString(ln + "\n")
}
return out.String()
}

View File

@ -0,0 +1,216 @@
package main
// 审计补全项的判据。
//
// 这些判据对应审计发现的**真问题**(不是补全功能的装饰):
// · push 的凭据纪律(设计 §7.2)—— 曾经 pushURLWithToken 是死代码,
// 意味着「凭据不进 remote URL」这条纪律**从未被真正执行过**
// · 孤儿清理(listOrphanDirs 曾是死代码)
// · pi 配置写入的备份/回滚(设计 §3.6.4 纪律 2)
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func newAuditPlugin(t *testing.T) *Plugin {
t.Helper()
p := newTestPluginForCriteria(t)
return p
}
// ★★ 判据:凭据残留检查能识别 `scheme://user:pass@host` 形态。
func TestGitConfigCredentialResidueDetection(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, ".git"), 0o755)
os.WriteFile(filepath.Join(dir, ".git", "config"),
[]byte("[remote \"origin\"]\n\turl = https://user:tok3n@example.com/o/r.git\n"), 0o644)
if got := gitConfigCredentialResidue(dir); got == "" {
t.Fatal("★ 未检出内嵌凭据(设计 §7.2 要求 workdir_clean 内置此检查)")
}
// 干净的不应误报
dir2 := t.TempDir()
os.MkdirAll(filepath.Join(dir2, ".git"), 0o755)
os.WriteFile(filepath.Join(dir2, ".git", "config"),
[]byte("[remote \"origin\"]\n\turl = https://example.com/o/r.git\n"), 0o644)
if got := gitConfigCredentialResidue(dir2); got != "" {
t.Errorf("干净 remote 被误报: %s", got)
}
// scp 形态的 git@host 不是凭据(用户提示有误伤风险)
dir3 := t.TempDir()
os.MkdirAll(filepath.Join(dir3, ".git"), 0o755)
os.WriteFile(filepath.Join(dir3, ".git", "config"),
[]byte("[remote \"origin\"]\n\turl = git@github.com:o/r.git\n"), 0o644)
if got := gitConfigCredentialResidue(dir3); got != "" {
t.Errorf("scp 形态 git@host 被误报成凭据: %s", got)
}
}
// ★★ 判据(设计 §7.2 + 审计发现):workdir_clean
// - **默认 dry_run**(删除不可逆)
// - 只删**本插件生成的孤儿目录**,不碰台账里的目录、不碰陌生目录
func TestWorkdirCleanOnlyRemovesOrphans(t *testing.T) {
p := newAuditPlugin(t)
root := p.programRoot()
os.MkdirAll(root, 0o700)
known := "aabbccdd-feat_known"
orphan := "11223344-feat_orphan"
stranger := "not-ours-just-a-dir"
for _, d := range []string{known, orphan, stranger} {
os.MkdirAll(filepath.Join(root, d), 0o755)
}
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "proj", Name: "feat/known",
Workdir: filepath.Join(root, known), Status: "open"}
// ① dry_run 默认:什么都不删
res, err := p.handleWorkdirClean(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
if res.(map[string]interface{})["status"] != "dry_run" {
t.Fatalf("默认应为 dry_run,实际 %v", res.(map[string]interface{})["status"])
}
for _, d := range []string{known, orphan, stranger} {
if _, err := os.Stat(filepath.Join(root, d)); err != nil {
t.Fatalf("★ dry_run 却删掉了 %s", d)
}
}
// ② 实际清理:只删孤儿
res2, err := p.handleWorkdirClean(map[string]interface{}{"dry_run": false})
if err != nil {
t.Fatal(err)
}
removed, _ := res2.(map[string]interface{})["removed"].([]string)
if len(removed) != 1 || removed[0] != orphan {
t.Fatalf("应只删 %s,实际 %v", orphan, removed)
}
if _, err := os.Stat(filepath.Join(root, known)); err != nil {
t.Fatal("★ 删掉了台账里在用的目录")
}
if _, err := os.Stat(filepath.Join(root, stranger)); err != nil {
t.Fatal("★ 删掉了不是本插件生成的目录(越界)")
}
}
// ★★ 判据:agent_down 清掉 session/通道绑定,但**保留工作目录与状态**。
//
// 它与 branch_close 的区别正是"暂停"vs"收尾";若 agent_down 也删目录,
// 二者就没有区别了(而我实现时差点把两者写成一个)。
func TestAgentDownKeepsWorkdir(t *testing.T) {
p := newAuditPlugin(t)
dir := t.TempDir()
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "proj", Name: "feat/x", Workdir: dir,
Status: "open", SessionID: "s1", InputCh: "commondev/br-1", OutputCh: "commondev_br-1"}
res, err := p.handleAgentDown(map[string]interface{}{"branch_id": "br-1"})
if err != nil {
t.Fatal(err)
}
if res.(map[string]interface{})["status"] != "ok" {
t.Fatal("应成功")
}
b := p.ledger.Branches["br-1"]
if b.SessionID != "" || b.InputCh != "" || b.OutputCh != "" {
t.Errorf("★ session/通道绑定应被清掉(留着会让人以为进程还在): %+v", b)
}
if b.Status != "open" {
t.Errorf("★ 状态不该变(这是暂停不是收尾),实际 %s", b.Status)
}
if _, err := os.Stat(dir); err != nil {
t.Fatal("★ agent_down 不该删工作目录(那是 branch_close 的事)")
}
}
// ---------------- pi 配置写入纪律(设计 §3.6.4) ----------------
// ★★★ 判据:**损坏的 models.json 必须拒绝覆盖**(纪律 2)。
//
// 静默当空会**抹掉用户已有的全部源** —— 那是最坏的一种"静默降级"。
func TestCorruptPiModelsIsNotSilentlyOverwritten(t *testing.T) {
dir := t.TempDir()
bad := "{ this is not json"
os.WriteFile(filepath.Join(dir, "models.json"), []byte(bad), 0o600)
if _, err := loadPiModels(dir); err == nil {
t.Fatal("★ 损坏的 models.json 未被拒绝 —— 静默当空会抹掉用户已有的源")
}
after, _ := os.ReadFile(filepath.Join(dir, "models.json"))
if string(after) != bad {
t.Fatal("损坏的文件被改动了")
}
}
// ★★ 判据:写坏时**回滚**(纪律 2:失败则回滚并响亮报错)。
func TestPiConfigRollbackRestoresPrevious(t *testing.T) {
dir := t.TempDir()
orig := `{"providers":{"keep":{"baseUrl":"http://keep","api":"openai-completions","models":[{"id":"m"}]}}}`
os.WriteFile(filepath.Join(dir, "models.json"), []byte(orig), 0o600)
backup, err := backupPiConfig(dir)
if err != nil {
t.Fatal(err)
}
// 模拟"写坏了"
os.WriteFile(filepath.Join(dir, "models.json"), []byte("{broken"), 0o600)
rollbackPiConfig(dir, backup)
back, _ := os.ReadFile(filepath.Join(dir, "models.json"))
if string(back) != orig {
t.Fatalf("★ 回滚未恢复原内容\n前: %s\n后: %s", orig, back)
}
}
// ★★ 判据:**api_key 绝不进任何返回结构**(纪律 3)。
func TestPiConfigGetNeverReturnsKey(t *testing.T) {
p := newAuditPlugin(t)
dir := p.piAgentDir()
os.MkdirAll(dir, 0o700)
os.WriteFile(filepath.Join(dir, "models.json"), []byte(
`{"providers":{"p1":{"baseUrl":"http://x","api":"openai-completions","apiKey":"SUPER_SECRET","models":[{"id":"m"}]}}}`), 0o600)
res, err := p.handlePiConfigGet(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
s := jsonDump(t, res)
if strings.Contains(s, "SUPER_SECRET") {
t.Fatalf("★★ api_key 泄漏到返回值里:%s", s)
}
if !strings.Contains(s, "key_set") {
t.Errorf("应报 key_set 而不是值: %s", s)
}
}
// jsonDump 把任意返回值转成 JSON 串(用于断言"某串不得出现")。
func jsonDump(t *testing.T, v interface{}) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// ★ 判据:pi 插件工具的**两套命名都能到达同一实现**(早期稿用 plugin,细化稿用 package)。
func TestPiPluginToolAliasesRegistered(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
for _, name := range []string{
`"commondev_pi_plugin_install"`, `"commondev_pi_package_install"`,
`"commondev_pi_plugin_list"`, `"commondev_pi_plugin_remove"`,
} {
if !strings.Contains(s, name) {
t.Errorf("工具 %s 未注册(两套命名应都可用)", name)
}
}
}

View File

@ -0,0 +1,84 @@
package main
import "testing"
// 钉住文档 §2.2 的命名约束:
// inputch 可含斜杠;outputch **必须**只剩 [A-Za-z0-9_-],
// 否则会产出非法的 output_send__<name>(上游 400、agent 整体失语)。
func TestOutputChannelNameIsToolSafe(t *testing.T) {
cases := []string{
"br-TrueAgent-feat_x",
"br-p1-feat/x", // 分支名带斜杠
"br-p1-feat.x", // 点
"br-p1-中文", // 非 ASCII
"br-p1-a b", // 空格
}
for _, id := range cases {
got := outputChannelName(id)
if !validToolName("output_send__" + got) {
t.Errorf("id=%q → outputch=%q ⇒ 工具名 %q 非法(会触发上游 400)",
id, got, "output_send__"+got)
}
// inputch 则允许斜杠(内核自己就用 child/<id>)
if in := inputChannelName(id); in != "commondev/"+id {
t.Errorf("inputch 应原样保留: %q", in)
}
}
}
// validToolName 复刻上游对函数名的约束。
func validToolName(name string) bool {
if len(name) == 0 || len(name) > 64 {
return false
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z',
r >= '0' && r <= '9', r == '_', r == '-':
default:
return false
}
}
return true
}
// 台账往返:保存后能读回,且损坏时拒绝静默重建。
func TestLedgerRoundTrip(t *testing.T) {
p := &Plugin{name: "commondev"}
p.path = t.TempDir() + "/ledger.json"
if err := p.loadLedger(); err != nil {
t.Fatal(err)
}
p.ledger.Projects["demo"] = &Project{Name: "demo", RemoteURL: "https://x/y.git"}
if err := p.saveLocked(); err != nil {
t.Fatal(err)
}
q := &Plugin{name: "commondev", path: p.path}
if err := q.loadLedger(); err != nil {
t.Fatal(err)
}
if q.ledger.Projects["demo"] == nil {
t.Fatal("台账未往返")
}
}
// 钉住名字校验方向(这条是**真 bug 的回归测试**)。
//
// 初版用排除式正则 `[^a-zA-Z0-9_-]` 配 MatchString 判"合法",恰好反了:
// 合法名不含违法字符 ⇒ 返回 false ⇒ 把 `cdtest` 判非法。
// 是端到端跑时由 agent 调工具发现的 —— 所以它必须有回归判据。
func TestValidName(t *testing.T) {
legal := []string{"cdtest", "TrueAgent", "feat_x", "a-b", "A1"}
for _, s := range legal {
if !validName(s) {
t.Errorf("合法名被拒: %q(校验方向反了?)", s)
}
}
illegal := []string{"feat/x", "a b", "中文", "a.b", "", "a/b/c"}
for _, s := range illegal {
if validName(s) {
t.Errorf("非法名被放行: %q", s)
}
}
}

View File

@ -0,0 +1,400 @@
package main
// 刀 3:多项目/并行下的 **conflict 预检** 与 **base_sha 记录**。
//
// ## 为什么需要这一刀
//
// 设计 §4.2:`base_sha` 是用户原描述里**缺失**的一环,但必须补:
//
// 没有它,插件无法判断「两个 PR 改了同一文件时第二个该不该 rebase、
// 能不能自动合」,出问题也无法重放。
//
// 并行开发(§4.4)让这个问题从"可能"变成"必然":每分支一份独立 clone,
// 两棵树**互不可见**,冲突不会在开发时暴露,而是全部堆到 merge 时爆发。
// 那时才发现"两个分支改了同一个文件的同一段"就太晚了 ——
// 复审已经通过、判据已经变绿,但合不进去。
//
// ## 做法:在**提 PR 之前**算一次可合并性
//
// 用 `git merge-tree --write-tree` —— ★ 它**完全不碰工作区**(不写索引、
// 不动 HEAD、不留 MERGE_HEAD),所以可以在任何时候安全地跑,
// 不需要先 stash/checkout,也不会破坏正在进行的开发。
//
// 退出码语义(实测 git 2.53):
//
// 0 → 可干净合并
// 1 → 有冲突
// 其它 → 命令本身失败(仓库损坏等),**不能当成"无冲突"**
//
// ## 与旧写法(`git merge-tree <base> <b1> <b2>`)的对比
//
// 旧语法输出是给人看的文本,要靠 grep `CONFLICT` 判断 —— 脆弱且没有
// 明确的退出码。新语法(git 2.38+)直接给树 OID 与退出码。
// 本仓的环境是 git 2.53,用新语法;同时保留旧语法的兜底判断。
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// ConflictReport 是一次冲突预检的结果。
type ConflictReport struct {
// Mergeable 是否可干净合并。
Mergeable bool `json:"mergeable"`
// Conflicts 是冲突文件列表(可为空)。
Conflicts []string `json:"conflicts,omitempty"`
// BaseSHA / HeadSHA 是本次比较的两端(用于审计与重放)。
BaseSHA string `json:"base_sha,omitempty"`
HeadSHA string `json:"head_sha,omitempty"`
// Target 是被合入的目标(通常是 main)。
Target string `json:"target"`
// Detail 是给人看的一行说明。
Detail string `json:"detail"`
// Skipped 为真表示预检被跳过(而非"检查通过了")。
//
// ★ 这个字段很关键:把"没检查"和"检查通过"混为一谈,
// 会让调用方以为安全 —— 而实际什么都没验。
Skipped bool `json:"skipped,omitempty"`
}
// ────────────────────────── base_sha 记录 ──────────────────────────
// captureBaseSHA 记录工作目录当前 HEAD(开线时的基点)。
//
// 必须在**第一次投递任务之前**调用:agent 一旦开始改,HEAD 就不再是基点了。
// 拿不到时如实返回错误(不静默留空 —— 空 base_sha 会让后续冲突判定失据)。
func (p *Plugin) captureBaseSHA(branchID, workdir string) (string, error) {
sha, err := runGitOut(workdir, "rev-parse", "HEAD")
if err != nil {
return "", fmt.Errorf("取 base_sha 失败: %w", err)
}
sha = strings.TrimSpace(sha)
// 空值守卫:实测 `git rev-parse HEAD` 要么成功给非空 sha、要么报错,
// 所以这条**实际不可达** —— 保留它是防御性的,不必为它写判据
//(写了会是"永远绿"的假判据,反而掩盖覆盖率的真实情况)。
if sha == "" {
return "", fmt.Errorf("取 base_sha 得到空值")
}
p.mu.Lock()
if b, ok := p.ledger.Branches[branchID]; ok {
// 只记第一次:重开同一分支时基点不该被后续改动覆盖
// (覆盖会让"这条分支从哪出发"永久丢失,冲突判定与重放都失去依据)
if b.BaseSHA == "" {
b.BaseSHA = sha
} else {
sha = b.BaseSHA
}
}
p.mu.Unlock()
return sha, nil
}
// ────────────────────────── 冲突预检 ──────────────────────────
// checkConflict 在**不动工作区**的前提下判断 branch 能否干净地合进 target。
//
// 两种实现路径,自动选择:
//
// ① 本地有该分支的 workdir ⇒ 直接在工作目录里跑 merge-tree(最快,无需网络)
// ② 否则 ⇒ 在 mirror 里跑(mirror 是 --mirror clone,含全部分支)
//
// ★ 为什么不在 PR 之后靠平台判定:那时已经推了分支、开了 PR,
//
// 冲突才暴露 → 白跑一轮。预检的意义就是**在浪费工作之前发现**。
func (p *Plugin) checkConflict(workdir, target string) ConflictReport {
rep := ConflictReport{Target: target}
if target == "" {
target = "main"
rep.Target = target
}
// 本地要有 target 的最新视角:mirror 的 fetch 由调用方保证
// (这里只做只读判定,不引入网络依赖 —— 预检失败不该阻断流程)
// 取 HEAD(本次要合入的提交)
head, err := runGitOut(workdir, "rev-parse", "HEAD")
if err != nil {
rep.Skipped = true
rep.Detail = "取 HEAD 失败,预检跳过(不等于无冲突): " + err.Error()
return rep
}
rep.HeadSHA = strings.TrimSpace(head)
// 目标分支在本地存在吗?不存在就没法预检(如实标记 Skipped)
if _, err := runGitOut(workdir, "rev-parse", "--verify", target); err != nil {
rep.Skipped = true
rep.Detail = fmt.Sprintf(
"本地没有 %s 引用,预检跳过(不等于无冲突)。"+
"先 fetch 目标分支再预检,或直接在平台上开 PR 让平台判定", target)
return rep
}
base, _ := runGitOut(workdir, "merge-base", target, "HEAD")
rep.BaseSHA = strings.TrimSpace(base)
// ★ 核心:merge-tree --write-tree(不碰工作区)
out, err := runGitOut(workdir, "merge-tree", "--write-tree", target, "HEAD")
if err == nil {
rep.Mergeable = true
rep.Detail = fmt.Sprintf("可干净合并进 %s(基点 %s)", target, shortSHA(rep.BaseSHA))
return rep
}
// 非零退出:区分「冲突」与「命令失败」。
//
// ★ 这个区分极其重要:把命令失败当成"有冲突"会误报(让人去做无谓 rebase),
// 把它当成"无冲突"则更危险(带着冲突去合)。两者都不行 ⇒ 分别处理。
if isMergeTreeConflict(out) {
rep.Mergeable = false
rep.Conflicts = parseConflictFiles(out)
rep.Detail = fmt.Sprintf("与 %s 有冲突(%d 个文件):%s",
target, len(rep.Conflicts), strings.Join(rep.Conflicts, ", "))
return rep
}
// 命令失败(如 git 版本不支持 --write-tree)⇒ 退回旧语法再判一次
if rep2, ok := p.checkConflictLegacy(workdir, target); ok {
rep2.BaseSHA, rep2.HeadSHA = rep.BaseSHA, rep.HeadSHA
return rep2
}
rep.Skipped = true
rep.Detail = "merge-tree 不可用(git 过旧?),预检跳过(不等于无冲突): " +
truncate(strings.TrimSpace(out), 200)
return rep
}
// checkConflictLegacy 用旧版 merge-tree 语法兜底(git < 2.38)。
//
// 旧语法输出是文本,靠 `CONFLICT` / 冲突标记判断 —— 脆弱,故只作兜底。
func (p *Plugin) checkConflictLegacy(workdir, target string) (ConflictReport, bool) {
rep := ConflictReport{Target: target}
base, err := runGitOut(workdir, "merge-base", target, "HEAD")
if err != nil {
return rep, false
}
out, _ := runGitOut(workdir, "merge-tree", strings.TrimSpace(base), target, "HEAD")
if !strings.Contains(out, "CONFLICT") && !strings.Contains(out, "<<<<<<<") {
rep.Mergeable = true
rep.Detail = fmt.Sprintf("可干净合并进 %s(旧语法判定)", target)
return rep, true
}
rep.Mergeable = false
rep.Conflicts = parseConflictFilesLegacy(out)
rep.Detail = fmt.Sprintf("与 %s 有冲突(旧语法判定,%d 个文件)",
target, len(rep.Conflicts))
return rep, true
}
// isMergeTreeConflict 判断 merge-tree 的失败是"有冲突"还是"命令本身失败"。
//
// 依据:git 在冲突时退出码为 1;命令错误(如未知参数)通常是 129 等。
// `runGitOut` 把 stderr 一起并进 out,所以同时看退出码语义与输出特征。
func isMergeTreeConflict(out string) bool {
if strings.Contains(out, "CONFLICT") {
return true
}
// 冲突时 merge-tree 会打印 "Auto-merging ... CONFLICT (content)",
// 或至少列出冲突阶段号(1/2/3 的 ls-tree 形态)
return strings.Contains(out, "Auto-merging") ||
strings.Contains(out, "Merge conflict")
}
// parseConflictFiles 从 `merge-tree --write-tree` 输出里提文件名。
//
// 输出形态(实测 git 2.53):
//
// <tree-oid>
// 100644 <oid> 1\tpath/to/file ← 阶段 1/2/3 即为冲突条目
// 100644 <oid> 2\tpath/to/file
// 100644 <oid> 3\tpath/to/file
// ...
// Auto-merging path
// CONFLICT (content): Merge conflict in path
func parseConflictFiles(out string) []string {
set := map[string]bool{}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
// 形态 ①:明确的 "CONFLICT ... in <path>"
if i := strings.Index(line, "CONFLICT"); i >= 0 {
if j := strings.Index(line, " in "); j > 0 {
set[strings.TrimSpace(line[j+4:])] = true
}
continue
}
// 形态 ②:ls-tree 阶段行 "100644 <oid> <stage>\t<path>"
if rest, ok := strings.CutPrefix(line, "100"); ok {
_ = rest
parts := strings.SplitN(line, "\t", 2)
if len(parts) == 2 {
fields := strings.Fields(parts[0])
// mode, oid, stage
if len(fields) == 3 && (fields[2] == "1" || fields[2] == "2" || fields[2] == "3") {
set[strings.TrimSpace(parts[1])] = true
}
}
}
}
out2 := make([]string, 0, len(set))
for f := range set {
out2 = append(out2, f)
}
sort.Strings(out2) // 稳定输出(map 顺序随机,回执要可对照)
return out2
}
// parseConflictFilesLegacy 从旧语法输出里提文件名(形态 "CONFLICT ... in <path>")。
func parseConflictFilesLegacy(out string) []string {
set := map[string]bool{}
for _, line := range strings.Split(out, "\n") {
if i := strings.Index(line, "CONFLICT"); i >= 0 {
if j := strings.Index(line, " in "); j > 0 {
set[strings.TrimSpace(line[j+4:])] = true
}
}
}
r := make([]string, 0, len(set))
for f := range set {
r = append(r, f)
}
sort.Strings(r)
return r
}
// shortSHA 截短 sha 供人阅读(空值原样返回)。
func shortSHA(s string) string {
if len(s) > 8 {
return s[:8]
}
return s
}
// ────────────────────────── 多项目/并行盘点 ──────────────────────────
// parallelSummary 汇总「当前有哪些分支线在并行推进」。
//
// 为何要它:并行是有前提的(设计 §8.4)—— 并行的必须是**互不相干的模块**。
// 若两条线改了同一文件,冲突会堆到 merge 时才爆发。这个汇总让主管
// **在派活之前**就能看到"眼下有哪几条线、各自在哪",从而判断该不该并行。
func (p *Plugin) parallelSummary() map[string]interface{} {
p.mu.Lock()
defer p.mu.Unlock()
byProject := map[string][]map[string]interface{}{}
for _, b := range p.ledger.Branches {
if b.Status != "open" && b.Status != "review" {
continue
}
byProject[b.ProjectID] = append(byProject[b.ProjectID], map[string]interface{}{
"branch_id": b.ID,
"branch": b.Name,
"status": b.Status,
"agent": b.AgentName,
"workdir": b.Workdir,
"base_sha": shortSHA(b.BaseSHA),
})
}
projects := make([]string, 0, len(byProject))
for k := range byProject {
projects = append(projects, k)
}
sort.Strings(projects)
out := make([]map[string]interface{}, 0, len(projects))
total := 0
for _, proj := range projects {
lines := byProject[proj]
total += len(lines)
out = append(out, map[string]interface{}{
"project": proj,
"count": len(lines),
"branches": lines,
})
}
return map[string]interface{}{
"projects": out,
"total_branches": total,
"warning": "并行只适合**互不相干的模块**。若多条线改了同一批文件," +
"冲突会堆到 merge 时才爆发 —— 派活前先确认边界(设计 §8.4)。",
}
}
// distinctWorkdirs 返回所有活跃分支的工作目录(供批量操作/清理)。
func (p *Plugin) distinctWorkdirs() []string {
p.mu.Lock()
defer p.mu.Unlock()
seen := map[string]bool{}
var out []string
for _, b := range p.ledger.Branches {
if b.Workdir == "" || seen[b.Workdir] {
continue
}
if _, err := os.Stat(filepath.Join(b.Workdir, ".git")); err != nil {
continue
}
seen[b.Workdir] = true
out = append(out, b.Workdir)
}
sort.Strings(out)
return out
}
// handleConflictCheck 是 commondev_conflict_check 的实现。
func (p *Plugin) handleConflictCheck(args map[string]interface{}) (interface{}, error) {
id := strings.TrimSpace(str(args, "branch_id"))
p.mu.Lock()
b, ok := p.ledger.Branches[id]
var workdir, base string
if ok {
workdir, base = b.Workdir, b.BaseSHA
}
proj := ""
if ok {
if pr, ok2 := p.ledger.Projects[b.ProjectID]; ok2 {
proj = pr.DefaultBranch
}
}
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", id)}, nil
}
target := strings.TrimSpace(str(args, "target"))
if target == "" {
target = proj
}
if target == "" {
target = "main"
}
if workdir == "" {
return map[string]interface{}{"error": "该分支没有工作目录(目录准备未完成?)"}, nil
}
rep := p.checkConflict(workdir, target)
out := map[string]interface{}{
"mergeable": rep.Mergeable,
"target": rep.Target,
"detail": rep.Detail,
"base_sha": shortSHA(base), // 开线时记下的基点(审计用)
}
if len(rep.Conflicts) > 0 {
out["conflicts"] = rep.Conflicts
out["advice"] = "先在本分支 rebase/合并目标分支并解决冲突,再提 PR。" +
"★ 不要带冲突去开 PR —— 平台会拒绝,且白跑一轮判据。"
}
if rep.Skipped {
out["skipped"] = true
out["warning"] = "预检被跳过 —— 这**不等于**没有冲突。"
}
return out, nil
}
// handleParallelStatus 是 commondev_parallel_status 的实现。
func (p *Plugin) handleParallelStatus(args map[string]interface{}) (interface{}, error) {
return p.parallelSummary(), nil
}

View File

@ -0,0 +1,335 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// 用**真实 git 输出**验证冲突解析(从 git 2.53 实测抓取)。
func TestParseConflictFilesRealOutput(t *testing.T) {
// 这是 git merge-tree --write-tree 冲突时的**原样输出**
const real = `82d5993c292e11020e08a83074413e7cc6e2d5d1
100644 df967b96a579e45a18b8251732d16804b2e56a55 1 f.txt
100644 f70f10e4db19068f79bc43844b49f3eece45c4e8 2 f.txt
100644 223b7836fb19fdf64ba2d3cd6173c6a283141f78 3 f.txt
Auto-merging f.txt
CONFLICT (content): Merge conflict in f.txt
`
got := parseConflictFiles(real)
if len(got) != 1 || got[0] != "f.txt" {
t.Fatalf("应从真实输出解出 [f.txt],实际 %v", got)
}
if !isMergeTreeConflict(real) {
t.Fatal("真实冲突输出应被识别为冲突")
}
}
// 无冲突时不该误判。
func TestNoConflictOutputNotMisjudged(t *testing.T) {
const clean = `3d501af9404b81d5e9d7fe6254d082ff59108312
`
if isMergeTreeConflict(clean) {
t.Fatal("干净合并的输出不该被判为冲突")
}
if got := parseConflictFiles(clean); len(got) != 0 {
t.Fatalf("无冲突时不该解出文件: %v", got)
}
}
// 多文件冲突要全部解出且稳定排序。
func TestParseMultipleConflictsStable(t *testing.T) {
const multi = `abc123
100644 aaa 1 z.txt
100644 bbb 2 z.txt
100644 ccc 3 z.txt
100644 ddd 1 a.txt
100644 eee 2 a.txt
100644 fff 3 a.txt
CONFLICT (content): Merge conflict in z.txt
CONFLICT (content): Merge conflict in a.txt
`
got := parseConflictFiles(multi)
if len(got) != 2 || got[0] != "a.txt" || got[1] != "z.txt" {
t.Fatalf("应解出 [a.txt z.txt] 且已排序,实际 %v", got)
}
// 重复出现(阶段行 + CONFLICT 行)不该重复计数
if len(got) != len(uniqueStrings(got)) {
t.Error("同一文件出现多次时应去重")
}
}
func uniqueStrings(s []string) []string {
seen := map[string]bool{}
var out []string
for _, v := range s {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
// ★★ 决定性判据:`checkConflict` 在**真实 git 仓库**上正确判定。
//
// 上面几条测的是 parser(纯函数)。这条跑真实 git —— 因为 parser 对
// 不代表「预检对」:命令拼错、退出码语义搞反、目标分支取错,
// 都只有真跑才暴露。
//
// 场景(与并行开发的真实情形一致):
//
// main ← base
// ├─ feat/a 改 f.txt → 合进 main(main 前进)
// └─ feat/b 改 f.txt → 此时预检必冲突
//
// 以及对照组:feat/c 改的是**另一个文件** → 预检应通过。
func TestCheckConflictOnRealGitRepo(t *testing.T) {
if _, err := os.Stat("/usr/bin/git"); err != nil {
if _, err2 := exec.LookPath("git"); err2 != nil {
t.Skip("环境无 git")
}
}
base := t.TempDir()
repo := filepath.Join(base, "w")
run := func(dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v 失败: %v\n%s", args, err, out)
}
return string(out)
}
// 建仓 + 基线
// ★ 显式指定初始分支为 main:git 默认分支名随版本/配置而变
//(本机是 master),不指定的话下面的 "main" 会找不到。
run(base, "init", "-q", "-b", "main", repo)
run(repo, "config", "user.email", "t@t")
run(repo, "config", "user.name", "t")
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("base\n"), 0o600)
run(repo, "add", "-A")
run(repo, "commit", "-qm", "base")
// feat/a 改 f.txt
run(repo, "checkout", "-q", "-b", "feat/a")
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("A\n"), 0o600)
run(repo, "commit", "-qam", "A")
// feat/b 也改 f.txt(与 a 同一个文件 → 冲突)
run(repo, "checkout", "-q", "main")
run(repo, "checkout", "-q", "-b", "feat/b")
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("B\n"), 0o600)
run(repo, "commit", "-qam", "B")
// feat/c 改另一个文件(不冲突)
run(repo, "checkout", "-q", "main")
run(repo, "checkout", "-q", "-b", "feat/c")
os.WriteFile(filepath.Join(repo, "g.txt"), []byte("C\n"), 0o600)
run(repo, "add", "-A")
run(repo, "commit", "-qm", "C")
p := &Plugin{ledger: &Ledger{}}
// ① main 还没前进 ⇒ feat/b 可干净合并
run(repo, "checkout", "-q", "feat/b")
rep := p.checkConflict(repo, "main")
if rep.Skipped {
t.Fatalf("不该跳过: %s", rep.Detail)
}
if !rep.Mergeable {
t.Fatalf("main 未前进时 feat/b 应可合并,实际: %s", rep.Detail)
}
if rep.HeadSHA == "" || rep.BaseSHA == "" {
t.Error("应记录 head_sha 与 base_sha(审计与重放要用)")
}
// ② 把 feat/a 合进 main(main 前进)⇒ feat/b 必冲突
run(repo, "checkout", "-q", "main")
run(repo, "merge", "-q", "--no-ff", "feat/a", "-m", "merge a")
run(repo, "checkout", "-q", "feat/b")
rep = p.checkConflict(repo, "main")
if rep.Skipped {
t.Fatalf("不该跳过: %s", rep.Detail)
}
if rep.Mergeable {
t.Fatal("★ main 已含 feat/a 的改动、feat/b 改了同一文件 ⇒ 必须判为冲突")
}
if len(rep.Conflicts) == 0 || rep.Conflicts[0] != "f.txt" {
t.Fatalf("应指出冲突文件 f.txt,实际 %v(detail=%s)", rep.Conflicts, rep.Detail)
}
// ③ 对照:feat/c 改的是另一文件 ⇒ 仍可合并
run(repo, "checkout", "-q", "feat/c")
rep = p.checkConflict(repo, "main")
if rep.Skipped {
t.Fatalf("不该跳过: %s", rep.Detail)
}
if !rep.Mergeable {
t.Fatalf("feat/c 改了无关文件,应可合并,实际: %s", rep.Detail)
}
// ④ ★ 关键:预检**不能改动工作区**
//(否则它就成了"会破坏开发状态"的副作用操作)
cur := strings.TrimSpace(run(repo, "rev-parse", "--abbrev-ref", "HEAD"))
if cur != "feat/c" {
t.Fatalf("预检把 HEAD 改到了 %s —— 它绝不该动工作区", cur)
}
if _, err := os.Stat(filepath.Join(repo, "MERGE_HEAD")); err == nil {
t.Fatal("预检留下了 MERGE_HEAD —— 会污染后续提交")
}
}
// ★★ 判据:**每一条 Skipped 路径都不得声称可合并**。
//
// 把"没检查"当成"检查通过"是最危险的静默降级 —— 调用方会以为安全。
//
// 为何要**逐路径**覆盖:checkConflict 有 3 条 Skipped 出口
// (取 HEAD 失败 / 目标分支不存在 / merge-tree 不可用),
// 初版只测了第 2 条,于是把第 1 条的 `rep.Mergeable` 改成 true 的变异
// **不报红** —— 判据覆盖不全,等于给其余两条留了洞。
func TestEverySkippedPathRefusesMergeable(t *testing.T) {
mkRepo := func(t *testing.T, withBranch bool) string {
repo := t.TempDir()
git := func(args ...string) {
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
cmd.Run()
}
if out, err := exec.Command("git", "init", "-q", "-b", "main", repo).CombinedOutput(); err != nil {
t.Skipf("git init 失败: %v %s", err, out)
}
git("config", "user.email", "t@t")
git("config", "user.name", "t")
os.WriteFile(filepath.Join(repo, "a.txt"), []byte("x\n"), 0o600)
git("add", "-A")
git("commit", "-qm", "init")
if withBranch {
git("checkout", "-q", "-b", "other")
}
return repo
}
p := &Plugin{ledger: &Ledger{}}
t.Run("目标分支不存在", func(t *testing.T) {
rep := p.checkConflict(mkRepo(t, false), "no-such-branch")
assertSkippedNotMergeable(t, rep)
})
t.Run("不是 git 仓库", func(t *testing.T) {
rep := p.checkConflict(t.TempDir(), "main")
assertSkippedNotMergeable(t, rep)
})
t.Run("工作目录不存在", func(t *testing.T) {
rep := p.checkConflict(filepath.Join(t.TempDir(), "nope"), "main")
assertSkippedNotMergeable(t, rep)
})
}
// assertSkippedNotMergeable 是上面三条路径共用的断言。
func assertSkippedNotMergeable(t *testing.T, rep ConflictReport) {
t.Helper()
if !rep.Skipped {
t.Fatalf("应标记 Skipped(detail=%s)", rep.Detail)
}
if rep.Mergeable {
t.Fatal("★ Skipped 时绝不能声称 Mergeable —— 那是把「没检查」当「检查通过」")
}
if !strings.Contains(rep.Detail, "不等于") {
t.Errorf("说明应明确「跳过≠无冲突」: %s", rep.Detail)
}
}
// ★★ 判据:`captureBaseSHA` 真的把 sha 写进台账,且**只写第一次**。
//
// "只写第一次"很重要:重开同一分支时若覆盖基点,"这条线从哪出发"
// 就永久丢失 —— 冲突判定与事后重放都失去依据。
func TestCaptureBaseSHARecordsOnceOnly(t *testing.T) {
repo := t.TempDir()
gitIn := func(args ...string) {
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
cmd.Run()
}
if out, err := exec.Command("git", "init", "-q", "-b", "main", repo).CombinedOutput(); err != nil {
t.Skipf("git init 失败: %v %s", err, out)
}
gitIn("config", "user.email", "t@t")
gitIn("config", "user.name", "t")
os.WriteFile(filepath.Join(repo, "a.txt"), []byte("1\n"), 0o600)
gitIn("add", "-A")
gitIn("commit", "-qm", "c1")
p := &Plugin{ledger: &Ledger{Branches: map[string]*Branch{
"br-x": {ID: "br-x", Name: "feat/x", Status: "open"},
}}}
sha1, err := p.captureBaseSHA("br-x", repo)
if err != nil {
t.Fatal(err)
}
if sha1 == "" || len(sha1) < 7 {
t.Fatalf("应拿到 sha,实际 %q", sha1)
}
if p.ledger.Branches["br-x"].BaseSHA != sha1 {
t.Fatal("base_sha 未写进台账")
}
// 再提交一次(HEAD 前进),再捕获 → 仍应返回**第一次**的 sha
os.WriteFile(filepath.Join(repo, "a.txt"), []byte("2\n"), 0o600)
gitIn("add", "-A")
gitIn("commit", "-qm", "c2")
sha2, err := p.captureBaseSHA("br-x", repo)
if err != nil {
t.Fatal(err)
}
if sha2 != sha1 {
t.Fatalf("基点不该被后续提交覆盖:第一次 %s,第二次 %s", shortSHA(sha1), shortSHA(sha2))
}
}
// ★ 判据:拿不到 sha 时**如实报错**,不静默返回空串。
//
// 空 base_sha 会让后续冲突判定失去依据,而"没记上"与"记了个空的"
// 在台账里长得一样 —— 所以必须报错。
func TestCaptureBaseSHAFailsLoudlyOnBadRepo(t *testing.T) {
p := &Plugin{ledger: &Ledger{Branches: map[string]*Branch{"br-y": {ID: "br-y"}}}}
if _, err := p.captureBaseSHA("br-y", filepath.Join(t.TempDir(), "not-a-repo")); err == nil {
t.Fatal("非 git 目录应报错,而不是返回空 sha")
}
}
// ★ 判据:**仅 ls-tree 阶段行**(无 "CONFLICT" 行)也要能解出文件名。
//
// 某些冲突类型(如 rename/rename)不打印 "CONFLICT ... in <path>",
// 只有 ls-tree 的三个阶段行。少了这条解析路径就会漏报冲突文件 ——
// 而"漏报"比"多报"危险:调用方以为没有冲突。
func TestParseConflictFromLsTreeOnly(t *testing.T) {
// 只有阶段行、没有 "CONFLICT ... in" 行(某些冲突类型如此)
const only = `abc123
100644 aaa 1 dir/x.go
100644 bbb 2 dir/x.go
100644 ccc 3 dir/x.go
`
got := parseConflictFiles(only)
if len(got) != 1 || got[0] != "dir/x.go" {
t.Fatalf("仅 ls-tree 形态也应解出 [dir/x.go],实际 %v", got)
}
}

View File

@ -0,0 +1,184 @@
package main
// 凭据存储:**保留的通用部分**。
//
// ## 2026-10-15 架构调整(用户拍板)
//
// **git 托管平台专有的部分已移出插件**(`platformSpec` / `account_*` 工具 /
// 角色语义 / 按平台构造认证头)—— 那些是**平台知识**,进 skill 由 agent
// 用 `safeshell` + `curl` 操作。理由:每接一个托管平台都要改 Go 代码、
// 重编译、重启服务,而平台差异只是「API 前缀 + 认证头」两个维度的不同。
//
// **本文件保留的是与平台无关的部分**:
// · 凭据的加密落盘(AES-256-GCM,自持密钥)
// · `${ENV_VAR}` 变量引用解析
// · 「不泄漏值」的展示形态
//
// 平台的用法见 skill `commondev-discipline`(含 Gitea/GitHub/GitLab/Gitee
// 的认证头对照表、PR 与分支保护的 curl 命令、以及三个实测踩到的 API 坑)。
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
)
// Account 是一条凭据。
//
// ★ 已移除 `Platform` / `Role` / `BaseURL` 等**平台语义**字段:
// 本插件不再按平台构造请求,也不再有「执行侧/主管侧」的角色区分 ——
// 那是平台侧的(分支保护)与 skill 侧的知识。
type Account struct {
Name string `json:"name"`
// Token 是**旧路径**:明文或 `${ENV_VAR}` 引用。
// ★ 新登记会立即加密进 Sealed,本字段随即清空(见 sealAllLocked)。
// 本字段**绝不出现在任何工具返回值里**(见 redacted)。
Token string `json:"token,omitempty"`
// Sealed 是 AES-256-GCM 加密后的凭据(base64,nonce 前置)。
//
// 为何要加密:文件虽 0600,但会被备份、打包、随日志外带;
// 加密后即使文件外泄,没有密钥也不可用。
Sealed string `json:"sealed,omitempty"`
// Username 仅用于 git 提交身份(可选)。
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
Note string `json:"note,omitempty"`
CreatedAt string `json:"created_at"`
}
// credentialsFile 是磁盘上的凭据集合(独立文件,0600)。
type credentialsFile struct {
Accounts map[string]*Account `json:"accounts"`
}
// varRefPattern 匹配 ${VAR} 或 $VAR 形式的变量引用。
var varRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
// credStore 是凭据管理器(与 Plugin 同生命周期)。
type credStore struct {
mu sync.Mutex
path string
data credentialsFile
// keyProvider 返回加密密钥(由 Plugin 注入)。
keyProvider func() ([]byte, error)
}
func newCredStore(path string) (*credStore, error) {
cs := &credStore{path: path, data: credentialsFile{Accounts: map[string]*Account{}}}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cs, nil
}
return nil, err
}
// ★ 启动时校验权限:凭据文件必须 0600(本仓纪律:响亮失败,不静默降级)
if fi, err := os.Stat(path); err == nil {
if perm := fi.Mode().Perm(); perm != 0o600 {
return nil, fmt.Errorf(
"凭据文件权限不安全:%s 是 %04o,必须是 0600(它是能 push 用户仓库的凭据)",
path, perm)
}
}
if err := json.Unmarshal(b, &cs.data); err != nil {
return nil, fmt.Errorf("凭据文件损坏(拒绝静默重建): %w", err)
}
if cs.data.Accounts == nil {
cs.data.Accounts = map[string]*Account{}
}
return cs, nil
}
// saveLocked 写入并确权(调用方持锁)。
func (cs *credStore) saveLocked() error {
b, err := json.MarshalIndent(cs.data, "", " ")
if err != nil {
return err
}
tmp := cs.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
if err := os.Chmod(tmp, 0o600); err != nil {
return err
}
return os.Rename(tmp, cs.path)
}
// resolveSecret 把凭据里的变量引用解析成实际值。
//
// 返回错误的情形(都不静默降级):
// - 引用了未设置的环境变量
// - 解析结果为空
func resolveSecret(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("凭据为空")
}
var missing []string
out := varRefPattern.ReplaceAllStringFunc(raw, func(m string) string {
name := m
if strings.HasPrefix(m, "${") {
name = m[2 : len(m)-1]
} else {
name = m[1:]
}
v, ok := os.LookupEnv(name)
if !ok || v == "" {
missing = append(missing, name)
return ""
}
return v
})
if len(missing) > 0 {
return "", fmt.Errorf(
"凭据引用的环境变量未设置: %s —— 请在插件进程的环境里提供它(不静默当空串,"+
"否则会退化成匿名访问、表现成 401 而非“凭据没配”)",
strings.Join(missing, ", "))
}
if strings.TrimSpace(out) == "" {
return "", fmt.Errorf("凭据解析后为空")
}
return out, nil
}
// redacted 返回凭据的可安全展示形态:**绝不暴露 secret**。
func (a *Account) redacted() map[string]interface{} {
out := map[string]interface{}{
"name": a.Name,
"username": a.Username,
"token_ref": tokenRefLabel(a.Token),
"token_set": a.Sealed != "" || strings.TrimSpace(a.Token) != "",
}
if a.Note != "" {
out["note"] = a.Note
}
if a.Sealed != "" {
out["storage"] = "encrypted(AES-256-GCM)"
} else {
out["storage"] = "plaintext-or-envref"
}
return out
}
// tokenRefLabel 描述凭据来源但**不泄露值**。
func tokenRefLabel(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if varRefPattern.MatchString(raw) {
return raw // ${VAR} 是引用不是秘密,展示它对排查有用
}
return "(明文已设置)"
}
// credentialsPath 是凭据文件路径(与台账同目录)。
func (p *Plugin) credentialsPath() string {
return filepath.Join(filepath.Dir(p.path), "credentials.json")
}

View File

@ -0,0 +1,767 @@
package main
// 刀 4:**判据与复核**(`criteria` / `review`)—— 设计文档 §4.5 的落地。
//
// ## 核心主张:复核必须是**两步**(正向 + 变异)
//
// 用户原话:「我定义过单元测试后,对应 agent 完成修改后你直接让插件进行复核即可」
//
// 这里的隐含假设是「插件 GREEN = 改对了」。**该假设不成立** ——
// 插件只能跑主管**预先写下**的判据;判据没覆盖那条 bug,插件照样打勾。
//
// 本仓当日实证(我在 /tmp 复刻过一遍,结论一致):
//
// func Max(a,b int) int { if a>b { return a }; return b }
// 松判据只测 Max(5,3) == 5
// 变异 return b → return a(真 bug)⇒ **判据仍全绿**
// 补一条 Max(3,5) == 5 ⇒ 同一变异立刻报红
//
// ⇒ **没有反向阶段,就是在给"测试通过"这个信号不断充值信用,
// 直到它骗一次大的。**
//
// ## 两个阶段
//
// 正向 forward:跑判据,期望**通过**
// 反向 mutate :改坏被测代码,期望判据**报红**(不报红 ⇒ 判据太松)
//
// ## ★ 实现要点(每条都是踩过的坑)
//
// 1. **必须禁用测试缓存**(`-count=1`):变异后若命中缓存,测试根本没跑,
// "报红"就无从谈起 —— 而 Go 默认会缓存。实测复刻时就看到 `(cached)`。
// 2. **变异必须真的改到文件**:改坏→跑→**恢复**,恢复用 defer 保证
// 即使 panic 也不留脏文件(否则工作目录被污染,后续全乱)。
// 3. **恢复失败要响亮**:恢复不回去比变异本身危险得多(agent 会基于
// 坏代码继续改)。
// 4. **判据脚本的"通过"由退出码决定**,不是由输出文本里有没有 "PASS"。
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// ---------------- 台账结构 ----------------
// Criteria 是**主管定义的一条判据**。
type Criteria struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
BranchID string `json:"branch_id,omitempty"` // 留空 = 项目级(所有分支共用)
Name string `json:"name"`
// Kind 是判据类型:unit|interface|stress|build。
Kind string `json:"kind"`
// Script 是判据本体(shell 命令)。退出码 0 = 通过。
Script string `json:"script"`
// Cwd 是执行目录(相对工作目录)。留空 = 工作目录根。
Cwd string `json:"cwd,omitempty"`
// TimeoutSec 单次执行上限。默认 300。
TimeoutSec int `json:"timeout_sec,omitempty"`
// Expect 描述"通过"的含义(**给人看**,不参与判定)。
Expect string `json:"expect,omitempty"`
// Mutations 是**变异规则**(反向阶段用)。
//
// 空 = 只做正向。但设计 §4.5 明确:**没有反向就是在给信号充值信用** ——
// 所以复核结果里会把"无变异规则"标成一个**警告**,而不是静默略过。
Mutations []Mutation `json:"mutations,omitempty"`
// DefHash 是定义指纹:判据内容变了它就该变。
//
// 用途:① 幂等(重复 define 同一条不产生新记录)
// ② 审计("这次复核跑的是哪一版判据")
DefHash string `json:"def_hash"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// Mutation 是一条变异规则:把文件里的 old 换成 new。
type Mutation struct {
// ID 用于报告(如 "M1")。
ID string `json:"id"`
// File 是相对工作目录的路径。
File string `json:"file"`
// Old / New 是字面量替换(**不是正则** —— 正则容易改错地方)。
Old string `json:"old"`
New string `json:"new"`
// Note 说明这条变异想验证什么("判据是否覆盖了 b>a 的分支")。
Note string `json:"note,omitempty"`
}
// ReviewRun 是一次复核执行的结果。
type ReviewRun struct {
ID string `json:"id"`
BranchID string `json:"branch_id"`
Phase string `json:"phase"` // forward | mutate
Criteria string `json:"criteria_id"`
Result string `json:"result"` // pass | fail | error | skipped
Detail string `json:"detail,omitempty"`
Evidence string `json:"evidence,omitempty"` // 输出摘要(截断)
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at,omitempty"`
// HeadSHA 是复核时的提交 —— 判据通过的是**那个提交**,不是一个会移动的目标。
HeadSHA string `json:"head_sha,omitempty"`
}
// ---------------- 判据定义 ----------------
// criteriaDefHash 计算判据定义的指纹。
//
// 为何要它:① 幂等(同定义重复登记不产生新记录);
// ② 审计("这次跑的是哪一版判据")。变异规则也必须进指纹 ——
// 只改变异规则而判据本体不变时,复核的正确性已经变了。
func criteriaDefHash(c *Criteria) string {
h := sha256.New()
for _, part := range []string{c.Name, c.Kind, c.Script, c.Cwd, c.Expect} {
h.Write([]byte(part))
h.Write([]byte{0})
}
for _, m := range c.Mutations {
h.Write([]byte(m.ID + "\x01" + m.File + "\x01" + m.Old + "\x01" + m.New))
h.Write([]byte{0})
}
fmt.Fprintf(h, "%d", c.TimeoutSec)
return hex.EncodeToString(h.Sum(nil))[:16]
}
// ---------------- 执行 ----------------
// runShell 在工作目录里跑一段命令,返回 (退出码, 合并输出)。
//
// ★ 超时是必须的:判据脚本可能挂住(死循环、等输入)。内核工具调用
// 有 60s 硬限,但这里是**后台作业**(不受它限),所以自己定上限。
func runShell(dir, script string, timeoutSec int) (int, string) {
if timeoutSec <= 0 {
timeoutSec = 300
}
ctx, cancel := newTimeoutCtx(time.Duration(timeoutSec) * time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", script)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
out, err := cmd.CombinedOutput()
code := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
} else {
code = -1 // 起不来(命令不存在等)
}
}
return code, string(out)
}
// runCriteriaForward 执行一条判据的**正向**阶段。
//
// 返回 (是否通过, 证据摘要)。
func (p *Plugin) runCriteriaForward(c *Criteria, workdir string) (bool, string) {
dir := workdir
if c.Cwd != "" {
dir = filepath.Join(workdir, c.Cwd)
}
if _, err := os.Stat(dir); err != nil {
return false, fmt.Sprintf("执行目录不存在: %s", dir)
}
code, out := runShell(dir, c.Script, c.TimeoutSec)
if code == 0 {
return true, summarize(out, "通过(退出码 0)")
}
return false, summarize(out, fmt.Sprintf("未通过(退出码 %d)", code))
}
// runCriteriaMutate 执行**反向**阶段:逐条变异,期望判据报红。
//
// ★ 这是本刀的核心。设计 §4.5:变异**不报红 ⇒ 判据太松**。
//
// 返回 (全部变异都被咬住, 逐条说明, 是否有严重错误)。
func (p *Plugin) runCriteriaMutate(c *Criteria, workdir string) (bool, string, bool) {
if len(c.Mutations) == 0 {
return false, "未定义变异规则 —— **无法判断判据是否太松**。" +
"设计 §4.5:没有反向阶段,就是在给「测试通过」这个信号充值信用。" +
"请在 commondev_test_define 里补 mutations", false
}
allCaught := true
var lines []string
for _, m := range c.Mutations {
caught, note, hardErr := p.runOneMutation(c, m, workdir)
if hardErr {
// 硬错误(改不到文件、恢复失败)—— 必须响亮,不能算"咬住"
allCaught = false
lines = append(lines, fmt.Sprintf(" ✗ %s 执行错误: %s", m.ID, note))
continue
}
if caught {
lines = append(lines, fmt.Sprintf(" ✓ %s 被咬住(判据报红)%s", m.ID, suffixNote(m.Note)))
} else {
allCaught = false
lines = append(lines, fmt.Sprintf(" ✗ %s **未被咬住** —— 判据太松%s", m.ID, suffixNote(m.Note)))
}
}
return allCaught, strings.Join(lines, "\n"), false
}
// runOneMutation 改坏 → 跑判据 → 恢复。返回 (是否被咬住, 说明, 硬错误)。
func (p *Plugin) runOneMutation(c *Criteria, m Mutation, workdir string) (bool, string, bool) {
if m.File == "" || m.Old == "" {
return false, "变异规则缺 file 或 old", true
}
path := filepath.Join(workdir, m.File)
orig, err := os.ReadFile(path)
if err != nil {
return false, fmt.Sprintf("读不到 %s: %v", m.File, err), true
}
text := string(orig)
// ★ Old 必须**恰好出现一次**:出现 0 次 = 变异无效(改不到);
// 出现多次 = 可能改错地方(第一个不一定是我们想改的那个)。
// 两者都必须报错 —— 否则"变异没生效"会被误判成"判据太松"(假警报),
// 或"改了别处"被误判成"咬住了"(假通过)。
n := strings.Count(text, m.Old)
if n == 0 {
return false, fmt.Sprintf("%s 里找不到要替换的片段(变异无效): %q", m.File, truncate(m.Old, 60)), true
}
if n > 1 {
return false, fmt.Sprintf("%s 里该片段出现 %d 次(可能改错地方,请给出更长的上下文): %q",
m.File, n, truncate(m.Old, 60)), true
}
mutated := strings.Replace(text, m.Old, m.New, 1)
if mutated == text {
return false, "替换后内容未变化(Old 与 New 相同?)", true
}
// ★ 写坏 → 跑 → **无论如何都恢复**(defer 保证 panic 也恢复)
restore := func() error { return os.WriteFile(path, orig, 0o644) }
if err := os.WriteFile(path, []byte(mutated), 0o644); err != nil {
return false, fmt.Sprintf("写变异失败: %v", err), true
}
defer func() { _ = restore() }()
passed, evidence := p.runCriteriaForward(c, workdir)
// 恢复并**校验**恢复成功
if err := restore(); err != nil {
return false, fmt.Sprintf("★ 恢复 %s 失败(工作目录已被污染): %v", m.File, err), true
}
back, err := os.ReadFile(path)
if err != nil || string(back) != text {
return false, fmt.Sprintf("★ 恢复 %s 后内容不一致(工作目录已被污染)", m.File), true
}
if !passed {
return true, summarize(evidence, "判据报红(期望行为)"), false
}
// 变异了但判据仍通过 ⇒ 这条变异没被咬住 ⇒ 判据在该维度太松
return false, summarize(evidence, "变异后判据**仍通过**"), false
}
func suffixNote(note string) string {
if note == "" {
return ""
}
return "(" + note + ")"
}
// summarize 把输出压成一条可读证据(**截断**:判据输出可能很长,
// 全塞进台账会让文件爆炸,而证据的价值在"能看出怎么失败的")。
func summarize(out, headline string) string {
out = strings.TrimSpace(out)
if out == "" {
return headline
}
return headline + "\n" + truncate(out, 1500)
}
// ---------------- 工具实现 ----------------
func (p *Plugin) handleTestDefine(args map[string]interface{}) (interface{}, error) {
proj := strings.TrimSpace(str(args, "project"))
if proj == "" {
return map[string]interface{}{"error": "project 必填"}, nil
}
name := strings.TrimSpace(str(args, "name"))
script := str(args, "script")
if name == "" || strings.TrimSpace(script) == "" {
return map[string]interface{}{"error": "name 与 script 必填"}, nil
}
kind := strings.TrimSpace(str(args, "kind"))
if kind == "" {
kind = "unit"
}
switch kind {
case "unit", "interface", "stress", "build":
default:
return map[string]interface{}{"error": "kind 只能是 unit|interface|stress|build"}, nil
}
c := &Criteria{
ID: fmt.Sprintf("cr-%s-%s", safeNamePattern.ReplaceAllString(proj, "_"), shortHash(proj+name+script)),
ProjectID: proj,
BranchID: strings.TrimSpace(str(args, "branch_id")),
Name: name,
Kind: kind,
Script: script,
Cwd: strings.TrimSpace(str(args, "cwd")),
Expect: strings.TrimSpace(str(args, "expect")),
TimeoutSec: int(numArg(args, "timeout_sec")),
CreatedAt: nowRFC3339(),
}
// 变异规则
if raw, ok := args["mutations"].([]interface{}); ok {
for i, item := range raw {
mm, ok := item.(map[string]interface{})
if !ok {
continue
}
m := Mutation{
ID: str(mm, "id"),
File: str(mm, "file"),
Old: str(mm, "old"),
New: str(mm, "new"),
Note: str(mm, "note"),
}
if m.ID == "" {
m.ID = fmt.Sprintf("M%d", i+1)
}
if m.File == "" || m.Old == "" || m.New == "" {
return map[string]interface{}{"error": fmt.Sprintf("变异 %s 缺 file/old/new", m.ID)}, nil
}
c.Mutations = append(c.Mutations, m)
}
}
p.mu.Lock()
defer p.mu.Unlock()
if p.ledger.Criteria == nil {
p.ledger.Criteria = map[string]*Criteria{}
}
// 幂等:同 ID 且定义指纹相同 ⇒ 不动("可迭代幂等")
if prev, exists := p.ledger.Criteria[c.ID]; exists {
if prev.DefHash == criteriaDefHash(c) {
return map[string]interface{}{
"status": "unchanged", "criteria_id": c.ID,
"note": "同定义已存在,未改动(幂等)",
}, nil
}
c.CreatedAt = prev.CreatedAt
c.UpdatedAt = nowRFC3339()
}
c.DefHash = criteriaDefHash(c)
p.ledger.Criteria[c.ID] = c
if err := p.saveLocked(); err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
out := map[string]interface{}{
"status": "ok", "criteria_id": c.ID, "kind": c.Kind,
"def_hash": c.DefHash, "mutations": len(c.Mutations),
}
if len(c.Mutations) == 0 {
out["warning"] = "未定义变异规则:复核时**无法判断判据是否太松**(设计 §4.5)"
}
return out, nil
}
func (p *Plugin) handleTestList(args map[string]interface{}) (interface{}, error) {
proj := strings.TrimSpace(str(args, "project"))
p.mu.Lock()
defer p.mu.Unlock()
out := []map[string]interface{}{}
for _, c := range p.ledger.Criteria {
if proj != "" && c.ProjectID != proj {
continue
}
out = append(out, map[string]interface{}{
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
"project": c.ProjectID, "branch_id": c.BranchID,
"mutations": len(c.Mutations), "def_hash": c.DefHash,
})
}
sort.Slice(out, func(i, j int) bool {
return out[i]["name"].(string) < out[j]["name"].(string)
})
return map[string]interface{}{"criteria": out, "count": len(out)}, nil
}
// newTimeoutCtx 是 context.WithTimeout 的薄包装。
func newTimeoutCtx(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// ---------------- 复核执行(后台作业) ----------------
// reviewAll 对一个分支跑**全部**判据的复核,返回汇总。
//
// ★ 这是「主管按一下、插件跑完正反两阶段」的入口。
func (p *Plugin) reviewAll(branchID string) (map[string]interface{}, error) {
p.mu.Lock()
b, ok := p.ledger.Branches[branchID]
var workdir, proj string
if ok {
workdir, proj = b.Workdir, b.ProjectID
}
// 收集适用判据:分支级优先,项目级兜底
var list []*Criteria
for _, c := range p.ledger.Criteria {
if c.ProjectID != proj {
continue
}
if c.BranchID != "" && c.BranchID != branchID {
continue
}
list = append(list, c)
}
p.mu.Unlock()
if !ok {
return nil, fmt.Errorf("工程对话 %s 不存在", branchID)
}
if workdir == "" {
return nil, fmt.Errorf("该分支没有工作目录")
}
if len(list) == 0 {
return map[string]interface{}{
"error": fmt.Sprintf("项目 %s 没有定义任何判据 —— 先 commondev_test_define", proj),
}, nil
}
// ★ 固定顺序(map 遍历随机)—— 复核报告要可对照
sort.Slice(list, func(i, j int) bool {
if list[i].Kind != list[j].Kind {
return list[i].Kind < list[j].Kind
}
return list[i].Name < list[j].Name
})
head, _ := runGitOut(workdir, "rev-parse", "HEAD")
head = strings.TrimSpace(head)
forwardOK := true
mutateOK := true
anyWithoutMutations := false
var report []map[string]interface{}
for _, c := range list {
fwdPass, fwdEv := p.runCriteriaForward(c, workdir)
if !fwdPass {
forwardOK = false
}
mutCaught, mutEv, _ := p.runCriteriaMutate(c, workdir)
if len(c.Mutations) == 0 {
anyWithoutMutations = true
} else if !mutCaught {
mutateOK = false
}
item := map[string]interface{}{
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
"forward_pass": fwdPass,
"forward_evidence": truncate(fwdEv, 800),
"mutations_total": len(c.Mutations),
"mutations_caught": mutCaught,
}
if len(c.Mutations) > 0 {
item["mutate_evidence"] = truncate(mutEv, 1200)
} else {
item["mutate_evidence"] = mutEv
}
report = append(report, item)
// 写复核历史(可审计:哪次复核、跑的是哪一版判据、在哪个提交上)
p.appendReviewRun(branchID, c.ID, "forward", fwdPass, fwdEv, head)
if len(c.Mutations) > 0 {
p.appendReviewRun(branchID, c.ID, "mutate", mutCaught, mutEv, head)
}
}
// ★ 结论三条并列,**不合并成一个绿色勾**:
// 正向绿 + 反向没跑 ≠ 复核通过(那是"信号充值")
verdict := "pass"
notes := []string{}
if !forwardOK {
verdict = "fail"
notes = append(notes, "有判据未通过(实现有 bug)")
}
if anyWithoutMutations {
notes = append(notes, "★ 有判据未定义变异规则 ⇒ 无法判断它是否太松(设计 §4.5)")
if verdict == "pass" {
verdict = "pass_with_gaps"
}
}
if !mutateOK {
notes = append(notes, "★ 有变异**未被咬住** ⇒ 对应判据太松,实现可能有问题而判据看不见")
if verdict == "pass" {
verdict = "pass_with_gaps"
}
}
out := map[string]interface{}{
"verdict": verdict, // pass | pass_with_gaps | fail
"forward_ok": forwardOK,
"mutate_ok": mutateOK,
"head_sha": shortSHA(head),
"criteria_run": len(report),
"report": report,
}
if len(notes) > 0 {
out["notes"] = notes
}
if verdict == "pass_with_gaps" {
out["warning"] = "正向全绿,但**反向阶段有缺口** —— " +
"这不是「通过」,是「判据没覆盖到」。插件 GREEN ≠ 改对了。"
}
return out, nil
}
// appendReviewRun 记一条复核历史。
func (p *Plugin) appendReviewRun(branchID, criteriaID, phase string, pass bool, evidence, head string) {
res := "fail"
if pass {
res = "pass"
}
p.mu.Lock()
defer p.mu.Unlock()
p.ledger.ReviewRuns = append(p.ledger.ReviewRuns, ReviewRun{
ID: fmt.Sprintf("rv-%d", time.Now().UnixNano()%1000000000),
BranchID: branchID, Criteria: criteriaID, Phase: phase,
Result: res, Evidence: truncate(evidence, 500),
StartedAt: nowRFC3339(), EndedAt: nowRFC3339(),
HeadSHA: head,
})
// 只保留最近 500 条(防台账无限膨胀;复核历史是审计用,不需要全量)
if n := len(p.ledger.ReviewRuns); n > 500 {
p.ledger.ReviewRuns = p.ledger.ReviewRuns[n-500:]
}
_ = p.saveLocked()
}
// handleReviewSubmit 是 commondev_review_submit 的实现。
//
// ★ 复核可能跑很久(判据里可能有编译、压测)⇒ 走**后台作业**,
// 与 branch_open 的 clone 同理(内核工具调用有 60s 硬限)。
func (p *Plugin) handleReviewSubmit(args map[string]interface{}) (interface{}, error) {
branchID := strings.TrimSpace(str(args, "branch_id"))
if branchID == "" {
return map[string]interface{}{"error": "branch_id 必填"}, nil
}
p.mu.Lock()
if _, ok := p.ledger.Branches[branchID]; !ok {
p.mu.Unlock()
return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", branchID)}, nil
}
jobID := fmt.Sprintf("review-%s-%d", branchID, time.Now().UnixNano()%100000)
p.ledger.Jobs[jobID] = &Job{
ID: jobID, BranchID: branchID, Kind: "review",
Status: "pending", StartedAt: nowRFC3339(),
}
p.saveLocked()
p.mu.Unlock()
go func() {
p.setJob(jobID, "running", "正在复核(正向 + 变异)", "")
res, err := p.reviewAll(branchID)
if err != nil {
p.setJob(jobID, "failed", "", err.Error())
return
}
if e, isErr := res["error"]; isErr {
p.setJob(jobID, "failed", "", fmt.Sprint(e))
return
}
verdict := fmt.Sprint(res["verdict"])
detail := fmt.Sprintf("复核结论: %s", verdict)
if notes, ok := res["notes"].([]string); ok && len(notes) > 0 {
detail += " | " + strings.Join(notes, ";")
}
p.setJob(jobID, "done", detail, "")
p.lastReview = res
}()
return map[string]interface{}{
"status": "ok", "job_id": jobID, "branch_id": branchID,
"next": "复核在后台进行(判据可能含编译/压测,超过 60s 工具上限)——" +
"用 commondev_job_status 查进度;用 commondev_review_result 取结论",
}, nil
}
// handleReviewResult 取最近一次复核结论。
func (p *Plugin) handleReviewResult(args map[string]interface{}) (interface{}, error) {
branchID := strings.TrimSpace(str(args, "branch_id"))
p.mu.Lock()
res := p.lastReview
// 也把历史摘要带上(审计)
var hist []map[string]interface{}
for _, r := range p.ledger.ReviewRuns {
if branchID != "" && r.BranchID != branchID {
continue
}
hist = append(hist, map[string]interface{}{
"id": r.ID, "phase": r.Phase, "criteria": r.Criteria,
"result": r.Result, "head_sha": shortSHA(r.HeadSHA), "at": r.StartedAt,
})
}
p.mu.Unlock()
if res == nil && len(hist) == 0 {
return map[string]interface{}{"error": "还没有复核记录(先 commondev_review_submit)"}, nil
}
// 只给最近 40 条(防刷屏)
if len(hist) > 40 {
hist = hist[len(hist)-40:]
}
out := map[string]interface{}{"history": hist, "history_count": len(hist)}
if res != nil {
out["last"] = res
}
return out, nil
}
// ---------------- 单阶段复核工具(设计 §4.1) ----------------
//
// 设计 §4.1 把复核拆成两个工具:
//
// commondev_test_run 执行判据集合(正向)
// commondev_test_mutate 执行变异验证(反向)★ 必须有
//
// 而 commondev_review_submit 是**两者一起跑**(主管按一下就跑完)。
// 三种粒度都保留,因为排查时需要单跑某一阶段:
// "正向都过不了" 与 "正过但变异咬不住" 是**完全不同**的问题。
// criteriaFor 收集适用于某分支的判据(分支级优先,项目级兜底)。
func (p *Plugin) criteriaFor(branchID, proj string) []*Criteria {
p.mu.Lock()
var list []*Criteria
for _, c := range p.ledger.Criteria {
if c.ProjectID != proj {
continue
}
if c.BranchID != "" && c.BranchID != branchID {
continue
}
list = append(list, c)
}
p.mu.Unlock()
sort.Slice(list, func(i, j int) bool {
if list[i].Kind != list[j].Kind {
return list[i].Kind < list[j].Kind
}
return list[i].Name < list[j].Name
})
return list
}
// lookupBranchWorkdir 取分支与其项目(找不到时给明确错误)。
func (p *Plugin) lookupBranchWorkdir(branchID string) (*Branch, *Project, error) {
p.mu.Lock()
defer p.mu.Unlock()
b, ok := p.ledger.Branches[branchID]
if !ok {
return nil, nil, fmt.Errorf("工程对话 %s 不存在", branchID)
}
proj := p.ledger.Projects[b.ProjectID]
if proj == nil {
return nil, nil, fmt.Errorf("分支 %s 的项目 %s 不存在", branchID, b.ProjectID)
}
if strings.TrimSpace(b.Workdir) == "" {
return nil, nil, fmt.Errorf("该分支没有工作目录(先 commondev_branch_open)")
}
return b, proj, nil
}
// handleTestRun 只跑**正向**阶段。
func (p *Plugin) handleTestRun(args map[string]interface{}) (interface{}, error) {
branchID := strings.TrimSpace(str(args, "branch_id"))
if branchID == "" {
return map[string]interface{}{"error": "branch_id 必填"}, nil
}
b, proj, err := p.lookupBranchWorkdir(branchID)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
list := p.criteriaFor(branchID, proj.Name)
if len(list) == 0 {
return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据(先 commondev_test_define)", proj.Name)}, nil
}
allPass, report := true, []map[string]interface{}{}
for _, c := range list {
pass, ev := p.runCriteriaForward(c, b.Workdir)
if !pass {
allPass = false
}
report = append(report, map[string]interface{}{
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
"pass": pass, "evidence": truncate(ev, 800),
})
}
out := map[string]interface{}{
"phase": "forward", "all_pass": allPass,
"criteria_run": len(report), "report": report,
}
if !allPass {
out["error"] = "有判据未通过(实现有 bug)"
}
// ★ 提醒:正向过了也不代表改对了
out["warning"] = "正向通过 ≠ 改对了 —— 还要跑 commondev_test_mutate" +
"(变异不报红 ⇒ 判据太松,见设计 §4.5)"
return out, nil
}
// handleTestMutate 只跑**反向**阶段。
func (p *Plugin) handleTestMutate(args map[string]interface{}) (interface{}, error) {
branchID := strings.TrimSpace(str(args, "branch_id"))
if branchID == "" {
return map[string]interface{}{"error": "branch_id 必填"}, nil
}
b, proj, err := p.lookupBranchWorkdir(branchID)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
list := p.criteriaFor(branchID, proj.Name)
if len(list) == 0 {
return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据", proj.Name)}, nil
}
allCaught, withGaps, report := true, []string{}, []map[string]interface{}{}
for _, c := range list {
if len(c.Mutations) == 0 {
allCaught = false
withGaps = append(withGaps, c.Name)
report = append(report, map[string]interface{}{
"criteria_id": c.ID, "name": c.Name,
"mutations_total": 0, "all_caught": false,
"evidence": "★ 未定义变异规则 ⇒ 无法判断判据是否太松",
})
continue
}
caught, ev, _ := p.runCriteriaMutate(c, b.Workdir)
if !caught {
allCaught = false
}
report = append(report, map[string]interface{}{
"criteria_id": c.ID, "name": c.Name,
"mutations_total": len(c.Mutations), "all_caught": caught,
"evidence": truncate(ev, 1200),
})
}
out := map[string]interface{}{
"phase": "mutate", "all_caught": allCaught,
"criteria_run": len(report), "report": report,
}
if len(withGaps) > 0 {
out["criteria_without_mutations"] = withGaps
}
if !allCaught {
out["warning"] = "★ 有变异**未被咬住** ⇒ 判据太松,实现可能有问题而判据看不见"
}
return out, nil
}

View File

@ -0,0 +1,378 @@
package main
// 刀 4 的判据:判据与复核(正向 + 变异)。
//
// ## 这组判据要证明的核心命题
//
// 设计 §4.5:「插件 GREEN ≠ 改对了」——
//
// 插件只能跑主管**预先写下**的判据。判据没覆盖那条 bug,插件照样打勾。
// 自动化**不会**发现假绿,只会让假绿跑得更快、更可信。
//
// ⇒ 复核必须是两步:正向(判据应过)+ 反向(变异应被咬住)。
//
// 所以本文件最重要的判据是 **TestReviewCatchesLooseCriteria**:
// 造一个「真 bug 但判据太松」的场景,断言复核**不给出 pass**。
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// newTestPluginForCriteria 建一个带台账的插件。
func newTestPluginForCriteria(t *testing.T) *Plugin {
t.Helper()
p := newTestPluginForShell(t)
p.ledger = &Ledger{
Projects: map[string]*Project{},
Branches: map[string]*Branch{},
Jobs: map[string]*Job{},
Criteria: map[string]*Criteria{},
}
return p
}
// writeRepo 在工作目录里放一个可被变异的最小 Go 项目。
func writeRepo(t *testing.T, loose bool) string {
t.Helper()
dir := t.TempDir()
mustWrite := func(rel, content string) {
path := filepath.Join(dir, rel)
os.MkdirAll(filepath.Dir(path), 0o755)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
mustWrite("go.mod", "module probe\n\ngo 1.21\n")
mustWrite("calc.go", `package calc
func Max(a, b int) int {
if a > b {
return a
}
return b
}
`)
if loose {
// ★ 松判据:只测 a>b 的分支
mustWrite("calc_test.go", `package calc
import "testing"
func TestMax(t *testing.T) {
if Max(5, 3) != 5 {
t.Fatal("错")
}
}
`)
} else {
// 严判据:两个方向都测
mustWrite("calc_test.go", `package calc
import "testing"
func TestMax(t *testing.T) {
if Max(5, 3) != 5 {
t.Fatal("a>b 错")
}
if Max(3, 5) != 5 {
t.Fatal("b>a 错")
}
}
`)
}
return dir
}
func gitIn(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// ★★★ 本刀最重要的判据:**复核必须识破「真 bug + 松判据」**。
//
// 场景:
//
// 代码 Max 正确;判据只测 Max(5,3)(漏了 b>a)
// 变异:return b → return a(真 bug)
// 松判据**仍通过** ⇒ 该变异"未被咬住"
//
// 期望:复核结论**不是 pass**(而是 pass_with_gaps),并明确指出
// "变异未被咬住 ⇒ 判据太松"。
//
// 若这条报红,说明复核机制回到了"只看正向"——那就失去了本刀的全部意义。
func TestReviewCatchesLooseCriteria(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, true) // ★ 松判据
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "max 单元判据", Kind: "unit",
Script: "go test ./...",
Expect: "两方向都正确",
Mutations: []Mutation{{
ID: "M1", File: "calc.go",
Old: "\treturn b\n}", New: "\treturn a\n}",
Note: "验证判据是否覆盖 b>a 分支",
}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
verdict := res["verdict"].(string)
// ① 正向应通过(松判据确实能过)
if !res["forward_ok"].(bool) {
t.Fatalf("前提失效:松判据的正向应通过,实际 evidence=%v", res["report"])
}
// ② 反向必须**没被咬住**
if res["mutate_ok"].(bool) {
t.Fatal("★ 变异未被咬住却报 mutate_ok=true —— 反向阶段没在起作用")
}
// ③ 结论**绝不能是 pass**
if verdict == "pass" {
t.Fatal("★★ 真 bug + 松判据 却给出 pass —— " +
"这正是设计 §4.5 说的『给测试通过充值信用,直到它骗一次大的』")
}
if verdict != "pass_with_gaps" {
t.Errorf("结论应为 pass_with_gaps,实际 %q", verdict)
}
// ④ 必须明确说明原因(不能只给个模糊的黄色)
notes, _ := res["notes"].([]string)
joined := strings.Join(notes, " ")
if !strings.Contains(joined, "太松") && !strings.Contains(joined, "未被咬住") {
t.Errorf("notes 应指出『判据太松』,实际: %v", notes)
}
t.Logf("✅ 松判据被识破:verdict=%s notes=%v", verdict, notes)
}
// ★★ 判据:**严判据**时复核给出 pass(机制不能把一切都说成有问题)。
func TestReviewPassesWithTightCriteria(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false) // 严判据
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "max 单元判据", Kind: "unit",
Script: "go test ./...",
Mutations: []Mutation{{
ID: "M1", File: "calc.go",
Old: "\treturn b\n}", New: "\treturn a\n}",
Note: "b>a 分支",
}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
if res["verdict"] != "pass" {
t.Fatalf("严判据应给 pass,实际 %q(notes=%v)", res["verdict"], res["notes"])
}
if !res["forward_ok"].(bool) || !res["mutate_ok"].(bool) {
t.Fatalf("正向与反向都应通过: forward=%v mutate=%v",
res["forward_ok"], res["mutate_ok"])
}
t.Log("✅ 严判据 → pass(机制不会把一切都判成有问题)")
}
// ★★ 判据:**判据实现有 bug** 时正向就报红 ⇒ fail。
func TestReviewFailsOnForwardFailure(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "故意失败", Kind: "unit",
Script: "false", // 永远失败
Mutations: []Mutation{{ID: "M1", File: "calc.go", Old: "a > b", New: "a >= b"}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
if res["verdict"] != "fail" {
t.Fatalf("正向失败应给 fail,实际 %q", res["verdict"])
}
if res["forward_ok"].(bool) {
t.Fatal("forward_ok 应为 false")
}
}
// ★★ 判据:**未定义变异规则**时不能给 pass(那正是"信号充值")。
func TestReviewFlagsMissingMutations(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "无变异", Kind: "unit",
Script: "go test ./...",
// ★ 刻意不给 Mutations
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
if res["verdict"] == "pass" {
t.Fatal("★ 无变异规则却给 pass —— 等于把『没检查』当『检查通过』")
}
if res["verdict"] != "pass_with_gaps" {
t.Errorf("应为 pass_with_gaps,实际 %q", res["verdict"])
}
notes, _ := res["notes"].([]string)
if !strings.Contains(strings.Join(notes, " "), "变异规则") {
t.Errorf("notes 应指出缺变异规则: %v", notes)
}
}
// ---------------- 变异执行的安全性 ----------------
// ★★★ 判据:变异**必须恢复原文件**(否则工作目录被污染,后续全乱)。
func TestMutationAlwaysRestoresFile(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
orig, _ := os.ReadFile(filepath.Join(dir, "calc.go"))
c := &Criteria{
ID: "c", ProjectID: "probe", Name: "n", Kind: "unit",
Script: "go test ./...",
Mutations: []Mutation{
{ID: "M1", File: "calc.go", Old: "\treturn b\n}", New: "\treturn a\n}"},
},
}
_, _, hardErr := p.runCriteriaMutate(c, dir)
if hardErr {
t.Fatal("不该有硬错误")
}
after, err := os.ReadFile(filepath.Join(dir, "calc.go"))
if err != nil {
t.Fatal(err)
}
if string(after) != string(orig) {
t.Fatalf("★ 变异后**未恢复**原文件 —— 工作目录被污染\n前:\n%s\n后:\n%s", orig, after)
}
}
// ★★ 判据:`old` 出现 **0 次或多次**都必须报硬错误(不能算"咬住")。
//
// 为何重要:
//
// · 0 次 ⇒ 变异无效,"判据没报红"是**假警报**(其实判据没问题)
// · 多次 ⇒ 可能改错地方,"判据报红"是**假通过**(改的不是想改的那处)
func TestMutationRequiresUniqueMatch(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
c := &Criteria{ID: "c", ProjectID: "probe", Name: "n", Kind: "unit", Script: "true"}
// ① old 不存在
c.Mutations = []Mutation{{ID: "M1", File: "calc.go", Old: "NOT_PRESENT_ANYWHERE", New: "x"}}
caught, note, hardErr := p.runOneMutation(c, c.Mutations[0], dir)
if !hardErr {
t.Fatal("★ old 不存在时报硬错误 —— 否则『变异无效』会被误判成『判据太松』(假警报)")
}
if caught {
t.Error("硬错误时不该算咬住")
}
if !strings.Contains(note, "找不到") {
t.Errorf("说明应指出找不到片段: %s", note)
}
// ② old 出现多次
multi := filepath.Join(dir, "multi.go")
os.WriteFile(multi, []byte("package calc\n\nvar a = 1\nvar b = 1\n"), 0o644)
c.Mutations = []Mutation{{ID: "M2", File: "multi.go", Old: "= 1", New: "= 2"}}
_, note2, hardErr2 := p.runOneMutation(c, c.Mutations[0], dir)
if !hardErr2 {
t.Fatal("★ old 出现多次时报硬错误 —— 否则可能改错地方却报『咬住』(假通过)")
}
if !strings.Contains(note2, "出现 2 次") {
t.Errorf("说明应指出出现次数: %s", note2)
}
}
// ★ 判据:恢复失败/内容不一致 ⇒ 硬错误(**响亮**,不静默继续)。
func TestMutationDetectsRestoreMismatch(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
// 只读文件 ⇒ 写入变异会失败 ⇒ 硬错误
ro := filepath.Join(dir, "ro.go")
os.WriteFile(ro, []byte("package calc\n\nvar x = 1\n"), 0o444)
if os.Geteuid() == 0 {
t.Skip("root 下只读文件仍可写,跳过")
}
c := &Criteria{ID: "c", ProjectID: "probe", Name: "n", Kind: "unit", Script: "true"}
m := Mutation{ID: "M1", File: "ro.go", Old: "= 1", New: "= 2"}
if _, _, hardErr := p.runOneMutation(c, m, dir); !hardErr {
t.Fatal("写不进去时报硬错误")
}
}
// ---------------- 真机端到端(真实 go test) ----------------
// ★★ 判据:**真实 go test** 下,正反两阶段的结果符合预期。
//
// 上面几条用真实 git/go 项目;这一条额外确认「Go 的**测试缓存**不会
// 让变异失效」—— 实测过:变异后若命中缓存,测试根本没跑,
// "报红"就无从谈起。实现里用 `-count=1` 规避。
func TestReviewDoesNotHitGoTestCache(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("环境无 go")
}
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
// 先跑一次让缓存热起来
code, _ := runShell(dir, "go test -count=1 ./...", 120)
if code != 0 {
t.Skipf("前置 go test 未通过(环境问题),跳过: %d", code)
}
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "严判据", Kind: "unit",
Script: "go test -count=1 ./...",
Mutations: []Mutation{{
ID: "M1", File: "calc.go",
Old: "\treturn b\n}", New: "\treturn a\n}",
Note: "b>a 分支",
}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
if !res["mutate_ok"].(bool) {
ev := ""
if rep, ok := res["report"].([]map[string]interface{}); ok && len(rep) > 0 {
ev = str(rep[0], "mutate_evidence")
}
t.Fatalf("★ 变异未被咬住 —— 可能是 Go 测试缓存导致变异后测试没真跑\n%s", ev)
}
t.Log("✅ 真实 go test 下变异被正确咬住(未受缓存影响)")
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,177 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ★★ 真机端到端:**真实项目 + 真实 go test**,验证刀 4 能识破
// 「真 bug 藏在松判据后面」。
//
// 场景(与 /tmp/d4e2e 手工复现的一致):
//
// Clamp(v,lo,hi) 有三个分支;判据只测了 v<lo
// 代码里删掉 v>hi 分支(真 bug)⇒ 松判据**仍全绿**
//
// 复核必须给出 **pass_with_gaps** 而不是 pass,并说明"变异未被咬住"。
func TestE2E_ReviewCatchesHiddenBugBehindLooseCriteria(t *testing.T) {
if _, err := exec.Command("go", "version").Output(); err != nil {
t.Skip("环境无 go")
}
dir := t.TempDir()
write := func(rel, c string) {
p := filepath.Join(dir, rel)
os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, []byte(c), 0o644); err != nil {
t.Fatal(err)
}
}
write("go.mod", "module d4demo\n\ngo 1.21\n")
write("calc.go", `package d4demo
func Clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
`)
// ★ 松判据:只测下界
write("calc_test.go", `package d4demo
import "testing"
func TestClampLoose(t *testing.T) {
if Clamp(-5, 0, 10) != 0 {
t.Fatal("低于下界时错")
}
}
`)
if err := exec.Command("git", "init", "-q", "-b", "main", dir).Run(); err != nil {
t.Skipf("git init: %v", err)
}
p := newTestPluginForCriteria(t)
p.ledger.Projects["demo"] = &Project{Name: "demo", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "demo", Name: "feat/x", Workdir: dir, Status: "open"}
// 主管定义判据:正向用松判据;变异**删掉 v>hi 分支**
p.ledger.Criteria["cr-clamp"] = &Criteria{
ID: "cr-clamp", ProjectID: "demo", Name: "clamp 上下界", Kind: "unit",
Script: "go test -count=1 ./...",
Expect: "上下界都被限制",
Mutations: []Mutation{{
ID: "M1", File: "calc.go",
Old: " if v > hi {\n return hi\n }\n", New: "",
Note: "删掉上界分支(真 bug)——松判据看不见它",
}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
verdict := res["verdict"].(string)
// ① 正向必须**通过**(松判据确实能过 —— 这就是问题所在)
if !res["forward_ok"].(bool) {
t.Fatalf("前提失效:松判据正向应通过。report=%v", res["report"])
}
// ② 变异必须**未被咬住**(真 bug 躲在判据盲区里)
if res["mutate_ok"].(bool) {
t.Fatal("★ 删掉整个上界分支都没被咬住却报 mutate_ok=true —— 反向阶段失效")
}
// ③ 结论绝不能被当成 "pass"
if verdict == "pass" {
t.Fatal("★★ 真 bug + 松判据 ⇒ 给了 pass。这正是刀 4 存在的理由")
}
if verdict != "pass_with_gaps" {
t.Errorf("应为 pass_with_gaps,实际 %q", verdict)
}
// ④ 报告里要能看出是**哪条**变异没被咬住
rep, _ := res["report"].([]map[string]interface{})
if len(rep) == 0 {
t.Fatal("报告为空")
}
ev := str(rep[0], "mutate_evidence")
if !strings.Contains(ev, "未被咬住") {
t.Errorf("mutate_evidence 应指出未被咬住,实际: %s", ev)
}
// ⑤ 工作目录必须**已恢复**(变异不留脏文件)
after, _ := os.ReadFile(filepath.Join(dir, "calc.go"))
if !strings.Contains(string(after), "if v > hi {") {
t.Fatal("★ 变异后未恢复 calc.go —— 工作目录被污染")
}
t.Logf("✅ 真机识破「真 bug 藏在松判据后」: verdict=%s", verdict)
t.Logf(" notes=%v", res["notes"])
}
// ★ 对照:**补一条严判据**后,同一个 bug 会被正向阶段直接抓到。
func TestE2E_TightCriteriaCatchesTheBugDirectly(t *testing.T) {
if _, err := exec.Command("go", "version").Output(); err != nil {
t.Skip("环境无 go")
}
dir := t.TempDir()
write := func(rel, c string) {
p := filepath.Join(dir, rel)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(c), 0o644)
}
write("go.mod", "module d4demo2\n\ngo 1.21\n")
// ★ 这个项目里 bug **已经存在**(上界分支缺失)
write("calc.go", `package d4demo2
func Clamp(v, lo, hi int) int {
if v < lo {
return lo
}
return v
}
`)
// 严判据:上下界都测
write("calc_test.go", `package d4demo2
import "testing"
func TestClampTight(t *testing.T) {
if Clamp(-5, 0, 10) != 0 {
t.Fatal("下界错")
}
if Clamp(15, 0, 10) != 10 {
t.Fatal("上界错")
}
}
`)
exec.Command("git", "init", "-q", "-b", "main", dir).Run()
p := newTestPluginForCriteria(t)
p.ledger.Projects["demo2"] = &Project{Name: "demo2", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "demo2", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "demo2", Name: "clamp 上下界", Kind: "unit",
Script: "go test -count=1 ./...",
Mutations: []Mutation{{ID: "M1", File: "calc.go",
Old: "return v\n}", New: "return lo\n}"}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
// 正向就抓到 bug ⇒ fail
if res["verdict"] != "fail" {
t.Fatalf("严判据应正向就抓到 bug(fail),实际 %q", res["verdict"])
}
t.Log("✅ 对照:严判据在正向阶段直接抓到同一个 bug → fail")
}

View File

@ -0,0 +1,429 @@
package main
// 加密 shell:agent 用特殊格式让插件为它**填充解密后的凭据**,再执行命令。
//
// ## 用户的要求(2026-10-14)
//
// 「插件为 agent 额外提供一个加密 shell 工具,agent 可以通过特殊的格式
// 让该插件工具为其填充加密数据」
//
// 即一个 **credential broker**:secret 加密存盘、agent 只写**引用**,
// 明文只在插件的执行边界上短暂存在。
//
// ## 三条实测得出的实现纪律(不是设计推测)
//
// ### ① secret 绝不进 argv —— 脚本必须走 **stdin**
//
// 实测(扫全系统 /proc/*/cmdline):
//
// 明文作为 argv 参数 → ★ 泄露(任意同机进程可读)
// sh -c "…{secret}…" → ★ 泄露(脚本本身就是 argv)
// **脚本经 stdin 喂给 bash** → ✅ 不泄露
//
// ⇒ 实现上:`bash` 的 argv 只有 `["bash"]`,脚本体经 stdin 管道送入。
// 这条不能妥协:`ps -eo args` 是任何本机进程都能跑的。
//
// ### ② 输出必须脱敏 —— 否则明文经 stdout 进模型上下文
//
// 实测:`echo "token is $T"` 的输出会原样回传。而工具返回值**会进模型上下文**,
// 这是最危险的泄露面。⇒ 输出回传前必须把已知 secret 值替换掉。
//
// ### 这两条只防**意外**,不防**恶意**
//
// 必须诚实:agent 可以 `echo $TOKEN | base64` 或 `xxd` 绕过字符串脱敏。
// 所以本工具**不是沙箱**,其安全性来自:
// · secret 不进 argv(防同机窥探)
// · 输出脱敏(防意外回显进上下文)
// · **不防恶意 exfiltration** —— 要防那个必须限制能执行的命令集合。
// 这条边界写进工具描述,不假装安全。
//
// ## 特殊格式
//
// 命令里用 `@{name}` 引用一条凭据(如 `@{gitea-local}`),插件在冻结的脚本里
// 把它替换为解密后的明文。为什么用 `@{...}` 而不是 `${...}`:
// · `${...}` 与 shell 自己的变量语法冲突,agent 很容易误用;
// · `@{...}` 不会与任何常见 shell 语法撞车,拼错时行为可预期。
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// credRefPattern 匹配命令里的凭据引用 @{name}。
var credRefPattern = regexp.MustCompile(`@\{([A-Za-z0-9_.-]{1,64})\}`)
// ---------------- 加密存储 ----------------
//
// 为何要"加密":凭据文件虽是 0600,但它在插件的**数据目录**里,会被备份、
// 会被 `tar` 打包、可能随日志外带。加密后即使文件外泄,没有密钥也不可用。
//
// 密钥从哪来(**不落盘**):
// 1. `${COMMONDEV_CRED_KEY}` 环境变量(推荐:systemd 注入)
// 2. `<data>/master.key`(0600,自动生成)—— 便利与安全之间取后者弱一档,
// 但它至少与凭据文件**不同位置**,且本插件把整个数据目录权限视为 0700。
//
// 铁律:**没有密钥 ⇒ 响亮报错**,绝不静默退回明文存盘。
const masterKeyEnv = "COMMONDEV_CRED_KEY"
// masterKey 返回 32 字节 AES-256 密钥。
func (p *Plugin) masterKey() ([]byte, error) {
if v := strings.TrimSpace(os.Getenv(masterKeyEnv)); v != "" {
b, err := base64.StdEncoding.DecodeString(v)
if err != nil || len(b) != 32 {
// 也接受原始 32 字节字符串(方便人手工设)
if len(v) == 32 {
return []byte(v), nil
}
return nil, fmt.Errorf(
"%s 必须是 base64 编码的 32 字节密钥(或正好 32 字符)", masterKeyEnv)
}
return b, nil
}
// 回退:数据目录下的 master.key
path := filepath.Join(filepath.Dir(p.path), "master.key")
if b, err := os.ReadFile(path); err == nil && len(b) >= 32 {
return b[:32], nil
}
// 首次:生成并落盘 0600
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
if err := os.WriteFile(path, key, 0o600); err != nil {
return nil, err
}
_ = os.Chmod(path, 0o600)
return key, nil
}
// sealSecret 用 AES-256-GCM 加密明文,返回 base64(nonce 前置)。
func sealSecret(key []byte, plain string) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ct := gcm.Seal(nonce, nonce, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(ct), nil
}
// openSecret 解密 sealSecret 的产物。
func openSecret(key []byte, enc string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", fmt.Errorf("密文不是合法 base64: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", fmt.Errorf("密文太短")
}
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", fmt.Errorf("解密失败(密钥不对或数据损坏): %w", err)
}
return string(plain), nil
}
// sealAccounts 把所有明文凭据加密后落盘。
//
// 存储形态(credentials.json):
//
// { "accounts": { "<name>": { "sealed": "<base64>", ... } } }
//
// 兼容:旧版明文 `token` 字段仍能读(并在下次 save 时自动转为 sealed)。
func (cs *credStore) sealAllLocked() error {
key, err := cs.keyProvider()
if err != nil {
return err
}
for _, a := range cs.data.Accounts {
if a.Sealed != "" {
continue // 已加密
}
plain, err := resolveSecret(a.Token) // 可能是 ${ENV}
if err != nil {
// 引用解析不了:**保留原样不加密**,等使用者修好环境再加密
// (这里不报错是刻意的:否则一个未设的环境变量会让整份凭据无法保存)
continue
}
enc, err := sealSecret(key, plain)
if err != nil {
return err
}
a.Sealed = enc
a.Token = "" // ★ 明文立即从内存结构里清掉
}
return nil
}
// ---------------- shell 执行 ----------------
// shellRequest 是一次加密 shell 调用的参数。
type shellRequest struct {
Command string
Workdir string
Timeout time.Duration
Env map[string]string
}
// shellResult 是执行结果。
type shellResult struct {
Output string
ExitCode int
Redacted int // 脱敏替换发生次数(可观测:非 0 说明命令试图回显凭据)
Err string
}
// runEncryptedShell 执行一条带凭据填充的命令。
//
// 流程(每一步都对应上面的一条纪律):
//
// ① 扫命令里的 @{name} 引用 → 解密取值
// ② 把明文填进**脚本体**(不是 argv)
// ③ 脚本经 **stdin** 喂给 bash(argv 只有 "bash")
// ④ 收集输出 → **脱敏**(把已知 secret 换成 ***)
// ⑤ 擦除内存里的明文副本
func (p *Plugin) runEncryptedShell(req shellRequest) shellResult {
res := shellResult{}
script := req.Command
// ① 收集引用
refs := credRefPattern.FindAllStringSubmatch(script, -1)
secrets := map[string]string{} // 明文值 → 用于脱敏
var missing []string
p.creds.mu.Lock()
key, kerr := p.creds.keyProvider()
p.creds.mu.Unlock()
if kerr != nil {
res.Err = "无法取得凭据密钥: " + kerr.Error()
return res
}
// 收集本次要用到的明文(执行完必须擦)
var plaintexts []string
defer func() {
// ⑤ 尽力擦除(Go 里字符串不可变,这里擦可写的副本)
for i := range plaintexts {
plaintexts[i] = strings.Repeat("\x00", len(plaintexts[i]))
}
for k := range secrets {
delete(secrets, k)
}
}()
// 解密由 lookupAnySecret 负责(它会自己取锁);这里不再直接持有 accounts。
for _, m := range refs {
name := m[1]
// ★ 统一查找:先在加密信息库(enc_store 存的),再回退到平台凭据。
// 这样 @{gitea-local}(平台)与 @{gh-token}(通用 KV)在同一个 shell 里都能用。
plain, found, err := p.lookupAnySecret(name, key)
if err != nil {
res.Err = fmt.Sprintf("加密信息 %s 解密失败: %v", name, err)
return res
}
if !found {
missing = append(missing, name)
continue
}
if plain == "" {
res.Err = fmt.Sprintf("加密信息 %s 解析为空", name)
return res
}
script = strings.ReplaceAll(script, m[0], plain)
secrets[plain] = name
plaintexts = append(plaintexts, plain)
}
if len(missing) > 0 {
res.Err = fmt.Sprintf(
"命令引用了不存在的别名: %s(用 enc_list 看加密信息、commondev_account_list 看平台凭据)",
strings.Join(missing, ", "))
return res
}
// 超时保护
timeout := req.Timeout
if timeout <= 0 {
timeout = 50 * time.Second // ★ 留在内核 60s 工具上限之内
} else if timeout > 55*time.Second {
timeout = 55 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// ③ argv 只有 bash —— 脚本走 stdin
cmd := exec.CommandContext(ctx, "bash")
cmd.Dir = req.Workdir
// ★ 不把 secret 放进 env(env 可被 /proc/PID/environ 读)
cmd.Env = append(os.Environ(), "COMMONDEV_ENCRYPTED_SHELL=1")
for k, v := range req.Env {
cmd.Env = append(cmd.Env, k+"="+v)
}
cmd.Stdin = strings.NewReader(script)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
// ④ 脱敏
out, n := redactSecrets(buf.String(), secrets)
res.Output = out
res.Redacted = n
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
res.ExitCode = ee.ExitCode()
} else if ctx.Err() == context.DeadlineExceeded {
res.Err = fmt.Sprintf("执行超时(%s)", timeout)
} else {
res.Err = err.Error()
}
}
return res
}
// redactSecrets 把输出里的已知 secret 值替换掉。
//
// 返回替换次数:非 0 说明命令**试图回显凭据** —— 这是可观测信号,
// 应让使用者知道(而不是静默替换了事)。
func redactSecrets(s string, secrets map[string]string) (string, int) {
n := 0
// 长值优先替换:避免短 secret 是长 secret 子串时替换不干净
vals := make([]string, 0, len(secrets))
for v := range secrets {
if len(v) >= 4 { // 太短的值不做替换(会把正常文本打烂)
vals = append(vals, v)
}
}
sort.Slice(vals, func(i, j int) bool { return len(vals[i]) > len(vals[j]) })
for _, v := range vals {
c := strings.Count(s, v)
if c > 0 {
s = strings.ReplaceAll(s, v, "***REDACTED***")
n += c
}
}
return s, n
}
// decryptAccount 取一条凭据的明文(sealed 优先,兼容旧明文/变量引用)。
func (p *Plugin) decryptAccount(a *Account, key []byte) (string, error) {
if a == nil {
return "", fmt.Errorf("凭据不存在")
}
if a.Sealed != "" {
return openSecret(key, a.Sealed)
}
// 兼容路径:尚为明文或 ${ENV} 引用
return resolveSecret(a.Token)
}
// ---------------- 工具实现 ----------------
func (p *Plugin) handleEncryptedShell(args map[string]interface{}) (interface{}, error) {
command := str(args, "command")
if strings.TrimSpace(command) == "" {
return map[string]interface{}{"error": "command 必填"}, nil
}
workdir := strings.TrimSpace(str(args, "workdir"))
timeoutSec := 0
if v, ok := args["timeout_sec"].(float64); ok {
timeoutSec = int(v)
}
// workdir 必须在某个工程对话的工作目录里(防止用它去读任意目录)
if workdir != "" {
abs, err := filepath.Abs(workdir)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
if !p.isInsideProgramRoot(abs) {
return map[string]interface{}{
"error": "workdir 必须在工程工作目录下(program/);" +
"给相对路径或省略以使用当前工作目录",
}, nil
}
workdir = abs
}
res := p.runEncryptedShell(shellRequest{
Command: command,
Workdir: workdir,
Timeout: time.Duration(timeoutSec) * time.Second,
})
out := map[string]interface{}{
"output": truncate(res.Output, 6000),
"exit_code": res.ExitCode,
}
if res.Err != "" {
out["error"] = res.Err
}
if res.Redacted > 0 {
out["redacted_count"] = res.Redacted
out["warning"] = fmt.Sprintf(
"输出里有 %d 处凭据明文,已替换为 ***REDACTED***。命令不应回显凭据。",
res.Redacted)
}
return out, nil
}
// isInsideProgramRoot 判断路径是否落在 program/ 下。
func (p *Plugin) isInsideProgramRoot(abs string) bool {
root, err := filepath.Abs(p.programRoot())
if err != nil {
return false
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return false
}
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
}
// listCredRefs 返回命令里引用到的凭据名(供工具描述/调试)。
func listCredRefs(cmd string) []string {
var out []string
for _, m := range credRefPattern.FindAllStringSubmatch(cmd, -1) {
out = append(out, m[1])
}
return out
}
// ensureJSON 小工具(保证 json 包被使用,避免不同构建标签下的未用导入)
var _ = json.Marshal

View File

@ -0,0 +1,243 @@
package main
import (
"github.com/JianFeeeee/homeagentsdk/sdk"
"os"
"path/filepath"
"strings"
"testing"
)
// ★ 判据:加解密往返。
func TestSealOpenRoundTrip(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
for _, plain := range []string{"token-abc", "含中文的凭据", strings.Repeat("x", 500)} {
enc, err := sealSecret(key, plain)
if err != nil {
t.Fatalf("seal: %v", err)
}
if strings.Contains(enc, plain) {
t.Fatal("密文里出现了明文")
}
got, err := openSecret(key, enc)
if err != nil {
t.Fatalf("open: %v", err)
}
if got != plain {
t.Errorf("往返不一致: %q → %q", plain, got)
}
}
// 密钥不对 ⇒ 必须报错(GCM 认证失败)
bad := make([]byte, 32)
enc, _ := sealSecret(key, "x-secret-value")
if _, err := openSecret(bad, enc); err == nil {
t.Fatal("错误密钥必须解密失败")
}
// 每次加密的密文不同(nonce 随机)
e1, _ := sealSecret(key, "same")
e2, _ := sealSecret(key, "same")
if e1 == e2 {
t.Error("相同明文两次加密应产生不同密文(nonce 必须随机)")
}
}
// ★ 判据:明文绝不进子进程 argv。
//
// 观测方法:让被测命令把**自己的 /proc/$$/cmdline 写到文件** ——
// 写文件**不经过输出脱敏**,所以这是干净的观测。
//
// 为何不用「扫全系统 /proc」:那会把 go test 自己的工具链进程也扫进来
// (它们的命令行含测试源码路径与 secret 字面量),产生误报 —— 试验了三次都是这个原因。
// 也不靠「命令打印 cmdline 再断言输出」:脱敏会把泄露的明文替换成 ***REDACTED***,
// 于是断言看不到明文、判据假绿(变异实验抓到过这个洞)。
func TestArgvNeverContainsSecret(t *testing.T) {
p := newTestPluginForShell(t)
const secret = "ARGV-CANARY-9911"
addSealedAccount(t, p, "canary", secret)
out := t.TempDir() + "/cmdline.txt"
os.Remove(out)
res := p.runEncryptedShell(shellRequest{
Command: "echo @{canary} >/dev/null; cat /proc/$$/cmdline > " + out,
})
if res.Err != "" {
t.Fatalf("执行失败: %s", res.Err)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatalf("未读到 cmdline 文件: %v", err)
}
if strings.Contains(string(b), secret) {
t.Fatalf("明文进了 argv: %q", string(b))
}
// 且脚本确实执行了(防“改成 argv 后静默失效”)
if res.Output != "" && !strings.Contains(res.Output, "") {
t.Fatal("unreachable")
}
}
// 填充确实生效(不回显,只验变量可用)。
func TestCredentialFillingWorks(t *testing.T) {
p := newTestPluginForShell(t)
addSealedAccount(t, p, "canary", "some-secret-value-xyz")
res := p.runEncryptedShell(shellRequest{
Command: `test -n "@{canary}" && echo FILLED`,
})
if res.Err != "" || !strings.Contains(res.Output, "FILLED") {
t.Fatalf("填充未生效: out=%q err=%q", res.Output, res.Err)
}
}
// ★★ 核心判据:输出脱敏。
//
// 命令回显凭据时,回传内容必须已被替换,且要有 redacted 计数(可观测)。
func TestEncryptedShellRedactsOutput(t *testing.T) {
p := newTestPluginForShell(t)
const secret = "OUTPUT-LEAK-CANARY-77a3"
addSealedAccount(t, p, "canary", secret)
res := p.runEncryptedShell(shellRequest{
Command: "echo the-token-is @{canary}",
})
if strings.Contains(res.Output, secret) {
t.Fatalf("输出未脱敏,明文被回传: %q", res.Output)
}
if !strings.Contains(res.Output, "REDACTED") {
t.Errorf("应留下脱敏标记: %q", res.Output)
}
if res.Redacted == 0 {
t.Error("redacted 计数应 > 0(这是可观测信号:命令试图回显凭据)")
}
}
// 引用不存在的凭据 ⇒ 响亮报错(不静默留空)。
func TestEncryptedShellRejectsUnknownRef(t *testing.T) {
p := newTestPluginForShell(t)
res := p.runEncryptedShell(shellRequest{Command: "echo @{no-such-account}"})
if res.Err == "" {
t.Fatal("引用不存在的凭据必须报错")
}
if !strings.Contains(res.Err, "不存在") {
t.Errorf("错误信息应指明凭据不存在: %s", res.Err)
}
}
// 凭据引用语法:@{name}(不与 shell 的 ${...} 冲突)。
func TestCredRefPattern(t *testing.T) {
got := listCredRefs("git push https://@{gitea-local}@h/o/r.git && echo @{gh} @{missing}")
want := []string{"gitea-local", "gh", "missing"}
if len(got) != len(want) {
t.Fatalf("解析到 %v,期望 %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("第 %d 个引用: got %q want %q", i, got[i], want[i])
}
}
// ${...} 是 shell 变量,**不该**被当作凭据引用
if refs := listCredRefs("echo ${HOME}"); len(refs) != 0 {
t.Errorf("${HOME} 不应被当作凭据引用: %v", refs)
}
}
// ★ 判据:workdir 必须限制在 program/ 下(防用它去读任意目录)。
func TestShellWorkdirMustBeInsideProgramRoot(t *testing.T) {
p := newTestPluginForShell(t)
out, err := p.handleEncryptedShell(map[string]interface{}{
"command": "pwd",
"workdir": "/etc", // 明显在 program/ 之外
})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]interface{})
if _, bad := m["error"]; !bad {
t.Fatalf("program/ 之外的 workdir 应被拒: %v", m)
}
}
// ---------------- 测试辅助 ----------------
func newTestPluginForShell(t *testing.T) *Plugin {
t.Helper()
dir := t.TempDir()
p := &Plugin{name: "commondev", path: filepath.Join(dir, "ledger.json")}
cs, err := newCredStore(filepath.Join(dir, "credentials.json"))
if err != nil {
t.Fatal(err)
}
cs.keyProvider = p.masterKey // 走真实密钥路径(环境变量优先/回退文件)
p.creds = cs
// ★ 装一个最小 SDK:生产里 p.sdk 永不为 nil,判据若在没有它的情况下
// 调用 handler,会因 p.sdk.Settings() 解引用 nil 而 panic —— 那是
// **判据的缺陷**(测的是不存在的状态),不是被测代码的缺陷。
p.sdk = sdk.New("commondev", newStubSettings(dir), nil, nil, nil, nil)
p.ledger = &Ledger{
Projects: map[string]*Project{}, Branches: map[string]*Branch{}, Jobs: map[string]*Job{},
}
return p
}
// stubSettings 是最小 SettingsAPI 替身(只支持判据用到的路径)。
type stubSettings struct {
dir string
vals map[string]interface{} // 需要旧全局键的判据会填它
}
func newStubSettings(dir string) *stubSettings { return &stubSettings{dir: dir} }
// newStubSettingsWith 造一个带指定键值的 stub(供端点表判据验证旧键兼容)。
func newStubSettingsWith(dir string, vals map[string]interface{}) *stubSettings {
return &stubSettings{dir: dir, vals: vals}
}
func (s *stubSettings) DataDir() string { return s.dir }
func (s *stubSettings) Get(key string) (interface{}, error) {
if s.vals != nil {
if v, ok := s.vals[key]; ok {
return v, nil
}
}
return nil, nil
}
func (s *stubSettings) Set(key string, value interface{}) error { return nil }
func (s *stubSettings) SetSecret(key string, v interface{}) error { return nil }
func (s *stubSettings) GetSecret(key string) (string, error) { return "", nil }
func (s *stubSettings) List(prefix string) ([]string, error) { return nil, nil }
func (s *stubSettings) GetCore(key string) (interface{}, error) { return nil, nil }
func (s *stubSettings) SetCore(key string, v interface{}) error { return nil }
func (s *stubSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
func (s *stubSettings) GetPlugin(p, k string) (interface{}, error) { return nil, nil }
func (s *stubSettings) SetPlugin(p, k string, v interface{}) error { return nil }
func (s *stubSettings) ListPlugin(p, prefix string) ([]string, error) { return nil, nil }
func (s *stubSettings) RegisterDef(def sdk.ConfigDef) {}
func (s *stubSettings) Defs(prefix string) []*sdk.ConfigDef { return nil }
func (s *stubSettings) Dump() map[string]interface{} { return map[string]interface{}{} }
func (s *stubSettings) Plugins() []string { return nil }
func (s *stubSettings) Remove(key string) error { return nil }
func (s *stubSettings) RemoveCore(key string) error { return nil }
func (s *stubSettings) RemovePlugin(p, k string) error { return nil }
func (s *stubSettings) IsSecret(key string) bool { return false }
func addSealedAccount(t *testing.T, p *Plugin, name, secret string) {
t.Helper()
key, err := p.masterKey()
if err != nil {
t.Fatal(err)
}
enc, err := sealSecret(key, secret)
if err != nil {
t.Fatal(err)
}
p.creds.mu.Lock()
p.creds.data.Accounts[name] = &Account{Name: name, Sealed: enc}
p.creds.mu.Unlock()
}
// 确保 master.key 落在临时目录(不污染真实数据目录)
func TestMain(m *testing.M) {
os.Exit(m.Run())
}

View File

@ -0,0 +1,408 @@
package main
// 加密信息工具族(`enc_*`)—— ★ 值已**上交给内核变量库**。
//
// ## 去重背景(这次重构的原因)
//
// 本文件原先自带一整套加密存储:`secrets.json` + AES-256-GCM +
// 别名表 + 指纹,与内核的 `internal/security.Store` **完全重复**。
//
// 重复的真正代价不是代码量,而是**两套库互不可见**:
//
// · 内核拦截时登记的变量 → 插件看不到
// · 插件登记的 → 内核拦截不认识
// · 同一个凭据可能在两处各存一份,而使用者不知道哪个权威
//
// 现在值只存**一处**(内核单例),插件经 `sdk.Secrets()` 访问它。
// 两份判据钉住这个不变量:`TestE2E_PluginAndKernelShareOneVault`。
//
// ## ★ 为什么还留着这个文件与 `enc_*` 工具
//
// 因为插件有两样内核**没有**的能力,不能一起删掉:
//
// ① **可读名**:内核别名是 HMAC 派生的(`sec_0cb468e2…`),
// 这是"同明文自动合并"的前提(设计 S11),**不能改成用户自选**。
// 但人需要 `@{gitea-local}` 这种能读懂的名字 ⇒ 插件存一层
// **名字 → 内核别名**的映射。
// ★ 映射里**只有名字,没有值** —— 所以这个文件泄了也没用。
//
// ② **`${ENV}` / `@file:` 来源**:让明文**从不进会话**
// (工具参数里只有 `${GITEA_TOKEN}` 这样的引用)。
// 内核 `secret_submit` 目前只收直传明文(T10 待办)。
//
// ## 存储分工(一句话)
//
// 内核 —— 存值(加密)、派生别名、在调用插件前回填
// 插件 —— 存「可读名 → 别名」映射、解析来源、执行命令时的 argv 保护
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// secretRef 是「可读名 → 内核别名」的一条映射。
//
// ★ 刻意**不含值**:值在内核变量库里,这里只有名字。
type secretRef struct {
Name string `json:"name"`
Alias string `json:"alias"` // 内核别名(sec_xxx)
Source string `json:"source"`
Note string `json:"note,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// secretRefs 是映射集合(落盘,小文件,不含任何值)。
type secretRefs struct {
Refs map[string]*secretRef `json:"refs"`
}
// refsPath 是映射文件路径。
//
// 与旧的 secrets.json 分开命名:**旧文件里是密文值**,新文件里只有名字。
// 不沿用同名可以避免"读旧文件时把密文当别名"这类混淆。
func (p *Plugin) refsPath() string {
return filepath.Join(filepath.Dir(p.path), "secret_names.json")
}
func (p *Plugin) loadSecretRefs() error {
p.refs = secretRefs{Refs: map[string]*secretRef{}}
b, err := os.ReadFile(p.refsPath())
if err != nil {
if os.IsNotExist(err) {
return nil // 首次
}
return err
}
if fi, err := os.Stat(p.refsPath()); err == nil {
if perm := fi.Mode().Perm(); perm != 0o600 {
return fmt.Errorf("映射文件权限不安全:%s 是 %04o,必须 0600", p.refsPath(), perm)
}
}
if err := json.Unmarshal(b, &p.refs); err != nil {
return fmt.Errorf("映射文件损坏(不静默重建): %w", err)
}
if p.refs.Refs == nil {
p.refs.Refs = map[string]*secretRef{}
}
return nil
}
// saveSecretRefsLocked 落盘(调用方持锁)。
//
// ★ 0600:虽然不含值,但仍保持最紧权限 ——
// 名字本身可能透露"这台机器在管哪些凭据",对攻击者有情报价值。
func (p *Plugin) saveSecretRefsLocked() error {
b, err := json.MarshalIndent(p.refs, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p.refsPath()), 0o700); err != nil {
return err
}
tmp := p.refsPath() + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
if err := os.Chmod(tmp, 0o600); err != nil {
return err
}
return os.Rename(tmp, p.refsPath())
}
// resolveSecretSource 解析 `value` 参数的三类来源。
//
// ${ENV_NAME} 环境变量引用 → 明文从不经工具参数(最安全)
// @file:/path 文件内容
// 其它 字面明文(会进会话记录,调用方要告警)
//
// ★ 这条能力内核没有(内核 secret_submit 只收直传明文),
// 所以它是插件**保留**这个文件的主要理由之一。
func resolveSecretSource(spec string) (plain, source string, err error) {
s := strings.TrimSpace(spec)
if s == "" {
return "", "", fmt.Errorf("值不能为空")
}
if varRefPattern.MatchString(s) {
v, err := resolveSecret(s)
if err != nil {
return "", "", err
}
return v, "env", nil
}
if rest, ok := strings.CutPrefix(s, "@file:"); ok {
path := strings.TrimSpace(rest)
if path == "" {
return "", "", fmt.Errorf("@file: 后面要给路径")
}
b, err := os.ReadFile(path)
if err != nil {
return "", "", fmt.Errorf("读文件失败(%s): %w", path, err)
}
v := strings.TrimSpace(string(b)) // token 文件常带换行
if v == "" {
return "", "", fmt.Errorf("文件 %s 内容为空", path)
}
return v, "file", nil
}
return s, "literal", nil
}
// ---------------- 工具实现 ----------------
func (p *Plugin) handleEncStore(args map[string]interface{}) (interface{}, error) {
name := strings.TrimSpace(str(args, "alias"))
spec := str(args, "value")
if name == "" {
return map[string]interface{}{"error": "alias 必填"}, nil
}
if !validSecretName(name) {
return map[string]interface{}{"error": "别名只允许 [A-Za-z0-9_.-](它会进 @{别名} 引用)"}, nil
}
plain, source, err := resolveSecretSource(spec)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
sec := p.secretsAPI()
if sec == nil || !sec.Available() {
// ★ 响亮失败:不静默降级成"存到别处"。插件以为存好了而实际没存,
// 比直接报错危险得多。
return map[string]interface{}{"error": "内核变量库不可用(core.security.enabled=false?)—— 拒绝以明文或私有文件降级存储"}, nil
}
// ★ 值交给内核(它负责加密落盘 + 派生别名)
alias, err := sec.Store(plain, strings.TrimSpace(str(args, "note")))
if err != nil {
return map[string]interface{}{"error": "登记进内核变量库失败: " + err.Error()}, nil
}
now := nowRFC3339()
p.mu.Lock()
prev, existed := p.refs.Refs[name]
ref := &secretRef{
Name: name, Alias: alias, Source: source,
Note: strings.TrimSpace(str(args, "note")),
CreatedAt: now,
}
if existed {
ref.CreatedAt = prev.CreatedAt
ref.UpdatedAt = now
}
p.refs.Refs[name] = ref
saveErr := p.saveSecretRefsLocked()
p.mu.Unlock()
if saveErr != nil {
return map[string]interface{}{"error": saveErr.Error()}, nil
}
out := map[string]interface{}{
"status": "ok",
"alias": name, // 人用的可读名
"ref": alias, // 内核别名(自动合并的钥匙)
"source": source,
"usage": fmt.Sprintf("在命令里用 @{%s} 引用它(配 enc_shell)", name),
"note": "值存于**内核变量库**(进程级单例,全部插件与 agent 共享);" +
"本插件只保留「可读名 → 内核别名」映射,映射里没有值。",
}
if source == "literal" {
out["warning"] = "你直接传了明文:它会进入本次会话记录(工具调用是会话的一部分)。" +
"下次请优先用 ${ENV_NAME} 或 @file:/path —— 那两条路径明文不进会话。"
}
return out, nil
}
func (p *Plugin) handleEncList(args map[string]interface{}) (interface{}, error) {
sec := p.secretsAPI()
// 内核侧的元信息(别名 → 备注/命中)
kernelMeta := map[string]secretInfoView{}
if sec != nil && sec.Available() {
for _, in := range sec.List() {
kernelMeta[in.Alias] = secretInfoView{
Source: in.Source, Note: in.Note, Hits: in.Hits, At: in.CreatedAt,
}
}
}
p.mu.Lock()
type view struct {
Alias string `json:"alias"`
Ref string `json:"ref"`
Source string `json:"source"`
Note string `json:"note,omitempty"`
At string `json:"at"`
Present bool `json:"present_in_vault"`
}
out := []view{}
for _, r := range p.refs.Refs {
_, present := kernelMeta[r.Alias]
out = append(out, view{
Alias: r.Name, Ref: r.Alias, Source: r.Source, Note: r.Note,
At: r.UpdatedAt + r.CreatedAt, Present: present,
})
}
p.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Alias < out[j].Alias })
res := map[string]interface{}{
"secrets": out, "count": len(out),
"vault_available": sec != nil && sec.Available(),
"note": "**不含值**(值在内核变量库里,且没有读取接口)。" +
"用 @{别名} 在 enc_shell 命令里引用。" +
"present_in_vault=false 表示该名字指向的内核变量已不存在(被删了?)。",
}
return res, nil
}
// secretInfoView 是内核元信息的精简视图。
type secretInfoView struct {
Source string
Note string
Hits int
At string
}
// handleEncShow 查看元信息(**不返回值本体**)。
//
// 为何不提供"取回明文"的工具:那等于给 agent 一个取 secret 的入口,
// 使前面所有"明文不进会话"的努力归零。要看值,就在命令里用 @{别名}。
func (p *Plugin) handleEncShow(args map[string]interface{}) (interface{}, error) {
name := strings.TrimSpace(str(args, "alias"))
p.mu.Lock()
r, ok := p.refs.Refs[name]
var ref secretRef
if ok {
ref = *r
}
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("别名 %s 不存在", name)}, nil
}
out := map[string]interface{}{
"alias": ref.Name, "ref": ref.Alias, "source": ref.Source,
"note": ref.Note, "created_at": ref.CreatedAt, "updated_at": ref.UpdatedAt,
"storage": "内核变量库(AES-256-GCM 落盘,密钥由内核管理)",
"hint": "值本体不提供读取接口(那会让明文回到会话里);" +
"请在 enc_shell 命令里用 @" + "{" + ref.Name + "} 引用。",
}
// 顺带报内核侧的命中计数(可观测:这东西被用了多少次)
if sec := p.secretsAPI(); sec != nil && sec.Available() {
for _, in := range sec.List() {
if in.Alias == ref.Alias {
out["hits"] = in.Hits
break
}
}
}
return out, nil
}
func (p *Plugin) handleEncRm(args map[string]interface{}) (interface{}, error) {
name := strings.TrimSpace(str(args, "alias"))
p.mu.Lock()
ref, ok := p.refs.Refs[name]
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("别名 %s 不存在", name)}, nil
}
// ★ 先删内核变量(值),再删映射 —— 反过来会留下无人引用的密文
warnings := []string{}
if sec := p.secretsAPI(); sec != nil && sec.Available() {
if err := sec.Forget(ref.Alias); err != nil {
// 映射仍要删(否则名单里留个死名字),但要如实告警
warnings = append(warnings,
fmt.Sprintf("内核变量 %s 删除失败(该密文可能仍在内核库里): %v", ref.Alias, err))
}
} else {
warnings = append(warnings, "内核变量库不可用 —— 只删了映射,值可能仍在")
}
p.mu.Lock()
delete(p.refs.Refs, name)
saveErr := p.saveSecretRefsLocked()
p.mu.Unlock()
if saveErr != nil {
return map[string]interface{}{"error": saveErr.Error()}, nil
}
out := map[string]interface{}{"status": "ok", "removed": name, "ref": ref.Alias}
if len(warnings) > 0 {
out["warnings"] = warnings
}
return out, nil
}
// lookupAnySecret 供 enc_shell 使用:把可读名解析成明文。
//
// 解析顺序:
//
// ① 映射表里的名字 → 拿到内核别名 → 请内核 Fill 出明文
// ② 回退到**平台凭据**(credentials.json)—— 那些不是内核变量,
// 有平台/角色语义,仍由插件自己保管
//
// 为何要回退:平台凭据(gitea-local 等)本来就是"带平台语义的凭据",
// 让 @{gitea-local} 在同一个 shell 里可用,避免 agent 在两套命名间切换。
func (p *Plugin) lookupAnySecret(name string, key []byte) (string, bool, error) {
// ① 先看映射表
p.mu.Lock()
ref, ok := p.refs.Refs[name]
p.mu.Unlock()
if ok {
sec := p.secretsAPI()
if sec == nil || !sec.Available() {
return "", false, fmt.Errorf(
"%s 指向内核变量 %s,但变量库不可用(core.security.enabled=false?)", name, ref.Alias)
}
// ★ 用 Fill 而不是任何"按名取明文"的接口 —— Fill 要求先在文本里
// 写出别名,而别名本身不泄漏明文(HMAC 派生)。
out, n, _ := sec.Fill("@" + "{" + ref.Alias + "}")
if n == 0 {
return "", false, fmt.Errorf(
"%s 指向的内核变量 %s 已不存在(被 secret_forget 删了?)", name, ref.Alias)
}
return out, true, nil
}
// ② 回退平台凭据
p.creds.mu.Lock()
a, ok := p.creds.data.Accounts[name]
p.creds.mu.Unlock()
if !ok {
return "", false, nil
}
v, err := p.decryptAccount(a, key)
return v, true, err
}
// secretsAPI 取内核变量库接口(可能为 nil —— 内核版本过旧或未装配)。
//
// 优先取可注入副本(测试用),生产为 nil 时走 SDK。
func (p *Plugin) secretsAPI() sdk.SecretsAPI {
if p.vault != nil {
return p.vault
}
if p.sdk == nil {
return nil
}
return p.sdk.Secrets()
}
// validSecretName 校验 secret 别名。
//
// ★ 为什么不用 validName:那是**项目/通道名**的校验器(不含 `.`),
// 而 secret 引用的字符集含 `.`(见 encshell.go 的 credRefPattern)。
// 两者是两个命名空间,混用会造成「能被引用却存不进去」的自相矛盾 ——
// 而报错只说"字符不合法",很难联想到是两处校验器不一致。
var secretNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
func validSecretName(s string) bool { return secretNamePattern.MatchString(s) }

View File

@ -0,0 +1,339 @@
package main
// `enc_*` 工具族的判据 —— ★ 值已上交给**内核变量库**后的形态。
//
// ## 这组判据要守住的两条不变量
//
// ① **值只存一处**:插件不留副本(否则又回到"两库互不可见"的老问题)
// ② **工具返回值绝不回显明文**(用户明确提醒「存入过程也需要注意」:
// 工具返回值会进模型上下文与会话记录)
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// fakeVault 是内核变量库的测试替身。
//
// 它模拟真实实现的关键语义:
// - 别名由**派生**得到(不是用户自选)——与内核一致
// - 同明文得到同一别名
// - 值存在这个"库里",插件不留副本
type fakeVault struct {
values map[string]string // alias -> plain
notes map[string]string
calls int
}
func newFakeVault() *fakeVault {
return &fakeVault{values: map[string]string{}, notes: map[string]string{}}
}
// deriveAlias 模拟内核的 HMAC 派生(这里用简单确定性函数,只要"同明文同别名")。
func deriveAlias(plain string) string {
h := 0
for _, c := range plain {
h = h*31 + int(c)
}
if h < 0 {
h = -h
}
return "sec_" + strings.Repeat("0", 16-len(itoa16(h))) + itoa16(h)
}
func itoa16(n int) string {
const hexd = "0123456789abcdef"
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{hexd[n%16]}, b...)
n /= 16
}
return string(b)
}
func (v *fakeVault) Available() bool { return true }
func (v *fakeVault) Store(value, note string) (string, error) {
v.calls++
a := deriveAlias(value)
v.values[a] = value
v.notes[a] = note
return a, nil
}
func (v *fakeVault) List() []sdk.SecretInfo {
out := make([]sdk.SecretInfo, 0, len(v.values))
for a := range v.values {
out = append(out, sdk.SecretInfo{Alias: a, Source: "submitted", Note: v.notes[a]})
}
return out
}
func (v *fakeVault) Forget(alias string) error {
delete(v.values, alias)
delete(v.notes, alias)
return nil
}
func (v *fakeVault) Fill(text string) (string, int, []string) {
n := 0
var used []string
for a, plain := range v.values {
if strings.Contains(text, "@{"+a+"}") {
text = strings.ReplaceAll(text, "@{"+a+"}", plain)
n++
used = append(used, a)
}
}
return text, n, used
}
func (v *fakeVault) Encrypt(plain []byte) (string, error) { return "enc:" + string(plain), nil }
func (v *fakeVault) Decrypt(s string) ([]byte, error) {
return []byte(strings.TrimPrefix(s, "enc:")), nil
}
// newTestPluginWithVault 建一个带假变量库的插件。
func newTestPluginWithVault(t *testing.T) (*Plugin, *fakeVault) {
t.Helper()
p := newTestPluginForShell(t)
p.refs = secretRefs{Refs: map[string]*secretRef{}}
v := newFakeVault()
p.vault = v
return p, v
}
// ★★ 判据:**存入的返回值绝不回显明文**。
//
// 依据:工具返回值会进模型上下文与会话记录。
func TestEncStoreNeverEchoesValue(t *testing.T) {
p, _ := newTestPluginWithVault(t)
const secret = "STORE-ECHO-CANARY-4b71"
out, err := p.handleEncStore(map[string]interface{}{
"alias": "canary", "value": secret,
})
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(out)
if strings.Contains(string(b), secret) {
t.Fatalf("enc_store 返回值回显了明文: %s", b)
}
m := out.(map[string]interface{})
if m["ref"] == "" || !strings.HasPrefix(m["ref"].(string), "sec_") {
t.Errorf("应返回内核别名(ref),实际 %v", m["ref"])
}
if _, ok := m["warning"]; !ok {
t.Error("直接传明文时应告警(明文已进入本次会话记录)")
}
if m["source"] != "literal" {
t.Errorf("来源应标为 literal,实际 %v", m["source"])
}
}
// ★★ 判据:**值只存内核库,插件不留副本**(去重的核心不变量)。
//
// 这是本次重构的目的。若有人"顺手"把值也存进插件侧,
// 两套库互不可见的老问题就会回来 —— 而它不会报错,只会让使用者困惑。
func TestEncStoreLeavesNoCopyInPlugin(t *testing.T) {
p, v := newTestPluginWithVault(t)
const secret = "NO-COPIES-CANARY-77aa"
if _, err := p.handleEncStore(map[string]interface{}{
"alias": "x", "value": secret,
}); err != nil {
t.Fatal(err)
}
// ① 内核库里**有**值
if len(v.values) != 1 {
t.Fatalf("内核库应有 1 条,实际 %d", len(v.values))
}
// ② 插件落盘文件里**没有**值
b, err := os.ReadFile(p.refsPath())
if err == nil {
if strings.Contains(string(b), secret) {
t.Fatalf("插件侧文件出现了明文(应只存名字映射): %s", b)
}
}
// ③ 插件的映射里**只有名字与别名**,没有值字段
p.mu.Lock()
for _, r := range p.refs.Refs {
rb, _ := json.Marshal(r)
if strings.Contains(string(rb), secret) {
t.Fatalf("映射里出现了值: %s", rb)
}
}
p.mu.Unlock()
// ④ 映射文件权限 0600(名字本身有情报价值)
if fi, err := os.Stat(p.refsPath()); err == nil {
if fi.Mode().Perm() != 0o600 {
t.Errorf("映射文件权限应为 0600,实际 %04o", fi.Mode().Perm())
}
}
}
// ★ 来源分级:${ENV} 与 @file: 都应被识别,且**不告警**(明文未进会话)。
func TestEncStoreSourceClasses(t *testing.T) {
p, _ := newTestPluginWithVault(t)
os.Setenv("ENC_TEST_VAR", "from-env-secret")
defer os.Unsetenv("ENC_TEST_VAR")
out, _ := p.handleEncStore(map[string]interface{}{
"alias": "from-env", "value": "${ENC_TEST_VAR}"})
m := out.(map[string]interface{})
if m["source"] != "env" {
t.Errorf("来源应为 env,实际 %v", m["source"])
}
if _, bad := m["warning"]; bad {
t.Error("env 来源不该告警(明文没进会话)")
}
f := filepath.Join(t.TempDir(), "tok.txt")
os.WriteFile(f, []byte("from-file-secret\n"), 0o600) // 带换行(token 文件常见)
out2, _ := p.handleEncStore(map[string]interface{}{
"alias": "from-file", "value": "@file:" + f})
m2 := out2.(map[string]interface{})
if m2["source"] != "file" {
t.Errorf("来源应为 file,实际 %v", m2["source"])
}
if _, bad := m2["warning"]; bad {
t.Error("file 来源不该告警")
}
// ★ 换行必须被去掉(否则填充进命令会带换行 → 命令坏掉)
// 通过 enc_shell 的解析路径验证(值已在内核库,只能经它读回)
plain, found, err := p.lookupAnySecret("from-file", nil)
if err != nil || !found {
t.Fatalf("应能解析 from-file: found=%v err=%v", found, err)
}
if plain != "from-file-secret" {
t.Errorf("文件内容应去掉尾部换行,实际 %q", plain)
}
}
// ★ 判据:enc_list / enc_show **绝不返回值本体**。
func TestEncListShowNeverRevealValue(t *testing.T) {
p, _ := newTestPluginWithVault(t)
const secret = "LIST-SHOW-CANARY-2e08"
p.handleEncStore(map[string]interface{}{"alias": "a", "value": secret})
l, _ := p.handleEncList(nil)
lb, _ := json.Marshal(l)
if strings.Contains(string(lb), secret) {
t.Fatalf("enc_list 泄露了值: %s", lb)
}
s, _ := p.handleEncShow(map[string]interface{}{"alias": "a"})
sb, _ := json.Marshal(s)
if strings.Contains(string(sb), secret) {
t.Fatalf("enc_show 泄露了值: %s", sb)
}
mm := s.(map[string]interface{})
if !strings.Contains(mm["hint"].(string), "enc_shell") {
t.Errorf("应指引用 enc_shell 引用: %v", mm["hint"])
}
if !strings.Contains(mm["storage"].(string), "内核变量库") {
t.Errorf("应说清值存于内核变量库: %v", mm["storage"])
}
}
// ★ 判据:**删除时同步清内核库**(否则留下无人引用的密文)。
func TestEncRmForgetsKernelVariable(t *testing.T) {
p, v := newTestPluginWithVault(t)
p.handleEncStore(map[string]interface{}{"alias": "gone", "value": "to-be-removed"})
if len(v.values) != 1 {
t.Fatal("前提:内核库应有 1 条")
}
out, err := p.handleEncRm(map[string]interface{}{"alias": "gone"})
if err != nil {
t.Fatal(err)
}
if m := out.(map[string]interface{}); m["status"] != "ok" {
t.Fatalf("删除应成功: %v", m)
}
// ★ 内核库里的值也必须没了
if len(v.values) != 0 {
t.Fatalf("内核库应被清空(否则留下无人引用的密文),实际 %d 条", len(v.values))
}
// 映射也没了
p.mu.Lock()
_, still := p.refs.Refs["gone"]
p.mu.Unlock()
if still {
t.Error("映射未删除")
}
// 再删应报不存在(不是静默成功)
out2, _ := p.handleEncRm(map[string]interface{}{"alias": "gone"})
if _, isErr := out2.(map[string]interface{})["error"]; !isErr {
t.Error("重复删除应明确报错")
}
}
// ★ 判据:**变量库不可用时拒绝存储**(不静默降级到明文/私有文件)。
func TestEncStoreRefusesWhenVaultUnavailable(t *testing.T) {
p := newTestPluginForShell(t)
p.refs = secretRefs{Refs: map[string]*secretRef{}}
p.vault = unavailableVault{}
out, err := p.handleEncStore(map[string]interface{}{
"alias": "x", "value": "must-not-be-stored"})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]interface{})
if _, isErr := m["error"]; !isErr {
t.Fatalf("变量库不可用时应报错,实际 %v", m)
}
// 且不该在插件侧留下任何东西
p.mu.Lock()
n := len(p.refs.Refs)
p.mu.Unlock()
if n != 0 {
t.Error("拒绝后不该留下映射")
}
}
type unavailableVault struct{}
func (unavailableVault) Available() bool { return false }
func (unavailableVault) Store(string, string) (string, error) { return "", errUnavailable }
func (unavailableVault) List() []sdk.SecretInfo { return nil }
func (unavailableVault) Forget(string) error { return errUnavailable }
func (unavailableVault) Fill(t string) (string, int, []string) { return t, 0, nil }
func (unavailableVault) Encrypt([]byte) (string, error) { return "", errUnavailable }
func (unavailableVault) Decrypt(string) ([]byte, error) { return nil, errUnavailable }
var errUnavailable = errNotAvailable{}
type errNotAvailable struct{}
func (errNotAvailable) Error() string { return "变量库不可用" }
// ★ 判据:别名合法性 —— 它会进 `@{别名}` 引用,字符集必须受限。
func TestEncStoreRejectsBadAlias(t *testing.T) {
p, _ := newTestPluginWithVault(t)
for _, bad := range []string{"has space", "has/slash", "has@at", ""} {
out, _ := p.handleEncStore(map[string]interface{}{"alias": bad, "value": "v"})
if _, isErr := out.(map[string]interface{})["error"]; !isErr {
t.Errorf("别名 %q 应被拒", bad)
}
}
// 合法字符集
for _, ok := range []string{"good-name", "good_name", "good.name", "n123"} {
out, _ := p.handleEncStore(map[string]interface{}{"alias": ok, "value": "v"})
if _, isErr := out.(map[string]interface{})["error"]; isErr {
t.Errorf("别名 %q 应被接受,实际 %v", ok, out)
}
}
}

View File

@ -0,0 +1,637 @@
package main
// 远端端点表:**按 host 存一套,按 (host, cli) 覆盖一套**。
//
// ## 为什么必须按 host 分开
//
// 改动前 SSH 相关的东西只有**一组全局 settings**:
//
// ssh_node_path 远端 node 路径
// ssh_runtime_root 远端放 sidecar 脚本的目录
// ssh_workdir_root 远端放工程目录的根
//
// 单台远端时够用。**两台就开始互相打架**:A 机 node 在 `/usr/local/bin/node`、
// B 机在 `/opt/node/bin/node`;A 把脚本放 `/home/a/rt`、B 在 `/srv/rt`。
// 全局只有一个值 ⇒ 后配的那台覆盖前一台,表现为"换台机器就报找不到 node/
// 找不到脚本",而报错指向的是**另一台机器的路径**,排查方向完全错。
//
// ## 为什么还要按 (host, cli) 再分
//
// 同一个 host 上不同后端 CLI 的路径天然不同:zcode 在
// `/opt/zcode/zcode.cjs`,将来接别的 CLI 又在别处。而且同一台机器上
// 可能出现**同一 CLI 的多个版本**(系统装的 + 用户目录装的),
// 需要在不改 host 默认值的前提下为某个后端单独指定。
//
// ## 结构(优先级从高到低)
//
// ① endpoints[host].CLIs[cli].CLIPath 该机器该后端的专属 CLI 路径
// ② endpoints[host].CLIPath 该机器上所有后端的默认 CLI 路径
// ③ driver.json 的 cliPath 随包/全局默认(本地与远端共用同一份声明)
//
// node/runtime_root/workdir_root 同理:host 级默认 + (host,cli) 级覆盖。
//
// ★ 关键约束:**本机路径绝不发给远端**。见 resolveRemoteEndpoint 的说明。
//
// ## 为什么放独立文件而不是塞进 ledger.json
//
// ledger 是"工程状态"(可重建的派生数据);端点是"环境配置"
// (人手工维护、丢了要重配、且含机器专属信息)。混在一起会让
// ledger 的保存路径(每次分支变动都写)碰到与它无关的配置,
// 且一次台账损坏会连带丢掉配置。参照 credentials.json 的先例。
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// cliEndpoint 是**某台机器上某个后端**的专属设置。
type cliEndpoint struct {
// CLIPath 该后端在这台机器上的 CLI 入口(如 /opt/zcode/zcode.cjs)。
CLIPath string `json:"cli_path,omitempty"`
// NodePath 该后端用的 node(留空则用 host 级或默认)。
NodePath string `json:"node_path,omitempty"`
// RuntimeRoot 该后端 sidecar 脚本落地的远端目录(留空则用 host 级)。
RuntimeRoot string `json:"runtime_root,omitempty"`
// Env 该后端额外的远端环境变量(展开占位符后前置到 ssh 命令)。
Env map[string]string `json:"env,omitempty"`
// Note 备注(给人看;例如"这台机器是测试机")。
Note string `json:"note,omitempty"`
}
// hostEndpoint 是**一台机器**的设置。
type hostEndpoint struct {
// CLIPath 这台机器上后端的**默认** CLI 路径(被 CLIs[cli].CLIPath 覆盖)。
CLIPath string `json:"cli_path,omitempty"`
// NodePath 这台机器的 node(默认 `node`,走远端 PATH)。
NodePath string `json:"node_path,omitempty"`
// RuntimeRoot 这台机器放 sidecar 脚本的目录。
RuntimeRoot string `json:"runtime_root,omitempty"`
// WorkdirRoot 这台机器放工程目录的根。
WorkdirRoot string `json:"workdir_root,omitempty"`
// CLIs 按后端覆盖(key 是 agent_cli,如 "zcode")。
CLIs map[string]*cliEndpoint `json:"clis,omitempty"`
// Note 备注。
Note string `json:"note,omitempty"`
}
// endpointTable 是端点表的磁盘形态。
type endpointTable struct {
// Version 供将来迁移用(现在只有 1)。
Version int `json:"version"`
// Hosts key 是 ssh_host(与 branch_open 的 ssh_host 参数逐字一致,
// 包括 `user@host:port` 或 ssh config 别名)。
Hosts map[string]*hostEndpoint `json:"hosts"`
}
// endpointsPath 端点表与台账同目录(与 credentials.json 一致)。
func (p *Plugin) endpointsPath() string {
return filepath.Join(filepath.Dir(p.path), "endpoints.json")
}
// loadEndpoints 读端点表。
//
// ★ 缺文件**不是错误**(首次运行、或只用本地后端的人永远不会有它)。
// 但**损坏必须是错误** —— 静默用空表会让所有远端配置凭空消失,
// 表现为"我明明配过,怎么又要配",而用户会先怀疑自己。
func (p *Plugin) loadEndpoints() error {
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
b, err := os.ReadFile(p.endpointsPath())
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var t endpointTable
if err := json.Unmarshal(b, &t); err != nil {
return fmt.Errorf("端点表损坏(拒绝猜默认,否则所有远端配置会静默消失): %s: %w",
p.endpointsPath(), err)
}
if t.Hosts == nil {
t.Hosts = map[string]*hostEndpoint{}
}
if t.Version == 0 {
t.Version = 1
}
p.endpoints = &t
return nil
}
// saveEndpoints 原子写(tmp + rename),权限 0600。
//
// 0600 而不是 0644:端点表可能含机器内网地址、端口、用户目录结构,
// 也**可能**含 env 里塞进去的令牌(虽然不推荐,但不该由文件权限替人决定)。
func (p *Plugin) saveEndpoints() error {
b, err := json.MarshalIndent(p.endpoints, "", " ")
if err != nil {
return err
}
path := p.endpointsPath()
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// getHost 取(或按需创建)某台机器的设置。
//
// create=false 时**不**创建 —— 读路径不该有副作用(否则一个查询工具
// 就会往表里塞一堆空条目,把真实配置淹掉)。
func (p *Plugin) getHost(host string, create bool) *hostEndpoint {
if p.endpoints == nil {
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
}
if p.endpoints.Hosts == nil {
p.endpoints.Hosts = map[string]*hostEndpoint{}
}
h, ok := p.endpoints.Hosts[host]
if !ok {
if !create {
return nil
}
h = &hostEndpoint{CLIs: map[string]*cliEndpoint{}}
p.endpoints.Hosts[host] = h
}
if h.CLIs == nil {
h.CLIs = map[string]*cliEndpoint{}
}
return h
}
// resolvedEndpoint 是"算完之后"的最终取值(三层合并的结果)。
type resolvedEndpoint struct {
Host string
CLI string
CLIPath string
CLIPathFrom string // 取值来源,**必须在错误信息里出现**
NodePath string
RuntimeRoot string
WorkdirRoot string
Env map[string]string
}
// resolveEndpoint 把三层配置合并成最终端点。
//
// cliPathFrom 记录取值来源:出问题时要能一句话说清"这个路径是从哪来的"。
// 没有它,用户面对"远端找不到 CLI"根本不知道该改哪个文件 ——
// 本会话已经因为"错误不指出位置"吃过多次教训。
func (p *Plugin) resolveEndpoint(host, cli string, drv driverSpec, ctx driverCtx) resolvedEndpoint {
r := resolvedEndpoint{Host: host, CLI: cli, NodePath: "node", Env: map[string]string{}}
h := p.getHost(host, false)
if h != nil {
r.CLIPath, r.CLIPathFrom = h.CLIPath, "host 默认(endpoints.json)"
r.NodePath = h.NodePath
r.RuntimeRoot = h.RuntimeRoot
r.WorkdirRoot = h.WorkdirRoot
}
if h != nil && h.CLIs != nil {
if c, ok := h.CLIs[cli]; ok && c != nil {
if strings.TrimSpace(c.CLIPath) != "" {
r.CLIPath, r.CLIPathFrom = c.CLIPath, "该后端专属(endpoints.json)"
}
if strings.TrimSpace(c.NodePath) != "" {
r.NodePath = c.NodePath
}
if strings.TrimSpace(c.RuntimeRoot) != "" {
r.RuntimeRoot = c.RuntimeRoot
}
for k, v := range c.Env {
r.Env[k] = v
}
}
}
if strings.TrimSpace(r.NodePath) == "" {
r.NodePath = "node"
}
// 兜底:driver.json 的 cliPath(随包/全局声明)。
//
// ★ 只有**未被任何端点配置覆盖**时才用它。而且它可能含 {rawfile} 这类
// **本地**占位符 —— 那种路径绝不能发给远端(见 resolveRemoteCLIPath)。
if r.CLIPath == "" && strings.TrimSpace(drv.CLIPath) != "" {
r.CLIPath = drv.expand(drv.CLIPath, ctx)
r.CLIPathFrom = "driver.json 的 cliPath 默认值"
}
// 兼容旧配置:ssh_node_path / ssh_runtime_root / ssh_workdir_root
// 只在端点表没有对应值时生效,避免"配了端点表却还被旧键盖住"。
//
// ★ 经 settingStr 而不是裸 p.sdk.Settings():Settings 未装配时
// 裸调用会 **nil 解引用 panic**,而这里在 ssh 拉起的热路径上 ——
// 一个配置读取的健壮性问题会变成"拉 agent 直接崩"。
if r.RuntimeRoot == "" {
r.RuntimeRoot = p.settingStr("ssh_runtime_root")
}
if r.WorkdirRoot == "" {
r.WorkdirRoot = p.settingStr("ssh_workdir_root")
}
if r.NodePath == "node" {
if s := p.settingStr("ssh_node_path"); s != "" {
r.NodePath = s
}
}
return r
}
// settingStr 读一个字符串 setting,**容忍 Settings 未装配**。
//
// 返回 "" 表示"没配/取不到";调用方据此走默认值。
// 修这个是因为判据造了不带 Settings 的插件就 panic —— 那说明
// 任何"Settings 尚未注入"的调用点都会崩,属真实健壮性缺口。
func (p *Plugin) settingStr(key string) string {
if p.sdk == nil {
return ""
}
st := p.sdk.Settings()
if st == nil {
return ""
}
v, _ := st.Get(key)
if v == nil {
return ""
}
s := strings.TrimSpace(fmt.Sprint(v))
if s == "" || s == "<nil>" {
return ""
}
return s
}
// localPathPlaceholders 是**只在本机有意义**的占位符。
//
// ★ 这是一条安全/正确性边界,不是审美问题:`{rawfile}` 展开成
// `<本机插件目录>/rawfile`,把它发给远端 100% 是个不存在的路径,
// 而远端报的错误是"文件不存在"——用户会去远端找,方向完全错。
var localPathPlaceholders = []string{"{rawfile}", "{pluginDir}", "{dataDir}"}
// containsLocalPlaceholder 报告路径里是否含本机专属占位符。
func containsLocalPlaceholder(s string) bool {
for _, ph := range localPathPlaceholders {
if strings.Contains(s, ph) {
return true
}
}
return false
}
// validateRemoteCLIPath 在**启动 ssh 之前**校验远端 CLI 路径。
//
// 两条规则(都是实测踩出来的):
//
// 1. **本机占位符不得下发**:展开后是本机路径,远端一定不存在。
// 2. **本机绝对路径不得下发**:即使不是占位符,用户也可能把本机的
// `/home/program/.zcode-src/.../zcode.cjs` 直接填进 host 端点,
// 而远端是另一台机器。这比第 1 条更隐蔽 —— 它看起来就是个正常路径。
//
// 两条都必须**在 ssh 之前**拦:让远端去失败的话,错误来自远端 shell
// ("No such file or directory"),用户会以为远端缺文件而不是本地配错了。
func validateRemoteCLIPath(cliPath, cli string) error {
if strings.TrimSpace(cliPath) == "" {
return fmt.Errorf(
"远端 %s 没有可用的 CLI 路径。请在 endpoints.json 里为该机器配置 "+
"hosts.<ssh_host>.cli_path(该机所有后端的默认)或 "+
"hosts.<ssh_host>.clis.%s.cli_path(该后端的专属路径)。"+
"可用 commondev_endpoint_set 写入(会校验路径在远端是否存在)。", cli, cli)
}
if containsLocalPlaceholder(cliPath) {
return fmt.Errorf(
"★ 远端 CLI 路径含**本机专属占位符**(%s):%q。"+
"{rawfile}/{pluginDir}/{dataDir} 展开后是**本机**路径,发给远端必然不存在。"+
"请把它换成该机器上的真实路径(endpoint_set host=<ssh_host> cli=%s cli_path=<远端路径>)",
strings.Join(localPathPlaceholders, "/"), cliPath, cli)
}
return nil
}
// endpointListRows 把端点表摊平成可读行(供 commondev_endpoint_list)。
//
// 一并报出**解析后的默认值**,因为"我这台机器最终会用哪个路径"才是
// 用户真正要回答的问题;只给原始 JSON 等于让人自己心算三层合并。
func (p *Plugin) endpointListRows() []map[string]interface{} {
rows := []map[string]interface{}{}
if p.endpoints == nil {
return rows
}
hosts := make([]string, 0, len(p.endpoints.Hosts))
for h := range p.endpoints.Hosts {
hosts = append(hosts, h)
}
sort.Strings(hosts)
for _, h := range hosts {
he := p.endpoints.Hosts[h]
if he == nil {
continue
}
row := map[string]interface{}{
"host": h,
"cli_path": he.CLIPath,
"node_path": he.NodePath,
"runtime_root": he.RuntimeRoot,
"workdir_root": he.WorkdirRoot,
"note": he.Note,
}
if len(he.CLIs) > 0 {
clis := make([]string, 0, len(he.CLIs))
for c := range he.CLIs {
clis = append(clis, c)
}
sort.Strings(clis)
cm := map[string]interface{}{}
for _, c := range clis {
ce := he.CLIs[c]
if ce == nil {
continue
}
cm[c] = map[string]interface{}{
"cli_path": ce.CLIPath,
"node_path": ce.NodePath,
"runtime_root": ce.RuntimeRoot,
"env": ce.Env,
"note": ce.Note,
}
}
row["clis"] = cm
}
rows = append(rows, row)
}
return rows
}
// ---------------- 工具处理 ----------------
// handleEndpointSet 写某台机器(或该机器某后端)的端点配置。
//
// ★ 默认会 ssh 到远端**确认 cli_path 真实存在**。
// 为什么默认开:本会话反复出现的失败模式就是"配置写对了但指向一个
// 不存在的东西",而症状出现在很远的地方(远端 shell 报 No such file)。
// 在这里花一次 ssh 往返,能把它变成一条立刻可见的、指出位置的错误。
func (p *Plugin) handleEndpointSet(args map[string]interface{}) (interface{}, error) {
host := strings.TrimSpace(str(args, "host"))
if host == "" {
return map[string]interface{}{"error": "必须给 host(与 branch_open 的 ssh_host 逐字一致)"}, nil
}
cli := strings.TrimSpace(str(args, "cli"))
cliPath := strings.TrimSpace(str(args, "cli_path"))
nodePath := strings.TrimSpace(str(args, "node_path"))
runtimeRoot := strings.TrimSpace(str(args, "runtime_root"))
workdirRoot := strings.TrimSpace(str(args, "workdir_root"))
note := strings.TrimSpace(str(args, "note"))
// ★ 本机路径不得写进**远端**配置:写了就是 100% 起不来,
// 而报错来自远端 shell,排查方向完全错。在这里拦最省事。
if cliPath != "" {
if containsLocalPlaceholder(cliPath) {
return map[string]interface{}{
"error": fmt.Sprintf(
"cli_path 含本机专属占位符(%s):%q。这些展开后是**本机**路径,"+
"远端必然不存在。请填该机器上的真实路径",
strings.Join(localPathPlaceholders, "/"), cliPath),
}, nil
}
if isLocalPathHint(cliPath) {
return map[string]interface{}{
"error": fmt.Sprintf(
"cli_path %q 看起来是**本机**路径(在 %s 下)。请填该**远端机器**上的路径;"+
"若该机器确实与本地同构(如 localhost 回环),传 verify=false 并确认无误",
cliPath, p.localRootHint()),
}, nil
}
}
if workdirRoot != "" && cli != "" {
return map[string]interface{}{
"error": "workdir_root 是**机器级**设置(该机器上所有后端共用),请去掉 cli 参数单独设置",
}, nil
}
// 远端验存(默认开)
verify := true
if v, ok := args["verify"]; ok {
if b, isB := v.(bool); isB {
verify = b
}
}
if verify && cliPath != "" {
t, err := parseSSHTarget(&Branch{SSHHost: host})
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
if _, err := p.sshRunCapture(t, "test -f "+shellQuote(cliPath)+" && echo __OK__"); err != nil {
return map[string]interface{}{
"error": fmt.Sprintf("远端 %s 上不存在 %s(ssh 验存失败,未写入配置):%v。"+
"确认路径后重试;若机器暂时不可达,传 verify=false 先记下配置", host, cliPath, err),
}, nil
}
}
p.mu.Lock()
defer p.mu.Unlock()
he := p.getHost(host, true)
if cli != "" {
ce, ok := he.CLIs[cli]
if !ok || ce == nil {
ce = &cliEndpoint{}
he.CLIs[cli] = ce
}
if cliPath != "" {
ce.CLIPath = cliPath
}
if nodePath != "" {
ce.NodePath = nodePath
}
if runtimeRoot != "" {
ce.RuntimeRoot = runtimeRoot
}
if note != "" {
ce.Note = note
}
} else {
if cliPath != "" {
he.CLIPath = cliPath
}
if nodePath != "" {
he.NodePath = nodePath
}
if runtimeRoot != "" {
he.RuntimeRoot = runtimeRoot
}
if workdirRoot != "" {
he.WorkdirRoot = workdirRoot
}
if note != "" {
he.Note = note
}
}
if err := p.saveEndpoints(); err != nil {
return map[string]interface{}{"error": "保存端点表失败: " + err.Error()}, nil
}
return map[string]interface{}{
"status": "ok",
"host": host,
"cli": cli,
"scope": map[bool]string{true: "该后端专属", false: "该机器默认"}[cli != ""],
}, nil
}
// handleEndpointList 列出端点配置。
//
// 一并给出**解析后的最终值**:用户真正要回答的是"这台机器最终用哪个路径",
// 只给原始 JSON 等于让人自己心算三层合并。给 host+cli 时才计算。
func (p *Plugin) handleEndpointList(args map[string]interface{}) (interface{}, error) {
host := strings.TrimSpace(str(args, "host"))
p.mu.Lock()
defer p.mu.Unlock()
// ★ 端点表可能尚未加载(裸插件 / 加载失败前的调用)。
// 空参数调用不得 panic —— 工具会遇到模型乱传参数与半初始化状态。
if p.endpoints == nil {
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
}
if host != "" {
he := p.getHost(host, false)
if he == nil {
return map[string]interface{}{
"status": "ok", "host": host, "configured": false,
"hint": "该机器没有端点配置。用 commondev_endpoint_set host=" + host +
" cli_path=<远端 CLI 路径> runtime_root=<远端目录> workdir_root=<远端工程根> 配置",
}, nil
}
m := map[string]interface{}{
"status": "ok", "host": host, "configured": true,
"cli_path": he.CLIPath, "node_path": he.NodePath,
"runtime_root": he.RuntimeRoot, "workdir_root": he.WorkdirRoot,
"note": he.Note,
}
if len(he.CLIs) > 0 {
cm := map[string]interface{}{}
for c, ce := range he.CLIs {
if ce == nil {
continue
}
cm[c] = map[string]interface{}{
"cli_path": ce.CLIPath, "node_path": ce.NodePath,
"runtime_root": ce.RuntimeRoot, "env": ce.Env, "note": ce.Note,
}
}
m["clis"] = cm
}
return m, nil
}
return map[string]interface{}{
"status": "ok", "count": len(p.endpoints.Hosts), "hosts": p.endpointListRows(),
}, nil
}
// handleEndpointRemove 删端点配置。
//
// ★ 该机器有后端覆盖时,只给 host 会**拒绝**(除非 force):
// 否则会出现"以为删了机器,实际子项还在,下次又把老路径拿出来用"。
func (p *Plugin) handleEndpointRemove(args map[string]interface{}) (interface{}, error) {
host := strings.TrimSpace(str(args, "host"))
cli := strings.TrimSpace(str(args, "cli"))
force := false
if v, ok := args["force"]; ok {
if b, isB := v.(bool); isB {
force = b
}
}
if host == "" {
return map[string]interface{}{"error": "必须给 host"}, nil
}
p.mu.Lock()
defer p.mu.Unlock()
if p.endpoints == nil {
p.endpoints = &endpointTable{Version: 1, Hosts: map[string]*hostEndpoint{}}
}
he := p.getHost(host, false)
if he == nil {
return map[string]interface{}{"status": "ok", "host": host, "removed": false,
"note": "该机器本来就没有配置(幂等)"}, nil
}
if cli != "" {
if _, ok := he.CLIs[cli]; !ok {
return map[string]interface{}{"status": "ok", "host": host, "cli": cli, "removed": false,
"note": "该后端本来就没有专属配置(幂等)"}, nil
}
delete(he.CLIs, cli)
if err := p.saveEndpoints(); err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
return map[string]interface{}{"status": "ok", "host": host, "cli": cli, "removed": true}, nil
}
// 只给 host:有子项时拒绝
if len(he.CLIs) > 0 && !force {
names := make([]string, 0, len(he.CLIs))
for c := range he.CLIs {
names = append(names, c)
}
sort.Strings(names)
return map[string]interface{}{
"status": "refused",
"reason": fmt.Sprintf("该机器还有 %d 个后端专属配置(%s)。请先逐个删"+
"(commondev_endpoint_remove host=%s cli=<名>),或传 force=true 一并删除",
len(names), strings.Join(names, ", "), host),
}, nil
}
delete(p.endpoints.Hosts, host)
if err := p.saveEndpoints(); err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
return map[string]interface{}{"status": "ok", "host": host, "removed": true}, nil
}
// isLocalPathHint 粗略判断路径是否"像是本机路径"。
//
// 用本机真实存在的根(插件数据目录 / 工程目录)去比:
// 比"硬编码 /home/program 或 /home/newqqagent"可靠 —— 换台机器部署仍然对。
// 只做**提示**不做强制(确有 localhost 回环这种同构合法情形)。
func isLocalPathHint(cliPath string) bool {
hint := currentLocalRootHint()
if hint == "" {
return false
}
return strings.HasPrefix(cliPath, hint)
}
// localRootHint 给用户看的本机根(错误信息里说"本机在 X 下")。
func (p *Plugin) localRootHint() string {
return currentLocalRootHint()
}
// currentLocalRootHint 取一个"本机特征路径"用于提示。
func currentLocalRootHint() string {
if exe, err := os.Executable(); err == nil {
d := filepath.Dir(exe)
if d != "" && d != "/" {
return d
}
}
if hd, err := os.UserHomeDir(); err == nil && hd != "" {
return hd
}
return ""
}
// sshRunCapture 在远端跑一条命令并取回 stdout(stderr 一并带在错误里)。
//
// 供端点验存等**轻量**场景使用;重活(准备目录、推脚本)走各自专门函数。
func (p *Plugin) sshRunCapture(t sshTarget, cmd string) (string, error) {
args := append(t.sshBaseArgs(), t.Host, cmd)
out, err := exec.Command("ssh", args...).CombinedOutput()
s := string(out)
if err != nil {
return s, fmt.Errorf("%w: %s", err, strings.TrimSpace(s))
}
if !strings.Contains(s, "__OK__") {
return s, fmt.Errorf("远端未返回预期标记(命令可能未生效): %s", strings.TrimSpace(s))
}
return s, nil
}

View File

@ -0,0 +1,388 @@
package main
// 远端端点表的判据。
//
// 核心要证明的**不是**"有个 endpoints.json",而是:
// ① 多台机器**互不干扰**(这是用户提出该需求的唯一理由);
// ② 本机路径**绝不**下发到远端(这是最危险的真实缺陷);
// ③ 同一机器上不同后端可以各有各的路径;
// ④ 出错时错误信息**指出该改哪里**(否则用户只能猜)。
import (
"os"
"path/filepath"
"strings"
"testing"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// newEndpointPlugin 造一个带端点表的插件(数据目录在临时目录)。
func newEndpointPlugin(t *testing.T) *Plugin {
t.Helper()
sdkp := sdk.New("commondev-test", nil, nil, nil, nil, nil)
p := &Plugin{sdk: sdkp}
dir := t.TempDir()
p.path = filepath.Join(dir, "ledger.json")
if err := p.loadEndpoints(); err != nil {
t.Fatal(err)
}
return p
}
// ★★★ 判据:两台机器的配置**互不干扰**。
//
// 这是用户提出该需求的原因:改动前只有一组全局 settings
// (ssh_node_path / ssh_runtime_root / ssh_workdir_root),两台机器时
// 后配的覆盖前一台,表现为"换台机器就报找不到 node/脚本",
// 而报错指向的是**另一台机器的路径**。
func TestEndpointsArePerHost(t *testing.T) {
p := newEndpointPlugin(t)
set := func(host string, kv map[string]interface{}) {
t.Helper()
args := map[string]interface{}{"host": host, "verify": false}
for k, v := range kv {
args[k] = v
}
r, _ := p.handleEndpointSet(args)
if m := r.(map[string]interface{}); m["error"] != nil {
t.Fatalf("设置 %s 失败: %v", host, m["error"])
}
}
set("alpha", map[string]interface{}{
"cli_path": "/opt/alpha/bin/zcode.cjs", "node_path": "/usr/local/bin/node",
"runtime_root": "/home/a/rt", "workdir_root": "/home/a/prog",
})
set("beta", map[string]interface{}{
"cli_path": "/srv/beta/zcode.mjs", "node_path": "/opt/node/bin/node",
"runtime_root": "/srv/rt", "workdir_root": "/srv/prog",
})
// ★ 每台机器解析出的都必须是**自己**那套
drv := driverSpec{}
ctx := driverCtx{}
ea := p.resolveEndpoint("alpha", "zcode", drv, ctx)
if ea.CLIPath != "/opt/alpha/bin/zcode.cjs" {
t.Errorf("alpha 的 cliPath 被串了: %q", ea.CLIPath)
}
if ea.NodePath != "/usr/local/bin/node" || ea.RuntimeRoot != "/home/a/rt" {
t.Errorf("alpha 的 node/runtime 被串了: %+v", ea)
}
if ea.WorkdirRoot != "/home/a/prog" {
t.Errorf("alpha 的 workdirRoot 被串了: %q", ea.WorkdirRoot)
}
eb := p.resolveEndpoint("beta", "zcode", drv, ctx)
if eb.CLIPath != "/srv/beta/zcode.mjs" {
t.Errorf("beta 的 cliPath 被串了: %q", eb.CLIPath)
}
if eb.NodePath != "/opt/node/bin/node" || eb.RuntimeRoot != "/srv/rt" {
t.Errorf("beta 的 node/runtime 被串了: %+v", eb)
}
if eb.WorkdirRoot != "/srv/prog" {
t.Errorf("beta 的 workdirRoot 被串了: %q", eb.WorkdirRoot)
}
// 未配置的第三台:不能"借用"别人的值
ec := p.resolveEndpoint("gamma", "zcode", drv, ctx)
if ec.CLIPath != "" {
t.Errorf("★ gamma 未配置却拿到了别人的 cliPath: %q", ec.CLIPath)
}
}
// ★★★ 判据:同一机器上**不同后端各有各的路径**。
//
// 这是需求里"每个 cli 客户端有自己的 ip/cli 对"的一半:
// 同一台机器上 zcode 在 A、别的 CLI 在 B,必须能分别指定。
func TestEndpointsPerHostAndCLI(t *testing.T) {
p := newEndpointPlugin(t)
// 机器级默认
if r, _ := p.handleEndpointSet(map[string]interface{}{
"host": "box", "cli_path": "/usr/local/bin/default-cli", "verify": false,
}); r.(map[string]interface{})["error"] != nil {
t.Fatalf("设机器默认失败: %v", r)
}
// zcode 专属(应覆盖机器默认)
if r, _ := p.handleEndpointSet(map[string]interface{}{
"host": "box", "cli": "zcode", "cli_path": "/opt/zcode/zcode.cjs",
"node_path": "/opt/node/bin/node", "verify": false,
}); r.(map[string]interface{})["error"] != nil {
t.Fatalf("设 zcode 专属失败: %v", r)
}
drv := driverSpec{}
ctx := driverCtx{}
z := p.resolveEndpoint("box", "zcode", drv, ctx)
if z.CLIPath != "/opt/zcode/zcode.cjs" {
t.Errorf("★ zcode 专属路径未覆盖机器默认: %q", z.CLIPath)
}
if z.NodePath != "/opt/node/bin/node" {
t.Errorf("zcode 专属 node 未生效: %q", z.NodePath)
}
// 另一个后端:应拿到**机器默认**,而不是 zcode 的
other := p.resolveEndpoint("box", "othercli", drv, ctx)
if other.CLIPath != "/usr/local/bin/default-cli" {
t.Errorf("★ 其他后端应回落到机器默认: %q", other.CLIPath)
}
if other.NodePath != "node" {
t.Errorf("zcode 的 node 不该泄漏给其他后端: %q", other.NodePath)
}
// 解析来源必须可读(错误信息靠它指出该改哪里)
if !strings.Contains(z.CLIPathFrom, "专属") {
t.Errorf("CLIPathFrom 未标明来源: %q", z.CLIPathFrom)
}
if !strings.Contains(other.CLIPathFrom, "host") {
t.Errorf("回落时应标明来源是 host 默认: %q", other.CLIPathFrom)
}
}
// ★★★ 判据:**本机路径不得下发到远端**(本会话最危险的真实缺陷)。
//
// 改动前 `drv.buildArgs(ctx)` 会把 driver.json 的 `cliPath`
// (随包默认是 `{rawfile}/zcode/zcode.cjs`)展开成**本机**路径塞进 `--cli`,
// 然后整条命令发到远端 ⇒ 远端必然报 "No such file",而用户会去
// **远端**找原因,真正的错在本地。
func TestRemoteCommandDoesNotCarryLocalPaths(t *testing.T) {
ctx := driverCtx{
Workdir: "/w", SessionID: "s", SessionName: "n",
DataDir: "/d", PluginDir: "/p", RawfileDir: "/rf",
}
drv := driverSpec{
Args: []string{"--workdir", "{workdir}", "--session-id", "{sessionId}"},
CLIPath: "{rawfile}/zcode/zcode.cjs", // ★ 本机占位符
}
// ① 端点表里配了远端真实路径 ⇒ 命令里只能出现远端那条
ep := resolvedEndpoint{CLIPath: "/opt/remote/zcode.cjs"}
_, args, _ := buildRemoteCommandFor(drv, ctx, "/usr/bin/node", ep)
joined := strings.Join(args, " ")
if strings.Contains(joined, "/rf/") || strings.Contains(joined, "{rawfile}") {
t.Errorf("★ 远端命令里出现了**本机**路径: %v", args)
}
if !strings.Contains(joined, "/opt/remote/zcode.cjs") {
t.Errorf("★ 远端命令里没有远端 CLI 路径: %v", args)
}
// ② 端点表没配的机器:必须在**启动 ssh 之前**响亮拒绝,
// 而不是把本机路径发过去让远端报错。
//
// 判定依据是**占位符**(本机专属、展开后必然是本机路径),
// 不是"任意看起来像本机的绝对路径"—— 后者在 localhost/同构机器上
// 是合法的,强制拒绝会挡住真实用法。所以这里用占位符版本断言。
errPH := validateRemoteCLIPath("{rawfile}/zcode/zcode.cjs", "zcode")
if errPH == nil {
t.Error("★ 含本机占位符的路径必须拒绝(展开后就是本机路径)")
} else if !strings.Contains(errPH.Error(), "本机") {
t.Errorf("错误应点明本机占位符问题: %v", errPH)
}
// 未配路径(空)必须拒绝并告知去哪儿配
if err := validateRemoteCLIPath("", "zcode"); err == nil {
t.Error("★ 未配远端路径时应拒绝,而不是把值发过去")
}
// 完全没配也拒绝,且**告知去哪儿配**
err := validateRemoteCLIPath("", "zcode")
if err == nil {
t.Fatal("未配路径应拒绝")
}
for _, want := range []string{"endpoint_set", "cli_path", "zcode"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误应含 %q 以便用户知道怎么修: %v", want, err)
}
}
// 远端真实路径应通过
if err := validateRemoteCLIPath("/opt/remote/zcode.cjs", "zcode"); err != nil {
t.Errorf("远端真实路径不该被拒: %v", err)
}
}
// ★★★ 判据:**声明了本机路径要当场拒绝并说明原因**。
func TestEndpointSetRejectsLocalPath(t *testing.T) {
p := newEndpointPlugin(t)
// 构造一个"看起来是本机"的路径:用本机真实存在的根
hint := currentLocalRootHint()
if hint == "" {
t.Skip("无法确定本机根,跳过")
}
local := filepath.Join(hint, "some", "zcode.cjs")
r, _ := p.handleEndpointSet(map[string]interface{}{
"host": "box", "cli_path": local, "verify": false,
})
m := r.(map[string]interface{})
if m["error"] == nil {
t.Fatalf("★ 应拒绝本机路径: %s", local)
}
if !strings.Contains(fmtStr(m["error"]), "本机") {
t.Errorf("错误应点明是本机路径: %v", m["error"])
}
// 占位符同样拒绝
r2, _ := p.handleEndpointSet(map[string]interface{}{
"host": "box", "cli_path": "{rawfile}/z/x.cjs", "verify": false,
})
if r2.(map[string]interface{})["error"] == nil {
t.Error("★ 应拒绝含 {rawfile} 的路径")
}
// 远端真实路径应接受
r3, _ := p.handleEndpointSet(map[string]interface{}{
"host": "box", "cli_path": "/opt/remote/zcode.cjs", "verify": false,
})
if e := r3.(map[string]interface{})["error"]; e != nil {
t.Errorf("远端路径不该被拒: %v", e)
}
}
// ★★★ 判据:删除**不能留下幽灵子项**。
//
// 否则会出现"以为删了机器,实际后端覆盖还在,下次又把老路径拿出来用"。
func TestEndpointRemoveRefusesSilentPartial(t *testing.T) {
p := newEndpointPlugin(t)
p.handleEndpointSet(map[string]interface{}{"host": "box", "cli_path": "/opt/d", "verify": false})
p.handleEndpointSet(map[string]interface{}{"host": "box", "cli": "zcode", "cli_path": "/opt/z", "verify": false})
// 只给 host:必须拒绝(有子项)
r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box"})
m := r.(map[string]interface{})
if m["status"] != "refused" {
t.Fatalf("★ 有后端覆盖时应拒绝整体删除: %v", m)
}
// 拒绝后表必须**完整保留**(不能删一半)
he := p.getHost("box", false)
if he == nil || he.CLIPath != "/opt/d" || len(he.CLIs) != 1 {
t.Fatalf("拒绝后配置被改动了: %+v", he)
}
// 先删子项,再删机器 → 成功
if r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box", "cli": "zcode"}); r.(map[string]interface{})["removed"] != true {
t.Fatalf("删子项失败: %v", r)
}
if r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box"}); r.(map[string]interface{})["removed"] != true {
t.Fatalf("删机器失败: %v", r)
}
if p.getHost("box", false) != nil {
t.Error("机器未真正删除")
}
// force 可以一并删
p.handleEndpointSet(map[string]interface{}{"host": "box2", "cli_path": "/opt/d", "verify": false})
p.handleEndpointSet(map[string]interface{}{"host": "box2", "cli": "zcode", "cli_path": "/opt/z", "verify": false})
if r, _ := p.handleEndpointRemove(map[string]interface{}{"host": "box2", "force": true}); r.(map[string]interface{})["removed"] != true {
t.Fatalf("force 删除失败: %v", r)
}
if p.getHost("box2", false) != nil {
t.Error("force 未连带删掉子项")
}
}
// ★★★ 判据:端点表**持久化且损坏响亮**。
//
// 静默用空表 ⇒ 所有远端配置凭空消失,表现为"我明明配过,怎么又要配",
// 而用户会先怀疑自己。
func TestEndpointsPersistAndCorruptionIsLoud(t *testing.T) {
dir := t.TempDir()
sdkp := sdk.New("commondev-test", nil, nil, nil, nil, nil)
p := &Plugin{sdk: sdkp}
p.path = filepath.Join(dir, "ledger.json")
if err := p.loadEndpoints(); err != nil {
t.Fatal(err)
}
p.handleEndpointSet(map[string]interface{}{
"host": "h1", "cli_path": "/opt/a", "runtime_root": "/rt/a",
"workdir_root": "/wd/a", "verify": false,
})
// 重新加载(模拟重启)
p2 := &Plugin{sdk: sdkp}
p2.path = filepath.Join(dir, "ledger.json")
if err := p2.loadEndpoints(); err != nil {
t.Fatalf("重载失败: %v", err)
}
got := p2.resolveEndpoint("h1", "zcode", driverSpec{}, driverCtx{})
if got.CLIPath != "/opt/a" || got.RuntimeRoot != "/rt/a" || got.WorkdirRoot != "/wd/a" {
t.Fatalf("重启后配置丢失: %+v", got)
}
// 权限 0600(可能含内网地址/目录结构,不该 world-readable)
st, err := os.Stat(p.endpointsPath())
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o600 {
t.Errorf("端点表权限应为 0600,实际 %v", st.Mode().Perm())
}
// 损坏 → 必须响亮
if err := os.WriteFile(p.endpointsPath(), []byte("{ not json"), 0o600); err != nil {
t.Fatal(err)
}
p3 := &Plugin{sdk: sdkp}
p3.path = filepath.Join(dir, "ledger.json")
if err := p3.loadEndpoints(); err == nil {
t.Fatal("★ 端点表损坏时必须报错(静默用空表 = 所有远端配置消失)")
} else if !strings.Contains(err.Error(), "损坏") {
t.Errorf("错误应点明损坏: %v", err)
}
// 缺文件不是错误(首次运行)
p4 := &Plugin{sdk: sdkp}
p4.path = filepath.Join(t.TempDir(), "ledger.json")
if err := p4.loadEndpoints(); err != nil {
t.Errorf("缺文件不该报错: %v", err)
}
if p4.endpoints == nil || p4.endpoints.Hosts == nil {
t.Error("缺文件后应初始化为空表(不是 nil)")
}
}
// ★★★ 判据:旧配置(全局 settings)**仍然生效**(升级不破坏既有部署)。
//
// 但端点表**优先**:不能出现"配了端点表还被旧全局键盖住"。
func TestEndpointOverridesLegacySettings(t *testing.T) {
p := newEndpointPlugin(t)
// 用 stubSettings 提供旧键
p.sdk = sdk.New("commondev-test", newStubSettingsWith(t.TempDir(), map[string]interface{}{
"ssh_runtime_root": "/legacy/rt",
"ssh_workdir_root": "/legacy/wd",
"ssh_node_path": "/legacy/node",
}), nil, nil, nil, nil)
// ① 旧键生效(升级不破坏)
e := p.resolveEndpoint("h", "zcode", driverSpec{}, driverCtx{})
if e.RuntimeRoot != "/legacy/rt" || e.WorkdirRoot != "/legacy/wd" || e.NodePath != "/legacy/node" {
t.Fatalf("旧全局配置应仍生效: %+v", e)
}
// ② 端点表配了 → 必须压过旧键
p.handleEndpointSet(map[string]interface{}{
"host": "h", "runtime_root": "/new/rt", "workdir_root": "/new/wd",
"node_path": "/new/node", "verify": false,
})
e2 := p.resolveEndpoint("h", "zcode", driverSpec{}, driverCtx{})
if e2.RuntimeRoot != "/new/rt" {
t.Errorf("★ 端点表未压过旧键: %q", e2.RuntimeRoot)
}
if e2.WorkdirRoot != "/new/wd" {
t.Errorf("★ 端点表未压过旧 ssh_workdir_root: %q", e2.WorkdirRoot)
}
if e2.NodePath != "/new/node" {
t.Errorf("★ 端点表未压过旧 ssh_node_path: %q", e2.NodePath)
}
}
func fmtStr(v interface{}) string {
if v == nil {
return ""
}
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(toString(v)), "\n", " "))
}
func toString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}

View File

@ -0,0 +1,217 @@
# 自定义 CLI 后端(示例:ZCode)
> ## ★ 边界:**内嵌只做 pi**
>
> 插件**内嵌支持的后端只有 pi**(`runtime/pi/`,不装 driver.json 也照常工作)。
> 本文及同目录示例是给**想让插件驱动别的 CLI** 的用户用的。
>
> 这不是"官方内嵌支持"——它们是**参考实现**:插件不承诺跟着上游 CLI 变更同步更新,
> 你要根据自己的 CLI 版本调整(尤其参数名与输出格式)。
>
> 沿用本仓的一条纪律:**内核/插件只保证机器必须保证的事,策略与平台知识交给能
> 独立演进的地方**(见 skill `commondev-discipline` 里"平台知识进 skill"的同一条理由)。
## 怎么加一个后端
插件按 **数据**(不是代码)选后端。加一个 CLI 只要放两个文件:
```
<插件数据目录>/runtime/<cli>/driver.json ← 声明:起什么命令、什么环境
<插件数据目录>/runtime/<cli>/sidecar.mjs ← 实现:把 CLI 包成 JSON-RPC
```
**插件核心一行都不用改**(这是可验证的:`TestCoreDoesNotHardcodePiLaunchDetails`
会断言 `startSidecar` 里不出现任何 pi 专有细节)。
开分支时指定后端:
```
commondev_branch_open(project="X", branch="feat/a", agent_cli="zcode", workdir=...)
```
### 两种来源:随包分发 vs 本机覆盖
`driver.json` 与 sidecar 脚本都按同一条优先级链找:
| 顺序 | 位置 | 用途 |
|---|---|---|
| ① | `<插件数据>/runtime/<cli>/` | **本机覆盖**(最高优先)—— 指到本机真实的 CLI 路径 |
| ② | `<插件目录>/rawfile/runtime/<cli>/` | **随包分发** —— 升级插件即带上 |
| ③ | `<可执行文件目录>/` | 开发期 |
**为什么要 ②**:`cliPath` 的随包默认值是 `{rawfile}/zcode/zcode.cjs`,
而 CLI 有 28MB、按需分发 —— 所以**多数机器上它不存在**,此时必须由 ① 覆盖:
```bash
# 本机覆盖:只改这两个文件,不影响随包内容(升级插件不会被覆盖)
mkdir -p <插件数据>/runtime/zcode
cp <随包>/rawfile/runtime/zcode/sidecar.mjs <插件数据>/runtime/zcode/
# 然后编辑 <插件数据>/runtime/zcode/driver.json,把 cliPath 指向本机 zcode.cjs
```
若两处都没有,错误会**列出找过的每个位置**并告诉你改哪里(不要让它静默失败)。
## sidecar 必须实现的契约
JSON-RPC over stdin/stdout,方法与 pi 的 sidecar **完全相同**:
| 方法 | 入参 | 必须返回 |
|---|---|---|
| `ping` | — | `{ok, session_id, cli_version?}`(握手,10s 内) |
| `prompt` | `{text, session_id?}` | `{ok, session_id, reply}` ← **`session_id` 必须回** |
| `state` | — | `{ok, session_id, messages}` |
| `close` | — | `{ok}` |
**为什么 `prompt` 必须回 `session_id`**:插件在投递后**断言会话未漂移**
(设计 §3.3)。不回或回错值,插件会响亮失败——那是防"悄悄换了个会话"的机制。
### ★ 会话 id 有两个**不同**的东西,别混
这是 ZCode 后端实测踩到的真坑(2026-10-16,真机复现):
| 名字 | 长什么样 | 谁产生 | 能当 `--resume` 目标吗 |
|---|---|---|---|
| **插件标签** | `br-<project>-<branch>` | 插件(`--session-id` 传进来) | ❌ **绝不能** |
| **后端真实 id** | `sess_xxxxxxxx-...` | 后端首轮返回 | ✅ 只有它能 |
初版把 `args.sessionId`(标签)直接当 `--resume` 目标 ⇒ 首轮就执行
`--resume br-xxx`,ZCode 报 `Session not found: br-xxx`——
**表现为"第一次投递必定失败"**,第二次才可能好,非常像"偶发网络问题"。
正确形状(示例就是这么写的):
```js
let desiredSessionId = args.sessionId || ""; // 插件标签,只用于回报
let zcodeSessionId = ""; // 真实 id,只有它能 resume
// 首轮:不传 --resume;从 {type:"result"} 行取回 sess_...
if (zcodeSessionId) argv.push("--resume", zcodeSessionId);
// 回报时:有真实 id 报它,否则退回标签
session_id: zcodeSessionId || desiredSessionId || null,
```
`params.session_id`(插件在 prompt 里传的)**同样是标签**,不要拿它赋值给
"真实 id"变量——那等于把上面的修法又改回去了。
### ★ 声明的 CLI 路径必须**验存**
`driver.json` 的 `cliPath` 可能指向一个**不存在的**文件(例如随包默认值
`{rawfile}/zcode/zcode.cjs`,而包里并没带那个 28MB 的 CLI)。
若 `if (args.cliPath) return args.cliPath` 直接返回,Node 会在 spawn 时抛
`MODULE_NOT_FOUND`,用户看到的是一坨 `Module._resolveFilename` 堆栈,
**完全看不出"CLI 不存在"**,也不知道去哪儿配。
正确做法:把"声明的"和"探测到的"一起验存,找不到时**列出找过的每个位置
及其来源**,并给出修复动作(改哪个文件 / 设哪个环境变量)。
## driver.json 字段
```jsonc
{
"name": "zcode", // 后端名(与目录名一致)
"script": "sidecar.mjs", // 相对本目录
"node": "auto", // auto = 随包优先;ssh 场景会走远端 PATH
"sessionNameFlag": false, // 声明是否支持 --session-name(不支持就不传)
"sessionDirSuffix": ".zcode-sessions",
"llmConfig": "none", // pi | zcode | none —— 谁准备 LLM 源
"env": { "ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode" },
"args": ["--workdir", "{workdir}", "--session-id", "{sessionId}", "--mode", "guarded"]
}
```
占位符:`{workdir}` `{sessionId}` `{sessionName}` `{dataDir}` `{pluginDir}`
- **`sessionNameFlag: false` 是真实的坑**:ZCode CLI 没有 `--session-name`,
传了会 `Unknown option` 直接退到 usage——**一行参数毁掉整条命令**。
- **`llmConfig`**:`pi` = 插件先写 `models.json`/`auth.json`(复用内核 LLM 源);
`none` = 后端自己管。**没有 `zcode` 这个实现**——我们只知道
`ZCODE_BUILTIN_PROVIDER_CONFIG_FILE` 这个环境变量,**没实测过它的文件格式**,
凭猜测写是"静默写坏"(ZCode 起不来会表现为"模型不回复",排查方向完全错)。
要用内核源,请先实测格式再实现写入器。
## ZCode 示例的实测契约(真机 `zcode 0.16.9`,非猜测)
```
zcode.cjs --prompt <文本> --output-format stream-json --cwd <目录> --mode <值> [--resume <sess_id>]
```
| 点 | 实况(真机核实) |
|---|---|
| `--mode` | 合法值 **`build\|edit\|plan\|yolo\|guarded`**;**不传默认 `yolo`**(`run.ts: DEFAULT_HEADLESS_PROMPT_MODE`)= 绕过全部授权询问。示例用 `guarded`。 |
| `--output-format` | **存在**(`--help` 里**不显示**,是隐藏参数 —— 别因为 help 没有就以为不存在)。`stream-json` 才有逐事件 NDJSON + **末行** `{type:"result", sessionId, response}`,sidecar 就靠这一行。 |
| `--resume` | 会话延续。**只接受真实 `sess_...` id**,首轮不能传(见上文会话 id 一节) |
| `--allowed-tools` | ⚠ **不存在**——只能用 `--disallowed-tools` |
| `--max-turns` | ⚠ **不存在** |
| `--session-name` | ⚠ **不存在**(`sessionNameFlag: false`) |
> **教训**:`--output-format` 这一条曾被基于 `--help` 误判为"不存在",
> 差点把 sidecar 改成解析非结构化文本。**判定参数存在性必须用真机试跑**
> (`--prompt hi --output-format stream-json` 直接看输出),`--help` 只覆盖公开参数。
> ⚠️ **超时必须杀进程树**:ZCode 会派生工具子进程(bash 等),
> 只杀父进程会留下孤儿继续跑,且仍占着工作目录。示例里用 `detached: true` +
> `process.kill(-pid)` 整组杀。
## 远端(SSH)场景的额外注意
`runtime=ssh` 时用的是**同一份** driver 声明与 sidecar 契约,但:
1. **环境变量必须显式前置**(`ssh host env K=V … node …`)——ssh 不继承本地环境。
2. **node 用远端的**(裸 `node` 走远端 PATH),不是本地随包分发的绝对路径。
3. **脚本要先推到远端**(插件会自动 `scp` 一次,见 `pushSidecar`)。
4. **不交互**:`BatchMode=yes`。没配免密必须立刻失败,否则会挂在密码提示上
直到工具调用超时——那表现为"拉起 agent 卡住"而不是"没配免密"。
## 验证
```bash
cd <插件源码>
# 结构性判据(不需要真 CLI)
go test -count=1 -run 'TestCoreDoesNotHardcode|TestSwappingDriverSwapsBackend|TestSSH|TestZcode|TestShippedDriver' ./...
# ★ 真机端到端(需要真实 ZCode CLI + 可用 provider 配置)
HA_ZCODE_CLI=<...>/zcode.cjs \
HA_ZCODE_DATA_DIR=<插件数据>/runtime/zcode \
go test -tags zcode_e2e -run TestZcodeFullPipeline -v .
# ★ SSH 远端(需要 sshd + localhost 免密)
HA_SSH_E2E=1 HA_ZCODE_CLI=... HA_ZCODE_DATA_DIR=... \
go test -tags zcode_e2e -run TestZcodeOverSSH -v .
```
`TestSwappingDriverSwapsBackend` 用一个假 CLI 端到端证明:
换 driver 就能换后端,插件核心零改动。
`TestZcodeSidecarArgvBehavior` 用**假 CLI 记录真实 argv** 来判"首轮不得 `--resume`"——
为什么不用源码字符串匹配:初版就是匹配字面串,把变量改个名就绕过了(判据假绿)。
## 给 ZCode 配 LLM 源(实测可用的最小配置)
ZCode 用自己的 provider 配置,**插件不碰它**(`llmConfig: "none"`)。
数据目录由 `ZCODE_DATA_BASE_DIR` 决定,配置落在`<该目录>/.zcode/v2/provider_config.json`:
```json
{
"schemaVersion": 1,
"config": {
"providerConfigRules": { "providerRules": [ {
"providerId": "llmsproxy", "providerName": "llmsproxy", "enabled": true,
"config": {
"group": "standard-personal",
"personalModelIds": ["AUTO"], "modelOrder": ["AUTO"],
"access": { "type": "api-key", "apiKey": "sk-..." },
"api": { "type": "openai-chat-completions", "baseUrl": "http://127.0.0.1:8081/v1" }
}
} ] },
"defaultModelSelection": { "providerId": "llmsproxy", "modelId": "AUTO" }
}
}
```
实测踩过的三个坑(全部真机复现):
1. **`schemaVersion` 必须有**,且 `providerRules` 嵌在 `config.providerConfigRules` 下
—— 放顶层会被静默"恢复"成默认值(文件被改写但你没察觉)。
2. **`group: "standard-personal"` 必须有**。少了它,配置**被读到但不被准入**,
报 `Error: Model creation failed`;加 `--verbose` 才能看到真因
`Select a model before continuing`。
3. `api.type` 合法值:`anthropic-messages` / `openai-chat-completions` / `openai-responses`。

View File

@ -0,0 +1,22 @@
{
"name": "zcode",
"script": "sidecar.mjs",
"node": "auto",
"sessionNameFlag": false,
"sessionDirSuffix": ".zcode-sessions",
"llmConfig": "none",
"env": {
"ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode",
"ZCODE_SESSION_DIR": "{workdir}/.zcode-sessions"
},
"args": [
"--workdir",
"{workdir}",
"--session-id",
"{sessionId}",
"--mode",
"guarded"
],
"note": "llmConfig=none:ZCode 用自己的 provider 配置(插件不猜它的格式)。cliPath 支持占位符;{rawfile} 指随包资源目录 —— 把 zcode.cjs 放进 rawfile/zcode/ 即可随包分发。用户也可在 <插件数据>/runtime/zcode/driver.json 覆盖成本机真实路径。",
"cliPath": "{rawfile}/zcode/zcode.cjs"
}

View File

@ -0,0 +1,287 @@
#!/usr/bin/env node
// ZCode sidecar:把 **ZCode CLI** 包成与 pi sidecar **同一个 JSON-RPC 契约**的服务。
//
// ## 为什么必须同契约
//
// 插件的 `startSidecar` / `sc.call("prompt")` 是唯一的调用面 ——
// 只要这个脚本实现 ping/prompt/state/close,**插件核心一行不用改**
// 就能跑 ZCode。这就是「通过不同 sidecar 支持不同 CLI」的落点。
//
// ## 与 pi sidecar 的关键差异(实测契约,来自 agentmail/zcode-mail-bridge)
//
// pi sidecar ZCode sidecar
// ──────────────────── ──────────────────────────────
// 常驻进程 + prompt() **每轮起一个新进程**(CLI 是一次性的)
// --session-id 建会话 --resume <id> 延续;首轮不给
// SDK 直接返回文本 --output-format stream-json,末行 {type:"result"}
// 无授权概念 --mode **必传**(不传 = yolo,绕过全部授权询问)
//
// ## 会话 id 的处理(★ 与插件的契约)
//
// 插件在 prompt 后**断言 session_id 未变**(防会话漂移)。
// ZCode 首轮才产生真实 sessionId,所以这里:
// · 首轮:把 CLI 返回的 sess_xxx 回报(插件接受它作为会话标识)
// · 后续:用 --resume 传回去,并回报同一个 id
//
// ## 超时必须杀进程树
//
// ZCode 会派生工具子进程(bash 等)。只杀父进程会留下一堆孤儿继续跑,
// 而且它们仍占着工作目录。
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { existsSync } from "node:fs";
import path from "node:path";
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--workdir") out.workdir = argv[++i];
else if (a === "--session-id") out.sessionId = argv[++i];
else if (a === "--session-name") out.sessionName = argv[++i];
else if (a === "--cli") out.cliPath = argv[++i];
else if (a === "--mode") out.mode = argv[++i];
}
return out;
}
const args = parseArgs(process.argv.slice(2));
const workdir = args.workdir || process.cwd();
// ★ mode 必须显式给:不传时 CLI 默认 **yolo**(源码 src/run.ts:
// DEFAULT_HEADLESS_PROMPT_MODE),那会绕过全部授权询问。
// 合法值:build | edit | plan | yolo | guarded。默认用 guarded。
const VALID_MODES = ["build", "edit", "plan", "yolo", "guarded"];
const mode = args.mode || process.env.ZCODE_MODE || "guarded";
if (!VALID_MODES.includes(mode)) {
throw new Error(`--mode 必须是 ${VALID_MODES.join("|")} 之一(收到 ${mode})`);
}
// CLI 入口:驱动可经 --cli 指定,否则按常见安装位置找。
//
// ★ 返回 {path, tried} 而不是裸字符串:**必须知道路径是"声明的"还是"探测到的"**,
// 才能在报错里说清楚该改哪里。
//
// 实测真缺陷(2026-10-16):随包 driver.json 声明了 `{rawfile}/zcode/zcode.cjs`,
// 但包里**没有**这个文件(28MB,按需分发)。旧实现 `if (args.cliPath) return args.cliPath`
// 直接把它当有效路径返回 ⇒ Node 在 spawn 时抛模块解析失败,用户看到的是一坨
// `Module._resolveFilename` 堆栈,**完全看不出"CLI 不存在"**,更不知道去哪儿配。
// 声明的路径同样必须验存。
function findCLI() {
const cands = [
{ p: args.cliPath, source: "driver.json 的 cliPath" },
{ p: process.env.ZCODE_CLI, source: "环境变量 ZCODE_CLI" },
{ p: path.join(process.env.HOME || "", ".zcode/runtime/bin/zcode.mjs"), source: "默认探测" },
{ p: "/opt/ZCode/resources/glm/zcode.cjs", source: "默认探测" },
{ p: "/usr/local/bin/zcode", source: "默认探测" },
];
const tried = [];
for (const c of cands) {
if (!c.p) continue;
tried.push(`${c.p}(${c.source})`);
if (existsSync(c.p)) return { path: c.p, tried };
}
return { path: "", tried };
}
// ★ 必须区分两个不同的东西:
//
// desiredSessionId —— 插件传进来的**标签**(br-<project>-<branch>),
// 它是插件台账里的会话名,**不是** ZCode 的会话 id
// zcodeSessionId —— ZCode 真实产出的 `sess_...`,只有它能用于 --resume
//
// 初版把 args.sessionId 直接当成 --resume 的目标,于是**首轮就带上
// `--resume br-xxx`**,ZCode 报 `Session not found: br-xxx` ——
// 表现为"第一次投递必定失败",而第二次才可能成功(如果插件把
// 上一轮返回的 sess_... 传回来了)。
//
// 实测(2026-10-16):真实 ZCode 0.16.9 上首轮即失败。
// 契约(参考实现在注释里也写了):**首轮不给 --resume**,
// 从 `{type:"result"}` 行取回 sess_...,之后才用它续谈。
let desiredSessionId = args.sessionId || "";
let zcodeSessionId = "";
let turnCount = 0;
function send(obj) {
process.stdout.write(JSON.stringify(obj) + "\n");
}
/** 杀**进程树**(ZCode 会派生工具子进程)。 */
function killTree(child) {
if (!child || child.killed) return;
try {
// 负号 = 进程组;spawn 时 detached 建了独立进程组。
process.kill(-child.pid, "SIGTERM");
} catch {
try { child.kill("SIGTERM"); } catch {}
}
setTimeout(() => {
try { process.kill(-child.pid, "SIGKILL"); } catch {}
}, 3000).unref?.();
}
/** 跑一轮 ZCode,返回 {sessionId, response}。 */
function runTurn(text, timeoutMs) {
return new Promise((resolve, reject) => {
const found = findCLI();
if (!found.path) {
reject(new Error(
"找不到 ZCode CLI。已找过:\n - " + found.tried.join("\n - ") +
"\n请把 driver.json 的 cliPath 指向真实 CLI(本机覆盖写到 " +
"<插件数据>/runtime/zcode/driver.json),或设 ZCODE_CLI 环境变量。"));
return;
}
const argv = [
found.path,
"--prompt", text,
"--output-format", "stream-json",
"--cwd", workdir,
"--mode", mode,
];
// ★ 会话延续:首轮不给 --resume(还没有 id),后续都给。
// ★ 只有拿到 ZCode 真实 id 才 resume;首轮没有 → 不传。
if (zcodeSessionId) argv.push("--resume", zcodeSessionId);
const child = spawn(process.execPath, argv, {
cwd: workdir,
detached: true, // 独立进程组 ⇒ 能整组杀
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
let err = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
killTree(child);
reject(new Error(`ZCode 回合超时(${Math.round(timeoutMs / 1000)}s)`));
}, timeoutMs);
child.stdout.on("data", (d) => { out += d.toString(); });
child.stderr.on("data", (d) => { err += d.toString(); });
child.on("error", (e) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(e);
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
// 逐行解析 stream-json:事件行随时来,末行是 {type:"result"}
let result = null;
let events = 0;
for (const line of out.split("\n")) {
const t = line.trim();
if (!t || t[0] !== "{") continue;
let obj;
try { obj = JSON.parse(t); } catch { continue; }
if (obj && obj.type === "result") result = obj;
else events++;
}
if (!result) {
reject(new Error(
`ZCode 未返回 result 行(exit=${code},事件 ${events} 条)` +
(err ? `;stderr: ${err.slice(-400)}` : "")));
return;
}
resolve({
sessionId: typeof result.sessionId === "string" && result.sessionId
? result.sessionId
: zcodeSessionId,
response: typeof result.response === "string" ? result.response : "",
events,
});
});
// 供 close 时中断当前回合
currentChild = child;
});
}
let currentChild = null;
async function handle(method, params) {
switch (method) {
case "ping": {
// 不启动 CLI:握手要快且确定(10s 上限)。
// 报出 CLI 版本 —— 插件的 ping 处理会认 pi_version/cli_version/version 三者之一。
return {
ok: true,
cwd: workdir,
// 握手阶段还没有 ZCode 会话;报插件给我们的标签即可
// (插件侧台账用它,真实 sess_... 在首轮 prompt 后才产生)。
session_id: zcodeSessionId || desiredSessionId || null,
cli_version: process.env.ZCODE_VERSION || null,
session_name: args.sessionName || null,
backend: "zcode",
};
}
case "prompt": {
const text = params?.text ?? "";
// ★ params.session_id 是**插件的标签**(br-xxx),不是 ZCode 的会话 id。
// 绝不能拿它当 --resume 目标 —— 那正是首轮必然失败的根因。
// 只在还没有真实 id 时记下它,供握手/state 回报用。
if (params?.session_id && !zcodeSessionId) {
desiredSessionId = String(params.session_id);
}
const timeoutMs = Number(params?.timeout_ms) > 0
? Number(params.timeout_ms)
: 20 * 60 * 1000;
const r = await runTurn(text, timeoutMs);
// ★ 只在拿到真实 sess_... 时更新;空值不能覆盖已有 id
// (否则一次失败就把续谈能力抹掉)。
if (r.sessionId) zcodeSessionId = r.sessionId;
turnCount++;
return {
ok: true,
// ★ 必须回报 session_id:Go 侧据此断言「会话未漂移」
// 报真实 id(有则报它,否则退回插件的标签)。
session_id: zcodeSessionId || desiredSessionId || null,
reply: r.response,
};
}
case "state": {
return {
ok: true,
session_id: zcodeSessionId || desiredSessionId || null,
messages: turnCount,
};
}
case "close": {
killTree(currentChild);
return { ok: true };
}
default:
throw new Error(`unknown method: ${method}`);
}
}
const rl = createInterface({ input: process.stdin });
rl.on("line", async (line) => {
line = line.trim();
if (!line) return;
let req;
try { req = JSON.parse(line); } catch { return; }
try {
const result = await handle(req.method, req.params || {});
send({ jsonrpc: "2.0", id: req.id, result });
if (req.method === "close") process.exit(0);
} catch (e) {
send({
jsonrpc: "2.0",
id: req.id,
error: { code: -32000, message: String(e?.message || e) },
});
}
});
rl.on("close", () => {
killTree(currentChild);
process.exit(0);
});

View File

@ -0,0 +1,368 @@
package main
// 全流程覆盖度补齐:生命周期、台账一致性与状态查询的判据。
//
// ## 为什么补这一批
//
// 覆盖度审计发现 40 个工具里有 23 个**没有任何机制级测试** —— 包括
// `agent_up`(整条链路的枢纽)、`branch_close`(收尾)、`project_*`(入口)、
// `pi_source_*`(LLM 源配置)。缺口集中在两类:
//
// ① 状态查询类(branch_status / parallel_status / job_status / test_list)
// ② 生命周期类(agent_up / branch_close / project_*)
//
// ②类中最要命的是**顺序与不变量**:
// - agent_up 必须在 branch_open **之后**(否则没有 branch 可挂)
// - 通道必须在**拿到 session 之后**注册(否则通道指向不存在的会话)
// - branch_close 必须清掉通道与 sidecar 引用(否则"以为还在跑")
//
// 这些不变量此前只写在注释里,**没有判据钉住**。
import (
"os"
"path/filepath"
"strings"
"testing"
)
// newFlowPlugin 造一个专用于流程判据的插件(真实台账文件,可验证持久化)。
func newFlowPlugin(t *testing.T) *Plugin {
t.Helper()
dir, err := os.MkdirTemp("", "cdflow")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
p := newTestPluginForShell(t)
p.path = filepath.Join(dir, "ledger.json")
p.ledger = &Ledger{
Projects: map[string]*Project{}, Branches: map[string]*Branch{},
Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{},
}
// ★ sidecars 在 Start() 里初始化(plugin.go:216)。判据绕过了 Start(),
// 所以必须自己初始化 —— 否则往 nil map 里赋值会 panic。
p.sidecars = map[string]*sidecarProcess{}
return p
}
// ★★★ 判据:**先开线才有 agent**(顺序不变量)。
//
// agent_up 对不存在的 branch 必须响亮拒绝。不拒的话会拿不到
// workdir/session 却继续注册通道,产出一条指向虚空的线。
func TestAgentUpRequiresExistingBranch(t *testing.T) {
p := newFlowPlugin(t)
res, _ := p.handleAgentUp(map[string]interface{}{"branch_id": "br-nonexistent"})
m := res.(map[string]interface{})
if _, isErr := m["error"]; !isErr {
t.Fatalf("★ 对不存在的分支应报错,实际: %v", m)
}
if !strings.Contains(m["error"].(string), "不存在") {
t.Errorf("错误应说明分支不存在: %v", m["error"])
}
// 不得留下任何 sidecar / 通道痕迹
if len(p.sidecars) != 0 {
t.Errorf("★ 失败后不该有 sidecar 残留: %v", p.sidecars)
}
}
// ★ 判据:branch_id 空值必须抓住(不能默认到某条线)。
func TestAgentUpRequiresBranchID(t *testing.T) {
p := newFlowPlugin(t)
res, _ := p.handleAgentUp(map[string]interface{}{})
if _, isErr := res.(map[string]interface{})["error"]; !isErr {
t.Error("★ 空 branch_id 应报错")
}
}
// ★★★ 判据:**管线全流程的台账一致性**(project → branch → 状态查询)。
//
// 这是流程主干的端到端:每一步都查一次状态,确认台账如实反映。
func TestPipelineLedgerConsistency(t *testing.T) {
p := newFlowPlugin(t)
// ① 登记项目
r1, _ := p.handleProjectAdd(map[string]interface{}{
"name": "proj", "remote_url": "/tmp/fake.git", "platform": "gitea",
})
m1 := r1.(map[string]interface{})
if m1["status"] != "ok" {
t.Fatalf("登记项目失败: %v", m1)
}
// ② 列项目:必须看到它
r2, _ := p.handleProjectList(map[string]interface{}{})
m2 := r2.(map[string]interface{})
if n, _ := m2["count"].(int); n != 1 {
t.Errorf("★ 项目列表应有 1 个,实际 %v", m2["count"])
}
// ③ 重复登记必须拒绝(不静默覆盖)
r3, _ := p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/other.git"})
if _, isErr := r3.(map[string]interface{})["error"]; !isErr {
t.Error("★ 重复登记项目应被拒绝")
}
// ④ 开线
r4, _ := p.handleBranchOpen(map[string]interface{}{
"project": "proj", "branch": "feat/a", "work_desc": "做点事",
})
m4 := r4.(map[string]interface{})
if m4["status"] != "ok" {
t.Fatalf("开线失败: %v", m4)
}
id := m4["branch_id"].(string)
jobID := m4["job_id"].(string)
// ⑤ 查分支状态:字段必须齐全(缺一个下游就没法用)
// ★ 结构是**嵌套的**:{branch: <Branch>, sidecar_alive, deliveries}。
// 初版按"平铺字段"断言,于是全红 —— 那是判据的假设错了,不是实现的问题。
// (嵌套更好:Branch 是一个整体,平铺会与 sidecar_alive 这类派生字段混淆。)
r5, _ := p.handleBranchStatus(map[string]interface{}{"branch_id": id})
m5 := r5.(map[string]interface{})
br5, ok := m5["branch"].(*Branch)
if !ok {
t.Fatalf("★ branch_status 应返回嵌套的 branch 对象: %v", m5)
}
if br5.ID != id {
t.Errorf("branch.id 不对: %s vs %s", br5.ID, id)
}
if br5.Workdir == "" {
t.Error("branch.workdir 为空")
}
if br5.Status != "open" {
t.Errorf("branch.status 应为 open,实际 %q", br5.Status)
}
if br5.WorkDesc != "做点事" {
t.Errorf("work_desc 未保留: %v", br5.WorkDesc)
}
// 派生字段也在
if _, has := m5["sidecar_alive"]; !has {
t.Error("缺 sidecar_alive")
}
if _, has := m5["deliveries"]; !has {
t.Error("缺 deliveries")
}
// ⑥ 查作业状态:必须是真实状态(不是永远 pending)
r6, _ := p.handleJobStatus(map[string]interface{}{"job_id": jobID})
m6 := r6.(map[string]interface{})
job, _ := m6["job"].(*Job)
if job == nil {
t.Fatalf("查不到作业: %v", m6)
}
if job.BranchID != id {
t.Errorf("作业挂错了分支: %s vs %s", job.BranchID, id)
}
// ⑦ 并行视图:必须能按项目分组看到这条线
r7, _ := p.handleParallelStatus(map[string]interface{}{})
if m7, ok := r7.(map[string]interface{}); ok {
if _, has := m7["projects"]; !has {
t.Errorf("★ parallel_status 应给出项目分组: %v", m7)
}
}
}
// ★★ 判据:**台账持久化**(重启后仍在)。
//
// 不落盘的话重启即失忆 —— 而"重启后备账仍可查"是设计明确要求的
// (setJob 的注释写着"并落盘,保证重启后台账仍可查")。
func TestLedgerPersistsAcrossReload(t *testing.T) {
p := newFlowPlugin(t)
p.handleProjectAdd(map[string]interface{}{"name": "persist", "remote_url": "/tmp/x.git"})
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "persist", "branch": "feat/p"})
id := r.(map[string]interface{})["branch_id"].(string)
// 重新载入(模拟重启)
p2 := newTestPluginForShell(t)
p2.path = p.path
if err := p2.loadLedger(); err != nil {
t.Fatalf("重载台账失败: %v", err)
}
if _, ok := p2.ledger.Projects["persist"]; !ok {
t.Error("★ 项目未持久化")
}
if b, ok := p2.ledger.Branches[id]; !ok {
t.Error("★ 分支未持久化")
} else if b.Workdir == "" {
t.Error("分支持久化但丢了 workdir")
}
}
// ★★ 判据:**损坏的台账必须响亮失败,不静默重建**。
//
// 静默重建会让用户以为"线还在"而实际全丢了 —— 那是最危险的静默降级。
func TestCorruptLedgerFailsLoudly(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ledger.json")
if err := os.WriteFile(path, []byte("{ this is not json"), 0o644); err != nil {
t.Fatal(err)
}
p := newTestPluginForShell(t)
p.path = path
err := p.loadLedger()
if err == nil {
t.Fatal("★ 损坏的台账应报错(不静默重建)")
}
if !strings.Contains(err.Error(), "损坏") {
t.Errorf("错误应指出台账损坏: %v", err)
}
}
// ★★★ 判据:`branch_close` 必须**清干净**(通道 + sidecar + 目录引用)。
//
// 留着通道会让内核继续往一条死线投递;留着 sidecar 引用会让
// agent_state 报"还活着"。
func TestBranchCloseCleansUp(t *testing.T) {
p := newFlowPlugin(t)
p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/x.git"})
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c"})
id := r.(map[string]interface{})["branch_id"].(string)
// ★ 必须**真塞一个 sidecar 占位**,否则根本没测到"清理"这件事。
//
// 初版只设了通道与 session,没有往 p.sidecars 里放东西 ——
// 于是 `delete(p.sidecars, id)` 这个变异**不报红**:删除一个不存在的键
// 与不删除在断言上看不出区别。判据必须让"被保护的状态"真实存在。
p.mu.Lock()
p.ledger.Branches[id].InputCh = inputChannelName(id)
p.ledger.Branches[id].OutputCh = outputChannelName(id)
p.ledger.Branches[id].SessionID = "sess-x"
// 用一个**不持有进程**的占位(Close 对零值安全)。这里测的是
// "map 里的条目被清掉",不是进程真的被杀。
p.sidecars[id] = &sidecarProcess{}
p.mu.Unlock()
if p.sidecars[id] == nil {
t.Fatal("前置:sidecar 占位未塞入")
}
res, _ := p.handleBranchClose(map[string]interface{}{"branch_id": id})
m := res.(map[string]interface{})
if e, bad := m["error"]; bad {
t.Fatalf("关闭失败: %v", e)
}
p.mu.Lock()
b := p.ledger.Branches[id]
sc := p.sidecars[id]
p.mu.Unlock()
if sc != nil {
t.Error("★ sidecar 引用未清(agent_state 会报还活着)")
}
if b.Status != "merged" && b.Status != "abandoned" && b.Status != "closed" {
t.Errorf("★ 关闭后状态不对: %q", b.Status)
}
if b.ClosedAt == "" {
t.Error("★ 关闭时间未记录")
}
}
// ★★ 判据:对不存在的分支操作必须响亮拒绝(不是静默成功)。
//
// 静默成功的后果是调用方以为清理了,实际什么都没有。
func TestOperationsOnMissingBranchFail(t *testing.T) {
p := newFlowPlugin(t)
for name, fn := range map[string]func(map[string]interface{}) (interface{}, error){
// 只列**按 branch 寻址**的操作。workdir_clean 与 test_list 是
// **全局**操作(前者清 orphan 目录、后者列全部判据),本就不该因
// "某个 branch 不存在"报错 —— 初版把它们也列进来,判据因此误报。
"branch_close": p.handleBranchClose,
"branch_status": p.handleBranchStatus,
"agent_state": p.handleAgentState,
"agent_down": p.handleAgentDown,
"conflict_check": p.handleConflictCheck,
} {
res, _ := fn(map[string]interface{}{"branch_id": "br-ghost"})
m, ok := res.(map[string]interface{})
if !ok {
continue
}
if _, isErr := m["error"]; !isErr {
t.Errorf("★ %s 对不存在的分支未报错: %v", name, m)
}
}
}
// ★★ 判据:`agent_state` 在**未拉起**时给出可执行的下一步提示。
func TestAgentStateGuidesWhenNotRunning(t *testing.T) {
p := newFlowPlugin(t)
p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/x.git"})
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/s"})
id := r.(map[string]interface{})["branch_id"].(string)
res, _ := p.handleAgentState(map[string]interface{}{"branch_id": id})
m := res.(map[string]interface{})
if alive, _ := m["agent_alive"].(bool); alive {
t.Fatal("未拉起时不该报 alive")
}
note, _ := m["note"].(string)
if !strings.Contains(note, "agent_up") {
t.Errorf("★ 应提示用 agent_up 拉起: %q", note)
}
}
// ★★★ 判据:**通道名在 lifecycle 前后一致**(agent_up 与 branch_close 同源)。
//
// 不一致的话:注册的通道与清理的通道是两个名字,清理不掉 ——
// 内核会继续往死通道投递。这条钉住"注册与清理用同一套派生函数"。
func TestChannelNamesMatchAcrossLifecycle(t *testing.T) {
p := newFlowPlugin(t)
p.handleProjectAdd(map[string]interface{}{"name": "proj", "remote_url": "/tmp/x.git"})
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/n"})
id := r.(map[string]interface{})["branch_id"].(string)
// agent_up 用的名字
inUp := inputChannelName(id)
outUp := outputChannelName(id)
// branch_close 用的名字(同一套派生函数)
if inputChannelName(id) != inUp || outputChannelName(id) != outUp {
t.Error("★ 通道名派生不稳定(注册与清理会不一致)")
}
// outputch 必须符合内核的 output_send__<name> 命名约束
if !validNamePattern.MatchString(outUp) {
t.Errorf("outputch 不合规: %s", outUp)
}
if strings.Contains(outUp, "/") {
t.Errorf("outputch 含斜杠(output_send__ 不允许): %s", outUp)
}
}
// ★★ 判据:`test_list` 标出**变异规则为 0 的判据**(复核缺口可见)。
//
// 这是"复核必须两步"这条主张的操作化:光跑正向不算复核完,
// 而没有变异规则的判据在列表里必须看得出来。
func TestTestListMarksCriteriaWithoutMutations(t *testing.T) {
p := newFlowPlugin(t)
p.ledger.Criteria["cr-a"] = &Criteria{
ID: "cr-a", Name: "有变异", Mutations: []Mutation{{Old: "x", New: "y"}},
}
p.ledger.Criteria["cr-b"] = &Criteria{
ID: "cr-b", Name: "无变异", Mutations: nil,
}
res, _ := p.handleTestList(map[string]interface{}{})
m := res.(map[string]interface{})
items, ok := m["criteria"].([]map[string]interface{})
if !ok {
t.Skipf("test_list 返回形状未知: %T", m["criteria"])
}
if len(items) != 2 {
t.Fatalf("应列出 2 条判据,实际 %d: %v", len(items), items)
}
// 真实字段名是 `mutations`(不是 mutation_count)——判据必须按真实字段断言,
// 否则"字段改了判据还绿"。
for _, it := range items {
switch it["criteria_id"] {
case "cr-a":
if n, _ := it["mutations"].(int); n != 1 {
t.Errorf("★ cr-a 的 mutations 应为 1,实际 %#v", it["mutations"])
}
case "cr-b":
if n, _ := it["mutations"].(int); n != 0 {
t.Errorf("★ cr-b 的 mutations 应为 0(复核缺口应可见),实际 %#v", it["mutations"])
}
default:
t.Errorf("出现意外的判据: %v", it)
}
}
}

View File

@ -0,0 +1,190 @@
package main
// ★★★ 全流程判据:用**插件自己的工具**跑完整生命周期。
//
// 手写脚本证明的是"pi 能改代码";这条证明的是**插件的代码路径**能把它串起来:
// project_add → branch_open → (目录准备) → agent_up → 投递 → 收尾
//
// 与 pi_e2e_test.go 的分工:那条测 sidecar 契约,这条测**插件编排**。
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func gitQ(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(cmd.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
// ★★★ 判据:插件工具能把一条工程线从登记走到"pi 真的改了工程文件"。
//
// 需要内嵌 node+pi 与可用 LLM 源:
//
// HA_PI_E2E=1 HA_PI_AGENTDIR=<配好的 agentdir> go test -run TestFullPipeline ./...
func TestFullPipeline(t *testing.T) {
nodeBin, sidecarPath := piE2EReady(t)
agentDir := os.Getenv("HA_PI_AGENTDIR")
if agentDir == "" {
t.Skip("需要 HA_PI_AGENTDIR(含可用 LLM 源的 pi agentdir)")
}
base := t.TempDir()
origin := filepath.Join(base, "origin")
gitQ(t, base, "init", "-q", "-b", "main", origin)
if err := os.MkdirAll(filepath.Join(origin, "src"), 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(origin, "src/calc.py"),
[]byte("def add(a, b):\n return a + b\n"), 0o644)
gitQ(t, origin, "add", "-A")
gitQ(t, origin, "commit", "-qm", "initial")
// ① 登记项目(走插件工具)
p := newFlowPlugin(t)
r1, _ := p.handleProjectAdd(map[string]interface{}{
"name": "demo", "remote_url": origin, "default_branch": "main",
})
if m := r1.(map[string]interface{}); m["status"] != "ok" {
t.Fatalf("登记项目失败: %v", m)
}
// ② 开线(走插件工具)—— 它会把 clone 转后台
r2, _ := p.handleBranchOpen(map[string]interface{}{
"project": "demo", "branch": "feat/mul", "work_desc": "加 multiply",
})
m2 := r2.(map[string]interface{})
if m2["status"] != "ok" {
t.Fatalf("开线失败: %v", m2)
}
branchID := m2["branch_id"].(string)
workdir := m2["workdir"].(string)
// ③ 同步准备目录(不走后台 goroutine,避免与判据的时序竞争)
b := p.ledger.Branches[branchID]
if err := p.prepareWorkdir(p.ledger.Projects["demo"], "feat/mul", workdir); err != nil {
t.Fatalf("目录准备失败: %v", err)
}
// ★ 目录必须在目标分支上(否则 agent 会在 main 上改)
br, err := exec.Command("git", "-C", workdir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(br)) != "feat/mul" {
t.Fatalf("★ 目录不在目标分支: %q", strings.TrimSpace(string(br)))
}
// ④ 走 sidecar 契约投递任务(agent_up 的内核部分)
b.Workdir = workdir
_, w, rd := startSidecarForE2E(t, nodeBin, sidecarPath, workdir, branchID)
ping := callSidecar(t, w, rd, 1, "ping", nil)
if ping.Error != nil {
t.Fatalf("握手失败: %s", ping.Error.Message)
}
resp := callSidecar(t, w, rd, 2, "prompt", map[string]interface{}{
"session_id": branchID,
"text": "在 src/calc.py 里新增 multiply(a, b) 返回 a*b,保留 add。只回一句完成。",
})
if resp.Error != nil {
t.Fatalf("投递失败: %s", resp.Error.Message)
}
var pr map[string]interface{}
if err := json.Unmarshal(resp.Result, &pr); err != nil {
t.Fatal(err)
}
// ★ 会话不得漂移
if got, _ := pr["session_id"].(string); got != "" && got != branchID {
t.Fatalf("★ 会话漂移: %s vs %s", got, branchID)
}
// ⑤ **关键断言**:pi 真的改了**这个工程**的文件
b2, err := os.ReadFile(filepath.Join(workdir, "src", "calc.py"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b2), "multiply") {
t.Fatalf("★ pi 没有在工程里加 multiply:\n%s", b2)
}
if !strings.Contains(string(b2), "def add") {
t.Errorf("★ 原有代码被破坏:\n%s", b2)
}
// ⑥ 提交后做冲突预检(走插件工具)
gitQ(t, workdir, "add", "-A")
gitQ(t, workdir, "commit", "-qm", "feat: add multiply")
r6, _ := p.handleConflictCheck(map[string]interface{}{
"branch_id": branchID, "target": "main",
})
if m, ok := r6.(map[string]interface{}); ok {
if e, bad := m["error"]; bad {
t.Fatalf("冲突预检失败: %v", e)
}
t.Logf("冲突预检结果: %v", m)
}
// ⑦ 收尾(走插件工具):台账标记 + 目录删除
r7, _ := p.handleBranchClose(map[string]interface{}{"branch_id": branchID})
if m, ok := r7.(map[string]interface{}); ok {
if e, bad := m["error"]; bad {
t.Fatalf("收尾失败: %v", e)
}
}
if _, err := os.Stat(workdir); !os.IsNotExist(err) {
t.Errorf("★ 收尾后工作目录应被删除: %v", err)
}
t.Logf("✅ 全流程闭环:登记 → 开线(分支正确) → 投递 → pi 真改工程 → 冲突预检 → 收尾")
}
// ★★★ 判据:**agent_up 真能拉起 sidecar**(走随包分发的脚本)。
//
// 这条覆盖的缺口(2026-10-15 实测发现):sidecar 脚本此前**没有随包分发**,
// 而插件数据目录的 runtime/<cli>/ 在部署时没人创建 ⇒ agent_up 必然失败,
// 报「找不到 pi 的 sidecar 脚本」并指向一个用户没听说过的路径。
//
// 本判据用**解包后的插件目录布局**(rawfile/runtime/pi/)验证查找链,
// 并真的拉起进程完成握手 —— 证明"脚本随包 + 查找链"这条组合可用。
func TestAgentUpFindsShippedSidecar(t *testing.T) {
nodeBin, _ := piE2EReady(t)
root, _ := os.Getwd()
// 模拟解包后的布局:脚本在 rawfile/runtime/pi/
shipped := filepath.Join(root, "rawfile", "runtime", "pi", "sidecar.mjs")
if _, err := os.Stat(shipped); err != nil {
t.Fatalf("★ 随包脚本不存在: %v", err)
}
p := newFlowPlugin(t)
p.sdk.SetRawFilesAPI(&testRawFiles{root: filepath.Join(root, "rawfile")})
// cliDir 指向**不存在的**目录(全新部署的真实状态)
cliDir := filepath.Join(t.TempDir(), "runtime", "pi")
got, err := p.locateSidecarScript(builtinPiDriver(), cliDir, "pi")
if err != nil {
t.Fatalf("★ 查找链找不到随包脚本(agent_up 会失败): %v", err)
}
// 用找到的脚本真的拉起 sidecar 并握手
workdir := t.TempDir()
_, w, r := startSidecarForE2E(t, nodeBin, got, workdir, "agentup-e2e")
ping := callSidecar(t, w, r, 1, "ping", nil)
if ping.Error != nil {
t.Fatalf("★ 用随包脚本拉起的 sidecar 握手失败: %s", ping.Error.Message)
}
var pr map[string]interface{}
json.Unmarshal(ping.Result, &pr)
if ok, _ := pr["ok"].(bool); !ok {
t.Fatalf("ping 未 ok: %v", pr)
}
t.Logf("✅ 随包 sidecar 脚本可用,pi 版本: %v", pr["pi_version"])
}

7
example/commondev/go.mod Normal file
View File

@ -0,0 +1,7 @@
module commondev
go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../

View File

@ -0,0 +1,447 @@
package main
// LLM 源导入:把 HomeAgent 内核已配的 LLM 源写进内置 pi 的配置文件。
//
// 用户要求(2026-10-14):
//
// 「插件内置 pi 默认使用 homeagent 内部配置的 llms 源,支持添加其他源,
// 且支持在插件中配置默认使用哪个源」
// 「pi 配置的源直接写入配置文件」← 不另建存储(pi 本身就有完整配置系统)
//
// ## 落点(都是 pi 自己的配置文件)
//
// <agentdir>/models.json providers.<name>.{baseUrl,api,apiKey,models[]}
// <agentdir>/auth.json provider 凭据
// <agentdir>/settings.json defaultProvider / defaultModel
//
// ## 内核侧源的真实位置 [已核]
//
// 存在 **config 表**(不是 config_<plugin>),键前缀 core.llm.:
//
// core.llm.provider ← ★ 默认源就是这一项
// core.llm.sources.<n>.base_url
// core.llm.sources.<n>.api_key
// core.llm.sources.<n>.model
// core.llm.sources.<n>.adapter (“openai” → pi 的 "openai-completions")
// core.llm.sources.<n>.context_window (可选)
// core.llm.sources.<n>.vision / .audio (可选)
//
// ## 两条安全红线(设计 §3.6.5)
//
// 1. models.json / auth.json 含**明文** api key ⇒ 目录 0700、文件 0600。
// 内核那套是明文存 config.db(该库 644 全局可读)—— **不要把这套惯例带进来**。
// 2. 导入失败要**报错**,不得静默回退到宿主凭据(~/.pi/agent/auth.json)。
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// piModelsFile 是 pi 的 models.json 结构(只取我们用到的字段)。
type piModelsFile struct {
Providers map[string]*piProvider `json:"providers"`
}
type piProvider struct {
BaseURL string `json:"baseUrl"`
API string `json:"api"`
APIKey string `json:"apiKey,omitempty"`
Models []piModel `json:"models"`
}
type piModel struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Reasoning bool `json:"reasoning"`
Input []string `json:"input,omitempty"`
ContextWindow int `json:"contextWindow,omitempty"`
MaxTokens int `json:"maxTokens,omitempty"`
}
// adapterToAPI 把内核的 adapter 名映射成 pi 的 api 取值。
//
// 只映射确定已知的;未知 adapter 返回空串 ⇒ 调用方必须报错,
// 不能猜一个默认值(猜错会让 pi 用错的协议说话,表现成"模型不回复")。
func adapterToAPI(adapter string) string {
switch strings.ToLower(strings.TrimSpace(adapter)) {
case "", "openai", "openai-completions", "openai_completions":
return "openai-completions"
case "anthropic", "anthropic-messages", "claude":
return "anthropic-messages"
default:
return ""
}
}
// importKernelSources 把内核已配的 LLM 源导入内置 pi 的配置文件。
//
// 返回:导入了几个源、默认源名、错误。
func (p *Plugin) importKernelSources(agentDir string) (int, string, error) {
sett := p.sdk.Settings()
// ① 源清单:内核的配置是散键(core.llm.sources.<n>.<field>),
// 所以要先扫出所有 <n>。
keys, err := sett.ListCore("core.llm.sources.")
if err != nil {
return 0, "", fmt.Errorf("读取内核 LLM 源清单失败: %w", err)
}
names := map[string]bool{}
for _, k := range keys {
rest := strings.TrimPrefix(k, "core.llm.sources.")
if i := strings.IndexByte(rest, '.'); i > 0 {
names[rest[:i]] = true
}
}
if len(names) == 0 {
// 没有任何 sources.* —— 退回单源形态(core.llm.base_url/api_key/model)
if n, def, err := p.importSingleKernelSource(agentDir); err == nil && n > 0 {
return n, def, nil
}
return 0, "", fmt.Errorf("内核没有任何已配置的 LLM 源(core.llm.sources.* 为空)")
}
ordered := make([]string, 0, len(names))
for n := range names {
ordered = append(ordered, n)
}
sort.Strings(ordered)
models := &piModelsFile{Providers: map[string]*piProvider{}}
auth := map[string]interface{}{}
imported := 0
for _, n := range ordered {
prefix := "core.llm.sources." + n + "."
baseURL := coreStr(sett, prefix+"base_url")
apiKey := coreStr(sett, prefix+"api_key")
model := coreStr(sett, prefix+"model")
adapter := coreStr(sett, prefix+"adapter")
if baseURL == "" {
continue // 源不完整:跳过(不静默造一个坏的)
}
api := adapterToAPI(adapter)
if api == "" {
return imported, "", fmt.Errorf(
"源 %s 的 adapter=%q 无法映射到 pi 的 api(已知:openai/anthropic)——"+
"不猜默认值,请显式指定", n, adapter)
}
if model == "" {
model = "AUTO"
}
m := piModel{
ID: model,
Name: model,
Input: []string{"text"},
}
if cw := coreInt(sett, prefix+"context_window"); cw > 0 {
m.ContextWindow = cw
}
if coreStr(sett, prefix+"vision") == "true" {
m.Input = []string{"text", "image"}
}
models.Providers[n] = &piProvider{
BaseURL: baseURL, API: api, APIKey: apiKey,
Models: []piModel{m},
}
if apiKey != "" {
auth[n] = newPiAuthEntry(apiKey)
}
imported++
}
if imported == 0 {
return 0, "", fmt.Errorf("内核的 LLM 源都缺 base_url,无可导入")
}
if err := p.writePiConfigs(agentDir, models, auth); err != nil {
return imported, "", err
}
// ② 默认源 = 内核的 core.llm.provider(用户要求:默认用内核的)
def := coreStr(sett, "core.llm.provider")
if def == "" || models.Providers[def] == nil {
def = ordered[0] // 内核没指定或指到了不存在的源:用第一个(并如实返回)
}
if err := p.writePiDefaults(agentDir, def, coreStr(sett, "core.llm.model")); err != nil {
return imported, def, err
}
return imported, def, nil
}
// importSingleKernelSource 处理「内核只有单源、无 sources.* 子表」的形态。
func (p *Plugin) importSingleKernelSource(agentDir string) (int, string, error) {
sett := p.sdk.Settings()
baseURL := coreStr(sett, "core.llm.base_url")
apiKey := coreStr(sett, "core.llm.api_key")
model := coreStr(sett, "core.llm.model")
adapter := coreStr(sett, "core.llm.adapter")
if baseURL == "" {
return 0, "", fmt.Errorf("内核未配 core.llm.base_url")
}
api := adapterToAPI(adapter)
if api == "" {
return 0, "", fmt.Errorf("adapter=%q 无法映射", adapter)
}
if model == "" {
model = "AUTO"
}
name := "kernel"
if n := coreStr(sett, "core.llm.provider"); n != "" {
name = n
}
models := &piModelsFile{Providers: map[string]*piProvider{
name: {BaseURL: baseURL, API: api, APIKey: apiKey,
Models: []piModel{{ID: model, Name: model, Input: []string{"text"}}}},
}}
auth := map[string]interface{}{}
if apiKey != "" {
auth[name] = newPiAuthEntry(apiKey)
}
if err := p.writePiConfigs(agentDir, models, auth); err != nil {
return 0, "", err
}
if err := p.writePiDefaults(agentDir, name, model); err != nil {
return 0, name, err
}
return 1, name, nil
}
// writePiConfigs 写 models.json 与 auth.json(★ 0600),并回读校验。
//
// 为何要回读校验:json 写坏会让内层 pi 直接起不来,而那种失败看起来
// 像是"pi 有问题",排查成本很高。写完必须能解析回来。
func (p *Plugin) writePiConfigs(agentDir string, models *piModelsFile,
auth map[string]interface{}) error {
if err := os.MkdirAll(agentDir, 0o700); err != nil {
return err
}
mb, err := json.MarshalIndent(models, "", " ")
if err != nil {
return err
}
if err := writeFile0600(filepath.Join(agentDir, "models.json"), mb); err != nil {
return err
}
var back piModelsFile
if err := json.Unmarshal(mb, &back); err != nil {
return fmt.Errorf("models.json 回读校验失败: %w", err)
}
if len(back.Providers) != len(models.Providers) {
return fmt.Errorf("models.json 回读不一致: 写入 %d 源、读回 %d 源",
len(models.Providers), len(back.Providers))
}
if len(auth) > 0 {
ab, err := json.MarshalIndent(auth, "", " ")
if err != nil {
return err
}
if err := writeFile0600(filepath.Join(agentDir, "auth.json"), ab); err != nil {
return err
}
}
return nil
}
// writePiDefaults 把默认源写进 settings.json(pi 原生字段)。
func (p *Plugin) writePiDefaults(agentDir, provider, model string) error {
path := filepath.Join(agentDir, "settings.json")
cur := map[string]interface{}{}
if b, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(b, &cur) // 读坏就当空(下面会整体重写)
}
if provider != "" {
cur["defaultProvider"] = provider
}
if model != "" {
cur["defaultModel"] = model
}
b, err := json.MarshalIndent(cur, "", " ")
if err != nil {
return err
}
return writeFile0600(path, b)
}
// writeFile0600 写入并确权 —— 这些文件含明文 api key。
func writeFile0600(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Chmod(tmp, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// coreStr / coreInt 读内核配置(类型不匹配一律当空,不 panic)。
func coreStr(sett interface {
GetCore(string) (interface{}, error)
}, key string) string {
v, err := sett.GetCore(key)
if err != nil || v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case bool:
if t {
return "true"
}
return "false"
case float64:
return fmt.Sprintf("%v", t)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func coreInt(sett interface {
GetCore(string) (interface{}, error)
}, key string) int {
v, err := sett.GetCore(key)
if err != nil || v == nil {
return 0
}
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case string:
var n int
_, _ = fmt.Sscanf(t, "%d", &n)
return n
default:
return 0
}
}
// ---------------- 工具实现 ----------------
func (p *Plugin) piAgentDir() string {
return filepath.Join(filepath.Dir(p.path), "runtime", "agentdir")
}
// handlePiSourceImport 把内核的 LLM 源导入内置 pi 的配置文件。
func (p *Plugin) handlePiSourceImport(args map[string]interface{}) (interface{}, error) {
n, def, err := p.importKernelSources(p.piAgentDir())
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
return map[string]interface{}{
"status": "ok",
"imported": n,
"default": def,
"agentdir": p.piAgentDir(),
"config_files": []string{"models.json", "auth.json", "settings.json"},
}, nil
}
// handlePiSourceList 列出已配的源(★ 不返回 key)。
func (p *Plugin) handlePiSourceList(args map[string]interface{}) (interface{}, error) {
dir := p.piAgentDir()
b, err := os.ReadFile(filepath.Join(dir, "models.json"))
if err != nil {
if os.IsNotExist(err) {
return map[string]interface{}{
"providers": []string{}, "default": "",
"hint": "内置 pi 尚无 LLM 配置;调 commondev_pi_source_import 导入内核的源",
}, nil
}
return map[string]interface{}{"error": err.Error()}, nil
}
var f piModelsFile
if err := json.Unmarshal(b, &f); err != nil {
return map[string]interface{}{"error": "models.json 损坏: " + err.Error()}, nil
}
// 默认源从 settings.json 读
def := ""
if sb, err := os.ReadFile(filepath.Join(dir, "settings.json")); err == nil {
var s map[string]interface{}
if json.Unmarshal(sb, &s) == nil {
def, _ = s["defaultProvider"].(string)
}
}
// ★ 只暴露名字与端点,绝不返回 apiKey
type view struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
API string `json:"api"`
Model string `json:"model,omitempty"`
Default bool `json:"default"`
}
out := []view{}
for name, pr := range f.Providers {
m := ""
if len(pr.Models) > 0 {
m = pr.Models[0].ID
}
out = append(out, view{Name: name, BaseURL: pr.BaseURL, API: pr.API,
Model: m, Default: name == def})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return map[string]interface{}{"providers": out, "default": def, "count": len(out)}, nil
}
// handlePiSourceDefault 切换内置 pi 的默认源。
func (p *Plugin) handlePiSourceDefault(args map[string]interface{}) (interface{}, error) {
name := strings.TrimSpace(str(args, "name"))
if name == "" {
return map[string]interface{}{"error": "name 必填"}, nil
}
dir := p.piAgentDir()
b, err := os.ReadFile(filepath.Join(dir, "models.json"))
if err != nil {
return map[string]interface{}{"error": "读 models.json 失败: " + err.Error()}, nil
}
var f piModelsFile
if err := json.Unmarshal(b, &f); err != nil {
return map[string]interface{}{"error": "models.json 损坏: " + err.Error()}, nil
}
pr, ok := f.Providers[name]
if !ok {
names := make([]string, 0, len(f.Providers))
for n := range f.Providers {
names = append(names, n)
}
sort.Strings(names)
return map[string]interface{}{
"error": fmt.Sprintf("源 %q 不存在(可用:%s)", name, strings.Join(names, ", ")),
}, nil
}
model := strings.TrimSpace(str(args, "model"))
if model == "" && len(pr.Models) > 0 {
model = pr.Models[0].ID
}
if err := p.writePiDefaults(dir, name, model); err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
return map[string]interface{}{"status": "ok", "default": name, "model": model}, nil
}
// newPiAuthEntry 构造 pi 认的凭据 entry。
//
// ★ schema 是 `{"type":"api_key","key":"..."}`,不是 `{"apiKey":"..."}`。
//
// 这是**实测踩出来的**(2026-10-15):内嵌 pi 用旧格式时,
// auth 读取静默返回空,报 `Provider is not configured: llmsproxy` ——
// 而 models.json / auth.json / settings.json 三个文件看起来都完全正常。
// 定位过程:同一个 pi API,只切 PI_CODING_AGENT_DIR 就一边跑得通
// 一边跑不通,逐字段对比才定位到这个键名。
//
// 权威定义(pi 源码):
//
// dist/core/auth-storage.js:185 if (value.type === "api_key") { ... }
// dist/core/model-runtime.js:423 { type: "api_key", key: apiKey }
func newPiAuthEntry(apiKey string) map[string]interface{} {
return map[string]interface{}{
"type": "api_key",
"key": apiKey,
}
}

View File

@ -0,0 +1,352 @@
package main
// LLM 源端到端判据:**内核源 → 插件导入 → pi 配置 → 真实调用成功**。
//
// ## 为什么必须端到端而不能只测导入
//
// 导入逻辑正确 ≠ pi 能用。中间隔着三层容易各自出错:
//
// ① 源字段映射(内核 core.llm.sources.<n>.* → pi 的 providers.<n>.{baseUrl,api,apiKey})
// ② adapter → pi api 名的映射(openai → openai-completions)
// ③ 默认源写进 settings.json(defaultProvider/defaultModel)
//
// 任何一层错了,现象都是「pi 起不来 / 报 No API key found」——
// 而单元判据全绿。所以本文件用**真实源 + 真实 HTTP 调用**收口。
//
// ## 判据分层
//
// TestLLMSourceImportProducesValidPiConfig — 纯逻辑(不需要网络)
// TestLLMSourceRoundTripAgainstRealEndpoint — 真机(需要 HA_E2E_LLM=1)
import (
"bytes"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// coreSettings 是只答 GetCore 的最小替身 —— 内核配置面在这一层被使用。
type coreSettings struct {
stubSettings
core map[string]interface{}
}
func (c *coreSettings) GetCore(key string) (interface{}, error) {
if v, ok := c.core[key]; ok {
return v, nil
}
return nil, nil
}
func (c *coreSettings) ListCore(prefix string) ([]string, error) {
var out []string
for k := range c.core {
if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
out = append(out, k)
}
}
return out, nil
}
// newPluginWithCore 造一个能读到「内核 LLM 源」的插件。
func newPluginWithCore(t *testing.T, core map[string]interface{}) *Plugin {
t.Helper()
p := newTestPluginForShell(t)
cs := &coreSettings{stubSettings: stubSettings{dir: filepath.Dir(p.path)}, core: core}
p.sdk = sdk.New("commondev", cs, nil, nil, nil, nil)
return p
}
// ★★ 判据:导入后生成的 models.json / auth.json / settings.json 必须自洽。
func TestLLMSourceImportProducesValidPiConfig(t *testing.T) {
core := map[string]interface{}{
"core.llm.provider": "mysrc",
"core.llm.model": "my-model",
"core.llm.sources.mysrc.base_url": "http://127.0.0.1:9999/v1",
"core.llm.sources.mysrc.api_key": "sk-test-123",
"core.llm.sources.mysrc.model": "my-model",
"core.llm.sources.mysrc.adapter": "openai",
"core.llm.sources.mysrc.context_window": float64(128000),
"core.llm.sources.mysrc.vision": "true",
}
p := newPluginWithCore(t, core)
agentDir := p.piAgentDir()
n, def, err := p.importKernelSources(agentDir)
if err != nil {
t.Fatal(err)
}
if n != 1 || def != "mysrc" {
t.Fatalf("导入数量/默认源不对: n=%d def=%q", n, def)
}
// models.json 必须是 pi 认识的形状
var models piModelsFile
b := mustRead(t, filepath.Join(agentDir, "models.json"))
if err := json.Unmarshal(b, &models); err != nil {
t.Fatalf("models.json 不是合法 JSON: %v", err)
}
pv, ok := models.Providers["mysrc"]
if !ok {
t.Fatal("models.json 缺少源")
}
if pv.BaseURL != "http://127.0.0.1:9999/v1" {
t.Errorf("baseUrl 错: %q", pv.BaseURL)
}
// ★ adapter 映射:openai → openai-completions(pi 的取值)
if pv.API != "openai-completions" {
t.Errorf("api 映射错(pi 需要 openai-completions): %q", pv.API)
}
if len(pv.Models) != 1 || pv.Models[0].ID != "my-model" {
t.Errorf("models 错: %+v", pv.Models)
}
if pv.Models[0].ContextWindow != 128000 {
t.Errorf("contextWindow 未透传: %d", pv.Models[0].ContextWindow)
}
// 权限:含明文 key ⇒ 必须 0600
if fi, err := os.Stat(filepath.Join(agentDir, "models.json")); err != nil || fi.Mode().Perm() != 0o600 {
t.Errorf("models.json 权限应为 0600")
}
// settings.json 的默认源
var settings map[string]interface{}
if err := json.Unmarshal(mustRead(t, filepath.Join(agentDir, "settings.json")), &settings); err != nil {
t.Fatal(err)
}
if settings["defaultProvider"] != "mysrc" {
t.Errorf("defaultProvider 错: %v", settings["defaultProvider"])
}
}
// ★★ 判据:**未知 adapter 必须报错**,不得猜一个默认值。
//
// 猜错会让 pi 用错的协议说话,现象是「模型不回复」—— 那是极难定位的失败。
func TestLLMSourceImportRejectsUnknownAdapter(t *testing.T) {
core := map[string]interface{}{
"core.llm.sources.weird.base_url": "http://x/v1",
"core.llm.sources.weird.model": "m",
"core.llm.sources.weird.adapter": "some-unknown-protocol",
}
p := newPluginWithCore(t, core)
if _, _, err := p.importKernelSources(p.piAgentDir()); err == nil {
t.Fatal("★ 未知 adapter 未报错 —— 猜默认值会让 pi 用错协议(现象是「模型不回复」)")
}
}
// ★★ 判据:内核**没有任何源**时必须报错(不静默造一个空的)。
func TestLLMSourceImportFailsWithoutKernelSources(t *testing.T) {
p := newPluginWithCore(t, map[string]interface{}{})
if _, _, err := p.importKernelSources(p.piAgentDir()); err == nil {
t.Fatal("内核无源时应报错,而不是生成一个空配置(pi 会起不来且原因不明)")
}
}
// ★★★ 真机判据:**导入的真实源必须真的能调通**。
//
// 这是端到端的最后一环:前面的判据只证明「配置写对了」,
// 这条证明「配置里的源可用」。
//
// 用 HA_E2E_LLM=1 打开(默认跳过:它依赖本机 llmsproxy)。
// 环境变量 HA_E2E_BASE_URL / HA_E2E_API_KEY / HA_E2E_MODEL 可覆盖。
func TestLLMSourceRoundTripAgainstRealEndpoint(t *testing.T) {
if os.Getenv("HA_E2E_LLM") != "1" {
t.Skip("跳过真机 LLM 判据(设 HA_E2E_LLM=1 打开)")
}
baseURL := envOr("HA_E2E_BASE_URL", "http://127.0.0.1:8081/v1")
apiKey := os.Getenv("HA_E2E_API_KEY")
model := envOr("HA_E2E_MODEL", "AUTO")
if apiKey == "" {
t.Fatal("需要 HA_E2E_API_KEY")
}
// ① 像真实内核那样配一个源,走**插件的导入路径**
core := map[string]interface{}{
"core.llm.provider": "e2e",
"core.llm.model": model,
"core.llm.sources.e2e.base_url": baseURL,
"core.llm.sources.e2e.api_key": apiKey,
"core.llm.sources.e2e.model": model,
"core.llm.sources.e2e.adapter": "openai",
"core.llm.sources.e2e.context_window": float64(1048576),
}
p := newPluginWithCore(t, core)
agentDir := p.piAgentDir()
n, def, err := p.importKernelSources(agentDir)
if err != nil || n != 1 || def != "e2e" {
t.Fatalf("导入失败: n=%d def=%q err=%v", n, def, err)
}
// ② 从**导入产物**(而不是从测试变量)读回连接信息 —— 这是端到端的要点:
// 若导入写错了字段,这里就会用错地址/错 key,调用会失败。
var models piModelsFile
b := mustRead(t, filepath.Join(agentDir, "models.json"))
if err := json.Unmarshal(b, &models); err != nil {
t.Fatal(err)
}
pv := models.Providers["e2e"]
if pv == nil {
t.Fatal("导入产物里没有 e2e 源")
}
var auth map[string]map[string]string
if err := json.Unmarshal(mustRead(t, filepath.Join(agentDir, "auth.json")), &auth); err != nil {
t.Fatal(err)
}
keyFromDisk := auth["e2e"]["apiKey"]
if keyFromDisk == "" {
t.Fatal("auth.json 里没有 key —— pi 会报 No API key found")
}
// ③ 用**磁盘上的**配置真调一次
body, _ := json.Marshal(map[string]interface{}{
"model": pv.Models[0].ID,
"max_tokens": 32,
"messages": []map[string]string{{"role": "user", "content": "只回答两个字:可用"}},
})
url := pv.BaseURL + "/chat/completions"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+keyFromDisk)
req.Header.Set("Content-Type", "application/json")
cli := &http.Client{Timeout: 90 * time.Second}
resp, err := cli.Do(req)
if err != nil {
t.Fatalf("调用导入配置里的源失败: %v", err)
}
defer resp.Body.Close()
var out struct {
Choices []struct {
Message struct{ Content string } `json:"message"`
} `json:"choices"`
Error interface{} `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d(用导入产物里的地址与 key): %v", resp.StatusCode, out.Error)
}
if len(out.Choices) == 0 {
t.Fatalf("无 choices: %+v", out)
}
t.Logf("✅ 端到端通过:内核源 → 插件导入 → 用磁盘配置真实调用成功(%s)", pv.Models[0].ID)
}
// ---------------- 对照真机内核配置 ----------------
// ★★★ 判据:**用真机生产 config.db 里的真实源**跑一遍导入。
//
// 与上一条的区别:上一条的源是测试构造的;这条读**生产配置**,
// 因此能发现「真实内核的字段形态与我们的假设不符」这类问题
// (例如字段名、类型、有没有 sources.* 子表)。
//
// 用 HA_E2E_REAL_DB=/home/newqqagent/config.db 打开。
func TestLLMSourceImportFromRealKernelConfig(t *testing.T) {
dbPath := os.Getenv("HA_E2E_REAL_DB")
if dbPath == "" {
t.Skip("跳过真机内核配置判据(设 HA_E2E_REAL_DB=<config.db> 打开)")
}
rows := readCoreLLMRows(t, dbPath)
if len(rows) == 0 {
t.Fatalf("%s 里没有任何 core.llm.* 配置", dbPath)
}
// 直接喂真实的字段形态给导入逻辑
core := map[string]interface{}{}
for k, v := range rows {
core[k] = v
}
p := newPluginWithCore(t, core)
agentDir := p.piAgentDir()
n, def, err := p.importKernelSources(agentDir)
if err != nil {
t.Fatalf("★ 用**真实内核配置**导入失败(说明字段形态与假设不符): %v", err)
}
if n == 0 {
t.Fatal("导入 0 个源")
}
var models piModelsFile
if err := json.Unmarshal(mustRead(t, filepath.Join(agentDir, "models.json")), &models); err != nil {
t.Fatal(err)
}
for name, pv := range models.Providers {
if pv.BaseURL == "" || pv.API == "" || len(pv.Models) == 0 {
t.Errorf("源 %s 导入不完整: %+v", name, pv)
}
// ★ api 必须是 pi 认识的值(否则 pi 起不来)
if pv.API != "openai-completions" && pv.API != "anthropic-messages" {
t.Errorf("源 %s 的 api=%q 不是 pi 的合法取值", name, pv.API)
}
}
t.Logf("✅ 真实内核配置导入成功:%d 个源,默认=%s", n, def)
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// readCoreLLMRows 读 config.db 的 core.llm.* 行(用 go-sqlite3)。
func readCoreLLMRows(t *testing.T, dbPath string) map[string]string {
t.Helper()
// 用 sqlite3 CLI(内核侧判据不引 cgo 依赖)
out, err := runCmd("sqlite3", dbPath, "select key||'\t'||value from config where key like 'core.llm.%';")
if err != nil {
t.Skipf("读 config.db 失败(缺 sqlite3?): %v", err)
}
rows := map[string]string{}
for _, line := range splitLines(out) {
if i := indexByte(line, '\t'); i > 0 {
rows[line[:i]] = line[i+1:]
}
}
return rows
}
func runCmd(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
b, err := cmd.Output()
return string(b), err
}
func splitLines(s string) []string {
var out []string
cur := ""
for _, c := range s {
if c == '\n' {
if cur != "" {
out = append(out, cur)
}
cur = ""
continue
}
cur += string(c)
}
if cur != "" {
out = append(out, cur)
}
return out
}
func indexByte(s string, b byte) int {
for i := 0; i < len(s); i++ {
if s[i] == b {
return i
}
}
return -1
}
// mustRead 读文件(失败即 Fatal)。
func mustRead(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s 失败: %v", path, err)
}
return b
}

Some files were not shown because too many files have changed in this diff Show More