mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-21 17:38:03 +00:00
Compare commits
5 Commits
main
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d14afa8d6 | |||
| 87241bc4ff | |||
| 535922c6a0 | |||
| fe66f72b9c | |||
| 0a4eb76542 |
12
README.md
12
README.md
@ -487,18 +487,6 @@ enabled := sdk.AutoRestart()
|
||||
|
||||
插件崩溃时平台自动拉起,保障服务可用性。
|
||||
|
||||
重启是**有节制的**,默认参数(内核 `internal/plugin/registry.go`):
|
||||
|
||||
| 参数 | 值 | 含义 |
|
||||
|---|---|---|
|
||||
| `procRestartBackoff` | `1s` | 第 n 次重启前等 `n × 1s`(线性退避,非立即拉起) |
|
||||
| `procMaxRestarts` | `3` | 窗口内允许的重启次数上限 |
|
||||
| `procCrashWindow` | `5min` | 窗口内无新崩溃则计数归零 |
|
||||
|
||||
即崩溃后的实际序列是 **1s → 2s → 3s**;同一 5 分钟窗口内第 **4** 次崩溃
|
||||
(`n > 3`)**不再自动拉起**,交人工介入。这不是「立即无感恢复」——
|
||||
如果插件需要秒级就位,请自己在 `OnStart` 里做好重连与重建。
|
||||
|
||||
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
|
||||
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
|
||||
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
|
||||
|
||||
13
README_EN.md
13
README_EN.md
@ -443,19 +443,6 @@ enabled := sdk.AutoRestart()
|
||||
|
||||
The platform automatically restarts the plugin on crash, ensuring service availability.
|
||||
|
||||
Restarts are **rate-limited**. Defaults (kernel `internal/plugin/registry.go`):
|
||||
|
||||
| Parameter | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `procRestartBackoff` | `1s` | Before restart #n, wait `n × 1s` (linear backoff, not immediate) |
|
||||
| `procMaxRestarts` | `3` | Max restarts within the window |
|
||||
| `procCrashWindow` | `5min` | No new crash within the window resets the count |
|
||||
|
||||
So the actual sequence is **1s → 2s → 3s**; the **4th** crash in the same 5-minute
|
||||
window (`n > 3`) is **not** restarted automatically and needs manual intervention.
|
||||
This is not instant, invisible recovery — if your plugin must be back in seconds,
|
||||
reconnect and rebuild your own state in `OnStart`.
|
||||
|
||||
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
|
||||
> external connection has been established, and that connection setup usually happens in a
|
||||
> background goroutine while the kernel reads the flag from another one — which is inherently
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,51 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,34 +1,13 @@
|
||||
# ai_image · 文生图
|
||||
# ai_image
|
||||
|
||||
按文字提示生成图片,下载到本地并返回**文件路径**。
|
||||
ai_image plugin
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 说明 |
|
||||
|---|---|
|
||||
| `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` 就能指向自建/兼容网关,不必依赖官方接口。
|
||||
|
||||
## 构建
|
||||
## Build
|
||||
|
||||
```bash
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Upload the .hmap file through the Plugin Manager API.
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,66 +0,0 @@
|
||||
# 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,57 +1,13 @@
|
||||
# calendar · 日历事件
|
||||
# calendar
|
||||
|
||||
事件管理:支持**重复事件**与**多档提醒**。
|
||||
calendar plugin
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 说明 |
|
||||
|---|---|
|
||||
| `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,插件重启后保留。
|
||||
|
||||
## 构建
|
||||
## Build
|
||||
|
||||
```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 v0.0.0
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
||||
|
||||
|
||||
|
||||
@ -18,4 +18,4 @@ require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
|
||||
|
||||
@ -6,12 +6,7 @@
|
||||
"description": "为 agent 提供真正的联网信息检索:本地 SearXNG 聚合多引擎(返回标题/URL/摘要/时间),支持新闻、时间范围、指定引擎;并提供网页正文抽取与「搜索+读前K篇」的深检索",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.bin",
|
||||
"tags": [
|
||||
"search",
|
||||
"web",
|
||||
"searxng",
|
||||
"retrieval",
|
||||
"news"
|
||||
],
|
||||
"sdk": "1.2.0",
|
||||
"tags": ["search", "web", "searxng", "retrieval", "news"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,44 +0,0 @@
|
||||
# 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,16 +1,13 @@
|
||||
# luademo
|
||||
|
||||
Lua 插件全功能示例,展示 Lua SDK 的完整能力面(对齐 SDK 1.3.0):
|
||||
Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面:
|
||||
|
||||
- **工具注册**:`no_memory` + `context_policy` + `cleaner`(记忆计算层过滤)
|
||||
- **工具注册**:`no_memory` + `cleaner`(记忆计算层过滤)
|
||||
- **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域
|
||||
- **通道**:`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_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_api`、`set_auto_restart`
|
||||
|
||||
> `luademo_probe_v2` 巡检 1.1/1.2/1.3 新增面。它**故意不调用** `inject_input_sync`:工具 handler 在 LLM 回合内运行,同步注入会自己等自己(死锁)。
|
||||
|
||||
## 本地独立测试
|
||||
|
||||
```bash
|
||||
|
||||
@ -67,65 +67,6 @@ 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,413 +1,67 @@
|
||||
-- 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")
|
||||
|
||||
-- HomeAgent Lua Plugin SDK (standalone mock)
|
||||
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
|
||||
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 = {}
|
||||
-- !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
|
||||
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, 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
|
||||
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,30 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,40 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,167 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -155,41 +155,6 @@ 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 {
|
||||
@ -351,8 +316,6 @@ 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()
|
||||
|
||||
@ -376,18 +339,12 @@ 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)
|
||||
@ -439,9 +396,7 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
// qq 通道到达的是**中断通知(meta)**,不是用户正文,不据它召回;
|
||||
// 真实正文由 qq_get_message 取回后由该工具声明 RecallPolicy=auto 触发召回。
|
||||
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner, RecallPolicy: sdk.RecallPolicyNone})
|
||||
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner})
|
||||
|
||||
// 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化
|
||||
cleaner := func(output string) string {
|
||||
@ -461,9 +416,6 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
||||
// 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里,
|
||||
// 长会话下持续挤占 token 预算(§13.8)。
|
||||
ContextPolicy: "prune",
|
||||
// 正文才是真实内容:取回后用**正文**触发一次召回,
|
||||
// 而不是用中断通知的 meta 文本去召回(那是无关词)。
|
||||
RecallPolicy: "auto",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object", "properties": map[string]interface{}{
|
||||
"message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
|
||||
@ -489,12 +441,6 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
|
||||
Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name),可用 qq_download_file 工具下载。",
|
||||
NoMemory: false,
|
||||
Cleaner: cleaner,
|
||||
// 与 get_message 同理:返回的是**真实聊天正文**,不只当轮需要,
|
||||
// 还可能牵出与这些正文相关的长期记忆。故取回后既裁剪(用完不长期占
|
||||
// L0)又据正文召回(取进来)。不声明 recall 的话就是「记忆里有、但
|
||||
// 拉回历史消息时不注入」的盲区。
|
||||
ContextPolicy: "prune",
|
||||
RecallPolicy: "auto",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object", "properties": map[string]interface{}{
|
||||
"group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"},
|
||||
@ -751,8 +697,6 @@ 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()
|
||||
@ -817,10 +761,6 @@ 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:
|
||||
@ -1040,40 +980,6 @@ 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 精确恢复身份,不依赖昵称或用户正文。
|
||||
@ -1124,28 +1030,13 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b
|
||||
p.auth.generation = next.generation
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
func messageIDFromInput(raw string) int64 {
|
||||
match := qqMessageIDRe.FindStringSubmatch(raw)
|
||||
if len(match) != 2 {
|
||||
return 0
|
||||
}
|
||||
return ids
|
||||
id, _ := strconv.ParseInt(match[1], 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
||||
@ -1153,27 +1044,24 @@ func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
|
||||
source, _ := ctx.Extra["input_source"].(string)
|
||||
raw := ctx.RawMessage
|
||||
ctx.RUnlock()
|
||||
|
||||
p.authMu.Lock()
|
||||
// 默认降权: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)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
// p.auth 只作为"帧上没绑身份"时的兜底(单测/异常帧),权威副本在帧上。
|
||||
p.auth = next
|
||||
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份。
|
||||
p.auth = qqAuthContext{active: true}
|
||||
p.resetTurnGuardLocked()
|
||||
p.authMu.Unlock()
|
||||
|
||||
ctx.Lock()
|
||||
bindAuthOnFrame(ctx, next)
|
||||
ctx.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -1193,13 +1081,9 @@ func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, qqAuthContext) {
|
||||
// 身份以本帧为准(中断恢复后全局身份可能已属于别的轮)。
|
||||
auth, onFrame := authOnFrame(ctx)
|
||||
func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) {
|
||||
p.authMu.RLock()
|
||||
if !onFrame {
|
||||
auth = p.auth
|
||||
}
|
||||
auth := p.auth
|
||||
var patterns []string
|
||||
if auth.active && !auth.owner {
|
||||
if auth.isGroup {
|
||||
@ -1308,7 +1192,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
|
||||
return nil
|
||||
}
|
||||
tc := &ctx.ToolCalls[0]
|
||||
allowed, auth := p.currentToolAllowed(ctx, tc.Name)
|
||||
allowed, auth := p.currentToolAllowed(tc.Name)
|
||||
if !auth.active {
|
||||
return nil
|
||||
}
|
||||
@ -1415,157 +1299,6 @@ 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,
|
||||
// 中断文本是路由/取正文的指令,不是对话内容,不据它召回。
|
||||
RecallPolicy: sdk.RecallPolicyNone,
|
||||
})
|
||||
}
|
||||
|
||||
// flushBatch 投递一批:n==1 沿用单条原文;n>1 生成「共几条」的合并中断。
|
||||
func (p *Plugin) flushBatch(key string) {
|
||||
p.batchMu.Lock()
|
||||
b := p.batches[key]
|
||||
delete(p.batches, key)
|
||||
p.batchMu.Unlock()
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
text := b.single
|
||||
if len(b.msgIDs) > 1 {
|
||||
text = p.buildBatchInterrupt(b)
|
||||
}
|
||||
// 一批里只要有一条来自 Bot 所有者,整批按 L2 投递(不因混入路人消息而降低)。
|
||||
p.injectInterrupt(text, p.interruptLevel(b.owner))
|
||||
}
|
||||
|
||||
// flushAllBatches 停机前把未到点的批次立刻投出去(best effort)。
|
||||
func (p *Plugin) flushAllBatches() {
|
||||
p.batchMu.Lock()
|
||||
keys := make([]string, 0, len(p.batches))
|
||||
for k := range p.batches {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
p.batchMu.Unlock()
|
||||
for _, k := range keys {
|
||||
p.flushBatch(k)
|
||||
}
|
||||
}
|
||||
|
||||
// buildBatchInterrupt 生成合并中断:说清「一共几条」「分别是哪些 message_id」,
|
||||
// 并给出一次拿全上下文的建议(get_history),避免模型逐条 get_message。
|
||||
func (p *Plugin) buildBatchInterrupt(b *pendingBatch) string {
|
||||
tp := p.name + "_"
|
||||
outputTool := "output_send__" + p.name
|
||||
n := len(b.msgIDs)
|
||||
ids := formatMsgIDs(b.msgIDs)
|
||||
var s string
|
||||
if b.isGroup {
|
||||
s = fmt.Sprintf("来自「%s」在群里短时间内连续发来 %d 条消息(message_id=%s)。建议先用%sget_history(group_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复群聊",
|
||||
b.nickname, n, ids, tp, b.groupID, n+5, tp, outputTool)
|
||||
} else {
|
||||
s = fmt.Sprintf("来自「%s」的私聊短时间内连续发来 %d 条消息(message_id=%s, user_id=%d)。建议先用%sget_history(user_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复对方",
|
||||
b.nickname, n, ids, b.userID, tp, b.userID, n+5, tp, outputTool)
|
||||
}
|
||||
if b.highRisk {
|
||||
s = "【⚠️ 高危信息,谨慎处理】" + s
|
||||
}
|
||||
if b.owner {
|
||||
s = "【重要!Bot 所有者消息】" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatMsgIDs 把 message_id 列表压成一行;过多时截断,避免中断文字过长。
|
||||
func formatMsgIDs(ids []int64) string {
|
||||
const capN = 12
|
||||
parts := make([]string, 0, len(ids)+1)
|
||||
for i, id := range ids {
|
||||
if i >= capN {
|
||||
parts = append(parts, "…")
|
||||
break
|
||||
}
|
||||
parts = append(parts, strconv.FormatInt(id, 10))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
@ -1684,9 +1417,7 @@ 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
|
||||
}
|
||||
|
||||
@ -1715,9 +1446,15 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
p.startTyping(evt.UserID)
|
||||
}
|
||||
|
||||
// 合并投递:同一会话同一发送者在 batchWindow 内的连续消息并成一次中断。
|
||||
p.enqueueInterrupt(evt.MessageType, evt.UserID, evt.GroupID, evt.MessageID, nickname, interrupt, p.isOwner(evt.UserID), highRisk)
|
||||
|
||||
if p.sdk != nil {
|
||||
// NoMemory:HTTP 侧来的中断提示,不是对话内容。
|
||||
// Priority:QQ 消息是**低级别中断**——既不是时钟那样的实时工作,
|
||||
// 也不是紧急工作,所以声明 L1(完全可等)。
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{
|
||||
NoMemory: true,
|
||||
Priority: sdk.PriorityL1,
|
||||
})
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
@ -2815,7 +2552,7 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
|
||||
// Priority:同上,QQ 侧一律低级别中断(L1)。
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||
fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath),
|
||||
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1, RecallPolicy: sdk.RecallPolicyNone})
|
||||
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1})
|
||||
}
|
||||
} else {
|
||||
errMsg = "下载失败,文件可能已过期"
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
@ -236,198 +235,3 @@ func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
|
||||
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 消息合并(debounce)----
|
||||
|
||||
// collectInterrupts 用注入钩子收集中断文本(避免测试依赖真实 SDK)。
|
||||
func collectInterrupts(p *Plugin) *[]string {
|
||||
got := []string{}
|
||||
p.injectHook = func(s, _ string) { got = append(got, s) }
|
||||
return &got
|
||||
}
|
||||
|
||||
func TestConsecutiveMessagesFromSameSenderAreBatched(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := collectInterrupts(p)
|
||||
p.batchWindow = 20 * time.Millisecond
|
||||
p.batchMax = time.Second
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
p.enqueueInterrupt("private", 10001, 0, int64(100+i), "小明", "单条", false, false)
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if len(*got) != 1 {
|
||||
t.Fatalf("同一发送者连发 3 条应合并成 1 次中断,实际 %d 次: %#v", len(*got), *got)
|
||||
}
|
||||
if !strings.Contains((*got)[0], "3 条消息") {
|
||||
t.Fatalf("合并中断应说明一共几条,实际: %s", (*got)[0])
|
||||
}
|
||||
// 三个 message_id 都要带上,模型才能取全
|
||||
for _, id := range []string{"100", "101", "102"} {
|
||||
if !strings.Contains((*got)[0], id) {
|
||||
t.Fatalf("合并中断漏了 message_id=%s: %s", id, (*got)[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentSendersAreNotBatchedTogether(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := collectInterrupts(p)
|
||||
p.batchWindow = 20 * time.Millisecond
|
||||
p.batchMax = time.Second
|
||||
|
||||
p.enqueueInterrupt("private", 10001, 0, 1, "小明", "a", false, false)
|
||||
p.enqueueInterrupt("private", 10002, 0, 2, "小红", "b", false, false)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if len(*got) != 2 {
|
||||
t.Fatalf("不同发送者不该合并,应有 2 次中断,实际 %d: %#v", len(*got), *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchWindowZeroFallsBackToPerMessage(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := collectInterrupts(p)
|
||||
p.batchWindow = 0
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
p.enqueueInterrupt("private", 10001, 0, int64(i), "小明", "原文", false, false)
|
||||
}
|
||||
if len(*got) != 3 {
|
||||
t.Fatalf("关闭合并时应逐条投递(3 次),实际 %d: %#v", len(*got), *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleMessageKeepsOriginalText(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := collectInterrupts(p)
|
||||
p.batchWindow = 20 * time.Millisecond
|
||||
p.batchMax = time.Second
|
||||
|
||||
p.enqueueInterrupt("group", 10001, 20002, 7, "小明", "单条原文", true, false)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if len(*got) != 1 || (*got)[0] != "单条原文" {
|
||||
t.Fatalf("单条消息应沿用原文(含所有者前缀),实际 %#v", *got)
|
||||
}
|
||||
}
|
||||
|
||||
// Bot 所有者/管理员的消息给 L2,普通人的给 L1 —— 否则所有者的话会被路人
|
||||
// 的 L1 闲聊抢占/挤到队尾。
|
||||
func TestOwnerMessagesGetHigherInterruptLevel(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
got := []string{}
|
||||
p.injectHook = func(text, level string) { got = append(got, text+"|"+level) }
|
||||
p.batchWindow = 20 * time.Millisecond
|
||||
p.batchMax = time.Second
|
||||
|
||||
p.enqueueInterrupt("private", 1, 0, 1, "owner", "owner-msg", true, false)
|
||||
p.enqueueInterrupt("private", 2, 0, 2, "someone", "other-msg", false, false)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
joined := strings.Join(got, ",")
|
||||
if !strings.Contains(joined, "owner-msg|L2") {
|
||||
t.Fatalf("所有者消息应为 L2,实际 %q", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "other-msg|L1") {
|
||||
t.Fatalf("普通人消息应为 L1,实际 %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// 身份必须绑在帧上:中断抢占当前轮、中断轮收尾清空插件全局身份之后,
|
||||
// 外层轮被恢复(resumeTask 复用同一帧、不重跑 onInput)时权限门不能整体失效。
|
||||
func TestAuthSurvivesInterruptPreemptionOfAnotherTurn(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
|
||||
// 中断轮(Bot 所有者)跑完:afterOutput 会清掉插件全局身份。
|
||||
inner := &sdk.StageContext{Extra: map[string]interface{}{
|
||||
qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886},
|
||||
}}
|
||||
if err := p.afterOutputAuthContext(inner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.auth.active {
|
||||
t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)")
|
||||
}
|
||||
|
||||
// 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。
|
||||
frame := &sdk.StageContext{
|
||||
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, userID: 10001, groupID: 20002, isGroup: true}},
|
||||
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||
}
|
||||
if err := p.beforeToolcall(frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frame.Response == nil || !strings.Contains(*frame.Response, "私人资源工具") {
|
||||
t.Fatalf("中断恢复后权限门失效(整体放行): %#v", frame.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行中到达的新消息会改写插件全局身份;正在跑的那一轮必须不受影响。
|
||||
func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
|
||||
frame := &sdk.StageContext{
|
||||
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}},
|
||||
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||
}
|
||||
// 路人的群消息在所有者轮运行中到达。
|
||||
p.activateAuthContext(4242, 10001, 20002, true)
|
||||
if p.auth.owner {
|
||||
t.Fatal("到达事件应改写全局身份(复现场景)")
|
||||
}
|
||||
|
||||
if err := p.beforeToolcall(frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frame.Response != nil {
|
||||
t.Fatalf("在跑的所有者轮被到达消息篡改: %s", *frame.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并中断正文里的整批 message_id 都要消费掉,并在帧上绑定身份。
|
||||
func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.authByMessageID = map[int64]qqAuthContext{
|
||||
100: {active: true, owner: true, userID: 2198972886},
|
||||
101: {active: true, owner: true, userID: 2198972886},
|
||||
}
|
||||
ctx := &sdk.StageContext{
|
||||
RawMessage: "来自「老板」的私聊短时间内连续发来 2 条消息(message_id=100,101, user_id=2198972886)。",
|
||||
Extra: map[string]interface{}{"input_source": "qq"},
|
||||
}
|
||||
if err := p.onInputAuthContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.auth.owner {
|
||||
t.Fatalf("合并中断未恢复所有者身份: %+v", p.auth)
|
||||
}
|
||||
if len(p.authByMessageID) != 0 {
|
||||
t.Fatalf("同批 message_id 未全部清理: %v", p.authByMessageID)
|
||||
}
|
||||
if auth, ok := authOnFrame(ctx); !ok || !auth.owner {
|
||||
t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 非 QQ 来源(webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。
|
||||
func TestNonQQFrameBindsInactiveAuth(t *testing.T) {
|
||||
p := newPermissionTestPlugin(t)
|
||||
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
|
||||
|
||||
ctx := &sdk.StageContext{
|
||||
RawMessage: "webui 里的提问",
|
||||
Extra: map[string]interface{}{"input_source": "webui"},
|
||||
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
|
||||
}
|
||||
if err := p.onInputAuthContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.beforeToolcall(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
# 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,44 +1,13 @@
|
||||
# rss · RSS/Atom 订阅监控
|
||||
# rss
|
||||
|
||||
订阅 RSS/Atom 源,**有新文章时主动通知** agent(不必每轮去问)。
|
||||
rss plugin
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 说明 |
|
||||
|---|---|
|
||||
| `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`)。
|
||||
|
||||
## 构建
|
||||
## Build
|
||||
|
||||
```bash
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Upload the .hmap file through the Plugin Manager API.
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
# 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`。
|
||||
@ -1,77 +0,0 @@
|
||||
# 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 v0.0.0
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
||||
|
||||
// 与同目录其它示例一致:SDK 指向仓库内的 vendored 副本
|
||||
|
||||
@ -12,4 +12,4 @@ require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
|
||||
|
||||
@ -6,12 +6,7 @@
|
||||
"description": "Vikunja 待办/任务管理:任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理;并附通用 API 直通工具兜底",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.bin",
|
||||
"tags": [
|
||||
"vikunja",
|
||||
"todo",
|
||||
"task",
|
||||
"gtd",
|
||||
"productivity"
|
||||
],
|
||||
"sdk": "1.2.0",
|
||||
"tags": ["vikunja", "todo", "task", "gtd", "productivity"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,33 +1,13 @@
|
||||
# weather · 天气查询
|
||||
# weather
|
||||
|
||||
给 agent 补上天气查询能力(基于 [wttr.in](https://wttr.in),无需 API Key)。
|
||||
weather plugin
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 说明 |
|
||||
|---|---|
|
||||
| `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`**:输出参与记忆计算前先过滤,只保留摘要行 —— 天气查询会反复出现,全文进记忆会挤占上下文预算,而"上周三北京多少度"通常并不需要召回。
|
||||
|
||||
## 构建
|
||||
## Build
|
||||
|
||||
```bash
|
||||
hmapdev build
|
||||
```
|
||||
|
||||
产出 `.hmap` 后经 Plugin Manager API 安装。
|
||||
## Install
|
||||
|
||||
Upload the .hmap file through the Plugin Manager API.
|
||||
|
||||
16
meta/meta.go
16
meta/meta.go
@ -41,14 +41,16 @@ var (
|
||||
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
|
||||
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
||||
//
|
||||
// 现为 1.4.0:1.3.0 已随核心的正式 tag `v1.3.0` 定版并发版(本仓 tag v1.3.0、
|
||||
// release/v1.3.x 承载它),该号从此归发布线所有,main 遂推进到下一个未发布中版本。
|
||||
// 现为 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。
|
||||
//
|
||||
// ❗本仓**不发 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"
|
||||
// 注意:这里与核心 main **故意不对称**。核心一旦切出 release/v1.2.x,
|
||||
// 1.2.0 就归发布线所有,main 立刻推进到 1.3.0;而 SDK 因为要等正式 tag,
|
||||
// 它的 main 在 v1.2.0 打出来之前不得越过 1.2.0。
|
||||
// (曾误按 §七.4 把这里推到 1.3.0,等于宣称 1.2.0 已发布。)
|
||||
Version = "1.3.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
#!/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
|
||||
@ -1,38 +0,0 @@
|
||||
#!/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
413
sdk/lua/sdk.lua
@ -1,413 +0,0 @@
|
||||
-- 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
|
||||
@ -54,26 +54,6 @@ func ValidContextPolicy(policy string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
|
||||
//
|
||||
// 与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
|
||||
// RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
|
||||
// 裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
|
||||
// 默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
|
||||
const (
|
||||
RecallPolicyNone = "none"
|
||||
RecallPolicyAuto = "auto"
|
||||
)
|
||||
|
||||
// ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。
|
||||
func ValidRecallPolicy(policy string) bool {
|
||||
switch policy {
|
||||
case "", RecallPolicyNone, RecallPolicyAuto:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
||||
//
|
||||
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
||||
@ -85,7 +65,6 @@ func ValidRecallPolicy(policy string) bool {
|
||||
//
|
||||
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
|
||||
// RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。
|
||||
//
|
||||
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
|
||||
//
|
||||
@ -98,11 +77,7 @@ func ValidRecallPolicy(policy string) bool {
|
||||
type InjectOptions struct {
|
||||
NoMemory bool
|
||||
ContextPolicy string
|
||||
// RecallPolicy 声明此次注入是否据其内容召回相关记忆。
|
||||
// 空串 = 默认(输入/注入 auto,即保持既有「每条输入都召回」的行为);
|
||||
// RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。
|
||||
RecallPolicy string
|
||||
CleanerName string
|
||||
CleanerName string
|
||||
|
||||
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
|
||||
//
|
||||
@ -131,7 +106,6 @@ const (
|
||||
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
||||
// RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
|
||||
//
|
||||
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
||||
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
||||
@ -140,8 +114,6 @@ type ChannelDef struct {
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
Cleaner func(string) string `json:"-"`
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
// RecallPolicy 见 InjectOptions.RecallPolicy;空串等价 auto(保持既有行为)。
|
||||
RecallPolicy string `json:"recall_policy,omitempty"`
|
||||
}
|
||||
|
||||
// StageContext provides context for stage handlers.
|
||||
@ -208,10 +180,6 @@ type ToolDef struct {
|
||||
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
||||
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
||||
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
|
||||
// RecallPolicy 声明此工具输出是否触发一次记忆召回(注入)。
|
||||
// ""(默认 none) / RecallPolicyNone / RecallPolicyAuto。
|
||||
// 默认 none:多数工具输出是噪声;需要「取回真实内容后据它召回」的工具(如 qq_get_message)应显式声明 auto。
|
||||
RecallPolicy string `json:"recall_policy,omitempty"`
|
||||
}
|
||||
|
||||
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||
@ -775,12 +743,8 @@ func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetAutoRestart 设置插件崩溃后内核是否自动重启它。
|
||||
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
|
||||
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||
//
|
||||
// 重启是有限度的:线性退避(第 n 次等 n×1s,即 1s→2s→3s),
|
||||
// 且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README)。
|
||||
// 注意这与「重载」(换 plugin.bin 后重新加载)是两回事。
|
||||
func (s *PluginSDK) SetAutoRestart(enabled bool) {
|
||||
s.apiMu.Lock()
|
||||
s.autoRestart = enabled
|
||||
|
||||
@ -1,413 +0,0 @@
|
||||
-- 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,18 +75,6 @@ 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
|
||||
}
|
||||
@ -266,30 +254,6 @@ 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,14 +13,7 @@ import (
|
||||
// tmplLuaDebug is the temporary Lua debug script template
|
||||
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
|
||||
-- Generated by hmapdev debug --lua
|
||||
-- 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
|
||||
sdk = require("sdk")
|
||||
local ok, plugin = pcall(dofile, "main.lua")
|
||||
if not ok then
|
||||
print("[debug] ERROR loading main.lua: " .. tostring(plugin))
|
||||
@ -134,16 +127,6 @@ 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 {
|
||||
@ -154,7 +137,6 @@ 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,13 +164,7 @@ func cmdInit(args []string) {
|
||||
// Detect SDK info for Go plugin go.mod.
|
||||
// 生成的 go.mod 除 require 外还写一条指向本机 SDK 的 replace:
|
||||
// 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)。
|
||||
if isLua {
|
||||
// Lua 插件也要记录它按哪版 SDK 语义编写:Lua `sdk.*` 是公开契约,
|
||||
// 与内核能力版本挂钩;不写版本就只能靠“调用时才发现是 nil”。
|
||||
if root := tryActiveSDKRoot(); root != "" {
|
||||
data.Plg.SDK = normalizeSDKVersion(readMetaVersion(root))
|
||||
}
|
||||
} else {
|
||||
if !isLua {
|
||||
sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
|
||||
data.ModulePath = name
|
||||
data.GoVersion = goVer
|
||||
@ -193,14 +187,7 @@ 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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
writeTemplate(filepath.Join(dir, "sdk.lua"), tmplSDKLua, data)
|
||||
} else {
|
||||
writeTemplate(filepath.Join(dir, "plugin.go"), tmplPluginGo, data)
|
||||
}
|
||||
@ -223,43 +210,6 @@ 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()
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
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,7 +1,5 @@
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// tmplPlgJSON is the plg.json template
|
||||
const tmplPlgJSON = `{
|
||||
"name": "{{.Plg.Name}}",
|
||||
@ -93,14 +91,74 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
}
|
||||
`
|
||||
|
||||
// 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 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
|
||||
`
|
||||
|
||||
const tmplMainLua = `-- {{.Plg.Name}} plugin
|
||||
local plugin = { name = "{{.Plg.Name}}" }
|
||||
|
||||
Reference in New Issue
Block a user