1 Commits
main ... v1.1.0

Author SHA1 Message Date
219b11a1f9 chore(release): SDK 定版 1.1.0(1.1.x 线全程共用)
按 核心仓 docs/git-branching.md §七.1,SDK 版本跟随核心的中版本、
patch 位恒为 .0:整条核心 1.1.x 线(1.1.0、1.1.1、…、1.1.N)共用 SDK 1.1.0。

**为什么不跟着核心 patch 走**:SDK 版本号是插件开发者的依赖声明。核心的 patch
专用于 bugfix 与漏洞修复,不碰公开接口;若每个 bugfix 都推一个 SDK 新号,
开发者要么被迫跟版、要么怀疑自己版本过时,而接口一个字都没变。

本版内容见 tag v1.1.0 的发布说明。
2026-09-06 10:04:28 +08:00
366 changed files with 4633 additions and 58569 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,350 +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
# ★★ 开发阶段保护(2026-10-18 实测事故)
#
# 本 workflow 的触发条件就是 push release/**,而 tag 名 = v$V。
# 于是只要有人**为了走 PR 流程**随手推一个 release/vX.Y.x 分支,
# meta 里的开发版本号就会被**自动打 tag 并发布 Release**:
#
# 2026-10-17 08:35 github-actions[bot] 打出 v1.4.0 并发布
# → 而 1.4.0 当时还是开发阶段
#
# 更麻烦的是它指向旧提交:之后又合了三个修复(异步投递、台账回填),
# 已发布的 1.4.0 里**没有**这些修复,而 Release 看起来是"最新"。
#
# ⇒ 分支名与 meta.Version 必须**一致**才允许发版。
# 推 release/v1.4.x 而 meta 写着 1.4.0(开发中)时,
# 意图明显是"走流程"而不是"发这个版本",直接拒绝。
BRANCH="${GITHUB_REF_NAME:-}"
case "$BRANCH" in
release/v*.*.x)
# release/vX.Y.x —— 中版本长跑线,允许发该中版本下的任何 patch
MAJMIN=$(printf '%s' "$BRANCH" | sed -E 's#^release/v([0-9]+\.[0-9]+)\.x$#\1#')
case "$V" in
"$MAJMIN"|"$MAJMIN".*) ;;
*)
echo "ERROR: 分支 $BRANCH 与 meta.Version=$V 不匹配。" >&2
echo " 该分支承载的是 $MAJMIN.* 系列,而 meta 是 $V。" >&2
echo " 开发阶段请勿推 release/** —— 它会自动打 tag 并发 Release。" >&2
exit 1 ;;
esac ;;
release/v[0-9]*.[0-9]*.[0-9]*)
BVER=$(printf '%s' "$BRANCH" | sed -E 's#^release/v##')
if [ "$BVER" != "$V" ]; then
echo "ERROR: 分支 $BRANCH(钉到 $BVER)与 meta.Version=$V 不一致。" >&2
exit 1
fi ;;
esac
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

14
.gitignore vendored
View File

@ -35,17 +35,3 @@ z_entry.c
# plugindev binary in tools/
tools/plugindev/plugindev
# 文档站构建产物(由 tools/apidoc/build.sh 生成)
site_build/
# mkdocs 缓存
.cache/
# agent 入口的「全文汇总」是派生件:由 gensite 把 docs/ 下所有 Markdown 拼成一份,
# 每次改任何一页都会整份重写(115KB),进版本库只产生噪声。它由构建产出,
# llms.txt(索引,小且稳定)仍提交。
docs/llms-full.txt
# ★ rawfile/ 的内容是随包资源(可能内嵌 node/pi 等上百兆二进制),
# 由构建方自行准备,不进版本库。忽略规则放在 rawfile/.gitignore 内
# (自包含,避免依赖上层规则的作用域与否定式语义)。

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 JianFeeeee
Permission is hereby granted, free of charge, to any person obtaining a copy
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
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

418
README.md
View File

@ -2,237 +2,6 @@
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+**)。
**版本号跟随内核的中版本,patch 位恒为 `.0`**:
| 内核版本 | 对应 SDK |
|---|---|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 起 | **1.3.0** |
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
因此 **SDK 仓在一个中版本里只发一次**(`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
(2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
**1.0.x 插件升到 1.1.x:不需要改代码,也不需要重编。** 1.1.0 的新增全部是
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
实测验证:在新内核上直接建链通过,因为握手校验的是主版本号、不是 SDK 全等版本)。
想用新字段时重编即可。
**1.1.x 插件升到 1.2.x:接口纯追加;若内核共享区布局改了则需重编。** 公开接口没有签名变更
(新增 `InjectOptions` 与六个 `*Opts` 变体、`ChannelDef.ContextPolicy`),不调新能力就不受影响。
1.2.0 同时把 fd3 换成了统一共享区(SuperBlock + StageContext + EvtRing),
这类**布局**变化由握手时的结构判据拦下(统一区魔数、`field_count` 描述符个数),
而不是靠某个版本号——它们都是二进制里的派生事实,不会「忘同步」。
## 1.3.0 新增:注入优先级与动态输出通道
### 注入优先级(`InjectOptions.Priority`)
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
| 级别 | 常量 | 谁用 |
|---|---|---|
| L1–L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选(L1 最低) |
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
⇒ 存量插件不需要改一行、也不需要重编。
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
(现场保存/恢复对插件透明)。
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
### 动态输出通道(`UnregisterOutputChannel`)
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
1.3.0 起成对提供:
| API | 用途 |
|---|---|
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`,
违规会让**整条 LLM 请求**被上游拒绝(2026-09-13 生产事故:`device/<id>` 导致全量对话 403)。
派生规则与约束见下方「输出通道」一节。
## 业务字段透传(`InjectOptions.Payload`)
插件可以在**一次注入**上携带自己的业务字段,内核**不解释、只透传**:
```go
type InjectOptions struct {
// ... 上面的 NoMemory / ContextPolicy / CleanerName / Priority ...
// Payload 是本次注入要额外并入事件 payload 的字段(不透明)。
// 合并时**内核保留键不可覆盖**(见下)。
Payload map[string]interface{}
}
```
### 它解决什么
`InputEvent.Payload` 本就是通道送入内核的请求体,业务字段本该在那里。但
`IOInjector` 的注入方法全是 `(source, channel, text, opts)` —— 插件无法直接
给 payload 加字段。`Payload` 就是这个扩展口。
典型用途:**通道插件需要知道"这条消息是发给谁的"**。
```go
// 注入时声明身份(键名加插件前缀,避免与内核字段撞车)
sdk.InjectInterruptTextOpts("qq", "qq", text, sdk.InjectOptions{
NoMemory: true,
Payload: map[string]interface{}{"qq_uid": userID},
})
```
### 内核怎么传递
```
注入点 opts.Payload
→ 与真 payload 合并(保留键跳过)
→ emitResponse / output_send 把**非保留键**原样写进 stageCtx.Extra
```
插件在 stage 里直接读:
```go
uid, ok := ctx.Extra["qq_uid"]
```
### 保留键不可覆盖
`no_memory` / `context_policy` / `recall_policy` / `scene_policy` /
`cleaner_name` / `priority` / `type` / `content` / `interrupt` / `stop` /
`command` / `scene` / `source` / `channel` / `media_blocks` / `media_type`
由内核语义决定,插件写了会被**跳过并记日志**(不是拒绝整次注入)。
原因:允许覆盖等于允许插件伪造「这条不记记忆」或「这条要立即打断」——
那是权限提升,不是便利。
## 注入行为与上下文裁剪(1.2.0)
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
1.2.0 起**注入侧也能声明**,并且二者共用同一套语义与取值。
```go
type InjectOptions struct {
NoMemory bool // true = 不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
ContextPolicy string // ""/none = 不裁剪(默认);prune = 据此裁剪上下文
CleanerName string // 计算层过滤函数名:先经 Cleaner 得到实际有效内容,再计算/裁剪
}
const (
ContextPolicyNone = "none"
ContextPolicyPrune = "prune"
)
// 六个变体,与旧的三参数方法一一对应,只多一个 opts
InjectTextOpts(source, channel, text string, opts InjectOptions)
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
要点:
- **零值 `InjectOptions{}` 与旧的三参数方法逐键等价**(记入记忆 + 不裁剪)。旧方法保留为
零值糖(`InjectText` / `InjectInterruptText` / `InjectTextNoMemory` …),存量插件不改一行、
不需重编即可继续调用。
- **裁剪(`prune`)必须显式声明**:它会归档丢弃低相关事件,是有副作用的行为,故默认关闭。
内核只放行 `""` / `none` / `prune`(`ValidContextPolicy`),未声明的取值会被拒。
- 裁剪前先经该插件注册的 **`Cleaner`**(由 `CleanerName` 指定)拿到实际有效内容,
避开「按原文裁剪、按清洗后计算」这种不一致。
- `ChannelDef` 也有同名 `context_policy`(并且 1.2.0 给它补上了 JSON tag——通道定义要跨进程
传给内核,而 `Cleaner` 是函数必须忽略;无 tag 时新增字段会被静默丢掉)。
## 阶段钩子:触发时机与陷阱
七个阶段,按一轮任务的真实时序:
```
on_input → pre_action → [ stepLLM 调模型 ] → post_action
↓
工具循环: before_toolcall → [ 执行工具 ] → after_toolcall
↓ ↓
(还有工具则回到 stepLLM) (否则)
↓
before_output → [ 发送 ] → after_output
```
| 阶段 | 时机 | 典型用途 |
|---|---|---|
| `on_input` | 输入事件刚进内核 | 权限上下文绑定、改写用户输入 |
| `pre_action` | **即将调 LLM** | 标记"接下来要等模型" |
| `post_action` | LLM 已返回、工具未执行 | 审阅/改写模型的 tool_calls |
| `before_toolcall` | 即将执行工具 | 权限门、参数校验、拦截 |
| `after_toolcall` | 工具已返回 | 清洗结果、度量 |
| `before_output` | 即将发送/输出 | 改写文本、发"正在输入"指示 |
| `after_output` | 已发送/一轮结束 | 收尾、状态归位 |
### ★★ 每个阶段只能注册一个 handler
插件侧把 handler 存在 `map[string]StageHandler`(**阶段名为键**)⇒
**同一阶段注册两次,后者静默覆盖前者**。
```go
s.RegisterStage(sdk.StageBeforeToolcall, p.permissionGate) // ← 被下一条覆盖
s.RegisterStage(sdk.StageBeforeToolcall, p.updateStatus) // ← 实际生效的
```
后果不只是功能不生效 —— 如果被覆盖的是**权限门**,等于权限被静默关掉。
而内核日志会显示 `2 handlers [qq qq]`,看起来完全正常。
**正确做法:把多个功能合并进同一个 handler。** 每次阶段往返都有跨进程开销,
合并写法本身也是性能要求。hmapdev 在**构建期**对重复注册告警:
```
⚠️ 警告: stage StageBeforeToolcall 注册了 2 次
插件侧 stageHandlers 是 map(阶段名为键)⇒ 只有最后一次注册生效
```
### ★★ `before_output` / `after_output` 有**两条触发路径**
它们的触发源不止"一轮结束",还有"单次 output_send__调用":
| 来源 | Extra 内容 | 含义 |
|---|---|---|
| `emitResponse` | `input_source`、本轮 payload 透传字段 | **一轮的回复**(turn 边界) |
| `output_send__<ch>` | `output_channel` / `output_meta` / `output_type` | **单次发送** |
插件必须区分,否则会错:
```go
// 判断是否"单次发送"
_, isSingleSend := ctx.Extra["output_meta"]
```
- **"正在输入"类指示器**:应挂在单次发送上(消息发出前开始、发出后结束)。
挂在轮次边界会漏 —— 一轮可能发多条消息。
- **状态归位(回到"空闲")**:**只能**由轮次边界触发。每条发送都归位的话,
状态会在干活期间反复跳回空闲。
## SDK API 接口
### Plugin 接口
@ -251,15 +20,11 @@ type Plugin interface {
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
> **通道的方向契约**:入站与出站是分开登记的两件事。凡是用 `InjectText*/InjectInput*/InjectInterrupt*`
> 注入的通道名都要 `RegisterInputChannel` —— inputch 是内核最基本的**输入路由单位**,
> 只有登记过的通道才能被"划给驻留子";只登记出站通道时内核会兜底登记同名 inputch 并告警(兼容老插件)。
| 分类 | 方法 | 说明 |
|------|------|------|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__<name>` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道,def 为 `ChannelDef`(NoMemory/Cleaner) |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道,def 为 `ChannelDef`,caps 为能力位掩码 |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
@ -306,14 +71,6 @@ sdk.RegisterInputChannel("qq", ChannelDef{
### 输出通道
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
> "这个工具不可用",而是**整条请求被 400 拒绝**(`Invalid 'tools[N].function.name'`),
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`(13 字符)的余量。
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
> 内核**不会**替你净化。
```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
```
@ -463,31 +220,18 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
## hmapdev 工具链
## plugindev 工具链
`hmapdev` 提供插件开发全流程支持,最终产出 `.hmap` 插件包(工具名即来自该包格式)。
预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),
下载后加入 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`。
> SDK 存储目录同时由 `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`
> (旧目录会被自动沿用,不会丢已装版本)。
`plugindev` 提供插件开发全流程支持。预编译二进制作为 **release 附件**分发(linux/darwin/windows × amd64/arm64),从
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
```bash
# 从 release 附件下载(以 linux amd64 为例,<版本> 如 v1.3.0)
# 当前实际分发地址是 gitcode(见下方说明):
curl -Lo hmapdev https://github.com/JianFeeeee/homeagentsdk/releases/download/<版本>/hmapdev_linux_amd64
chmod +x hmapdev
# 从 release 附件下载(以 v1.0.0 / linux amd64 为例)
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
chmod +x plugindev
# 或从源码自己编
cd tools/hmapdev && go build -o hmapdev .
cd tools/plugindev && go build -o plugindev .
```
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用):5 个平台各 26-28MB,
@ -495,11 +239,11 @@ cd tools/hmapdev && go build -o hmapdev .
| 命令 | 说明 |
|------|------|
| `hmapdev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
| `hmapdev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
| `hmapdev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
| `hmapdev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
| `hmapdev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md) |
| `plugindev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
| `plugindev clean` | 清理 `build/`、`dist/` 目录及生成文件(plugin.json、z_bridge_gen.go) |
| `plugindev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
| `plugindev sdk <command>` | SDK 版本管理(子命令:list/install/use/path/current/latest) |
支持 **Go** 和 **Lua** 两种插件语言。
@ -606,7 +350,7 @@ return plugin
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`hmapdev` 模板含 onRemove 演示。
- 示例:`example/calendar`(删 events.json)、`example/memo`(删 memos.json)、`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`plugindev` 模板含 onRemove 演示。
```go
sdk.RegisterOnRemoveHandler(func() {
@ -624,43 +368,6 @@ enabled := sdk.AutoRestart()
插件崩溃时平台自动拉起,保障服务可用性。
重启是**有节制的**,默认参数(内核 `internal/plugin/registry.go`):
| 参数 | 值 | 含义 |
|---|---|---|
| `procRestartBackoff` | `1s` | 第 n 次重启前等 `n × 1s`(线性退避,非立即拉起) |
| `procMaxRestarts` | `3` | 窗口内允许的重启次数上限 |
| `procCrashWindow` | `5min` | 窗口内无新崩溃则计数归零 |
即崩溃后的实际序列是 **1s → 2s → 3s**;同一 5 分钟窗口内第 **4** 次崩溃
(`n > 3`)**不再自动拉起**,交人工介入。这不是「立即无感恢复」——
如果插件需要秒级就位,请自己在 `OnStart` 里做好重连与重建。
> ⚠️ `SetAutoRestart` 的典型用法是「外部连接建好后再判定能否自动重启」,而连接建立
> 通常在后台 goroutine 里,内核又在另一个 goroutine 读它——这对读写天然并发。
> **SDK 1.1.0 已给这个标志与全部 API 字段加锁**(`-race` 实测 11 处竞态,
> 生产表现是插件重载瞬间偶发 nil 解引用崩溃)。早于 1.1.0 的版本建议升级。
## 插件开发者的并发约定
`PluginSDK` 是**被多个 goroutine 同时使用的共享对象**:你在 `Start()` 里起的轮询、
监听、定时器都拿着同一份 `*PluginSDK` 往里注消息,而内核会在加载/重载时写它的
API 字段。因此:
- **SDK 侧已保证的**:全部 API 访问器(`Memory()`/`DocMemory()`/…)、全部注入方法、
`SetAutoRestart`/`AutoRestart`、`RegisterTool`/`RegisterStage`、
`RunStopHandlers`/`RunOnRemoveHandlers`(幂等,并发调也只执行一次)。
- **你需要自己保证的**:`StageContext` 的字段全部导出,并发读写必须自己持
`ctx.Lock()`/`ctx.RLock()`。尤其是 `ctx.Extra`——**map 的并发写在 Go 里是直接 fatal,
`recover` 接不住**。
```go
ctx.Lock()
ctx.Extra["mykey"] = value
ctx.FinalText += "补充说明"
ctx.Unlock()
```
## 受限 SDK vs 完整 SDK
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
@ -672,57 +379,6 @@ ctx.Unlock()
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
## 项目声明 SDK 版本(plg.json 的 `sdk` 字段)
`hmapdev init` 生成的工程里,`plg.json` 会带一个 `sdk` 字段:
```json
{
"name": "MyPlugin",
"version": "0.1.0",
"entry": "plugin.bin",
"sdk": "1.2.0"
}
```
它的语义是**本插件针对的 SDK 版本**,工具链据此在本地 SDK 存储里选择版本:
命中就用它,并把 `go.mod` 的 `require`/`replace` 同步到该版本;未命中则**明确报错**
(列出已装版本 + `hmapdev sdk install vX.Y.Z`),**绝不静默退化成 `current`**。
```bash
$ hmapdev build
[hmapdev] SDK 1.2.0(项目声明 sdk=1.2.0)
```
为什么要这个字段:以前项目里没有任何「我要哪版 SDK」的声明,工具链只能用存储里的
`current`——谁改过 `current` 就拿谁的版本编,出错时表现为一堆看不懂的编译错误
(例如存储里只有陈旧的 `v0.8.0` 时,模板项目首次构建会报 `undefined: sdk.InjectOptions`)。
**写法必须是完整版本号(`1.2.0`),不接受区间写法(`1.2`)。** 原因见上文的版本纪律:
SDK 版本跟随内核中版本、patch 位恒为 `.0`,一条内核线只对应一个 SDK 版本;
写区间会让人误以为同一条线里还能挑不同 SDK(工具链会直接拒绝并说明这条规矩)。
- 显式 `--sdk-path` 或 `plg.json` 的 `sdk_path` 优先(本机改 SDK 联调时用);
- 存量工程(`plg.json` 没有 `sdk` 字段)行为不变,仍按 `current` 构建;
- 产物 `.hmap` 里的 `plugin.json` 会记录**实际选中的 SDK 版本**,便于事后追溯。
## IDE 支持:VSCode 扩展(`tools/vscode-hmapdev`)
调试插件的实操回路是「构建 → 运行 → 看内核日志」,这三步都在 IDE 之外很别扭,
所以仓库里带了一个 VSCode 扩展([tools/vscode-hmapdev](tools/vscode-hmapdev)):
- **plg.json 诊断**:必需字段、`sdk` 是否是完整版本号、声明的 SDK 是否已安装(直接给安装命令);
- **状态栏**:`插件 · SDK <声明> · hmapdev <版本>`,工具链缺失或工程有错时变色;
- **命令 / 任务**:build / build(全部目标)/ clean / debug(解释执行),编译错误进 Problems;
- **跟随内核日志**:读 `<dataDir>/log` 下最新的 `homed_*.log` 并按插件名过滤。
```bash
cd tools/vscode-hmapdev && npm install && npm run compile # 然后在 VSCode 里按 F5
```
它不是源码级调试器(没有断点/单步):插件要么编译成产物在内核里跑、要么用
`hmapdev debug` 解释执行,两条路都没有 DAP 会话;扩展做的是构建、运行、看日志与清单校验。
## 示例插件
| 插件 | 类型 | 说明 |
@ -743,11 +399,6 @@ cd tools/vscode-hmapdev && npm install && npm run compile # 然后在 VSCode
| [rss](example/rss) | Go | RSS 订阅 |
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
**发版时附带预编译示例产物**:SDK 的 release 除 5 平台 `hmapdev` 外,还包含各示例插件的
`.hmap` 与 `SHA256SUMS`/`MANIFEST.txt`。原因是插件二进制与内核在**布局上绑定**(主版本号
+ 统一共享区魔数 + `field_count`),只发工具链不发示例产物,很容易拿旧产物去装而握手失败
——那看起来像「插件坏了」而不是「版本不配套」。
## Remote Device SDK
用于开发**远程设备接入适配器**的 C 语言 SDK,零外部依赖,兼容嵌入式平台。
@ -849,10 +500,10 @@ ha_transport_t my_transport = {
### 使用方式
通过 `hmapdev` 工具链初始化项目:
通过 `plugindev` 工具链初始化项目:
```bash
hmapdev init my-adapter --type remotedevice
plugindev init my-adapter --type remotedevice
```
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
@ -1064,17 +715,17 @@ curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
### 位置
- **SDK 源码**: `remotedevice/`
- **hmapdev 模板**: `hmapdev init --type remotedevice`
- **plugindev 模板**: `plugindev init --type remotedevice`
## 构建与安装
### 构建
```bash
hmapdev build
plugindev build
```
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `hmapdev build --no-bundle`)。
输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `plugindev build --no-bundle`)。
### 安装
@ -1092,32 +743,3 @@ curl -X POST http://127.0.0.1:9876/plugins \
```
或通过 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 之类的传染许可,插件作者就会被强制以其对外开源。选 MIT 就是为了
让插件作者**自由选择自己的许可**——闭源、商业、私有均可,无需向本项目回馈,
也无需取得任何例外或商业授权。第三方插件生态的安全与活跃正建立在这条之上。
前提是 SDK 本身**完全自包含**:`go.mod` 零外部依赖,`sdk/` 只依赖 Go 标准库
(`sync`),不引用核心仓的任何代码,因此 MIT 授权不与其他许可冲突。
第三方组件(Go 依赖:go-sqlite3、gojieba、bubbletea 等,均为 MIT / BSD-3 / Apache-2.0)
保持各自原有许可。平台侧的模型与推理运行时(Chinese-CLIP Apache-2.0、ONNX Runtime MIT)
不属于本 SDK,其许可全文随发行包放在 `/usr/share/doc/homeagent/licenses/`。

View File

@ -2,113 +2,6 @@
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
Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
| Kernel version | Matching SDK |
|---|---|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
| 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 onward | **1.3.0** |
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
public interface, so the SDK version has no reason to move with it — otherwise you would either be
forced to chase releases or suspect your version is stale, when not one character of the interface
has changed.
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
## New in 1.3.0: Injection Priority and Dynamic Output Channels
- **`InjectOptions.Priority` / `PriorityL1`–`PriorityL4`** — a plugin declares the interrupt level of
its own injection; the kernel schedules L1–L4, where **L4 is reserved for the kernel and
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
Queued input has no level: anything can jump ahead of it.
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
channels that die with their resource (one channel per remote device) can now be unregistered;
previously they lingered and the model kept "successfully" sending into a dead channel.
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
external IDs — never use the raw ID.
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
in the "plugin calls, kernel implements" direction, so not calling it means not being affected
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,
because the handshake validates the **major** version, not the exact SDK version). Rebuild only when you want
the new fields.
**Upgrading a 1.1.x plugin to 1.2.x: the interface is purely additive, but a rebuild is required.**
No public signature changed (the SDK adds `InjectOptions`, six `*Opts` variants and
`ChannelDef.ContextPolicy`), so not calling the new capabilities means not being affected — but the
kernel's **plugin protocol went to 2** (the fd3 layout of the unified shared-memory region changed,
and **rolling upgrades are not supported**). `plugin.bin` must therefore be rebuilt with the matching
`hmapdev` and installed **together with** the kernel; otherwise the handshake fails on protocol
version mismatch (the error says explicitly to rebuild with the matching hmapdev — it never
degrades silently).
## Injection Behaviour and Context Pruning (1.2.0)
"Should this go into memory" and "should the context be pruned based on this" used to be
something only `ToolDef` could declare. Since 1.2.0 **injections can declare them too**, sharing
the same semantics and values.
```go
type InjectOptions struct {
NoMemory bool // true = excluded from memory computation (vectorize/keywords/distill); the
// original text still stays in context
ContextPolicy string // ""/none = do not prune (default); prune = prune context based on this
CleanerName string // name of the compute-layer cleaner: run it first to get the effective
// content, then compute/prune on that
}
const (
ContextPolicyNone = "none"
ContextPolicyPrune = "prune"
)
// Six variants, one-to-one with the older three-argument methods, plus opts
InjectTextOpts(source, channel, text string, opts InjectOptions)
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
```
Key points:
- **A zero-valued `InjectOptions{}` is key-for-key equivalent to the older three-argument methods**
(recorded in memory, not pruned). The old methods remain as zero-value sugar (`InjectText`,
`InjectInterruptText`, `InjectTextNoMemory`, …), so existing plugins keep working without a single
line changed *or* a rebuild.
- **Pruning (`prune`) must be declared explicitly**: it archives/drops low-relevance events, which
is a side effect, so it is off by default. The kernel only accepts `""` / `none` / `prune`
(`ValidContextPolicy`); anything else is rejected.
- Pruning first goes through the plugin's registered **`Cleaner`** (named by `CleanerName`) to get
the effective content, avoiding the inconsistency of "prune on the raw text, compute on the
cleaned text".
- `ChannelDef` carries the same `context_policy` (1.2.0 also gave `ChannelDef` JSON tags — the
definition crosses the process boundary, while `Cleaner` is a function that must be ignored; with
no tags, newly added fields would be silently dropped).
## SDK API Surface
### Plugin Interface
@ -333,24 +226,19 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
## hmapdev Toolchain
## plugindev Toolchain
`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**
`plugindev` provides full development workflow support. Prebuilt binaries ship as **release assets**
(linux/darwin/windows × amd64/arm64); download from
[Releases](https://github.com/JianFeeeee/homeagentsdk/releases) and put it on your PATH:
> 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 old directory is still honored, so installed versions are not lost).
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
```bash
# From release assets (latest SDK release / linux amd64 shown)
curl -Lo hmapdev https://github.com/JianFeeeee/homeagentsdk/releases/download/<version>/hmapdev_linux_amd64
chmod +x hmapdev
# From release assets (v1.0.0 / linux amd64 shown)
curl -Lo plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/releases/download/v1.0.0/plugindev_linux_amd64
chmod +x plugindev
# Or build from source
cd tools/hmapdev && go build -o hmapdev .
cd tools/plugindev && go build -o plugindev .
```
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
@ -359,11 +247,11 @@ cd tools/hmapdev && go build -o hmapdev .
| Command | Description |
|---------|-------------|
| `hmapdev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
| `hmapdev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
| `hmapdev clean` | Clean `build/` and `dist/` plus generated files |
| `hmapdev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
| `hmapdev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
| `plugindev init <name> [--lua]` | Initialize plugin project (generates plg.json, plugin.go or main.lua, go.mod, README.md) |
| `plugindev build [flags]` | Build and package into a `.hmap` (supports cross-compilation and bundle mode) |
| `plugindev clean` | Clean `build/` and `dist/` plus generated files |
| `plugindev debug [dir]` | Load plugin source through the Yaegi Go interpreter and start an interactive REPL |
| `plugindev sdk <command>` | SDK version management (list/install/use/path/current/latest) |
Supports both **Go** and **Lua** plugin languages.
@ -435,7 +323,7 @@ Supports both **Go** and **Lua** plugin languages.
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `hmapdev` template includes an onRemove demo.
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `plugindev` template includes an onRemove demo.
```go
sdk.RegisterOnRemoveHandler(func() {
@ -453,46 +341,6 @@ enabled := sdk.AutoRestart()
The platform automatically restarts the plugin on crash, ensuring service availability.
Restarts are **rate-limited**. Defaults (kernel `internal/plugin/registry.go`):
| Parameter | Value | Meaning |
|---|---|---|
| `procRestartBackoff` | `1s` | Before restart #n, wait `n × 1s` (linear backoff, not immediate) |
| `procMaxRestarts` | `3` | Max restarts within the window |
| `procCrashWindow` | `5min` | No new crash within the window resets the count |
So the actual sequence is **1s → 2s → 3s**; the **4th** crash in the same 5-minute
window (`n > 3`) is **not** restarted automatically and needs manual intervention.
This is not instant, invisible recovery — if your plugin must be back in seconds,
reconnect and rebuild your own state in `OnStart`.
> ⚠️ `SetAutoRestart` is typically used to decide whether auto-restart is safe *after* an
> external connection has been established, and that connection setup usually happens in a
> background goroutine while the kernel reads the flag from another one — which is inherently
> concurrent. **SDK 1.1.0 locks this flag and all API fields** (`-race` reported 11 data races;
> in production this showed up as sporadic nil-dereference crashes during plugin reload). Upgrade
> if you are on anything earlier.
## Concurrency Contract for Plugin Developers
`PluginSDK` is a **shared object used by multiple goroutines**: the polling, listening and timer
callbacks you start in `Start()` all hold the same `*PluginSDK` and push messages into it, while
the kernel writes its API fields during load/reload. So:
- **Guaranteed by the SDK**: all API accessors (`Memory()`/`DocMemory()`/…), all injection methods,
`SetAutoRestart`/`AutoRestart`, `RegisterTool`/`RegisterStage`, and
`RunStopHandlers`/`RunOnRemoveHandlers` (idempotent; concurrent calls still run it once).
- **Your responsibility**: every field of `StageContext` is exported, and concurrent read/write
must hold `ctx.Lock()`/`ctx.RLock()`. Especially `ctx.Extra` — **concurrent map writes are a
fatal in Go, and `recover` cannot catch it**.
```go
ctx.Lock()
ctx.Extra["mykey"] = value
ctx.FinalText += "supplementary note"
ctx.Unlock()
```
## Restricted SDK vs Full SDK
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
@ -524,13 +372,6 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
| [rss](example/rss) | Go | RSS subscriptions |
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
**Prebuilt example artifacts ship with every release**: besides the 5-platform `hmapdev`, an SDK
release contains the example plugins' `.hmap` files plus `SHA256SUMS`/`MANIFEST.txt`. The reason is
that plugin binaries are **layout-bound** to the kernel (major version + unified-region magic +
`field_count`), so shipping the toolchain without matching artifacts invites installing an old artifact —
which fails the handshake and looks like "the plugin is broken" rather than "the versions don't
match".
## Remote Device SDK
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
@ -615,10 +456,10 @@ ha_transport_t my_transport = {
### Usage
Initialize a project via the `hmapdev` toolchain:
Initialize a project via the `plugindev` toolchain:
```bash
hmapdev init my-adapter --type remotedevice
plugindev init my-adapter --type remotedevice
```
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
@ -828,17 +669,17 @@ curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
### Location
- **SDK Source**: `remotedevice/`
- **hmapdev template**: `hmapdev init --type remotedevice`
- **plugindev template**: `plugindev init --type remotedevice`
## Building & Installing
### Build
```bash
hmapdev build
plugindev build
```
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `hmapdev build --no-bundle` for a single-target build).
Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `plugindev build --no-bundle` for a single-target build).
### Install
@ -856,23 +697,3 @@ curl -X POST http://127.0.0.1:9876/plugins \
```
Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.
## License
The SDK is released under the **MIT license** — see [LICENSE](LICENSE).
**This permissiveness is deliberate**: the SDK is **statically linked** into your plugin
(its source ends up in the plugin binary). Under a copyleft license such as AGPL that would
force plugin authors to open-source their work; MIT exists precisely so that plugin authors
can **pick their own license** — closed-source, commercial or private — with no obligation to
contribute back and no need for any exception or commercial grant. The safety and vitality of
the third-party plugin ecosystem rest on this.
This is sound because the SDK is **fully self-contained**: `go.mod` has zero external
dependencies and `sdk/` imports only the Go standard library (`sync`), never any code from the
core repository — so the MIT grant conflicts with nothing.
Third-party components (Go dependencies: go-sqlite3, gojieba, bubbletea, … — MIT / BSD-3 /
Apache-2.0) keep their own licenses. The platform-side model and inference runtime
(Chinese-CLIP Apache-2.0, ONNX Runtime MIT) are not part of this SDK; their full license texts
ship with the release packages under `/usr/share/doc/homeagent/licenses/`.

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,285 +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` 与旧的三参数方法完全等价,因此存量插件**不需要改、也不需要重编**;
想用新字段的重编即可。
### SDK 1.4.0 → 本轮新增(PluginMgr 运行态启停)⚠️ 有一个例外
本轮给 `PluginMgrAPI` 新增 3 个方法(`StopPlugin` / `StartPlugin` /
`IsPluginRuntimeStopped`,见 `docs/zh/kernel-modularization-design.md` §2.6.7)。
按上面的规则,这属于"**插件调用、内核实现**"的纯追加 ⇒ 无需重编。**但有一个例外**:
| 对象 | 是否需要动 | 原因 |
|---|---|---|
| **已编译的存量插件**(`plugin.bin`) | ❌ 不需要 | 不调新方法就不受影响(与 1.1/1.2 一致) |
| **存量插件的源码要重编** | ⚠️ **需要同步 hmapdev** | `proc_main.go.tmpl` 里的 `procPluginMgr` 必须实现新方法,否则**编译不过** |
| **新插件** | ✅ 自动 | 模板已同步(`hmapdev` 已更新) |
!!! warning "为什么这次和 1.1/1.2 不一样"
1.1/1.2 只改了 `sdk/`(插件**调用**的一侧)与内核实现。
本轮还改了 **`tools/hmapdev/templates/proc_main.go.tmpl`** ——
那是**生成插件代码**的模板,属于"插件**被编译**的一侧"。
`plugin.bin` 里的 `procPluginMgr` 是**编译期**由模板生成的具体类型,
它必须满足 `PluginMgrAPI` 的**全部**方法(Go 接口是精确匹配)。
模板不同步 ⇒ 生成物不满足接口 ⇒ **编译失败**(不是运行期降级)。
⇒ 判据 `internal/plugin/proc/bridge_sync_test.go` 已有 3 条自动守这个
(SDK 方法集 ⇒ 能力表 / Handle 分派 / hmapdev 模板,三处必须同步)。
## 脚手架模板的协议通道纪律(`hmapdev init` 生成物)
子进程插件的 **stdout 是 JSON-RPC 协议通道**,**stderr 才是日志通道**
(SDK 侧 `log.SetOutput(os.Stderr)`)。这条纪律**必须写进脚手架**,
否则每个新插件作者都会踩。
### 曾踩的坑(2026-10-16 实测)
`hmapdev init` 生成的 `plugin.go` 原先用 `fmt.Printf` 打日志 ⇒ 写进 stdout
⇒ 内核 readLoop 收到非 JSON 文本:
```
[proc] final_probe 收到非法 JSON 帧(21 字节): invalid character 'i' in literal false
```
两个后果叠加、都很难定位:
1. 那句日志**丢了**(内核把它当坏帧丢弃);
2. 多了一条看起来像**协议故障**的报错 —— 而真因是"插件自己 printf"。
**A/B 实证**(同一内核,两个插件):
| 插件 | 脚手架 | 非法 JSON 帧 | 日志可见 |
|---|---|---|---|
| `final_probe` | 旧(`fmt.Printf`) | **1 次** | ❌ 丢失 |
| `fixed_probe` | 新(`log.Printf`) | **0 次** | ✅ `[fixed_probe] started` |
### 修法
模板改成 `log.Printf`,并把这条纪律写进生成物的**头部注释**
(让每个插件作者第一眼就看到)。
### 判据(`tools/hmapdev/scaffold_stdout_test.go`,3 条)
1. `TestScaffoldTemplateDoesNotWriteStdout` —— **代码行**里不得有
`fmt.Printf/Println/Print/os.Stdout`
2. `TestScaffoldTemplateUsesStderrLogging` —— 反向钉住:光禁 stdout 不够,
把日志全删也能过第一条 ⇒ 必须**真的**用 `log.Printf`(并 import "log")
3. `TestScaffoldLuaTemplateDoesNotWriteStdout` —— Lua 脚手架同理
**变异 2/2 咬住**:改回 `fmt.Printf` ⇒ 红;删光日志 ⇒ 红。
!!! warning "判据自身踩的坑(值得记住)"
第一版判据直接扫描模板字符串 ⇒ **被模板里的注释判红**(注释**故意**
提到 `fmt.Printf` 来讲这个坑)。必须**先剥注释**再扫代码行。
这是"源码扫描判据"的固有脆弱性:本仓规范要求把坑写进注释,
而注释里必然出现那些符号名 ⇒ **任何时候用字符串扫描判据,
都要先想"注释里会不会也有这个串"**(同类事故见
`docs/zh/kernel-modularization-design.md` §8.6,那里连续骗过三次)。
## 示例插件的协议通道纪律(已修)
上面的脚手架纪律同样适用于**示例插件** —— 它们是**子进程插件**
(`package main` + `plugin.bin`),stdout 即协议通道。
### 生产环境实测的缺陷(不是理论问题)
`journalctl -u homeagent | grep -c "非法 JSON 帧"` ⇒ **1038 条**,来源:
| 插件 | 次数 | 本仓? |
|---|---|---|
| `huawei_smarthome` | 720 | 第三方(内核侧报错就是为它准备的) |
| **`weather`** | 113 | ✅ 示例 |
| **`rss`** | 103 | ✅ 示例 |
| **`calendar`** | 102 | ✅ 示例 |
**根因**:这三个示例插件(以及 `ai_image`)用了 `fmt.Printf` 打日志 ⇒
裸文本进入 stdout 协议流 ⇒ 内核报非法帧并**丢弃该日志**。
### 修法(已完成)
4 个插件共 **14 处** `fmt.Printf` → `log.Printf`,并补 `"log"` import
(`calendar`/`rss`/`weather` 原先没 import log;`ai_image` 已有)。
★ 注意:`internal/plugins/cli` 里也有一处 `fmt.Println`,但它是
**进程内插件**(`plugin.RegisterFactory`,编译进内核),
写的是内核的真实终端 ⇒ **不是缺陷**。判据只针对"子进程插件"
(`package main`)。
### 判据(`tools/hmapdev/example_stdout_test.go`,2 条)
1. `TestExamplePluginsDoNotWriteStdout` —— 扫描全部**子进程**示例插件
(实测 20 个),剥注释后不得含 `fmt.Printf/Println/Print/os.Stdout`;
报错时给出**文件:行号**
2. `TestExamplePluginsUseStderrLogging` —— 反向钉住:至少 weather/rss/calendar
真的在用 `log.Printf`(否则"删光日志"也能过第一条)
### 内核侧:让非法帧报错**可定位**(这是对第三方插件的通用修复)
原报错只有 `收到非法 JSON 帧(N 字节): invalid character 'x'`:
不显示内容(不知是哪句话)、不提原因(看起来像内核故障)。
1038 条报错的定位花了一整轮排查 —— 就因为缺这两样。
现在报错含**三要素**:
```
[proc] bad_probe 收到非法 JSON 帧(19 字节): invalid character 'b' looking for beginning of value
↑ 内容摘要: [bad_probe] started
可能原因:插件往 **stdout** 打印了日志,而 stdout 是JSON-RPC **协议通道**(stderr 才是日志通道)。
修法:把插件里的 fmt.Printf/fmt.Println 改成 log.Printf(脚手架模板 1.4.0 起已修正)。
```
(`summarizeFrame` 做控制字符替换 + 截断,保证单行且不刷屏。)
判据在 `internal/plugin/proc/process_test.go`:
`TestSummarizeFrameIsReadable`、`TestIllegalFrameErrorIsActionable`。
**变异 2/2 咬住**(去掉 summarizeFrame 调用 ⇒ 红;报错不提原因 ⇒ 红)。
## 升级已装的 SDK store 与 hmapdev 二进制(操作步骤)
!!! danger "顺序不能反 —— 否则会装出「二进制新、store 旧」的组合"
**模板是 `//go:embed` 进 hmapdev 二进制的**(`tools/hmapdev/proc_runtime.go`),
而 **SDK 接口(`sdk/`)来自 store**。二者必须**同代**:
| 组合 | 结果 |
|---|---|
| 旧二进制 + 旧 store | 自洽(但拿不到新能力) |
| 新二进制 + 新 store | ✅ 自洽 |
| **新二进制 + 旧 store** | ⚠️ 模板的 `procPluginMgr` 有 6 个方法,旧接口只有 3 个 ⇒ 生成物**多出 3 个方法**(Go 允许,但新方法调用的 `plugin.mgrStop` 内核可能不认) |
| **旧二进制 + 新 store** | ❌ 模板 3 个方法 vs 接口 6 个 ⇒ 生成物**不满足接口** ⇒ **编译失败** |
⇒ **先升级 store,再升级二进制**(或两者同一批做完)。
### 步骤(本机,用源码)
```bash
# 1) 用**当前**hmapdev 把本机源码装成 store(--from 在旧版也支持)
hmapdev sdk install --from <SDK 源码目录> # 例:third_party/homeagent-sdk
# 2) 重编 hmapdev 二进制(模板 embed 进去了,必须重编)
cd <SDK 源码>/tools/hmapdev
CGO_ENABLED=1 go build -tags onnxruntime -o /tmp/hmapdev-new . # TAG_ARGS 视内核而定
# 3) 原子替换(先备份!)
cp /usr/local/bin/hmapdev /usr/local/bin/hmapdev.bak.$(date +%Y%m%d-%H%M%S)
install -m 0755 /tmp/hmapdev-new /usr/local/bin/hmapdev
```
### 验证(三步都要过)
```bash
hmapdev --version # 版本号
strings /usr/local/bin/hmapdev | grep -c mgrStop # ≥1 ⇒ 新模板已 embed
diff <源码>/tools/hmapdev/templates/proc_main.go.tmpl \
~/.homeagent/hmapdev/sdk/<ver>/tools/hmapdev/templates/proc_main.go.tmpl # 必须无差异
```
★ **`diff` 陷阱**:本机 PATH 上的 `diff` 是 HarmonyOS 工具链里的一个
**坏二进制**(`/opt/huawei/harmonyos/ohos-sdk/linux/toolchains/diff`),
对**不同**的文件也返回 **exit 0 且无输出** ⇒ 会给出"已同步"的**假结论**。
用 `cmp` 或 Python 比对,或显式用 `/usr/bin/diff`。
### 判据(自动守"三处同步")
`internal/plugin/proc/bridge_sync_test.go` 有 3 条,任一不同步即红:
1. `TestBridgeCoversEveryPublishedPluginMgrMethod` —— SDK 方法集 ↔ 能力表
2. `TestBridgeDispatchesEveryPluginMethod` —— 内核 Handle 分派
3. `TestHmapdevTemplateImplementsAllPluginMgrMethods` —— **hmapdev 模板**
第 3 条直接读 `third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl`,
所以**源码模板**始终被守;store 里的副本靠上面的 `diff` 验证。
!!! tip "什么时候必须重编"
只有一种情况:**内核跨了主版本**(如 1.x → 2.x)。
主版本跃迁是破坏性变更的定义,此后的帧格式与共享段布局都不兼容,
必须用配套 `hmapdev` 重编。
**中版本跃迁(如 1.1 → 1.2)不需要重编**:1.x 内的新增全部是
「插件调用、内核实现」,不调新面就不受影响。
## 协议兼容性:只认主版本号
**没有独立的协议版本号。** 兼容性由双方版本号的**主版本段**表态:
内核的 `meta.Version` 与插件的 `sdk_version`(插件包里的 `sdk_version` 字段,
即编译时链接的 SDK 版本)。
| 内核 | 插件 SDK | 结果 |
|---|---|---|
| 1.4.0 | 1.0.0 / 1.1.0 / 1.3.0 | ✅ 建链(同主版本) |
| 1.4.0 | 2.0.0 | ❌ 拒绝(跨主版本) |
**为什么不设独立的协议号**:语义版本变动频繁(修 bug、加字段),如果每个
这类改动都要人去升一个额外的号,那个号必然会被漏升而腐烂(历史实证:
曾有一个 `ProtocolVersion` 常量,多次布局变更里只升过一次,而对应的守卫
测试长期处于失败状态、无人察觉)。让兼容性只挂在**始终会被发的那个号**上,
就没有「忘同步」这个失效模式。
跨主版本错配**不会静默失效**——握手时会显式拦下,并提示用配套 `hmapdev` 重编。
此外握手还会比对 `field_count`(StageContext 描述符个数):它决定标志位基址,
不一致会让插件读到错位字段,因此也在握手上显式拒绝。
## 怎么确认自己在用什么
装的 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
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=
github.com/JianFeeeee/homeagentsdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo=
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1 h1:2XEtUgV200uOqbGGEiKT5QyBmZ5aIfNiwm/Ozrm9AOg=
gitcode.com/JianFeeeee/homeagent-sdk v0.7.1/go.mod h1:G48Rgpw9ReTkCf0qBHf50jb5CSeNR2c4OWcgcEm0plo=

View File

@ -3,7 +3,7 @@
package main
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) {

View File

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

View File

@ -12,7 +12,7 @@ import (
"sync"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
@ -47,9 +47,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*a2aSession)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
tp := p.name + "_"
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,

View File

@ -1,45 +0,0 @@
---
name: a2a-tools
description: Agent-to-Agent 协议通信(TCP 服务端 + 客户端)。本插件**常驻自启**:它在监听外部 Agent 请求。
---
# A2A 通信
## 本插件为什么常驻
a2a 插件在 **TCP 端口上监听**其他 Agent 发来的 Tasks/Send 请求,收到后注入给本 agent 处理。
改成按需加载 ⇒ 端口不监听 ⇒ **外部 Agent 的请求无人应答**,
对方只会看到连接失败或超时,而本机**毫无记录**。
## 工具
| 工具 | 用途 |
|---|---|
| `a2a_query` | 向其他 Agent 发请求 |
| `a2a_discover` | 发现可用的对端 |
| `a2a_configure` | 配置对端地址 |
| `a2a_restart` | 重启监听 |
| `a2a_status` | 查监听状态与已知对端 |
## 注意
- 监听地址/端口在插件配置里。端口冲突时 `a2a_status` 会明确报错。
- 出站 `a2a_query` 需要能连上对端,网络不通时明确报错,不要重试到超时。
## ★ 用 plugin_stop / plugin_start,不要用 plugin_disable / plugin_enable
四个工具看着像同义词,语义差别很大:
| 工具 | 作用 | 跨重启 |
|---|---|---|
| `plugin_stop` | **运行态**关闭,现在停掉 | **不影响**,重启照常加载 |
| `plugin_start` | **运行态**拉起,现在开回来 | 不影响 |
| `plugin_disable` | **持久**禁用 | **会记下来**,开机也不自启 |
| `plugin_enable` | 解除持久禁用 | 会记下来 |
**释放上下文用 `plugin_stop`。** 用 `plugin_disable` 会顺手把"开机自启"
永久关掉 —— 用户下次开机发现插件没了,得再手动开回来。
(本插件声明 `auto_start: false`,所以它本来就不自启;但 `plugin_disable`
仍会写进持久禁用清单,让 `plugin_start` 拒绝拉起,只能再用 `plugin_enable` 解。)

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
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
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) {

View File

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

View File

@ -13,7 +13,7 @@ import (
"sync"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
@ -47,9 +47,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*sessionState)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
tp := p.name + "_"
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点。

View File

@ -1,43 +0,0 @@
---
name: acp-tools
description: Agent Client Protocol 通信(服务端 + 客户端)。本插件**常驻自启**:它在监听外部 ACP 请求。
---
# ACP 通信
## 本插件为什么常驻
acp 插件在 **TCP 端口上监听** ACP 客户端的 session/new 等请求。
改成按需加载 ⇒ 端口不监听 ⇒ 外部调用方(如编辑器、其他 agent 框架)
连不上,而本机无任何记录。
## 工具
| 工具 | 用途 |
|---|---|
| `acp_query` | 以 ACP 客户端身份向对端发起请求 |
| `acp_configure` | 配置对端 |
| `acp_status` | 查监听与对端状态 |
## 注意
与 a2a 的区别:A2A 是 Agent 对 Agent 的任务协议,ACP 是
"客户端驱动 agent 会话"的协议(更像 IDE 集成)。两者可能同时在跑。
## ★ 用 plugin_stop / plugin_start,不要用 plugin_disable / plugin_enable
四个工具看着像同义词,语义差别很大:
| 工具 | 作用 | 跨重启 |
|---|---|---|
| `plugin_stop` | **运行态**关闭,现在停掉 | **不影响**,重启照常加载 |
| `plugin_start` | **运行态**拉起,现在开回来 | 不影响 |
| `plugin_disable` | **持久**禁用 | **会记下来**,开机也不自启 |
| `plugin_enable` | 解除持久禁用 | 会记下来 |
**释放上下文用 `plugin_stop`。** 用 `plugin_disable` 会顺手把"开机自启"
永久关掉 —— 用户下次开机发现插件没了,得再手动开回来。
(本插件声明 `auto_start: false`,所以它本来就不自启;但 `plugin_disable`
仍会写进持久禁用清单,让 `plugin_start` 拒绝拉起,只能再用 `plugin_enable` 解。)

View File

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

View File

@ -2,6 +2,6 @@ module ai_image
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
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) {

View File

@ -16,6 +16,5 @@
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": [],
"auto_start": false
"source_dirs": []
}

View File

@ -13,7 +13,7 @@ import (
"strings"
"time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
@ -170,12 +170,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleGenerate)
log.Printf("[%s] started (provider=%s, model=%s)", p.name, p.provider, p.model)
fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model)
return nil
}
func (p *Plugin) Stop() error {
log.Printf("[%s] stopped", p.name)
fmt.Printf("[%s] stopped\n", p.name)
return nil
}

View File

@ -1,50 +0,0 @@
---
name: ai-image-tools
description: AI 图像生成(DALL·E / Stable Diffusion)。按需工具:先 plugin_start ai_image,用完可 plugin_stop。
---
# AI 画图
## 什么时候用
用户要求**生成图片**(画一张、生成插画、做个配图)时。
## 先拉起插件
```
plugin_start name=ai_image
```
| 工具 | 用途 |
|---|---|
| `ai_image_generate` | 按提示词生成图片 |
## 用完可释放
```
plugin_stop name=ai_image
```
## 注意
- 需要配置**上游服务**(DALL·E 或 SD)与各自的密钥;没配时会明确报错,
不要重试或猜一个 URL。
- 生图有成本(按张计费)。一次不满意就调整提示词,不要连续盲试。
- 提示词用**画面描述**(主体/风格/构图/光线),不是命令式句子。
## ★ 用 plugin_stop / plugin_start,不要用 plugin_disable / plugin_enable
四个工具看着像同义词,语义差别很大:
| 工具 | 作用 | 跨重启 |
|---|---|---|
| `plugin_stop` | **运行态**关闭,现在停掉 | **不影响**,重启照常加载 |
| `plugin_start` | **运行态**拉起,现在开回来 | 不影响 |
| `plugin_disable` | **持久**禁用 | **会记下来**,开机也不自启 |
| `plugin_enable` | 解除持久禁用 | 会记下来 |
**释放上下文用 `plugin_stop`。** 用 `plugin_disable` 会顺手把"开机自启"
永久关掉 —— 用户下次开机发现插件没了,得再手动开回来。
(本插件声明 `auto_start: false`,所以它本来就不自启;但 `plugin_disable`
仍会写进持久禁用清单,让 `plugin_start` 拒绝拉起,只能再用 `plugin_enable` 解。)

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
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
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) {

View File

@ -15,6 +15,5 @@
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": [],
"auto_start": false
"source_dirs": []
}

View File

@ -2,59 +2,21 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
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 }
@ -105,24 +67,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
return nil
}
// Stop 取消并等待正在跑的下载。
//
// 空实现的代价(实测):yt-dlp 是分钟级操作,Stop 时它还在跑,
// 而它**继承本插件的 stdout**。内核 Kill 掉本插件后,yt-dlp 变孤儿
// 且继续持有管道写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 整个关停挂死
// 直到 systemd 90 秒超时 SIGKILL(线上症状:只有 bili 报
// "SIGKILL 后 2s 仍未被收割",之后近 90 秒无日志)。
func (p *Plugin) Stop() error {
p.runMu.Lock()
cancel := p.runCancel
p.runMu.Unlock()
if cancel != nil {
cancel()
}
// 等它真的退出:不等的话内核会先释放共享段,孙进程仍在写 stdout。
p.runWG.Wait()
return nil
}
func (p *Plugin) Stop() error { return nil }
type ytdlpFormat struct {
FormatID string `json:"format_id"`
@ -174,20 +119,11 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
var out bytes.Buffer
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
ctx, done := p.trackRun()
defer done()
// CommandContext:Stop 里的 cancel 能直接掐掉它。
// Setpgid:让 yt-dlp 自成进程组,它再拉的 ffmpeg 也在组内,
// killRunGroup 能一次带走整棵子进程树。
cmd := exec.CommandContext(ctx, "yt-dlp", ytdlpArgs...)
cmd := exec.Command("yt-dlp", ytdlpArgs...)
cmd.Stdout = &out
cmd.Stderr = &out
cmd.Env = proxyEnv(p.proxy)
setPgid(cmd)
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()))
}
@ -273,16 +209,12 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
dlArgs = append(dlArgs, "-f", format)
}
dlArgs = append(dlArgs, url)
cmd2 := exec.CommandContext(ctx, "yt-dlp", dlArgs...)
cmd2 := exec.Command("yt-dlp", dlArgs...)
cmd2.Env = proxyEnv(p.proxy)
var dlOut bytes.Buffer
cmd2.Stdout = &dlOut
cmd2.Stderr = &dlOut
setPgid(cmd2)
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()))
}
@ -324,15 +256,6 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
}, 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 {
env := os.Environ()
if proxy != "" {

View File

@ -1,49 +0,0 @@
---
name: bili-tools
description: B站视频清晰度查询与下载(yt-dlp)。按需工具:先 plugin_start bili,用完可 plugin_stop。
---
# B站视频
## 什么时候用
用户要**下载 B站视频**,或想知道某个视频有哪些清晰度可选时。
## 先拉起插件
```
plugin_start name=bili
```
| 工具 | 用途 |
|---|---|
| `bili_video` | 查清晰度列表 / 按指定格式下载 |
## 用完可释放
```
plugin_stop name=bili
```
## 注意
- 依赖 **yt-dlp**,未安装时会明确报错。
- 下载目录由插件配置决定,不要假设是当前工作目录。
- 先查清晰度列表再下载,不要盲目选最高(可能体积很大)。
## ★ 用 plugin_stop / plugin_start,不要用 plugin_disable / plugin_enable
四个工具看着像同义词,语义差别很大:
| 工具 | 作用 | 跨重启 |
|---|---|---|
| `plugin_stop` | **运行态**关闭,现在停掉 | **不影响**,重启照常加载 |
| `plugin_start` | **运行态**拉起,现在开回来 | 不影响 |
| `plugin_disable` | **持久**禁用 | **会记下来**,开机也不自启 |
| `plugin_enable` | 解除持久禁用 | 会记下来 |
**释放上下文用 `plugin_stop`。** 用 `plugin_disable` 会顺手把"开机自启"
永久关掉 —— 用户下次开机发现插件没了,得再手动开回来。
(本插件声明 `auto_start: false`,所以它本来就不自启;但 `plugin_disable`
仍会写进持久禁用清单,让 `plugin_start` 拒绝拉起,只能再用 `plugin_enable` 解。)

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

@ -1,71 +0,0 @@
package main
import (
"context"
"github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/remote"
)
// chromedp v0.20 是**泛型重写**(Action[T] 返回值而不填指针),
// 本文件把旧写法收敛成几个小助手,让业务代码的改动面最小。
//
// 为何不直接在每个调用点改:插件里有 20+ 处 `chromedp.Run(ctx, 步骤...)`,
// 逐处改成「Run 取一个值 / Do 跑多步」会让 diff 淹没在机械改写里,
// 真正要审的「行为变了没有」反而看不清。收敛到助手后,业务代码保持原样。
// chromedpDo 依次跑多个不返回值的步骤(旧 Run(ctx, steps...) 的等价物)。
//
// 对应 v0.20 的 chromedp.Do。放在这里是为了让调用点看起来与旧代码一致。
func chromedpDo(ctx context.Context, steps ...chromedp.Action[chromedp.Void]) error {
return chromedp.Do(ctx, steps...)
}
// newRemoteAllocator 连接一个已在运行的 CDP 端点(如共享浏览器后端)。
//
// v0.20 把它移到了独立子模块:chromedp.NewRemoteAllocator → remote.NewAllocator。
// 子模块存在的理由:核心模块现在只用标准库 + cdproto,需要 websocket 的东西
// 全部搬出去(见 chromedp docs/decisions/2026-10-04-...)。
func newRemoteAllocator(parent context.Context, url string) (context.Context, context.CancelFunc) {
return remote.NewAllocator(parent, url)
}
// chromedpTitle 取当前页标题。
func chromedpTitle(sel ...chromedp.QueryOption) chromedp.Action[string] {
return chromedp.Title()
}
// chromedpLocation 取当前页 URL。
func chromedpLocation() chromedp.Action[string] {
return chromedp.Location()
}
// chromedpOuterHTML 取元素的外部 HTML(旧写法传 &html 接收,现在直接返回)。
func chromedpOuterHTML(sel string, opts ...chromedp.QueryOption) chromedp.Action[string] {
return chromedp.OuterHTML(sel, opts...)
}
// chromedpScreenshot 截图(旧写法传 &buf 接收,现在直接返回)。
func chromedpScreenshot(sel string, opts ...chromedp.QueryOption) chromedp.Action[[]byte] {
return chromedp.Screenshot(sel, opts...)
}
// chromedpFullScreenshot 整页截图。
func chromedpFullScreenshot(quality int) chromedp.Action[[]byte] {
return chromedp.FullScreenshot(quality)
}
// chromedpCaptureScreenshot 截当前**视口**。
//
// ★ 为何不用 chromedp.Screenshot("body")(2026-10-11):后者内部是
// QueryAfter + NodeVisible,而 NodeVisible 在本机的 headless Chromium 上
// 永远不会满足 —— 实测会一直挂到调用方超时(生产表现为 60s 工具超时)。
// 详见 plugin.go 里 handleScreenshot 的注释(含完整对照表)。
func chromedpCaptureScreenshot() chromedp.Action[[]byte] {
return chromedp.CaptureScreenshot()
}
// chromedpEvaluate 执行 JS(返回 Void 表示不关心返回值)。
func chromedpEvaluateVoid(expr string) chromedp.Action[chromedp.Void] {
return chromedp.Evaluate[chromedp.Void](expr, nil)
}

View File

@ -1,107 +0,0 @@
package main
import (
"os"
"strings"
"testing"
)
// 视口截图**必须**走 CaptureScreenshot,不能用 Screenshot(sel)。
//
// # 为什么这条判据存在(2026-10-11 实测)
//
// chromedp 的 Screenshot(sel) 内部是 QueryAfter + **NodeVisible**,
// 而 NodeVisible 在本机的 headless Chromium 上**永远不会满足** —— 它先
// 用 dom.GetBoxModel 拿盒子(实测 300µs 就成功),再用 visibleJS 判定
// (实测返回 true,offsetWidth=480),两步都对,但它仍一直等下去。
//
// 同一后端上的完整对照(实测):
//
// Do(Query body) ✓ Do(WaitReady body) ✓
// Do(WaitVisible body) ✗挂住 Do(WaitVisible html) ✗挂住
// Run(Screenshot html) ✗挂住 Run(FullScreenshot) ✓
// Run(CaptureScreenshot) ✓
//
// 且**旧版 chromedp v0.9.5 同样挂** —— 这不是升级引入的,是既有缺陷。
// 生产日志佐证:成功的都是 agent 传了 full:true(走 FullScreenshot),
// 超时的都是默认分支(走 Screenshot("body"))。
//
// 所以这条判据守的是:不许把 CaptureScreenshot 改回 Screenshot(sel)。
func TestViewportScreenshotUsesCaptureScreenshot(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 j := strings.Index(seg, "\nfunc (p *Plugin) saveScreenshot("); j > 0 {
seg = seg[:j]
}
// ① 视口分支必须用 CaptureScreenshot
if !strings.Contains(seg, "chromedpCaptureScreenshot()") {
t.Error("视口截图必须走 chromedpCaptureScreenshot()(=CaptureScreenshot):\n" +
" 用 Screenshot(\"body\") 会因 NodeVisible 在 headless 下永不满足而挂到超时")
}
// ② 不得再出现挂住的那种写法
if strings.Contains(seg, `chromedpScreenshot(`) {
t.Error("handleScreenshot 里出现了 chromedpScreenshot(sel) —— " +
"该动作在 headless 下必挂(实测),改用 chromedpCaptureScreenshot()")
}
// ③ full 分支仍走 FullScreenshot(那条一直是好的)
if !strings.Contains(seg, "chromedpFullScreenshot(") {
t.Error("full=true 应走 chromedpFullScreenshot(FullScreenshot 实测正常)")
}
}
// TestCompatLayerExists 确认兼容层还在(迁移收敛点)。
//
// 这一层是为了让 20+ 处调用点的迁移不淹没 diff。若有人删掉它而没把
// 调用点改对,编译会红;但若有人把它改成直接透传错误 API,就只会
// 在运行时挂住 —— 所以这里钉住关键几个。
func TestCompatLayerExists(t *testing.T) {
src, err := os.ReadFile("chromedp_compat.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
for _, need := range []string{
"func chromedpDo(",
"func newRemoteAllocator(",
"func chromedpCaptureScreenshot(",
"func chromedpFullScreenshot(",
"func chromedpTitle(",
} {
if !strings.Contains(s, need) {
t.Errorf("chromedp 兼容层缺少 %s", need)
}
}
// 必须用 remote 子模块的分配器(v0.20 把它移出去了)
if !strings.Contains(s, "remote.NewAllocator") {
t.Error("newRemoteAllocator 必须用 remote.NewAllocator(v0.20 移出核心模块)")
}
}
// TestNoLegacyPointerScreenshot 确认没有残留的旧式「传指针接收」写法。
//
// v0.20 的截图动作是**返回值**,传 &buf 会直接编译失败 —— 但这条判据
// 仍值得留着:它锁的是「值语义」这个契约,而不是某次编译结果。
func TestNoLegacyPointerScreenshot(t *testing.T) {
for _, f := range []string{"plugin.go", "chromedp_compat.go"} {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
s := string(b)
if strings.Contains(s, "Screenshot(&") || strings.Contains(s, "FullScreenshot(&") {
t.Errorf("%s 仍用旧式传指针接收截图(v0.20 已改为返回值)", f)
}
}
}

View File

@ -3,21 +3,24 @@ module browser
go 1.25.0
require (
github.com/JianFeeeee/homeagentsdk v0.0.0
github.com/chromedp/cdproto v0.157.8
github.com/chromedp/chromedp v0.20.1
github.com/chromedp/chromedp/remote v0.2.0
gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
github.com/chromedp/chromedp v0.9.5
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732
github.com/chromedp/sysutil v1.0.0
github.com/gobwas/httphead v0.1.0
github.com/gobwas/pool v0.2.1
github.com/gobwas/ws v1.4.0
github.com/gobwas/ws v1.3.2
github.com/josharian/intern v1.0.0
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80
github.com/mailru/easyjson v0.7.7
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde
golang.org/x/sys v0.47.0
golang.org/x/sys v0.16.0
)
require github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 // indirect
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace github.com/JianFeeeee/homeagentsdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,25 +1,15 @@
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8=
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.157.8 h1:Tkq0AjsUlC3Z4sJfHBzV/uAAxnTF/AZyuSTjfV/1gvk=
github.com/chromedp/cdproto v0.157.8/go.mod h1:h68JaIuFr9o3yiayxEYhBdvaxgYO3shDrPSYhNlyfu0=
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
github.com/chromedp/chromedp v0.20.1 h1:983T9wmyM09JtROWlxhLQwljKOQovqt8CVE89W3U0NA=
github.com/chromedp/chromedp v0.20.1/go.mod h1:6z1Y5FdKUxRzxx2qcC5h1xjaBJrSWrZp1mjgITfNZig=
github.com/chromedp/chromedp/remote v0.2.0 h1:Jo5OuOEXMuH3IAOiY5RhXO5JvOpfKMFCZ+cPv6qIksM=
github.com/chromedp/chromedp/remote v0.2.0/go.mod h1:XW8uWIvN8Z4EUxHzYCb6p0lmeTYheYRrEEQpv5FHOH8=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 h1:XGPtjxqp0FS8jj1Z7QGcSOSgyq1fUPjNyJXvi3KfI4A=
github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
@ -31,5 +21,3 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzb
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

View File

@ -3,7 +3,7 @@
package main
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) {

View File

@ -2,7 +2,7 @@
"name": "browser",
"name_zh": "浏览器",
"name_en": "Browser",
"version": "2.4.1",
"version": "2.3.0",
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
"author": "HomeAgent",
"entry": "plugin.so",

View File

@ -6,7 +6,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net"
@ -22,7 +21,7 @@ import (
"time"
"unicode"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
"github.com/chromedp/chromedp"
)
@ -42,24 +41,6 @@ type Plugin struct {
stopOnce sync.Once
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,
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
@ -178,18 +159,6 @@ func errResult(msg string) map[string]interface{} {
return map[string]interface{}{"isError": true, "content": msg}
}
func parseBrowserSessionTimeout(args map[string]interface{}) (time.Duration, error) {
raw := strings.TrimSpace(readArg(args, "timeout", ""))
if raw == "" {
return 0, fmt.Errorf("timeout is required;创建浏览器会话时必须明确指定关闭时长,如 15m 或 2h")
}
timeout, err := time.ParseDuration(raw)
if err != nil || timeout <= 0 {
return 0, fmt.Errorf("invalid timeout %q;请使用大于 0 的时长,如 15m 或 2h", raw)
}
return timeout, nil
}
func newHTTPClient(timeout int, proxyURL string) *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
@ -222,9 +191,6 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client {
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.SetAutoRestart(true)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout", Default: "30", Type: "int",
@ -249,11 +215,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.client = newHTTPClient(p.timeout, p.proxy)
// 持久化 profile 根目录:<data>/browser_profiles
// 截图落盘目录:<data>/browser_screenshots
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
if s2, ok := dd.(string); ok && s2 != "" {
p.profilesDir = filepath.Join(s2, "browser_profiles")
p.screenshotsDir = filepath.Join(s2, "browser_screenshots")
}
}
@ -312,15 +276,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"start", sdk.ToolDef{
Name: tp + "start",
Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout;到期后插件关闭标签页。同来源复用已有标签页时,也按本次 timeout 重新设定关闭时间。",
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
"timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,如 15m、2h;必须大于 0"},
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
},
"required": []string{"timeout"},
},
}, p.handleBrowserStart)
@ -340,16 +303,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"screenshot", sdk.ToolDef{
Name: tp + "screenshot",
Description: "对交互式浏览器当前页面截图,**保存为 PNG 文件并返回绝对路径**。" +
"要看图请拿返回的 path 调 describe_image(path=...);要提取文字调 ocr_image(path=...)。" +
"(不再直接返回 base64——那样会把十几万 token 灌进上下文而你看不见图。)",
Description: "对交互式浏览器当前页面截图。返回 base64 编码的 PNG 图片。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"},
"format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"},
"inline": map[string]interface{}{"type": "boolean", "description": "可选:额外返回 base64/data_uri(默认 false)。仅当调用方拿不到文件系统时才需要;带上会把大段 base64 灌进上下文"},
},
"required": []string{"id"},
},
@ -511,9 +471,7 @@ type searchResult struct {
}
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
// 用 cn.bing.com:www.bing.com 对程序化请求常回 302(同意/重定向页),拿不到结果块。
// 另:Bing 忽略 count 参数,翻页靠 first=,这里保留 count 只为兼容旧调用语义。
u := fmt.Sprintf("https://cn.bing.com/search?q=%s&first=1&count=%d&setlang=zh-CN", url.QueryEscape(query), count)
u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(query), count)
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
@ -523,112 +481,38 @@ func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Bing 返回 HTTP %d(%d 字节)", resp.StatusCode, len(body))
}
results := parseBingResults(string(body), count)
if len(results) == 0 {
// 关键:把「解析不出来」与「真的没结果」区分开。
// 以前两者都变成 "No results found.",版式一变就静默退化成「搜不到」。
return nil, fmt.Errorf("Bing 返回 %d 字节但未解析出结果(可能被反爬或版式变更,可改用 deepsearch 插件)", len(body))
}
return results, nil
return parseBingResults(string(body), count), nil
}
var (
bingBlockRe = regexp.MustCompile(`<li class="b_algo"`)
bingTitleRe = regexp.MustCompile(`(?s)<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
bingAnyLinkRe = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
bingSnipRe = regexp.MustCompile(`(?s)<p class="b_lineclamp[^"]*"[^>]*>(.*?)</p>`)
bingCaptionRe = regexp.MustCompile(`(?s)<div class="b_caption"[^>]*>(.*?)</div>`)
)
// splitBingBlocks 按块标记切分,每块内容延伸到下一个块标记为止。
//
// 不用 `<li class="b_algo"(?s)(.*?)</li>`:结果块内部可能嵌套 <li>(deep links),
// 非贪婪匹配会在错误位置截断;而且块内第一个 <a> 往往是 Bing 的「来源行」,
// 取到的是 `deepin.orghttps://www.deepin.org` 这种垃圾标题。
func splitBingBlocks(pageHTML string) []string {
locs := bingBlockRe.FindAllStringIndex(pageHTML, -1)
if len(locs) == 0 {
return nil
}
blocks := make([]string, 0, len(locs))
for i, loc := range locs {
end := len(pageHTML)
if i+1 < len(locs) {
end = locs[i+1][0]
}
blocks = append(blocks, pageHTML[loc[1]:end])
}
return blocks
}
func parseBingResults(pageHTML string, count int) []searchResult {
if count <= 0 {
count = 5
}
func parseBingResults(html string, count int) []searchResult {
var results []searchResult
for _, block := range splitBingBlocks(pageHTML) {
re := regexp.MustCompile(`<li class="b_algo"(?s)(.*?)</li>`)
matches := re.FindAllStringSubmatch(html, -1)
for _, m := range matches {
if len(results) >= count {
break
}
// 标题:现代 Bing 是 <h2><a href=...>标题</a></h2>;没有 h2 时才退回到块内第一个链接。
var href, title string
if m := bingTitleRe.FindStringSubmatch(block); m != nil {
href, title = m[1], html.UnescapeString(stripTags(m[2]))
} else if m := bingAnyLinkRe.FindStringSubmatch(block); m != nil {
href, title = m[1], html.UnescapeString(stripTags(m[2]))
block := m[1]
var r searchResult
hrefRe := regexp.MustCompile(`<a[^>]+href="([^"]+)"[^>]*>`)
if hm := hrefRe.FindStringSubmatch(block); len(hm) > 1 {
r.URL = hm[1]
}
href = bingRealURL(html.UnescapeString(href))
// 摘要:新版在 p.b_lineclamp*,旧版在 div.b_caption > p
var snippet string
if m := bingSnipRe.FindStringSubmatch(block); m != nil {
snippet = html.UnescapeString(stripTags(m[1]))
} else if m := bingCaptionRe.FindStringSubmatch(block); m != nil {
snippet = html.UnescapeString(stripTags(m[1]))
titleRe := regexp.MustCompile(`<a[^>]+href="[^"]+"[^>]*>(.*?)</a>`)
if tm := titleRe.FindStringSubmatch(block); len(tm) > 1 {
r.Title = stripTags(tm[1])
}
title, snippet = strings.TrimSpace(title), strings.TrimSpace(snippet)
if href == "" || title == "" || !strings.HasPrefix(href, "http") {
continue
snipRe := regexp.MustCompile(`<div class="b_caption">.*?<p>(.*?)</p>`)
if sm := snipRe.FindStringSubmatch(block); len(sm) > 1 {
r.Snippet = stripTags(sm[1])
}
if r.URL != "" && r.Title != "" {
results = append(results, r)
}
results = append(results, searchResult{Title: title, URL: href, Snippet: snippet})
}
return results
}
// bingRealURL 解开 Bing 的跳转包装:/ck/a?...&u=a1<base64url>&... → 真实 URL。
// 不解的话模型拿到的是 `https://cn.bing.com/ck/a?...` 这种不可读地址。
func bingRealURL(href string) string {
href = strings.TrimSpace(href)
if href == "" {
return ""
}
if !strings.Contains(href, "/ck/a") && !strings.Contains(href, "u=a1") {
return href
}
u, err := url.Parse(href)
if err != nil {
return href
}
raw := u.Query().Get("u")
if !strings.HasPrefix(raw, "a1") {
return href
}
b64 := raw[2:]
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding} {
if dec, err := enc.DecodeString(b64); err == nil {
s := string(dec)
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
return s
}
}
}
return href
}
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
query := readArg(args, "query", "")
if query == "" {
@ -841,30 +725,25 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
ok, needInstall, _ := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := newRemoteAllocator(context.Background(), cdpEndpoint)
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
defer remoteCancel()
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
defer tabCancel()
steps := []chromedp.Action[chromedp.Void]{
actions := []chromedp.Action{
chromedp.Navigate(rawURL),
chromedp.WaitReady("body"),
}
if waitSec > 0 {
steps = append(steps, chromedp.Sleep(time.Duration(waitSec)*time.Second))
actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second))
}
actions = append(actions,
chromedp.Title(&title),
chromedp.OuterHTML("html", &html),
)
// 整体限时 30s,防慢页拖死工具
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
defer rcancel()
// 标题与 HTML 现在是**返回值**,不再传指针接收。
// 顺序仍是「先跑完交互步骤,再取值」——取值动作本身也会被 Do 依次执行。
steps = append(steps, chromedp.WaitReady("html"))
if err := chromedpDo(rctx, steps...); err == nil {
if t, terr := chromedp.Run(rctx, chromedpTitle()); terr == nil {
title = t
}
if h, herr := chromedp.Run(rctx, chromedpOuterHTML("html")); herr == nil {
html = h
}
if err := chromedp.Run(rctx, actions...); err == nil {
rendered = true
} else {
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
@ -973,7 +852,7 @@ func (p *Plugin) ensureBackend() (bool, bool, error) {
// sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
if err := chromedpDo(tabCtx); err != nil {
if err := chromedp.Run(tabCtx); err != nil {
tabCancel()
return nil, nil, err
}
@ -994,7 +873,7 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
}
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, _ := chromedp.NewContext(allocCtx)
if err := chromedpDo(ctx); err != nil {
if err := chromedp.Run(ctx); err != nil {
cancelAlloc()
return nil, nil, nil, err
}
@ -1002,9 +881,10 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
}
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
timeout, err := parseBrowserSessionTimeout(args)
timeoutStr := readArg(args, "timeout", "10m")
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
return errResult(err.Error()), nil
timeout = 10 * time.Minute
}
source := readArg(args, "source", "")
@ -1019,19 +899,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
s.mu.Lock()
id := s.id
cur := s.currentURL
s.createdAt = time.Now()
s.timeout = timeout
closesAt := s.createdAt.Add(timeout)
s.mu.Unlock()
p.mu.Unlock()
log.Printf("[%s] reused browser session %s: timeout=%v closes_at=%s source=%s", p.name, id, timeout, closesAt.Format(time.RFC3339), source)
return map[string]interface{}{
"id": id,
"status": "reused",
"url": cur,
"timeout": timeout.String(),
"closes_at": closesAt.Format(time.RFC3339),
"note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间",
"note": "已复用本来源的现有标签页(登录态全机共享)",
}, nil
}
}
@ -1042,14 +916,14 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
// 路径一:systemd 托管的共享后端(主路径)
ok, needInstall, berr := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := newRemoteAllocator(context.Background(), cdpEndpoint)
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
probe, _ := chromedp.NewContext(remoteCtx)
if err := chromedpDo(probe); err != nil {
if err := chromedp.Run(probe); err != nil {
remoteCancel()
return errResult("connect to browser backend failed: " + err.Error()), nil
}
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
if err := chromedpDo(tabCtx); err != nil {
if err := chromedp.Run(tabCtx); err != nil {
remoteCancel()
return errResult("open tab failed: " + err.Error()), nil
}
@ -1085,7 +959,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
initURL := readArg(args, "url", "")
if initURL != "" {
if err := chromedpDo(session.ctx,
if err := chromedp.Run(session.ctx,
chromedp.Navigate(initURL),
chromedp.WaitReady("body"),
); err != nil {
@ -1098,15 +972,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
session.currentURL = initURL
}
closesAt := session.createdAt.Add(timeout)
log.Printf("[%s] created browser session %s: url=%s timeout=%v closes_at=%s source=%s", p.name, id, initURL, timeout, closesAt.Format(time.RFC3339), source)
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
return map[string]interface{}{
"id": id,
"status": "created",
"mode": "shared-backend",
"url": initURL,
"timeout": timeout.String(),
"closes_at": closesAt.Format(time.RFC3339),
}, nil
}
@ -1131,7 +1003,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error
return errResult(err.Error()), nil
}
waitSec := int64(readArg(args, "wait", float64(2)))
if err := chromedpDo(s.ctx,
if err := chromedp.Run(s.ctx,
chromedp.Navigate(rawURL),
chromedp.WaitReady("body"),
chromedp.Sleep(time.Duration(waitSec)*time.Second),
@ -1163,98 +1035,21 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
var buf []byte
var err error
if full {
buf, err = chromedp.Run(s.ctx, chromedpFullScreenshot(90))
err = chromedp.Run(s.ctx, chromedp.FullScreenshot(&buf, 90))
} else {
// ★ 视口截图用 CaptureScreenshot,**不能用 Screenshot("body")**(2026-10-11 修)。
//
// Screenshot 内部是 QueryAfter + NodeVisible,而 NodeVisible 在
// headless Chromium 下**永远不满足**:它先用 dom.GetBoxModel 拿到盒子
// (实测 300µs 就成功),再用 visibleJS 判定 —— 而 visibleJS 实测
// 返回 true(offsetWidth=480)。两者都对,但它仍会一直等下去。
//
// 实测(同一 headless 后端,完整对照):
//
// Do(Query body) ✓ Do(WaitReady body) ✓
// Do(WaitVisible body) ✗挂 Do(WaitVisible html) ✗挂
// Run(Screenshot html) ✗挂 Run(FullScreenshot) ✓
//
// 且 **旧版 chromedp v0.9.5 同样挂** —— 这不是升级引入的,是早就存在的
// 缺陷。生产日志佐证:成功的 8 次都是 agent 传了 full:true(走
// FullScreenshot),超时的 5 次都是默认分支。
//
// CaptureScreenshot 截整个视口,不经过 NodeVisible 那条链,实测 70KB / 正常返回。
// 语义也更对:「不传 full 就只截当前视口」本来就是 API 文档的含义。
buf, err = chromedp.Run(s.ctx, chromedpCaptureScreenshot())
err = chromedp.Run(s.ctx, chromedp.Screenshot("body", &buf))
}
if err != nil {
return errResult("screenshot failed: " + err.Error()), nil
}
// ★ 默认落盘并返回**路径**(2026-10-10 改)。
//
// 为何不改回直接回 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{}{
b64 := base64.StdEncoding.EncodeToString(buf)
return map[string]interface{}{
"status": "ok",
"format": format,
"size": len(buf),
// path 是**主要返回值**:模型应拿它去调 describe_image / ocr_image,
// 或交给 files_* / cmd_run 做后续处理。
"path": path,
// 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
"base64": b64,
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
}, nil
}
func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
@ -1270,17 +1065,15 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
if v, ok := args["max_chars"].(float64); ok && v > 0 {
maxChars = int(v)
}
html, err := chromedp.Run(s.ctx, chromedpOuterHTML("html"))
if err != nil {
var html string
if err := chromedp.Run(s.ctx, chromedp.OuterHTML("html", &html)); err != nil {
return errResult("get html failed: " + err.Error()), nil
}
var title, currentURL string
if t, terr := chromedp.Run(s.ctx, chromedpTitle()); terr == nil {
title = t
}
if u, uerr := chromedp.Run(s.ctx, chromedpLocation()); uerr == nil {
currentURL = u
}
chromedp.Run(s.ctx,
chromedp.Title(&title),
chromedp.Location(&currentURL),
)
truncated := len(html) > maxChars
if truncated {
html = html[:maxChars] + "\n\n[HTML truncated]"
@ -1304,7 +1097,7 @@ func (p *Plugin) handleClick(args map[string]interface{}) (interface{}, error) {
if err != nil {
return errResult(err.Error()), nil
}
if err := chromedpDo(s.ctx,
if err := chromedp.Run(s.ctx,
chromedp.WaitVisible(selector),
chromedp.Click(selector),
); err != nil {
@ -1324,7 +1117,7 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) {
if err != nil {
return errResult(err.Error()), nil
}
actions := []chromedp.Action[chromedp.Void]{
actions := []chromedp.Action{
chromedp.WaitVisible(selector),
chromedp.Click(selector, chromedp.NodeVisible),
chromedp.Clear(selector),
@ -1337,7 +1130,7 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) {
if submit {
actions = append(actions, chromedp.SendKeys(selector, "\r"))
}
if err := chromedpDo(s.ctx, actions...); err != nil {
if err := chromedp.Run(s.ctx, actions...); err != nil {
return errResult("type failed (element may not exist or page blocking): " + err.Error()), nil
}
return map[string]interface{}{"status": "ok", "selector": selector}, nil
@ -1367,7 +1160,7 @@ func (p *Plugin) handleScroll(args map[string]interface{}) (interface{}, error)
default:
return errResult("dir 必须是 up/down/left/right"), nil
}
if err := chromedpDo(s.ctx, chromedpEvaluateVoid(scrollJS)); err != nil {
if err := chromedp.Run(s.ctx, chromedp.Evaluate(scrollJS, nil)); err != nil {
return errResult("scroll failed: " + err.Error()), nil
}
return map[string]interface{}{"status": "ok", "dir": dir, "amount": amount}, nil
@ -1411,20 +1204,13 @@ func (p *Plugin) cleanupLoop() {
case <-p.stopCh:
return
case <-ticker.C:
now := time.Now()
p.mu.Lock()
for id, s := range p.sessions {
s.mu.Lock()
closesAt := s.createdAt.Add(s.timeout)
expired := !now.Before(closesAt)
s.mu.Unlock()
if expired {
log.Printf("[%s] cleanup: browser session %s reached agent-specified close time %s", p.name, id, closesAt.Format(time.RFC3339))
if time.Since(s.createdAt) >= s.timeout {
log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
delete(p.sessions, id)
s.Close()
// NoMemory:会话生命周期通知,不是记忆内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true})
p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
}
}
p.mu.Unlock()

View File

@ -1,213 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
)
func TestParseBrowserSessionTimeoutRequiresExplicitValue(t *testing.T) {
_, err := parseBrowserSessionTimeout(map[string]interface{}{})
if err == nil || !strings.Contains(err.Error(), "timeout is required") {
t.Fatalf("expected required timeout error, got %v", err)
}
}
func TestParseBrowserSessionTimeoutAcceptsPositiveDuration(t *testing.T) {
got, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": "2h30m"})
if err != nil {
t.Fatal(err)
}
if got != 2*time.Hour+30*time.Minute {
t.Fatalf("timeout=%v", got)
}
}
func TestParseBrowserSessionTimeoutRejectsInvalidOrNonPositive(t *testing.T) {
for _, value := range []string{"invalid", "0s", "-1m"} {
if _, err := parseBrowserSessionTimeout(map[string]interface{}{"timeout": value}); err == nil {
t.Errorf("timeout %q should be rejected", value)
}
}
}
func TestBrowserStartReuseResetsExplicitCloseTime(t *testing.T) {
p := &Plugin{
name: "browser",
sessions: map[string]*BrowserSession{
"browser_1": {
id: "browser_1",
shared: true,
sessionKey: "qq",
createdAt: time.Now().Add(-time.Hour),
timeout: time.Minute,
currentURL: "https://example.com",
},
},
}
before := time.Now()
result, err := p.handleBrowserStart(map[string]interface{}{"source": "qq", "timeout": "3h"})
if err != nil {
t.Fatal(err)
}
out := result.(map[string]interface{})
if out["status"] != "reused" || out["timeout"] != "3h0m0s" {
t.Fatalf("unexpected result: %#v", out)
}
s := p.sessions["browser_1"]
if s.timeout != 3*time.Hour || s.createdAt.Before(before) {
t.Fatalf("deadline not reset: createdAt=%v timeout=%v", s.createdAt, s.timeout)
}
}
// ── Bing 解析器(2026-09 版式)─────────────────────────────
//
// 背景:旧实现把块内**第一个 <a>** 当标题 —— 拿到的是 Bing 的「来源行」
// `deepin.orghttps://www.deepin.org`;摘要正则 `<div class="b_caption">.*?<p>`
// 对现代 Bing 命中 0/N(摘要已迁到 p.b_lineclamp*),于是结果「有标题没摘要」,
// 模型只好反复换词重搜。夹具 testdata/bing_cn.html 是真实 cn.bing.com 响应裁剪。
func TestParseBingResultsRealBingHTML(t *testing.T) {
page, err := os.ReadFile("testdata/bing_cn.html")
if err != nil {
t.Fatalf("读取夹具失败: %v", err)
}
results := parseBingResults(string(page), 3)
if len(results) != 3 {
t.Fatalf("应解析出 3 条,实际 %d 条: %+v", len(results), results)
}
for i, r := range results {
if !strings.HasPrefix(r.URL, "http") {
t.Errorf("第 %d 条 URL 不是真实地址: %q", i+1, r.URL)
}
if strings.Contains(r.Title, "http") || strings.Contains(r.Title, "://") {
t.Errorf("第 %d 条标题混入了 URL(旧 bug 的典型症状): %q", i+1, r.Title)
}
if r.Snippet == "" {
t.Errorf("第 %d 条没有摘要(旧 bug 的典型症状): %+v", i+1, r)
}
}
// 第一条必须与样本里的真实结果一致
if results[0].URL != "https://www.deepin.org/" {
t.Errorf("第一条 URL 应为 https://www.deepin.org/,实际 %q", results[0].URL)
}
if !strings.Contains(results[0].Title, "deepin") {
t.Errorf("第一条标题不对: %q", results[0].Title)
}
if len(results[0].Snippet) < 10 || strings.Contains(results[0].Snippet, "://") {
t.Errorf("第一条摘要不对(应是有内容的文本): %q", results[0].Snippet)
}
}
// 块内嵌套 <li>(deep links)时不能截断 —— 旧的 `<li class="b_algo"(?s)(.*?)</li>` 会在此翻车
func TestParseBingResultsNestedLiKeepsResult(t *testing.T) {
page := `<ol id="b_results"><li class="b_algo" data-id iid=SERP.1>` +
`<h2><a href="https://a.example/x" h="ID=SERP,1">真标题</a></h2>` +
`<div class="b_caption"><p class="b_lineclamp2">真摘要</p></div>` +
`<div><ul><li><a href="https://sub.example/deeplink">子链接</a></li></ul></div>` +
`</li><li class="b_algo"><h2><a href="https://b.example/y">第二条</a></h2>` +
`<p class="b_lineclamp3">摘要二</p></li></ol>`
rs := parseBingResults(page, 5)
if len(rs) != 2 {
t.Fatalf("应解析 2 条,实际 %d 条: %+v", len(rs), rs)
}
if rs[0].URL != "https://a.example/x" || rs[0].Title != "真标题" || rs[0].Snippet != "真摘要" {
t.Errorf("第一条解析错误: %+v", rs[0])
}
if rs[1].Title != "第二条" || rs[1].Snippet != "摘要二" {
t.Errorf("第二条(无 b_caption,摘要走 b_lineclamp3)解析错误: %+v", rs[1])
}
}
func TestBingRealURLDecodesRedirectWrapper(t *testing.T) {
// Bing 跳转包装:/ck/a?...&u=a1<base64url>
wrapped := "/ck/a?!&&p=abc&u=a1aHR0cHM6Ly93d3cuZGVlcGluLm9yZy96aC9EZWVwaW4v&ntb=1"
if got := bingRealURL(wrapped); got != "https://www.deepin.org/zh/Deepin/" {
t.Errorf("未解开跳转包装: %q", got)
}
if got := bingRealURL("https://direct.example/p"); got != "https://direct.example/p" {
t.Errorf("直链不应被改动: %q", got)
}
// 解不开时保守返回原值,不能返回空
bad := "/ck/a?u=a1!!!!"
if got := bingRealURL(bad); got == "" {
t.Errorf("解不开时应保留原值,实际返回空")
}
}
// roundTripFunc 把任意请求转给本地测试服务器,从而离线测 bingSearch 的完整路径
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestBingSearchReportsParseFailureInsteadOfEmptyResult(t *testing.T) {
var seenURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("<html><body>no result blocks here</body></html>"))
}))
defer srv.Close()
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
seenURL = r.URL.String()
return srv.Client().Transport.RoundTrip(&http.Request{
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
})
})}}
if _, err := p.bingSearch("任意查询", 5); err == nil {
t.Fatal("解析不出结果时必须报错,而不是伪装成「没有结果」")
} else if !strings.Contains(err.Error(), "未解析出结果") {
t.Errorf("错误信息应说明是解析失败: %v", err)
}
// 数据源必须是 cn.bing.com(www.bing.com 对程序化请求回 302,拿不到结果块)
if !strings.Contains(seenURL, "cn.bing.com") {
t.Errorf("应请求 cn.bing.com,实际 %q", seenURL)
}
if strings.Contains(seenURL, "www.bing.com") {
t.Errorf("不应再请求 www.bing.com: %q", seenURL)
}
}
// 正常路径:能解析出结果时返回结果且不报错
func TestBingSearchParsesFixtureThroughClient(t *testing.T) {
page, err := os.ReadFile("testdata/bing_cn.html")
if err != nil {
t.Fatalf("读取夹具失败: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(page)
}))
defer srv.Close()
p := &Plugin{name: "browser", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return srv.Client().Transport.RoundTrip(&http.Request{
Method: r.Method, URL: mustParseURL(t, srv.URL), Header: r.Header, Body: r.Body,
})
})}}
results, err := p.bingSearch("deepin", 2)
if err != nil {
t.Fatalf("应成功,实际 %v", err)
}
if len(results) != 2 {
t.Fatalf("应返回 2 条(count 生效),实际 %d", len(results))
}
if results[0].Snippet == "" {
t.Errorf("摘要不应为空: %+v", results[0])
}
}
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("解析测试 URL 失败: %v", err)
}
return u
}

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,55 +0,0 @@
---
name: browser-tools
description: 统一浏览器能力(搜索/HTTP 抓取/无头渲染/CDP 交互)。本插件**常驻自启**:它有 ticker 与长命令回调,会主动注入。
---
# 浏览器
## 本插件为什么常驻
browser 插件有两处**主动注入**:
1. **30 秒 ticker** —— 检查长任务(渲染、下载)的进度
2. **长时间命令的完成回调** —— 命令跑完后把结果注入回来
改成按需加载 ⇒ 长任务还在跑、插件却被卸载 ⇒ 结果无人接收,
或者在跑的任务变成孤儿进程。
## 工具
| 组 | 工具 |
|---|---|
| 轻量 | `search` `fetch`(HTTP 抓取,快) |
| 渲染 | `render` `html`(无头浏览器,慢) |
| 交互 | `start` `navigate` `screenshot` `click` `type` `scroll` `close` |
| 环境 | `install`(装浏览器依赖) |
## 选哪个
| 需求 | 用 |
|---|---|
| 只要页面文字 | `fetch`(最快,不执行 JS) |
| 页面靠 JS 渲染 | `render` |
| 要点击/填表/截图 | `start` → `navigate` → 交互 → `close` |
## 注意
- 交互式会话**用完必须 `close`**,否则浏览器进程一直占内存。
- `install` 只在缺依赖时用,不要每次都跑。
## ★ 用 plugin_stop / plugin_start,不要用 plugin_disable / plugin_enable
四个工具看着像同义词,语义差别很大:
| 工具 | 作用 | 跨重启 |
|---|---|---|
| `plugin_stop` | **运行态**关闭,现在停掉 | **不影响**,重启照常加载 |
| `plugin_start` | **运行态**拉起,现在开回来 | 不影响 |
| `plugin_disable` | **持久**禁用 | **会记下来**,开机也不自启 |
| `plugin_enable` | 解除持久禁用 | 会记下来 |
**释放上下文用 `plugin_stop`。** 用 `plugin_disable` 会顺手把"开机自启"
永久关掉 —— 用户下次开机发现插件没了,得再手动开回来。
(本插件声明 `auto_start: false`,所以它本来就不自启;但 `plugin_disable`
仍会写进持久禁用清单,让 `plugin_start` 拒绝拉起,只能再用 `plugin_enable` 解。)

File diff suppressed because one or more lines are too long

View File

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

View File

@ -2,6 +2,6 @@ module calendar
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
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) {

View File

@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@ -12,7 +11,7 @@ import (
"sync"
"time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
const (
@ -277,16 +276,13 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
// 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
log.Printf("[%s] mkdir %s: %v", p.name, p.dataDir, err)
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
}
p.loadEvents()
@ -402,7 +398,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.wg.Add(1)
go p.remindLoop()
log.Printf("[%s] started (%d events)", p.name, len(p.events))
fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events))
return nil
}
@ -410,7 +406,7 @@ func (p *Plugin) Stop() error {
p.remindTicker.Stop()
close(p.stopCh)
p.wg.Wait()
log.Printf("[%s] stopped", p.name)
fmt.Printf("[%s] stopped\n", p.name)
return nil
}
@ -524,8 +520,7 @@ func (p *Plugin) checkReminders() {
p.mu.Unlock()
for _, msg := range injectMsgs {
// NoMemory:日程到点提醒,不是记忆内容。
p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})
p.sdk.InjectInterruptText("calendar", "calendar", msg)
}
}
@ -612,9 +607,9 @@ func (p *Plugin) cleanupData() {
p.mu.Lock()
defer p.mu.Unlock()
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
log.Printf("[calendar] onRemove cleanup: %v", err)
fmt.Printf("[calendar] onRemove cleanup: %v\n", err)
} else {
log.Printf("[calendar] onRemove removed %s", p.eventsFile())
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
}
}

View File

@ -1,47 +0,0 @@
---
name: calendar-tools
description: 日历事件与提醒。本插件**常驻自启**:它定时检查提醒并主动通知。
---
# 日历
## 本插件为什么常驻
日历插件有一个 **30 秒的提醒 ticker**。到点的事件会主动注入给 agent,
让 agent 提醒用户。
改成按需加载 ⇒ 没有 ticker ⇒ **提醒永远不会响**。而"提醒不响"比"没有提醒功能"
更糟:用户以为设了闹钟,实际上什么都没发生。
## 工具
| 工具 | 用途 |
|---|---|
| `calendar_event_add` | 新建事件(可带提醒) |
| `calendar_event_list` | 列出事件 |
| `calendar_event_update` | 修改事件 |
| `calendar_event_delete` | 删除事件 |
| `calendar_today` / `week` / `month` | 按范围查看 |
| `calendar_search` | 搜索事件 |
## 注意
- 时间必须确认**时区**。用户说"明天三点"时,先确认是本地时间。
- 重复事件要明确是每天/每周/每月。
## ★ 用 plugin_stop / plugin_start,不要用 plugin_disable / plugin_enable
四个工具看着像同义词,语义差别很大:
| 工具 | 作用 | 跨重启 |
|---|---|---|
| `plugin_stop` | **运行态**关闭,现在停掉 | **不影响**,重启照常加载 |
| `plugin_start` | **运行态**拉起,现在开回来 | 不影响 |
| `plugin_disable` | **持久**禁用 | **会记下来**,开机也不自启 |
| `plugin_enable` | 解除持久禁用 | 会记下来 |
**释放上下文用 `plugin_stop`。** 用 `plugin_disable` 会顺手把"开机自启"
永久关掉 —— 用户下次开机发现插件没了,得再手动开回来。
(本插件声明 `auto_start: false`,所以它本来就不自启;但 `plugin_disable`
仍会写进持久禁用清单,让 `plugin_start` 拒绝拉起,只能再用 `plugin_enable` 解。)

View File

@ -1,13 +0,0 @@
# cmd — 命令执行插件
沙箱工作目录里执行 shell 命令。按需加载(`auto_start: false`):
由 agent 经 `plugin_enable("cmd")` 拉起,用完 `plugin_stop` 释放。
源自内核内置插件(2026-10-19 外置,判定标准见主仓 ARCHITECTURE
「内置插件的判定标准」:它只用公开 SDK,无必须内置的理由)。
## 构建
hmapdev build
产物:`dist/cmd_linux_amd64.hmap`

View File

@ -1,7 +0,0 @@
module homeagent-cmd
go 1.25.0
require github.com/JianFeeeee/homeagentsdk v0.0.0
replace github.com/JianFeeeee/homeagentsdk => ../../

View File

@ -1,18 +0,0 @@
{
"name": "cmd",
"name_zh": "命令执行",
"name_en": "cmd",
"version": "1.0.0",
"description": "沙箱内执行",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"cmd"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": false,
"replaces": {},
"source_dirs": [],
"auto_start": false
}

View File

@ -1,302 +0,0 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// isWindows 缓存运行时检测结果
var isWindows = runtime.GOOS == "windows"
// shellUnquote 拆解命令字符串,处理单引号/双引号包裹的参数
func shellUnquote(s string) []string {
var args []string
var cur strings.Builder
inSingle := false
inDouble := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '\'' && !inDouble:
inSingle = !inSingle
case c == '"' && !inSingle:
inDouble = !inDouble
case (c == ' ' || c == '\t') && !inSingle && !inDouble:
if cur.Len() > 0 {
args = append(args, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
args = append(args, cur.String())
}
return args
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
}
type cmdRecord struct {
Timestamp time.Time `json:"timestamp"`
Command string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Workdir string `json:"workdir"`
Timeout string `json:"timeout"`
Duration string `json:"duration"`
Status string `json:"status"`
}
const maxHistory = 100
type Plugin struct {
name string
defaultTimeout string
maxOutput int
mu sync.Mutex
history []cmdRecord
}
func New(name string) *Plugin {
return &Plugin{name: name}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_timeout", Type: "string", DisplayName: "默认命令超时",
Description: "命令执行的默认超时时间,例如 30s, 1m, 5m(默认 30s)",
Default: "30s",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "max_output_bytes", Type: "int", DisplayName: "最大输出字节数",
Description: "命令输出的最大字节数,超出部分将被截断(默认 32000)",
Default: "32000",
})
p.maxOutput = 32000
if v, _ := s.Settings().Get("max_output_bytes"); v != nil {
if s, ok := v.(string); ok && s != "" {
if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 {
}
}
}
p.defaultTimeout = "30s"
if v, _ := s.Settings().Get("default_timeout"); v != nil {
if s, ok := v.(string); ok && s != "" {
p.defaultTimeout = s
}
}
s.RegisterTool("cmd_run", sdk.ToolDef{
Name: "cmd_run",
Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。经完整 bash 执行,支持管道、分号、&&、命令替换、heredoc、重定向;不支持交互(vim/ssh/top 这类需要持续输入的程序请用 terminal_create,不要硬等它退出)。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{
"type": "string",
"description": "要执行的命令",
},
"timeout": map[string]interface{}{
"type": "string",
"description": "超时时间,例如 10s, 1m, 30s(默认 30s)",
},
"workdir": map[string]interface{}{
"type": "string",
"description": "工作目录(可选,默认由 core.agent.workdir 配置决定)",
},
},
"required": []string{"command"},
},
// 执行体只依赖入参;唯一共享 p.history 由 recordCmd 加 p.mu 保护
ParallelSafe: true,
}, func(args map[string]interface{}) (interface{}, error) {
command, _ := args["command"].(string)
if command == "" {
return map[string]interface{}{"error": "command is required"}, nil
}
timeoutStr, _ := args["timeout"].(string)
if timeoutStr == "" {
timeoutStr = p.defaultTimeout
}
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("invalid timeout %q: %v", timeoutStr, err)}, nil
}
workdir, _ := args["workdir"].(string)
if workdir == "" {
if sett := s.Settings(); sett != nil {
if v, _ := sett.GetCore("core.agent.workdir"); v != nil {
if str, ok := v.(string); ok && str != "" {
workdir = str
}
}
}
}
tStart := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Linux/Unix 经 /bin/sh -c 执行完整 shell 语法:管道、分号、&&、
// 命令替换、heredoc、重定向全部可用。旧实现 shellUnquote 拆词后
// 直接 exec,导致 pwd; ls / 变成执行名为 "pwd;" 的程序(exit -1)、
// heredoc 被截断——agent 多次反馈"命令解析奇怪"即此。
var cmd *exec.Cmd
if isWindows {
execCmd := "chcp 65001>nul & " + command
cmd = exec.CommandContext(ctx, "cmd.exe", "/d", "/c", execCmd)
} else {
shell := "/bin/sh"
if _, err := os.Stat("/bin/bash"); err == nil {
shell = "/bin/bash" // bash 支持更完整的语法(数组、[[ ]] 等)
}
cmd = exec.CommandContext(ctx, shell, "-c", command)
}
if workdir != "" {
cmd.Dir = workdir
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
rec := cmdRecord{Timestamp: tStart, Command: command, Workdir: workdir, Timeout: timeoutStr}
exitCode := -1
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
rec.Status = "timeout"
rec.Stdout = p.truncateOutput(stdout.String())
rec.Stderr = p.truncateOutput(stderr.String())
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
p.recordCmd(rec)
return map[string]interface{}{
"status": "timeout",
"stdout": rec.Stdout,
"stderr": rec.Stderr,
"error": fmt.Sprintf("命令执行超时(%s)", timeoutStr),
}, nil
}
}
if cmd.ProcessState != nil {
exitCode = cmd.ProcessState.ExitCode()
}
rec.Status = "ok"
rec.Stdout = p.truncateOutput(stdout.String())
rec.Stderr = p.truncateOutput(stderr.String())
rec.ExitCode = exitCode
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
p.recordCmd(rec)
return map[string]interface{}{
"status": "ok",
"stdout": rec.Stdout,
"stderr": rec.Stderr,
"exit_code": exitCode,
"command": command,
}, nil
})
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
if len(ctx.ToolCalls) == 0 {
return nil
}
tc := ctx.ToolCalls[0]
if tc.Name != "cmd_run" {
return nil
}
command, _ := tc.Arguments["command"].(string)
if command == "" {
return nil
}
if blocked, reason := isDangerousCommand(command); blocked {
ctx.Lock()
ctx.Response = &reason
ctx.Unlock()
return nil
}
return nil
}, sdk.StageScopeOwnTools)
return nil
}
func (p *Plugin) Stop() error {
return nil
}
func (p *Plugin) recordCmd(r cmdRecord) {
p.mu.Lock()
defer p.mu.Unlock()
p.history = append(p.history, r)
if len(p.history) > maxHistory {
p.history = p.history[len(p.history)-maxHistory:]
}
}
func (p *Plugin) truncateOutput(s string) string {
maxLen := p.maxOutput
if maxLen <= 0 {
maxLen = 32000
}
if len(s) > maxLen {
return s[:maxLen] + fmt.Sprintf("\n... [输出被截断,共 %d 字节]", len(s))
}
return strings.TrimRight(s, "\n")
}
func isDangerousCommand(command string) (bool, string) {
pid := os.Getpid()
pidStr := fmt.Sprintf("%d", pid)
lower := strings.ToLower(command)
selfTargets := []string{"homed", pidStr}
killCmds := []string{"kill ", "killall ", "pkill ", "kill -", "kill -9"}
serviceCmds := []string{
"systemctl stop homeagent",
"systemctl restart homeagent",
"systemctl kill homeagent",
"service homeagent stop",
"service homeagent restart",
}
for _, kp := range killCmds {
if !strings.Contains(lower, kp) {
continue
}
for _, t := range selfTargets {
if strings.Contains(lower, strings.ToLower(t)) {
return true, fmt.Sprintf("命令被拦截:禁止向 homed 进程(pid=%s)发送信号", pidStr)
}
}
}
for _, sc := range serviceCmds {
if strings.Contains(lower, sc) {
return true, fmt.Sprintf("命令被拦截:禁止操作 homed 服务进程(pid=%s)", pidStr)
}
}
return false, ""
}

View File

@ -1,54 +0,0 @@
---
name: cmd-tools
description: 一次性 shell 命令执行(cmd_run),支持管道/heredoc/重定向。按需工具:先 plugin_start cmd,用完可 plugin_stop。
---
# 命令执行
## 什么时候用
需要跑一条**不需要交互**的 shell 命令时:查系统信息、跑脚本、批量处理文件、
`git`/`go`/`python` 之类的一次性调用。
需要**持续输入**的程序(vim、top、ssh 会话)不要用本插件,用 agentcli 的
`terminal_create` / `terminal_write` / `terminal_read`。
## 先拉起插件
```
plugin_start name=cmd
```
| 工具 | 用途 |
|---|---|
| `cmd_run` | 执行一条命令,返回 stdout/stderr/exit_code |
参数:`command`(必填)、`timeout`(如 30s/5m,默认 30s)、`workdir`。
## 用完可释放
```
plugin_stop name=cmd
```
## 注意
- 经完整 bash 执行:管道、`;`、`&&`、`$()`、heredoc、重定向都可用。
- 长驻进程别用 cmd_run 挂着等它退出,那会撞 timeout;用终端工具。
- **文件读写不要用 shell 代替**(`cat`/`echo`/`sed`/`tee`/重定向):文件里有已知内容要写、
或要读文件内容时,走 files 插件的 `files_read`/`files_write`/`files_edit`。
只有当文件是命令的副产物(`go build -o`、`git clone`、日志重定向)才由命令自己产生。
- 危险命令(`rm -rf`、写系统目录)先向用户确认。
## ★ 用 plugin_stop / plugin_start,不要用 plugin_disable / plugin_enable
| 工具 | 作用 | 跨重启 |
|---|---|---|
| `plugin_stop` | **运行态**关闭,现在停掉 | **不影响**,重启照常加载 |
| `plugin_start` | **运行态**拉起,现在开回来 | 不影响 |
| `plugin_disable` | **持久**禁用 | **会记下来**,开机也不自启 |
| `plugin_enable` | 解除持久禁用 | 会记下来 |
**释放上下文用 `plugin_stop`。** 用 `plugin_disable` 会顺手把"开机自启"
永久关掉。本插件声明 `auto_start: false`,本来就不自启;但 `plugin_disable`
仍会写进持久禁用清单,让 `plugin_start` 拒绝拉起。

View File

@ -1,28 +0,0 @@
# commondev
commondev plugin
## Build
```bash
hmapdev build
```
## Channels
入站与出站是分开登记的两件事:
| 方向 | API | 用途 |
|---|---|---|
| 入站 inputch | RegisterInputChannel(name, def) | 声明「谁会往这个通道注入输入」。**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...) 注入的通道名都要登记** |
| 出站 output | RegisterOutputChannel(name, caps, desc, def, handler) | 声明 output_send__<name> 的回复发给谁;handler 收到 {payload,type,meta} |
def(ChannelDef)描述该通道在记忆计算层的行为:NoMemory: true = 该通道输入不进记忆;
Cleaner = 计算层清洗后再向量化/提关键词(原文不改)。
> 只登记出站通道、却用同名通道注入输入时,内核会兜底登记同名 inputch 并在日志里告警。
> 兜底只为兼容老插件 —— 请显式登记,让「这是入站通道」成为插件的明确意图。
## Install
Upload the .hmap file through the Plugin Manager API.

View File

@ -1,222 +0,0 @@
package main
// 「投到指定目录的指定 session」的判据。
//
// 用户问的是这句话能不能成立。成立的前提是**地址唯一确定一条线**:
// branch_id → 工作目录(program/<hash>-<name>)
// → pi session(SessionID)
// → 一对通道(InputCh / OutputCh)
//
// 这四者必须一一对应,且**不能被两条线共享**。共享的任何一环都会让
// "投到指定 session"变成"投到某一条(不确定)"。
//
// 最危险的一环是唯一性:独立 clone 没有 worktree 的
// 「同分支不可双挂」保护(git 会拒绝),所以插件必须自己补闸。
// 设计 §4.3 明确写了这条,但**此前没有判据钉住它**。
import (
"os"
"path/filepath"
"strings"
"testing"
)
// newTestPluginForAddr 造一个只做台账的 Plugin(不碰网络/进程)。
//
// ★ ledger.json 放在 t.TempDir() **之外**的独立临时目录:
// branch_open 会把 clone 转后台 goroutine(runPrepareWorkdir),
// 而它会在 programRoot() 下建目录 —— 与 t.TempDir() 的清理构成竞态
// (实测报 "TempDir RemoveAll cleanup: directory not empty")。
// 用 os.MkdirTemp + 手动清理可以让清理发生在 goroutine 之后。
func newTestPluginForAddr(t *testing.T) *Plugin {
t.Helper()
dir, err := os.MkdirTemp("", "cdaddr")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
p := newTestPluginForShell(t)
p.path = filepath.Join(dir, "ledger.json")
p.ledger = &Ledger{
Projects: map[string]*Project{},
Branches: map[string]*Branch{},
Jobs: map[string]*Job{},
Criteria: map[string]*Criteria{},
}
return p
}
// ★★★ 判据:同名分支不能开两条未结的工程对话(唯一性闸)。
//
// 这是"投到指定 session"的**前提**:若同一个 branch_id 能对应两条线,
// 或者两条线共用同一个地址,寻址就不再唯一。
//
// ★ 独立 clone 丢掉了 git 的天然保护:worktree 模式下同一分支被两棵
// worktree 挂载时 git 直接 `fatal: 'feat/a' is already used`,
// 而独立 clone 完全没有这层拦截 —— 必须由插件补,否则两条线会
// 各自改各自的目录然后互相覆盖。
func TestDuplicateBranchIsRejected(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
// 第一次开:应成功
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/a"})
m1, _ := r1.(map[string]interface{})
if m1["status"] != "ok" {
t.Fatalf("首次开分支应成功: %v", m1)
}
id1, _ := m1["branch_id"].(string)
if id1 == "" {
t.Fatal("应返回 branch_id")
}
// 第二次开同一个分支:应被唯一性闸拒绝
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/a"})
m2, _ := r2.(map[string]interface{})
if _, isErr := m2["error"]; !isErr {
t.Fatalf("★ 同名分支被允许开第二条(唯一性闸失效): %v", m2)
}
if !strings.Contains(m2["error"].(string), "唯一性闸") {
t.Errorf("拒绝理由应指明唯一性闸: %v", m2["error"])
}
// 台账里必须只有一条
if n := len(p.ledger.Branches); n != 1 {
t.Errorf("★ 台账里应只有 1 条分支,实际 %d", n)
}
}
// ★★ 判据:**已结**的分支可以重开(闸只挡"未结"的)。
//
// 闸太严会让"合入后想再改同一个分支名"变成不可能 ——
// 而那是正常做法(分支已 merged,重开一条新的同名线是合理的)。
func TestClosedBranchCanBeReopened(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/b"})
id1 := r1.(map[string]interface{})["branch_id"].(string)
// 标记为已合入
p.ledger.Branches[id1].Status = "merged"
// 重开应成功
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/b"})
m2, _ := r2.(map[string]interface{})
if m2["status"] != "ok" {
t.Fatalf("已结分支应可重开: %v", m2)
}
// 但 id 相同 ⇒ 会覆盖旧台账项;这本身是设计取舍,只断言不报错
}
// ★★ 判据:review 状态同样算"未结"(不能趁复核期再开一条同类线)。
func TestReviewStatusStillBlocksDuplicate(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c",
"work_desc": "x"})
id1 := r1.(map[string]interface{})["branch_id"].(string)
p.ledger.Branches[id1].Status = "review"
r2, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat/c"})
if _, isErr := r2.(map[string]interface{})["error"]; !isErr {
t.Errorf("★ review 状态应算未结,但被允许重开")
}
}
// ★★★ 判据:地址四元组**一一对应**且互不共享。
//
// branch_id 必须唯一映射到 一个目录 + 一个 session + 一对通道。
// 任意两条线共用其中任何一项,寻址就不唯一("投到指定 session"退化为
// "投到某一条")。
func TestAddressTupleIsUniquePerBranch(t *testing.T) {
p := newTestPluginForAddr(t)
p.ledger.Projects["proj"] = &Project{Name: "proj", RemoteURL: "/tmp/x", DefaultBranch: "main"}
// 造两条不同的线
for _, br := range []string{"feat/x", "feat/y"} {
r, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": br})
if _, isErr := r.(map[string]interface{})["error"]; isErr {
t.Fatalf("开 %s 失败: %v", br, r)
}
}
if len(p.ledger.Branches) != 2 {
t.Fatalf("应有 2 条线,实际 %d", len(p.ledger.Branches))
}
seenDir := map[string]string{}
seenID := map[string]bool{}
for id, b := range p.ledger.Branches {
if seenID[id] {
t.Errorf("★ branch_id 重复: %s", id)
}
seenID[id] = true
if b.Workdir == "" {
t.Errorf("★ %s 没有工作目录", id)
}
// 目录不能共享
if prev, dup := seenDir[b.Workdir]; dup {
t.Errorf("★ 两条线共用同一目录: %s(%s 与 %s)—— 投到目录无法区分线", b.Workdir, prev, id)
}
seenDir[b.Workdir] = id
}
// ★ 关键:不同分支名必须得到不同目录(设计 §4.3 的目录映射不变式)
r1, _ := p.handleBranchOpen(map[string]interface{}{"project": "proj", "branch": "feat.z"})
_ = r1
// feat.z 与 feat/z 派生后 slug 相同,但目录 hash 含原始分支名 ⇒ 目录不同
d1 := branchDir(p.programRoot(), "proj", "feat/z")
d2 := branchDir(p.programRoot(), "proj", "feat.z")
if d1 == d2 {
t.Errorf("★ feat/z 与 feat.z 得到了同一目录 %s —— 两条线会互相覆盖", d1)
}
if filepath.Base(d1) == filepath.Base(d2) {
t.Errorf("★ 目录基名相同 %s,说明 hash 没把分支名纳入", filepath.Base(d1))
}
}
// ★★ 判据:投递必须打到**该 id 自己的**通道。
//
// deliverToPi 从台账取 SessionID;通道名由 id 派生。
// 若两条线的通道名撞了,内核路由就会把任务投到错的那条 pi。
func TestChannelNamesAreUniquePerBranch(t *testing.T) {
names := map[string]string{}
for _, id := range []string{
"br-proj-feat_a", "br-proj-feat_b", "br-other-feat_a",
} {
in := inputChannelName(id)
out := outputChannelName(id)
for _, n := range []string{in, out} {
if prev, dup := names[n]; dup {
t.Errorf("★ 通道名冲突 %q(%s 与 %s)—— 投递会打到错的后端", n, prev, id)
}
names[n] = id
}
// outputch 不能含斜杠(会拼成 output_send__<name>)
if strings.Contains(out, "/") {
t.Errorf("★ outputch 含斜杠: %q(output_send__ 名不允许)", out)
}
// 上游要求 ^[a-zA-Z0-9_-]{1,64}$
if !validNamePattern.MatchString(out) || len(out) > 64 {
t.Errorf("outputch %q 不合规(须匹配 ^[a-zA-Z0-9_-]{1,64}$)", out)
}
}
}
// ★★ 判据:**会话漂移必须响亮失败**(不静默改投新会话)。
//
// 这是"投到指定 session"的守门人:如果 pi 返回了不同的 session_id
// 而插件默默接受,任务就投到了另一条会话 —— 而调用方以为投到了指定的那条。
func TestSessionDriftIsRejectedNotSilentlyAccepted(t *testing.T) {
b, err := os.ReadFile("plugin.go")
if err != nil {
t.Skip("读不到 plugin.go")
}
src := string(b)
if !strings.Contains(src, "会话漂移") {
t.Fatal("★ deliverToPi 缺少会话漂移检查")
}
// 必须**拒绝**而不是记录后继续
if !strings.Contains(src, "拒绝继续") {
t.Error("★ 会话漂移应明确拒绝,而非仅记录")
}
}

View File

@ -1,340 +0,0 @@
package main
// 对齐审计:**逐个调用**已注册工具,检查它们真的能工作。
//
// 为何要这个:注册了名字 ≠ 能用。上一轮补了工具,但「能注册」只证明
// 编译通过。本文件对每个工具做一次最小真实调用,把「写了但没接上」
// (空返回、nil handler、路径写死错、参数名不匹配)暴露出来。
//
// 判定原则:
// - 返回 error ≠ 失败(很多工具对缺参数就是返回 error,那是正确的响亮行为)
// - 真正的失败是:panic、handler 为 nil、或**参数齐全却依然拒绝**
// - 这里的目标是发现"连正确调用都走不通"的工具
import (
"os"
"path/filepath"
"strings"
"testing"
)
// listRegisteredTools 从 plugin.go 里抽出注册的工具名。
// 用源码而非运行期 sdk,因为 SDK 的 mock 不暴露注册表。
func listRegisteredTools(t *testing.T) []string {
t.Helper()
b, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
var out []string
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
const p = `p.sdk.RegisterTool("`
if i := strings.Index(line, p); i >= 0 {
rest := line[i+len(p):]
if j := strings.Index(rest, `"`); j > 0 {
out = append(out, rest[:j])
}
}
}
return out
}
// handlerFor 通过名字映射到实际 handler 函数。
//
// ★ 这里刻意**手写映射**而不是反射:如果新增了工具却忘了在这里登记,
// 下面的覆盖率判据会报出来 —— 这正好是"新工具没接 handler"的探测。
func handlerFor(p *Plugin, name string) func(map[string]interface{}) (interface{}, error) {
switch name {
case "commondev_project_add", "commondev_project_open":
return p.handleProjectAdd
case "commondev_project_list":
return p.handleProjectList
case "commondev_branch_open":
return p.handleBranchOpen
case "commondev_branch_status":
return p.handleBranchStatus
case "commondev_branch_close":
return p.handleBranchClose
case "commondev_job_status":
return p.handleJobStatus
case "commondev_agent_up":
return p.handleAgentUp
case "commondev_agent_state":
return p.handleAgentState
case "commondev_agent_down":
return p.handleAgentDown
case "commondev_workdir_prepare":
return p.handleWorkdirPrepare
case "commondev_workdir_clean":
return p.handleWorkdirClean
case "commondev_test_define":
return p.handleTestDefine
case "commondev_test_list":
return p.handleTestList
case "commondev_test_run":
return p.handleTestRun
case "commondev_test_mutate":
return p.handleTestMutate
case "commondev_review_submit":
return p.handleReviewSubmit
case "commondev_review_result":
return p.handleReviewResult
case "commondev_conflict_check":
return p.handleConflictCheck
case "commondev_parallel_status":
return p.handleParallelStatus
case "commondev_pi_config_get":
return p.handlePiConfigGet
case "commondev_pi_config_set":
return p.handlePiConfigSet
case "commondev_pi_source_add":
return p.handlePiSourceAdd
case "commondev_pi_source_list":
return p.handlePiSourceList
case "commondev_pi_source_remove":
return p.handlePiSourceRemove
case "commondev_pi_source_default":
return p.handlePiSourceDefault
case "commondev_pi_source_import":
return p.handlePiSourceImport
case "commondev_pi_package_install", "commondev_pi_plugin_install":
return p.handlePiPackageInstall
case "commondev_pi_plugin_list":
return p.handlePiPluginList
case "commondev_pi_plugin_remove":
return p.handlePiPluginRemove
case "commondev_pi_plugin_update":
return p.handlePiPluginUpdate
case "commondev_pi_mcp_list":
return p.handlePiMCPList
case "commondev_pi_mcp_add":
return p.handlePiMCPAdd
case "commondev_pi_mcp_remove":
return p.handlePiMCPRemove
case "enc_store":
return p.handleEncStore
case "enc_list":
return p.handleEncList
case "enc_show":
return p.handleEncShow
case "enc_rm":
return p.handleEncRm
case "enc_shell":
return p.handleEncryptedShell
case "commondev_endpoint_set":
return p.handleEndpointSet
case "commondev_endpoint_list":
return p.handleEndpointList
case "commondev_endpoint_remove":
return p.handleEndpointRemove
case "commondev_agent_interrupt":
return p.handleAgentInterrupt
}
return nil
}
// ★★ 判据:**每个注册的工具都必须有对应 handler**。
//
// 这条能抓出「加了 RegisterTool 却指向不存在的 handler」或
// 「加了工具但这里没同步登记」(后者是需要人补映射,属于审计信号)。
func TestEveryRegisteredToolHasHandler(t *testing.T) {
p := newAuditPlugin(t)
tools := listRegisteredTools(t)
if len(tools) < 40 {
t.Fatalf("注册工具数异常偏少(%d),插件文件可能没被正确解析", len(tools))
}
var missing []string
for _, name := range tools {
if handlerFor(p, name) == nil {
missing = append(missing, name)
}
}
if len(missing) > 0 {
t.Fatalf("★ 这些工具没有可用 handler(注册了但没接上): %v", missing)
}
t.Logf("已注册 %d 个工具,全部有 handler", len(tools))
}
// ★★ 判据:**无参数调用不得 panic**。
//
// 工具会遇到模型乱传参数(缺字段、类型错)。正确行为是返回 error,
// 不是崩掉整个插件进程。
func TestNoToolPanicsOnEmptyArgs(t *testing.T) {
p := newAuditPlugin(t)
for _, name := range listRegisteredTools(t) {
h := handlerFor(p, name)
if h == nil {
continue
}
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("★ %s 空参数 panic: %v", name, r)
}
}()
// 空 map + nil map 都要试:模型可能传 null
h(map[string]interface{}{})
h(nil)
}()
}
}
// ★★★ 判据:**本轮补的工具必须真的能完成一次正常调用**。
//
// 与前两条不同:这里给足参数,检查业务是否真走通。
// 「注册 + 不 panic」还不够 —— 一条写死的错误路径也能满足那两条。
func TestNewlyAddedToolsActuallyWork(t *testing.T) {
p := newAuditPlugin(t)
p.ledger.Projects["proj"] = &Project{
Name: "proj", RemoteURL: "https://example.invalid/o/r.git", DefaultBranch: "main"}
dir := t.TempDir()
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "proj", Name: "feat/x", Workdir: dir, Status: "open"}
// ① agent_state 应报出真实状态(此前完全不存在)
res, err := p.handleAgentState(map[string]interface{}{"branch_id": "br-1"})
if err != nil {
t.Fatal(err)
}
m := res.(map[string]interface{})
if m["branch_id"] != "br-1" || m["agent_alive"] != false {
t.Errorf("agent_state 返回不对: %v", m)
}
// ② agent_down 应成功停掉(无进程时也幂等成功)
res2, err := p.handleAgentDown(map[string]interface{}{"branch_id": "br-1"})
if err != nil {
t.Fatal(err)
}
if res2.(map[string]interface{})["status"] != "ok" {
t.Errorf("agent_down 未成功: %v", res2)
}
// ③ workdir_clean 默认 dry_run,且能识别孤儿
root := p.programRoot()
os.MkdirAll(filepath.Join(root, "deadbeef-feat_x"), 0o755)
res3, err := p.handleWorkdirClean(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
m3 := res3.(map[string]interface{})
if m3["status"] != "dry_run" {
t.Errorf("workdir_clean 默认应为 dry_run: %v", m3)
}
if n, _ := m3["count"].(int); n != 1 {
t.Errorf("应识别 1 个孤儿目录,实际 %v", m3["orphans"])
}
// ④ pi_config_get 必须能读到结构(即使没有任何 provider)
res4, err := p.handlePiConfigGet(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
if _, ok := res4.(map[string]interface{})["providers"]; !ok {
t.Errorf("pi_config_get 缺少 providers 字段: %v", res4)
}
}
// ★★ 判据:**设计文档点名的工具必须真的存在**。
//
// 这条把「文档说做了」与「代码里有」绑起来 —— 上一轮就是靠它
// 发现 pr_open / agent_state 等工具根本不存在。
//
// ⚠ 2026-10-15 起:**git 托管平台专有的工具已不在清单里**
// (account_* / repo_search / pr_* / protect_branch)—— 那些能力进 skill,
// 由 agent 用 safeshell + curl 完成。
func TestDesignNamedToolsExist(t *testing.T) {
tools := listRegisteredTools(t)
have := map[string]bool{}
for _, n := range tools {
have[n] = true
}
want := []string{
"commondev_project_add", "commondev_project_list",
"commondev_branch_open", "commondev_branch_status", "commondev_branch_close",
"commondev_workdir_prepare", "commondev_workdir_clean",
"commondev_agent_up", "commondev_agent_state", "commondev_agent_down",
"commondev_pi_plugin_install", "commondev_pi_plugin_remove", "commondev_pi_plugin_list",
"commondev_pi_source_import", "commondev_pi_source_add", "commondev_pi_source_list",
"commondev_pi_source_default", "commondev_pi_config_get",
"commondev_test_define", "commondev_test_run", "commondev_test_mutate",
"commondev_review_submit",
}
var missing []string
for _, w := range want {
if !have[w] {
missing = append(missing, w)
}
}
if len(missing) > 0 {
t.Fatalf("设计点名的工具缺失: %v", missing)
}
}
// ★ 判据:**git 平台专有的工具已全部移出插件**。
//
// 这条钉住 2026-10-15 的架构决定:平台知识进 skill。若有人又把
// account_* / pr_* / protect_branch 加回插件,这里会报红。
func TestPlatformToolsAreNotInPlugin(t *testing.T) {
tools := listRegisteredTools(t)
have := map[string]bool{}
for _, n := range tools {
have[n] = true
}
for _, forbidden := range []string{
"commondev_account_add", "commondev_account_list", "commondev_account_test",
"commondev_account_remove", "commondev_repo_search",
"commondev_pr_open", "commondev_pr_status", "commondev_pr_approve",
"commondev_pr_merge", "commondev_protect_branch",
} {
if have[forbidden] {
t.Errorf("★ %s 属 git 平台专有能力,不应再注册在插件里(应进 skill + safeshell)", forbidden)
}
}
}
// ★★ 判据:**平台专有代码不得回到插件里**。
//
// 钉住 2026-10-15 的架构决定(用户拍板):git 托管平台的知识进 skill,
// 插件只保留与平台无关的机制。若有人把 platformSpec / 按平台构造认证头
// 又加回来,这里会报红。
//
// 为什么要用判据而不只是文档:文档说了还会有人(包括将来的我)图省事加回去,
// 而那种回退在**功能上看起来完全正常** —— 只是把可移植性换成了硬编码。
func TestNoPlatformSpecificCodeInPlugin(t *testing.T) {
// 逐文件扫源码(排除测试与注释性提及由人工判断)
files := []string{"plugin.go", "credentials.go", "pi_tools.go", "criteria.go",
"encstore.go", "encshell.go", "conflict.go", "workdir.go", "workdir_security.go",
"util.go", "llmsource.go", "sidecar.go"}
forbidden := map[string]string{
"platformSpec(": "按平台构造请求(认证头/端点差异)应进 skill",
"plaftformSpec": "平台适配结构体",
"DefaultAPI": "硬编码平台 API 前缀",
"AuthHeader:": "硬编码平台认证头",
"RoleExecutor": "平台角色语义",
"handleAccountAdd": "平台凭据工具",
"handleRepoSearch": "平台搜索工具",
"handlePROpen": "平台 PR 工具",
"handleProtectBranch": "平台分支保护工具",
}
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
continue
}
src := string(b)
for sym, why := range forbidden {
// 只认代码出现,不认注释里的解释性提及
for i, line := range strings.Split(src, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
continue // 注释里说明"已移出"是允许的
}
if strings.Contains(line, sym) {
t.Errorf("★ %s:%d 含平台专有代码 %q(%s)", f, i+1, sym, why)
}
}
}
}
}

View File

@ -1,103 +0,0 @@
package main
import (
"os"
"strings"
"testing"
)
// ★ 判据:慢操作(clone/fetch/测试)**绝不能**出现在工具 handler 的同步路径里。
//
// 依据(实测踩到):内核把每次工具调用硬限制在 **60 秒**
// (internal/agent/core/toolcall.go:62,硬编码无配置项)。同步 clone 的后果是
// 工具报超时、但 mirror 其实已建好 —— 调用方只看到失败,状态却是"做了一半"。
//
// 这条判据用**源码文本**做检查(同本仓既有做法:判据类检查必须先剔除注释)。
func TestNoSyncCloneInToolHandlers(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
// 剔除注释,避免注释里提到 runGit 也会命中
body := stripComments(string(src))
// 工具 handler 的区间:从 handleProjectAdd 到 handleJobStatus 之前
start := strings.Index(body, "func (p *Plugin) handleProjectAdd")
end := strings.Index(body, "func (p *Plugin) handleJobStatus")
if start < 0 || end < 0 || end <= start {
t.Fatal("找不到 handler 区间(函数名可能已改)")
}
handlers := body[start:end]
// handleBranchOpen 的**后台函数**是允许慢操作的,单独排除
if i := strings.Index(handlers, "func (p *Plugin) runPrepareWorkdir"); i >= 0 {
handlers = handlers[:i]
}
for _, bad := range []string{"runGit(", "prepareWorkdir("} {
if strings.Contains(handlers, bad) {
t.Errorf("工具 handler 里出现慢操作 %s —— 会撞 60s 工具上限;应转后台(见 runPrepareWorkdir)", bad)
}
}
// 但后台函数自己必须用到它(否则重构把功能删了)
if !strings.Contains(body, "func (p *Plugin) runPrepareWorkdir") ||
!strings.Contains(body, "prepareWorkdir(pr, branch, workdir)") {
t.Error("runPrepareWorkdir 未实现或未调用 prepareWorkdir —— 目录准备功能丢了")
}
// ★ 且必须切到目标分支(“每个目录下是独立仓库的独立分支”——用户原话)
//
// 判据必须检查**两条路径都切**:新建路径(clone 后)与复用路径(目录已存在)。
// 为何要查两处:初版只查字符串存在,而 ensureOnBranch 的**定义**本身
// (func … ensureOnBranch(branch, workdir string))就包含该子串 —— 于是
// 只删调用点、定义还在,判据仍然全绿(变异实验当场发现了这个漏洞)。
// ⇒ 改为数**调用点**个数(排除定义行)。
calls := 0
for _, ln := range strings.Split(body, "\n") {
t := strings.TrimSpace(ln)
if strings.HasPrefix(t, "func ") {
continue // 定义行不算
}
if strings.Contains(t, "p.ensureOnBranch(branch, workdir)") {
calls++
}
}
if calls < 2 {
t.Errorf("prepareWorkdir 切分支的调用点只有 %d 处(=new建路径+复用路径);"+
"缺一处就会让目录停在默认分支(main)", calls)
}
}
// stripComments 去掉 // 行注释与 /* */ 块注释。
//
// 为何必须先剔除:本仓既有教训 —— 注释里模仿真实签名会污染一切按文本匹配的判据。
func stripComments(s string) string {
var out strings.Builder
lines := strings.Split(s, "\n")
inBlock := false
for _, ln := range lines {
trimmed := strings.TrimSpace(ln)
if inBlock {
if idx := strings.Index(ln, "*/"); idx >= 0 {
inBlock = false
ln = ln[idx+2:]
} else {
out.WriteString("\n")
continue
}
}
if strings.HasPrefix(trimmed, "/*") {
if idx := strings.Index(ln, "*/"); idx >= 0 {
ln = ln[:strings.Index(ln, "/*")] + ln[idx+2:]
} else {
inBlock = true
out.WriteString("\n")
continue
}
}
if i := strings.Index(ln, "//"); i >= 0 {
ln = ln[:i]
}
out.WriteString(ln + "\n")
}
return out.String()
}

View File

@ -1,216 +0,0 @@
package main
// 审计补全项的判据。
//
// 这些判据对应审计发现的**真问题**(不是补全功能的装饰):
// · push 的凭据纪律(设计 §7.2)—— 曾经 pushURLWithToken 是死代码,
// 意味着「凭据不进 remote URL」这条纪律**从未被真正执行过**
// · 孤儿清理(listOrphanDirs 曾是死代码)
// · pi 配置写入的备份/回滚(设计 §3.6.4 纪律 2)
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func newAuditPlugin(t *testing.T) *Plugin {
t.Helper()
p := newTestPluginForCriteria(t)
return p
}
// ★★ 判据:凭据残留检查能识别 `scheme://user:pass@host` 形态。
func TestGitConfigCredentialResidueDetection(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, ".git"), 0o755)
os.WriteFile(filepath.Join(dir, ".git", "config"),
[]byte("[remote \"origin\"]\n\turl = https://user:tok3n@example.com/o/r.git\n"), 0o644)
if got := gitConfigCredentialResidue(dir); got == "" {
t.Fatal("★ 未检出内嵌凭据(设计 §7.2 要求 workdir_clean 内置此检查)")
}
// 干净的不应误报
dir2 := t.TempDir()
os.MkdirAll(filepath.Join(dir2, ".git"), 0o755)
os.WriteFile(filepath.Join(dir2, ".git", "config"),
[]byte("[remote \"origin\"]\n\turl = https://example.com/o/r.git\n"), 0o644)
if got := gitConfigCredentialResidue(dir2); got != "" {
t.Errorf("干净 remote 被误报: %s", got)
}
// scp 形态的 git@host 不是凭据(用户提示有误伤风险)
dir3 := t.TempDir()
os.MkdirAll(filepath.Join(dir3, ".git"), 0o755)
os.WriteFile(filepath.Join(dir3, ".git", "config"),
[]byte("[remote \"origin\"]\n\turl = git@github.com:o/r.git\n"), 0o644)
if got := gitConfigCredentialResidue(dir3); got != "" {
t.Errorf("scp 形态 git@host 被误报成凭据: %s", got)
}
}
// ★★ 判据(设计 §7.2 + 审计发现):workdir_clean
// - **默认 dry_run**(删除不可逆)
// - 只删**本插件生成的孤儿目录**,不碰台账里的目录、不碰陌生目录
func TestWorkdirCleanOnlyRemovesOrphans(t *testing.T) {
p := newAuditPlugin(t)
root := p.programRoot()
os.MkdirAll(root, 0o700)
known := "aabbccdd-feat_known"
orphan := "11223344-feat_orphan"
stranger := "not-ours-just-a-dir"
for _, d := range []string{known, orphan, stranger} {
os.MkdirAll(filepath.Join(root, d), 0o755)
}
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "proj", Name: "feat/known",
Workdir: filepath.Join(root, known), Status: "open"}
// ① dry_run 默认:什么都不删
res, err := p.handleWorkdirClean(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
if res.(map[string]interface{})["status"] != "dry_run" {
t.Fatalf("默认应为 dry_run,实际 %v", res.(map[string]interface{})["status"])
}
for _, d := range []string{known, orphan, stranger} {
if _, err := os.Stat(filepath.Join(root, d)); err != nil {
t.Fatalf("★ dry_run 却删掉了 %s", d)
}
}
// ② 实际清理:只删孤儿
res2, err := p.handleWorkdirClean(map[string]interface{}{"dry_run": false})
if err != nil {
t.Fatal(err)
}
removed, _ := res2.(map[string]interface{})["removed"].([]string)
if len(removed) != 1 || removed[0] != orphan {
t.Fatalf("应只删 %s,实际 %v", orphan, removed)
}
if _, err := os.Stat(filepath.Join(root, known)); err != nil {
t.Fatal("★ 删掉了台账里在用的目录")
}
if _, err := os.Stat(filepath.Join(root, stranger)); err != nil {
t.Fatal("★ 删掉了不是本插件生成的目录(越界)")
}
}
// ★★ 判据:agent_down 清掉 session/通道绑定,但**保留工作目录与状态**。
//
// 它与 branch_close 的区别正是"暂停"vs"收尾";若 agent_down 也删目录,
// 二者就没有区别了(而我实现时差点把两者写成一个)。
func TestAgentDownKeepsWorkdir(t *testing.T) {
p := newAuditPlugin(t)
dir := t.TempDir()
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "proj", Name: "feat/x", Workdir: dir,
Status: "open", SessionID: "s1", InputCh: "commondev/br-1", OutputCh: "commondev_br-1"}
res, err := p.handleAgentDown(map[string]interface{}{"branch_id": "br-1"})
if err != nil {
t.Fatal(err)
}
if res.(map[string]interface{})["status"] != "ok" {
t.Fatal("应成功")
}
b := p.ledger.Branches["br-1"]
if b.SessionID != "" || b.InputCh != "" || b.OutputCh != "" {
t.Errorf("★ session/通道绑定应被清掉(留着会让人以为进程还在): %+v", b)
}
if b.Status != "open" {
t.Errorf("★ 状态不该变(这是暂停不是收尾),实际 %s", b.Status)
}
if _, err := os.Stat(dir); err != nil {
t.Fatal("★ agent_down 不该删工作目录(那是 branch_close 的事)")
}
}
// ---------------- pi 配置写入纪律(设计 §3.6.4) ----------------
// ★★★ 判据:**损坏的 models.json 必须拒绝覆盖**(纪律 2)。
//
// 静默当空会**抹掉用户已有的全部源** —— 那是最坏的一种"静默降级"。
func TestCorruptPiModelsIsNotSilentlyOverwritten(t *testing.T) {
dir := t.TempDir()
bad := "{ this is not json"
os.WriteFile(filepath.Join(dir, "models.json"), []byte(bad), 0o600)
if _, err := loadPiModels(dir); err == nil {
t.Fatal("★ 损坏的 models.json 未被拒绝 —— 静默当空会抹掉用户已有的源")
}
after, _ := os.ReadFile(filepath.Join(dir, "models.json"))
if string(after) != bad {
t.Fatal("损坏的文件被改动了")
}
}
// ★★ 判据:写坏时**回滚**(纪律 2:失败则回滚并响亮报错)。
func TestPiConfigRollbackRestoresPrevious(t *testing.T) {
dir := t.TempDir()
orig := `{"providers":{"keep":{"baseUrl":"http://keep","api":"openai-completions","models":[{"id":"m"}]}}}`
os.WriteFile(filepath.Join(dir, "models.json"), []byte(orig), 0o600)
backup, err := backupPiConfig(dir)
if err != nil {
t.Fatal(err)
}
// 模拟"写坏了"
os.WriteFile(filepath.Join(dir, "models.json"), []byte("{broken"), 0o600)
rollbackPiConfig(dir, backup)
back, _ := os.ReadFile(filepath.Join(dir, "models.json"))
if string(back) != orig {
t.Fatalf("★ 回滚未恢复原内容\n前: %s\n后: %s", orig, back)
}
}
// ★★ 判据:**api_key 绝不进任何返回结构**(纪律 3)。
func TestPiConfigGetNeverReturnsKey(t *testing.T) {
p := newAuditPlugin(t)
dir := p.piAgentDir()
os.MkdirAll(dir, 0o700)
os.WriteFile(filepath.Join(dir, "models.json"), []byte(
`{"providers":{"p1":{"baseUrl":"http://x","api":"openai-completions","apiKey":"SUPER_SECRET","models":[{"id":"m"}]}}}`), 0o600)
res, err := p.handlePiConfigGet(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
s := jsonDump(t, res)
if strings.Contains(s, "SUPER_SECRET") {
t.Fatalf("★★ api_key 泄漏到返回值里:%s", s)
}
if !strings.Contains(s, "key_set") {
t.Errorf("应报 key_set 而不是值: %s", s)
}
}
// jsonDump 把任意返回值转成 JSON 串(用于断言"某串不得出现")。
func jsonDump(t *testing.T, v interface{}) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// ★ 判据:pi 插件工具的**两套命名都能到达同一实现**(早期稿用 plugin,细化稿用 package)。
func TestPiPluginToolAliasesRegistered(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
for _, name := range []string{
`"commondev_pi_plugin_install"`, `"commondev_pi_package_install"`,
`"commondev_pi_plugin_list"`, `"commondev_pi_plugin_remove"`,
} {
if !strings.Contains(s, name) {
t.Errorf("工具 %s 未注册(两套命名应都可用)", name)
}
}
}

View File

@ -1,84 +0,0 @@
package main
import "testing"
// 钉住文档 §2.2 的命名约束:
// inputch 可含斜杠;outputch **必须**只剩 [A-Za-z0-9_-],
// 否则会产出非法的 output_send__<name>(上游 400、agent 整体失语)。
func TestOutputChannelNameIsToolSafe(t *testing.T) {
cases := []string{
"br-TrueAgent-feat_x",
"br-p1-feat/x", // 分支名带斜杠
"br-p1-feat.x", // 点
"br-p1-中文", // 非 ASCII
"br-p1-a b", // 空格
}
for _, id := range cases {
got := outputChannelName(id)
if !validToolName("output_send__" + got) {
t.Errorf("id=%q → outputch=%q ⇒ 工具名 %q 非法(会触发上游 400)",
id, got, "output_send__"+got)
}
// inputch 则允许斜杠(内核自己就用 child/<id>)
if in := inputChannelName(id); in != "commondev/"+id {
t.Errorf("inputch 应原样保留: %q", in)
}
}
}
// validToolName 复刻上游对函数名的约束。
func validToolName(name string) bool {
if len(name) == 0 || len(name) > 64 {
return false
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z',
r >= '0' && r <= '9', r == '_', r == '-':
default:
return false
}
}
return true
}
// 台账往返:保存后能读回,且损坏时拒绝静默重建。
func TestLedgerRoundTrip(t *testing.T) {
p := &Plugin{name: "commondev"}
p.path = t.TempDir() + "/ledger.json"
if err := p.loadLedger(); err != nil {
t.Fatal(err)
}
p.ledger.Projects["demo"] = &Project{Name: "demo", RemoteURL: "https://x/y.git"}
if err := p.saveLocked(); err != nil {
t.Fatal(err)
}
q := &Plugin{name: "commondev", path: p.path}
if err := q.loadLedger(); err != nil {
t.Fatal(err)
}
if q.ledger.Projects["demo"] == nil {
t.Fatal("台账未往返")
}
}
// 钉住名字校验方向(这条是**真 bug 的回归测试**)。
//
// 初版用排除式正则 `[^a-zA-Z0-9_-]` 配 MatchString 判"合法",恰好反了:
// 合法名不含违法字符 ⇒ 返回 false ⇒ 把 `cdtest` 判非法。
// 是端到端跑时由 agent 调工具发现的 —— 所以它必须有回归判据。
func TestValidName(t *testing.T) {
legal := []string{"cdtest", "TrueAgent", "feat_x", "a-b", "A1"}
for _, s := range legal {
if !validName(s) {
t.Errorf("合法名被拒: %q(校验方向反了?)", s)
}
}
illegal := []string{"feat/x", "a b", "中文", "a.b", "", "a/b/c"}
for _, s := range illegal {
if validName(s) {
t.Errorf("非法名被放行: %q", s)
}
}
}

View File

@ -1,400 +0,0 @@
package main
// 刀 3:多项目/并行下的 **conflict 预检** 与 **base_sha 记录**。
//
// ## 为什么需要这一刀
//
// 设计 §4.2:`base_sha` 是用户原描述里**缺失**的一环,但必须补:
//
// 没有它,插件无法判断「两个 PR 改了同一文件时第二个该不该 rebase、
// 能不能自动合」,出问题也无法重放。
//
// 并行开发(§4.4)让这个问题从"可能"变成"必然":每分支一份独立 clone,
// 两棵树**互不可见**,冲突不会在开发时暴露,而是全部堆到 merge 时爆发。
// 那时才发现"两个分支改了同一个文件的同一段"就太晚了 ——
// 复审已经通过、判据已经变绿,但合不进去。
//
// ## 做法:在**提 PR 之前**算一次可合并性
//
// 用 `git merge-tree --write-tree` —— ★ 它**完全不碰工作区**(不写索引、
// 不动 HEAD、不留 MERGE_HEAD),所以可以在任何时候安全地跑,
// 不需要先 stash/checkout,也不会破坏正在进行的开发。
//
// 退出码语义(实测 git 2.53):
//
// 0 → 可干净合并
// 1 → 有冲突
// 其它 → 命令本身失败(仓库损坏等),**不能当成"无冲突"**
//
// ## 与旧写法(`git merge-tree <base> <b1> <b2>`)的对比
//
// 旧语法输出是给人看的文本,要靠 grep `CONFLICT` 判断 —— 脆弱且没有
// 明确的退出码。新语法(git 2.38+)直接给树 OID 与退出码。
// 本仓的环境是 git 2.53,用新语法;同时保留旧语法的兜底判断。
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// ConflictReport 是一次冲突预检的结果。
type ConflictReport struct {
// Mergeable 是否可干净合并。
Mergeable bool `json:"mergeable"`
// Conflicts 是冲突文件列表(可为空)。
Conflicts []string `json:"conflicts,omitempty"`
// BaseSHA / HeadSHA 是本次比较的两端(用于审计与重放)。
BaseSHA string `json:"base_sha,omitempty"`
HeadSHA string `json:"head_sha,omitempty"`
// Target 是被合入的目标(通常是 main)。
Target string `json:"target"`
// Detail 是给人看的一行说明。
Detail string `json:"detail"`
// Skipped 为真表示预检被跳过(而非"检查通过了")。
//
// ★ 这个字段很关键:把"没检查"和"检查通过"混为一谈,
// 会让调用方以为安全 —— 而实际什么都没验。
Skipped bool `json:"skipped,omitempty"`
}
// ────────────────────────── base_sha 记录 ──────────────────────────
// captureBaseSHA 记录工作目录当前 HEAD(开线时的基点)。
//
// 必须在**第一次投递任务之前**调用:agent 一旦开始改,HEAD 就不再是基点了。
// 拿不到时如实返回错误(不静默留空 —— 空 base_sha 会让后续冲突判定失据)。
func (p *Plugin) captureBaseSHA(branchID, workdir string) (string, error) {
sha, err := runGitOut(workdir, "rev-parse", "HEAD")
if err != nil {
return "", fmt.Errorf("取 base_sha 失败: %w", err)
}
sha = strings.TrimSpace(sha)
// 空值守卫:实测 `git rev-parse HEAD` 要么成功给非空 sha、要么报错,
// 所以这条**实际不可达** —— 保留它是防御性的,不必为它写判据
//(写了会是"永远绿"的假判据,反而掩盖覆盖率的真实情况)。
if sha == "" {
return "", fmt.Errorf("取 base_sha 得到空值")
}
p.mu.Lock()
if b, ok := p.ledger.Branches[branchID]; ok {
// 只记第一次:重开同一分支时基点不该被后续改动覆盖
// (覆盖会让"这条分支从哪出发"永久丢失,冲突判定与重放都失去依据)
if b.BaseSHA == "" {
b.BaseSHA = sha
} else {
sha = b.BaseSHA
}
}
p.mu.Unlock()
return sha, nil
}
// ────────────────────────── 冲突预检 ──────────────────────────
// checkConflict 在**不动工作区**的前提下判断 branch 能否干净地合进 target。
//
// 两种实现路径,自动选择:
//
// ① 本地有该分支的 workdir ⇒ 直接在工作目录里跑 merge-tree(最快,无需网络)
// ② 否则 ⇒ 在 mirror 里跑(mirror 是 --mirror clone,含全部分支)
//
// ★ 为什么不在 PR 之后靠平台判定:那时已经推了分支、开了 PR,
//
// 冲突才暴露 → 白跑一轮。预检的意义就是**在浪费工作之前发现**。
func (p *Plugin) checkConflict(workdir, target string) ConflictReport {
rep := ConflictReport{Target: target}
if target == "" {
target = "main"
rep.Target = target
}
// 本地要有 target 的最新视角:mirror 的 fetch 由调用方保证
// (这里只做只读判定,不引入网络依赖 —— 预检失败不该阻断流程)
// 取 HEAD(本次要合入的提交)
head, err := runGitOut(workdir, "rev-parse", "HEAD")
if err != nil {
rep.Skipped = true
rep.Detail = "取 HEAD 失败,预检跳过(不等于无冲突): " + err.Error()
return rep
}
rep.HeadSHA = strings.TrimSpace(head)
// 目标分支在本地存在吗?不存在就没法预检(如实标记 Skipped)
if _, err := runGitOut(workdir, "rev-parse", "--verify", target); err != nil {
rep.Skipped = true
rep.Detail = fmt.Sprintf(
"本地没有 %s 引用,预检跳过(不等于无冲突)。"+
"先 fetch 目标分支再预检,或直接在平台上开 PR 让平台判定", target)
return rep
}
base, _ := runGitOut(workdir, "merge-base", target, "HEAD")
rep.BaseSHA = strings.TrimSpace(base)
// ★ 核心:merge-tree --write-tree(不碰工作区)
out, err := runGitOut(workdir, "merge-tree", "--write-tree", target, "HEAD")
if err == nil {
rep.Mergeable = true
rep.Detail = fmt.Sprintf("可干净合并进 %s(基点 %s)", target, shortSHA(rep.BaseSHA))
return rep
}
// 非零退出:区分「冲突」与「命令失败」。
//
// ★ 这个区分极其重要:把命令失败当成"有冲突"会误报(让人去做无谓 rebase),
// 把它当成"无冲突"则更危险(带着冲突去合)。两者都不行 ⇒ 分别处理。
if isMergeTreeConflict(out) {
rep.Mergeable = false
rep.Conflicts = parseConflictFiles(out)
rep.Detail = fmt.Sprintf("与 %s 有冲突(%d 个文件):%s",
target, len(rep.Conflicts), strings.Join(rep.Conflicts, ", "))
return rep
}
// 命令失败(如 git 版本不支持 --write-tree)⇒ 退回旧语法再判一次
if rep2, ok := p.checkConflictLegacy(workdir, target); ok {
rep2.BaseSHA, rep2.HeadSHA = rep.BaseSHA, rep.HeadSHA
return rep2
}
rep.Skipped = true
rep.Detail = "merge-tree 不可用(git 过旧?),预检跳过(不等于无冲突): " +
truncate(strings.TrimSpace(out), 200)
return rep
}
// checkConflictLegacy 用旧版 merge-tree 语法兜底(git < 2.38)。
//
// 旧语法输出是文本,靠 `CONFLICT` / 冲突标记判断 —— 脆弱,故只作兜底。
func (p *Plugin) checkConflictLegacy(workdir, target string) (ConflictReport, bool) {
rep := ConflictReport{Target: target}
base, err := runGitOut(workdir, "merge-base", target, "HEAD")
if err != nil {
return rep, false
}
out, _ := runGitOut(workdir, "merge-tree", strings.TrimSpace(base), target, "HEAD")
if !strings.Contains(out, "CONFLICT") && !strings.Contains(out, "<<<<<<<") {
rep.Mergeable = true
rep.Detail = fmt.Sprintf("可干净合并进 %s(旧语法判定)", target)
return rep, true
}
rep.Mergeable = false
rep.Conflicts = parseConflictFilesLegacy(out)
rep.Detail = fmt.Sprintf("与 %s 有冲突(旧语法判定,%d 个文件)",
target, len(rep.Conflicts))
return rep, true
}
// isMergeTreeConflict 判断 merge-tree 的失败是"有冲突"还是"命令本身失败"。
//
// 依据:git 在冲突时退出码为 1;命令错误(如未知参数)通常是 129 等。
// `runGitOut` 把 stderr 一起并进 out,所以同时看退出码语义与输出特征。
func isMergeTreeConflict(out string) bool {
if strings.Contains(out, "CONFLICT") {
return true
}
// 冲突时 merge-tree 会打印 "Auto-merging ... CONFLICT (content)",
// 或至少列出冲突阶段号(1/2/3 的 ls-tree 形态)
return strings.Contains(out, "Auto-merging") ||
strings.Contains(out, "Merge conflict")
}
// parseConflictFiles 从 `merge-tree --write-tree` 输出里提文件名。
//
// 输出形态(实测 git 2.53):
//
// <tree-oid>
// 100644 <oid> 1\tpath/to/file ← 阶段 1/2/3 即为冲突条目
// 100644 <oid> 2\tpath/to/file
// 100644 <oid> 3\tpath/to/file
// ...
// Auto-merging path
// CONFLICT (content): Merge conflict in path
func parseConflictFiles(out string) []string {
set := map[string]bool{}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
// 形态 ①:明确的 "CONFLICT ... in <path>"
if i := strings.Index(line, "CONFLICT"); i >= 0 {
if j := strings.Index(line, " in "); j > 0 {
set[strings.TrimSpace(line[j+4:])] = true
}
continue
}
// 形态 ②:ls-tree 阶段行 "100644 <oid> <stage>\t<path>"
if rest, ok := strings.CutPrefix(line, "100"); ok {
_ = rest
parts := strings.SplitN(line, "\t", 2)
if len(parts) == 2 {
fields := strings.Fields(parts[0])
// mode, oid, stage
if len(fields) == 3 && (fields[2] == "1" || fields[2] == "2" || fields[2] == "3") {
set[strings.TrimSpace(parts[1])] = true
}
}
}
}
out2 := make([]string, 0, len(set))
for f := range set {
out2 = append(out2, f)
}
sort.Strings(out2) // 稳定输出(map 顺序随机,回执要可对照)
return out2
}
// parseConflictFilesLegacy 从旧语法输出里提文件名(形态 "CONFLICT ... in <path>")。
func parseConflictFilesLegacy(out string) []string {
set := map[string]bool{}
for _, line := range strings.Split(out, "\n") {
if i := strings.Index(line, "CONFLICT"); i >= 0 {
if j := strings.Index(line, " in "); j > 0 {
set[strings.TrimSpace(line[j+4:])] = true
}
}
}
r := make([]string, 0, len(set))
for f := range set {
r = append(r, f)
}
sort.Strings(r)
return r
}
// shortSHA 截短 sha 供人阅读(空值原样返回)。
func shortSHA(s string) string {
if len(s) > 8 {
return s[:8]
}
return s
}
// ────────────────────────── 多项目/并行盘点 ──────────────────────────
// parallelSummary 汇总「当前有哪些分支线在并行推进」。
//
// 为何要它:并行是有前提的(设计 §8.4)—— 并行的必须是**互不相干的模块**。
// 若两条线改了同一文件,冲突会堆到 merge 时才爆发。这个汇总让主管
// **在派活之前**就能看到"眼下有哪几条线、各自在哪",从而判断该不该并行。
func (p *Plugin) parallelSummary() map[string]interface{} {
p.mu.Lock()
defer p.mu.Unlock()
byProject := map[string][]map[string]interface{}{}
for _, b := range p.ledger.Branches {
if b.Status != "open" && b.Status != "review" {
continue
}
byProject[b.ProjectID] = append(byProject[b.ProjectID], map[string]interface{}{
"branch_id": b.ID,
"branch": b.Name,
"status": b.Status,
"agent": b.AgentName,
"workdir": b.Workdir,
"base_sha": shortSHA(b.BaseSHA),
})
}
projects := make([]string, 0, len(byProject))
for k := range byProject {
projects = append(projects, k)
}
sort.Strings(projects)
out := make([]map[string]interface{}, 0, len(projects))
total := 0
for _, proj := range projects {
lines := byProject[proj]
total += len(lines)
out = append(out, map[string]interface{}{
"project": proj,
"count": len(lines),
"branches": lines,
})
}
return map[string]interface{}{
"projects": out,
"total_branches": total,
"warning": "并行只适合**互不相干的模块**。若多条线改了同一批文件," +
"冲突会堆到 merge 时才爆发 —— 派活前先确认边界(设计 §8.4)。",
}
}
// distinctWorkdirs 返回所有活跃分支的工作目录(供批量操作/清理)。
func (p *Plugin) distinctWorkdirs() []string {
p.mu.Lock()
defer p.mu.Unlock()
seen := map[string]bool{}
var out []string
for _, b := range p.ledger.Branches {
if b.Workdir == "" || seen[b.Workdir] {
continue
}
if _, err := os.Stat(filepath.Join(b.Workdir, ".git")); err != nil {
continue
}
seen[b.Workdir] = true
out = append(out, b.Workdir)
}
sort.Strings(out)
return out
}
// handleConflictCheck 是 commondev_conflict_check 的实现。
func (p *Plugin) handleConflictCheck(args map[string]interface{}) (interface{}, error) {
id := strings.TrimSpace(str(args, "branch_id"))
p.mu.Lock()
b, ok := p.ledger.Branches[id]
var workdir, base string
if ok {
workdir, base = b.Workdir, b.BaseSHA
}
proj := ""
if ok {
if pr, ok2 := p.ledger.Projects[b.ProjectID]; ok2 {
proj = pr.DefaultBranch
}
}
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", id)}, nil
}
target := strings.TrimSpace(str(args, "target"))
if target == "" {
target = proj
}
if target == "" {
target = "main"
}
if workdir == "" {
return map[string]interface{}{"error": "该分支没有工作目录(目录准备未完成?)"}, nil
}
rep := p.checkConflict(workdir, target)
out := map[string]interface{}{
"mergeable": rep.Mergeable,
"target": rep.Target,
"detail": rep.Detail,
"base_sha": shortSHA(base), // 开线时记下的基点(审计用)
}
if len(rep.Conflicts) > 0 {
out["conflicts"] = rep.Conflicts
out["advice"] = "先在本分支 rebase/合并目标分支并解决冲突,再提 PR。" +
"★ 不要带冲突去开 PR —— 平台会拒绝,且白跑一轮判据。"
}
if rep.Skipped {
out["skipped"] = true
out["warning"] = "预检被跳过 —— 这**不等于**没有冲突。"
}
return out, nil
}
// handleParallelStatus 是 commondev_parallel_status 的实现。
func (p *Plugin) handleParallelStatus(args map[string]interface{}) (interface{}, error) {
return p.parallelSummary(), nil
}

View File

@ -1,335 +0,0 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// 用**真实 git 输出**验证冲突解析(从 git 2.53 实测抓取)。
func TestParseConflictFilesRealOutput(t *testing.T) {
// 这是 git merge-tree --write-tree 冲突时的**原样输出**
const real = `82d5993c292e11020e08a83074413e7cc6e2d5d1
100644 df967b96a579e45a18b8251732d16804b2e56a55 1 f.txt
100644 f70f10e4db19068f79bc43844b49f3eece45c4e8 2 f.txt
100644 223b7836fb19fdf64ba2d3cd6173c6a283141f78 3 f.txt
Auto-merging f.txt
CONFLICT (content): Merge conflict in f.txt
`
got := parseConflictFiles(real)
if len(got) != 1 || got[0] != "f.txt" {
t.Fatalf("应从真实输出解出 [f.txt],实际 %v", got)
}
if !isMergeTreeConflict(real) {
t.Fatal("真实冲突输出应被识别为冲突")
}
}
// 无冲突时不该误判。
func TestNoConflictOutputNotMisjudged(t *testing.T) {
const clean = `3d501af9404b81d5e9d7fe6254d082ff59108312
`
if isMergeTreeConflict(clean) {
t.Fatal("干净合并的输出不该被判为冲突")
}
if got := parseConflictFiles(clean); len(got) != 0 {
t.Fatalf("无冲突时不该解出文件: %v", got)
}
}
// 多文件冲突要全部解出且稳定排序。
func TestParseMultipleConflictsStable(t *testing.T) {
const multi = `abc123
100644 aaa 1 z.txt
100644 bbb 2 z.txt
100644 ccc 3 z.txt
100644 ddd 1 a.txt
100644 eee 2 a.txt
100644 fff 3 a.txt
CONFLICT (content): Merge conflict in z.txt
CONFLICT (content): Merge conflict in a.txt
`
got := parseConflictFiles(multi)
if len(got) != 2 || got[0] != "a.txt" || got[1] != "z.txt" {
t.Fatalf("应解出 [a.txt z.txt] 且已排序,实际 %v", got)
}
// 重复出现(阶段行 + CONFLICT 行)不该重复计数
if len(got) != len(uniqueStrings(got)) {
t.Error("同一文件出现多次时应去重")
}
}
func uniqueStrings(s []string) []string {
seen := map[string]bool{}
var out []string
for _, v := range s {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
// ★★ 决定性判据:`checkConflict` 在**真实 git 仓库**上正确判定。
//
// 上面几条测的是 parser(纯函数)。这条跑真实 git —— 因为 parser 对
// 不代表「预检对」:命令拼错、退出码语义搞反、目标分支取错,
// 都只有真跑才暴露。
//
// 场景(与并行开发的真实情形一致):
//
// main ← base
// ├─ feat/a 改 f.txt → 合进 main(main 前进)
// └─ feat/b 改 f.txt → 此时预检必冲突
//
// 以及对照组:feat/c 改的是**另一个文件** → 预检应通过。
func TestCheckConflictOnRealGitRepo(t *testing.T) {
if _, err := os.Stat("/usr/bin/git"); err != nil {
if _, err2 := exec.LookPath("git"); err2 != nil {
t.Skip("环境无 git")
}
}
base := t.TempDir()
repo := filepath.Join(base, "w")
run := func(dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v 失败: %v\n%s", args, err, out)
}
return string(out)
}
// 建仓 + 基线
// ★ 显式指定初始分支为 main:git 默认分支名随版本/配置而变
//(本机是 master),不指定的话下面的 "main" 会找不到。
run(base, "init", "-q", "-b", "main", repo)
run(repo, "config", "user.email", "t@t")
run(repo, "config", "user.name", "t")
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("base\n"), 0o600)
run(repo, "add", "-A")
run(repo, "commit", "-qm", "base")
// feat/a 改 f.txt
run(repo, "checkout", "-q", "-b", "feat/a")
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("A\n"), 0o600)
run(repo, "commit", "-qam", "A")
// feat/b 也改 f.txt(与 a 同一个文件 → 冲突)
run(repo, "checkout", "-q", "main")
run(repo, "checkout", "-q", "-b", "feat/b")
os.WriteFile(filepath.Join(repo, "f.txt"), []byte("B\n"), 0o600)
run(repo, "commit", "-qam", "B")
// feat/c 改另一个文件(不冲突)
run(repo, "checkout", "-q", "main")
run(repo, "checkout", "-q", "-b", "feat/c")
os.WriteFile(filepath.Join(repo, "g.txt"), []byte("C\n"), 0o600)
run(repo, "add", "-A")
run(repo, "commit", "-qm", "C")
p := &Plugin{ledger: &Ledger{}}
// ① main 还没前进 ⇒ feat/b 可干净合并
run(repo, "checkout", "-q", "feat/b")
rep := p.checkConflict(repo, "main")
if rep.Skipped {
t.Fatalf("不该跳过: %s", rep.Detail)
}
if !rep.Mergeable {
t.Fatalf("main 未前进时 feat/b 应可合并,实际: %s", rep.Detail)
}
if rep.HeadSHA == "" || rep.BaseSHA == "" {
t.Error("应记录 head_sha 与 base_sha(审计与重放要用)")
}
// ② 把 feat/a 合进 main(main 前进)⇒ feat/b 必冲突
run(repo, "checkout", "-q", "main")
run(repo, "merge", "-q", "--no-ff", "feat/a", "-m", "merge a")
run(repo, "checkout", "-q", "feat/b")
rep = p.checkConflict(repo, "main")
if rep.Skipped {
t.Fatalf("不该跳过: %s", rep.Detail)
}
if rep.Mergeable {
t.Fatal("★ main 已含 feat/a 的改动、feat/b 改了同一文件 ⇒ 必须判为冲突")
}
if len(rep.Conflicts) == 0 || rep.Conflicts[0] != "f.txt" {
t.Fatalf("应指出冲突文件 f.txt,实际 %v(detail=%s)", rep.Conflicts, rep.Detail)
}
// ③ 对照:feat/c 改的是另一文件 ⇒ 仍可合并
run(repo, "checkout", "-q", "feat/c")
rep = p.checkConflict(repo, "main")
if rep.Skipped {
t.Fatalf("不该跳过: %s", rep.Detail)
}
if !rep.Mergeable {
t.Fatalf("feat/c 改了无关文件,应可合并,实际: %s", rep.Detail)
}
// ④ ★ 关键:预检**不能改动工作区**
//(否则它就成了"会破坏开发状态"的副作用操作)
cur := strings.TrimSpace(run(repo, "rev-parse", "--abbrev-ref", "HEAD"))
if cur != "feat/c" {
t.Fatalf("预检把 HEAD 改到了 %s —— 它绝不该动工作区", cur)
}
if _, err := os.Stat(filepath.Join(repo, "MERGE_HEAD")); err == nil {
t.Fatal("预检留下了 MERGE_HEAD —— 会污染后续提交")
}
}
// ★★ 判据:**每一条 Skipped 路径都不得声称可合并**。
//
// 把"没检查"当成"检查通过"是最危险的静默降级 —— 调用方会以为安全。
//
// 为何要**逐路径**覆盖:checkConflict 有 3 条 Skipped 出口
// (取 HEAD 失败 / 目标分支不存在 / merge-tree 不可用),
// 初版只测了第 2 条,于是把第 1 条的 `rep.Mergeable` 改成 true 的变异
// **不报红** —— 判据覆盖不全,等于给其余两条留了洞。
func TestEverySkippedPathRefusesMergeable(t *testing.T) {
mkRepo := func(t *testing.T, withBranch bool) string {
repo := t.TempDir()
git := func(args ...string) {
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
cmd.Run()
}
if out, err := exec.Command("git", "init", "-q", "-b", "main", repo).CombinedOutput(); err != nil {
t.Skipf("git init 失败: %v %s", err, out)
}
git("config", "user.email", "t@t")
git("config", "user.name", "t")
os.WriteFile(filepath.Join(repo, "a.txt"), []byte("x\n"), 0o600)
git("add", "-A")
git("commit", "-qm", "init")
if withBranch {
git("checkout", "-q", "-b", "other")
}
return repo
}
p := &Plugin{ledger: &Ledger{}}
t.Run("目标分支不存在", func(t *testing.T) {
rep := p.checkConflict(mkRepo(t, false), "no-such-branch")
assertSkippedNotMergeable(t, rep)
})
t.Run("不是 git 仓库", func(t *testing.T) {
rep := p.checkConflict(t.TempDir(), "main")
assertSkippedNotMergeable(t, rep)
})
t.Run("工作目录不存在", func(t *testing.T) {
rep := p.checkConflict(filepath.Join(t.TempDir(), "nope"), "main")
assertSkippedNotMergeable(t, rep)
})
}
// assertSkippedNotMergeable 是上面三条路径共用的断言。
func assertSkippedNotMergeable(t *testing.T, rep ConflictReport) {
t.Helper()
if !rep.Skipped {
t.Fatalf("应标记 Skipped(detail=%s)", rep.Detail)
}
if rep.Mergeable {
t.Fatal("★ Skipped 时绝不能声称 Mergeable —— 那是把「没检查」当「检查通过」")
}
if !strings.Contains(rep.Detail, "不等于") {
t.Errorf("说明应明确「跳过≠无冲突」: %s", rep.Detail)
}
}
// ★★ 判据:`captureBaseSHA` 真的把 sha 写进台账,且**只写第一次**。
//
// "只写第一次"很重要:重开同一分支时若覆盖基点,"这条线从哪出发"
// 就永久丢失 —— 冲突判定与事后重放都失去依据。
func TestCaptureBaseSHARecordsOnceOnly(t *testing.T) {
repo := t.TempDir()
gitIn := func(args ...string) {
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
cmd.Run()
}
if out, err := exec.Command("git", "init", "-q", "-b", "main", repo).CombinedOutput(); err != nil {
t.Skipf("git init 失败: %v %s", err, out)
}
gitIn("config", "user.email", "t@t")
gitIn("config", "user.name", "t")
os.WriteFile(filepath.Join(repo, "a.txt"), []byte("1\n"), 0o600)
gitIn("add", "-A")
gitIn("commit", "-qm", "c1")
p := &Plugin{ledger: &Ledger{Branches: map[string]*Branch{
"br-x": {ID: "br-x", Name: "feat/x", Status: "open"},
}}}
sha1, err := p.captureBaseSHA("br-x", repo)
if err != nil {
t.Fatal(err)
}
if sha1 == "" || len(sha1) < 7 {
t.Fatalf("应拿到 sha,实际 %q", sha1)
}
if p.ledger.Branches["br-x"].BaseSHA != sha1 {
t.Fatal("base_sha 未写进台账")
}
// 再提交一次(HEAD 前进),再捕获 → 仍应返回**第一次**的 sha
os.WriteFile(filepath.Join(repo, "a.txt"), []byte("2\n"), 0o600)
gitIn("add", "-A")
gitIn("commit", "-qm", "c2")
sha2, err := p.captureBaseSHA("br-x", repo)
if err != nil {
t.Fatal(err)
}
if sha2 != sha1 {
t.Fatalf("基点不该被后续提交覆盖:第一次 %s,第二次 %s", shortSHA(sha1), shortSHA(sha2))
}
}
// ★ 判据:拿不到 sha 时**如实报错**,不静默返回空串。
//
// 空 base_sha 会让后续冲突判定失去依据,而"没记上"与"记了个空的"
// 在台账里长得一样 —— 所以必须报错。
func TestCaptureBaseSHAFailsLoudlyOnBadRepo(t *testing.T) {
p := &Plugin{ledger: &Ledger{Branches: map[string]*Branch{"br-y": {ID: "br-y"}}}}
if _, err := p.captureBaseSHA("br-y", filepath.Join(t.TempDir(), "not-a-repo")); err == nil {
t.Fatal("非 git 目录应报错,而不是返回空 sha")
}
}
// ★ 判据:**仅 ls-tree 阶段行**(无 "CONFLICT" 行)也要能解出文件名。
//
// 某些冲突类型(如 rename/rename)不打印 "CONFLICT ... in <path>",
// 只有 ls-tree 的三个阶段行。少了这条解析路径就会漏报冲突文件 ——
// 而"漏报"比"多报"危险:调用方以为没有冲突。
func TestParseConflictFromLsTreeOnly(t *testing.T) {
// 只有阶段行、没有 "CONFLICT ... in" 行(某些冲突类型如此)
const only = `abc123
100644 aaa 1 dir/x.go
100644 bbb 2 dir/x.go
100644 ccc 3 dir/x.go
`
got := parseConflictFiles(only)
if len(got) != 1 || got[0] != "dir/x.go" {
t.Fatalf("仅 ls-tree 形态也应解出 [dir/x.go],实际 %v", got)
}
}

View File

@ -1,184 +0,0 @@
package main
// 凭据存储:**保留的通用部分**。
//
// ## 2026-10-15 架构调整(用户拍板)
//
// **git 托管平台专有的部分已移出插件**(`platformSpec` / `account_*` 工具 /
// 角色语义 / 按平台构造认证头)—— 那些是**平台知识**,进 skill 由 agent
// 用 `safeshell` + `curl` 操作。理由:每接一个托管平台都要改 Go 代码、
// 重编译、重启服务,而平台差异只是「API 前缀 + 认证头」两个维度的不同。
//
// **本文件保留的是与平台无关的部分**:
// · 凭据的加密落盘(AES-256-GCM,自持密钥)
// · `${ENV_VAR}` 变量引用解析
// · 「不泄漏值」的展示形态
//
// 平台的用法见 skill `commondev-discipline`(含 Gitea/GitHub/GitLab/Gitee
// 的认证头对照表、PR 与分支保护的 curl 命令、以及三个实测踩到的 API 坑)。
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
)
// Account 是一条凭据。
//
// ★ 已移除 `Platform` / `Role` / `BaseURL` 等**平台语义**字段:
// 本插件不再按平台构造请求,也不再有「执行侧/主管侧」的角色区分 ——
// 那是平台侧的(分支保护)与 skill 侧的知识。
type Account struct {
Name string `json:"name"`
// Token 是**旧路径**:明文或 `${ENV_VAR}` 引用。
// ★ 新登记会立即加密进 Sealed,本字段随即清空(见 sealAllLocked)。
// 本字段**绝不出现在任何工具返回值里**(见 redacted)。
Token string `json:"token,omitempty"`
// Sealed 是 AES-256-GCM 加密后的凭据(base64,nonce 前置)。
//
// 为何要加密:文件虽 0600,但会被备份、打包、随日志外带;
// 加密后即使文件外泄,没有密钥也不可用。
Sealed string `json:"sealed,omitempty"`
// Username 仅用于 git 提交身份(可选)。
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
Note string `json:"note,omitempty"`
CreatedAt string `json:"created_at"`
}
// credentialsFile 是磁盘上的凭据集合(独立文件,0600)。
type credentialsFile struct {
Accounts map[string]*Account `json:"accounts"`
}
// varRefPattern 匹配 ${VAR} 或 $VAR 形式的变量引用。
var varRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
// credStore 是凭据管理器(与 Plugin 同生命周期)。
type credStore struct {
mu sync.Mutex
path string
data credentialsFile
// keyProvider 返回加密密钥(由 Plugin 注入)。
keyProvider func() ([]byte, error)
}
func newCredStore(path string) (*credStore, error) {
cs := &credStore{path: path, data: credentialsFile{Accounts: map[string]*Account{}}}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cs, nil
}
return nil, err
}
// ★ 启动时校验权限:凭据文件必须 0600(本仓纪律:响亮失败,不静默降级)
if fi, err := os.Stat(path); err == nil {
if perm := fi.Mode().Perm(); perm != 0o600 {
return nil, fmt.Errorf(
"凭据文件权限不安全:%s 是 %04o,必须是 0600(它是能 push 用户仓库的凭据)",
path, perm)
}
}
if err := json.Unmarshal(b, &cs.data); err != nil {
return nil, fmt.Errorf("凭据文件损坏(拒绝静默重建): %w", err)
}
if cs.data.Accounts == nil {
cs.data.Accounts = map[string]*Account{}
}
return cs, nil
}
// saveLocked 写入并确权(调用方持锁)。
func (cs *credStore) saveLocked() error {
b, err := json.MarshalIndent(cs.data, "", " ")
if err != nil {
return err
}
tmp := cs.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
if err := os.Chmod(tmp, 0o600); err != nil {
return err
}
return os.Rename(tmp, cs.path)
}
// resolveSecret 把凭据里的变量引用解析成实际值。
//
// 返回错误的情形(都不静默降级):
// - 引用了未设置的环境变量
// - 解析结果为空
func resolveSecret(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("凭据为空")
}
var missing []string
out := varRefPattern.ReplaceAllStringFunc(raw, func(m string) string {
name := m
if strings.HasPrefix(m, "${") {
name = m[2 : len(m)-1]
} else {
name = m[1:]
}
v, ok := os.LookupEnv(name)
if !ok || v == "" {
missing = append(missing, name)
return ""
}
return v
})
if len(missing) > 0 {
return "", fmt.Errorf(
"凭据引用的环境变量未设置: %s —— 请在插件进程的环境里提供它(不静默当空串,"+
"否则会退化成匿名访问、表现成 401 而非“凭据没配”)",
strings.Join(missing, ", "))
}
if strings.TrimSpace(out) == "" {
return "", fmt.Errorf("凭据解析后为空")
}
return out, nil
}
// redacted 返回凭据的可安全展示形态:**绝不暴露 secret**。
func (a *Account) redacted() map[string]interface{} {
out := map[string]interface{}{
"name": a.Name,
"username": a.Username,
"token_ref": tokenRefLabel(a.Token),
"token_set": a.Sealed != "" || strings.TrimSpace(a.Token) != "",
}
if a.Note != "" {
out["note"] = a.Note
}
if a.Sealed != "" {
out["storage"] = "encrypted(AES-256-GCM)"
} else {
out["storage"] = "plaintext-or-envref"
}
return out
}
// tokenRefLabel 描述凭据来源但**不泄露值**。
func tokenRefLabel(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if varRefPattern.MatchString(raw) {
return raw // ${VAR} 是引用不是秘密,展示它对排查有用
}
return "(明文已设置)"
}
// credentialsPath 是凭据文件路径(与台账同目录)。
func (p *Plugin) credentialsPath() string {
return filepath.Join(filepath.Dir(p.path), "credentials.json")
}

View File

@ -1,767 +0,0 @@
package main
// 刀 4:**判据与复核**(`criteria` / `review`)—— 设计文档 §4.5 的落地。
//
// ## 核心主张:复核必须是**两步**(正向 + 变异)
//
// 用户原话:「我定义过单元测试后,对应 agent 完成修改后你直接让插件进行复核即可」
//
// 这里的隐含假设是「插件 GREEN = 改对了」。**该假设不成立** ——
// 插件只能跑主管**预先写下**的判据;判据没覆盖那条 bug,插件照样打勾。
//
// 本仓当日实证(我在 /tmp 复刻过一遍,结论一致):
//
// func Max(a,b int) int { if a>b { return a }; return b }
// 松判据只测 Max(5,3) == 5
// 变异 return b → return a(真 bug)⇒ **判据仍全绿**
// 补一条 Max(3,5) == 5 ⇒ 同一变异立刻报红
//
// ⇒ **没有反向阶段,就是在给"测试通过"这个信号不断充值信用,
// 直到它骗一次大的。**
//
// ## 两个阶段
//
// 正向 forward:跑判据,期望**通过**
// 反向 mutate :改坏被测代码,期望判据**报红**(不报红 ⇒ 判据太松)
//
// ## ★ 实现要点(每条都是踩过的坑)
//
// 1. **必须禁用测试缓存**(`-count=1`):变异后若命中缓存,测试根本没跑,
// "报红"就无从谈起 —— 而 Go 默认会缓存。实测复刻时就看到 `(cached)`。
// 2. **变异必须真的改到文件**:改坏→跑→**恢复**,恢复用 defer 保证
// 即使 panic 也不留脏文件(否则工作目录被污染,后续全乱)。
// 3. **恢复失败要响亮**:恢复不回去比变异本身危险得多(agent 会基于
// 坏代码继续改)。
// 4. **判据脚本的"通过"由退出码决定**,不是由输出文本里有没有 "PASS"。
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// ---------------- 台账结构 ----------------
// Criteria 是**主管定义的一条判据**。
type Criteria struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
BranchID string `json:"branch_id,omitempty"` // 留空 = 项目级(所有分支共用)
Name string `json:"name"`
// Kind 是判据类型:unit|interface|stress|build。
Kind string `json:"kind"`
// Script 是判据本体(shell 命令)。退出码 0 = 通过。
Script string `json:"script"`
// Cwd 是执行目录(相对工作目录)。留空 = 工作目录根。
Cwd string `json:"cwd,omitempty"`
// TimeoutSec 单次执行上限。默认 300。
TimeoutSec int `json:"timeout_sec,omitempty"`
// Expect 描述"通过"的含义(**给人看**,不参与判定)。
Expect string `json:"expect,omitempty"`
// Mutations 是**变异规则**(反向阶段用)。
//
// 空 = 只做正向。但设计 §4.5 明确:**没有反向就是在给信号充值信用** ——
// 所以复核结果里会把"无变异规则"标成一个**警告**,而不是静默略过。
Mutations []Mutation `json:"mutations,omitempty"`
// DefHash 是定义指纹:判据内容变了它就该变。
//
// 用途:① 幂等(重复 define 同一条不产生新记录)
// ② 审计("这次复核跑的是哪一版判据")
DefHash string `json:"def_hash"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// Mutation 是一条变异规则:把文件里的 old 换成 new。
type Mutation struct {
// ID 用于报告(如 "M1")。
ID string `json:"id"`
// File 是相对工作目录的路径。
File string `json:"file"`
// Old / New 是字面量替换(**不是正则** —— 正则容易改错地方)。
Old string `json:"old"`
New string `json:"new"`
// Note 说明这条变异想验证什么("判据是否覆盖了 b>a 的分支")。
Note string `json:"note,omitempty"`
}
// ReviewRun 是一次复核执行的结果。
type ReviewRun struct {
ID string `json:"id"`
BranchID string `json:"branch_id"`
Phase string `json:"phase"` // forward | mutate
Criteria string `json:"criteria_id"`
Result string `json:"result"` // pass | fail | error | skipped
Detail string `json:"detail,omitempty"`
Evidence string `json:"evidence,omitempty"` // 输出摘要(截断)
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at,omitempty"`
// HeadSHA 是复核时的提交 —— 判据通过的是**那个提交**,不是一个会移动的目标。
HeadSHA string `json:"head_sha,omitempty"`
}
// ---------------- 判据定义 ----------------
// criteriaDefHash 计算判据定义的指纹。
//
// 为何要它:① 幂等(同定义重复登记不产生新记录);
// ② 审计("这次跑的是哪一版判据")。变异规则也必须进指纹 ——
// 只改变异规则而判据本体不变时,复核的正确性已经变了。
func criteriaDefHash(c *Criteria) string {
h := sha256.New()
for _, part := range []string{c.Name, c.Kind, c.Script, c.Cwd, c.Expect} {
h.Write([]byte(part))
h.Write([]byte{0})
}
for _, m := range c.Mutations {
h.Write([]byte(m.ID + "\x01" + m.File + "\x01" + m.Old + "\x01" + m.New))
h.Write([]byte{0})
}
fmt.Fprintf(h, "%d", c.TimeoutSec)
return hex.EncodeToString(h.Sum(nil))[:16]
}
// ---------------- 执行 ----------------
// runShell 在工作目录里跑一段命令,返回 (退出码, 合并输出)。
//
// ★ 超时是必须的:判据脚本可能挂住(死循环、等输入)。内核工具调用
// 有 60s 硬限,但这里是**后台作业**(不受它限),所以自己定上限。
func runShell(dir, script string, timeoutSec int) (int, string) {
if timeoutSec <= 0 {
timeoutSec = 300
}
ctx, cancel := newTimeoutCtx(time.Duration(timeoutSec) * time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", script)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
out, err := cmd.CombinedOutput()
code := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
} else {
code = -1 // 起不来(命令不存在等)
}
}
return code, string(out)
}
// runCriteriaForward 执行一条判据的**正向**阶段。
//
// 返回 (是否通过, 证据摘要)。
func (p *Plugin) runCriteriaForward(c *Criteria, workdir string) (bool, string) {
dir := workdir
if c.Cwd != "" {
dir = filepath.Join(workdir, c.Cwd)
}
if _, err := os.Stat(dir); err != nil {
return false, fmt.Sprintf("执行目录不存在: %s", dir)
}
code, out := runShell(dir, c.Script, c.TimeoutSec)
if code == 0 {
return true, summarize(out, "通过(退出码 0)")
}
return false, summarize(out, fmt.Sprintf("未通过(退出码 %d)", code))
}
// runCriteriaMutate 执行**反向**阶段:逐条变异,期望判据报红。
//
// ★ 这是本刀的核心。设计 §4.5:变异**不报红 ⇒ 判据太松**。
//
// 返回 (全部变异都被咬住, 逐条说明, 是否有严重错误)。
func (p *Plugin) runCriteriaMutate(c *Criteria, workdir string) (bool, string, bool) {
if len(c.Mutations) == 0 {
return false, "未定义变异规则 —— **无法判断判据是否太松**。" +
"设计 §4.5:没有反向阶段,就是在给「测试通过」这个信号充值信用。" +
"请在 commondev_test_define 里补 mutations", false
}
allCaught := true
var lines []string
for _, m := range c.Mutations {
caught, note, hardErr := p.runOneMutation(c, m, workdir)
if hardErr {
// 硬错误(改不到文件、恢复失败)—— 必须响亮,不能算"咬住"
allCaught = false
lines = append(lines, fmt.Sprintf(" ✗ %s 执行错误: %s", m.ID, note))
continue
}
if caught {
lines = append(lines, fmt.Sprintf(" ✓ %s 被咬住(判据报红)%s", m.ID, suffixNote(m.Note)))
} else {
allCaught = false
lines = append(lines, fmt.Sprintf(" ✗ %s **未被咬住** —— 判据太松%s", m.ID, suffixNote(m.Note)))
}
}
return allCaught, strings.Join(lines, "\n"), false
}
// runOneMutation 改坏 → 跑判据 → 恢复。返回 (是否被咬住, 说明, 硬错误)。
func (p *Plugin) runOneMutation(c *Criteria, m Mutation, workdir string) (bool, string, bool) {
if m.File == "" || m.Old == "" {
return false, "变异规则缺 file 或 old", true
}
path := filepath.Join(workdir, m.File)
orig, err := os.ReadFile(path)
if err != nil {
return false, fmt.Sprintf("读不到 %s: %v", m.File, err), true
}
text := string(orig)
// ★ Old 必须**恰好出现一次**:出现 0 次 = 变异无效(改不到);
// 出现多次 = 可能改错地方(第一个不一定是我们想改的那个)。
// 两者都必须报错 —— 否则"变异没生效"会被误判成"判据太松"(假警报),
// 或"改了别处"被误判成"咬住了"(假通过)。
n := strings.Count(text, m.Old)
if n == 0 {
return false, fmt.Sprintf("%s 里找不到要替换的片段(变异无效): %q", m.File, truncate(m.Old, 60)), true
}
if n > 1 {
return false, fmt.Sprintf("%s 里该片段出现 %d 次(可能改错地方,请给出更长的上下文): %q",
m.File, n, truncate(m.Old, 60)), true
}
mutated := strings.Replace(text, m.Old, m.New, 1)
if mutated == text {
return false, "替换后内容未变化(Old 与 New 相同?)", true
}
// ★ 写坏 → 跑 → **无论如何都恢复**(defer 保证 panic 也恢复)
restore := func() error { return os.WriteFile(path, orig, 0o644) }
if err := os.WriteFile(path, []byte(mutated), 0o644); err != nil {
return false, fmt.Sprintf("写变异失败: %v", err), true
}
defer func() { _ = restore() }()
passed, evidence := p.runCriteriaForward(c, workdir)
// 恢复并**校验**恢复成功
if err := restore(); err != nil {
return false, fmt.Sprintf("★ 恢复 %s 失败(工作目录已被污染): %v", m.File, err), true
}
back, err := os.ReadFile(path)
if err != nil || string(back) != text {
return false, fmt.Sprintf("★ 恢复 %s 后内容不一致(工作目录已被污染)", m.File), true
}
if !passed {
return true, summarize(evidence, "判据报红(期望行为)"), false
}
// 变异了但判据仍通过 ⇒ 这条变异没被咬住 ⇒ 判据在该维度太松
return false, summarize(evidence, "变异后判据**仍通过**"), false
}
func suffixNote(note string) string {
if note == "" {
return ""
}
return "(" + note + ")"
}
// summarize 把输出压成一条可读证据(**截断**:判据输出可能很长,
// 全塞进台账会让文件爆炸,而证据的价值在"能看出怎么失败的")。
func summarize(out, headline string) string {
out = strings.TrimSpace(out)
if out == "" {
return headline
}
return headline + "\n" + truncate(out, 1500)
}
// ---------------- 工具实现 ----------------
func (p *Plugin) handleTestDefine(args map[string]interface{}) (interface{}, error) {
proj := strings.TrimSpace(str(args, "project"))
if proj == "" {
return map[string]interface{}{"error": "project 必填"}, nil
}
name := strings.TrimSpace(str(args, "name"))
script := str(args, "script")
if name == "" || strings.TrimSpace(script) == "" {
return map[string]interface{}{"error": "name 与 script 必填"}, nil
}
kind := strings.TrimSpace(str(args, "kind"))
if kind == "" {
kind = "unit"
}
switch kind {
case "unit", "interface", "stress", "build":
default:
return map[string]interface{}{"error": "kind 只能是 unit|interface|stress|build"}, nil
}
c := &Criteria{
ID: fmt.Sprintf("cr-%s-%s", safeNamePattern.ReplaceAllString(proj, "_"), shortHash(proj+name+script)),
ProjectID: proj,
BranchID: strings.TrimSpace(str(args, "branch_id")),
Name: name,
Kind: kind,
Script: script,
Cwd: strings.TrimSpace(str(args, "cwd")),
Expect: strings.TrimSpace(str(args, "expect")),
TimeoutSec: int(numArg(args, "timeout_sec")),
CreatedAt: nowRFC3339(),
}
// 变异规则
if raw, ok := args["mutations"].([]interface{}); ok {
for i, item := range raw {
mm, ok := item.(map[string]interface{})
if !ok {
continue
}
m := Mutation{
ID: str(mm, "id"),
File: str(mm, "file"),
Old: str(mm, "old"),
New: str(mm, "new"),
Note: str(mm, "note"),
}
if m.ID == "" {
m.ID = fmt.Sprintf("M%d", i+1)
}
if m.File == "" || m.Old == "" || m.New == "" {
return map[string]interface{}{"error": fmt.Sprintf("变异 %s 缺 file/old/new", m.ID)}, nil
}
c.Mutations = append(c.Mutations, m)
}
}
p.mu.Lock()
defer p.mu.Unlock()
if p.ledger.Criteria == nil {
p.ledger.Criteria = map[string]*Criteria{}
}
// 幂等:同 ID 且定义指纹相同 ⇒ 不动("可迭代幂等")
if prev, exists := p.ledger.Criteria[c.ID]; exists {
if prev.DefHash == criteriaDefHash(c) {
return map[string]interface{}{
"status": "unchanged", "criteria_id": c.ID,
"note": "同定义已存在,未改动(幂等)",
}, nil
}
c.CreatedAt = prev.CreatedAt
c.UpdatedAt = nowRFC3339()
}
c.DefHash = criteriaDefHash(c)
p.ledger.Criteria[c.ID] = c
if err := p.saveLocked(); err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
out := map[string]interface{}{
"status": "ok", "criteria_id": c.ID, "kind": c.Kind,
"def_hash": c.DefHash, "mutations": len(c.Mutations),
}
if len(c.Mutations) == 0 {
out["warning"] = "未定义变异规则:复核时**无法判断判据是否太松**(设计 §4.5)"
}
return out, nil
}
func (p *Plugin) handleTestList(args map[string]interface{}) (interface{}, error) {
proj := strings.TrimSpace(str(args, "project"))
p.mu.Lock()
defer p.mu.Unlock()
out := []map[string]interface{}{}
for _, c := range p.ledger.Criteria {
if proj != "" && c.ProjectID != proj {
continue
}
out = append(out, map[string]interface{}{
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
"project": c.ProjectID, "branch_id": c.BranchID,
"mutations": len(c.Mutations), "def_hash": c.DefHash,
})
}
sort.Slice(out, func(i, j int) bool {
return out[i]["name"].(string) < out[j]["name"].(string)
})
return map[string]interface{}{"criteria": out, "count": len(out)}, nil
}
// newTimeoutCtx 是 context.WithTimeout 的薄包装。
func newTimeoutCtx(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// ---------------- 复核执行(后台作业) ----------------
// reviewAll 对一个分支跑**全部**判据的复核,返回汇总。
//
// ★ 这是「主管按一下、插件跑完正反两阶段」的入口。
func (p *Plugin) reviewAll(branchID string) (map[string]interface{}, error) {
p.mu.Lock()
b, ok := p.ledger.Branches[branchID]
var workdir, proj string
if ok {
workdir, proj = b.Workdir, b.ProjectID
}
// 收集适用判据:分支级优先,项目级兜底
var list []*Criteria
for _, c := range p.ledger.Criteria {
if c.ProjectID != proj {
continue
}
if c.BranchID != "" && c.BranchID != branchID {
continue
}
list = append(list, c)
}
p.mu.Unlock()
if !ok {
return nil, fmt.Errorf("工程对话 %s 不存在", branchID)
}
if workdir == "" {
return nil, fmt.Errorf("该分支没有工作目录")
}
if len(list) == 0 {
return map[string]interface{}{
"error": fmt.Sprintf("项目 %s 没有定义任何判据 —— 先 commondev_test_define", proj),
}, nil
}
// ★ 固定顺序(map 遍历随机)—— 复核报告要可对照
sort.Slice(list, func(i, j int) bool {
if list[i].Kind != list[j].Kind {
return list[i].Kind < list[j].Kind
}
return list[i].Name < list[j].Name
})
head, _ := runGitOut(workdir, "rev-parse", "HEAD")
head = strings.TrimSpace(head)
forwardOK := true
mutateOK := true
anyWithoutMutations := false
var report []map[string]interface{}
for _, c := range list {
fwdPass, fwdEv := p.runCriteriaForward(c, workdir)
if !fwdPass {
forwardOK = false
}
mutCaught, mutEv, _ := p.runCriteriaMutate(c, workdir)
if len(c.Mutations) == 0 {
anyWithoutMutations = true
} else if !mutCaught {
mutateOK = false
}
item := map[string]interface{}{
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
"forward_pass": fwdPass,
"forward_evidence": truncate(fwdEv, 800),
"mutations_total": len(c.Mutations),
"mutations_caught": mutCaught,
}
if len(c.Mutations) > 0 {
item["mutate_evidence"] = truncate(mutEv, 1200)
} else {
item["mutate_evidence"] = mutEv
}
report = append(report, item)
// 写复核历史(可审计:哪次复核、跑的是哪一版判据、在哪个提交上)
p.appendReviewRun(branchID, c.ID, "forward", fwdPass, fwdEv, head)
if len(c.Mutations) > 0 {
p.appendReviewRun(branchID, c.ID, "mutate", mutCaught, mutEv, head)
}
}
// ★ 结论三条并列,**不合并成一个绿色勾**:
// 正向绿 + 反向没跑 ≠ 复核通过(那是"信号充值")
verdict := "pass"
notes := []string{}
if !forwardOK {
verdict = "fail"
notes = append(notes, "有判据未通过(实现有 bug)")
}
if anyWithoutMutations {
notes = append(notes, "★ 有判据未定义变异规则 ⇒ 无法判断它是否太松(设计 §4.5)")
if verdict == "pass" {
verdict = "pass_with_gaps"
}
}
if !mutateOK {
notes = append(notes, "★ 有变异**未被咬住** ⇒ 对应判据太松,实现可能有问题而判据看不见")
if verdict == "pass" {
verdict = "pass_with_gaps"
}
}
out := map[string]interface{}{
"verdict": verdict, // pass | pass_with_gaps | fail
"forward_ok": forwardOK,
"mutate_ok": mutateOK,
"head_sha": shortSHA(head),
"criteria_run": len(report),
"report": report,
}
if len(notes) > 0 {
out["notes"] = notes
}
if verdict == "pass_with_gaps" {
out["warning"] = "正向全绿,但**反向阶段有缺口** —— " +
"这不是「通过」,是「判据没覆盖到」。插件 GREEN ≠ 改对了。"
}
return out, nil
}
// appendReviewRun 记一条复核历史。
func (p *Plugin) appendReviewRun(branchID, criteriaID, phase string, pass bool, evidence, head string) {
res := "fail"
if pass {
res = "pass"
}
p.mu.Lock()
defer p.mu.Unlock()
p.ledger.ReviewRuns = append(p.ledger.ReviewRuns, ReviewRun{
ID: fmt.Sprintf("rv-%d", time.Now().UnixNano()%1000000000),
BranchID: branchID, Criteria: criteriaID, Phase: phase,
Result: res, Evidence: truncate(evidence, 500),
StartedAt: nowRFC3339(), EndedAt: nowRFC3339(),
HeadSHA: head,
})
// 只保留最近 500 条(防台账无限膨胀;复核历史是审计用,不需要全量)
if n := len(p.ledger.ReviewRuns); n > 500 {
p.ledger.ReviewRuns = p.ledger.ReviewRuns[n-500:]
}
_ = p.saveLocked()
}
// handleReviewSubmit 是 commondev_review_submit 的实现。
//
// ★ 复核可能跑很久(判据里可能有编译、压测)⇒ 走**后台作业**,
// 与 branch_open 的 clone 同理(内核工具调用有 60s 硬限)。
func (p *Plugin) handleReviewSubmit(args map[string]interface{}) (interface{}, error) {
branchID := strings.TrimSpace(str(args, "branch_id"))
if branchID == "" {
return map[string]interface{}{"error": "branch_id 必填"}, nil
}
p.mu.Lock()
if _, ok := p.ledger.Branches[branchID]; !ok {
p.mu.Unlock()
return map[string]interface{}{"error": fmt.Sprintf("工程对话 %s 不存在", branchID)}, nil
}
jobID := fmt.Sprintf("review-%s-%d", branchID, time.Now().UnixNano()%100000)
p.ledger.Jobs[jobID] = &Job{
ID: jobID, BranchID: branchID, Kind: "review",
Status: "pending", StartedAt: nowRFC3339(),
}
p.saveLocked()
p.mu.Unlock()
go func() {
p.setJob(jobID, "running", "正在复核(正向 + 变异)", "")
res, err := p.reviewAll(branchID)
if err != nil {
p.setJob(jobID, "failed", "", err.Error())
return
}
if e, isErr := res["error"]; isErr {
p.setJob(jobID, "failed", "", fmt.Sprint(e))
return
}
verdict := fmt.Sprint(res["verdict"])
detail := fmt.Sprintf("复核结论: %s", verdict)
if notes, ok := res["notes"].([]string); ok && len(notes) > 0 {
detail += " | " + strings.Join(notes, ";")
}
p.setJob(jobID, "done", detail, "")
p.lastReview = res
}()
return map[string]interface{}{
"status": "ok", "job_id": jobID, "branch_id": branchID,
"next": "复核在后台进行(判据可能含编译/压测,超过 60s 工具上限)——" +
"用 commondev_job_status 查进度;用 commondev_review_result 取结论",
}, nil
}
// handleReviewResult 取最近一次复核结论。
func (p *Plugin) handleReviewResult(args map[string]interface{}) (interface{}, error) {
branchID := strings.TrimSpace(str(args, "branch_id"))
p.mu.Lock()
res := p.lastReview
// 也把历史摘要带上(审计)
var hist []map[string]interface{}
for _, r := range p.ledger.ReviewRuns {
if branchID != "" && r.BranchID != branchID {
continue
}
hist = append(hist, map[string]interface{}{
"id": r.ID, "phase": r.Phase, "criteria": r.Criteria,
"result": r.Result, "head_sha": shortSHA(r.HeadSHA), "at": r.StartedAt,
})
}
p.mu.Unlock()
if res == nil && len(hist) == 0 {
return map[string]interface{}{"error": "还没有复核记录(先 commondev_review_submit)"}, nil
}
// 只给最近 40 条(防刷屏)
if len(hist) > 40 {
hist = hist[len(hist)-40:]
}
out := map[string]interface{}{"history": hist, "history_count": len(hist)}
if res != nil {
out["last"] = res
}
return out, nil
}
// ---------------- 单阶段复核工具(设计 §4.1) ----------------
//
// 设计 §4.1 把复核拆成两个工具:
//
// commondev_test_run 执行判据集合(正向)
// commondev_test_mutate 执行变异验证(反向)★ 必须有
//
// 而 commondev_review_submit 是**两者一起跑**(主管按一下就跑完)。
// 三种粒度都保留,因为排查时需要单跑某一阶段:
// "正向都过不了" 与 "正过但变异咬不住" 是**完全不同**的问题。
// criteriaFor 收集适用于某分支的判据(分支级优先,项目级兜底)。
func (p *Plugin) criteriaFor(branchID, proj string) []*Criteria {
p.mu.Lock()
var list []*Criteria
for _, c := range p.ledger.Criteria {
if c.ProjectID != proj {
continue
}
if c.BranchID != "" && c.BranchID != branchID {
continue
}
list = append(list, c)
}
p.mu.Unlock()
sort.Slice(list, func(i, j int) bool {
if list[i].Kind != list[j].Kind {
return list[i].Kind < list[j].Kind
}
return list[i].Name < list[j].Name
})
return list
}
// lookupBranchWorkdir 取分支与其项目(找不到时给明确错误)。
func (p *Plugin) lookupBranchWorkdir(branchID string) (*Branch, *Project, error) {
p.mu.Lock()
defer p.mu.Unlock()
b, ok := p.ledger.Branches[branchID]
if !ok {
return nil, nil, fmt.Errorf("工程对话 %s 不存在", branchID)
}
proj := p.ledger.Projects[b.ProjectID]
if proj == nil {
return nil, nil, fmt.Errorf("分支 %s 的项目 %s 不存在", branchID, b.ProjectID)
}
if strings.TrimSpace(b.Workdir) == "" {
return nil, nil, fmt.Errorf("该分支没有工作目录(先 commondev_branch_open)")
}
return b, proj, nil
}
// handleTestRun 只跑**正向**阶段。
func (p *Plugin) handleTestRun(args map[string]interface{}) (interface{}, error) {
branchID := strings.TrimSpace(str(args, "branch_id"))
if branchID == "" {
return map[string]interface{}{"error": "branch_id 必填"}, nil
}
b, proj, err := p.lookupBranchWorkdir(branchID)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
list := p.criteriaFor(branchID, proj.Name)
if len(list) == 0 {
return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据(先 commondev_test_define)", proj.Name)}, nil
}
allPass, report := true, []map[string]interface{}{}
for _, c := range list {
pass, ev := p.runCriteriaForward(c, b.Workdir)
if !pass {
allPass = false
}
report = append(report, map[string]interface{}{
"criteria_id": c.ID, "name": c.Name, "kind": c.Kind,
"pass": pass, "evidence": truncate(ev, 800),
})
}
out := map[string]interface{}{
"phase": "forward", "all_pass": allPass,
"criteria_run": len(report), "report": report,
}
if !allPass {
out["error"] = "有判据未通过(实现有 bug)"
}
// ★ 提醒:正向过了也不代表改对了
out["warning"] = "正向通过 ≠ 改对了 —— 还要跑 commondev_test_mutate" +
"(变异不报红 ⇒ 判据太松,见设计 §4.5)"
return out, nil
}
// handleTestMutate 只跑**反向**阶段。
func (p *Plugin) handleTestMutate(args map[string]interface{}) (interface{}, error) {
branchID := strings.TrimSpace(str(args, "branch_id"))
if branchID == "" {
return map[string]interface{}{"error": "branch_id 必填"}, nil
}
b, proj, err := p.lookupBranchWorkdir(branchID)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
list := p.criteriaFor(branchID, proj.Name)
if len(list) == 0 {
return map[string]interface{}{"error": fmt.Sprintf("项目 %s 没有定义判据", proj.Name)}, nil
}
allCaught, withGaps, report := true, []string{}, []map[string]interface{}{}
for _, c := range list {
if len(c.Mutations) == 0 {
allCaught = false
withGaps = append(withGaps, c.Name)
report = append(report, map[string]interface{}{
"criteria_id": c.ID, "name": c.Name,
"mutations_total": 0, "all_caught": false,
"evidence": "★ 未定义变异规则 ⇒ 无法判断判据是否太松",
})
continue
}
caught, ev, _ := p.runCriteriaMutate(c, b.Workdir)
if !caught {
allCaught = false
}
report = append(report, map[string]interface{}{
"criteria_id": c.ID, "name": c.Name,
"mutations_total": len(c.Mutations), "all_caught": caught,
"evidence": truncate(ev, 1200),
})
}
out := map[string]interface{}{
"phase": "mutate", "all_caught": allCaught,
"criteria_run": len(report), "report": report,
}
if len(withGaps) > 0 {
out["criteria_without_mutations"] = withGaps
}
if !allCaught {
out["warning"] = "★ 有变异**未被咬住** ⇒ 判据太松,实现可能有问题而判据看不见"
}
return out, nil
}

View File

@ -1,378 +0,0 @@
package main
// 刀 4 的判据:判据与复核(正向 + 变异)。
//
// ## 这组判据要证明的核心命题
//
// 设计 §4.5:「插件 GREEN ≠ 改对了」——
//
// 插件只能跑主管**预先写下**的判据。判据没覆盖那条 bug,插件照样打勾。
// 自动化**不会**发现假绿,只会让假绿跑得更快、更可信。
//
// ⇒ 复核必须是两步:正向(判据应过)+ 反向(变异应被咬住)。
//
// 所以本文件最重要的判据是 **TestReviewCatchesLooseCriteria**:
// 造一个「真 bug 但判据太松」的场景,断言复核**不给出 pass**。
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// newTestPluginForCriteria 建一个带台账的插件。
func newTestPluginForCriteria(t *testing.T) *Plugin {
t.Helper()
p := newTestPluginForShell(t)
p.ledger = &Ledger{
Projects: map[string]*Project{},
Branches: map[string]*Branch{},
Jobs: map[string]*Job{},
Criteria: map[string]*Criteria{},
}
return p
}
// writeRepo 在工作目录里放一个可被变异的最小 Go 项目。
func writeRepo(t *testing.T, loose bool) string {
t.Helper()
dir := t.TempDir()
mustWrite := func(rel, content string) {
path := filepath.Join(dir, rel)
os.MkdirAll(filepath.Dir(path), 0o755)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
mustWrite("go.mod", "module probe\n\ngo 1.21\n")
mustWrite("calc.go", `package calc
func Max(a, b int) int {
if a > b {
return a
}
return b
}
`)
if loose {
// ★ 松判据:只测 a>b 的分支
mustWrite("calc_test.go", `package calc
import "testing"
func TestMax(t *testing.T) {
if Max(5, 3) != 5 {
t.Fatal("错")
}
}
`)
} else {
// 严判据:两个方向都测
mustWrite("calc_test.go", `package calc
import "testing"
func TestMax(t *testing.T) {
if Max(5, 3) != 5 {
t.Fatal("a>b 错")
}
if Max(3, 5) != 5 {
t.Fatal("b>a 错")
}
}
`)
}
return dir
}
func gitIn(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// ★★★ 本刀最重要的判据:**复核必须识破「真 bug + 松判据」**。
//
// 场景:
//
// 代码 Max 正确;判据只测 Max(5,3)(漏了 b>a)
// 变异:return b → return a(真 bug)
// 松判据**仍通过** ⇒ 该变异"未被咬住"
//
// 期望:复核结论**不是 pass**(而是 pass_with_gaps),并明确指出
// "变异未被咬住 ⇒ 判据太松"。
//
// 若这条报红,说明复核机制回到了"只看正向"——那就失去了本刀的全部意义。
func TestReviewCatchesLooseCriteria(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, true) // ★ 松判据
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "max 单元判据", Kind: "unit",
Script: "go test ./...",
Expect: "两方向都正确",
Mutations: []Mutation{{
ID: "M1", File: "calc.go",
Old: "\treturn b\n}", New: "\treturn a\n}",
Note: "验证判据是否覆盖 b>a 分支",
}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
verdict := res["verdict"].(string)
// ① 正向应通过(松判据确实能过)
if !res["forward_ok"].(bool) {
t.Fatalf("前提失效:松判据的正向应通过,实际 evidence=%v", res["report"])
}
// ② 反向必须**没被咬住**
if res["mutate_ok"].(bool) {
t.Fatal("★ 变异未被咬住却报 mutate_ok=true —— 反向阶段没在起作用")
}
// ③ 结论**绝不能是 pass**
if verdict == "pass" {
t.Fatal("★★ 真 bug + 松判据 却给出 pass —— " +
"这正是设计 §4.5 说的『给测试通过充值信用,直到它骗一次大的』")
}
if verdict != "pass_with_gaps" {
t.Errorf("结论应为 pass_with_gaps,实际 %q", verdict)
}
// ④ 必须明确说明原因(不能只给个模糊的黄色)
notes, _ := res["notes"].([]string)
joined := strings.Join(notes, " ")
if !strings.Contains(joined, "太松") && !strings.Contains(joined, "未被咬住") {
t.Errorf("notes 应指出『判据太松』,实际: %v", notes)
}
t.Logf("✅ 松判据被识破:verdict=%s notes=%v", verdict, notes)
}
// ★★ 判据:**严判据**时复核给出 pass(机制不能把一切都说成有问题)。
func TestReviewPassesWithTightCriteria(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false) // 严判据
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "max 单元判据", Kind: "unit",
Script: "go test ./...",
Mutations: []Mutation{{
ID: "M1", File: "calc.go",
Old: "\treturn b\n}", New: "\treturn a\n}",
Note: "b>a 分支",
}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
if res["verdict"] != "pass" {
t.Fatalf("严判据应给 pass,实际 %q(notes=%v)", res["verdict"], res["notes"])
}
if !res["forward_ok"].(bool) || !res["mutate_ok"].(bool) {
t.Fatalf("正向与反向都应通过: forward=%v mutate=%v",
res["forward_ok"], res["mutate_ok"])
}
t.Log("✅ 严判据 → pass(机制不会把一切都判成有问题)")
}
// ★★ 判据:**判据实现有 bug** 时正向就报红 ⇒ fail。
func TestReviewFailsOnForwardFailure(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "故意失败", Kind: "unit",
Script: "false", // 永远失败
Mutations: []Mutation{{ID: "M1", File: "calc.go", Old: "a > b", New: "a >= b"}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
if res["verdict"] != "fail" {
t.Fatalf("正向失败应给 fail,实际 %q", res["verdict"])
}
if res["forward_ok"].(bool) {
t.Fatal("forward_ok 应为 false")
}
}
// ★★ 判据:**未定义变异规则**时不能给 pass(那正是"信号充值")。
func TestReviewFlagsMissingMutations(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "无变异", Kind: "unit",
Script: "go test ./...",
// ★ 刻意不给 Mutations
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
if res["verdict"] == "pass" {
t.Fatal("★ 无变异规则却给 pass —— 等于把『没检查』当『检查通过』")
}
if res["verdict"] != "pass_with_gaps" {
t.Errorf("应为 pass_with_gaps,实际 %q", res["verdict"])
}
notes, _ := res["notes"].([]string)
if !strings.Contains(strings.Join(notes, " "), "变异规则") {
t.Errorf("notes 应指出缺变异规则: %v", notes)
}
}
// ---------------- 变异执行的安全性 ----------------
// ★★★ 判据:变异**必须恢复原文件**(否则工作目录被污染,后续全乱)。
func TestMutationAlwaysRestoresFile(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
orig, _ := os.ReadFile(filepath.Join(dir, "calc.go"))
c := &Criteria{
ID: "c", ProjectID: "probe", Name: "n", Kind: "unit",
Script: "go test ./...",
Mutations: []Mutation{
{ID: "M1", File: "calc.go", Old: "\treturn b\n}", New: "\treturn a\n}"},
},
}
_, _, hardErr := p.runCriteriaMutate(c, dir)
if hardErr {
t.Fatal("不该有硬错误")
}
after, err := os.ReadFile(filepath.Join(dir, "calc.go"))
if err != nil {
t.Fatal(err)
}
if string(after) != string(orig) {
t.Fatalf("★ 变异后**未恢复**原文件 —— 工作目录被污染\n前:\n%s\n后:\n%s", orig, after)
}
}
// ★★ 判据:`old` 出现 **0 次或多次**都必须报硬错误(不能算"咬住")。
//
// 为何重要:
//
// · 0 次 ⇒ 变异无效,"判据没报红"是**假警报**(其实判据没问题)
// · 多次 ⇒ 可能改错地方,"判据报红"是**假通过**(改的不是想改的那处)
func TestMutationRequiresUniqueMatch(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
c := &Criteria{ID: "c", ProjectID: "probe", Name: "n", Kind: "unit", Script: "true"}
// ① old 不存在
c.Mutations = []Mutation{{ID: "M1", File: "calc.go", Old: "NOT_PRESENT_ANYWHERE", New: "x"}}
caught, note, hardErr := p.runOneMutation(c, c.Mutations[0], dir)
if !hardErr {
t.Fatal("★ old 不存在时报硬错误 —— 否则『变异无效』会被误判成『判据太松』(假警报)")
}
if caught {
t.Error("硬错误时不该算咬住")
}
if !strings.Contains(note, "找不到") {
t.Errorf("说明应指出找不到片段: %s", note)
}
// ② old 出现多次
multi := filepath.Join(dir, "multi.go")
os.WriteFile(multi, []byte("package calc\n\nvar a = 1\nvar b = 1\n"), 0o644)
c.Mutations = []Mutation{{ID: "M2", File: "multi.go", Old: "= 1", New: "= 2"}}
_, note2, hardErr2 := p.runOneMutation(c, c.Mutations[0], dir)
if !hardErr2 {
t.Fatal("★ old 出现多次时报硬错误 —— 否则可能改错地方却报『咬住』(假通过)")
}
if !strings.Contains(note2, "出现 2 次") {
t.Errorf("说明应指出出现次数: %s", note2)
}
}
// ★ 判据:恢复失败/内容不一致 ⇒ 硬错误(**响亮**,不静默继续)。
func TestMutationDetectsRestoreMismatch(t *testing.T) {
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
// 只读文件 ⇒ 写入变异会失败 ⇒ 硬错误
ro := filepath.Join(dir, "ro.go")
os.WriteFile(ro, []byte("package calc\n\nvar x = 1\n"), 0o444)
if os.Geteuid() == 0 {
t.Skip("root 下只读文件仍可写,跳过")
}
c := &Criteria{ID: "c", ProjectID: "probe", Name: "n", Kind: "unit", Script: "true"}
m := Mutation{ID: "M1", File: "ro.go", Old: "= 1", New: "= 2"}
if _, _, hardErr := p.runOneMutation(c, m, dir); !hardErr {
t.Fatal("写不进去时报硬错误")
}
}
// ---------------- 真机端到端(真实 go test) ----------------
// ★★ 判据:**真实 go test** 下,正反两阶段的结果符合预期。
//
// 上面几条用真实 git/go 项目;这一条额外确认「Go 的**测试缓存**不会
// 让变异失效」—— 实测过:变异后若命中缓存,测试根本没跑,
// "报红"就无从谈起。实现里用 `-count=1` 规避。
func TestReviewDoesNotHitGoTestCache(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("环境无 go")
}
p := newTestPluginForCriteria(t)
dir := writeRepo(t, false)
// 先跑一次让缓存热起来
code, _ := runShell(dir, "go test -count=1 ./...", 120)
if code != 0 {
t.Skipf("前置 go test 未通过(环境问题),跳过: %d", code)
}
p.ledger.Projects["probe"] = &Project{Name: "probe", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{ID: "br-1", ProjectID: "probe", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "probe", Name: "严判据", Kind: "unit",
Script: "go test -count=1 ./...",
Mutations: []Mutation{{
ID: "M1", File: "calc.go",
Old: "\treturn b\n}", New: "\treturn a\n}",
Note: "b>a 分支",
}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
if !res["mutate_ok"].(bool) {
ev := ""
if rep, ok := res["report"].([]map[string]interface{}); ok && len(rep) > 0 {
ev = str(rep[0], "mutate_evidence")
}
t.Fatalf("★ 变异未被咬住 —— 可能是 Go 测试缓存导致变异后测试没真跑\n%s", ev)
}
t.Log("✅ 真实 go test 下变异被正确咬住(未受缓存影响)")
}

View File

@ -1,176 +0,0 @@
package main
// 投递台账**结果回填**的判据(2026-10-18 生产实测缺陷)。
//
// ## 缺陷
//
// 异步投递后,投递那一刻只记 "accepted";真实结果几十秒后才由完成通知
// 送回。但原先**没人把它写回台账** ⇒ 台账永远停在 accepted:
//
// 2026-10-18T07:39:09 accepted result=None
// 2026-10-18T07:40:09 accepted result=None
// 2026-10-18T07:41:18 accepted result=None ← 13 条全是 None
//
// 实测后果(同一天的日志):
//
// [timer] firing: 【轮询轮次 2/6】…检查是否有新 result 回填…
//
// agent 自己起了 6 轮 timer 去轮询"有没有新 result"——而那件事永远不会发生。
//
// ## 判据
//
// 1. 完成通知必须回填最新那条 accepted
// 2. 只回填该 branch 的,不串到别的工程线
// 3. 已回填的不会被第二条通知重复覆盖(幂等边界)
// 4. 无匹配时不能 panic / 不能误改别的记录
import (
"os"
"strings"
"testing"
)
func newLedgerPlugin(t *testing.T) *Plugin {
t.Helper()
dir := t.TempDir()
p := &Plugin{path: dir + "/plugin.bin", ledger: &Ledger{}}
p.ledger.Branches = map[string]*Branch{}
return p
}
// ★★★ 判据:完成通知回填最新 accepted。
func TestCompleteDeliveryBackfillsLatestAccepted(t *testing.T) {
p := newLedgerPlugin(t)
p.recordDelivery("br-a", "sess-1", "任务一", "accepted", "")
p.recordDelivery("br-a", "sess-1", "任务二", "accepted", "")
p.completeDelivery("br-a", "sess-2", "done", "改好了 multiply")
ds := p.ledger.Deliveries
if len(ds) != 2 {
t.Fatalf("投递条数应不变(2),实际 %d", len(ds))
}
// 最新那条被回填
if ds[1].Status != "done" || ds[1].Result != "改好了 multiply" {
t.Fatalf("★ 最新 accepted 未回填:status=%q result=%q", ds[1].Status, ds[1].Result)
}
if ds[1].SessionID != "sess-2" {
t.Errorf("session 应更新为 sess-2,实际 %q", ds[1].SessionID)
}
// 更早那条不能被误改
if ds[0].Status != "accepted" || ds[0].Result != "" {
t.Fatalf("★ 误改了更早的记录:status=%q result=%q", ds[0].Status, ds[0].Result)
}
}
// ★★★ 判据:只回填同一 branch,不串线。
func TestCompleteDeliveryDoesNotCrossBranches(t *testing.T) {
p := newLedgerPlugin(t)
p.recordDelivery("br-a", "s1", "a 的任务", "accepted", "")
p.recordDelivery("br-b", "s2", "b 的任务", "accepted", "")
p.completeDelivery("br-a", "s1", "done", "a 完成")
if p.ledger.Deliveries[0].Status != "done" {
t.Fatal("br-a 的记录应被回填")
}
if p.ledger.Deliveries[1].Status != "accepted" {
t.Fatalf("★ br-b 的记录被误改:status=%q", p.ledger.Deliveries[1].Status)
}
}
// ★★★ 判据:已回填的记录不会被第二条通知覆盖(同一投递只认第一次结果)。
func TestCompleteDeliveryIsNotOverwritten(t *testing.T) {
p := newLedgerPlugin(t)
p.recordDelivery("br-a", "s1", "任务", "accepted", "")
p.completeDelivery("br-a", "s1", "done", "第一次结果")
p.completeDelivery("br-a", "s1", "failed", "第二次结果")
d := p.ledger.Deliveries[0]
if d.Status != "done" || d.Result != "第一次结果" {
t.Fatalf("★ 已回填的记录被第二条通知覆盖:status=%q result=%q", d.Status, d.Result)
}
}
// ★★★ 判据:没有待回填记录时不 panic、不改任何东西。
func TestCompleteDeliveryNoMatchIsSafe(t *testing.T) {
p := newLedgerPlugin(t)
p.recordDelivery("br-a", "s1", "任务", "failed", "同步就失败了")
// 这条是 failed,不是 accepted ⇒ 不该被回填
p.completeDelivery("br-a", "s1", "done", "迟到的通知")
if p.ledger.Deliveries[0].Status != "failed" {
t.Fatalf("★ 非 accepted 的记录被回填了:%q", p.ledger.Deliveries[0].Status)
}
// 完全无记录也不该 panic
p.completeDelivery("no-such-branch", "", "done", "x")
}
// ★★★ 判据:recordDelivery 必须返回 key(回填/追溯的前提)。
func TestRecordDeliveryReturnsKey(t *testing.T) {
p := newLedgerPlugin(t)
k := p.recordDelivery("br-a", "s1", "任务", "accepted", "")
if k == "" {
t.Fatal("recordDelivery 应返回非空 key(否则无法追溯某次投递)")
}
if p.ledger.Deliveries[0].Key != k {
t.Fatalf("返回的 key 与台账记录不一致:%q vs %q", k, p.ledger.Deliveries[0].Key)
}
}
// ★★★ 判据:**完成通知必须真的调用回填**(接线,不只是函数存在)。
//
// 为什么需要这条(2026-10-18 实测判据漏洞):
// 上面四条测的是 `completeDelivery` 自身行为。但我把 sidecar.go 里
// **那一行调用删掉**之后,它们依然全绿 —— 因为没人再调它了。
// 而这正是原始缺陷的形态:函数写得对,但没人用。
//
// ⇒ 判据必须覆盖接线。这里读 sidecar.go 的 `makePromptNotifier` 块,
//
// 要求 prompt_done / prompt_error 两条分支里都出现 completeDelivery 调用。
//
// ★ 用**行扫描界定块边界**而不是正则:正则遇到嵌套大括号容易提前结束
//
// (本仓在别处踩过这个坑)。
func TestPromptNotifierWiresCompleteDelivery(t *testing.T) {
b, err := os.ReadFile("sidecar.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
i := strings.Index(src, "func (p *Plugin) makePromptNotifier")
if i < 0 {
t.Fatal("找不到 makePromptNotifier")
}
block := src[i:]
if j := strings.Index(block[1:], "\nfunc "); j > 0 {
block = block[:j+1]
}
for _, ev := range []string{"prompt_done", "prompt_error"} {
k := strings.Index(block, `case "`+ev+`":`)
if k < 0 {
t.Fatalf("★ makePromptNotifier 里没有 %s 分支", ev)
}
// 该分支到**下一个** case 之间必须出现回填调用。
//
// ★ 偏移必须跳过当前 "case " 本身:从 k 开始找 "case " 会命中
// k 自己(n=0),于是 seg 不被截断、包含整个函数剩余部分 ——
// 那样 prompt_done 分支即便**删掉**回填调用也照样能"找到"
// prompt_error 分支里的那一行 ⇒ 判据假绿。
// 实测踩过:变异删掉 done 分支的回填,判据仍然 PASS。
seg := block[k:]
lead := len(`case "` + ev + `":`)
if n := strings.Index(seg[lead:], "case "); n >= 0 {
seg = seg[:lead+n]
}
if !strings.Contains(seg, "p.completeDelivery(") {
t.Fatalf("★ %s 分支**没有调用 completeDelivery** —— "+
"结果是:台账永远停在 accepted,看板与 agent 都查不到任何结果\n"+
"(实测后果:agent 起了 6 轮 timer 空等一个永远不会出现的结果)", ev)
}
}
}

View File

@ -1 +0,0 @@
{}

View File

@ -1,177 +0,0 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ★★ 真机端到端:**真实项目 + 真实 go test**,验证刀 4 能识破
// 「真 bug 藏在松判据后面」。
//
// 场景(与 /tmp/d4e2e 手工复现的一致):
//
// Clamp(v,lo,hi) 有三个分支;判据只测了 v<lo
// 代码里删掉 v>hi 分支(真 bug)⇒ 松判据**仍全绿**
//
// 复核必须给出 **pass_with_gaps** 而不是 pass,并说明"变异未被咬住"。
func TestE2E_ReviewCatchesHiddenBugBehindLooseCriteria(t *testing.T) {
if _, err := exec.Command("go", "version").Output(); err != nil {
t.Skip("环境无 go")
}
dir := t.TempDir()
write := func(rel, c string) {
p := filepath.Join(dir, rel)
os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, []byte(c), 0o644); err != nil {
t.Fatal(err)
}
}
write("go.mod", "module d4demo\n\ngo 1.21\n")
write("calc.go", `package d4demo
func Clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
`)
// ★ 松判据:只测下界
write("calc_test.go", `package d4demo
import "testing"
func TestClampLoose(t *testing.T) {
if Clamp(-5, 0, 10) != 0 {
t.Fatal("低于下界时错")
}
}
`)
if err := exec.Command("git", "init", "-q", "-b", "main", dir).Run(); err != nil {
t.Skipf("git init: %v", err)
}
p := newTestPluginForCriteria(t)
p.ledger.Projects["demo"] = &Project{Name: "demo", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "demo", Name: "feat/x", Workdir: dir, Status: "open"}
// 主管定义判据:正向用松判据;变异**删掉 v>hi 分支**
p.ledger.Criteria["cr-clamp"] = &Criteria{
ID: "cr-clamp", ProjectID: "demo", Name: "clamp 上下界", Kind: "unit",
Script: "go test -count=1 ./...",
Expect: "上下界都被限制",
Mutations: []Mutation{{
ID: "M1", File: "calc.go",
Old: " if v > hi {\n return hi\n }\n", New: "",
Note: "删掉上界分支(真 bug)——松判据看不见它",
}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
verdict := res["verdict"].(string)
// ① 正向必须**通过**(松判据确实能过 —— 这就是问题所在)
if !res["forward_ok"].(bool) {
t.Fatalf("前提失效:松判据正向应通过。report=%v", res["report"])
}
// ② 变异必须**未被咬住**(真 bug 躲在判据盲区里)
if res["mutate_ok"].(bool) {
t.Fatal("★ 删掉整个上界分支都没被咬住却报 mutate_ok=true —— 反向阶段失效")
}
// ③ 结论绝不能被当成 "pass"
if verdict == "pass" {
t.Fatal("★★ 真 bug + 松判据 ⇒ 给了 pass。这正是刀 4 存在的理由")
}
if verdict != "pass_with_gaps" {
t.Errorf("应为 pass_with_gaps,实际 %q", verdict)
}
// ④ 报告里要能看出是**哪条**变异没被咬住
rep, _ := res["report"].([]map[string]interface{})
if len(rep) == 0 {
t.Fatal("报告为空")
}
ev := str(rep[0], "mutate_evidence")
if !strings.Contains(ev, "未被咬住") {
t.Errorf("mutate_evidence 应指出未被咬住,实际: %s", ev)
}
// ⑤ 工作目录必须**已恢复**(变异不留脏文件)
after, _ := os.ReadFile(filepath.Join(dir, "calc.go"))
if !strings.Contains(string(after), "if v > hi {") {
t.Fatal("★ 变异后未恢复 calc.go —— 工作目录被污染")
}
t.Logf("✅ 真机识破「真 bug 藏在松判据后」: verdict=%s", verdict)
t.Logf(" notes=%v", res["notes"])
}
// ★ 对照:**补一条严判据**后,同一个 bug 会被正向阶段直接抓到。
func TestE2E_TightCriteriaCatchesTheBugDirectly(t *testing.T) {
if _, err := exec.Command("go", "version").Output(); err != nil {
t.Skip("环境无 go")
}
dir := t.TempDir()
write := func(rel, c string) {
p := filepath.Join(dir, rel)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(c), 0o644)
}
write("go.mod", "module d4demo2\n\ngo 1.21\n")
// ★ 这个项目里 bug **已经存在**(上界分支缺失)
write("calc.go", `package d4demo2
func Clamp(v, lo, hi int) int {
if v < lo {
return lo
}
return v
}
`)
// 严判据:上下界都测
write("calc_test.go", `package d4demo2
import "testing"
func TestClampTight(t *testing.T) {
if Clamp(-5, 0, 10) != 0 {
t.Fatal("下界错")
}
if Clamp(15, 0, 10) != 10 {
t.Fatal("上界错")
}
}
`)
exec.Command("git", "init", "-q", "-b", "main", dir).Run()
p := newTestPluginForCriteria(t)
p.ledger.Projects["demo2"] = &Project{Name: "demo2", RemoteURL: "x"}
p.ledger.Branches["br-1"] = &Branch{
ID: "br-1", ProjectID: "demo2", Name: "feat/x", Workdir: dir, Status: "open"}
p.ledger.Criteria["cr-1"] = &Criteria{
ID: "cr-1", ProjectID: "demo2", Name: "clamp 上下界", Kind: "unit",
Script: "go test -count=1 ./...",
Mutations: []Mutation{{ID: "M1", File: "calc.go",
Old: "return v\n}", New: "return lo\n}"}},
DefHash: "x",
}
res, err := p.reviewAll("br-1")
if err != nil {
t.Fatal(err)
}
// 正向就抓到 bug ⇒ fail
if res["verdict"] != "fail" {
t.Fatalf("严判据应正向就抓到 bug(fail),实际 %q", res["verdict"])
}
t.Log("✅ 对照:严判据在正向阶段直接抓到同一个 bug → fail")
}

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