15 Commits

Author SHA1 Message Date
5af2a86816 docs(sdk): 补全自动重启的真实约束(退避 / 上限 / 非无感)
原文只说「插件崩溃时平台自动拉起,保障服务可用性」,读起来像
无感瞬时恢复。实测与源码都不是这样:

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

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

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

## 顺带改准一处混淆

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 用法

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

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

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

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

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

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

## 验证

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

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

## 写法

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

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

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

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

## 验证

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 为什么

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

  plugindev_init → plugindev_build → plugin_install(path) → plgreload

## 工具面(5 个)

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

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

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

## 现场验证

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

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

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

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

内核侧**不做**净化/反解:通道名是插件自己的声明,就该由插件遵守契约。
2026-09-13 13:10:40 +08:00
39 changed files with 3080 additions and 178 deletions

View File

@ -487,6 +487,18 @@ enabled := sdk.AutoRestart()
插件崩溃时平台自动拉起,保障服务可用性。
重启是**有节制的**,默认参数(内核 `internal/plugin/registry.go`
| 参数 | 值 | 含义 |
|---|---|---|
| `procRestartBackoff` | `1s` | 第 n 次重启前等 `n × 1s`(线性退避,非立即拉起) |
| `procMaxRestarts` | `3` | 窗口内允许的重启次数上限 |
| `procCrashWindow` | `5min` | 窗口内无新崩溃则计数归零 |
即崩溃后的实际序列是 **1s → 2s → 3s**;同一 5 分钟窗口内第 **4** 次崩溃
`n > 3`**不再自动拉起**,交人工介入。这不是「立即无感恢复」——
如果插件需要秒级就位,请自己在 `OnStart` 里做好重连与重建。
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**`-race` 实测 11 处竞态,

View File

@ -443,6 +443,19 @@ enabled := sdk.AutoRestart()
The platform automatically restarts the plugin on crash, ensuring service availability.
Restarts are **rate-limited**. Defaults (kernel `internal/plugin/registry.go`):
| Parameter | Value | Meaning |
|---|---|---|
| `procRestartBackoff` | `1s` | Before restart #n, wait `n × 1s` (linear backoff, not immediate) |
| `procMaxRestarts` | `3` | Max restarts within the window |
| `procCrashWindow` | `5min` | No new crash within the window resets the count |
So the actual sequence is **1s → 2s → 3s**; the **4th** crash in the same 5-minute
window (`n > 3`) is **not** restarted automatically and needs manual intervention.
This is not instant, invisible recovery — if your plugin must be back in seconds,
reconnect and rebuild your own state in `OnStart`.
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
> external connection has been established, and that connection setup usually happens in a
> background goroutine while the kernel reads the flag from another one — which is inherently

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

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

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

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

@ -2,7 +2,7 @@ module deepsearch-plugin
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
@ -18,4 +18,4 @@ require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

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

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

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

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

@ -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
@ -396,7 +439,9 @@ type 枚举: text文字/ voice语音转文字后发送/ image
}
return cleaned
}
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner})
// qq 通道到达的是**中断通知meta**,不是用户正文,不据它召回;
// 真实正文由 qq_get_message 取回后由该工具声明 RecallPolicy=auto 触发召回。
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner, RecallPolicy: sdk.RecallPolicyNone})
// 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化
cleaner := func(output string) string {
@ -416,6 +461,9 @@ type 枚举: text文字/ voice语音转文字后发送/ image
// 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里,
// 长会话下持续挤占 token 预算§13.8)。
ContextPolicy: "prune",
// 正文才是真实内容:取回后用**正文**触发一次召回,
// 而不是用中断通知的 meta 文本去召回(那是无关词)。
RecallPolicy: "auto",
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
@ -441,6 +489,12 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name可用 qq_download_file 工具下载。",
NoMemory: false,
Cleaner: cleaner,
// 与 get_message 同理:返回的是**真实聊天正文**,不只当轮需要,
// 还可能牵出与这些正文相关的长期记忆。故取回后既裁剪(用完不长期占
// L0又据正文召回取进来。不声明 recall 的话就是「记忆里有、但
// 拉回历史消息时不注入」的盲区。
ContextPolicy: "prune",
RecallPolicy: "auto",
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"},
@ -697,6 +751,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 +817,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:
@ -980,6 +1040,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 精确恢复身份,不依赖昵称或用户正文。
@ -1030,13 +1124,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 {
@ -1044,24 +1153,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
}
@ -1081,9 +1193,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 {
@ -1192,7 +1308,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
}
@ -1299,6 +1415,157 @@ 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,
// 中断文本是路由/取正文的指令,不是对话内容,不据它召回。
RecallPolicy: sdk.RecallPolicyNone,
})
}
// 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)
@ -1417,7 +1684,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
}
@ -1446,15 +1715,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
p.startTyping(evt.UserID)
}
if p.sdk != nil {
// NoMemoryHTTP 侧来的中断提示,不是对话内容。
// PriorityQQ 消息是**低级别中断**——既不是时钟那样的实时工作,
// 也不是紧急工作,所以声明 L1完全可等
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{
NoMemory: true,
Priority: sdk.PriorityL1,
})
}
// 合并投递:同一会话同一发送者在 batchWindow 内的连续消息并成一次中断。
p.enqueueInterrupt(evt.MessageType, evt.UserID, evt.GroupID, evt.MessageID, nickname, interrupt, p.isOwner(evt.UserID), highRisk)
w.WriteHeader(http.StatusOK)
}
@ -2552,7 +2815,7 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
// Priority同上QQ 侧一律低级别中断L1
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("文件下载完成: %s保存在 %s", filepath.Base(savePath), savePath),
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1})
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1, RecallPolicy: sdk.RecallPolicyNone})
}
} else {
errMsg = "下载失败,文件可能已过期"

View File

@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
@ -235,3 +236,198 @@ func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
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

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

View File

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

View File

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

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.3.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

@ -54,6 +54,26 @@ func ValidContextPolicy(policy string) bool {
return false
}
// 召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
//
// 与 ContextPolicy **正交**ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
// RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
// 裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
// 默认 auto输入/注入),仅**工具**默认 none工具输出多为噪声按需声明
const (
RecallPolicyNone = "none"
RecallPolicyAuto = "auto"
)
// ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。
func ValidRecallPolicy(policy string) bool {
switch policy {
case "", RecallPolicyNone, RecallPolicyAuto:
return true
}
return false
}
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
//
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
@ -65,6 +85,7 @@ func ValidContextPolicy(policy string) bool {
//
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
// RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto召回
//
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
//
@ -77,7 +98,11 @@ func ValidContextPolicy(policy string) bool {
type InjectOptions struct {
NoMemory bool
ContextPolicy string
CleanerName string
// RecallPolicy 声明此次注入是否据其内容召回相关记忆。
// 空串 = 默认(输入/注入 auto即保持既有「每条输入都召回」的行为
// RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。
RecallPolicy string
CleanerName string
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
//
@ -106,6 +131,7 @@ const (
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none不裁剪
// RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto召回
//
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
// 没有 tag 时既无法整体 marshalfunc 不支持),又会诱使调用方手写字段白名单——
@ -114,6 +140,8 @@ type ChannelDef struct {
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"`
ContextPolicy string `json:"context_policy,omitempty"`
// RecallPolicy 见 InjectOptions.RecallPolicy空串等价 auto保持既有行为
RecallPolicy string `json:"recall_policy,omitempty"`
}
// StageContext provides context for stage handlers.
@ -180,6 +208,10 @@ type ToolDef struct {
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
// RecallPolicy 声明此工具输出是否触发一次记忆召回(注入)。
// ""(默认 none) / RecallPolicyNone / RecallPolicyAuto。
// 默认 none多数工具输出是噪声需要「取回真实内容后据它召回」的工具如 qq_get_message应显式声明 auto。
RecallPolicy string `json:"recall_policy,omitempty"`
}
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
@ -743,8 +775,12 @@ func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock) {
}
}
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)
// SetAutoRestart 设置插件崩溃后内核是否自动重启它
// 默认 true。如果插件有无法恢复的状态如外部连接应设为 false。
//
// 重启是有限度的:线性退避(第 n 次等 n×1s即 1s→2s→3s
// 且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README
// 注意这与「重载」(换 plugin.bin 后重新加载)是两回事。
func (s *PluginSDK) SetAutoRestart(enabled bool) {
s.apiMu.Lock()
s.autoRestart = enabled

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,6 +75,18 @@ 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
}
@ -254,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 {

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

@ -164,7 +164,13 @@ 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
@ -187,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)
}
@ -210,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()

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

@ -1,5 +1,7 @@
package main
import _ "embed"
// tmplPlgJSON is the plg.json template
const tmplPlgJSON = `{
"name": "{{.Plg.Name}}",
@ -91,74 +93,14 @@ 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}}" }