25 Commits

Author SHA1 Message Date
2d1e899b18 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:48 +08:00
f6d9fee2da 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:54 +08:00
1df184d47e revert(version): 撤回 SDK 1.2.1 —— 版本纪律是 patch 位**恒为 .0**
我上一步把工具链/打包修复当成 patch 发布(发了 v1.2.1),这违反了 README 写明的
「版本号跟随内核中版本,patch 位恒为 .0,内核 patch 不碰公开接口 → SDK 不跟版」。

正确口径:
- SDK 面向 1.2 内核线的版本号就是 **1.2.0**,工具链修复不改这个号;
- 本分支保留修复代码(hmapdev 版本显示 + 跨平台示例构建)是对的,错的只是版本号;
- 已推送的 tag v1.2.1 属误发,将由发布页下架处理(tag 不可改写,另行处置)。

main 上的 1.3.0 是「下一个未发布中版本」,patch 位为 .0,本来就合规,不动。
2026-09-12 15:46:52 +08:00
ff4484e193 chore(version): release/v1.2.x 路牌推到 1.2.1(本版只有工具链/打包修复,公开接口未变)
SDK 的 patch 位只用于**工具链与打包**修复:本次是 hmapdev 版本显示失效(-X 注入
因 meta 包未被引用而静默无效)与跨平台示例构建用了目标平台二进制。
公开 SDK 接口一个字节没变,所以内核 1.2.x(含 patch)配的仍是同一套接口。
2026-09-12 15:09:27 +08:00
d011d0676a 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:09:08 +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
59 changed files with 4381 additions and 337 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/>.

197
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 接口
@ -220,18 +284,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
@ -239,11 +308,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** 两种插件语言。
@ -350,7 +419,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() {
@ -368,6 +437,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**,仅暴露安全子集:
@ -379,6 +473,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 会话;扩展做的是构建、运行、看日志与清单校验。
## 示例插件
| 插件 | 类型 | 说明 |
@ -399,6 +544,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零外部依赖兼容嵌入式平台。
@ -500,10 +650,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`,可直接编译或作为三方库引入:
@ -715,17 +865,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`)。
### 安装
@ -743,3 +893,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
@ -226,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
@ -247,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.
@ -323,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() {
@ -341,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:
@ -372,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.
@ -456,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:
@ -669,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
@ -697,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

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

View File

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

View File

@ -520,7 +520,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

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

View File

@ -292,8 +292,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,16 @@ 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 侧来的中断提示,不是对话内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{NoMemory: true})
}
w.WriteHeader(http.StatusOK)
}
@ -1320,6 +1738,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 +1772,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 +1790,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 +1813,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 +1851,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 +2530,10 @@ 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下载完成的状态通知不是记忆内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("文件下载完成: %s保存在 %s", filepath.Base(savePath), savePath),
sdk.InjectOptions{NoMemory: true})
}
} else {
errMsg = "下载失败,文件可能已过期"
@ -2557,6 +3001,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 +3126,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

@ -300,7 +300,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()
}

View File

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

View File

@ -28,12 +28,29 @@ var (
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**
// 不调就不受影响。想用新字段的插件重编即可。
//
// ❗发布分支上此值是**本条发布线的 SDK 定版**main 上则是下一个未发布中版本
// (见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
// 1.2.0:注入行为的记忆/裁剪标志位。**全部是新增,无签名变更**
// - InjectOptions{NoMemory, ContextPolicy}
// - IOInjector 的六个 *Opts 变体(排队/中断/同步/带媒体各一对)
// - ChannelDef.ContextPolicy顺带给 ChannelDef 补上 JSON tag
// 它要跨进程传给内核,而 Cleaner 是函数必须忽略;无 tag 时只能
// 手写字段白名单,新增字段会被静默丢掉)
// 语义:零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 +
// 不裁剪),因此存量插件不需要改一行也不需要重编。
// 裁剪ContextPolicy=prune必须显式声明——它会归档丢弃低相关事件。
//
// 本分支定版 1.1.0,服务整条核心 1.1.x 线1.1.0、1.1.1、…):
// patch 位恒为 .0,核心的 bugfix 不碰公开接口SDK 号没有理由跟着动
Version = "1.1.0"
// ❗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.2.0"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
@ -44,7 +61,7 @@ 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 所兼容的最低核心版本。
@ -56,6 +73,10 @@ var (
// 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

@ -49,15 +49,17 @@ type TextMemoryAPI interface {
//
// 两个方向共用一个类型:
// - 写入InsertWithMedia给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
// - 读出Query内核只填 Digest/MIME/Description**不回 Data**——
// - 读出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"`
Description string `json:"description,omitempty"`
Digest string `json:"digest,omitempty"`
MIME string `json:"mime,omitempty"`
Data []byte `json:"data,omitempty"`
Name string `json:"name,omitempty"`
}
type TextEvent struct {
@ -74,8 +76,8 @@ type DocMemoryAPI interface {
Insert(doc *Doc) error
// InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
// 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
// 插件无需自己拼标记:内核会把 `[mime <短 digest>] <描述>` 补进 Content
// 让向量检索和后续蒸馏都能看到这份媒体
// 媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量
// 因此图片按自己的向量被召回,不依赖任何生成的描述文本
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
Remove(id string)
Stats() map[string]interface{}

View File

@ -35,12 +35,63 @@ 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
}
// 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.
@ -100,12 +151,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.
@ -124,6 +176,17 @@ type IOInjector interface {
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.
@ -504,54 +567,94 @@ func (s *PluginSDK) injector() IOInjector {
}
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
// 等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
if io := s.injector(); io != nil {
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 io := s.injector(); io != nil {
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 io := s.injector(); io != nil {
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 {
io := s.injector()
if io == nil {
return ""
}
return io.InjectInputSync(source, channel, text)
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) {
if io := s.injector(); io != nil {
io.InjectInputMedia(source, channel, text, blocks)
}
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 io.InjectInputMediaSync(source, channel, text, blocks)
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 处理。

View File

@ -43,6 +43,7 @@ type injectCall struct {
channel string
text string
blocks []ContentBlock
opts InjectOptions // 调用点声明的记忆/裁剪行为
}
func (r *recordingInjector) record(c injectCall) {
@ -93,6 +94,41 @@ func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBloc
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()
@ -463,11 +499,10 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
data[i] = byte(i * 7 % 256)
}
att := MediaAttachment{
Digest: strings.Repeat("a", 64),
MIME: "image/png",
Data: data,
Name: "图片-名字 with space & 符号.png",
Description: "一张紫蓝红三色带图,含 emoji 🎨 与换行\n第二行",
Digest: strings.Repeat("a", 64),
MIME: "image/png",
Data: data,
Name: "图片-名字 with space & 符号.png",
}
b, err := json.Marshal(att)
if err != nil {
@ -485,7 +520,7 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
}
}
if back.Name != att.Name || back.Description != att.Description || back.MIME != att.MIME || back.Digest != att.Digest {
if back.Name != att.Name || back.MIME != att.MIME || back.Digest != att.Digest {
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
}
}

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,22 @@ 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))
}
// Ensure go.mod exists with correct SDK path
sdkModule := ensureGoMod(plg, sdkPath)
@ -92,11 +117,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 +137,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 +174,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 +185,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 +210,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 +285,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 +390,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 +482,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 +498,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 +547,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 +555,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 +568,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 +620,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 +645,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
}

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 {
@ -50,13 +63,13 @@ func cmdSDK(args []string) {
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 +85,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 +98,9 @@ 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 use v0.7.1
`)
}
@ -127,7 +140,7 @@ 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.")
}
}
@ -292,7 +305,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 +318,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 +453,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

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,8 +92,9 @@ 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内核也就永远收不到而两边都不报错。
@ -131,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)
@ -147,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 一致。
//
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
@ -281,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}}"
}
@ -511,7 +512,7 @@ const tmplReadme = `# {{.Plg.Name}}
## Build
` + "```bash" + `
plugindev build
hmapdev build
` + "```" + `
## Install

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,197 @@ 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
}
}
// ---- 全局状态 ----
var (
@ -88,15 +305,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 +638,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 +664,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 +687,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 +707,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,7 +743,18 @@ 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)
}
}
@ -493,15 +762,29 @@ func (procIO) SetToolBlocks(blocks []sdk.ContentBlock) {
// 带媒体的注入:插件主动发起一轮带图/音频的对话。
// 与 SetToolBlocks 的区别是媒体在**本轮**就到模型手上,而不是等下一条 tool message。
func (procIO) InjectInputMedia(s, c, t string, blocks []sdk.ContentBlock) {
callCoreVoid("io.injectMedia", map[string]interface{}{
"source": s, "channel": c, "text": t, "blocks": blocks,
})
procIO{}.InjectInputMediaOpts(s, c, t, blocks, sdk.InjectOptions{})
}
func (procIO) InjectInputMediaSync(s, c, t string, blocks []sdk.ContentBlock) string {
raw, err := callCore("io.injectMediaSync", map[string]interface{}{
"source": s, "channel": c, "text": t, "blocks": blocks,
})
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 ""
}
@ -512,10 +795,26 @@ func (procIO) InjectInputMediaSync(s, c, t string, blocks []sdk.ContentBlock) st
return r.Reply
}
func (procIO) InjectInterruptMedia(s, c, t string, blocks []sdk.ContentBlock) {
callCoreVoid("io.injectInterruptMedia", map[string]interface{}{
"source": s, "channel": c, "text": t, "blocks": blocks,
})
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{}
@ -583,7 +882,19 @@ 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 写入文档并关联媒体。
@ -591,9 +902,28 @@ func (procDocMemory) Insert(d *sdk.Doc) error {
// 内核会把 `[mime <短digest>] <描述>` 标记补进 Content 并挂上引用,回传的
// doc 带着补好的 Content/ID/MediaDigests——回写进 d 让调用方能拿到这些。
func (procDocMemory) InsertWithMedia(d *sdk.Doc, atts []sdk.MediaAttachment) error {
raw, err := callCore("doc.insertWithMedia", map[string]interface{}{
"doc": d, "attachments": atts,
})
// 文档正文与附件(含媒体 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
}
@ -632,6 +962,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) {
@ -943,10 +1280,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()
@ -954,12 +1308,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)
@ -968,8 +1378,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()
@ -978,7 +1403,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
@ -1045,10 +1470,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)
@ -1065,41 +1490,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,24 +83,52 @@ 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 多模态内容块格式)。
@ -391,6 +419,34 @@ 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

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"]
}