37 Commits

Author SHA1 Message Date
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
78 changed files with 11578 additions and 513 deletions

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

285
README.md
View File

@ -2,6 +2,70 @@
HomeAgent 插件开发 SDK用于构建与 HomeAgent 平台交互的智能插件。
## 版本与兼容性
当前:**SDK 1.2.0**(需内核 **1.2.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.0 |
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
**1.0.x 插件升到 1.1.x不需要改代码也不需要重编。** 1.1.0 的新增全部是
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
想用新字段时重编即可。
**1.1.x 插件升到 1.2.x接口纯追加但必须重编。** 公开接口没有签名变更(新增
`InjectOptions` 与六个 `*Opts` 变体、`ChannelDef.ContextPolicy`),不调新能力就不受影响;
但内核的**插件运行协议升到了 2**(统一共享内存区的 fd3 布局改变,**不支持滚动升级**
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
## 注入行为与上下文裁剪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 +84,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 为能力位掩码 |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API实体-关系存储) |
@ -36,6 +104,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()` | 控制崩溃自动重启 |
### 阶段钩子
@ -106,6 +175,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 +208,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 +288,23 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
## plugindev 工具链
## hmapdev 工具链
`plugindev` 提供插件开发全流程支持。预编译二进制作为 **release 附件**分发linux/darwin/windows × amd64/arm64
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
预编译二进制作为 **release 附件**分发linux/darwin/windows × amd64/arm64
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
> 改名说明:工具链原名 `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 附件下载(以最新 SDK 发布 / linux amd64 为例)
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/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 +312,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 +423,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 +441,31 @@ enabled := sdk.AutoRestart()
插件崩溃时平台自动拉起,保障服务可用性。
> ⚠️ `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 +477,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 +548,11 @@ enabled := sdk.AutoRestart()
| [rss](example/rss) | Go | RSS 订阅 |
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
**发版时附带预编译示例产物**SDK 的 release 除 5 平台 `hmapdev` 外,还包含各示例插件的
`.hmap``SHA256SUMS`/`MANIFEST.txt`。原因是插件二进制与内核**协议绑定**`ProtocolVersion`
+ 共享内存区魔数),只发工具链不发示例产物,很容易拿旧产物去装而握手失败——那看起来像
「插件坏了」而不是「版本不配套」。
## Remote Device SDK
用于开发**远程设备接入适配器**的 C 语言 SDK零外部依赖兼容嵌入式平台。
@ -420,10 +654,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 +869,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 +897,16 @@ curl -X POST http://127.0.0.1:9876/plugins \
```
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
## 许可
SDK 以 **AGPL-3.0-only** 发布,全文见 [LICENSE](LICENSE)。
**这对插件开发者是实质性约束**SDK 会随插件一起**静态链接**(其源码进入插件二进制),
插件因此是本 SDK 的衍生作品,**必须以相同许可AGPL-3.0-only发布**;并且因为 AGPL §13
覆盖网络交互,通过 HTTP/WebSocket 等向用户提供服务的插件同样要向使用者提供源码。
若你的插件需要闭源,唯一合规路径是另行取得本项目的例外/商业授权——目前不提供。
第三方组件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,83 @@
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
## Version and Compatibility
Current: **SDK 1.2.0** (requires kernel **1.2.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 onward | 1.2.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.
**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 `ProtocolVersion`, not the 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 +113,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 +184,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 +219,65 @@ The Triple data structure includes additional fields:
- `Confidence` — confidence score (0.01.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 +303,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:
> 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://gitcode.com/JianFeeeee/homeagent-sdk/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 +329,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 +405,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 +423,33 @@ enabled := sdk.AutoRestart()
The platform automatically restarts the plugin on crash, ensuring service availability.
> ⚠️ `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 +481,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 **protocol-bound** to the kernel (`ProtocolVersion` + the shared-memory
magic), 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 +572,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 +785,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 +813,19 @@ 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 **AGPL-3.0-only** — see [LICENSE](LICENSE).
**This is a substantive constraint for plugin developers**: the SDK is **statically linked** into
your plugin (its source ends up in the plugin binary), so the plugin is a derivative work of
this SDK and **must be released under the same license**. Because AGPL §13 covers network
interaction, a plugin that serves users over HTTP/WebSocket must also offer them the source.
If you need a closed-source plugin, the only compliant route is a separate exception/commercial
license from this project — none is offered today.
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/`.

View File

@ -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"`
}

View File

@ -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

@ -5,7 +5,7 @@ ai_image plugin
## Build
```bash
plugindev build
hmapdev build
```
## Install

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"
@ -45,20 +46,20 @@ type Plugin struct {
// 登录态/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 +160,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 +204,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",
@ -276,14 +292,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)
@ -471,7 +488,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.comwww.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 +500,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 == "" {
@ -881,10 +974,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 +991,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
}
}
@ -942,7 +1040,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
@ -972,13 +1070,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
}
@ -1044,11 +1144,11 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
}
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),
"status": "ok",
"format": format,
"size": len(buf),
"base64": b64,
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
}, nil
}
@ -1079,11 +1179,11 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
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
}
@ -1204,13 +1304,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 +1417,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.comwww.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
}

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

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,7 @@ calendar plugin
## Build
```bash
plugindev build
hmapdev build
```
## Install

View File

@ -15,31 +15,31 @@ import (
)
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,6 +276,9 @@ 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 = "."
@ -359,7 +362,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 +370,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)
@ -520,7 +523,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})
}
}

View File

@ -0,0 +1,100 @@
# 联网检索插件HomeAgent
给 agent 补上**真正的信息检索**能力:检索交给本地 SearXNG多引擎聚合、结构化 JSON
并补上「读完前 K 篇再回答」的深检索。
## 为什么需要它(背景)
agent 原本只有 `browser_*` 那套浏览器工具,联网检索实际只有 `browser_search` 一个入口,而它是
**「抓 Bing HTML + 正则解析」**
| 缺陷 | 实测结果 |
|---|---|
| 标题取的是结果块里**第一个 `<a>`** | 拿到的是 Bing 的「来源行」而非标题 → `deepin.orghttps://www.deepin.org` |
| 摘要正则 `<div class="b_caption">.*?<p>` | 对现代 Bing **命中 0/10**(摘要已迁到 `p.b_lineclamp*`)→ 结果**完全没有摘要** |
| 用 `www.bing.com` | 程序化请求直接 302`cn.bing.com` 才返回 10 个结果块 |
| 单引擎、无兜底、无去重、无站点读取 | 模型只能反复换词重搜(日志里 8 秒 6 连击) |
结果就是日志里那句用户反馈:**「你的搜索能力好像不太行啊」**。
## 依赖:本地 SearXNG由本插件托管
插件会**自己管后端**
- **启动时**:探 `healthz`;已在跑就**直接接管**(不重启),没跑就 `docker compose up -d` 并等就绪(上限 6s
- **停止时**:跑 `docker compose stop -t 2` 关闭它
配置项 `manage_searxng`(默认 true`searxng_dir`(默认 `/root/searxng-agent`)控制这套行为;
`stop_searxng_on_exit`(默认 true设 false 可让后端在插件停止后继续跑(**插件重载频繁时建议设 false**
否则每次重载都会把后端重启一遍)。
### 生命周期契约(依据内核源码,非猜测)
| 环节 | 内核行为 |
|---|---|
| 停止插件 | 发 `plugin.stop` → 插件先跑 **RunStopHandlersLIFO、幂等** → 再 `Stop()``exit(0)` |
| 宽限期 | **5 秒**;未退出则直接 SIGKILL —— 所以关闭动作限时 4s`searxShutdownBudget` |
| stdin 关闭 | 同样会跑 handlers + `Stop()` |
| 崩溃/被 kill | 关闭动作不会执行,后端会留在运行态;下次启动探测到就直接接管(**更安全的失败方向** |
| 自动重启 | `SetAutoRestart(true)` 由注入的 runtime 在 `plugin.start` 后经 `lifecycle.autoRestart` **显式上报**内核 |
### SearXNG 侧配置
部署在 **.60**`127.0.0.1:8888`
```
/root/searxng-agent/docker-compose.yml # host 网络(要访问宿主 clash
/root/searxng-agent/settings.yml # json 输出 + limiter 关闭 + 出站走 clash
```
两个必须知道的坑:
1. **`search.formats` 必须含 `json`**,否则 `/search?format=json` 返回 **403**(看起来像网络问题,其实是配置)。
2. 该镜像默认 `GRANIAN_PORT=8080`,而 granian 的 `GRANIAN_*` **优先级高于 settings.yml**
.60 上 8080 被 homeagent 占用 → 不改 `SEARXNG_PORT` 就是无休止的 `Address already in use` 崩溃循环。
实测可用的引擎2026-09-12`duckduckgo``brave``google cse``quark` 时好时坏;
`baidu`/`google` 经代理出口触发 CAPTCHA`sogou` 崩溃,`wikidata` 报 HTTP error已关
## 工具
| 工具 | 说明 |
|---|---|
| `deepsearch_search` | 联网检索(首选):标题 + URL + 摘要 + 发布时间,支持 `engines`/`category`/`time_range`/`language`,自动按 URL 去重并按分数排序;会回报**引擎覆盖度与无响应引擎** |
| `deepsearch_news` | 新闻检索:`news` 类别 + 默认最近一周;新闻为空时自动回退 general + 时间范围 |
| `deepsearch_fetch` | 抓单个网页并抽正文(去脚本/样式/导航),返回标题 + 纯文本,可设截断长度 |
| `deepsearch_deep` | **深检索**:检索 → 并行抓前 K 篇正文 → 一次返回「候选清单 + 证据正文」;单篇失败不影响整体 |
| `deepsearch_status` | 自检healthz、json 是否可用、延迟、**哪些引擎真的在返回结果**(检索出问题先跑这个) |
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `searxng_url` | `http://127.0.0.1:8888` | 本地 SearXNG 地址 |
| `max_results` | `8` | 默认条数(控制上下文体积) |
| `language` | `zh-CN` | 检索语言 |
| `safesearch` | `0` | 0 关 / 1 中 / 2 严 |
| `request_timeout` | `20` | 单次请求超时(秒) |
| `fetch_max_chars` | `4000` | `deepsearch_fetch` 正文上限 |
| `proxy` | 空 | 仅作用于本插件直连抓取(搜索出网由 SearXNG 侧负责) |
| `user_agent` | Chrome UA | 抓取用 |
每次调用前重读配置,改完即时生效。
## 开发与验证
```bash
go test -count=1 -race ./... # 11 项测试httptest 打桩 SearXNG
# 真实后端联调(默认跳过):跑的就是当初失败的那条查询
DEEPSEARCH_LIVE_SEARXNG=http://127.0.0.1:8888 go test -run TestLiveSearxng -v ./...
hmapdev build # 产出 dist/deep_search_bundle.hmap
```
## 已知边界
- **知乎等站点对直连抓取返回 403**(反爬),`deepsearch_deep` 会如实标注该篇抓取失败并继续;
这类页面请改用浏览器工具(`browser_navigate` + `browser_render`)。
- 引擎可用性随出口 IP 与目标站点风控变化;`deepsearch_status` 与每次结果里的「覆盖度」行就是给这个用的。
- 未做正文去重/相似度合并:同一事件的多篇转载会各占一条(摘要已能区分)。

21
example/deepsearch/go.mod Normal file
View File

@ -0,0 +1,21 @@
module deepsearch-plugin
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0

View File

@ -0,0 +1,81 @@
package main
import (
"os"
"strings"
"testing"
)
// 真实后端联调(默认跳过,需显式指定地址):
//
// DEEPSEARCH_LIVE_SEARXNG=http://127.0.0.1:8888 go test -run TestLiveSearxng -v ./...
//
// 它跑的就是当初失败的场景(日志里那条「你的搜索能力好像不太行啊」对应的查询),
// 用来回答一个具体问题:换了后端之后,模型拿到的是不是「带摘要的相关结果」。
func TestLiveSearxng(t *testing.T) {
base := os.Getenv("DEEPSEARCH_LIVE_SEARXNG")
if base == "" {
t.Skip("未设置 DEEPSEARCH_LIVE_SEARXNG跳过真实后端联调")
}
p := &Plugin{
name: "deepsearch",
searxURL: strings.TrimRight(base, "/"),
maxItems: 6,
language: "zh-CN",
fetchMax: 1200,
userAgent: defaultUA,
}
p.ensure()
// 1) 自检
st, err := p.handleStatus(map[string]interface{}{})
if err != nil {
t.Fatalf("status: %v", err)
}
t.Logf("status: %v", st)
// 2) 当初失败的那条查询
res, err := p.handleSearch(map[string]interface{}{"query": "深度科技 deepin 开发者 被开除"})
if err != nil {
t.Fatalf("search: %v", err)
}
txt := res.(map[string]interface{})["content"].(string)
t.Logf("检索结果:\n%s", txt)
if !strings.Contains(txt, "摘要:") {
t.Errorf("结果里应当有摘要(这正是原实现缺失的东西)")
}
if !strings.Contains(txt, "覆盖:") {
t.Errorf("应报告引擎覆盖度")
}
// 3) 正文抓取(取第一条结果的 URL
var firstURL string
for _, line := range strings.Split(txt, "\n") {
l := strings.TrimSpace(line)
if strings.HasPrefix(l, "http") {
firstURL = l
break
}
}
if firstURL == "" {
t.Fatal("未从结果中解析出 URL")
}
page, err := p.handleFetch(map[string]interface{}{"url": firstURL, "max_chars": float64(600)})
if err != nil {
t.Logf("抓取 %s 失败(真实站点有反爬/需 JS 属正常):%v", firstURL, err)
} else {
body := page.(map[string]interface{})["content"].(string)
t.Logf("抓取 %s 正文前 400 字:%s", firstURL, oneLine(body, 400))
}
// 4) 深检索
deep, err := p.handleDeep(map[string]interface{}{"query": "统信 UOS 内核工程师 西装 事件", "top_k": float64(2)})
if err != nil {
t.Fatalf("deep: %v", err)
}
dTxt := deep.(map[string]interface{})["content"].(string)
if !strings.Contains(dTxt, "候选清单") || !strings.Contains(dTxt, "正文证据") {
t.Errorf("深检索输出结构不对")
}
t.Logf("深检索输出前 800 字:\n%s", oneLine(dTxt, 800))
}

View File

@ -0,0 +1,12 @@
{
"name": "deepsearch",
"name_zh": "联网检索",
"name_en": "Deep Search",
"version": "1.1.2",
"description": "为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索",
"author": "HomeAgent",
"entry": "plugin.bin",
"sdk": "1.2.0",
"tags": ["search", "web", "searxng", "retrieval", "news"],
"targets": "linux/amd64"
}

1038
example/deepsearch/plugin.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,343 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func newTestPlugin(t *testing.T, h http.HandlerFunc) (*Plugin, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
p := &Plugin{
name: "deepsearch",
searxURL: srv.URL,
maxItems: 5,
language: "zh-CN",
fetchMax: 1000,
userAgent: "test-agent",
http: srv.Client(),
}
return p, srv
}
// 一份贴近真实 SearXNG 的响应:含重复 URL、缺摘要、多引擎、无响应引擎
const sampleResponse = `{
"query": "deepin 被开除",
"results": [
{"url":"https://www.zhihu.com/question/1?utm_source=x","title":"网传统信内核开发工程师因没穿西服被开除","content":"截止1月9日最新情况…","engines":["duckduckgo","brave"],"score":9.5,"publishedDate":"2026-09-10T00:00:00"},
{"url":"https://www.zhihu.com/question/1","title":"网传统信内核开发工程师因没穿西服被开除(重复项)","content":"重复条目","engines":["brave"],"score":1.0},
{"url":"https://www.163.com/dy/article/KIQURODQ.html","title":"离谱!传某信创操作系统大厂因西装开除核心开发者","content":"一位负责Linux内核开发的核心工程师…","engines":["brave","quark"],"score":7.2},
{"url":"https://bbs.deepin.org.cn/zh","title":"deepin官方论坛","content":"","engines":["duckduckgo"],"score":2.0}
],
"answers": [],
"suggestions": ["deepin 王勇 离职"],
"unresponsive_engines": [["baidu","CAPTCHA"],["sogou","unexpected crash"]],
"timings": {"search": 1.2}
}`
// 1) 检索:去重 + 按分数排序 + 摘要/覆盖度输出
func TestSearchDedupAndFormat(t *testing.T) {
var gotQuery url.Values
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/search" {
gotQuery = r.URL.Query()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(sampleResponse))
return
}
http.NotFound(w, r)
})
res, err := p.handleSearch(map[string]interface{}{"query": "deepin 被开除", "count": float64(5)})
if err != nil {
t.Fatalf("err: %v", err)
}
if gotQuery.Get("format") != "json" {
t.Errorf("必须要求 json 输出,实际 %q", gotQuery.Get("format"))
}
// SearXNG 的 /search **不认** count/limit实测两者都返回同样的条数
// 所以「要几条」必须由插件侧截断 —— 也不要再发这种无意义参数(曾以为它生效过)。
if gotQuery.Get("limit") != "" || gotQuery.Get("count") != "" {
t.Errorf("不应依赖 SearXNG 的条数参数(它不认): %q", gotQuery.Encode())
}
txt := res.(map[string]interface{})["content"].(string)
// utm_source 应被规范化掉,重复项只剩一条
if n := strings.Count(txt, "zhihu.com/question/1"); n != 1 {
t.Errorf("URL 未正确去重(出现 %d 次):\n%s", n, txt)
}
if !strings.Contains(txt, "网传统信内核开发工程师") {
t.Errorf("缺少标题: %s", txt)
}
if !strings.Contains(txt, "摘要:") {
t.Errorf("应输出摘要: %s", txt)
}
if !strings.Contains(txt, "baidu(CAPTCHA)") {
t.Errorf("应回报无响应引擎(让模型知道覆盖度): %s", txt)
}
if !strings.Contains(txt, "duckduckgo") || !strings.Contains(txt, "quark") {
t.Errorf("应回报引擎覆盖: %s", txt)
}
// 高分条目应排在前面
if strings.Index(txt, "统信内核开发工程师") > strings.Index(txt, "离谱!") {
t.Errorf("未按分数排序:\n%s", txt)
}
}
// 2) 403未开 json必须给出可操作提示而不是裸错误
func TestSearchForbiddenHint(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("Forbidden"))
})
_, err := p.handleSearch(map[string]interface{}{"query": "x"})
if err == nil {
t.Fatal("应返回错误")
}
msg := err.Error()
if !strings.Contains(msg, "403") || !strings.Contains(msg, "formats") {
t.Errorf("403 提示应指向 json/limiter 配置,实际: %s", msg)
}
}
// 3) 空结果:要给出原因与下一步建议
func TestSearchEmptyHint(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"query":"x","results":[],"suggestions":["换个词"],"unresponsive_engines":[["google","CAPTCHA"]]}`))
})
res, err := p.handleSearch(map[string]interface{}{"query": "x"})
if err != nil {
t.Fatalf("err: %v", err)
}
txt := res.(map[string]interface{})["content"].(string)
for _, want := range []string{"未返回结果", "google(CAPTCHA)", "换个词", "deepsearch_news"} {
if !strings.Contains(txt, want) {
t.Errorf("空结果提示缺少 %q: %s", want, txt)
}
}
}
// 4) 新闻:应带 categories=news 与 time_range=week新闻为空时回退 general
func TestNewsParamsAndFallback(t *testing.T) {
var calls []url.Values
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.URL.Query())
if r.URL.Query().Get("categories") == "news" {
_, _ = w.Write([]byte(`{"query":"n","results":[]}`))
return
}
_, _ = w.Write([]byte(`{"query":"n","results":[{"url":"https://a.com/1","title":"回退结果","content":"内容","engines":["brave"],"score":1}]}`))
})
res, err := p.handleNews(map[string]interface{}{"query": "某事"})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(calls) != 2 {
t.Fatalf("新闻为空时应回退 general实际调用 %d 次", len(calls))
}
if calls[0].Get("categories") != "news" || calls[0].Get("time_range") != "week" {
t.Errorf("首次应为 news + week实际 categories=%q time_range=%q", calls[0].Get("categories"), calls[0].Get("time_range"))
}
if tmp := res.(map[string]interface{})["content"].(string); !strings.Contains(tmp, "回退结果") {
t.Errorf("回退结果未被采用: %s", tmp)
}
}
// 5) 正文抽取:去脚本/样式/导航,保留 article
func TestFetchExtractsArticle(t *testing.T) {
page := `<!doctype html><html><head><title>测试标题 - 站点</title>
<style>.x{color:red}</style><script>var secret="SHOULD_NOT_APPEAR";</script></head>
<body><nav>导航链接</nav><article>
<p>第一段正文,包含关键事实。</p><p>第二段正文。</p>
</article><footer>页脚</footer></body></html>`
var srvURL string
p, srv := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(page))
})
srvURL = srv.URL
// 注意:不要用 example.com 之类真实域名——本机 DNS/proxy 会把它们转走,测试会飘
res, err := p.handleFetch(map[string]interface{}{"url": srvURL + "/a"})
if err != nil {
t.Fatalf("err: %v", err)
}
txt := res.(map[string]interface{})["content"].(string)
if !strings.Contains(txt, "第一段正文") {
t.Errorf("正文丢失: %s", txt)
}
if strings.Contains(txt, "SHOULD_NOT_APPEAR") {
t.Errorf("脚本内容不应出现: %s", txt)
}
if strings.Contains(txt, "导航链接") || strings.Contains(txt, "页脚") {
t.Errorf("导航/页脚应被剥离: %s", txt)
}
if !strings.Contains(txt, "测试标题") {
t.Errorf("标题应被提取: %s", txt)
}
}
// 6) 深检索:候选 + 正文证据;单篇失败不应导致整体失败
func TestDeepSearch(t *testing.T) {
var srvURL string // 处理函数先于 server 存在,故用闭包变量回填
p, srv := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search":
_, _ = w.Write([]byte(`{"query":"d","results":[
{"url":"` + srvURL + `/ok1","title":"好文一","content":"摘要一","engines":["brave"],"score":3},
{"url":"` + srvURL + `/bad","title":"打不开的","content":"摘要二","engines":["brave"],"score":2},
{"url":"` + srvURL + `/ok2","title":"好文二","content":"摘要三","engines":["brave"],"score":1}]}`))
case "/ok1", "/ok2":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<html><body><article><p>正文内容 " + r.URL.Path + "</p></article></body></html>"))
case "/bad":
w.WriteHeader(http.StatusForbidden)
default:
http.NotFound(w, r)
}
})
srvURL = srv.URL
res, err := p.handleDeep(map[string]interface{}{"query": "d", "top_k": float64(3), "max_chars": float64(500)})
if err != nil {
t.Fatalf("err: %v", err)
}
txt := res.(map[string]interface{})["content"].(string)
for _, want := range []string{"候选清单", "正文证据", "正文内容 /ok1", "正文内容 /ok2", "抓取失败"} {
if !strings.Contains(txt, want) {
t.Errorf("深检索输出缺少 %q:\n%s", want, txt)
}
}
}
// 7) 自检:健康检查 + 探测检索 + 引擎覆盖统计
func TestStatusReportsEngines(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
_, _ = w.Write([]byte("OK"))
return
}
_, _ = w.Write([]byte(sampleResponse))
})
res, err := p.handleStatus(map[string]interface{}{})
if err != nil {
t.Fatalf("err: %v", err)
}
m := res.(map[string]interface{})
if m["healthz"] != 200 {
t.Errorf("healthz 应为 200实际 %v", m["healthz"])
}
if m["search_ok"] != true {
t.Errorf("search_ok 应为 true%v", m["search_ok"])
}
engs, ok := m["engines_returning_results"].(map[string]int)
if !ok || engs["brave"] == 0 || engs["quark"] == 0 {
t.Errorf("引擎统计不正确: %#v", m["engines_returning_results"])
}
}
// 8) 摘要压成一行并按字符截断(避免巨长摘要吃掉上下文)
func TestOneLineTruncate(t *testing.T) {
got := oneLine("第一行\n第二行\t第三行", 5)
if strings.Contains(got, "\n") {
t.Errorf("应为单行: %q", got)
}
if r := []rune(got); len(r) != 6 { // 5 字符 + 省略号
t.Errorf("截断长度不符: %q (%d runes)", got, len(r))
}
}
// 9) 正文抽取长度上限生效
func TestHtmlToTextTruncation(t *testing.T) {
long := strings.Repeat("字", 5000)
_, text := htmlToText("<html><body><article><p>"+long+"</p></article></body></html>", 100)
if !strings.Contains(text, "已截断") {
t.Errorf("超长正文应被截断: %d", len([]rune(text)))
}
}
// 10) 非 http(s) 协议应被拒绝
func TestFetchRejectsBadScheme(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
if _, err := p.handleFetch(map[string]interface{}{"url": "file:///etc/passwd"}); err == nil {
t.Fatal("file:// 应被拒绝")
}
if _, err := p.handleFetch(map[string]interface{}{"url": "javascript:alert(1)"}); err == nil {
t.Fatal("javascript: 应被拒绝")
}
}
// 11) raw 模式返回结构化 JSON排查用
func TestSearchRawMode(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(sampleResponse))
})
res, err := p.handleSearch(map[string]interface{}{"query": "q", "raw": true})
if err != nil {
t.Fatalf("err: %v", err)
}
m, ok := res.(*searxResponse)
if !ok {
t.Fatalf("raw 应返回结构化响应,实际 %T", res)
}
if len(m.Results) != 4 {
t.Errorf("结果数应为 4raw 不去重),实际 %d", len(m.Results))
}
if _, err := json.Marshal(m); err != nil {
t.Errorf("结构化结果应可序列化: %v", err)
}
}
// 13) 条数截断SearXNG 不认条数参数,插件必须自己截,并且**如实说明**给了几条
func TestSearchTruncatesToCountAndSaysSo(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(sampleResponse)) // 4 条,去重后 3 条
})
res, err := p.handleSearch(map[string]interface{}{"query": "deepin", "count": float64(2)})
if err != nil {
t.Fatalf("err: %v", err)
}
txt := res.(map[string]interface{})["content"].(string)
// 必须明确区分「命中几条」与「返回几条」:写成「命中 N 条」而实际给了 M<N 条,
// 模型会把 N 当成拿到手的条数(实测被 agent 当成事实报给用户)。
if !strings.Contains(txt, "命中 3 条,返回前 2 条") {
t.Errorf("应如实说明命中数与返回数:\n%s", txt)
}
// 按 score 排序后的前两条zhihu(9.5)、163(7.2);第三条 bbs.deepin(2.0) 必须被截掉
if !strings.Contains(txt, "统信内核开发工程师") || !strings.Contains(txt, "离谱!") {
t.Errorf("前两条(按分数)应在:\n%s", txt)
}
if strings.Contains(txt, "deepin官方论坛") {
t.Errorf("第 3 条score 最低)超出了 count=2不该出现:\n%s", txt)
}
// 条目行数也要正好 2 条(防「头部说 2 条、正文还是全量」)
if n := strings.Count(txt, "\n http"); n != 2 {
t.Errorf("正文应恰好 2 条,实际 %d 条:\n%s", n, txt)
}
}
// 14) 条数上限:不因为模型要 200 条就真给 200 条
func TestLimitResultsCapsAndDefaults(t *testing.T) {
p := &Plugin{name: "deepsearch", maxItems: 8}
many := make([]searxResult, 30)
for i := range many {
many[i] = searxResult{URL: "https://e.test/", Title: "t"}
}
if got := len(p.limitResults(map[string]interface{}{}, many)); got != 8 {
t.Errorf("未指定 count 时应取配置的 max_items=8实际 %d", got)
}
if got := len(p.limitResults(map[string]interface{}{"count": float64(3)}, many)); got != 3 {
t.Errorf("count=3 应返回 3 条,实际 %d", got)
}
if got := len(p.limitResults(map[string]interface{}{"count": float64(200)}, many)); got != maxSearchResults {
t.Errorf("超过上限应收敛到 %d 条,实际 %d", maxSearchResults, got)
}
// 结果比 count 少时不能造数据
few := many[:2]
if got := len(p.limitResults(map[string]interface{}{"count": float64(5)}, few)); got != 2 {
t.Errorf("结果不足时应原样返回,实际 %d", got)
}
}

View File

@ -0,0 +1,164 @@
package main
// SearXNG 生命周期托管:插件启动时拉起搜索后端,插件停止时关闭它。
//
// 契约依据(内核侧 internal/plugin/proc/*,已逐行核对):
// - 内核停止插件:发 `plugin.stop` → 插件先跑 RunStopHandlersLIFO、幂等→ 再 Stop() → exit(0)
// - 若插件未在 stopGracePeriod**5 秒**)内退出,内核直接 SIGKILL
// - stdin 关闭(内核消失)同样会跑 handlers + Stop()
//
// 因此这里的关闭动作必须**有界**searxShutdownBudget 取 4s留 1s 余量。
//
// 归属规则(谁拉起谁关):**只有本插件真正执行了 `docker compose up -d` 的实例才算「我们起的」**。
// 探活发现已在运行的实例只「接管」——不认领关闭责任。否则同一台机器上的第二个实例
// E2E 测试拉起的插件、另一个 daemon退出时会把生产后端一起带走实测就是这条把
// 线上搜索服务反复关停的(测试实例用默认配置,测试结束就 `docker compose stop`)。
// 若插件是被 kill -9 / OOM 带走的,关闭动作不会执行 —— SearXNG 会留在运行态;
// 下次 Start 探测到它在跑就直接接管,这是更安全的失败方向。
import (
"context"
"log"
"net/http"
"os/exec"
"time"
)
const (
cfgManageSearx = "manage_searxng"
cfgSearxDir = "searxng_dir"
cfgStopOnExit = "stop_searxng_on_exit"
defaultSearxDir = "/root/searxng-agent"
searxProbeTimeout = 1500 * time.Millisecond // 单次 healthz 探测
searxUpBudget = 20 * time.Second // docker compose up -d 的上限(正常 1s 内返回)
searxReadyBudget = 6 * time.Second // up 之后等 healthz 就绪的上限
searxShutdownBudget = 4 * time.Second // 必须 < 内核 5s 宽限期
)
// searxBudget 把四个时间预算收拢,便于单测注入短值(否则测试要真等就绪窗口)。
type searxBudget struct {
probe time.Duration
up time.Duration
ready time.Duration
shutdown time.Duration
}
func (p *Plugin) budget() searxBudget {
b := p.bud
if b.probe == 0 {
b.probe = searxProbeTimeout
}
if b.up == 0 {
b.up = searxUpBudget
}
if b.ready == 0 {
b.ready = searxReadyBudget
}
if b.shutdown == 0 {
b.shutdown = searxShutdownBudget
}
return b
}
// cmdRunner 抽出来是为了让生命周期逻辑可单测:注入假执行器,不起真容器。
type cmdRunner func(ctx context.Context, dir, name string, args ...string) (string, error)
func defaultRunner(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
return string(out), err
}
// searxReachable 探测搜索后端是否可用(只看 healthz不发检索请求
func (p *Plugin) searxReachable(timeout time.Duration) bool {
if p.searxURL == "" {
return false
}
base := p.http
if base == nil {
base = &http.Client{}
}
cl := *base // 复制一份,避免改到共享 client 的超时
cl.Timeout = timeout
req, err := http.NewRequest(http.MethodGet, p.searxURL+"/healthz", nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", p.userAgent)
resp, err := cl.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode < 400
}
// ensureSearxng 在插件启动时确保搜索后端在跑;已在跑则直接接管,不重启。
func (p *Plugin) ensureSearxng() {
b := p.budget()
if !p.manageSearx {
log.Printf("[%s] 未启用 SearXNG 托管manage_searxng=false假定 %s 由外部维护", p.name, p.searxURL)
return
}
if p.searxReachable(b.probe) {
// 只接管,不认领:不是我们拉起来的,就不能由我们关掉
log.Printf("[%s] SearXNG 已在运行(%s直接接管不认领关闭责任", p.name, p.searxURL)
return
}
ctx, cancel := context.WithTimeout(context.Background(), b.up)
out, err := p.run(ctx, p.searxDir, "docker", "compose", "up", "-d")
cancel()
if err != nil {
log.Printf("[%s] 拉起 SearXNG 失败dir=%s请检查 manage_searxng/searxng_dir 配置): %v输出: %s",
p.name, p.searxDir, err, oneLine(out, 300))
return
}
log.Printf("[%s] 已执行 docker compose up -d%s%s", p.name, p.searxDir, oneLine(out, 200))
deadline := time.Now().Add(b.ready)
for time.Now().Before(deadline) {
if p.searxReachable(800 * time.Millisecond) {
log.Printf("[%s] SearXNG 就绪", p.name)
p.markSearxOwned()
return
}
time.Sleep(600 * time.Millisecond)
}
log.Printf("[%s] SearXNG 已启动但 %s 内未就绪;首次检索会自动等待", p.name, b.ready)
p.markSearxOwned()
}
func (p *Plugin) markSearxOwned() {
p.searxMu.Lock()
p.searxOwned = true
p.searxMu.Unlock()
}
// shutdownSearxng 关闭搜索后端。幂等,且有界(内核宽限期 5s这里最多 4s
func (p *Plugin) shutdownSearxng() {
b := p.budget()
p.searxMu.Lock()
owned := p.searxOwned
p.searxOwned = false
p.searxMu.Unlock()
if !owned {
return // 不是我们拉起来的 / 已经关过
}
if !p.manageSearx || !p.stopOnExit {
log.Printf("[%s] 保留 SearXNG 运行stop_searxng_on_exit=false", p.name)
return
}
ctx, cancel := context.WithTimeout(context.Background(), b.shutdown)
defer cancel()
out, err := p.run(ctx, p.searxDir, "docker", "compose", "stop", "-t", "2")
if err != nil {
// 故意只记日志:这里再重试就会拖过内核宽限期,被 SIGKILL 更糟
log.Printf("[%s] 关闭 SearXNG 失败(忽略): %v输出: %s", p.name, err, oneLine(out, 200))
return
}
log.Printf("[%s] 已关闭 SearXNG", p.name)
}

View File

@ -0,0 +1,223 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
type fakeCall struct {
dir string
name string
args []string
}
func (c fakeCall) String() string { return c.name + " " + strings.Join(c.args, " ") }
// newFakeRunner 记录调用并返回预设结果
func newFakeRunner(calls *[]fakeCall, out string, err error) cmdRunner {
var mu sync.Mutex
return func(ctx context.Context, dir, name string, args ...string) (string, error) {
mu.Lock()
*calls = append(*calls, fakeCall{dir: dir, name: name, args: args})
mu.Unlock()
return out, err
}
}
// fastBudget 把就绪窗口压到毫秒级,避免单测真等
func fastBudget() searxBudget {
return searxBudget{
probe: 50 * time.Millisecond,
up: time.Second,
ready: 200 * time.Millisecond,
shutdown: time.Second,
}
}
// 1) 后端没跑 → 应执行 docker compose up -d并认领关闭责任
func TestEnsureSearxngStartsWhenUnreachable(t *testing.T) {
var calls []fakeCall
p := &Plugin{
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
manageSearx: true, stopOnExit: true, userAgent: "test",
bud: fastBudget(), run: newFakeRunner(&calls, "Container searxng-agent Started", nil),
}
p.ensureSearxng()
if len(calls) != 1 {
t.Fatalf("应恰好拉起一次,实际 %d 次:%v", len(calls), calls)
}
got := calls[0]
if got.name != "docker" || strings.Join(got.args, " ") != "compose up -d" {
t.Errorf("命令不对:%s", got)
}
if got.dir != "/tmp/fake-searx" {
t.Errorf("工作目录应为配置的 compose 目录,实际 %q", got.dir)
}
if !p.searxOwned {
t.Error("既然是我们拉起的,就应认领关闭责任")
}
}
// 2) 后端已在跑 → 不重启,**且不认领关闭责任**
//
// 这条是关键同一台机器上会有第二个实例E2E 测试拉起的插件、另一个 daemon
// 如果「接管」也算「我拥有」,任一实例退出就会把生产后端关掉 —— 线上实测就是
// 测试实例在 teardown 时 `docker compose stop`,把搜索服务反复关停。
func TestEnsureSearxngAdoptsRunningBackendWithoutOwning(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
_, _ = w.Write([]byte("OK"))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
var calls []fakeCall
p := &Plugin{
name: "deepsearch", searxURL: srv.URL, searxDir: "/tmp/fake-searx",
manageSearx: true, stopOnExit: true, userAgent: "test",
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
}
p.ensureSearxng()
if len(calls) != 0 {
t.Errorf("已在跑就不该重启它,实际执行了:%v", calls)
}
if p.searxOwned {
t.Error("不是我们拉起的,就不能认领关闭责任(否则退出时会带走别人的后端)")
}
}
// 2b) 接管的实例退出时,一个 docker 命令都不能发
func TestAdoptedBackendSurvivesShutdown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("OK"))
}))
defer srv.Close()
var calls []fakeCall
p := &Plugin{
name: "deepsearch", searxURL: srv.URL, searxDir: "/tmp/fake-searx",
manageSearx: true, stopOnExit: true, userAgent: "test",
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
}
p.ensureSearxng()
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if len(calls) != 0 {
t.Errorf("接管来的后端在退出时必须留着,实际执行了:%v", calls)
}
}
// 3) 关掉托管 → 完全不碰 docker
func TestEnsureSearxngDisabled(t *testing.T) {
var calls []fakeCall
p := &Plugin{
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
manageSearx: false, stopOnExit: true, userAgent: "test",
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
}
p.ensureSearxng()
if len(calls) != 0 || p.searxOwned {
t.Errorf("manage_searxng=false 时不该有任何动作calls=%v owned=%v", calls, p.searxOwned)
}
}
// 4) 拉起失败不能让插件起不来(记日志即可)
func TestEnsureSearxngFailureNonFatal(t *testing.T) {
var calls []fakeCall
p := &Plugin{
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
manageSearx: true, stopOnExit: true, userAgent: "test",
bud: fastBudget(), run: newFakeRunner(&calls, "Cannot connect to the Docker daemon", errors.New("exit status 1")),
}
p.ensureSearxng() // 不应 panic
if p.searxOwned {
t.Error("没拉起来就不该认领关闭责任(否则停止时会去关一个不是我们起的服务)")
}
}
// 5) 停止:关掉我们拉起的后端,且幂等
func TestShutdownStopsOwnedBackend(t *testing.T) {
var calls []fakeCall
runner := newFakeRunner(&calls, "ok", nil)
p := &Plugin{
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
manageSearx: true, stopOnExit: true, userAgent: "test",
bud: fastBudget(), run: runner,
}
p.ensureSearxng()
calls = nil
p.shutdownSearxng()
if len(calls) != 1 {
t.Fatalf("应执行一次 compose stop实际 %v", calls)
}
if got := strings.Join(calls[0].args, " "); !strings.HasPrefix(got, "compose stop") {
t.Errorf("停止命令不对:%s", got)
}
if p.searxOwned {
t.Error("停止后应清掉认领标记")
}
p.shutdownSearxng() // 幂等:不应再调一次
if len(calls) != 1 {
t.Errorf("重复停止应无副作用,实际 %v", calls)
}
}
// 6) 不是我们拉起的 → 停止时不许动它
func TestShutdownSkippedWhenNotOwned(t *testing.T) {
var calls []fakeCall
p := &Plugin{
name: "deepsearch", searxDir: "/tmp/fake-searx", manageSearx: true, stopOnExit: true,
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
}
p.shutdownSearxng()
if len(calls) != 0 {
t.Errorf("不该去停一个我们没起的服务:%v", calls)
}
}
// 7) 配了「停止时保留」→ 认领过也不关
func TestShutdownKeepsBackendWhenConfigured(t *testing.T) {
var calls []fakeCall
p := &Plugin{
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
manageSearx: true, stopOnExit: false, userAgent: "test",
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
}
p.ensureSearxng()
calls = nil
p.shutdownSearxng()
if len(calls) != 0 {
t.Errorf("stop_searxng_on_exit=false 时不应关闭:%v", calls)
}
}
// 8) Stop() 自身也要收尾(内核 stdin 关闭路径不会走 stop handler 的注册顺序之外)
func TestStopTriggersShutdown(t *testing.T) {
var calls []fakeCall
p := &Plugin{
name: "deepsearch", searxURL: "http://127.0.0.1:1", searxDir: "/tmp/fake-searx",
manageSearx: true, stopOnExit: true, userAgent: "test",
bud: fastBudget(), run: newFakeRunner(&calls, "", nil),
}
p.ensureSearxng()
calls = nil
if err := p.Stop(); err != nil {
t.Fatalf("Stop 返回错误: %v", err)
}
if len(calls) != 1 {
t.Errorf("Stop 应触发一次关闭,实际 %v", calls)
}
}

View File

@ -17,7 +17,7 @@ lua main.lua # 使用 sdk.lua mock不依赖内核
## 构建
```bash
plugindev build
hmapdev build
```
## 安装

View File

@ -48,6 +48,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.tp = p.name + "_"
// 入站通道:本插件用 p.name 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
@ -292,8 +295,9 @@ func (p *Plugin) periodicCheck() {
continue
}
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, p.name,
fmt.Sprintf("注意,你还有%d条待办未完成请检查", n))
// NoMemory这是定时提醒不是记忆内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("注意,你还有%d条待办未完成请检查", n), sdk.InjectOptions{NoMemory: true})
}
}
}

View File

@ -2,7 +2,7 @@
"name": "qq",
"name_zh": "QQ消息",
"name_en": "qq",
"version": "1.2.0",
"version": "1.4.0",
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
"author": "HomeAgent",
"entry": "plugin.so",

View File

@ -85,6 +85,34 @@ type DownloadTask struct {
CreatedAt string `json:"created_at"`
}
type qqAuthContext struct {
active bool
owner bool
messageID int64
userID int64
groupID int64
isGroup bool
generation uint64
}
var qqMessageIDRe = regexp.MustCompile(`message_id=(-?\d+)`)
// 默认只开放公共信息与当前 QQ 会话所需能力。日历、邮件、记忆、知识库、
// 主机文件/命令、设备、配置、插件管理及 QQ 联系人/跨会话列表均不在白名单中。
const defaultPublicToolAllowlist = `["output_send__qq","output_list_channels","qq_get_message","qq_get_history","qq_mark_read","qq_get_group_member_info","qq_get_group_files","qq_video_download","weather_*","browser_search","browser_fetch","browser_render","ocr_*","multimodal_*","bili_*","music_*","ai_image_*"]`
const defaultGroupToolAllowlists = `{"*":["output_send__qq","output_list_channels","qq_get_message","qq_get_history","qq_mark_read","qq_get_group_member_info","qq_get_group_files","qq_video_download","weather_*","browser_search","browser_fetch","browser_render","ocr_*","multimodal_*","bili_*","music_*","ai_image_*"]}`
const (
// 单轮 QQ 触发的工具调用总数上限0 = 不限制)。只作跑飞兜底,
// 不应拦下正常的长时间多步任务。
defaultMaxQQToolCalls = 200
// 单轮 QQ 主动发送的不同消息条数上限0 = 不限制)。
defaultMaxQQOutputCalls = 20
// 单轮内同一条消息参数完全相同允许重复发送的次数0 = 不限制)。
defaultMaxDuplicateSend = 1
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
@ -93,7 +121,7 @@ type Plugin struct {
remoteDir string
filesDir string
webhookToken string
adminIDs []int64
ownerIDs []int64
botID int64
botNickname string
dmPolicy string
@ -108,12 +136,26 @@ type Plugin struct {
typingMu sync.Mutex
typingMap map[int64]*typingState
// msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats
msgMu sync.Mutex
msgMap map[int64]msgRef // message_id → {peer, time}
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
}
authMu sync.RWMutex
auth qqAuthContext
authGeneration uint64
authByMessageID map[int64]qqAuthContext
lastDenial string
denialLocked bool
toolCallCount int
outputCallCount int
outputSignatures map[string]int
maxQQToolCalls int
maxQQOutputCalls int
maxDuplicateSend int
groupToolAllowlists map[int64][]string // 0 表示通配配置 "*"
privateToolAllowlist []string
// msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats
msgMu sync.Mutex
msgMap map[int64]msgRef // message_id → {peer, time}
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
}
type typingState struct {
userID int64
@ -258,7 +300,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号列表,逗号分隔。收到其消息时标记【重要!老大消息】", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "owner", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ", Description: "Bot 所有者 QQ 号列表,逗号分隔。所有者无论私聊或群聊均拥有完整工具权限", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ旧配置", Description: "兼容旧版 admin 配置owner 为空时作为 Bot 所有者列表", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_tool_allowlists", Default: defaultGroupToolAllowlists, Type: "string", DisplayName: "群聊工具白名单", Description: "JSON 对象:群号到允许工具名/前缀*的数组;* 为未单独配置群的默认白名单。Bot 所有者不受限制", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "private_tool_allowlist", Default: defaultPublicToolAllowlist, Type: "string", DisplayName: "非所有者私聊工具白名单", Description: "JSON 数组,支持工具精确名和尾部 * 前缀。硬性私人资源工具不能由此白名单放行Bot 所有者不受权限限制", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_qq_tool_calls", Default: defaultMaxQQToolCalls, Type: "int", DisplayName: "单轮 QQ 工具调用上限", Description: "QQ 输入触发的单轮推理最多调用工具次数0=不限制);仅作跑飞兜底,不拦参数不同的必需调用", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_qq_output_calls", Default: defaultMaxQQOutputCalls, Type: "int", DisplayName: "单轮 QQ 发送上限", Description: "单轮最多主动发送的不同消息条数0=不限制);参数不同的消息不视为重复", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_duplicate_qq_send", Default: defaultMaxDuplicateSend, Type: "int", DisplayName: "单轮相同 QQ 发送上限", Description: "单轮内参数完全相同的 output_send__qq 允许重复的次数0=不限制);这才是循环保险的真正触发条件", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
@ -274,7 +322,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580")
p.webhookToken = getSetting[string](settings, "webhook_token", "")
p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/")
p.adminIDs = parseIDList(getSetting[string](settings, "admin", ""))
ownerRaw := getSetting[string](settings, "owner", "")
if strings.TrimSpace(ownerRaw) == "" {
ownerRaw = getSetting[string](settings, "admin", "")
}
p.ownerIDs = parseIDList(ownerRaw)
p.groupToolAllowlists = parseGroupToolAllowlists(getSetting[string](settings, "group_tool_allowlists", defaultGroupToolAllowlists))
p.privateToolAllowlist = parseToolAllowlist(getSetting[string](settings, "private_tool_allowlist", defaultPublicToolAllowlist))
p.maxQQToolCalls = nonNegativeOrDefault(int(getSetting[int64](settings, "max_qq_tool_calls", int64(defaultMaxQQToolCalls))), defaultMaxQQToolCalls)
p.maxQQOutputCalls = nonNegativeOrDefault(int(getSetting[int64](settings, "max_qq_output_calls", int64(defaultMaxQQOutputCalls))), defaultMaxQQOutputCalls)
p.maxDuplicateSend = nonNegativeOrDefault(int(getSetting[int64](settings, "max_duplicate_qq_send", int64(defaultMaxDuplicateSend))), defaultMaxDuplicateSend)
p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open"))
p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open"))
p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", ""))
@ -312,10 +369,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
`发送QQ群聊/私聊消息,支持文字、语音、图片、文件。
meta JSON 格式:
{
"group_id": 123456, // 群号(与 user_id 二选一)
"user_id": 123456, // QQ号(与 group_id 二选一)
"group_id": 123456, // 群号
"user_id": 123456, // QQ号
"reply_to": 12345 // 可选,回复指定消息 ID
}
路由规则:仅 group_id 发群;仅 user_id 发私聊;两者同时存在时发到 group_id并在消息头 @user_id。
type 枚举: text文字/ voice语音转文字后发送/ image图片URL/ file文件URL`,
sdk.ChannelDef{}, p.handleChannelOutput)
@ -354,6 +412,10 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Name: tp + "get_message", Description: botInfo + "通过 message_id 从 NapCat 实时获取消息正文、发送者、附件等信息。message_id 从中断消息的 message_id=N 获取,或从 reply_to 的 message_id 获取。",
NoMemory: false,
Cleaner: cleaner,
// 消息正文只在当轮需要(决策怎么回复);用完即裁剪。
// 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里,
// 长会话下持续挤占 token 预算§13.8)。
ContextPolicy: "prune",
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
@ -599,7 +661,13 @@ type 枚举: text文字/ voice语音转文字后发送/ image
NoMemory: true,
}, p.handleSendLike)
s.RegisterStage(sdk.StageBeforeToolcall, p.beforeOwnToolcall, sdk.StageScopeOwnTools)
// 全局权限门:只有 QQ 当前输入需要受此插件约束Bot 所有者始终完整放行。
s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageBeforeToolcall, p.beforeToolcall, sdk.StageScopeGlobal)
// before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉,
// 否则内核会把它误当作“结束整个推理”的最终响应。
s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal)
s.RegisterStage(sdk.StageAfterOutput, p.afterOutputAuthContext, sdk.StageScopeGlobal)
// ---- HTTP server for NapCat webhook ----
mux := http.NewServeMux()
@ -757,8 +825,8 @@ func parseIDList(raw string) []int64 {
return out
}
func (p *Plugin) isAdmin(userID int64) bool {
for _, id := range p.adminIDs {
func (p *Plugin) isOwner(userID int64) bool {
for _, id := range p.ownerIDs {
if id == userID {
return true
}
@ -766,6 +834,261 @@ func (p *Plugin) isAdmin(userID int64) bool {
return false
}
func parseToolAllowlist(raw string) []string {
var patterns []string
if json.Unmarshal([]byte(raw), &patterns) != nil {
return nil
}
out := make([]string, 0, len(patterns))
for _, pattern := range patterns {
if pattern = strings.TrimSpace(pattern); pattern != "" {
out = append(out, pattern)
}
}
return out
}
func parseGroupToolAllowlists(raw string) map[int64][]string {
var encoded map[string][]string
if json.Unmarshal([]byte(raw), &encoded) != nil {
return map[int64][]string{}
}
out := make(map[int64][]string, len(encoded))
for key, patterns := range encoded {
var groupID int64
if key != "*" {
parsed, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64)
if err != nil || parsed <= 0 {
continue
}
groupID = parsed
}
clean := make([]string, 0, len(patterns))
for _, pattern := range patterns {
if pattern = strings.TrimSpace(pattern); pattern != "" {
clean = append(clean, pattern)
}
}
out[groupID] = clean
}
return out
}
func matchesToolAllowlist(name string, patterns []string) bool {
for _, pattern := range patterns {
if pattern == name {
return true
}
if strings.HasSuffix(pattern, "*") && strings.HasPrefix(name, strings.TrimSuffix(pattern, "*")) {
return true
}
}
return false
}
// nonNegativeOrDefault 保留 0表示“不限制”仅把负数纠正为默认值。
func nonNegativeOrDefault(value, fallback int) int {
if value < 0 {
return fallback
}
return value
}
// isHardPrivateTool 是不可由群/私聊白名单覆盖的私人资源边界。
// output_send__qq 及 QQ 当前会话工具在参数级另行约束,不在此处按名称误杀。
func isHardPrivateTool(name string) bool {
for _, prefix := range []string{
"calendar_", "email_", "mail_", "agentmail_", "memory_", "knowledge_",
"device_", "devicectl_", "terminal_", "shell_", "command_", "exec_",
"filesystem_", "agentfs_", "config_", "settings_", "plugin_", "plugins_",
} {
if strings.HasPrefix(name, prefix) {
return true
}
}
return matchesToolAllowlist(name, []string{
"read_file", "write_file", "edit_file", "delete_file", "list_files", "run_command",
"homeagent_config", "homeagent_restart", "output_send__email", "output_send__mail",
})
}
func argInt64(args map[string]interface{}, key string) (int64, bool) {
value, exists := args[key]
if !exists || value == nil {
return 0, false
}
parsed, err := convInt64(value)
return parsed, err == nil && parsed != 0
}
func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}, auth qqAuthContext) (bool, string) {
if !auth.active || auth.owner {
return true, ""
}
currentPeer := auth.userID
if auth.isGroup {
currentPeer = auth.groupID
}
if currentPeer == 0 {
return false, "可信 QQ 会话身份不完整"
}
matchCurrentPeer := func() bool {
groupID, hasGroup := argInt64(args, "group_id")
userID, hasUser := argInt64(args, "user_id")
if auth.isGroup {
return hasGroup && groupID == auth.groupID && !hasUser
}
return hasUser && userID == auth.userID && !hasGroup
}
switch name {
case p.name + "_get_history", p.name + "_mark_read":
if !matchCurrentPeer() {
return false, "只能访问当前 QQ 会话"
}
case p.name + "_get_message":
messageID, ok := argInt64(args, "message_id")
if !ok {
return false, "缺少有效 message_id"
}
if messageID == auth.messageID {
return true, ""
}
peerID, isGroup, _, found := p.lookupMsgRef(messageID)
if !found || isGroup != auth.isGroup || peerID != currentPeer {
return false, "message_id 不属于当前 QQ 会话"
}
case p.name + "_get_group_member_info", p.name + "_get_group_files":
groupID, ok := argInt64(args, "group_id")
if !auth.isGroup || !ok || groupID != auth.groupID {
return false, "只能访问当前 QQ 群的数据"
}
}
return true, ""
}
// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup bool) {
p.authMu.Lock()
defer p.authMu.Unlock()
if p.authByMessageID == nil {
p.authByMessageID = make(map[int64]qqAuthContext)
}
p.authGeneration++
next := qqAuthContext{
active: true, owner: p.isOwner(userID), messageID: messageID, userID: userID,
groupID: groupID, isGroup: isGroup, generation: p.authGeneration,
}
if messageID != 0 {
p.authByMessageID[messageID] = next
if len(p.authByMessageID) > 2048 {
cutoff := p.authGeneration - 1024
for id, auth := range p.authByMessageID {
if auth.generation < cutoff {
delete(p.authByMessageID, id)
}
}
}
}
if !p.auth.active {
p.auth = next
return
}
if p.auth.userID == userID && p.auth.groupID == groupID && p.auth.isGroup == isGroup {
p.auth.owner = p.auth.owner && next.owner
p.auth.messageID = next.messageID
p.auth.generation = next.generation
return
}
// 不同可信来源被内核合并到同一推理时,只在双方都是所有者时保留完整权限。
bothOwners := p.auth.owner && next.owner
sameGroup := p.auth.isGroup && next.isGroup && p.auth.groupID == groupID
p.auth.owner = bothOwners
p.auth.messageID = 0
p.auth.userID = 0
p.auth.isGroup = sameGroup
if sameGroup {
p.auth.groupID = groupID
} else {
p.auth.groupID = 0
}
p.auth.generation = next.generation
}
func messageIDFromInput(raw string) int64 {
match := qqMessageIDRe.FindStringSubmatch(raw)
if len(match) != 2 {
return 0
}
id, _ := strconv.ParseInt(match[1], 10, 64)
return id
}
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
ctx.RLock()
source, _ := ctx.Extra["input_source"].(string)
raw := ctx.RawMessage
ctx.RUnlock()
p.authMu.Lock()
defer p.authMu.Unlock()
if source != p.name {
p.auth = qqAuthContext{}
p.resetTurnGuardLocked()
return nil
}
if messageID := messageIDFromInput(raw); messageID != 0 {
if auth, ok := p.authByMessageID[messageID]; ok {
p.auth = auth
delete(p.authByMessageID, messageID)
p.resetTurnGuardLocked()
return nil
}
}
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份。
p.auth = qqAuthContext{active: true}
p.resetTurnGuardLocked()
return nil
}
func (p *Plugin) resetTurnGuardLocked() {
p.lastDenial = ""
p.denialLocked = false
p.toolCallCount = 0
p.outputCallCount = 0
p.outputSignatures = make(map[string]int)
}
func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
p.authMu.Lock()
p.auth = qqAuthContext{}
p.resetTurnGuardLocked()
p.authMu.Unlock()
return nil
}
func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) {
p.authMu.RLock()
auth := p.auth
var patterns []string
if auth.active && !auth.owner {
if auth.isGroup {
patterns, _ = p.groupToolAllowlists[auth.groupID]
if patterns == nil {
patterns = p.groupToolAllowlists[0]
}
} else {
patterns = p.privateToolAllowlist
}
}
p.authMu.RUnlock()
if !auth.active || auth.owner {
return true, auth
}
return matchesToolAllowlist(name, patterns), auth
}
// isAtBot checks if the message contains an @-mention of the bot.
func (p *Plugin) isAtBot(msg interface{}) bool {
segments, ok := msg.([]interface{})
@ -824,19 +1147,110 @@ func (p *Plugin) isGroupAllowed(groupID int64) bool {
}
}
func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
func (p *Plugin) setDenial(ctx *sdk.StageContext, message string) {
ctx.Response = &message
p.authMu.Lock()
p.lastDenial = message
p.authMu.Unlock()
}
func (p *Plugin) clearPreviousDenial(ctx *sdk.StageContext) {
p.authMu.Lock()
last := p.lastDenial
p.lastDenial = ""
p.authMu.Unlock()
if last != "" && ctx.Response != nil && *ctx.Response == last {
ctx.Response = nil
}
}
func (p *Plugin) clearDeniedResponse(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
p.clearPreviousDenial(ctx)
return nil
}
func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
p.clearPreviousDenial(ctx)
if len(ctx.ToolCalls) == 0 {
return nil
}
tc := &ctx.ToolCalls[0]
allowed, auth := p.currentToolAllowed(tc.Name)
if !auth.active {
return nil
}
p.authMu.Lock()
p.toolCallCount++
toolCount := p.toolCallCount
denialLocked := p.denialLocked
if tc.Name == "output_send__"+p.name {
p.outputCallCount++
signatureBytes, _ := json.Marshal(tc.Arguments)
signature := string(signatureBytes)
p.outputSignatures[signature]++
duplicateCount := p.outputSignatures[signature]
distinctCount := len(p.outputSignatures)
// 循环保险只拦“参数完全相同的重复调用”。参数不同的必需调用一律放行,
// 否则多次 cmd_run / update_schedule / 多条不同消息都会被误杀。
if p.maxDuplicateSend > 0 && duplicateCount > p.maxDuplicateSend {
p.authMu.Unlock()
msg := fmt.Sprintf("QQ 循环保险已阻止重复发送:本轮第 %d 次出现参数完全相同的消息;请勿重复发送同一内容", duplicateCount)
p.setDenial(ctx, msg)
return nil
}
if p.maxQQOutputCalls > 0 && distinctCount > p.maxQQOutputCalls {
p.authMu.Unlock()
msg := fmt.Sprintf("QQ 循环保险已阻止本次发送:单轮主动发送的不同消息数已达上限 %d0=不限制,可在插件配置调整)", p.maxQQOutputCalls)
p.setDenial(ctx, msg)
return nil
}
}
if p.maxQQToolCalls > 0 && toolCount > p.maxQQToolCalls {
p.authMu.Unlock()
msg := fmt.Sprintf("QQ 循环保险已阻止工具调用:单轮工具调用总数已达上限 %d0=不限制,可在插件配置调整)", p.maxQQToolCalls)
p.setDenial(ctx, msg)
return nil
}
p.authMu.Unlock()
if denialLocked && tc.Name != "output_send__"+p.name {
msg := fmt.Sprintf("QQ 权限策略已锁止本轮后续工具 %s仅允许发送一次权限说明", tc.Name)
p.setDenial(ctx, msg)
return nil
}
if !auth.owner && isHardPrivateTool(tc.Name) {
p.authMu.Lock()
p.denialLocked = true
p.authMu.Unlock()
msg := fmt.Sprintf("QQ 权限策略拒绝私人资源工具 %s该限制不能由群聊或私聊白名单覆盖请不要重试", tc.Name)
p.setDenial(ctx, msg)
return nil
}
if !allowed {
scope := "非所有者私聊"
if auth.isGroup {
scope = fmt.Sprintf("群聊 %d", auth.groupID)
}
msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s%s 的工具白名单未包含该工具;请不要重试,改为直接说明权限限制", tc.Name, scope)
p.setDenial(ctx, msg)
return nil
}
if argsAllowed, reason := p.sessionToolArgsAllowed(tc.Name, tc.Arguments, auth); !argsAllowed {
msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s%s请不要改用其他会话 ID 重试", tc.Name, reason)
p.setDenial(ctx, msg)
return nil
}
if tc.Name == p.name+"_group_manage" {
cmd, _ := tc.Arguments["command"].(string)
if requiresConfirmGroupCommand(cmd) {
if ok, _ := tc.Arguments["confirm"].(bool); !ok {
msg := fmt.Sprintf("QQ群管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd)
ctx.Response = &msg
p.setDenial(ctx, msg)
return nil
}
}
@ -846,7 +1260,7 @@ func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
if requiresConfirmFriendCommand(cmd) {
if ok, _ := tc.Arguments["confirm"].(bool); !ok {
msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd)
ctx.Response = &msg
p.setDenial(ctx, msg)
return nil
}
}
@ -978,8 +1392,8 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
} else {
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d, user_id=%d)。先用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(user_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复对方", nickname, evt.MessageID, evt.UserID, tp, evt.MessageID, tp, evt.UserID, tp, outputTool)
}
if p.isAdmin(evt.UserID) {
interrupt = "【重要!老大消息】" + interrupt
if p.isOwner(evt.UserID) {
interrupt = "【重要!Bot 所有者消息】" + interrupt
}
if text != "" {
@ -1012,12 +1426,21 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
}
}
// 必须在注入前记录 OneBot 可信来源;权限判断绝不依赖昵称、正文或模型参数。
p.activateAuthContext(evt.MessageID, evt.UserID, evt.GroupID, evt.MessageType == "group")
if evt.MessageType == "private" {
p.startTyping(evt.UserID)
}
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, p.name, interrupt)
// NoMemoryHTTP 侧来的中断提示,不是对话内容。
// PriorityQQ 消息是**低级别中断**——既不是时钟那样的实时工作,
// 也不是紧急工作,所以声明 L1完全可等
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{
NoMemory: true,
Priority: sdk.PriorityL1,
})
}
w.WriteHeader(http.StatusOK)
}
@ -1320,6 +1743,8 @@ func (p *Plugin) getMsgFromNapcat(msgID int64) (interface{}, error) {
// handleChannelOutput — output_send(channel="qq") 的处理器
// args 包含 payload, type, (可选 meta)
// 成功时只返回极简标记,不回传 NapCat 完整响应——避免"已发送"类富回执喂给模型
// 造成"看到成功→继续发下一条"的回声循环issue: output loop echo
func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
rawType, _ := args["type"].(string)
@ -1352,10 +1777,15 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
p.stopTyping(userID)
}
var sendErr error
switch rawType {
case "text":
text := p.sensitiveFilter(payload)
msg := map[string]interface{}{"message": text}
message := interface{}(text)
if groupID != 0 && userID != 0 {
message = messageWithMention(userID, map[string]interface{}{"type": "text", "data": map[string]interface{}{"text": text}})
}
msg := map[string]interface{}{"message": message}
if groupID != 0 {
msg["group_id"] = groupID
} else {
@ -1365,9 +1795,10 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
msg["reply_to"] = replyTo
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
_, sendErr = p.napcat("send_group_msg", msg)
} else {
_, sendErr = p.napcat("send_private_msg", msg)
}
return p.napcat("send_private_msg", msg)
case "voice", "audio":
text := p.sensitiveFilter(payload)
@ -1387,21 +1818,23 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
os.Remove(audioFile)
uri := fmt.Sprintf("file:///app/files/%s", filepath.Base(dest))
cqMsg := fmt.Sprintf("[CQ:record,file=%s]", uri)
msg := map[string]interface{}{"message": cqMsg}
message := interface{}(cqMsg)
if groupID != 0 && userID != 0 {
message = messageWithMention(userID, map[string]interface{}{"type": "record", "data": map[string]interface{}{"file": uri}})
}
msg := map[string]interface{}{"message": message}
if groupID != 0 {
msg["group_id"] = groupID
} else {
msg["user_id"] = userID
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
_, sendErr = p.napcat("send_group_msg", msg)
} else {
_, sendErr = p.napcat("send_private_msg", msg)
}
return p.napcat("send_private_msg", msg)
case "image", "file":
// 收敛到 output 通道payload 支持本地路径或 http(s) URL。
// 本地路径拷入 NapCat 共享目录转 file:// URI与 voice 分支同模式),
// 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。
uri := payload
if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") &&
!strings.HasPrefix(payload, "file://") {
@ -1423,34 +1856,48 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
if rawType == "image" {
cqTag = "image"
}
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
message := interface{}(fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri))
if groupID != 0 && userID != 0 {
message = messageWithMention(userID, map[string]interface{}{"type": cqTag, "data": map[string]interface{}{"file": uri}})
}
msg := map[string]interface{}{"message": message}
if groupID != 0 {
msg["group_id"] = groupID
} else {
msg["user_id"] = userID
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
_, sendErr = p.napcat("send_group_msg", msg)
} else {
_, sendErr = p.napcat("send_private_msg", msg)
}
return p.napcat("send_private_msg", msg)
default:
return nil, fmt.Errorf("不支持的 type: %s枚举值: text/voice/image/file", rawType)
}
if sendErr != nil {
return nil, sendErr
}
// 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型,
// 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。
return "ok", nil
}
func (p *Plugin) buildOutputHelp() string {
return `【参数】
payload — 消息载荷。type=text时直接填文字type=voice时填文字自动转语音type=image/file时填URL
meta — JSON 元数据,含 group_id群聊或 user_id私聊可选 reply_to
meta — JSON 元数据,含 group_id群聊和/或 user_id私聊或群内@),可选 reply_to
type — text / voice / image / file
【示例】
群聊文字output_send__qq(payload="你好", meta="{\"group_id\":123456789}", type="text")
私聊语音output_send__qq(payload="你好", meta="{\"user_id\":123456789}", type="voice")
群内@用户output_send__qq(payload="你好", meta="{\"group_id\":123456789,\"user_id\":987654321}", type="text")
发送图片output_send__qq(payload="https://example.com/img.jpg", meta="{\"group_id\":123456789}", type="image")
【注意】
- group_id 与 user_id 同时存在时始终发送到 group_id并在消息头 @user_id
- type=text 时 payload 直接是文字,无需 JSON 包裹
- type=voice 时 payload 是文字内容,自动转语音发送
- type=image/file 时 payload 是 URL 或路径`
@ -2088,8 +2535,11 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
p.updateDownloadTask(t, "done", savePath, "")
log.Printf("[qq] 文件下载完成: %s", savePath)
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, p.name,
fmt.Sprintf("文件下载完成: %s保存在 %s", filepath.Base(savePath), savePath))
// NoMemory下载完成的状态通知不是记忆内容。
// Priority同上QQ 侧一律低级别中断L1
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("文件下载完成: %s保存在 %s", filepath.Base(savePath), savePath),
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1})
}
} else {
errMsg = "下载失败,文件可能已过期"
@ -2557,6 +3007,13 @@ func (p *Plugin) handleVideoDownload(args map[string]interface{}) (interface{},
// ======== NapCat HTTP Client ========
func messageWithMention(userID int64, content interface{}) []interface{} {
return []interface{}{
map[string]interface{}{"type": "at", "data": map[string]interface{}{"qq": strconv.FormatInt(userID, 10)}},
content,
}
}
func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) {
data, _ := json.Marshal(params)
url := fmt.Sprintf("%s/%s", p.napcatURL, action)
@ -2675,12 +3132,19 @@ func convInt64(v interface{}) (int64, error) {
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{
name: name,
allowFrom: make(map[int64]struct{}),
groupAllowFrom: make(map[int64]struct{}),
downloadTasks: make([]*DownloadTask, 0),
typingMap: make(map[int64]*typingState),
dmPolicy: "open",
groupPolicy: "open",
name: name,
allowFrom: make(map[int64]struct{}),
groupAllowFrom: make(map[int64]struct{}),
authByMessageID: make(map[int64]qqAuthContext),
outputSignatures: make(map[string]int),
maxQQToolCalls: defaultMaxQQToolCalls,
maxQQOutputCalls: defaultMaxQQOutputCalls,
maxDuplicateSend: defaultMaxDuplicateSend,
groupToolAllowlists: parseGroupToolAllowlists(defaultGroupToolAllowlists),
privateToolAllowlist: parseToolAllowlist(defaultPublicToolAllowlist),
downloadTasks: make([]*DownloadTask, 0),
typingMap: make(map[int64]*typingState),
dmPolicy: "open",
groupPolicy: "open",
}, nil
}

204
example/qq/plugin_test.go Normal file
View File

@ -0,0 +1,204 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func newPermissionTestPlugin(t *testing.T) *Plugin {
t.Helper()
instance, err := NewPluginFactory("qq", nil)
if err != nil {
t.Fatal(err)
}
return instance.(*Plugin)
}
func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext {
return &sdk.StageContext{ToolCalls: []sdk.ToolCall{{Name: name, Arguments: args}}}
}
func TestOwnerBypassesQQPermissionBoundary(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := toolCallContext("calendar_list", nil)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("owner call rejected: %s", *ctx.Response)
}
}
func TestPrivateResourceCannotBeAllowlisted(t *testing.T) {
p := newPermissionTestPlugin(t)
p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*")
p.auth = qqAuthContext{active: true, userID: 10001}
ctx := toolCallContext("calendar_list", nil)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "私人资源工具") {
t.Fatalf("expected private-resource denial, got %#v", ctx.Response)
}
}
func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true}
ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "当前 QQ 会话") {
t.Fatalf("cross-group history not rejected: %#v", ctx.Response)
}
ctx = toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20002)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("current-group history rejected: %s", *ctx.Response)
}
}
func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := &sdk.StageContext{
RawMessage: "来自未知事件(message_id=404)",
Extra: map[string]interface{}{"input_source": "qq"},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if !p.auth.active || p.auth.owner || p.auth.userID != 0 {
t.Fatalf("unmatched input reused prior privilege: %+v", p.auth)
}
}
func TestDuplicateQQOutputIsStopped(t *testing.T) {
p := newPermissionTestPlugin(t)
p.maxDuplicateSend = 1
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`}
ctx := toolCallContext("output_send__qq", args)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("first send rejected: %s", *ctx.Response)
}
ctx = toolCallContext("output_send__qq", args)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "循环保险") {
t.Fatalf("duplicate send not stopped: %#v", ctx.Response)
}
}
func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) {
var path string
var request map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","retcode":0,"data":{"message_id":1}}`))
}))
defer server.Close()
p := newPermissionTestPlugin(t)
p.napcatURL = server.URL
p.httpClient = server.Client()
_, err := p.handleChannelOutput(map[string]interface{}{
"payload": "hello",
"type": "text",
"meta": `{"group_id":20002,"user_id":10001}`,
})
if err != nil {
t.Fatal(err)
}
if path != "/send_group_msg" {
t.Fatalf("path=%q, want /send_group_msg", path)
}
segments, ok := request["message"].([]interface{})
if !ok || len(segments) < 2 {
t.Fatalf("message is not a segment array: %#v", request["message"])
}
mention, _ := segments[0].(map[string]interface{})
data, _ := mention["data"].(map[string]interface{})
if mention["type"] != "at" || data["qq"] != "10001" {
t.Fatalf("leading mention=%#v", mention)
}
}
// 回归:循环保险曾按“总数”拦截,导致参数不同且必需的调用被误杀。
// 现在只拦参数完全相同的重复调用。
func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
// maxDuplicateSend 默认 1同一条消息重复才会被拦不同消息必须全部放行。
for i := 0; i < 5; i++ {
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": fmt.Sprintf("message-%d", i),
"type": "text",
"meta": `{"user_id":123}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("distinct message %d was blocked: %s", i, *ctx.Response)
}
}
}
func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
// 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。
for i := 0; i < 50; i++ {
ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("necessary tool call %d was blocked: %s", i, *ctx.Response)
}
}
}
func TestZeroLimitsMeanUnlimited(t *testing.T) {
p := newPermissionTestPlugin(t)
p.maxQQOutputCalls = 0
p.maxDuplicateSend = 0
p.maxQQToolCalls = 0
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
for i := 0; i < 30; i++ {
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": "same-content",
"type": "text",
"meta": `{"user_id":123}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("0 should mean unlimited, blocked at %d: %s", i, *ctx.Response)
}
}
}

View File

@ -5,7 +5,7 @@ rss plugin
## Build
```bash
plugindev build
hmapdev build
```
## Install

View File

@ -104,6 +104,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.client = &http.Client{Timeout: 30 * time.Second}
// 入站通道:本插件用 "rss" 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel("rss", sdk.ChannelDef{NoMemory: true})
p.fp = gofeed.NewParser()
p.stopCh = make(chan struct{})
p.seenGUIDs = make(map[string]bool)
@ -158,7 +161,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"list", sdk.ToolDef{
Name: tp + "list", Description: "List all subscribed feeds",
Parameters: map[string]interface{}{
"type": "object",
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleList)
@ -167,7 +170,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Name: tp + "check_now", Description: "Manually check all feeds for new articles now",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleCheckNow)
@ -300,7 +303,10 @@ func (p *Plugin) checkFeed(sub FeedSub) {
lines = append(lines, line)
}
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
// 中断注入是「系统通知」NoMemory 写明意图:这类提醒不参与记忆计算,
// 原文仍进上下文(模型当轮看得到)。
p.sdk.InjectInterruptTextOpts("rss", "rss", strings.Join(lines, "\n"),
sdk.InjectOptions{NoMemory: true})
p.saveData()
}
@ -442,7 +448,7 @@ func (p *Plugin) loadData() {
return
}
var data struct {
Feeds []FeedSub `json:"feeds"`
Feeds []FeedSub `json:"feeds"`
SeenGUIDs map[string]bool `json:"seen"`
}
if json.Unmarshal(b, &data) != nil {
@ -460,7 +466,7 @@ func (p *Plugin) saveData() {
p.mu.RLock()
defer p.mu.RUnlock()
data := struct {
Feeds []FeedSub `json:"feeds"`
Feeds []FeedSub `json:"feeds"`
SeenGUIDs map[string]bool `json:"seen"`
}{
Feeds: p.feeds,
@ -485,8 +491,6 @@ func (p *Plugin) cleanupData() {
}
}
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"

81
example/vikunja/README.md Normal file
View File

@ -0,0 +1,81 @@
# Vikunja 插件HomeAgent
把 [Vikunja](https://vikunja.io) 待办/任务管理接入 HomeAgent用自然语言查任务、建任务、改期、完成、看板拖动、指派、评论、时间跟踪、导入数据等。
## 配置项(全部可在插件配置界面修改)
| 键 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `url` | string | `https://vikunja.jianfgit.xyz` | 站点根地址,**不带** `/api` |
| `token` | password(secret) | 空 | **必填**。Vikunja → 设置 → API Tokens 生成(`tk_` 开头)。令牌的权限范围决定本插件能力上限:勾全范围即为完整能力 |
| `api_version` | select | `v2` | `v2`(推荐,标准 REST含时间跟踪等新能力`v1`(用于 v2 暂未提供的端点) |
| `default_project_id` | string | 空 | 新建任务未指定项目时落到这里;留空则必须显式指定 |
| `max_items` | int | `25` | 列表类工具的默认条数,控制上下文体积 |
| `compact_output` | bool | `true` | 任务/项目/标签列表只返回关键字段;关闭则返回 Vikunja 完整对象 |
| `timeout_seconds` | int | `20` | 单次 HTTP 超时 |
| `verify_tls` | bool | `true` | 自签证书站点可关闭(不建议) |
配置在**每次工具调用前重新读取**,因此换了 token 不必重启插件。
## 工具
| 工具 | 能力 |
|---|---|
| `vikunja_status` | 连接/配置自检地址、token 对应的用户、API 版本、服务器能力、CalDAV 地址 |
| `vikunja_tasks` | 列任务按项目、完成状态、截止today/this_week/overdue/no_due、关键词、原生 filter 表达式 |
| `vikunja_task_get` / `task_create` / `task_update` / `task_done` / `task_delete` | 任务增删改查(`task_update` 只传要改的字段) |
| `vikunja_task_bulk` | 批量改完成状态/项目/优先级/截止/标签 |
| `vikunja_task_assignees` / `task_labels` / `task_comments` / `task_relations` / `task_attachments` | 指派、标签、评论、关联(子任务/依赖/相关)、附件(支持上传本地文件) |
| `vikunja_projects` / `project_views` | 项目增删改查、归档;视图与看板桶(把任务移入桶=看板拖动) |
| `vikunja_labels` / `filters` | 标签、保存的筛选器Saved Filter |
| `vikunja_teams` / `sharing` | 团队与成员;项目分享(用户/团队授权、链接分享含密码) |
| `vikunja_notifications` / `subscriptions` / `webhooks` | 通知、订阅、Webhook 管理 |
| `vikunja_time_entries` | 时间跟踪(**仅 v2**):补录/修改/删除、开始与停止计时器 |
| `vikunja_migrate` | 从 TickTick/WeKan/CSV/Planka/Vikunja 文件v2与 Todoist/Trello/微软待办v1导入 |
| `vikunja_user` / `vikunja_admin` | 当前账号设置、登录会话、API Token与实例管理用户增删/提权/停用/改密、项目归属转移,需实例管理员) |
| `vikunja_reactions` | 任务/评论的表情回应 |
| `vikunja_api` | **通用直通**:调任意端点,未封装的能力走这里(可强制指定 v1/v2保证能力无死角 |
## v1 / v2 差异(已按实例自带规范逐条核对)
插件默认 v2并自动处理下列差异
| 操作 | v1 | v2 |
|---|---|---|
| 建任务 | `PUT /projects/{id}/tasks` | `POST /projects/{id}/tasks` |
| 改任务 | `POST /tasks/{id}`(必须整对象 → 插件自动取回-合并-提交) | `PATCH /tasks/{id}`merge-patch只发变更字段被拒则回落取回-合并-PUT |
| 搜索参数 | `?s=` | `?q=` |
| 加标签 | `PUT`Label 对象) | `POST``{"label_id":N}` |
| 批量改 | `POST /tasks/bulk` | `PUT /tasks/bulk` |
| 时间跟踪 | 不支持 | `/time-entries``end_time` 为 null 即计时中;停止用 `/time-entries/timer/stop` |
| 导入 | Todoist / Trello / 微软待办 | TickTick / WeKan / CSV / Planka / Vikunja 文件 |
> 官方路线v1 仍支持但新端点只进 v23.0 弃用、4.0 移除。除“导入”外建议一律用 v2。
## 开发与构建
```bash
cd third_party/homeagent-sdk/example/vikunja
go test -count=1 -race ./... # 16 项测试httptest 打桩,不需要真 token
hmapdev build # 产出 dist/vikunja_bundle.hmap
```
`go.mod` 里的 `replace` 把 SDK 指向仓库内的 `third_party/homeagent-sdk`,因此无需联网拉私有模块。
### 部署到运行实例
`.hmap` 包内是 `plugin.json` + `plugin.bin.<os>.<arch>`,安装时按运行平台重命名入口文件:
```bash
unzip -o dist/vikunja_bundle.hmap -d /home/newqqagent/plugins/vikunja
cd /home/newqqagent/plugins/vikunja && mv plugin.bin.linux.amd64 plugin.bin
# 然后重载插件(或重启 homeagent.service
```
## 已知边界
- **附件下载**未单独封装:`task_attachments` 支持列出/上传/删除,下载请用 `vikunja_api` 访问附件 URL。
- **链接分享的字段**`right`/`password`)按 Vikunja 版本语义透传;如遇 4xx可直接用 `raw` 参数传完整 JSON。
- **批量改标签**的 `fields` 结构以 `BulkTask` 为准,未在真实实例上验证过(缺少可用 token如有偏差请用 `vikunja_api` 直通。
- CalDAV 是客户端协议,插件只提供地址(`vikunja_status` 里的 `caldav_url`),不做 CalDAV 同步。

15
example/vikunja/go.mod Normal file
View File

@ -0,0 +1,15 @@
module vikunja-plugin
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
// 与同目录其它示例一致SDK 指向仓库内的 vendored 副本
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0

12
example/vikunja/plg.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "vikunja",
"name_zh": "Vikunja 待办",
"name_en": "Vikunja",
"version": "1.0.1",
"description": "Vikunja 待办/任务管理任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理并附通用 API 直通工具兜底",
"author": "HomeAgent",
"entry": "plugin.bin",
"sdk": "1.2.0",
"tags": ["vikunja", "todo", "task", "gtd", "productivity"],
"targets": "linux/amd64"
}

2617
example/vikunja/plugin.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,523 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// newTestPlugin 构造一个不依赖 sdk 的插件实例,指向 httptest 服务。
// ensure() 在 sdk==nil 时会保留已设置的字段,因此可以这样直接测处理器。
func newTestPlugin(t *testing.T, h http.HandlerFunc) (*Plugin, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
p := &Plugin{
name: "vikunja",
baseURL: srv.URL,
token: "tk_test",
apiVer: "v2",
maxItems: 5,
compact: true,
http: srv.Client(),
}
return p, srv
}
func mustJSON(t *testing.T, v interface{}) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
// 1) 列表v2 用 q= 搜索,且 filter 会带上默认 done 条件
func TestTasksListV2(t *testing.T) {
var gotQuery string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
if r.Header.Get("Authorization") != "Bearer tk_test" {
t.Errorf("缺少 Bearer 头: %q", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":1,"title":"写周报","done":false,"project_id":3,"due_date":"2026-09-13T10:00:00Z","labels":[{"title":"工作"}],"assignees":[{"username":"jianf"}]}]`))
})
res, err := p.handleTasksList(map[string]interface{}{"search": "周报", "limit": float64(5)})
if err != nil {
t.Fatalf("err: %v", err)
}
if !strings.Contains(gotQuery, "q=%E5%91%A8%E6%8A%A5") {
t.Errorf("v2 应使用 q= 搜索,实际 query=%s", gotQuery)
}
if strings.Contains(gotQuery, "s=") {
t.Errorf("v2 不应使用 s=,实际 query=%s", gotQuery)
}
if !strings.Contains(gotQuery, "per_page=5") {
t.Errorf("per_page 未生效: %s", gotQuery)
}
if !strings.Contains(gotQuery, "filter=done+%3D+false") && !strings.Contains(gotQuery, "filter=done%20%3D%20false") {
t.Errorf("默认应过滤未完成,实际 filter 片段: %s", gotQuery)
}
m, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("结果应为 map实际 %T", res)
}
if m["count"].(int) != 1 {
t.Errorf("count 应为 1实际 %v", m["count"])
}
tasks := m["tasks"].([]interface{})
tk := tasks[0].(map[string]interface{})
if _, ok := tk["labels"].([]string); !ok {
t.Errorf("标签应被投影成名称数组,实际 %T", tk["labels"])
}
if _, ok := tk["description"]; ok {
t.Errorf("精简输出不应出现 description")
}
}
// 2) 列表v1 用 s= 搜索
func TestTasksListV1SearchParam(t *testing.T) {
var gotQuery string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = w.Write([]byte(`[]`))
})
p.apiVer = "v1"
if _, err := p.handleTasksList(map[string]interface{}{"search": "abc"}); err != nil {
t.Fatalf("err: %v", err)
}
if !strings.Contains(gotQuery, "s=abc") {
t.Errorf("v1 应使用 s= 搜索,实际 %s", gotQuery)
}
if strings.Contains(gotQuery, "q=") {
t.Errorf("v1 不应出现 q=,实际 %s", gotQuery)
}
}
// 3) 建任务v1=PUT、v2=POST同路径方法不同
func TestTaskCreateMethodByVersion(t *testing.T) {
for _, tc := range []struct {
ver string
method string
}{
{"v1", http.MethodPut},
{"v2", http.MethodPost},
} {
var gotMethod, gotPath string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath = r.Method, r.URL.Path
_, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`))
})
p.apiVer = tc.ver
if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": "3", "title": "买菜"}); err != nil {
t.Fatalf("[%s] err: %v", tc.ver, err)
}
if gotMethod != tc.method {
t.Errorf("[%s] 期望 %s实际 %s", tc.ver, tc.method, gotMethod)
}
if gotPath != "/api/"+tc.ver+"/projects/3/tasks" {
t.Errorf("[%s] 路径错误: %s", tc.ver, gotPath)
}
}
}
// 4) 改任务v2走 merge-patch只发变更字段
func TestTaskUpdateV2MergePatch(t *testing.T) {
var method, ctype string
var body map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
method = r.Method
ctype = r.Header.Get("Content-Type")
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"id":7,"done":true}`))
})
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "done": true}); err != nil {
t.Fatalf("err: %v", err)
}
if method != http.MethodPatch {
t.Errorf("v2 应用 PATCH实际 %s", method)
}
if !strings.Contains(ctype, "merge-patch") {
t.Errorf("应使用 merge-patch 内容类型,实际 %s", ctype)
}
if len(body) != 1 || body["done"] != true {
t.Errorf("只应发送变更字段,实际 %v", body)
}
}
// 5) 改任务v2回退merge-patch 被拒 → 取回-合并-PUT
func TestTaskUpdateV2FallbackToMergePut(t *testing.T) {
var calls []string
var putBody map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch {
case r.Method == http.MethodPatch:
w.WriteHeader(http.StatusUnsupportedMediaType)
_, _ = w.Write([]byte(`{"code":9,"message":"unsupported media type"}`))
case r.Method == http.MethodGet:
_, _ = w.Write([]byte(`{"id":7,"title":"旧标题","done":false,"priority":1}`))
case r.Method == http.MethodPut:
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &putBody)
_, _ = w.Write([]byte(`{"id":7,"title":"新标题","done":false,"priority":1}`))
default:
t.Errorf("意外请求: %s %s", r.Method, r.URL.Path)
}
})
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "title": "新标题"}); err != nil {
t.Fatalf("err: %v", err)
}
want := []string{"PATCH /api/v2/tasks/7", "GET /api/v2/tasks/7", "PUT /api/v2/tasks/7"}
if len(calls) != len(want) {
t.Fatalf("调用序列不符: %v", calls)
}
for i := range want {
if calls[i] != want[i] {
t.Errorf("第 %d 步期望 %s实际 %s", i+1, want[i], calls[i])
}
}
if putBody["title"] != "新标题" {
t.Errorf("合并后的 body 应含新标题,实际 %v", putBody)
}
if putBody["priority"] != float64(1) {
t.Errorf("合并必须保留原有字段priority实际 %v", putBody)
}
}
// 6) 改任务v1没有 merge-patch必须取回-合并-POST
func TestTaskUpdateV1FetchMergePost(t *testing.T) {
var calls []string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"id":9,"title":"旧","priority":2}`))
return
}
_, _ = w.Write([]byte(`{"id":9,"title":"新","priority":2}`))
})
p.apiVer = "v1"
if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "9", "title": "新"}); err != nil {
t.Fatalf("err: %v", err)
}
want := []string{"GET /api/v1/tasks/9", "POST /api/v1/tasks/9"}
if len(calls) != 2 || calls[0] != want[0] || calls[1] != want[1] {
t.Fatalf("v1 应为 GET→POST实际 %v", calls)
}
}
// 7) 错误映射401 提示检查 token
func TestErrorHint401(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":11,"message":"invalid token"}`))
})
_, err := p.handleTasksList(map[string]interface{}{})
if err == nil {
t.Fatal("应返回错误")
}
msg := err.Error()
if !strings.Contains(msg, "401") || !strings.Contains(msg, "code=11") {
t.Errorf("错误信息应含状态码与 Vikunja code实际 %s", msg)
}
if !strings.Contains(msg, "token") {
t.Errorf("401 应给出 token 提示,实际 %s", msg)
}
}
// 8) 未配置 token 时应给出可操作提示,而不是发出无凭据请求
func TestMissingToken(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("未配置 token 时不应发请求")
})
p.token = ""
_, err := p.handleTasksList(map[string]interface{}{})
if err == nil || !strings.Contains(err.Error(), "vikunja.token") {
t.Fatalf("应提示配置项名,实际 %v", err)
}
}
// 9) 导入Todoist 必须走 v1即使插件默认是 v2
func TestMigrateUsesV1ForTodoist(t *testing.T) {
var path string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
_, _ = w.Write([]byte(`{"ok":true}`))
})
if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "todoist", "code": "abc"}); err != nil {
t.Fatalf("err: %v", err)
}
if path != "/api/v1/migration/todoist/migrate" {
t.Errorf("Todoist 导入必须走 v1实际 %s", path)
}
}
// 10) 导入WeKan 走 v2
func TestMigrateUsesV2ForWekan(t *testing.T) {
var path, method string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
path, method = r.URL.Path, r.Method
_, _ = w.Write([]byte(`{"ok":true}`))
})
if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "wekan"}); err != nil {
t.Fatalf("err: %v", err)
}
if path != "/api/v2/migration/wekan/migrate" || method != http.MethodPost {
t.Errorf("WeKan 应走 v2 POST实际 %s %s", method, path)
}
}
// 11) 时间跟踪:秒数换算成 end_time计时开始则不带 end_time
func TestTimeEntrySecondsBecomesEndTime(t *testing.T) {
var body map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"id":1}`))
})
start := "2026-09-12T10:00:00+08:00"
if _, err := p.handleTimeEntries(map[string]interface{}{
"action": "create", "task_id": "5", "seconds": float64(600),
"start_time": start,
}); err != nil {
t.Fatalf("err: %v", err)
}
// 判据不写死字符串:按时区无关的方式比较两个时间点
sStart, err := time.Parse(time.RFC3339, start)
if err != nil {
t.Fatalf("case 自身时间写错: %v", err)
}
gotEnd, ok := body["end_time"].(string)
if !ok {
t.Fatalf("应有 end_time实际 %v", body["end_time"])
}
tEnd, err := time.Parse(time.RFC3339, gotEnd)
if err != nil {
t.Fatalf("end_time 不是 RFC3339: %q", gotEnd)
}
if diff := tEnd.Sub(sStart); diff != 10*time.Minute {
t.Errorf("end_time 应由 start_time+600s 推出,实际差值 %v", diff)
}
if _, ok := body["seconds"]; ok {
t.Errorf("TimeEntry 没有 seconds 字段,不应发送:%v", body)
}
body = nil
if _, err := p.handleTimeEntries(map[string]interface{}{"action": "timer_start", "task_id": "5"}); err != nil {
t.Fatalf("err: %v", err)
}
v, present := body["end_time"]
if !present || v != nil {
t.Errorf("计时开始应显式 end_time=nulllive timer实际 %v", body)
}
}
// 12) 时间跟踪在 v1 下应给出明确不可用提示
func TestTimeEntryUnavailableOnV1(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
p.apiVer = "v1"
_, err := p.handleTimeEntries(map[string]interface{}{"action": "list"})
if err == nil || !strings.Contains(err.Error(), "v2") {
t.Fatalf("v1 下应提示改用 v2实际 %v", err)
}
}
// 13) 标签v1 收 Label 对象、v2 收 label_id
func TestLabelBodyByVersion(t *testing.T) {
var body map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{}`))
})
if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil {
t.Fatalf("err: %v", err)
}
if body["label_id"] != float64(5) {
t.Errorf("v2 应发送 label_id实际 %v", body)
}
body = nil
p.apiVer = "v1"
if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil {
t.Fatalf("err: %v", err)
}
if body["id"] != float64(5) {
t.Errorf("v1 应发送 Label 对象(id),实际 %v", body)
}
}
// 14) 时间字符串容忍today / +3d / ISO
func TestNormalizeTime(t *testing.T) {
for _, in := range []string{"today", "tomorrow", "+3d", "2026-09-12 18:00", "2026-09-12T18:00:00+08:00"} {
got := normalizeTime(in)
s, ok := got.(string)
if !ok {
t.Fatalf("%s: 期望字符串,实际 %T", in, got)
}
if _, err := time.Parse(time.RFC3339, s); err != nil {
t.Errorf("%s → %s 不是 RFC3339: %v", in, s, err)
}
}
}
// 15) 通用直通:可指定 api_versionmethod 大小写不敏感
func TestRawAPI(t *testing.T) {
var method, path string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
method, path = r.Method, r.URL.Path
_, _ = w.Write([]byte(`[]`))
})
if _, err := p.handleRawAPI(map[string]interface{}{"method": "get", "path": "projects", "api_version": "v1"}); err != nil {
t.Fatalf("err: %v", err)
}
if method != http.MethodGet || path != "/api/v1/projects" {
t.Errorf("直通参数未生效: %s %s", method, path)
}
}
// 16) 精简输出可关闭(关闭时返回原样)
func TestCompactToggle(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"id":1,"title":"t","description":"很长的描述","done":false}]`))
})
p.compact = false
res, err := p.handleTasksList(map[string]interface{}{})
if err != nil {
t.Fatalf("err: %v", err)
}
arr, ok := res.([]interface{})
if !ok {
t.Fatalf("关闭精简后应原样返回数组,实际 %T", res)
}
if _, ok := arr[0].(map[string]interface{})["description"]; !ok {
t.Errorf("关闭精简后应保留 description")
}
}
// ── 回归JSON body 里的 ID 必须是数字(线上实测的 422 缺口)────────────
//
// vikunja v2.6.0 实测2026-09-12
// {"project_id":"1"} → 422 expected integer at body.project_id
// {"user_id":"1"} → 422 expected integer at body.user_id
// {"username":"jianf"} → 422 unexpected property at body.username
// 旧实现把 argID() 的字符串直接塞进 bodyassignee 还额外带 username
// 于是「建任务」「指派」在 v2 下必定失败 —— 只有真调用才暴露,单测没盖到。
func TestTaskCreateSendsNumericProjectID(t *testing.T) {
var body map[string]interface{}
var raw []byte
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
raw, _ = io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`))
})
// project_id 传 float64 —— 这正是 SDK 从 JSON 解出来的真实类型
if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": float64(3), "title": "买菜"}); err != nil {
t.Fatalf("err: %v", err)
}
if _, ok := body["project_id"].(float64); !ok {
t.Errorf("project_id 必须是 JSON 数字,实际 %T=%v", body["project_id"], body["project_id"])
}
if strings.Contains(string(raw), `"project_id":"`) {
t.Errorf("出现字符串型 project_idv2 会 422 expected integer: %s", raw)
}
}
func TestAssigneeAddResolvesUsernameToNumericUserID(t *testing.T) {
var body map[string]interface{}
var raw []byte
var calls []string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch r.URL.Path {
case "/api/v2/users":
if r.URL.Query().Get("q") != "alice" {
t.Errorf("v2 用户搜索应用 q=,实际 query=%q", r.URL.RawQuery)
}
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"},{"id":9,"username":"alice2"}]`))
case "/api/v2/tasks/1/assignees":
raw, _ = io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"user_id":7}`))
default:
t.Errorf("意外请求: %s %s", r.Method, r.URL.Path)
}
})
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"}); err != nil {
t.Fatalf("err: %v", err)
}
if len(calls) != 2 {
t.Fatalf("应先查用户再指派,实际调用: %v", calls)
}
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
t.Errorf("user_id 必须是数字 7实际 %T=%v", body["user_id"], body["user_id"])
}
if _, ok := body["username"]; ok {
t.Errorf("v2 不接受 username 字段422 unexpected property: %s", raw)
}
}
func TestAssigneeAddNumericUserSkipsLookup(t *testing.T) {
var calls []string
var body map[string]interface{}
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
if r.URL.Path == "/api/v2/users" {
t.Errorf("传数字 ID 时不该再查用户表")
}
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"user_id":7}`))
})
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "7"}); err != nil {
t.Fatalf("err: %v", err)
}
if len(calls) != 1 {
t.Errorf("应只有一次请求,实际: %v", calls)
}
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
t.Errorf("user_id 应为数字 7实际 %T=%v", body["user_id"], body["user_id"])
}
}
func TestAssigneeRemoveUsesResolvedNumericPath(t *testing.T) {
var gotPath string
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/users":
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"}]`))
default:
gotPath = r.Method + " " + r.URL.Path
w.WriteHeader(http.StatusNoContent)
}
})
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "remove", "user": "alice"}); err != nil {
t.Fatalf("err: %v", err)
}
if gotPath != "DELETE /api/v2/tasks/1/assignees/7" {
t.Errorf("移除应用解析出的数字 ID实际 %q", gotPath)
}
}
func TestAssigneeAddUnknownUserGivesReadableError(t *testing.T) {
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"id":7,"username":"bob"}]`))
})
_, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"})
if err == nil {
t.Fatal("找不到用户时必须报错,而不是发出一个注定 422 的请求")
}
if !strings.Contains(err.Error(), "找不到用户") || !strings.Contains(err.Error(), "bob") {
t.Errorf("错误信息应说明找不到并给出相近候选: %v", err)
}
}

View File

@ -5,7 +5,7 @@ weather plugin
## Build
```bash
plugindev build
hmapdev build
```
## Install

View File

@ -6,10 +6,51 @@ var (
// Version 是 HomeAgent SDK 版本号。
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
//
// 版本号语义:**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 依赖(见 核心仓 docs/git-branching.md §七)。
//
// 1.0.0:插件运行模型从 C ABI 动态库改为子进程 + 共享内存。
// 公开 SDK 接口sdk/ 目录)**零改动**——插件业务代码不需要改一行,
// 但产物形态变了plugin.so → plugin.bin必须用新版 plugindev 重编。
Version = "1.0.0"
// 公开 SDK 接口零改动但产物形态变了plugin.so → plugin.bin
// 1.1.0:多模态贯通插件边界。**全部是新增,无签名变更**
// - Triple.SentenceText / Triple.MediaDigests
// - Doc.MediaDigests / Doc.Attachments、MediaAttachment
// - TextEvent.Attachments
// - DocMemoryAPI.InsertWithMedia
// - IOInjector 的 InjectInputMedia / InjectInputMediaSync /
// InjectInterruptMediaPluginSDK 补上缺失的 SetToolBlocks 包装
// 同版修掉两处并发竞态sdk/stress_test.go 的 -race 实证,不是理论风险):
// PluginSDK 的 API 字段与 autoRestart 标志此前无锁,而写方
// (内核注入 API、插件 SetAutoRestart与读方插件后台 goroutine
// 注入、内核 registry 读 AutoRestart天然跨 goroutine。
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**
// 不调就不受影响。想用新字段的插件重编即可。
//
// 1.2.0:注入行为的记忆/裁剪标志位。**全部是新增,无签名变更**
// - InjectOptions{NoMemory, ContextPolicy}
// - IOInjector 的六个 *Opts 变体(排队/中断/同步/带媒体各一对)
// - ChannelDef.ContextPolicy顺带给 ChannelDef 补上 JSON tag
// 它要跨进程传给内核,而 Cleaner 是函数必须忽略;无 tag 时只能
// 手写字段白名单,新增字段会被静默丢掉)
// 语义:零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 +
// 不裁剪),因此存量插件不需要改一行也不需要重编。
// 裁剪ContextPolicy=prune必须显式声明——它会归档丢弃低相关事件。
//
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
// release/vX.Y.x 分支与 tag见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
//
// 现为 1.2.0:核心的 1.2.x 线正在发布中release/v1.2.x 承载 1.2.0
// 但 **SDK 不跟 beta 发版**(§七.2——SDK 1.2.0 的定版与 tag 随核心的
// **正式** tag 一起做(§七.3)。在那之前 1.2.0 仍是 SDK 尚未发布的中版本,
// 所以 main 就停在 1.2.0。
//
// 注意:这里与核心 main **故意不对称**。核心一旦切出 release/v1.2.x
// 1.2.0 就归发布线所有main 立刻推进到 1.3.0;而 SDK 因为要等正式 tag
// 它的 main 在 v1.2.0 打出来之前不得越过 1.2.0。
// (曾误按 §七.4 把这里推到 1.3.0,等于宣称 1.2.0 已发布。)
Version = "1.3.0"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
@ -20,13 +61,22 @@ var (
// SDKName 是 SDK 名称。
SDKName = "HomeAgent SDK"
// CoreModule 是核心仓的 Go module pathplugindev 生成 go.mod 时使用。
// CoreModule 是核心仓的 Go module pathhmapdev 生成 go.mod 时使用。
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
// CoreVersion 是此 SDK 所兼容的最低核心版本。
//
// 1.0.0 是硬下限而非建议值0.9.x 内核只会 dlopen `.so`
// 本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别。
//
// ⚠️ 1.1.0 新增的媒体接口需要核心 **1.1.1+**(更早的核心没有
// doc.insertWithMedia / io.injectMedia* 这些 RPC调用会返回 unknown method
// 这里仍写 1.0.0因为它是「SDK 能在其上运行」的下限;
// 媒体接口是可选能力,不用就不受影响。
//
// ⚠️ 1.2.0 新增的注入标志位同理需要核心 **1.2.0+**:内核在 1.2.0 之前会
// 忽略注入参数里的 no_memory/context_policy 字段(不会报错,但不生效)。
// 想用这些标志位的插件应当要求核心 1.2.0+;不用就不受影响。
CoreVersion = "1.0.0"
)

150
package/build-examples.sh Normal file
View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
# 给 SDK 发版打包**示例插件**的 .hmap 产物。
#
# 为什么要在 SDK 仓库里发示例插件的 hmap
# 插件二进制与内核是**协议绑定**的internal/plugin/proc/protocol.go 的
# ProtocolVersion + 统一共享内存区魔数。SDK 升版往往同时意味着协议变化,
# 而示例插件qq/memo/browser/…)是使用者最常直接安装的东西。
# 如果 SDK 只发工具链不发示例产物,使用者要么自己重编、要么用到与本版 SDK
# 不匹配的旧产物——后者的表现是握手失败(协议/魔数不匹配),而且看起来像
# 「插件坏了」而不是「版本不配套」。
#
# 用法:
# package/build-examples.sh [TARGET] [OUT_DIR]
# TARGET native(默认) | linux/amd64 | linux/arm64 | darwin/amd64 | darwin/arm64 | windows/amd64 | all
# OUT_DIR 产物目录(默认 build/examples
#
# 产物:
# <OUT_DIR>/<name>_<goos>_<goarch>.hmap 每个示例插件一份
# <OUT_DIR>/SHA256SUMS 全部产物齐全**之后**才计算
# <OUT_DIR>/MANIFEST.txt 版本、协议版本、产自哪个 commit
#
# 纪律(与本项目其它构建脚本一致):
# 1. 判成功看**产物是否存在**不看退出码——hmapdev 对部分错误只打印不退出。
# 2. SHA256SUMS 必须在全部产物生成完毕后一次算完,边打边算会漏掉后生成的包。
set -uo pipefail
SDK_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TARGET="${1:-native}"
OUT_DIR="${2:-$SDK_ROOT/build/examples}"
GO="${GO:-$(command -v go 2>/dev/null || echo go)}"
case "$TARGET" in
native) GOOS=""; GOARCH="" ;;
linux/amd64) GOOS=linux; GOARCH=amd64 ;;
linux/arm64) GOOS=linux; GOARCH=arm64 ;;
darwin/amd64) GOOS=darwin; GOARCH=amd64 ;;
darwin/arm64) GOOS=darwin; GOARCH=arm64 ;;
windows/amd64)
# 明确拒绝,而不是让调用方拿到一句深层 Go 编译错误。
# 协议 2 的统一共享内存区只移植到了 Unix内核 internal/plugin/proc/
# shmpass_windows.go 仍是旧的 SHM_STAGE/SHM_EVTRING 两段布局,
# 插件模板 proc_shm_windows.go 也缺 attachUnifiedShm。
echo "windows 目标暂不支持:协议 2 的统一共享内存区未移植到 Windows内核与插件模板均缺实现。" >&2
exit 1
;;
all)
echo "本脚本一次只构建一个平台;请由 package/build.sh 传入具体目标。" >&2
exit 1
;;
*)
echo "Unknown target: $TARGET" >&2
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [OUT_DIR]" >&2
exit 1
;;
esac
export CGO_ENABLED=0
# 按平台逐个构建,**不用** bundle 模式:
# - bundle 会连 windows 一起编,而协议 2 的统一共享区尚未移植到 Windows
# (内核 shmpass_windows.go 仍是旧的两段布局),必然失败;
# - 逐平台构建每个目标都产出一份 .hmap正是发版要附的产物。
# 平台名解析成本脚本后面用(校验和与 MANIFEST 都要写清楚是哪个平台)。
if [ -z "${GOOS:-}" ]; then
GOOS="$(go env GOOS)"; GOARCH="$(go env GOARCH)"
fi
# 1) 先保证工具链可用:示例必须用**本仓当前源码**构建,否则产物协议与这一版 SDK 不符。
# 允许外部指定(发版脚本会在跨平台构建后把刚产出的工具链路径传进来)。
# 工具链二进制名由 plugindev 改为 hmapdev旧变量名 PLUGINDEV 仍兼容。
HMAPDEV="${HMAPDEV:-${PLUGINDEV:-$SDK_ROOT/build/hmapdev}}"
if [ ! -x "$PLUGINDEV" ]; then
echo "[examples] 先构建 hmapdev ..."
( cd "$SDK_ROOT/tools/hmapdev" && "$GO" build -o "$HMAPDEV" . ) || {
echo "[examples] hmapdev 构建失败,无法继续" >&2; exit 1; }
fi
if [ ! -x "$PLUGINDEV" ]; then
echo "[examples] hmapdev 不存在或不可执行:$HMAPDEV" >&2
exit 1
fi
echo "=== 协议 ==="
echo " ProtocolVersion = $(grep -m1 '^const ProtocolVersion' "$SDK_ROOT/../internal/plugin/proc/protocol.go" 2>/dev/null | grep -oE '[0-9]+' || echo '?(本仓非内核仓,跳过)')"
mkdir -p "$OUT_DIR"
# 清掉上一次的校验和:残留的 SHA256SUMS 会掩盖本次缺产物。
rm -f "$OUT_DIR"/SHA256SUMS "$OUT_DIR"/MANIFEST.txt
ok=0
fail=0
failed_names=""
for dir in "$SDK_ROOT"/example/*/; do
[ -f "$dir/plugin.go" ] || continue
name="$(basename "$dir")"
# 清掉旧产物:残留会让人(和本脚本)误判成功。
rm -rf "$dir/build" "$dir/dist"
out=$( cd "$dir" && "$PLUGINDEV" build --no-bundle --target "$GOOS/$GOARCH" 2>&1 )
rc=$?
# 判据是**退出码 + 产物存在**,两者都要。
# 只看退出码hmapdev 曾经出错也退 0已修但脚本不该依赖它「现在」是对的
# 只看产物:部分平台失败时会留下上一次的产物,看起来像成功。
hmap="$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1)"
if [ $rc -eq 0 ] && [ -n "$hmap" ]; then
# 保留插件自己声明的产物名(它用的是 plg.json 的 name_en是插件的身份
# 只在前面加平台前缀避免多平台互相覆盖。
dest="$OUT_DIR/${GOOS}_${GOARCH}_$(basename "$hmap")"
cp "$hmap" "$dest"
printf "✓ %-14s → %s (%s)\n" "$name" "$(basename "$dest")" "$(du -h "$dest" | cut -f1)"
ok=$((ok + 1))
else
printf "✗ %-14s 构建失败 (rc=%d)\n" "$name" "$rc"
echo "$out" | tail -6 | sed 's/^/ /'
fail=$((fail + 1))
failed_names="$failed_names $name"
fi
done
echo
echo "示例产物: 成功 $ok / 失败 $fail"
[ -n "$failed_names" ] && echo "失败:$failed_names"
# 有失败就不算发版闭环:宁可整个中断,也不要发出「少几个插件」的包。
if [ $fail -ne 0 ]; then
echo "[examples] 有示例构建失败,不生成 SHA256SUMS" >&2
exit 1
fi
# 2) 全部产物齐了才算校验和。
( cd "$OUT_DIR" && sha256sum ./*.hmap > SHA256SUMS )
VERSION="${VERSION:-$(git -C "$SDK_ROOT" describe --tags --dirty 2>/dev/null || echo unknown)}"
COMMIT="${COMMIT:-$(git -C "$SDK_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)}"
{
echo "sdk_version: $VERSION"
echo "sdk_commit: $COMMIT"
echo "target: $GOOS/$GOARCH"
echo "plugins: $ok"
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo
echo "这些 .hmap 与本版 SDK 的插件协议绑定,必须与同版本内核配套安装。"
echo "校验sha256sum -c SHA256SUMS"
} > "$OUT_DIR/MANIFEST.txt"
echo "[examples] 产物: $OUT_DIR"
echo "[examples] 清单: $OUT_DIR/MANIFEST.txt"
echo "[examples] 校验: $OUT_DIR/SHA256SUMS"

View File

@ -5,6 +5,13 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="${PROJECT_ROOT}/build"
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.7.1")}"
GO="${GO:-$(command -v go 2>/dev/null || echo "/home/jianf/go1.26.5/go/bin/go")}"
# 宿主平台必须在**本脚本 export GOOS/GOARCH 之前**取定。
# 否则 `go env GOOS` 会返回被 export 的目标平台(此前 `build.sh all all`
# 就是因此拿 darwin 二进制在 linux 上跑,报 cannot execute binary file
NATIVE_GOOS="$(env -u GOOS -u GOARCH "$GO" env GOOS 2>/dev/null || uname -s | tr 'A-Z' 'a-z')"
NATIVE_GOARCH="$(env -u GOOS -u GOARCH "$GO" env GOARCH 2>/dev/null || uname -m)"
case "$NATIVE_GOARCH" in x86_64|amd64) NATIVE_GOARCH="amd64" ;; aarch64|arm64) NATIVE_GOARCH="arm64" ;; esac
case "$NATIVE_GOOS" in darwin|linux|windows) ;; *) NATIVE_GOOS="linux" ;; esac
GOCACHE="${GOCACHE:-}"
GOPATH="${GOPATH:-}"
@ -28,7 +35,7 @@ case "$TARGET" in
;;
*)
echo "Unknown target: $TARGET"
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|plugindev]"
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|hmapdev|examples]"
exit 1
esac
@ -42,12 +49,12 @@ export CGO_ENABLED=0
mkdir -p "$BUILD_DIR"
build_plugindev() {
local src="tools/plugindev"
local out="$BUILD_DIR/plugindev${SUFFIX:+_$SUFFIX}"
build_hmapdev() {
local src="tools/hmapdev"
local out="$BUILD_DIR/hmapdev${SUFFIX:+_$SUFFIX}"
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
echo "[BUILD] plugindev ${GOOS:-linux}/${GOARCH:-amd64}$out"
echo "[BUILD] hmapdev ${GOOS:-linux}/${GOARCH:-amd64}$out"
cd "$PROJECT_ROOT/$src"
"$GO" build -trimpath -ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=${VERSION}" \
-o "$out" .
@ -55,9 +62,47 @@ build_plugindev() {
cd "$PROJECT_ROOT"
}
# 示例插件产物随 SDK 一起发。
#
# 为什么必须发:插件二进制与内核是**协议绑定**的ProtocolVersion + 统一共享
# 内存区魔数。SDK 升版常伴随协议变化,只发工具链不发示例产物,使用者很可能
# 拿旧产物去装,表现是握手失败(魔数不匹配)——看起来像「插件坏了」而不是
# 「版本不配套」。
#
# 用**宿主可执行**的那把工具链(而非 PATH 里的),保证产物与本次发版同源。
#
# 为什么不能用目标平台的那把:示例的跨平台构建是由 hmapdev 的 `--target GOOS/GOARCH`
# 完成的,被执行的进程本身必須能在当前机器上跑。拿目标平台的二进制去跑只会得到
# “cannot execute binary file: Exec format error”`build.sh all all` 在 darwin 处断过)。
build_examples() {
local dev
dev="$BUILD_DIR/hmapdev_${NATIVE_GOOS}_${NATIVE_GOARCH}"
[ "$NATIVE_GOOS" = "windows" ] && dev="${dev}.exe"
# 宿主工具链缺失时先补建(`all` 的第一个目标可能不是宿主平台)。
if [ ! -x "$dev" ]; then
echo "[BUILD] 先补建宿主工具链 ${NATIVE_GOOS}/${NATIVE_GOARCH}(示例的跨平台由 --target 完成)"
( unset GOOS GOARCH; bash "$0" "${NATIVE_GOOS}/${NATIVE_GOARCH}" hmapdev ) || return 1
fi
if [ ! -x "$dev" ]; then
echo "[BUILD] 无法构建示例:缺少宿主可执行的工具链 $dev" >&2
echo " 先跑: $0 ${NATIVE_GOOS}/${NATIVE_GOARCH} hmapdev" >&2
return 1
fi
echo "[BUILD] example plugins ${GOOS:-linux}/${GOARCH:-amd64}$BUILD_DIR/examples${NATIVE_GOOS}/${NATIVE_GOARCH} 的工具链交叉构建)"
PLUGINDEV="$dev" VERSION="$VERSION" bash "$PROJECT_ROOT/package/build-examples.sh" "$TARGET" "$BUILD_DIR/examples"
echo " OK"
}
case "$COMPONENT" in
all|plugindev) build_plugindev ;;
all)
# 工具链必须先建完:示例用它来构建(同源保证协议一致)。
build_hmapdev
build_examples
;;
hmapdev) build_hmapdev ;;
examples) build_examples ;;
*)
echo "Unknown component: $COMPONENT"
exit 1
;;
esac

View File

@ -48,7 +48,7 @@ Function pageConfirm
${EndIf}
${NSD_CreateLabel} 0 5u 100% 12u "将安装以下组件:"
Pop $0
${NSD_CreateLabel} 15u 20u 100% 12u "plugindev.exe — 插件开发工具"
${NSD_CreateLabel} 15u 20u 100% 12u "hmapdev.exe — 插件开发工具"
Pop $0
${NSD_CreateLabel} 15u 35u 100% 12u "• SDK ${SDK_VERSION} — 将从远程仓库自动下载"
Pop $0
@ -64,11 +64,11 @@ Section "Install" SEC_INSTALL
SetOutPath "$INSTDIR"
DetailPrint "复制工具链文件..."
File "plugindev.exe"
File "hmapdev.exe"
DetailPrint "创建快捷方式..."
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk" "$INSTDIR\plugindev.exe" "" "$INSTDIR\plugindev.exe" 0
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\hmapdev.lnk" "$INSTDIR\hmapdev.exe" "" "$INSTDIR\hmapdev.exe" 0
DetailPrint "配置环境变量..."
; Add to system PATH
@ -93,23 +93,23 @@ Section "Install" SEC_INSTALL
DetailPrint "Git 已安装: $1"
${Else}
DetailPrint "未检测到 Git将跳过 SDK 自动下载"
DetailPrint "安装完成后请手动运行: plugindev sdk install ${SDK_VERSION}"
DetailPrint "安装完成后请手动运行: hmapdev sdk install ${SDK_VERSION}"
${EndIf}
${If} $hasGit == "1"
DetailPrint "正在下载 SDK ${SDK_VERSION}..."
nsExec::ExecToStack '"$INSTDIR\plugindev.exe" sdk install ${SDK_VERSION}'
nsExec::ExecToStack '"$INSTDIR\hmapdev.exe" sdk install ${SDK_VERSION}'
Pop $0
Pop $1
${If} $0 == 0
StrCpy $sdkInstallOk "1"
DetailPrint "SDK ${SDK_VERSION} 下载完成"
DetailPrint "正在激活 SDK ${SDK_VERSION}..."
nsExec::Exec '"$INSTDIR\plugindev.exe" sdk use ${SDK_VERSION}'
nsExec::Exec '"$INSTDIR\hmapdev.exe" sdk use ${SDK_VERSION}'
Pop $0
${Else}
DetailPrint "SDK 下载失败 (错误码: $0)"
DetailPrint "请手动运行: plugindev sdk install ${SDK_VERSION}"
DetailPrint "请手动运行: hmapdev sdk install ${SDK_VERSION}"
${EndIf}
${EndIf}
@ -126,10 +126,10 @@ SectionEnd
Section "Uninstall"
Delete "$INSTDIR\Uninstall.exe"
Delete "$INSTDIR\plugindev.exe"
Delete "$INSTDIR\hmapdev.exe"
RMDir /r "$INSTDIR\sdk"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\${PRODUCT_NAME}\plugindev.lnk"
Delete "$SMPROGRAMS\${PRODUCT_NAME}\hmapdev.lnk"
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
DeleteRegValue HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "HOMEAGENT_SDK_DIR"
DeleteRegKey HKLM "Software\Microsoft\CurrentVersion\Uninstall\${PRODUCT_NAME}"

View File

@ -25,13 +25,18 @@ type Relation struct {
}
// Triple represents a subject-relation-object triple for the knowledge graph.
//
// SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
// 所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
type Triple struct {
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
SentenceText string `json:"sentence_text,omitempty"`
MediaDigests []string `json:"media_digests,omitempty"`
}
// TextMemoryAPI provides access to chronological text event storage.
@ -40,27 +45,54 @@ type TextMemoryAPI interface {
}
// TextEvent represents a single text memory event.
// MediaAttachment 描述一份与记忆关联的媒体。
//
// 两个方向共用一个类型:
// - 写入InsertWithMedia给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
// - 读出Query内核只填 Digest/MIME**不回 Data**——
// 一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
// 需要字节时拿 Digest 单独取。
//
// 刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。
// 它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。
type MediaAttachment struct {
Digest string `json:"digest,omitempty"`
MIME string `json:"mime,omitempty"`
Data []byte `json:"data,omitempty"`
Name string `json:"name,omitempty"`
}
type TextEvent struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
Channel string `json:"channel,omitempty"`
Role string `json:"role"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
Channel string `json:"channel,omitempty"`
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
// DocMemoryAPI provides access to the document vector store.
type DocMemoryAPI interface {
Query(text string, topK int) []*Doc
Insert(doc *Doc) error
// InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
// 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
// 媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
// 因此图片按自己的向量被召回,不依赖任何生成的描述文本。
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
Remove(id string)
Stats() map[string]interface{}
}
// Doc represents a document in the document store.
//
// MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
type Doc struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
MediaDigests []string `json:"media_digests,omitempty"`
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
// SocialAPI provides read-only access to the social graph (person profiles and relationships).
@ -75,9 +107,9 @@ type SocialAPI interface {
// PersonProfile represents a person's complete profile (traits + social relations).
type PersonProfile struct {
Name string `json:"name"`
Traits map[string]string `json:"traits,omitempty"`
Relations []SocialRelation `json:"relations,omitempty"`
Name string `json:"name"`
Traits map[string]string `json:"traits,omitempty"`
Relations []SocialRelation `json:"relations,omitempty"`
}
// SocialRelation represents a social relationship between two persons.

View File

@ -35,12 +35,85 @@ const (
StageAfterOutput Stage = "after_output"
)
// 上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
//
// 默认(空串或 ContextPolicyNone**不裁剪**:裁剪会归档丢弃低相关事件,
// 必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
// 插件会在背后把别人的内容挤掉,且看不出是谁干的。
const (
ContextPolicyNone = "none"
ContextPolicyPrune = "prune"
)
// ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
func ValidContextPolicy(policy string) bool {
switch policy {
case "", ContextPolicyNone, ContextPolicyPrune:
return true
}
return false
}
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
//
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
// 因此调用方只有在确实需要改变行为时才需要填它。
//
// 为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
// 属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
// 也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
//
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
//
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
//
// CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
//
// 空串 = 按注入的 source 查通道定义(既有行为)。
// 为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
// 而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
// 不指定就只能退到「按 source 查不到就不清洗」。
type InjectOptions struct {
NoMemory bool
ContextPolicy string
CleanerName string
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
//
// 取值 PriorityL1..PriorityL4空等同 L1默认级
// L4 只有**内核级插件**能用(见 PriorityL4 注释);外部插件的 L4 会被夹到 L3。
//
// 排队注入InjectText*/InjectInputSync没有级别它们本就是“不需及时处理”
// 的那一类,可被任何中断打断。
Priority string
}
// 中断优先级取值。
//
// L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
// 如 cli/webui/timer才能声明——它用于实现真正的“立即打断”能力
// 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
const (
PriorityL1 = "L1"
PriorityL2 = "L2"
PriorityL3 = "L3"
// PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
PriorityL4 = "L4"
)
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none不裁剪
//
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
// 没有 tag 时既无法整体 marshalfunc 不支持),又会诱使调用方手写字段白名单——
// 那样新增字段会被静默丢掉。
type ChannelDef struct {
NoMemory bool
Cleaner func(string) string
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"`
ContextPolicy string `json:"context_policy,omitempty"`
}
// StageContext provides context for stage handlers.
@ -61,14 +134,18 @@ type StageContext struct {
Memory []MemItem
NoMemory bool
Extra map[string]interface{}
Errors []string // 阶段处理过程中的错误信息
Errors []string // 阶段处理过程中的错误信息
}
func (c *StageContext) RLock() { c.mu.RLock() }
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
func (c *StageContext) Lock() { c.mu.Lock() }
func (c *StageContext) Unlock() { c.mu.Unlock() }
func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil }
func (c *StageContext) RLock() { c.mu.RLock() }
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
func (c *StageContext) Lock() { c.mu.Lock() }
func (c *StageContext) Unlock() { c.mu.Unlock() }
func (c *StageContext) IsResponded() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.Response != nil
}
// MemItem represents a memory item in stage context.
type MemItem struct {
@ -96,12 +173,13 @@ type ToolResult struct {
// ToolDef describes a tool that the plugin exposes.
type ToolDef struct {
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
}
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
@ -117,6 +195,20 @@ type IOInjector interface {
// SetToolBlocks 插件工具注入多模态内容块image_url/audio_url内核在下一条
// tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
SetToolBlocks(blocks []ContentBlock)
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
// 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
//
// 上面那些不带 opts 的方法等价于传零值 InjectOptions记入记忆 + 不裁剪),
// 保留它们是为了不破坏已有插件;新代码应当用 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)
}
// EventType identifies the kind of system event.
@ -192,6 +284,13 @@ type InputChannelRegistrar func(name string, def ChannelDef) error
// OutputChannelRegistrar registers an output channel that the output_send tool can use.
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
// OutputChannelUnregistrar 注销一个输出通道。
//
// 为什么需要它:输出通道不止有"启动时注册一次"的静态通道,还有**随外部资源生灭**的
// 动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后
// 必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。
type OutputChannelUnregistrar func(name string) error
// Output capability flags
const (
CapText = 1
@ -204,22 +303,41 @@ const (
// PluginSDK is the main API surface provided to plugins at runtime.
// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
type PluginSDK struct {
name string
regTool ToolRegistrar
regStage StageRegistrar
regAPI APIRegistrar
regOutput OutputChannelRegistrar
regInput InputChannelRegistrar
io IOInjector
mem MemoryAPI
textMem TextMemoryAPI
docMem DocMemoryAPI
know KnowledgeAPI
llm LLMAPI
sett SettingsAPI
social SocialAPI
events EventSubscriber
plgMgr PluginMgrAPI
name string
regTool ToolRegistrar
regStage StageRegistrar
regAPI APIRegistrar
regOutput OutputChannelRegistrar
regOutputUnreg OutputChannelUnregistrar
regInput InputChannelRegistrar
io IOInjector
mem MemoryAPI
textMem TextMemoryAPI
docMem DocMemoryAPI
know KnowledgeAPI
llm LLMAPI
sett SettingsAPI
social SocialAPI
events EventSubscriber
plgMgr PluginMgrAPI
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
//
// 这些字段的写方与读方天然跨 goroutine
// - 写方是内核(加载/重载插件时注入 API与插件自己SetAutoRestart
// - 读方是插件在 Start() 里起的后台 goroutine轮询、监听、定时器
// 都要拿 injector 往管道里注消息),以及内核 registry —— 它在
// 另一个 goroutine 读 AutoRestart() 决定崩溃后是否重启。
// SetAutoRestart 的文档用法本身就是「连接建立后再决定能否自动重启」,
// 而连接建立通常发生在后台 goroutine 里,于是这对读写必然并发。
//
// sdk/stress_test.go 的 -race 实测确认这是真竞态,不是理论风险。
// 未加锁时的生产表现是偶发 nil 解引用崩溃(读到半个接口值)。
//
// 约定:只在持锁期间取字段值,取完立刻释放再调用。
// 持锁调用会把 InjectInputSync 这类阻塞到 agent 回复(可达数分钟)的
// 方法与 SetIOInjector 串到一起,让插件重载卡死。
apiMu sync.RWMutex
autoRestart bool
@ -247,28 +365,57 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
func (s *PluginSDK) PluginName() string { return s.name }
// Settings returns the settings API for reading/writing plugin configuration.
// sett 在 New 时一次性写入且无 setter故不需要加锁。
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
// Memory returns the graph memory API (may be nil if not available).
func (s *PluginSDK) Memory() MemoryAPI { return s.mem }
func (s *PluginSDK) Memory() MemoryAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.mem
}
// TextMemory returns the text memory API (may be nil if not available).
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
func (s *PluginSDK) TextMemory() TextMemoryAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.textMem
}
// DocMemory returns the document memory API (may be nil if not available).
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
func (s *PluginSDK) DocMemory() DocMemoryAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.docMem
}
// Knowledge returns the knowledge store API (may be nil if not available).
func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
func (s *PluginSDK) Knowledge() KnowledgeAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.know
}
// LLM returns the LLM provider API (may be nil if not available).
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
func (s *PluginSDK) LLM() LLMAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.llm
}
// Social returns the social graph API (may be nil if not available).
func (s *PluginSDK) Social() SocialAPI { return s.social }
func (s *PluginSDK) Social() SocialAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.social
}
// Events returns the event subscriber for listening to kernel events (may be nil if not available).
func (s *PluginSDK) Events() EventSubscriber { return s.events }
func (s *PluginSDK) Events() EventSubscriber {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.events
}
// RegisterTool registers a tool that the LLM can call.
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
@ -282,8 +429,9 @@ func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler)
}
// 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.
//
// scope: StageScopeGlobal (default) — receives all stage events.
// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) {
if s.regStage == nil {
return
@ -327,92 +475,280 @@ func (s *PluginSDK) RegisterPluginAPI(name string) 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")
// 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)
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error {
if s.regOutput != nil {
return s.regOutput(name, caps, desc, def, handler)
s.apiMu.RLock()
reg := s.regOutput
s.apiMu.RUnlock()
if reg != nil {
return reg(name, caps, desc, def, handler)
}
return nil
}
// UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
func (s *PluginSDK) UnregisterOutputChannel(name string) error {
s.apiMu.RLock()
reg := s.regOutputUnreg
s.apiMu.RUnlock()
if reg != nil {
return reg(name)
}
return nil
}
// 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: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {
if s.regInput != nil {
return s.regInput(name, def)
s.apiMu.RLock()
reg := s.regInput
s.apiMu.RUnlock()
if reg != nil {
return reg(name, def)
}
return nil
}
// 以下 setter 由内核在启动/重载时调用,与插件后台 goroutine 的读并发,故加锁。
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r }
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) {
s.apiMu.Lock()
s.regOutput = r
s.apiMu.Unlock()
}
// SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar) {
s.apiMu.Lock()
s.regOutputUnreg = r
s.apiMu.Unlock()
}
// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) { s.regInput = r }
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) {
s.apiMu.Lock()
s.regInput = r
s.apiMu.Unlock()
}
// SetIOInjector sets the IO injector (called by the core at startup).
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
func (s *PluginSDK) SetIOInjector(io IOInjector) {
s.apiMu.Lock()
s.io = io
s.apiMu.Unlock()
}
// SetMemoryAPI sets the memory API (called by the core at startup).
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem }
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm }
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm }
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn }
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) {
s.apiMu.Lock()
s.mem = mem
s.apiMu.Unlock()
}
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) {
s.apiMu.Lock()
s.textMem = tm
s.apiMu.Unlock()
}
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) {
s.apiMu.Lock()
s.docMem = dm
s.apiMu.Unlock()
}
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) {
s.apiMu.Lock()
s.know = kn
s.apiMu.Unlock()
}
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) {
s.apiMu.Lock()
s.llm = llm
s.apiMu.Unlock()
}
func (s *PluginSDK) SetSocialAPI(social SocialAPI) {
s.apiMu.Lock()
s.social = social
s.apiMu.Unlock()
}
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) {
s.apiMu.Lock()
s.events = es
s.apiMu.Unlock()
}
// SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) { s.plgMgr = pm }
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) {
s.apiMu.Lock()
s.plgMgr = pm
s.apiMu.Unlock()
}
// PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
// May be nil if the host did not wire it.
func (s *PluginSDK) PluginMgr() PluginMgrAPI { return s.plgMgr }
func (s *PluginSDK) PluginMgr() PluginMgrAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.plgMgr
}
// ---- IO Convenience Methods ----
// injector 取当前 injector 的快照。
//
// 取完即释放锁再调用InjectInputSync 会阻塞到 agent 回复(可达数分钟),
// 若持锁调用,插件重载时的 SetIOInjector 会一起卡住。
func (s *PluginSDK) injector() IOInjector {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.io
}
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
// 等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
if s.io != nil {
s.io.InjectInterruptText(source, channel, text)
}
s.InjectInterruptTextOpts(source, channel, text, InjectOptions{})
}
// InjectText injects a text message into the agent pipeline.
// 等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
func (s *PluginSDK) InjectText(source, channel, text string) {
if s.io != nil {
s.io.InjectText(source, channel, text)
}
s.InjectTextOpts(source, channel, text, InjectOptions{})
}
// InjectTextNoMemory injects a text message without generating memory.
// 等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
if s.io != nil {
s.io.InjectTextNoMemory(source, channel, text)
}
s.InjectTextOpts(source, channel, text, InjectOptions{NoMemory: true})
}
// 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.
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
if s.io == nil {
return s.InjectInputSyncOpts(source, channel, text, InjectOptions{})
}
// InjectInputMedia 注入带媒体内容块image_url/audio_url的输入。
// blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
// 发给 LLM让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
// 的「下一轮 tool message」语义。
// 等价于 InjectInputMediaOpts(..., InjectOptions{})。
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
s.InjectInputMediaOpts(source, channel, text, blocks, InjectOptions{})
}
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
// 等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
return s.InjectInputMediaSyncOpts(source, channel, text, blocks, InjectOptions{})
}
// ---- 带 InjectOptions 的注入(声明记忆/裁剪行为)----
// InjectTextOpts 注入文本到 agent并在这一次注入上声明记忆与裁剪行为。
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions) {
if io := s.injector(); io != nil {
io.InjectTextOpts(source, channel, text, opts)
}
}
// InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
//
// 中断也允许声明 ContextPolicyPrune中断同样携带内容进入上下文
// 是否需要据此裁剪由调用方决定(默认不裁剪)。
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
if io := s.injector(); io != nil {
io.InjectInterruptTextOpts(source, channel, text, opts)
}
}
// InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
io := s.injector()
if io == nil {
return ""
}
return s.io.InjectInputSync(source, channel, text)
return io.InjectInputSyncOpts(source, channel, text, opts)
}
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
if io := s.injector(); io != nil {
io.InjectInputMediaOpts(source, channel, text, blocks, opts)
}
}
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
io := s.injector()
if io == nil {
return ""
}
return io.InjectInputMediaSyncOpts(source, channel, text, blocks, opts)
}
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
if io := s.injector(); io != nil {
io.InjectInterruptMediaOpts(source, channel, text, blocks, opts)
}
}
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
// blocks 随中断消息一起发给模型。
func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) {
if io := s.injector(); io != nil {
io.InjectInterruptMedia(source, channel, text, blocks)
}
}
// SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
// 的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock) {
if io := s.injector(); io != nil {
io.SetToolBlocks(blocks)
}
}
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
// 默认 true。如果插件有无法恢复的状态如外部连接应设为 false。
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
func (s *PluginSDK) SetAutoRestart(enabled bool) {
s.apiMu.Lock()
s.autoRestart = enabled
s.apiMu.Unlock()
}
// AutoRestart 返回插件是否允许自动重启。
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
func (s *PluginSDK) AutoRestart() bool {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.autoRestart
}
// RegisterStopHandler 注册插件停止阶段的清理回调。
// 注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,

760
sdk/stress_test.go Normal file
View File

@ -0,0 +1,760 @@
package sdk
import (
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
)
// SDK 公开接口的并发压力测试1.1.0 媒体接口上线后新增)。
//
// 为什么这一层需要压测SDK 是**被多个 goroutine 同时使用的共享对象**。
// 一个插件的典型形态是 Start() 里起若干后台 goroutine轮询、监听、定时器
// 它们各自持同一个 *PluginSDK 往里注入消息;内核侧同时还有 stage 扇出、
// 工具调用、以及读 AutoRestart() 决定崩溃后是否重启。
// 单线程单测全绿不代表这些并发路径成立。
//
// 关注点不是吞吐数字,而是不变量:
// 1. 注入调用不丢、不串(媒体块必须与文本配对,不能张冠李戴)
// 2. 状态字段的读写不产生数据竞争(-race 下必须干净)
// 3. handler 注册/执行在并发下"恰好一次"
// 4. 跨进程 JSON 序列化对新媒体类型必须字节级往返一致
//
// 媒体接口尤其需要 3 与 4媒体块要经 JSON 过子进程边界,
// 而 []byte 在 JSON 里是 base64往返不一致的后果是图片静默损坏。
// ---------- 测试替身 ----------
// recordingInjector 记录每一次注入调用,用于验证"不丢不串"。
type recordingInjector struct {
mu sync.Mutex
calls []injectCall
// 计数用原子量:并发路径上只增不减,可在不持锁时安全读。
nText, nMedia, nInterrupt, nSync atomic.Int64
}
type injectCall struct {
kind string // text / media / interruptMedia / sync ...
source string
channel string
text string
blocks []ContentBlock
opts InjectOptions // 调用点声明的记忆/裁剪行为
}
func (r *recordingInjector) record(c injectCall) {
r.mu.Lock()
r.calls = append(r.calls, c)
r.mu.Unlock()
}
func (r *recordingInjector) InjectInterruptText(s, c, t string) {
r.nInterrupt.Add(1)
r.record(injectCall{kind: "interruptText", source: s, channel: c, text: t})
}
func (r *recordingInjector) InjectText(s, c, t string) {
r.nText.Add(1)
r.record(injectCall{kind: "text", source: s, channel: c, text: t})
}
func (r *recordingInjector) InjectTextNoMemory(s, c, t string) {
r.nText.Add(1)
r.record(injectCall{kind: "textNoMem", source: s, channel: c, text: t})
}
func (r *recordingInjector) InjectInputSync(s, c, t string) string {
r.nSync.Add(1)
r.record(injectCall{kind: "sync", source: s, channel: c, text: t})
return "reply:" + t
}
func (r *recordingInjector) SetToolBlocks(blocks []ContentBlock) {
r.record(injectCall{kind: "toolBlocks", blocks: blocks})
}
func (r *recordingInjector) InjectInputMedia(s, c, t string, b []ContentBlock) {
r.nMedia.Add(1)
r.record(injectCall{kind: "media", source: s, channel: c, text: t, blocks: b})
}
func (r *recordingInjector) InjectInputMediaSync(s, c, t string, b []ContentBlock) string {
r.nMedia.Add(1)
r.nSync.Add(1)
r.record(injectCall{kind: "mediaSync", source: s, channel: c, text: t, blocks: b})
return "reply:" + t
}
func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBlock) {
r.nMedia.Add(1)
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
}
// ---- 带 InjectOptions 的注入:记录 opts 以便测试断言标志位确实传到了内核 ----
func (r *recordingInjector) InjectTextOpts(s, c, t string, o InjectOptions) {
r.nText.Add(1)
r.record(injectCall{kind: "textOpts", source: s, channel: c, text: t, opts: o})
}
func (r *recordingInjector) InjectInterruptTextOpts(s, c, t string, o InjectOptions) {
r.nInterrupt.Add(1)
r.record(injectCall{kind: "interruptTextOpts", source: s, channel: c, text: t, opts: o})
}
func (r *recordingInjector) InjectInputSyncOpts(s, c, t string, o InjectOptions) string {
r.nSync.Add(1)
r.record(injectCall{kind: "syncOpts", source: s, channel: c, text: t, opts: o})
return "reply:" + t
}
func (r *recordingInjector) InjectInputMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
r.nMedia.Add(1)
r.record(injectCall{kind: "mediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
}
func (r *recordingInjector) InjectInputMediaSyncOpts(s, c, t string, b []ContentBlock, o InjectOptions) string {
r.nMedia.Add(1)
r.nSync.Add(1)
r.record(injectCall{kind: "mediaSyncOpts", source: s, channel: c, text: t, blocks: b, opts: o})
return "reply:" + t
}
func (r *recordingInjector) InjectInterruptMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
r.nMedia.Add(1)
r.record(injectCall{kind: "interruptMediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
}
func (r *recordingInjector) snapshot() []injectCall {
r.mu.Lock()
defer r.mu.Unlock()
return append([]injectCall{}, r.calls...)
}
var _ IOInjector = (*recordingInjector)(nil)
// imageBlock 构造一个带可识别 URL 的图片块。
func imageBlock(tag string) ContentBlock {
return ContentBlock{
Type: "image_url",
ImageURL: &ImageURL{URL: "data:image/png;base64," + tag, Detail: "auto"},
}
}
// ---------- 1. 媒体注入并发不丢不串 ----------
// 三个媒体注入方法在高并发下必须:调用数精确、且每次调用的 text 与 blocks 配对不错。
//
// "不串"是这里的关键断言。注入是插件里最容易被后台 goroutine 并发调用的入口,
// 若实现里出现任何共享中间状态(比如把 blocks 暂存到 SDK 字段再读出),
// 高并发下就会出现 A 的文本配上 B 的图——而两者单独看都"成功"了,不报错。
func TestStress_MediaInjectionConcurrentNoCrossTalk(t *testing.T) {
const workers, perWorker = 32, 200
inj := &recordingInjector{}
s := &PluginSDK{name: "stress"}
s.SetIOInjector(inj)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < perWorker; i++ {
// tag 唯一标识这次调用,文本与图片 URL 里都带上它。
tag := fmt.Sprintf("w%d-i%d", w, i)
switch i % 3 {
case 0:
s.InjectInputMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
case 1:
if got := s.InjectInputMediaSync("src", "ch", tag, []ContentBlock{imageBlock(tag)}); got != "reply:"+tag {
t.Errorf("同步注入回复错位: got %q want %q", got, "reply:"+tag)
}
default:
s.InjectInterruptMedia("src", "ch", tag, []ContentBlock{imageBlock(tag)})
}
}
}(w)
}
wg.Wait()
total := int64(workers * perWorker)
if got := inj.nMedia.Load(); got != total {
t.Fatalf("媒体注入调用数 = %d期望 %d有调用丢失", got, total)
}
// 逐条校验文本与媒体块配对URL 必须含该次调用自己的 tag。
seen := map[string]bool{}
for _, c := range inj.snapshot() {
if len(c.blocks) == 0 {
continue
}
if c.blocks[0].ImageURL == nil {
t.Fatalf("媒体块 ImageURL 丢失: %+v", c.blocks[0])
}
if !strings.HasSuffix(c.blocks[0].ImageURL.URL, c.text) {
t.Fatalf("文本与媒体块错位: text=%q url=%q", c.text, c.blocks[0].ImageURL.URL)
}
if seen[c.text] {
t.Fatalf("同一次调用被记录两次: %s", c.text)
}
seen[c.text] = true
}
if len(seen) != int(total) {
t.Fatalf("去重后调用数 = %d期望 %d", len(seen), total)
}
}
// ---------- 2. 注入期间热替换 injector ----------
// 内核在插件运行期间可能重新注入 API重载、恢复、子进程重连握手
// 此时插件的后台 goroutine 仍在注入。这条路径若无同步就是对 s.io 的数据竞争,
// 在 -race 下会被抓出;生产表现是偶发 nil 解引用崩溃。
func TestStress_InjectorSwapDuringInjection(t *testing.T) {
s := &PluginSDK{name: "stress"}
s.SetIOInjector(&recordingInjector{})
stop := make(chan struct{})
var injectors, swapper sync.WaitGroup
// 注入方:持续打直到 stop
for w := 0; w < 8; w++ {
injectors.Add(1)
go func() {
defer injectors.Done()
for {
select {
case <-stop:
return
default:
s.InjectInputMedia("src", "ch", "x", []ContentBlock{imageBlock("x")})
s.InjectText("src", "ch", "y")
}
}
}()
}
// 替换方:反复换 injector含换成 nil——内核卸载 API 时的真实状态)
swapper.Add(1)
go func() {
defer swapper.Done()
for i := 0; i < 500; i++ {
if i%7 == 0 {
s.SetIOInjector(nil)
} else {
s.SetIOInjector(&recordingInjector{})
}
}
}()
// 先等替换跑完,再告知注入方退出。
// 顺序写反了就是死锁:注入方只依 close(stop) 退出。
swapper.Wait()
close(stop)
injectors.Wait()
// 断言就是「没崩、-race 没报」。nil injector 时必须静默跳过而非 panic。
}
// ---------- 3. autoRestart 标志的并发读写 ----------
// SetAutoRestart 的文档用途是"插件有无法恢复的状态(如外部连接)时设为 false"——
// 而连接建立本身通常是异步的,所以这个写入天然发生在后台 goroutine。
// 内核侧 registry 在另一个 goroutine 读 AutoRestart() 决定崩溃后是否重启。
// 这是一对跨 goroutine 的读写,必须同步。
func TestStress_AutoRestartFlagConcurrent(t *testing.T) {
s := &PluginSDK{name: "stress", autoRestart: true}
var wg sync.WaitGroup
for w := 0; w < 16; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < 500; i++ {
s.SetAutoRestart(i%2 == 0)
}
}(w)
}
// 读方模拟内核 registry
for r := 0; r < 8; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 500; i++ {
_ = s.AutoRestart()
}
}()
}
wg.Wait()
}
// ---------- 4. stop / onRemove handler 的"恰好一次" ----------
// RunStopHandlers 的契约是"执行后清空,幂等"。内核在停止插件时可能并发触发
// (超时强杀与正常 Stop 竞争handler 里往往是关连接、落盘——
// 执行两次的后果从"重复写文件"到"close 已关闭的 channel 直接 panic"。
func TestStress_StopHandlersExactlyOnce(t *testing.T) {
const n = 300
s := &PluginSDK{name: "stress"}
var counters [n]atomic.Int64
for i := 0; i < n; i++ {
i := i
s.RegisterStopHandler(func() { counters[i].Add(1) })
}
var wg sync.WaitGroup
for w := 0; w < 16; w++ {
wg.Add(1)
go func() {
defer wg.Done()
s.RunStopHandlers()
}()
}
wg.Wait()
for i := 0; i < n; i++ {
if got := counters[i].Load(); got != 1 {
t.Fatalf("handler %d 执行 %d 次,期望恰好 1 次", i, got)
}
}
}
// 注册与执行并发:已注册的 handler 一次都不能多跑,未跑到的也不能被丢。
// 断言用"每个 handler 的执行次数 <= 1"而非"总数相等"——
// 与 RunStopHandlers 竞争的注册可能落在快照之后,那属于合法的未执行。
func TestStress_StopHandlersRegisterWhileRunning(t *testing.T) {
s := &PluginSDK{name: "stress"}
const n = 500
var counters [n]atomic.Int64
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < n; i++ {
i := i
s.RegisterStopHandler(func() { counters[i].Add(1) })
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
s.RunStopHandlers()
}
}()
wg.Wait()
s.RunStopHandlers() // 收尾:把剩下的都跑掉
for i := 0; i < n; i++ {
if got := counters[i].Load(); got > 1 {
t.Fatalf("handler %d 被执行 %d 次(重复执行)", i, got)
}
}
}
func TestStress_OnRemoveHandlersExactlyOnce(t *testing.T) {
const n = 200
s := &PluginSDK{name: "stress"}
var counters [n]atomic.Int64
for i := 0; i < n; i++ {
i := i
s.RegisterOnRemoveHandler(func() { counters[i].Add(1) })
}
var wg sync.WaitGroup
for w := 0; w < 12; w++ {
wg.Add(1)
go func() {
defer wg.Done()
s.RunOnRemoveHandlers()
}()
}
wg.Wait()
for i := 0; i < n; i++ {
if got := counters[i].Load(); got != 1 {
t.Fatalf("onRemove handler %d 执行 %d 次,期望恰好 1 次", i, got)
}
}
}
// ---------- 5. StageContext 并发读改写 ----------
// StageContext 是全部 stage handler 共享的可变状态,字段全导出、靠调用方自觉
// 持 Lock/RLock。媒体链路让 Extra 成为新热点media_blocks 挂在这里),
// 而 map 的并发写在 Go 里是直接 fatalrecover 都接不住。
//
// 这条测试锁定的不变量:按约定持锁的并发读改写不丢更新、不 fatal。
func TestStress_StageContextConcurrentExtraAndFinalText(t *testing.T) {
ctx := &StageContext{Extra: map[string]interface{}{}}
const workers, rounds = 16, 200
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
// 写:模拟插件往 Extra 塞媒体块并追加文本(读-改-写)
ctx.Lock()
ctx.Extra[fmt.Sprintf("k%d-%d", w, i)] = []ContentBlock{imageBlock("x")}
ctx.FinalText += "."
ctx.Unlock()
// 读:模拟另一个 handler 检查是否已被响应
_ = ctx.IsResponded()
ctx.RLock()
_ = len(ctx.Extra)
ctx.RUnlock()
}
}(w)
}
wg.Wait()
ctx.RLock()
defer ctx.RUnlock()
if len(ctx.Extra) != workers*rounds {
t.Fatalf("Extra 键数 = %d期望 %d出现 lost update", len(ctx.Extra), workers*rounds)
}
if len(ctx.FinalText) != workers*rounds {
t.Fatalf("FinalText 长度 = %d期望 %d出现 lost update", len(ctx.FinalText), workers*rounds)
}
}
// ---------- 6. OwnTools scope 包装器的并发正确性 ----------
// StageScopeOwnTools 的包装闭环里要读 ctx.ToolCalls 判断归属。
// 并发下若判断与执行之间状态被改写,就会出现"别人的工具触发了我的 handler"——
// 后果是插件对不属于自己的工具结果动手,且没有任何错误。
func TestStress_OwnToolsScopeNoCrossPluginLeak(t *testing.T) {
var registered StageHandler
s := &PluginSDK{
name: "mine",
regStage: func(stage Stage, h StageHandler) { registered = h },
}
var fired atomic.Int64
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
fired.Add(1)
ctx.RLock()
defer ctx.RUnlock()
// 触发了就必须确实是自己的工具
if len(ctx.ToolCalls) == 0 || ctx.ToolCalls[0].Plugin != "mine" {
t.Errorf("handler 被别的插件的工具触发: %+v", ctx.ToolCalls)
}
return nil
}, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler 未注册")
}
const workers, rounds = 16, 100
var wg sync.WaitGroup
var mineCount atomic.Int64
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
// 每个 goroutine 用自己的 ctx——真实内核里 stage 扇出共享同一个
// ctx但那部分的并发由内核 host 仲裁;这里验证包装器本身。
owner := "other"
if (w+i)%2 == 0 {
owner = "mine"
mineCount.Add(1)
}
ctx := &StageContext{Extra: map[string]interface{}{}}
ctx.ToolCalls = []ToolCall{{Plugin: owner, Name: "t"}}
if err := registered(ctx); err != nil {
t.Errorf("handler 返回错误: %v", err)
}
}
}(w)
}
wg.Wait()
if got, want := fired.Load(), mineCount.Load(); got != want {
t.Fatalf("handler 触发 %d 次,期望 %d 次(漏触发或跨插件触发)", got, want)
}
}
// ---------- 7. 媒体类型的 JSON 往返(跨进程边界的真实形态) ----------
// 媒体块与附件要经 JSON 过子进程边界。[]byte 在 JSON 里是 base64
// 往返不一致的后果是图片字节静默损坏——落进 CAS 后 digest 校验才会发现,
// 而那时已经无从追查是谁改坏的。
func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
// 覆盖真实会遇到的边界:空、单字节、含 0x00、全 0xFF、超过 base64 分组边界的长度
sizes := []int{0, 1, 2, 3, 255, 256, 1023, 4096, 65537}
for _, n := range sizes {
data := make([]byte, n)
for i := range data {
data[i] = byte(i * 7 % 256)
}
att := MediaAttachment{
Digest: strings.Repeat("a", 64),
MIME: "image/png",
Data: data,
Name: "图片-名字 with space & 符号.png",
}
b, err := json.Marshal(att)
if err != nil {
t.Fatalf("size=%d marshal: %v", n, err)
}
var back MediaAttachment
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("size=%d unmarshal: %v", n, err)
}
if len(back.Data) != n {
t.Fatalf("size=%d 往返后长度 = %d", n, len(back.Data))
}
for i := range data {
if back.Data[i] != data[i] {
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
}
}
if back.Name != att.Name || back.MIME != att.MIME || back.Digest != att.Digest {
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
}
}
}
// omitempty 必须真的生效:读路径上内核不回 Data若序列化仍产出 "data":null
// 之类的键,跨进程消息会凭空变大,且插件侧无法区分"没有字节"与"空字节"。
func TestStress_MediaTypesOmitEmpty(t *testing.T) {
cases := []struct {
name string
v interface{}
absent []string
present []string
}{
{
name: "Triple 无媒体",
v: Triple{Subject: "甲方", Relation: "签署", Object: "合同"},
absent: []string{"media_digests", "sentence_text", "confidence", "subject_type", "object_type"},
present: []string{"subject", "relation", "object"},
},
{
name: "Triple 带媒体",
v: Triple{Subject: "甲方", Relation: "包含", Object: "图", MediaDigests: []string{"abc12345"}, SentenceText: "句子"},
absent: []string{"confidence"},
present: []string{"media_digests", "sentence_text"},
},
{
name: "Doc 读路径无字节",
v: Doc{ID: "d1", Title: "标题", Content: "正文", Attachments: []MediaAttachment{{Digest: "abc12345", MIME: "image/png"}}},
absent: []string{"\"data\"", "media_digests", "score"},
present: []string{"attachments", "digest", "mime"},
},
{
name: "TextEvent 无附件",
v: TextEvent{Role: "user", Content: "hi"},
absent: []string{"attachments", "channel"},
present: []string{"role", "content"},
},
{
name: "ContentBlock 纯文本",
v: ContentBlock{Type: "text", Text: "hi"},
absent: []string{"image_url", "audio_url"},
present: []string{"type", "text"},
},
{
name: "ContentBlock 图片",
v: imageBlock("AAA"),
absent: []string{"audio_url", "\"text\""},
present: []string{"image_url", "detail"},
},
}
for _, c := range cases {
b, err := json.Marshal(c.v)
if err != nil {
t.Fatalf("%s marshal: %v", c.name, err)
}
s := string(b)
for _, k := range c.absent {
if strings.Contains(s, k) {
t.Errorf("%s: 不该出现的键 %s —— %s", c.name, k, s)
}
}
for _, k := range c.present {
if !strings.Contains(s, k) {
t.Errorf("%s: 缺少键 %s —— %s", c.name, k, s)
}
}
}
}
// 媒体块在并发序列化下必须各自独立ImageURL/AudioURL 是指针,
// 若某处复用同一个指针再改写,序列化结果会互相污染。
func TestStress_ContentBlockConcurrentMarshal(t *testing.T) {
const workers, rounds = 16, 300
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < rounds; i++ {
tag := fmt.Sprintf("w%d-i%d", w, i)
blocks := []ContentBlock{
{Type: "text", Text: tag},
imageBlock(tag),
{Type: "audio_url", AudioURL: &AudioURL{URL: "data:audio/wav;base64," + tag}},
}
b, err := json.Marshal(blocks)
if err != nil {
t.Errorf("marshal: %v", err)
return
}
var back []ContentBlock
if err := json.Unmarshal(b, &back); err != nil {
t.Errorf("unmarshal: %v", err)
return
}
if len(back) != 3 {
t.Errorf("块数 = %d", len(back))
return
}
if back[0].ImageURL != nil || back[0].AudioURL != nil {
t.Errorf("文本块被填了媒体指针: %+v", back[0])
}
if back[1].ImageURL == nil || !strings.HasSuffix(back[1].ImageURL.URL, tag) {
t.Errorf("图片块 URL 错位: %+v", back[1].ImageURL)
}
if back[1].AudioURL != nil {
t.Errorf("图片块被填了音频指针")
}
if back[2].AudioURL == nil || !strings.HasSuffix(back[2].AudioURL.URL, tag) {
t.Errorf("音频块 URL 错位: %+v", back[2].AudioURL)
}
}
}(w)
}
wg.Wait()
}
// ---------- 8. 注册面的并发 ----------
// 插件在 Start() 里起多个 goroutine 分别注册工具是常见写法。
// def.Plugin 的默认填充若不是每次调用独立的,就会出现工具归属错乱——
// 表现是 OwnTools scope 失效、WebUI 里工具挂在别的插件名下。
func TestStress_RegisterToolConcurrentPluginDefaulting(t *testing.T) {
var mu sync.Mutex
got := map[string]string{} // toolName -> def.Plugin
s := &PluginSDK{
name: "mine",
regTool: func(name string, def ToolDef, h ToolHandler) error {
mu.Lock()
got[name] = def.Plugin
mu.Unlock()
return nil
},
}
const workers, perWorker = 16, 100
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < perWorker; i++ {
name := fmt.Sprintf("tool_w%d_i%d", w, i)
def := ToolDef{Description: "d", Parameters: map[string]interface{}{}}
// 一半显式指定归属,一半靠 SDK 填默认值
if i%2 == 0 {
def.Plugin = "explicit"
}
if err := s.RegisterTool(name, def, func(map[string]interface{}) (interface{}, error) {
return nil, nil
}); err != nil {
t.Errorf("RegisterTool: %v", err)
}
}
}(w)
}
wg.Wait()
if len(got) != workers*perWorker {
t.Fatalf("注册工具数 = %d期望 %d", len(got), workers*perWorker)
}
for name, owner := range got {
want := "mine"
if isEvenSuffix(name) {
want = "explicit"
}
if owner != want {
t.Fatalf("工具 %s 归属 = %q期望 %q", name, owner, want)
}
}
}
// isEvenSuffix 判断 tool_wX_iY 里的 Y 是否为偶数。
func isEvenSuffix(name string) bool {
idx := strings.LastIndex(name, "_i")
if idx < 0 {
return false
}
n := 0
if _, err := fmt.Sscanf(name[idx+2:], "%d", &n); err != nil {
return false
}
return n%2 == 0
}
// nil 依赖下所有便捷方法必须静默降级而非 panic。
//
// 这是"媒体存储可关闭"在 SDK 层的对应物:内核未注入某个 API 时
// (精简部署、插件权限不足、子进程握手尚未完成),插件的调用不该崩。
func TestStress_NilDependenciesDegradeSilently(t *testing.T) {
s := &PluginSDK{name: "bare"}
const workers = 16
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
s.InjectText("s", "c", "t")
s.InjectTextNoMemory("s", "c", "t")
s.InjectInterruptText("s", "c", "t")
if got := s.InjectInputSync("s", "c", "t"); got != "" {
t.Errorf("无 injector 时同步注入应返回空串got %q", got)
}
s.InjectInputMedia("s", "c", "t", []ContentBlock{imageBlock("x")})
if got := s.InjectInputMediaSync("s", "c", "t", nil); got != "" {
t.Errorf("无 injector 时媒体同步注入应返回空串got %q", got)
}
s.InjectInterruptMedia("s", "c", "t", nil)
// getter 全部应返回 nil 而非 panic
_ = s.Memory()
_ = s.TextMemory()
_ = s.DocMemory()
_ = s.Knowledge()
_ = s.LLM()
_ = s.Social()
_ = s.Events()
_ = s.PluginMgr()
_ = s.Settings()
// 注册面无 registrar 时应返回 nil error
if err := s.RegisterTool("t", ToolDef{}, nil); err != nil {
t.Errorf("无 registrar 时 RegisterTool 应返回 nilgot %v", err)
}
if err := s.RegisterPluginAPI("a"); err != nil {
t.Errorf("无 registrar 时 RegisterPluginAPI 应返回 nilgot %v", err)
}
s.RegisterStage(StageOnInput, func(*StageContext) error { return nil })
}
}()
}
wg.Wait()
}

View File

@ -20,6 +20,15 @@ type BuildConfig struct {
Replaces []string
}
// buildFailed 记录本次构建是否有平台失败。
//
// 为什么要它:这两个构建函数遇到错误只是 Printf 后 return而 cmdBuild 返回
// void于是**构建失败却以 0 退出**。调用方批量重编脚本、CI、发版脚本
// 只能靠翻日志发现失败——实测中一个示例的 windows 目标编译失败,脚本却报
// 「17/17 全绿」,并因此少产出 16 个 .hmap。
// 判成功要看退出码,不能靠人读日志。
var buildFailed bool
func cmdBuild(args []string) {
// Read all config from plg.json first
plg, err := readPlgJSON("plg.json")
@ -70,6 +79,34 @@ func cmdBuild(args []string) {
return
}
// 项目可在 plg.json 里声明 sdk中版本或完整版本如 "1.2" / "1.2.1"
// 显式 --sdk-path / plg.json 的 sdk_path 优先 —— 那是直指源码目录,
// 常用于本机改 SDK 的联调场景。
if sdkPath == "" && strings.TrimSpace(plg.SDK) != "" {
dir, ver, err := ResolveSDKForProject(plg.SDK)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
sdkPath, plg.ResolvedSDK = dir, ver
fmt.Printf("[hmapdev] SDK %s项目声明 sdk=%s\n", ver, plg.SDK)
} else if sdkPath != "" && plg.ResolvedSDK == "" {
// 走的是显式路径:尽力记录它是哪版(读不到就不记,不因此失败)
plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(sdkPath))
}
// SDK 能力前置校验proc 桥的模板z_proc_gen.go会透传 InjectOptions.Priority
// 而旧版 SDK 没有这个字段。不校验的话,用户看到的是 z_proc_gen.go 里两条
// "opts.Priority undefined" 编译错误——错误信息指向生成物,完全看不出是 SDK 版本问题。
if sdkPath != "" && !sdkHasInjectPriority(sdkPath) {
fmt.Printf("error: 当前 SDK%s缺少 sdk.InjectOptions.Priority\n", plg.ResolvedSDK)
fmt.Printf(" 子进程模式proc 桥)的模板需要它来透传注入优先级 L1-L4。\n")
fmt.Printf(" 解决办法(二选一):\n")
fmt.Printf(" 1) 升级 SDKhmapdev sdk install <含该能力的版本> && hmapdev sdk use <版本>\n")
fmt.Printf(" 2) 用本地 SDK 源码hmapdev sdk install --from /path/to/homeagent-sdk\n")
os.Exit(1)
}
// Ensure go.mod exists with correct SDK path
sdkModule := ensureGoMod(plg, sdkPath)
@ -92,11 +129,18 @@ func cmdBuild(args []string) {
if bundle || len(targets) == 0 {
buildBundle(plg, outDir, sdkPath)
return
} else {
for _, t := range targets {
buildTarget(plg, t, outDir, sdkPath)
}
}
for _, t := range targets {
buildTarget(plg, t, outDir, sdkPath)
// 以非零码退出调用方批量重编、CI、发版脚本靠退出码判成败。
// 以前这里直接 return失败也退 0于是「构建失败」只能靠人翻日志发现——
// 实测中就因此把一次部分失败当成了全绿。
if buildFailed {
fmt.Println("error: 至少一个目标构建失败(详见上面日志)")
os.Exit(1)
}
}
@ -105,13 +149,33 @@ func cmdBuild(args []string) {
// 子进程模式下各平台产物同名plugin.bin——进程边界即 ABI 边界,
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
//
// **不含 windows**:插件只能运行在 homed 能跑的平台上,而 homed 已明确放弃
// Windows 原生支持(插件体系依赖 fd 继承 + 统一共享内存区的段内偏移,
// Windows 句柄模型无法表达。Windows 用户走 WSL2而 WSL2 就是 linux/amd64。
var allBundleTargets = []struct {
target string
entry string // 二进制在 zip 中的文件名
}{
{"linux/amd64", "plugin.bin.linux.amd64"},
{"darwin/amd64", "plugin.bin.darwin.amd64"},
{"windows/amd64", "plugin.bin.windows.amd64"},
}
// checkTargetSupported 在构建前拦下**已知不支持**的目标,给出可执行的报错。
//
// 为什么要有它:插件运行在 homed 的进程里,所以目标平台必须是 homed 能跑的。
// homed 已放弃 Windows 原生(原因:插件依赖 fd 继承与统一共享内存区段内偏移,
// Windows 句柄模型无法表达),却还去构建 windows 插件,结果是死在一句
// 「undefined: attachUnifiedShm」——看起来像代码 bug实际是平台策略。
// 这里换成明确的结论,并且**不静默跳过**:静默跳过会让人以为产出的包里包含 windows。
func checkTargetSupported(target string) error {
if strings.HasPrefix(target, "windows/") {
return fmt.Errorf("不支持 windows 插件目标:插件运行在 homed 内," +
"而 homed 已放弃 Windows 原生支持(插件体系依赖 fd 继承与统一共享内存区" +
"段内偏移解引用Windows 句柄模型无法表达。Windows 请用 WSL2——" +
"它就是 linux/amd64用 --target linux/amd64 即可")
}
return nil
}
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
@ -122,6 +186,7 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
runtimeCleanup, err := generateProcRuntime()
if err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
defer runtimeCleanup()
@ -132,9 +197,15 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
var binaries []binEntry
for _, bt := range allBundleTargets {
if err := checkTargetSupported(bt.target); err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
cfg, errMsg := resolveBuild(bt.target)
if cfg == nil {
fmt.Printf(" error: %s\n", errMsg)
buildFailed = true
return
}
@ -151,7 +222,10 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil {
// 单平台失败即整包失败bundle 少一个平台就是个坏包,
// 却仍会生成 .hmap 让人以为打包成功。
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
buildFailed = true
return
}
binaries = append(binaries, binEntry{src: outPath, zip: bt.entry})
@ -223,6 +297,10 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
if len(plg.Tags) > 0 {
m["tags"] = plg.Tags
}
// 记录「用哪版 SDK 编的」:插件产物与内核协议绑定,出问题时这是第一个要看的字段。
if plg.ResolvedSDK != "" {
m["sdk"] = plg.ResolvedSDK
}
data, _ := json.MarshalIndent(m, "", " ")
os.WriteFile("plugin.json", data, 0644)
}
@ -324,7 +402,28 @@ func ensureGoMod(plg *PlgConfig, sdkPath string) string {
}
keep = append(keep, line)
}
if alreadyExists {
// 同步 require 版本replace 指向 1.2.1 而 require 还写 1.2.0 是自相矛盾的
// —— 有人删掉 replace 就会静默退回旧版本去编(`go list -m` 报的也是假版本)。
// 以本次真正选中的版本为准改写 require 行。
requireChanged := false
if v := normalizeSDKVersion(plg.ResolvedSDK); v != "" {
want := "require " + sdkModule + " v" + v
for i, line := range keep {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, "require ") {
continue
}
parts := strings.Fields(t)
if len(parts) >= 3 && parts[1] == sdkModule {
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
if t != want {
keep[i] = indent + want
requireChanged = true
}
}
}
}
if alreadyExists && !requireChanged {
return sdkModule
}
keep = append(keep, replaceLine, "")
@ -395,8 +494,8 @@ func ensureSDKResolvable(plg *PlgConfig, sdkModule, sdkPath string) {
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
}
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK\n", sdkModule)
fmt.Printf(" plugindev sdk install latest # 装一份到 ~/.homeagent/plugindev/sdk\n")
fmt.Printf(" plugindev build --sdk-path <路径> # 或直接指定源码目录\n")
fmt.Printf(" hmapdev sdk install latest # 装一份到 ~/.homeagent/hmapdev/sdk\n")
fmt.Printf(" hmapdev build --sdk-path <路径> # 或直接指定源码目录\n")
}
}
@ -411,17 +510,12 @@ func findLocalSDK(sdkPath string) string {
candidates = append(candidates, abs)
}
}
// plugindev 自身所在位置往上三级tools/plugindev/plugindev → SDK 根)
// hmapdev 自身所在位置往上三级tools/hmapdev/hmapdev → SDK 根)
if self, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
}
// plugindev sdk use 选定的版本
store := os.Getenv("HOMEAGENT_SDK_DIR")
if store == "" {
if home, err := os.UserHomeDir(); err == nil {
store = filepath.Join(home, ".homeagent", "plugindev", "sdk")
}
}
// hmapdev sdk use 选定的版本(复用 sdkStore(),含改名前的旧目录回退)
store := sdkStore()
if store != "" {
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
if ver := strings.TrimSpace(string(d)); ver != "" {
@ -465,7 +559,7 @@ func resolveSDKPath(sdkPath string) string {
fmt.Printf("error: --sdk-path %q not a valid SDK\n", sdkPath)
os.Exit(1)
}
// Detect from plugindev's own location (internal dev)
// Detect from hmapdev's own location (internal dev)
self, err := os.Executable()
if err == nil {
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
@ -473,14 +567,8 @@ func resolveSDKPath(sdkPath string) string {
return cand
}
}
// Active SDK via plugindev sdk use
store := os.Getenv("HOMEAGENT_SDK_DIR")
if store == "" {
home, _ := os.UserHomeDir()
if home != "" {
store = filepath.Join(home, ".homeagent", "plugindev", "sdk")
}
}
// Active SDK via hmapdev sdk use
store := sdkStore()
if store != "" {
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
ver := strings.TrimSpace(string(d))
@ -492,7 +580,7 @@ func resolveSDKPath(sdkPath string) string {
}
}
}
fmt.Printf("error: cannot locate SDK. Use --sdk-path or 'plugindev sdk use'\n")
fmt.Printf("error: cannot locate SDK. Use --sdk-path or 'hmapdev sdk use'\n")
os.Exit(1)
return ""
}
@ -544,6 +632,13 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
}
defer runtimeCleanup()
// 已知未实现的目标在编译前拦下,给可执行的报错(见 checkTargetSupported
if err := checkTargetSupported(target); err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
// Auto-link thirdpart/ contents + source_dirs + replace targets
thirdpartCleanup := linkThirdpart(plg, target)
defer thirdpartCleanup()
@ -562,6 +657,7 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil {
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
buildFailed = true
return
}
@ -785,3 +881,22 @@ func linkThirdpart(plg *PlgConfig, target string) func() {
os.Remove(importFile)
}
}
// sdkHasInjectPriority 报告该 SDK 源码是否已具备 InjectOptions.Priority
// proc 桥透传注入优先级所必需的能力SDK 开发期与已发布版本可能不一致)。
func sdkHasInjectPriority(sdkPath string) bool {
data, err := os.ReadFile(filepath.Join(sdkPath, "sdk", "plugin.go"))
if err != nil {
return true // 读不到就不拦(不在校验范围内)
}
src := string(data)
i := strings.Index(src, "type InjectOptions struct")
if i < 0 {
return true
}
seg := src[i:]
if j := strings.Index(seg, "\n}"); j > 0 {
seg = seg[:j]
}
return strings.Contains(seg, "Priority")
}

View File

@ -7,12 +7,12 @@ import (
"path/filepath"
"strings"
"github.com/JianFeeeee/homeagent-sdk/tools/plugindev/yaegi"
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/yaegi"
)
// tmplLuaDebug is the temporary Lua debug script template
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
-- Generated by plugindev debug --lua
-- Generated by hmapdev debug --lua
sdk = require("sdk")
local ok, plugin = pcall(dofile, "main.lua")
if not ok then
@ -115,7 +115,7 @@ func debugLua(dir, sdkPath, luaPath string) {
luaBin, err := exec.LookPath("lua")
if err != nil {
fmt.Println("error: lua interpreter not found in PATH")
fmt.Println(" install Lua 5.1+ or use plugindev build to compile your plugin")
fmt.Println(" install Lua 5.1+ or use hmapdev build to compile your plugin")
os.Exit(1)
}

View File

@ -34,6 +34,18 @@ type PlgConfig struct {
GoVersion string `json:"go_version,omitempty"`
Replaces map[string]string `json:"replaces,omitempty"`
SourceDirs []string `json:"source_dirs,omitempty"`
// SDK 声明本插件针对的 SDK **接口版本**(中版本或完整版本,如 "1.2" / "1.2.1")。
//
// 为何需要:工具链存储里可能装有多个 SDK 版本,而插件产物与内核是协议绑定的——
// 不给声明就只能猜(旧行为是直接用 current谁改过 current 就拿谁的版本编,
// 出错时表现为莫名其妙的编译错误)。写中版本表示「只要 1.2 这条接口线,
// 补丁由工具链挑最新」patch 只含工具链/打包修复,接口不变,见 README 版本语义)。
SDK string `json:"sdk,omitempty"`
// ResolvedSDK 是本次构建实际选中的 SDK 版本build 按 SDK 声明解析后回填),
// 只写进产物里的 plugin.json便于事后追溯「这个 .hmap 是哪版 SDK 编的」。
ResolvedSDK string `json:"-"`
}
// TargetList parses the Targets string into a slice.
@ -63,7 +75,7 @@ type TemplateData struct {
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
//
// 为何必须写gitcode 的模块不在 proxy.golang.org 上,只 require 一个
// 版本号的 go.mod 配上缺失的 go.sum新用户第一次 `plugindev build`
// 版本号的 go.mod 配上缺失的 go.sum新用户第一次 `hmapdev build`
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
// 一个不存在的条目。有了本地 replacego 完全不需要 go.sum 条目。
SDKLocalPath string
@ -71,7 +83,7 @@ type TemplateData struct {
func cmdInit(args []string) {
if len(args) < 1 {
fmt.Println("Usage: plugindev init <name> [--lua] [--type remotedevice]")
fmt.Println("Usage: hmapdev init <name> [--lua] [--type remotedevice]")
os.Exit(1)
}
@ -159,6 +171,9 @@ func cmdInit(args []string) {
data.SDKModule = sdkMod
data.SDKVersion = "v" + sdkVer
data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/")
// 声明**完整版本号**SDK 版本跟随内核中版本、patch 位恒为 .0
// 一条内核线只对应一个 SDK 版本build 时按此解析,见 ResolveSDKForProject
data.Plg.SDK = normalizeSDKVersion(sdkVer)
}
if err := os.MkdirAll(dir, 0755); err != nil {
@ -192,7 +207,7 @@ func cmdInit(args []string) {
if isLua {
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
}
fmt.Printf(" cd %s && plugindev build\n", dir)
fmt.Printf(" cd %s && hmapdev build\n", dir)
}
// detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version.

View File

@ -14,7 +14,12 @@ import (
"time"
)
const sdkDirName = "plugindev/sdk"
const sdkDirName = "hmapdev/sdk"
// legacySDKDirName 是改名前的存储目录。工具链在 1.2.0 从 plugindev 改名 hmapdev
// 已装过旧版的机器上 SDK 仍在旧路径,直接换名会让它找不到已装 SDK
// (表现为「没有活动版本」)。新目录不存在而旧目录存在时沿用旧目录。
const legacySDKDirName = "plugindev/sdk"
// sdkStore returns the root directory for stored SDK versions.
func sdkStore() string {
@ -26,7 +31,15 @@ func sdkStore() string {
fmt.Printf("error: cannot determine home directory: %v\n", err)
os.Exit(1)
}
return filepath.Join(home, ".homeagent", sdkDirName)
dir := filepath.Join(home, ".homeagent", sdkDirName)
if _, err := os.Stat(dir); err != nil {
if legacy := filepath.Join(home, ".homeagent", legacySDKDirName); legacy != "" {
if _, err := os.Stat(legacy); err == nil {
return legacy
}
}
}
return dir
}
func sdkCurrentDir() string {
@ -45,18 +58,39 @@ func cmdSDK(args []string) {
sdkHelp()
return
}
// install --from <本地目录> [version]:用本地 SDK 源码装一个版本并激活。
if args[0] == "install" {
from := ""
rest := []string{}
for i := 1; i < len(args); i++ {
if args[i] == "--from" && i+1 < len(args) {
from = args[i+1]
i++
continue
}
rest = append(rest, args[i])
}
if from != "" {
version := ""
if len(rest) > 0 && rest[0] != "latest" {
version = rest[0]
}
cmdSDKInstallFromDir(from, version)
return
}
}
switch args[0] {
case "list":
cmdSDKList()
case "install":
if len(args) < 2 {
fmt.Println("Usage: plugindev sdk install <version>")
fmt.Println("Usage: hmapdev sdk install <version>")
os.Exit(1)
}
cmdSDKInstall(args[1])
case "use":
if len(args) < 2 {
fmt.Println("Usage: plugindev sdk use <version>")
fmt.Println("Usage: hmapdev sdk use <version>")
os.Exit(1)
}
cmdSDKUse(args[1])
@ -72,7 +106,7 @@ func cmdSDK(args []string) {
}
func sdkHelp() {
fmt.Print(`Usage: plugindev sdk <command>
fmt.Print(`Usage: hmapdev sdk <command>
Manage installed HomeAgent SDK versions.
@ -85,9 +119,10 @@ Commands:
latest Show the latest available version from remote
Examples:
plugindev sdk install v0.7.1
plugindev sdk install latest
plugindev sdk use v0.7.1
hmapdev sdk install v0.7.1
hmapdev sdk install latest
hmapdev sdk install v0.7.1
hmapdev sdk install --from /path/to/homeagent-sdk # 用本地源码SDK 开发时用sdk use v0.7.1
`)
}
@ -127,10 +162,55 @@ func cmdSDKList() {
fmt.Printf(" %s %s\n", mark, v)
}
if current == "" {
fmt.Println("\nNo version active. Use 'plugindev sdk use <version>' to set one.")
fmt.Println("\nNo version active. Use 'hmapdev sdk use <version>' to set one.")
}
}
// cmdSDKInstallFromDir 从**本地 SDK 源码目录**安装一个版本。
//
// 为什么需要它:`install` 只能从 Release 归档下载,而 SDK 开发时的新能力
// (例如 `InjectOptions.Priority` 这类 proc 桥要透传的字段)往往还没发版 ——
// 此时生成出来的插件工程会因为"引用的 SDK 还没有该字段"直接编译失败。
// 有 --from 才能"用本地源码当这个版本的 SDK",边改 SDK 边验证模板工程。
func cmdSDKInstallFromDir(src, version string) {
store := sdkStore()
if err := os.MkdirAll(store, 0755); err != nil {
fmt.Printf("error: create SDK store %s: %v\n", store, err)
os.Exit(1)
}
if version == "" {
version = readMetaVersion(src)
}
if version == "" {
fmt.Printf("error: cannot determine version from %s/meta/meta.go\n", src)
os.Exit(1)
}
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if _, err := os.Stat(filepath.Join(src, "go.mod")); err != nil {
fmt.Printf("error: %s 看起来不是 SDK 源码目录(缺 go.mod\n", src)
os.Exit(1)
}
dest := sdkVersionDir(version)
_ = os.RemoveAll(dest)
if err := copyDir(src, dest); err != nil {
fmt.Printf("error: copy %s -> %s: %v\n", src, dest, err)
os.Exit(1)
}
// 源码目录里的开发产物不该带进 store。
for _, junk := range []string{".git", "dist", "build"} {
_ = os.RemoveAll(filepath.Join(dest, junk))
}
fmt.Printf("Installed SDK %s from %s\n", version, src)
fmt.Printf(" %s\n", dest)
if err := os.WriteFile(filepath.Join(store, "current"), []byte(version), 0644); err != nil {
fmt.Printf("error: activate %s: %v\n", version, err)
os.Exit(1)
}
fmt.Printf("Activated SDK %s\n", version)
}
// cmdSDKInstall downloads and installs an SDK version from Release archive.
func cmdSDKInstall(version string) {
store := sdkStore()
@ -292,7 +372,7 @@ func cmdSDKUse(version string) {
verDir := sdkVersionDir(version)
if _, err := os.Stat(verDir); os.IsNotExist(err) {
fmt.Printf("SDK version %s is not installed.\n", version)
fmt.Printf("Install it first: plugindev sdk install %s\n", version)
fmt.Printf("Install it first: hmapdev sdk install %s\n", version)
os.Exit(1)
}
setCurrentVersion(store, version)
@ -305,7 +385,7 @@ func cmdSDKPath() {
current := resolveCurrentVersion(store)
if current == "" {
fmt.Println("No active SDK version set.")
fmt.Println("Use 'plugindev sdk use <version>' to set one.")
fmt.Println("Use 'hmapdev sdk use <version>' to set one.")
os.Exit(1)
}
fmt.Println(sdkVersionDir(current))
@ -440,21 +520,21 @@ func parseSemver(tag string) [3]int {
}
// activeSDKRoot returns the path to the active SDK root.
// It replaces the old runtime.Caller(0) approach so plugindev can work
// It replaces the old runtime.Caller(0) approach so hmapdev can work
// independently of its own build location.
func activeSDKRoot() string {
store := sdkStore()
current := resolveCurrentVersion(store)
if current == "" {
fmt.Printf("error: no active SDK version set\n")
fmt.Printf(" Install one: plugindev sdk install latest\n")
fmt.Printf(" Or set one: plugindev sdk use <version>\n")
fmt.Printf(" Install one: hmapdev sdk install latest\n")
fmt.Printf(" Or set one: hmapdev sdk use <version>\n")
os.Exit(1)
}
root := sdkVersionDir(current)
if _, err := os.Stat(root); os.IsNotExist(err) {
fmt.Printf("error: active SDK version %s not found at %s\n", current, root)
fmt.Printf(" Reinstall: plugindev sdk install %s\n", current)
fmt.Printf(" Reinstall: hmapdev sdk install %s\n", current)
os.Exit(1)
}
return root
@ -507,5 +587,3 @@ func readMetaVersion(sdkRoot string) string {
}
return "0.0.0"
}

View File

@ -1,4 +1,4 @@
module github.com/JianFeeeee/homeagent-sdk/tools/plugindev
module gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev
go 1.21.0

80
tools/hmapdev/main.go Normal file
View File

@ -0,0 +1,80 @@
package main
import (
"fmt"
"io"
"os"
"runtime"
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
func main() {
if len(os.Args) < 2 {
help()
return
}
switch os.Args[1] {
case "init":
cmdInit(os.Args[2:])
case "build":
cmdBuild(os.Args[2:])
case "clean":
cmdClean(os.Args[2:])
case "debug":
cmdDebug(os.Args[2:])
case "sdk":
cmdSDK(os.Args[2:])
case "version", "-v", "--version":
printVersion()
default:
help()
}
}
// printVersion 输出工具链自身的版本身份。
//
// 为何必须有:此前工具链不报版本,而插件产物与内核是**协议绑定**的——
// 手里是哪一版工具链、能不能配当前内核,只能靠翻文件名或猜。
// 版本号来自 meta.Version与 SDK 发布同源,由 -ldflags -X 注入);
// lnflags 未注入时它是源码里的默认值,此时提示它可能是开发构建。
func printVersion() {
printVersionTo(os.Stdout)
}
// printVersionTo 把版本身份写到 w抽出来是为了能被测试钉住
func printVersionTo(w io.Writer) {
fmt.Fprintf(w, "hmapdev %s\n", meta.Version)
fmt.Fprintf(w, " SDK 模块: %s\n", "gitcode.com/JianFeeeee/homeagent-sdk")
if meta.Commit != "" && meta.Commit != "unknown" {
fmt.Fprintf(w, " 构建提交: %s\n", meta.Commit)
}
if meta.BuildTime != "" && meta.BuildTime != "unknown" {
fmt.Fprintf(w, " 构建时间: %s\n", meta.BuildTime)
}
fmt.Fprintf(w, " 构建用 Go: %s\n", runtime.Version())
fmt.Fprintf(w, " 可执行文件: %s\n", os.Args[0])
}
func help() {
fmt.Print(`HomeAgent Plugin Dev Tool
Usage:
hmapdev version Print toolchain version
hmapdev init <name> Scaffold a new plugin project
hmapdev init <name> --lua Create Lua plugin
hmapdev init <name> --type remotedevice
Create C remote device adapter
hmapdev build [flags] Compile and package plugin
hmapdev clean Clean build/dist artifacts
hmapdev debug [dir] Interpret and debug plugin source
hmapdev sdk <command> Manage SDK versions
Flags:
--outdir Output directory (default: dist)
--target Target OS/arch (e.g. linux/amd64), repeatable
--lua Create Lua plugin (for init)
--type Project type: "remotedevice" (for init)
-t Alias for --type
`)
}

View File

@ -0,0 +1,57 @@
package main
import (
"bytes"
"strings"
"testing"
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
// 工具链必须能报出自己的版本。
//
// 为什么值得钉住:插件产物与内核是协议绑定的,「手里是哪一版工具链」直接决定
// 产物能不能建链;此前既没有 version 子命令,`-ldflags -X meta.Version` 也因为
// meta 包没被链接而**静默无效**(表现为报不出任何版本)。
func TestPrintVersionReportsInjectedVersion(t *testing.T) {
origVersion, origCommit := meta.Version, meta.Commit
defer func() { meta.Version, meta.Commit = origVersion, origCommit }()
// 模拟 -ldflags 注入后的取值
meta.Version = "9.9.9"
meta.Commit = "deadbee"
var buf bytes.Buffer
printVersionTo(&buf)
out := buf.String()
if !strings.Contains(out, "9.9.9") {
t.Fatalf("版本号未出现在输出里(-X 注入会失效):\n%s", out)
}
if !strings.Contains(out, "hmapdev") {
t.Fatalf("输出里没有工具名:\n%s", out)
}
if !strings.Contains(out, "deadbee") {
t.Fatalf("提交号未出现在输出里:\n%s", out)
}
if !strings.Contains(out, "HomeAgent") && !strings.Contains(out, "homeagent-sdk") {
t.Fatalf("输出里没有 SDK 模块标识:\n%s", out)
}
}
// 未注入时(源码默认值)也必须能报——否则开发构建和发版构建长得一样。
func TestPrintVersionWorksWithoutInjection(t *testing.T) {
origCommit, origBuildTime := meta.Commit, meta.BuildTime
defer func() { meta.Commit, meta.BuildTime = origCommit, origBuildTime }()
meta.Commit, meta.BuildTime = "unknown", "unknown"
var buf bytes.Buffer
printVersionTo(&buf)
out := buf.String()
if !strings.Contains(out, meta.Version) {
t.Fatalf("未注入时应报出源码默认版本 %q:\n%s", meta.Version, out)
}
if strings.Contains(out, "unknown") {
t.Fatalf("unknown 字段不应出现在输出里(噪声):\n%s", out)
}
}

View File

@ -56,7 +56,7 @@ const procGenFile = "z_proc_gen.go"
// generateProcRuntime 把子进程运行时(平台无关主体 + 两个平台挂载实现)
// 写入插件目录,返回清理函数。
func generateProcRuntime() (func(), error) {
// 清理历史 C ABI 产物:旧版 plugindev 生成过这两个文件,残留下来会与
// 清理历史 C ABI 产物:旧版 hmapdev原名 plugindev生成过这两个文件,残留下来会与
// 本模板的 main 冲突。无需人工清理就能从旧版升级。
for _, stale := range []string{"z_bridge_gen.go", "z_entry.c"} {
os.Remove(stale)

View File

@ -92,15 +92,21 @@ func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
required := []string{
// 注册面
"tool.register", "stage.register", "output.register", "api.register", "input.register",
// IO 注入
"io.injectText", "io.injectInterrupt", "io.injectTextNoMem", "io.injectInputSync",
// IO 注入io.injectTextNoMem 见下方 deprecated内核保留为兼容旧二进制
// 当前模板改走 io.injectText + NoMemory 标志位,不再发那个 id
"io.injectText", "io.injectInterrupt", "io.injectInputSync",
"io.setToolBlocks",
// 多模态注入1.1.0 新增)。漏接线的后果是插件调 InjectInputMedia 静默无效果:
// 模板不发这个 RPC内核也就永远收不到而两边都不报错。
"io.injectMedia", "io.injectMediaSync", "io.injectInterruptMedia",
// 生命周期
"lifecycle.autoRestart",
// 图记忆
"memory.recall", "memory.commit", "memory.introspect", "memory.merge", "memory.purge",
// 文档记忆
"doc.query", "doc.insert", "doc.remove", "doc.stats",
// 文档媒体1.1.0 新增)
"doc.insertWithMedia",
// 知识库
"knowledge.search", "knowledge.add", "knowledge.list",
// 文本记忆
@ -126,15 +132,37 @@ func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
t.Errorf("模板缺少 core method %q内核已提供插件侧未接线", m)
}
}
// 内核保留、但**当前模板不再发送**的 method id。
//
// 它们不是「公开接口新增却忘记接线」,而是刻意的向后兼容面:
// 内核必须继续接受用旧模板编出的插件二进制发来的 id而当前模板没有理由再发。
//
// 可复查的判据ba49dfd 之前的模板里 InjectTextNoMemory 发的就是
// "io.injectTextNoMem";注入标志位落地后它改走 "io.injectText" + NoMemory。
//
// 为何要单独列而不是直接从 required 删掉:这条守卫的价值在于「新接口必须接线」,
// 而把「内核有、模板就必须发」当不变量,会让它常驻误报——常驻误报的守卫迟早
// 被人习惯性忽略,那时真漏接线也就没人看见了。
deprecated := map[string]string{
"io.injectTextNoMem": "旧模板经此表达「不进记忆」;现由 io.injectText + NoMemory 表达",
}
// 反向保护allowlist 条目一旦又出现在模板里,说明它已过期,必须删掉,
// 否则这里会悄悄变成「永久豁免」的垃圾抽屉。
for m, why := range deprecated {
if strings.Contains(src, `"`+m+`"`) {
t.Errorf("deprecated 里的 %q 又出现在模板里(%s——条目已过期请从 deprecated 移除", m, why)
}
}
}
// 模板必须处理内核发来的全部 7 个调用(原 C ABI 的 7 个 //export)。
// 模板必须处理内核发来的全部调用(含无法 JSON 序列化的 Cleaner 回调)。
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
src := loadProcTemplate(t)
for _, m := range []string{
"handshake",
"plugin.init", "plugin.start", "plugin.stop",
"tool.invoke", "stage.invoke", "output.invoke",
"tool.invoke", "cleaner.invoke", "stage.invoke", "output.invoke",
} {
if !strings.Contains(src, `case "`+m+`"`) {
t.Errorf("模板未处理内核调用 %q", m)
@ -142,6 +170,45 @@ func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
}
}
// 工具调用的 payload 必须走内核标定的**调用帧**funccall 模型)。
//
// 共享内存是内核内部实现(插件作者只看到普通 map但模板必须在传输层
// 正确读写 frame / args_len / result_ref。漏接线的后果很隐蔽参数被静默
// 丢弃、结果只走内联,性能退化而不报错。
func TestProcTemplate_ToolInvokeUsesSharedRef(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{"frame", "args_len", "result_ref"} {
if !strings.Contains(src, want) {
t.Errorf("模板的 tool.invoke 必须处理 %qpayload 走内核标定的调用帧)", want)
}
}
}
// 模板必须通过 arena.alloc / arena.free 向内核申请与归还共享内存。
//
// 共享内存是内核独占管理的**内部实现**:插件不能自己维护分配游标。
// 历史上两版跨进程分配器bump 游标 / 模板内位图 CAS都因为把可变
// 分配状态放在共享内存里而出竞态,所以这里做回归保护。
func TestProcTemplate_UsesKernelArenaRPC(t *testing.T) {
src := loadProcTemplate(t)
for _, m := range []string{`"arena.alloc"`, `"arena.free"`} {
if !strings.Contains(src, m) {
t.Errorf("模板缺少内核共享内存 RPC %s插件必须向内核申请/归还)", m)
}
}
// 禁止插件侧再出现本地分配器符号。
//
// 只查代码不查注释:注释里会解释“为什么不再这么做”。
code := stripComments(t, src)
for _, forbidden := range []string{"arenaUsed", "arenaWrite"} {
if strings.Contains(code, forbidden) {
t.Errorf("模板不应再出现插件侧分配器 %q共享内存由内核独占管理", forbidden)
}
}
}
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
//
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
@ -276,10 +343,11 @@ func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
}
}
// 协议与共享段版本不匹配必须拒绝,不得半兼容运行。
// 协议与共享内存区域版本/魔数不匹配必须拒绝,不得半兼容运行。
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "共享段魔数不匹配"} {
// §13.1 起共享段合并为单一「统一区域」,魔数校验文案随之更新。
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "统一区域魔数不匹配"} {
if !strings.Contains(src, want) {
t.Errorf("握手应校验并拒绝 %q", want)
}

View File

@ -0,0 +1,135 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// sdkVersionEntry 是本地 SDK 存储里的一个版本。
//
// Dir 是存储目录名(历史上有 "v0.8.0" 与 "1.2.0" 两种写法都出现过,所以
// 目录名与规范化版本号要分开存Version 是去掉 v 前缀的 x.y.z。
type sdkVersionEntry struct {
Dir string
Version string
}
// normalizeSDKVersion 去掉常见的前缀写法,得到 x.y.z。
func normalizeSDKVersion(v string) string {
return strings.TrimPrefix(strings.TrimSpace(v), "v")
}
// parseSDKVersion 解析 x.y.z / x.y后者补 0
func parseSDKVersion(v string) (maj, min, patch int, ok bool) {
v = normalizeSDKVersion(v)
parts := strings.Split(v, ".")
if len(parts) < 2 || len(parts) > 3 {
return 0, 0, 0, false
}
nums := make([]int, 0, 3)
for _, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return 0, 0, 0, false
}
nums = append(nums, n)
}
for len(nums) < 3 {
nums = append(nums, 0)
}
return nums[0], nums[1], nums[2], true
}
// compareSDKVersion 比较两个 x.y.za<b 返回 -1相等 0a>b 返回 1
func compareSDKVersion(a, b string) int {
amaj, amin, apat, aok := parseSDKVersion(a)
bmaj, bmin, bpat, bok := parseSDKVersion(b)
if !aok || !bok {
return strings.Compare(normalizeSDKVersion(a), normalizeSDKVersion(b))
}
for _, d := range [][2]int{{amaj, bmaj}, {amin, bmin}, {apat, bpat}} {
switch {
case d[0] < d[1]:
return -1
case d[0] > d[1]:
return 1
}
}
return 0
}
// listInstalledSDKs 列出存储里已安装的 SDK按版本升序。
func listInstalledSDKs() []sdkVersionEntry {
store := sdkStore()
entries, err := os.ReadDir(store)
if err != nil {
return nil
}
var out []sdkVersionEntry
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "current" || strings.HasPrefix(name, ".") {
continue
}
v := normalizeSDKVersion(name)
if _, _, _, ok := parseSDKVersion(v); !ok {
continue // 非版本目录(用户放别的东西进去时不误判)
}
out = append(out, sdkVersionEntry{Dir: name, Version: v})
}
sort.Slice(out, func(i, j int) bool { return compareSDKVersion(out[i].Version, out[j].Version) < 0 })
return out
}
// ResolveSDKForProject 按项目声明的 SDK 版本plg.json 的 "sdk" 字段)在本地存储里定位 SDK。
//
// 声明必须是**完整版本号**x.y.z如 "1.2.0"SDK 的版本纪律是「跟随内核中版本,
// patch 位恒为 .0」(内核的 patch 不碰公开接口 → SDK 不跟版),所以一条内核线
// 只对应一个 SDK 版本号,写 "1.2" 这种区间写法既不必要、又容易让人以为
// 「同一条线里还能挑不同 SDK」。工具链直接拒它顺便把这条规矩说清楚。
//
// 找不到时必须报**可执行**的错误:列出已装版本 + 可直接粘贴的安装命令 ——
// 只说 "not found" 会让人以为是工具链坏了。
func ResolveSDKForProject(declared string) (dir, version string, err error) {
declared = normalizeSDKVersion(declared)
if strings.Count(declared, ".") != 2 {
return "", "", fmt.Errorf(
"plg.json 的 sdk 字段 %q 必须是完整版本号(如 \"1.2.0\")——\n"+
" SDK 版本跟随内核中版本、patch 位恒为 .0,一条内核线只有一个 SDK 版本", declared)
}
maj, min, pat, ok := parseSDKVersion(declared)
if !ok {
return "", "", fmt.Errorf("plg.json 的 sdk 字段 %q 不是合法版本号(写法:\"1.2.0\"", declared)
}
installed := listInstalledSDKs()
for i := range installed {
e := installed[i]
emaj, emin, epat, _ := parseSDKVersion(e.Version)
if emaj == maj && emin == min && epat == pat {
return filepath.Join(sdkStore(), e.Dir), e.Version, nil
}
}
// 未命中:给出可执行的下一步
var have []string
for _, e := range installed {
have = append(have, e.Version)
}
avail := "(存储里还没有任何 SDK"
if len(have) > 0 {
avail = "已安装:" + strings.Join(have, ", ")
}
return "", "", fmt.Errorf(
"项目声明 sdk=%s但本地 SDK 存储里没有这个版本;%s\n"+
" 安装hmapdev sdk install v%s\n"+
" 查看hmapdev sdk list",
declared, avail, declared)
}

View File

@ -0,0 +1,103 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// withSDKStore 把 SDK 存储指到临时目录sdkStore 读 HOME并造出给定版本目录。
func withSDKStore(t *testing.T, versions ...string) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
store := filepath.Join(home, ".homeagent", sdkDirName)
if err := os.MkdirAll(store, 0755); err != nil {
t.Fatal(err)
}
for _, v := range versions {
if err := os.MkdirAll(filepath.Join(store, v), 0755); err != nil {
t.Fatal(err)
}
}
}
// 项目声明中版本 → 挑该接口线里最新的补丁;声明完整版本 → 精确命中。
//
// 为什么允许中版本是关键判据patch 位只含工具链/打包修复(接口不变),
// 让项目声明 "1.2" 而不是死钉 "1.2.0",才能既跟得上工具链修复又不跨接口线。
func TestResolveSDKForProject(t *testing.T) {
t.Run("区间写法1.2)被拒并说明版本纪律", func(t *testing.T) {
// 判据SDK 的 patch 位恒为 .0 → 一条内核线只有一个 SDK 版本,
// 区间写法会让人误以为「同一条线里还能挑版本」,所以直接拒。
withSDKStore(t, "1.2.0")
_, _, err := ResolveSDKForProject("1.2")
if err == nil {
t.Fatal("1.2 这种区间写法应被拒绝")
}
for _, want := range []string{"完整版本号", "patch 位恒为 .0"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("拒绝理由里应说清规矩(缺少 %q: %v", want, err)
}
}
})
t.Run("完整版本精确命中", func(t *testing.T) {
withSDKStore(t, "1.2.0", "1.3.0")
_, ver, err := ResolveSDKForProject("1.2.0")
if err != nil || ver != "1.2.0" {
t.Fatalf("精确命中失败: ver=%s err=%v", ver, err)
}
})
t.Run("存储目录带 v 前缀也能命中", func(t *testing.T) {
withSDKStore(t, "v1.2.0")
dir, ver, err := ResolveSDKForProject("1.2.0")
if err != nil {
t.Fatal(err)
}
if ver != "1.2.0" || !strings.HasSuffix(dir, "v1.2.0") {
t.Fatalf("带 v 前缀的目录名未被识别: dir=%s ver=%s", dir, ver)
}
})
t.Run("未命中要给出可执行命令与已装清单", func(t *testing.T) {
withSDKStore(t, "1.2.0")
_, _, err := ResolveSDKForProject("2.0.0")
if err == nil {
t.Fatal("应报错")
}
msg := err.Error()
for _, want := range []string{"sdk=2.0.0", "hmapdev sdk install", "hmapdev sdk list", "1.2.0"} {
if !strings.Contains(msg, want) {
t.Fatalf("错误信息缺少 %q要可执行不能只说 not found:\n%s", want, msg)
}
}
})
t.Run("空存储也能给出安装指引", func(t *testing.T) {
withSDKStore(t)
_, _, err := ResolveSDKForProject("1.2.0")
if err == nil || !strings.Contains(err.Error(), "hmapdev sdk install v1.2.0") {
t.Fatalf("空存储时应提示装哪个版本: %v", err)
}
})
t.Run("非法声明直接拒绝", func(t *testing.T) {
withSDKStore(t, "1.2.0")
for _, bad := range []string{"abc", "1", "1.2", "1.2.3.4", "-1.2.0"} {
if _, _, err := ResolveSDKForProject(bad); err == nil {
t.Fatalf("非法版本 %q 应被拒绝(宁可报错也不许当通配符)", bad)
}
}
})
t.Run("非版本目录不参与匹配", func(t *testing.T) {
withSDKStore(t, "1.2.0", "backup-old", ".hidden", "current")
_, ver, err := ResolveSDKForProject("1.2.0")
if err != nil || ver != "1.2.0" {
t.Fatalf("杂项目录不应干扰: ver=%s err=%v", ver, err)
}
})
}

View File

@ -9,6 +9,7 @@ const tmplPlgJSON = `{
"description": "{{.Plg.Description}}",
"author": "{{.Plg.Author}}",
"entry": "{{.Plg.Entry}}",
"sdk": "{{.Plg.SDK}}",
"tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}],
"targets": "{{.Plg.Targets}}"
}
@ -48,6 +49,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
DisplayName: "示例配置", Description: "An example configuration key",
Category: "{{.Plg.Name}}",
})
// ---- 通道channel两个方向是分开的两件事 ----
//
// 入站 inputch ——「谁会往这个通道注入输入」。
// 凡是用 s.InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名,
// 都要在这里登记inputch 是内核最基本的**输入路由单位**,只有登记过的通道
// 才能被「划给驻留子resident sub-agent没登记就划分会失败inputch 未注册)。
// 只登记出站通道时内核会兜底登记同名 inputch **并打告警**(兼容老插件)。
chName := p.name
_ = s.RegisterInputChannel(chName, sdk.ChannelDef{NoMemory: true})
// 出站 output ——「output_send__<name> 的回复发给谁」。
// handler 收到 mappayload(string) / type(string) / meta(string|optional)。
_ = s.RegisterOutputChannel(chName, sdk.CapText, "示例通道(回复由此返回)",
sdk.ChannelDef{NoMemory: true}, func(args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"status": "ok"}, nil
})
tp := p.name + "_"
s.RegisterTool(tp+"hello", sdk.ToolDef{
Name: tp + "hello",
@ -147,6 +164,15 @@ const tmplMainLua = `-- {{.Plg.Name}} plugin
local plugin = { name = "{{.Plg.Name}}" }
function plugin.start(sdk)
sdk.log("info", "{{.Plg.Name}} starting...")
-- 通道入站与出站分开登记
-- 入站 inputch凡是用 sdk.inject_text/sdk.inject_interrupt(source, "<name>", ...) 注入的通道名
-- 都要登记只有登记过的通道才能被划给驻留子没登记会报 inputch 未注册
sdk.register_input_channel("{{.Plg.Name}}", { no_memory = true })
-- 出站 outputoutput_send__<name> 的回复由 handler 处理
sdk.register_output_channel("{{.Plg.Name}}", 1, "示例通道(回复由此返回)", { no_memory = true },
function(args) return { status = "ok" } end)
sdk.register_tool("{{.Plg.Name}}_hello", {
description = "A hello world tool",
parameters = { type = "object", properties = {} }
@ -511,9 +537,24 @@ const tmplReadme = `# {{.Plg.Name}}
## Build
` + "```bash" + `
plugindev build
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} |
defChannelDef描述该通道在记忆计算层的行为NoMemory: true = 该通道输入不进记忆
Cleaner = 计算层清洗后再向量化/提关键词原文不改
> 只登记出站通道却用同名通道注入输入时内核会兜底登记同名 inputch 并在日志里告警
> 兜底只为兼容老插件 请显式登记这是入站通道成为插件的明确意图
## Install
Upload the .hmap file through the Plugin Manager API.

View File

@ -25,9 +25,35 @@ import (
// ---- 协议常量(须与内核 internal/plugin/proc/protocol.go 一致)----
const procProtocolVersion = 1
// procProtocolVersion 必须与内核的 proc.ProtocolVersion 完全一致。
//
// v2内核→插件的 payload 改用调用帧tool/cleaner/output媒体块改走
// blocks_ref。v1 插件只读内联 args遇上 v2 内核会拿到空参数;反过来 v2
// 插件发 blocks_refv1 内核也会静默忽略。两边错配都不报错、只是静默失效,
// 所以靠这个常量在握手上显式拦下。
const procProtocolVersion = 2
// ---- 共享段布局(与内核 internal/plugin/proc/shm.go 一致)----
// ---- 统一共享内存区域布局(与内核 internal/plugin/proc/unified.go 一致)----
const (
unifiedMagic = 0x554D5352 // "UMSR" — Unified Memory Shared Region
unifiedVersion = 1
superBlockSize = 64
sbOffMagic = 0
sbOffVersion = 4
sbOffGeneration = 8
sbOffCapacity = 16
sbOffCtxOff = 20
sbOffCtxSize = 24
sbOffEvtOff = 28
sbOffEvtSize = 32
sbOffArenaOff = 36
sbOffArenaCap = 40
sbOffArenaUsed = 44
)
// ---- StageContext 段内部布局(与内核 internal/plugin/proc/shm.go 一致)----
const (
shmStageFieldCount = 18
@ -72,6 +98,201 @@ const (
flagResponseSet = 1
)
// SharedRef 跨进程共享内存描述符。
//
// ⚠️ 这是**内部实现细节**:插件开发者永远看不到它。公开 SDK 只暴露普通
// 字符串与 Map模板运行时在传输层按 payload 大小自动选择内联 JSON 还是
// 共享槽。直接使用 SharedRef 属于运行时内部行为,不是插件 API。
type SharedRef struct {
Offset uint32 `json:"offset"`
Length uint32 `json:"length"`
Generation uint32 `json:"generation"`
Flags uint32 `json:"flags"`
}
func (r SharedRef) IsZero() bool { return r.Offset == 0 && r.Length == 0 }
func (r SharedRef) Slice(data []byte) []byte {
if r.IsZero() || int(r.Offset)+int(r.Length) > len(data) {
return nil
}
return data[r.Offset : r.Offset+r.Length]
}
// SharedRef.Flags 语义位(须与内核 internal/plugin/proc/arena.go 一致)。
const (
sharedRefFlagJSON = 1 << 0 // 载荷是 JSON
sharedRefFlagExpand = 1 << 1 // 引用指向插件申请的扩容块
)
// region 是内核传入的统一共享区域 mmaphandshake 时设置)。
var region []byte
// arenaAlloc 向内核申请一块共享内存内核返回偏移与大小Length 为槽容量)。
//
// 分配器由内核独占管理(见内核 proc/arena.go插件只申请与归还
// 不做任何分配决策,因此不存在跨进程分配器的竞争。
func arenaAlloc(size uint32) (SharedRef, error) {
raw, err := callCore("arena.alloc", map[string]interface{}{"size": size})
if err != nil {
return SharedRef{}, err
}
var r struct {
Ref SharedRef `json:"ref"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return SharedRef{}, err
}
if r.Ref.IsZero() {
return SharedRef{}, fmt.Errorf("arena.alloc: 内核返回空引用")
}
return r.Ref, nil
}
// arenaFree 通知内核回收先前申请的共享内存。
func arenaFree(ref SharedRef) {
if ref.IsZero() {
return
}
callCoreVoid("arena.free", map[string]interface{}{"ref": ref})
}
// ---- 调用帧funccall 模型)辅助 ----
//
// 工具调用/清洗由内核发起:内核标定一块内存帧交给插件,插件在帧内工作,
// 只有结果超出内核预留的预算时才向内核申请扩容块。
// frameInput 返回帧内的输入段(内核写入的参数/输入文本)。
func frameInput(frame SharedRef, inputLen uint32) []byte {
if frame.IsZero() || int(inputLen) > len(frame.Slice(region)) {
return nil
}
return frame.Slice(region)[:inputLen]
}
// frameOutput 尝试把 payload 写进帧的结果区(帧内 [inputLen, frame.Length))。
// 放不下时返回错误,由调用方决定是否申请扩容块。
func frameOutput(frame SharedRef, inputLen uint32, payload []byte, jsonFlag bool) (SharedRef, error) {
if frame.IsZero() {
return SharedRef{}, fmt.Errorf("无调用帧")
}
area := frame.Slice(region)
start := int(inputLen)
if start > len(area) || len(payload) > len(area)-start {
return SharedRef{}, fmt.Errorf("帧内空间不足(需 %d剩 %d", len(payload), len(area)-start)
}
copy(region[frame.Offset+uint32(start):], payload)
ref := SharedRef{
Offset: frame.Offset + uint32(start),
Length: uint32(len(payload)),
Generation: frame.Generation,
}
if jsonFlag {
ref.Flags |= sharedRefFlagJSON
}
return ref, nil
}
// arenaPut 申请一块扩容块并写入 payload引用上打 sharedRefFlagExpand
// 告知内核该块需单独归还(插件只申请,回收由内核做)。
func arenaPut(payload []byte, jsonFlag bool) (SharedRef, error) {
ref, err := arenaAlloc(uint32(len(payload)))
if err != nil {
return SharedRef{}, err
}
if len(payload) > int(ref.Length) {
arenaFree(ref)
return SharedRef{}, fmt.Errorf("扩容块容量不足(需 %d得 %d", len(payload), ref.Length)
}
copy(region[ref.Offset:ref.Offset+uint32(len(payload))], payload)
ref.Length = uint32(len(payload))
ref.Flags |= sharedRefFlagExpand
if jsonFlag {
ref.Flags |= sharedRefFlagJSON
}
return ref, nil
}
// inlinePayloadLimit 是走内联 JSON 的上限。
//
// 小 payload 走内联省两次 RPC申请 + 归还);大 payload 走共享内存,
// 避免把长文本塞进 NDJSON 帧。这是纯传输层优化,插件开发者无感。
const inlinePayloadLimit = 512
// putInArena 把 payload 写入内核分配的共享槽,返回可随业务 RPC 回传的引用。
//
// 任一步失败都返回 ok=false让调用方退回内联共享内存只是优化
// 池满或超限绝不能影响功能。
func putInArena(payload string) (SharedRef, bool) {
if len(payload) <= inlinePayloadLimit || len(region) == 0 {
return SharedRef{}, false
}
ref, err := arenaAlloc(uint32(len(payload)))
if err != nil {
return SharedRef{}, false
}
if len(payload) > int(ref.Length) || int(ref.Offset)+len(payload) > len(region) {
arenaFree(ref)
return SharedRef{}, false
}
copy(region[ref.Offset:ref.Offset+uint32(len(payload))], payload)
ref.Length = uint32(len(payload))
return ref, true
}
// putValueInArena 把任意值 JSON 序列化后放进共享槽,太小或 arena 不可用时
// 返回 false调用方退到内联
func putValueInArena(v interface{}) (SharedRef, bool) {
blob, err := json.Marshal(v)
if err != nil {
return SharedRef{}, false
}
return putInArena(string(blob))
}
// callWithText 按 payload 大小自动选择共享槽或内联,发起一次带文本的业务 RPC。
//
// 共享内存对插件开发者完全透明SDK 层只看得到 string。
func callWithText(method, source, channel, text string) (json.RawMessage, error) {
return callWithTextOpts(method, source, channel, text, sdk.InjectOptions{})
}
// callWithTextOpts 是 callWithText 的带标志位版本。
//
// 只在标志位非零时才写入参数:零值(记入记忆 + 不裁剪)与旧参数形态完全一致,
// 便于内核侧做兼容与灰度。
func callWithTextOpts(method, source, channel, text string, opts sdk.InjectOptions) (json.RawMessage, error) {
if ref, ok := putInArena(text); ok {
defer arenaFree(ref)
args := map[string]interface{}{
"source": source, "channel": channel, "text_ref": ref,
}
applyInjectOpts(args, opts)
return callCore(method, args)
}
args := map[string]interface{}{
"source": source, "channel": channel, "text": text,
}
applyInjectOpts(args, opts)
return callCore(method, args)
}
// applyInjectOpts 把 InjectOptions 摊进注入参数字典(仅非零值)。
func applyInjectOpts(args map[string]interface{}, opts sdk.InjectOptions) {
if opts.NoMemory {
args["no_memory"] = true
}
if opts.ContextPolicy != "" {
args["context_policy"] = opts.ContextPolicy
}
if opts.CleanerName != "" {
args["cleaner_name"] = opts.CleanerName
}
// priority 只对中断注入有意义(排队注入没有级别)。
if opts.Priority != "" {
args["priority"] = opts.Priority
}
}
// ---- 全局状态 ----
var (
@ -88,15 +309,20 @@ var (
handlerMu sync.RWMutex
toolHandlers = map[string]sdk.ToolHandler{}
toolCleaners = map[string]func(string) string{}
inputCleaners = map[string]func(string) string{}
stageHandlers = map[string]sdk.StageHandler{}
outputHandlers = map[string]sdk.ToolHandler{}
outputCleaners = map[string]func(string) string{}
shm []byte
// region 见文件头部 SharedRef 注释handshake 时设置)。
// 事件环§3.6fd 4 = 事件环段 mmapfd 5 = eventfd 读端
evtRingData []byte
evtNotifier evtWaiter
evtHandlers = map[uint32]func(*sdk.Event){}
evtRingData []byte
evtNotifier evtWaiter
evtHandlers = map[uint32]func(*sdk.Event){}
evtHandlerMu sync.RWMutex
)
@ -416,9 +642,14 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error {
handlerMu.Lock()
toolHandlers[toolName] = handler
if def.Cleaner != nil {
toolCleaners[toolName] = def.Cleaner
} else {
delete(toolCleaners, toolName)
}
handlerMu.Unlock()
return callCoreVoid("tool.register", map[string]interface{}{
"name": toolName, "def": def,
"name": toolName, "def": def, "has_cleaner": def.Cleaner != nil,
})
},
func(stage sdk.Stage, handler sdk.StageHandler) {
@ -437,10 +668,16 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
func(chName string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
handlerMu.Lock()
outputHandlers[chName] = handler
if def.Cleaner != nil {
outputCleaners[chName] = def.Cleaner
} else {
delete(outputCleaners, chName)
}
handlerMu.Unlock()
return callCoreVoid("output.register", map[string]interface{}{
"name": chName, "caps": caps, "desc": desc,
"def": map[string]interface{}{"NoMemory": def.NoMemory},
"def": map[string]interface{}{"NoMemory": def.NoMemory},
"has_cleaner": def.Cleaner != nil,
})
},
)
@ -454,9 +691,19 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
base.SetTextMemoryAPI(procTextMemory{})
base.SetPluginMgrAPI(procPluginMgr{})
base.SetInputChannelRegistrar(func(chName string, def sdk.ChannelDef) error {
handlerMu.Lock()
if def.Cleaner != nil {
inputCleaners[chName] = def.Cleaner
} else {
delete(inputCleaners, chName)
}
handlerMu.Unlock()
return callCoreVoid("input.register", map[string]interface{}{
"name": chName,
"def": map[string]interface{}{"NoMemory": def.NoMemory},
// 整个结构体:手写字段白名单会把新增字段静默丢掉
// ChannelDef.Cleaner 已标 json:"-",可以整体 marshal
"def": def,
"has_cleaner": def.Cleaner != nil,
})
})
return base
@ -464,17 +711,32 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
type procIO struct{}
// 下面六个三参数方法是 *Opts 变体的零值糖:记入记忆 + 不裁剪。
func (procIO) InjectText(s, c, t string) {
callCoreVoid("io.injectText", map[string]string{"source": s, "channel": c, "text": t})
procIO{}.InjectTextOpts(s, c, t, sdk.InjectOptions{})
}
func (procIO) InjectInterruptText(s, c, t string) {
callCoreVoid("io.injectInterrupt", map[string]string{"source": s, "channel": c, "text": t})
procIO{}.InjectInterruptTextOpts(s, c, t, sdk.InjectOptions{})
}
func (procIO) InjectTextNoMemory(s, c, t string) {
callCoreVoid("io.injectTextNoMem", map[string]string{"source": s, "channel": c, "text": t})
procIO{}.InjectTextOpts(s, c, t, sdk.InjectOptions{NoMemory: true})
}
func (procIO) InjectInputSync(s, c, t string) string {
raw, err := callCore("io.injectInputSync", map[string]string{"source": s, "channel": c, "text": t})
return procIO{}.InjectInputSyncOpts(s, c, t, sdk.InjectOptions{})
}
// 以下为带标志位的注入opts 决定这次注入是否进记忆、是否据此裁剪上下文。
func (procIO) InjectTextOpts(s, c, t string, opts sdk.InjectOptions) {
// 忽略错误:注入是 fire-and-forget与内联路径语义一致
_, _ = callWithTextOpts("io.injectText", s, c, t, opts)
}
func (procIO) InjectInterruptTextOpts(s, c, t string, opts sdk.InjectOptions) {
_, _ = callWithTextOpts("io.injectInterrupt", s, c, t, opts)
}
func (procIO) InjectInputSyncOpts(s, c, t string, opts sdk.InjectOptions) string {
raw, err := callWithTextOpts("io.injectInputSync", s, c, t, opts)
if err != nil {
return ""
}
@ -485,11 +747,80 @@ func (procIO) InjectInputSync(s, c, t string) string {
return r.Reply
}
func (procIO) SetToolBlocks(blocks []sdk.ContentBlock) {
if err := callCoreVoid("io.setToolBlocks", map[string]interface{}{"blocks": blocks}); err != nil {
// 媒体块经共享内存blocks_ref本地生成的图/音频是 base64 data URL
// 一张图可达数 MB内联时整份 base64 还要在 RPC 报文里再编码/再拷贝一遍。
// 更重要的是内容本体落在共享段里,插件回调才能就地改写。
// 小 payload如纯文本块仍走内联省一次 RPC。
args := map[string]interface{}{}
if ref, ok := putValueInArena(blocks); ok {
defer arenaFree(ref)
args["blocks_ref"] = ref
} else {
args["blocks"] = blocks
}
if err := callCoreVoid("io.setToolBlocks", args); err != nil {
log.Printf("SetToolBlocks: %v", err)
}
}
// 带媒体的注入:插件主动发起一轮带图/音频的对话。
// 与 SetToolBlocks 的区别是媒体在**本轮**就到模型手上,而不是等下一条 tool message。
func (procIO) InjectInputMedia(s, c, t string, blocks []sdk.ContentBlock) {
procIO{}.InjectInputMediaOpts(s, c, t, blocks, sdk.InjectOptions{})
}
func (procIO) InjectInputMediaSync(s, c, t string, blocks []sdk.ContentBlock) string {
return procIO{}.InjectInputMediaSyncOpts(s, c, t, blocks, sdk.InjectOptions{})
}
func (procIO) InjectInterruptMedia(s, c, t string, blocks []sdk.ContentBlock) {
procIO{}.InjectInterruptMediaOpts(s, c, t, blocks, sdk.InjectOptions{})
}
func (procIO) InjectInputMediaOpts(s, c, t string, blocks []sdk.ContentBlock, opts sdk.InjectOptions) {
args, free := mediaArgsOwned(s, c, t, blocks)
defer free()
applyInjectOpts(args, opts)
callCoreVoid("io.injectMedia", args)
}
func (procIO) InjectInputMediaSyncOpts(s, c, t string, blocks []sdk.ContentBlock, opts sdk.InjectOptions) string {
args, free := mediaArgsOwned(s, c, t, blocks)
defer free()
applyInjectOpts(args, opts)
raw, err := callCore("io.injectMediaSync", args)
if err != nil {
return ""
}
var r struct {
Reply string `json:"reply"`
}
json.Unmarshal(raw, &r)
return r.Reply
}
func (procIO) InjectInterruptMediaOpts(s, c, t string, blocks []sdk.ContentBlock, opts sdk.InjectOptions) {
args, free := mediaArgsOwned(s, c, t, blocks)
defer free()
applyInjectOpts(args, opts)
callCoreVoid("io.injectInterruptMedia", args)
}
// mediaArgsOwned 构造媒体注入参数,并返回释放函数。
//
// 为什么要返回释放函数而不是自己 defer调用方可能是需要等应答的同步调用
// injectMediaSync槽在应答到达前不能被回收否则内核读到的是已释放的内存。
func mediaArgsOwned(s, c, t string, blocks []sdk.ContentBlock) (map[string]interface{}, func()) {
args := map[string]interface{}{"source": s, "channel": c, "text": t}
ref, ok := putValueInArena(blocks)
if !ok {
args["blocks"] = blocks
return args, func() {}
}
args["blocks_ref"] = ref
return args, func() { arenaFree(ref) }
}
type procMemory struct{}
func (procMemory) Recall(q []string, d int) ([]sdk.Entity, []sdk.Relation, error) {
@ -555,7 +886,58 @@ func (procDocMemory) Query(text string, topK int) []*sdk.Doc {
return r.Docs
}
func (procDocMemory) Insert(d *sdk.Doc) error {
return callCoreVoid("doc.insert", map[string]interface{}{"doc": d})
return callCoreVoid("doc.insert", docInsertArgs(d))
}
// docInsertArgs 构造 doc.insert 参数:正文优先走共享内存。
//
// doc_content 可达几十 KB数 MB内联时整份要在 RPC 报文里再编码再拷贝一遍;
// 且内容本体落在共享段里,插件回调才能就地改写。小文档仍内联。
func docInsertArgs(d *sdk.Doc) map[string]interface{} {
if ref, ok := putValueInArena(d); ok {
defer arenaFree(ref)
return map[string]interface{}{"doc_ref": ref}
}
return map[string]interface{}{"doc": d}
}
// InsertWithMedia 写入文档并关联媒体。
//
// 内核会把 `[mime <短digest>] <描述>` 标记补进 Content 并挂上引用,回传的
// doc 带着补好的 Content/ID/MediaDigests——回写进 d 让调用方能拿到这些。
func (procDocMemory) InsertWithMedia(d *sdk.Doc, atts []sdk.MediaAttachment) error {
// 文档正文与附件(含媒体 data URL都优先走共享内存。
args := map[string]interface{}{}
var frees []func()
defer func() {
for _, f := range frees {
f()
}
}()
if ref, ok := putValueInArena(d); ok {
frees = append(frees, func() { arenaFree(ref) })
args["doc_ref"] = ref
} else {
args["doc"] = d
}
if ref, ok := putValueInArena(atts); ok {
frees = append(frees, func() { arenaFree(ref) })
args["attachments_ref"] = ref
} else {
args["attachments"] = atts
}
raw, err := callCore("doc.insertWithMedia", args)
if err != nil {
return err
}
var r struct {
Doc *sdk.Doc `json:"doc"`
}
if json.Unmarshal(raw, &r) == nil && r.Doc != nil {
*d = *r.Doc
}
return nil
}
func (procDocMemory) Remove(id string) {
callCoreVoid("doc.remove", map[string]string{"id": id})
@ -584,6 +966,13 @@ func (procKnowledge) Search(q string, topK int) ([]*sdk.Knowledge, error) {
return r.Results, nil
}
func (procKnowledge) Add(name, content string) error {
// 知识正文可达数十 KB优先走共享内存内容是 JSON 字符串)。
if ref, ok := putValueInArena(content); ok {
defer arenaFree(ref)
return callCoreVoid("knowledge.add", map[string]interface{}{
"name": name, "content_ref": ref,
})
}
return callCoreVoid("knowledge.add", map[string]string{"name": name, "content": content})
}
func (procKnowledge) List() ([]string, error) {
@ -895,10 +1284,27 @@ func handleKernelRequest(req *rpcRequest) {
case "tool.invoke":
var p struct {
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
Frame SharedRef `json:"frame"`
ArgsLen uint32 `json:"args_len"`
}
json.Unmarshal(req.Params, &p)
// 参数:内核标定帧的前段。只有直连 RPC 的调用方(无帧)才走
// 内联 Args——生产路径永远走帧。
args := p.Args
if !p.Frame.IsZero() {
if blob := frameInput(p.Frame, p.ArgsLen); len(blob) > 0 {
var decoded map[string]interface{}
if err := json.Unmarshal(blob, &decoded); err != nil {
respondErr(req.ID, fmt.Errorf("解析共享参数: %w", err))
return
}
args = decoded
}
}
handlerMu.RLock()
h, ok := toolHandlers[p.Name]
handlerMu.RUnlock()
@ -906,12 +1312,68 @@ func handleKernelRequest(req *rpcRequest) {
respondErr(req.ID, fmt.Errorf("未注册的工具: %s", p.Name))
return
}
res, err := h(p.Args)
res, err := h(args)
if err != nil {
respondErr(req.ID, err)
return
}
respond(req.ID, map[string]interface{}{"result": res})
blob, err := json.Marshal(res)
if err != nil {
respondErr(req.ID, fmt.Errorf("序列化结果: %w", err))
return
}
// 结果优先写进内核标定的帧;放不下才申请扩容块。
outRef, err := frameOutput(p.Frame, p.ArgsLen, blob, true)
if err != nil {
outRef, err = arenaPut(blob, true)
if err != nil {
respondErr(req.ID, fmt.Errorf("结果扩容失败: %w", err))
return
}
}
respond(req.ID, map[string]interface{}{"result_ref": outRef})
case "cleaner.invoke":
var p struct {
Scope string `json:"scope"`
Name string `json:"name"`
Frame SharedRef `json:"frame"`
InputLen uint32 `json:"input_len"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
respondErr(req.ID, fmt.Errorf("解析 Cleaner 参数: %w", err))
return
}
handlerMu.RLock()
var cleaner func(string) string
switch p.Scope {
case "tool":
cleaner = toolCleaners[p.Name]
case "input":
cleaner = inputCleaners[p.Name]
case "output":
cleaner = outputCleaners[p.Name]
}
handlerMu.RUnlock()
if cleaner == nil {
respondErr(req.ID, fmt.Errorf("%s %s 未注册 Cleaner", p.Scope, p.Name))
return
}
input := string(frameInput(p.Frame, p.InputLen))
output := cleaner(input)
// 结果优先写进内核标定的帧;放不下才申请扩容块。
outRef, err := frameOutput(p.Frame, p.InputLen, []byte(output), false)
if err != nil {
outRef, err = arenaPut([]byte(output), false)
if err != nil {
respondErr(req.ID, fmt.Errorf("结果扩容失败: %w", err))
return
}
}
respond(req.ID, map[string]interface{}{"text_ref": outRef})
case "stage.invoke":
handleStageInvoke(req)
@ -920,8 +1382,23 @@ func handleKernelRequest(req *rpcRequest) {
var p struct {
Channel string `json:"channel"`
Args map[string]interface{} `json:"args"`
Frame SharedRef `json:"frame"`
ArgsLen uint32 `json:"args_len"`
}
json.Unmarshal(req.Params, &p)
// 参数内核标定帧的前段§13.6)。只有直连 RPC 的调用方(无帧)
// 才走内联 Args——生产路径永远走帧大 payload 不再爆管道。
args := p.Args
if !p.Frame.IsZero() {
if blob := frameInput(p.Frame, p.ArgsLen); len(blob) > 0 {
var decoded map[string]interface{}
if err := json.Unmarshal(blob, &decoded); err != nil {
respondErr(req.ID, fmt.Errorf("解析共享输出参数: %w", err))
return
}
args = decoded
}
}
handlerMu.RLock()
h, ok := outputHandlers[p.Channel]
handlerMu.RUnlock()
@ -930,7 +1407,7 @@ func handleKernelRequest(req *rpcRequest) {
return
}
// 同步返回真实结果——内核据此告知模型成功/失败不再假成功§9.4
res, err := h(p.Args)
res, err := h(args)
if err != nil {
respondErr(req.ID, err)
return
@ -997,10 +1474,10 @@ func handleKernelRequest(req *rpcRequest) {
func handleHandshake(req *rpcRequest) {
var p struct {
Protocol int `json:"protocol"`
ShmVersion uint32 `json:"shm_version"`
ShmSize int `json:"shm_size"`
PluginName string `json:"plugin_name"`
EvtRingSize int `json:"evt_ring_size,omitempty"`
ShmVersion uint32 `json:"shm_version"`
ShmSize int `json:"shm_size"`
PluginName string `json:"plugin_name"`
EvtRingSize int `json:"evt_ring_size,omitempty"`
}
json.Unmarshal(req.Params, &p)
@ -1017,41 +1494,44 @@ func handleHandshake(req *rpcRequest) {
pluginName = p.PluginName
}
// 挂载 StageContext 共享段
// 传递机制按平台不同Unix 用继承的 fdWindows 用命名段),
// 由 z_proc_shm_*.go 承担——本文件保持平台无关
// 挂载统一共享内存区域
// fd 3 (Unix) / 命名对象 (Windows) 传给插件子进程,包含 SuperBlock +
// StageContext + EvtRing 两段。SuperBlock 记录各段的偏移与大小
if p.ShmSize > 0 {
m, err := attachStageShm(p.ShmSize)
m, err := attachUnifiedShm(p.ShmSize)
if err != nil {
respondErr(req.ID, fmt.Errorf("挂载共享段失败: %w", err))
respondErr(req.ID, fmt.Errorf("挂载统一共享区域失败: %w", err))
return
}
if got := binary.LittleEndian.Uint32(m[shmOffMagic:]); got != shmMagic {
respondErr(req.ID, fmt.Errorf("共享段魔数不匹配0x%x", got))
if got := binary.LittleEndian.Uint32(m[sbOffMagic:]); got != unifiedMagic {
respondErr(req.ID, fmt.Errorf("统一区域魔数不匹配(0x%x期望 0x%x", got, unifiedMagic))
return
}
shm = m
}
// 挂载事件环段 + 打开通知句柄§3.6
if p.EvtRingSize > 0 {
er, err := attachEvtRingShm(p.EvtRingSize)
if err != nil {
respondErr(req.ID, fmt.Errorf("挂载事件环段失败: %w", err))
return
ctxOff := binary.LittleEndian.Uint32(m[sbOffCtxOff:])
ctxSize := binary.LittleEndian.Uint32(m[sbOffCtxSize:])
evtOff := binary.LittleEndian.Uint32(m[sbOffEvtOff:])
evtSize := binary.LittleEndian.Uint32(m[sbOffEvtSize:])
// shm 指向 StageContext 段,后续代码用 shm[off...] 访问该段内部字段
shm = m[ctxOff : ctxOff+ctxSize]
// region 保存完整 mmap 区域。arena 的偏移与大小由内核在
// arena.alloc 的应答里下发,插件侧不再自己解析槽池布局。
region = m
// 挂载事件环段 + 打开通知句柄§13.1EvtRing 在统一区域内)
if p.EvtRingSize > 0 && evtSize > 0 {
er := m[evtOff : evtOff+evtSize]
if got := binary.LittleEndian.Uint32(er[evtOffMagic : evtOffMagic+4]); got != evtRingMagic {
respondErr(req.ID, fmt.Errorf("事件环魔数不匹配0x%x", got))
return
}
notifier, err := openEvtNotifier()
if err != nil {
respondErr(req.ID, fmt.Errorf("打开事件通知句柄失败: %w", err))
return
}
evtRingData = er
evtNotifier = notifier
go evtConsumerLoop()
}
if got := binary.LittleEndian.Uint32(er[evtOffMagic : evtOffMagic+4]); got != evtRingMagic {
respondErr(req.ID, fmt.Errorf("事件环魔数不匹配0x%x", got))
return
}
notifier, err := openEvtNotifier()
if err != nil {
respondErr(req.ID, fmt.Errorf("打开事件通知句柄失败: %w", err))
return
}
evtRingData = er
evtNotifier = notifier
go evtConsumerLoop()
}
respond(req.ID, map[string]interface{}{

View File

@ -10,31 +10,23 @@ import (
// Unix 侧共享段挂载:内核经 ExtraFiles 传入继承的 fd。
//
// fd 布局(与内核 internal/plugin/proc/plugin.go 的 ExtraFiles 顺序一致
// 统一共享内存区域布局§13.1
//
// fd 3 = StageContext 段memfd / 已 unlink 的临时文件
// fd 4 = 事件环段
// fd 5 = 事件通知Linux eventfd / macOS pipe 读端)
// fd 3 = 统一区域SuperBlock + StageContext + EvtRing
// fd 4 = 事件通知Linux eventfd / macOS pipe 读端)
//
// 继承的 fd 无需文件名,也不残留——这是选 memfd 而非 /dev/shm 的原因。
const (
fdStageShm = 3
fdEvtRingShm = 4
fdEvtNotifier = 5
fdUnifiedShm = 3
fdEvtNotifier = 4
)
// attachStageShm 挂载 StageContext 共享段
// attachUnifiedShm 挂载统一共享内存区域
//
// 各进程 mmap 到不同虚拟地址,段内一律用相对偏移而非指针,故仍能正确解引用
// (实验 2 已验证父子 mmap 基址不同时偏移解引用正确)。
func attachStageShm(size int) ([]byte, error) {
return syscall.Mmap(fdStageShm, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
}
// attachEvtRingShm 挂载事件环段。
func attachEvtRingShm(size int) ([]byte, error) {
return syscall.Mmap(fdEvtRingShm, 0, size,
func attachUnifiedShm(size int) ([]byte, error) {
return syscall.Mmap(fdUnifiedShm, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
}

View File

@ -13,7 +13,7 @@ import (
"github.com/traefik/yaegi/interp"
"github.com/traefik/yaegi/stdlib"
"github.com/JianFeeeee/homeagent-sdk/tools/plugindev/yaegi/mocksdk"
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/yaegi/mocksdk"
)
type YaegiDebugger struct {

View File

@ -83,18 +83,69 @@ type ToolResult struct {
}
type ToolDef struct {
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"`
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"`
ContextPolicy string `json:"context_policy,omitempty"`
}
// 上下文策略取值,与公共 SDK 一致。
const (
ContextPolicyNone = "none"
ContextPolicyPrune = "prune"
)
// InjectOptions 与公共 SDK 同构:声明一次注入是否记入记忆、是否据此裁剪上下文、
// 以及用哪个已注册的通道 cleaner 清洗注入内容。
type InjectOptions struct {
NoMemory bool
ContextPolicy string
CleanerName string
}
type IOInjector interface {
InjectInterruptText(source, channel, text string)
InjectText(source, channel, text string)
InjectTextNoMemory(source, channel, text string)
// InjectInputSync 注入输入事件并同步等待 agent 回复(无回复时返回空串)。
// 通道类插件qq / a2a 等)靠它完成「收到入站 → agent 处理 → 回复取回」闭环,
// 而 mock 此前只有带 flags 的 InjectInputSyncOpts、没有这个零值糖——
// 于是一个能在 plugin.bin 里编译通过、在 yaegi 下却调不通的方法就长住了。
InjectInputSync(source, channel, text string) string
// 1.1.0 媒体注入。与公共 SDK 同构:插件在 yaegi 下调得通的方法,
// 编成 plugin.bin 后必须也调得通,否则调试期与真实运行行为不一致。
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
SetToolBlocks(blocks []ContentBlock)
// 1.2.0 带标志位的注入,与公共 SDK 同构。
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)
}
// ContentBlock 与公共 SDK 同构OpenAI 多模态内容块格式)。
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
AudioURL *AudioURL `json:"audio_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
type AudioURL struct {
URL string `json:"url"`
}
type EventType string
@ -159,29 +210,33 @@ type mockSettings struct{ data map[string]interface{} }
func (s *mockSettings) Get(key string) (interface{}, error) {
v, ok := s.data[key]
if !ok { return nil, nil }
if !ok {
return nil, nil
}
return v, nil
}
func (s *mockSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil }
func (s *mockSettings) List(prefix string) ([]string, error) {
var ks []string
for k := range s.data {
if strings.HasPrefix(k, prefix) { ks = append(ks, k) }
if strings.HasPrefix(k, prefix) {
ks = append(ks, k)
}
}
return ks, nil
}
func (s *mockSettings) GetCore(key string) (interface{}, error) { return nil, nil }
func (s *mockSettings) SetCore(key string, value interface{}) error { return nil }
func (s *mockSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
func (s *mockSettings) GetPlugin(p, k string) (interface{}, error) { return nil, nil }
func (s *mockSettings) SetPlugin(p, k string, v interface{}) error { return nil }
func (s *mockSettings) GetCore(key string) (interface{}, error) { return nil, nil }
func (s *mockSettings) SetCore(key string, value interface{}) error { return nil }
func (s *mockSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
func (s *mockSettings) GetPlugin(p, k string) (interface{}, error) { return nil, nil }
func (s *mockSettings) SetPlugin(p, k string, v interface{}) error { return nil }
func (s *mockSettings) ListPlugin(p, prefix string) ([]string, error) { return nil, nil }
func (s *mockSettings) RegisterDef(def ConfigDef) {
logf("config def: %s = %s", def.Key, def.Default)
}
func (s *mockSettings) Defs(prefix string) []*ConfigDef { return nil }
func (s *mockSettings) Dump() map[string]interface{} { return s.data }
func (s *mockSettings) Plugins() []string { return nil }
func (s *mockSettings) Dump() map[string]interface{} { return s.data }
func (s *mockSettings) Plugins() []string { return nil }
type Entity struct {
Name string `json:"name"`
@ -195,10 +250,21 @@ type Relation struct {
Object string `json:"object"`
}
// Triple 与公共 SDK 同构。
//
// ❗字段名曾是 `Predicate`,而公共 SDK 一直叫 `Relation`。
// yaegi 解释器下插件写 `Relation:` 会报未知字段,写 `Predicate:` 则在
// 编成 plugin.bin 时报错——谁都不对。没人发现是因为没有任何代码
// 对着 mocksdk 编译,漂移不会被编译器抓到。
type Triple struct {
Subject string `json:"subject"`
Predicate string `json:"predicate"`
Object string `json:"object"`
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
SentenceText string `json:"sentence_text,omitempty"`
MediaDigests []string `json:"media_digests,omitempty"`
}
type MemoryAPI interface {
@ -212,37 +278,57 @@ type MemoryAPI interface {
type mockMemory struct{}
func (mockMemory) Recall(q []string, d int) ([]Entity, []Relation, error) { return nil, nil, nil }
func (mockMemory) Commit(t []Triple) error { return nil }
func (mockMemory) Introspect() (map[string]interface{}, error) { return map[string]interface{}{}, nil }
func (mockMemory) MergeEntities(s, t string) (int, error) { return 0, nil }
func (mockMemory) Purge(c map[string]string, m string) (int, error) { return 0, nil }
func (mockMemory) Commit(t []Triple) error { return nil }
func (mockMemory) Introspect() (map[string]interface{}, error) { return map[string]interface{}{}, nil }
func (mockMemory) MergeEntities(s, t string) (int, error) { return 0, nil }
func (mockMemory) Purge(c map[string]string, m string) (int, error) { return 0, nil }
type Doc struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Source string `json:"source"`
// 1.1.0:媒体字段。与公共 SDK 保持同构,否则插件在 yaegi 下跑得通、
// 编成 plugin.bin 却编不过(或反之)。
MediaDigests []string `json:"media_digests,omitempty"`
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
// MediaAttachment 与公共 SDK 同构:写入时给 Data+MIME引用已有内容时只给 Digest。
type MediaAttachment struct {
Digest string `json:"digest,omitempty"`
MIME string `json:"mime,omitempty"`
Data []byte `json:"data,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}
type DocMemoryAPI interface {
Query(text string, topK int) []*Doc
Insert(doc *Doc) error
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
Remove(id string)
Stats() map[string]interface{}
}
type mockDocMemory struct{}
func (mockDocMemory) Query(t string, k int) []*Doc { return nil }
func (mockDocMemory) Insert(doc *Doc) error { return nil }
func (mockDocMemory) Remove(id string) {}
func (mockDocMemory) Stats() map[string]interface{} { return nil }
func (mockDocMemory) Query(t string, k int) []*Doc { return nil }
func (mockDocMemory) Insert(doc *Doc) error { return nil }
func (mockDocMemory) InsertWithMedia(doc *Doc, atts []MediaAttachment) error {
logf("doc_insert_with_media: %d 份附件", len(atts))
return nil
}
func (mockDocMemory) Remove(id string) {}
func (mockDocMemory) Stats() map[string]interface{} { return nil }
type TextEvent struct {
Timestamp int64 `json:"timestamp"`
Role string `json:"role"`
Content string `json:"content"`
Source string `json:"source"`
// 1.1.0:附件。读回时内核从正文标记反解,写入时内核把标记并进正文。
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
type TextMemoryAPI interface {
@ -305,8 +391,8 @@ type LLMAPI interface {
type mockLLM struct{}
func (mockLLM) ListSources() []string { return nil }
func (mockLLM) SetSource(n string) error { return nil }
func (mockLLM) CurrentSource() string { return "" }
func (mockLLM) SetSource(n string) error { return nil }
func (mockLLM) CurrentSource() string { return "" }
type IOInjectorImpl struct{}
@ -319,27 +405,68 @@ func (IOInjectorImpl) InjectText(source, channel, text string) {
func (IOInjectorImpl) InjectTextNoMemory(source, channel, text string) {
logf("inject_text_no_memory: source=%s channel=%s", source, channel)
}
func (IOInjectorImpl) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
logf("inject_input_media: source=%s channel=%s blocks=%d", source, channel, len(blocks))
}
func (IOInjectorImpl) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
logf("inject_input_media_sync: source=%s channel=%s blocks=%d", source, channel, len(blocks))
return ""
}
func (IOInjectorImpl) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) {
logf("inject_interrupt_media: source=%s channel=%s blocks=%d", source, channel, len(blocks))
}
func (IOInjectorImpl) SetToolBlocks(blocks []ContentBlock) {
logf("set_tool_blocks: blocks=%d", len(blocks))
}
// ---- 带 InjectOptions 的注入 ----
func (IOInjectorImpl) InjectInputSync(source, channel, text string) string {
logf("inject_sync: source=%s channel=%s", source, channel)
return ""
}
func (IOInjectorImpl) InjectTextOpts(source, channel, text string, opts InjectOptions) {
logf("inject_text_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
}
func (IOInjectorImpl) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
logf("inject_interrupt_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
}
func (IOInjectorImpl) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
logf("inject_sync_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
return ""
}
func (IOInjectorImpl) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
logf("inject_input_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
}
func (IOInjectorImpl) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
logf("inject_input_media_sync_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
return ""
}
func (IOInjectorImpl) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
logf("inject_interrupt_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
}
type PluginSDK struct {
Name string
mu sync.RWMutex
toolDefs map[string]ToolDef
toolHandlers map[string]ToolHandler
Name string
mu sync.RWMutex
toolDefs map[string]ToolDef
toolHandlers map[string]ToolHandler
stageHandlers map[string]StageHandler
outChannels map[string]ToolHandler
Settings SettingsAPI
IO IOInjector
outChannels map[string]ToolHandler
Settings SettingsAPI
IO IOInjector
}
func New(name string) *PluginSDK {
return &PluginSDK{
Name: name,
toolDefs: make(map[string]ToolDef),
toolHandlers: make(map[string]ToolHandler),
Name: name,
toolDefs: make(map[string]ToolDef),
toolHandlers: make(map[string]ToolHandler),
stageHandlers: make(map[string]StageHandler),
outChannels: make(map[string]ToolHandler),
Settings: &mockSettings{data: map[string]interface{}{}},
IO: IOInjectorImpl{},
outChannels: make(map[string]ToolHandler),
Settings: &mockSettings{data: map[string]interface{}{}},
IO: IOInjectorImpl{},
}
}

View File

@ -1,49 +0,0 @@
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
help()
return
}
switch os.Args[1] {
case "init":
cmdInit(os.Args[2:])
case "build":
cmdBuild(os.Args[2:])
case "clean":
cmdClean(os.Args[2:])
case "debug":
cmdDebug(os.Args[2:])
case "sdk":
cmdSDK(os.Args[2:])
default:
help()
}
}
func help() {
fmt.Print(`HomeAgent Plugin Dev Tool
Usage:
plugindev init <name> Scaffold a new plugin project
plugindev init <name> --lua Create Lua plugin
plugindev init <name> --type remotedevice
Create C remote device adapter
plugindev build [flags] Compile and package plugin
plugindev clean Clean build/dist artifacts
plugindev debug [dir] Interpret and debug plugin source
plugindev sdk <command> Manage SDK versions
Flags:
--outdir Output directory (default: dist)
--target Target OS/arch (e.g. linux/amd64), repeatable
--lua Create Lua plugin (for init)
--type Project type: "remotedevice" (for init)
-t Alias for --type
`)
}

3
tools/vscode-hmapdev/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
out/
*.vsix

View File

@ -0,0 +1,5 @@
src/
tsconfig.json
node_modules/
out/test/
.gitignore

View File

@ -0,0 +1,69 @@
# hmapdev — HomeAgent 插件开发 VSCode 扩展
调试与构建 HomeAgent 插件工程的 IDE 支持:**plg.json 校验、SDK 版本解析、构建/运行、内核日志跟随**。
## 为什么需要它
插件的真实形态是「**独立子进程 + 内核侧握手**」,所以插件的三类问题几乎都在 IDE 之外发生:
1. **编不出来** —— 最常见的原因不是代码,而是项目没声明要用哪版 SDK工具链拿了存储里的
`current`(可能是陈旧的 `v0.8.0`),于是报一堆看不懂的 `undefined: sdk.XXX`
2. **编出来但起不来** —— 产物与内核**协议绑定**(协议版本 + 共享内存魔数),用错工具链编出来的
插件会在握手时被拒;
3. **起来了但行为不对** —— 真因往往只在内核日志里(建链失败、崩溃重启、工具报错)。
本扩展把这三件事拉进 IDE**先把「用哪版 SDK」摆到明面上**,再让构建/运行/看日志变成一条动作链。
## 功能
| 功能 | 说明 |
|---|---|
| **plg.json 诊断** | 必需字段;`sdk` 必须是**完整版本号**(区间写法 `1.2` 会报错并说明「patch 位恒为 .0」);声明的 SDK 若未安装在本地存储,直接给出 `hmapdev sdk install vX.Y.Z` |
| **状态栏** | `插件 · SDK <声明> · hmapdev <版本>`;工具链缺失或工程有错时变红/黄tooltip 列出已装 SDK |
| **构建 / 清理 / 运行** | `hmapdev build``build --target all``clean``debug`(解释执行,快速迭代)——在集成终端里跑,可 Ctrl-C |
| **任务Tasks** | 同一批动作注册为 `hmapdev` 任务,可绑快捷键、串依赖;带 **Go 问题匹配器**,编译错误进 Problems 面板 |
| **跟随内核日志** | 读 `<dataDir>/log` 下最新的 `homed_*.log`,按插件名过滤后持续输出(真正的联调回路) |
| **SDK 管理** | 查看工具链版本、列出/安装/切换 SDK 版本(走 QuickPick不用记命令 |
| **JSON 支持** | `plg.json` 的 schema 校验 + 骨架片段 |
## 安装
```bash
cd tools/vscode-hmapdev
npm install
npm run compile
```
然后二选一:
- **开发模式**:在 VSCode 里打开本目录,按 `F5`Extension Development Host把插件工程目录作为工作区打开
- **安装到本机**`npx @vscode/vsce package` 生成 `.vsix`,再 `code --install-extension hmapdev-vscode-0.1.0.vsix`
前提:`hmapdev``PATH` 上(或设置 `hmapdev.path`)。
## 配置
| 设置 | 默认 | 说明 |
|---|---|---|
| `hmapdev.path` | `hmapdev` | 工具链可执行文件路径 |
| `hmapdev.kernelDataDir` | 空 | 内核数据目录(`homed -data` 的那个);填了才能跟随内核日志 |
| `hmapdev.diagnoseSdk` | `true` | 是否校验声明的 SDK 是否已安装(需要能执行 hmapdev |
## 用法(典型开发回路)
1. 打开插件工程(含 `plg.json`)→ 状态栏出现 `插件 · SDK <版本> · hmapdev <版本>`
2.`sdk` 报错(未声明 / 区间写法 / 未安装)→ 按提示执行 `hmapdev: 安装 SDK 版本…`,再 `hmapdev: 刷新状态`
3. `hmapdev: 构建插件`(或 `构建(全部目标平台)`)→ 编译错误直接进 Problems
4. 快速验证行为:`hmapdev: 运行插件(解释执行)`
5. 与内核联调:设置 `hmapdev.kernelDataDir``hmapdev: 跟随内核日志`,只看本插件的行;
6. 改代码 → 重复 3/5。装进内核时记得**与内核同批替换**(协议绑定的产物不支持滚动升级)。
## 诚实的边界
- **这不是源码级调试器**:没有断点/单步。插件的 Go 代码要么编译成产物在内核里跑、要么用
`hmapdev debug`yaegi 解释执行)跑,两条路都不提供 DAP 调试会话。本扩展做的是
「构建 + 运行 + 看内核日志 + 清单校验」,这也是插件问题实际能被定位的方式。
- **Windows 目标**:不支持(协议 2 的统一共享内存区未移植到 Windows内核侧改走 WSL2
扩展只给提示,不假装能构建。
- **`sdk` 字段的语义**:它声明的是**本插件针对的 SDK 版本**= 接口线),不是内核版本。
SDK 版本跟随内核中版本、patch 位恒为 `.0`

59
tools/vscode-hmapdev/package-lock.json generated Normal file
View File

@ -0,0 +1,59 @@
{
"name": "hmapdev-vscode",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hmapdev-vscode",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"devDependencies": {
"@types/node": "^22.0.0",
"@types/vscode": "^1.85.0",
"typescript": "^5.6.0"
},
"engines": {
"vscode": "^1.85.0"
}
},
"node_modules/@types/node": {
"version": "22.20.2",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.2.tgz",
"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/vscode": {
"version": "1.137.0",
"resolved": "https://registry.npmmirror.com/@types/vscode/-/vscode-1.137.0.tgz",
"integrity": "sha512-0dc/BBWxkyUsJzXIZ7PkKSalThmS4xiBT+8YEDiWdCefRKHGVV5ZNkM5NB5ULYamallYJujIfncNoXWFlyzL8A==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@ -0,0 +1,174 @@
{
"name": "hmapdev-vscode",
"displayName": "HomeAgent Plugin Dev (hmapdev)",
"description": "调试与构建 HomeAgent 插件工程plg.json 校验、SDK 版本解析、hmapdev 构建/运行、内核日志跟随。",
"version": "0.1.0",
"publisher": "JianFeeeee",
"license": "AGPL-3.0-only",
"private": true,
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Programming Languages",
"Debuggers",
"Other"
],
"main": "./out/extension.js",
"activationEvents": [
"workspaceContains:plg.json",
"workspaceContains:**/plg.json"
],
"contributes": {
"commands": [
{
"command": "hmapdev.build",
"title": "hmapdev: 构建插件"
},
{
"command": "hmapdev.buildAll",
"title": "hmapdev: 构建插件(全部目标平台)"
},
{
"command": "hmapdev.clean",
"title": "hmapdev: 清理产物"
},
{
"command": "hmapdev.run",
"title": "hmapdev: 运行插件(解释执行,快速迭代)"
},
{
"command": "hmapdev.showVersion",
"title": "hmapdev: 查看工具链版本"
},
{
"command": "hmapdev.listSdk",
"title": "hmapdev: 列出 SDK 版本"
},
{
"command": "hmapdev.installSdk",
"title": "hmapdev: 安装 SDK 版本…"
},
{
"command": "hmapdev.useSdk",
"title": "hmapdev: 切换当前 SDK 版本…"
},
{
"command": "hmapdev.tailKernelLog",
"title": "hmapdev: 跟随内核日志(按插件过滤)"
},
{
"command": "hmapdev.stopTailKernelLog",
"title": "hmapdev: 停止跟随内核日志"
},
{
"command": "hmapdev.openPlgJson",
"title": "hmapdev: 打开 plg.json"
},
{
"command": "hmapdev.refresh",
"title": "hmapdev: 刷新状态(重新探测工具链与 SDK"
}
],
"configuration": {
"title": "HomeAgent Plugin Dev",
"properties": {
"hmapdev.path": {
"type": "string",
"default": "hmapdev",
"description": "hmapdev 可执行文件路径(默认从 PATH 找)。"
},
"hmapdev.kernelDataDir": {
"type": "string",
"default": "",
"description": "内核数据目录homed -data 的那个目录)。填了才能跟随内核日志调试;留空则「跟随内核日志」会先询问。"
},
"hmapdev.diagnoseSdk": {
"type": "boolean",
"default": true,
"description": "校验 plg.json 里声明的 SDK 版本是否已安装在本地 SDK 存储(需要能执行 hmapdev。"
}
}
},
"taskDefinitions": [
{
"type": "hmapdev",
"required": [
"action"
],
"properties": {
"action": {
"type": "string",
"enum": [
"build",
"buildAll",
"clean",
"run"
],
"description": "要执行的 hmapdev 动作。"
},
"cwd": {
"type": "string",
"description": "插件工程目录(默认取 plg.json 所在目录)。"
}
}
}
],
"problemMatchers": [
{
"name": "hmapdev-go",
"owner": "go",
"source": "hmapdev",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": [
{
"regexp": "^(.+\\.go):(\\d+):(\\d+):\\s+(.+)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
},
{
"regexp": "^(.+\\.go):(\\d+):\\s+(.+)$",
"file": 1,
"line": 2,
"message": 3
}
]
}
],
"languages": [
{
"id": "json",
"filenames": [
"plg.json"
]
}
],
"jsonValidation": [
{
"fileMatch": "plg.json",
"url": "./schema/plg.schema.json"
}
],
"snippets": [
{
"language": "json",
"path": "./snippets/plg.json.code-snippets"
}
]
},
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"test": "tsc -p ./ && node --test out/test/*.test.js"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/vscode": "^1.85.0",
"typescript": "^5.6.0"
}
}

View File

@ -0,0 +1,41 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "HomeAgent 插件清单plg.json",
"type": "object",
"required": ["name", "version", "entry"],
"properties": {
"name": {
"type": "string",
"description": "插件名(与目录名一致最省事)"
},
"name_zh": { "type": "string", "description": "中文显示名" },
"name_en": { "type": "string", "description": "英文显示名" },
"version": { "type": "string", "description": "插件自身版本号(如 0.1.0),与内核/SDK 版本无关" },
"description": { "type": "string" },
"author": { "type": "string" },
"entry": {
"type": "string",
"description": "入口产物文件名(子进程模式通常是 plugin.binLua 是 main.lua"
},
"sdk": {
"type": "string",
"pattern": "^v?\\d+\\.\\d+\\.\\d+$",
"description": "本插件针对的 SDK 版本,必须是完整版本号(如 1.2.0。SDK 版本跟随内核中版本、patch 位恒为 .0,一条内核线只有一个 SDK 版本;工具链按此在本地 SDK 存储里选择版本。"
},
"tags": { "type": "array", "items": { "type": "string" } },
"targets": {
"type": "string",
"description": "目标平台,逗号分隔,如 linux/amd64,darwin/arm64windows 目标暂不支持插件产物)"
},
"outdir": { "type": "string", "description": "产物目录(默认 dist" },
"bundle": { "type": "boolean", "description": "是否打包成 .hmap默认 true" },
"sdk_path": {
"type": "string",
"description": "直接指定 SDK 源码目录(本机改 SDK 联调时用);设置后优先于 sdk 字段"
},
"go_version": { "type": "string" },
"replaces": { "type": "object", "additionalProperties": { "type": "string" } },
"source_dirs": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": true
}

View File

@ -0,0 +1,26 @@
{
"plg.json 骨架": {
"prefix": "plg",
"body": [
"{",
" \"name\": \"${1:MyPlugin}\",",
" \"name_zh\": \"${2:中文名}\",",
" \"name_en\": \"${1:MyPlugin}\",",
" \"version\": \"0.1.0\",",
" \"description\": \"${3:插件说明}\",",
" \"author\": \"${4:HomeAgent}\",",
" \"entry\": \"plugin.bin\",",
" \"sdk\": \"${5:1.2.0}\",",
" \"tags\": [\"${1:MyPlugin}\"],",
" \"targets\": \"linux/amd64,darwin/arm64\"",
"}",
"$0"
],
"description": "plg.json 必需字段骨架sdk 必须是完整版本号)"
},
"sdk 字段": {
"prefix": "sdk",
"body": ["\"sdk\": \"${1:1.2.0}\","],
"description": "声明本插件针对的 SDK 版本完整版本号patch 位恒为 .0"
}
}

View File

@ -0,0 +1,174 @@
/**
* 纯逻辑层:不 import vscode便于用 node --test 直接单测。
*
* 这里的规矩必须与工具链一致tools/hmapdev/sdk_resolve.go
* - SDK 版本跟随内核中版本、**patch 位恒为 .0** → 一条内核线只有一个 SDK 版本;
* - 因此 plg.json 的 `sdk` 必须是**完整版本号**x.y.z区间写法"1.2")要报错,
* 否则项目会以为「同一条线里还能挑不同 SDK」。
*/
/** plg.json 的字段(未知字段保留,不做拒绝)。 */
export interface PlgConfig {
name?: string;
name_zh?: string;
name_en?: string;
version?: string;
description?: string;
author?: string;
entry?: string;
sdk?: string;
tags?: string[];
targets?: string;
sdk_path?: string;
outdir?: string;
bundle?: boolean;
replaces?: Record<string, string>;
source_dirs?: string[];
}
/** 诊断级别(与 vscode.DiagnosticSeverity 数值对齐,避免耦合)。 */
export enum Severity {
Error = 0,
Warning = 1,
Information = 2,
Hint = 3,
}
export interface PlgDiagnostic {
severity: Severity;
message: string;
/** plg.json 里的字段名(用于在 JSON 文档里定位)。 */
field?: string;
}
/** 完整版本号x.y.z允许 v 前缀)。 */
export function isFullVersion(v: string): boolean {
return /^v?\d+\.\d+\.\d+$/.test((v ?? "").trim());
}
/** 中版本x.y。 */
export function isMinorVersion(v: string): boolean {
return /^v?\d+\.\d+$/.test((v ?? "").trim());
}
export function normalizeVersion(v: string): string {
return (v ?? "").trim().replace(/^v/, "");
}
/**
* 校验 plg.json。
*
* `installedSdks` 为本地 SDK 存储里已安装的版本(不带 v 前缀);传 undefined 表示
* 没探测(例如工具链不可用),此时只校验格式、不报「未安装」。
*/
export function validatePlg(cfg: PlgConfig, installedSdks?: string[]): PlgDiagnostic[] {
const out: PlgDiagnostic[] = [];
const req = (field: keyof PlgConfig, hint: string) => {
const v = cfg[field];
if (v === undefined || v === null || String(v).trim() === "") {
out.push({ severity: Severity.Error, message: `${field} 不能为空(${hint}`, field: field as string });
}
};
req("name", "插件名,与目录名一致最省事");
req("entry", "入口产物,子进程模式通常是 plugin.bin");
req("version", "插件自身版本号,如 0.1.0");
// SDK 声明:这是「工具链自动选 SDK 版本」的依据,缺了就只能退回 current
if (cfg.sdk === undefined || cfg.sdk === null || String(cfg.sdk).trim() === "") {
out.push({
severity: Severity.Warning,
message: "缺少 sdk 字段:工具链无法据此选择 SDK 版本,会退回存储里的 current换机器/换人后容易编出与预期不符的产物)",
field: "sdk",
});
} else if (isMinorVersion(cfg.sdk)) {
out.push({
severity: Severity.Error,
message:
`sdk 必须是完整版本号(如 "1.2.0"${cfg.sdk} 这种区间写法会让人以为同一条内核线里还能挑不同 SDK。` +
`SDK 版本跟随内核中版本、patch 位恒为 .0,一条内核线只有一个 SDK 版本。`,
field: "sdk",
});
} else if (!isFullVersion(cfg.sdk)) {
out.push({ severity: Severity.Error, message: `sdk 不是合法版本号(写法:"1.2.0"`, field: "sdk" });
} else if (installedSdks && !installedSdks.includes(normalizeVersion(cfg.sdk))) {
const have = installedSdks.length ? installedSdks.join(", ") : "(存储里还没有任何 SDK";
out.push({
severity: Severity.Error,
message: `声明的 SDK ${normalizeVersion(cfg.sdk)} 未安装。已安装:${have}。安装hmapdev sdk install v${normalizeVersion(cfg.sdk)}`,
field: "sdk",
});
}
// 目标平台windows 目前不支持(协议 2 的统一共享内存区未移植)
const targets = (cfg.targets ?? "").toLowerCase();
if (targets.includes("windows")) {
out.push({
severity: Severity.Information,
message: "windows 目标暂不支持插件产物:协议 2 的统一共享内存区未移植 Windows内核改走 WSL2。构建会在该目标上明确报错。",
field: "targets",
});
}
if (!cfg.targets) {
out.push({ severity: Severity.Information, message: "未声明 targets构建时按默认目标处理", field: "targets" });
}
return out;
}
/**
* 解析 `hmapdev sdk list` 的输出,返回已安装版本(去 v 前缀、升序)。
*
* 输出形如:
* Installed SDK versions:
* * v1.2.0
* v0.8.0
* 每行可能带 `*` 标记(当前版本)或前导空格。
*/
export function parseSdkList(text: string): string[] {
const out: string[] = [];
for (const raw of (text ?? "").split(/\r?\n/)) {
const line = raw.trim().replace(/^\*\s*/, "").trim();
const m = /^v?(\d+\.\d+\.\d+)$/.exec(line);
if (m) {
out.push(m[1]);
}
}
return out.sort(compareVersions);
}
/**
* 解析 `hmapdev version` 的自述(首行形如 `hmapdev 1.2.0`)。
*
* 只认**以数字开头**的版本 token否则 `hmapdev 未找到` / `hmapdev error`
* 这类输出会被当成版本号,把「工具链不在」误报成「工具链 1.x」
* (状态栏与「是否已装 SDK」的判断都基于它假版本会让诊断全面失真
*/
export function parseToolchainVersion(text: string): string {
for (const raw of (text ?? "").split(/\r?\n/)) {
const m = /^hmapdev\s+v?(\d+(?:\.\d+)*(?:[-+.][0-9A-Za-z.-]+)?)\s*$/.exec(raw.trim());
if (m) {
return m[1];
}
}
return "";
}
/** 数值比较 x.y.z字典序会把 1.2.9 排在 1.2.10 之后)。 */
export function compareVersions(a: string, b: string): number {
const pa = normalizeVersion(a).split(".").map((n) => parseInt(n, 10) || 0);
const pb = normalizeVersion(b).split(".").map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < 3; i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d !== 0) {
return d;
}
}
return 0;
}
/** 状态栏文本:插件 + 声明 SDK + 工具链版本(缺项用 "?")。 */
export function statusBarText(cfg: PlgConfig | undefined, toolchainVersion: string): string {
const name = cfg?.name?.trim() || "(未识别插件)";
const sdk = cfg?.sdk ? normalizeVersion(cfg.sdk) : "未声明";
const tc = toolchainVersion ? `hmapdev ${toolchainVersion}` : "hmapdev 未找到";
return `${name} · SDK ${sdk} · ${tc}`;
}

View File

@ -0,0 +1,272 @@
import * as cp from "child_process";
import * as path from "path";
import * as vscode from "vscode";
import { PlgConfig, Severity, statusBarText, validatePlg } from "./core";
import { Toolchain } from "./toolchain";
let out: vscode.OutputChannel;
let tc: Toolchain;
let status: vscode.StatusBarItem;
let diagnostics: vscode.DiagnosticCollection;
let tailChild: cp.ChildProcess | undefined;
/** 找到工作区里的 plg.json多个时取第一个并提示。 */
async function findPlg(): Promise<vscode.Uri | undefined> {
const found = await vscode.workspace.findFiles("**/plg.json", "**/{node_modules,out,dist,build}/**", 5);
if (found.length === 0) {
return undefined;
}
return found[0];
}
async function readPlg(uri: vscode.Uri): Promise<PlgConfig | undefined> {
try {
const txt = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString("utf8");
return JSON.parse(txt) as PlgConfig;
} catch (e) {
out.appendLine(`error: 解析 ${uri.fsPath} 失败:${e instanceof Error ? e.message : String(e)}`);
return undefined;
}
}
function severityToVscode(s: Severity): vscode.DiagnosticSeverity {
switch (s) {
case Severity.Error:
return vscode.DiagnosticSeverity.Error;
case Severity.Warning:
return vscode.DiagnosticSeverity.Warning;
case Severity.Information:
return vscode.DiagnosticSeverity.Information;
default:
return vscode.DiagnosticSeverity.Hint;
}
}
/** 在 JSON 文档里定位字段(找不到就标整个文件,至少让人看见)。 */
function rangeForField(doc: vscode.TextDocument, field?: string): vscode.Range {
if (field) {
const idx = doc.getText().indexOf(`"${field}"`);
if (idx >= 0) {
const start = doc.positionAt(idx);
const end = doc.positionAt(idx + field.length + 2);
return new vscode.Range(start, end);
}
}
return new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0));
}
async function refresh(): Promise<void> {
const uri = await findPlg();
diagnostics.clear();
if (!uri) {
status.text = statusBarText(undefined, await tc.version());
status.tooltip = "工作区里没有找到 plg.json本扩展只在插件工程里工作";
return;
}
const cfg = await readPlg(uri);
if (!cfg) {
return;
}
const tcVersion = await tc.version();
const diagSetting = vscode.workspace.getConfiguration("hmapdev").get<boolean>("diagnoseSdk", true);
const installed = diagSetting ? await tc.sdkList() : undefined;
const doc = await vscode.workspace.openTextDocument(uri);
const items = validatePlg(cfg, installed).map((d) => {
const vd = new vscode.Diagnostic(rangeForField(doc, d.field), d.message, severityToVscode(d.severity));
vd.source = "hmapdev";
return vd;
});
diagnostics.set(uri, items);
const errors = items.filter((d) => d.severity === vscode.DiagnosticSeverity.Error).length;
status.text = `$(tools) ${statusBarText(cfg, tcVersion)}`;
status.backgroundColor = tc.isMissing()
? new vscode.ThemeColor("statusBarItem.errorBackground")
: errors > 0
? new vscode.ThemeColor("statusBarItem.warningBackground")
: undefined;
const installedText = installed ? installed.join(", ") || "(无)" : "(未探测)";
status.tooltip = [
`插件:${cfg.name ?? "?"}`,
`声明 SDK${cfg.sdk ?? "未声明"}`,
`已安装 SDK${installedText}`,
`工具链:${tcVersion ? `hmapdev ${tcVersion}` : "未找到(检查 hmapdev.path / PATH"}`,
`plg.json${uri.fsPath}`,
].join("\n");
status.command = "hmapdev.openPlgJson";
status.show();
}
async function pluginDir(): Promise<string | undefined> {
const uri = await findPlg();
return uri ? path.dirname(uri.fsPath) : undefined;
}
async function withDir(fn: (dir: string) => unknown | Promise<unknown>): Promise<void> {
const dir = await pluginDir();
if (!dir) {
void vscode.window.showWarningMessage("当前工作区没有 plg.json无法定位插件工程。");
return;
}
await fn(dir);
}
export function activate(context: vscode.ExtensionContext): void {
out = vscode.window.createOutputChannel("hmapdev");
tc = new Toolchain(out);
diagnostics = vscode.languages.createDiagnosticCollection("hmapdev");
status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
context.subscriptions.push(out, diagnostics, status);
const reg = (id: string, fn: () => unknown) =>
context.subscriptions.push(vscode.commands.registerCommand(id, async () => {
try {
await fn();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
out.appendLine(`error: ${msg}`);
void vscode.window.showErrorMessage(`hmapdev: ${msg}`);
}
}));
reg("hmapdev.build", () => withDir((d) => tc.runInTerminal(["build"], d, "hmapdev build")));
reg("hmapdev.buildAll", () => withDir((d) => tc.runInTerminal(["build", "--target", "all"], d, "hmapdev build all")));
reg("hmapdev.clean", () => withDir((d) => tc.runInTerminal(["clean"], d, "hmapdev clean")));
reg("hmapdev.run", () => withDir((d) => tc.runInTerminal(["debug", d], d, "hmapdev debug")));
reg("hmapdev.showVersion", async () => {
out.show(true);
const v = await tc.version(true);
if (!v) {
void vscode.window.showErrorMessage("找不到 hmapdev请把它放到 PATH或设置 hmapdev.path。");
return;
}
await tc.runAndLog(["version"], process.cwd());
});
reg("hmapdev.listSdk", async () => {
out.show(true);
await tc.runAndLog(["sdk", "list"], process.cwd());
await refresh();
});
reg("hmapdev.installSdk", async () => {
const v = await vscode.window.showInputBox({
title: "安装 SDK 版本",
prompt: '输入完整版本号(如 1.2.0)或 latest。注意SDK 版本跟随内核中版本patch 位恒为 .0。',
placeHolder: "1.2.0",
});
if (!v) {
return;
}
tc.runInTerminal(["sdk", "install", `v${v.replace(/^v/, "")}`], process.cwd(), "hmapdev sdk install");
void vscode.window.showInformationMessage(`安装完成后执行「hmapdev: 刷新状态」以重新校验。`);
});
reg("hmapdev.useSdk", async () => {
const list = await tc.sdkList(true);
if (!list || list.length === 0) {
void vscode.window.showWarningMessage("没有探测到已安装的 SDK 版本先跑「hmapdev: 列出 SDK 版本」看看)。");
return;
}
const pick = await vscode.window.showQuickPick(list, { title: "切换当前 SDK 版本(存储里的 current" });
if (!pick) {
return;
}
tc.runInTerminal(["sdk", "use", `v${pick}`], process.cwd(), "hmapdev sdk use");
setTimeout(() => void refresh(), 1500);
});
reg("hmapdev.tailKernelLog", async () => {
const cfgDir = vscode.workspace.getConfiguration("hmapdev");
let dataDir = cfgDir.get<string>("kernelDataDir", "");
if (!dataDir) {
const answer = await vscode.window.showInputBox({
title: "内核数据目录",
prompt: "homed -data 指向的目录(用于跟随内核日志)。填一次会记住到设置里。",
placeHolder: "/home/newqqagent",
});
if (!answer) {
return;
}
dataDir = answer;
await cfgDir.update("kernelDataDir", dataDir, vscode.ConfigurationTarget.Workspace);
}
const uri = await findPlg();
const filter = uri ? (await readPlg(uri))?.name ?? "" : "";
tailChild?.kill();
tailChild = tc.tailKernelLog(dataDir, filter);
out.show(true);
});
reg("hmapdev.stopTailKernelLog", () => {
if (tailChild) {
tailChild.kill();
tailChild = undefined;
out.appendLine("已停止跟随内核日志");
}
});
reg("hmapdev.openPlgJson", async () => {
const uri = await findPlg();
if (uri) {
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uri));
} else {
void vscode.window.showWarningMessage("工作区里没有 plg.json。");
}
});
reg("hmapdev.refresh", async () => {
await tc.sdkList(true);
await tc.version(true);
await refresh();
});
// 任务提供者:把 hmapdev 动作接进 VSCode 的任务体系(可绑定快捷键 / 串联依赖 / 复用问题匹配器)
context.subscriptions.push(
vscode.tasks.registerTaskProvider("hmapdev", {
provideTasks: async () => {
const dir = await pluginDir();
if (!dir) {
return [];
}
const mk = (action: string, label: string, args: string[]) => {
const def: vscode.TaskDefinition = { type: "hmapdev", action };
const exec = new vscode.ProcessExecution(
vscode.workspace.getConfiguration("hmapdev").get<string>("path", "hmapdev") || "hmapdev",
args,
{ cwd: dir }
);
return new vscode.Task(def, vscode.TaskScope.Workspace, label, "hmapdev", exec, ["$hmapdev-go"]);
};
return [
mk("build", "hmapdev: build", ["build"]),
mk("buildAll", "hmapdev: build (all targets)", ["build", "--target", "all"]),
mk("clean", "hmapdev: clean", ["clean"]),
mk("run", "hmapdev: run (interpreted)", ["debug", dir]),
];
},
resolveTask: (task) => task,
})
);
// plg.json 变化 → 重算诊断(含保存与外部修改)
const watcher = vscode.workspace.createFileSystemWatcher("**/plg.json");
context.subscriptions.push(
watcher,
watcher.onDidChange(() => void refresh()),
watcher.onDidCreate(() => void refresh()),
watcher.onDidDelete(() => void refresh())
);
void refresh();
}
export function deactivate(): void {
tailChild?.kill();
tailChild = undefined;
}

View File

@ -0,0 +1,106 @@
import * as assert from "node:assert/strict";
import { test } from "node:test";
import {
PlgConfig,
Severity,
compareVersions,
isFullVersion,
isMinorVersion,
normalizeVersion,
parseSdkList,
parseToolchainVersion,
statusBarText,
validatePlg,
} from "../core";
const good: PlgConfig = { name: "memo", version: "0.1.0", entry: "plugin.bin", sdk: "1.2.0" };
test("isFullVersion / isMinorVersion 区分完整版本与区间写法", () => {
assert.equal(isFullVersion("1.2.0"), true);
assert.equal(isFullVersion("v1.2.0"), true);
assert.equal(isFullVersion("1.2"), false);
assert.equal(isFullVersion("1.2.3.4"), false);
assert.equal(isMinorVersion("1.2"), true);
assert.equal(isMinorVersion("1.2.0"), false);
assert.equal(normalizeVersion("v1.2.0"), "1.2.0");
});
test("合法的 plg.json 不产生错误", () => {
const d = validatePlg(good, ["1.2.0"]);
assert.equal(d.filter((x) => x.severity === Severity.Error).length, 0, JSON.stringify(d));
});
test("缺必需字段要报错并指出字段", () => {
const d = validatePlg({ sdk: "1.2.0" }, ["1.2.0"]);
const fields = d.filter((x) => x.severity === Severity.Error).map((x) => x.field).sort();
assert.deepEqual(fields, ["entry", "name", "version"]);
});
test("区间写法 1.2 必须被拒,并说明 patch 位恒为 .0", () => {
const d = validatePlg({ ...good, sdk: "1.2" }, ["1.2.0"]);
const err = d.find((x) => x.field === "sdk" && x.severity === Severity.Error);
assert.ok(err, "区间写法应报错");
assert.match(err!.message, /完整版本号/);
assert.match(err!.message, /patch 位恒为 \.0/);
});
test("缺 sdk 字段只警告(向后兼容存量项目)", () => {
const d = validatePlg({ name: "memo", version: "0.1.0", entry: "plugin.bin" }, ["1.2.0"]);
const sdk = d.find((x) => x.field === "sdk");
assert.ok(sdk);
assert.equal(sdk!.severity, Severity.Warning);
});
test("声明的 SDK 未安装要报错并给出安装命令", () => {
const d = validatePlg(good, ["0.8.0"]);
const err = d.find((x) => x.field === "sdk" && x.severity === Severity.Error);
assert.ok(err, "未安装应报错");
assert.match(err!.message, /hmapdev sdk install v1\.2\.0/);
assert.match(err!.message, /0\.8\.0/);
});
test("探测不到已装列表时不误报未安装", () => {
const d = validatePlg(good, undefined);
assert.equal(d.filter((x) => x.severity === Severity.Error).length, 0, JSON.stringify(d));
});
test("windows 目标给提示(协议 2 未移植)", () => {
const d = validatePlg({ ...good, targets: "linux/amd64,windows/amd64" }, ["1.2.0"]);
const info = d.find((x) => x.field === "targets");
assert.ok(info);
assert.match(info!.message, /WSL2/);
});
test("parseSdkList 吃掉 * 标记与空格,并按数值排序", () => {
const text = ["Installed SDK versions:", " * v1.2.0", " v0.8.0", " v1.2.10"].join("\n");
assert.deepEqual(parseSdkList(text), ["0.8.0", "1.2.0", "1.2.10"]);
assert.deepEqual(parseSdkList("No SDK versions installed."), []);
});
test("parseToolchainVersion 从 self-report 里取版本", () => {
const text = ["hmapdev 1.2.0", " SDK 模块: gitcode.com/JianFeeeee/homeagent-sdk", " 构建用 Go: go1.25.12"].join("\n");
assert.equal(parseToolchainVersion(text), "1.2.0");
assert.equal(parseToolchainVersion("Usage:\n hmapdev init <name>"), "");
});
// 反向核对抓到的真缺陷:`hmapdev <非版本>` 形状的输出曾被当成版本号,
// 于是「工具链不在」会被显示成「工具链 <垃圾词>」,并让 SDK 诊断跟着失真。
test("parseToolchainVersion 不会把非版本 token 当成版本", () => {
for (const bad of ["hmapdev 未找到", "hmapdev error", "hmapdev not found", "hmapdev -v", "hmapdev"]) {
assert.equal(parseToolchainVersion(bad), "", `不应从 ${JSON.stringify(bad)} 解析出版本`);
}
assert.equal(parseToolchainVersion("hmapdev 1.3.0-dev"), "1.3.0-dev"); // 开发构建的后缀要带出来
assert.equal(parseToolchainVersion("hmapdev v1.2.0"), "1.2.0");
});
test("compareVersions 是数值比较1.2.10 > 1.2.9", () => {
assert.ok(compareVersions("1.2.10", "1.2.9") > 0);
assert.ok(compareVersions("1.2.0", "1.2.0") === 0);
assert.ok(compareVersions("0.8.0", "1.2.0") < 0);
});
test("状态栏文本包含插件、声明 SDK 与工具链版本", () => {
assert.equal(statusBarText(good, "1.2.0"), "memo · SDK 1.2.0 · hmapdev 1.2.0");
assert.equal(statusBarText({ ...good, sdk: undefined }, ""), "memo · SDK 未声明 · hmapdev 未找到");
});

View File

@ -0,0 +1,139 @@
import * as cp from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import { parseSdkList, parseToolchainVersion } from "./core";
/** execFile 的 Promise 版(不引第三方依赖)。 */
function execFile(
file: string,
args: string[],
cwd: string,
timeoutMs = 120_000
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
cp.execFile(file, args, { cwd, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0;
resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
});
});
}
/**
* 工具链封装:定位 hmapdev、执行命令、缓存 version / sdk list。
*
* 为什么要缓存并显式 refreshSDK 存储会在外部变化(`hmapdev sdk install` 之后),
* 而诊断信息依赖它——不刷新就会一直报「未安装」。
*/
export class Toolchain {
private versionCache?: string;
private sdkCache?: string[];
private missing = false;
constructor(private readonly out: vscode.OutputChannel) {}
private get exe(): string {
return vscode.workspace.getConfiguration("hmapdev").get<string>("path", "hmapdev") || "hmapdev";
}
/** 记录一条消息到输出通道(加上工具链前缀,便于与构建输出区分)。 */
log(line: string): void {
this.out.appendLine(line);
}
async version(refresh = false): Promise<string> {
if (this.versionCache !== undefined && !refresh) {
return this.versionCache;
}
const r = await execFile(this.exe, ["version"], process.cwd(), 20_000);
if (r.code !== 0 && !r.stdout) {
this.missing = true;
this.versionCache = "";
return "";
}
this.missing = false;
this.versionCache = parseToolchainVersion(r.stdout + r.stderr);
return this.versionCache;
}
async sdkList(refresh = false): Promise<string[] | undefined> {
if (this.sdkCache !== undefined && !refresh) {
return this.sdkCache;
}
const r = await execFile(this.exe, ["sdk", "list"], process.cwd(), 20_000);
if (r.code !== 0 && !r.stdout) {
this.sdkCache = undefined; // 探测不到就不做「未安装」判断,避免误报
return undefined;
}
this.sdkCache = parseSdkList(r.stdout + r.stderr);
return this.sdkCache;
}
isMissing(): boolean {
return this.missing;
}
/** 在集成终端里执行(构建/运行这类长命令:要能看进度、能 Ctrl-C。 */
runInTerminal(args: string[], cwd: string, name: string): vscode.Terminal {
const term = vscode.window.createTerminal({ name, cwd });
term.show(true);
const cmd = [this.exe, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ");
this.log(`$ ${cmd}`);
term.sendText(cmd, true);
return term;
}
/** 一次性执行并把输出写进输出通道(查询类命令)。 */
async runAndLog(args: string[], cwd: string): Promise<number> {
this.log(`$ ${this.exe} ${args.join(" ")}`);
const r = await execFile(this.exe, args, cwd, 60_000);
if (r.stdout) {
this.out.append(r.stdout);
}
if (r.stderr) {
this.out.append(r.stderr);
}
return r.code;
}
/**
* 跟随内核日志:定位 <dataDir>/log 下最新的 homed 日志并按插件名过滤。
*
* 为什么这是「调试插件」的正路:插件是子进程、跑在内核里,真正的问题几乎都
* 表现为内核日志里的几行(握手失败/崩溃重启/工具报错),在 IDE 里跟住它比
* 反复手动 tail 高效得多。
*/
tailKernelLog(dataDir: string, filter: string): cp.ChildProcess | undefined {
const logDir = path.join(dataDir, "log");
if (!fs.existsSync(logDir)) {
this.log(`error: 日志目录不存在:${logDir}hmapdev.kernelDataDir 是否指对?)`);
return undefined;
}
const newest = fs
.readdirSync(logDir)
.filter((f) => f.startsWith("homed_") && f.endsWith(".log"))
.map((f) => ({ f, m: fs.statSync(path.join(logDir, f)).mtimeMs }))
.sort((a, b) => b.m - a.m)[0];
if (!newest) {
this.log(`error: ${logDir} 下没有 homed_*.log`);
return undefined;
}
const file = path.join(logDir, newest.f);
this.log(`跟随 ${file}${filter ? `(过滤 ${filter}` : ""}`);
const child = cp.spawn("tail", ["-F", file], { stdio: ["ignore", "pipe", "pipe"] });
const emit = (buf: Buffer) => {
for (const line of buf.toString("utf8").split(/\r?\n/)) {
if (!line) {
continue;
}
if (!filter || line.includes(filter)) {
this.out.appendLine(line);
}
}
};
child.stdout?.on("data", emit);
child.stderr?.on("data", emit);
return child;
}
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2020"],
"outDir": "out",
"rootDir": "src",
"strict": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "vscode"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "out"]
}