5 Commits

Author SHA1 Message Date
5d14afa8d6 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 确认送达。

(cherry picked from commit 4852d70d77)
2026-09-13 16:03:24 +08:00
87241bc4ff docs(sdk): 同步 1.3.0 能力说明与发版口径到发布线
发布线是 SDK 1.3.0 的产物来源,README 停在内核 1.2.0 的口径会让插件作者
按过期说明写代码。只捡文档,不动本线 meta.Version(这里必须恒为 1.3.0)。
2026-09-13 14:40:35 +08:00
535922c6a0 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:17:06 +08:00
fe66f72b9c 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:09 +08:00
0a4eb76542 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:07:07 +08:00
178 changed files with 4318 additions and 17767 deletions

View File

@ -1,251 +0,0 @@
# 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 github.com/JianFeeeee/homeagentsdk/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

View File

@ -1,314 +0,0 @@
# 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,13 +35,3 @@ 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

674
LICENSE
View File

@ -1,21 +1,661 @@
MIT License GNU AFFERO GENERAL PUBLIC 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
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy Preamble
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The GNU Affero General Public License is a free, copyleft license for
copies or substantial portions of the Software. software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR The licenses for most software and other practical works are designed
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, to take away your freedom to share and change the works. By contrast,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE our General Public Licenses are intended to guarantee your freedom to
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER share and change all versions of a program--to make sure it remains free
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, software for all its users.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
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/>.

View File

@ -2,15 +2,6 @@
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.3.0**(需内核 **1.3.0+**)。 当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
@ -346,24 +337,16 @@ 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),从
下载后加入 PATH 即可: [Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
> ⚠️ **二进制的实际分发地址目前是 gitcode**(两个仓的 release 附件不同步):
> `hmapdev_linux_amd64` 等 5 个平台二进制 + `SHA256SUMS` 在
> <https://github.com/JianFeeeee/homeagentsdk/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 附件下载(以 linux amd64 为例,<版本> 如 v1.3.0) # 从 release 附件下载(以最新 SDK 发布 / linux amd64 为例)
# 当前实际分发地址是 gitcode(见下方说明): curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<版本>/hmapdev_linux_amd64
curl -Lo hmapdev https://github.com/JianFeeeee/homeagentsdk/releases/download/<版本>/hmapdev_linux_amd64
chmod +x hmapdev chmod +x hmapdev
# 或从源码自己编 # 或从源码自己编
@ -504,18 +487,6 @@ enabled := sdk.AutoRestart()
插件崩溃时平台自动拉起,保障服务可用性。 插件崩溃时平台自动拉起,保障服务可用性。
重启是**有节制的**,默认参数(内核 `internal/plugin/registry.go`):
| 参数 | 值 | 含义 |
|---|---|---|
| `procRestartBackoff` | `1s` | 第 n 次重启前等 `n × 1s`(线性退避,非立即拉起) |
| `procMaxRestarts` | `3` | 窗口内允许的重启次数上限 |
| `procCrashWindow` | `5min` | 窗口内无新崩溃则计数归零 |
即崩溃后的实际序列是 **1s → 2s → 3s**;同一 5 分钟窗口内第 **4** 次崩溃
(`n > 3`)**不再自动拉起**,交人工介入。这不是「立即无感恢复」——
如果插件需要秒级就位,请自己在 `OnStart` 里做好重连与重建。
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立 > ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。 > 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态, > **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
@ -973,30 +944,14 @@ 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 以 **MIT** 发布,全文见 [LICENSE](LICENSE)。 SDK 以 **AGPL-3.0-only** 发布,全文见 [LICENSE](LICENSE)。
**这是刻意的宽松**:SDK 会随插件一起**静态链接**(其源码进入插件二进制), **这对插件开发者是实质性约束**:SDK 会随插件一起**静态链接**(其源码进入插件二进制),
若用 AGPL 之类的传染许可,插件作者就会被强制以其对外开源。选 MIT 就是为了 插件因此是本 SDK 的衍生作品,**必须以相同许可(AGPL-3.0-only)发布**;并且因为 AGPL §13
让插件作者**自由选择自己的许可**——闭源、商业、私有均可,无需向本项目回馈, 覆盖网络交互,通过 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,16 +2,6 @@
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.3.0** (requires kernel **1.3.0+**). Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
@ -338,7 +328,7 @@ Plugin developers only need to implement the `Plugin` interface and export a `Ne
`hmapdev` provides full development workflow support and produces `.hmap` plugin bundles (the tool is `hmapdev` provides full development workflow support and produces `.hmap` plugin bundles (the tool is
named after that package format). Prebuilt binaries ship as **release assets** named after that package format). Prebuilt binaries ship as **release assets**
(linux/darwin/windows × amd64/arm64); download from (linux/darwin/windows × amd64/arm64); download from
[Releases](https://github.com/JianFeeeee/homeagentsdk/releases) and put it on your PATH: [Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
> Rename note: the toolchain was called `plugindev` and is `hmapdev` since 1.2.0. > Rename note: the toolchain was called `plugindev` and is `hmapdev` since 1.2.0.
> The SDK store moved from `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk` > The SDK store moved from `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk`
@ -346,7 +336,7 @@ named after that package format). Prebuilt binaries ship as **release assets**
```bash ```bash
# From release assets (latest SDK release / linux amd64 shown) # From release assets (latest SDK release / linux amd64 shown)
curl -Lo hmapdev https://github.com/JianFeeeee/homeagentsdk/releases/download/<version>/hmapdev_linux_amd64 curl -Lo hmapdev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/<version>/hmapdev_linux_amd64
chmod +x hmapdev chmod +x hmapdev
# Or build from source # Or build from source
@ -453,19 +443,6 @@ 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
@ -859,18 +836,14 @@ Or upload via the WebUI plugin management page, or manually place the `.hmap` in
## License ## License
The SDK is released under the **MIT license** — see [LICENSE](LICENSE). The SDK is released under **AGPL-3.0-only** — see [LICENSE](LICENSE).
**This permissiveness is deliberate**: the SDK is **statically linked** into your plugin **This is a substantive constraint for plugin developers**: the SDK is **statically linked** into
(its source ends up in the plugin binary). Under a copyleft license such as AGPL that would your plugin (its source ends up in the plugin binary), so the plugin is a derivative work of
force plugin authors to open-source their work; MIT exists precisely so that plugin authors this SDK and **must be released under the same license**. Because AGPL §13 covers network
can **pick their own license** — closed-source, commercial or private — with no obligation to interaction, a plugin that serves users over HTTP/WebSocket must also offer them the source.
contribute back and no need for any exception or commercial grant. The safety and vitality of If you need a closed-source plugin, the only compliant route is a separate exception/commercial
the third-party plugin ecosystem rest on this. license from this project — none is offered today.
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

View File

@ -1,204 +0,0 @@
<!-- 本页由 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>

View File

@ -1,79 +0,0 @@
<!-- 本页由 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()
```

View File

@ -1,650 +0,0 @@
<!-- 本页由 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>

View File

@ -1,86 +0,0 @@
<!-- 本页由 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 鉴权,直接把请求转发给上游。 |

View File

@ -1,69 +0,0 @@
<!-- 本页由 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>

View File

@ -1,39 +0,0 @@
# 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):
它说明哪些能力外部插件有、哪些没有,以及**为什么**。

View File

@ -1,209 +0,0 @@
<!-- 本页由 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>

View File

@ -1,50 +0,0 @@
<!-- 本页由 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>

View File

@ -1,383 +0,0 @@
<!-- 本页由 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>

File diff suppressed because it is too large Load Diff

View File

@ -1,197 +0,0 @@
<!-- 本页由 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>

View File

@ -1,154 +0,0 @@
<!-- 本页由 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>

View File

@ -1,77 +0,0 @@
<!-- 本页由 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>

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,28 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,60 +0,0 @@
<!-- 本页由 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

@ -1,75 +0,0 @@
# 能力边界:哪些 API 外部插件能用
HomeAgent 有两类插件:
| 类型 | 说明 | 分发 |
|---|---|---|
| **外部插件** | 第三方开发,编译成 `.hmap` 后安装 | 独立分发,**可闭源** |
| **内置插件** | 编译进内核,`init()` 自注册 | 随内核发行,需合入主仓 |
SDK 包是**同一个** `github.com/JianFeeeee/homeagentsdk/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

@ -1,111 +0,0 @@
# 第一个 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 版参考实现

View File

@ -1,162 +0,0 @@
# 第一个 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"
"github.com/JianFeeeee/homeagentsdk/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

@ -1,68 +0,0 @@
# 环境与工具链
`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://github.com/JianFeeeee/homeagentsdk/releases`。
> Go 模块路径仍是 `github.com/JianFeeeee/homeagentsdk` —— 这是有意保留的,
> 改模块路径会让现有插件的 `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

@ -1,56 +0,0 @@
# 多平台构建
## 默认就是多平台
`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)

View File

@ -1,114 +0,0 @@
# 打包与发布
`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

@ -1,112 +0,0 @@
# 工具并发声明:`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` 相关)

View File

@ -1,113 +0,0 @@
# 场景记忆(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` —— 常量与校验

View File

@ -1,81 +0,0 @@
# 受限 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

@ -1,108 +0,0 @@
# 流式多 `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` —— 压测数据与协议族清单

View File

@ -1,49 +0,0 @@
# 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

@ -1,264 +0,0 @@
/*
* 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();
}
})();

View File

@ -1,36 +0,0 @@
# 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 位恒为

View File

@ -1,123 +0,0 @@
/* 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;
}

View File

@ -1,77 +0,0 @@
# 版本与兼容
## 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`,
每条裁定都附源码依据 —— 改这里而不是改生成物。

View File

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

View File

@ -2,6 +2,6 @@ module a2a
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,2 +1,2 @@
github.com/JianFeeeee/homeagentsdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiwm/Ozrm9AOg= gitcode.com/JianFeeeee/homeagent-sdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiwm/Ozrm9AOg=
github.com/JianFeeeee/homeagentsdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo= gitcode.com/JianFeeeee/homeagent-sdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo=

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -12,7 +12,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Plugin struct { type Plugin struct {

View File

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

View File

@ -2,6 +2,6 @@ module acp
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -13,7 +13,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集: // acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:

View File

@ -1,34 +1,13 @@
# 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.

View File

@ -2,6 +2,6 @@ module ai_image
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -13,7 +13,7 @@ import (
"strings" "strings"
"time" "time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Plugin struct { type Plugin struct {

View File

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

View File

@ -2,6 +2,6 @@ module bili
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -2,59 +2,21 @@ 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"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Plugin struct { 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 }
@ -105,24 +67,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
return nil return nil
} }
// Stop 取消并等待正在跑的下载。 func (p *Plugin) Stop() error { return nil }
//
// 空实现的代价(实测):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"`
@ -174,20 +119,11 @@ 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}
ctx, done := p.trackRun() cmd := exec.Command("yt-dlp", ytdlpArgs...)
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()))
} }
@ -273,16 +209,12 @@ 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.CommandContext(ctx, "yt-dlp", dlArgs...) cmd2 := exec.Command("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()))
} }
@ -324,15 +256,6 @@ 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 != "" {

View File

@ -1,66 +0,0 @@
# browser · 统一浏览器
一个插件覆盖三种"访问网页"的能力,从最轻到最重。**按需选层**是这个插件的重点 ——
绝大多数抓取用 HTTP 就够,不该为了一句话启动 Chromium。
## 三种能力层
| 层 | 工具 | 何时用 |
|---|---|---|
| **搜索** | `browser_search` | 要的是"找到哪些页面",不是页面本身 |
| **quick(纯 HTTP)** | `browser_fetch`(`mode=quick`) | 静态页、API、能直接拿到 HTML |
| **normal(无头渲染)** | `browser_render` / `browser_fetch`(`mode=render`) | JS 渲染的页面,HTTP 拿不到内容 |
| **interactive(CDP)** | `browser_start` + `navigate`/`click`/`type`/`scroll`/`html`/`screenshot` | 需要交互:登录、点按、翻页 |
`browser_fetch` 的 `mode`:
- `auto`(默认):先试 HTTP,**遇 403/429 才降级**用 Chromium 渲染
- `render`:强制 Chromium
- `quick`:纯 HTTP,不降级
## 工具
| 工具 | 说明 |
|---|---|
| `browser_search` | 网页搜索 |
| `browser_fetch` | 抓取 URL 内容,三种 mode 见上 |
| `browser_render` | 无头 Chromium 渲染并提取文本(normal) |
| `browser_start` | 启动交互式浏览器会话(CDP) |
| `browser_navigate` | 导航到指定 URL |
| `browser_click` | 点击元素 |
| `browser_type` | 输入文本 |
| `browser_scroll` | 滚动页面 |
| `browser_html` | 取当前页 HTML |
| `browser_screenshot` | 截图,**保存为 PNG 并返回路径**(拿 path 调 `describe_image` / `ocr_image`) |
| `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

@ -3,7 +3,7 @@ module browser
go 1.25.0 go 1.25.0
require ( require (
github.com/JianFeeeee/homeagentsdk v0.0.0 gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
github.com/chromedp/chromedp v0.9.5 github.com/chromedp/chromedp v0.9.5
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732
github.com/chromedp/sysutil v1.0.0 github.com/chromedp/sysutil v1.0.0
@ -17,10 +17,10 @@ require (
golang.org/x/sys v0.16.0 golang.org/x/sys v0.16.0
) )
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -22,7 +22,7 @@ import (
"time" "time"
"unicode" "unicode"
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
) )
@ -42,24 +42,6 @@ type Plugin struct {
stopOnce sync.Once stopOnce sync.Once
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用 profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用
// screenshotsDir 是截图落盘目录(<data>/browser_screenshots)。
//
// ★ 为何要落盘而不是直接回 base64(2026-10-10 修):
//
// 工具结果是 JSON 文本,base64 PNG 会整段进模型上下文。
// 实测一次视口截图 100-500KB ⇒ base64 后 137-685KB
// (约 3-17 万 token),而模型**看不见**这些字符里的图。
//
// 更糟的是它为每个消费方制造了同一道重复劳动:生产日志里 2026-10-10
// 那轮 agent 调 browser_screenshot 后紧跟着 cmd_run(自己 base64 解码
// 写文件),最后才 describe_image —— 它在**绕开**这个设计,
// 而那一轮跑了 920 秒。
//
// 内核早已有约定:describe_image 的 path 参数描述写着
// 「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传
// file 路径)」。本插件返回路径才是合上这个约定。
screenshotsDir string
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir, // 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir,
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个 // 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在 // 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
@ -249,11 +231,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.client = newHTTPClient(p.timeout, p.proxy) p.client = newHTTPClient(p.timeout, p.proxy)
// 持久化 profile 根目录:<data>/browser_profiles // 持久化 profile 根目录:<data>/browser_profiles
// 截图落盘目录:<data>/browser_screenshots
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil { if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
if s2, ok := dd.(string); ok && s2 != "" { if s2, ok := dd.(string); ok && s2 != "" {
p.profilesDir = filepath.Join(s2, "browser_profiles") p.profilesDir = filepath.Join(s2, "browser_profiles")
p.screenshotsDir = filepath.Join(s2, "browser_screenshots")
} }
} }
@ -340,16 +320,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"screenshot", sdk.ToolDef{ s.RegisterTool(tp+"screenshot", sdk.ToolDef{
Name: tp + "screenshot", Name: tp + "screenshot",
Description: "对交互式浏览器当前页面截图,**保存为 PNG 文件并返回绝对路径**。" + Description: "对交互式浏览器当前页面截图。返回 base64 编码的 PNG 图片。",
"要看图请拿返回的 path 调 describe_image(path=...);要提取文字调 ocr_image(path=...)。" +
"(不再直接返回 base64——那样会把十几万 token 灌进上下文而你看不见图。)",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"}, "full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"},
"format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"}, "format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"},
"inline": map[string]interface{}{"type": "boolean", "description": "可选:额外返回 base64/data_uri(默认 false)。仅当调用方拿不到文件系统时才需要;带上会把大段 base64 灌进上下文"},
}, },
"required": []string{"id"}, "required": []string{"id"},
}, },
@ -1165,72 +1142,14 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
if err != nil { if err != nil {
return errResult("screenshot failed: " + err.Error()), nil return errResult("screenshot failed: " + err.Error()), nil
} }
b64 := base64.StdEncoding.EncodeToString(buf)
// ★ 默认落盘并返回**路径**(2026-10-10 改)。 return map[string]interface{}{
//
// 为何不改回直接回 base64:那是把「解码 + 写文件」这件重复劳动
// 推给每一个消费方,而模型自己做不到(它看不见 base64 里的图)。
// 生产日志实证过 agent 的绕行链路:browser_screenshot → cmd_run 自己
// 解码写盘 → describe_image。
path, saveErr := p.saveScreenshot(buf, format, id)
if saveErr != nil {
// 落盘失败时不静默退回 base64:那会让「上下文被灌爆」这件事
// 在磁盘满/权限错时重新出现,而且没有迹象。
return errResult("screenshot saved failed: " + saveErr.Error()), nil
}
res := map[string]interface{}{
"status": "ok", "status": "ok",
"format": format, "format": format,
"size": len(buf), "size": len(buf),
// path 是**主要返回值**:模型应拿它去调 describe_image / ocr_image, "base64": b64,
// 或交给 files_* / cmd_run 做后续处理。 "data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
"path": path, }, nil
// hint 直接写在结果里:工具描述模型可能漏看,结果里的提示更近。
"hint": "截图已保存。要看图请调 describe_image(path=...);" +
"要 OCR 请调 ocr_image(path=...)。不要再自己解码 base64。",
}
// inline=true 时才额外带 base64:只有确实拿不到文件系统
// (如前端要把图直接嵌进消息)的少数场景才该用。
if v, ok := args["inline"].(bool); ok && v {
b64 := base64.StdEncoding.EncodeToString(buf)
res["base64"] = b64
res["data_uri"] = fmt.Sprintf("data:image/png;base64,%s", b64)
}
return res, nil
}
// saveScreenshot 把截图写入 <data>/browser_screenshots/,返回**绝对路径**。
//
// 为何文件名带时间戳与会话 id:同一会话连续截图是常态(生产日志里一轮
// 内截了 3 次),固定文件名会互相覆盖,而 agent 很可能还想回看前一张。
// 时间戳用 `20060102-150405.000`(毫秒):同秒内多次截图也不撞名。
func (p *Plugin) saveScreenshot(buf []byte, format, sessionID string) (string, error) {
dir := p.screenshotsDir
if dir == "" {
// 未注入 data_dir(如测试环境)时退到临时目录,而不是报错:
// 截图本身是成功的,只该降级存储位置。
dir = filepath.Join(os.TempDir(), "homeagent-browser-screenshots")
}
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("创建截图目录 %s: %w", dir, err)
}
// 会话 id 由调用方传入,可能含路径分隔符 —— 过滤掉,
// 否则 `../` 能把截图写出目录外。
safeID := strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == 0 {
return '_'
}
return r
}, sessionID)
name := fmt.Sprintf("shot-%s-%s.%s",
time.Now().Format("20060102-150405.000"), safeID, format)
full := filepath.Join(dir, name)
if err := os.WriteFile(full, buf, 0644); err != nil {
return "", fmt.Errorf("写入 %s: %w", full, err)
}
return full, nil
} }
func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {

View File

@ -1,235 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 截图必须**落盘并返回路径**,而不是把 base64 塞进工具结果。
//
// # 为何这条判据重要(2026-10-10)
//
// 工具结果是 JSON 文本,base64 PNG 会整段进模型上下文,而模型**看不见**
// 这些字符里的图。实测一次视口截图 100-500KB ⇒ base64 后 137-685KB
// (约 3-17 万 token),纯粹是上下文浪费。
//
// 更关键的是它把「解码 + 写文件」这件重复劳动推给了每个消费方,
// 而模型做不到。生产日志实证过 agent 的绕行链路:
//
// browser_screenshot → cmd_run(自己 base64 解码写盘)→ describe_image
//
// 那一轮跑了 920 秒。内核早有对应约定:describe_image 的 path 参数描述写着
// 「★处理设备/工具回传的媒体时必须传(screensee / camerasue 会回传 file 路径)」。
func TestSaveScreenshotWritesFileAndReturnsPath(t *testing.T) {
dir := t.TempDir()
p := &Plugin{name: "browser", screenshotsDir: dir}
// 造一个最小 PNG 头(内容不重要,测的是落盘行为)
data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3}
path, err := p.saveScreenshot(data, "png", "sess-1")
if err != nil {
t.Fatalf("saveScreenshot: %v", err)
}
// ① 返回的必须是**绝对路径**且指向真实存在的文件
if !filepath.IsAbs(path) {
t.Errorf("返回的应是绝对路径(模型要拿它调 describe_image),实为 %q", path)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("截图的路径不可读(%s): %v", path, err)
}
if string(got) != string(data) {
t.Errorf("落盘内容与截图字节不一致:%d vs %d", len(got), len(data))
}
// ② 路径必须在 screenshotsDir 内
if !strings.HasPrefix(path, dir+string(filepath.Separator)) {
t.Errorf("截图应落在 %s 内,实为 %q", dir, path)
}
// ③ 文件名要能看出是截图且带 png 后缀
if !strings.HasSuffix(path, ".png") {
t.Errorf("文件名应带 .png 后缀,实为 %q", path)
}
if !strings.Contains(filepath.Base(path), "shot-") {
t.Errorf("文件名应可识别为截图,实为 %q", filepath.Base(path))
}
}
// 同一会话连续截图不能互相覆盖。
//
// 为何专门测:生产日志里**一轮之内**就调了 3 次 browser_screenshot,
// 而 agent 很可能还想回看前一张。固定文件名会静默覆盖。
func TestSaveScreenshotDoesNotOverwrite(t *testing.T) {
dir := t.TempDir()
p := &Plugin{name: "browser", screenshotsDir: dir}
first, err := p.saveScreenshot([]byte("AAA"), "png", "sess-1")
if err != nil {
t.Fatal(err)
}
// 确保时间戳至少差 1ms(文件名精度是毫秒)
time.Sleep(2 * time.Millisecond)
second, err := p.saveScreenshot([]byte("BBB"), "png", "sess-1")
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatalf("两次截图写到了同一个文件(%s)—— 前一张被覆盖", first)
}
b1, _ := os.ReadFile(first)
if string(b1) != "AAA" {
t.Errorf("第一次截图被覆盖了:%q", b1)
}
}
// 会话 id 不能把截图写出目录外(它来自模型,且被拼进路径)。
func TestSaveScreenshotRejectsPathTraversalInSessionID(t *testing.T) {
dir := t.TempDir()
p := &Plugin{name: "browser", screenshotsDir: dir}
for _, evil := range []string{"../../etc/evil", "a/../../b", `win\..\..\x`} {
path, err := p.saveScreenshot([]byte("X"), "png", evil)
if err != nil {
t.Fatalf("saveScreenshot(%q): %v", evil, err)
}
// 关键:解析后仍必须在 dir 内
abs, _ := filepath.Abs(path)
base, _ := filepath.Abs(dir)
if !strings.HasPrefix(abs, base+string(filepath.Separator)) {
t.Errorf("会话 id %q 让截图写到了目录外:%s(应在 %s 内)", evil, abs, base)
}
}
}
// 未注入 data_dir 时应降级到临时目录,而不是让截图整条失败。
//
// 理由:截图本身是成功的,只有存储位置该降级。
// 直接报错会把「环境没配」变成「功能不可用」。
func TestSaveScreenshotFallsBackWhenDirUnset(t *testing.T) {
p := &Plugin{name: "browser"} // screenshotsDir 为空
path, err := p.saveScreenshot([]byte("X"), "png", "s1")
if err != nil {
t.Fatalf("未注入目录时应降级到临时目录,实为报错: %v", err)
}
defer os.Remove(path)
if !filepath.IsAbs(path) {
t.Errorf("降级路径也应是绝对路径,实为 %q", path)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("降级路径的文件不存在: %v", err)
}
}
// 工具描述必须告诉模型「返回路径 + 拿 path 去 describe_image」。
//
// 为何钉描述而不只钉实现:模型是靠描述决定怎么用工具的。
// 描述若退回「返回 base64」,模型就会又去自己解码。
func TestScreenshotToolDescriptionMentionsPath(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, `tp+"screenshot"`)
if i < 0 {
t.Fatal("找不到 screenshot 工具注册")
}
seg := s[i:]
if end := strings.Index(seg, "}, p.handleScreenshot)"); end > 0 {
seg = seg[:end]
}
if !strings.Contains(seg, "path") {
t.Error("工具描述必须提到返回 path(模型靠描述决定怎么用工具)")
}
if !strings.Contains(seg, "describe_image") {
t.Error("工具描述应指明拿 path 去调 describe_image —— " +
"不写的话模型不知道下一步该做什么")
}
if strings.Contains(seg, "返回 base64 编码的 PNG 图片") {
t.Error("工具描述退回了「返回 base64」,与实现不符(实现已改为落盘 + 返回路径)")
}
}
// handleScreenshot 的**返回结构**必须是 path 而不是 base64。
//
// # 为何用源码断言而不是调用它
//
// handleScreenshot 需要真实的 chromedp 会话(真 Chromium + CDP),
// 单测跑不动。而「结果里返回什么字段」是**静态可判定**的结构事实,
// 源码断言能可靠守住,回归时立刻红。
//
// 为何需要这条额外判据:saveScreenshot 的单测只覆盖落盘函数本身,
// 把 handleScreenshot 改回「返回 base64」**不会**让它变红
// (变异测试实测过 —— 这是一个真实的判据漏洞)。
func TestHandleScreenshotReturnsPathNotBase64(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "func (p *Plugin) handleScreenshot(")
if i < 0 {
t.Fatal("找不到 handleScreenshot")
}
seg := s[i:]
if end := strings.Index(seg, "\nfunc (p *Plugin) saveScreenshot("); end > 0 {
seg = seg[:end]
}
// ① 必须调用 saveScreenshot 并把它返回的路径带进结果
if !strings.Contains(seg, "p.saveScreenshot(") {
t.Error("handleScreenshot 必须调用 saveScreenshot 落盘(而不是直接编 base64)")
}
if !strings.Contains(seg, `"path":`) {
t.Error("handleScreenshot 的结果必须含 \"path\" 字段 —— " +
"模型拿它去调 describe_image / ocr_image")
}
// ② base64 只能是**可选**(受 inline 开关控制),不能无条件出现。
//
// 注意:这条要挡住的是「无条件把 base64 塞进返回值」,因为那会让
// 上下文被灌爆——正是本次要修的问题。变异测试发现单看
// `res["base64"] = b64` 是否存在不够:把 base64 写成 map 字面量的
// 一个键(`"base64": ...`)同样绕过它,所以两种写法都要查。
// ⚠️ 这条必须查**真实代码行**而不是整段文本:注释里也出现过 "inline"
// 和 "base64",按整段 Contains 判会把注释算成保护开关
// (变异测试实测:把 `if ...inline...` 改成 `if false` 仍能通过)。
code := stripLineComments(seg)
if strings.Contains(code, `"base64"`) || strings.Contains(code, `data_uri`) {
if !strings.Contains(code, "args[\"inline\"]") {
t.Error("返回结构里出现了 base64/data_uri,但没有真实的 inline 开关保护:" +
"无条件带上就等于把十几万 token 又灌回上下文(本次修复要消掉的问题)")
}
}
// ③ 落盘失败时不得静默退回 base64
if strings.Contains(code, "saveErr") && !strings.Contains(code, `return errResult("screenshot saved failed`) {
t.Error("screenshot saved 失败时必须明确报错(return errResult)," +
"不得静默退回 base64 —— 那会让上下文被灌爆这件事没有迹象")
}
}
// stripLineComments 去掉整行注释与行尾注释,只留可执行代码。
//
// 为何需要:源码断言若把注释也算进去,就会出现「注释里的词冒充保护逻辑」
// 这种假通过。变异测试实测过这个坑。
func stripLineComments(src string) string {
var out []string
for _, line := range strings.Split(src, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "//") {
continue
}
if i := strings.Index(line, "//"); i >= 0 {
line = line[:i]
}
out = append(out, line)
}
return strings.Join(out, "\n")
}

View File

@ -1,57 +1,13 @@
# 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,6 +2,6 @@ module calendar
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -11,7 +11,7 @@ import (
"sync" "sync"
"time" "time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
const ( const (

View File

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

View File

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

View File

@ -40,7 +40,7 @@ import (
"sync" "sync"
"time" "time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
const ( const (

View File

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

View File

@ -2,6 +2,6 @@ module editdoc
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -9,7 +9,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Plugin struct { type Plugin struct {

View File

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

View File

@ -2,6 +2,6 @@ module files
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -10,7 +10,7 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Plugin struct { type Plugin struct {

View File

@ -1,16 +1,13 @@
# luademo # luademo
Lua 插件全功能示例,展示 Lua SDK 的完整能力面(对齐 SDK 1.3.0): Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面:
- **工具注册**:`no_memory` + `context_policy` + `cleaner`(记忆计算层过滤) - **工具注册**:`no_memory` + `cleaner`(记忆计算层过滤)
- **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域 - **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域
- **通道**:`register_output_channel` / `register_input_channel` / `unregister_output_channel`(def 支持 no_memory/context_policy/cleaner) - **通道**:`register_output_channel` / `register_input_channel`(def 支持 no_memory/cleaner)
- **注入**:`inject_text` / `inject_interrupt` / `inject_text_no_memory`、`*_opts`(no_memory/context_policy/cleaner_name/priority)、`inject_input_sync`、`inject_*_media`、`set_tool_blocks` - **数据类 API**:`sdk.memory.*`、`sdk.doc.*`、`sdk.knowledge.*`、`sdk.text_memory.*`、`sdk.llm.*`、`sdk.settings.*`、`sdk.social.*`
- **数据类 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,65 +67,6 @@ 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,413 +1,67 @@
-- HomeAgent Lua Plugin SDK -- HomeAgent Lua Plugin SDK (standalone mock)
-- 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
-- !impl function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
-- level: "debug" | "info" | "warn" | "error" function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
function sdk.log(level, msg) function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
end function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
function sdk.get_setting(key) return nil end
-- !impl function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text } function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
-- handler: function(args) -> result function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
function sdk.register_tool(name, def, handler) function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
print("[lua-plugin] register_tool: " .. tostring(name)) function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
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)
local ok, result = pcall(function() if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
local function _encode(v) elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
local t = type(v) elseif type(val) == "table" then local parts, i = {}, 1
if t == "string" then for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t') return "{" .. table.concat(parts, ",") .. "}" end
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" return "null"
end end
end function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil 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 = {} sdk.http = {}
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, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
function sdk.http.get(url)
print("[lua-plugin] http.get: " .. tostring(url))
return {status=200, body='{"mock":true}', headers={}}
end
-- !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

View File

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

View File

@ -2,6 +2,6 @@ module memo
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -10,7 +10,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
// Todo 待办条目:会被主动提醒 // Todo 待办条目:会被主动提醒

View File

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

View File

@ -2,6 +2,6 @@ module music
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Plugin struct { type Plugin struct {

View File

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

View File

@ -2,6 +2,6 @@ module ocr
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -13,7 +13,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Plugin struct { type Plugin struct {

View File

@ -2,6 +2,6 @@ module plugindev
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -26,7 +26,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
const ( const (

View File

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

View File

@ -2,6 +2,6 @@ module qq
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -3,7 +3,7 @@
package main package main
import ( import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk" sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {

View File

@ -22,7 +22,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type ForwardRule struct { type ForwardRule struct {
@ -155,41 +155,6 @@ 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 {
@ -351,8 +316,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录,主机路径。发文件时文件会复制到此目录,NapCat内部映射为/app/files/", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "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()
@ -376,18 +339,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/") p.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)
@ -439,9 +396,7 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图
} }
return cleaned return cleaned
} }
// qq 通道到达的是**中断通知(meta)**,不是用户正文,不据它召回; s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner})
// 真实正文由 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 {
@ -461,9 +416,6 @@ 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 获取)"},
@ -489,12 +441,6 @@ 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二选一)"},
@ -751,8 +697,6 @@ 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()
@ -817,10 +761,6 @@ 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:
@ -1040,40 +980,6 @@ 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 精确恢复身份,不依赖昵称或用户正文。
@ -1124,28 +1030,13 @@ func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup b
p.auth.generation = next.generation p.auth.generation = next.generation
} }
var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`) func messageIDFromInput(raw string) int64 {
match := qqMessageIDRe.FindStringSubmatch(raw)
// messageIDsFromInput 取出一段输入里出现的全部 message_id。
//
// 合并中继的正文是 `(message_id=100,101,102)`:只取第一个会留下同批其余 id 永不清理;
// 身份表用 id 做键,泄漏的条目要等 generation 回收才会消失。
func messageIDsFromInput(raw string) []int64 {
matches := qqMessageIDsRe.FindAllStringSubmatch(raw, -1)
ids := make([]int64, 0, len(matches))
for _, match := range matches {
if len(match) != 2 { if len(match) != 2 {
continue return 0
} }
for _, part := range strings.Split(match[1], ",") { id, _ := strconv.ParseInt(match[1], 10, 64)
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) return id
if err != nil || id == 0 {
continue
}
ids = append(ids, id)
}
}
return ids
} }
func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error { func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
@ -1153,27 +1044,24 @@ func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error {
source, _ := ctx.Extra["input_source"].(string) source, _ := ctx.Extra["input_source"].(string)
raw := ctx.RawMessage raw := ctx.RawMessage
ctx.RUnlock() ctx.RUnlock()
p.authMu.Lock() p.authMu.Lock()
// 默认降权:QQ 来源却对不上可信事件时绝不复用上一条消息的身份。 defer p.authMu.Unlock()
next := qqAuthContext{active: source == p.name} if source != p.name {
ids := messageIDsFromInput(raw) p.auth = qqAuthContext{}
if source == p.name && len(ids) > 0 { p.resetTurnGuardLocked()
if auth, ok := p.authByMessageID[ids[0]]; ok { return nil
next = auth }
for _, id := range ids { if messageID := messageIDFromInput(raw); messageID != 0 {
delete(p.authByMessageID, id) if auth, ok := p.authByMessageID[messageID]; ok {
} p.auth = auth
} delete(p.authByMessageID, messageID)
} p.resetTurnGuardLocked()
// p.auth 只作为"帧上没绑身份"时的兜底(单测/异常帧),权威副本在帧上。 return nil
p.auth = next }
}
// QQ 来源却无法精确匹配可信 OneBot 事件时必须强制降权,不能复用上一条消息的身份。
p.auth = qqAuthContext{active: true}
p.resetTurnGuardLocked() p.resetTurnGuardLocked()
p.authMu.Unlock()
ctx.Lock()
bindAuthOnFrame(ctx, next)
ctx.Unlock()
return nil return nil
} }
@ -1193,13 +1081,9 @@ func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error {
return nil return nil
} }
func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, qqAuthContext) { func (p *Plugin) currentToolAllowed(name string) (bool, qqAuthContext) {
// 身份以本帧为准(中断恢复后全局身份可能已属于别的轮)。
auth, onFrame := authOnFrame(ctx)
p.authMu.RLock() p.authMu.RLock()
if !onFrame { auth := p.auth
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 {
@ -1308,7 +1192,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(ctx, tc.Name) allowed, auth := p.currentToolAllowed(tc.Name)
if !auth.active { if !auth.active {
return nil return nil
} }
@ -1415,157 +1299,6 @@ func requiresConfirmFriendCommand(cmd string) bool {
} }
} }
// enqueueInterrupt 把一条已通过策略/@ 检查的消息并入待投批次,并重置 debounce 计时。
//
// batchWindow<=0 时退回逐条投递(合并前行为)。
func (p *Plugin) enqueueInterrupt(msgType string, userID, groupID, messageID int64, nickname, single string, owner, highRisk bool) {
if p.sdk == nil && p.injectHook == nil {
return
}
if p.batchWindow <= 0 {
p.injectInterrupt(single, p.interruptLevel(owner))
return
}
key := qqBatchKey(msgType, groupID, userID)
p.batchMu.Lock()
if p.batches == nil {
p.batches = make(map[string]*pendingBatch)
}
b := p.batches[key]
if b == nil {
b = &pendingBatch{key: key, first: time.Now()}
p.batches[key] = b
}
b.isGroup = msgType == "group"
b.userID, b.groupID, b.nickname = userID, groupID, nickname
b.msgIDs = append(b.msgIDs, messageID)
b.single = single
b.owner = b.owner || owner
b.highRisk = b.highRisk || highRisk
// debounce:每来一条就推迟;但整体不超过 batchMax(否则持续刷屏会一直不投)。
delay := p.batchWindow
if p.batchMax > 0 {
if remain := p.batchMax - time.Since(b.first); remain < delay {
delay = remain
}
}
if delay < 0 {
delay = 0
}
if b.timer != nil {
b.timer.Stop()
}
b.timer = time.AfterFunc(delay, func() { p.flushBatch(key) })
p.batchMu.Unlock()
}
// interruptLevel 决定一条 QQ 消息的中断级别。
//
// - Bot 所有者/管理员的消息 → **L2**(一般提醒);
// - 其他人的消息 → L1(后台,完全可等)。
//
// 为什么不能一律 L1:L1 之间可以随时互相抢占、也可以被任何更高一级打断,
// 于是「老板发的话」会被路人的闲聊挤到后面,甚至对方持续刷屏时一直排在队尾。
// 为什么也不该给 L3:L3 是时钟/终端那类"需要及时处理"的实时工作,QQ 是异步
// 消息,抬到 L3 会反过来打断真正实时的事情。
func (p *Plugin) interruptLevel(owner bool) string {
if owner {
return sdk.PriorityL2
}
return sdk.PriorityL1
}
// injectInterrupt 投递一条中断提示(NoMemory:HTTP 侧来的不是对话内容)。
func (p *Plugin) injectInterrupt(text, level string) {
if text == "" {
return
}
if level == "" {
level = sdk.PriorityL1
}
if p.injectHook != nil {
p.injectHook(text, level)
return
}
if p.sdk == nil {
return
}
p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{
NoMemory: true,
Priority: level,
// 中断文本是路由/取正文的指令,不是对话内容,不据它召回。
RecallPolicy: sdk.RecallPolicyNone,
})
}
// flushBatch 投递一批:n==1 沿用单条原文;n>1 生成「共几条」的合并中断。
func (p *Plugin) flushBatch(key string) {
p.batchMu.Lock()
b := p.batches[key]
delete(p.batches, key)
p.batchMu.Unlock()
if b == nil {
return
}
text := b.single
if len(b.msgIDs) > 1 {
text = p.buildBatchInterrupt(b)
}
// 一批里只要有一条来自 Bot 所有者,整批按 L2 投递(不因混入路人消息而降低)。
p.injectInterrupt(text, p.interruptLevel(b.owner))
}
// flushAllBatches 停机前把未到点的批次立刻投出去(best effort)。
func (p *Plugin) flushAllBatches() {
p.batchMu.Lock()
keys := make([]string, 0, len(p.batches))
for k := range p.batches {
keys = append(keys, k)
}
p.batchMu.Unlock()
for _, k := range keys {
p.flushBatch(k)
}
}
// buildBatchInterrupt 生成合并中断:说清「一共几条」「分别是哪些 message_id」,
// 并给出一次拿全上下文的建议(get_history),避免模型逐条 get_message。
func (p *Plugin) buildBatchInterrupt(b *pendingBatch) string {
tp := p.name + "_"
outputTool := "output_send__" + p.name
n := len(b.msgIDs)
ids := formatMsgIDs(b.msgIDs)
var s string
if b.isGroup {
s = fmt.Sprintf("来自「%s」在群里短时间内连续发来 %d 条消息(message_id=%s)。建议先用%sget_history(group_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复群聊",
b.nickname, n, ids, tp, b.groupID, n+5, tp, outputTool)
} else {
s = fmt.Sprintf("来自「%s」的私聊短时间内连续发来 %d 条消息(message_id=%s, user_id=%d)。建议先用%sget_history(user_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复对方",
b.nickname, n, ids, b.userID, tp, b.userID, n+5, tp, outputTool)
}
if b.highRisk {
s = "【⚠️ 高危信息,谨慎处理】" + s
}
if b.owner {
s = "【重要!Bot 所有者消息】" + s
}
return s
}
// formatMsgIDs 把 message_id 列表压成一行;过多时截断,避免中断文字过长。
func formatMsgIDs(ids []int64) string {
const capN = 12
parts := make([]string, 0, len(ids)+1)
for i, id := range ids {
if i >= capN {
parts = append(parts, "…")
break
}
parts = append(parts, strconv.FormatInt(id, 10))
}
return strings.Join(parts, ",")
}
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { 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)
@ -1684,9 +1417,7 @@ 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
} }
@ -1715,9 +1446,15 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
p.startTyping(evt.UserID) p.startTyping(evt.UserID)
} }
// 合并投递:同一会话同一发送者在 batchWindow 内的连续消息并成一次中断。 if p.sdk != nil {
p.enqueueInterrupt(evt.MessageType, evt.UserID, evt.GroupID, evt.MessageID, nickname, interrupt, p.isOwner(evt.UserID), highRisk) // NoMemory:HTTP 侧来的中断提示,不是对话内容。
// 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)
} }
@ -2815,7 +2552,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, RecallPolicy: sdk.RecallPolicyNone}) sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1})
} }
} else { } else {
errMsg = "下载失败,文件可能已过期" errMsg = "下载失败,文件可能已过期"

View File

@ -7,9 +7,8 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func newPermissionTestPlugin(t *testing.T) *Plugin { func newPermissionTestPlugin(t *testing.T) *Plugin {
@ -236,198 +235,3 @@ func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response) 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

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

View File

@ -2,6 +2,6 @@ module recoverydiag
go 1.25.0 go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../ replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

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