27 Commits
v1.2.0 ... main

Author SHA1 Message Date
db207fd9b7 fix(scripts): 默认产物目录落到内核仓 dist/plugins
原默认 `$SDK_DIR/../dist/plugins`,在 SDK 仓位于 third_party/homeagent-sdk 时
会解析成 **third_party/dist/plugins** —— 既不在内核仓的发布产物目录
(upload_assets.py 认 dist/release 那套),也不在 SDK 仓内,等于丢在夹缝里,
必须每次显式传 OUT 才不会错。

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

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

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

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

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

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

## 用法

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

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

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

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

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

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

## 验证

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

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

## 写法

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

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

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

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

## 验证

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 为什么

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

  plugindev_init → plugindev_build → plugin_install(path) → plgreload

## 工具面(5 个)

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

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

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

## 现场验证

已在本机生产装上(`plugin_install` 带 path):5 个工具注册成功、
启动日志 `[plugindev] 就绪:hmapdev=/usr/local/bin/hmapdev 工作区=/home/newqqagent/plugindev`,
`hmapdev` 已装到 `/usr/local/bin/hmapdev`(版本 1.3.0)。
2026-09-13 14:16:46 +08:00
11303e3ee4 chore(example): a2a 1.3.0→1.3.1、acp 1.2.0→1.2.1(补 RegisterInputChannel 后升 patch)
上一提交(4cb3a0b)给这两个示例补了显式 `RegisterInputChannel`,但没有升版本号,
于是生产上"重装"出来的包与实际安装版本同名(1.3.0/1.2.0),既不好追溯也没法用版本比较升级。

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

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

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

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

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

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

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

## SDK 面

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

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

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

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

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

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

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

## 未决(发布期事项)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验证:`go build` 默认输出 1.3.0(主干默认值);`-X …meta.Version=1.2.1
-X …meta.Commit=abc1234` 后输出 1.2.1 + 提交号,且二进制内精确匹配到该串
(说明注入真的进了镜像);`go test -run PrintVersion` 2/2 PASS。
2026-09-12 15:08:56 +08:00
b93fe6b878 chore(version): main 路牌推到 1.3.0(v1.2.0 已从 release/v1.2.x 发出)
按版本纪律:release/v1.2.x 停在 v1.2.0 的发布提交(140cd34),主干 meta.Version
永远是「下一个未发布中版本」。
2026-09-12 14:16:46 +08:00
83 changed files with 11081 additions and 297 deletions

109
README.md
View File

@ -4,7 +4,7 @@ HomeAgent 插件开发 SDK用于构建与 HomeAgent 平台交互的智能插
## 版本与兼容性
当前:**SDK 1.2.0**(需内核 **1.2.0+**)。
当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
**版本号跟随内核的中版本patch 位恒为 `.0`**
@ -12,11 +12,15 @@ HomeAgent 插件开发 SDK用于构建与 HomeAgent 平台交互的智能插
|---|---|
| 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 |
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 起 | **1.3.0** |
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
因此 **SDK 仓在一个中版本里只发一次**`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
**1.0.x 插件升到 1.1.x不需要改代码也不需要重编。** 1.1.0 的新增全部是
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
@ -28,6 +32,40 @@ HomeAgent 插件开发 SDK用于构建与 HomeAgent 平台交互的智能插
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
## 1.3.0 新增:注入优先级与动态输出通道
### 注入优先级(`InjectOptions.Priority`
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
| 级别 | 常量 | 谁用 |
|---|---|---|
| L1L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选L1 最低) |
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
⇒ 存量插件不需要改一行、也不需要重编。
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
(现场保存/恢复对插件透明)。
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
### 动态输出通道(`UnregisterOutputChannel`
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
1.3.0 起成对提供:
| API | 用途 |
|---|---|
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`
违规会让**整条 LLM 请求**被上游拒绝2026-09-13 生产事故:`device/<id>` 导致全量对话 403
派生规则与约束见下方「输出通道」一节。
## 注入行为与上下文裁剪1.2.0
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
@ -84,11 +122,15 @@ type Plugin interface {
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
> **通道的方向契约**:入站与出站是分开登记的两件事。凡是用 `InjectText*/InjectInput*/InjectInterrupt*`
> 注入的通道名都要 `RegisterInputChannel` —— inputch 是内核最基本的**输入路由单位**
> 只有登记过的通道才能被"划给驻留子";只登记出站通道时内核会兜底登记同名 inputch 并告警(兼容老插件)。
| 分类 | 方法 | 说明 |
|------|------|------|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道def 为 `ChannelDef`NoMemory/Cleaner |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道def 为 `ChannelDef`caps 为能力位掩码 |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道**入站**:谁会往这个通道注入输入)def 为 `ChannelDef`NoMemory/Cleaner |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道**出站**`output_send__<name>` 的回复发给谁def 为 `ChannelDef`caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API实体-关系存储) |
@ -135,6 +177,14 @@ sdk.RegisterInputChannel("qq", ChannelDef{
### 输出通道
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
> "这个工具不可用",而是**整条请求被 400 拒绝**`Invalid 'tools[N].function.name'`
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`13 字符)的余量。
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
> 内核**不会**替你净化。
```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
```
@ -473,6 +523,57 @@ ctx.Unlock()
内部插件(平台内置)拥有完整 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 会话;扩展做的是构建、运行、看日志与清单校验。
## 示例插件
| 插件 | 类型 | 说明 |

View File

@ -4,7 +4,7 @@ Plugin development SDK for building intelligent plugins that interact with the H
## Version and Compatibility
Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
@ -12,13 +12,33 @@ Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|---|---|
| 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 |
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 onward | **1.3.0** |
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
public interface, so the SDK version has no reason to move with it — otherwise you would either be
forced to chase releases or suspect your version is stale, when not one character of the interface
has changed.
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
## New in 1.3.0: Injection Priority and Dynamic Output Channels
- **`InjectOptions.Priority` / `PriorityL1``PriorityL4`** — a plugin declares the interrupt level of
its own injection; the kernel schedules L1L4, where **L4 is reserved for the kernel and
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
Queued input has no level: anything can jump ahead of it.
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
channels that die with their resource (one channel per remote device) can now be unregistered;
previously they lingered and the model kept "successfully" sending into a dead channel.
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
external IDs — never use the raw ID.
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net"
@ -45,20 +46,20 @@ type Plugin struct {
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留每个 start 创建一个
// 新标签页CDP Target。同 source 复用自己的标签页。浏览器进程在
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
sharedAllocCtx context.Context
sharedAllocCtx context.Context
sharedAllocCancel context.CancelFunc
sharedMu sync.Mutex
sharedMu sync.Mutex
}
type BrowserSession struct {
id string
allocCtx context.Context // 共享浏览器进程上下文shared=true 时指向全局单例)
cancel context.CancelFunc
ctx context.Context // 本会话的 Target 上下文(一个标签页)
createdAt time.Time
timeout time.Duration
closed bool
mu sync.Mutex
id string
allocCtx context.Context // 共享浏览器进程上下文shared=true 时指向全局单例)
cancel context.CancelFunc
ctx context.Context // 本会话的 Target 上下文(一个标签页)
createdAt time.Time
timeout time.Duration
closed bool
mu sync.Mutex
currentURL string
shared bool // true=共享浏览器的一个标签页false=独占浏览器实例
profileDir string // 非空表示使用持久化 profile关闭时不删目录
@ -159,6 +160,18 @@ func errResult(msg string) map[string]interface{} {
return map[string]interface{}{"isError": true, "content": msg}
}
func parseBrowserSessionTimeout(args map[string]interface{}) (time.Duration, error) {
raw := strings.TrimSpace(readArg(args, "timeout", ""))
if raw == "" {
return 0, fmt.Errorf("timeout is required创建浏览器会话时必须明确指定关闭时长如 15m 或 2h")
}
timeout, err := time.ParseDuration(raw)
if err != nil || timeout <= 0 {
return 0, fmt.Errorf("invalid timeout %q请使用大于 0 的时长,如 15m 或 2h", raw)
}
return timeout, nil
}
func newHTTPClient(timeout int, proxyURL string) *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
@ -191,6 +204,9 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client {
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.SetAutoRestart(true)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout", Default: "30", Type: "int",
@ -276,14 +292,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"start", sdk.ToolDef{
Name: tp + "start",
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install无法安装时自动降级本地临时模式。同来源复用已有标签页。",
Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout到期后插件关闭标签页。同来源复用已有标签页时也按本次 timeout 重新设定关闭时间。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL可选"},
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m默认 10m)"},
"timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,15m、2h必须大于 0"},
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main。同名档案共享登录态与浏览历史不指定则为一次性临时会话"},
},
"required": []string{"timeout"},
},
}, p.handleBrowserStart)
@ -471,7 +488,9 @@ type searchResult struct {
}
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(query), count)
// 用 cn.bing.comwww.bing.com 对程序化请求常回 302同意/重定向页),拿不到结果块。
// 另Bing 忽略 count 参数,翻页靠 first=,这里保留 count 只为兼容旧调用语义。
u := fmt.Sprintf("https://cn.bing.com/search?q=%s&first=1&count=%d&setlang=zh-CN", url.QueryEscape(query), count)
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
@ -481,38 +500,112 @@ func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return parseBingResults(string(body), count), nil
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Bing 返回 HTTP %d%d 字节)", resp.StatusCode, len(body))
}
results := parseBingResults(string(body), count)
if len(results) == 0 {
// 关键:把「解析不出来」与「真的没结果」区分开。
// 以前两者都变成 "No results found.",版式一变就静默退化成「搜不到」。
return nil, fmt.Errorf("Bing 返回 %d 字节但未解析出结果(可能被反爬或版式变更,可改用 deepsearch 插件)", len(body))
}
return results, nil
}
func parseBingResults(html string, count int) []searchResult {
var (
bingBlockRe = regexp.MustCompile(`<li class="b_algo"`)
bingTitleRe = regexp.MustCompile(`(?s)<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
bingAnyLinkRe = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
bingSnipRe = regexp.MustCompile(`(?s)<p class="b_lineclamp[^"]*"[^>]*>(.*?)</p>`)
bingCaptionRe = regexp.MustCompile(`(?s)<div class="b_caption"[^>]*>(.*?)</div>`)
)
// splitBingBlocks 按块标记切分,每块内容延伸到下一个块标记为止。
//
// 不用 `<li class="b_algo"(?s)(.*?)</li>`:结果块内部可能嵌套 <li>deep links
// 非贪婪匹配会在错误位置截断;而且块内第一个 <a> 往往是 Bing 的「来源行」,
// 取到的是 `deepin.orghttps://www.deepin.org` 这种垃圾标题。
func splitBingBlocks(pageHTML string) []string {
locs := bingBlockRe.FindAllStringIndex(pageHTML, -1)
if len(locs) == 0 {
return nil
}
blocks := make([]string, 0, len(locs))
for i, loc := range locs {
end := len(pageHTML)
if i+1 < len(locs) {
end = locs[i+1][0]
}
blocks = append(blocks, pageHTML[loc[1]:end])
}
return blocks
}
func parseBingResults(pageHTML string, count int) []searchResult {
if count <= 0 {
count = 5
}
var results []searchResult
re := regexp.MustCompile(`<li class="b_algo"(?s)(.*?)</li>`)
matches := re.FindAllStringSubmatch(html, -1)
for _, m := range matches {
for _, block := range splitBingBlocks(pageHTML) {
if len(results) >= count {
break
}
block := m[1]
var r searchResult
hrefRe := regexp.MustCompile(`<a[^>]+href="([^"]+)"[^>]*>`)
if hm := hrefRe.FindStringSubmatch(block); len(hm) > 1 {
r.URL = hm[1]
// 标题:现代 Bing 是 <h2><a href=...>标题</a></h2>;没有 h2 时才退回到块内第一个链接。
var href, title string
if m := bingTitleRe.FindStringSubmatch(block); m != nil {
href, title = m[1], html.UnescapeString(stripTags(m[2]))
} else if m := bingAnyLinkRe.FindStringSubmatch(block); m != nil {
href, title = m[1], html.UnescapeString(stripTags(m[2]))
}
titleRe := regexp.MustCompile(`<a[^>]+href="[^"]+"[^>]*>(.*?)</a>`)
if tm := titleRe.FindStringSubmatch(block); len(tm) > 1 {
r.Title = stripTags(tm[1])
href = bingRealURL(html.UnescapeString(href))
// 摘要:新版在 p.b_lineclamp*,旧版在 div.b_caption > p
var snippet string
if m := bingSnipRe.FindStringSubmatch(block); m != nil {
snippet = html.UnescapeString(stripTags(m[1]))
} else if m := bingCaptionRe.FindStringSubmatch(block); m != nil {
snippet = html.UnescapeString(stripTags(m[1]))
}
snipRe := regexp.MustCompile(`<div class="b_caption">.*?<p>(.*?)</p>`)
if sm := snipRe.FindStringSubmatch(block); len(sm) > 1 {
r.Snippet = stripTags(sm[1])
}
if r.URL != "" && r.Title != "" {
results = append(results, r)
title, snippet = strings.TrimSpace(title), strings.TrimSpace(snippet)
if href == "" || title == "" || !strings.HasPrefix(href, "http") {
continue
}
results = append(results, searchResult{Title: title, URL: href, Snippet: snippet})
}
return results
}
// bingRealURL 解开 Bing 的跳转包装:/ck/a?...&u=a1<base64url>&... → 真实 URL。
// 不解的话模型拿到的是 `https://cn.bing.com/ck/a?...` 这种不可读地址。
func bingRealURL(href string) string {
href = strings.TrimSpace(href)
if href == "" {
return ""
}
if !strings.Contains(href, "/ck/a") && !strings.Contains(href, "u=a1") {
return href
}
u, err := url.Parse(href)
if err != nil {
return href
}
raw := u.Query().Get("u")
if !strings.HasPrefix(raw, "a1") {
return href
}
b64 := raw[2:]
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding} {
if dec, err := enc.DecodeString(b64); err == nil {
s := string(dec)
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
return s
}
}
}
return href
}
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
query := readArg(args, "query", "")
if query == "" {
@ -881,10 +974,9 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
}
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
timeoutStr := readArg(args, "timeout", "10m")
timeout, err := time.ParseDuration(timeoutStr)
timeout, err := parseBrowserSessionTimeout(args)
if err != nil {
timeout = 10 * time.Minute
return errResult(err.Error()), nil
}
source := readArg(args, "source", "")
@ -899,13 +991,19 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
s.mu.Lock()
id := s.id
cur := s.currentURL
s.createdAt = time.Now()
s.timeout = timeout
closesAt := s.createdAt.Add(timeout)
s.mu.Unlock()
p.mu.Unlock()
log.Printf("[%s] reused browser session %s: timeout=%v closes_at=%s source=%s", p.name, id, timeout, closesAt.Format(time.RFC3339), source)
return map[string]interface{}{
"id": id,
"status": "reused",
"url": cur,
"note": "已复用本来源的现有标签页(登录态全机共享)",
"id": id,
"status": "reused",
"url": cur,
"timeout": timeout.String(),
"closes_at": closesAt.Format(time.RFC3339),
"note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间",
}, nil
}
}
@ -942,7 +1040,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
"插件会注册 homeagent-browser.service 并启动。" +
"若本机无法联网安装 chromium可继续用本地临时模式重试 browser_start 即自动降级)。"
return map[string]interface{}{
"error": "backend not installed",
"error": "backend not installed",
"need_install": true,
"guide": guide,
}, nil
@ -972,13 +1070,15 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
session.currentURL = initURL
}
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
closesAt := session.createdAt.Add(timeout)
log.Printf("[%s] created browser session %s: url=%s timeout=%v closes_at=%s source=%s", p.name, id, initURL, timeout, closesAt.Format(time.RFC3339), source)
return map[string]interface{}{
"id": id,
"status": "created",
"mode": "shared-backend",
"url": initURL,
"timeout": timeout.String(),
"id": id,
"status": "created",
"mode": "shared-backend",
"url": initURL,
"timeout": timeout.String(),
"closes_at": closesAt.Format(time.RFC3339),
}, nil
}
@ -1044,11 +1144,11 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
}
b64 := base64.StdEncoding.EncodeToString(buf)
return map[string]interface{}{
"status": "ok",
"format": format,
"size": len(buf),
"base64": b64,
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
"status": "ok",
"format": format,
"size": len(buf),
"base64": b64,
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
}, nil
}
@ -1079,11 +1179,11 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
html = html[:maxChars] + "\n\n[HTML truncated]"
}
return map[string]interface{}{
"status": "ok",
"title": title,
"url": currentURL,
"html": html,
"length": len(html),
"status": "ok",
"title": title,
"url": currentURL,
"html": html,
"length": len(html),
}, nil
}
@ -1204,13 +1304,20 @@ func (p *Plugin) cleanupLoop() {
case <-p.stopCh:
return
case <-ticker.C:
now := time.Now()
p.mu.Lock()
for id, s := range p.sessions {
if time.Since(s.createdAt) >= s.timeout {
log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
delete(p.sessions, id)
s.Close()
p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
s.mu.Lock()
closesAt := s.createdAt.Add(s.timeout)
expired := !now.Before(closesAt)
s.mu.Unlock()
if expired {
log.Printf("[%s] cleanup: browser session %s reached agent-specified close time %s", p.name, id, closesAt.Format(time.RFC3339))
delete(p.sessions, id)
s.Close()
// NoMemory会话生命周期通知不是记忆内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true})
}
}
p.mu.Unlock()
@ -1310,7 +1417,7 @@ WantedBy=multi-user.target
return map[string]interface{}{
"status": "installed",
"endpoint": cdpEndpoint,
"chrome": chromePath,
"chrome": chromePath,
"profile": profileDir,
"guide": guide,
}, nil

View File

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

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -15,31 +15,31 @@ import (
)
const (
RepeatNone = "none"
RepeatDaily = "daily"
RepeatWeekday = "weekday"
RepeatWeekly = "weekly"
RepeatBiweekly = "biweekly"
RepeatMonthly = "monthly"
RepeatYearly = "yearly"
RepeatNone = "none"
RepeatDaily = "daily"
RepeatWeekday = "weekday"
RepeatWeekly = "weekly"
RepeatBiweekly = "biweekly"
RepeatMonthly = "monthly"
RepeatYearly = "yearly"
RepeatLunarYearly = "lunar_yearly"
)
type CalendarEvent struct {
ID string `json:"id"`
Title string `json:"title"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time,omitempty"`
AllDay bool `json:"all_day,omitempty"`
Location string `json:"location,omitempty"`
Note string `json:"note,omitempty"`
Reminds []int `json:"reminds,omitempty"`
RemindAt []int64 `json:"remind_at,omitempty"`
Repeat string `json:"repeat,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Lunar bool `json:"lunar,omitempty"`
LunarMonth int `json:"lunar_month,omitempty"`
LunarDay int `json:"lunar_day,omitempty"`
ID string `json:"id"`
Title string `json:"title"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time,omitempty"`
AllDay bool `json:"all_day,omitempty"`
Location string `json:"location,omitempty"`
Note string `json:"note,omitempty"`
Reminds []int `json:"reminds,omitempty"`
RemindAt []int64 `json:"remind_at,omitempty"`
Repeat string `json:"repeat,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Lunar bool `json:"lunar,omitempty"`
LunarMonth int `json:"lunar_month,omitempty"`
LunarDay int `json:"lunar_day,omitempty"`
}
type Plugin struct {
@ -276,6 +276,9 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
// 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
@ -359,7 +362,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"today", sdk.ToolDef{
Name: tp + "today", Description: "Show today's events with countdown.",
Parameters: map[string]interface{}{
"type": "object",
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleToday)
@ -367,7 +370,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"week", sdk.ToolDef{
Name: tp + "week", Description: "Show this week's events grouped by day.",
Parameters: map[string]interface{}{
"type": "object",
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleWeek)

View File

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

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

@ -0,0 +1,21 @@
module deepsearch-plugin
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

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

View File

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

1038
example/deepsearch/plugin.go Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

49
example/editdoc/README.md Normal file
View File

@ -0,0 +1,49 @@
# editdoc · Office 文档编辑
编辑 `.docx` / `.xlsx` / `.pptx` 内容:查找替换、改单元格、插行。
> ⚠️ **版本说明**:本目录是 **v1.0.0**,只有 `edit_document` 一个工具。
> 线上部署的 v2.0.0(全能办公版,支持新建/读取/转换 docx·xlsx·pptx·md·csv·txt
> **源码尚未公开**,本文档不描述那些能力。参见 `plugin.json` 的 `version`。
## 工具
| 工具 | 说明 |
|---|---|
| `edit_document` | 编辑文档内容,**编辑后原文件被覆盖** |
参数:
| 参数 | 说明 |
|---|---|
| `file` | 文档路径(必填) |
| `operation` | `replace_text`(查找替换)/ `set_cell`(设置单元格)/ `insert_row`(插入行)(必填) |
| `target` | 要查找的文本(`replace_text` 用) |
| `replacement` | 替换为的文本(`replace_text` 用) |
| `sheet` | 工作表名xlsx 可选) |
| `row` | 行号(`set_cell` / `insert_row` 用) |
| `col` | 列号(`set_cell` 用) |
| `value` | 单元格值(`set_cell` 用) |
编辑前建议先读一遍内容确认目标文本 —— 查找替换是**全文件覆盖写**,没有撤销。
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `script_path` | 空 | `edit_doc.py` 的绝对路径。留空则用插件可执行文件同目录下的 `edit_doc.py` |
| `venv_python` | 空 | 执行 `edit_doc.py` 的 Python 解释器(建议用 venv 里的)。**必须配置,留空会报错** |
## 工作原理
本插件是 Go 写的薄壳:把参数序列化成 JSON交给 Python 脚本 `edit_doc.py` 执行实际文档操作。
文档解析依赖 Python 侧的库python-docx / openpyxl / python-pptx 之类),所以:
- **需要自备 `edit_doc.py`**:它不在本目录里。
-`venv_python` 指向装了这些库的解释器,避免污染系统 Python。
## 构建
```bash
hmapdev build
```

44
example/files/README.md Normal file
View File

@ -0,0 +1,44 @@
# files · 沙箱文件操作
读写与编辑文件,**全部操作限制在沙箱目录内**。
## 工具
| 工具 | 说明 |
|---|---|
| `files_read` | 读文件内容,支持 `offset` / `limit` 读大文件 |
| `files_write` | 写文件,**自动创建父目录** |
| `files_edit` | 按精确字符串替换改文件 |
| `files_ls` | 列目录(目录名带 `/` 后缀) |
`files_edit``edits[]` 传多组替换,每组 `{old, new}`
- 每个 `old` 必须在**原文件**中**恰好出现一次** —— 不唯一会报错,避免改错地方。
- 所有替换都针对**原内容**匹配,不要在同一个 `edits` 里写相互重叠的改动。
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `dir` | 空 | 允许访问的根目录。留空用默认沙箱(主数据目录下的 `files_sandbox`)。**不建议设为 `/`** |
## 沙箱实现
路径校验不止一次,是两道:
1. **规范化后判断**`filepath.Abs` + `filepath.Clean`,再用 `withinSandbox`
检查结果是否在根目录之下(`/` 作为特例放行)。
2. **解析符号链接后再判断**`filepath.EvalSymlinks` 求出真实路径,**再查一次**沙箱。
第 2 步是关键:只做第 1 步的话,沙箱内一个指向外部的软链接就能绕过限制
`.../sandbox/link -> /etc`)。报错文案也区分了这两种情况
`path outside sandbox` vs `path escapes sandbox via symlink`)。
对不存在的路径(`write` 会用到),求真实路径时只对已存在的部分做 `EvalSymlinks`
其余保留为未创建的尾部。
## 构建
```bash
hmapdev build
```

View File

@ -1,13 +1,16 @@
# luademo
Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面:
Lua 插件全功能示例,展示 Lua SDK 的完整能力面(对齐 SDK 1.3.0
- **工具注册**`no_memory` + `cleaner`(记忆计算层过滤)
- **工具注册**`no_memory` + `context_policy` + `cleaner`(记忆计算层过滤)
- **阶段钩子**`register_stage(stage, handler, scope)``own_tools` 与全局作用域
- **通道**`register_output_channel` / `register_input_channel`def 支持 no_memory/cleaner
- **数据类 API**`sdk.memory.*``sdk.doc.*``sdk.knowledge.*``sdk.text_memory.*``sdk.llm.*``sdk.settings.*``sdk.social.*`
- **通道**`register_output_channel` / `register_input_channel` / `unregister_output_channel`def 支持 no_memory/context_policy/cleaner
- **注入**`inject_text` / `inject_interrupt` / `inject_text_no_memory``*_opts`no_memory/context_policy/cleaner_name/priority`inject_input_sync``inject_*_media``set_tool_blocks`
- **数据类 API**`sdk.memory.*`(含 sentence_text/media_digests`sdk.doc.*`(含 insert_with_media`sdk.knowledge.*``sdk.text_memory.*`(含 attachments`sdk.llm.*``sdk.settings.*``sdk.social.*``sdk.events.*``sdk.plugin_mgr.*`
- **其他**`register_api``set_auto_restart`
> `luademo_probe_v2` 巡检 1.1/1.2/1.3 新增面。它**故意不调用** `inject_input_sync`:工具 handler 在 LLM 回合内运行,同步注入会自己等自己(死锁)。
## 本地独立测试
```bash

View File

@ -67,6 +67,65 @@ function plugin.start(sdk)
return { content = res }
end)
-- 工具1.1/1.2/1.3 新增能力巡检(媒体块 / 注入标志位 / 事件 / 动态通道注销)
-- 注意:故意不在这里调用 sdk.inject_input_sync——工具handler 运行在 LLM 回合内,
-- 同步注入会等本轮回复,等于自己等自己(死锁)。同步注入只适合事件回调等外部入口。
sdk.register_tool("luademo_probe_v2", {
description = "Exercise media blocks, inject opts, events and channel unregister",
parameters = { type = "object", properties = {} },
no_memory = true,
context_policy = "prune",
}, function(args)
local res = {}
-- 多模态:设置下一轮 tool message 携带的内容块
sdk.set_tool_blocks({
{ type = "text", text = "luademo media block" },
{ type = "image_url", image_url = { url = "https://example.com/x.png", detail = "low" } },
})
res.set_tool_blocks = "ok"
-- 注入标志位(零值 opts 与旧三参数等价)
sdk.inject_text_opts("luademo", "luademo_in", "opts inject", {
no_memory = true, context_policy = "prune",
})
res.inject_text_opts = "ok"
-- 带媒体的中断注入
sdk.inject_interrupt_media("luademo", "luademo_in", "media inject", {
{ type = "audio_url", audio_url = { url = "https://example.com/a.mp3" } },
})
res.inject_interrupt_media = "ok"
-- 媒体入记忆:三元组带原句,文档带附件
local _, merr = sdk.memory.commit({{
subject = "luademo", relation = "shows", object = "image",
sentence_text = "luademo shows an image", media_digests = {},
}})
res.memory_commit_with_sentence = { err = merr }
local _, derr = sdk.doc.insert_with_media(
{ id = "luademo-media", title = "media", content = "with attachment" },
{ { mime = "image/png", name = "x.png", data = "aGVsbG8=" } })
res.doc_insert_with_media = { err = derr }
-- 事件订阅(返回取消订阅函数)
local unsub = sdk.events.subscribe("agent_output", function(evt)
sdk.log("info", "luademo event: " .. tostring(evt.type))
end)
res.events_subscribe = type(unsub)
if unsub then unsub() end
-- 插件管理(只读查询)
res.plugin_mgr_loaded = type(sdk.plugin_mgr.list_loaded())
-- 动态输出通道注销
sdk.register_output_channel("luademo_dyn", 0, "dynamic", {}, function(a) return { ok = true } end)
local _, uerr = sdk.unregister_output_channel("luademo_dyn")
res.unregister = { err = uerr }
return { content = res }
end)
-- 阶段钩子own_tools 作用域(仅本插件工具被调用时触发)
sdk.register_stage("before_toolcall", function(ctx)
local calls = ctx.tool_calls or {}

View File

@ -1,67 +1,413 @@
-- HomeAgent Lua Plugin SDK (standalone mock)
-- HomeAgent Lua Plugin SDK
-- Interface contract between Lua plugins and HomeAgent kernel.
-- !impl functions are replaced by Go implementations at runtime.
-- Standalone/debug: pure Lua mock implementations are used.
-- Usage: local sdk = require("sdk")
sdk = {}
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
function sdk.get_setting(key) return nil end
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
-- !impl
-- level: "debug" | "info" | "warn" | "error"
function sdk.log(level, msg)
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
end
-- !impl
-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_tool(name, def, handler)
print("[lua-plugin] register_tool: " .. tostring(name))
end
-- !impl
-- stage: "on_input" | "pre_action" | "post_action" | ...
-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发)
function sdk.register_stage(stage, handler, scope)
print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope))
end
-- !impl
function sdk.register_api(name)
print("[lua-plugin] register_api: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_output_channel(name, caps, desc, def, handler)
print("[lua-plugin] register_output_channel: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
function sdk.register_input_channel(name, def)
print("[lua-plugin] register_input_channel: " .. tostring(name))
end
-- !impl
function sdk.get_setting(key)
return nil
end
-- !impl
function sdk.set_setting(key, value)
print("[lua-plugin] set_setting: " .. tostring(key))
end
-- !impl
function sdk.inject_text(source, channel, text)
print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel))
end
-- !impl
function sdk.inject_interrupt(source, channel, text)
print("[lua-plugin] inject_interrupt: " .. tostring(source))
end
-- !impl
function sdk.inject_text_no_memory(source, channel, text)
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
end
-- !impl
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
function sdk.inject_text_opts(source, channel, text, opts)
print("[lua-plugin] inject_text_opts: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_opts(source, channel, text, opts)
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
end
-- !impl
-- 同步注入在 Lua 插件中**不可用**:会等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。
-- 真实内核里恒返回 (nil, err);这里返回同样的错误,避免离线测试误以为可用。
function sdk.inject_input_sync(source, channel, text)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_input_sync_opts(source, channel, text, opts)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt确需同步等待请改用 Go 插件。"
end
-- !impl
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
function sdk.set_tool_blocks(blocks)
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
end
-- !impl
-- blocks 每项:{ type="text", text="..." }
-- | { type="image_url", image_url={ url="...", detail="high" } }
-- | { type="audio_url", audio_url={ url="..." } }
function sdk.inject_input_media(source, channel, text, blocks)
print("[lua-plugin] inject_input_media: " .. tostring(source))
end
-- !impl
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
end
-- !impl
-- 同 sdk.inject_input_syncLua 中不可用。
function sdk.inject_input_media_sync(source, channel, text, blocks)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media_opts确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_interrupt_media(source, channel, text, blocks)
print("[lua-plugin] inject_interrupt_media: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source))
end
-- !impl
-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。
function sdk.unregister_output_channel(name) return nil, nil end
-- !impl
-- enabled: true/false崩溃时内核自动拉起
function sdk.set_auto_restart(enabled)
print("[lua-plugin] set_auto_restart: " .. tostring(enabled))
end
-- ============ graph memory ============
-- !impl
sdk.memory = {}
-- !impl
-- query: string, depth: number -> {entities={...}, relations={...}}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
-- !impl
-- triples: { {subject=, relation=, object=, [confidence=], [sentence_text=]} } -> err
function sdk.memory.commit(triples) return nil end
-- !impl
function sdk.memory.introspect() return {} end
-- !impl
function sdk.memory.merge(source, target) return 0 end
-- !impl
-- criteria: {key=value}, hard: boolean
function sdk.memory.purge(criteria, hard) return 0 end
-- ============ document memory ============
-- !impl
sdk.doc = {}
-- !impl
function sdk.doc.query(text, top_k) return {} end
-- !impl
-- doc: { id=, title=, content= }
function sdk.doc.insert(doc) return nil end
-- !impl
-- attachments 每项:{ digest=, mime=, name=, data=<base64> }
function sdk.doc.insert_with_media(doc, attachments) return nil end
-- !impl
function sdk.doc.remove(id) return nil end
-- !impl
function sdk.doc.stats() return {} end
-- ============ knowledge ============
-- !impl
sdk.knowledge = {}
-- !impl
function sdk.knowledge.search(query, limit) return {} end
-- !impl
function sdk.knowledge.add(tag, content) return nil end
-- !impl
function sdk.knowledge.list() return {} end
-- ============ text memory ============
-- !impl
sdk.text_memory = {}
-- !impl
-- evt: { timestamp=, role=, content=, channel= }
function sdk.text_memory.append(evt) return nil end
-- ============ llm ============
-- !impl
sdk.llm = {}
-- !impl
function sdk.llm.list_sources() return {} end
-- !impl
function sdk.llm.set_source(name) return nil end
-- !impl
function sdk.llm.current_source() return nil end
-- ============ social (只读) ============
-- !impl
sdk.social = {}
-- !impl
function sdk.social.get_person(name) return {} end
-- !impl
function sdk.social.get_network(name, depth) return {} end
-- !impl
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
-- !impl
function sdk.social.get_relations(name) return {} end
-- !impl
function sdk.social.list_persons() return {} end
-- ============ settings (作用域变体) ============
-- !impl
sdk.settings = {}
-- !impl
function sdk.settings.get_core(key) return nil end
-- !impl
function sdk.settings.set_core(key, value) return nil end
-- !impl
function sdk.settings.list_core(prefix) return {} end
-- !impl
function sdk.settings.get_plugin(plugin, key) return nil end
-- !impl
function sdk.settings.set_plugin(plugin, key, value) return nil end
-- !impl
function sdk.settings.list_plugin(plugin, prefix) return {} end
-- !impl
function sdk.settings.list(prefix) return {} end
-- !impl
-- def: { key=, type=, display_name=, description=, category=, options=, default=,
-- min=, max=, step=, required=, secret= }
function sdk.settings.register_def(def) return nil end
-- !impl
function sdk.settings.defs(prefix) return {} end
-- !impl
function sdk.settings.dump() return {} end
-- !impl
function sdk.settings.plugins() return {} end
-- ============ events只读订阅 ============
-- !impl
-- subscribe(event_type, handler) -> unsubscribe()
-- handler 收到 { type=, source=, timestamp=, payload= }
-- 回调在其内核事件发布 goroutine 上执行只做轻量转发不可阻塞Lua 单状态 + 互斥锁)。
sdk.events = {}
function sdk.events.subscribe(event_type, handler)
print("[lua-plugin] events.subscribe: " .. tostring(event_type))
return function() end
end
-- ============ plugin_mgr ============
-- !impl
sdk.plugin_mgr = {}
function sdk.plugin_mgr.reload_one(name) return nil end
function sdk.plugin_mgr.list_loaded() return {} end
function sdk.plugin_mgr.is_disabled(name) return false end
-- json utils (pure Lua)
sdk.json = {}
function sdk.json.encode(val)
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
elseif type(val) == "table" then local parts, i = {}, 1
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
return "{" .. table.concat(parts, ",") .. "}" end
local ok, result = pcall(function()
local function _encode(v)
local t = type(v)
if t == "string" then
local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t')
return '"' .. s .. '"'
elseif t == "number" then
return tostring(v)
elseif t == "boolean" then
return tostring(v)
elseif t == "table" then
local keys = {}
local is_array = true
local maxn = 0
for k in pairs(v) do
keys[#keys + 1] = k
if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
is_array = false
end
if type(k) == "number" and k > maxn then maxn = k end
end
if is_array and #keys >= maxn then
local parts = {}
for i = 1, maxn do
parts[#parts + 1] = _encode(v[i])
end
return "[" .. table.concat(parts, ",") .. "]"
else
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k])
end
return "{" .. table.concat(parts, ",") .. "}"
end
else
return "null"
end
end
return _encode(val)
end)
if ok then return result end
return "null"
end
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
function sdk.json.decode(str)
local ok, result = pcall(function()
local pos, _end = 1, #str
local function skip()
while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end
end
local function parse()
skip()
if pos > _end then return nil end
local c = str:sub(pos, pos)
if c == '"' then
local s = {}
pos = pos + 1
while pos <= _end do
local ch = str:sub(pos, pos)
if ch == '"' then
pos = pos + 1
return table.concat(s)
elseif ch == '\\' then
pos = pos + 1
local n = str:sub(pos, pos)
if n == '"' then s[#s+1] = '"'
elseif n == '\\' then s[#s+1] = '\\'
elseif n == '/' then s[#s+1] = '/'
elseif n == 'b' then s[#s+1] = '\b'
elseif n == 'f' then s[#s+1] = '\f'
elseif n == 'n' then s[#s+1] = '\n'
elseif n == 'r' then s[#s+1] = '\r'
elseif n == 't' then s[#s+1] = '\t'
elseif n == 'u' then
local hex = str:sub(pos+1, pos+4)
pos = pos + 4
s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?'
end
pos = pos + 1
else
s[#s+1] = ch
pos = pos + 1
end
end
return table.concat(s)
elseif c == 't' then pos = pos + 4; return true
elseif c == 'f' then pos = pos + 5; return false
elseif c == 'n' then pos = pos + 4; return nil
elseif c == '{' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == '}' then pos = pos + 1; return t end
while true do
skip(); local k = parse(); skip()
if str:sub(pos, pos) == ':' then pos = pos + 1 end
skip(); t[k] = parse(); skip()
local sep = str:sub(pos, pos)
if sep == '}' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
elseif c == '[' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == ']' then pos = pos + 1; return t end
local idx = 1
while true do
skip(); t[idx] = parse(); idx = idx + 1; skip()
local sep = str:sub(pos, pos)
if sep == ']' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
else
local s, e = str:find('^[-%d%.eE]+', pos)
if s then
local num = tonumber(str:sub(s, e))
pos = e + 1
return num
end
return nil
end
end
return parse()
end)
if ok then return result end
return nil
end
-- http utils
sdk.http = {}
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
-- !impl
function sdk.http.get(url)
print("[lua-plugin] http.get: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
-- !impl
function sdk.http.post(url, body, content_type)
print("[lua-plugin] http.post: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
return sdk

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

@ -0,0 +1,43 @@
# memo · 待办与备忘录
两类条目,行为**刻意不同**
| 类型 | 用途 | 是否主动提醒 |
|---|---|---|
| **待办**todo | 有截止概念、需要被催的事 | ✅ 会 |
| **备忘录**memo | 纯记事,供以后查阅 | ❌ 不会 |
分开的理由:把"提醒我"和"记一下"混成一类,要么备忘录天天弹、要么待办被忘掉。
## 工具
| 工具 | 说明 |
|---|---|
| `memo_todo_add` | 添加待办(会被主动提醒) |
| `memo_todo_complete` | 标记待办完成(不再提醒) |
| `memo_todo_list` | 列出未完成待办(含 ID、内容、创建时间 |
| `memo_todo_delete` | 删除待办(含已完成的) |
| `memo_memo_create` | 创建备忘录(纯记事,不提醒) |
| `memo_memo_list` | 列出全部备忘录 |
| `memo_memo_delete` | 删除备忘录 |
> 工具名前缀取自插件名(`p.tp`),上面按默认的 `memo_` 写法列出。
## 提醒机制
- 后台 **每 5 分钟**检查一次未完成待办数;有则通过 `InjectInterruptText` 注入一条
「注意,你还有 N 条待办未完成,请检查」。
- 注入带 **`NoMemory: true`** —— 这是定时提醒,不是记忆内容,不该进向量化。
- 通道声明为 **`NoMemory`**`RegisterInputChannel(p.name, ChannelDef{NoMemory:true})`
理由同上:提醒是瞬时信号。
- 另有 `StagePreAction` 钩子,在每轮动作前参与。
## 存储
条目落在数据目录的 `todos.json`,插件重启后仍在。
## 构建
```bash
hmapdev build
```

View File

@ -48,6 +48,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.tp = p.name + "_"
// 入站通道:本插件用 p.name 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {

30
example/music/README.md Normal file
View File

@ -0,0 +1,30 @@
# music · 音乐搜索
按关键词搜歌、按 ID 查歌词(数据来自网易云音乐公开接口)。
## 工具
| 工具 | 说明 |
|---|---|
| `music_search` | 按关键词搜歌,返回歌曲列表(含歌曲 ID |
| `music_lyrics` | 按歌曲 ID 取歌词 |
典型两段式用法:先 `music_search` 拿 ID`music_lyrics` 取词。
## 实现要点
- 请求打的是 `https://music.163.com/api/...`,并固定带上 `Referer: https://music.163.com/` ——
该接口对缺少来源头的请求会拒绝。
- 是**只读**插件:不下载音频、不写本地文件,因此没有需要清理的副作用。
## 已知边界
- 依赖第三方(网易云)公开接口,其可用性与返回结构不受本插件控制;
接口变动时可能返回空列表,而不是报错。
- 仅覆盖"搜索 + 歌词",不含播放地址解析。
## 构建
```bash
hmapdev build
```

40
example/ocr/README.md Normal file
View File

@ -0,0 +1,40 @@
# ocr · 图片文字识别
从图片里提取文字(中英文),基于 [Tesseract](https://github.com/tesseract-ocr/tesseract) OCR 引擎。
## 前置依赖
需要系统里装有 `tesseract` 可执行文件:
```bash
# Debian/Ubuntu
apt install tesseract-ocr tesseract-ocr-chi-sim
```
中文识别需要 `chi_sim` 语言包;缺它时中文会识别成乱码而非报错。
## 工具
| 工具 | 说明 |
|---|---|
| `ocr_ocr_image` | 对图片做 OCR返回识别文本 |
参数:
| 参数 | 说明 |
|---|---|
| `image_url` | 图片的 HTTP/HTTPS 地址(与 `image_data` 二选一) |
| `image_data` | 图片的 base64 数据,**不含** `data:image/...` 前缀(与 `image_url` 二选一) |
| `language` | 识别语言,默认 `chi_sim+eng`;可选 `chi_sim` / `eng` / `chi_sim+eng` |
## 实现要点
- 传入的图先落到临时目录OCR 完 `defer os.RemoveAll` 清掉,不残留。
- 调用参数固定 `--psm 3`(全自动页面分割),适合截图与常规排版图片;对单行小图或竖排文本效果会下降。
- **`Cleaner`**:工具返回的是 JSON`text``language` 等字段),进记忆计算前只取 `text` 正文 —— 否则 JSON 结构本身会参与向量化。
## 构建
```bash
hmapdev build
```

View File

@ -0,0 +1,47 @@
# plugindev — 插件开发工具链Agent 可调用)
把 SDK 的 `hmapdev` 封装成插件,让 **Agent 自己**走完「新建插件 → 构建 → 安装」全流程,
不需要人来敲命令行:
```
plugindev_init 生成工程骨架(等价 hmapdev init <name> [--lua]
↓ 改 plugin.go
plugindev_build 构建打包(等价在该目录 hmapdev build→ dist/*.hmap
plugin_install 安装(用 path 指向刚构建出的 .hmapoverwrite=true 表示原地更新)
plgreload 重载生效
```
## 工具
| 工具 | 参数 | 说明 |
|---|---|---|
| `plugindev_status` | — | hmapdev 是否可用/版本/当前 SDK 版本与路径/工作区;**排查"为什么不能构建"先用它** |
| `plugindev_init` | `name``lang`(go/lua)、`dir` | 生成工程骨架;插件名必须 `[a-zA-Z0-9_-]{1,64}` |
| `plugindev_build` | `dir``target` | 在工程目录构建打包;产物路径会在返回里给出 |
| `plugindev_sdk` | `action``version``from` | SDK 版本管理list/current/path/latest/install/use`from` 可指向本地 SDK 源码 |
| `plugindev_projects` | — | 列出工作区里已有工程与产物 |
## 配置
| 键 | 默认 | 说明 |
|---|---|---|
| `hmapdev_path` | 自动查找 | 依次尝试:本配置项 → PATH → `/usr/local/bin/hmapdev``/root/go/bin/hmapdev` |
| `workspace_dir` | `<data_dir>/plugindev` | `plugindev_init` 生成工程的默认目录 |
| `build_timeout_sec` | 600 | 单次 hmapdev 调用超时 |
## 前置:装 hmapdev
```bash
cd <sdk-repo>/tools/hmapdev && go build -buildvcs=false -o /usr/local/bin/hmapdev .
hmapdev version
```
## 安全边界(都在实现里,不只写在文档里)
- 只 exec **hmapdev 一个可执行文件**,不接受任意命令、不做 shell 拼接;
- `plugindev_build` 只接受含 `plg.json` 的目录("看起来是插件工程"才构建),
避免把这个工具变成对任意目录跑构建;
- 子进程全部带超时,输出**截断**后才返回(构建日志动辄几百 KB直接回灌会撑爆模型上下文
- 工程名约束与内核/上游对"进工具名的标识符"的规则一致(`[a-zA-Z0-9_-]{1,64}`)。

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

@ -0,0 +1,7 @@
module plugindev
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/plugindev/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -0,0 +1,19 @@
{
"name": "plugindev",
"name_zh": "插件开发工具链",
"name_en": "Plugin Dev Toolchain",
"version": "1.0.0",
"description": "把 hmapdev 工具链封装成 Agent 可调用的工具:脚手架生成插件工程、构建打包 .hmap、管理 SDK 版本。配合 plugin_install 即可让 Agent 自己做完「新建插件 → 构建 → 安装」全流程。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"plugindev",
"toolchain",
"developer"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

455
example/plugindev/plugin.go Normal file
View File

@ -0,0 +1,455 @@
package main
// plugindev把 SDK 的 hmapdev 工具链封装成 Agent 可调用的插件。
//
// 为什么需要它hmapdev 是"给人和 CI 用"的命令行工具。做成插件后Agent 能自己:
// plugindev_init脚手架→ plugindev_build构建出 .hmap→ plugin_install安装→ plgreload
// 也就是"让 Agent 自己写/改/装插件"这条链不需要人来敲命令。
//
// 安全边界(都在实现里落实,不只写在描述里):
// - 只有 **hmapdev 一个可执行文件**会被 exec不接受任意命令/参数拼接);
// - `plugindev_build` 只接受"看起来是插件工程"的目录(含 plg.json
// 避免把一个 `hmapdev build` 变成对任意目录的操作;
// - `plugindev_init` 生成的工程名必须满足 `[a-zA-Z0-9_-]{1,64}`(与 LLM 函数名
// 同一套约束 —— 插件名会进 `output_send__<通道>` 之类的工具名);
// - 所有子进程都有超时,输出截断后再返回(防止把几十 MB 构建日志灌进模型上下文)。
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
const (
defaultBuildTimeout = 10 * time.Minute
maxOutputChars = 6000
)
// namePattern 与内核/上游对"会进工具名的标识符"的约束一致。
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
type Plugin struct {
name string
sdk *sdk.PluginSDK
hmapdev string // 解析到的 hmapdev 可执行文件路径
workspace string // 默认工作区(生成的工程落在这里)
timeout time.Duration // 单次 hmapdev 调用的超时
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "hmapdev_path", Type: "string", DisplayName: "hmapdev 路径",
Description: "插件开发工具链可执行文件路径。留空则按 PATH → /usr/local/bin/hmapdev → /root/go/bin/hmapdev 查找",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "workspace_dir", Type: "string", DisplayName: "工程工作区",
Description: "plugindev_init 生成工程的默认目录。留空则用 <data_dir>/plugindev",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "build_timeout_sec", Default: 600, Type: "int", DisplayName: "构建超时(秒)",
Description: "单次 hmapdev 调用的超时上限",
Category: p.name,
})
p.hmapdev = p.resolveHmapdev()
p.workspace = p.resolveWorkspace()
p.timeout = defaultBuildTimeout
if v, _ := s.Settings().Get("build_timeout_sec"); v != nil {
if n, ok := toInt(v); ok && n > 0 {
p.timeout = time.Duration(n) * time.Second
}
}
s.RegisterTool("plugindev_status", sdk.ToolDef{
Name: "plugindev_status",
Description: "查看插件开发工具链状态hmapdev 是否可用、版本、当前 SDK 版本与路径、工程工作区目录。排查\"为什么不能构建插件\"时先用它。",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, p.handleStatus)
s.RegisterTool("plugindev_init", sdk.ToolDef{
Name: "plugindev_init",
Description: "生成一个新的插件工程骨架(等价于 `hmapdev init <name> [--lua]`)。生成后在返回的目录里改 plugin.go再用 plugindev_build 构建。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
"description": "插件名(也是工程目录名):只允许字母数字下划线短横,长度 1-64。例my_plugin",
},
"lang": map[string]interface{}{
"type": "string", "description": "go默认或 lua",
},
"dir": map[string]interface{}{
"type": "string", "description": "在哪个目录下生成(默认工作区)。必须是已存在的目录",
},
},
"required": []string{"name"},
},
}, p.handleInit)
s.RegisterTool("plugindev_build", sdk.ToolDef{
Name: "plugindev_build",
Description: "构建并打包一个插件工程(等价于在该工程目录里执行 `hmapdev build [target]`),产物是 dist/*.hmap。构建成功后用 plugin_install 安装(本地路径)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"dir": map[string]interface{}{
"type": "string", "description": "插件工程目录(必须含 plg.json",
},
"target": map[string]interface{}{
"type": "string", "description": "构建目标,留空 = native当前平台。例linux/amd64",
},
},
"required": []string{"dir"},
},
}, p.handleBuild)
s.RegisterTool("plugindev_sdk", sdk.ToolDef{
Name: "plugindev_sdk",
Description: "管理插件 SDK 版本hmapdev sdk 子命令list 列出已安装、current 当前版本、path 当前路径、latest 远端最新、install 安装某版本(可用 from 指定本地源码目录、use 切换版本。构建插件报\"SDK 缺少某能力\"时用它升级 SDK。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string", "description": "list | current | path | latest | install | use",
},
"version": map[string]interface{}{
"type": "string", "description": "install/use 的版本号,如 v1.3.0",
},
"from": map[string]interface{}{
"type": "string", "description": "install 时用本地 SDK 源码目录(开发中的 SDK 用这个)",
},
},
"required": []string{"action"},
},
}, p.handleSDK)
s.RegisterTool("plugindev_projects", sdk.ToolDef{
Name: "plugindev_projects",
Description: "列出工作区里已有的插件工程(名字、版本、是否已构建出 dist 产物),用于接续之前的开发。",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, p.handleProjects)
log.Printf("[plugindev] 就绪hmapdev=%s 工作区=%s", fallback(p.hmapdev, "(未找到)"), p.workspace)
return nil
}
func (p *Plugin) Stop() error { return nil }
// ---------------- 工具实现 ----------------
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
out := map[string]interface{}{
"hmapdev": fallback(p.hmapdev, ""),
"workspace": p.workspace,
}
if p.hmapdev == "" {
out["available"] = false
out["hint"] = "未找到 hmapdev。请安装go build -o /usr/local/bin/hmapdev <sdk>/tools/hmapdev"
return out, nil
}
out["available"] = true
if txt, err := p.run(nil, ""); err == nil {
out["version"] = strings.TrimSpace(txt)
} else {
out["error"] = err.Error()
}
if txt, err := p.run([]string{"sdk", "current"}, ""); err == nil {
out["sdk_current"] = strings.TrimSpace(txt)
}
if txt, err := p.run([]string{"sdk", "path"}, ""); err == nil {
out["sdk_path"] = strings.TrimSpace(txt)
}
return out, nil
}
func (p *Plugin) handleInit(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
name = strings.TrimSpace(name)
if !namePattern.MatchString(name) {
return map[string]interface{}{
"error": "插件名只允许 [a-zA-Z0-9_-],长度 1-64它会进 LLM 工具名,违规会让整条请求被上游拒绝)",
}, nil
}
dir, _ := args["dir"].(string)
if dir == "" {
dir = p.workspace
}
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return map[string]interface{}{"error": fmt.Sprintf("目录不存在: %s", dir)}, nil
}
cmd := []string{"init", name}
if lang, _ := args["lang"].(string); strings.EqualFold(lang, "lua") {
cmd = append(cmd, "--lua")
}
txt, err := p.run(cmd, dir)
res := map[string]interface{}{"output": txt, "project_dir": filepath.Join(dir, name)}
if err != nil {
res["error"] = err.Error()
}
return res, nil
}
func (p *Plugin) handleBuild(args map[string]interface{}) (interface{}, error) {
dir, _ := args["dir"].(string)
if dir == "" {
return map[string]interface{}{"error": "dir 不能为空"}, nil
}
abs, err := filepath.Abs(dir)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
// 只在"插件工程"里构建:必须存在 plg.json。这样这个工具不会变成对任意目录跑构建。
manifest := filepath.Join(abs, "plg.json")
if _, err := os.Stat(manifest); err != nil {
return map[string]interface{}{
"error": fmt.Sprintf("%s 不是插件工程(缺 plg.json用 plugindev_init 先建一个", abs),
}, nil
}
cmd := []string{"build"}
if target, _ := args["target"].(string); strings.TrimSpace(target) != "" {
cmd = append(cmd, strings.TrimSpace(target))
}
txt, runErr := p.run(cmd, abs)
res := map[string]interface{}{"output": txt, "project_dir": abs}
if pkgs := listHmap(filepath.Join(abs, "dist")); len(pkgs) > 0 {
res["artifacts"] = pkgs
res["next"] = "用 plugin_install 安装本地产物path 指向上面 artifacts 里的 .hmap然后 plgreload"
}
if runErr != nil {
res["error"] = runErr.Error()
}
return res, nil
}
func (p *Plugin) handleSDK(args map[string]interface{}) (interface{}, error) {
action, _ := args["action"].(string)
action = strings.TrimSpace(action)
switch action {
case "list", "current", "path", "latest":
txt, err := p.run([]string{"sdk", action}, "")
res := map[string]interface{}{"output": txt}
if err != nil {
res["error"] = err.Error()
}
return res, nil
case "install":
version, _ := args["version"].(string)
from, _ := args["from"].(string)
cmd := []string{"sdk", "install"}
if strings.TrimSpace(from) != "" {
cmd = append(cmd, "--from", strings.TrimSpace(from))
}
if strings.TrimSpace(version) != "" {
cmd = append(cmd, strings.TrimSpace(version))
} else if strings.TrimSpace(from) == "" {
cmd = append(cmd, "latest")
}
txt, err := p.run(cmd, "")
res := map[string]interface{}{"output": txt}
if err != nil {
res["error"] = err.Error()
}
return res, nil
case "use":
version, _ := args["version"].(string)
if strings.TrimSpace(version) == "" {
return map[string]interface{}{"error": "use 需要 version"}, nil
}
txt, err := p.run([]string{"sdk", "use", strings.TrimSpace(version)}, "")
res := map[string]interface{}{"output": txt}
if err != nil {
res["error"] = err.Error()
}
return res, nil
default:
return map[string]interface{}{"error": "action 只能是 list/current/path/latest/install/use"}, nil
}
}
func (p *Plugin) handleProjects(args map[string]interface{}) (interface{}, error) {
entries, err := os.ReadDir(p.workspace)
if err != nil {
return map[string]interface{}{"error": err.Error(), "workspace": p.workspace}, nil
}
var out []map[string]interface{}
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(p.workspace, e.Name())
projects := []string{dir}
// 有些工程会被生成到子目录里hmapdev init 支持指定目录),这里只看一层
for _, sub := range projects {
if _, err := os.Stat(filepath.Join(sub, "plg.json")); err != nil {
continue
}
item := map[string]interface{}{"name": e.Name(), "dir": sub}
if v := readPlgVersion(filepath.Join(sub, "plg.json")); v != "" {
item["version"] = v
}
if pkgs := listHmap(filepath.Join(sub, "dist")); len(pkgs) > 0 {
item["artifacts"] = pkgs
}
out = append(out, item)
}
}
sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
return map[string]interface{}{"workspace": p.workspace, "projects": out}, nil
}
// ---------------- 基础设施 ----------------
// run 执行一次 hmapdev。args 为空时执行 `hmapdev version`(用于探活)。
func (p *Plugin) run(args []string, dir string) (string, error) {
if p.hmapdev == "" {
return "", fmt.Errorf("未找到 hmapdev 可执行文件")
}
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
defer cancel()
cmd := exec.CommandContext(ctx, p.hmapdev, args...)
if dir != "" {
cmd.Dir = dir
}
// 继承环境Go 工具链需要 GOCACHE/GOPATH/PATH 等)。
out, err := cmd.CombinedOutput()
txt := truncateOutput(string(out))
if ctx.Err() == context.DeadlineExceeded {
return txt, fmt.Errorf("hmapdev %s 超时(%s", strings.Join(args, " "), p.timeout)
}
if err != nil {
return txt, fmt.Errorf("hmapdev %s 失败: %v", strings.Join(args, " "), err)
}
return txt, nil
}
// resolveHmapdev 依次尝试:配置项 → PATH → 常见安装位置。
func (p *Plugin) resolveHmapdev() string {
if v, _ := p.sdk.Settings().Get("hmapdev_path"); v != nil {
if s, _ := v.(string); strings.TrimSpace(s) != "" {
if _, err := os.Stat(strings.TrimSpace(s)); err == nil {
return strings.TrimSpace(s)
}
}
}
if path, err := exec.LookPath("hmapdev"); err == nil {
return path
}
for _, cand := range []string{"/usr/local/bin/hmapdev", "/root/go/bin/hmapdev"} {
if _, err := os.Stat(cand); err == nil {
return cand
}
}
return ""
}
// resolveWorkspace配置项 → <data_dir>/plugindev → ./plugindev。
func (p *Plugin) resolveWorkspace() string {
if v, _ := p.sdk.Settings().Get("workspace_dir"); v != nil {
if s, _ := v.(string); strings.TrimSpace(s) != "" {
ws := strings.TrimSpace(s)
_ = os.MkdirAll(ws, 0o755)
return ws
}
}
if v, err := p.sdk.Settings().GetCore("core.daemon.data_dir"); err == nil {
if dd, _ := v.(string); dd != "" {
ws := filepath.Join(dd, "plugindev")
_ = os.MkdirAll(ws, 0o755)
return ws
}
}
ws := "plugindev"
_ = os.MkdirAll(ws, 0o755)
return ws
}
// truncateOutput 截断长输出:构建日志动辄几百 KB直接返回会灌爆模型上下文。
func truncateOutput(s string) string {
if len(s) <= maxOutputChars {
return s
}
head := s[:maxOutputChars/2]
tail := s[len(s)-maxOutputChars/2:]
return fmt.Sprintf("%s\n…输出被截断共 %d 字节)…\n%s", head, len(s), tail)
}
// listHmap 列出目录下的 .hmap 产物(按名字排序,稳定输出)。
func listHmap(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".hmap") {
continue
}
out = append(out, filepath.Join(dir, e.Name()))
}
sort.Strings(out)
return out
}
func readPlgVersion(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
s := string(b)
i := strings.Index(s, `"version"`)
if i < 0 {
return ""
}
rest := s[i:]
j := strings.Index(rest, ":")
if j < 0 {
return ""
}
rest = strings.TrimSpace(rest[j+1:])
rest = strings.TrimPrefix(rest, `"`)
if k := strings.Index(rest, `"`); k > 0 {
return rest[:k]
}
return ""
}
func toInt(v interface{}) (int, bool) {
switch n := v.(type) {
case int:
return n, true
case int64:
return int(n), true
case float64:
return int(n), true
}
return 0, false
}
func fallback(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}

167
example/qq/README.md Normal file
View File

@ -0,0 +1,167 @@
# qq · QQ 消息桥接
通过 [NapCat](https://github.com/NapNeko/NapCatQQ) 把 QQ 接成 HomeAgent 的一个 IO 通道:
让 agent 收发 QQ 消息、读群/好友信息、传文件。
> ⚠️ 这是**安全敏感**插件:它让外部 QQ 用户能触达 agent 的工具。
> 本文档的「权限模型」一节请务必读完。
## 通道与钩子
| 类型 | 名称 | 说明 |
|---|---|---|
| 出站 | `qq` | `CapText` + `CapFile` + `CapImage` + `CapAudio`;发消息/文件给 QQ |
| 入站 | `qq` | `NoMemory: true` + `Cleaner` + `RecallPolicy: None` |
四个阶段钩子(全部 `StageScopeGlobal`
| 钩子 | 作用 |
|---|---|
| `on_input` | 把本轮 QQ 身份**绑到帧上** |
| `before_toolcall` | 权限门:逐个工具判断是否放行 |
| `post_action` | 清掉被拒绝时模型已经吐出的废话 |
| `after_output` | 收尾时清理插件全局身份 |
## 工具20 个)
| 工具 | 说明 |
|---|---|
| `qq_get_message` | 按 `message_id` 取消息正文、发送者、附件 |
| `qq_get_history` | 取群/私聊最近历史消息 |
| `qq_list_chats` | 会话列表(按最新消息排序,带未读数与摘要) |
| `qq_mark_read` | 把某会话未读数清零 |
| `qq_send_file` | 发文件/图片(私聊或群聊) |
| `qq_get_groups` | 群列表,可按关键词搜 |
| `qq_get_friends` | 好友列表,可按昵称/备注搜 |
| `qq_get_recent_contacts` | 最近有消息的联系人与群 |
| `qq_resolve_name` / `qq_resolve_nickname` | 名字 ↔ QQ 号互查 |
| `qq_get_group_member_info` | 群成员信息 |
| `qq_group_manage` | 群综合管理(见下) |
| `qq_friend_action` | 好友操作 |
| `qq_get_group_files` | 群文件列表 |
| `qq_download_file` / `qq_upload_group_file` / `qq_get_download_tasks` | 文件传输与任务 |
| `qq_read_document` | 读 QQ 传来的文档 |
| `qq_video_download` | 下载视频 |
| `qq_send_like` | 点赞 |
`qq_group_manage` 一个工具承载多种操作(`command` 参数):
`leave` 退群、`kick` 踢人、`ban`/`unban` 禁言解禁、`rename` 改名、`mute-all` 全员禁言、
`set-card` 设名片、`set-admin` 设管理、`set-title` 设头衔、`member-list``group-info`
`msg-history``recall` 撤回、`pin-msg` 精华、`list-files``pending-requests``folder-create` 等。
**破坏性操作**`leave`/`kick`/`ban`/`unban`/`rename`/`mute-all`/`set-card`/`set-admin`/
`set-title`/`recall`/`pin-msg`/`folder-create`**必须显式传 `confirm: true`**。
## 权限模型
这是本插件最重要的部分。
### 身份分级
| 身份 | 权限 |
|---|---|
| **owner**Bot 所有者) | 私聊或群聊均**完整放行** |
| **普通 QQ 用户** | 只放行白名单内的工具 |
### 身份必须「绑帧」,不能只存插件全局
源码注释记录了两个真实故障,这就是绑帧的原因:
1. **中断抢占后身份丢失**:中断会抢占当前轮、把现场压栈。中断轮收尾时
`after_output` 会清空插件**全局**身份;随后外层被恢复(`resumeTask` 复用同一帧、
**不重跑 `on_input`**)。若身份只存全局,恢复后的外层就是"无身份"
`before_toolcall``!auth.active` 处直接返回 —— **整个权限门失效**
2. **运行中到达的消息改写身份**:新消息会调 `activateAuthContext` 改写全局身份,
把**正在跑的那一轮**换成另一方的身份(换高=越权,换低=误拒)。
帧上的 `Extra` 随帧一起压栈/恢复,正好是"这一轮的身份"。
### 合并取最小权限
多来源被内核合并到同一推理时,权限**取交集**而非并集:
```go
p.auth.owner = p.auth.owner && next.owner
```
防的是"非所有者请求 + 随后所有者消息"意外把前一个请求提权。
### 硬私有工具
非所有者**一律拒绝**(不看白名单),按前缀拦截:
`calendar_``email_``mail_``agentmail_``memory_``knowledge_``device_`
`devicectl_``terminal_``shell_``command_``exec_``filesystem_``agentfs_`
`config_``settings_``plugin_``plugins_`
外加 `read_file``write_file``edit_file``delete_file``list_files``run_command`
`homeagent_config``homeagent_restart``output_send__email``output_send__mail`
### 参数与会话一致性校验
光看工具名不够,还要检查**参数指向的会话与当前身份一致**,否则可以拿别人的
`message_id` 去读别处内容:
-`message_id` 的工具:该 ID 必须属于当前 QQ 会话(`lookupMsgRef` 校验 peer 与群/私聊类型)。
- `get_group_member_info` / `get_group_files``group_id` 必须是**当前群**。
### 频率与重复控制
| 键 | 作用 |
|---|---|
| `max_qq_tool_calls` | 单轮工具调用上限 |
| `max_qq_output_calls` | 单轮输出调用上限 |
| `max_duplicate_qq_send` | 重复发送上限,防刷屏 |
| `batch_window_ms` / `batch_max_ms` | 消息合批窗口 |
被拒时只允许**发一次权鉴说明**,之后锁止本轮剩余工具调用
`clearDeniedResponse` 再把模型已写出的内容清掉,避免输出里带一堆"我不能…")。
## 配置项
### 连接
| 键 | 默认 | 说明 |
|---|---|---|
| `napcat_url` | — | NapCat 服务地址 |
| `listen` | — | 本插件 HTTP 监听地址 |
| `webhook_token` | — | webhook 校验令牌 |
### 身份与准入
| 键 | 默认 | 说明 |
|---|---|---|
| `owner` | 空 | Bot 所有者 QQ 列表(逗号分隔),拥有完整权限 |
| `admin` | 空 | **旧配置名**`owner` 为空时作为所有者列表(兼容用) |
| `dm_policy` | `open` | 私聊策略:`open` / `allowlist` / `disabled` |
| `allow_from` | 空 | 私聊白名单QQ 号,逗号分隔) |
| `group_policy` | `open` | 群聊策略:`open` / `allowlist` / `disabled` |
| `group_allow_from` | 空 | 群白名单 |
| `private_tool_allowlist` | 空 | 私聊下非所有者可用的工具 |
| `group_tool_allowlists` | 空 | 按群配置的工具白名单 |
### 文件与转发
| 键 | 说明 |
|---|---|
| `files_dir` | 本地文件目录 |
| `remote_dir` | 供 NapCat 容器访问的目录(发文件前先复制到这里) |
| `agentfs_dir` | agent 文件系统目录 |
| `forward_rules` | JSON 数组,每项 `{group_id,host,port,password,template}`:匹配的群消息经 **RCON** 转发到 Minecraft`template` 支持 `{nickname}` / `{message}` 占位 |
## 部署前提
需要**自行部署 NapCat**(本插件不含 QQ 协议实现,只是 NapCat 的客户端)。
发文件前会先把文件复制到 `remote_dir`,因为 NapCat 通常在容器里,看不到宿主任意路径。
## 测试
```bash
go test -count=1 -race ./...
```
含权限门与绑帧的回归测试。改动权限相关代码后务必跑 `-race`
## 构建
```bash
hmapdev build
```

View File

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

View File

@ -155,6 +155,41 @@ type Plugin struct {
msgMu sync.Mutex
msgMap map[int64]msgRef // message_id → {peer, time}
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
// 消息合并debounce同一会话、同一发送者在 batchWindow 内连续到达的消息
// 合并成一次中断。同一个人连发「在吗」「帮我看看」「报错是这个」三条,
// 逐条注入会把 Agent 唤醒三次,且前两次拿到的信息都不完整。
batchMu sync.Mutex
batches map[string]*pendingBatch
batchWindow time.Duration // 最后一条到达后再等多久(<=0 = 关闭合并,逐条投递)
batchMax time.Duration // 一批最长等多久(防持续刷屏时永远不投)
// injectHook 仅供测试:非 nil 时 injectInterrupt 走它而不是真实 SDK。
injectHook func(text, level string)
}
// pendingBatch 是一批待投递的消息(同一会话、同一发送者、短时间内的连续消息)。
type pendingBatch struct {
key string
isGroup bool
userID int64
groupID int64
nickname string
msgIDs []int64
single string // 单条时沿用的原文(含所有者/高危前缀),保证 n==1 行为不变
owner bool
highRisk bool
first time.Time
timer *time.Timer
}
// qqBatchKey 同一会话 + 同一发送者 = 一组。私聊按 QQ 号;群聊按 (群号, QQ 号)——
// 群里不同人各发各的,不该并成一条。
func qqBatchKey(msgType string, groupID, userID int64) string {
if msgType == "group" {
return fmt.Sprintf("g:%d:%d", groupID, userID)
}
return fmt.Sprintf("p:%d", userID)
}
type typingState struct {
@ -316,6 +351,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录主机路径。发文件时文件会复制到此目录NapCat内部映射为/app/files/", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "webhook_token", Default: "", Type: "string", DisplayName: "Webhook 令牌", Description: "NapCat 上报请求头 X-Webhook-Token 校验值,留空则不校验", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录read_document/video_download 等工具的默认工作目录", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "batch_window_ms", Default: "1500", Type: "int", DisplayName: "消息合并窗口(毫秒)", Description: "同一会话同一发送者的连续消息在该窗口内合并成一次中断并告知共几条0=关闭合并(逐条投递)", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "batch_max_ms", Default: "30000", Type: "int", DisplayName: "消息合并上限(毫秒)", Description: "一批消息最长等这么久就投递,避免对方持续刷屏时一直不唤醒 Agent", Category: "qq"})
settings := s.Settings()
@ -339,12 +376,18 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/")
p.agentfsDir = strings.TrimRight(getSetting[string](settings, "agentfs_dir", "/home/newqqagent/agentfs/merged"), "/")
p.remoteDir = strings.TrimRight(getSetting[string](settings, "remote_dir", "/home/program/qq-workspace/remote"), "/")
p.batchWindow = time.Duration(getSetting[int64](settings, "batch_window_ms", 1500)) * time.Millisecond
p.batchMax = time.Duration(getSetting[int64](settings, "batch_max_ms", 30000)) * time.Millisecond
if p.batchWindow < 0 {
p.batchWindow = 0
}
os.MkdirAll(p.remoteDir, 0755)
p.httpClient = &http.Client{Timeout: 30 * time.Second}
// msg_id → peer 映射 + 会话状态(不缓存正文)
p.msgMap = make(map[int64]msgRef)
p.batches = make(map[string]*pendingBatch)
p.chats = make(map[int64]*chatMeta)
// 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s
@ -697,6 +740,8 @@ func (p *Plugin) Stop() error {
}
}
p.typingMu.Unlock()
// 停机前把未到点的合并批次立刻投出去,别把对方的消息吞掉。
p.flushAllBatches()
if p.srv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -761,6 +806,10 @@ func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallb
}
case int64:
switch val := v.(type) {
case int:
return any(int64(val)).(T)
case int64:
return any(val).(T)
case float64:
return any(int64(val)).(T)
case string:
@ -925,6 +974,19 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
if !auth.active || auth.owner {
return true, ""
}
// 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。
//
// 为什么:输出是 agent 的**主动调用**,发到哪个会话由它自己给的 meta
// group_id / user_id决定 —— handleChannelOutput 会强制要求该字段存在,
// 缺了会得到明确的报错。这里再要求"本轮能精确匹配可信 OneBot 事件"是多余的门,
// 而且会把合法发送一起拒掉:现场(被子的中断唤醒的一轮)父带齐 meta 也发不出去,
// 报「可信 QQ 会话身份不完整」。
// 「只能访问当前会话」这类限制只对**读取类**工具get_history / mark_read /
// get_message成立 —— 那才是真的不能跨会话读。
if name == "output_send__"+p.name {
return true, ""
}
currentPeer := auth.userID
if auth.isGroup {
currentPeer = auth.groupID
@ -967,6 +1029,40 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
return true, ""
}
// qqAuthExtraKey 是本轮QQ 身份挂在 StageContext.Extra 上的键。
//
// 身份必须**绑帧**,不能只存插件全局:
// - 中断会抢占当前轮并把现场压栈scheduler 的 suspendStack中断轮收尾时
// afterOutput 把插件全局身份清空随后外层被恢复resumeTask 复用同一帧、
// 不重跑 StageOnInput若身份只存全局恢复后的外层就是"无身份"——
// beforeToolcall 会在 !auth.active 处直接返回,权限门整体失效。
// - 运行中到达的新消息会调 activateAuthContext 改写全局身份,把**正在跑的那一轮**
// 换成另一方的身份(换高=越权,换低=误拒)。
//
// 帧上的 Extra 随帧一起压栈/恢复,正好是"这一轮的身份"。
const qqAuthExtraKey = "qq_auth"
// authOnFrame 读取本帧绑定的身份ok=false 表示本帧未绑定过 QQ 身份。
// 调用方需持有 ctx 的读(或写)锁。
func authOnFrame(ctx *sdk.StageContext) (qqAuthContext, bool) {
if ctx == nil || ctx.Extra == nil {
return qqAuthContext{}, false
}
auth, ok := ctx.Extra[qqAuthExtraKey].(qqAuthContext)
return auth, ok
}
// bindAuthOnFrame 把身份绑到本帧上。调用方需持有 ctx 的写锁。
func bindAuthOnFrame(ctx *sdk.StageContext, auth qqAuthContext) {
if ctx == nil {
return
}
if ctx.Extra == nil {
ctx.Extra = make(map[string]interface{})
}
ctx.Extra[qqAuthExtraKey] = auth
}
// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
@ -1017,13 +1113,28 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b
p.auth.generation = next.generation
}
func messageIDFromInput(raw string) int64 {
match := qqMessageIDRe.FindStringSubmatch(raw)
if len(match) != 2 {
return 0
var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`)
// messageIDsFromInput 取出一段输入里出现的全部 message_id。
//
// 合并中继的正文是 `(message_id=100,101,102)`:只取第一个会留下同批其余 id 永不清理;
// 身份表用 id 做键,泄漏的条目要等 generation 回收才会消失。
func messageIDsFromInput(raw string) []int64 {
matches := qqMessageIDsRe.FindAllStringSubmatch(raw, -1)
ids := make([]int64, 0, len(matches))
for _, match := range matches {
if len(match) != 2 {
continue
}
for _, part := range strings.Split(match[1], ",") {
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || id == 0 {
continue
}
ids = append(ids, id)
}
}
id, _ := strconv.ParseInt(match[1], 10, 64)
return id
return ids
}
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
@ -1031,24 +1142,27 @@ func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
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 来源却对不上可信事件时绝不复用上一条消息的身份。
next := qqAuthContext{active: source == p.name}
ids := messageIDsFromInput(raw)
if source == p.name && len(ids) > 0 {
if auth, ok := p.authByMessageID[ids[0]]; ok {
next = auth
for _, id := range ids {
delete(p.authByMessageID, id)
}
}
}
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份
p.auth = qqAuthContext{active: true}
// p.auth 只作为"帧上没绑身份"时的兜底(单测/异常帧),权威副本在帧上
p.auth = next
p.resetTurnGuardLocked()
p.authMu.Unlock()
ctx.Lock()
bindAuthOnFrame(ctx, next)
ctx.Unlock()
return nil
}
@ -1068,9 +1182,13 @@ func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
return nil
}
func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) {
func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, qqAuthContext) {
// 身份以本帧为准(中断恢复后全局身份可能已属于别的轮)。
auth, onFrame := authOnFrame(ctx)
p.authMu.RLock()
auth := p.auth
if !onFrame {
auth = p.auth
}
var patterns []string
if auth.active && !auth.owner {
if auth.isGroup {
@ -1179,7 +1297,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
return nil
}
tc := &ctx.ToolCalls[0]
allowed, auth := p.currentToolAllowed(tc.Name)
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
if !auth.active {
return nil
}
@ -1286,6 +1404,155 @@ func requiresConfirmFriendCommand(cmd string) bool {
}
}
// enqueueInterrupt 把一条已通过策略/@ 检查的消息并入待投批次,并重置 debounce 计时。
//
// batchWindow<=0 时退回逐条投递(合并前行为)。
func (p *Plugin) enqueueInterrupt(msgType string, userID, groupID, messageID int64, nickname, single string, owner, highRisk bool) {
if p.sdk == nil && p.injectHook == nil {
return
}
if p.batchWindow <= 0 {
p.injectInterrupt(single, p.interruptLevel(owner))
return
}
key := qqBatchKey(msgType, groupID, userID)
p.batchMu.Lock()
if p.batches == nil {
p.batches = make(map[string]*pendingBatch)
}
b := p.batches[key]
if b == nil {
b = &pendingBatch{key: key, first: time.Now()}
p.batches[key] = b
}
b.isGroup = msgType == "group"
b.userID, b.groupID, b.nickname = userID, groupID, nickname
b.msgIDs = append(b.msgIDs, messageID)
b.single = single
b.owner = b.owner || owner
b.highRisk = b.highRisk || highRisk
// debounce每来一条就推迟但整体不超过 batchMax否则持续刷屏会一直不投
delay := p.batchWindow
if p.batchMax > 0 {
if remain := p.batchMax - time.Since(b.first); remain < delay {
delay = remain
}
}
if delay < 0 {
delay = 0
}
if b.timer != nil {
b.timer.Stop()
}
b.timer = time.AfterFunc(delay, func() { p.flushBatch(key) })
p.batchMu.Unlock()
}
// interruptLevel 决定一条 QQ 消息的中断级别。
//
// - Bot 所有者/管理员的消息 → **L2**(一般提醒);
// - 其他人的消息 → L1后台完全可等
//
// 为什么不能一律 L1L1 之间可以随时互相抢占、也可以被任何更高一级打断,
// 于是「老板发的话」会被路人的闲聊挤到后面,甚至对方持续刷屏时一直排在队尾。
// 为什么也不该给 L3L3 是时钟/终端那类"需要及时处理"的实时工作QQ 是异步
// 消息,抬到 L3 会反过来打断真正实时的事情。
func (p *Plugin) interruptLevel(owner bool) string {
if owner {
return sdk.PriorityL2
}
return sdk.PriorityL1
}
// injectInterrupt 投递一条中断提示NoMemoryHTTP 侧来的不是对话内容)。
func (p *Plugin) injectInterrupt(text, level string) {
if text == "" {
return
}
if level == "" {
level = sdk.PriorityL1
}
if p.injectHook != nil {
p.injectHook(text, level)
return
}
if p.sdk == nil {
return
}
p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{
NoMemory: true,
Priority: level,
})
}
// flushBatch 投递一批n==1 沿用单条原文n>1 生成「共几条」的合并中断。
func (p *Plugin) flushBatch(key string) {
p.batchMu.Lock()
b := p.batches[key]
delete(p.batches, key)
p.batchMu.Unlock()
if b == nil {
return
}
text := b.single
if len(b.msgIDs) > 1 {
text = p.buildBatchInterrupt(b)
}
// 一批里只要有一条来自 Bot 所有者,整批按 L2 投递(不因混入路人消息而降低)。
p.injectInterrupt(text, p.interruptLevel(b.owner))
}
// flushAllBatches 停机前把未到点的批次立刻投出去best effort
func (p *Plugin) flushAllBatches() {
p.batchMu.Lock()
keys := make([]string, 0, len(p.batches))
for k := range p.batches {
keys = append(keys, k)
}
p.batchMu.Unlock()
for _, k := range keys {
p.flushBatch(k)
}
}
// buildBatchInterrupt 生成合并中断:说清「一共几条」「分别是哪些 message_id」
// 并给出一次拿全上下文的建议get_history避免模型逐条 get_message。
func (p *Plugin) buildBatchInterrupt(b *pendingBatch) string {
tp := p.name + "_"
outputTool := "output_send__" + p.name
n := len(b.msgIDs)
ids := formatMsgIDs(b.msgIDs)
var s string
if b.isGroup {
s = fmt.Sprintf("来自「%s」在群里短时间内连续发来 %d 条消息(message_id=%s)。建议先用%sget_history(group_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复群聊",
b.nickname, n, ids, tp, b.groupID, n+5, tp, outputTool)
} else {
s = fmt.Sprintf("来自「%s」的私聊短时间内连续发来 %d 条消息(message_id=%s, user_id=%d)。建议先用%sget_history(user_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复对方",
b.nickname, n, ids, b.userID, tp, b.userID, n+5, tp, outputTool)
}
if b.highRisk {
s = "【⚠️ 高危信息,谨慎处理】" + s
}
if b.owner {
s = "【重要Bot 所有者消息】" + s
}
return s
}
// formatMsgIDs 把 message_id 列表压成一行;过多时截断,避免中断文字过长。
func formatMsgIDs(ids []int64) string {
const capN = 12
parts := make([]string, 0, len(ids)+1)
for i, id := range ids {
if i >= capN {
parts = append(parts, "…")
break
}
parts = append(parts, strconv.FormatInt(id, 10))
}
return strings.Join(parts, ",")
}
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "", http.StatusMethodNotAllowed)
@ -1404,7 +1671,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
return
}
highRisk := false
if highRiskRe.MatchString(text) {
highRisk = true
interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt
}
@ -1433,10 +1702,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
p.startTyping(evt.UserID)
}
if p.sdk != nil {
// NoMemoryHTTP 侧来的中断提示,不是对话内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{NoMemory: true})
}
// 合并投递:同一会话同一发送者在 batchWindow 内的连续消息并成一次中断。
p.enqueueInterrupt(evt.MessageType, evt.UserID, evt.GroupID, evt.MessageID, nickname, interrupt, p.isOwner(evt.UserID), highRisk)
w.WriteHeader(http.StatusOK)
}
@ -2531,9 +2799,10 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
log.Printf("[qq] 文件下载完成: %s", savePath)
if p.sdk != nil {
// NoMemory下载完成的状态通知不是记忆内容。
// Priority同上QQ 侧一律低级别中断L1
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("文件下载完成: %s保存在 %s", filepath.Base(savePath), savePath),
sdk.InjectOptions{NoMemory: true})
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1})
}
} else {
errMsg = "下载失败,文件可能已过期"

View File

@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
@ -202,3 +203,231 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) {
}
}
}
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
// (报「可信 QQ 会话身份不完整」)。
//
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true}
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": "带齐 meta 的主动发送",
"type": "text",
"meta": `{"user_id":2198972886}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("降权时输出被拒: %s", *ctx.Response)
}
ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713})
if err := p.beforeToolcall(ctx2); err != nil {
t.Fatal(err)
}
if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") {
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response)
}
}
// ---- 消息合并debounce----
// collectInterrupts 用注入钩子收集中断文本(避免测试依赖真实 SDK
func collectInterrupts(p *Plugin) *[]string {
got := []string{}
p.injectHook = func(s, _ string) { got = append(got, s) }
return &got
}
func TestConsecutiveMessagesFromSameSenderAreBatched(t *testing.T) {
p := newPermissionTestPlugin(t)
got := collectInterrupts(p)
p.batchWindow = 20 * time.Millisecond
p.batchMax = time.Second
for i := 0; i < 3; i++ {
p.enqueueInterrupt("private", 10001, 0, int64(100+i), "小明", "单条", false, false)
}
time.Sleep(120 * time.Millisecond)
if len(*got) != 1 {
t.Fatalf("同一发送者连发 3 条应合并成 1 次中断,实际 %d 次: %#v", len(*got), *got)
}
if !strings.Contains((*got)[0], "3 条消息") {
t.Fatalf("合并中断应说明一共几条,实际: %s", (*got)[0])
}
// 三个 message_id 都要带上,模型才能取全
for _, id := range []string{"100", "101", "102"} {
if !strings.Contains((*got)[0], id) {
t.Fatalf("合并中断漏了 message_id=%s: %s", id, (*got)[0])
}
}
}
func TestDifferentSendersAreNotBatchedTogether(t *testing.T) {
p := newPermissionTestPlugin(t)
got := collectInterrupts(p)
p.batchWindow = 20 * time.Millisecond
p.batchMax = time.Second
p.enqueueInterrupt("private", 10001, 0, 1, "小明", "a", false, false)
p.enqueueInterrupt("private", 10002, 0, 2, "小红", "b", false, false)
time.Sleep(120 * time.Millisecond)
if len(*got) != 2 {
t.Fatalf("不同发送者不该合并,应有 2 次中断,实际 %d: %#v", len(*got), *got)
}
}
func TestBatchWindowZeroFallsBackToPerMessage(t *testing.T) {
p := newPermissionTestPlugin(t)
got := collectInterrupts(p)
p.batchWindow = 0
for i := 0; i < 3; i++ {
p.enqueueInterrupt("private", 10001, 0, int64(i), "小明", "原文", false, false)
}
if len(*got) != 3 {
t.Fatalf("关闭合并时应逐条投递3 次),实际 %d: %#v", len(*got), *got)
}
}
func TestSingleMessageKeepsOriginalText(t *testing.T) {
p := newPermissionTestPlugin(t)
got := collectInterrupts(p)
p.batchWindow = 20 * time.Millisecond
p.batchMax = time.Second
p.enqueueInterrupt("group", 10001, 20002, 7, "小明", "单条原文", true, false)
time.Sleep(120 * time.Millisecond)
if len(*got) != 1 || (*got)[0] != "单条原文" {
t.Fatalf("单条消息应沿用原文(含所有者前缀),实际 %#v", *got)
}
}
// Bot 所有者/管理员的消息给 L2普通人的给 L1 —— 否则所有者的话会被路人
// 的 L1 闲聊抢占/挤到队尾。
func TestOwnerMessagesGetHigherInterruptLevel(t *testing.T) {
p := newPermissionTestPlugin(t)
got := []string{}
p.injectHook = func(text, level string) { got = append(got, text+"|"+level) }
p.batchWindow = 20 * time.Millisecond
p.batchMax = time.Second
p.enqueueInterrupt("private", 1, 0, 1, "owner", "owner-msg", true, false)
p.enqueueInterrupt("private", 2, 0, 2, "someone", "other-msg", false, false)
time.Sleep(120 * time.Millisecond)
joined := strings.Join(got, ",")
if !strings.Contains(joined, "owner-msg|L2") {
t.Fatalf("所有者消息应为 L2实际 %q", joined)
}
if !strings.Contains(joined, "other-msg|L1") {
t.Fatalf("普通人消息应为 L1实际 %q", joined)
}
}
// 身份必须绑在帧上:中断抢占当前轮、中断轮收尾清空插件全局身份之后,
// 外层轮被恢复resumeTask 复用同一帧、不重跑 onInput时权限门不能整体失效。
func TestAuthSurvivesInterruptPreemptionOfAnotherTurn(t *testing.T) {
p := newPermissionTestPlugin(t)
// 中断轮Bot 所有者跑完afterOutput 会清掉插件全局身份。
inner := &sdk.StageContext{Extra: map[string]interface{}{
qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886},
}}
if err := p.afterOutputAuthContext(inner); err != nil {
t.Fatal(err)
}
if p.auth.active {
t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)")
}
// 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。
frame := &sdk.StageContext{
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, userID: 10001, groupID: 20002, isGroup: true}},
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
}
if err := p.beforeToolcall(frame); err != nil {
t.Fatal(err)
}
if frame.Response == nil || !strings.Contains(*frame.Response, "私人资源工具") {
t.Fatalf("中断恢复后权限门失效(整体放行): %#v", frame.Response)
}
}
// 运行中到达的新消息会改写插件全局身份;正在跑的那一轮必须不受影响。
func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) {
p := newPermissionTestPlugin(t)
frame := &sdk.StageContext{
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}},
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
}
// 路人的群消息在所有者轮运行中到达。
p.activateAuthContext(4242, 10001, 20002, true)
if p.auth.owner {
t.Fatal("到达事件应改写全局身份(复现场景)")
}
if err := p.beforeToolcall(frame); err != nil {
t.Fatal(err)
}
if frame.Response != nil {
t.Fatalf("在跑的所有者轮被到达消息篡改: %s", *frame.Response)
}
}
// 合并中断正文里的整批 message_id 都要消费掉,并在帧上绑定身份。
func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
p := newPermissionTestPlugin(t)
p.authByMessageID = map[int64]qqAuthContext{
100: {active: true, owner: true, userID: 2198972886},
101: {active: true, owner: true, userID: 2198972886},
}
ctx := &sdk.StageContext{
RawMessage: "来自「老板」的私聊短时间内连续发来 2 条消息(message_id=100,101, user_id=2198972886)。",
Extra: map[string]interface{}{"input_source": "qq"},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if !p.auth.owner {
t.Fatalf("合并中断未恢复所有者身份: %+v", p.auth)
}
if len(p.authByMessageID) != 0 {
t.Fatalf("同批 message_id 未全部清理: %v", p.authByMessageID)
}
if auth, ok := authOnFrame(ctx); !ok || !auth.owner {
t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok)
}
}
// 非 QQ 来源webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。
func TestNonQQFrameBindsInactiveAuth(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := &sdk.StageContext{
RawMessage: "webui 里的提问",
Extra: map[string]interface{}{"input_source": "webui"},
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response)
}
}

View File

@ -0,0 +1,53 @@
# recoverydiag · 快速检查 / 崩溃取证
给 guard 与 failback 用的**确定性诊断工具集**。
设计基调(源码原话):**返回结论而非原文,确定性检出,不消耗 LLM token。**
崩溃后最忌讳的是把几万行日志塞进模型上下文让它"看看",那既慢又不可靠 ——
这里每个工具都在本地算出结论再返回。
## 工具
| 工具 | 说明 |
|---|---|
| `recoverydiag_diag_triage` | 快速分诊:按退出码 / 信号 / 存活状态粗分类别(进程死亡 vs 配置类不可达 vs 正常) |
| `recoverydiag_diag_db` | config.db 完整性(`PRAGMA integrity_check`+ LLM 源解析校验(`core.llm.sources.*` 必备字段),逐项 ok/fail |
| `recoverydiag_diag_log_scan` | 在日志目录的时间窗内统计已知错误签名panic / OOM / 网络不可达 / provider 失败 / sql / 致命)出现次数,给出主导结论 |
| `recoverydiag_diag_delta` | 对比 baseline上次 good 快照/目录)与现状,列出 created / modified / deleted 清单与摘要,判定"改了什么" |
| `recoverydiag_diag_loc` | 综合前四项结论,按**因果强度正交排序**定位根因并给出推荐恢复动作 |
## 用法顺序
```
diag_triage → diag_db → diag_log_scan → diag_delta → diag_loc
(各自独立,可只跑需要的) (要传前四项的结论)
```
`diag_loc` 需要你把它余下的结论**作为参数传进去**`triage` / `db` / `log` / `delta` 四个对象),
它不自己去调 —— 这样它只做归因,不重复执行。
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `db_check_cmd` | `auto` | `diag_db` 用的 `sqlite3` 命令。留空=auto可用时用 sqlite3缺失则回退读内核 Settings |
| `recovery_kb_dir` | 空 | `diag_loc` 结论 JSON 的落盘目录。缺省 `<data_dir>/recovery_kb` |
## 不注册通道与钩子
本插件**只提供工具**,不订阅输入、不挂阶段钩子 —— 它是被 guard 或 agent 主动调用的,
不做后台干预。
## 测试
```bash
go test -count=1 ./...
```
`diag_test.go` 覆盖各诊断项的判定逻辑。
## 构建
```bash
hmapdev build
```

View File

@ -1,13 +1,44 @@
# rss
# rss · RSS/Atom 订阅监控
rss plugin
订阅 RSS/Atom 源,**有新文章时主动通知** agent不必每轮去问
## Build
## 工具
| 工具 | 说明 |
|---|---|
| `rss_subscribe` | 订阅一个 RSS/Atom 源 |
| `rss_unsubscribe` | 取消订阅 |
| `rss_list` | 列出全部订阅 |
| `rss_check_now` | 立即检查所有源(不等轮询周期) |
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `poll_interval` | `30` | 默认轮询间隔(**分钟** |
订阅时可对单个源覆盖间隔。
## 通知机制
- 后台按各自间隔轮询(默认 30 分钟)。
- 发现新条目时通过 `InjectInterruptText` 注入,格式形如
`📡 <源标题> (<URL>) — N 篇新文章:` 后跟条目。
- 注入带 **`NoMemory: true`**,通道 `rss` 也声明为 `NoMemory` ——
订阅推送是信号不是知识,不该进向量化挤掉别的记忆。
## 实现要点
- **订阅时就记下全部已有 GUID**`handleSubscribe` 会把抓取到的历史条目
一次性标为 `seenGUIDs`,所以**订阅一个源不会把它的历史文章全部推送一遍**。
只有订阅之后新出现的条目才通知。这是避免刷屏的关键。
- **去重按「源 URL + GUID」**:不同源可能用相同 GUID只用 GUID 会互相误判。
GUID 缺失时回退用 `link`;两者都缺则跳过该条。
- `seenGUIDs` 有清理逻辑,不会无限增长。
- 解析用 [gofeed](https://github.com/mmcdole/gofeed)`v1.4.0`)。
## 构建
```bash
hmapdev build
```
## Install
Upload the .hmap file through the Plugin Manager API.

View File

@ -104,6 +104,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.client = &http.Client{Timeout: 30 * time.Second}
// 入站通道:本插件用 "rss" 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel("rss", sdk.ChannelDef{NoMemory: true})
p.fp = gofeed.NewParser()
p.stopCh = make(chan struct{})
p.seenGUIDs = make(map[string]bool)
@ -158,7 +161,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"list", sdk.ToolDef{
Name: tp + "list", Description: "List all subscribed feeds",
Parameters: map[string]interface{}{
"type": "object",
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleList)
@ -167,7 +170,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Name: tp + "check_now", Description: "Manually check all feeds for new articles now",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleCheckNow)
@ -445,7 +448,7 @@ func (p *Plugin) loadData() {
return
}
var data struct {
Feeds []FeedSub `json:"feeds"`
Feeds []FeedSub `json:"feeds"`
SeenGUIDs map[string]bool `json:"seen"`
}
if json.Unmarshal(b, &data) != nil {
@ -463,7 +466,7 @@ func (p *Plugin) saveData() {
p.mu.RLock()
defer p.mu.RUnlock()
data := struct {
Feeds []FeedSub `json:"feeds"`
Feeds []FeedSub `json:"feeds"`
SeenGUIDs map[string]bool `json:"seen"`
}{
Feeds: p.feeds,
@ -488,8 +491,6 @@ func (p *Plugin) cleanupData() {
}
}
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"

View File

@ -0,0 +1,48 @@
# sanitizer · 文本清洗
**不注册任何工具**,只挂三个阶段钩子,在 Agent 全链路上洗掉两类污染:
1. **坏字节**:坏 UTF-8、`U+FFFD`替换符、ANSI 转义序列
2. **思维泄漏**LLM 输出里残留的工具调用标记
## 为什么需要它
坏字节会**被 LLM 复读**。一次工具返回乱码(比如源码里带 ANSI 颜色码、或二进制片段被当文本读出来),
这些字节会进上下文,之后模型每次生成都可能把它抄一遍 —— 越滚越脏。
在每个入口洗掉,比事后清理便宜得多。
思维泄漏则是另一种:模型有时把 `<tool_call>...</tool_call>` 这类内部标记直接写进正文,
用户就看到一堆不该出现的 XML。
## 挂载的三个阶段
| 阶段 | 处理对象 | 作用 |
|---|---|---|
| `on_input` | `ctx.RawMessage` | 洗用户输入,脏字节不进后续链路 |
| `after_toolcall` | `ctx.ToolResults` | 洗工具结果,**坏字节不进 LLM 上下文** |
| `post_action` | `ctx.LLMText` | 洗模型输出:先清思维泄漏,再清乱码 |
每次有改动都打一行日志(`cleaned N bytes`),便于确认它真的在工作而不是静默失败。
## 识别哪些泄漏形态
按正则匹配多种标记写法,覆盖不同模型家族的习惯:
- `<tool_call>…</tool_call>``<invoke>…</invoke>``<tool>…</tool>`
- `<function>…</function>`
- 上述标记包在 ```xml / ```json 代码块里的形态
- 中文括号变体:`【tool_call】…【/tool_call】`
## 实现要点
- 依赖 **ABI v2 的 stage 写回能力**:插件对 `StageContext` 的修改会同步回内核。
在 v1 上改了不生效。
- 读写 `StageContext` 时按约定加 `ctx.Lock()`
## 构建
```bash
go build -buildmode=plugin -o sanitizer.so .
```
或经 `hmapdev build` 打包为 `.hmap`

77
example/vanblog/README.md Normal file
View File

@ -0,0 +1,77 @@
# vanblog · VanBlog 博客管理
用管理 API 操作 [VanBlog](https://vanblog.mereith.com/) 开源博客系统:
文章增删改查、分类标签、草稿发布、备份导出等。
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `url` | `https://blog.jianfgit.xyz` | VanBlog 站点基地址 |
| `token` | 空 | 管理员 API Token长期令牌从后台「Token 管理」创建) |
| `reset_token` | 空 | 用于 `auth/restore` 重置管理员密码的**特殊** Token |
`token``reset_token` 都是 `password` 类型(界面遮蔽)。
## 工具28 个)
### 文章
| 工具 | 说明 |
|---|---|
| `vanblog_list_articles` | 列文章,支持分页与搜索 |
| `vanblog_get_article` | 取单篇完整内容 |
| `vanblog_create_article` | 新建(`title``category` 必填) |
| `vanblog_update_article` | 更新(**只传要改的字段** |
| `vanblog_delete_article` | 删除(**软删除** |
| `vanblog_search_articles` | 按链接搜索文章 |
### 草稿
`vanblog_manage_drafts``list` / `get` / `create` / `update` / `delete` / **`publish`**
### 内容组织
| 工具 | 命令 |
|---|---|
| `vanblog_manage_categories` | `list` / `get` / `create` / `update` / `delete` |
| `vanblog_manage_tags` | `list` / `get` / `rename` / `delete` |
### 站点与运维
| 工具 | 说明 |
|---|---|
| `vanblog_manage_site` / `_settings` / `_menu` / `_social` / `_links` | 站点配置类 |
| `vanblog_manage_about` / `_pages` | 关于页与自定义页面 |
| `vanblog_manage_images` | 图床管理 |
| `vanblog_manage_rewards` | 赞赏配置 |
| `vanblog_manage_backup` | 备份 |
| `vanblog_manage_caddy` | Caddy 配置 |
| `vanblog_manage_isr` | ISR 增量静态渲染 |
| `vanblog_manage_pipelines` | 流水线 |
| `vanblog_manage_collaborators` | 协作者 |
| `vanblog_manage_tokens` | Token 管理 |
| `vanblog_get_analysis` / `_logs` / `_meta` | 统计、日志、元信息 |
| `vanblog_auth` | 认证相关(含 `restore` 重置密码) |
> 工具名前缀取自插件名(`tp`),上面按默认 `vanblog_` 列出。
## 实现要点
- 走的是 VanBlog 的管理 API`/api/admin/...`),所以必须配 **admin token**
不是前台只读接口。
- `update_article` 是**部分更新**:只传想改的字段,没传的保持不变。
(不要为了改标题而把正文一起传一遍。)
- `delete_article` 是**软删除**,内容仍在,可在后台恢复。
- 早期版本把 token 放在内核配置(`plugin.vanblog.token`)里,
现在会**自动迁移**到插件配置,迁移后清空内核侧取值。
## 前置
需要一个可访问的 VanBlog 实例,并在后台创建一个长期 Token。
## 构建
```bash
hmapdev build
```

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

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

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

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

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

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

2617
example/vikunja/plugin.go Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,13 +1,33 @@
# weather
# weather · 天气查询
weather plugin
给 agent 补上天气查询能力(基于 [wttr.in](https://wttr.in),无需 API Key
## Build
## 工具
| 工具 | 说明 |
|---|---|
| `weather_current` | 查询某城市当前天气 |
| `weather_forecast` | 查询未来几天预报 |
| `weather_set_location` | 设置默认城市 |
`weather_current` / `weather_forecast` 都接受 `location`(城市名,如 `Beijing``Shanghai`
省略时用配置里的默认城市。`weather_current` 另有 `units``metric`(摄氏,默认)或 `imperial`(华氏)。
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `default_location` | 空 | 默认城市名。留空则每次调用都必须传 `location` |
## 实现要点
- **`NoMemory: true`**:天气是外部实时数据,对记忆计算无长期价值,跳过向量化与关键词提取(原文仍保留在对话里)。
- **`Cleaner`**:输出参与记忆计算前先过滤,只保留摘要行 —— 天气查询会反复出现,全文进记忆会挤占上下文预算,而"上周三北京多少度"通常并不需要召回。
## 构建
```bash
hmapdev build
```
## Install
Upload the .hmap file through the Plugin Manager API.
产出 `.hmap` 后经 Plugin Manager API 安装。

View File

@ -41,16 +41,14 @@ var (
// ❗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。
// 现为 1.4.01.3.0 已随核心的正式 tag `v1.3.0` 定版并发版(本仓 tag v1.3.0、
// release/v1.3.x 承载它该号从此归发布线所有main 遂推进到下一个未发布中版本。
//
// 注意:这里与核心 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"
// ❗本仓**不发 patch tag**(§七.1):一个中版本只发一次 `vX.Y.0`,核心的 1.3.x
// 后续 patch **不伴随 SDK 发版** —— patch 位恒为 `.0`,带非零 patch 的 SDK tag
// 都是错的。2026-09-13 曾误发 `v1.3.1`,已撤回;`v1.2.1` 是同一类历史遗留。)
//
Version = "1.4.0"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"

147
scripts/build_plugin_bundles.sh Executable file
View File

@ -0,0 +1,147 @@
#!/usr/bin/env bash
# 为 SDK 仓的 example 插件批量打 .hmap 包,产出可直接随 release 发布的插件包。
#
# 背景release 此前只发 homed/waiter 二进制与 hmapdev 工具链,**不发插件包**。
# 用户要用某个插件,得自己装 Go 1.25、拉依赖、装 hmapdev、逐个 build —— 这是
# 「开箱即用」名不副实的根源。本脚本把这一步前置到发布流程里。
#
# 用法:
# ./build_plugin_bundles.sh # 全部 example
# ./build_plugin_bundles.sh weather qq memo # 指定插件
# OUT=../dist/plugins ./build_plugin_bundles.sh
#
# 环境:
# HMAPDEV hmapdev 可执行文件(默认取 PATH 上的 hmapdev
# OUT 产物目录。默认取**内核仓**的 dist/pluginsupload_assets.py 认这个位置),
# 以便直接随 release 发布;不在内核仓内时回退到 SDK 仓的 dist/plugins。
# JOBS 并行度(默认 CPU 核数)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SDK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# 默认产物落到内核仓的 dist/plugins。判定方式从 SDK 目录向上找“含 internal/ 与
# go.mod”的目录即内核仓根找不到就用 SDK 仓自己的 dist/plugins。
# 为何不写死 ../../SDK 仓在主仓里是 third_party/homeagent-sdk但也可以被单独
# clone 出来,写死相对路径会把产物丢到仓外或 third_party/dist。
default_out() {
local d="$SDK_DIR"
for _ in 1 2 3 4; do
d="$(cd "$d/.." && pwd)"
if [ -f "$d/go.mod" ] && [ -d "$d/internal" ]; then
echo "$d/dist/plugins"; return
fi
done
echo "$SDK_DIR/dist/plugins"
}
EX_DIR="$SDK_DIR/example"
OUT="${OUT:-$(default_out)}"
HMAPDEV="${HMAPDEV:-hmapdev}"
command -v "$HMAPDEV" >/dev/null 2>&1 || {
echo "error: 找不到 hmapdev设 HMAPDEV=/path/to/hmapdev 或用 'hmapdev sdk install' 装)" >&2
exit 1
}
mkdir -p "$OUT"
# 收集候选插件:有 plg.json 才可构建
all=()
for d in "$EX_DIR"/*/; do
n="$(basename "$d")"
[ -f "$d/plg.json" ] || continue
all+=("$n")
done
# 参数指定则取交集(并校验名字有效,避免拼错静默跳过)
if [ "$#" -gt 0 ]; then
want=("$@")
sel=()
for w in "${want[@]}"; do
found=""
for n in "${all[@]}"; do [ "$n" = "$w" ] && found=1 && break; done
[ -n "$found" ] || { echo "error: 未知插件 '$w'(可用: ${all[*]}" >&2; exit 1; }
sel+=("$w")
done
all=("${sel[@]}")
fi
echo "=== 打包 ${#all[@]} 个插件 → $OUT ==="
echo " hmapdev: $("$HMAPDEV" --version 2>/dev/null | head -1 || echo "$HMAPDEV")"
build_one() {
local name="$1"
local dir="$EX_DIR/$name"
local log="$OUT/.$name.log"
# hmapdev build 必须在插件目录内跑(它读当前目录的 plg.json
if ! (cd "$dir" && "$HMAPDEV" build >"$log" 2>&1); then
echo "$name 构建失败(见 $log"
return 1
fi
# 产物有三种形态,不能只认 _bundle.hmap
# 1) <name>_bundle.hmap 多平台 bundleplg.json 里 bundle: true
# 2) <name>_<os>_<arch>.hmap 单平台bundle 关掉时,如 qq
# 3) <name>_lua.hmap Lua 插件(不编译 Go如 luademo
#
# 注意用 if 而非 `[ -z ] && found=$(ls...)`:在 set -e 下,
# 一次 ls 无匹配就会让整个子 shell 直接退出,根本走不到后面的兜底。
local found=""
local cand
for pat in "$dir"/dist/*_bundle.hmap "$dir"/dist/*.hmap "$dir"/*_bundle.hmap; do
if [ -z "$found" ]; then
cand="$(ls -t $pat 2>/dev/null | head -1 || true)"
[ -n "$cand" ] && found="$cand"
fi
done
if [ -z "$found" ]; then
echo "$name 未产出 .hmap$log"
return 1
fi
cp -f "$found" "$OUT/"
local sz bn
bn="$(basename "$found")"
sz="$(stat -c%s "$OUT/$bn" 2>/dev/null || stat -f%z "$OUT/$bn")"
# 标注形态:单平台/Lua 包与多平台 bundle 不同,发布时要能一眼看出
local tag=""
case "$bn" in
*_bundle.hmap) tag="bundle" ;;
*_lua.hmap) tag="lua " ;;
*) tag="单平台" ;;
esac
printf " ✓ %-16s %7.1f MB %s\n" "$name" "$(echo "$sz" | awk '{print $1/1048576}')" "$tag"
rm -f "$log"
}
fail=0
pids=()
for n in "${all[@]}"; do
# 有 nproc 就限并发,没有就串行
if command -v nproc >/dev/null 2>&1; then
while [ "$(jobs -rp | wc -l)" -ge "${JOBS:-$(nproc)}" ]; do wait -n 2>/dev/null || true; done
fi
( build_one "$n" ) &
pids+=($!)
done
for p in "${pids[@]}"; do wait "$p" || fail=$((fail+1)); done
echo
echo "=== 产出 ==="
ls -la "$OUT"/*.hmap 2>/dev/null | awk '{printf " %-46s %8.1f MB\n", $9, $5/1048576}' || echo " (无)"
# 汇总校验和,便于随 release 一起发布与验证
if ls "$OUT"/*.hmap >/dev/null 2>&1; then
( cd "$OUT" && sha256sum ./*.hmap > SHA256SUMS.plugins )
echo
echo "=== 校验和 → $OUT/SHA256SUMS.plugins ==="
cat "$OUT/SHA256SUMS.plugins" | sed 's/^/ /'
fi
if [ "$fail" -gt 0 ]; then
echo
echo "error: $fail 个插件构建失败" >&2
exit 1
fi

38
scripts/sync-lua-sdk.sh Executable file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# 同步 Lua SDK mock 的单一事实源到各副本。
#
# 事实源sdk/lua/sdk.lua本仓
# 副本:
# - tools/hmapdev/assets/sdk.lua 工具链内嵌回退hmapdev init --lua 无 SDK 时用)
# - example/luademo/sdk.lua 示例插件的离线测试副本
# - <core>/internal/lua/sdk/sdk.lua 内核内嵌副本(本仓被 vendored 到
# <core>/third_party/homeagent-sdk 时自动识别;独立 clone 时跳过)
#
# 为什么要有它:三份 sdk.lua 曾各自漂移出现「mock 有、内核没有」的静默失配。
# 改 mock 只改事实源,然后跑这个脚本;内核仓另有契约测试比对。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/sdk/lua/sdk.lua"
[ -f "$SRC" ] || { echo "error: canonical sdk.lua not found: $SRC" >&2; exit 1; }
copy() {
local dst="$1"
mkdir -p "$(dirname "$dst")"
cp "$SRC" "$dst"
echo " synced -> $dst"
}
copy "$ROOT/tools/hmapdev/assets/sdk.lua"
copy "$ROOT/example/luademo/sdk.lua"
# 被内核仓 vendored 时(本仓位于 <core>/third_party/homeagent-sdk同步内核副本。
CORE_COPY="$ROOT/../../internal/lua/sdk/sdk.lua"
if [ -d "$ROOT/../../internal" ]; then
copy "$(cd "$(dirname "$CORE_COPY")" && pwd)/sdk.lua"
else
echo " note: core repo not vendored next to this checkout, skipping core copy"
fi
echo "Lua SDK mock synced."

413
sdk/lua/sdk.lua Normal file
View File

@ -0,0 +1,413 @@
-- HomeAgent Lua Plugin SDK
-- Interface contract between Lua plugins and HomeAgent kernel.
-- !impl functions are replaced by Go implementations at runtime.
-- Standalone/debug: pure Lua mock implementations are used.
-- Usage: local sdk = require("sdk")
sdk = {}
-- !impl
-- level: "debug" | "info" | "warn" | "error"
function sdk.log(level, msg)
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
end
-- !impl
-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_tool(name, def, handler)
print("[lua-plugin] register_tool: " .. tostring(name))
end
-- !impl
-- stage: "on_input" | "pre_action" | "post_action" | ...
-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发)
function sdk.register_stage(stage, handler, scope)
print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope))
end
-- !impl
function sdk.register_api(name)
print("[lua-plugin] register_api: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_output_channel(name, caps, desc, def, handler)
print("[lua-plugin] register_output_channel: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
function sdk.register_input_channel(name, def)
print("[lua-plugin] register_input_channel: " .. tostring(name))
end
-- !impl
function sdk.get_setting(key)
return nil
end
-- !impl
function sdk.set_setting(key, value)
print("[lua-plugin] set_setting: " .. tostring(key))
end
-- !impl
function sdk.inject_text(source, channel, text)
print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel))
end
-- !impl
function sdk.inject_interrupt(source, channel, text)
print("[lua-plugin] inject_interrupt: " .. tostring(source))
end
-- !impl
function sdk.inject_text_no_memory(source, channel, text)
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
end
-- !impl
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
function sdk.inject_text_opts(source, channel, text, opts)
print("[lua-plugin] inject_text_opts: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_opts(source, channel, text, opts)
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
end
-- !impl
-- 同步注入在 Lua 插件中**不可用**:会等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。
-- 真实内核里恒返回 (nil, err);这里返回同样的错误,避免离线测试误以为可用。
function sdk.inject_input_sync(source, channel, text)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_input_sync_opts(source, channel, text, opts)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt确需同步等待请改用 Go 插件。"
end
-- !impl
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
function sdk.set_tool_blocks(blocks)
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
end
-- !impl
-- blocks 每项:{ type="text", text="..." }
-- | { type="image_url", image_url={ url="...", detail="high" } }
-- | { type="audio_url", audio_url={ url="..." } }
function sdk.inject_input_media(source, channel, text, blocks)
print("[lua-plugin] inject_input_media: " .. tostring(source))
end
-- !impl
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
end
-- !impl
-- 同 sdk.inject_input_syncLua 中不可用。
function sdk.inject_input_media_sync(source, channel, text, blocks)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media_opts确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_interrupt_media(source, channel, text, blocks)
print("[lua-plugin] inject_interrupt_media: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source))
end
-- !impl
-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。
function sdk.unregister_output_channel(name) return nil, nil end
-- !impl
-- enabled: true/false崩溃时内核自动拉起
function sdk.set_auto_restart(enabled)
print("[lua-plugin] set_auto_restart: " .. tostring(enabled))
end
-- ============ graph memory ============
-- !impl
sdk.memory = {}
-- !impl
-- query: string, depth: number -> {entities={...}, relations={...}}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
-- !impl
-- triples: { {subject=, relation=, object=, [confidence=], [sentence_text=]} } -> err
function sdk.memory.commit(triples) return nil end
-- !impl
function sdk.memory.introspect() return {} end
-- !impl
function sdk.memory.merge(source, target) return 0 end
-- !impl
-- criteria: {key=value}, hard: boolean
function sdk.memory.purge(criteria, hard) return 0 end
-- ============ document memory ============
-- !impl
sdk.doc = {}
-- !impl
function sdk.doc.query(text, top_k) return {} end
-- !impl
-- doc: { id=, title=, content= }
function sdk.doc.insert(doc) return nil end
-- !impl
-- attachments 每项:{ digest=, mime=, name=, data=<base64> }
function sdk.doc.insert_with_media(doc, attachments) return nil end
-- !impl
function sdk.doc.remove(id) return nil end
-- !impl
function sdk.doc.stats() return {} end
-- ============ knowledge ============
-- !impl
sdk.knowledge = {}
-- !impl
function sdk.knowledge.search(query, limit) return {} end
-- !impl
function sdk.knowledge.add(tag, content) return nil end
-- !impl
function sdk.knowledge.list() return {} end
-- ============ text memory ============
-- !impl
sdk.text_memory = {}
-- !impl
-- evt: { timestamp=, role=, content=, channel= }
function sdk.text_memory.append(evt) return nil end
-- ============ llm ============
-- !impl
sdk.llm = {}
-- !impl
function sdk.llm.list_sources() return {} end
-- !impl
function sdk.llm.set_source(name) return nil end
-- !impl
function sdk.llm.current_source() return nil end
-- ============ social (只读) ============
-- !impl
sdk.social = {}
-- !impl
function sdk.social.get_person(name) return {} end
-- !impl
function sdk.social.get_network(name, depth) return {} end
-- !impl
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
-- !impl
function sdk.social.get_relations(name) return {} end
-- !impl
function sdk.social.list_persons() return {} end
-- ============ settings (作用域变体) ============
-- !impl
sdk.settings = {}
-- !impl
function sdk.settings.get_core(key) return nil end
-- !impl
function sdk.settings.set_core(key, value) return nil end
-- !impl
function sdk.settings.list_core(prefix) return {} end
-- !impl
function sdk.settings.get_plugin(plugin, key) return nil end
-- !impl
function sdk.settings.set_plugin(plugin, key, value) return nil end
-- !impl
function sdk.settings.list_plugin(plugin, prefix) return {} end
-- !impl
function sdk.settings.list(prefix) return {} end
-- !impl
-- def: { key=, type=, display_name=, description=, category=, options=, default=,
-- min=, max=, step=, required=, secret= }
function sdk.settings.register_def(def) return nil end
-- !impl
function sdk.settings.defs(prefix) return {} end
-- !impl
function sdk.settings.dump() return {} end
-- !impl
function sdk.settings.plugins() return {} end
-- ============ events只读订阅 ============
-- !impl
-- subscribe(event_type, handler) -> unsubscribe()
-- handler 收到 { type=, source=, timestamp=, payload= }
-- 回调在其内核事件发布 goroutine 上执行只做轻量转发不可阻塞Lua 单状态 + 互斥锁)。
sdk.events = {}
function sdk.events.subscribe(event_type, handler)
print("[lua-plugin] events.subscribe: " .. tostring(event_type))
return function() end
end
-- ============ plugin_mgr ============
-- !impl
sdk.plugin_mgr = {}
function sdk.plugin_mgr.reload_one(name) return nil end
function sdk.plugin_mgr.list_loaded() return {} end
function sdk.plugin_mgr.is_disabled(name) return false end
-- json utils (pure Lua)
sdk.json = {}
function sdk.json.encode(val)
local ok, result = pcall(function()
local function _encode(v)
local t = type(v)
if t == "string" then
local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t')
return '"' .. s .. '"'
elseif t == "number" then
return tostring(v)
elseif t == "boolean" then
return tostring(v)
elseif t == "table" then
local keys = {}
local is_array = true
local maxn = 0
for k in pairs(v) do
keys[#keys + 1] = k
if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
is_array = false
end
if type(k) == "number" and k > maxn then maxn = k end
end
if is_array and #keys >= maxn then
local parts = {}
for i = 1, maxn do
parts[#parts + 1] = _encode(v[i])
end
return "[" .. table.concat(parts, ",") .. "]"
else
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k])
end
return "{" .. table.concat(parts, ",") .. "}"
end
else
return "null"
end
end
return _encode(val)
end)
if ok then return result end
return "null"
end
function sdk.json.decode(str)
local ok, result = pcall(function()
local pos, _end = 1, #str
local function skip()
while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end
end
local function parse()
skip()
if pos > _end then return nil end
local c = str:sub(pos, pos)
if c == '"' then
local s = {}
pos = pos + 1
while pos <= _end do
local ch = str:sub(pos, pos)
if ch == '"' then
pos = pos + 1
return table.concat(s)
elseif ch == '\\' then
pos = pos + 1
local n = str:sub(pos, pos)
if n == '"' then s[#s+1] = '"'
elseif n == '\\' then s[#s+1] = '\\'
elseif n == '/' then s[#s+1] = '/'
elseif n == 'b' then s[#s+1] = '\b'
elseif n == 'f' then s[#s+1] = '\f'
elseif n == 'n' then s[#s+1] = '\n'
elseif n == 'r' then s[#s+1] = '\r'
elseif n == 't' then s[#s+1] = '\t'
elseif n == 'u' then
local hex = str:sub(pos+1, pos+4)
pos = pos + 4
s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?'
end
pos = pos + 1
else
s[#s+1] = ch
pos = pos + 1
end
end
return table.concat(s)
elseif c == 't' then pos = pos + 4; return true
elseif c == 'f' then pos = pos + 5; return false
elseif c == 'n' then pos = pos + 4; return nil
elseif c == '{' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == '}' then pos = pos + 1; return t end
while true do
skip(); local k = parse(); skip()
if str:sub(pos, pos) == ':' then pos = pos + 1 end
skip(); t[k] = parse(); skip()
local sep = str:sub(pos, pos)
if sep == '}' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
elseif c == '[' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == ']' then pos = pos + 1; return t end
local idx = 1
while true do
skip(); t[idx] = parse(); idx = idx + 1; skip()
local sep = str:sub(pos, pos)
if sep == ']' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
else
local s, e = str:find('^[-%d%.eE]+', pos)
if s then
local num = tonumber(str:sub(s, e))
pos = e + 1
return num
end
return nil
end
end
return parse()
end)
if ok then return result end
return nil
end
-- http utils
sdk.http = {}
-- !impl
function sdk.http.get(url)
print("[lua-plugin] http.get: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
-- !impl
function sdk.http.post(url, body, content_type)
print("[lua-plugin] http.post: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
return sdk

View File

@ -78,8 +78,30 @@ type InjectOptions struct {
NoMemory bool
ContextPolicy string
CleanerName string
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
//
// 取值 PriorityL1..PriorityL4空等同 L1默认级
// L4 只有**内核级插件**能用(见 PriorityL4 注释);外部插件的 L4 会被夹到 L3。
//
// 排队注入InjectText*/InjectInputSync没有级别它们本就是“不需及时处理”
// 的那一类,可被任何中断打断。
Priority string
}
// 中断优先级取值。
//
// L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
// 如 cli/webui/timer才能声明——它用于实现真正的“立即打断”能力
// 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
const (
PriorityL1 = "L1"
PriorityL2 = "L2"
PriorityL3 = "L3"
// PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
PriorityL4 = "L4"
)
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
@ -262,6 +284,13 @@ type InputChannelRegistrar func(name string, def ChannelDef) error
// OutputChannelRegistrar registers an output channel that the output_send tool can use.
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
// OutputChannelUnregistrar 注销一个输出通道。
//
// 为什么需要它:输出通道不止有"启动时注册一次"的静态通道,还有**随外部资源生灭**的
// 动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后
// 必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。
type OutputChannelUnregistrar func(name string) error
// Output capability flags
const (
CapText = 1
@ -274,22 +303,23 @@ const (
// PluginSDK is the main API surface provided to plugins at runtime.
// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
type PluginSDK struct {
name string
regTool ToolRegistrar
regStage StageRegistrar
regAPI APIRegistrar
regOutput OutputChannelRegistrar
regInput InputChannelRegistrar
io IOInjector
mem MemoryAPI
textMem TextMemoryAPI
docMem DocMemoryAPI
know KnowledgeAPI
llm LLMAPI
sett SettingsAPI
social SocialAPI
events EventSubscriber
plgMgr PluginMgrAPI
name string
regTool ToolRegistrar
regStage StageRegistrar
regAPI APIRegistrar
regOutput OutputChannelRegistrar
regOutputUnreg OutputChannelUnregistrar
regInput InputChannelRegistrar
io IOInjector
mem MemoryAPI
textMem TextMemoryAPI
docMem DocMemoryAPI
know KnowledgeAPI
llm LLMAPI
sett SettingsAPI
social SocialAPI
events EventSubscriber
plgMgr PluginMgrAPI
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
//
@ -445,7 +475,20 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
}
// RegisterOutputChannel registers an output channel that the output_send tool can route to.
// name: channel name (e.g. "qq", "webui")
//
// 与 RegisterInputChannel 的分工:本函数声明**出站**output_send__<name> 的回复发给谁);
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
// 若该通道同时也是你的注入入口,两个都要登记。
//
// name: channel name (e.g. "qq", "webui")。
//
// ❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`
// 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用"
// 而是**整条请求被上游 400 拒绝**`Invalid 'tools[N].function.name'`
// 网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
// 所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`13 字符)的余量。
// 若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**
// 而不是把原始值直接当通道名。
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
// desc: description of the channel, expected meta format, and type enum
// def: 通道在记忆计算层的行为NoMemory/Cleaner
@ -460,7 +503,27 @@ func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, de
return nil
}
// UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
func (s *PluginSDK) UnregisterOutputChannel(name string) error {
s.apiMu.RLock()
reg := s.regOutputUnreg
s.apiMu.RUnlock()
if reg != nil {
return reg(name)
}
return nil
}
// RegisterInputChannel registers an input channel with its memory behavior.
//
// 契约:**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)
// 注入的通道名,都应当在这里登记**。inputch 是内核里最基本的**输入路由单位**
// 只有登记过的通道才能在 inputch 登记表里出现,父 agent 才能"把某个 inputch 划给驻留子"
// 没登记就划分会直接失败(`inputch 未注册`)。
//
// 只登记输出通道RegisterOutputChannel而没登记输入通道时内核会兜底登记同名
// inputch 并打告警日志 —— 兜底只为兼容老插件,新插件请显式登记。
//
// def.NoMemory: 此通道输入不参与记忆计算
// def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {
@ -482,6 +545,13 @@ func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) {
s.apiMu.Unlock()
}
// SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar) {
s.apiMu.Lock()
s.regOutputUnreg = r
s.apiMu.Unlock()
}
// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) {
s.apiMu.Lock()

View File

@ -0,0 +1,413 @@
-- HomeAgent Lua Plugin SDK
-- Interface contract between Lua plugins and HomeAgent kernel.
-- !impl functions are replaced by Go implementations at runtime.
-- Standalone/debug: pure Lua mock implementations are used.
-- Usage: local sdk = require("sdk")
sdk = {}
-- !impl
-- level: "debug" | "info" | "warn" | "error"
function sdk.log(level, msg)
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
end
-- !impl
-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_tool(name, def, handler)
print("[lua-plugin] register_tool: " .. tostring(name))
end
-- !impl
-- stage: "on_input" | "pre_action" | "post_action" | ...
-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发)
function sdk.register_stage(stage, handler, scope)
print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope))
end
-- !impl
function sdk.register_api(name)
print("[lua-plugin] register_api: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_output_channel(name, caps, desc, def, handler)
print("[lua-plugin] register_output_channel: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
function sdk.register_input_channel(name, def)
print("[lua-plugin] register_input_channel: " .. tostring(name))
end
-- !impl
function sdk.get_setting(key)
return nil
end
-- !impl
function sdk.set_setting(key, value)
print("[lua-plugin] set_setting: " .. tostring(key))
end
-- !impl
function sdk.inject_text(source, channel, text)
print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel))
end
-- !impl
function sdk.inject_interrupt(source, channel, text)
print("[lua-plugin] inject_interrupt: " .. tostring(source))
end
-- !impl
function sdk.inject_text_no_memory(source, channel, text)
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
end
-- !impl
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
function sdk.inject_text_opts(source, channel, text, opts)
print("[lua-plugin] inject_text_opts: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_opts(source, channel, text, opts)
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
end
-- !impl
-- 同步注入在 Lua 插件中**不可用**:会等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。
-- 真实内核里恒返回 (nil, err);这里返回同样的错误,避免离线测试误以为可用。
function sdk.inject_input_sync(source, channel, text)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_input_sync_opts(source, channel, text, opts)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt确需同步等待请改用 Go 插件。"
end
-- !impl
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
function sdk.set_tool_blocks(blocks)
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
end
-- !impl
-- blocks 每项:{ type="text", text="..." }
-- | { type="image_url", image_url={ url="...", detail="high" } }
-- | { type="audio_url", audio_url={ url="..." } }
function sdk.inject_input_media(source, channel, text, blocks)
print("[lua-plugin] inject_input_media: " .. tostring(source))
end
-- !impl
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
end
-- !impl
-- 同 sdk.inject_input_syncLua 中不可用。
function sdk.inject_input_media_sync(source, channel, text, blocks)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_input_media_opts确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_interrupt_media(source, channel, text, blocks)
print("[lua-plugin] inject_interrupt_media: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source))
end
-- !impl
-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。
function sdk.unregister_output_channel(name) return nil, nil end
-- !impl
-- enabled: true/false崩溃时内核自动拉起
function sdk.set_auto_restart(enabled)
print("[lua-plugin] set_auto_restart: " .. tostring(enabled))
end
-- ============ graph memory ============
-- !impl
sdk.memory = {}
-- !impl
-- query: string, depth: number -> {entities={...}, relations={...}}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
-- !impl
-- triples: { {subject=, relation=, object=, [confidence=], [sentence_text=]} } -> err
function sdk.memory.commit(triples) return nil end
-- !impl
function sdk.memory.introspect() return {} end
-- !impl
function sdk.memory.merge(source, target) return 0 end
-- !impl
-- criteria: {key=value}, hard: boolean
function sdk.memory.purge(criteria, hard) return 0 end
-- ============ document memory ============
-- !impl
sdk.doc = {}
-- !impl
function sdk.doc.query(text, top_k) return {} end
-- !impl
-- doc: { id=, title=, content= }
function sdk.doc.insert(doc) return nil end
-- !impl
-- attachments 每项:{ digest=, mime=, name=, data=<base64> }
function sdk.doc.insert_with_media(doc, attachments) return nil end
-- !impl
function sdk.doc.remove(id) return nil end
-- !impl
function sdk.doc.stats() return {} end
-- ============ knowledge ============
-- !impl
sdk.knowledge = {}
-- !impl
function sdk.knowledge.search(query, limit) return {} end
-- !impl
function sdk.knowledge.add(tag, content) return nil end
-- !impl
function sdk.knowledge.list() return {} end
-- ============ text memory ============
-- !impl
sdk.text_memory = {}
-- !impl
-- evt: { timestamp=, role=, content=, channel= }
function sdk.text_memory.append(evt) return nil end
-- ============ llm ============
-- !impl
sdk.llm = {}
-- !impl
function sdk.llm.list_sources() return {} end
-- !impl
function sdk.llm.set_source(name) return nil end
-- !impl
function sdk.llm.current_source() return nil end
-- ============ social (只读) ============
-- !impl
sdk.social = {}
-- !impl
function sdk.social.get_person(name) return {} end
-- !impl
function sdk.social.get_network(name, depth) return {} end
-- !impl
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
-- !impl
function sdk.social.get_relations(name) return {} end
-- !impl
function sdk.social.list_persons() return {} end
-- ============ settings (作用域变体) ============
-- !impl
sdk.settings = {}
-- !impl
function sdk.settings.get_core(key) return nil end
-- !impl
function sdk.settings.set_core(key, value) return nil end
-- !impl
function sdk.settings.list_core(prefix) return {} end
-- !impl
function sdk.settings.get_plugin(plugin, key) return nil end
-- !impl
function sdk.settings.set_plugin(plugin, key, value) return nil end
-- !impl
function sdk.settings.list_plugin(plugin, prefix) return {} end
-- !impl
function sdk.settings.list(prefix) return {} end
-- !impl
-- def: { key=, type=, display_name=, description=, category=, options=, default=,
-- min=, max=, step=, required=, secret= }
function sdk.settings.register_def(def) return nil end
-- !impl
function sdk.settings.defs(prefix) return {} end
-- !impl
function sdk.settings.dump() return {} end
-- !impl
function sdk.settings.plugins() return {} end
-- ============ events只读订阅 ============
-- !impl
-- subscribe(event_type, handler) -> unsubscribe()
-- handler 收到 { type=, source=, timestamp=, payload= }
-- 回调在其内核事件发布 goroutine 上执行只做轻量转发不可阻塞Lua 单状态 + 互斥锁)。
sdk.events = {}
function sdk.events.subscribe(event_type, handler)
print("[lua-plugin] events.subscribe: " .. tostring(event_type))
return function() end
end
-- ============ plugin_mgr ============
-- !impl
sdk.plugin_mgr = {}
function sdk.plugin_mgr.reload_one(name) return nil end
function sdk.plugin_mgr.list_loaded() return {} end
function sdk.plugin_mgr.is_disabled(name) return false end
-- json utils (pure Lua)
sdk.json = {}
function sdk.json.encode(val)
local ok, result = pcall(function()
local function _encode(v)
local t = type(v)
if t == "string" then
local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t')
return '"' .. s .. '"'
elseif t == "number" then
return tostring(v)
elseif t == "boolean" then
return tostring(v)
elseif t == "table" then
local keys = {}
local is_array = true
local maxn = 0
for k in pairs(v) do
keys[#keys + 1] = k
if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
is_array = false
end
if type(k) == "number" and k > maxn then maxn = k end
end
if is_array and #keys >= maxn then
local parts = {}
for i = 1, maxn do
parts[#parts + 1] = _encode(v[i])
end
return "[" .. table.concat(parts, ",") .. "]"
else
local parts = {}
for _, k in ipairs(keys) do
parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k])
end
return "{" .. table.concat(parts, ",") .. "}"
end
else
return "null"
end
end
return _encode(val)
end)
if ok then return result end
return "null"
end
function sdk.json.decode(str)
local ok, result = pcall(function()
local pos, _end = 1, #str
local function skip()
while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end
end
local function parse()
skip()
if pos > _end then return nil end
local c = str:sub(pos, pos)
if c == '"' then
local s = {}
pos = pos + 1
while pos <= _end do
local ch = str:sub(pos, pos)
if ch == '"' then
pos = pos + 1
return table.concat(s)
elseif ch == '\\' then
pos = pos + 1
local n = str:sub(pos, pos)
if n == '"' then s[#s+1] = '"'
elseif n == '\\' then s[#s+1] = '\\'
elseif n == '/' then s[#s+1] = '/'
elseif n == 'b' then s[#s+1] = '\b'
elseif n == 'f' then s[#s+1] = '\f'
elseif n == 'n' then s[#s+1] = '\n'
elseif n == 'r' then s[#s+1] = '\r'
elseif n == 't' then s[#s+1] = '\t'
elseif n == 'u' then
local hex = str:sub(pos+1, pos+4)
pos = pos + 4
s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?'
end
pos = pos + 1
else
s[#s+1] = ch
pos = pos + 1
end
end
return table.concat(s)
elseif c == 't' then pos = pos + 4; return true
elseif c == 'f' then pos = pos + 5; return false
elseif c == 'n' then pos = pos + 4; return nil
elseif c == '{' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == '}' then pos = pos + 1; return t end
while true do
skip(); local k = parse(); skip()
if str:sub(pos, pos) == ':' then pos = pos + 1 end
skip(); t[k] = parse(); skip()
local sep = str:sub(pos, pos)
if sep == '}' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
elseif c == '[' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == ']' then pos = pos + 1; return t end
local idx = 1
while true do
skip(); t[idx] = parse(); idx = idx + 1; skip()
local sep = str:sub(pos, pos)
if sep == ']' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
else
local s, e = str:find('^[-%d%.eE]+', pos)
if s then
local num = tonumber(str:sub(s, e))
pos = e + 1
return num
end
return nil
end
end
return parse()
end)
if ok then return result end
return nil
end
-- http utils
sdk.http = {}
-- !impl
function sdk.http.get(url)
print("[lua-plugin] http.get: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
-- !impl
function sdk.http.post(url, body, content_type)
print("[lua-plugin] http.post: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
return sdk

View File

@ -75,10 +75,50 @@ func cmdBuild(args []string) {
}
if plg.IsLua() {
// Lua 插件不经过 Go 编译,但也必须做两件与纪律相关的事:
// 1) 记录「用哪版 SDK 语义写的」——否则新 API 在旧内核上只会静默缺失;
// 2) 打包前做语法预检——否则语法错会被原样包进 .hmap到内核加载时才暴露。
if root := tryActiveSDKRoot(); root != "" {
plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(root))
}
if err := checkLuaSyntax("main.lua"); err != nil {
fmt.Printf(" error: %v\n", err)
// 直接退出而非置 buildFailedLua 分支不进入后面的收尾统计,
// 早期 return 会让调用方拿到 0 退出码。
os.Exit(1)
}
buildTarget(plg, "lua", outDir, "")
return
}
// 项目可在 plg.json 里声明 sdk中版本或完整版本如 "1.2" / "1.2.1"
// 显式 --sdk-path / plg.json 的 sdk_path 优先 —— 那是直指源码目录,
// 常用于本机改 SDK 的联调场景。
if sdkPath == "" && strings.TrimSpace(plg.SDK) != "" {
dir, ver, err := ResolveSDKForProject(plg.SDK)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
sdkPath, plg.ResolvedSDK = dir, ver
fmt.Printf("[hmapdev] SDK %s项目声明 sdk=%s\n", ver, plg.SDK)
} else if sdkPath != "" && plg.ResolvedSDK == "" {
// 走的是显式路径:尽力记录它是哪版(读不到就不记,不因此失败)
plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(sdkPath))
}
// SDK 能力前置校验proc 桥的模板z_proc_gen.go会透传 InjectOptions.Priority
// 而旧版 SDK 没有这个字段。不校验的话,用户看到的是 z_proc_gen.go 里两条
// "opts.Priority undefined" 编译错误——错误信息指向生成物,完全看不出是 SDK 版本问题。
if sdkPath != "" && !sdkHasInjectPriority(sdkPath) {
fmt.Printf("error: 当前 SDK%s缺少 sdk.InjectOptions.Priority\n", plg.ResolvedSDK)
fmt.Printf(" 子进程模式proc 桥)的模板需要它来透传注入优先级 L1-L4。\n")
fmt.Printf(" 解决办法(二选一):\n")
fmt.Printf(" 1) 升级 SDKhmapdev sdk install <含该能力的版本> && hmapdev sdk use <版本>\n")
fmt.Printf(" 2) 用本地 SDK 源码hmapdev sdk install --from /path/to/homeagent-sdk\n")
os.Exit(1)
}
// Ensure go.mod exists with correct SDK path
sdkModule := ensureGoMod(plg, sdkPath)
@ -226,6 +266,30 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
// 这是 entry 字段唯一仍在使用的用途Go 插件不再看 entry 值,一律产出 plugin.bin。
func (p *PlgConfig) IsLua() bool { return p.Entry == luaEntryFile }
// checkLuaSyntax 在打包前对 Lua 源码做语法预检。
//
// 为什么不只是“能做就做”Lua 分支不经过编译器,语法错会被原样包进 .hmap
// 直到内核加载时才报错,且错误现场是内核日志而不是构建日志。
// 有 luac 用 luac -p只解析不执行只有 lua 时用 loadfile 同样只解析;
// 两者都没有才降级为警告,不阻断构建(构建机可以没有 Lua 解释器)。
func checkLuaSyntax(path string) error {
if bin, err := exec.LookPath("luac"); err == nil {
if out, err := exec.Command(bin, "-p", path).CombinedOutput(); err != nil {
return fmt.Errorf("lua syntax check failed (%s): %s", path, strings.TrimSpace(string(out)))
}
return nil
}
if bin, err := exec.LookPath("lua"); err == nil {
script := fmt.Sprintf("local f,e=loadfile(%q); if not f then io.stderr:write(e) os.exit(1) end", path)
if out, err := exec.Command(bin, "-e", script).CombinedOutput(); err != nil {
return fmt.Errorf("lua syntax check failed (%s): %s", path, strings.TrimSpace(string(out)))
}
return nil
}
fmt.Println(" note: lua/luac not found, skipping syntax check")
return nil
}
func readPlgJSON(path string) (*PlgConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
@ -269,6 +333,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)
}
@ -370,7 +438,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, "")
@ -828,3 +917,22 @@ func linkThirdpart(plg *PlgConfig, target string) func() {
os.Remove(importFile)
}
}
// sdkHasInjectPriority 报告该 SDK 源码是否已具备 InjectOptions.Priority
// proc 桥透传注入优先级所必需的能力SDK 开发期与已发布版本可能不一致)。
func sdkHasInjectPriority(sdkPath string) bool {
data, err := os.ReadFile(filepath.Join(sdkPath, "sdk", "plugin.go"))
if err != nil {
return true // 读不到就不拦(不在校验范围内)
}
src := string(data)
i := strings.Index(src, "type InjectOptions struct")
if i < 0 {
return true
}
seg := src[i:]
if j := strings.Index(seg, "\n}"); j > 0 {
seg = seg[:j]
}
return strings.Contains(seg, "Priority")
}

View File

@ -13,7 +13,14 @@ import (
// tmplLuaDebug is the temporary Lua debug script template
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
-- Generated by hmapdev debug --lua
sdk = require("sdk")
-- sdk.lua 优先用激活 SDK 的权威 mockHMAPDEV_SDK_LUA否则回退项目内副本
-- 避免拿一份过期的 sdk.lua 调试出“本地能跑、内核报 nil”的假象。
local sdk_path = os.getenv("HMAPDEV_SDK_LUA")
if sdk_path and sdk_path ~= "" then
sdk = dofile(sdk_path)
else
sdk = require("sdk")
end
local ok, plugin = pcall(dofile, "main.lua")
if not ok then
print("[debug] ERROR loading main.lua: " .. tostring(plugin))
@ -127,6 +134,16 @@ func debugLua(dir, sdkPath, luaPath string) {
fmt.Println("warning: sdk.lua not found, debug SDK mock will not be available")
}
// 优先用激活 SDK 里的权威 mock避免调试用的是项目里可能过期的副本。
env := os.Environ()
if root := tryActiveSDKRoot(); root != "" {
canonical := filepath.Join(root, "sdk", "lua", "sdk.lua")
if _, err := os.Stat(canonical); err == nil {
env = append(env, "HMAPDEV_SDK_LUA="+canonical)
fmt.Printf("[debug] SDK mock: %s\n", canonical)
}
}
// write temporary debug script
debugScript := filepath.Join(dir, "_debug.lua")
if err := os.WriteFile(debugScript, []byte(tmplLuaDebug), 0644); err != nil {
@ -137,6 +154,7 @@ func debugLua(dir, sdkPath, luaPath string) {
cmd := exec.Command(luaBin, filepath.Base(debugScript))
cmd.Dir = dir
cmd.Env = env
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

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.
@ -152,13 +164,22 @@ func cmdInit(args []string) {
// Detect SDK info for Go plugin go.mod.
// 生成的 go.mod 除 require 外还写一条指向本机 SDK 的 replace
// 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)。
if !isLua {
if isLua {
// Lua 插件也要记录它按哪版 SDK 语义编写Lua `sdk.*` 是公开契约,
// 与内核能力版本挂钩;不写版本就只能靠“调用时才发现是 nil”。
if root := tryActiveSDKRoot(); root != "" {
data.Plg.SDK = normalizeSDKVersion(readMetaVersion(root))
}
} else {
sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
data.ModulePath = name
data.GoVersion = goVer
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 {
@ -172,7 +193,14 @@ func cmdInit(args []string) {
// Lua plugins get main.lua + sdk.lua; Go plugins get plugin.go only
if isLua {
writeTemplate(filepath.Join(dir, "main.lua"), tmplMainLua, data)
writeTemplate(filepath.Join(dir, "sdk.lua"), tmplSDKLua, data)
// sdk.lua 是给 `lua main.lua` 离线测试用的 mock单一事实源在 SDK 仓的
// sdk/lua/sdk.lua优先从当前激活的 SDK 拷,拷不到才回退内嵌模板。
if !copyCanonicalLuaSDK(dir) {
if err := os.WriteFile(filepath.Join(dir, "sdk.lua"), []byte(fallbackLuaSDK), 0644); err != nil {
fmt.Printf("error: write sdk.lua: %v\n", err)
os.Exit(1)
}
}
} else {
writeTemplate(filepath.Join(dir, "plugin.go"), tmplPluginGo, data)
}
@ -195,6 +223,43 @@ func cmdInit(args []string) {
fmt.Printf(" cd %s && hmapdev build\n", dir)
}
// activeSDKRoot 返回当前激活 SDK 的根目录,未安装/未激活则报错退出。
//
// 与 activeSDKRootfatal 版)区别:这里只探测,不退出。
// Lua 插件的 mock 是“锦上添花”,没装 SDK 不应该阻断 init。
func tryActiveSDKRoot() string {
store := sdkStore()
current := resolveCurrentVersion(store)
if current == "" {
return ""
}
root := sdkVersionDir(current)
if _, err := os.Stat(root); err != nil {
return ""
}
return root
}
// copyCanonicalLuaSDK 把激活 SDK 的 sdk/lua/sdk.lua 拷进新项目。
// 三份 sdk.lua内核内嵌 / 工具链模板 / 项目副本)各自漂移是本工具链的历史债,
// 单一事实源在 SDK 仓,工具链只负责搬运。返回是否成功。
func copyCanonicalLuaSDK(dir string) bool {
root := tryActiveSDKRoot()
if root == "" {
return false
}
src := filepath.Join(root, "sdk", "lua", "sdk.lua")
data, err := os.ReadFile(src)
if err != nil {
return false
}
if err := os.WriteFile(filepath.Join(dir, "sdk.lua"), data, 0644); err != nil {
return false
}
fmt.Printf(" sdk.lua <- %s\n", src)
return true
}
// detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version.
func detectSDKInfo() (modulePath, goVersion, sdkPath, sdkVersion string) {
root := activeSDKRoot()

View File

@ -58,6 +58,27 @@ func cmdSDK(args []string) {
sdkHelp()
return
}
// install --from <本地目录> [version]:用本地 SDK 源码装一个版本并激活。
if args[0] == "install" {
from := ""
rest := []string{}
for i := 1; i < len(args); i++ {
if args[i] == "--from" && i+1 < len(args) {
from = args[i+1]
i++
continue
}
rest = append(rest, args[i])
}
if from != "" {
version := ""
if len(rest) > 0 && rest[0] != "latest" {
version = rest[0]
}
cmdSDKInstallFromDir(from, version)
return
}
}
switch args[0] {
case "list":
cmdSDKList()
@ -100,7 +121,8 @@ Commands:
Examples:
hmapdev sdk install v0.7.1
hmapdev sdk install latest
hmapdev sdk use v0.7.1
hmapdev sdk install v0.7.1
hmapdev sdk install --from /path/to/homeagent-sdk # 用本地源码SDK 开发时用sdk use v0.7.1
`)
}
@ -144,6 +166,51 @@ func cmdSDKList() {
}
}
// cmdSDKInstallFromDir 从**本地 SDK 源码目录**安装一个版本。
//
// 为什么需要它:`install` 只能从 Release 归档下载,而 SDK 开发时的新能力
// (例如 `InjectOptions.Priority` 这类 proc 桥要透传的字段)往往还没发版 ——
// 此时生成出来的插件工程会因为"引用的 SDK 还没有该字段"直接编译失败。
// 有 --from 才能"用本地源码当这个版本的 SDK",边改 SDK 边验证模板工程。
func cmdSDKInstallFromDir(src, version string) {
store := sdkStore()
if err := os.MkdirAll(store, 0755); err != nil {
fmt.Printf("error: create SDK store %s: %v\n", store, err)
os.Exit(1)
}
if version == "" {
version = readMetaVersion(src)
}
if version == "" {
fmt.Printf("error: cannot determine version from %s/meta/meta.go\n", src)
os.Exit(1)
}
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if _, err := os.Stat(filepath.Join(src, "go.mod")); err != nil {
fmt.Printf("error: %s 看起来不是 SDK 源码目录(缺 go.mod\n", src)
os.Exit(1)
}
dest := sdkVersionDir(version)
_ = os.RemoveAll(dest)
if err := copyDir(src, dest); err != nil {
fmt.Printf("error: copy %s -> %s: %v\n", src, dest, err)
os.Exit(1)
}
// 源码目录里的开发产物不该带进 store。
for _, junk := range []string{".git", "dist", "build"} {
_ = os.RemoveAll(filepath.Join(dest, junk))
}
fmt.Printf("Installed SDK %s from %s\n", version, src)
fmt.Printf(" %s\n", dest)
if err := os.WriteFile(filepath.Join(store, "current"), []byte(version), 0644); err != nil {
fmt.Printf("error: activate %s: %v\n", version, err)
os.Exit(1)
}
fmt.Printf("Activated SDK %s\n", version)
}
// cmdSDKInstall downloads and installs an SDK version from Release archive.
func cmdSDKInstall(version string) {
store := sdkStore()
@ -520,5 +587,3 @@ func readMetaVersion(sdkRoot string) string {
}
return "0.0.0"
}

35
tools/hmapdev/lua_test.go Normal file
View File

@ -0,0 +1,35 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// TestCheckLuaSyntax 钉住 Lua 打包前的语法预检:语法错必须被拒。
// 没有 lua/luac 的构建机跳过(预检按设计降级为警告)。
func TestCheckLuaSyntax(t *testing.T) {
if _, err := exec.LookPath("luac"); err != nil {
if _, err := exec.LookPath("lua"); err != nil {
t.Skip("no lua/luac in PATH")
}
}
dir := t.TempDir()
good := filepath.Join(dir, "good.lua")
if err := os.WriteFile(good, []byte("local x = 1\nreturn x\n"), 0644); err != nil {
t.Fatal(err)
}
if err := checkLuaSyntax(good); err != nil {
t.Fatalf("valid Lua rejected: %v", err)
}
bad := filepath.Join(dir, "bad.lua")
if err := os.WriteFile(bad, []byte("function broken(\n"), 0644); err != nil {
t.Fatal(err)
}
if err := checkLuaSyntax(bad); err == nil {
t.Fatal("invalid Lua accepted; syntax check is not effective")
}
}

View File

@ -2,7 +2,11 @@ package main
import (
"fmt"
"io"
"os"
"runtime"
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
func main() {
@ -21,15 +25,42 @@ func main() {
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

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

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

@ -1,5 +1,7 @@
package main
import _ "embed"
// tmplPlgJSON is the plg.json template
const tmplPlgJSON = `{
"name": "{{.Plg.Name}}",
@ -9,6 +11,7 @@ const tmplPlgJSON = `{
"description": "{{.Plg.Description}}",
"author": "{{.Plg.Author}}",
"entry": "{{.Plg.Entry}}",
"sdk": "{{.Plg.SDK}}",
"tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}],
"targets": "{{.Plg.Targets}}"
}
@ -48,6 +51,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
DisplayName: "示例配置", Description: "An example configuration key",
Category: "{{.Plg.Name}}",
})
// ---- 通道channel两个方向是分开的两件事 ----
//
// 入站 inputch ——「谁会往这个通道注入输入」。
// 凡是用 s.InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名,
// 都要在这里登记inputch 是内核最基本的**输入路由单位**,只有登记过的通道
// 才能被「划给驻留子resident sub-agent没登记就划分会失败inputch 未注册)。
// 只登记出站通道时内核会兜底登记同名 inputch **并打告警**(兼容老插件)。
chName := p.name
_ = s.RegisterInputChannel(chName, sdk.ChannelDef{NoMemory: true})
// 出站 output ——「output_send__<name> 的回复发给谁」。
// handler 收到 mappayload(string) / type(string) / meta(string|optional)。
_ = s.RegisterOutputChannel(chName, sdk.CapText, "示例通道(回复由此返回)",
sdk.ChannelDef{NoMemory: true}, func(args map[string]interface{}) (interface{}, error) {
return map[string]interface{}{"status": "ok"}, nil
})
tp := p.name + "_"
s.RegisterTool(tp+"hello", sdk.ToolDef{
Name: tp + "hello",
@ -74,79 +93,28 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
}
`
const tmplSDKLua = `-- HomeAgent Lua Plugin SDK (standalone mock)
sdk = {}
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
function sdk.get_setting(key) return nil end
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
sdk.memory = {}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
function sdk.memory.commit(triples) return nil end
function sdk.memory.introspect() return {} end
function sdk.memory.merge(source, target) return 0 end
function sdk.memory.purge(criteria, hard) return 0 end
sdk.doc = {}
function sdk.doc.query(text, top_k) return {} end
function sdk.doc.insert(doc) return nil end
function sdk.doc.remove(id) return nil end
function sdk.doc.stats() return {} end
sdk.knowledge = {}
function sdk.knowledge.search(query, limit) return {} end
function sdk.knowledge.add(tag, content) return nil end
function sdk.knowledge.list() return {} end
sdk.text_memory = {}
function sdk.text_memory.append(evt) return nil end
sdk.llm = {}
function sdk.llm.list_sources() return {} end
function sdk.llm.set_source(name) return nil end
function sdk.llm.current_source() return nil end
sdk.social = {}
function sdk.social.get_person(name) return {} end
function sdk.social.get_network(name, depth) return {} end
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
function sdk.social.get_relations(name) return {} end
function sdk.social.list_persons() return {} end
sdk.settings = {}
function sdk.settings.get_core(key) return nil end
function sdk.settings.set_core(key, value) return nil end
function sdk.settings.list_core(prefix) return {} end
function sdk.settings.get_plugin(plugin, key) return nil end
function sdk.settings.set_plugin(plugin, key, value) return nil end
function sdk.settings.list_plugin(plugin, prefix) return {} end
function sdk.settings.list(prefix) return {} end
function sdk.settings.register_def(def) return nil end
function sdk.settings.defs(prefix) return {} end
function sdk.settings.dump() return {} end
function sdk.settings.plugins() return {} end
sdk.json = {}
function sdk.json.encode(val)
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
elseif type(val) == "table" then local parts, i = {}, 1
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
return "{" .. table.concat(parts, ",") .. "}" end
return "null"
end
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
sdk.http = {}
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
return sdk
`
// fallbackLuaSDK 是 sdk.lua 的内嵌回退副本(单一事实源为 SDK 仓 sdk/lua/sdk.lua
//
// 为什么不再内联一份手写 mock三份 sdk.lua内核内嵌 / 工具链模板 / 项目副本)
// 各自漂移过一次结果就是“mock 有、内核没有”或反过来。改为从 assets/sdk.lua
// 内嵌 + 由 SDK 仓同步脚本搬运,并配契约测试守住。
//
//go:embed assets/sdk.lua
var fallbackLuaSDK string
const tmplMainLua = `-- {{.Plg.Name}} plugin
local plugin = { name = "{{.Plg.Name}}" }
function plugin.start(sdk)
sdk.log("info", "{{.Plg.Name}} starting...")
-- 通道:入站与出站分开登记。
-- 入站 inputch凡是用 sdk.inject_text/sdk.inject_interrupt(source, "<name>", ...) 注入的通道名
-- 都要登记;只有登记过的通道才能被「划给驻留子」(没登记会报 inputch 未注册)。
sdk.register_input_channel("{{.Plg.Name}}", { no_memory = true })
-- 出站 outputoutput_send__<name> 的回复由 handler 处理
sdk.register_output_channel("{{.Plg.Name}}", 1, "示例通道(回复由此返回)", { no_memory = true },
function(args) return { status = "ok" } end)
sdk.register_tool("{{.Plg.Name}}_hello", {
description = "A hello world tool",
parameters = { type = "object", properties = {} }
@ -514,6 +482,21 @@ const tmplReadme = `# {{.Plg.Name}}
hmapdev build
` + "```" + `
## Channels
入站与出站是分开登记的两件事:
| 方向 | API | 用途 |
|---|---|---|
| 入站 inputch | RegisterInputChannel(name, def) | 声明「谁会往这个通道注入输入」。**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名都要登记** |
| 出站 output | RegisterOutputChannel(name, caps, desc, def, handler) | 声明 output_send__<name> 的回复发给谁handler 收到 {payload,type,meta} |
defChannelDef描述该通道在记忆计算层的行为NoMemory: true = 该通道输入不进记忆;
Cleaner = 计算层清洗后再向量化/提关键词(原文不改)。
> 只登记出站通道、却用同名通道注入输入时,内核会兜底登记同名 inputch 并在日志里告警。
> 兜底只为兼容老插件 —— 请显式登记,让「这是入站通道」成为插件的明确意图。
## Install
Upload the .hmap file through the Plugin Manager API.

View File

@ -287,6 +287,10 @@ func applyInjectOpts(args map[string]interface{}, opts sdk.InjectOptions) {
if opts.CleanerName != "" {
args["cleaner_name"] = opts.CleanerName
}
// priority 只对中断注入有意义(排队注入没有级别)。
if opts.Priority != "" {
args["priority"] = opts.Priority
}
}
// ---- 全局状态 ----

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