mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-21 01:18:02 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db207fd9b7 | |||
| 953fbb2f50 | |||
| 7ef9bc2ad3 | |||
| cfa72df3e9 | |||
| fe1c4cdb09 | |||
| a01fe21ab1 | |||
| f09891f054 | |||
| efb396d7b3 | |||
| 4852d70d77 | |||
| 63b6eafaf0 | |||
| 21221f20c5 | |||
| 11303e3ee4 | |||
| f4f6968987 |
52
README.md
52
README.md
@ -4,7 +4,7 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|
||||
|
||||
## 版本与兼容性
|
||||
|
||||
当前:**SDK 1.2.0**(需内核 **1.2.0+**)。
|
||||
当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
|
||||
|
||||
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
|
||||
|
||||
@ -12,11 +12,15 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|
||||
|---|---|
|
||||
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||
| 1.2.0 起 | 1.2.0 |
|
||||
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||
| 1.3.0 起 | **1.3.0** |
|
||||
|
||||
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
|
||||
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
|
||||
|
||||
因此 **SDK 仓在一个中版本里只发一次**(`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
|
||||
(2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
|
||||
|
||||
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
|
||||
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
|
||||
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
|
||||
@ -28,6 +32,40 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|
||||
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
|
||||
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
|
||||
|
||||
## 1.3.0 新增:注入优先级与动态输出通道
|
||||
|
||||
### 注入优先级(`InjectOptions.Priority`)
|
||||
|
||||
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
|
||||
|
||||
| 级别 | 常量 | 谁用 |
|
||||
|---|---|---|
|
||||
| L1–L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选(L1 最低) |
|
||||
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
|
||||
|
||||
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
|
||||
⇒ 存量插件不需要改一行、也不需要重编。
|
||||
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
|
||||
(现场保存/恢复对插件透明)。
|
||||
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
|
||||
|
||||
### 动态输出通道(`UnregisterOutputChannel`)
|
||||
|
||||
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
|
||||
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
|
||||
|
||||
1.3.0 起成对提供:
|
||||
|
||||
| API | 用途 |
|
||||
|---|---|
|
||||
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
|
||||
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
|
||||
|
||||
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
|
||||
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`,
|
||||
违规会让**整条 LLM 请求**被上游拒绝(2026-09-13 生产事故:`device/<id>` 导致全量对话 403)。
|
||||
派生规则与约束见下方「输出通道」一节。
|
||||
|
||||
## 注入行为与上下文裁剪(1.2.0)
|
||||
|
||||
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
|
||||
@ -92,7 +130,7 @@ type Plugin interface {
|
||||
|------|------|------|
|
||||
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
||||
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) |
|
||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码 |
|
||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
|
||||
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
||||
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
||||
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
||||
@ -139,6 +177,14 @@ sdk.RegisterInputChannel("qq", ChannelDef{
|
||||
|
||||
### 输出通道
|
||||
|
||||
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
|
||||
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
|
||||
> "这个工具不可用",而是**整条请求被 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
|
||||
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`(13 字符)的余量。
|
||||
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
|
||||
> 内核**不会**替你净化。
|
||||
|
||||
```go
|
||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
|
||||
```
|
||||
|
||||
24
README_EN.md
24
README_EN.md
@ -4,7 +4,7 @@ Plugin development SDK for building intelligent plugins that interact with the H
|
||||
|
||||
## Version and Compatibility
|
||||
|
||||
Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
||||
Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
|
||||
|
||||
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
|
||||
|
||||
@ -12,13 +12,33 @@ Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|
||||
|---|---|
|
||||
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
|
||||
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
|
||||
| 1.2.0 onward | 1.2.0 |
|
||||
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
|
||||
| 1.3.0 onward | **1.3.0** |
|
||||
|
||||
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
|
||||
public interface, so the SDK version has no reason to move with it — otherwise you would either be
|
||||
forced to chase releases or suspect your version is stale, when not one character of the interface
|
||||
has changed.
|
||||
|
||||
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
|
||||
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
|
||||
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
|
||||
|
||||
## New in 1.3.0: Injection Priority and Dynamic Output Channels
|
||||
|
||||
- **`InjectOptions.Priority` / `PriorityL1`–`PriorityL4`** — a plugin declares the interrupt level of
|
||||
its own injection; the kernel schedules L1–L4, where **L4 is reserved for the kernel and
|
||||
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
|
||||
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
|
||||
Queued input has no level: anything can jump ahead of it.
|
||||
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
|
||||
channels that die with their resource (one channel per remote device) can now be unregistered;
|
||||
previously they lingered and the model kept "successfully" sending into a dead channel.
|
||||
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
|
||||
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
|
||||
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
|
||||
external IDs — never use the raw ID.
|
||||
|
||||
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
|
||||
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
|
||||
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
|
||||
|
||||
47
example/a2a/README.md
Normal file
47
example/a2a/README.md
Normal 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
|
||||
```
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "a2a",
|
||||
"name_zh": "A2A 代理通信",
|
||||
"name_en": "A2A Agent Communication",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
|
||||
51
example/acp/README.md
Normal file
51
example/acp/README.md
Normal 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
|
||||
```
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "acp",
|
||||
"name_zh": "ACP 代理通信",
|
||||
"name_en": "ACP Agent Client Protocol",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
|
||||
@ -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
43
example/bili/README.md
Normal 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
66
example/browser/README.md
Normal file
@ -0,0 +1,66 @@
|
||||
# browser · 统一浏览器
|
||||
|
||||
一个插件覆盖三种"访问网页"的能力,从最轻到最重。**按需选层**是这个插件的重点 ——
|
||||
绝大多数抓取用 HTTP 就够,不该为了一句话启动 Chromium。
|
||||
|
||||
## 三种能力层
|
||||
|
||||
| 层 | 工具 | 何时用 |
|
||||
|---|---|---|
|
||||
| **搜索** | `browser_search` | 要的是"找到哪些页面",不是页面本身 |
|
||||
| **quick(纯 HTTP)** | `browser_fetch`(`mode=quick`) | 静态页、API、能直接拿到 HTML |
|
||||
| **normal(无头渲染)** | `browser_render` / `browser_fetch`(`mode=render`) | JS 渲染的页面,HTTP 拿不到内容 |
|
||||
| **interactive(CDP)** | `browser_start` + `navigate`/`click`/`type`/`scroll`/`html`/`screenshot` | 需要交互:登录、点按、翻页 |
|
||||
|
||||
`browser_fetch` 的 `mode`:
|
||||
|
||||
- `auto`(默认):先试 HTTP,**遇 403/429 才降级**用 Chromium 渲染
|
||||
- `render`:强制 Chromium
|
||||
- `quick`:纯 HTTP,不降级
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 说明 |
|
||||
|---|---|
|
||||
| `browser_search` | 网页搜索 |
|
||||
| `browser_fetch` | 抓取 URL 内容,三种 mode 见上 |
|
||||
| `browser_render` | 无头 Chromium 渲染并提取文本(normal) |
|
||||
| `browser_start` | 启动交互式浏览器会话(CDP) |
|
||||
| `browser_navigate` | 导航到指定 URL |
|
||||
| `browser_click` | 点击元素 |
|
||||
| `browser_type` | 输入文本 |
|
||||
| `browser_scroll` | 滚动页面 |
|
||||
| `browser_html` | 取当前页 HTML |
|
||||
| `browser_screenshot` | 截图 |
|
||||
| `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
|
||||
```
|
||||
@ -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.
|
||||
|
||||
@ -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 => ../../
|
||||
|
||||
@ -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
49
example/editdoc/README.md
Normal 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
44
example/files/README.md
Normal 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
|
||||
```
|
||||
@ -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
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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_sync:Lua 中不可用。
|
||||
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
43
example/memo/README.md
Normal 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
30
example/music/README.md
Normal 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
40
example/ocr/README.md
Normal 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
|
||||
```
|
||||
47
example/plugindev/README.md
Normal file
47
example/plugindev/README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# plugindev — 插件开发工具链(Agent 可调用)
|
||||
|
||||
把 SDK 的 `hmapdev` 封装成插件,让 **Agent 自己**走完「新建插件 → 构建 → 安装」全流程,
|
||||
不需要人来敲命令行:
|
||||
|
||||
```
|
||||
plugindev_init 生成工程骨架(等价 hmapdev init <name> [--lua])
|
||||
↓ 改 plugin.go
|
||||
plugindev_build 构建打包(等价在该目录 hmapdev build)→ dist/*.hmap
|
||||
↓
|
||||
plugin_install 安装(用 path 指向刚构建出的 .hmap,overwrite=true 表示原地更新)
|
||||
↓
|
||||
plgreload 重载生效
|
||||
```
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 参数 | 说明 |
|
||||
|---|---|---|
|
||||
| `plugindev_status` | — | hmapdev 是否可用/版本/当前 SDK 版本与路径/工作区;**排查"为什么不能构建"先用它** |
|
||||
| `plugindev_init` | `name`、`lang`(go/lua)、`dir` | 生成工程骨架;插件名必须 `[a-zA-Z0-9_-]{1,64}` |
|
||||
| `plugindev_build` | `dir`、`target` | 在工程目录构建打包;产物路径会在返回里给出 |
|
||||
| `plugindev_sdk` | `action`、`version`、`from` | SDK 版本管理(list/current/path/latest/install/use);`from` 可指向本地 SDK 源码 |
|
||||
| `plugindev_projects` | — | 列出工作区里已有工程与产物 |
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `hmapdev_path` | 自动查找 | 依次尝试:本配置项 → PATH → `/usr/local/bin/hmapdev` → `/root/go/bin/hmapdev` |
|
||||
| `workspace_dir` | `<data_dir>/plugindev` | `plugindev_init` 生成工程的默认目录 |
|
||||
| `build_timeout_sec` | 600 | 单次 hmapdev 调用超时 |
|
||||
|
||||
## 前置:装 hmapdev
|
||||
|
||||
```bash
|
||||
cd <sdk-repo>/tools/hmapdev && go build -buildvcs=false -o /usr/local/bin/hmapdev .
|
||||
hmapdev version
|
||||
```
|
||||
|
||||
## 安全边界(都在实现里,不只写在文档里)
|
||||
|
||||
- 只 exec **hmapdev 一个可执行文件**,不接受任意命令、不做 shell 拼接;
|
||||
- `plugindev_build` 只接受含 `plg.json` 的目录("看起来是插件工程"才构建),
|
||||
避免把这个工具变成对任意目录跑构建;
|
||||
- 子进程全部带超时,输出**截断**后才返回(构建日志动辄几百 KB,直接回灌会撑爆模型上下文);
|
||||
- 工程名约束与内核/上游对"进工具名的标识符"的规则一致(`[a-zA-Z0-9_-]{1,64}`)。
|
||||
7
example/plugindev/go.mod
Normal file
7
example/plugindev/go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module plugindev
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
11
example/plugindev/main.go
Normal file
11
example/plugindev/main.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !windows || !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return NewPluginFactory(name, config)
|
||||
}
|
||||
19
example/plugindev/plg.json
Normal file
19
example/plugindev/plg.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "plugindev",
|
||||
"name_zh": "插件开发工具链",
|
||||
"name_en": "Plugin Dev Toolchain",
|
||||
"version": "1.0.0",
|
||||
"description": "把 hmapdev 工具链封装成 Agent 可调用的工具:脚手架生成插件工程、构建打包 .hmap、管理 SDK 版本。配合 plugin_install 即可让 Agent 自己做完「新建插件 → 构建 → 安装」全流程。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": [
|
||||
"plugindev",
|
||||
"toolchain",
|
||||
"developer"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": true,
|
||||
"replaces": {},
|
||||
"source_dirs": []
|
||||
}
|
||||
455
example/plugindev/plugin.go
Normal file
455
example/plugindev/plugin.go
Normal file
@ -0,0 +1,455 @@
|
||||
package main
|
||||
|
||||
// plugindev:把 SDK 的 hmapdev 工具链封装成 Agent 可调用的插件。
|
||||
//
|
||||
// 为什么需要它:hmapdev 是"给人和 CI 用"的命令行工具。做成插件后,Agent 能自己:
|
||||
// plugindev_init(脚手架)→ plugindev_build(构建出 .hmap)→ plugin_install(安装)→ plgreload
|
||||
// 也就是"让 Agent 自己写/改/装插件"这条链不需要人来敲命令。
|
||||
//
|
||||
// 安全边界(都在实现里落实,不只写在描述里):
|
||||
// - 只有 **hmapdev 一个可执行文件**会被 exec(不接受任意命令/参数拼接);
|
||||
// - `plugindev_build` 只接受"看起来是插件工程"的目录(含 plg.json),
|
||||
// 避免把一个 `hmapdev build` 变成对任意目录的操作;
|
||||
// - `plugindev_init` 生成的工程名必须满足 `[a-zA-Z0-9_-]{1,64}`(与 LLM 函数名
|
||||
// 同一套约束 —— 插件名会进 `output_send__<通道>` 之类的工具名);
|
||||
// - 所有子进程都有超时,输出截断后再返回(防止把几十 MB 构建日志灌进模型上下文)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBuildTimeout = 10 * time.Minute
|
||||
maxOutputChars = 6000
|
||||
)
|
||||
|
||||
// namePattern 与内核/上游对"会进工具名的标识符"的约束一致。
|
||||
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
|
||||
hmapdev string // 解析到的 hmapdev 可执行文件路径
|
||||
workspace string // 默认工作区(生成的工程落在这里)
|
||||
timeout time.Duration // 单次 hmapdev 调用的超时
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.SetAutoRestart(true)
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "hmapdev_path", Type: "string", DisplayName: "hmapdev 路径",
|
||||
Description: "插件开发工具链可执行文件路径。留空则按 PATH → /usr/local/bin/hmapdev → /root/go/bin/hmapdev 查找",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "workspace_dir", Type: "string", DisplayName: "工程工作区",
|
||||
Description: "plugindev_init 生成工程的默认目录。留空则用 <data_dir>/plugindev",
|
||||
Category: p.name,
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "build_timeout_sec", Default: 600, Type: "int", DisplayName: "构建超时(秒)",
|
||||
Description: "单次 hmapdev 调用的超时上限",
|
||||
Category: p.name,
|
||||
})
|
||||
|
||||
p.hmapdev = p.resolveHmapdev()
|
||||
p.workspace = p.resolveWorkspace()
|
||||
p.timeout = defaultBuildTimeout
|
||||
if v, _ := s.Settings().Get("build_timeout_sec"); v != nil {
|
||||
if n, ok := toInt(v); ok && n > 0 {
|
||||
p.timeout = time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
s.RegisterTool("plugindev_status", sdk.ToolDef{
|
||||
Name: "plugindev_status",
|
||||
Description: "查看插件开发工具链状态:hmapdev 是否可用、版本、当前 SDK 版本与路径、工程工作区目录。排查\"为什么不能构建插件\"时先用它。",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, p.handleStatus)
|
||||
|
||||
s.RegisterTool("plugindev_init", sdk.ToolDef{
|
||||
Name: "plugindev_init",
|
||||
Description: "生成一个新的插件工程骨架(等价于 `hmapdev init <name> [--lua]`)。生成后在返回的目录里改 plugin.go,再用 plugindev_build 构建。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "插件名(也是工程目录名):只允许字母数字下划线短横,长度 1-64。例:my_plugin",
|
||||
},
|
||||
"lang": map[string]interface{}{
|
||||
"type": "string", "description": "go(默认)或 lua",
|
||||
},
|
||||
"dir": map[string]interface{}{
|
||||
"type": "string", "description": "在哪个目录下生成(默认工作区)。必须是已存在的目录",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
}, p.handleInit)
|
||||
|
||||
s.RegisterTool("plugindev_build", sdk.ToolDef{
|
||||
Name: "plugindev_build",
|
||||
Description: "构建并打包一个插件工程(等价于在该工程目录里执行 `hmapdev build [target]`),产物是 dist/*.hmap。构建成功后用 plugin_install 安装(本地路径)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"dir": map[string]interface{}{
|
||||
"type": "string", "description": "插件工程目录(必须含 plg.json)",
|
||||
},
|
||||
"target": map[string]interface{}{
|
||||
"type": "string", "description": "构建目标,留空 = native(当前平台)。例:linux/amd64",
|
||||
},
|
||||
},
|
||||
"required": []string{"dir"},
|
||||
},
|
||||
}, p.handleBuild)
|
||||
|
||||
s.RegisterTool("plugindev_sdk", sdk.ToolDef{
|
||||
Name: "plugindev_sdk",
|
||||
Description: "管理插件 SDK 版本(hmapdev sdk 子命令):list 列出已安装、current 当前版本、path 当前路径、latest 远端最新、install 安装某版本(可用 from 指定本地源码目录)、use 切换版本。构建插件报\"SDK 缺少某能力\"时用它升级 SDK。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string", "description": "list | current | path | latest | install | use",
|
||||
},
|
||||
"version": map[string]interface{}{
|
||||
"type": "string", "description": "install/use 的版本号,如 v1.3.0",
|
||||
},
|
||||
"from": map[string]interface{}{
|
||||
"type": "string", "description": "install 时用本地 SDK 源码目录(开发中的 SDK 用这个)",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
}, p.handleSDK)
|
||||
|
||||
s.RegisterTool("plugindev_projects", sdk.ToolDef{
|
||||
Name: "plugindev_projects",
|
||||
Description: "列出工作区里已有的插件工程(名字、版本、是否已构建出 dist 产物),用于接续之前的开发。",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, p.handleProjects)
|
||||
|
||||
log.Printf("[plugindev] 就绪:hmapdev=%s 工作区=%s", fallback(p.hmapdev, "(未找到)"), p.workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
// ---------------- 工具实现 ----------------
|
||||
|
||||
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||
out := map[string]interface{}{
|
||||
"hmapdev": fallback(p.hmapdev, ""),
|
||||
"workspace": p.workspace,
|
||||
}
|
||||
if p.hmapdev == "" {
|
||||
out["available"] = false
|
||||
out["hint"] = "未找到 hmapdev。请安装:go build -o /usr/local/bin/hmapdev <sdk>/tools/hmapdev"
|
||||
return out, nil
|
||||
}
|
||||
out["available"] = true
|
||||
if txt, err := p.run(nil, ""); err == nil {
|
||||
out["version"] = strings.TrimSpace(txt)
|
||||
} else {
|
||||
out["error"] = err.Error()
|
||||
}
|
||||
if txt, err := p.run([]string{"sdk", "current"}, ""); err == nil {
|
||||
out["sdk_current"] = strings.TrimSpace(txt)
|
||||
}
|
||||
if txt, err := p.run([]string{"sdk", "path"}, ""); err == nil {
|
||||
out["sdk_path"] = strings.TrimSpace(txt)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleInit(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if !namePattern.MatchString(name) {
|
||||
return map[string]interface{}{
|
||||
"error": "插件名只允许 [a-zA-Z0-9_-],长度 1-64(它会进 LLM 工具名,违规会让整条请求被上游拒绝)",
|
||||
}, nil
|
||||
}
|
||||
dir, _ := args["dir"].(string)
|
||||
if dir == "" {
|
||||
dir = p.workspace
|
||||
}
|
||||
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("目录不存在: %s", dir)}, nil
|
||||
}
|
||||
cmd := []string{"init", name}
|
||||
if lang, _ := args["lang"].(string); strings.EqualFold(lang, "lua") {
|
||||
cmd = append(cmd, "--lua")
|
||||
}
|
||||
txt, err := p.run(cmd, dir)
|
||||
res := map[string]interface{}{"output": txt, "project_dir": filepath.Join(dir, name)}
|
||||
if err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleBuild(args map[string]interface{}) (interface{}, error) {
|
||||
dir, _ := args["dir"].(string)
|
||||
if dir == "" {
|
||||
return map[string]interface{}{"error": "dir 不能为空"}, nil
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}, nil
|
||||
}
|
||||
// 只在"插件工程"里构建:必须存在 plg.json。这样这个工具不会变成对任意目录跑构建。
|
||||
manifest := filepath.Join(abs, "plg.json")
|
||||
if _, err := os.Stat(manifest); err != nil {
|
||||
return map[string]interface{}{
|
||||
"error": fmt.Sprintf("%s 不是插件工程(缺 plg.json);用 plugindev_init 先建一个", abs),
|
||||
}, nil
|
||||
}
|
||||
cmd := []string{"build"}
|
||||
if target, _ := args["target"].(string); strings.TrimSpace(target) != "" {
|
||||
cmd = append(cmd, strings.TrimSpace(target))
|
||||
}
|
||||
txt, runErr := p.run(cmd, abs)
|
||||
res := map[string]interface{}{"output": txt, "project_dir": abs}
|
||||
if pkgs := listHmap(filepath.Join(abs, "dist")); len(pkgs) > 0 {
|
||||
res["artifacts"] = pkgs
|
||||
res["next"] = "用 plugin_install 安装本地产物(path 指向上面 artifacts 里的 .hmap),然后 plgreload"
|
||||
}
|
||||
if runErr != nil {
|
||||
res["error"] = runErr.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleSDK(args map[string]interface{}) (interface{}, error) {
|
||||
action, _ := args["action"].(string)
|
||||
action = strings.TrimSpace(action)
|
||||
switch action {
|
||||
case "list", "current", "path", "latest":
|
||||
txt, err := p.run([]string{"sdk", action}, "")
|
||||
res := map[string]interface{}{"output": txt}
|
||||
if err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
case "install":
|
||||
version, _ := args["version"].(string)
|
||||
from, _ := args["from"].(string)
|
||||
cmd := []string{"sdk", "install"}
|
||||
if strings.TrimSpace(from) != "" {
|
||||
cmd = append(cmd, "--from", strings.TrimSpace(from))
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
cmd = append(cmd, strings.TrimSpace(version))
|
||||
} else if strings.TrimSpace(from) == "" {
|
||||
cmd = append(cmd, "latest")
|
||||
}
|
||||
txt, err := p.run(cmd, "")
|
||||
res := map[string]interface{}{"output": txt}
|
||||
if err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
case "use":
|
||||
version, _ := args["version"].(string)
|
||||
if strings.TrimSpace(version) == "" {
|
||||
return map[string]interface{}{"error": "use 需要 version"}, nil
|
||||
}
|
||||
txt, err := p.run([]string{"sdk", "use", strings.TrimSpace(version)}, "")
|
||||
res := map[string]interface{}{"output": txt}
|
||||
if err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
default:
|
||||
return map[string]interface{}{"error": "action 只能是 list/current/path/latest/install/use"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handleProjects(args map[string]interface{}) (interface{}, error) {
|
||||
entries, err := os.ReadDir(p.workspace)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "workspace": p.workspace}, nil
|
||||
}
|
||||
var out []map[string]interface{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(p.workspace, e.Name())
|
||||
projects := []string{dir}
|
||||
// 有些工程会被生成到子目录里(hmapdev init 支持指定目录),这里只看一层
|
||||
for _, sub := range projects {
|
||||
if _, err := os.Stat(filepath.Join(sub, "plg.json")); err != nil {
|
||||
continue
|
||||
}
|
||||
item := map[string]interface{}{"name": e.Name(), "dir": sub}
|
||||
if v := readPlgVersion(filepath.Join(sub, "plg.json")); v != "" {
|
||||
item["version"] = v
|
||||
}
|
||||
if pkgs := listHmap(filepath.Join(sub, "dist")); len(pkgs) > 0 {
|
||||
item["artifacts"] = pkgs
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
|
||||
return map[string]interface{}{"workspace": p.workspace, "projects": out}, nil
|
||||
}
|
||||
|
||||
// ---------------- 基础设施 ----------------
|
||||
|
||||
// run 执行一次 hmapdev。args 为空时执行 `hmapdev version`(用于探活)。
|
||||
func (p *Plugin) run(args []string, dir string) (string, error) {
|
||||
if p.hmapdev == "" {
|
||||
return "", fmt.Errorf("未找到 hmapdev 可执行文件")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, p.hmapdev, args...)
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
// 继承环境(Go 工具链需要 GOCACHE/GOPATH/PATH 等)。
|
||||
out, err := cmd.CombinedOutput()
|
||||
txt := truncateOutput(string(out))
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return txt, fmt.Errorf("hmapdev %s 超时(%s)", strings.Join(args, " "), p.timeout)
|
||||
}
|
||||
if err != nil {
|
||||
return txt, fmt.Errorf("hmapdev %s 失败: %v", strings.Join(args, " "), err)
|
||||
}
|
||||
return txt, nil
|
||||
}
|
||||
|
||||
// resolveHmapdev 依次尝试:配置项 → PATH → 常见安装位置。
|
||||
func (p *Plugin) resolveHmapdev() string {
|
||||
if v, _ := p.sdk.Settings().Get("hmapdev_path"); v != nil {
|
||||
if s, _ := v.(string); strings.TrimSpace(s) != "" {
|
||||
if _, err := os.Stat(strings.TrimSpace(s)); err == nil {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("hmapdev"); err == nil {
|
||||
return path
|
||||
}
|
||||
for _, cand := range []string{"/usr/local/bin/hmapdev", "/root/go/bin/hmapdev"} {
|
||||
if _, err := os.Stat(cand); err == nil {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveWorkspace:配置项 → <data_dir>/plugindev → ./plugindev。
|
||||
func (p *Plugin) resolveWorkspace() string {
|
||||
if v, _ := p.sdk.Settings().Get("workspace_dir"); v != nil {
|
||||
if s, _ := v.(string); strings.TrimSpace(s) != "" {
|
||||
ws := strings.TrimSpace(s)
|
||||
_ = os.MkdirAll(ws, 0o755)
|
||||
return ws
|
||||
}
|
||||
}
|
||||
if v, err := p.sdk.Settings().GetCore("core.daemon.data_dir"); err == nil {
|
||||
if dd, _ := v.(string); dd != "" {
|
||||
ws := filepath.Join(dd, "plugindev")
|
||||
_ = os.MkdirAll(ws, 0o755)
|
||||
return ws
|
||||
}
|
||||
}
|
||||
ws := "plugindev"
|
||||
_ = os.MkdirAll(ws, 0o755)
|
||||
return ws
|
||||
}
|
||||
|
||||
// truncateOutput 截断长输出:构建日志动辄几百 KB,直接返回会灌爆模型上下文。
|
||||
func truncateOutput(s string) string {
|
||||
if len(s) <= maxOutputChars {
|
||||
return s
|
||||
}
|
||||
head := s[:maxOutputChars/2]
|
||||
tail := s[len(s)-maxOutputChars/2:]
|
||||
return fmt.Sprintf("%s\n…(输出被截断,共 %d 字节)…\n%s", head, len(s), tail)
|
||||
}
|
||||
|
||||
// listHmap 列出目录下的 .hmap 产物(按名字排序,稳定输出)。
|
||||
func listHmap(dir string) []string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".hmap") {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.Join(dir, e.Name()))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func readPlgVersion(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
s := string(b)
|
||||
i := strings.Index(s, `"version"`)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := s[i:]
|
||||
j := strings.Index(rest, ":")
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimSpace(rest[j+1:])
|
||||
rest = strings.TrimPrefix(rest, `"`)
|
||||
if k := strings.Index(rest, `"`); k > 0 {
|
||||
return rest[:k]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case float64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func fallback(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
167
example/qq/README.md
Normal file
167
example/qq/README.md
Normal 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
|
||||
```
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "qq",
|
||||
"name_zh": "QQ消息",
|
||||
"name_en": "qq",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
|
||||
@ -155,6 +155,41 @@ type Plugin struct {
|
||||
msgMu sync.Mutex
|
||||
msgMap map[int64]msgRef // message_id → {peer, time}
|
||||
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
|
||||
|
||||
// 消息合并(debounce):同一会话、同一发送者在 batchWindow 内连续到达的消息
|
||||
// 合并成一次中断。同一个人连发「在吗」「帮我看看」「报错是这个」三条,
|
||||
// 逐条注入会把 Agent 唤醒三次,且前两次拿到的信息都不完整。
|
||||
batchMu sync.Mutex
|
||||
batches map[string]*pendingBatch
|
||||
batchWindow time.Duration // 最后一条到达后再等多久(<=0 = 关闭合并,逐条投递)
|
||||
batchMax time.Duration // 一批最长等多久(防持续刷屏时永远不投)
|
||||
|
||||
// injectHook 仅供测试:非 nil 时 injectInterrupt 走它而不是真实 SDK。
|
||||
injectHook func(text, level string)
|
||||
}
|
||||
|
||||
// pendingBatch 是一批待投递的消息(同一会话、同一发送者、短时间内的连续消息)。
|
||||
type pendingBatch struct {
|
||||
key string
|
||||
isGroup bool
|
||||
userID int64
|
||||
groupID int64
|
||||
nickname string
|
||||
msgIDs []int64
|
||||
single string // 单条时沿用的原文(含所有者/高危前缀),保证 n==1 行为不变
|
||||
owner bool
|
||||
highRisk bool
|
||||
first time.Time
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
// qqBatchKey 同一会话 + 同一发送者 = 一组。私聊按 QQ 号;群聊按 (群号, QQ 号)——
|
||||
// 群里不同人各发各的,不该并成一条。
|
||||
func qqBatchKey(msgType string, groupID, userID int64) string {
|
||||
if msgType == "group" {
|
||||
return fmt.Sprintf("g:%d:%d", groupID, userID)
|
||||
}
|
||||
return fmt.Sprintf("p:%d", userID)
|
||||
}
|
||||
|
||||
type typingState struct {
|
||||
@ -316,6 +351,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录,主机路径。发文件时文件会复制到此目录,NapCat内部映射为/app/files/", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "webhook_token", Default: "", Type: "string", DisplayName: "Webhook 令牌", Description: "NapCat 上报请求头 X-Webhook-Token 校验值,留空则不校验", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录,read_document/video_download 等工具的默认工作目录", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "batch_window_ms", Default: "1500", Type: "int", DisplayName: "消息合并窗口(毫秒)", Description: "同一会话同一发送者的连续消息在该窗口内合并成一次中断并告知共几条;0=关闭合并(逐条投递)", Category: "qq"})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{Key: "batch_max_ms", Default: "30000", Type: "int", DisplayName: "消息合并上限(毫秒)", Description: "一批消息最长等这么久就投递,避免对方持续刷屏时一直不唤醒 Agent", Category: "qq"})
|
||||
|
||||
settings := s.Settings()
|
||||
|
||||
@ -339,12 +376,18 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/")
|
||||
p.agentfsDir = strings.TrimRight(getSetting[string](settings, "agentfs_dir", "/home/newqqagent/agentfs/merged"), "/")
|
||||
p.remoteDir = strings.TrimRight(getSetting[string](settings, "remote_dir", "/home/program/qq-workspace/remote"), "/")
|
||||
p.batchWindow = time.Duration(getSetting[int64](settings, "batch_window_ms", 1500)) * time.Millisecond
|
||||
p.batchMax = time.Duration(getSetting[int64](settings, "batch_max_ms", 30000)) * time.Millisecond
|
||||
if p.batchWindow < 0 {
|
||||
p.batchWindow = 0
|
||||
}
|
||||
os.MkdirAll(p.remoteDir, 0755)
|
||||
|
||||
p.httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// msg_id → peer 映射 + 会话状态(不缓存正文)
|
||||
p.msgMap = make(map[int64]msgRef)
|
||||
p.batches = make(map[string]*pendingBatch)
|
||||
p.chats = make(map[int64]*chatMeta)
|
||||
|
||||
// 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s)
|
||||
@ -697,6 +740,8 @@ func (p *Plugin) Stop() error {
|
||||
}
|
||||
}
|
||||
p.typingMu.Unlock()
|
||||
// 停机前把未到点的合并批次立刻投出去,别把对方的消息吞掉。
|
||||
p.flushAllBatches()
|
||||
if p.srv != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@ -761,6 +806,10 @@ func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallb
|
||||
}
|
||||
case int64:
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return any(int64(val)).(T)
|
||||
case int64:
|
||||
return any(val).(T)
|
||||
case float64:
|
||||
return any(int64(val)).(T)
|
||||
case string:
|
||||
@ -925,6 +974,19 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
||||
if !auth.active || auth.owner {
|
||||
return true, ""
|
||||
}
|
||||
// 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。
|
||||
//
|
||||
// 为什么:输出是 agent 的**主动调用**,发到哪个会话由它自己给的 meta
|
||||
// (group_id / user_id)决定 —— handleChannelOutput 会强制要求该字段存在,
|
||||
// 缺了会得到明确的报错。这里再要求"本轮能精确匹配可信 OneBot 事件"是多余的门,
|
||||
// 而且会把合法发送一起拒掉:现场(被子的中断唤醒的一轮)父带齐 meta 也发不出去,
|
||||
// 报「可信 QQ 会话身份不完整」。
|
||||
// 「只能访问当前会话」这类限制只对**读取类**工具(get_history / mark_read /
|
||||
// get_message)成立 —— 那才是真的不能跨会话读。
|
||||
if name == "output_send__"+p.name {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
currentPeer := auth.userID
|
||||
if auth.isGroup {
|
||||
currentPeer = auth.groupID
|
||||
@ -967,6 +1029,40 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// qqAuthExtraKey 是本轮(帧)QQ 身份挂在 StageContext.Extra 上的键。
|
||||
//
|
||||
// 身份必须**绑帧**,不能只存插件全局:
|
||||
// - 中断会抢占当前轮并把现场压栈(scheduler 的 suspendStack),中断轮收尾时
|
||||
// afterOutput 把插件全局身份清空;随后外层被恢复(resumeTask 复用同一帧、
|
||||
// 不重跑 StageOnInput),若身份只存全局,恢复后的外层就是"无身份"——
|
||||
// beforeToolcall 会在 !auth.active 处直接返回,权限门整体失效。
|
||||
// - 运行中到达的新消息会调 activateAuthContext 改写全局身份,把**正在跑的那一轮**
|
||||
// 换成另一方的身份(换高=越权,换低=误拒)。
|
||||
//
|
||||
// 帧上的 Extra 随帧一起压栈/恢复,正好是"这一轮的身份"。
|
||||
const qqAuthExtraKey = "qq_auth"
|
||||
|
||||
// authOnFrame 读取本帧绑定的身份;ok=false 表示本帧未绑定过 QQ 身份。
|
||||
// 调用方需持有 ctx 的读(或写)锁。
|
||||
func authOnFrame(ctx *sdk.StageContext) (qqAuthContext, bool) {
|
||||
if ctx == nil || ctx.Extra == nil {
|
||||
return qqAuthContext{}, false
|
||||
}
|
||||
auth, ok := ctx.Extra[qqAuthExtraKey].(qqAuthContext)
|
||||
return auth, ok
|
||||
}
|
||||
|
||||
// bindAuthOnFrame 把身份绑到本帧上。调用方需持有 ctx 的写锁。
|
||||
func bindAuthOnFrame(ctx *sdk.StageContext, auth qqAuthContext) {
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
if ctx.Extra == nil {
|
||||
ctx.Extra = make(map[string]interface{})
|
||||
}
|
||||
ctx.Extra[qqAuthExtraKey] = auth
|
||||
}
|
||||
|
||||
// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
|
||||
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
|
||||
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
|
||||
@ -1017,13 +1113,28 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b
|
||||
p.auth.generation = next.generation
|
||||
}
|
||||
|
||||
func messageIDFromInput(raw string) int64 {
|
||||
match := qqMessageIDRe.FindStringSubmatch(raw)
|
||||
if len(match) != 2 {
|
||||
return 0
|
||||
var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`)
|
||||
|
||||
// messageIDsFromInput 取出一段输入里出现的全部 message_id。
|
||||
//
|
||||
// 合并中继的正文是 `(message_id=100,101,102)`:只取第一个会留下同批其余 id 永不清理;
|
||||
// 身份表用 id 做键,泄漏的条目要等 generation 回收才会消失。
|
||||
func messageIDsFromInput(raw string) []int64 {
|
||||
matches := qqMessageIDsRe.FindAllStringSubmatch(raw, -1)
|
||||
ids := make([]int64, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
if len(match) != 2 {
|
||||
continue
|
||||
}
|
||||
for _, part := range strings.Split(match[1], ",") {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
id, _ := strconv.ParseInt(match[1], 10, 64)
|
||||
return id
|
||||
return ids
|
||||
}
|
||||
|
||||
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
||||
@ -1031,24 +1142,27 @@ func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
||||
source, _ := ctx.Extra["input_source"].(string)
|
||||
raw := ctx.RawMessage
|
||||
ctx.RUnlock()
|
||||
|
||||
p.authMu.Lock()
|
||||
defer p.authMu.Unlock()
|
||||
if source != p.name {
|
||||
p.auth = qqAuthContext{}
|
||||
p.resetTurnGuardLocked()
|
||||
return nil
|
||||
}
|
||||
if messageID := messageIDFromInput(raw); messageID != 0 {
|
||||
if auth, ok := p.authByMessageID[messageID]; ok {
|
||||
p.auth = auth
|
||||
delete(p.authByMessageID, messageID)
|
||||
p.resetTurnGuardLocked()
|
||||
return nil
|
||||
// 默认降权:QQ 来源却对不上可信事件时绝不复用上一条消息的身份。
|
||||
next := qqAuthContext{active: source == p.name}
|
||||
ids := messageIDsFromInput(raw)
|
||||
if source == p.name && len(ids) > 0 {
|
||||
if auth, ok := p.authByMessageID[ids[0]]; ok {
|
||||
next = auth
|
||||
for _, id := range ids {
|
||||
delete(p.authByMessageID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份。
|
||||
p.auth = qqAuthContext{active: true}
|
||||
// p.auth 只作为"帧上没绑身份"时的兜底(单测/异常帧),权威副本在帧上。
|
||||
p.auth = next
|
||||
p.resetTurnGuardLocked()
|
||||
p.authMu.Unlock()
|
||||
|
||||
ctx.Lock()
|
||||
bindAuthOnFrame(ctx, next)
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -1068,9 +1182,13 @@ func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) {
|
||||
func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, qqAuthContext) {
|
||||
// 身份以本帧为准(中断恢复后全局身份可能已属于别的轮)。
|
||||
auth, onFrame := authOnFrame(ctx)
|
||||
p.authMu.RLock()
|
||||
auth := p.auth
|
||||
if !onFrame {
|
||||
auth = p.auth
|
||||
}
|
||||
var patterns []string
|
||||
if auth.active && !auth.owner {
|
||||
if auth.isGroup {
|
||||
@ -1179,7 +1297,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
||||
return nil
|
||||
}
|
||||
tc := &ctx.ToolCalls[0]
|
||||
allowed, auth := p.currentToolAllowed(tc.Name)
|
||||
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
|
||||
if !auth.active {
|
||||
return nil
|
||||
}
|
||||
@ -1286,6 +1404,155 @@ func requiresConfirmFriendCommand(cmd string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueInterrupt 把一条已通过策略/@ 检查的消息并入待投批次,并重置 debounce 计时。
|
||||
//
|
||||
// batchWindow<=0 时退回逐条投递(合并前行为)。
|
||||
func (p *Plugin) enqueueInterrupt(msgType string, userID, groupID, messageID int64, nickname, single string, owner, highRisk bool) {
|
||||
if p.sdk == nil && p.injectHook == nil {
|
||||
return
|
||||
}
|
||||
if p.batchWindow <= 0 {
|
||||
p.injectInterrupt(single, p.interruptLevel(owner))
|
||||
return
|
||||
}
|
||||
key := qqBatchKey(msgType, groupID, userID)
|
||||
p.batchMu.Lock()
|
||||
if p.batches == nil {
|
||||
p.batches = make(map[string]*pendingBatch)
|
||||
}
|
||||
b := p.batches[key]
|
||||
if b == nil {
|
||||
b = &pendingBatch{key: key, first: time.Now()}
|
||||
p.batches[key] = b
|
||||
}
|
||||
b.isGroup = msgType == "group"
|
||||
b.userID, b.groupID, b.nickname = userID, groupID, nickname
|
||||
b.msgIDs = append(b.msgIDs, messageID)
|
||||
b.single = single
|
||||
b.owner = b.owner || owner
|
||||
b.highRisk = b.highRisk || highRisk
|
||||
// debounce:每来一条就推迟;但整体不超过 batchMax(否则持续刷屏会一直不投)。
|
||||
delay := p.batchWindow
|
||||
if p.batchMax > 0 {
|
||||
if remain := p.batchMax - time.Since(b.first); remain < delay {
|
||||
delay = remain
|
||||
}
|
||||
}
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
if b.timer != nil {
|
||||
b.timer.Stop()
|
||||
}
|
||||
b.timer = time.AfterFunc(delay, func() { p.flushBatch(key) })
|
||||
p.batchMu.Unlock()
|
||||
}
|
||||
|
||||
// interruptLevel 决定一条 QQ 消息的中断级别。
|
||||
//
|
||||
// - Bot 所有者/管理员的消息 → **L2**(一般提醒);
|
||||
// - 其他人的消息 → L1(后台,完全可等)。
|
||||
//
|
||||
// 为什么不能一律 L1:L1 之间可以随时互相抢占、也可以被任何更高一级打断,
|
||||
// 于是「老板发的话」会被路人的闲聊挤到后面,甚至对方持续刷屏时一直排在队尾。
|
||||
// 为什么也不该给 L3:L3 是时钟/终端那类"需要及时处理"的实时工作,QQ 是异步
|
||||
// 消息,抬到 L3 会反过来打断真正实时的事情。
|
||||
func (p *Plugin) interruptLevel(owner bool) string {
|
||||
if owner {
|
||||
return sdk.PriorityL2
|
||||
}
|
||||
return sdk.PriorityL1
|
||||
}
|
||||
|
||||
// injectInterrupt 投递一条中断提示(NoMemory:HTTP 侧来的不是对话内容)。
|
||||
func (p *Plugin) injectInterrupt(text, level string) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
if level == "" {
|
||||
level = sdk.PriorityL1
|
||||
}
|
||||
if p.injectHook != nil {
|
||||
p.injectHook(text, level)
|
||||
return
|
||||
}
|
||||
if p.sdk == nil {
|
||||
return
|
||||
}
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{
|
||||
NoMemory: true,
|
||||
Priority: level,
|
||||
})
|
||||
}
|
||||
|
||||
// flushBatch 投递一批:n==1 沿用单条原文;n>1 生成「共几条」的合并中断。
|
||||
func (p *Plugin) flushBatch(key string) {
|
||||
p.batchMu.Lock()
|
||||
b := p.batches[key]
|
||||
delete(p.batches, key)
|
||||
p.batchMu.Unlock()
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
text := b.single
|
||||
if len(b.msgIDs) > 1 {
|
||||
text = p.buildBatchInterrupt(b)
|
||||
}
|
||||
// 一批里只要有一条来自 Bot 所有者,整批按 L2 投递(不因混入路人消息而降低)。
|
||||
p.injectInterrupt(text, p.interruptLevel(b.owner))
|
||||
}
|
||||
|
||||
// flushAllBatches 停机前把未到点的批次立刻投出去(best effort)。
|
||||
func (p *Plugin) flushAllBatches() {
|
||||
p.batchMu.Lock()
|
||||
keys := make([]string, 0, len(p.batches))
|
||||
for k := range p.batches {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
p.batchMu.Unlock()
|
||||
for _, k := range keys {
|
||||
p.flushBatch(k)
|
||||
}
|
||||
}
|
||||
|
||||
// buildBatchInterrupt 生成合并中断:说清「一共几条」「分别是哪些 message_id」,
|
||||
// 并给出一次拿全上下文的建议(get_history),避免模型逐条 get_message。
|
||||
func (p *Plugin) buildBatchInterrupt(b *pendingBatch) string {
|
||||
tp := p.name + "_"
|
||||
outputTool := "output_send__" + p.name
|
||||
n := len(b.msgIDs)
|
||||
ids := formatMsgIDs(b.msgIDs)
|
||||
var s string
|
||||
if b.isGroup {
|
||||
s = fmt.Sprintf("来自「%s」在群里短时间内连续发来 %d 条消息(message_id=%s)。建议先用%sget_history(group_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复群聊",
|
||||
b.nickname, n, ids, tp, b.groupID, n+5, tp, outputTool)
|
||||
} else {
|
||||
s = fmt.Sprintf("来自「%s」的私聊短时间内连续发来 %d 条消息(message_id=%s, user_id=%d)。建议先用%sget_history(user_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复对方",
|
||||
b.nickname, n, ids, b.userID, tp, b.userID, n+5, tp, outputTool)
|
||||
}
|
||||
if b.highRisk {
|
||||
s = "【⚠️ 高危信息,谨慎处理】" + s
|
||||
}
|
||||
if b.owner {
|
||||
s = "【重要!Bot 所有者消息】" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatMsgIDs 把 message_id 列表压成一行;过多时截断,避免中断文字过长。
|
||||
func formatMsgIDs(ids []int64) string {
|
||||
const capN = 12
|
||||
parts := make([]string, 0, len(ids)+1)
|
||||
for i, id := range ids {
|
||||
if i >= capN {
|
||||
parts = append(parts, "…")
|
||||
break
|
||||
}
|
||||
parts = append(parts, strconv.FormatInt(id, 10))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
@ -1404,7 +1671,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
highRisk := false
|
||||
if highRiskRe.MatchString(text) {
|
||||
highRisk = true
|
||||
interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt
|
||||
}
|
||||
|
||||
@ -1433,15 +1702,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
p.startTyping(evt.UserID)
|
||||
}
|
||||
|
||||
if p.sdk != nil {
|
||||
// NoMemory:HTTP 侧来的中断提示,不是对话内容。
|
||||
// Priority:QQ 消息是**低级别中断**——既不是时钟那样的实时工作,
|
||||
// 也不是紧急工作,所以声明 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)
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
@ -202,3 +203,231 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时,
|
||||
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
|
||||
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
|
||||
// (报「可信 QQ 会话身份不完整」)。
|
||||
//
|
||||
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
|
||||
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true}
|
||||
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
|
||||
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
|
||||
|
||||
ctx := toolCallContext("output_send__qq", map[string]interface{}{
|
||||
"payload": "带齐 meta 的主动发送",
|
||||
"type": "text",
|
||||
"meta": `{"user_id":2198972886}`,
|
||||
})
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("降权时输出被拒: %s", *ctx.Response)
|
||||
}
|
||||
|
||||
ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713})
|
||||
if err := p.beforeToolcall(ctx2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") {
|
||||
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 消息合并(debounce)----
|
||||
|
||||
// collectInterrupts 用注入钩子收集中断文本(避免测试依赖真实 SDK)。
|
||||
func collectInterrupts(p *Plugin) *[]string {
|
||||
got := []string{}
|
||||
p.injectHook = func(s, _ string) { got = append(got, s) }
|
||||
return &got
|
||||
}
|
||||
|
||||
func TestConsecutiveMessagesFromSameSenderAreBatched(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := collectInterrupts(p)
|
||||
p.batchWindow = 20 * time.Millisecond
|
||||
p.batchMax = time.Second
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
p.enqueueInterrupt("private", 10001, 0, int64(100+i), "小明", "单条", false, false)
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if len(*got) != 1 {
|
||||
t.Fatalf("同一发送者连发 3 条应合并成 1 次中断,实际 %d 次: %#v", len(*got), *got)
|
||||
}
|
||||
if !strings.Contains((*got)[0], "3 条消息") {
|
||||
t.Fatalf("合并中断应说明一共几条,实际: %s", (*got)[0])
|
||||
}
|
||||
// 三个 message_id 都要带上,模型才能取全
|
||||
for _, id := range []string{"100", "101", "102"} {
|
||||
if !strings.Contains((*got)[0], id) {
|
||||
t.Fatalf("合并中断漏了 message_id=%s: %s", id, (*got)[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentSendersAreNotBatchedTogether(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := collectInterrupts(p)
|
||||
p.batchWindow = 20 * time.Millisecond
|
||||
p.batchMax = time.Second
|
||||
|
||||
p.enqueueInterrupt("private", 10001, 0, 1, "小明", "a", false, false)
|
||||
p.enqueueInterrupt("private", 10002, 0, 2, "小红", "b", false, false)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if len(*got) != 2 {
|
||||
t.Fatalf("不同发送者不该合并,应有 2 次中断,实际 %d: %#v", len(*got), *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchWindowZeroFallsBackToPerMessage(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := collectInterrupts(p)
|
||||
p.batchWindow = 0
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
p.enqueueInterrupt("private", 10001, 0, int64(i), "小明", "原文", false, false)
|
||||
}
|
||||
if len(*got) != 3 {
|
||||
t.Fatalf("关闭合并时应逐条投递(3 次),实际 %d: %#v", len(*got), *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleMessageKeepsOriginalText(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := collectInterrupts(p)
|
||||
p.batchWindow = 20 * time.Millisecond
|
||||
p.batchMax = time.Second
|
||||
|
||||
p.enqueueInterrupt("group", 10001, 20002, 7, "小明", "单条原文", true, false)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if len(*got) != 1 || (*got)[0] != "单条原文" {
|
||||
t.Fatalf("单条消息应沿用原文(含所有者前缀),实际 %#v", *got)
|
||||
}
|
||||
}
|
||||
|
||||
// Bot 所有者/管理员的消息给 L2,普通人的给 L1 —— 否则所有者的话会被路人
|
||||
// 的 L1 闲聊抢占/挤到队尾。
|
||||
func TestOwnerMessagesGetHigherInterruptLevel(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := []string{}
|
||||
p.injectHook = func(text, level string) { got = append(got, text+"|"+level) }
|
||||
p.batchWindow = 20 * time.Millisecond
|
||||
p.batchMax = time.Second
|
||||
|
||||
p.enqueueInterrupt("private", 1, 0, 1, "owner", "owner-msg", true, false)
|
||||
p.enqueueInterrupt("private", 2, 0, 2, "someone", "other-msg", false, false)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
joined := strings.Join(got, ",")
|
||||
if !strings.Contains(joined, "owner-msg|L2") {
|
||||
t.Fatalf("所有者消息应为 L2,实际 %q", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "other-msg|L1") {
|
||||
t.Fatalf("普通人消息应为 L1,实际 %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// 身份必须绑在帧上:中断抢占当前轮、中断轮收尾清空插件全局身份之后,
|
||||
// 外层轮被恢复(resumeTask 复用同一帧、不重跑 onInput)时权限门不能整体失效。
|
||||
func TestAuthSurvivesInterruptPreemptionOfAnotherTurn(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
|
||||
// 中断轮(Bot 所有者)跑完:afterOutput 会清掉插件全局身份。
|
||||
inner := &sdk.StageContext{Extra: map[string]interface{}{
|
||||
qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886},
|
||||
}}
|
||||
if err := p.afterOutputAuthContext(inner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.auth.active {
|
||||
t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)")
|
||||
}
|
||||
|
||||
// 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。
|
||||
frame := &sdk.StageContext{
|
||||
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, userID: 10001, groupID: 20002, isGroup: true}},
|
||||
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||
}
|
||||
if err := p.beforeToolcall(frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frame.Response == nil || !strings.Contains(*frame.Response, "私人资源工具") {
|
||||
t.Fatalf("中断恢复后权限门失效(整体放行): %#v", frame.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行中到达的新消息会改写插件全局身份;正在跑的那一轮必须不受影响。
|
||||
func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
|
||||
frame := &sdk.StageContext{
|
||||
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}},
|
||||
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||
}
|
||||
// 路人的群消息在所有者轮运行中到达。
|
||||
p.activateAuthContext(4242, 10001, 20002, true)
|
||||
if p.auth.owner {
|
||||
t.Fatal("到达事件应改写全局身份(复现场景)")
|
||||
}
|
||||
|
||||
if err := p.beforeToolcall(frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frame.Response != nil {
|
||||
t.Fatalf("在跑的所有者轮被到达消息篡改: %s", *frame.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并中断正文里的整批 message_id 都要消费掉,并在帧上绑定身份。
|
||||
func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.authByMessageID = map[int64]qqAuthContext{
|
||||
100: {active: true, owner: true, userID: 2198972886},
|
||||
101: {active: true, owner: true, userID: 2198972886},
|
||||
}
|
||||
ctx := &sdk.StageContext{
|
||||
RawMessage: "来自「老板」的私聊短时间内连续发来 2 条消息(message_id=100,101, user_id=2198972886)。",
|
||||
Extra: map[string]interface{}{"input_source": "qq"},
|
||||
}
|
||||
if err := p.onInputAuthContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.auth.owner {
|
||||
t.Fatalf("合并中断未恢复所有者身份: %+v", p.auth)
|
||||
}
|
||||
if len(p.authByMessageID) != 0 {
|
||||
t.Fatalf("同批 message_id 未全部清理: %v", p.authByMessageID)
|
||||
}
|
||||
if auth, ok := authOnFrame(ctx); !ok || !auth.owner {
|
||||
t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 非 QQ 来源(webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。
|
||||
func TestNonQQFrameBindsInactiveAuth(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||
|
||||
ctx := &sdk.StageContext{
|
||||
RawMessage: "webui 里的提问",
|
||||
Extra: map[string]interface{}{"input_source": "webui"},
|
||||
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||
}
|
||||
if err := p.onInputAuthContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response)
|
||||
}
|
||||
}
|
||||
|
||||
53
example/recoverydiag/README.md
Normal file
53
example/recoverydiag/README.md
Normal 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
|
||||
```
|
||||
@ -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.
|
||||
|
||||
48
example/sanitizer/README.md
Normal file
48
example/sanitizer/README.md
Normal 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
77
example/vanblog/README.md
Normal 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
|
||||
```
|
||||
@ -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 => ../../
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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 安装。
|
||||
|
||||
16
meta/meta.go
16
meta/meta.go
@ -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.0:1.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
147
scripts/build_plugin_bundles.sh
Executable 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/plugins(upload_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 多平台 bundle(plg.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
38
scripts/sync-lua-sdk.sh
Executable 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
413
sdk/lua/sdk.lua
Normal 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_sync:Lua 中不可用。
|
||||
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
|
||||
@ -480,7 +480,15 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
||||
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
|
||||
// 若该通道同时也是你的注入入口,两个都要登记。
|
||||
//
|
||||
// name: channel name (e.g. "qq", "webui")
|
||||
// name: channel name (e.g. "qq", "webui")。
|
||||
//
|
||||
// ❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`),
|
||||
// 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用",
|
||||
// 而是**整条请求被上游 400 拒绝**(`Invalid 'tools[N].function.name'`),
|
||||
// 网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
|
||||
// 所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13 字符)的余量。
|
||||
// 若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**,
|
||||
// 而不是把原始值直接当通道名。
|
||||
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
||||
// desc: description of the channel, expected meta format, and type enum
|
||||
// def: 通道在记忆计算层的行为(NoMemory/Cleaner)
|
||||
|
||||
413
tools/hmapdev/assets/sdk.lua
Normal file
413
tools/hmapdev/assets/sdk.lua
Normal 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_sync:Lua 中不可用。
|
||||
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
|
||||
@ -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)
|
||||
// 直接退出而非置 buildFailed:Lua 分支不进入后面的收尾统计,
|
||||
// 早期 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 {
|
||||
|
||||
@ -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 的权威 mock(HMAPDEV_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
|
||||
|
||||
@ -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 的根目录,未安装/未激活则报错退出。
|
||||
//
|
||||
// 与 activeSDKRoot(fatal 版)区别:这里只探测,不退出。
|
||||
// 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
35
tools/hmapdev/lua_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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}}" }
|
||||
|
||||
Reference in New Issue
Block a user