33 Commits
v1.3.0 ... main

Author SHA1 Message Date
c7e5d2a446 docs: README 补文档站入口,并把「SDK 源码」链接迁到 GitHub
SDK 仓此前**没有任何地方指向文档站** —— README 不写、GitHub 仓的
homepage 字段也是空的,于是从仓首页根本走不到 sdk.homeagent.jianfgit.xyz。
文档站本身一直在线(106 个文件的 mkdocs 产物),只是没人从仓里指过去。

本次补上:
  - README.md / README_EN.md 顶部加文档站入口(快速开始 / API 参考 / 能力边界
    / llms.txt + llms-full.txt 的 agent 直读入口),并在末尾加「文档」章节
    列出全部页面的直链。
  - mkdocs.yml 的社交链接由 gitcode 改指 GitHub,并**新增** gitcode 镜像入口与
    介绍站入口(国内直连 gitcode 更快,海外/权威源看 GitHub,两个都留)。
  - docs/guide/getting-started.md 的 `git clone` 地址改为 GitHub 仓名
    (homeagentsdk),并写明「release 附件暂时仍在 gitcode」这个事实。

★ 刻意**不**改的东西:Go 模块路径 `gitcode.com/JianFeeeee/homeagent-sdk`。
改模块路径会让所有现有插件的 go.mod 全面失效,属于破坏性变更,
与「仓库托管位置迁移」是两件事。

关于 GitHub 侧没有 release 条目(因此不能写 releases/latest/download/...,
那会 404):已在 README 里如实写明 —— 二进制目前只在 gitcode 的 release 附件里,
GitHub 从下一个 SDK 版本(v1.4.0)起才会同步发布。

验证:两份 README 里 28 个 URL 全部实测 200;文档站重建(106 文件)并部署,
线上 `git clone` 已是 GitHub 地址、旧 gitcode clone 地址 0 次、
三个社交入口名称正确。
2026-09-29 20:43:27 +08:00
c16411a77d ci: 修 CI 自身的三处错误(本地能过 ≠ CI 能过)
首次跑出来的 8 个失败全是本 workflow 自己的问题,不是代码缺陷。
三条都属同一类:**命令本地验证过,但环境前提不同**。

## 1. hmapdev 交叉编译:在错误的目录跑(5 个平台全红)

     no Go files in /home/runner/work/homeagentsdk/homeagentsdk

`go build .` 缺 `working-directory: tools/hmapdev`,于是在仓库根执行 ——
根目录没有 Go 文件(包在 sdk/ 子目录)。加 working-directory 后本地实测
linux/arm64、darwin/arm64、windows/amd64 均产出 27–30MB 二进制。

## 2. Examples:装了 Go 1.21,而 examples 要求 1.25

     go: go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)

`go-version-file: go.mod` 读的是**根** go.mod —— 它写 `go 1.21.0`,
而 20 个 `example/*/go.mod` 都要求 `go 1.25.0`(只有根与 tools/hmapdev 是 1.21)。
于是 CI 装 1.21,examples 构建必失败。

★ 为什么本地测不出来:本机 Go 1.27 且 GOTOOLCHAIN 默认可自动取更高工具链,
  更高版本能满足 1.25 的下限,所以一路通过。而 GitHub runner 上
  **GOTOOLCHAIN=local**,Go 拒绝自动下载工具链,低版本直接报错。
  本地「能过」在这里完全不构成证据。

改法:SDK CI 全部钉 `go-version: '1.25'`(同时满足 1.21 的下限与
examples 的 1.25 要求)。

## 3. 技能检查:hmapdev 建到了 /tmp

     error: no active SDK version set

`resolveSkillsSource()` 先用**可执行文件位置**向上找仓库
(tools/hmapdev → ../../skills),落空才回退到 SDK store 的活跃版本。
我把二进制建到 /tmp/hmapdev ⇒ 推出仓库失败 ⇒ 回退 ⇒ CI 里没有 store ⇒ 报错。

改法:建到仓库内 `<repo>/build/hmapdev_ci`。本地实测(构建在仓库内时,
即便从 /tmp 调用也能正确识别仓库源——解析依据是 exe 位置而非 cwd)。

## 附带记录

- 根 go.mod(1.21) 与 example/*/go.mod(1.25) 的版本不一致本身值得关注:
  用 Go 1.22–1.24 的用户按示例走会失败。是否统一属产品决策,本次不动。
- actionlint 全绿。
2026-09-29 15:29:16 +08:00
5759e58bad ci: 建立 CI —— 此前 main 的推送与 PR 完全没有检查
## 缺口

本仓此前**只有 release.yml**(只在 release/** 推送时跑)。也就是说
推 main、开 PR 一律无检查,而 SDK 正是外部插件开发者直接依赖的契约面
(sdk/plugin.go 接口一破,所有外部插件编译失败)。

## 四个 job(每条命令都本地实测过)

| job | 内容 | 实测耗时 |
|---|---|---|
| go | 根模块 build + vet + test | <1s |
| hmapdev | 嵌套 module 测试 + 五平台交叉编译(矩阵) | ~2s + 编译 |
| examples | 构建全部 20 个示例(linux/amd64、darwin/arm64) | 9s / 21s |
| consistency | Lua SDK 三副本一致 + skills 可识别 + mkdocs --strict | ~2s |

## 三处「不能想当然」的地方(都有实测依据)

1. **示例不能用 `go build` 验**。它们是插件(只有 plugin.go、没有 func main),
   必须由 hmapdev 注入 main 包装;直接 go build 得到
   "function main is undeclared in the main package" —— 那不是缺陷,是方式不对。
   故与发版走**同一个脚本**(package/build-examples.sh),避免 CI 与发版路径分叉。
2. **tools/hmapdev 是独立 module**,根模块的 `go test ./...` 不会进入它 ——
   必须单独跑,否则它的测试永远不在 CI 里执行(Go 的模块边界)。
3. **不能用 `yaml.safe_load` 校验 mkdocs.yml**:它含 mkdocs-material 的
   `!!python/name:` 标签(配置 emoji 的官方写法),safe_load 报
   ConstructorError —— 是校验方式不对。改用 `mkdocs build --strict`
   (本地实测 2s、0 warning)。

## 明确不进 CI

- `hmapdev skill install` —— 它会写开发者本机的 ~/.claude、~/.codex 等目录
- 需要内核仓在场的检查(本仓独立可测)
- 任何网络/真机依赖

CGO_ENABLED=0(SDK 纯 Go,与主仓相反)。

actionlint 全绿(修掉一处 shellcheck SC2012)。
2026-09-29 15:18:58 +08:00
275a054287 fix(packaging): build-examples.sh 用新变量名 HMAPDEV 会 unbound variable
## 症状

按注释声称的「兼容」用法传新名,脚本立刻死:

    HMAPDEV=... bash package/build-examples.sh linux/amd64 /tmp/out
    → package/build-examples.sh: line 72: PLUGINDEV: unbound variable   (exit 1)

而传旧名一切正常:

    PLUGINDEV=... bash package/build-examples.sh linux/amd64 /tmp/out
    → 成功 20 / 失败 0

## 根因

第 71 行写的是:

    HMAPDEV="${HMAPDEV:-${PLUGINDEV:-$SDK_ROOT/build/hmapdev}}"

这**只定义了 HMAPDEV**;PLUGINDEV 若未设置就仍是未定义变量。
而紧接着的 72/77/100 三行读的却是 `$PLUGINDEV`:

    if [ ! -x "$PLUGINDEV" ]; then        # ← 未定义 → set -u 下致命
    out=$( cd "$dir" && "$PLUGINDEV" build ... )

于是只有当调用方**恰好传旧名**时 PLUGINDEV 才有值、才工作。
注释说「旧变量名 PLUGINDEV 仍兼容」——兼容的方向反了:是旧名恰好能跑,
新名不能。

## 为何一直没暴露

发版路径传的正是旧名(build.sh:92 `PLUGINDEV="$dev" ...`),
所以 release 流程一路是绿的。任何人按新名(或根本不传)调用都会撞上。

## 修法

三处改读 `$HMAPDEV`(第 71 行刚解析出的规范名)。

## 验证(三种调用方式都要能工作)

    A) HMAPDEV=...    → exit 0,20/20,20 个 .hmap(修复前 exit 1)
    B) PLUGINDEV=...  → exit 0,20/20(向后兼容保持)
    C) 都不传          → 自动先构建 hmapdev,再 20/20

跨平台也验过:linux/arm64 20/20(19s)、darwin/amd64 20/20(21s);
windows 被有意拒绝并给出明确原因(协议 2 的统一共享内存区未移植)。
2026-09-29 15:18:43 +08:00
56c694cd7c ci: gh release download 需显式 --repo
回读校验先 cd /tmp/back(非 git 目录),而 gh 默认从当前目录的
git 上下文推断仓库,于是报 "not a git repository" —— 发布本身
成功(tag/release/附件齐全),却被这道校验误判为失败。

改为 gh release download "$TAG" --repo "$GITHUB_REPOSITORY"。
2026-09-29 14:39:12 +08:00
dc9e7d0495 ci: SDK 仓发布流水线 —— release/** 推送即出 hmapdev 五平台产物
与主仓 Release 流水线同构,差异只在产物与打包命令:

  prepare  读 meta.Version;tag 已存在则整轮跳过;go test 门可显式跳过
           (改 meta 的提交里写 [skip-release-tests],查该提交而非 HEAD)
  build    go build(硬门)→ go test → build.sh all hmapdev → 验证 5 平台齐全
           → 生成 SHA256SUMS → artifact
  publish  打 tag → gh release create 传附件 → 回读校验
  sync-gitcode  有 GITCODE_TOKEN 时同步(无则跳过)

产物清单不是猜的,依 gitcode 上 v1.2.0/v1.3.0 的实际附件(各 6 个):
  hmapdev_{linux,darwin}_{amd64,arm64} + hmapdev_windows_amd64.exe + SHA256SUMS

两个易错点都有实测依据:

1. **测试要分两处跑**:tools/hmapdev 是**独立 module**,根模块的
   `go test ./...` 不会进入它(Go 的模块边界,不是配置问题)。
2. **gitcode 上传必须显式列文件名**:上传脚本的路径语义是
   `os.path.join(ASSET_DIR, name)`,所以要 cd 进目录 + `ASSET_DIR=.` + 裸名;
   而它按扩展名识别产物的默认扫描对 hmapdev **无效** —— 五个产物里只有
   windows 那个有扩展名,自动扫描会静默地一个都不传。
   故把主仓的 upload_assets.py 一并纳入本仓 scripts/(两仓各自独立可取)。

本地已验证:`VERSION=1.4.0 bash package/build.sh all hmapdev` 产出 5 个
二进制(各 27–29M),根模块 go test 通过,actionlint 全绿。
2026-09-29 14:09:35 +08:00
29c61f0e07 feat(hmapdev): skill 子命令 —— 插件开发知识随 SDK 分发
## 为什么需要

HomeAgent 插件开发的知识(hmapdev 工具链、SDK 版本坑、plg.json、
plugin.bin 部署、内置 vs 独立二进制)此前只存在于**某个 agent 的对话历史**里。
本机四个 agent(pi / claude / codex / .agents)都不会开发插件,
因为它们**没有任何渠道**拿到这些知识。

知识属于 SDK(与工具链同源、随 SDK 版本走),所以:
- 源:`skills/`(随 SDK 仓分发)
- 装:`hmapdev skill install`

## 命令

    hmapdev skill list              列出活跃 SDK 里的 skills
    hmapdev skill install [name...] 装到各 agent 的 skills 目录
    hmapdev skill path               显示源目录与来源

安装目标(**只装这些**,不认识的目录不创建):

    ~/.pi/agent/skills   ~/.claude/skills
    ~/.codex/skills       ~/.agents/skills

## 两条策略,与别处**故意相反**

1. **总是覆盖**。skill 是**工具生成物**,不是用户数据。保留用户改过的版本
   会让它与工具链脱节 —— 而工具链的命令面会随版本变。
   (对比:`hmapdev build` 写出的适配器更新会**保护**用户修改,
   因为那是运行时代码;skill 只是说明文档。)
2. **不扫 glob 自动发现**。不认识的目录建出来也没用,
   还会让用户以为装上了。

## 源目录:仓库优先,store 回退

store(`~/.homeagent/hmapdev/sdk/<v>/`)是 `sdk install` 复制的**副本**。
而 skill 是纯文档、加它不需要动 SDK 的编译产物 ——
「改了 skill 却要重装 SDK 才能生效」对日常维护不合理。

故优先用 hmapdev **自身所在仓库**的 `skills/`(靠可执行文件位置反推,
并校验 go.mod 的 module 是 homeagent-sdk),落空才回退 store。

## 判据 9 条(cmd_skill_test.go)

覆盖:目标无重复/不逃出 home、**真实仓库**里 skills/ 的布局合规、
隐藏目录与普通文件不算 skill、幂等、覆盖用户修改、跳过未知目标、
真实入口 `cmdSkillInstall` 覆盖用户修改、未知 skill 名不静默。

### 写判据时踩的三个坑

1. **`TestSkillSourceHasValidLayout` 原用 `t.TempDir()`** ⇒ 那个目录是空的,
   判据永远红,且红得毫无意义("文件不存在"是真的,但真实文件在 SDK 仓里)。
   改为指向**真实仓库**。
2. **fixture 造错**:我 `MkdirAll` 出一个叫 `README.md` 的**目录**,
   于是实现"正确地"把它当 skill,判据却报「把 README.md 当成了 skill」。
   是 fixture 错,不是实现错。
3. ★ **`TestSkillInstallOverwritesUserEdit` 只验 `copyDir`、没走真实入口**
   ⇒ 我把 `cmdSkillInstall` 里的 copyDir 换成「已存在就跳过」,
   **判据依然全绿**。变异测试抓出来的。补 `TestCmdSkillInstallOverwritesUserEdit`
   走真实入口后,变异立刻变红。

## 实测

删掉三处 skill 后 `hmapdev skill install` 一条命令装回四处,
四处 md5 与源一致,二次安装结果不变(幂等)。

## 门禁

- `go test ./...`(hmapdev 独立 module):ok,0 FAIL
- `go build ./...`:ok
- 9 条判据全通过,变异测试确认能抓回归
2026-09-28 11:23:58 +08:00
82e8d9dbac docs: 补两篇指南 —— 工具并发声明、流式多 tool_call
`ParallelSafe` / `Serial` / `stream_index` 三个新能力此前**零文档**:
README 提到 `Serial` 的那处是 UART 串口,与并发声明无关。插件作者只能
读源码注释才能知道这些字段存在及其优先级。

## docs/guide/parallel-tool-declaration.md

- 保守 opt-in 的理由:存量插件不改一行就得串行,不会被升级意外并发
- 声明 `ParallelSafe` 的三个条件(线程安全 / 不争抢资源 / 顺序无关)
- `Serial` 存在的意义:让"我确认过**必须**串行"与"我没想过"可区分
- **`Serial` 胜出**,不允许被 `ParallelSafe` 或默认值覆盖
- 声明字段放在 `ToolDef` 末尾,遵循既有 `NoMemory` 风格
- 压测效果 N=2/4/8 → 1.41×/2.22×/3.88×,并附"必须同时统计实际执行数"
  的理由(旧适配器耗时更短但实际处理 0 个工具)

## docs/guide/stream-tool-call-index.md

面向写 Lua 适配器的人:

- 上游 `index` 字段的用途:分桶累积 `id`/`name`/`arguments`
- **键名是 `stream_index` 不是 `index`** —— 写错会被 Go 解码器静默丢弃
- 不透传的实际后果:name 互相覆盖、args 碎片混拼、工具被当空参数调用
- 顺带记两个易踩点:不能按 name 过滤分片;扁平结构的协议族同样要带
- 自检命令;并注明 `gemini.lua` 不涉及(协议是 `functionCall`)

## 其它

- `mkdocs.yml` nav 登记两篇 —— 之前它们会被 mkdocs 明确警告
  "not included in the nav configuration",等于在站点里不可达
- 重跑 `tools/apidoc/build.sh` 同步 `docs/api/*`、`llms.txt`(生成物)

核实过的事实,避免臆造:
- 内置工具声明是**核心仓**的 `toolDefOptions`/`parallelOpts()`
  (`internal/agent/core/tooldefs.go`),不是 `sdk.BuiltinToolDef` —— 初稿写错
- `gemini.lua` 对 `functionCall|tool_calls` 匹配数为 0,确认无流式实现
- `server/kimicode/anthropic/ollama` 四个适配器确有 `stream_index`(2~5 处)
- 跨仓相对链接不可解析,故改为纯文本路径指路

`docs/api/tools.md` 属生成物(首行注明"请勿手改"),其 `ToolDef` 签名被截断、
不展示字段 —— 这是生成器既有行为,本次 diff 只是行号漂移,未改它。
2026-09-27 22:25:38 +08:00
1b8218afa9 fix(tools): 审计器补齐 recv/selfCalls 索引与只读白名单;标注器定位修正
## audit_parallel 的四个误判来源

**① indexMethods 没设 recv 和 selfCalls ⇒ 递归跟进空转**
`scanWritesDeep` 靠 recv 组候选键、靠 selfCalls 才知道跟进谁,缺任一个
就静默漏判。handleRestart 只调 p.stopServer/startServer 两个方法,
因此被判成"只读" —— 而 stopServer 里有 `p.server = nil`。
(我先补了闭包那条分支的 selfCalls,误以为问题在闭包上,其实是
 indexMethods 整体缺字段。)

**② writePatterns 写了 ".Set(" 而 callString 收集的是方法名**
`p.sdk.Settings().Set(...)` 是三段链,callString 返回 "Settings.Set",
永远匹配不上 ".Set("。漏判比误判更难发现:结果看着仍然合理。
→ 模式改为不带括号。

**③ looksLikeExternal 用黑名单 ⇒ 纯读标准库调用全被判"可能写"**
Marshal / ReadAll / NewRequest / NewReader 都不带写操作迹象,却被判写,
120 个工具里 119 个判成 SERIAL —— 等于工具没在工作,却**看上去在工作**
(保守方向不会引起怀疑)。
→ 改为 knownReadOnlyCalls 白名单。判定原则:**默认怀疑,明确信任** ——
   写不动的东西要逐个列出来。

**④ 工具名提取打印 AST 内部结构**
`fmt.Sprintf("%v", a.Y)` 输出 `tp&{10500 10515 STRING "manage_social"}`,
名字里混着指针地址,人没法核对。
→ 改用 BasicLit.Value + 归一化去掉运行期前缀。

## annotate_parallel 的插入点定位

改文本匹配改结构体字面量这条路走了三次弯:
- 正则找"最后一个顶层字段"被嵌套 map 里的同形文本骗到,823 处重排改坏文件
- 变量前缀匹配没要求 RegisterTool( 在**同一行**,命中函数体里散落的字面量,
  起点错到 switch case 中间,报错指向一处看起来完全无辜的分支
- 只按行末**净**深度判断:Parameters 写在单行时(进出同一行,净变化 0)
  永远察觉不到曾进入深度 3,追踪一路跑到 1305 行才"收敛"
→ 最终按**行内峰值深度** + 记录进入深度 3 的行号判定。

## 幂等

未标注过的工具重复跑会插入第二份 ParallelSafe(duplicate field 编译错误)。
→ 加 blockHasDecl 预检。
2026-09-27 16:17:04 +08:00
deeda22650 tools: 工具并发安全审计器(audit_parallel + annotate_parallel)
两个工具,都是为了让「这个工具能不能并发」有**可复现的依据**,而不是靠人眼扫。

## tools/audit_parallel.go —— 审计

为什么需要它:正则扫 `ToolDef` 字面量**不可靠**。用它审计主仓 27 个工具时,
把 `config_set` 判成"无共享写",而它的 handler 其实在 `p.handleSet` 里且无锁 ——
原因:RegisterTool 第三个参数是方法名/闭包,正则看不到执行体。

本工具用 go/ast 从**注册点跟进到 handler 实现**,递归 3 层(带环检测),
输出三态:SAFE / SERIAL / UNKNOWN。
★ UNKNOWN 一律不声明并发安全 —— 追不到实现就不能声称安全。

### 开发它时踩的坑(都写在代码注释里)

- **通用 HTTP 包装器**:vanblog 的 manage_social / manage_settings 全走
  `p.do("GET"|"POST"|"PUT", ...)`,方法名毫无写操作迹象,纯靠名字匹配
  **全判成 SAFE** —— 而它们明确含 POST/PUT。审计工具给出与代码相反的结论,
  比不给结论更危险(人会信它)。改为按 HTTP 动词判定后,
  统计从 72/37 变为 46/63。
- **链式调用丢方法**:`p.sdk.Settings().Set(...)` 是三段链,只看最外层只得到
  "Settings",`.Set` 整个丢失 ⇒ handleConfigure(写配置 + 启停服务)被判只读。
  改为收集整条链的所有方法名。
- **变量名 ≠ 类型名**:调用点写 `p.handleRead()`,定义处是
  `func (p *Plugin) handleRead()`。拿变量名去查类型索引**永远匹配不上**,
  143 个工具全报 UNKNOWN。须建"变量名 → 接收者类型"索引。
- **方法接收者不是局部变量**:example 里根本没有 `p := &Plugin{}`,
  p 是 Start 的接收者,局部变量索引全空。须把接收者变量名也纳入索引。
- **枚举被字符串污染**:verdict = "Serial:true" 而 report 只认三个枚举值
  ⇒ 全部落进 UNKNOWN,输出"共 13:UNKNOWN 13",看着像工具没在工作。
- **作者声明必须优先**:我把"已声明"当 finding 记录后照常跑写入检测并
  **覆盖** verdict,于是 plugin_install(已标 Serial:true)被判 SAFE。

## tools/annotate_parallel/ —— 标注

按 SDK 风格插入声明项:Name 在首位,声明项在末尾(Parameters 之后、
handler 之前),不打散 gofmt 对齐。

插入点必须用**括号深度 + 记录进入深度 3 的行号**定位:
- `RegisterTool( =1, ToolDef{ =2, Parameters{ =3`
- 只判 depth==2 会在 `Name:` 行就返回(那行本来就是深度 2),
  插入点跑到 RegisterTool 之前,编译报 "expected 1 expression"
- 空 `properties: map[string]interface{}{}` 让深度**在同一行**进出平衡,
  所以"曾触及深度 3"也不能作门控,必须记行号
- 试过用正则找"最后一个顶层字段",被嵌套 map 里的同形文本骗到,
  823 处错误重排把文件改坏 —— 文本匹配改结构体字面量就是这条路

用法:go build -o /tmp/annotate ./tools/annotate_parallel
     /tmp/annotate <file> <tool:parallel|serial:说明> ...
2026-09-27 16:17:04 +08:00
1d7c330cfc fix(hmapdev): mocksdk 补齐 ToolDef 声明项,并加字段/类型一致性判据
## 问题

mocksdk(yaegi 解释执行时的替身 SDK)的 ToolDef 只有 6 个字段,
公共 SDK 已有 10 个 —— 缺 RecallPolicy / ParallelSafe / Serial。

★ 危害不在编译期,而在**调试期**:插件作者用 hmapdev 在本地解释执行时,
写了 ParallelSafe:true 照常跑、不报错;直到编译安装后才发现声明根本没
被内核读到。这类不一致不会让任何现有测试失败。

这正是"黑名单不会自动跟上新执行能力"的又一次复现:主 SDK 每加一个声明项,
替身不会自动跟上。

## 修复

① mocksdk/plugin.go 补齐 RecallPolicy / ParallelSafe / Serial 三个字段,
   注释写明"主 SDK 先加、mocksdk 没跟上"这段历史,避免后人再漂移。

② 新增 yaegi/mocksdk/parity_test.go:
   - TestMockSDKToolDefMatchesSDK  比对**字段名**集合,双向都查
     (少字段 = 声明静默失效;多字段 = 替身比本体还多,必有一方理解错了)
   - TestMockSDKToolDefTypesMatch 比对**字段类型**,逐个断言
     ★ 归一化必须抹掉所有空白:gofmt 打印 "interface{}" 而反射给
       "interface {}",这是打印格式差异。我第一版没抹空白,
       结果每个复合类型都被误报成"类型不符" —— 判据自己制造假警报。

判据放在 tools/hmapdev 模块内(它是独立 module,主模块不包含它),
跑法:cd tools/hmapdev && go test ./yaegi/mocksdk/
2026-09-27 16:17:04 +08:00
bd73a9b241 feat(sdk): 通用反代声明项(DeclareProxy)+ ToolDef.Serial 串行标记 + 场面策略文档
本次一并提交工作区此前累积的改动(均已验证),并接入工具并发调度所需的
声明项。

把「谁来反代谁」从内核硬编码变成插件可声明。设备网关(remotedevice)
这类**编译进内核、没有独立插件目录与 plugin.json** 的服务,静态扫描扫不到,
此前只能靠约定。新增 DeclareProxy 让它们能自己声明反代路由。

ParallelSafe 的**反向**声明项。判据优先级:Serial 胜出,显式声明不允许被
ParallelSafe 或任何默认值覆盖。

为什么需要它:ParallelSafe 零值 false 已表达「安全/串行」,插件无法区分
「我没想过」和「我确认过必须串行」。没有这个区分,工具作者只能靠命名约定
传递意图,那不是契约。

ParallelSafe 本身也补齐了注释,明确其零值语义(默认串行、保守)与理由
(新语义下并发会改变工具的行为前提,让存量插件意外并发比慢一点危险得多)。

配套 ScenePolicy 声明项的使用说明。

remotedevice/ 整目录(C 实现的设备网关,已由 Go 侧 DeclareProxy 路径取代)。

- sdk/knowledge.go:随场面策略配套调整
- docs/api/*、docs/assets/api-index.json、docs/llms.txt、mkdocs.yml:
  由 tools/apidoc/build.sh 从源码重新生成(行号随 plugin.go 变动漂移)
2026-09-27 16:17:04 +08:00
dev
e417c69fc8 fix(example/bili): 进程组隔离 + 可取消的下载 —— 修拖死内核关停
## 现象

线上关停必超时:systemd 报 `State 'stop-sigterm' timed out. Killing.`,
其中只有 bili 报 `[proc] bili SIGKILL 后 2s 仍未被收割`,之后近 90 秒无日志。

## 根因

本插件用 `exec.Command("yt-dlp", ...)` + `cmd.Run()`:
- 无 CommandContext ⇒ Stop 无法取消
- 无 Setpgid      ⇒ yt-dlp 与本插件同进程组
- Stop() 是 `return nil` ⇒ 内核 Kill 插件本体时,yt-dlp 变孤儿

yt-dlp 还会再 fork ffmpeg,**孙进程继承本插件的 stdout 管道写端**。
插件被 SIGKILL 后孙进程仍持有写端 ⇒ 内核 readLoop 永远等不到 EOF
⇒ `Kill()` 末尾的 readerWG.Wait 永不返回 ⇒ 整个关停挂死。

## 改法

1. 两处 `exec.Command` → `exec.CommandContext`,Stop 里 cancel 能掐掉
2. `setPgid` 让命令自成进程组:yt-dlp 拉起的 ffmpeg 也在组内,
   kill(-pgid) 能一次带走整棵子进程树,不留孤儿
3. `Stop()` 不再是空实现:cancel 之后**必须 wait**。
   只 cancel 不 wait 的话内核会先释放共享段,而 yt-dlp 还在写 stdout ——
   那正是内核 readLoop 挂死的成因。`runWG` 等它真正退出。

顺带:取消时返回明确文案("已取消(插件停止)")而不是含糊的 exec 错误。

内核侧的三处修法(Setpgid / 杀进程组 / readerWG 超时)在主仓 ceeef0b,
对所有 8 个同样 exec.Command 且无进程组隔离的插件都有效。

验证:hmapdev build 通过;主仓部署后实测关停 90s → 1s、
`[homed] stopped` 打出、孙进程无残留。
2026-09-26 17:08:34 +08:00
a176cc3e20 feat(sdk): 反代声明(ProxyDef / RegisterProxy)—— 插件声明服务,HomeAgent 反代出去
配套核心仓「webui 通用反向代理」。SDK 1.4.0 尚未发布,接口未冻结,
本次按开发期自由变更处理(正式发版时并入版本号推进)。

## 声明契约

plugin.json 的 proxies 字段(声明式,静态可发现)或 RegisterProxy
(运行期,供没有 plugin.json 的内置插件用):

    {"name":"ui","host":"myapp","path":"/p/myapp","strip_path":true,
     "target":"127.0.0.1:12100","auth":"homeagent"}

命名与既有能力对齐(ToolDef / ChannelDef / ConfigDef / RegisterTool /
ToolRegistrar)——第一版写成 ProxyDecl / DeclareProxy / ProxyDeclarer
被评审指出「跟 SDK 其他接口不是一个风格」,已全面改名。

## strip_path:Path 的两种语义

Path 不能一刀切成「原样保留」,真实需求有两种且**不能自动判定**
(同一个 path 在两种语义下都说得通,猜错即全部 404 且像上游故障):

  strip_path 缺省/false(别名模式)—— path 是上游真实路径的一部分
    /api/v1/device/ws + path=/api/v1/device → 上游收到原样
    适用:客户端**已硬编码**路径的机器接口(设备网关即如此)

  strip_path=true(前缀模式)—— path 只是门户上的挂载点
    /p/myapp/api/status + path=/p/myapp → 上游收到 /api/status
    适用:自带 UI 的服务(前端用相对路径)

非法组合(strip_path 而无 path)被 ValidateProxyDef 挡住。

## 单一入口原则(契约级要求)

一个声明 = 一个入口。两种挂载形态对「根路径」处理截然不同:
Host 形态下根路径是插件的根(fetch('/api/x') 天然正确);
Path 形态下根路径**属于门户**,同样代码会打到门户自己身上
(静默错路由:页面能开、功能全坏)。

故被反代的插件必须**一律使用相对路径**,绝不硬编码以 / 开头的绝对路径。
这样同一份前端在两种形态下都正确,插件不必知道自己被挂在哪,
反代层也能按外部条件(子域是否有证书/放行)自由选择形态。

## 判据

sdk/proxy_test.go:两种语义的映射、非法组合、单一入口原则的契约存在性。
hmapdev proxy_config_test.go:schema 漂移保护(新增字段忘了同步就判红)、
非法声明在**打包时**就被拒(不必装到 HomeAgent 才看到)。

两模块 go test 全绿;文档站已重新生成(ProxyDef 与单一入口原则进入
docs/api/misc.md 与 llms-full.txt)。

## 顺带修回的一处(此前随工作树丢失)

writePluginJSON 漏写 proxies 键 —— 漏写的话插件装得上、启动正常、
就是不出现,没有任何报错。该 bug 曾在核心仓侧出现过(判据抓到过),
这次移植时由 TestWritePluginJSONPreservesProxies 再次判红并修复。
2026-09-26 14:08:30 +08:00
255d6479ad feat(docs): 换品牌图标 + 补备案号 + 给 agent 的直读入口
三件事,都是「站点上线后看出来的」问题。

## ① 图标用的是 Material 默认,不是我们的

站上 favicon 是 mkdocs-material 自带的那张(`assets/images/favicon.png`),
与主站 introduce 不一致。改为引入主站同一份 logo:

- `docs/assets/logo.svg` —— 原样(浅色底,适合 favicon)
- `docs/assets/logo-mark.svg` —— 去掉底色矩形(否则在靛蓝页头上是个白方块)

`theme.favicon` / `theme.logo` 分别指向它们。实测子页面路径也正确
(mkdocs 生成 `../../assets/logo.svg`,不是错误的相对路径)。

## ② 缺备案号

`introduce` 底部有 ICP + 公安备案,文档站没有。Material 的 footer 只渲染
`config.copyright` 一个字符串,塞不进「两条带链接的备案」——所以覆盖了
`overrides/partials/footer.html`(`theme.custom_dir`),并顺带把许可也写进底部,
读者在任何一页都能看到,不必翻到首页。

## ③ 没有给 agent 的入口

站点是给人看的(HTML + 主题 + JS 搜索),但越来越多读者是 agent。让它们爬
HTML 既浪费 token(样板占大头)又容易漏内容。

新增 `tools/apidoc/gensite/agent.go`,随构建产出:

| 路径 | 内容 |
|---|---|
| `/llms.txt` | 站点目录:每页一行,带 URL 与一句话说明(4.3KB)|
| `/llms-full.txt` | 全部文档正文拼成一份,可一次读完(115KB)|
| `/<page>.md` | 每个页面的 Markdown 原文,含生成的 API 页(text/markdown)|

沿用 llms.txt 社区约定:`llms.txt` 读目录、`llms-full.txt` 一次读全。

**一个必须处理的坑**:mkdocs 只把 `.md` **渲染**成 HTML,不会把 Markdown
放进产物目录 —— 那样 `llms.txt` 里的链接会全部 404。所以 `build.sh` 增加了
第 4 步 `copy_agent_files`,在构建后把 23 个 Markdown 复制进 `site_build/`。

`docs/llms-full.txt` 已 gitignore:它是派生件,改任何一页都会整份重写,
进版本库只产生噪声(`llms.txt` 索引小且稳定,仍提交)。

验证:`mkdocs build --strict` 零告警;favicon/logo 可取(200,naturalWidth=400);
三页底部均含两个备案号;4 个 agent 入口均可访问且 content-type 正确。
2026-09-24 13:27:58 +08:00
9b6abe1b73 fix(docs): 中文搜不到英文注释的 API —— 补关键词层
## 问题(实测)

SDK 里 100 个有摘要的符号中 **66 个是英文注释**,例如:

    RegisterTool registers a tool that the LLM can call.

于是搜「注册工具」——中文受众最自然的问法——**RegisterTool 得分 0,一条都搜不到**。
更糟的是逐字匹配把噪声顶上来了:搜「注册工具」返回 24 条,排第一的是
`SetToolBlocks`(描述里有「工具」二字),`RegisterTool` 根本不在列表里。

## 修法

不改源码注释(那会让代码与文档脱节),而是在检索索引上加一层**人工标注的
中文功能词**:`tools/apidoc/keywords.json`。

- `rules`:按符号名前缀/子串批量覆盖(`Register*` 全带「注册」,`*Memory*` 带「记忆」)
- `symbols`:逐符号补充(重点 API、或规则覆盖不到的)

词只进 `api-index.json` 的 `g` 字段,**不影响页面展示**;检索结果里会显示
(「为何命中」),读者能理解排序依据。

同时把中文逐字匹配从主信号降为**弱信号**(要求 60% 以上字符命中)——
它正是噪声来源:凡是含「工具」二字的说明都会被「注册工具」匹上。

## 结果

| 查询 | 修改前 | 修改后 |
|---|---|---|
| 注册工具 | 24 条,RegisterTool 缺席 | **7 条,RegisterTool 第一** |
| 崩溃 | 1 条 | 2 条(SetAutoRestart + AutoRestart)|
| InjectText | 7 条(含重复)| 6 条 |
| memory.recall | 1 条 | 1 条(不变)|

顺带修掉索引重复:接口会同时作为 `type` 符号与接口本身被加两次
(`MemoryAPI` 等 11 个各重复一条)。现在接口只走接口那条路径,索引 200 → 189 条。

keywords.json 是**可选**的:读不到只警告不中断,检索退化为原行为。
2026-09-24 13:00:32 +08:00
0a6e2b7dc4 docs: 插件 SDK 文档站(API 参考从源码生成 + 自建检索)
为插件作者建一个文档站,重点是**能按描述搜到 API**,以及**明确能力边界**。

## 为什么 API 参考要生成而不是手写

公开 API 面有 115 个符号、11 个接口。手抄必然与代码漂移——这是文档站最常见的
死法(本仓 README 里已经有过几处「文档说一套、代码是另一套」)。

所以 `tools/apidoc` 直接从 `sdk/*.go` 提取签名、文档注释与代码块示例,渲染成
`docs/api/*.md`。发现文档不对时改的是**源码注释**,不是生成物。生成页首行带
「勿手改」标记,防止有人改了下次构建白改。

- `extract.go`:go/ast + go/doc 提取(只用标准库,离线可跑,不引入依赖)
- `gensite/`:渲染 Markdown + 检索索引
- `gensite/usages.go`:从 `example/` 21 个示例插件里反查**真实调用点**,
  贴在每个 API 下(源码注释里几乎没有可运行示例,但示例插件都是能编译跑的真代码)

## 能力边界:写这个站时查出的三处文档错误

这是本次最有价值的部分。原以为「公开 SDK 里有的 API 外部插件都能用」,
实测对照桥接模板后发现三处不符,站内已更正:

1. **`PluginMgr()` 被写成「仅内置可用」——错的。** 桥接模板第 692 行显式
   `base.SetPluginMgrAPI(procPluginMgr{})`,公开 `PluginMgrAPI` 注释也写「外部插件可调用」。
   真正的区别是**方法数**:公开面 3 个(ReloadOne/ListLoadedPlugins/IsPluginDisabled),
   内部面 9 个。容易混淆是因为两个包里有同名但不同的接口。
2. **`Events()` 外部插件恒为 nil。** `SetEventSubscriber` 全仓只有定义、无调用点,
   故 subscriber 从未被注入。外部插件的事件订阅实际由生成的运行时走
   `events.subscribe` RPC 完成——旧文档把它当成可用入口,会让人写出必然失效的代码。
3. **`UnregisterOutputChannel` 是静默无效,不是报错。** 桥接只注入 registrar、
   不注入 unregistrar,于是 `regOutputUnreg == nil`,函数命中 else 分支**直接返回 nil**
   (sdk/plugin.go:539-549)——不报错、通道也没注销。

每条裁定的依据写进 `tools/apidoc/tiers.json`(文件:行号 或 grep 结论),
站上以告警框呈现,读者可自行核对。判断依据三源:桥接模板的 `base.Set*` 注入点、
`internal/sdk` 完整面、`internal/plugin/proc/protocol.go` 的 RPC 表。

## 检索(用户的核心诉求)

两套互补:

- **MkDocs 内置搜索**:全文,中文走 jieba 分词。
- **自建 API 检索**(`docs/javascripts/api-search.js` + `assets/api-index.json`):
  支持四类查询——按名称、**按功能描述**(「注册工具」→ RegisterTool、
  「崩溃」→ SetAutoRestart)、按 `限定符.方法`(`memory.recall` → MemoryAPI.Recall)、
  按签名片段(`(string) error`)。并标出「仅内置」,避免外部插件作者踩空。

自建的理由:Material 内置搜索按整页文本索引,搜 `InjectText` 会列出所有提到它的
页面,但分不清哪条是它的定义;而且它要等 mkdocs build 才更新。

## 文档结构

- `docs/guide/`:快速开始、Go/Lua 首个插件、能力边界、打包发布、多平台、受限 SDK 与安全
- `docs/api/`:10 个按「你想做什么」划分的章节(工具/阶段/记忆/通道/配置/生命周期/
  事件/LLM/常量/桥接)+ 仅内置汇总页
- `docs/versions.md`:SDK 版本语义(跟随内核中版本、patch 恒为 .0)、
  1.0.0 是唯一破坏性变更、RPC 协议版本

## 验证

- `mkdocs build --strict` 零告警
- 23 个页面的全部站内链接与锚点可达(自动校验)
- 1440 / 768 / 390px 三视口:无横向溢出、无控制台错误
- 四种检索模式实测有结果且跳转锚点正确
- 构建产物 `site_build/` 已 gitignore

用法:`tools/apidoc/build.sh`(生成+构建)、`tools/apidoc/build.sh serve`(预览)。
2026-09-24 12:08:37 +08:00
e97cafc8de license: SDK 改用 MIT —— 让第三方插件不被 AGPL 传染
原 LICENSE 是 2026-09-12(93ab794)引入的 AGPL-3.0-only 全文,README 据此
声明「插件静态链接 SDK,故必须以相同许可发布,闭源只能另取商业授权」。
这堵死了闭源插件,与第三方插件生态的目标相反。

## 为什么 MIT 才自洽

SDK 会随插件一起静态链接(源码进入插件二进制)。用传染许可,插件作者就被
强制开源;用 MIT,插件作者可自由选择许可(闭源 / 商业 / 私有均可),无需
回馈、无需任何例外或商业授权。这是刻意的宽松,也是插件生态安全的基础。

MIT 在这里**不会与任何许可冲突**:SDK 完全自包含 —— go.mod 零外部依赖,
sdk/ 只 import Go 标准库(sync),不引用核心仓任何代码(已实测确认)。

## 改动

- LICENSE:AGPL-3.0-only 全文(661 行)→ MIT 正文(21 行,版权人 JianFeeeee)
- README.md / README_EN.md:许可章节重写,把「必须同许可」改为「可自选许可」,
  并写明自包含这一前提

内核仓仍为 AGPL-3.0-only(其 README 的对应论述与本提交同批更新)。

验证:go build ./sdk/... ./meta/... 通过。
2026-09-24 10:55:17 +08:00
5af2a86816 docs(sdk): 补全自动重启的真实约束(退避 / 上限 / 非无感)
原文只说「插件崩溃时平台自动拉起,保障服务可用性」,读起来像
无感瞬时恢复。实测与源码都不是这样:

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

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

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

## 顺带改准一处混淆

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 用法

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

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

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

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

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

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

## 验证

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

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

## 写法

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

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

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

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

## 验证

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 为什么

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

  plugindev_init → plugindev_build → plugin_install(path) → plgreload

## 工具面(5 个)

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

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

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

## 现场验证

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

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

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

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

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

251
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,251 @@
# HomeAgent SDK 仓 CI。
#
# 与 release.yml 分工:本文件管「推送到 main / PR 时的检查」,release.yml 管
# 「release/** 推送时的发版」。此前本仓**只有** release.yml —— main 的推送与
# PR 完全没有检查,而 SDK 是被外部插件开发者直接依赖的契约面(sdk/plugin.go
# 的接口一旦破,所有外部插件编译失败),必须有门。
#
# 设计原则与主仓一致:**CI 里每条命令都是本地已实测通过的**。
# 不写「应该有用来试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态。
#
# 明确不进 CI 的:
# - `hmapdev skill install`(会写 ~/.claude、~/.codex 等**开发者本机目录**)
# - 需要内核仓在场的检查(本仓独立可测;sync-lua-sdk.sh 会在缺内核时
# 明确 skip 而不是假装成功)
# - 任何网络/真机依赖
name: CI
on:
push:
branches: [main, 'release/**']
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# SDK 本身不需要 cgo(hmapdev 与 sdk 包都是纯 Go)—— 与主仓相反。
CGO_ENABLED: 0
GOFLAGS: -buildvcs=false
jobs:
# ── 根模块:SDK 对外接口所在 ──
go:
name: Go build / vet / test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
# 读根 go.mod 会装 1.21,examples 构建必然失败
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
# 所以本地「能过」不代表 CI 能过。
- uses: actions/setup-go@v7
with:
go-version: '1.25'
cache: true
- name: go build ./...
run: go build ./...
- name: go vet ./...
run: go vet ./...
- name: go test ./...
run: go test ./... -count=1 -timeout 10m
# ── hmapdev 工具链(嵌套 module,根模块的 ./... 不会进入它)──
hmapdev:
name: hmapdev (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
ext: ''
- goos: linux
goarch: arm64
ext: ''
- goos: darwin
goarch: amd64
ext: ''
- goos: darwin
goarch: arm64
ext: ''
- goos: windows
goarch: amd64
ext: '.exe'
steps:
- uses: actions/checkout@v7
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
# 读根 go.mod 会装 1.21,examples 构建必然失败
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
# 所以本地「能过」不代表 CI 能过。
- uses: actions/setup-go@v7
with:
go-version: '1.25'
cache: true
# tools/hmapdev 是**独立 module**(有自己的 go.mod)——
# 根模块的 `go test ./...` 不会进入它,必须单独跑,否则它的测试
# 永远不在 CI 里执行。这是 Go 的模块边界,不是配置问题。
- name: go test(嵌套 module)
working-directory: tools/hmapdev
run: go test ./... -count=1 -timeout 10m
# main 包在 tools/hmapdev —— 不指定工作目录会在仓库根跑 `go build .`,
# 而根目录没有 Go 文件(包在 sdk/ 子目录),报 "no Go files in ..."。
- name: 交叉编译
working-directory: tools/hmapdev
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
set -euo pipefail
out="hmapdev_${{ matrix.goos }}_${{ matrix.goarch }}${{ matrix.ext }}"
go build -trimpath \
-ldflags "-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=0.0.0-ci" \
-o "/tmp/$out" .
test -s "/tmp/$out" || { echo "ERROR: 未产出 $out"; exit 1; }
printf " ✓ %-28s %s 字节\n" "$out" "$(stat -c %s "/tmp/$out")"
file "/tmp/$out"
# ── 示例插件:发版产物里最容易被使用者直接安装的东西 ──
#
# 为什么必须验:示例是使用者的模板(拷了就改),且**发版会连同 .hmap 一起发**
# —— 插件二进制与内核是协议绑定的(ProtocolVersion + 统一共享内存区魔数),
# 示例编不出来就意味着这次发版发不出配套产物。
#
# ★ 示例**不能**用 `go build` 验:它们是插件(只有 plugin.go、没有 func main),
# 必须由 hmapdev 注入 main 包装。直接 go build 会得到
# "function main is undeclared in the main package" —— 那不是缺陷,
# 是构建方式不对(本地实测确认)。
examples:
name: Examples (${{ matrix.target }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
# windows 不在列:build-examples.sh 会明确拒绝(协议 2 的统一共享
# 内存区未移植到 Windows),那是有意的设计而不是待修缺陷。
target: ['linux/amd64', 'darwin/arm64']
steps:
- uses: actions/checkout@v7
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
# 读根 go.mod 会装 1.21,examples 构建必然失败
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
# 所以本地「能过」不代表 CI 能过。
- uses: actions/setup-go@v7
with:
go-version: '1.25'
cache: true
- name: 构建 hmapdev(宿主)
run: |
set -euo pipefail
cd tools/hmapdev
go build -o "$GITHUB_WORKSPACE/build/hmapdev_host" .
# 用发版同一个脚本(package/build-examples.sh),避免 CI 与发版路径分叉。
- name: 构建全部示例
env:
HMAPDEV: ${{ github.workspace }}/build/hmapdev_host
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
bash package/build-examples.sh "$TARGET" /tmp/examples
echo
n=$(find /tmp/examples -name '*.hmap' | wc -l)
echo " .hmap 产物数: $n"
# 示例目录数应与产物数一致(少一个都要红)
want=$(find example -mindepth 2 -maxdepth 2 -name plugin.go | wc -l)
echo " 示例源码数: $want"
[ "$n" -ge "$want" ] || { echo "ERROR: 产物少于示例数"; exit 1; }
for f in /tmp/examples/*.hmap; do
printf " %9.1fKB %s\n" \
"$(stat -c %s "$f" | awk '{print $1/1024}')" "$(basename "$f")"
done
# ── 一致性与文档 ──
consistency:
name: Consistency & docs
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
# sdk.lua 有**四份**副本(本仓三份 + 内核仓一份),历史上漂移过,
# 出现「mock 有、内核没有」的静默失配。此处钉住本仓内的三份。
- name: Lua SDK 副本一致性
run: |
set -euo pipefail
canon=sdk/lua/sdk.lua
[ -f "$canon" ] || { echo "ERROR: 缺 $canon"; exit 1; }
want=$(sha256sum "$canon" | cut -d' ' -f1)
fail=0
for f in tools/hmapdev/assets/sdk.lua example/luademo/sdk.lua; do
have=$(sha256sum "$f" | cut -d' ' -f1)
if [ "$have" = "$want" ]; then
echo " ✓ $f"
else
echo " ✗ $f 与 $canon 不一致" >&2
echo " canon=$want" >&2
echo " this =$have" >&2
echo " 解法:bash scripts/sync-lua-sdk.sh" >&2
fail=1
fi
done
[ "$fail" = "0" ] || exit 1
# ★ 钉 1.25 而不是 go-version-file: go.mod —— 根 go.mod 写的是
# go 1.21.0,但 20 个 example/*/go.mod 都要求 go 1.25.0。
# 读根 go.mod 会装 1.21,examples 构建必然失败
# ("go.mod requires go >= 1.25.0 (running go 1.21.0; GOTOOLCHAIN=local)")。
# GitHub runner 上 GOTOOLCHAIN=local,Go 不会自动下载更高工具链,
# 所以本地「能过」不代表 CI 能过。
- uses: actions/setup-go@v7
with:
go-version: '1.25'
cache: true
# skills/ 会随 SDK store 分发给所有 agent(hmapdev skill install)。
# skill list 会读 skills/ 目录并报告可用项 —— 目录结构坏掉会在这里红。
- name: 技能目录可被 hmapdev 识别
run: |
set -euo pipefail
# ★ 必须把 hmapdev 建在**仓库内**:resolveSkillsSource() 先从
# 可执行文件位置向上找仓库(tools/hmapdev → ../../skills),
# 落空才回退到 SDK store 的活跃版本 —— 而 CI 里没有 store,
# 于是报 "no active SDK version set"。建到 /tmp 就会落空。
mkdir -p build
( cd tools/hmapdev && go build -o "$GITHUB_WORKSPACE/build/hmapdev_ci" . )
./build/hmapdev_ci skill list
./build/hmapdev_ci skill path
- uses: actions/setup-python@v7
with:
python-version: '3.12'
# 文档站会被部署(deploy-sdk-site.sh)——站点构建坏了此前无从发现。
# --strict 把 warning 升级为 error:断链、缺失引用都会红。
#
# ★ 不能用 yaml.safe_load 校验 mkdocs.yml:它含 mkdocs-material 的
# `!!python/name:` 标签(配置 emoji 的官方写法),safe_load 处理不了,
# 会报 ConstructorError —— 那是校验方式不对,不是配置有问题。
- name: 文档站构建(mkdocs --strict)
run: |
set -euo pipefail
python3 -m pip install --quiet mkdocs-material
mkdocs build --strict --site-dir /tmp/site

314
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,314 @@
# HomeAgent SDK 仓发布流水线:release/** 推送即发版。
#
# 与主仓的 Release 流水线同构(见主仓 .github/workflows/release.yml),
# 差异只在产物与打包命令:
#
# 主仓 → 3 个 deb + 1 个 tar.gz(含 719MB 向量模型)
# SDK → hmapdev_{linux,darwin}_{amd64,arm64} + hmapdev_windows_amd64.exe
# + SHA256SUMS(约 140MB)
#
# 产物清单依据:gitcode 上 v1.2.0/v1.3.0 的实际附件(各 6 个),
# 以及 docs/git-branching.md §七 的「发版产物清单」。
name: Release
on:
push:
branches: ['release/**']
workflow_dispatch:
inputs:
skip_tests:
description: '跳过发版前的 go test 门(仅用于已知红的历史维护线)'
type: boolean
default: false
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CGO_ENABLED: 0
GOFLAGS: -buildvcs=false
jobs:
prepare:
name: Prepare
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }}
prerelease: ${{ steps.ver.outputs.prerelease }}
exists: ${{ steps.ver.outputs.exists }}
skip_tests: ${{ steps.ver.outputs.skip_tests }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# 版本事实源是 meta/meta.go 的 Version,故发版动作 =
# 在 release/vX.Y.x 上把它改成目标版本后推送。
#
# 幂等闸门:tag 已存在 ⇒ 整轮跳过(改文档不会重发版)。
#
# go test 门可跳过:新旧发布线的测试健康状况不同,硬门会让历史
# 维护线完全无法发版。发版人可在**改动 meta 的那个提交**里写
# [skip-release-tests] 显式跳过 —— 决定因此可被 git 历史审计。
# (标记查在改 meta 的提交上而不是 HEAD:发版提交之后常还会跟
# 几个提交,只看 HEAD 会让标记被顶掉、静默失效。)
- id: ver
name: 读取 meta.Version 并检查 tag
run: |
set -euo pipefail
V=$(sed -n 's/^[[:space:]]*Version = "\(.*\)"/\1/p' \
meta/meta.go | head -1)
if [ -z "$V" ]; then
echo "ERROR: 无法从 meta/meta.go 读出 Version"
exit 1
fi
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "tag=v$V" >> "$GITHUB_OUTPUT"
case "$V" in
*-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;;
*) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;;
esac
if git ls-remote --exit-code --tags origin "refs/tags/v$V" \
>/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo " tag v$V 已存在 —— 跳过发版"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo " 将为 v$V 发版"
fi
SKIP="${{ inputs.skip_tests }}"
REL_COMMIT=$(git log -1 --format=%H -- meta/meta.go)
REL_MSG=$(git log -1 --pretty=%B "$REL_COMMIT")
case "$REL_MSG" in
*'[skip-release-tests]'*) MARKER=1 ;;
*) MARKER=0 ;;
esac
echo " 发版提交: ${REL_COMMIT:0:12}"
if [ "$SKIP" = "true" ] || [ "$MARKER" = "1" ]; then
echo "skip_tests=true" >> "$GITHUB_OUTPUT"
echo " ⚠️ **已请求跳过发版前的 go test 门**"
else
echo "skip_tests=false" >> "$GITHUB_OUTPUT"
echo " 发版前会跑 go test 门"
fi
build:
name: Build hmapdev
needs: prepare
if: needs.prepare.outputs.exists == 'false'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
# 门:go build 是硬门;go test 可在发版 commit 里显式跳过。
#
# 测试分两处跑 —— tools/hmapdev 是**独立 module**,根模块的
# `go test ./...` 不会进入它(这是 Go 的模块边界,不是配置问题)。
- name: go build(硬门)
run: |
set -euo pipefail
go build ./...
- name: go test(根模块)
if: needs.prepare.outputs.skip_tests != 'true'
run: go test ./... -count=1 -timeout 15m
- name: go test(tools/hmapdev 独立 module)
if: needs.prepare.outputs.skip_tests != 'true'
working-directory: tools/hmapdev
run: go test ./... -count=1 -timeout 15m
- name: go test 被跳过(显式声明的后果)
if: needs.prepare.outputs.skip_tests == 'true'
run: |
echo "::warning title=go test 门已跳过::本次发版未跑 go test,产物可能建立在单元测试失败的代码上。"
- name: 打包 hmapdev(5 个平台)
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
rm -rf build
bash package/build.sh all hmapdev
echo
echo " 产物:"
for f in build/*; do
printf " %8.1fMB %s\n" \
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$(basename "$f")"
done
# 逐个确认 5 个平台都产出了 —— 只查目录非空会漏掉"少了一个平台"。
- name: 验证产物齐全
run: |
set -euo pipefail
missing=0
for f in hmapdev_linux_amd64 hmapdev_linux_arm64 \
hmapdev_darwin_amd64 hmapdev_darwin_arm64 \
hmapdev_windows_amd64.exe; do
if [ -s "build/$f" ]; then
echo " ✓ $f"
else
echo " ✗ 缺 $f" >&2
missing=1
fi
done
[ "$missing" = "0" ] || exit 1
# 校验和必须**全部产物齐全之后**一次算完(边打边算会漏包);
# 且只覆盖本批产物 —— build/ 可能残留上次的,故先清干净再建。
- name: 生成 SHA256SUMS
run: |
set -euo pipefail
mkdir -p /tmp/out
cp build/hmapdev_linux_amd64 build/hmapdev_linux_arm64 \
build/hmapdev_darwin_amd64 build/hmapdev_darwin_arm64 \
build/hmapdev_windows_amd64.exe /tmp/out/
cd /tmp/out
sha256sum hmapdev_* > SHA256SUMS
echo " SHA256SUMS:"
sed 's/^/ /' SHA256SUMS
sha256sum -c SHA256SUMS
- uses: actions/upload-artifact@v7
with:
name: hmapdev
path: /tmp/out/*
retention-days: 7
if-no-files-found: error
publish:
name: Publish
needs: [prepare, build]
if: needs.prepare.outputs.exists == 'false'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
name: hmapdev
path: dist
- name: 打 tag
env:
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
- name: 建 release 并上传附件
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
PRE: ${{ needs.prepare.outputs.prerelease }}
run: |
set -euo pipefail
cd dist
FLAGS=()
[ "$PRE" = "true" ] && FLAGS+=(--prerelease)
gh release create "$TAG" \
--title "HomeAgent SDK $VERSION" \
--notes "HomeAgent 插件 SDK $VERSION
\`hmapdev\` 工具链(Linux / macOS / Windows,amd64 + arm64)。
校验见 SHA256SUMS。
内核版本需与 SDK 的中版本对齐;协议不配套时插件握手会失败
(魔数不匹配),此时升级内核或改用对应版本的 SDK。" \
"${FLAGS[@]}" \
./hmapdev_* ./SHA256SUMS
echo "=== release 内容 ==="
gh release view "$TAG" --json assets \
--jq '.assets[] | " \(.name) \(.size) 字节"'
- name: 回读校验
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
mkdir -p /tmp/back && cd /tmp/back
# 同主仓:/tmp/back 不是 git 仓库,gh 无法从上下文推断仓库,
# 必须显式 --repo。
gh release download "$TAG" --repo "$GITHUB_REPOSITORY"
for f in *; do
printf " %8.1fMB %s\n" \
"$(stat -c %s "$f" | awk '{print $1/1048576}')" "$f"
done
sha256sum -c SHA256SUMS
echo " ✓ 回读校验通过"
sync-gitcode:
name: Sync to gitcode
needs: [prepare, publish]
if: needs.prepare.outputs.exists == 'false'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- id: tok
name: 检查 gitcode 凭据
run: |
if [ -n "${{ secrets.GITCODE_TOKEN }}" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo " 未配置 GITCODE_TOKEN —— 跳过 gitcode 同步"
fi
- uses: actions/download-artifact@v8
if: steps.tok.outputs.ok == 'true'
with:
name: hmapdev
path: dist
- name: 推 tag 与附件到 gitcode
if: steps.tok.outputs.ok == 'true'
env:
GC_TOKEN: ${{ secrets.GITCODE_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "$TAG" 2>/dev/null || true
GC_URL="https://JianFeeeee:${GC_TOKEN}@gitcode.com"
git push "${GC_URL}/JianFeeeee/homeagent-sdk.git" "$TAG"
curl -sS --max-time 60 -X POST \
-H "private-token: ${GC_TOKEN}" \
-H "Content-Type: application/json" \
"https://gitcode.com/api/v5/repos/JianFeeeee/homeagent-sdk/releases" \
-d "{\"tag_name\":\"$TAG\",\"body\":\"同步自 GitHub\"}" \
-o /tmp/.gcrel -w " 建 release → %{http_code}\n"
cd dist
# 脚本的路径语义是 os.path.join(ASSET_DIR, name) ⇒
# 必须 cd 进资产目录、ASSET_DIR=.、并传**裸文件名**。
# 传 "./x" 或 "dist/x" 都会拼成 dist/dist/x 而找不到文件。
#
# 为何显式列名而不是让它自动扫描:自动扫描只认 ARTIFACT_SUFFIXES
# 里的扩展名,而 hmapdev 的产物多数**没有扩展名**(只有 windows
# 那个是 .exe)⇒ 自动扫描会静默地一个都不传。
ASSET_DIR=. GITCODE_REPO=JianFeeeee/homeagent-sdk \
python3 ../scripts/upload-assets.py "$TAG" "$GC_TOKEN" \
hmapdev_linux_amd64 hmapdev_linux_arm64 \
hmapdev_darwin_amd64 hmapdev_darwin_arm64 \
hmapdev_windows_amd64.exe SHA256SUMS

10
.gitignore vendored
View File

@ -35,3 +35,13 @@ z_entry.c
# plugindev binary in tools/ # plugindev binary in tools/
tools/plugindev/plugindev tools/plugindev/plugindev
# 文档站构建产物(由 tools/apidoc/build.sh 生成)
site_build/
# mkdocs 缓存
.cache/
# agent 入口的「全文汇总」是派生件:由 gensite 把 docs/ 下所有 Markdown 拼成一份,
# 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出,
# llms.txt(索引,小且稳定)仍提交。
docs/llms-full.txt

682
LICENSE
View File

@ -1,661 +1,21 @@
GNU AFFERO GENERAL PUBLIC LICENSE MIT License
Version 3, 19 November 2007
Copyright (c) 2026 JianFeeeee
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Permission is hereby granted, free of charge, to any person obtaining a copy
of this license document, but changing it is not allowed. of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
Preamble to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
The GNU Affero General Public License is a free, copyleft license for furnished to do so, subject to the following conditions:
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
our General Public Licenses are intended to guarantee your freedom to IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
share and change all versions of a program--to make sure it remains free FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
software for all its users. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
When we speak of free software, we are referring to freedom, not OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
price. Our General Public Licenses are designed to make sure that you SOFTWARE.
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

113
README.md
View File

@ -2,9 +2,18 @@
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。 HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
> 📖 **完整文档**:<https://sdk.homeagent.jianfgit.xyz/>
>
> 快速开始 / API 参考 / 指南 / 示例插件都在那里,**本 README 只是速览**,
> 接口细节以文档站为准。直接进入:[快速开始](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/)
> · [API 参考](https://sdk.homeagent.jianfgit.xyz/api/) · [能力边界](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/)
>
> 面向 agent 的纯文本入口:[`llms.txt`](https://sdk.homeagent.jianfgit.xyz/llms.txt)
> (含全部 API 的 [`llms-full.txt`](https://sdk.homeagent.jianfgit.xyz/llms-full.txt))。
## 版本与兼容性 ## 版本与兼容性
当前:**SDK 1.2.0**(需内核 **1.2.0+**)。 当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
**版本号跟随内核的中版本,patch 位恒为 `.0`**: **版本号跟随内核的中版本,patch 位恒为 `.0`**:
@ -12,11 +21,15 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
|---|---| |---|---|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 | | 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** | | 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
| 1.2.0 起 | 1.2.0 | | 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 起 | **1.3.0** |
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动—— 内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。 否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
因此 **SDK 仓在一个中版本里只发一次**(`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
(2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是 **1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin` 「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。 实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
@ -28,6 +41,40 @@ HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本 所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。 不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
## 1.3.0 新增:注入优先级与动态输出通道
### 注入优先级(`InjectOptions.Priority`)
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
| 级别 | 常量 | 谁用 |
|---|---|---|
| L1–L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选(L1 最低) |
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
⇒ 存量插件不需要改一行、也不需要重编。
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
(现场保存/恢复对插件透明)。
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
### 动态输出通道(`UnregisterOutputChannel`)
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
1.3.0 起成对提供:
| API | 用途 |
|---|---|
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`,
违规会让**整条 LLM 请求**被上游拒绝(2026-09-13 生产事故:`device/<id>` 导致全量对话 403)。
派生规则与约束见下方「输出通道」一节。
## 注入行为与上下文裁剪(1.2.0) ## 注入行为与上下文裁剪(1.2.0)
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明; 「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
@ -92,7 +139,7 @@ type Plugin interface {
|------|------|------| |------|------|------|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) | | 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) | | 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码 | | 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 | | 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 | | 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) | | 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
@ -139,6 +186,14 @@ sdk.RegisterInputChannel("qq", ChannelDef{
### 输出通道 ### 输出通道
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
> "这个工具不可用",而是**整条请求被 400 拒绝**(`Invalid 'tools[N].function.name'`),
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`(13 字符)的余量。
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
> 内核**不会**替你净化。
```go ```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler) sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
``` ```
@ -291,15 +346,23 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
## hmapdev 工具链 ## hmapdev 工具链
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。 `hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从 预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可: 下载后加入 PATH 即可:
> ⚠️ **二进制的实际分发地址目前是 gitcode**(两个仓的 release 附件不同步):
> `hmapdev_linux_amd64` 等 5 个平台二进制 + `SHA256SUMS` 在
> <https://gitcode.com/JianFeeeee/homeagent-sdk/releases>。
> GitHub 侧(<https://github.com/JianFeeeee/homeagentsdk/releases>)从
> 下一个 SDK 版本(v1.4.0)起才会同步发布 —— 因为 SDK 仓**移仓后还没发过版**。
> 源码与文档一律以 GitHub 为准,**只有二进制暂时还得到 gitcode 取**。
> 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。 > 改名说明:工具链原名 `plugindev`,自 1.2.0 起更名 `hmapdev`。
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk` > SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
> (旧目录会被自动沿用,不会丢已装版本)。 > (旧目录会被自动沿用,不会丢已装版本)。
```bash ```bash
# 从 release 附件下载(以最新 SDK 发布 / linux amd64 为例) # 从 release 附件下载(以 linux amd64 为例,<版本> 如 v1.3.0)
# 当前实际分发地址是 gitcode(见下方说明):
curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64 curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64
chmod +x hmapdev chmod +x hmapdev
@ -441,6 +504,18 @@ enabled := sdk.AutoRestart()
插件崩溃时平台自动拉起,保障服务可用性。 插件崩溃时平台自动拉起,保障服务可用性。
重启是**有节制的**,默认参数(内核 `internal/plugin/registry.go`):
| 参数 | 值 | 含义 |
|---|---|---|
| `procRestartBackoff` | `1s` | 第 n 次重启前等 `n × 1s`(线性退避,非立即拉起) |
| `procMaxRestarts` | `3` | 窗口内允许的重启次数上限 |
| `procCrashWindow` | `5min` | 窗口内无新崩溃则计数归零 |
即崩溃后的实际序列是 **1s → 2s → 3s**;同一 5 分钟窗口内第 **4** 次崩溃
(`n > 3`)**不再自动拉起**,交人工介入。这不是「立即无感恢复」——
如果插件需要秒级就位,请自己在 `OnStart` 里做好重连与重建。
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立 > ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。 > 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态, > **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
@ -898,14 +973,30 @@ curl -X POST http://127.0.0.1:9876/plugins \
或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。 或通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。
## 文档
- 文档站:**<https://sdk.homeagent.jianfgit.xyz/>**
- 快速开始:[环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/) · [第一个 Go 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-plugin/) · [第一个 Lua 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-lua-plugin/)
- API 参考:[总览](https://sdk.homeagent.jianfgit.xyz/api/) · [工具](https://sdk.homeagent.jianfgit.xyz/api/tools/) · [阶段钩子](https://sdk.homeagent.jianfgit.xyz/api/stages/) · [记忆](https://sdk.homeagent.jianfgit.xyz/api/memory/) · [输入/输出通道](https://sdk.homeagent.jianfgit.xyz/api/channels/)
- 指南:[能力边界(哪些 API 外部可用)](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/) · [工具并发声明](https://sdk.homeagent.jianfgit.xyz/guide/parallel-tool-declaration/) · [流式多 tool_call](https://sdk.homeagent.jianfgit.xyz/guide/stream-tool-call-index/) · [场景记忆](https://sdk.homeagent.jianfgit.xyz/guide/scene-memory/) · [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging/)
- 版本与兼容:<https://sdk.homeagent.jianfgit.xyz/versions/>
- 核心仓(内核 / 记忆 / 调度):<https://github.com/JianFeeeee/HomeAgent>
- 介绍站:<https://introduce.homeagent.jianfgit.xyz/>
> 文档站为自托管(国内直连,不必挂代理)。构建与部署见核心仓的
> `deploy-sdk-site.sh`(`--check` 只比对、`--rollback` 回滚)。
## 许可 ## 许可
SDK 以 **AGPL-3.0-only** 发布,全文见 [LICENSE](LICENSE)。 SDK 以 **MIT** 发布,全文见 [LICENSE](LICENSE)。
**这对插件开发者是实质性约束**:SDK 会随插件一起**静态链接**(其源码进入插件二进制), **这是刻意的宽松**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
插件因此是本 SDK 的衍生作品,**必须以相同许可(AGPL-3.0-only)发布**;并且因为 AGPL §13 若用 AGPL 之类的传染许可,插件作者就会被强制以其对外开源。选 MIT 就是为了
覆盖网络交互,通过 HTTP/WebSocket 等向用户提供服务的插件同样要向使用者提供源码。 让插件作者**自由选择自己的许可**——闭源、商业、私有均可,无需向本项目回馈,
若你的插件需要闭源,唯一合规路径是另行取得本项目的例外/商业授权——目前不提供。 也无需取得任何例外或商业授权。第三方插件生态的安全与活跃正建立在这条之上。
前提是 SDK 本身**完全自包含**:`go.mod` 零外部依赖,`sdk/` 只依赖 Go 标准库
(`sync`),不引用核心仓的任何代码,因此 MIT 授权不与其他许可冲突。
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0) 第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT) 保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)

View File

@ -2,9 +2,19 @@
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform. Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
> 📖 **Full documentation**: <https://sdk.homeagent.jianfgit.xyz/>
>
> Quick start / API reference / guides / example plugins all live there. **This README is a
> summary only** — the docs site is authoritative for interface details.
> Jump to: [Getting started](https://sdk.homeagent.jianfgit.xyz/guide/getting-started/)
> · [API reference](https://sdk.homeagent.jianfgit.xyz/api/) · [Capability boundary](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary/)
>
> Agent-friendly plain-text entry points: [`llms.txt`](https://sdk.homeagent.jianfgit.xyz/llms.txt)
> and [`llms-full.txt`](https://sdk.homeagent.jianfgit.xyz/llms-full.txt) (the whole docs in one file).
## Version and Compatibility ## Version and Compatibility
Current: **SDK 1.2.0** (requires kernel **1.2.0+**). Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**: **The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
@ -12,13 +22,33 @@ Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|---|---| |---|---|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 | | 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** | | 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
| 1.2.0 onward | 1.2.0 | | 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 onward | **1.3.0** |
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
public interface, so the SDK version has no reason to move with it — otherwise you would either be public interface, so the SDK version has no reason to move with it — otherwise you would either be
forced to chase releases or suspect your version is stale, when not one character of the interface forced to chase releases or suspect your version is stale, when not one character of the interface
has changed. has changed.
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
## New in 1.3.0: Injection Priority and Dynamic Output Channels
- **`InjectOptions.Priority` / `PriorityL1`–`PriorityL4`** — a plugin declares the interrupt level of
its own injection; the kernel schedules L1–L4, where **L4 is reserved for the kernel and
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
Queued input has no level: anything can jump ahead of it.
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
channels that die with their resource (one channel per remote device) can now be unregistered;
previously they lingered and the model kept "successfully" sending into a dead channel.
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
external IDs — never use the raw ID.
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is **Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
in the "plugin calls, kernel implements" direction, so not calling it means not being affected in the "plugin calls, kernel implements" direction, so not calling it means not being affected
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel, (verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
@ -423,6 +453,19 @@ enabled := sdk.AutoRestart()
The platform automatically restarts the plugin on crash, ensuring service availability. 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 > ⚠️ `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 > 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 > background goroutine while the kernel reads the flag from another one — which is inherently
@ -816,14 +859,18 @@ Or upload via the WebUI plugin management page, or manually place the `.hmap` in
## License ## License
The SDK is released under **AGPL-3.0-only** — see [LICENSE](LICENSE). The SDK is released under the **MIT license** — see [LICENSE](LICENSE).
**This is a substantive constraint for plugin developers**: the SDK is **statically linked** into **This permissiveness is deliberate**: the SDK is **statically linked** into your plugin
your plugin (its source ends up in the plugin binary), so the plugin is a derivative work of (its source ends up in the plugin binary). Under a copyleft license such as AGPL that would
this SDK and **must be released under the same license**. Because AGPL §13 covers network force plugin authors to open-source their work; MIT exists precisely so that plugin authors
interaction, a plugin that serves users over HTTP/WebSocket must also offer them the source. can **pick their own license** — closed-source, commercial or private — with no obligation to
If you need a closed-source plugin, the only compliant route is a separate exception/commercial contribute back and no need for any exception or commercial grant. The safety and vitality of
license from this project — none is offered today. the third-party plugin ecosystem rest on this.
This is sound because the SDK is **fully self-contained**: `go.mod` has zero external
dependencies and `sdk/` imports only the Go standard library (`sync`), never any code from the
core repository — so the MIT grant conflicts with nothing.
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 / Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
Apache-2.0) keep their own licenses. The platform-side model and inference runtime Apache-2.0) keep their own licenses. The platform-side model and inference runtime

204
docs/api/bridge.md Normal file
View File

@ -0,0 +1,204 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 桥接装配点(Bridge)
以下方法**不是给插件业务代码调的**——它们由 `hmapdev` 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例。列在这里是为了让「公开 API 面」完整,并说明每个注入点对应什么能力。
### `APIRegistrar`
```go
type APIRegistrar func(name string) error
```
APIRegistrar registers a plugin API for external access.
<small>`plugin.go:410`</small>
### `InputChannelRegistrar`
```go
type InputChannelRegistrar func(name string, def ChannelDef) error
```
InputChannelRegistrar registers an input channel with its memory behavior.
<small>`plugin.go:413`</small>
### `OutputChannelRegistrar`
```go
type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
```
OutputChannelRegistrar registers an output channel that the output_send tool can use.
<small>`plugin.go:416`</small>
### `OutputChannelUnregistrar`
```go
type OutputChannelUnregistrar func(name string) error
```
OutputChannelUnregistrar 注销一个输出通道。
为什么需要它:输出通道不止有"启动时注册一次"的静态通道,还有**随外部资源生灭**的
动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后
必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。
<small>`plugin.go:423`</small>
### `PluginSDK.SetDocMemoryAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI)
```
<small>`plugin.go:718`</small>
### `PluginSDK.SetEventSubscriber`
!!! warning "仅内核内置插件可用"
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
```go
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
```
<small>`plugin.go:742`</small>
### `PluginSDK.SetIOInjector`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetIOInjector(io IOInjector)
```
SetIOInjector sets the IO injector (called by the core at startup).
<small>`plugin.go:699`</small>
### `PluginSDK.SetInputChannelRegistrar`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar)
```
SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
<small>`plugin.go:692`</small>
### `PluginSDK.SetKnowledgeAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI)
```
<small>`plugin.go:724`</small>
### `PluginSDK.SetLLMAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetLLMAPI(llm LLMAPI)
```
<small>`plugin.go:730`</small>
### `PluginSDK.SetMemoryAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI)
```
SetMemoryAPI sets the memory API (called by the core at startup).
<small>`plugin.go:706`</small>
### `PluginSDK.SetOutputChannelRegistrar`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar)
```
SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
<small>`plugin.go:678`</small>
### `PluginSDK.SetOutputChannelUnregistrar`
!!! warning "仅内核内置插件可用"
同上,桥接模板不注入。
```go
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
```
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
<small>`plugin.go:685`</small>
### `PluginSDK.SetPluginMgrAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI)
```
SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
<small>`plugin.go:749`</small>
### `PluginSDK.SetSocialAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetSocialAPI(social SocialAPI)
```
<small>`plugin.go:736`</small>
### `PluginSDK.SetTextMemoryAPI`
!!! info "桥接装配点"
桥接运行时注入点(由 hmapdev 生成的 proc_main 调用,插件业务代码不调用)
```go
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI)
```
<small>`plugin.go:712`</small>
### `ToolRegistrar`
```go
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
```
ToolRegistrar registers a tool dynamically.
<small>`plugin.go:404`</small>

79
docs/api/builtin-only.md Normal file
View File

@ -0,0 +1,79 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 仅内置插件可用的 API
这些 API **存在于公开 SDK 包里**,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级。列在这里是为了让边界显式——而不是让你在运行时才发现拿不到。
判断依据全部来自源码与 `hmapdev` 桥接模板,逐条记在每条说明里。
### `PriorityL4`
!!! warning "仅内核内置插件可用"
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
```go
const PriorityL4
```
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
<small>`plugin.go:165`</small>
### `PluginSDK.Events`
!!! warning "仅内核内置插件可用"
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
```go
func (s *PluginSDK) Events() EventSubscriber
```
Events returns the event subscriber for listening to kernel events (may be nil if not available).
<small>`plugin.go:550`</small>
### `PluginSDK.SetEventSubscriber`
!!! warning "仅内核内置插件可用"
同上:无调用点。标 builtin 而非 bridge,因为连桥接运行时都不注入它——外部插件无法用它获得事件订阅能力。
```go
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
```
<small>`plugin.go:742`</small>
### `PluginSDK.SetOutputChannelUnregistrar`
!!! warning "仅内核内置插件可用"
同上,桥接模板不注入。
```go
func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
```
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
<small>`plugin.go:685`</small>
### `PluginSDK.UnregisterOutputChannel`
!!! warning "仅内核内置插件可用"
外部插件的桥接模板只注入 SetOutputChannelRegistrar(proc_main.go.tmpl:693-705),**未**注入 SetOutputChannelUnregistrar(grep Unregistrar 在 templates/ 下无命中)。因此外部插件的 regOutputUnreg 为 nil,UnregisterOutputChannel 会命中 `if reg != nil` 的 else 分支**直接返回 nil**(sdk/plugin.go:539-549)——即**静默无效**:不报错、通道也没注销。内置插件由 internal/sdk/plugin.go:330 注入 cfg.RegOutputUnreg,真正生效。外部插件要让通道下线,只能重载插件。
```go
func (s *PluginSDK) UnregisterOutputChannel(name string) error
```
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
<small>`plugin.go:643`</small>
### `EventSubscriber.Subscribe`
!!! warning "仅内核内置插件可用"
```go
Subscribe(eventType EventType, handler EventHandler) func()
```

650
docs/api/channels.md Normal file
View File

@ -0,0 +1,650 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 输入 / 输出通道
通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口。**输入通道**接收外部消息,**输出通道**把消息投递出去。
## `IOInjector`
IOInjector provides methods for injecting input and interrupts into the agent pipeline.
All methods accept (source, channel) where channel is the target output channel
for routing the agent's response.
| 方法 | 说明 |
|---|---|
| [`InjectInputMedia`](#ioinjectorinjectinputmedia) | |
| [`InjectInputMediaOpts`](#ioinjectorinjectinputmediaopts) | |
| [`InjectInputMediaSync`](#ioinjectorinjectinputmediasync) | |
| [`InjectInputMediaSyncOpts`](#ioinjectorinjectinputmediasyncopts) | |
| [`InjectInputSync`](#ioinjectorinjectinputsync) | InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。 |
| [`InjectInputSyncOpts`](#ioinjectorinjectinputsyncopts) | |
| [`InjectInterruptMedia`](#ioinjectorinjectinterruptmedia) | |
| [`InjectInterruptMediaOpts`](#ioinjectorinjectinterruptmediaopts) | |
| [`InjectInterruptText`](#ioinjectorinjectinterrupttext) | |
| [`InjectInterruptTextOpts`](#ioinjectorinjectinterrupttextopts) | |
| [`InjectText`](#ioinjectorinjecttext) | |
| [`InjectTextNoMemory`](#ioinjectorinjecttextnomemory) | |
| [`InjectTextOpts`](#ioinjectorinjecttextopts) | 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。 |
| [`SetToolBlocks`](#ioinjectorsettoolblocks) | SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 |
### `IOInjector.InjectInputMedia`
```go
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
```
<small>`plugin.go:329`</small>
### `IOInjector.InjectInputMediaOpts`
```go
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
<small>`plugin.go:340`</small>
### `IOInjector.InjectInputMediaSync`
```go
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
```
<small>`plugin.go:330`</small>
### `IOInjector.InjectInputMediaSyncOpts`
```go
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
```
<small>`plugin.go:341`</small>
### `IOInjector.InjectInputSync`
```go
InjectInputSync(source, channel, text string) string
```
InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
<small>`plugin.go:325`</small>
### `IOInjector.InjectInputSyncOpts`
```go
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
```
<small>`plugin.go:339`</small>
### `IOInjector.InjectInterruptMedia`
```go
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
```
<small>`plugin.go:331`</small>
### `IOInjector.InjectInterruptMediaOpts`
```go
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
<small>`plugin.go:342`</small>
### `IOInjector.InjectInterruptText`
```go
InjectInterruptText(source, channel, text string)
```
<small>`plugin.go:320`</small>
### `IOInjector.InjectInterruptTextOpts`
```go
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
<small>`plugin.go:338`</small>
### `IOInjector.InjectText`
```go
InjectText(source, channel, text string)
```
<small>`plugin.go:321`</small>
### `IOInjector.InjectTextNoMemory`
```go
InjectTextNoMemory(source, channel, text string)
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
<small>`plugin.go:322`</small>
### `IOInjector.InjectTextOpts`
```go
InjectTextOpts(source, channel, text string, opts InjectOptions)
```
以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
<small>`plugin.go:337`</small>
### `IOInjector.SetToolBlocks`
```go
SetToolBlocks(blocks []ContentBlock)
```
SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
<small>`plugin.go:328`</small>
### `CapAudio`
```go
const CapAudio
```
Output capability flags
<small>`plugin.go:430`</small>
### `CapFile`
```go
const CapFile
```
Output capability flags
<small>`plugin.go:428`</small>
### `CapImage`
```go
const CapImage
```
Output capability flags
<small>`plugin.go:429`</small>
### `CapStructured`
```go
const CapStructured
```
Output capability flags
<small>`plugin.go:431`</small>
### `CapText`
```go
const CapText
```
Output capability flags
<small>`plugin.go:427`</small>
### `ChannelDef`
```go
type ChannelDef struct { NoMemory bool `json:"no_memory,omitempty"` Cleaner func(string) string `json:"-"` ContextPolicy string `json: …
```
ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
那样新增字段会被静默丢掉。
<small>`plugin.go:178`</small>
### `ContextPolicyNone`
```go
const ContextPolicyNone
```
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
插件会在背后把别人的内容挤掉,且看不出是谁干的。
<small>`plugin.go:44`</small>
### `ContextPolicyPrune`
```go
const ContextPolicyPrune
```
上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
插件会在背后把别人的内容挤掉,且看不出是谁干的。
<small>`plugin.go:45`</small>
### `PluginSDK.InjectInputMedia`
```go
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock)
```
InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
的「下一轮 tool message」语义。
等价于 InjectInputMediaOpts(..., InjectOptions{})。
<small>`plugin.go:805`</small>
### `PluginSDK.InjectInputMediaOpts`
```go
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
<small>`plugin.go:844`</small>
### `PluginSDK.InjectInputMediaSync`
```go
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
```
InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
<small>`plugin.go:811`</small>
### `PluginSDK.InjectInputMediaSyncOpts`
```go
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
```
InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
<small>`plugin.go:851`</small>
### `PluginSDK.InjectInputSync`
```go
func (s *PluginSDK) InjectInputSync(source, channel, text string) string
```
InjectInputSync injects a text message and synchronously waits for the agent reply,
returning the reply text (empty string if none). Replies must be dispatched back
to the source channel by the caller.
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
<small>`plugin.go:796`</small>
### `PluginSDK.InjectInputSyncOpts`
```go
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
```
InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
<small>`plugin.go:835`</small>
### `PluginSDK.InjectInterruptMedia`
```go
func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
```
InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
blocks 随中断消息一起发给模型。
<small>`plugin.go:868`</small>
### `PluginSDK.InjectInterruptMediaOpts`
```go
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
<small>`plugin.go:860`</small>
### `PluginSDK.InjectInterruptText`
```go
func (s *PluginSDK) InjectInterruptText(source, channel, text string)
```
InjectInterruptText injects a text interrupt that can preempt current LLM processing.
等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
<small>`plugin.go:777`</small>
### `PluginSDK.InjectInterruptTextOpts`
```go
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
```
InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文,
是否需要据此裁剪由调用方决定(默认不裁剪)。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1319` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:527` | `p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
<small>`plugin.go:828`</small>
### `InjectOptions`
```go
type InjectOptions struct { NoMemory bool ContextPolicy string // RecallPolicy 声明此次注入是否据其内容召回相关记忆。 // 空串 = 默认(输入/注<><E6B3A8> …
```
InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
因此调用方只有在确实需要改变行为时才需要填它。
为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。
中断注入也允许声明 prune——它同样会携带内容进入上下文。
CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
空串 = 按注入的 source 查通道定义(既有行为)。
为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
不指定就只能退到「按 source 查不到就不清洗」。
<small>`plugin.go:132`</small>
### `PluginSDK.InjectText`
```go
func (s *PluginSDK) InjectText(source, channel, text string)
```
InjectText injects a text message into the agent pipeline.
等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
<small>`plugin.go:783`</small>
### `PluginSDK.InjectTextNoMemory`
```go
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string)
```
InjectTextNoMemory injects a text message without generating memory.
等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
<small>`plugin.go:789`</small>
### `PluginSDK.InjectTextOpts`
```go
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions)
```
InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
<small>`plugin.go:818`</small>
### `PriorityL1`
```go
const PriorityL1
```
中断优先级取值。
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
<small>`plugin.go:161`</small>
### `PriorityL2`
```go
const PriorityL2
```
中断优先级取值。
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
<small>`plugin.go:162`</small>
### `PriorityL3`
```go
const PriorityL3
```
中断优先级取值。
L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
<small>`plugin.go:163`</small>
### `PriorityL4`
!!! warning "仅内核内置插件可用"
sdk/plugin.go:119-126 明确:L1..L3 任何插件可声明,L4 只有内核级插件可用,外部插件声明会被内核夹到 L3(内核侧有 priority_clamp_test.go 守着)。外部插件文档应说明「声明 L4 无效」。
```go
const PriorityL4
```
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
<small>`plugin.go:165`</small>
### `RecallPolicyAuto`
```go
const RecallPolicyAuto
```
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
<small>`plugin.go:65`</small>
### `RecallPolicyNone`
```go
const RecallPolicyNone
```
召回策略:决定一次工具调用/输入/注入是否据其内容**召回**(注入)相关记忆。
与 ContextPolicy **正交**:ContextPolicy 管「裁剪」(把低相关 L0 事件归档),
RecallPolicy 管「召回」(把 L2/L3 的相关记忆注入本轮)。两者默认值刻意相反——
裁剪是破坏性的,默认关(必须显式声明);召回是只读增量、日常对话本就需要,
默认 auto(输入/注入),仅**工具**默认 none(工具输出多为噪声,按需声明)。
<small>`plugin.go:64`</small>
### `PluginSDK.RegisterInputChannel`
```go
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error
```
RegisterInputChannel registers an input channel with its memory behavior.
契约:**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)
注入的通道名,都应当在这里登记**。inputch 是内核里最基本的**输入路由单位**:
只有登记过的通道才能在 inputch 登记表里出现,父 agent 才能"把某个 inputch 划给驻留子";
没登记就划分会直接失败(`inputch 未注册`)。
只登记输出通道(RegisterOutputChannel)而没登记输入通道时,内核会兜底登记同名
inputch 并打告警日志 —— 兜底只为兼容老插件,新插件请显式登记。
def.NoMemory: 此通道输入不参与记忆计算
def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:52` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:209` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:281` | `_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})` |
<small>`plugin.go:665`</small>
### `PluginSDK.RegisterOutputChannel`
```go
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
```
RegisterOutputChannel registers an output channel that the output_send tool can route to.
与 RegisterInputChannel 的分工:本函数声明**出站**(output_send__<name> 的回复发给谁);
入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
若该通道同时也是你的注入入口,两个都要登记。
name: channel name (e.g. "qq", "webui")。
❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`),
而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用",
而是**整条请求被上游 400 拒绝**(`Invalid 'tools[N].function.name'`),
网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13 字符)的余量。
若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**,
而不是把原始值直接当通道名。
caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
desc: description of the channel, expected meta format, and type enum
def: 通道在记忆计算层的行为(NoMemory/Cleaner)
handler: receives args map with keys: payload (string), type (string), meta (string|optional)
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:57` | `if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args…` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:57` | `s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[strin…` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:411` | `s.RegisterOutputChannel("qq", sdk.CapText\|sdk.CapFile\|sdk.CapImage\|sdk.CapAudio,` |
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:104` | `if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{` |
<small>`plugin.go:632`</small>
### `PluginSDK.SetToolBlocks`
```go
func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock)
```
SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
<small>`plugin.go:876`</small>
### `ValidContextPolicy`
```go
func ValidContextPolicy(policy string) bool
```
ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
<small>`plugin.go:49`</small>
### `ValidRecallPolicy`
```go
func ValidRecallPolicy(policy string) bool
```
ValidRecallPolicy 校验召回策略取值;空串按调用面取默认值。
<small>`plugin.go:69`</small>

86
docs/api/constants.md Normal file
View File

@ -0,0 +1,86 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 常量与枚举
SDK 里的取值枚举。其中带「仅内置」标注的取值在内核侧会被夹到较低级别。
## StageOnInput 等
| 名称 | 说明 |
|---|---|
| `StageOnInput` | |
| `StagePreAction` | |
| `StagePostAction` | |
| `StageBeforeToolcall` | |
| `StageAfterToolcall` | |
| `StageBeforeOutput` | |
| `StageAfterOutput` | |
## ContextPolicyNone 等
| 名称 | 说明 |
|---|---|
| `ContextPolicyNone` | |
| `ContextPolicyPrune` | |
## RecallPolicyNone 等
| 名称 | 说明 |
|---|---|
| `RecallPolicyNone` | |
| `RecallPolicyAuto` | |
## ScenePolicyAuto 等
| 名称 | 说明 |
|---|---|
| `ScenePolicyAuto` | |
| `ScenePolicyNone` | |
## PriorityL1 等
| 名称 | 说明 |
|---|---|
| `PriorityL1` | |
| `PriorityL2` | |
| `PriorityL3` | |
| `PriorityL4` | PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 |
## EventRawInput 等
| 名称 | 说明 |
|---|---|
| `EventRawInput` | |
| `EventAgentOutput` | |
| `EventAgentLLMChain` | |
| `EventToolCall` | |
| `EventReasoning` | |
| `EventStage` | |
| `EventSystem` | |
| `EventReasoningDelta` | 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。 |
| `EventContentDelta` | |
## StageScopeGlobal 等
| 名称 | 说明 |
|---|---|
| `StageScopeGlobal` | StageScopeGlobal receives all stage events (default). |
| `StageScopeOwnTools` | StageScopeOwnTools only receives events for this plugin's own tool calls |
## CapText 等
| 名称 | 说明 |
|---|---|
| `CapText` | |
| `CapFile` | |
| `CapImage` | |
| `CapAudio` | |
| `CapStructured` | |
## ProxyAuthHomeAgent 等
| 名称 | 说明 |
|---|---|
| `ProxyAuthHomeAgent` | ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话 |
| `ProxyAuthNone` | ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。 |

69
docs/api/events.md Normal file
View File

@ -0,0 +1,69 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 事件(Events)
订阅内核事件。**注意**:外部分布式插件的事件订阅不走 `Events()`(该接口在外部插件路径上未被注入,恒为 nil),而是由 `hmapdev` 生成的运行时通过 `events.subscribe` 完成。详见下方说明。
## `EventSubscriber`
EventSubscriber allows plugins to subscribe to kernel events.
This is a restricted interface: plugins can subscribe but the kernel
controls which events are delivered.
| 方法 | 说明 |
|---|---|
| [`Subscribe`](#eventsubscribersubscribe) | |
### `EventSubscriber.Subscribe`
!!! warning "仅内核内置插件可用"
```go
Subscribe(eventType EventType, handler EventHandler) func()
```
<small>`plugin.go:378`</small>
### `Event`
```go
type Event struct { Type EventType `json:"type"` Source string `json:"source"` Payload map[string]interface{} `json:"payload"` T …
```
Event represents a system event published by the kernel.
<small>`plugin.go:364`</small>
### `EventHandler`
```go
type EventHandler func(evt *Event)
```
EventHandler processes a system event.
<small>`plugin.go:372`</small>
### `EventType`
```go
type EventType string
```
EventType identifies the kind of system event.
<small>`plugin.go:346`</small>
### `PluginSDK.Events`
!!! warning "仅内核内置插件可用"
实测全仓 SetEventSubscriber 只有定义、无任何调用点(grep -rn SetEventSubscriber --include=*.go 仅命中 sdk/plugin.go 的定义与 lua_plugin.go 的一句注释)。外部插件拿到的 Events() 恒为 nil。外部插件订阅事件走的是桥接运行时的 events.subscribe RPC(proc_main.go.tmpl:1421 在插件侧分发、内核 protocol.go MethodEventsSubscribe),Lua 插件则走内部 SDK 的 Subscribe。这正是 PLUGIN_DEV.md 里没有说清的一处。
```go
func (s *PluginSDK) Events() EventSubscriber
```
Events returns the event subscriber for listening to kernel events (may be nil if not available).
<small>`plugin.go:550`</small>

39
docs/api/index.md Normal file
View File

@ -0,0 +1,39 @@
# API 参考
本页所有内容**从源码生成**(`tools/apidoc`),签名与说明直接取自 `sdk/*.go` 的
文档注释。因此不存在「文档写了一套、代码是另一套」的情况——发现不一致时,
改的是源码注释,不是这里。
## 怎么找 API
<div id="api-search"></div>
用上面的搜索框可以:
- **按名称搜**:`InjectText`、`RegisterTool`、`memory.recall`
- **按描述搜**:`注册工具`、`注入`、`重载`、`崩溃`
- **按签名搜**:`(string) error`、`[]ContentBlock`
- 带 <span class="api-badge api-badge-builtin">仅内置</span>
标记的条目在**外部插件里拿不到**,多数情况下你不需要它
## 章节划分
按「你想做什么」组织,不是按 Go 的符号类别:
| 章节 | 内容 |
|---|---|
| [工具(Tools)](tools.md) | 注册 LLM 可调用的工具——插件最常用的能力形态 |
| [阶段钩子(Stages)](stages.md) | 在处理管道的固定点位插入逻辑 |
| [记忆(Memory)](memory.md) | 三层记忆的读写:图 / 文档 / 文本,以及知识库 |
| [输入/输出通道](channels.md) | 与外界交换消息,以及往流水线里注入内容 |
| [配置(Settings)](settings.md) | 声明插件配置项,内核渲染到 WebUI |
| [生命周期(Lifecycle)](lifecycle.md) | 启动、停止、卸载、自动重启 |
| [事件(Events)](events.md) | 订阅内核事件 |
| [LLM 调用](llm.md) | 插件主动调用模型 |
| [常量与枚举](constants.md) | 取值枚举 |
| [桥接装配点](bridge.md) | 由 `hmapdev` 生成的运行时调用,插件业务代码不碰 |
| [仅内置插件可用](builtin-only.md) | 边界汇总——外部插件拿不到的 API 全在这里 |
!!! tip "先看「能力边界」能省很多时间"
如果你正在设计插件,先读 [能力边界](../guide/capability-boundary.md):
它说明哪些能力外部插件有、哪些没有,以及**为什么**。

209
docs/api/lifecycle.md Normal file
View File

@ -0,0 +1,209 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 生命周期(Lifecycle)
插件的启动、停止与卸载回调。停止与卸载是两件事:**停止**是进程/加载状态变化,**卸载**(onRemove)是插件被删除前的清理机会。
## `Plugin`
Plugin is the interface every plugin must implement.
| 方法 | 说明 |
|---|---|
| [`Name`](#pluginname) | |
| [`Start`](#pluginstart) | |
| [`Stop`](#pluginstop) | |
### `Plugin.Name`
```go
Name() string
```
<small>`plugin.go:14`</small>
### `Plugin.Start`
```go
Start(sdk *PluginSDK) error
```
<small>`plugin.go:15`</small>
### `Plugin.Stop`
```go
Stop() error
```
<small>`plugin.go:16`</small>
## `PluginMgrAPI`
PluginMgrAPI 提供插件管理能力(外部插件可调用)。
由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
| 方法 | 说明 |
|---|---|
| [`IsPluginDisabled`](#pluginmgrapiisplugindisabled) | IsPluginDisabled 查询插件是否被禁用。 |
| [`ListLoadedPlugins`](#pluginmgrapilistloadedplugins) | ListLoadedPlugins 列出已加载插件。 |
| [`ReloadOne`](#pluginmgrapireloadone) | ReloadOne 重载单个插件(停止后重新加载)。 |
### `PluginMgrAPI.IsPluginDisabled`
```go
IsPluginDisabled(name string) bool
```
IsPluginDisabled 查询插件是否被禁用。
<small>`plugin.go:389`</small>
### `PluginMgrAPI.ListLoadedPlugins`
```go
ListLoadedPlugins() []string
```
ListLoadedPlugins 列出已加载插件。
<small>`plugin.go:387`</small>
### `PluginMgrAPI.ReloadOne`
```go
ReloadOne(name string) error
```
ReloadOne 重载单个插件(停止后重新加载)。
<small>`plugin.go:385`</small>
### `PluginSDK.AutoRestart`
```go
func (s *PluginSDK) AutoRestart() bool
```
AutoRestart 返回插件是否允许自动重启。
<small>`plugin.go:895`</small>
### `PluginSDK.PluginMgr`
```go
func (s *PluginSDK) PluginMgr() PluginMgrAPI
```
PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
May be nil if the host did not wire it.
<small>`plugin.go:757`</small>
### `PluginSDK.PluginName`
```go
func (s *PluginSDK) PluginName() string
```
PluginName returns the name of the plugin.
<small>`plugin.go:501`</small>
### `PluginSDK.RegisterOnRemoveHandler`
```go
func (s *PluginSDK) RegisterOnRemoveHandler(fn func())
```
RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用,
适用于清理外部资源、删除配置表、下线状态等删除后处理。
可注册多个;执行后清空(一次删除只执行一次)。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:296` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:69` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
| [`rss`](../examples/index.md#rss) | `example/rss/plugin.go:127` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
<small>`plugin.go:930`</small>
### `PluginSDK.RegisterPluginAPI`
```go
func (s *PluginSDK) RegisterPluginAPI(name string) error
```
RegisterPluginAPI registers this plugin's API for access by other plugins.
<small>`plugin.go:606`</small>
### `PluginSDK.RegisterStopHandler`
```go
func (s *PluginSDK) RegisterStopHandler(fn func())
```
RegisterStopHandler 注册插件停止阶段的清理回调。
注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
适用于释放资源、落盘状态、关闭子进程等停止时清理操作。
可注册多个;执行后清空(进程停止前只执行一次)。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:294` | `s.RegisterStopHandler(p.saveEvents)` |
| [`deepsearch`](../examples/index.md#deepsearch) | `example/deepsearch/plugin.go:695` | `s.RegisterStopHandler(func() { p.shutdownSearxng() })` |
<small>`plugin.go:905`</small>
### `PluginSDK.RunOnRemoveHandlers`
```go
func (s *PluginSDK) RunOnRemoveHandlers()
```
RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。
由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。
<small>`plugin.go:941`</small>
### `PluginSDK.RunStopHandlers`
```go
func (s *PluginSDK) RunStopHandlers()
```
RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。
由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。
<small>`plugin.go:916`</small>
### `PluginSDK.SetAutoRestart`
```go
func (s *PluginSDK) SetAutoRestart(enabled bool)
```
SetAutoRestart 设置插件崩溃后内核是否自动重启它。
默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
重启是有限度的:线性退避(第 n 次等 n×1s,即 1s→2s→3s),
且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README)。
注意这与「重载」(换 plugin.bin 后重新加载)是两回事。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:47` | `s.SetAutoRestart(true)` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:47` | `s.SetAutoRestart(true)` |
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:110` | `s.SetAutoRestart(true)` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:63` | `s.SetAutoRestart(true)` |
<small>`plugin.go:888`</small>

50
docs/api/llm.md Normal file
View File

@ -0,0 +1,50 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# LLM 调用
让插件自己调用模型(而不是只等模型来调你)。
## `LLMAPI`
LLMAPI provides access to the LLM provider manager.
| 方法 | 说明 |
|---|---|
| [`CurrentSource`](#llmapicurrentsource) | |
| [`ListSources`](#llmapilistsources) | |
| [`SetSource`](#llmapisetsource) | |
### `LLMAPI.CurrentSource`
```go
CurrentSource() string
```
<small>`llm.go:7`</small>
### `LLMAPI.ListSources`
```go
ListSources() []string
```
<small>`llm.go:5`</small>
### `LLMAPI.SetSource`
```go
SetSource(name string) error
```
<small>`llm.go:6`</small>
### `PluginSDK.LLM`
```go
func (s *PluginSDK) LLM() LLMAPI
```
LLM returns the LLM provider API (may be nil if not available).
<small>`plugin.go:536`</small>

383
docs/api/memory.md Normal file
View File

@ -0,0 +1,383 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 记忆(Memory)
三层记忆的读写接口:**图记忆**(三元组关系)、**文档记忆**(带元数据的文档)、**文本记忆**(事件流水)。以及知识库。
## `DocMemoryAPI`
DocMemoryAPI provides access to the document vector store.
| 方法 | 说明 |
|---|---|
| [`Insert`](#docmemoryapiinsert) | |
| [`InsertWithMedia`](#docmemoryapiinsertwithmedia) | InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进 |
| [`Query`](#docmemoryapiquery) | |
| [`Remove`](#docmemoryapiremove) | |
| [`Stats`](#docmemoryapistats) | |
### `DocMemoryAPI.Insert`
```go
Insert(doc *Doc) error
```
<small>`memory.go:76`</small>
### `DocMemoryAPI.InsertWithMedia`
```go
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
```
InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
媒体成为文档直接持有的一等记忆块:文档向量会融合它们的原生向量,
因此图片按自己的向量被召回,不依赖任何生成的描述文本。
<small>`memory.go:81`</small>
### `DocMemoryAPI.Query`
```go
Query(text string, topK int) []*Doc
```
<small>`memory.go:75`</small>
### `DocMemoryAPI.Remove`
```go
Remove(id string)
```
<small>`memory.go:82`</small>
### `DocMemoryAPI.Stats`
```go
Stats() map[string]interface{}
```
<small>`memory.go:83`</small>
## `KnowledgeAPI`
KnowledgeAPI provides access to the knowledge store.
| 方法 | 说明 |
|---|---|
| [`Add`](#knowledgeapiadd) | |
| [`List`](#knowledgeapilist) | |
| [`Search`](#knowledgeapisearch) | |
### `KnowledgeAPI.Add`
```go
Add(name, content string) error
```
<small>`knowledge.go:6`</small>
### `KnowledgeAPI.List`
```go
List() ([]string, error)
```
<small>`knowledge.go:7`</small>
### `KnowledgeAPI.Search`
```go
Search(query string, topK int) ([]*Knowledge, error)
```
<small>`knowledge.go:5`</small>
## `MemoryAPI`
MemoryAPI provides access to the graph memory (entity-relation store).
| 方法 | 说明 |
|---|---|
| [`Commit`](#memoryapicommit) | |
| [`Introspect`](#memoryapiintrospect) | |
| [`MergeEntities`](#memoryapimergeentities) | |
| [`Purge`](#memoryapipurge) | |
| [`Recall`](#memoryapirecall) | |
### `MemoryAPI.Commit`
```go
Commit(triples []Triple) error
```
<small>`memory.go:6`</small>
### `MemoryAPI.Introspect`
```go
Introspect() (map[string]interface{}, error)
```
<small>`memory.go:7`</small>
### `MemoryAPI.MergeEntities`
```go
MergeEntities(source, target string) (int, error)
```
<small>`memory.go:8`</small>
### `MemoryAPI.Purge`
```go
Purge(criteria map[string]string, mode string) (int, error)
```
<small>`memory.go:9`</small>
### `MemoryAPI.Recall`
```go
Recall(query []string, depth int) ([]Entity, []Relation, error)
```
<small>`memory.go:5`</small>
## `SocialAPI`
SocialAPI provides read-only access to the social graph (person profiles and relationships).
External plugins can query person traits and social networks but cannot modify them.
| 方法 | 说明 |
|---|---|
| [`GetNetwork`](#socialapigetnetwork) | |
| [`GetPerson`](#socialapigetperson) | |
| [`GetRelations`](#socialapigetrelations) | |
| [`GetTrait`](#socialapigettrait) | |
| [`ListPersons`](#socialapilistpersons) | |
### `SocialAPI.GetNetwork`
```go
GetNetwork(name string, depth int) ([]*PersonProfile, error)
```
<small>`memory.go:104`</small>
### `SocialAPI.GetPerson`
```go
GetPerson(name string) (*PersonProfile, error)
```
<small>`memory.go:101`</small>
### `SocialAPI.GetRelations`
```go
GetRelations(name string) ([]SocialRelation, error)
```
<small>`memory.go:103`</small>
### `SocialAPI.GetTrait`
```go
GetTrait(name, trait string) (string, bool)
```
<small>`memory.go:102`</small>
### `SocialAPI.ListPersons`
```go
ListPersons() ([]string, error)
```
<small>`memory.go:105`</small>
## `TextMemoryAPI`
TextMemoryAPI provides access to chronological text event storage.
| 方法 | 说明 |
|---|---|
| [`Append`](#textmemoryapiappend) | |
### `TextMemoryAPI.Append`
```go
Append(evt TextEvent) error
```
<small>`memory.go:44`</small>
### `Doc`
```go
type Doc struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` Score …
```
Doc represents a document in the document store.
MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
<small>`memory.go:89`</small>
### `PluginSDK.DocMemory`
```go
func (s *PluginSDK) DocMemory() DocMemoryAPI
```
DocMemory returns the document memory API (may be nil if not available).
<small>`plugin.go:522`</small>
### `Entity`
```go
type Entity struct { Name string `json:"name"` Type string `json:"type"` MentionCount int `json:"mention_count"` }
```
Entity represents a named entity in the knowledge graph.
<small>`memory.go:13`</small>
### `Knowledge`
```go
type Knowledge struct { Name string `json:"name"` // Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。 // // 为何加这个字段:对<EFBC9A><E5AFB9> …
```
Knowledge represents a knowledge entry.
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
<small>`knowledge.go:11`</small>
### `PluginSDK.Knowledge`
```go
func (s *PluginSDK) Knowledge() KnowledgeAPI
```
Knowledge returns the knowledge store API (may be nil if not available).
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
<small>`plugin.go:529`</small>
### `MediaAttachment`
```go
type MediaAttachment struct { Digest string `json:"digest,omitempty"` MIME string `json:"mime,omitempty"` Data []byte `json:"data,omitempty"` Name string `json:"name,omite …
```
TextEvent represents a single text memory event.
MediaAttachment 描述一份与记忆关联的媒体。
两个方向共用一个类型:
- 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
- 读出(Query):内核只填 Digest/MIME,**不回 Data**——
一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
需要字节时拿 Digest 单独取。
刻意没有 Description 字段:媒体不作为文本被索引,也不带任何生成的描述。
它只按自己的原生向量被检索与召回;附加文字请写在文档 / 三元组的文本里。
<small>`memory.go:58`</small>
### `PluginSDK.Memory`
```go
func (s *PluginSDK) Memory() MemoryAPI
```
Memory returns the graph memory API (may be nil if not available).
<small>`plugin.go:508`</small>
### `PersonProfile`
```go
type PersonProfile struct { Name string `json:"name"` Traits map[string]string `json:"traits,omitempty"` Relations []SocialRelation `json:"relations,omitemp …
```
PersonProfile represents a person's complete profile (traits + social relations).
<small>`memory.go:109`</small>
### `Relation`
```go
type Relation struct { SourceName string `json:"source_name"` TargetName string `json:"target_name"` RelationType string `json:"relation_type"` Confidence float6 …
```
Relation represents a relationship between two entities.
<small>`memory.go:20`</small>
### `PluginSDK.Social`
```go
func (s *PluginSDK) Social() SocialAPI
```
Social returns the social graph API (may be nil if not available).
<small>`plugin.go:543`</small>
### `SocialRelation`
```go
type SocialRelation struct { Person string `json:"person"` Relation string `json:"relation"` }
```
SocialRelation represents a social relationship between two persons.
<small>`memory.go:116`</small>
### `TextEvent`
```go
type TextEvent struct { Role string `json:"role"` Content string `json:"content"` Timestamp int64 `json:"timestamp"` Channel …
```
<small>`memory.go:65`</small>
### `PluginSDK.TextMemory`
```go
func (s *PluginSDK) TextMemory() TextMemoryAPI
```
TextMemory returns the text memory API (may be nil if not available).
<small>`plugin.go:515`</small>
### `Triple`
```go
type Triple struct { Subject string `json:"subject"` Relation string `json:"relation"` Object string `json:"object"` Confidence float64 `json:"c …
```
Triple represents a subject-relation-object triple for the knowledge graph.
SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
<small>`memory.go:31`</small>

1044
docs/api/misc.md Normal file

File diff suppressed because it is too large Load Diff

197
docs/api/settings.md Normal file
View File

@ -0,0 +1,197 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 配置(Settings)
声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表。
## `SettingsAPI`
| 方法 | 说明 |
|---|---|
| [`DataDir`](#settingsapidatadir) | DataDir returns the plugin-specific data directory (guaranteed to exist): |
| [`Defs`](#settingsapidefs) | Defs returns config definitions matching the prefix. |
| [`Dump`](#settingsapidump) | Dump returns all config values. |
| [`Get`](#settingsapiget) | Get reads the plugin's own config value (config_<name> table). |
| [`GetCore`](#settingsapigetcore) | GetCore reads the core config table. |
| [`GetPlugin`](#settingsapigetplugin) | GetPlugin reads another plugin's config table. |
| [`List`](#settingsapilist) | List returns all keys matching the given prefix. |
| [`ListCore`](#settingsapilistcore) | ListCore lists core config keys matching the prefix. |
| [`ListPlugin`](#settingsapilistplugin) | ListPlugin lists another plugin's config keys matching the prefix. |
| [`Plugins`](#settingsapiplugins) | Plugins returns a list of all plugin config namespaces. |
| [`RegisterDef`](#settingsapiregisterdef) | RegisterDef registers a config definition for UI display. |
| [`Set`](#settingsapiset) | Set writes a config value to the plugin's own config table. |
| [`SetCore`](#settingsapisetcore) | SetCore writes to the core config table. |
| [`SetPlugin`](#settingsapisetplugin) | SetPlugin writes to another plugin's config table. |
### `SettingsAPI.DataDir`
```go
DataDir() string
```
DataDir returns the plugin-specific data directory (guaranteed to exist):
<daemon data>/plugin_data/<plugin_name>. Plugins should persist any
runtime files (generated images, caches, downloads) here.
<small>`settings.go:25`</small>
### `SettingsAPI.Defs`
```go
Defs(prefix string) []*ConfigDef
```
Defs returns config definitions matching the prefix.
<small>`settings.go:40`</small>
### `SettingsAPI.Dump`
```go
Dump() map[string]interface{}
```
Dump returns all config values.
<small>`settings.go:43`</small>
### `SettingsAPI.Get`
```go
Get(key string) (interface{}, error)
```
Get reads the plugin's own config value (config_<name> table).
<small>`settings.go:5`</small>
### `SettingsAPI.GetCore`
```go
GetCore(key string) (interface{}, error)
```
GetCore reads the core config table.
<small>`settings.go:14`</small>
### `SettingsAPI.GetPlugin`
```go
GetPlugin(plugin, key string) (interface{}, error)
```
GetPlugin reads another plugin's config table.
<small>`settings.go:28`</small>
### `SettingsAPI.List`
```go
List(prefix string) ([]string, error)
```
List returns all keys matching the given prefix.
<small>`settings.go:11`</small>
### `SettingsAPI.ListCore`
```go
ListCore(prefix string) ([]string, error)
```
ListCore lists core config keys matching the prefix.
<small>`settings.go:20`</small>
### `SettingsAPI.ListPlugin`
```go
ListPlugin(plugin, prefix string) ([]string, error)
```
ListPlugin lists another plugin's config keys matching the prefix.
<small>`settings.go:34`</small>
### `SettingsAPI.Plugins`
```go
Plugins() []string
```
Plugins returns a list of all plugin config namespaces.
<small>`settings.go:46`</small>
### `SettingsAPI.RegisterDef`
```go
RegisterDef(def ConfigDef)
```
RegisterDef registers a config definition for UI display.
<small>`settings.go:37`</small>
### `SettingsAPI.Set`
```go
Set(key string, value interface{}) error
```
Set writes a config value to the plugin's own config table.
<small>`settings.go:8`</small>
### `SettingsAPI.SetCore`
```go
SetCore(key string, value interface{}) error
```
SetCore writes to the core config table.
<small>`settings.go:17`</small>
### `SettingsAPI.SetPlugin`
```go
SetPlugin(plugin, key string, value interface{}) error
```
SetPlugin writes to another plugin's config table.
<small>`settings.go:31`</small>
### `ConfigDef`
```go
type ConfigDef struct { Key string `json:"key"` Default interface{} `json:"default,omitempty"` Type string `json:"type"` DisplayName string …
```
ConfigDef describes a configuration field for the WebUI.
<small>`settings.go:50`</small>
### `PluginSDK.Settings`
```go
func (s *PluginSDK) Settings() SettingsAPI
```
Settings returns the settings API for reading/writing plugin configuration.
sett 在 New 时一次性写入且无 setter,故不需要加锁。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:68` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:63` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:114` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:67` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
<small>`plugin.go:505`</small>

154
docs/api/stages.md Normal file
View File

@ -0,0 +1,154 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 阶段钩子(Stages)
在消息处理管道的固定点位插入自己的逻辑。阶段比工具更底层:工具是模型主动调用的,阶段是流程经过时必然触发的。
### `StageContext.IsResponded`
```go
func (c *StageContext) IsResponded() bool
```
<small>`plugin.go:213`</small>
### `StageContext.Lock`
```go
func (c *StageContext) Lock()
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:158` | `p.sessMu.Lock()` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:128` | `p.srvMu.Lock()` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:37` | `p.runMu.Lock()` |
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:427` | `p.mu.Lock()` |
<small>`plugin.go:211`</small>
### `StageContext.RLock`
```go
func (c *StageContext) RLock()
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:267` | `p.mu.RLock()` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:649` | `p.mu.RLock()` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:224` | `p.mu.RLock()` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1152` | `ctx.RLock()` |
<small>`plugin.go:209`</small>
### `StageContext.RUnlock`
```go
func (c *StageContext) RUnlock()
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:273` | `p.mu.RUnlock()` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:650` | `defer p.mu.RUnlock()` |
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:229` | `p.mu.RUnlock()` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1155` | `ctx.RUnlock()` |
<small>`plugin.go:210`</small>
### `PluginSDK.RegisterStage`
```go
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope)
```
RegisterStage registers a handler for a pipeline stage.
scope: StageScopeGlobal (default) — receives all stage events.
StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:152` | `s.RegisterStage(sdk.StagePreAction, p.stagePreAction)` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:719` | `s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal)` |
| [`sanitizer`](../examples/index.md#sanitizer) | `example/sanitizer/plugin.go:52` | `s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {` |
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:94` | `s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {` |
<small>`plugin.go:571`</small>
### `Stage`
```go
type Stage string
```
Stage represents a point in the message processing pipeline.
<small>`plugin.go:26`</small>
### `StageContext`
```go
type StageContext struct { mu sync.RWMutex RawMessage string UserID string GroupID string ContextMsgs []map[string]interface{} L …
```
StageContext provides context for stage handlers.
<small>`plugin.go:189`</small>
### `StageHandler`
```go
type StageHandler func(ctx *StageContext) error
```
StageHandler is a function that handles a pipeline stage event.
<small>`plugin.go:23`</small>
### `StageRegistrar`
```go
type StageRegistrar func(stage Stage, handler StageHandler)
```
StageRegistrar registers a stage handler.
<small>`plugin.go:407`</small>
### `StageScope`
```go
type StageScope int
```
StageScope controls which events a stage handler receives.
<small>`plugin.go:393`</small>
### `StageContext.Unlock`
```go
func (c *StageContext) Unlock()
```
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:164` | `p.sessMu.Unlock()` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:129` | `defer p.srvMu.Unlock()` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:40` | `p.runMu.Unlock()` |
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:432` | `p.mu.Unlock()` |
<small>`plugin.go:212`</small>

77
docs/api/tools.md Normal file
View File

@ -0,0 +1,77 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 工具(Tools)
注册 LLM 可调用的工具。工具是插件最主要的能力形态:模型看到 `ToolDef` 的说明后决定是否调用,调用时执行你的 `ToolHandler`。
### `ContentBlock`
```go
type ContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL *ImageURL `json:"image_url,omitempty"` AudioURL *AudioURL `jso …
```
ContentBlock 是多模态内容块(OpenAI 格式:text/image_url/audio_url)。
插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
<small>`plugin.go:954`</small>
### `PluginSDK.RegisterTool`
```go
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error
```
RegisterTool registers a tool that the LLM can call.
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:76` | `s.RegisterTool(tp+"a2a_query", sdk.ToolDef{` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:70` | `s.RegisterTool(tp+"acp_query", sdk.ToolDef{` |
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:159` | `s.RegisterTool(tp+"generate", sdk.ToolDef{` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:85` | `s.RegisterTool(tp+"video", sdk.ToolDef{` |
<small>`plugin.go:557`</small>
### `ToolCall`
```go
type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty …
```
ToolCall represents a model's request to call a tool.
<small>`plugin.go:227`</small>
### `ToolDef`
```go
type ToolDef struct { Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Description string …
```
ToolDef describes a tool that the plugin exposes.
<small>`plugin.go:279`</small>
### `ToolHandler`
```go
type ToolHandler func(args map[string]interface{}) (interface{}, error)
```
ToolHandler is a function that handles a tool call.
<small>`plugin.go:20`</small>
### `ToolResult`
```go
type ToolResult struct { CallID string `json:"call_id"` Name string `json:"name"` Plugin string `json:"plugin,omitempty"` Success bool `json:"suc …
```
ToolResult represents the result of a tool call.
<small>`plugin.go:235`</small>

3080
docs/assets/api-index.json Normal file

File diff suppressed because it is too large Load Diff

27
docs/assets/logo-mark.svg Normal file
View File

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
<g transform="translate(200,200)">
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

28
docs/assets/logo.svg Normal file
View File

@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="400" height="400">
<rect width="400" height="400" fill="#F8FAFC"/>
<g transform="translate(200,200)">
<circle cx="0" cy="0" r="105" fill="none" stroke="#E2E8F0" stroke-width="2.5" stroke-dasharray="8,6"/>
<rect x="-130" y="-170" width="36" height="340" rx="8" fill="#3B82F6"/>
<rect x="94" y="-170" width="36" height="140" rx="8" fill="#CBD5E1"/>
<rect x="94" y="70" width="36" height="100" rx="8" fill="#CBD5E1"/>
<rect x="-94" y="-18" width="188" height="36" rx="8" fill="#38BDF8"/>
<polygon points="0,-28 24.2,14 -24.2,14" fill="#F59E0B" stroke="#F59E0B" stroke-width="8" stroke-linejoin="round"/>
<circle cx="74" cy="-74" r="14" fill="#DBEAFE" stroke="#3B82F6" stroke-width="2.5"/>
<line x1="28" y1="-28" x2="63" y2="-63" stroke="#3B82F6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="-74" r="14" fill="#EDE9FE" stroke="#8B5CF6" stroke-width="2.5"/>
<line x1="-28" y1="-28" x2="-63" y2="-63" stroke="#8B5CF6" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="74" cy="74" r="14" fill="#CFFAFE" stroke="#06B6D4" stroke-width="2.5"/>
<line x1="28" y1="28" x2="63" y2="63" stroke="#06B6D4" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="-74" cy="74" r="14" fill="#FEF3C7" stroke="#F59E0B" stroke-width="2.5"/>
<line x1="-28" y1="28" x2="-63" y2="63" stroke="#F59E0B" stroke-width="3" stroke-dasharray="6,4" opacity="0.6"/>
<circle cx="0" cy="0" r="42" fill="none" stroke="#F59E0B" stroke-width="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

60
docs/examples/index.md Normal file
View File

@ -0,0 +1,60 @@
<!-- 本页由 tools/apidoc/gensite 从源码生成,请勿手改;要改文档就改 sdk/*.go 的注释。 -->
# 示例插件
SDK 仓 `example/` 下有多个**真实可编译**的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态。
每个示例都能用 `hmapdev build` 打成 `.hmap` 装进内核直接跑。
## `a2a`
用到的 API:`Error` · `InjectInputSync` · `Lock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
## `acp`
用到的 API:`Error` · `InjectInputSync` · `Lock` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
## `ai_image`
用到的 API:`Error` · `RegisterTool` · `SetAutoRestart` · `Settings`
## `bili`
用到的 API:`Lock` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
## `browser`
用到的 API:`Error` · `InjectInterruptTextOpts` · `InjectTextNoMemory` · `Lock` · `RegisterInputChannel` · `Unlock`
## `calendar`
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOnRemoveHandler` · `RegisterStopHandler`
## `deepsearch`
用到的 API:`RegisterStopHandler`
## `memo`
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOnRemoveHandler` · `RegisterStage`
## `qq`
用到的 API:`InjectInterruptTextOpts` · `RLock` · `RUnlock` · `RegisterOutputChannel` · `RegisterStage`
## `recoverydiag`
用到的 API:`Knowledge`
## `rss`
用到的 API:`RegisterOnRemoveHandler`
## `sanitizer`
用到的 API:`RegisterStage`
## `weather`
用到的 API:`RegisterOutputChannel` · `RegisterStage`

View File

@ -0,0 +1,75 @@
# 能力边界:哪些 API 外部插件能用
HomeAgent 有两类插件:
| 类型 | 说明 | 分发 |
|---|---|---|
| **外部插件** | 第三方开发,编译成 `.hmap` 后安装 | 独立分发,**可闭源** |
| **内置插件** | 编译进内核,`init()` 自注册 | 随内核发行,需合入主仓 |
SDK 包是**同一个** `gitcode.com/JianFeeeee/homeagent-sdk/sdk`,但两类插件拿到的
**能力不同**:外部插件跑在独立进程里,由内核通过桥接注入能力(IPC,不是共享内存里的直接调用)。
本页说明边界在哪、为什么,以及**怎么在写代码前就知道某个 API 是否可用**。
## 一句话规则
> **公开 SDK 包里声明的符号,不等于外部插件拿得到。**
原因是:有些能力只有进程内的内置插件才可能拥有(比如直接读事件发布通道、
直接注入到内核 IO 层)。外部插件通过桥接运行时拿到的是一份**受注入的能力集合**。
## 外部插件**不可用**的 API
这些 API 在公开包里存在,但在外部插件路径上拿不到。文档里每条都带
<span class="api-badge api-badge-builtin">仅内置</span> 标记,
完整清单见 [仅内置插件可用](../api/builtin-only.md)。
| API | 外部插件的实际情况 | 该用什么 |
|---|---|---|
| `sdk.PluginSDK.Events()` | **恒为 nil**。桥接运行时不注入 event subscriber(`SetEventSubscriber` 全仓无调用点) | 桥接运行时已按你的声明完成 `events.subscribe`;Lua 插件用 `sdk.events.subscribe` |
| `sdk.PluginSDK.SetEventSubscriber` | 无人调用 | 同上 |
| `UnregisterOutputChannel` | 桥接只注入 registrar、**不注入 unregistrar**,调用是**静默无效**(返回 nil,不报错也不注销) | `RegisterOutputChannel` 可用;注销需重载插件 |
| `SocialAPI` 的写操作 | 公开接口只有 6 个**只读**方法 | 读用 `s.GetPerson` 等;写需内置插件 |
| `EventSubscriber.Publish` | 公开接口**刻意只有 Subscribe**,没有 Publish | 只订阅 |
| `PriorityL4` | 声明会被内核**夹到 L3** | 用 L1–L3 |
| `RegisterChannel` / `ListChannels` / `OutputChan` / `InjectInput` / `InjectInterrupt` | 只存在于内核内部 SDK | `RegisterInputChannel` / `RegisterOutputChannel` / `InjectText` 等公开方法 |
| `PluginMgr()` 的完整能力 | 公开 `PluginMgrAPI` **只有 3 个方法**(`ReloadOne` / `ListLoadedPlugins` / `IsPluginDisabled`) | 就这 3 个;`ReloadPlugins`/`Disable`/`Remove` 属内部接口 |
!!! warning "两处常见的文档错误(本站已更正)"
1. **`PluginMgr()` 不是「仅内置可用」**。桥接模板显式注入了它
(`base.SetPluginMgrAPI(procPluginMgr{})`),公开 `PluginMgrAPI` 也注明
「外部插件可调用」。真正的区别是**方法数量**:公开面 3 个,内部面 9 个。
容易混淆是因为两个包里有**同名但不同**的接口:
`sdk.PluginMgrAPI`(3 方法)与 `internal/sdk.PluginManager`(9 方法)。
2. **`Events()` 恒为 nil 这件事以前没写清**。旧文档把 `Events()` 当作
可用的订阅入口,但桥接运行时不注入 subscriber。外部插件的事件订阅
实际由生成的运行时通过 `events.subscribe` 完成。
## 判定依据来自哪里
本站的「仅内置」标记不是猜的,逐条来自:
1. **`tools/hmapdev/templates/proc_main.go.tmpl`** —— 外部插件运行时**实际注入**
哪些能力,看 `buildPluginSDK()` 里的 `base.Set*` 调用。
2. **`internal/sdk`** —— 内置插件用的完整接口,与公开包对照。
3. **内核 RPC 协议表**(`internal/plugin/proc/protocol.go`)—— 外部插件**能发哪些请求**。
每条裁定的具体依据写在该 API 的告警框里,可以直接核对。
## 怎么快速确认
- 用 [API 搜索](../api/index.md) 搜 API 名或功能描述,带
<span class="api-badge api-badge-builtin">仅内置</span> 的就是外部不可用
- 直接看 [仅内置插件可用](../api/builtin-only.md) 汇总页
- 拿不准时,**读 `example/` 下的示例插件** —— 它们全是外部插件,
能被它们编译通过的写法,外部就一定可用
## 为什么这样设计
不是为了限制,而是**IPC 边界决定了能力边界**:外部插件跑在独立进程里,
内核只能通过显式的注入点把能力交过去。凡是需要「持有内核内部数据结构」
的能力(事件发布通道、IO 通道、插件注册表全量操作),进程外都无法安全暴露。
这套边界同时带来好处:插件崩溃不会带崩内核(进程隔离),
以及**插件可以闭源**(SDK 是 MIT,见[首页](../index.md#_3))。

View File

@ -0,0 +1,111 @@
# 第一个 Lua 插件
Lua 插件适合**轻量、快速原型**:不需要 Go 编译环境,改完重启内核即可生效。
但它有一个必须理解的限制 —— 执行模型是**被动回调**。
## 执行模型(先读这段)
Lua 插件跑在内核进程内的 gopher-lua 解释器里(单 Lua 状态 + 互斥锁):
- **被动回调**:`main.lua` 只在加载时执行一次。此后工具、阶段钩子、
输入输出通道全部由内核事件驱动回调你的 Lua 函数。**插件不能自己启动后台任务。**
- **没有并发**:Lua 侧没有 goroutine、协程调度,也没有 `os` / `io` 库和 socket 监听。
唯一主动出站通道是 `sdk.http.get/post`(同步请求)。
- **任何阻塞循环都会持锁卡死该插件的全部调用。**
!!! warning "要常驻服务就用 Go 插件"
需要监听端口、后台轮询、定时任务的,请用 [Go 插件](first-plugin.md)
(可自行启动 goroutine)。Lua 侧的等价做法是**事件驱动**:把逻辑挂在
工具、阶段钩子或通道回调上。
## 生成工程
```bash
hmapdev init myluaplugin --lua
cd myluaplugin
```
结构:
```
myluaplugin/
├── plg.json — entry: "main.lua", targets: "lua"
├── main.lua — 插件实现
├── sdk.lua — SDK 模拟层(支持独立测试)
└── README.md
```
## 一个完整的插件
```lua
-- main.lua
local plugin = {
name = "myluaplugin"
}
function plugin.start(sdk)
sdk.log("info", "myluaplugin starting...")
sdk.register_tool("myluaplugin_hello", {
description = "向指定的人打招呼",
parameters = {
type = "object",
properties = {
who = { type = "string", description = "要打招呼的对象" }
},
required = { "who" }
}
}, function(args)
return { content = "hello, " .. (args.who or "world") .. "!" }
end)
sdk.log("info", "myluaplugin started")
end
function plugin.stop()
sdk.log("info", "myluaplugin stopped")
end
return plugin
```
## 本地测试
`sdk.lua` 是纯 Lua 的 SDK 模拟实现,可以直接用解释器跑:
```bash
lua main.lua
# [lua-plugin] info: myluaplugin starting...
# [lua-plugin] register_tool: myluaplugin_hello
# [lua-plugin] info: myluaplugin started
```
在内核里运行时,`sdk.*` 由 Go 层注入,`sdk.lua` 里所有 `-- !impl` 标记的函数
会被替换成真实实现。
## API 约定的两点
- **注册类函数调用即时报错**(抛 Lua error)—— 注册失败不会静默。
- **数据类函数统一返回 `(result, err)`**,`err` 为 nil 表示成功。
核心未装配的子系统(如 SocialAPI)返回空值而非报错。
Lua 侧的 `sdk.*` 能力与外部 Go 插件对齐至 SDK 1.3.0(需内核 1.4.0+)。
!!! note "历史提醒"
1.1–1.3 期间,媒体 / 注入标志位 / 优先级能力只在 Go 侧有,Lua 侧静默缺失。
现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住
「`sdk.lua` 承诺的每个函数都有运行时绑定」。
## 构建
```bash
hmapdev build # → dist/myluaplugin_lua.hmap
```
Lua 插件直接打包源码,不经过编译。
## 下一步
- [能力边界](capability-boundary.md) —— Lua 与 Go 外部插件的能力面一致
- [打包与发布](packaging.md)
- [示例](../examples/index.md) —— `example/luademo` 是 Lua 版参考实现

162
docs/guide/first-plugin.md Normal file
View File

@ -0,0 +1,162 @@
# 第一个 Go 插件
以下是一个**能直接跑起来**的最小插件:注册一个工具、声明一项配置、处理停止与卸载。
## 1. 生成工程
```bash
hmapdev init myplugin
cd myplugin
```
生成的结构:
```
myplugin/
├── plg.json — 插件元信息(名称、版本、入口、目标平台)
├── plugin.go — 插件实现
├── go.mod — 模块定义
├── README.md
└── thirdpart/ — 外部源码存放目录(可选)
```
`hmapdev build` 时会在构建目录自动生成子进程运行时(`z_proc_gen.go` 等),
**不需要手工创建,也不要提交**。
## 2. 插件实现
插件的全部契约是一个 `Plugin` 接口([API 参考](../api/lifecycle.md#plugin)):
| 方法 | 何时调用 |
|---|---|
| `Name() string` | 内核需要标识这个插件时 |
| `Start(*sdk.PluginSDK) error` | 插件加载后。**在这里注册工具、通道、配置** |
| `Stop() error` | 插件停止时(重载、禁用、内核退出都会触发) |
再加一个工厂函数。**名字必须是 `NewPluginFactory`** —— 生成的运行时按这个名字调用:
```go
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
```
!!! warning "不要写成 `NewPlugin`"
生成的子进程运行时调用的入口是 `NewPluginFactory`。仓库里有 3 个早期示例
同时保留了两个名字(`NewPlugin` 只是遗留别名),但新插件只写
`NewPluginFactory` 即可。写错名字的后果是**编译能过、加载时找不到入口**。
## 3. 一个完整的例子
这是一个「打招呼」工具,带一项配置:
```go
package main
import (
"fmt"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
// ① 声明配置项:内核会把它渲染到 WebUI 设置页
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.myplugin.greeting",
Default: "hello",
Type: "string",
DisplayName: "问候语",
Description: "打招呼时使用的前缀",
Category: "myplugin",
})
// ② 注册工具:模型看到 Description 后决定是否调用
tp := p.name + "_"
s.RegisterTool(tp+"hello", sdk.ToolDef{
Name: tp + "hello",
Description: "向指定的人打招呼",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"who": map[string]interface{}{
"type": "string",
"description": "要打招呼的对象",
},
},
"required": []string{"who"},
},
}, p.handleHello)
// ③ 卸载(插件被删除)前清理自己产生的数据。
// 注意与 Stop 的区别:Stop 在每次重载时也会触发。
s.RegisterOnRemoveHandler(func() {
fmt.Printf("[%s] 清理数据\n", p.name)
})
return nil
}
func (p *Plugin) Stop() error { return nil }
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
who, _ := args["who"].(string)
greeting := "hello"
if v, err := p.sdk.Settings().Get("plugin.myplugin.greeting"); err == nil && v != "" {
greeting = v
}
return map[string]interface{}{
"content": fmt.Sprintf("%s, %s!", greeting, who),
}, nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
```
## 4. 工具返回值的两条约定
`ToolHandler` 返回 `(interface{}, error)`,模型侧看到的是一条 tool message:
- **正常结果**:返回一个 map,把要展示给模型的文本放在 `content` 字段。
未识别的字段也会一并传给模型,可以放结构化数据。
- **业务失败**:返回 `map[string]interface{}{"isError": true, "content": "原因"}`
**并返回 nil error**。这样模型能看到失败原因并自行调整;
若返回 Go 的 `error`,那是**工具调用本身出错**,语义不同。
```go
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{"isError": true, "content": msg}
}
```
## 5. 构建与安装
```bash
hmapdev build # 默认产出多平台 bundle
# → dist/myplugin_bundle.hmap
hmapdev build --no-bundle # 只构建当前平台
# → dist/myplugin_linux_amd64.hmap
```
安装到内核:在 WebUI 的插件管理页上传 `.hmap`,或从 URL / 本地路径安装。
详见 [打包与发布](packaging.md)。
## 下一步
- [能力边界](capability-boundary.md) —— 哪些 API 外部插件能用
- [工具(Tools)](../api/tools.md) —— `ToolDef` 的完整字段
- [记忆(Memory)](../api/memory.md) —— 让插件读写长期记忆
- [示例插件](../examples/index.md) —— `example/memo` 是个完整的可读实现

View File

@ -0,0 +1,68 @@
# 环境与工具链
`hmapdev` 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
最终产出 `.hmap` 插件包(工具名即取自这个包格式)。
!!! note "改名说明"
1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
`~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
(旧目录会自动继续沿用)。
## 安装
从源码构建:
```bash
git clone https://github.com/JianFeeeee/homeagentsdk
cd homeagentsdk/tools/hmapdev
go build -o hmapdev
# 把 hmapdev 放进 PATH,或直接用 ./hmapdev
```
也可以从 SDK 的 release 附件下载预编译二进制(`hmapdev_linux_amd64` 等,
共 5 个平台:linux/darwin/windows × amd64/arm64)。
> 仓已迁到 GitHub;gitcode 仅作国内镜像(源码同步,**release 附件暂时仍在那里**):
> `https://gitcode.com/JianFeeeee/homeagent-sdk/releases`。
> Go 模块路径仍是 `gitcode.com/JianFeeeee/homeagent-sdk` —— 这是有意保留的,
> 改模块路径会让现有插件的 `go.mod` 全面失效。
## SDK 版本管理
`hmapdev` 会维护一份本地 SDK 存储,`init` 时按 `plg.json` 里的 `sdk` 字段
选择版本。两者**必须**一致,否则编译出的插件与内核协议可能错配。
```bash
hmapdev sdk list # 已安装的 SDK 版本
hmapdev sdk current # 当前使用的版本
hmapdev sdk latest # 最新可用版本
hmapdev sdk install v1.2.0 # 安装指定版本
hmapdev sdk use v1.2.0 # 切换版本
hmapdev sdk path # 当前 SDK 路径
```
存储在 `~/.homeagent/hmapdev/sdk/<version>/`。
!!! warning "版本未命中会**明确报错**"
`plg.json` 声明的 `sdk` 版本若不在本地存储里,`hmapdev` 不会退回某个默认版本,
而是报错并让你先 `hmapdev sdk install`。这是有意的:静默降级会产出与内核
协议不匹配的插件,那种失败要到运行时才暴露。
## 源码调试
不编译直接跑插件源码,输出调用轨迹:
```bash
hmapdev debug [dir] # dir 默认当前目录
```
写 Lua 插件时更简单——`sdk.lua` 是 SDK 模拟层,可以直接用解释器跑:
```bash
lua main.lua
```
## 下一步
- [第一个 Go 插件](first-plugin.md)
- [第一个 Lua 插件](first-lua-plugin.md)

View File

@ -0,0 +1,56 @@
# 多平台构建
## 默认就是多平台
`hmapdev build` 默认 bundle 模式,一次产出含三个平台的单个 `.hmap`:
```
dist/myplugin_bundle.hmap
└── plugin.bin.linux.amd64
└── plugin.bin.darwin.amd64
└── plugin.bin.windows.amd64
```
安装时内核挑当前平台那份,重命名为 `plugin.bin`。
## 逐平台构建
```bash
hmapdev build --no-bundle # 按 plg.json 的 targets 构建
hmapdev build --target linux/arm64 # 追加一个目标
```
`plg.json` 里声明目标:
```json
{
"name": "myplugin",
"version": "1.0.0",
"targets": "linux/amd64,windows/amd64"
}
```
单平台输出文件名:`{name}_{os}_{arch}.hmap`。
## 交叉编译
子进程插件**不再需要 cgo**,所以交叉编译不需要目标平台的 C 工具链 ——
这是 v1.0.0 的收益之一。
!!! note "bundle 模式忽略 `targets`"
固定构建 linux/amd64、darwin/amd64、windows/amd64。如果你只需要其中一个,
用 `--no-bundle` 更快。
## 平台能力差异
历史上有过一处真实的平台断层,现已消除:
- **v1.0.0 之前**:Windows 上插件只看到 **3 个 stage 字段、且无法写回**。
- **v1.0.0 起**:Windows 与其他平台**共用同一套 RPC 实现**,16 字段全可见 + 写回。
因此**不必**为 Windows 写条件分支 —— 除非你的插件自己用了平台专有的外部命令。
## 下一步
- [打包与发布](packaging.md)
- [环境与工具链](getting-started.md)

114
docs/guide/packaging.md Normal file
View File

@ -0,0 +1,114 @@
# 打包与发布
`hmapdev build` 一次完成编译与打包,产出 `.hmap` 分发包(zip 格式,内含
`plugin.json` 清单 + 二进制)。
## 命令
```bash
hmapdev build # 默认 bundle(多平台合集)
hmapdev build --no-bundle # 只构建 plg.json targets 里的平台
hmapdev build --target linux/arm64 # 在 targets 基础上追加目标
hmapdev build --outdir out # 指定输出目录(默认 dist)
hmapdev build --sdk-path <path> # 覆盖 go.mod 的 replace 指向的 SDK
hmapdev build --replace <mod@path> # 追加 go.mod replace(可多次)
```
执行流程:
1. 读 `plg.json` 的 `targets` / `bundle` 决定构建目标
2. 生成子进程运行时代码(`z_proc_gen.go`、`z_proc_shm_*.go`)
3. **Go 插件**:`go build`(普通可执行文件,`CGO_ENABLED=0`)
**Lua 插件**:直接打包源码,不编译
4. 生成 `plugin.json` 输出清单
5. 打成 `.hmap`
## 两个 JSON 的区别
这一点经常混淆:
| 文件 | 谁维护 | 作用 | 关键字段 |
|---|---|---|---|
| `plg.json` | **你** | 项目元信息,构建输入 | `targets`、`bundle` |
| `plugin.json` | `hmapdev` 自动生成 | 构建产物清单 | `entry`、`platforms` |
`plg.json` 里的 `sdk` 字段声明**本插件针对的 SDK 版本**;未命中本地 SDK 存储
会明确报错(见[环境与工具链](getting-started.md))。
## 多平台(bundle)
`build` 默认就是 bundle 模式:一次编译 linux/amd64、darwin/amd64、windows/amd64,
产出一个含全部平台二进制的 `.hmap`;安装时内核挑当前平台那份。
```bash
hmapdev build # → dist/myplugin_bundle.hmap
hmapdev build --no-bundle # → dist/myplugin_linux_amd64.hmap 等
```
!!! note "bundle 模式会忽略 `plg.json` 的 `targets`"
固定构建上述三个平台。交叉编译需要对应工具链(如 Linux 上构建 darwin 需要
clang / macOS SDK),缺工具链时会失败 —— 此时用 `--no-bundle` 只构建当前平台。
bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分,安装时重命名为 `plugin.bin`。
## 产物形态
子进程插件是**普通可执行文件**,不分平台后缀:
| 平台 | 二进制 |
|---|---|
| Linux / macOS / Windows | `plugin.bin` |
!!! warning "v1.0.0 破坏性变更:不再加载 `.so` / `.dll`"
外部插件从 C ABI 动态库改为**子进程 + 共享内存**。
- `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。
新内核遇到旧产物会跳过并报可操作错误,不崩溃。
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev`
(原 `plugindev`)重编即可。
- `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写 `plugin.so` 也无妨),
现在只用于区分 Lua 插件。
- 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
## 安装
三种方式(`9876` 是 pluginmgr 的本地端口,默认只监听 `127.0.0.1`、无鉴权):
```bash
# 从 URL 安装(仅 http/https,流式下载不落盘)
curl -X POST http://127.0.0.1:9876/plugins \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/myplugin.hmap"}'
# 从本地路径安装(读取文件,不移动原文件)
curl -X POST http://127.0.0.1:9876/plugins \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/myplugin.hmap"}'
# 直接上传二进制
curl -X POST http://127.0.0.1:9876/plugins \
--data-binary @dist/myplugin_bundle.hmap
```
安装后调用 `/api/v1/plugins/reload` 或重启内核生效。
走 WebUI 的 HTTP API(默认 `8080`,需 `api_key` 鉴权,内部代理到 pluginmgr):
```bash
curl -X POST http://127.0.0.1:8080/api/v1/plugins \
-H "Authorization: Bearer <api_key>" \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/myplugin.hmap"}'
```
也可以在 WebUI 的插件管理页面上传。
## 发布前自查
- [ ] `plg.json` 的 `sdk` 版本与目标内核匹配
- [ ] `version` 已递增(内核按版本判断是否需要重装)
- [ ] 若插件有外部状态,`SetAutoRestart(false)` 或在 `Start` 里重建连接
(崩溃重启是**线性退避** 1s→2s→3s,5 分钟内第 4 次崩溃即停止,
见[生命周期](../api/lifecycle.md#pluginsdksetautorestart))
- [ ] `RegisterOnRemoveHandler` 里清理自己写下的数据文件
- [ ] 在 `-race` 下跑一遍:插件的 `Start` 与工具的并发访问是最常见的竞态来源

View File

@ -0,0 +1,112 @@
# 工具并发声明:`ParallelSafe` / `Serial`
> 对应 `sdk.ToolDef` 的两个字段。内核在**同一轮**收到多个 `tool_call` 时,
> 依据它们决定并发还是整批串行。
>
> 状态:已随 2026-09 的并行内核落地并在生产启用。
## 1. 为什么是"保守 opt-in"
**默认整批串行。** 只有当**批内每一个**工具都显式声明 `ParallelSafe: true`
时,那一批才并发;**只要有一个不声明,整批退回串行**。
这不是"漏了声明导致退化"的将就,而是刻意的设计:
- 存量插件**不改一行**就得到保守行为(整批串行),不会被升级意外并发
- 声明是**责任**而非特权 —— 声明者必须自己确认线程安全
- 宁可慢,不可错:一次错误的并发可能让两个工具抢同一个 SQLite 写、
同一台设备、或同一个输出通道
```go
// 同批全是安全工具 → 并发
tools: [a(ParallelSafe), b(ParallelSafe)] ⇒ 并发
// 只要有一个没声明 → 整批串行
tools: [a(ParallelSafe), b(默认)] ⇒ 串行
```
## 2. 三个条件都满足才可以声明 `ParallelSafe`
1. **handler 自身线程安全** —— 不持有跨调用的可变状态
2. **不与同批其它工具争抢同一资源** —— SQLite 写、设备、同一输出通道
3. **执行顺序无关** —— 顺序敏感的工具应留 `false`,由内核保序
第 3 条常被忽略:内核能保证**用户可见的消息**按声明顺序落盘,但**工具
之间的实际执行先后**在并发模式下不确定。有顺序依赖就留 `false`。
## 3. `Serial`:显式的反向标记
```go
Serial bool `json:"serial,omitempty"`
```
`ParallelSafe` 的零值 `false` 已经表达"串行",插件**无法区分**:
- "我没想过"
- "我确认过**必须**串行,且有原因"
一旦工具作者需要把"这里**故意**串行,是有原因的"写进代码(而不只是没填),
这个区分就是必需的 —— 否则只能靠命名约定传递意图。
适用场景:读操作但有隐含顺序约束(终端 `read`/`resize` 这类共享会话
状态);写操作虽已加锁但需要串行以获得可预测的交错顺序。
**优先级:`Serial` 胜出。** 即使同时写了 `ParallelSafe: true`,`Serial`
仍然生效 —— 显式声明"必须串行"不允许被 `ParallelSafe` 或任何默认值覆盖。
## 4. 写法
声明字段放在 `ToolDef` 结构体的**末尾**,遵循既有 `NoMemory` 的风格:
```go
sdk.RegisterTool(sdk.ToolDef{
Name: "my_readonly_query",
Description: "……",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Handler: h.query,
ParallelSafe: true, // 声明在末尾
}, s)
```
需要"故意串行"时:
```go
sdk.RegisterTool(sdk.ToolDef{
Name: "my_terminal_input",
Handler: h.input,
Serial: true, // 胜出,忽略 ParallelSafe
}, s)
```
## 5. 内置工具
核心仓的内置工具用 `toolDefOptions` / `parallelOpts()` 声明
(`internal/agent/core/tooldefs.go`),最终由 `buildToolDefs` 汇总成
`ToolDef.ParallelSafe`。
内核只提供并行调度基础设施,**不硬编码任何工具名的安全状态表** ——
状态由每个工具在自己的声明结构里给出,查询时走聚合表。
## 6. 效果(实测)
同批 N 个各约 250ms 的工具,新版内核"内部并发"对"强制串行":
| N | 加速比 |
| --- | --- |
| 2 | ~1.41× |
| 4 | ~2.22× |
| 8 | ~3.88× |
批耗时几乎不随 N 增长,串行批严格线性。
> 压测时**必须同时记录实际执行的工具数**,不能只看耗时。
> 某次对照中旧版本耗时更短、但因适配器缺 `stream_index` 导致
> **实际处理 0 个工具** —— 那不是性能提升,是全失败。
## 相关
设计背景与踩坑见**核心仓**文档(不在本仓):
- `docs/zh/toolcall-contract-and-sequence-design.md` —— 契约与序列设计
- `docs/zh/toolcall-parallel-execution-plan.md` —— 阶段、实测压测数据
- `docs/zh/deploy-runbook.md` —— 生产部署(含适配器 `stream_index` 相关)

113
docs/guide/scene-memory.md Normal file
View File

@ -0,0 +1,113 @@
# 场景记忆(Scene Memory)
> 场景式记忆是内核 v1.3 起的能力。它不新增 API 面,只影响**你的输入被怎样记住与取回**。
> 与 `NoMemory` / `ContextPolicy` / `RecallPolicy` 并列为第四项声明:`ScenePolicy`。
## 它解决什么问题
三层记忆按**字面相关性**召回:你得说出相近的词,记忆才会被取回来。
场景记忆补上另一半:按**场合**召回。
同一场合再次出现时,当时挂在这个场合上的约定、偏好、人物关系会自动回来——
与这次说了什么措辞无关。
```
你:以后在群里回消息简短点
└─ 这条记忆挂到场面「chan:qq + peer:group_xxx」上
一周后,同一个群里有人问「上次说的格式是什么」
└─ 场面重现(还没等你提到「格式」),那条约定已经被取回
```
## 场面是自己长出来的
场景**不需要声明**。每轮交互,内核采集一组可观察信号当这轮<E8BF99><E8BDAE><EFBFBD>「场面指纹」:
| 特征 | 来源 | 权重 | 说明 |
|---|---|---|---|
| `chan` | 输入通道名 | 1.0 | 最强的同一性信号 |
| `peer` / `peer_group` | 注入点给的 `payload` 里的 `group_id`/`user_id`/`chat_id` 等 | 1.0 | 群与私聊分开,避免互相命中 |
| `tool` | 触发这一步的工具名 | 0.8 | 行为信号 |
| `topic` | 清洗后输入的内容词 | 0.4 | 软信号,同场面的不同话题不该被拆开 |
| `part` | 时段(夜间/上午/下午/晚间) | 0.2 | 最弱,只做辅助 |
指纹反复重合时,一场场面就成形了。相似度按**加权 Jaccard** 算
(共享特征的权重和 ÷ 并集的权重和)——不加权的话,一次偶然的话题重合
会把两个不同场面并成一个。
**同类场面出现第二次才被认定。** 一次性的交互不建场面:
那不是「场面」,建了只会让图库被一次性事件撑满。
## 声明你的参与姿态
```go
sdk.ChannelDef{
ScenePolicy: sdk.ScenePolicyNone, // 这条通道不参与场面识别
}
```
或单次注入覆盖:
```go
sdk.InjectOptions{
ScenePolicy: sdk.ScenePolicyNone,
}
```
| 取值 | 含义 |
|---|---|
| `""`(空)/ `ScenePolicyAuto` | **参与**(默认,保持既有行为) |
| `ScenePolicyNone` | **不参与**:不产任何场面指纹,也不派生场景键 |
**默认是参与而不是不参与**,与 `ContextPolicy` 刻意相反。原因是场景只
**附加**检索路径、不改记忆本体,默认关会让存量通道突然失去场景召回;
而「关」是少数意图(纯内部信号)。
声明 `none` 之后连时段特征都不产——一个不参与的门面不该在场面索引里
留下任何足迹。
### 谁该考虑关掉
内核自循环(`system`)、心跳(`timer`)、内部状态汇报(`kernel`)这类
纯内部信号。它们每次触发都在撑一个场面,会把不相干的交互聚到一起。
反过来说,**多标一个通道通常没有代价**:一个没人往上面写记忆的场面,
召回时返回空。关不关都不影响正确性——所以拿不准时,默认参与就好。
## 怎么给场面命名
场景键有两种来源:
**通道派生(默认)**——`evt.Source` 派生出 `chan:qq` 这类键。你不用管。
**显式声明(进阶)**——在注入时给出更有语义的键:
```go
p.sdk.InjectInterruptTextOpts("qq", "qq", text, sdk.InjectOptions{
ScenePolicy: sdk.ScenePolicyAuto,
})
```
也可以通过 `payload["scene"]` 传层级键(支持 `string` / `[]string` /
`[]interface{}` 三种形态):
```go
"chan:qq/peer:group_1027"
```
召回走**前缀匹配**(`chan:qq` 能覆盖 `chan:qq/peer:xxx`),用 `/` 兜底
以免 `chan:qq` 误吞 `chan:qq2` 这种同前缀但不同层的场景。
## 场面记忆不改变什么
- **不改记忆本体**:场景是记忆的**附加索引**,删掉场景不删记忆。
- **不让模型负责**:`memory_commit` 的 `scene` 留空即可,内核会挂到本轮
解析出的场面上。留空是安全的一侧——猜错的场面会把无关记忆钉死。
- **不影响同步通道**:`webui` / `cli` / 终端走 `ResponseCh`,不经
`output_send__*`,与场面无关。
## 相关 API
- `ChannelDef.ScenePolicy` —— 通道级声明(见 [输入/输出通道](../api/channels.md))
- `InjectOptions.ScenePolicy` —— 单次注入覆盖(见 [其他类型](../api/misc.md))
- `ScenePolicyAuto` / `ScenePolicyNone` / `ValidScenePolicy` —— 常量与校验

81
docs/guide/security.md Normal file
View File

@ -0,0 +1,81 @@
# 受限 SDK 与安全
外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层**安全边界**:
外部插件的进程不共享内核地址空间,能力通过显式注入点交过去。
## 三层隔离
| 层 | 机制 | 防住了什么 |
|---|---|---|
| **进程** | 插件跑在独立子进程 | 插件 panic / 内存越界**不会带崩内核** |
| **能力** | 只注入显式声明的接口 | 插件拿不到未授权的内核内部结构 |
| **权限** | 公开接口是内部接口的**只读子集** | 插件无法改写他人数据 |
第一种是 v1.0.0 从 C ABI 动态库改为子进程 + 共享内存的直接收益:
在此之前,插件 panic 会带崩 `homed`。
## 受限接口是怎么实现的
**按接口裁剪,而不是按方法裁剪。** 同一个概念在公开包与内部包里是**两个不同的
接口声明**,公开的那个只保留安全子集:
```go
// 公开 SDK:6 个只读方法
type SocialAPI interface {
GetPerson(name string) (*PersonProfile, error)
GetTrait(name, trait string) (string, bool)
GetRelations(name string) ([]SocialRelation, error)
GetNetwork(name string, depth int) ([]*PersonProfile, error)
ListPersons() ([]string, error)
}
```
写操作只在内核内部接口里。这样外部插件**在类型层面就调不到**,
不是靠运行时检查拦截。
同理,`EventSubscriber` 公开版**刻意只有 `Subscribe`,没有 `Publish`**:
```go
// 插件可以订阅,但由内核决定投递哪些事件
type EventSubscriber interface {
Subscribe(eventType EventType, handler EventHandler) func()
}
```
## 进程边界带来的约束
事件订阅是理解这层边界的典型例子。公开包里有一个 `Events() EventSubscriber`,
但**外部插件拿到的恒为 nil** —— 桥接运行时不注入它(`SetEventSubscriber`
在全仓没有调用点)。外部插件的事件订阅由生成的运行时通过 `events.subscribe`
RPC 完成,Lua 插件走内部 SDK 的 `Subscribe`。
这不是缺陷,而是进程边界的结果:跨进程无法共享内核的事件发布通道。
详见[能力边界](capability-boundary.md)。
## 共享内存中的数据面
工具调用帧、Cleaner、输入输出通道、媒体块、文档与知识正文**都走共享内存**,
RPC 只传偏移描述符。因此:
- 大对象不经 JSON 序列化,避免了大 payload 的性能与内存放大;
- StageContext 在同一份状态上读改写,消除了副本模型的 lost update
(实测由 35.8~36.8% 降到 0)。
`SharedRef`(共享内存描述符)是**内部实现细节**,插件开发者看不到它 ——
公开 SDK 只暴露普通字符串与 map。
## 插件作者的实践建议
- **不要在 `Start` 里长时间阻塞** —— 内核在等待它返回。
- **工具处理器要可并发**:模型可能并发发起多个调用;共享状态用锁保护
(`example/memo` 用 `sync.RWMutex`)。
- **写文件用原子替换**(临时文件 + rename),避免进程被强杀时截断数据。
- **声明 `NoMemory`**:定时提醒、连接状态这类不是对话内容的东西,
别让它们污染记忆(`InjectOptions{NoMemory: true}`)。
- **在 `-race` 下测**:插件重载瞬间的并发访问是历史高发缺陷。
## 许可与分发
SDK 是 **MIT**,插件可以**闭源分发**,可商用、可私有,无需回馈。
这是刻意的:SDK 随插件静态链接(源码进入插件二进制),用传染性许可会
强迫插件开源。内核本身是 AGPL-3.0-only,但那是内核的许可,与外部插件无关。

View File

@ -0,0 +1,108 @@
# 流式多 `tool_call`:适配器必须透传 `index`
> 面向在 Lua 里写适配器(`transform_stream_chunk`)的插件作者。
>
> 状态:已随 2026-09 的并行内核落地;生产 `openai.lua` 等适配器已修复。
## 1. 问题
OpenAI 兼容的流式响应里,同一轮的多个 `tool_call` 以**分片**形式到达,
靠 `index` 字段区分归属:
```
data: {"choices":[{"delta":{"tool_calls":[
{"index":0,"id":"call_a","function":{"name":"alpha","arguments":""}}]}}]}
data: {"choices":[{"delta":{"tool_calls":[
{"index":1,"id":"call_b","function":{"name":"beta","arguments":""}}]}}]}
data: {"choices":[{"delta":{"tool_calls":[
{"index":0,"function":{"arguments":"{\"x\":1}"}}]}}]}
data: {"choices":[{"delta":{"tool_calls":[
{"index":1,"function":{"arguments":"{\"y\":2}"}}]}}]}
```
**每个 SSE chunk 通常只含一个 `tool_call` 元素。** 内核按 `index` 分桶累积
`id` / `name` / `arguments`。
## 2. 适配器必须做的事
`transform_stream_chunk` 的输出 JSON 里,每个 tool call 分片都要带
**`stream_index`**(值取上游的 `index`):
```lua
table.insert(tcs, {
id = tc.id or "",
type = tc.type or "function",
name = name,
raw_arguments = raw_args,
-- ★ 必须透传上游 index(键名是 stream_index,不是 index)。
-- 内核按 stream_index 分桶累积同一轮多个 tool_call 的分片。
stream_index = tc.index or 0
})
```
### ★ 键名是 `stream_index`,不是 `index`
内核的 `ToolCall.StreamIndex` 标签是 `json:"stream_index"`:
```go
StreamIndex int `json:"stream_index,omitempty"`
```
写成 `index` 会被 Go 的解码器**静默丢弃**(无匹配字段),
`StreamIndex` 恒为 0 ⇒ 全部落进 `accs[0]`。
## 3. 不透传的实际后果
不是"少个字段",而是**多工具并行调用整体失效**:
| 现象 | 原因 |
| --- | --- |
| `name` 相互覆盖 | 全进 `accs[0]`,后写的赢 |
| `arguments` 碎片混拼 | 两个工具的 JSON 片段交错拼接 |
| 报"参数不是合法 JSON" | 上面拼接的产物解析失败 |
| 工具被当成**空参数**调用 | 同上 |
2026-09-27 的对照压测里,旧适配器耗时**更短**但**实际处理 0 个工具** ——
每个工具都因参数非法失败。⇒ 压测**必须同时统计实际执行数**,不能只看耗时。
## 4. 还有两个容易踩的点
**① 不能按 `name` 过滤分片**
```lua
-- ✗ 错:后续块的 name 为空但携带 arguments
if tc.function and tc.function.name then ... end
-- ✓ 对:无 name 但有 arguments 的分片也要收,累积时再校验 name
```
**② 扁平结构 + `stream_index`**
部分协议族(`server` / `kimicode` / `anthropic` / `ollama`)的流式 tool call
是**扁平**结构(`name`/`arguments` 直接在 `tc` 上,不在 `tc.function` 里),
同样要带 `stream_index`。
## 5. 自检
```bash
# 1) 适配器是否透传
grep -n "stream_index" /home/newqqagent/adapters/<你的>.lua
# 2) 实测:发一个同轮多工具的请求,看是否两个都真被执行
# 内核日志里 executing tool 应出现两次(可能并发)
journalctl -u homeagent.service --since "-2 min" | grep "executing tool"
# 3) 有没有参数解析失败
journalctl -u homeagent.service --since "-2 min" | grep -E "参数|合法 JSON"
```
> `gemini.lua` 目前**没有**流式 tool call 实现,因此不涉及本条。
> Gemini 协议是 `functionCall` 而非 `tool_calls`,不能照搬 OpenAI 的做法。
## 相关
- `docs/guide/parallel-tool-declaration.md` —— 并发声明 `ParallelSafe`/`Serial`
- 核心仓 `docs/zh/toolcall-parallel-execution-plan.md` —— 压测数据与协议族清单

49
docs/index.md Normal file
View File

@ -0,0 +1,49 @@
# HomeAgent 插件 SDK
用 **Go** 或 **Lua** 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
<div id="api-search"></div>
## 从这里开始
<div class="grid cards" markdown>
- :material-rocket-launch: **第一次写插件**
装工具链、生成工程、写一个工具、打包成 `.hmap` 装进内核跑起来。
[:octicons-arrow-right-24: 快速开始](guide/getting-started.md)
- :material-book-open-variant: **API 参考**
逐个符号的签名与说明,直接取自源码注释。附示例插件里的真实调用点。
[:octicons-arrow-right-24: 工具(Tools)](api/tools.md)
- :material-shield-lock: **能力边界**
哪些 API 外部插件能用、哪些仅内置插件可用,以及为什么。**先看这个能省很多时间。**
[:octicons-arrow-right-24: 能力边界](guide/capability-boundary.md)
- :material-code-braces: **示例插件**
`example/` 下有多个真实可编译的插件,覆盖常见形态。
[:octicons-arrow-right-24: 示例总览](examples/index.md)
</div>
## 许可
SDK 以 **MIT** 发布 —— 插件作者可**自由选择自己的许可**(闭源、商业、私有均可),
不必同许可、也不必回馈。原因:SDK 会随插件一起静态链接(源码进入插件二进制),
若用传染性许可,插件作者就被强制开源;MIT 让第三方插件生态不必承担这个代价。
内核本身是 **AGPL-3.0-only**,但那是内核的许可,与外部插件无关 ——
SDK 完全自包含(`go.mod` 零外部依赖,只依赖 Go 标准库),不引用内核任何代码。
## 版本
本文档站的 API 参考从源码生成,对应 SDK 版本见 [版本与兼容](versions.md)。

View File

@ -0,0 +1,264 @@
/*
* API 即时检索。
*
* 为什么要自建:Material 内置搜索按「整页文本」建索引,搜 `InjectText`
* 会把所有提到它的页面都列出来,但**分不清哪一条是它的定义**;而且内置
* 索引要等 mkdocs build 才生成,改一行 API 也得重建。
*
* 这里读的是 `assets/api-index.json`——由 tools/apidoc/gensite 直接产出,
* 每条记录带 名称/签名/描述/类别/所属页面/是否仅内置/源文件:行号。
* 因此可以做到:
* - 按名称搜(精确/前缀优先)
* - 按描述搜(中文按字、英文按词,都对 API 的文档注释做匹配)
* - 按签名搜(如 "(string) error")
* - 过滤「仅内置」——外部插件作者最容易被这个绊住
*
* 设计取舍:纯前端、零依赖、不阻塞页面。索引 ~130 条、约 40KB,一次拉取足够。
*/
(function () {
"use strict";
var INDEX_URL = (function () {
// 文档站可能部署在子路径下,按当前页面深度回推到站点根。
var path = window.location.pathname;
var marker = "/api/";
var i = path.indexOf(marker);
if (i >= 0) return path.slice(0, i) + "/assets/api-index.json";
// guide/ 等目录同样回退一层。
var lastSlash = path.lastIndexOf("/");
return path.slice(0, lastSlash) + "/assets/api-index.json";
})();
var state = { all: [], loaded: false, loading: false };
function load() {
if (state.loaded || state.loading) return Promise.resolve(state.all);
state.loading = true;
return fetch(INDEX_URL)
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (data) {
state.all = data || [];
state.loaded = true;
return state.all;
})
.catch(function () {
state.all = [];
return [];
});
}
/* ---------- 打分 ---------- */
//
// 三级优先级:名称命中 > 描述命中 > 签名命中。
// 名称命中里再分「完全相等 / 前缀 / 子串」,因为用户敲 `InjectText` 时
// 想要的是那个符号,不是所有名字里含它的。
function score(item, q) {
var name = (item.n || "").toLowerCase();
var ql = q.toLowerCase();
var s = 0;
if (name === ql) s += 1000;
else if (name.indexOf(ql) === 0) s += 600;
else if (name.indexOf(ql) > 0) s += 350;
// 中文检索关键词(keywords.json 产出,字段 g)。
// 为什么需要:SDK 里 66/100 个符号是英文注释(`RegisterTool registers a
// tool that the LLM can call.`),懂中文的人搜「注册工具」会一条都找不到。
// 关键词命中给较高权重(仅次于名称精确命中),因为它就是为「按功能找」准备的。
var kws = item.g || [];
for (var ki = 0; ki < kws.length; ki++) {
var kw = String(kws[ki]).toLowerCase();
if (kw === ql) { s += 480; break; }
if (kw.indexOf(ql) >= 0) { s += 300; break; }
}
// 限定符:PluginSDK.RegisterTool / IOInjector.InjectText。
// 额外支持「去掉 API/SDK 后缀」与「去掉点号」两种写法,
// 因为读者习惯写 `memory.recall`(RPC 名),而 Go 名是 `MemoryAPI.Recall`。
var qual = ((item.r || "") + "." + name).toLowerCase();
if (item.r && qual.indexOf(ql) >= 0) s += 200;
if (item.r) {
var flat = qual.replace(/[._]/g, "").replace(/apis?dk|sdk|api/g, "");
var qflat = ql.replace(/[._\s]/g, "");
if (qflat && flat.indexOf(qflat) >= 0) s += 180;
}
var desc = (item.d || "").toLowerCase();
if (desc.indexOf(ql) >= 0) s += 120;
// 签名按 token 匹配:把查询拆词(去掉括号/逗号等标点),全部命中才算。
// 这样 `(string) error`、`ContentBlock 媒体` 这类片段都能搜到。
// 注意必须先去标点:否则 token `(string)` 永远匹配不到签名里的 `string`。
var sig = (item.s || "").toLowerCase();
if (sig.indexOf(ql) >= 0) s += 60;
var toks = ql
.replace(/[()\[\]{},;:]/g, " ")
.split(/\s+/)
.filter(function (t) { return t.length > 1; });
if (toks.length && sig.length) {
var allSig = toks.every(function (t) { return sig.indexOf(t) >= 0; });
if (allSig) s += 55;
}
// 中文按字匹配:中文没有词边界,逐字命中比整串更实用。
// 注意只把它当作**弱信号**:光靠逐字会把「注册工具」匹到凡是含「工具」
// 字样的任何东西(实测 ContextPolicyNone 的说明里有「工具调用」也会命中)。
// 所以阈值卡在 60% 以上才算有效命中。
if (/[\u4e00-\u9fa5]/.test(q)) {
var hit = 0;
var hay = desc + " " + kws.join(" ");
for (var i = 0; i < q.length; i++) {
if (hay.indexOf(q[i]) >= 0) hit++;
}
var ratio = hit / q.length;
if (ratio >= 0.6) s += Math.round(hit * 6);
}
// 公开 API 略优先于「仅内置」——后者通常是噪声。
if (s > 0 && !item.b) s += 15;
return s;
}
function search(q) {
var qq = (q || "").trim();
if (!qq) return [];
var out = [];
for (var i = 0; i < state.all.length; i++) {
var sc = score(state.all[i], qq);
if (sc > 0) out.push({ item: state.all[i], score: sc });
}
out.sort(function (a, b) {
if (b.score !== a.score) return b.score - a.score;
return (a.item.n || "").length - (b.item.n || "").length;
});
return out;
}
/* ---------- 渲染 ---------- */
//
// 挂在 Material 首页/目录页的一个容器上:#api-search。
// 没找到容器就不做任何事——这样同一份 JS 可以安全地全站引入。
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function render(mount, q) {
mount.innerHTML = "";
if (!q.trim()) {
mount.appendChild(el("p", "api-hint",
"输入 API 名称、描述或签名片段。例:InjectText、注册工具、崩溃、memory.recall、ContentBlock"));
return;
}
var results = search(q);
if (!results.length) {
mount.appendChild(el("p", "api-hint", "没有匹配的 API。试试更短的词,或按功能描述搜(如「注入」「重载」)。"));
return;
}
var head = el("p", "api-count", "命中 " + results.length + " 个 API");
mount.appendChild(head);
var list = el("ul", "api-results");
results.slice(0, 40).forEach(function (r) {
var it = r.item;
var li = el("li", "api-result");
var title = el("a", "api-name", (it.r ? it.r + "." : "") + it.n);
// 锚点必须用**完整标题文本**(`PluginSDK.InjectText`,点号被 slug 丢掉),
// 不是裸方法名 —— 否则跳到页面顶部而到不了那一条。
title.href = pageURL(it.p) + "#" + anchorOf((it.r ? it.r + "." : "") + it.n);
li.appendChild(title);
if (it.b) {
var badge = el("span", "api-badge api-badge-builtin", "仅内置");
badge.title = "外部(第三方)插件运行时拿不到这个 API";
li.appendChild(badge);
}
li.appendChild(el("code", "api-sig", it.s || ""));
if (it.d) {
var d = el("span", "api-desc", it.d);
li.appendChild(d);
}
// 关键词是给检索用的;显示出来能让读者明白“为什么这条被匹配到”。
if (it.g && it.g.length) {
li.appendChild(el("span", "api-kw", it.g.slice(0, 6).join(" · ")));
}
if (it.f) {
li.appendChild(el("span", "api-loc", it.f + (it.l ? ":" + it.l : "")));
}
list.appendChild(li);
});
mount.appendChild(list);
}
function pageURL(page) {
if (!page) return "#";
// 所有 API 章节都在 /api/ 下(生成物),示例页在 /examples/。
// 从当前 URL 回推到站点根,保证部署在子路径下也能用。
var path = window.location.pathname;
var i = path.indexOf("/api/");
var root;
if (i >= 0) {
root = path.slice(0, i + 1);
} else {
var j = path.indexOf("/guide/");
if (j >= 0) root = path.slice(0, j + 1);
else if (path.indexOf("/examples/") >= 0) root = path.slice(0, path.indexOf("/examples/") + 1);
else root = path.slice(0, path.lastIndexOf("/") + 1);
}
var dir = page === "examples" ? "examples" : "api";
return root + dir + "/" + page + "/";
}
// anchorOf 复现 MkDocs 的 slug:小写、去掉非 [a-z0-9_-] 的字符(点号被去掉)、
// 下划线保留、空格转连字符。
function anchorOf(name) {
return String(name)
.toLowerCase()
.replace(/[^a-z0-9_ -]/g, "")
.replace(/\s+/g, "-");
}
function mount() {
var box = document.getElementById("api-search");
if (!box) return;
var input = el("input", "api-input");
input.type = "search";
input.placeholder = "搜索 API:名称、描述、签名…";
input.setAttribute("autocomplete", "off");
input.setAttribute("spellcheck", "false");
var out = el("div", "api-output");
box.appendChild(input);
box.appendChild(out);
load().then(function () {
render(out, "");
input.addEventListener("input", function () {
render(out, input.value);
});
});
// 支持 ?q= 直达(可从别处链接到一次检索)。
var m = /[?&]q=([^&]+)/.exec(window.location.search);
if (m) {
input.value = decodeURIComponent(m[1].replace(/\+/g, " "));
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mount);
} else {
mount();
}
})();

36
docs/llms.txt Normal file
View File

@ -0,0 +1,36 @@
# HomeAgent 插件 SDK
> 用 Go 或 Lua 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
> 注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
>
> SDK 以 MIT 发布(插件可闭源、可商用,无需回馈)。内核本身是 AGPL-3.0-only。
>
> 本文件是给 agent 的入口。下列每个链接都是**纯 Markdown 正文**,可直接读,
> 不含 HTML 样板;也可以直接取 https://sdk.homeagent.jianfgit.xyz/llms-full.txt 一次读完全部文档。
- [HomeAgent 插件 SDK](https://sdk.homeagent.jianfgit.xyz/index.md): 用 Go 或 Lua 为 HomeAgent 编写插件
- [桥接装配点(Bridge)](https://sdk.homeagent.jianfgit.xyz/api/bridge.md): 以下方法不是给插件业务代码调的——它们由 hmapdev 生成的运行时在启动时调用,用来把内核能力注入到 SDK 实例
- [仅内置插件可用的 API](https://sdk.homeagent.jianfgit.xyz/api/builtin-only.md): 这些 API 存在于公开 SDK 包里,但在外部(第三方)插件的运行路径上不可用:要么桥接运行时根本不注入它(拿到 nil),要么内核会拒绝/降级
- [输入 / 输出通道](https://sdk.homeagent.jianfgit.xyz/api/channels.md): 通道是插件与外界(设备、其他 Agent、外部系统)交换消息的入口
- [常量与枚举](https://sdk.homeagent.jianfgit.xyz/api/constants.md): SDK 里的取值枚举
- [事件(Events)](https://sdk.homeagent.jianfgit.xyz/api/events.md): 订阅内核事件
- [API 参考](https://sdk.homeagent.jianfgit.xyz/api/index.md): 本页所有内容从源码生成(tools/apidoc),签名与说明直接取自 sdk/*
- [生命周期(Lifecycle)](https://sdk.homeagent.jianfgit.xyz/api/lifecycle.md): 插件的启动、停止与卸载回调
- [LLM 调用](https://sdk.homeagent.jianfgit.xyz/api/llm.md): 让插件自己调用模型(而不是只等模型来调你)
- [记忆(Memory)](https://sdk.homeagent.jianfgit.xyz/api/memory.md): 三层记忆的读写接口:图记忆(三元组关系)、文档记忆(带元数据的文档)、文本记忆(事件流水)
- [其他类型](https://sdk.homeagent.jianfgit.xyz/api/misc.md): 剩余的类型与方法:PluginSDK 本体的访问器、StageContext 的并发控制,以及多模态辅助类型
- [配置(Settings)](https://sdk.homeagent.jianfgit.xyz/api/settings.md): 声明插件自己的配置项,内核会把它渲染到 WebUI 的设置页,并为每个插件维护独立的配置表
- [阶段钩子(Stages)](https://sdk.homeagent.jianfgit.xyz/api/stages.md): 在消息处理管道的固定点位插入自己的逻辑
- [工具(Tools)](https://sdk.homeagent.jianfgit.xyz/api/tools.md): 注册 LLM 可调用的工具
- [示例插件](https://sdk.homeagent.jianfgit.xyz/examples/index.md): SDK 仓 example/ 下有多个真实可编译的示例插件,覆盖工具注册、通道、记忆读写、LLM 调用、生命周期等常见形态
- [能力边界:哪些 API 外部插件能用](https://sdk.homeagent.jianfgit.xyz/guide/capability-boundary.md): HomeAgent 有两类插件:
- [第一个 Lua 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-lua-plugin.md): Lua 插件适合轻量、快速原型:不需要 Go 编译环境,改完重启内核即可生效
- [第一个 Go 插件](https://sdk.homeagent.jianfgit.xyz/guide/first-plugin.md): 以下是一个能直接跑起来的最小插件:注册一个工具、声明一项配置、处理停止与卸载
- [环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started.md): hmapdev 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
- [多平台构建](https://sdk.homeagent.jianfgit.xyz/guide/multi-platform.md): hmapdev build 默认 bundle 模式,一次产出含三个平台的单个
- [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging.md): hmapdev build 一次完成编译与打包,产出
- [工具并发声明:`ParallelSafe` / `Serial`](https://sdk.homeagent.jianfgit.xyz/guide/parallel-tool-declaration.md): > 对应 sdk
- [场景记忆(Scene Memory)](https://sdk.homeagent.jianfgit.xyz/guide/scene-memory.md): > 场景式记忆是内核 v1
- [受限 SDK 与安全](https://sdk.homeagent.jianfgit.xyz/guide/security.md): 外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层安全边界:
- [流式多 `tool_call`:适配器必须透传 `index`](https://sdk.homeagent.jianfgit.xyz/guide/stream-tool-call-index.md): > 面向在 Lua 里写适配器(transform_stream_chunk)的插件作者
- [版本与兼容](https://sdk.homeagent.jianfgit.xyz/versions.md): SDK 版本跟随内核的中版本,patch 位恒为

123
docs/stylesheets/extra.css Normal file
View File

@ -0,0 +1,123 @@
/* API 即时检索与文档站的少量本地样式。
只补 Material 没覆盖的部分,不覆盖主题变量(保持深浅色自动适配)。 */
#api-search {
margin: 1.2rem 0 2rem;
}
.api-input {
width: 100%;
padding: 0.7rem 0.9rem;
font-size: 1rem;
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.3rem;
background: var(--md-default-bg-color);
color: var(--md-default-fg-color);
}
.api-input:focus {
outline: 2px solid var(--md-accent-fg-color);
outline-offset: 1px;
border-color: transparent;
}
.api-hint {
color: var(--md-default-fg-color--light);
font-size: 0.8rem;
margin: 0.6rem 0 0;
}
.api-count {
color: var(--md-default-fg-color--light);
font-size: 0.75rem;
margin: 0.8rem 0 0.4rem;
}
.api-results {
list-style: none;
margin: 0;
padding: 0;
}
.api-result {
padding: 0.55rem 0.6rem;
border-left: 3px solid var(--md-primary-fg-color);
margin-bottom: 0.4rem;
background: var(--md-code-bg-color);
border-radius: 0 0.2rem 0.2rem 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.45rem;
}
.api-name {
font-family: var(--md-code-font-family, monospace);
font-weight: 700;
font-size: 0.85rem;
}
.api-sig {
font-size: 0.72rem;
color: var(--md-default-fg-color--light);
background: none;
padding: 0;
overflow-wrap: anywhere;
}
.api-desc {
flex-basis: 100%;
font-size: 0.78rem;
color: var(--md-default-fg-color--light);
}
/* 「为何命中」的中文关键词(keywords.json 产出)。 */
.api-kw {
flex-basis: 100%;
font-size: 0.7rem;
color: var(--md-accent-fg-color);
opacity: 0.9;
}
.api-loc {
flex-basis: 100%;
font-size: 0.68rem;
color: var(--md-default-fg-color--lighter, var(--md-default-fg-color--light));
font-family: var(--md-code-font-family, monospace);
}
.api-badge {
font-size: 0.62rem;
padding: 0.08rem 0.34rem;
border-radius: 0.6rem;
font-weight: 600;
white-space: nowrap;
}
.api-badge-builtin {
background: rgba(245, 158, 11, 0.18);
color: #b45309;
border: 1px solid rgba(245, 158, 11, 0.5);
}
[data-md-color-scheme="slate"] .api-badge-builtin {
color: #fbbf24;
}
/* 「仅内置」告警块里的依据说明通常很长,窄屏下允许更小字号。 */
@media screen and (max-width: 44.98em) {
.api-sig { font-size: 0.68rem; }
}
/* 签名与长类型定义可能超出正文宽度(如 ContentBlock 的字段列表)。
highlight 代码块默认不换行,靠横向滚动;把默认改为换行显示,
因为文档读者更希望一眼看全签名而不是拖滚动条。 */
.md-typeset pre > code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* 接口方法表里的签名可能很长,允许在任意位置折行。 */
.md-typeset table code {
overflow-wrap: anywhere;
}

77
docs/versions.md Normal file
View File

@ -0,0 +1,77 @@
# 版本与兼容
## SDK 版本语义
**SDK 版本跟随内核的中版本,patch 位恒为 `.0`。**
整条内核 `1.1.x` 线(1.1.0、1.1.1、1.1.7…)共用 **SDK 1.1.0**;
只有内核进入 `1.2.0` 这种中版本跃迁时,SDK 才升到 1.2.0。
这样插件作者只需关心「我在为哪个中版本写插件」,不必跟着内核的每个 bugfix 换依赖。
当前内核声明的兼容上限是 **SDK 1.3.0**。
## 版本历史
| SDK | 内核 | 变化 | 需要重编? |
|---|---|---|---|
| **1.3.0** | 1.4.0+ | 驻留子 agent、`RecallPolicy` 等 | 想用新 API 才需要 |
| **1.2.0** | 1.2.0 / 1.3.x | `InjectOptions{NoMemory, ContextPolicy}`、六个 `*Opts` 变体、`ChannelDef.ContextPolicy` | 不需要 |
| **1.1.0** | 1.1.x | 多模态贯通:`Triple.SentenceText`、`Doc.Attachments`、`MediaAttachment`、`InsertWithMedia`、媒体注入方法 | 不需要 |
| **1.0.0** | 1.0.0+ | **运行模型变更**:C ABI 动态库 → 子进程 + 共享内存 | **需要** |
### 1.0.0 是唯一一次破坏性变更
- `.so` / `.dylib` / `.dll` **不再被加载**,遇到旧产物会跳过并报可操作错误(不崩溃)。
- **业务代码不用改一行** —— 公开 SDK 接口零改动,用新版 `hmapdev` 重编即可。
- 产物从 `plugin.so` 变为 `plugin.bin`;不再需要 cgo。
### 1.1.0 / 1.2.0 是纯追加
两次都是**新增方法由插件调用、内核实现**,不调就不受影响。
零值 `InjectOptions` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**;
想用新字段的重编即可。
!!! tip "什么时候必须重编"
只有两种情况:① 内核跨了中版本(如 1.1 → 1.2)且你用了新 API;
② 内核的 RPC 协议版本变了(`.hmap` 里的 `protocol` 字段与内核不匹配)。
后者的错配**不会静默失效** —— 握手时会显式拦下。
## RPC 协议版本
插件包里带 `protocol` 字段,必须等于内核的 `ProtocolVersion`(当前 **2**)。
协议 v2 引入了调用帧(tool / cleaner / output)与 `blocks_ref` 媒体块。
v1 插件遇上 v2 内核会拿到空参数,反过来 v2 插件发 `blocks_ref` 会被 v1 内核静默忽略 ——
**两边错配都不报错、只是静默失效**,所以协议版本在握手上显式校验。
## 怎么确认自己在用什么
装的 SDK 版本:
```bash
hmapdev sdk current
hmapdev sdk list
```
插件声明的目标版本在 `plg.json` 的 `sdk` 字段。若该版本不在本地存储里,
`hmapdev` 会**明确报错**,不静默降级 —— 静默降级会产出与内核协议不匹配的包,
那种失败要到运行时才暴露。
## 文档站对应的版本
本页与 [API 参考](api/index.md) 由 `tools/apidoc` 从源码生成,
内容随源码一起演进。发现文档与代码不一致时,**改的是源码注释**,
`go run ./tools/apidoc` 重新生成即可(见下方「维护」)。
## 维护(给 SDK 维护者)
```bash
cd homeagent-sdk
go run ./tools/apidoc -pkgdir ./sdk -out /tmp/api.json
go run ./tools/apidoc/gensite -api /tmp/api.json -out ./docs -examples ./example
mkdocs serve # 本地预览
mkdocs build # 产出 site_build/
```
API 面的**能力分层**(哪些 API 仅内置可用)记在 `tools/apidoc/tiers.json`,
每条裁定都附源码依据 —— 改这里而不是改生成物。

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

@ -2,12 +2,15 @@ package main
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"syscall"
"time" "time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
@ -17,6 +20,41 @@ type Plugin struct {
name string name string
sdk *sdk.PluginSDK sdk *sdk.PluginSDK
proxy string proxy string
// runCancel 取消**正在跑**的 yt-dlp;runWG 等它真正退出。
//
// 为何需要:下载是分钟级操作,而 Stop() 必须能把它掐掉。
// 只 cancel 不 wait 的话内核会在插件死后立刻释放共享段,
// 而 yt-dlp 还在往插件的 stdout 写 —— 那正是内核 readLoop 挂死的成因。
runMu sync.Mutex
runCancel context.CancelFunc
runWG sync.WaitGroup
}
// trackRun 登记一次外部命令运行,返回完成时调用 untrack。
func (p *Plugin) trackRun() (context.Context, func()) {
ctx, cancel := context.WithCancel(context.Background())
p.runMu.Lock()
p.runCancel = cancel
p.runWG.Add(1)
p.runMu.Unlock()
return ctx, func() {
p.runWG.Done()
p.runMu.Lock()
p.runCancel = nil
p.runMu.Unlock()
}
}
// killRunGroup 掐掉正在跑的 yt-dlp 及其子进程(ffmpeg 等)。
func (p *Plugin) killRunGroup(pid int) {
if pid <= 0 {
return
}
// 负 pid = 整个进程组(yt-dlp 拉起的 ffmpeg 也在内)
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
} }
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
@ -30,13 +68,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Key: "output_dir", Default: "/tmp/bili_videos", Key: "output_dir", Default: "/tmp/bili_videos",
Type: "string", DisplayName: "下载目录", Type: "string", DisplayName: "下载目录",
Description: "B站视频下载后的保存目录", Description: "B站视频下载后的保存目录",
Category: p.name, Category: p.name,
}) })
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "proxy", Default: "", Key: "proxy", Default: "",
Type: "string", DisplayName: "HTTP 代理", Type: "string", DisplayName: "HTTP 代理",
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置", Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置",
Category: p.name, Category: p.name,
}) })
if v, _ := s.Settings().Get("proxy"); v != nil { if v, _ := s.Settings().Get("proxy"); v != nil {
if str, ok := v.(string); ok { if str, ok := v.(string); ok {
@ -67,7 +105,24 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
return nil return nil
} }
func (p *Plugin) Stop() error { return nil } // Stop 取消并等待正在跑的下载。
//
// 空实现的代价(实测):yt-dlp 是分钟级操作,Stop 时它还在跑,
// 而它**继承本插件的 stdout**。内核 Kill 掉本插件后,yt-dlp 变孤儿
// 且继续持有管道写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 整个关停挂死
// 直到 systemd 90 秒超时 SIGKILL(线上症状:只有 bili 报
// "SIGKILL 后 2s 仍未被收割",之后近 90 秒无日志)。
func (p *Plugin) Stop() error {
p.runMu.Lock()
cancel := p.runCancel
p.runMu.Unlock()
if cancel != nil {
cancel()
}
// 等它真的退出:不等的话内核会先释放共享段,孙进程仍在写 stdout。
p.runWG.Wait()
return nil
}
type ytdlpFormat struct { type ytdlpFormat struct {
FormatID string `json:"format_id"` FormatID string `json:"format_id"`
@ -84,11 +139,11 @@ type ytdlpFormat struct {
} }
type ytdlpInfo struct { type ytdlpInfo struct {
Title string `json:"title"` Title string `json:"title"`
Duration float64 `json:"duration"` Duration float64 `json:"duration"`
WebpageURL string `json:"webpage_url"` WebpageURL string `json:"webpage_url"`
Filename string `json:"_filename"` Filename string `json:"_filename"`
Formats []ytdlpFormat `json:"formats"` Formats []ytdlpFormat `json:"formats"`
} }
func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) {
@ -119,11 +174,20 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
var out bytes.Buffer var out bytes.Buffer
ytdlpArgs := []string{"--no-warnings", "--dump-json", url} ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
cmd := exec.Command("yt-dlp", ytdlpArgs...) ctx, done := p.trackRun()
defer done()
// CommandContext:Stop 里的 cancel 能直接掐掉它。
// Setpgid:让 yt-dlp 自成进程组,它再拉的 ffmpeg 也在组内,
// killRunGroup 能一次带走整棵子进程树。
cmd := exec.CommandContext(ctx, "yt-dlp", ytdlpArgs...)
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &out cmd.Stderr = &out
cmd.Env = proxyEnv(p.proxy) cmd.Env = proxyEnv(p.proxy)
setPgid(cmd)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("yt-dlp info 已取消(插件停止)")
}
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String())) return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
} }
@ -209,12 +273,16 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
dlArgs = append(dlArgs, "-f", format) dlArgs = append(dlArgs, "-f", format)
} }
dlArgs = append(dlArgs, url) dlArgs = append(dlArgs, url)
cmd2 := exec.Command("yt-dlp", dlArgs...) cmd2 := exec.CommandContext(ctx, "yt-dlp", dlArgs...)
cmd2.Env = proxyEnv(p.proxy) cmd2.Env = proxyEnv(p.proxy)
var dlOut bytes.Buffer var dlOut bytes.Buffer
cmd2.Stdout = &dlOut cmd2.Stdout = &dlOut
cmd2.Stderr = &dlOut cmd2.Stderr = &dlOut
setPgid(cmd2)
if err := cmd2.Run(); err != nil { if err := cmd2.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("下载已取消(插件停止)")
}
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String())) return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
} }
@ -256,6 +324,15 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
}, nil }, nil
} }
// setPgid 让命令自成进程组:它自己拉的子进程(yt-dlp → ffmpeg)
// 都在同一组里,kill(-pgid) 能一次带走,避免孤儿持有 stdout 管道。
func setPgid(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
func proxyEnv(proxy string) []string { func proxyEnv(proxy string) []string {
env := os.Environ() env := os.Environ()
if proxy != "" { if proxy != "" {

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

@ -0,0 +1,66 @@
# browser · 统一浏览器
一个插件覆盖三种"访问网页"的能力,从最轻到最重。**按需选层**是这个插件的重点 ——
绝大多数抓取用 HTTP 就够,不该为了一句话启动 Chromium。
## 三种能力层
| 层 | 工具 | 何时用 |
|---|---|---|
| **搜索** | `browser_search` | 要的是"找到哪些页面",不是页面本身 |
| **quick(纯 HTTP)** | `browser_fetch`(`mode=quick`) | 静态页、API、能直接拿到 HTML |
| **normal(无头渲染)** | `browser_render` / `browser_fetch`(`mode=render`) | JS 渲染的页面,HTTP 拿不到内容 |
| **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
```

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@ -67,6 +67,65 @@ function plugin.start(sdk)
return { content = res } return { content = res }
end) 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 作用域(仅本插件工具被调用时触发) -- 阶段钩子:own_tools 作用域(仅本插件工具被调用时触发)
sdk.register_stage("before_toolcall", function(ctx) sdk.register_stage("before_toolcall", function(ctx)
local calls = ctx.tool_calls or {} local calls = ctx.tool_calls or {}

View File

@ -1,67 +1,413 @@
-- HomeAgent Lua Plugin SDK (standalone mock) -- HomeAgent Lua Plugin SDK
-- Interface contract between Lua plugins and HomeAgent kernel.
-- !impl functions are replaced by Go implementations at runtime.
-- Standalone/debug: pure Lua mock implementations are used.
-- Usage: local sdk = require("sdk")
sdk = {} 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 -- !impl
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end -- level: "debug" | "info" | "warn" | "error"
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end function sdk.log(level, msg)
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end end
function sdk.get_setting(key) return nil end
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)) end -- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text }
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end -- handler: function(args) -> result
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end function sdk.register_tool(name, def, handler)
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end 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 = {} sdk.memory = {}
-- !impl
-- query: string, depth: number -> {entities={...}, relations={...}}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end 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 function sdk.memory.commit(triples) return nil end
-- !impl
function sdk.memory.introspect() return {} end function sdk.memory.introspect() return {} end
-- !impl
function sdk.memory.merge(source, target) return 0 end function sdk.memory.merge(source, target) return 0 end
-- !impl
-- criteria: {key=value}, hard: boolean
function sdk.memory.purge(criteria, hard) return 0 end function sdk.memory.purge(criteria, hard) return 0 end
-- ============ document memory ============
-- !impl
sdk.doc = {} sdk.doc = {}
-- !impl
function sdk.doc.query(text, top_k) return {} end function sdk.doc.query(text, top_k) return {} end
-- !impl
-- doc: { id=, title=, content= }
function sdk.doc.insert(doc) return nil end 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 function sdk.doc.remove(id) return nil end
-- !impl
function sdk.doc.stats() return {} end function sdk.doc.stats() return {} end
-- ============ knowledge ============
-- !impl
sdk.knowledge = {} sdk.knowledge = {}
-- !impl
function sdk.knowledge.search(query, limit) return {} end function sdk.knowledge.search(query, limit) return {} end
-- !impl
function sdk.knowledge.add(tag, content) return nil end function sdk.knowledge.add(tag, content) return nil end
-- !impl
function sdk.knowledge.list() return {} end function sdk.knowledge.list() return {} end
-- ============ text memory ============
-- !impl
sdk.text_memory = {} sdk.text_memory = {}
-- !impl
-- evt: { timestamp=, role=, content=, channel= }
function sdk.text_memory.append(evt) return nil end function sdk.text_memory.append(evt) return nil end
-- ============ llm ============
-- !impl
sdk.llm = {} sdk.llm = {}
-- !impl
function sdk.llm.list_sources() return {} end function sdk.llm.list_sources() return {} end
-- !impl
function sdk.llm.set_source(name) return nil end function sdk.llm.set_source(name) return nil end
-- !impl
function sdk.llm.current_source() return nil end function sdk.llm.current_source() return nil end
-- ============ social (只读) ============
-- !impl
sdk.social = {} sdk.social = {}
-- !impl
function sdk.social.get_person(name) return {} end function sdk.social.get_person(name) return {} end
-- !impl
function sdk.social.get_network(name, depth) return {} end function sdk.social.get_network(name, depth) return {} end
-- !impl
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
-- !impl
function sdk.social.get_relations(name) return {} end function sdk.social.get_relations(name) return {} end
-- !impl
function sdk.social.list_persons() return {} end function sdk.social.list_persons() return {} end
-- ============ settings (作用域变体) ============
-- !impl
sdk.settings = {} sdk.settings = {}
-- !impl
function sdk.settings.get_core(key) return nil end function sdk.settings.get_core(key) return nil end
-- !impl
function sdk.settings.set_core(key, value) return nil end function sdk.settings.set_core(key, value) return nil end
-- !impl
function sdk.settings.list_core(prefix) return {} end function sdk.settings.list_core(prefix) return {} end
-- !impl
function sdk.settings.get_plugin(plugin, key) return nil end function sdk.settings.get_plugin(plugin, key) return nil end
-- !impl
function sdk.settings.set_plugin(plugin, key, value) return nil end function sdk.settings.set_plugin(plugin, key, value) return nil end
-- !impl
function sdk.settings.list_plugin(plugin, prefix) return {} end function sdk.settings.list_plugin(plugin, prefix) return {} end
-- !impl
function sdk.settings.list(prefix) return {} end 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 function sdk.settings.register_def(def) return nil end
-- !impl
function sdk.settings.defs(prefix) return {} end function sdk.settings.defs(prefix) return {} end
-- !impl
function sdk.settings.dump() return {} end function sdk.settings.dump() return {} end
-- !impl
function sdk.settings.plugins() return {} end 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 = {} sdk.json = {}
function sdk.json.encode(val) function sdk.json.encode(val)
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"' local ok, result = pcall(function()
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val) local function _encode(v)
elseif type(val) == "table" then local parts, i = {}, 1 local t = type(v)
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end if t == "string" then
return "{" .. table.concat(parts, ",") .. "}" end 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" return "null"
end end
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
function sdk.json.decode(str)
local ok, result = pcall(function()
local pos, _end = 1, #str
local function skip()
while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end
end
local function parse()
skip()
if pos > _end then return nil end
local c = str:sub(pos, pos)
if c == '"' then
local s = {}
pos = pos + 1
while pos <= _end do
local ch = str:sub(pos, pos)
if ch == '"' then
pos = pos + 1
return table.concat(s)
elseif ch == '\\' then
pos = pos + 1
local n = str:sub(pos, pos)
if n == '"' then s[#s+1] = '"'
elseif n == '\\' then s[#s+1] = '\\'
elseif n == '/' then s[#s+1] = '/'
elseif n == 'b' then s[#s+1] = '\b'
elseif n == 'f' then s[#s+1] = '\f'
elseif n == 'n' then s[#s+1] = '\n'
elseif n == 'r' then s[#s+1] = '\r'
elseif n == 't' then s[#s+1] = '\t'
elseif n == 'u' then
local hex = str:sub(pos+1, pos+4)
pos = pos + 4
s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?'
end
pos = pos + 1
else
s[#s+1] = ch
pos = pos + 1
end
end
return table.concat(s)
elseif c == 't' then pos = pos + 4; return true
elseif c == 'f' then pos = pos + 5; return false
elseif c == 'n' then pos = pos + 4; return nil
elseif c == '{' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == '}' then pos = pos + 1; return t end
while true do
skip(); local k = parse(); skip()
if str:sub(pos, pos) == ':' then pos = pos + 1 end
skip(); t[k] = parse(); skip()
local sep = str:sub(pos, pos)
if sep == '}' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
elseif c == '[' then
pos = pos + 1; skip()
local t = {}
if str:sub(pos, pos) == ']' then pos = pos + 1; return t end
local idx = 1
while true do
skip(); t[idx] = parse(); idx = idx + 1; skip()
local sep = str:sub(pos, pos)
if sep == ']' then pos = pos + 1; return t end
if sep == ',' then pos = pos + 1 end
end
else
local s, e = str:find('^[-%d%.eE]+', pos)
if s then
local num = tonumber(str:sub(s, e))
pos = e + 1
return num
end
return nil
end
end
return parse()
end)
if ok then return result end
return nil
end
-- http utils
sdk.http = {} sdk.http = {}
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end -- !impl
function sdk.http.get(url)
print("[lua-plugin] http.get: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
-- !impl
function sdk.http.post(url, body, content_type)
print("[lua-plugin] http.post: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
return sdk return sdk

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

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

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

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

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

@ -155,6 +155,41 @@ type Plugin struct {
msgMu sync.Mutex msgMu sync.Mutex
msgMap map[int64]msgRef // message_id → {peer, time} msgMap map[int64]msgRef // message_id → {peer, time}
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号) 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 { type typingState struct {
@ -316,6 +351,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录,主机路径。发文件时文件会复制到此目录,NapCat内部映射为/app/files/", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "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: "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: "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() settings := s.Settings()
@ -339,12 +376,18 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/") p.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.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.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) os.MkdirAll(p.remoteDir, 0755)
p.httpClient = &http.Client{Timeout: 30 * time.Second} p.httpClient = &http.Client{Timeout: 30 * time.Second}
// msg_id → peer 映射 + 会话状态(不缓存正文) // msg_id → peer 映射 + 会话状态(不缓存正文)
p.msgMap = make(map[int64]msgRef) p.msgMap = make(map[int64]msgRef)
p.batches = make(map[string]*pendingBatch)
p.chats = make(map[int64]*chatMeta) p.chats = make(map[int64]*chatMeta)
// 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s) // 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s)
@ -396,7 +439,9 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
} }
return cleaned return cleaned
} }
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner}) // qq 通道到达的是**中断通知(meta)**,不是用户正文,不据它召回;
// 真实正文由 qq_get_message 取回后由该工具声明 RecallPolicy=auto 触发召回。
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner, RecallPolicy: sdk.RecallPolicyNone})
// 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化 // 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化
cleaner := func(output string) string { cleaner := func(output string) string {
@ -416,6 +461,9 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
// 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里, // 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里,
// 长会话下持续挤占 token 预算(§13.8)。 // 长会话下持续挤占 token 预算(§13.8)。
ContextPolicy: "prune", ContextPolicy: "prune",
// 正文才是真实内容:取回后用**正文**触发一次召回,
// 而不是用中断通知的 meta 文本去召回(那是无关词)。
RecallPolicy: "auto",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "properties": 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 获取)"}, "message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(从中断消息的 message_id=N 或 reply_to.message_id 获取)"},
@ -441,6 +489,12 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name),可用 qq_download_file 工具下载。", Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name),可用 qq_download_file 工具下载。",
NoMemory: false, NoMemory: false,
Cleaner: cleaner, Cleaner: cleaner,
// 与 get_message 同理:返回的是**真实聊天正文**,不只当轮需要,
// 还可能牵出与这些正文相关的长期记忆。故取回后既裁剪(用完不长期占
// L0)又据正文召回(取进来)。不声明 recall 的话就是「记忆里有、但
// 拉回历史消息时不注入」的盲区。
ContextPolicy: "prune",
RecallPolicy: "auto",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"},
@ -697,6 +751,8 @@ func (p *Plugin) Stop() error {
} }
} }
p.typingMu.Unlock() p.typingMu.Unlock()
// 停机前把未到点的合并批次立刻投出去,别把对方的消息吞掉。
p.flushAllBatches()
if p.srv != nil { if p.srv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@ -761,6 +817,10 @@ func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallb
} }
case int64: case int64:
switch val := v.(type) { switch val := v.(type) {
case int:
return any(int64(val)).(T)
case int64:
return any(val).(T)
case float64: case float64:
return any(int64(val)).(T) return any(int64(val)).(T)
case string: case string:
@ -925,6 +985,19 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
if !auth.active || auth.owner { if !auth.active || auth.owner {
return true, "" return true, ""
} }
// 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。
//
// 为什么:输出是 agent 的**主动调用**,发到哪个会话由它自己给的 meta
// (group_id / user_id)决定 —— handleChannelOutput 会强制要求该字段存在,
// 缺了会得到明确的报错。这里再要求"本轮能精确匹配可信 OneBot 事件"是多余的门,
// 而且会把合法发送一起拒掉:现场(被子的中断唤醒的一轮)父带齐 meta 也发不出去,
// 报「可信 QQ 会话身份不完整」。
// 「只能访问当前会话」这类限制只对**读取类**工具(get_history / mark_read /
// get_message)成立 —— 那才是真的不能跨会话读。
if name == "output_send__"+p.name {
return true, ""
}
currentPeer := auth.userID currentPeer := auth.userID
if auth.isGroup { if auth.isGroup {
currentPeer = auth.groupID currentPeer = auth.groupID
@ -967,6 +1040,40 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
return true, "" 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。多个中断在同一推理轮合并时 // activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时
// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。 // 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。
// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。 // message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。
@ -1017,13 +1124,28 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b
p.auth.generation = next.generation p.auth.generation = next.generation
} }
func messageIDFromInput(raw string) int64 { var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`)
match := qqMessageIDRe.FindStringSubmatch(raw)
if len(match) != 2 { // messageIDsFromInput 取出一段输入里出现的全部 message_id。
return 0 //
// 合并中继的正文是 `(message_id=100,101,102)`:只取第一个会留下同批其余 id 永不清理;
// 身份表用 id 做键,泄漏的条目要等 generation 回收才会消失。
func messageIDsFromInput(raw string) []int64 {
matches := qqMessageIDsRe.FindAllStringSubmatch(raw, -1)
ids := make([]int64, 0, len(matches))
for _, match := range matches {
if len(match) != 2 {
continue
}
for _, part := range strings.Split(match[1], ",") {
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || id == 0 {
continue
}
ids = append(ids, id)
}
} }
id, _ := strconv.ParseInt(match[1], 10, 64) return ids
return id
} }
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error { func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
@ -1031,24 +1153,27 @@ func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
source, _ := ctx.Extra["input_source"].(string) source, _ := ctx.Extra["input_source"].(string)
raw := ctx.RawMessage raw := ctx.RawMessage
ctx.RUnlock() ctx.RUnlock()
p.authMu.Lock() p.authMu.Lock()
defer p.authMu.Unlock() // 默认降权:QQ 来源却对不上可信事件时绝不复用上一条消息的身份。
if source != p.name { next := qqAuthContext{active: source == p.name}
p.auth = qqAuthContext{} ids := messageIDsFromInput(raw)
p.resetTurnGuardLocked() if source == p.name && len(ids) > 0 {
return nil if auth, ok := p.authByMessageID[ids[0]]; ok {
} next = auth
if messageID := messageIDFromInput(raw); messageID != 0 { for _, id := range ids {
if auth, ok := p.authByMessageID[messageID]; ok { delete(p.authByMessageID, id)
p.auth = auth }
delete(p.authByMessageID, messageID)
p.resetTurnGuardLocked()
return nil
} }
} }
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份。 // p.auth 只作为"帧上没绑身份"时的兜底(单测/异常帧),权威副本在帧上。
p.auth = qqAuthContext{active: true} p.auth = next
p.resetTurnGuardLocked() p.resetTurnGuardLocked()
p.authMu.Unlock()
ctx.Lock()
bindAuthOnFrame(ctx, next)
ctx.Unlock()
return nil return nil
} }
@ -1068,9 +1193,13 @@ func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
return nil return nil
} }
func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) { func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, qqAuthContext) {
// 身份以本帧为准(中断恢复后全局身份可能已属于别的轮)。
auth, onFrame := authOnFrame(ctx)
p.authMu.RLock() p.authMu.RLock()
auth := p.auth if !onFrame {
auth = p.auth
}
var patterns []string var patterns []string
if auth.active && !auth.owner { if auth.active && !auth.owner {
if auth.isGroup { if auth.isGroup {
@ -1179,7 +1308,7 @@ func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error {
return nil return nil
} }
tc := &ctx.ToolCalls[0] tc := &ctx.ToolCalls[0]
allowed, auth := p.currentToolAllowed(tc.Name) allowed, auth := p.currentToolAllowed(ctx, tc.Name)
if !auth.active { if !auth.active {
return nil return nil
} }
@ -1286,6 +1415,157 @@ func requiresConfirmFriendCommand(cmd string) bool {
} }
} }
// enqueueInterrupt 把一条已通过策略/@ 检查的消息并入待投批次,并重置 debounce 计时。
//
// batchWindow<=0 时退回逐条投递(合并前行为)。
func (p *Plugin) enqueueInterrupt(msgType string, userID, groupID, messageID int64, nickname, single string, owner, highRisk bool) {
if p.sdk == nil && p.injectHook == nil {
return
}
if p.batchWindow <= 0 {
p.injectInterrupt(single, p.interruptLevel(owner))
return
}
key := qqBatchKey(msgType, groupID, userID)
p.batchMu.Lock()
if p.batches == nil {
p.batches = make(map[string]*pendingBatch)
}
b := p.batches[key]
if b == nil {
b = &pendingBatch{key: key, first: time.Now()}
p.batches[key] = b
}
b.isGroup = msgType == "group"
b.userID, b.groupID, b.nickname = userID, groupID, nickname
b.msgIDs = append(b.msgIDs, messageID)
b.single = single
b.owner = b.owner || owner
b.highRisk = b.highRisk || highRisk
// debounce:每来一条就推迟;但整体不超过 batchMax(否则持续刷屏会一直不投)。
delay := p.batchWindow
if p.batchMax > 0 {
if remain := p.batchMax - time.Since(b.first); remain < delay {
delay = remain
}
}
if delay < 0 {
delay = 0
}
if b.timer != nil {
b.timer.Stop()
}
b.timer = time.AfterFunc(delay, func() { p.flushBatch(key) })
p.batchMu.Unlock()
}
// interruptLevel 决定一条 QQ 消息的中断级别。
//
// - Bot 所有者/管理员的消息 → **L2**(一般提醒);
// - 其他人的消息 → L1(后台,完全可等)。
//
// 为什么不能一律 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) { func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "", http.StatusMethodNotAllowed) http.Error(w, "", http.StatusMethodNotAllowed)
@ -1404,7 +1684,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
highRisk := false
if highRiskRe.MatchString(text) { if highRiskRe.MatchString(text) {
highRisk = true
interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt
} }
@ -1433,15 +1715,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
p.startTyping(evt.UserID) p.startTyping(evt.UserID)
} }
if p.sdk != nil { // 合并投递:同一会话同一发送者在 batchWindow 内的连续消息并成一次中断。
// NoMemory:HTTP 侧来的中断提示,不是对话内容。 p.enqueueInterrupt(evt.MessageType, evt.UserID, evt.GroupID, evt.MessageID, nickname, interrupt, p.isOwner(evt.UserID), highRisk)
// Priority:QQ 消息是**低级别中断**——既不是时钟那样的实时工作,
// 也不是紧急工作,所以声明 L1(完全可等)。
p.sdk.InjectInterruptTextOpts(p.name, p.name, interrupt, sdk.InjectOptions{
NoMemory: true,
Priority: sdk.PriorityL1,
})
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
@ -2539,7 +2815,7 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
// Priority:同上,QQ 侧一律低级别中断(L1)。 // Priority:同上,QQ 侧一律低级别中断(L1)。
p.sdk.InjectInterruptTextOpts(p.name, p.name, p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath), fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath),
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1}) sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1, RecallPolicy: sdk.RecallPolicyNone})
} }
} else { } else {
errMsg = "下载失败,文件可能已过期" errMsg = "下载失败,文件可能已过期"

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -41,16 +41,14 @@ var (
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的 // ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。 // release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
// //
// 现为 1.2.0:核心的 1.2.x 线正在发布中(release/v1.2.x 承载 1.2.0), // 现为 1.4.0:1.3.0 已随核心的正式 tag `v1.3.0` 定版并发版(本仓 tag v1.3.0、
// 但 **SDK 不跟 beta 发版**(§七.2)——SDK 1.2.0 的定版与 tag 随核心的 // release/v1.3.x 承载它),该号从此归发布线所有,main 遂推进到下一个未发布中版本。
// **正式** tag 一起做(§七.3)。在那之前 1.2.0 仍是 SDK 尚未发布的中版本,
// 所以 main 就停在 1.2.0。
// //
// 注意:这里与核心 main **故意不对称**。核心一旦切出 release/v1.2.x, // ❗本仓**不发 patch tag**(§七.1):一个中版本只发一次 `vX.Y.0`,核心的 1.3.x
// 1.2.0 就归发布线所有,main 立刻推进到 1.3.0;而 SDK 因为要等正式 tag, // 后续 patch **不伴随 SDK 发版** —— patch 位恒为 `.0`,带非零 patch 的 SDK tag
// 它的 main 在 v1.2.0 打出来之前不得越过 1.2.0。 // 都是错的。(2026-09-13 曾误发 `v1.3.1`,已撤回;`v1.2.1` 是同一类历史遗留。)
// (曾误按 §七.4 把这里推到 1.3.0,等于宣称 1.2.0 已发布。) //
Version = "1.3.0" Version = "1.4.0"
// Commit 是构建时的 Git commit hash。 // Commit 是构建时的 Git commit hash。
Commit = "unknown" Commit = "unknown"

145
mkdocs.yml Normal file
View File

@ -0,0 +1,145 @@
# HomeAgent 插件 SDK 文档站配置。
#
# 设计取舍:
# - 每页顶部都放「版本 + 编辑链接」,因为 SDK 与内核的协议版本会错配,
# 读者必须先能确认自己看的是哪一版。
# - 中文检索依赖 jieba(已装);英文走内置分词。两者都不要额外服务。
# - docs/api/*.md 是**生成物**(tools/apidoc/gensite),页首会写明,防止手改。
site_name: HomeAgent 插件 SDK
site_description: 用 Go 或 Lua 为 HomeAgent 编写插件 —— API 参考与开发指南
site_url: https://sdk.homeagent.jianfgit.xyz/
copyright: MIT 许可 · JianFeeeee
# 主题覆盖目录:只覆盖 footer.html(补备案号,见该文件里的说明)。
docs_dir: docs
site_dir: site_build
theme:
name: material
language: zh
# custom_dir 必须写在 theme 下(顶层会被判为未知配置)。
custom_dir: overrides
# 品牌图标:与主站 introduce 同一份 logo(曾用 Material 默认,不是我们的)。
logo: assets/logo-mark.svg
favicon: assets/logo.svg
features:
- navigation.instant
- navigation.instant.progress
- navigation.tracking
- navigation.tabs
- navigation.sections
- navigation.indexes
- navigation.top
- toc.follow
- search.suggest
- search.highlight
- search.share
- content.code.copy
- content.code.annotate
- content.action.edit
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
accent: indigo
toggle:
icon: material/weather-night
name: 切换到深色
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: indigo
accent: indigo
toggle:
icon: material/weather-sunny
name: 切换到浅色
icon:
repo: fontawesome/brands/git-alt
plugins:
- search:
lang:
- zh
- en
separator: '[\s\u200b\-]'
# 中文按词切(jieba),否则整句变一个 token,检索不到。
jieba_dict: null
markdown_extensions:
- admonition
- attr_list
- def_list
- footnotes
- md_in_html
- tables
- toc:
permalink: true
toc_depth: 3
- pymdownx.details
# Material 的图标语法 :material-xxx: / :octicons-xxx: 依赖这个扩展。
# 没开时它们会**原样显示为文本**(实测首页四个卡片全花了)。
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
extra:
generator: false
social:
# 主仓已迁到 GitHub;gitcode 保留为国内镜像(源码同步)。
# 两个链接都放,是因为国内直连 gitcode 更快,而海外/权威源看 GitHub。
- icon: fontawesome/solid/code
link: https://github.com/JianFeeeee/homeagentsdk
name: SDK 源码(GitHub)
- icon: fontawesome/solid/code-branch
link: https://gitcode.com/JianFeeeee/homeagent-sdk
name: gitcode 镜像(国内)
- icon: fontawesome/solid/house
link: https://introduce.homeagent.jianfgit.xyz/
name: HomeAgent 介绍站
# 自定义检索端点在 docs/javascripts/api-search.js 里注册,
# 索引文件由 gensite 产出:docs/assets/api-index.json
nav:
- 首页: index.md
- 快速开始:
- 环境与工具链: guide/getting-started.md
- 第一个 Go 插件: guide/first-plugin.md
- 第一个 Lua 插件: guide/first-lua-plugin.md
- API 参考:
- api/index.md
- 工具(Tools): api/tools.md
- 阶段钩子(Stages): api/stages.md
- 记忆(Memory): api/memory.md
- 输入/输出通道: api/channels.md
- 配置(Settings): api/settings.md
- 生命周期(Lifecycle): api/lifecycle.md
- 事件(Events): api/events.md
- LLM 调用: api/llm.md
- 常量与枚举: api/constants.md
- 桥接装配点: api/bridge.md
- 其他类型: api/misc.md
- 仅内置插件可用: api/builtin-only.md
- 指南:
- 能力边界(哪些 API 外部可用): guide/capability-boundary.md
- 工具并发声明(ParallelSafe/Serial): guide/parallel-tool-declaration.md
- 流式多 tool_call(适配器透传 index): guide/stream-tool-call-index.md
- 场景记忆(按场合召回): guide/scene-memory.md
- 打包与发布: guide/packaging.md
- 多平台构建: guide/multi-platform.md
- 受限 SDK 与安全: guide/security.md
- 示例插件:
- 总览: examples/index.md
- 版本与兼容: versions.md
extra_css:
- stylesheets/extra.css
extra_javascript:
- javascripts/api-search.js

View File

@ -0,0 +1,59 @@
{#-
footer.html 覆盖:在版权行下补**备案号**。
为什么要覆盖主题文件:Material 的 copyright 只渲染 config.copyright(一个字符串),
而备案号必须是**带链接的 HTML**且含两个条目(ICP + 公安),塞不进那个字段。
另外把「许可」写进 footer:本站内容与主站一致受 AGPL 约束,
读者在任何页面底部都能看到,不必翻到首页。
-#}
<footer class="md-footer">
{% if "navigation.footer" in features %}
{% if page.previous_page or page.next_page %}
{% if page.meta and page.meta.hide %}
{% set hidden = "hidden" if "footer" in page.meta.hide %}
{% endif %}
<nav class="md-footer__inner md-grid" aria-label="{{ lang.t('footer') }}" {{ hidden }}>
{% if page.previous_page %}
{% set direction = lang.t("footer.previous") %}
<a href="{{ page.previous_page.url | url }}" class="md-footer__link md-footer__link--prev" aria-label="{{ direction }}: {{ page.previous_page.title | e }}">
<div class="md-footer__button md-icon">
{% set icon = config.theme.icon.previous or "material/arrow-left" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</div>
<div class="md-footer__title">
<span class="md-footer__direction">{{ direction }}</span>
<div class="md-ellipsis">{{ page.previous_page.title }}</div>
</div>
</a>
{% endif %}
{% if page.next_page %}
{% set direction = lang.t("footer.next") %}
<a href="{{ page.next_page.url | url }}" class="md-footer__link md-footer__link--next" aria-label="{{ direction }}: {{ page.next_page.title | e }}">
<div class="md-footer__title">
<span class="md-footer__direction">{{ direction }}</span>
<div class="md-ellipsis">{{ page.next_page.title }}</div>
</div>
<div class="md-footer__button md-icon">
{% set icon = config.theme.icon.next or "material/arrow-right" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</div>
</a>
{% endif %}
</nav>
{% endif %}
{% endif %}
<div class="md-footer-meta md-typeset">
<div class="md-footer-meta__inner md-grid">
{% include "partials/copyright.html" %}
<div class="md-copyright ha-beian">
<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener noreferrer">豫ICP备2024074105号-1</a>
<span class="ha-beian-sep">·</span>
<a href="https://beian.mps.gov.cn" target="_blank" rel="noopener noreferrer">豫公网安备41070202001579号</a>
</div>
{% if config.extra.social %}
{% include "partials/social.html" %}
{% endif %}
</div>
</div>
</footer>

View File

@ -68,13 +68,21 @@ fi
# 1) 先保证工具链可用:示例必须用**本仓当前源码**构建,否则产物协议与这一版 SDK 不符。 # 1) 先保证工具链可用:示例必须用**本仓当前源码**构建,否则产物协议与这一版 SDK 不符。
# 允许外部指定(发版脚本会在跨平台构建后把刚产出的工具链路径传进来)。 # 允许外部指定(发版脚本会在跨平台构建后把刚产出的工具链路径传进来)。
# 工具链二进制名由 plugindev 改为 hmapdev;旧变量名 PLUGINDEV 仍兼容。 # 工具链二进制名由 plugindev 改为 hmapdev;旧变量名 PLUGINDEV 仍兼容。
#
# ★ 下面三处必须读 **HMAPDEV**(上一行刚解析出的规范名)。
# 曾经读的是 PLUGINDEV:那样只有调用方恰好传旧名时才工作,
# 而新名 HMAPDEV 只被赋给 HMAPDEV 本身、PLUGINDEV 仍是未定义,
# 在 `set -u` 下第一处判断就 "PLUGINDEV: unbound variable" 直接退出。
# 实测(2026-09-29):HMAPDEV=... → exit 1;PLUGINDEV=... → 20/20 成功。
# 发版路径传的是旧名(build.sh:92)故一直没暴露——正是「兼容」二字
# 写在注释里、却没在代码里做到的那种缺陷。
HMAPDEV="${HMAPDEV:-${PLUGINDEV:-$SDK_ROOT/build/hmapdev}}" HMAPDEV="${HMAPDEV:-${PLUGINDEV:-$SDK_ROOT/build/hmapdev}}"
if [ ! -x "$PLUGINDEV" ]; then if [ ! -x "$HMAPDEV" ]; then
echo "[examples] 先构建 hmapdev ..." echo "[examples] 先构建 hmapdev ..."
( cd "$SDK_ROOT/tools/hmapdev" && "$GO" build -o "$HMAPDEV" . ) || { ( cd "$SDK_ROOT/tools/hmapdev" && "$GO" build -o "$HMAPDEV" . ) || {
echo "[examples] hmapdev 构建失败,无法继续" >&2; exit 1; } echo "[examples] hmapdev 构建失败,无法继续" >&2; exit 1; }
fi fi
if [ ! -x "$PLUGINDEV" ]; then if [ ! -x "$HMAPDEV" ]; then
echo "[examples] hmapdev 不存在或不可执行:$HMAPDEV" >&2 echo "[examples] hmapdev 不存在或不可执行:$HMAPDEV" >&2
exit 1 exit 1
fi fi
@ -97,7 +105,7 @@ for dir in "$SDK_ROOT"/example/*/; do
# 清掉旧产物:残留会让人(和本脚本)误判成功。 # 清掉旧产物:残留会让人(和本脚本)误判成功。
rm -rf "$dir/build" "$dir/dist" rm -rf "$dir/build" "$dir/dist"
out=$( cd "$dir" && "$PLUGINDEV" build --no-bundle --target "$GOOS/$GOARCH" 2>&1 ) out=$( cd "$dir" && "$HMAPDEV" build --no-bundle --target "$GOOS/$GOARCH" 2>&1 )
rc=$? rc=$?
# 判据是**退出码 + 产物存在**,两者都要。 # 判据是**退出码 + 产物存在**,两者都要。

View File

@ -1,116 +0,0 @@
cmake_minimum_required(VERSION 3.10)
project(ha_remotedevice VERSION 0.1.0 LANGUAGES C)
# ============================================================
# ha_remotedevice — HomeAgent 远程设备接入 C SDK
# 零外部依赖,纯 C 实现,兼容嵌入式平台。
#
# 使用方式:
# add_subdirectory(path/to/ha_remotedevice)
# target_link_libraries(my_app ha_remotedevice)
# target_include_directories(my_app PRIVATE
# ${HA_REMOTEDEVICE_INCLUDE_DIR})
# ============================================================
# 选项: 构建为静态库或动态库
option(BUILD_SHARED_LIBS "Build ha_remotedevice as shared library" OFF)
# 选项: 禁用 malloc/free(用于裸机环境,用户需提供 alloc 回调)
option(HA_NO_ALLOC "Disable dynamic memory allocation" OFF)
# 选项: 日志级别
set(HA_LOG_LEVEL 2 CACHE STRING "Log level: 0=none, 1=error, 2=info, 3=debug")
# 源文件
set(HA_REMOTEDEVICE_SRC
src/ha_remotedevice.c
src/ha_json.c
src/ha_ws.c
)
# 头文件
set(HA_REMOTEDEVICE_INCLUDE
${CMAKE_CURRENT_SOURCE_DIR}/include
)
# 编译选项
if(HA_NO_ALLOC)
add_definitions(-DHA_NO_ALLOC)
endif()
add_definitions(-DHA_LOG_LEVEL=${HA_LOG_LEVEL})
# 创建库
if(BUILD_SHARED_LIBS)
add_library(ha_remotedevice SHARED ${HA_REMOTEDEVICE_SRC})
if(WIN32)
# Windows 需要导出符号
set_target_properties(ha_remotedevice PROPERTIES
WINDOWS_EXPORT_ALL_SYMBOLS ON)
endif()
else()
add_library(ha_remotedevice STATIC ${HA_REMOTEDEVICE_SRC})
endif()
# 包含目录
target_include_directories(ha_remotedevice
PUBLIC ${HA_REMOTEDEVICE_INCLUDE}
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
)
# 不链接任何外部库
target_link_libraries(ha_remotedevice PRIVATE)
# 导出包含目录供外部项目使用
set(HA_REMOTEDEVICE_INCLUDE_DIR
${HA_REMOTEDEVICE_INCLUDE}
CACHE INTERNAL "ha_remotedevice include directories")
# 安装规则
install(TARGETS ha_remotedevice
EXPORT ha_remotedevice-targets
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
INCLUDES DESTINATION include
)
install(DIRECTORY include/
DESTINATION include
)
install(EXPORT ha_remotedevice-targets
DESTINATION lib/cmake/ha_remotedevice
NAMESPACE ha_remotedevice::
)
# ============================================================
# 测试(可选)
# ============================================================
option(BUILD_TESTS "Build ha_remotedevice tests" OFF)
if(BUILD_TESTS)
find_package(Threads REQUIRED)
add_executable(ha_remotedevice_test
test/test_ha_remotedevice.c
)
target_link_libraries(ha_remotedevice_test
PRIVATE ha_remotedevice Threads::Threads
)
target_include_directories(ha_remotedevice_test
PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR}
)
# 添加测试
add_test(NAME ha_remotedevice_test
COMMAND ha_remotedevice_test
)
endif()
# ============================================================
# 编译信息
# ============================================================
message(STATUS "ha_remotedevice ${PROJECT_VERSION}")
message(STATUS " Build type: $<CONFIG>")
message(STATUS " Shared lib: ${BUILD_SHARED_LIBS}")
message(STATUS " No alloc: ${HA_NO_ALLOC}")

View File

@ -1,216 +0,0 @@
#ifndef HA_REMOTEDEVICE_H
#define HA_REMOTEDEVICE_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ==================================================================
* ha_remotedevice — 远程设备接入 C SDK
*
* 零外部依赖,纯 C 实现,兼容嵌入式平台。
* 传输层由用户实现(4 个函数指针),SDK 处理所有协议细节。
*
* 声明式设计:
* 设备在代码中声明自己是什么(kind)和能做什么(caps),
* 声明支持哪些命令(shell/camerasue/screensee/...)并注册对应处理函数,
* SDK 自动处理协议握手、心跳、消息路由、结果回执。
*
* 协议流程:
* TCP 连接 → WS 升级 → hello(设备声明) → bind(令牌) → 就绪
* 就绪后循环:读帧 → 按 handlers 表分发命令 → 自动回执结果
* ================================================================== */
/* ======================== 状态码 ======================== */
typedef enum {
HA_OK = 0,
HA_ERR_GENERIC = -1,
HA_ERR_NOMEM = -2,
HA_ERR_INVALID = -3,
HA_ERR_TIMEOUT = -4,
HA_ERR_DISCONNECTED = -5,
HA_ERR_PROTOCOL = -6,
HA_ERR_TRANSPORT = -7,
HA_ERR_NOT_FOUND = -8,
} ha_status_t;
/* ======================== 传输层抽象 ========================
*
* 用户必须实现这 4 个函数,适配不同平台(FreeRTOS+lwIP、Zephyr、裸机等)。
*
* connect(ctx, host, port) → 建立 TCP 连接,返回 0 成功
* send(ctx, data, len) → 发送 len 字节,返回实际发送字节数,-1 失败
* recv(ctx, buf, len) → 接收最多 len 字节,返回实际接收字节数,0 断开,-1 失败
* close(ctx) → 关闭连接
*/
typedef struct {
int (*connect)(void *ctx, const char *host, uint16_t port);
int (*send)(void *ctx, const uint8_t *data, int len);
int (*recv)(void *ctx, uint8_t *buf, int len);
void (*close)(void *ctx);
void *ctx;
} ha_transport_t;
/* ======================== 设备声明 ========================
*
* 声明式配置:设备在代码中声明自己的类型和能力。
* 这些信息通过 hello 消息发送给网关。
*
* device_id — 唯一标识,如 "esp32-cam-1"
* name — 设备显示名,如 "门口摄像头"
* kind — 设备种类,如 "camera"、"computer"、"speaker"、"light"
* caps — 能力数组,以 NULL 结尾,如 {"camera","status",NULL}
* info_json — 额外信息(JSON 字符串),可选,如 '{"chip":"ESP32-S3","psram":8}'
*/
typedef struct {
const char *device_id;
const char *name;
const char *kind;
const char **caps; /* NULL 结尾 */
const char *info_json; /* 可选,NULL 或 JSON 字符串 */
} ha_device_info_t;
/* ======================== 命令结果 ========================
*
* 命令处理函数通过填写此结构体返回数据。
* SDK 收到结果后自动发送回执(文本或二进制分块)。
*
* 使用方式:
* 1. 简单文本:设置 status=0, output="结果文本"
* 2. 二进制数据:设置 has_binary=1, binary_data/binary_len/mime
* 3. 错误:设置 status=1, error="错误信息"
*
* 注意:output 字符串由 SDK 内部 strdup 后发送,handler 返回后即可释放。
* 我们约定 handler 不负责分配,由 SDK 在内部做好拷贝。
* 所以 handler 可以返回栈上或静态字符串。
*/
typedef struct {
int status; /* 0=ok, 非0=error */
const char *output; /* 输出文本(如 base64 图像数据),SDK 内部拷贝 */
const char *error; /* 错误信息 */
int has_binary; /* 1=通过二进制分块回传 */
const char *binary_mime; /* 二进制 MIME 类型 */
const uint8_t *binary_data; /* 二进制数据指针 */
int binary_len; /* 二进制数据长度 */
} ha_cmd_result_t;
/* ======================== 命令处理声明 ========================
*
* 声明式命令注册:设备在配置中声明支持哪些命令,并绑定处理函数。
*
* command 值说明:
* - "shell" → 处理 shell 类型命令,args 为完整命令字符串
* - "camerasue" → 处理 homeagent-camerasue 命令,args 为参数
* - "screensee" → 处理 homeagent-screensee 命令
* - "speakeruse" → 处理 homeagent-speakeruse 命令
* - "computeruse" → 处理 homeagent-computeruse 命令
* - "clipboardsee" → 处理 homeagent-clipboardsee 命令
* - "clipboardsue" → 处理 homeagent-clipboardsue 命令
* - "screensue" → 处理 homeagent-screensue 命令
* - "deviceinfo" → 处理设备信息查询
* - 其他自定义命令名 → 按字符串匹配分发
*
* handler 处理完毕后只需填写 result 结构体,SDK 自动回执。
*/
typedef ha_status_t (*ha_cmd_handler_t)(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata);
typedef struct {
const char *command; /* 命令名,如 "camerasue"、"shell" */
ha_cmd_handler_t handler; /* 处理函数 */
} ha_cmd_handler_def_t;
/* 二进制数据接收回调:收到服务端推送的二进制数据(如 TTS 音频)时调用。
* data 指针在回调返回后失效,如需保存请拷贝。 */
typedef void (*ha_binary_handler_t)(const char *req_id, const char *kind,
const char *mime, const uint8_t *data,
int len, void *userdata);
/* 连接状态变化回调 */
typedef void (*ha_state_callback_t)(int connected, void *userdata);
/* ======================== 客户端配置 ========================
*
* 所有配置在 ha_client_new() 时一次性声明。
* 声明式核心:handlers 表声明了设备支持的所有命令及其处理函数。
*/
typedef struct {
ha_transport_t transport; /* 传输层实现(必须) */
ha_device_info_t device; /* 设备声明(必须) */
const char *server; /* 服务端地址,如 "192.168.1.100:9890"(必须) */
const char *token; /* 接入令牌(必须) */
ha_cmd_handler_def_t *handlers; /* 声明式命令处理表,.command=NULL 标记结束 */
ha_binary_handler_t on_binary; /* 二进制数据接收回调(可选) */
ha_state_callback_t on_state; /* 状态变化回调(可选) */
void *userdata; /* 用户自定义数据,传给所有回调 */
int ping_interval; /* 心跳间隔秒数,0 则默认 30 */
int max_reconnect; /* 最大重连次数,-1 无限重连(默认),0 不重连 */
} ha_config_t;
/* ======================== 客户端 API ======================== */
typedef struct ha_client ha_client_t;
/* 创建客户端实例。config 数据会在内部拷贝,外部可释放。 */
ha_client_t *ha_client_new(const ha_config_t *config);
/* 启动连接:TCP 连接 → WS 升级 → hello → bind → 就绪。阻塞直到完成或失败。 */
ha_status_t ha_client_start(ha_client_t *client);
/* 主循环处理:必须在用户的主循环中周期性调用。
* - 读取 WS 帧并分发
* - 按 handlers 表查找命令处理函数,自动回执结果
* - 处理心跳 ping/pong
* - 处理断线重连
* 返回 HA_OK 表示正常,HA_ERR_DISCONNECTED 表示正在重连。 */
ha_status_t ha_client_process(ha_client_t *client);
/* ===== 主动上报(设备主动推送,非命令响应) ===== */
/* 发送设备主动上报事件。type 如 "motion_detected",detail 为 JSON 字符串。 */
void ha_client_send_event(ha_client_t *client, const char *type,
const char *detail);
/* 发送设备状态更新。status: "online"、"offline"、"busy" 等。 */
void ha_client_send_status(ha_client_t *client, const char *status);
/* ===== 生命周期 ===== */
/* 停止客户端,断开连接。 */
void ha_client_stop(ha_client_t *client);
/* 销毁客户端,释放所有资源。 */
void ha_client_destroy(ha_client_t *client);
/* ======================== 工具函数 ======================== */
/* 解析 homeagent-* 命令,返回能力名和参数。
* command = "camerasue 5" → cap="camerasue", args="5"
* command = "screensee" → cap="screensee", args=""
* command = "computeruse {...}" → cap="computeruse", args="..." */
void ha_cmd_parse_homeagent(const char *command, const char **cap,
const char **args);
/* 解析 JSON 格式的命令参数,提取 action 和 JSON 字符串。
* command = "computeruse {\"action\":\"click\",\"x\":100}"
* → action="computeruse", json_str="{\"action\":\"click\",...}" */
void ha_cmd_parse_json(const char *command, const char **action,
const char **json_str);
/* Base64 编码(用于将二进制数据编码为文本回传)。
* 返回写入 out 的字节数(不含 \0),out 不足时返回所需长度。 */
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len);
/* 获取版本号 */
const char *ha_version(void);
#ifdef __cplusplus
}
#endif
#endif /* HA_REMOTEDEVICE_H */

View File

@ -1,369 +0,0 @@
#include "ha_json.h"
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <stdio.h>
/* ======================== 解析器 ======================== */
/* 前向声明 */
static ha_json_node_t *parse_value(const char **pp);
/* 跳过空白 */
static const char *skip_ws(const char *p) {
while (*p && (unsigned char)*p <= ' ') p++;
return p;
}
/* 解析字符串("..."),返回新分配的字符串,p 更新到结束引号后 */
static char *parse_string(const char **pp) {
const char *p = skip_ws(*pp);
if (*p != '"') return NULL;
p++;
int len = 0;
const char *q = p;
while (*q && *q != '"') {
if (*q == '\\') { q++; if (*q) q++; }
else q++;
len++;
}
if (*q != '"') return NULL;
char *s = (char *)malloc(len + 1);
if (!s) return NULL;
q = p;
int i = 0;
while (*q && *q != '"') {
if (*q == '\\') {
q++;
switch (*q) {
case '"': s[i++] = '"'; break;
case '\\': s[i++] = '\\'; break;
case '/': s[i++] = '/'; break;
case 'b': s[i++] = '\b'; break;
case 'f': s[i++] = '\f'; break;
case 'n': s[i++] = '\n'; break;
case 'r': s[i++] = '\r'; break;
case 't': s[i++] = '\t'; break;
case 'u': q += 4; s[i++] = '?'; continue;
default: s[i++] = *q; break;
}
q++;
} else {
s[i++] = *q++;
}
}
s[i] = '\0';
*pp = q + 1;
return s;
}
static ha_json_node_t *new_node(ha_json_type_t type) {
ha_json_node_t *n = (ha_json_node_t *)calloc(1, sizeof(ha_json_node_t));
if (n) n->type = type;
return n;
}
/* 解析数字 */
static ha_json_node_t *parse_number(const char **pp) {
const char *p = *pp;
int neg = 0;
if (*p == '-') { neg = 1; p++; }
if (!isdigit((unsigned char)*p)) return NULL;
int val = 0;
while (isdigit((unsigned char)*p)) {
val = val * 10 + (*p - '0');
p++;
}
if (*p == '.') { p++; while (isdigit((unsigned char)*p)) p++; }
if (*p == 'e' || *p == 'E') {
p++;
if (*p == '+' || *p == '-') p++;
while (isdigit((unsigned char)*p)) p++;
}
*pp = p;
ha_json_node_t *n = new_node(HA_JSON_INT);
if (n) n->int_val = neg ? -val : val;
return n;
}
/* 解析 true/false/null */
static ha_json_node_t *parse_keyword(const char **pp) {
const char *p = *pp;
ha_json_node_t *n = NULL;
if (strncmp(p, "true", 4) == 0 && !isalnum((unsigned char)p[4])) {
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 1;
*pp = p + 4;
} else if (strncmp(p, "false", 5) == 0 && !isalnum((unsigned char)p[5])) {
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 0;
*pp = p + 5;
} else if (strncmp(p, "null", 4) == 0 && !isalnum((unsigned char)p[4])) {
n = new_node(HA_JSON_NULL);
*pp = p + 4;
}
return n;
}
/* 解析对象 */
static ha_json_node_t *parse_object(const char **pp) {
const char *p = skip_ws(*pp);
if (*p != '{') return NULL;
p++;
ha_json_node_t *obj = new_node(HA_JSON_OBJECT);
if (!obj) return NULL;
ha_json_node_t **tail = &obj->child;
p = skip_ws(p);
if (*p == '}') { *pp = p + 1; return obj; }
while (*p) {
p = skip_ws(p);
char *key = parse_string(&p);
if (!key) break;
p = skip_ws(p);
if (*p != ':') { free(key); break; }
p++;
ha_json_node_t *val = parse_value(&p);
if (!val) { free(key); break; }
val->key = key;
*tail = val;
tail = &val->next;
p = skip_ws(p);
if (*p == ',') { p++; continue; }
if (*p == '}') break;
}
p = skip_ws(p);
if (*p == '}') { *pp = p + 1; return obj; }
ha_json_free(obj);
return NULL;
}
/* 解析数组 */
static ha_json_node_t *parse_array(const char **pp) {
const char *p = skip_ws(*pp);
if (*p != '[') return NULL;
p++;
ha_json_node_t *arr = new_node(HA_JSON_ARRAY);
if (!arr) return NULL;
ha_json_node_t **tail = &arr->child;
p = skip_ws(p);
if (*p == ']') { *pp = p + 1; return arr; }
while (*p) {
ha_json_node_t *val = parse_value(&p);
if (!val) break;
*tail = val;
tail = &val->next;
p = skip_ws(p);
if (*p == ',') { p++; continue; }
if (*p == ']') break;
}
p = skip_ws(p);
if (*p == ']') { *pp = p + 1; return arr; }
ha_json_free(arr);
return NULL;
}
/* 解析值(主入口) */
static ha_json_node_t *parse_value(const char **pp) {
const char *p = skip_ws(*pp);
if (*p == '{') return parse_object(pp);
if (*p == '[') return parse_array(pp);
if (*p == '"') {
char *s = parse_string(pp);
if (!s) return NULL;
ha_json_node_t *n = new_node(HA_JSON_STRING);
if (!n) { free(s); return NULL; }
n->str_val = s;
return n;
}
if (*p == '-' || isdigit((unsigned char)*p)) return parse_number(pp);
return parse_keyword(pp);
}
/* ======================== 公共 API ======================== */
ha_json_node_t *ha_json_parse(const char *str) {
if (!str) return NULL;
const char *p = str;
return parse_value(&p);
}
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key) {
ha_json_node_t *n = ha_json_get(obj, key);
if (!n || n->type != HA_JSON_STRING) return NULL;
return n->str_val;
}
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def) {
ha_json_node_t *n = ha_json_get(obj, key);
if (!n || n->type != HA_JSON_INT) return def;
return n->int_val;
}
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key) {
if (!obj || obj->type != HA_JSON_OBJECT) return NULL;
ha_json_node_t *c = obj->child;
while (c) {
if (c->key && strcmp(c->key, key) == 0) return c;
c = c->next;
}
return NULL;
}
int ha_json_array_len(const ha_json_node_t *arr) {
if (!arr || arr->type != HA_JSON_ARRAY) return 0;
int n = 0;
ha_json_node_t *c = arr->child;
while (c) { n++; c = c->next; }
return n;
}
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index) {
if (!arr || arr->type != HA_JSON_ARRAY) return NULL;
ha_json_node_t *c = arr->child;
int i = 0;
while (c) {
if (i == index) return c;
i++; c = c->next;
}
return NULL;
}
void ha_json_free(ha_json_node_t *root) {
if (!root) return;
ha_json_node_t *c = root->child;
while (c) {
ha_json_node_t *next = c->next;
free(c->key);
if (c->type == HA_JSON_STRING) free(c->str_val);
ha_json_free(c);
c = next;
}
free(root);
}
/* ======================== 构建器 ======================== */
static void json_escape(ha_json_builder_t *jb, const char *s) {
if (!s) { ha_json_builder_raw(jb, "null"); return; }
ha_json_builder_raw(jb, "\"");
for (const char *p = s; *p; p++) {
unsigned char c = (unsigned char)*p;
switch (c) {
case '"': ha_json_builder_raw(jb, "\\\""); break;
case '\\': ha_json_builder_raw(jb, "\\\\"); break;
case '\b': ha_json_builder_raw(jb, "\\b"); break;
case '\f': ha_json_builder_raw(jb, "\\f"); break;
case '\n': ha_json_builder_raw(jb, "\\n"); break;
case '\r': ha_json_builder_raw(jb, "\\r"); break;
case '\t': ha_json_builder_raw(jb, "\\t"); break;
default:
if (c < 0x20) {
char buf[8];
snprintf(buf, sizeof(buf), "\\u%04x", c);
ha_json_builder_raw(jb, buf);
} else {
char buf[2] = { (char)c, 0 };
ha_json_builder_raw(jb, buf);
}
break;
}
}
ha_json_builder_raw(jb, "\"");
}
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap) {
jb->buf = buf;
jb->len = 0;
jb->cap = cap;
jb->depth = 0;
if (cap > 0) buf[0] = '\0';
}
void ha_json_builder_reset(ha_json_builder_t *jb) {
jb->len = 0;
jb->depth = 0;
if (jb->cap > 0) jb->buf[0] = '\0';
}
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s) {
while (*s && jb->len < jb->cap - 1) {
jb->buf[jb->len++] = *s++;
}
jb->buf[jb->len] = '\0';
}
void ha_json_builder_comma(ha_json_builder_t *jb) {
if (jb->depth > 0 && jb->item_count[jb->depth - 1] > 0) {
ha_json_builder_raw(jb, ",");
}
if (jb->depth > 0) jb->item_count[jb->depth - 1]++;
}
void ha_json_builder_begin_object(ha_json_builder_t *jb) {
ha_json_builder_comma(jb);
ha_json_builder_raw(jb, "{");
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
jb->depth++;
}
void ha_json_builder_end_object(ha_json_builder_t *jb) {
jb->depth--;
ha_json_builder_raw(jb, "}");
}
void ha_json_builder_begin_array(ha_json_builder_t *jb) {
ha_json_builder_comma(jb);
ha_json_builder_raw(jb, "[");
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
jb->depth++;
}
void ha_json_builder_end_array(ha_json_builder_t *jb) {
jb->depth--;
ha_json_builder_raw(jb, "]");
}
void ha_json_builder_key(ha_json_builder_t *jb, const char *key) {
ha_json_builder_comma(jb);
json_escape(jb, key);
ha_json_builder_raw(jb, ":");
}
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val) {
json_escape(jb, val);
}
void ha_json_builder_add_int(ha_json_builder_t *jb, int val) {
char buf[16];
snprintf(buf, sizeof(buf), "%d", val);
ha_json_builder_raw(jb, buf);
}
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val) {
ha_json_builder_raw(jb, val ? "true" : "false");
}
void ha_json_builder_add_null(ha_json_builder_t *jb) {
ha_json_builder_raw(jb, "null");
}
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val) {
ha_json_builder_key(jb, key);
json_escape(jb, val);
}
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val) {
ha_json_builder_key(jb, key);
ha_json_builder_add_int(jb, val);
}
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val) {
ha_json_builder_key(jb, key);
ha_json_builder_add_bool(jb, val);
}
const char *ha_json_builder_str(ha_json_builder_t *jb) {
return jb->buf;
}
int ha_json_builder_len(ha_json_builder_t *jb) {
return jb->len;
}

View File

@ -1,107 +0,0 @@
#ifndef HA_JSON_H
#define HA_JSON_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ======================== JSON 解析器(DOM 风格) ======================== */
typedef enum {
HA_JSON_NULL,
HA_JSON_BOOL,
HA_JSON_INT,
HA_JSON_STRING,
HA_JSON_ARRAY,
HA_JSON_OBJECT,
} ha_json_type_t;
typedef struct ha_json_node {
ha_json_type_t type;
union {
int bool_val;
int int_val;
char *str_val;
};
struct ha_json_node *next; /* linked list for array/object items */
struct ha_json_node *child; /* first child for array/object */
char *key; /* key for object members */
} ha_json_node_t;
/* 解析 JSON 字符串,返回根节点。失败返回 NULL。 */
ha_json_node_t *ha_json_parse(const char *str);
/* 从对象中按 key 获取字符串值,不存在返回 NULL */
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key);
/* 从对象中按 key 获取 int 值,不存在返回 def */
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def);
/* 从对象中按 key 获取子节点,不存在返回 NULL */
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key);
/* 获取数组长度 */
int ha_json_array_len(const ha_json_node_t *arr);
/* 获取数组第 index 个元素,越界返回 NULL */
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index);
/* 释放整个 JSON 树 */
void ha_json_free(ha_json_node_t *root);
/* ======================== JSON 构建器(直接写缓冲区) ======================== */
typedef struct {
char *buf;
int len;
int cap;
int depth;
int item_count[16]; /* 每层已添加元素数,用于逗号判断 */
} ha_json_builder_t;
/* 初始化构建器 */
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap);
/* 清空构建器 */
void ha_json_builder_reset(ha_json_builder_t *jb);
/* 基础写入 */
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s);
/* 逗号(自动判断是否需要加) */
void ha_json_builder_comma(ha_json_builder_t *jb);
/* 对象 */
void ha_json_builder_begin_object(ha_json_builder_t *jb);
void ha_json_builder_end_object(ha_json_builder_t *jb);
/* 数组 */
void ha_json_builder_begin_array(ha_json_builder_t *jb);
void ha_json_builder_end_array(ha_json_builder_t *jb);
/* 键名 */
void ha_json_builder_key(ha_json_builder_t *jb, const char *key);
/* 值 */
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val);
void ha_json_builder_add_int(ha_json_builder_t *jb, int val);
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val);
void ha_json_builder_add_null(ha_json_builder_t *jb);
/* 快捷方法:直接写 "key":"val" */
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val);
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val);
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val);
/* 获取当前构建的字符串指针 */
const char *ha_json_builder_str(ha_json_builder_t *jb);
/* 获取当前长度 */
int ha_json_builder_len(ha_json_builder_t *jb);
#ifdef __cplusplus
}
#endif
#endif /* HA_JSON_H */

View File

@ -1,628 +0,0 @@
#include "ha_remotedevice.h"
#include "ha_json.h"
#include "ha_ws.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#define HA_VERSION "0.1.0"
/* 前向声明(因 handle_cmd_msg 需要调用这些函数,而它们定义在后面) */
void ha_client_send_result(ha_client_t *client, const char *req_id,
const char *status, const char *output,
const char *error);
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
const char *kind, const char *mime,
const uint8_t *data, int len);
/* ======================== 内部状态 ======================== */
typedef enum {
HA_STATE_INIT,
HA_STATE_DISCONNECTED,
HA_STATE_CONNECTING,
HA_STATE_WS_UPGRADING,
HA_STATE_HELLO_SENT,
HA_STATE_BIND_SENT,
HA_STATE_READY,
HA_STATE_STOPPING,
} ha_state_t;
/* 语音数据聚合缓冲区 */
typedef struct {
char req_id[128];
char kind[64];
char mime[64];
int total;
uint8_t *data;
int len;
int cap;
} ha_speech_accum_t;
struct ha_client {
ha_config_t config; /* 拷贝的配置 */
ha_state_t state;
int reconnect_cnt; /* 当前重连次数 */
ha_ws_t ws; /* WS 连接 */
/* JSON 构建缓冲区 */
char json_buf[4096];
ha_json_builder_t jb;
/* 语音数据聚合 */
ha_speech_accum_t speech;
};
/* ======================== 辅助函数 ======================== */
static void set_sockbuf(ha_client_t *c, int i) { (void)c; (void)i; }
/* Base64 编码表 */
static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len) {
int needed = ((len + 2) / 3) * 4 + 1;
if (out_len < needed) {
if (out_len > 0) out[0] = '\0';
return needed;
}
int i = 0, j = 0;
while (i < len) {
int rem = len - i;
uint8_t b0 = data[i++];
uint8_t b1 = (rem > 1) ? data[i++] : 0;
uint8_t b2 = (rem > 2) ? data[i++] : 0;
out[j++] = b64[b0 >> 2];
out[j++] = b64[((b0 & 0x03) << 4) | (b1 >> 4)];
out[j++] = (rem > 1) ? b64[((b1 & 0x0F) << 2) | (b2 >> 6)] : '=';
out[j++] = (rem > 2) ? b64[b2 & 0x3F] : '=';
}
out[j] = '\0';
return j;
}
/* ======================== JSON 构建辅助 ======================== */
static void json_init(ha_client_t *c) {
ha_json_builder_init(&c->jb, c->json_buf, sizeof(c->json_buf));
}
/* ======================== WS 发送 JSON ======================== */
static int ws_send_json(ha_client_t *c) {
return ha_ws_send_text(&c->ws, c->json_buf);
}
/* ======================== 协议消息构造 ======================== */
/* 构建 hello 消息 */
static int send_hello(ha_client_t *c) {
json_init(c);
ha_json_builder_begin_object(&c->jb);
ha_json_builder_string(&c->jb, "op", "hello");
ha_json_builder_key(&c->jb, "device");
ha_json_builder_begin_object(&c->jb);
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
ha_json_builder_string(&c->jb, "name", c->config.device.name);
ha_json_builder_string(&c->jb, "kind", c->config.device.kind);
/* caps */
ha_json_builder_key(&c->jb, "caps");
ha_json_builder_begin_array(&c->jb);
if (c->config.device.caps) {
for (const char **p = c->config.device.caps; *p; p++) {
ha_json_builder_add_string(&c->jb, *p);
}
}
ha_json_builder_end_array(&c->jb);
/* info 可选 */
if (c->config.device.info_json && c->config.device.info_json[0]) {
ha_json_builder_string(&c->jb, "info", c->config.device.info_json);
}
ha_json_builder_end_object(&c->jb); /* device */
ha_json_builder_end_object(&c->jb); /* root */
return ws_send_json(c);
}
/* 构建 bind 消息 */
static int send_bind(ha_client_t *c) {
json_init(c);
ha_json_builder_begin_object(&c->jb);
ha_json_builder_string(&c->jb, "op", "bind");
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
ha_json_builder_string(&c->jb, "token", c->config.token);
ha_json_builder_end_object(&c->jb);
return ws_send_json(c);
}
/* ======================== 消息处理 ======================== */
/* 在 handlers 表中查找命令处理函数 */
static ha_cmd_handler_def_t *find_handler(ha_client_t *c, const char *name) {
if (!name || !c->config.handlers) return NULL;
for (ha_cmd_handler_def_t *h = c->config.handlers; h->command; h++) {
if (strcmp(h->command, name) == 0) return h;
}
return NULL;
}
/* 声明式命令分发:查找 handlers 表 → 调用 handler → 自动回执 */
static void handle_cmd_msg(ha_client_t *c, ha_json_node_t *msg) {
const char *req_id = ha_json_get_string(msg, "req_id");
const char *command = ha_json_get_string(msg, "command");
const char *cmd_type = ha_json_get_string(msg, "cmd_type");
if (!req_id || !command) return;
if (!cmd_type) cmd_type = "homeagent";
const char *handler_name = NULL;
const char *args = command;
if (strcmp(cmd_type, "shell") == 0) {
handler_name = "shell";
/* args 保持为完整命令字符串 */
} else {
/* homeagent-* 命令:提取能力名作为 handler 名 */
const char *cap = command;
const char *p = command;
if (strncmp(p, "homeagent-", 10) == 0) p += 10;
const char *space = strchr(p, ' ');
if (space) {
args = space + 1;
/* handler_name 用静态缓冲区 */
static char name_buf[128];
int n = (int)(space - p);
if (n > 127) n = 127;
strncpy(name_buf, p, n);
name_buf[n] = '\0';
handler_name = name_buf;
} else {
handler_name = p;
args = "";
}
}
ha_cmd_handler_def_t *def = find_handler(c, handler_name);
if (!def) {
ha_client_send_result(c, req_id, "error", NULL,
"unsupported command");
return;
}
/* 调用 handler,填写 result */
ha_cmd_result_t result;
memset(&result, 0, sizeof(result));
ha_status_t st = def->handler(req_id, args, &result, c->config.userdata);
/* 自动回执 */
if (st != HA_OK) {
ha_client_send_result(c, req_id, "error", NULL,
result.error ? result.error : "handler failed");
return;
}
if (result.has_binary && result.binary_data && result.binary_len > 0) {
/* 二进制分块回传 */
ha_client_send_data_chunked(c, req_id,
handler_name, result.binary_mime ? result.binary_mime : "application/octet-stream",
result.binary_data, result.binary_len);
} else {
/* 文本回传 */
ha_client_send_result(c, req_id, result.status == 0 ? "ok" : "error",
result.output, result.error);
}
}
static void handle_speech_start(ha_client_t *c, ha_json_node_t *msg) {
const char *req_id = ha_json_get_string(msg, "req_id");
const char *kind = ha_json_get_string(msg, "kind");
const char *mime = ha_json_get_string(msg, "mime");
if (!req_id) return;
/* 释放旧的聚合数据 */
free(c->speech.data);
memset(&c->speech, 0, sizeof(c->speech));
strncpy(c->speech.req_id, req_id, sizeof(c->speech.req_id) - 1);
if (kind) strncpy(c->speech.kind, kind, sizeof(c->speech.kind) - 1);
if (mime) strncpy(c->speech.mime, mime, sizeof(c->speech.mime) - 1);
c->speech.total = ha_json_get_int(msg, "total", 0);
}
static void handle_speech_end(ha_client_t *c, ha_json_node_t *msg) {
const char *req_id = ha_json_get_string(msg, "req_id");
if (!req_id || strcmp(req_id, c->speech.req_id) != 0) return;
if (c->config.on_binary && c->speech.data && c->speech.len > 0) {
c->config.on_binary(c->speech.req_id, c->speech.kind,
c->speech.mime, c->speech.data,
c->speech.len, c->config.userdata);
}
free(c->speech.data);
memset(&c->speech, 0, sizeof(c->speech));
}
static void handle_text_message(ha_client_t *c, const uint8_t *payload, int len) {
/* 解析 JSON */
char *tmp = (char *)malloc(len + 1);
if (!tmp) return;
memcpy(tmp, payload, len);
tmp[len] = '\0';
ha_json_node_t *root = ha_json_parse(tmp);
if (!root) { free(tmp); return; }
const char *op = ha_json_get_string(root, "op");
if (!op) { ha_json_free(root); free(tmp); return; }
switch (c->state) {
case HA_STATE_HELLO_SENT:
if (strcmp(op, "hello_ack") == 0) {
c->state = HA_STATE_BIND_SENT;
send_bind(c);
}
break;
case HA_STATE_BIND_SENT:
if (strcmp(op, "bind_ack") == 0) {
c->state = HA_STATE_READY;
if (c->config.on_state) {
c->config.on_state(1, c->config.userdata);
}
}
break;
case HA_STATE_READY:
if (strcmp(op, "cmd") == 0) {
handle_cmd_msg(c, root);
} else if (strcmp(op, "cmd_speech_start") == 0) {
handle_speech_start(c, root);
} else if (strcmp(op, "cmd_speech_end") == 0) {
handle_speech_end(c, root);
}
break;
default:
break;
}
ha_json_free(root);
free(tmp);
}
/* ======================== 连接管理 ======================== */
static int do_connect(ha_client_t *c) {
c->state = HA_STATE_CONNECTING;
c->reconnect_cnt++;
/* 解析 server 地址 */
char host[256] = {0};
uint16_t port = 9890;
const char *p = c->config.server;
if (!p) return -1;
/* 去掉 ws:// 前缀 */
if (strncmp(p, "ws://", 5) == 0) p += 5;
else if (strncmp(p, "wss://", 6) == 0) p += 6;
/* 提取 host:port */
const char *colon = strchr(p, ':');
const char *slash = strchr(p, '/');
if (colon && (!slash || colon < slash)) {
int host_len = (int)(colon - p);
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
memcpy(host, p, host_len);
host[host_len] = '\0';
port = (uint16_t)atoi(colon + 1);
} else {
int host_len = (slash ? (int)(slash - p) : (int)strlen(p));
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
memcpy(host, p, host_len);
host[host_len] = '\0';
}
c->state = HA_STATE_WS_UPGRADING;
if (ha_ws_connect(&c->ws, &c->config.transport, host, port,
"/api/v1/device/ws", c->config.token) != 0) {
c->state = HA_STATE_DISCONNECTED;
return -1;
}
/* 发送 hello */
c->state = HA_STATE_HELLO_SENT;
if (send_hello(c) != 0) {
ha_ws_close(&c->ws);
c->state = HA_STATE_DISCONNECTED;
return -1;
}
return 0;
}
/* ======================== 公共 API ======================== */
ha_client_t *ha_client_new(const ha_config_t *config) {
ha_client_t *c = (ha_client_t *)calloc(1, sizeof(ha_client_t));
if (!c) return NULL;
memcpy(&c->config, config, sizeof(ha_config_t));
c->state = HA_STATE_INIT;
c->reconnect_cnt = 0;
return c;
}
ha_status_t ha_client_start(ha_client_t *client) {
if (!client) return HA_ERR_INVALID;
if (client->state != HA_STATE_INIT) return HA_ERR_GENERIC;
/* 默认心跳间隔 30 秒 */
if (client->config.ping_interval <= 0) {
client->config.ping_interval = 30;
}
if (do_connect(client) != 0) {
return HA_ERR_TRANSPORT;
}
/* 等待 bind_ack(最多 5 秒) */
int wait_ms = 5000;
int step = 50;
while (wait_ms > 0 && client->state != HA_STATE_READY) {
/* 处理一帧 */
ha_status_t st = ha_client_process(client);
if (st != HA_OK && st != HA_ERR_DISCONNECTED) {
return st;
}
if (client->state == HA_STATE_READY) return HA_OK;
/* 简单延时:靠 process 中的 recv 阻塞 */
wait_ms -= step;
}
return (client->state == HA_STATE_READY) ? HA_OK : HA_ERR_TIMEOUT;
}
ha_status_t ha_client_process(ha_client_t *client) {
if (!client) return HA_ERR_INVALID;
if (client->state == HA_STATE_STOPPING) {
return HA_ERR_DISCONNECTED;
}
/* 断线重连 */
if (client->state == HA_STATE_DISCONNECTED ||
client->state == HA_STATE_INIT) {
if (client->config.max_reconnect >= 0 &&
client->reconnect_cnt > client->config.max_reconnect) {
return HA_ERR_DISCONNECTED;
}
/* 非阻塞模式:不在这里阻塞等待重连,返回 HA_ERR_DISCONNECTED */
return HA_ERR_DISCONNECTED;
}
if (!client->ws.connected) {
client->state = HA_STATE_DISCONNECTED;
if (client->config.on_state) {
client->config.on_state(0, client->config.userdata);
}
return HA_ERR_DISCONNECTED;
}
/* 尝试读取一帧 */
const uint8_t *payload = NULL;
int len = 0;
int ret = ha_ws_read_frame(&client->ws, &payload, &len);
if (ret < 0) {
/* 连接断开 */
client->state = HA_STATE_DISCONNECTED;
if (client->config.on_state) {
client->config.on_state(0, client->config.userdata);
}
return HA_ERR_DISCONNECTED;
}
switch (ret) {
case WS_OPCODE_TEXT:
handle_text_message(client, payload, len);
break;
case WS_OPCODE_BINARY:
/* 二进制帧:如果处于语音聚合状态,追加数据 */
if (client->speech.req_id[0] && payload) {
int new_len = client->speech.len + len;
if (new_len > client->speech.cap) {
int new_cap = client->speech.cap ? client->speech.cap * 2 : 4096;
while (new_cap < new_len) new_cap *= 2;
uint8_t *nd = (uint8_t *)realloc(client->speech.data, new_cap);
if (!nd) break;
client->speech.data = nd;
client->speech.cap = new_cap;
}
memcpy(client->speech.data + client->speech.len, payload, len);
client->speech.len = new_len;
}
break;
case WS_OPCODE_PING:
/* 回复 pong */
ha_ws_send_frame(&client->ws, WS_OPCODE_PONG, NULL, 0);
break;
case WS_OPCODE_PONG:
/* 收到 pong,忽略 */
break;
case WS_OPCODE_CLOSE:
client->state = HA_STATE_DISCONNECTED;
if (client->config.on_state) {
client->config.on_state(0, client->config.userdata);
}
return HA_ERR_DISCONNECTED;
}
return HA_OK;
}
void ha_client_send_result(ha_client_t *client, const char *req_id,
const char *status, const char *output,
const char *error) {
if (!client || client->state != HA_STATE_READY) return;
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "cmd_result");
ha_json_builder_string(&client->jb, "req_id", req_id);
ha_json_builder_string(&client->jb, "status", status ? status : "ok");
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
if (output && output[0]) {
ha_json_builder_string(&client->jb, "output", output);
}
if (error && error[0]) {
ha_json_builder_string(&client->jb, "error", error);
}
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
const char *kind, const char *mime,
const uint8_t *data, int len) {
if (!client || client->state != HA_STATE_READY) return;
/* cmd_data_start */
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "cmd_data_start");
ha_json_builder_string(&client->jb, "req_id", req_id);
ha_json_builder_string(&client->jb, "kind", kind ? kind : "data");
ha_json_builder_string(&client->jb, "mime", mime ? mime : "application/octet-stream");
ha_json_builder_int(&client->jb, "total", len);
ha_json_builder_int(&client->jb, "chunk_size", 8192);
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
/* 二进制帧分块发送 */
int off = 0;
while (off < len) {
int chunk = len - off;
if (chunk > 8192) chunk = 8192;
if (ha_ws_send_binary(&client->ws, data + off, chunk) != 0) return;
off += chunk;
}
/* cmd_data_end */
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "cmd_data_end");
ha_json_builder_string(&client->jb, "req_id", req_id);
ha_json_builder_string(&client->jb, "status", "ok");
ha_json_builder_int(&client->jb, "total", len);
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_send_event(ha_client_t *client, const char *type,
const char *detail) {
if (!client || client->state != HA_STATE_READY) return;
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "event");
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
ha_json_builder_string(&client->jb, "type", type ? type : "");
if (detail && detail[0]) {
ha_json_builder_string(&client->jb, "payload", detail);
}
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_send_status(ha_client_t *client, const char *status) {
if (!client || client->state != HA_STATE_READY) return;
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "status");
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
ha_json_builder_string(&client->jb, "status", status ? status : "online");
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_stop(ha_client_t *client) {
if (!client) return;
client->state = HA_STATE_STOPPING;
if (client->ws.connected) {
ha_ws_close(&client->ws);
}
}
void ha_client_destroy(ha_client_t *client) {
if (!client) return;
ha_client_stop(client);
free(client->speech.data);
free(client);
}
/* ======================== 工具函数 ======================== */
void ha_cmd_parse_homeagent(const char *command, const char **cap,
const char **args) {
*cap = command;
*args = "";
if (!command) {
*cap = "";
return;
}
/* 去掉 homeagent- 前缀 */
const char *p = command;
if (strncmp(p, "homeagent-", 10) == 0) {
p += 10;
}
/* 按空格分割 */
const char *space = strchr(p, ' ');
if (space) {
/* cap 指向 p 但不包含空格,需要临时拷贝 */
/* 返回指针到原始字符串,调用方用 strncpy 取出 */
*cap = command; /* 调用方应使用 ha_cmd_parse_homeagent 的要小心 */
/* 实际上,最简单的方式是原地修改,但 const 不允许 */
/* 用静态缓冲区或让调用方自己处理 */
static char cap_buf[256];
int n = (int)(space - p);
if (n > 255) n = 255;
strncpy(cap_buf, p, n);
cap_buf[n] = '\0';
*cap = cap_buf;
*args = space + 1;
} else {
static char cap_buf[256];
strncpy(cap_buf, p, sizeof(cap_buf) - 1);
cap_buf[sizeof(cap_buf) - 1] = '\0';
*cap = cap_buf;
*args = "";
}
}
void ha_cmd_parse_json(const char *command, const char **action,
const char **json_str) {
*action = "";
*json_str = "";
if (!command) return;
const char *p = command;
if (strncmp(p, "homeagent-", 10) == 0) {
p += 10;
}
const char *brace = strchr(p, '{');
if (brace) {
static char act_buf[256];
int n = (int)(brace - p);
while (n > 0 && (p[n - 1] == ' ' || p[n - 1] == '\t')) n--;
if (n > 255) n = 255;
strncpy(act_buf, p, n);
act_buf[n] = '\0';
*action = act_buf;
*json_str = brace;
} else {
static char act_buf[256];
strncpy(act_buf, p, sizeof(act_buf) - 1);
*action = act_buf;
}
}
const char *ha_version(void) {
return HA_VERSION;
}

View File

@ -1,325 +0,0 @@
#include "ha_ws.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
/* WS GUID 用于计算 Accept 值 */
#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
/* ======================== Base64 编码(用于 WS key) ======================== */
static const char b64t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static void base64_encode_bin(const uint8_t *in, int in_len, char *out) {
int i = 0, j = 0;
uint8_t b[3];
while (i < in_len) {
int rem = in_len - i;
if (rem >= 3) {
b[0] = in[i++]; b[1] = in[i++]; b[2] = in[i++];
out[j++] = b64t[b[0] >> 2];
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
out[j++] = b64t[((b[1] & 0x0F) << 2) | (b[2] >> 6)];
out[j++] = b64t[b[2] & 0x3F];
} else if (rem == 2) {
b[0] = in[i++]; b[1] = in[i++];
out[j++] = b64t[b[0] >> 2];
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
out[j++] = b64t[(b[1] & 0x0F) << 2];
out[j++] = '=';
} else {
b[0] = in[i++];
out[j++] = b64t[b[0] >> 2];
out[j++] = b64t[(b[0] & 0x03) << 4];
out[j++] = '=';
out[j++] = '=';
}
}
out[j] = '\0';
}
/* 简单伪随机数生成器 */
static uint32_t ws_rand_state = 0;
static void ws_rand_seed(uint32_t seed) { ws_rand_state = seed; }
static uint32_t ws_rand(void) {
ws_rand_state = ws_rand_state * 1103515245 + 12345;
return ws_rand_state;
}
/* 生成 WS 握手 key */
static void ws_gen_key(char *out) {
uint8_t buf[16];
for (int i = 0; i < 16; i++) {
buf[i] = (uint8_t)(ws_rand() & 0xFF);
}
base64_encode_bin(buf, 16, out);
}
/* ======================== 从传输层接收指定字节数 ======================== */
static int recv_all(ha_ws_t *ws, uint8_t *buf, int len) {
int pos = 0;
while (pos < len) {
int n = ws->transport->recv(ws->transport->ctx, buf + pos, len - pos);
if (n <= 0) return -1;
pos += n;
}
return 0;
}
/* ======================== 发送 WS 帧 ======================== */
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len) {
uint8_t hdr[14]; /* 最大帧头:2 + 8 + 4 = 14 */
int hdr_len = 0;
hdr[0] = 0x80 | opcode; /* FIN + opcode */
hdr_len = 2;
int ext_len = 0;
if (len < 126) {
hdr[1] = 0x80 | len; /* mask bit + length */
} else if (len < 65536) {
hdr[1] = 0x80 | 126;
hdr_len = 4;
hdr[2] = (uint8_t)(len >> 8);
hdr[3] = (uint8_t)(len & 0xFF);
ext_len = 2;
} else {
hdr[1] = 0x80 | 127;
hdr_len = 10;
uint64_t l = (uint64_t)len;
for (int i = 8; i > 0; i--) {
hdr[1 + i] = (uint8_t)(l & 0xFF);
l >>= 8;
}
ext_len = 8;
}
/* mask key */
uint8_t mask_key[4];
mask_key[0] = (uint8_t)(ws_rand() & 0xFF);
mask_key[1] = (uint8_t)(ws_rand() & 0xFF);
mask_key[2] = (uint8_t)(ws_rand() & 0xFF);
mask_key[3] = (uint8_t)(ws_rand() & 0xFF);
int mask_off = 2 + ext_len;
hdr[mask_off] = mask_key[0];
hdr[mask_off + 1] = mask_key[1];
hdr[mask_off + 2] = mask_key[2];
hdr[mask_off + 3] = mask_key[3];
hdr_len = mask_off + 4;
/* 发送帧头 */
if (ws->transport->send(ws->transport->ctx, hdr, hdr_len) != hdr_len) {
return -1;
}
/* 发送掩码后的 payload */
if (len > 0) {
/* 如果 payload 不大,用栈缓冲区 */
uint8_t stack_buf[2048];
uint8_t *masked = (len <= (int)sizeof(stack_buf)) ? stack_buf : (uint8_t *)malloc(len);
if (!masked) return -1;
for (int i = 0; i < len; i++) {
masked[i] = payload[i] ^ mask_key[i & 3];
}
int ret = (ws->transport->send(ws->transport->ctx, masked, len) == len) ? 0 : -1;
if (masked != stack_buf) free(masked);
if (ret != 0) return -1;
}
return 0;
}
/* ======================== 公共 API ======================== */
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
const char *host, uint16_t port,
const char *path, const char *token) {
memset(ws, 0, sizeof(ha_ws_t));
ws->transport = transport;
ws->connected = 0;
strncpy(ws->host, host, sizeof(ws->host) - 1);
ws->port = port;
strncpy(ws->path, path, sizeof(ws->path) - 1);
if (token) strncpy(ws->token, token, sizeof(ws->token) - 1);
/* 种子 */
ws_rand_seed((uint32_t)(uintptr_t)ws ^ (uint32_t)port);
/* 1. TCP 连接 */
if (transport->connect(transport->ctx, host, port) != 0) {
return -1;
}
/* 2. 发送 WS 升级请求 */
char key[32];
ws_gen_key(key);
char req[1024];
int n = snprintf(req, sizeof(req),
"GET %s HTTP/1.1\r\n"
"Host: %s:%u\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n",
path, host, (unsigned)port, key);
/* 如果 token 存在,加到路径参数中 */
if (token && token[0]) {
n = snprintf(req, sizeof(req),
"GET %s?token=%s HTTP/1.1\r\n"
"Host: %s:%u\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n",
path, token, host, (unsigned)port, key);
}
if (transport->send(transport->ctx, (uint8_t *)req, n) != n) {
transport->close(transport->ctx);
return -1;
}
/* 3. 读取响应头(直到 \r\n\r\n) */
char resp[1024];
int resp_len = 0;
int found = 0;
while (resp_len < (int)sizeof(resp) - 1) {
int n = transport->recv(transport->ctx, (uint8_t *)(resp + resp_len), 1);
if (n <= 0) {
transport->close(transport->ctx);
return -1;
}
resp_len += n;
resp[resp_len] = '\0';
if (resp_len >= 4 && strcmp(resp + resp_len - 4, "\r\n\r\n") == 0) {
found = 1;
break;
}
}
if (!found) {
transport->close(transport->ctx);
return -1;
}
/* 4. 检查状态码 101 */
if (strstr(resp, " 101 ") == NULL) {
transport->close(transport->ctx);
return -1;
}
ws->connected = 1;
return 0;
}
int ha_ws_send_text(ha_ws_t *ws, const char *text) {
if (!ws->connected) return -1;
return ha_ws_send_frame(ws, WS_OPCODE_TEXT, (const uint8_t *)text, (int)strlen(text));
}
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len) {
if (!ws->connected) return -1;
return ha_ws_send_frame(ws, WS_OPCODE_BINARY, data, len);
}
int ha_ws_send_ping(ha_ws_t *ws) {
if (!ws->connected) return -1;
return ha_ws_send_frame(ws, WS_OPCODE_PING, NULL, 0);
}
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len) {
if (!ws->connected) return -1;
*payload = NULL;
*len = 0;
/* 读取帧头:2 字节 */
uint8_t hdr[2];
if (recv_all(ws, hdr, 2) != 0) {
ws->connected = 0;
return -1;
}
int opcode = hdr[0] & 0x0F;
int masked = (hdr[1] & 0x80) ? 1 : 0;
uint64_t frame_len = hdr[1] & 0x7F;
if (frame_len == 126) {
uint8_t ext[2];
if (recv_all(ws, ext, 2) != 0) { ws->connected = 0; return -1; }
frame_len = ((uint64_t)ext[0] << 8) | ext[1];
} else if (frame_len == 127) {
uint8_t ext[8];
if (recv_all(ws, ext, 8) != 0) { ws->connected = 0; return -1; }
frame_len = 0;
for (int i = 0; i < 8; i++) {
frame_len = (frame_len << 8) | ext[i];
}
}
/* 读取 mask key */
uint8_t mask_key[4] = {0, 0, 0, 0};
if (masked) {
if (recv_all(ws, mask_key, 4) != 0) { ws->connected = 0; return -1; }
}
/* 限制帧大小 */
if (frame_len > sizeof(ws->read_buf)) {
/* 帧太大,跳过 payload */
uint64_t skip = frame_len;
uint8_t tmp[256];
while (skip > 0) {
int to_skip = (skip > sizeof(tmp)) ? (int)sizeof(tmp) : (int)skip;
if (recv_all(ws, tmp, to_skip) != 0) { ws->connected = 0; return -1; }
skip -= to_skip;
}
return -1; /* 返回错误,帧太大 */
}
/* 读取 payload */
if (frame_len > 0) {
if (recv_all(ws, ws->read_buf, (int)frame_len) != 0) {
ws->connected = 0;
return -1;
}
/* 如果有 mask,解掩码 */
if (masked) {
for (uint64_t i = 0; i < frame_len; i++) {
ws->read_buf[i] ^= mask_key[i & 3];
}
}
}
*payload = ws->read_buf;
*len = (int)frame_len;
switch (opcode) {
case WS_OPCODE_CLOSE:
ws->connected = 0;
return WS_OPCODE_CLOSE;
case WS_OPCODE_PING:
return WS_OPCODE_PING;
case WS_OPCODE_PONG:
return WS_OPCODE_PONG;
case WS_OPCODE_TEXT:
case WS_OPCODE_BINARY:
return opcode;
default:
return -1;
}
}
void ha_ws_close(ha_ws_t *ws) {
if (ws->connected) {
ha_ws_send_frame(ws, WS_OPCODE_CLOSE, NULL, 0);
ws->connected = 0;
}
ws->transport->close(ws->transport->ctx);
}

View File

@ -1,62 +0,0 @@
#ifndef HA_WS_H
#define HA_WS_H
#include <stdint.h>
#include <stddef.h>
#include "../include/ha_remotedevice.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ======================== WS 帧类型 ======================== */
#define WS_OPCODE_CONTINUATION 0x0
#define WS_OPCODE_TEXT 0x1
#define WS_OPCODE_BINARY 0x2
#define WS_OPCODE_CLOSE 0x8
#define WS_OPCODE_PING 0x9
#define WS_OPCODE_PONG 0xA
/* ======================== WS 连接 ======================== */
typedef struct {
ha_transport_t *transport; /* 用户实现的传输层 */
int connected; /* 是否已连接 */
uint8_t read_buf[8192]; /* 读缓冲区 */
int read_pos; /* 缓冲区中有效数据起始位置 */
int read_len; /* 缓冲区中有效数据长度 */
char host[256]; /* 缓存目标地址 */
uint16_t port;
char path[256];
char token[256];
} ha_ws_t;
/* 创建 WS 连接。返回 0 成功,非 0 失败。 */
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
const char *host, uint16_t port,
const char *path, const char *token);
/* 发送文本帧。返回 0 成功。 */
int ha_ws_send_text(ha_ws_t *ws, const char *text);
/* 发送二进制帧。返回 0 成功。 */
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len);
/* 发送 ping。返回 0 成功。 */
int ha_ws_send_ping(ha_ws_t *ws);
/* 读取一帧。
* 返回 opcode (0x1/0x2/0x8/0x9/0xA),-1 表示关闭或错误。
* payload 和 len 指向内部缓冲区,在下次调用前有效。 */
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len);
/* 发送原始 WS 帧(内部使用,用于回复 ping) */
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len);
/* 关闭 WS 连接 */
void ha_ws_close(ha_ws_t *ws);
#ifdef __cplusplus
}
#endif
#endif /* HA_WS_H */

File diff suppressed because it is too large Load Diff

147
scripts/build_plugin_bundles.sh Executable file
View File

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

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

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

133
scripts/upload-assets.py Executable file
View File

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""上传 release 资产到 gitcode(两步:取签名 URL → PUT 到 OBS)。
用法: upload_assets.py <tag> <token> [file...]
不传 file 时上传 dist/release/ 下全部发布产物。
环境变量:
GITCODE_REPO 目标仓库,默认 JianFeeeee/HomeAgent(SDK 仓传 JianFeeeee/homeagent-sdk)
ASSET_DIR 资产目录,默认 <repo>/dist/release
为何两步:gitcode 的 release 附件不走 API 直传,而是先向
`releases/<tag>/upload_url` 要一个 OBS 预签名 URL(带 x-obs-* 回调头),
再把文件 PUT 到那个 URL。回调头必须原样透传,否则 OBS 收下了文件但
gitcode 侧不会登记为 release 附件。
"""
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
REPO = os.environ.get("GITCODE_REPO", "JianFeeeee/HomeAgent")
API = "https://gitcode.com/api/v5/repos"
# 发布产物后缀。注意 Windows 安装器是 HomeAgent_v*_win64.exe,
# 与 bin/ 里的裸 .exe 靠 _win64.exe 后缀区分。
ARTIFACT_SUFFIXES = (
".tar.gz",
".zip",
".deb",
".rpm",
".pkg",
"_win64.exe",
# 插件包。之前不在白名单里,会被静默跳过——而 release 本该带上它们,
# 否则用户要自己装 Go + hmapdev 逐插件构建(见 SDK 仓 scripts/build_plugin_bundles.sh)。
".hmap",
# 插件包汇总校验和(与 SHA256SUMS 同性质,独立文件免得混淆内核包与插件)
"SHA256SUMS.plugins",
)
def is_artifact(name: str) -> bool:
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]:
q = urllib.parse.urlencode({"file_name": filename})
url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}"
req = urllib.request.Request(url, headers={"private-token": token})
with urllib.request.urlopen(req, timeout=30) as r:
data = json.loads(r.read())
return data["url"], data.get("headers", {})
def put_file(url: str, headers: dict, path: str) -> tuple[int, str]:
size = os.path.getsize(path)
with open(path, "rb") as f:
body = f.read()
req = urllib.request.Request(url, data=body, method="PUT")
for k, v in headers.items():
req.add_header(k, v)
req.add_header("Content-Length", str(size))
try:
# 大文件(Full 变体安装包近 100MB)给足超时。
with urllib.request.urlopen(req, timeout=900) as r:
return r.status, r.read().decode("utf-8", "replace")[:300]
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")[:300]
except Exception as e: # noqa: BLE001
return 0, f"{type(e).__name__}: {e}"
def project_root() -> str:
"""向上找带 go.mod 的目录作为仓库根。
为何不数 dirname:本脚本初版在 scripts/(深度 1),移到 deploy/scripts/
(深度 2)后写死的两层 dirname 就指向了 deploy/dist/release,上传直接
FileNotFoundError。这正是 v0.7.2 那次 package/ → deploy/packaging/ 打断
PROJECT_ROOT 的同一个坑,改成按标记文件定位以后怎么挑位置都不会错。
"""
d = os.path.dirname(os.path.abspath(__file__))
while d != os.path.dirname(d):
if os.path.exists(os.path.join(d, "go.mod")):
return d
d = os.path.dirname(d)
# 实在找不到(脚本被单独拷出仓库)就回退到 cwd,给 ASSET_DIR 一个机会
return os.getcwd()
def main() -> int:
if len(sys.argv) < 3:
print(__doc__)
return 2
tag, token = sys.argv[1], sys.argv[2]
outdir = os.environ.get("ASSET_DIR") or os.path.join(
project_root(), "dist", "release"
)
if not os.path.isdir(outdir):
print(f"error: 资产目录不存在: {outdir}")
print(" 用 ASSET_DIR=<目录> 显式指定,或先跑构建生成 dist/release/")
return 2
files = sys.argv[3:] or sorted(
f for f in os.listdir(outdir) if is_artifact(f)
)
if not files:
print(f"error: {outdir} 下没有可识别的发布产物")
return 2
print(f"repo={REPO} tag={tag} dir={outdir}", flush=True)
failed = []
for name in files:
path = os.path.join(outdir, name)
if not os.path.isfile(path):
print(f"skip (missing): {name}", flush=True)
continue
mib = os.path.getsize(path) / 1048576
print(f"==> {name} ({mib:.1f} MiB)", flush=True)
try:
url, headers = get_upload_url(tag, token, name)
except Exception as e: # noqa: BLE001
print(f" upload_url FAILED: {e}", flush=True)
failed.append(name)
continue
status, body = put_file(url, headers, path)
ok = 200 <= status < 300
print(f" PUT -> {status} {'OK' if ok else body}", flush=True)
if not ok:
failed.append(name)
print(f"\n{'ALL OK' if not failed else f'{len(failed)} FAILED: ' + ', '.join(failed)}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -9,6 +9,14 @@ type KnowledgeAPI interface {
// Knowledge represents a knowledge entry. // Knowledge represents a knowledge entry.
type Knowledge struct { type Knowledge struct {
Name string `json:"name"` Name string `json:"name"`
Content string `json:"content"` // Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。
//
// 为何加这个字段:对外服务(kbtree)要做**暴露范围过滤**就必须知道
// 每条结果属于哪个分类 —— 过滤只能发生在服务端(客户端过滤等于
// 没过滤,范围外内容已经随响应发出去了)。
// 之前这里只有 Name/Content,内核明明返回了 Category 却在
// knowledge_impl.SearchIn 的拷贝里丢掉,导致外部无法按分类判定。
Category string `json:"category,omitempty"`
Content string `json:"content"`
} }

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

@ -0,0 +1,413 @@
-- HomeAgent Lua Plugin SDK
-- Interface contract between Lua plugins and HomeAgent kernel.
-- !impl functions are replaced by Go implementations at runtime.
-- Standalone/debug: pure Lua mock implementations are used.
-- Usage: local sdk = require("sdk")
sdk = {}
-- !impl
-- level: "debug" | "info" | "warn" | "error"
function sdk.log(level, msg)
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg))
end
-- !impl
-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_tool(name, def, handler)
print("[lua-plugin] register_tool: " .. tostring(name))
end
-- !impl
-- stage: "on_input" | "pre_action" | "post_action" | ...
-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发)
function sdk.register_stage(stage, handler, scope)
print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope))
end
-- !impl
function sdk.register_api(name)
print("[lua-plugin] register_api: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
-- handler: function(args) -> result
function sdk.register_output_channel(name, caps, desc, def, handler)
print("[lua-plugin] register_output_channel: " .. tostring(name))
end
-- !impl
-- def: { no_memory=true/false, cleaner=function(text)->text }
function sdk.register_input_channel(name, def)
print("[lua-plugin] register_input_channel: " .. tostring(name))
end
-- !impl
function sdk.get_setting(key)
return nil
end
-- !impl
function sdk.set_setting(key, value)
print("[lua-plugin] set_setting: " .. tostring(key))
end
-- !impl
function sdk.inject_text(source, channel, text)
print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel))
end
-- !impl
function sdk.inject_interrupt(source, channel, text)
print("[lua-plugin] inject_interrupt: " .. tostring(source))
end
-- !impl
function sdk.inject_text_no_memory(source, channel, text)
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
end
-- !impl
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
function sdk.inject_text_opts(source, channel, text, opts)
print("[lua-plugin] inject_text_opts: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_opts(source, channel, text, opts)
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
end
-- !impl
-- 同步注入在 Lua 插件中**不可用**:会等本轮回复,而本轮正持有插件锁 ⇒ 必然自锁。
-- 真实内核里恒返回 (nil, err);这里返回同样的错误,避免离线测试误以为可用。
function sdk.inject_input_sync(source, channel, text)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
end
-- !impl
function sdk.inject_input_sync_opts(source, channel, text, opts)
return nil, "同步注入在 Lua 插件中不可用:请在事件回调/外部入口用 inject_text/inject_interrupt;确需同步等待请改用 Go 插件。"
end
-- !impl
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
function sdk.set_tool_blocks(blocks)
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
end
-- !impl
-- blocks 每项:{ type="text", text="..." }
-- | { type="image_url", image_url={ url="...", detail="high" } }
-- | { type="audio_url", audio_url={ url="..." } }
function sdk.inject_input_media(source, channel, text, blocks)
print("[lua-plugin] inject_input_media: " .. tostring(source))
end
-- !impl
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
end
-- !impl
-- 同 sdk.inject_input_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

View File

@ -54,6 +54,60 @@ func ValidContextPolicy(policy string) bool {
return false 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
}
// 场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。
//
// 与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管
// 「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是
// 「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹
// (通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。
//
// 默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自
// v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因:
// 1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量
// 通道突然失去场景召回;
// 2. 「关」是少数意图(内部信噪通道),少数意图不该是默认——
// 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。
//
// 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。
// 但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70
// strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间
// (loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。
// 「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件
// 将来确实需要时的闸门。
const (
ScenePolicyAuto = "auto"
ScenePolicyNone = "none"
)
// ValidScenePolicy 校验场面策略取值;空串等价于 ScenePolicyAuto。
func ValidScenePolicy(policy string) bool {
switch policy {
case "", ScenePolicyAuto, ScenePolicyNone:
return true
}
return false
}
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。 // InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
// //
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致, // 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
@ -65,6 +119,7 @@ func ValidContextPolicy(policy string) bool {
// //
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文 // NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。 // ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
// RecallPolicy: 此次注入是否依据(清洗后的)内容召回相关记忆;默认 auto(召回)。
// //
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。 // 中断注入也允许声明 prune——它同样会携带内容进入上下文。
// //
@ -77,7 +132,15 @@ func ValidContextPolicy(policy string) bool {
type InjectOptions struct { type InjectOptions struct {
NoMemory bool NoMemory bool
ContextPolicy string ContextPolicy string
CleanerName string // RecallPolicy 声明此次注入是否据其内容召回相关记忆。
// 空串 = 默认(输入/注入 auto,即保持既有「每条输入都召回」的行为);
// RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。
RecallPolicy string
// ScenePolicy 声明此次注入是否参与场面识别(场景式记忆)。
// 空串 = 默认参与(保持既有行为);ScenePolicyNone 显式关闭,
// 适用于不产生任何场面指纹的纯内部信号(心跳、自循环、内部状态)。
ScenePolicy string
CleanerName string
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。 // Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
// //
@ -106,6 +169,8 @@ const (
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中 // NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用 // Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪) // ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
// RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
// ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
// //
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。 // JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单—— // 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
@ -114,6 +179,10 @@ type ChannelDef struct {
NoMemory bool `json:"no_memory,omitempty"` NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"` Cleaner func(string) string `json:"-"`
ContextPolicy string `json:"context_policy,omitempty"` ContextPolicy string `json:"context_policy,omitempty"`
// RecallPolicy 见 InjectOptions.RecallPolicy;空串等价 auto(保持既有行为)。
RecallPolicy string `json:"recall_policy,omitempty"`
// ScenePolicy 见 InjectOptions.ScenePolicy;空串等价 auto(保持既有行为)。
ScenePolicy string `json:"scene_policy,omitempty"`
} }
// StageContext provides context for stage handlers. // StageContext provides context for stage handlers.
@ -171,6 +240,41 @@ type ToolResult struct {
Result interface{} `json:"result"` Result interface{} `json:"result"`
} }
// ToolError 描述一次工具调用的失败原因。
//
// 存在的理由:失败若只表达为文本,模型无法定位到字段,只能原样重试
// (实测 cmd_run 失败率 34%~48%,全部源于同一个成因:参数被截断或
// JSON 写坏,工具却只回报 "command is required" 这类与真因无关的错)。
//
// ⚠️ 零值语义:插件**不必**改用本类型。内核的失败识别同时兼容既有三种约定
// ({"error":…}、{"isError":true,…}、显式 error 返回),见 core.isToolError。
// 本类型是给**新写**的工具用的可选项,不是迁移要求。
type ToolError struct {
// Field 是出错的参数字段名(参数校验失败时填)。
Field string `json:"field,omitempty"`
// Reason 是机器可读的原因码:required / type / unauthorized / timeout / not_found。
Reason string `json:"reason"`
// Detail 是人类可读的补充说明。
Detail string `json:"detail,omitempty"`
// Hint 是给模型的可执行指引(该改什么、不要重试什么)。
Hint string `json:"hint,omitempty"`
}
// Error 实现 error,便于工具同时走 (ToolError, error) 通道。
func (e *ToolError) Error() string {
if e == nil {
return ""
}
s := e.Reason
if e.Field != "" {
s = e.Field + ": " + s
}
if e.Detail != "" {
s += " (" + e.Detail + ")"
}
return s
}
// ToolDef describes a tool that the plugin exposes. // ToolDef describes a tool that the plugin exposes.
type ToolDef struct { type ToolDef struct {
Name string `json:"name"` Name string `json:"name"`
@ -180,6 +284,33 @@ type ToolDef struct {
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留 NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用 Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
// RecallPolicy 声明此工具输出是否触发一次记忆召回(注入)。
// ""(默认 none) / RecallPolicyNone / RecallPolicyAuto。
// 默认 none:多数工具输出是噪声;需要「取回真实内容后据它召回」的工具(如 qq_get_message)应显式声明 auto。
RecallPolicy string `json:"recall_policy,omitempty"`
// ParallelSafe 声明此工具**可以被并发执行**(同一批多个 tool_call 同时跑)。
//
// ⚠️ 零值 false 是刻意的:存量插件不改一行就得到**保守**行为
//(整批串行),不会因升级被意外并发。声明它是**责任**而非特权。
//
// 判据(三者皆满足才可并发):
// · handler 自身线程安全(不持有跨调用的可变状态)
// · 不与同批其它工具争抢同一资源(SQLite 写、设备、同一输出通道)
// · 执行顺序无关(顺序敏感的工具应留 false,由内核保序)
ParallelSafe bool `json:"parallel_safe,omitempty"`
// Serial 声明本工具**必须**串行 —— ParallelSafe 的反向标记。
//
// 为什么需要它:ParallelSafe 的零值 false 已经表达"安全/串行",
// 插件无法区分"我没想过"和"我确认过必须串行"。一旦工具作者需要
// 把"这里**故意**串行,是有原因的"写进代码(而不只是没填),
// 这个区分就是必需的 —— 否则只能靠命名约定传递意图。
//
// 适用场景:读操作但有隐含顺序约束(终端 read/resize 这类共享会话
// 状态)、或写操作虽已加锁但需要串行以获得可预测的交错顺序。
//
// 判据优先级:**Serial 胜出**。显式声明"必须串行"不允许被
// ParallelSafe 或任何默认值覆盖。
Serial bool `json:"serial,omitempty"`
} }
// IOInjector provides methods for injecting input and interrupts into the agent pipeline. // IOInjector provides methods for injecting input and interrupts into the agent pipeline.
@ -321,6 +452,11 @@ type PluginSDK struct {
events EventSubscriber events EventSubscriber
plgMgr PluginMgrAPI plgMgr PluginMgrAPI
// proxyReg 是反代声明的注册回调(内置插件经 RegisterProxy 声明服务)。
// 与上面的 API 字段同受 apiMu 保护——写方是内核注入,读方是插件 Start
// 起的 goroutine。
proxyReg ProxyRegistrar
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。 // apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
// //
// 这些字段的写方与读方天然跨 goroutine: // 这些字段的写方与读方天然跨 goroutine:
@ -480,7 +616,15 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。 // 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
// 若该通道同时也是你的注入入口,两个都要登记。 // 若该通道同时也是你的注入入口,两个都要登记。
// //
// name: channel name (e.g. "qq", "webui") // name: channel name (e.g. "qq", "webui")。
//
// ❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`),
// 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用",
// 而是**整条请求被上游 400 拒绝**(`Invalid 'tools[N].function.name'`),
// 网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
// 所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13 字符)的余量。
// 若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**,
// 而不是把原始值直接当通道名。
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.) // caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
// desc: description of the channel, expected meta format, and type enum // desc: description of the channel, expected meta format, and type enum
// def: 通道在记忆计算层的行为(NoMemory/Cleaner) // def: 通道在记忆计算层的行为(NoMemory/Cleaner)
@ -735,8 +879,12 @@ func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock) {
} }
} }
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。 // SetAutoRestart 设置插件崩溃后内核是否自动重启它。
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。 // 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
//
// 重启是有限度的:线性退避(第 n 次等 n×1s,即 1s→2s→3s),
// 且同一 5 分钟窗口内第 4 次崩溃就停下不再拉起(详见 README)。
// 注意这与「重载」(换 plugin.bin 后重新加载)是两回事。
func (s *PluginSDK) SetAutoRestart(enabled bool) { func (s *PluginSDK) SetAutoRestart(enabled bool) {
s.apiMu.Lock() s.apiMu.Lock()
s.autoRestart = enabled s.autoRestart = enabled

345
sdk/proxy.go Normal file
View File

@ -0,0 +1,345 @@
package sdk
import (
"net"
"strconv"
"strings"
)
// 反向代理声明:插件告诉 HomeAgent「我起了个 HTTP 服务,请把它反代出去」。
//
// 为什么需要:插件自带 Web UI / HTTP API 时,监听地址在插件自己的配置里
// (如 127.0.0.1:12100),外部无从得知;而 webui 的对外端口通常只有一个
// (默认 :8080,且常经 frp 单端口隧道穿透)。没有声明机制时,用户只能
// 「知道端口 + 自己配转发」,插件换端口就失效。
//
// 设计取舍——**声明式而非注册式**:声明写在 plugin.json 里,由 HomeAgent
// 在加载插件时读取聚合,而不是让插件在运行期调 API 注册。理由:
// 1. 静态可发现:未启动/已崩溃的插件,其服务声明依然可见(可给出准确报错
// 「插件 X 声明了 ui 但目标 127.0.0.1:12100 不可达」,而不是静默 404);
// 2. 可版本化:声明随插件包一起分发、可 diff、可审计;
// 3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HomeAgent 读旧
// 插件、或旧 HomeAgent 读新插件都不会报错。
//
// 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。
// 声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def),
// 或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。
//
// 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
// capabilities 里另外开一个开关——最小权限默认生效。
//
// # 单一入口原则(强制要求)
//
// **一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从
// 该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。
//
// 为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同——
//
// Host 形态(host):插件独占 <标签>.<基域名>,根路径就是插件的根。
// 根绝对路径(fetch('/api/x'))**天然正确**。
// Path 形态(path):插件挂在门户自身 host 的某个前缀下,根路径属于**门户**。
// 此时插件里的 fetch('/api/x') 会打到门户自己的 /api/x
// —— 静默错路由,页面能开但功能全坏。
//
// 于是「同一个插件必须同时支持两种形态」这条要求,等价于:
//
// **插件内部一律使用相对路径**(或基于 <base>/location 推导的路径),
// 绝不硬编码以 / 开头的绝对路径。
//
// 这样同一份前端在两种形态下都正确,插件作者也不必知道自己被挂在哪。
// 反代层据此可以:外部子域可用时给 Host 形态,子域不可用(证书/放行限制)
// 时给 Path 形态,**无需插件配合改动**。
//
// 自检(插件作者在本地就该做):把页面挂到 <门户>/<任意前缀>/ 下访问,
// 所有请求都必须仍然打到插件自己。
//
// 本项目实测案例:某插件前端写死 fetch('/api/status'),配在
// /p/huawei/ 下会打到门户的 /api/status(404 或返回门户数据);
// 改成相对路径后两种形态同时可用。
// ProxyDef 是一个服务的**反代声明体**。
//
// 与 ToolDef 同构:Name 同时出现在字段与 RegisterProxy 的第一个参数里
// (ToolDef 也是这么做的 —— 字段供 plugin.json 序列化,参数供运行期调用)。
// Name 只用于展示、日志与冲突提示,**不参与路由**(路由键是 Host 与 Path)。
type ProxyDef struct {
// Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。
// 运行期由 RegisterProxy 的第一个参数填入;声明式由 plugin.json 的
// name 键填入。省略时由 HomeAgent 兜底为 "service"。
Name string `json:"name,omitempty"`
// Host 是**子域名标签**(不含基域名),如 "huawei" 对应 huawei.<基域名>。
//
// 约束:仅小写字母、数字与连字符,不以连字符开头/结尾,长度 ≤ 63
// (DNS label 规则)。省略时默认取插件名(下划线转连字符,因为下划线
// 不是合法 DNS label 字符)。
//
// 冲突处理:两个插件声明同一 Host 时,HomeAgent 不做「后者覆盖前者」——
// 那样会让先声明者静默消失。冲突条目被拒绝并在反代表里记录原因。
Host string `json:"host,omitempty"`
// Target 是上游地址,形如 "127.0.0.1:12100" 或 "http://127.0.0.1:12100"。
// 可带路径前缀(如 "127.0.0.1:3000/base"),HomeAgent 转发时保留该前缀。
//
// 端口由插件自己填它**实际监听**的地址,避免「声明与实际漂移」。
Target string `json:"target"`
// WebSocket 表示该服务需要 WebSocket 升级透传(默认 false)。
//
// 为什么必须显式声明而不是「有 Upgrade 头就转」:WS 是长连接,会占用
// 反代侧连接与 goroutine,且绕过普通请求的响应缓冲/超时逻辑。默认关闭
// 让普通 HTTP 服务的失败模式保持简单;未声明时的升级请求会被明确拒绝,
// 而不是静默降级成普通请求(后者表现为前端一直重连、排查困难)。
WebSocket bool `json:"websocket,omitempty"`
// Path 是可选的**路径挂载前缀**(如 "/api/v1/device")。
//
// 为什么 Host 子域之外还需要它:子域形态依赖 DNS 解析,而 *.localhost
// 只有浏览器内置该特例(RFC 6761)—— 普通进程(设备客户端、固件、
// CLI)走系统解析器,实测解析不到,会以「no such host」失败。
// 路径形态挂在门户自身 host 下,**无任何 DNS 依赖**,是给非浏览器
// 客户端用的。
//
// 语义:请求路径**原样保留**(不做前缀剥除)——声明者按上游真实路径填写,
// 例如上游注册 /api/v1/device/ws,就声明 Path="/api/v1/device"。
// 这样设备客户端可以直接使用它已硬编码的路径,不需要知道反代的存在。
//
// 与 Host 形态的关系(见包注释的「单一入口原则」):声明的服务应当
// **同时**能被两种形态访问。因此 Path 形态下插件内部必须用相对路径,
// 否则它的前端会把请求打到门户自己身上。
//
// 留空 = 只提供子域形态(插件自带 UI 的常见情形:UI 与它自己的 API
// 同源,走子域天然正确)。
Path string `json:"path,omitempty"`
// StripPath 决定转发前是否**剥掉** Path 前缀。默认 false(原样保留)。
//
// 两种挂载语义真实不同,必须由声明者选,不能靠猜:
//
// false(别名模式):Path 就是上游真实路径的一部分。
// 请求 /api/v1/device/ws + Path="/api/v1/device"
// → 上游收到 /api/v1/device/ws(一模一样)。
// 适用:客户端**已硬编码**路径的机器接口(设备网关就是如此,
// 它按 /api/v1/device/ws 连接,不可能知道反代的存在)。
//
// true(前缀模式):Path 只是门户上的挂载点,上游不知道它。
// 请求 /p/myapp/api/status + Path="/p/myapp"
// → 上游收到 /api/status。
// 适用:自带 UI 的服务(前端用相对路径,被挂到哪里都对)。
//
// 为什么不能自动判定:同一个声明「Path=/api/v1/device」在两种语义下
// 都说得通,代理无从分辨 —— 猜错的结果是全部请求 404,且看起来像
// 上游故障。所以由声明者显式写清楚。
StripPath bool `json:"strip_path,omitempty"`
// Auth 决定这条反代由谁保护,取值见 ProxyAuthNone / ProxyAuthHomeAgent。
// 空串等价于 ProxyAuthHomeAgent(默认安全)。
//
// 为什么做成可声明项:设备网关(remotedevice)这类服务的调用方是**设备**,
// 它们不可能持有浏览器会话 cookie,而服务自身已有接入令牌(如 ws_token)。
// 强制走 HomeAgent 门户鉴权会把这类链路挡死;反过来,插件自带的 UI 若
// 声明 none,就等于把管理界面裸露给任何能访问该端口的人。
// 因此必须由插件**逐条**声明,而不是全局一刀切。
Auth string `json:"auth,omitempty"`
}
// ProxyAuth 取值。空串按 ProxyAuthHomeAgent 处理(安全的默认)。
const (
// ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话
// (homeagent_session cookie),非浏览器客户端走 X-API-Key。
// 两者都没有时返回 401,而不是把请求透传给上游。
ProxyAuthHomeAgent = "homeagent"
// ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。
//
// 适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端),
// 或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**,
// 且该服务在网络层可达范围内对所有人开放。
ProxyAuthNone = "none"
)
// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthHomeAgent)。
func ValidProxyAuth(auth string) bool {
switch auth {
case "", ProxyAuthHomeAgent, ProxyAuthNone:
return true
}
return false
}
// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。
func EffectiveProxyAuth(auth string) string {
if auth == "" {
return ProxyAuthHomeAgent
}
return auth
}
// ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
//
// 独立成导出函数:插件作者在写声明时、HomeAgent 在加载时、工具链在打包时
// 都要用同一套规则判定,避免三处各写一份而互相不一致。
func ValidProxyHostLabel(label string) bool {
if label == "" || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
return true
}
// NormalizeProxyHost 由插件名派生默认 Host 标签。
//
// 下划线转连字符:插件名允许下划线(huawei_smarthome),但 DNS label 不允许,
// 直接用会导致该子域名无法解析——这里统一转换,避免每个插件各自碰运气。
func NormalizeProxyHost(pluginName string) string {
s := strings.ToLower(strings.TrimSpace(pluginName))
s = strings.ReplaceAll(s, "_", "-")
// 去掉其它非法字符,保证结果是合法 label(宁可退化成保守值也不产出非法域名)
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
b.WriteByte(c)
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "plugin"
}
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
//
// 为什么要在 SDK 里做校验:HomeAgent 加载插件时必须能明确拒绝坏声明并说明
// 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
// 拼错的 Target/Host。同一套规则两端共用。
func ValidateProxyDef(d ProxyDef) string {
if strings.TrimSpace(d.Target) == "" {
return "target 为空:必须给出上游地址(如 127.0.0.1:12100 或 http://127.0.0.1:12100)"
}
if !ValidProxyAuth(d.Auth) {
return "auth 取值非法:" + d.Auth + "(只允许 \"\" / \"homeagent\" / \"none\")"
}
if d.Host != "" && !ValidProxyHostLabel(d.Host) {
return "host 不是合法的子域名标签(只允许小写字母/数字/连字符,且不以连字符开头结尾): " + d.Host
}
if p := strings.TrimSpace(d.Path); p != "" {
if !strings.HasPrefix(p, "/") {
return "path 必须以 / 开头: " + d.Path
}
if strings.HasSuffix(p, "/") {
return "path 不应以 / 结尾(它是前缀,不是目录): " + d.Path
}
if strings.Contains(p, "..") || strings.ContainsAny(p, " \t\r\n\x00?#") {
return "path 含非法字符: " + d.Path
}
}
// 前缀模式必须给出可剥的前缀。
// 注意 "/" 不需要单独判:它是前缀又同时以 "/" 结尾,已被上面的
// 「不应以 / 结尾」规则挡掉(挂到门户根会覆盖整站的意图因此无法达成)。
if d.StripPath && strings.TrimSpace(d.Path) == "" {
return "strip_path=true 时必须给出 path(否则没有可剥的前缀)"
}
// Target 的 host:port 部分必须可解析;路径前缀允许保留。
//
// 规则(刻意从严,因为地址写错是最常见的声明错误,而错误的反代会把
// 用户带到别处去):
// - 带 scheme 时(http://…)允许省略端口,由反代层按 scheme 补默认值;
// - 不带 scheme 时必须给出 host:port;
// - 端口必须是数字(SplitHostPort 本身不校验数字,"host:abc" 会通过)。
scheme := ""
raw := d.Target
if i := strings.Index(raw, "://"); i >= 0 {
scheme = strings.ToLower(raw[:i])
if scheme != "http" && scheme != "https" {
return "target scheme 只支持 http/https(WS 由 websocket 字段声明,不写 ws://): " + d.Target
}
raw = raw[i+3:]
}
if i := strings.IndexByte(raw, '/'); i >= 0 {
raw = raw[:i]
}
if raw == "" {
return "target 缺少主机部分: " + d.Target
}
host, port, err := net.SplitHostPort(raw)
if err != nil {
if scheme == "" {
return "target 必须给出 host:port(或带 http:// 前缀以便省略端口): " + d.Target
}
// 带 scheme 且解析失败:只剩主机名一种合法情形。
host, port = raw, ""
}
if host == "" {
return "target 缺少主机部分: " + d.Target
}
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "target 端口非法(应为 1-65535 的数字): " + d.Target
}
}
return ""
}
// ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。
// 插件不直接调它,用 RegisterProxy。
//
// 为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件**
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到;
// 而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补:
// - 外部插件 → plugin.json 的 proxies(静态,未启动也可见)
// - 内置插件 → RegisterProxy(运行期,随 Start 注册)
type ProxyRegistrar func(name string, def ProxyDef)
// SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。
func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) {
if s == nil {
return
}
s.apiMu.Lock()
s.proxyReg = r
s.apiMu.Unlock()
}
// RegisterProxy 声明一个需要 HomeAgent 反代出去的服务。
//
// 与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格:
// 显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是
// def.Host / def.Path)。
//
// 用法(通常在 Start 里调用):
//
// s.RegisterProxy("ui", sdk.ProxyDef{
// Host: "myapp", Target: "127.0.0.1:12100",
// })
//
// 声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path
// 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 ——
// 插件作者需要看见冲突。
//
// 与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。
func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) {
if s == nil {
return
}
s.apiMu.RLock()
r := s.proxyReg
s.apiMu.RUnlock()
if r != nil {
r(name, def)
}
}

163
sdk/proxy_test.go Normal file
View File

@ -0,0 +1,163 @@
package sdk
import (
"os"
"strings"
"testing"
)
func TestProxyAuthDefaultsToHomeAgent(t *testing.T) {
// 空串必须归一化为「HomeAgent 统一保护」——这是安全默认。
// 若哪天有人把默认改成 none,这条会立刻红。
if got := EffectiveProxyAuth(""); got != ProxyAuthHomeAgent {
t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthHomeAgent, got)
}
if got := EffectiveProxyAuth(ProxyAuthNone); got != ProxyAuthNone {
t.Fatalf("显式 none 应保持 none,实际 %q", got)
}
for _, ok := range []string{"", ProxyAuthHomeAgent, ProxyAuthNone} {
if !ValidProxyAuth(ok) {
t.Errorf("%q 应合法", ok)
}
}
for _, bad := range []string{"nope", "HOMEAGENT", "None", "true"} {
if ValidProxyAuth(bad) {
t.Errorf("%q 应非法", bad)
}
}
}
func TestValidProxyHostLabel(t *testing.T) {
legit := []string{"huawei", "a", "a-b", "abc123", "0", "x" + string(make([]byte, 0)) + "yz"}
for _, s := range legit {
if !ValidProxyHostLabel(s) {
t.Errorf("%q 应为合法 label", s)
}
}
bad := []string{
"", "-a", "a-", "-", "a_b", "a.b", "A", "aB", "a b",
"a/b", "a:b", string(make([]byte, 64)), // 超长 63
}
for _, s := range bad {
if ValidProxyHostLabel(s) {
t.Errorf("%q 应为非法 label", s)
}
}
// 边界:恰好 63 合法,64 非法
l63 := ""
for i := 0; i < 63; i++ {
l63 += "a"
}
if !ValidProxyHostLabel(l63) {
t.Error("63 字符应为合法 label")
}
if ValidProxyHostLabel(l63 + "a") {
t.Error("64 字符应为非法 label")
}
}
func TestNormalizeProxyHost(t *testing.T) {
cases := map[string]string{
"huawei_smarthome": "huawei-smarthome", // 下划线不是合法 DNS label
"webui": "webui",
"UPPER_Case": "upper-case",
"a__b": "a--b",
"__x__": "x",
"---": "plugin", // 全非法 → 保守回退
"": "plugin",
"a.b.c": "abc",
}
for in, want := range cases {
if got := NormalizeProxyHost(in); got != want {
t.Errorf("NormalizeProxyHost(%q) = %q,期望 %q", in, got, want)
}
}
// 归一化结果必须自身合法(产物自洽)
for _, in := range []string{"huawei_smarthome", "UPPER_Case", "__x__", "a.b.c", "非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字"} {
if got := NormalizeProxyHost(in); !ValidProxyHostLabel(got) {
t.Errorf("NormalizeProxyHost(%q) = %q 不合法", in, got)
}
}
}
func TestValidateProxyDef(t *testing.T) {
valid := []ProxyDef{
{Target: "127.0.0.1:12100"},
{Target: "http://127.0.0.1:12100"},
{Target: "127.0.0.1:12100", Host: "huawei"},
{Target: "127.0.0.1:12100", Auth: ProxyAuthNone},
{Target: "127.0.0.1:12100", Auth: ProxyAuthHomeAgent, WebSocket: true},
{Target: "127.0.0.1:3000/base", Host: "x"},
{Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性)
}
for _, d := range valid {
if msg := ValidateProxyDef(d); msg != "" {
t.Errorf("%+v 应合法,却报: %s", d, msg)
}
}
bad := []ProxyDef{
{}, // 无 target
{Target: " "}, // 空白 target
{Target: "127.0.0.1:12100", Auth: "yes"}, // auth 非法
{Target: "127.0.0.1:12100", Host: "a_b"}, // host 非法
{Target: "127.0.0.1:12100", Host: "-x"},
{Target: "127.0.0.1:12100", Host: "X"},
{Target: "://12100"}, // 无主机
{Target: "http:///path"}, // 无主机
{Target: "127.0.0.1:notaport"}, // 端口非数字
}
for _, d := range bad {
if msg := ValidateProxyDef(d); msg == "" {
t.Errorf("%+v 应被拒绝,却通过了", d)
}
}
}
// ---- 单一入口原则 ----
// 被反代的插件必须能同时适配 Host 形态与 Path 形态。这两条判据把
// 「插件内部不得用根绝对路径」这条契约钉在**可执行**的层面:
// 声明合法不代表它的资源能被两种形态访问到 —— 后者取决于插件前端的写法,
// 而 SDK 只能把要求写清楚并给出校验工具。
func TestSingleEntryPrincipleDocumented(t *testing.T) {
// Path 形态下插件前端必须用相对路径,否则请求会打到门户自己。
// 这是**文档级约定**,只能靠 review 与这份判据共同保证:
// 判据确保 SDK 里确实写明了这条要求(防止后来者删掉注释)。
src, err := os.ReadFile("proxy.go")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"单一入口原则",
"相对路径",
"根绝对路径",
} {
if !strings.Contains(string(src), want) {
t.Errorf("SDK 文档缺少「%s」—— 单一入口原则是反代的硬要求,不能只存在于口头约定里", want)
}
}
}
// strip_path 的两种语义必须由声明者显式选,且非法组合要被挡住。
func TestStripPathValidation(t *testing.T) {
// 合法:两种模式
for _, d := range []ProxyDef{
{Target: "127.0.0.1:1", Path: "/p/app", StripPath: true},
{Target: "127.0.0.1:1", Path: "/api/v1/device", StripPath: false},
} {
if msg := ValidateProxyDef(d); msg != "" {
t.Errorf("应合法却被拒: %+v → %s", d, msg)
}
}
// 非法:strip_path 但没有 path(没有可剥的前缀)
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", StripPath: true}); msg == "" {
t.Error("strip_path=true 而无 path 应被拒(没有可剥的前缀)")
}
// 非法:前缀模式挂到根会吞掉整个门户。
// 实际由「不应以 / 结尾」规则挡下("/" 同时是前缀又以 / 结尾),
// 这里断言的是**行为**:这种声明无论如何都不能通过。
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", Path: "/", StripPath: true}); msg == "" {
t.Error("path=\"/\" + strip_path 应被拒(会覆盖整个门户)")
}
}

View File

@ -0,0 +1,205 @@
---
name: homeagent-plugin-dev
description: 开发、安装、调试 HomeAgent 插件。涉及 hmapdev 工具链、SDK 版本、plugin.bin 部署、插件注册排查时使用。触发词:HomeAgent 插件、hmapdev、plugin.bin、写插件、装插件、插件不生效、SDK 版本、RegisterTool、plugin_spawn。
---
# HomeAgent 插件开发与安装
> 本机(192.168.2.60)HomeAgent 的插件工具链手册。**所有命令都实测过**,
> 不是从文档抄的。改了本机环境后请回来更正。
## 0. 先搞清三件事,别猜
```bash
hmapdev version # 工具链版本 + SDK 模块 + 构建用 Go
hmapdev sdk current # 当前活跃 SDK 版本
hmapdev sdk list # 本机已装的所有 SDK 版本
```
**核心仓在** `/home/program/TrueAgent`(Go module `gitcode.com/JianFeeeee/HomeAgent`)。
**SDK 仓在** `third_party/homeagent-sdk`(独立 git 仓,独立版本号)。
## 1. 工具链:hmapdev
已装在 `/usr/local/bin/hmapdev`。这是插件开发的**唯一入口**。
| 命令 | 作用 |
| --- | --- |
| `hmapdev init <name>` | 生成 Go 插件脚手架 |
| `hmapdev init <name> --lua` | 生成 Lua 插件 |
| `hmapdev init <name> --type remotedevice` | 生成 C 语言远程设备适配器 |
| `hmapdev build` | 编译打包(`--outdir` / `--target os/arch`) |
| `hmapdev debug [dir]` | 解释执行 / 调试插件源码 |
| `hmapdev clean` | 清理 build/dist |
| `hmapdev sdk list/install/use/path/current/latest` | SDK 版本管理 |
⚠ **`hmapdev build` / `debug` 不支持 `--help`**:它们**不是**打印帮助,而是直接
去读当前目录的 `plg.json`,于是你会看到
error: read plg.json: open plg.json: no such file or directory
这**不是故障**,只是没有 `--help`。要看 build 的可用 flag 用 `hmapdev --help`
(那里列出了 `--outdir` / `--target` / `--lua` / `--type`)。
## 2. ★ SDK 版本:最常见的坑
**插件编译时会校验 SDK 能力**,用**文档里的旧接口**生成的工程会直接构建失败,
报错形如「某能力需要更高版本 SDK」,并给出两条出路:
```bash
hmapdev sdk install <version> # 1) 装对应版本
hmapdev sdk install --from <本地源码目录> # 2) 直接用本地 SDK 源码
```
本机现状:`hmapdev sdk current` = **v1.4.0**,与核心仓 `internal/meta.Version` 一致。
⇒ **动手前先 `hmapdev sdk current`**,别照着旧文档写。
⇒ 核心仓有**新能力但 SDK 未跟上**时(本机发生过),用 `--from` 指向本地源码:
`hmapdev sdk install --from /home/program/TrueAgent/third_party/homeagent-sdk`
## 3. 开发流程
```bash
mkdir -p /home/newqqagent/plugindev && cd /home/newqqagent/plugindev
hmapdev init myplugin
cd myplugin
# 编辑源码:注册工具用 s.RegisterTool(name, sdk.ToolDef{...}, handler)
hmapdev build # 产出 dist/myplugin_bundle.hmap
hmapdev debug . # 不想装就能先跑一遍
```
### ★ 产物形态(实测,别猜)
`hmapdev build` 产出的是 **`dist/<name>_bundle.hmap`**,它是 **ZIP**
(魔数 `PK`,用 `unzip` 而不是 `tar` 解),内含多平台二进制:
plugin.json
plugin.bin.linux.amd64
plugin.bin.darwin.amd64
README.md
而**生产上** `/home/newqqagent/plugins/<name>/plugin.bin` 是**解包后的单个二进制**
(**不是** zip)。⇒ 部署时要用对应平台的 `plugin.bin.<os>.<arch>`,
不要把 `.hmap` 直接丢进去。
### 注册工具的形状(照 example 写,别自创)
```go
s.RegisterTool(tp+"mytool", sdk.ToolDef{
Name: tp + "mytool", // ← Name 必填,且要放在结构体**首位**
Description: "……",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Handler: h.mytool,
NoMemory: true, // 声明字段一律放**末尾**,仿 NoMemory 的写法
}, s)
```
**并发声明**(同轮多个 tool_call 时):
- `ParallelSafe: true` —— 可并发(**三个条件都满足才可声明**:handler 线程安全 /
不与同批工具争抢同一资源 / 执行顺序无关)
- `Serial: true` —— 必须串行,**优先级高于 `ParallelSafe`**
- **默认(都不写)= 整批串行**,是保守设计不是遗漏
## 3.5 `plg.json` —— 插件工程标志
`hmapdev build` 找的就是它。缺失时报
`error: read plg.json: open plg.json: no such file or directory`。
以 `example/qq/plg.json` 为准:
```json
{
"name": "qq", // 插件名(英文,装到 plugins/<name>/ 用它)
"name_zh": "QQ消息", // 展示名
"version": "1.4.1",
"description": "……",
"author": "HomeAgent",
"entry": "plugin.so", // 入口(build 会替换为实际产物)
"tags": ["qq", "messaging"],
"targets": "linux/amd64", // 目标平台
"outdir": "dist" // 产物目录
}
```
## 4. 安装到生产
```bash
# ① 从 bundle 里取出本机平台的二进制
cd myplugin && unzip -o dist/myplugin_bundle.hmap 'plugin.bin.linux.amd64'
# ② 放进插件目录(<name> 要与 plg.json 的 name 一致)
sudo mkdir -p /home/newqqagent/plugins/myplugin
sudo cp plugin.json /home/newqqagent/plugins/myplugin/
sudo cp plugin.bin.linux.amd64 /home/newqqagent/plugins/myplugin/plugin.bin
sudo chmod +x /home/newqqagent/plugins/myplugin/plugin.bin
# ③ 重启并确认工具真的注册了
sudo systemctl restart homeagent.service
journalctl -u homeagent.service --since "-2 min" | grep "registering tool: <你的工具名>"
```
`plugin.json` 是**安装标志**(生产目录里没有它,插件不会被识别)。
★ **内置 vs 独立二进制**(目录存在 ≠ 有 plugin.bin):
```bash
for p in webui qq cmd seq; do
printf "%-8s " $p
ls /home/newqqagent/plugins/$p/plugin.bin >/dev/null 2>&1 \
&& echo "独立(要单独构建部署)" || echo "内置(随 homed 部署)"
done
```
实测:`webui`/`cmd`/`seq` 内置,`qq` 独立。
## 5. 排查:插件装了却不生效
按这个顺序查,**别跳步**:
```bash
# 1) 内置还是独立?内置的改了源码必须重编 homed,不是重启就行
ls /home/newqqagent/plugins/<name>/plugin.bin
# 2) 工具注册了吗
journalctl -u homeagent.service --since "-5 min" | grep "registering tool:" | grep -i <name>
# 3) 插件加载了吗
journalctl -u homeagent.service --since "-5 min" | grep -E "loaded: <name>"
# 4) 启动时有没有报错
journalctl -u homeagent.service --since "-5 min" | grep -iE "<name>.*(error|panic|failed)"
```
★ **改了内置插件的源码 ⇒ 必须重新构建并部署 homed**,重启服务不生效。
内嵌资源走 `//go:embed`,是编译进二进制的。
★ 判断线上跑的是哪次构建,看 commit 字段(**不要用 `strings`** ——
`//go:embed` 的资源在旧二进制里也可能出现新内容):
```bash
K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';")
curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"commit":"[^"]*"'
```
⚠ 必须带 `X-API-Key`:无认证时返回 **200 + 登录页 HTML**,只看状态码会误判。
## 6. 参考资料在哪(别凭记忆写 API)
| 内容 | 路径 |
| --- | --- |
| SDK 源码 | `third_party/homeagent-sdk/sdk/` |
| **可运行的示例插件** | `third_party/homeagent-sdk/example/`(a2a / acp / qq / bili …) |
| 文档站 | `https://sdk.homeagent.jianfgit.xyz`(本地源 `docs/`,构建 `tools/apidoc/build.sh`) |
| 能力边界(哪些 API 外部可用) | `docs/guide/capability-boundary.md` |
| 并发声明 | `docs/guide/parallel-tool-declaration.md` |
| Lua 适配器写法 | `docs/guide/first-lua-plugin.md` |
| 部署手册 | 核心仓 `docs/zh/deploy-runbook.md` |
★ **写插件前先翻 `example/`**:那里的代码是**编译通过**的,
比文档更可靠。文档与示例冲突时以示例为准。
## 7. 边界(别越界)
- **不要手改生成的文档**:`docs/api/*.md` 由 `tools/apidoc/gensite` 生成,
首行写着「请勿手改」;要改就去改 `sdk/*.go` 的注释再重新生成。
- **不要在插件里硬编码 token / 密钥**:走 `Settings()` 或宿主注入。
- **不要为了让插件生效去改 `homed` 的源码** —— 先确认它是不是内置插件。

View File

@ -0,0 +1,247 @@
// 命令 annotate_parallel 按 SDK 声明风格为工具加并发安全声明。
//
// 风格要求(照 SDK 的 NoMemory 走,不自创):
//
// · 声明项是**结构体字段**(ParallelSafe / Serial),不是注释标记;
// · 插在 Parameters 之后、handler 之前 —— 即字面量的**末尾**,
// 与 SDK 里 NoMemory/ContextPolicy/RecallPolicy 的位置一致;
// · Name 保持在首位,不打散 gofmt 对齐。
//
// 为什么用括号深度定位插入点:之前用正则找"最后一个顶层字段",
// 会被嵌套 map 里的同形文本骗到,结果把声明插到 Parameters 中间,
// 甚至把文件改坏(823 处重排)。深度计数是唯一可靠的。
//
// 用法:annotate_parallel <file> <tool:kind:note> ...
//
// kind: parallel | serial
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "用法: annotate_parallel <file> <tool:kind:note>...")
os.Exit(2)
}
path := os.Args[1]
lines := readLines(path)
// 从后往前改,避免行号漂移
type job struct {
tool, kind, note string
}
var jobs []job
for _, arg := range os.Args[2:] {
p := strings.SplitN(arg, ":", 3)
if len(p) != 3 {
fmt.Fprintf(os.Stderr, "参数格式错: %q\n", arg)
os.Exit(2)
}
jobs = append(jobs, job{p[0], p[1], p[2]})
}
// 反序处理(同一文件里多个工具,位置互不影响,但保守起见从后往前)
for i := len(jobs) - 1; i >= 0; i-- {
j := jobs[i]
at, err := findInsertPoint(lines, j.tool)
if err != nil {
fmt.Fprintf(os.Stderr, " 跳过 %s: %v\n", j.tool, err)
continue
}
// 幂等:块内已有并发声明就跳过。
//
// 不加这条时,重跑会在已标注的工具上**再插一份** —— 而
// duplicate field name 是编译期错误,跨文件批量跑时定位成本很高。
if blockHasDecl(lines, j.tool) {
fmt.Printf(" %s 已有声明,跳过\n", j.tool)
continue
}
field := "ParallelSafe: true,"
if j.kind == "serial" {
field = "Serial: true,"
}
ins := []string{"\t\t// " + j.note, "\t\t" + field}
out := append([]string{}, lines[:at]...)
out = append(out, ins...)
out = append(out, lines[at:]...)
lines = out
fmt.Printf(" %s @line %d (%s)\n", j.tool, at+1, j.kind)
}
writeLines(path, lines)
}
// findInsertPoint 找到该 RegisterTool 字面量中,Parameters 闭合之后的位置。
func findInsertPoint(lines []string, tool string) (int, error) {
// 匹配两种注册形式:
// RegisterTool("get_article", ...) 字面量
// RegisterTool(tp+"get_article", ...) 变量前缀 + 字面量
// 只认字面量会漏掉后者 —— example 里绝大多数是变量前缀形式。
head := fmt.Sprintf(`RegisterTool("%s"`, tool)
alt := fmt.Sprintf(`+"%s"`, tool)
start := -1
for i, l := range lines {
if strings.Contains(l, head) {
start = i
break
}
}
if start < 0 {
// ★ 必须 RegisterTool( 与字面量在**同一行**。
//
// 我第一版只找含 `+"name"` 的行,命中了函数体里的散落字面量
// (vanblog 的 handleAuth 里满是 "restore"/"update" 这类 case 分支),
// 起点错到函数体中间,深度追踪再也回不到 2 ⇒ 插入点落在 1300+ 行,
// 把文件改坏。
//
// 症状离原因很远:报错说"expected 1 expression",指向的是一处
// 看起来完全正常的 case 分支。
for i, l := range lines {
if strings.Contains(l, alt) && strings.Contains(l, "RegisterTool(") {
start = i
break
}
}
}
if start < 0 {
return 0, fmt.Errorf("找不到 RegisterTool(%q)", tool)
}
// 从 RegisterTool( 开始做括号深度追踪,找到 ToolDef 字面量的闭合 "}," 行
depth := 0
started := false
enteredAt := -1
inStr := false
esc := false
for i := start; i < len(lines); i++ {
// peak = 本行内的峰值深度。
//
// 每行重置:它表示"这一行曾深入到多深",不是全程最大值 ——
// 全程最大值一旦到过 3 就永远是 3,"曾进入 Parameters"判据随之失效。
peak := depth
for _, ch := range lines[i] {
if esc {
esc = false
continue
}
if ch == '\\' && inStr {
esc = true
continue
}
if ch == '"' {
inStr = !inStr
continue
}
if inStr {
continue
}
switch ch {
case '(', '{', '[':
depth++
started = true
if depth > peak {
peak = depth
}
case ')', '}', ']':
depth--
}
}
// 插入点 = Parameters 字段的**闭合之后**。
//
// 精确定位法:Parameters 起始处的深度是 3(RegisterTool( → ToolDef{ →
// Parameters{);它的闭合就是深度**首次从 3 回到 2** 的那一行。
//
// ⚠️ 我第一版没有这样做,而是"找 required 行的下一行,没有就返回字面量
// 闭合行"。对于没有 required 的工具(如 config_list_plugins),
// 后者落在 properties{} 内部 —— 生成的代码是
// "properties": map[string]interface{}{},
// ParallelSafe: true, ← 跑到 map 里去了
// 编译报 undefined: ParallelSafe,症状离原因很远。
// 插入点 = Parameters 字段闭合的**下一行**。
//
// 深度:RegisterTool( =1, ToolDef{ =2, Parameters{ =3。
//
// ★ 两个坑都是"同一行内深度进出平衡"造成的:
//
// 1. 空 properties:`"properties": map[string]interface{}{},`
// 深度 3→2 在**同一行**完成。所以不能用"曾触及 3"作门控,
// 必须记住**行号**:进入 3 的那行之后,首个回到 2 的行才是闭合行。
//
// 2. Name:/Description: 本来就在深度 2,早于 Parameters。
// 只判 depth==2 会在 Name 行就返回,插入点跑到 RegisterTool 之前,
// 编译报 "expected 1 expression"。
// 判定"进入过 Parameters"要按**行内峰值深度**,不能只看行末净深度。
//
// get_meta 的 Parameters 全在一行:
// Parameters: map[string]interface{}{"type":"object","properties":map[string]interface{}{}},
// 这行净深度变化是 0(进去又出来)—— 只看净深就永远察觉不到曾进入
// 深度 3 ⇒ 追踪一路跑到 1305 行才"收敛",插入点落在某个 case 分支
// 中间,文件改坏。症状离原因很远:报错指向一处看起来完全正常的
// switch case。
if started && peak >= 3 {
enteredAt = i
}
// 闭合判定:进入过 Parameters(enteredAt)之后,深度回到 2 的那一行
// **就是** Parameters 的闭合行;插入点取它的**下一行**。
//
// ★ 不能要求 i > enteredAt:Parameters 写在单行时(get_meta 就是)
// enteredAt 与闭合行是**同一行**,加上这个条件会跳到再下一行,
// 插到 log.Printf 之前 —— 不报错,但声明落在了字面量外面。
if enteredAt >= 0 && i >= enteredAt && depth <= 2 {
return i + 1, nil
}
}
return 0, fmt.Errorf("括号深度追踪未收敛")
}
// blockHasDecl 报告该工具的字面量里是否已有并发声明。
func blockHasDecl(lines []string, tool string) bool {
head := fmt.Sprintf(`RegisterTool("%s"`, tool)
alt := fmt.Sprintf(`+"%s"`, tool)
start := -1
for i, l := range lines {
if strings.Contains(l, head) || (strings.Contains(l, alt) && strings.Contains(l, "RegisterTool(")) {
start = i
break
}
}
if start < 0 {
return false
}
// 从注册行往后找 30 行(工具定义不会更长)
for i := start; i < len(lines) && i <= start+30; i++ {
if strings.Contains(lines[i], "ParallelSafe:") || strings.Contains(lines[i], "Serial:") {
return true
}
}
return false
}
func readLines(p string) []string {
f, err := os.Open(p)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
out = append(out, sc.Text())
}
return out
}
func writeLines(p string, lines []string) {
var sb strings.Builder
for _, l := range lines {
sb.WriteString(l)
sb.WriteString("\n")
}
if err := os.WriteFile(p, []byte(sb.String()), 0644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

86
tools/apidoc/README.md Normal file
View File

@ -0,0 +1,86 @@
# 插件 SDK 文档站
用 MkDocs Material 构建的 SDK 文档站。**API 参考不是手写的** ——
它从 `sdk/*.go` 的源码注释生成,因为手抄必然与代码漂移。
## 目录结构
```
mkdocs.yml 站点配置(导航、主题、中文检索)
docs/
├── index.md ┐
├── versions.md │
├── guide/*.md ├─ 手写:指南、边界说明、版本
├── api/index.md │
├── javascripts/ │
│ └── api-search.js │ 自建 API 检索(按名称/描述/签名)
├── stylesheets/extra.css ┘
├── api/*.md ┐ 生成物 —— 勿手改
├── examples/index.md │ (build 时覆盖)
└── assets/api-index.json ┘
tools/apidoc/ 生成器(本仓 Go 代码,零外部依赖)
├── extract.go 从源码提取符号、注释、分层
├── tiers.go 应用能力分层(public / builtin / bridge)
├── tiers.json **能力边界的事实源**(每条附源码依据)
├── gensite/main.go 渲染 Markdown + 检索索引
├── gensite/usages.go 从 example/ 抽取真实调用点
└── build.sh 一键生成 + 构建
```
## 构建
```bash
tools/apidoc/build.sh # 生成 + 构建到 site_build/
tools/apidoc/build.sh serve # 本地预览(http://127.0.0.1:8000)
```
依赖:Go 1.21+、`mkdocs-material`(`pip install mkdocs-material`)、
`jieba`(中文检索分词,`pip install jieba`)。
## 两条设计原则
**① API 参考从源码生成。** 签名、说明、示例全部来自 `sdk/*.go` 的文档注释。
发现文档不对时,**改的是源码注释**,然后重新生成。生成页首行有「勿手改」标记。
**② 能力边界是可核对的事实,不是印象。** 哪些 API 外部插件拿不到,
逐条记在 `tools/apidoc/tiers.json`,每条都写清**可复核的依据**
(文件:行号、或 `grep` 结论)。判断标准是:
| 依据 | 含义 |
|---|---|
| `tools/hmapdev/templates/proc_main.go.tmpl` 的 `base.Set*` 调用 | 外部插件运行时**实际注入**哪些能力 |
| `internal/sdk` | 内置插件用的完整接口(对照出外部缺什么) |
| `internal/plugin/proc/protocol.go` | 外部插件**能发哪些 RPC** |
文档站上每条「仅内置」告警都带这个依据,读者可自行核对。
### 为什么这个边界值得单独维护
写这个站时,实测发现文档与源码有**三处不符**(现已在站内更正):
1. `PluginMgr()` 曾被写成「仅内置可用」——实际桥接**显式注入**了它。
真正的区别是方法数:公开面 3 个,内部面 9 个(两个包里同名不同接口)。
2. `Events()` 曾被当作可用的事件订阅入口——实际桥接**不注入** subscriber,
外部插件拿到的恒为 nil(`SetEventSubscriber` 全仓无调用点)。
外部插件的事件订阅实际由生成的运行时走 `events.subscribe` RPC 完成。
3. `UnregisterOutputChannel` 易被当成「可用但会报错」——实际返回 nil,
**静默无效**(桥不注入 unregister),不报错也不注销。
## 检索
站内有两套检索,互补:
- **MkDocs 内置搜索**(右上角):全文检索,中文走 jieba 分词。
- **自建 API 检索**(首页与 API 参考页的输入框):读 `assets/api-index.json`,
专门解决「**按描述找 API**」——搜「注册工具」能找到 `RegisterTool`,
搜「崩溃」能找到 `SetAutoRestart`,并可区分公开/仅内置。
自建检索支持四类查询:名称、描述(中英文)、`限定符.方法`
(如 `memory.recall`)、签名片段(如 `(string) error`)。
## 维护提示
- **改了 `sdk/*.go` 的注释或签名** → 重跑 `build.sh`,改动自动进文档。
- **改了能力边界** → 改 `tiers.json`,不要直接改生成的 `.md`。
- **新增示例插件** → 自动出现在「示例插件」页的用法表里(扫 `example/`)。
- `site_build/` 是构建产物,已 gitignore,不要提交。

62
tools/apidoc/build.sh Executable file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# 生成并构建插件 SDK 文档站。
#
# 四步:
# 1. apidoc —— 从 sdk/*.go 提取公开 API 面(签名/注释/分层)→ JSON
# 2. gensite —— 把 JSON 渲染成 docs/api/*.md + 检索索引 + llms.txt
# 3. mkdocs —— 构建静态站
# 4. copy_agent —— 把 Markdown 源搬进站点产物(mkdocs 只渲染 .md,不复制)
#
# 为什么要脚本而不是手敲:API 参考是**生成物**,必须与源码同步,
# 否则文档会悄悄过时(这是文档站最常见的死法)。
#
# 用法:
# tools/apidoc/build.sh # 生成 + 构建
# tools/apidoc/build.sh serve # 生成 + 本地预览(热重载)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
TMP_API="${TMPDIR:-/tmp}/homeagent-sdk-api.json"
SITE=site_build
# copy_agent_files 把 docs/ 下的 Markdown 原样复制进站点产物。
#
# 为什么必须复制:mkdocs 只把 .md **渲染**成 HTML,不会把它们放进产物目录。
# 但 agent 需要 Markdown 原文(省 token、不含主题样板),所以 llms.txt 里
# 指的 /api/tools.md 必须真实可访问。llms.txt 与 llms-full.txt 由 gensite 生成。
copy_agent_files() {
local n=0 rel dir
while IFS= read -r -d '' f; do
rel="${f#docs/}"
[ "${rel##*/}" = "README.md" ] && continue
dir="$(dirname "$rel")"
[ "$dir" != "." ] && mkdir -p "$SITE/$dir"
cp "$f" "$SITE/$rel"
n=$((n + 1))
done < <(find docs -name '*.md' -print0)
echo " 复制 $n 个 Markdown 到 $SITE/(供 agent 直读)"
}
echo "=== 1/4 提取 API 面 ==="
go run ./tools/apidoc -pkgdir ./sdk -out "$TMP_API"
echo "=== 2/4 渲染文档页、检索索引与 agent 入口 ==="
go run ./tools/apidoc/gensite -api "$TMP_API" -out ./docs -examples ./example
echo "=== 3/4 构建静态站 ==="
if [ "${1:-}" = "serve" ]; then
# 预览模式也要能取到 .md(agent 入口),故先构建一次再起服务。
mkdocs build --strict >/dev/null
copy_agent_files
exec mkdocs serve
fi
mkdocs build --strict
echo "=== 4/4 供 agent 直读的 Markdown ==="
copy_agent_files
echo
echo "完成。产物在 $SITE/,本地预览:tools/apidoc/build.sh serve"
echo "agent 入口:$SITE/llms.txt(目录)、$SITE/llms-full.txt(全文)"

427
tools/apidoc/extract.go Normal file
View File

@ -0,0 +1,427 @@
// Command apidoc 从 SDK 源码提取公开 API 面,输出 JSON 供文档站生成使用。
//
// 设计约束:
// - **只用标准库**(go/ast、go/parser、go/token)——不需要网络、不依赖
// golang.org/x/tools,clone 下来就能跑。
// - **只读源码**,不做 import 解析:它按文件解析 `sdk/*.go`,因此不必处于
// 任何 Go module 内,也不会把依赖带进 SDK 主 module。
// - 输出是文档站生成的**唯一事实源**:文档里的签名、注释、示例代码块
// 全部来自这里,不手抄,避免文档与源码漂移。
//
// 用法:
//
// go run ./tools/apidoc -pkgdir ./sdk -out /tmp/api.json
package main
import (
"encoding/json"
"flag"
"fmt"
"go/ast"
"go/doc"
"go/parser"
"go/printer"
"go/token"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Symbol 是一条 API 记录。
type Symbol struct {
Kind string `json:"kind"` // func / method / type / const / var
Recv string `json:"recv"` // 方法接收者(仅 method)
Name string `json:"name"` // 符号名
Signature string `json:"signature"` // 一行签名
Doc string `json:"doc"` // 文档注释(原文,含 markdown)
DocBrief string `json:"doc_brief"` // 首行摘要
File string `json:"file"`
Line int `json:"line"`
Group string `json:"group"` // 归属分组(由 groupFor 决定)
Exported bool `json:"exported"`
Examples []string `json:"examples"` // 注释里的 ```go 代码块
// Deprecated/Since 由注释里的标记提取,供文档打标。
Deprecated bool `json:"deprecated"`
// BuiltinOnly 标记「仅内核内置插件可用」——由 tiers.json 注入。
BuiltinOnly bool `json:"builtin_only"`
// Tier 是可见级别(public / builtin / bridge)——由 tiers.json 注入。
// 用显式字段而非从 TierReason 里找关键字判断:中文说明里「桥接」两字
// 在公开条目的理由里也会出现(“桥接模板注入…外部插件可用”),
// 靠 strings.Contains 判断会把公开 API 误标成装配点。
Tier string `json:"tier"`
TierReason string `json:"tier_reason"`
}
// Interface 是一个接口类型及其方法。
type Interface struct {
Name string `json:"name"`
Doc string `json:"doc"`
Methods []Symbol `json:"methods"`
}
// Package 是提取结果。
type Package struct {
ImportPath string `json:"import_path"`
Doc string `json:"doc"`
Symbols []Symbol `json:"symbols"`
Interfaces []Interface `json:"interfaces"`
// ConstGroups 保留源码里 const(...) 的分组结构。
ConstGroups []ConstGroup `json:"const_groups"`
SDKVersion string `json:"sdk_version"`
}
type ConstGroup struct {
Doc string `json:"doc"`
Consts []Symbol `json:"consts"`
}
var (
codeBlockRe = regexp.MustCompile("(?s)```(?:go|bash|json|)\n(.*?)```")
deprecatedRe = regexp.MustCompile(`(?i)\b(deprecated|已废弃|已弃用|即将移除)\b`)
)
func main() {
pkgdir := flag.String("pkgdir", "./sdk", "要解析的包目录")
out := flag.String("out", "-", "输出 JSON 路径,- 表示 stdout")
flag.Parse()
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, *pkgdir, func(fi os.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, parser.ParseComments)
if err != nil {
log.Fatalf("解析 %s 失败: %v", *pkgdir, err)
}
var result Package
for name, pkg := range pkgs {
result.ImportPath = name
// doc.New 会归并同名符号、抽取示例,并给出包级文档。
d := doc.New(pkg, name, doc.AllDecls)
result.Doc = strings.TrimSpace(d.Doc)
for _, f := range d.Funcs {
result.Symbols = append(result.Symbols, makeFunc(fset, f, *pkgdir))
}
for _, t := range d.Types {
result.Symbols = append(result.Symbols, makeType(fset, t, *pkgdir))
for _, m := range t.Methods {
result.Symbols = append(result.Symbols, makeMethod(fset, m, t.Name, *pkgdir))
}
if iface, ok := t.Decl.Specs[0].(*ast.TypeSpec).Type.(*ast.InterfaceType); ok {
result.Interfaces = append(result.Interfaces,
makeInterface(t, iface, fset, *pkgdir))
}
}
// const/var 用 Value 承载,按源码 const 块分组保留。
for _, v := range d.Consts {
result.Symbols = append(result.Symbols, makeValue(fset, v, "const", *pkgdir)...)
}
for _, v := range d.Vars {
result.Symbols = append(result.Symbols, makeValue(fset, v, "var", *pkgdir)...)
}
result.ConstGroups = groupConsts(fset, pkg, *pkgdir)
}
sort.Slice(result.Symbols, func(i, j int) bool {
if result.Symbols[i].Group != result.Symbols[j].Group {
return result.Symbols[i].Group < result.Symbols[j].Group
}
return result.Symbols[i].Name < result.Symbols[j].Name
})
for i := range result.Interfaces {
sort.Slice(result.Interfaces[i].Methods, func(a, b int) bool {
return result.Interfaces[i].Methods[a].Name < result.Interfaces[i].Methods[b].Name
})
}
if err := applyTiers(&result); err != nil {
log.Fatalf("应用能力分层失败: %v", err)
}
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Fatal(err)
}
if *out == "-" {
os.Stdout.Write(data)
return
}
if err := os.WriteFile(*out, append(data, '\n'), 0o644); err != nil {
log.Fatal(err)
}
fmt.Fprintf(os.Stderr, "提取 %d 个符号 → %s\n", len(result.Symbols), *out)
}
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func brief(doc string) string {
for _, line := range strings.Split(doc, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "//") {
return line
}
}
return ""
}
func examplesOf(doc string) []string {
var out []string
for _, m := range codeBlockRe.FindAllStringSubmatch(doc, -1) {
out = append(out, strings.TrimRight(m[1], "\n"))
}
return out
}
func finish(s *Symbol) Symbol {
s.Doc = strings.TrimSpace(s.Doc)
s.DocBrief = brief(s.Doc)
s.Examples = examplesOf(s.Doc)
s.Deprecated = deprecatedRe.MatchString(s.DocBrief)
s.Group = groupFor(s)
return *s
}
func makeFunc(fset *token.FileSet, f *doc.Func, pkgdir string) Symbol {
pos := fset.Position(f.Decl.Pos())
return finish(&Symbol{
Kind: "func",
Name: f.Name,
Signature: sigOf(fset, f.Decl),
Doc: f.Doc,
File: rel(pkgdir, pos.Filename),
Line: pos.Line,
Exported: ast.IsExported(f.Name),
})
}
func makeMethod(fset *token.FileSet, f *doc.Func, recv, pkgdir string) Symbol {
pos := fset.Position(f.Decl.Pos())
return finish(&Symbol{
Kind: "method",
Recv: recv,
Name: f.Name,
Signature: sigOf(fset, f.Decl),
Doc: f.Doc,
File: rel(pkgdir, pos.Filename),
Line: pos.Line,
Exported: ast.IsExported(f.Name),
})
}
func makeType(fset *token.FileSet, t *doc.Type, pkgdir string) Symbol {
pos := fset.Position(t.Decl.Pos())
return finish(&Symbol{
Kind: "type",
Name: t.Name,
Signature: "type " + t.Name + " " + typeShape(fset, t),
Doc: t.Doc,
File: rel(pkgdir, pos.Filename),
Line: pos.Line,
Exported: ast.IsExported(t.Name),
})
}
func makeValue(fset *token.FileSet, v *doc.Value, kind, pkgdir string) []Symbol {
// 一个 const/var 声明里可能有多组名字(如 CapText/CapFile/... 同块),
// 拆成多条——把名字用逗号拼成一条在文档里很难读,检索也搜不到。
var out []Symbol
for _, spec := range v.Decl.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
docText := strings.TrimSpace(vs.Doc.Text())
if docText == "" {
docText = strings.TrimSpace(v.Doc)
}
for _, n := range vs.Names {
if !ast.IsExported(n.Name) {
continue
}
pos := fset.Position(n.Pos())
out = append(out, finish(&Symbol{
Kind: kind,
Name: n.Name,
Signature: kind + " " + n.Name,
Doc: docText,
File: rel(pkgdir, pos.Filename),
Line: pos.Line,
Exported: true,
}))
}
}
return out
}
// makeInterface 从接口类型的 AST 直接读方法。
//
// 注意:不能用 doc.Type.Methods —— 那个字段只收集**具名类型的方法声明**
// (即 `func (x T) M()`),不含接口内嵌的方法列表。接口成员只能在 AST 的
// InterfaceType.Methods 里拿到。
func makeInterface(t *doc.Type, iface *ast.InterfaceType, fset *token.FileSet, pkgdir string) Interface {
it := Interface{Name: t.Name, Doc: strings.TrimSpace(t.Doc)}
for _, field := range iface.Methods.List {
if len(field.Names) == 0 {
// 内嵌接口(如 interface { io.Closer }):记为一条说明性条目。
pos := fset.Position(field.Pos())
embedded := oneLine(exprString(field.Type))
it.Methods = append(it.Methods, finish(&Symbol{
Kind: "embedded",
Recv: t.Name,
Name: embedded,
Signature: embedded,
Doc: strings.TrimSpace(field.Doc.Text()),
File: rel(pkgdir, pos.Filename),
Line: pos.Line,
Exported: true,
}))
continue
}
ft, ok := field.Type.(*ast.FuncType)
if !ok {
continue
}
pos := fset.Position(field.Pos())
sig := oneLine(exprString(ft))
for _, n := range field.Names {
if !ast.IsExported(n.Name) {
continue
}
full := name(n.Name) + strings.TrimPrefix(sig, "func")
it.Methods = append(it.Methods, finish(&Symbol{
Kind: "method",
Recv: t.Name,
Name: n.Name,
Signature: full,
Doc: strings.TrimSpace(field.Doc.Text()),
File: rel(pkgdir, pos.Filename),
Line: pos.Line,
Exported: true,
}))
}
}
_ = iface
return it
}
// exprString 用 go/printer 把 AST 节点还原成源码文本。
func exprString(n ast.Node) string {
var buf strings.Builder
if err := printer.Fprint(&buf, token.NewFileSet(), n); err != nil {
return "?"
}
return buf.String()
}
func name(s string) string { return s }
func sigOf(fset *token.FileSet, fn *ast.FuncDecl) string {
if fn.Type == nil {
return fn.Name.Name
}
// 用源码原文截取签名,保证与源码逐字一致(不重新格式化)。
start := fset.Position(fn.Pos()).Offset
end := fset.Position(fn.Type.End()).Offset
src, err := os.ReadFile(fset.Position(fn.Pos()).Filename)
if err == nil && start < end && end <= len(src) {
return oneLine(string(src[start:end]))
}
return fn.Name.Name
}
func typeShape(fset *token.FileSet, t *doc.Type) string {
spec, ok := t.Decl.Specs[0].(*ast.TypeSpec)
if !ok {
return "?"
}
start := fset.Position(spec.Type.Pos()).Offset
end := fset.Position(spec.Type.End()).Offset
src, err := os.ReadFile(fset.Position(spec.Type.Pos()).Filename)
if err != nil || start >= end || end > len(src) {
return "?"
}
raw := src[start:end]
// 结构体只保留第一行 + 字段数提示,完整字段在 API 页单独展开。
if len(raw) > 160 {
return oneLine(string(raw[:160])) + " …"
}
return oneLine(string(raw))
}
func rel(base, p string) string {
if r, err := filepath.Rel(base, p); err == nil {
return r
}
return p
}
// groupFor 把符号归到文档站的章节。规则集中在这里,避免散落。
func groupFor(s *Symbol) string {
switch {
case s.Recv == "PluginSDK" || strings.HasPrefix(s.Name, "PluginSDK"):
return "plugin-sdk"
case s.Recv == "StageContext":
return "stages"
case s.Kind == "const" || s.Kind == "var":
return "constants"
case s.Recv == "" && s.Kind == "func":
return "functions"
case s.Kind == "type":
return "types"
case s.Recv != "":
return "interfaces"
}
return "misc"
}
func groupConsts(fset *token.FileSet, pkg *ast.Package, pkgdir string) []ConstGroup {
var groups []ConstGroup
for _, f := range pkg.Files {
for _, decl := range f.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.CONST {
continue
}
g := ConstGroup{}
if gd.Doc != nil {
g.Doc = strings.TrimSpace(gd.Doc.Text())
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
var names []string
for _, n := range vs.Names {
if ast.IsExported(n.Name) {
names = append(names, n.Name)
}
}
if len(names) == 0 {
continue
}
pos := fset.Position(vs.Pos())
g.Consts = append(g.Consts, Symbol{
Kind: "const",
Name: strings.Join(names, ", "),
Doc: strings.TrimSpace(vs.Doc.Text()),
DocBrief: brief(strings.TrimSpace(vs.Doc.Text())),
File: rel(pkgdir, pos.Filename),
Line: pos.Line,
Exported: true,
Group: "constants",
})
}
if len(g.Consts) > 0 {
groups = append(groups, g)
}
}
}
return groups
}

View File

@ -0,0 +1,170 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// 给 agent 用的入口。
//
// 为什么需要:文档站是给**人**看的(HTML + 主题 + JS 搜索),但越来越多读者是
// agent —— 它们要的是「一次拿到结构化事实」,而不是渲染后的页面。让 agent 去
// 爬 HTML 既浪费 token(主题样板占大头)又容易漏内容。
//
// 因此额外产出三样东西:
//
// /llms.txt 站点的**目录**:每个页面一行,带 URL 与一句话说明
// /llms-full.txt 全部文档**正文**(Markdown)拼成一份,可一次读完
// /<page>.md 每个页面的 Markdown 原文(含生成的 API 页)
// /<page>.json 机器可读版(API 页有结构化符号)
//
// 约定沿用 llms.txt 社区规范(Jeremy Howard 提出):llms.txt 是给「读目录」
// 用的精简索引,llms-full.txt 是给「一次读全」用的大文件。
const llmsHeader = `# HomeAgent 插件 SDK
> 用 Go 或 Lua 为 HomeAgent 编写插件。插件跑在独立进程里,通过公开 SDK 与内核通信:
> 注册工具供模型调用、挂阶段钩子干预流程、读写三层记忆、注册输入输出通道、订阅事件。
>
> SDK 以 MIT 发布(插件可闭源、可商用,无需回馈)。内核本身是 AGPL-3.0-only。
>
> 本文件是给 agent 的入口。下列每个链接都是**纯 Markdown 正文**,可直接读,
> 不含 HTML 样板;也可以直接取 %s 一次读完全部文档。
`
// writeAgentEntrypoints 产出 llms.txt 与 llms-full.txt,并把每个页面同时写成
// `.md`(Markdown 原文)。返回写出的页面数。
//
// 注意:mkdocs 只会把 `.md` 渲染成 HTML,不会把它们复制到站点产物里。
// 所以这里除了写 docs/,构建后还要把 Markdown 副本搬进 site_build/
// (见 build.sh 的 copy_agent_files)。
func writeAgentEntrypoints(docsDir, siteDir string) (int, error) {
type entry struct {
rel string // 相对 docs/ 的路径,如 api/tools.md
title string
desc string
}
var entries []entry
err := filepath.Walk(docsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".md") {
return nil
}
if filepath.Base(path) == "README.md" {
return nil
}
rel, _ := filepath.Rel(docsDir, path)
rel = filepath.ToSlash(rel)
body, err := os.ReadFile(path)
if err != nil {
return nil
}
title, desc := firstHeadingAndDesc(string(body))
entries = append(entries, entry{rel: rel, title: title, desc: desc})
return nil
})
if err != nil {
return 0, err
}
// 排序:首页最前,其余按路径。
sort.Slice(entries, func(i, j int) bool {
if entries[i].rel == "index.md" {
return true
}
if entries[j].rel == "index.md" {
return false
}
return entries[i].rel < entries[j].rel
})
base := "https://sdk.homeagent.jianfgit.xyz"
var idx strings.Builder
fmt.Fprintf(&idx, llmsHeader, base+"/llms-full.txt")
for _, e := range entries {
// URL 就是 .md 的落地路径(构建后把 docs/**/*.md 复制进站点产物)。
// 不要把 index.md 改成 index/ —— 那样指向的是 HTML 页而不是 Markdown 源。
mdURL := base + "/" + e.rel
fmt.Fprintf(&idx, "- [%s](%s)", e.title, mdURL)
if e.desc != "" {
fmt.Fprintf(&idx, ": %s", e.desc)
}
idx.WriteString("\n")
}
if err := os.WriteFile(filepath.Join(docsDir, "llms.txt"), []byte(idx.String()), 0o644); err != nil {
return 0, err
}
var full strings.Builder
fmt.Fprintf(&full, "# HomeAgent 插件 SDK — 完整文档\n\n")
full.WriteString("(本文件由 tools/apidoc/gensite 从 docs/ 汇总生成,供 agent 一次读取。)\n\n")
full.WriteString("---\n\n")
for _, e := range entries {
body, err := os.ReadFile(filepath.Join(docsDir, e.rel))
if err != nil {
continue
}
fmt.Fprintf(&full, "\n\n## <%s>\n\n", e.rel)
full.Write(body)
full.WriteString("\n")
}
fullPath := filepath.Join(docsDir, "llms-full.txt")
if err := os.WriteFile(fullPath, []byte(full.String()), 0o644); err != nil {
return 0, err
}
// 把 llms.txt / llms-full.txt 也复制进站点产物(mkdocs 不搬运非 md 页面)。
// 各页面的 .md 副本由 build.sh 统一复制——那时 docs/ 已经定稿。
if siteDir != "" {
if err := os.MkdirAll(siteDir, 0o755); err == nil {
_ = copyFile(filepath.Join(docsDir, "llms.txt"), filepath.Join(siteDir, "llms.txt"))
_ = copyFile(fullPath, filepath.Join(siteDir, "llms-full.txt"))
}
}
return len(entries), nil
}
// firstHeadingAndDesc 取首个 `# 标题` 与紧随其后的第一段(作一句话说明)。
func firstHeadingAndDesc(body string) (title, desc string) {
lines := strings.Split(body, "\n")
for i, l := range lines {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, "# ") && title == "" {
title = strings.TrimSpace(strings.TrimPrefix(l, "# "))
// 往下找第一段非空、非标题、非注释、非命令的文本。
for j := i + 1; j < len(lines); j++ {
t := strings.TrimSpace(lines[j])
if t == "" || strings.HasPrefix(t, "#") ||
strings.HasPrefix(t, "<!--") || strings.HasPrefix(t, "```") ||
strings.HasPrefix(t, "!!!") || strings.HasPrefix(t, "|") {
continue
}
// 去掉行内标记,截断到一句话。
d := strings.NewReplacer("**", "", "`", "", "\\", "").Replace(t)
if idx := strings.IndexAny(d, "。."); idx > 0 {
d = d[:idx]
}
return title, d
}
}
}
return title, ""
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}

View File

@ -0,0 +1,94 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// 中文检索关键词。
//
// 问题:SDK 里 100 个有摘要的符号中 **66 个是英文注释**
// (`RegisterTool registers a tool that the LLM can call.`),
// 于是搜「注册工具」找不到 RegisterTool —— 而受众主要是中文。
//
// 解法不是翻译源码注释(那会让代码与文档脱节),而是给检索索引补一层
// **人工标注的功能词**:不影响页面展示,只让中文能搜到。
//
// 词表在 tools/apidoc/keywords.json。规则用「前缀 → 词」批量覆盖
// (Register* 全带「注册」),少数重点符号再逐条补充。
type keywordTable struct {
Rules []keywordRule `json:"rules"`
Symbols map[string][]string `json:"symbols"`
}
type keywordRule struct {
// Prefix 匹配符号名前缀;Contains 匹配名字里是否含该子串。二选一。
Prefix string `json:"prefix,omitempty"`
Contains string `json:"contains,omitempty"`
Words []string `json:"words"`
}
// loadKeywords 读词表。找不到就返回空表(不报错中断)——
// 检索关键词是**增强**,缺失时退化为原行为,不该让构建失败。
func loadKeywords() (*keywordTable, error) {
path := keywordPath()
data, err := os.ReadFile(path)
if err != nil {
return &keywordTable{}, err
}
var t keywordTable
if err := json.Unmarshal(data, &t); err != nil {
return &keywordTable{}, fmt.Errorf("解析 %s: %w", path, err)
}
return &t, nil
}
// lookup 返回某符号的中文检索词(可能为空)。
func (t *keywordTable) lookup(name, docBrief string) []string {
if t == nil {
return nil
}
seen := map[string]bool{}
var out []string
add := func(ws []string) {
for _, w := range ws {
w = strings.TrimSpace(w)
if w == "" || seen[w] {
continue
}
seen[w] = true
out = append(out, w)
}
}
for _, r := range t.Rules {
if r.Prefix != "" && strings.HasPrefix(name, r.Prefix) {
add(r.Words)
}
if r.Contains != "" && strings.Contains(name, r.Contains) {
add(r.Words)
}
}
add(t.Symbols[name])
return out
}
// keywordPath 找 keywords.json:可执行文件旁 → 源码目录 → 工作目录。
func keywordPath() string {
candidates := []string{}
if exe, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "keywords.json"))
}
candidates = append(candidates,
filepath.Join("tools", "apidoc", "keywords.json"),
"keywords.json",
)
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return candidates[0]
}

Some files were not shown because too many files have changed in this diff Show More