9 Commits

Author SHA1 Message Date
e256023399 docs+plugindev: README 同步子进程架构,scaffold 修 entry 与 go.sum 死路
三类问题,都会让新用户第一次上手就走错:

1. README 仍写 plugin.so
   plugin.json 示例的 entry、entry 字段说明、.hmap 包格式三处描述的都是
   已退场的 C ABI 产物。改为 plugin.bin,并补 bundle 模式下
   plugin.bin.<goos>.<goarch> 的命名与安装时挑平台的行为。

2. cmd_init.go 的 entry := "plugin.so"
   scaffold 出来的 plg.json 带着一个已退场的 entry 值。build 实际不看这个
   值(只用它区分 Lua),但跟着模板走会误以为自己在做 C ABI 插件。

3. 生成的项目第一次 build 必定失败
   go.mod 只 require 一个 gitcode 模块版本号且不生成 go.sum。gitcode 不在
   proxy.golang.org 上,于是:
     go build     → missing go.sum entry
     go mod tidy  → 去公共 proxy 拉一个不存在的条目,超时
   原来 cmdBuild 里那句 `go mod download <mod>` 走的正是这条死路,失败后
   只打一行 warn 就继续编译,紧接着死在同一个错误上——用户看到两段无关报错。

   修法分两处:
   - 生成的 go.mod 直接写指向本机 SDK 的 replace(replace 到目录时 go 不
     需要也不校验 go.sum)
   - 新增 ensureSDKResolvable:三级策略(已有本地 replace → 探测本机 SDK
     并写入 → 兜底 go mod tidy 带 -mod=mod,失败给可操作提示)。存量项目
     go.mod 无 replace 时走第二级救回。

bin/ 5 个预编译二进制不再进仓库(改为 release 附件):
5 个平台各 26-28MB,每次重编都在 git 历史里再叠一份,而它们本质是可从源码
复现的产物。README 的下载说明同步改为 release 附件 URL + 从源码编译。
2026-09-03 19:27:03 +08:00
092d8f4ab0 Merge branch 'feature/plugin-proc-migration' into main
SDK 侧配合内核子进程化迁移:plugindev 工具链产出 plugin.bin
(纯 Go 二进制,零 cgo),模板支持共享内存 stage 与事件环消费。

- entry 语义收敛到 plugin.bin,删除 C ABI 工具链
- stage 回传改 diff,只回传变更字段(修复 lost update)
- 模板支持事件环消费(evtConsumerLoop)
- Windows 共享内存适配(OpenFileMappingW / OpenEventW)
- meta.Version 升到 1.0.0,与内核对齐

公开 SDK 接口(sdk/ 目录)全程零改动——接口冻结不变量。
2026-09-03 13:54:21 +08:00
5ed8d65479 meta: 版本升到 1.0.0,删除 C ABI 时代的死常量
## 为何是主版本号

公开 SDK 接口(sdk/ 目录)本轮**零改动**,插件业务代码一行不用改。
但产物形态变了:plugin.so → plugin.bin。0.9.x 内核只会 dlopen `.so`,
本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别——
这是不可互操作的破坏性变化,故 CoreVersion 也升 1.0.0 作为**硬下限**
而非建议值。

## 删掉的死代码

ABIVersion / ABIVersionMin / CABINum / CABINumMin / 51 个 Core<Method>
整数 ID,全部无使用者:

    grep -rn "CABINum" --include=*.go --include=*.tmpl .   → 只有定义处
    grep -rn "meta.Core[A-Z]" ...                          → 无

它们随 Part 6.2 删除 internal/plugin/cabi/ 就已失效:
  - 整数 method id 平移为 method 名字符串(proc/protocol.go 的 Method* 常量)
  - 版本协商改为握手帧里的 protocol 字段

留着有实际危害——下一个读这个文件的人会以为 C 层协商还在生效,
或者以为加 method 时要同步维护那张整数表。

## 协议版本与语义版本解耦

新注释写明:子进程 RPC 的 protocol 是独立小整数(当前 1),
只在帧格式或握手语义变化时升;语义版本变动频繁(修 bug、加字段)
不应牵动 wire 协议。这两者以前被 ABIVersion = CoreVersion 绑在一起,
现在分开。

验证:go build ./... 通过;git diff sdk/ 为空(接口冻结不变量)。
2026-09-02 22:40:15 +08:00
9f844123fe plugindev: entry 语义收敛 + 删 C ABI 工具链 + Windows 共享内存适配(Part 6.1)
## entry 不再是通道开关 —— 外部插件零改动的关键

17 个存量插件的 plg.json 都写着 "entry": "plugin.so"。若把 entry 当通道
开关,迁移就得改 17 个文件,而「外部插件零改动」是本次迁移的硬约束。

改法:Go 插件一律产出 plugin.bin,不看 entry 值。isProcEntry 删除,
resolveBuild 去掉 proc 参数。entry 现在只剩区分 Lua(main.lua)一个用途。

实测:weather 的 plg.json 一行不改(仍写 plugin.so),plugindev build
直接产出三平台 plugin.bin。

## Windows 不再是能力退化的第三套实现(§9.2 的正解)

C ABI 时代 Windows 是独立的第三套 ABI:dynamic_dll_windows.go 的 stage
只下发 3 个字段(raw_message/user_id/phase)且完全没有写回,sanitizer
这类改写型插件在 Windows 上静默失效,且无任何运行时警告。

现在 Windows 与 Unix 共用同一份 RPC 逻辑与同一份共享段布局。平台差异
收敛到三个挂载函数:
- Unix(linux/darwin/freebsd):内核经 ExtraFiles 传继承 fd(3=StageContext
  段,4=事件环段,5=eventfd/pipe)
- Windows:没有 fd 继承语义(os/exec 的 ExtraFiles 在 Windows 不支持),
  改用命名内核对象——父进程 CreateFileMapping/CreateEvent 建带名字的对象,
  子进程 OpenFileMappingW/OpenEventW 按同名打开。名字经环境变量传入而非
  硬编码:多个 homed 实例并存时不能撞名。

Windows 绑定用 syscall.NewLazyDLL 而非 golang.org/x/sys/windows:
OpenFileMappingW/OpenEventW 未被标准库 syscall 导出,而引入 x/sys 会给
**每个插件的 go.mod** 加一个新依赖,违反「插件仅依赖公开 SDK」。
LazyDLL 属标准库,零新增依赖。

新增 evtWaiter 接口抽象等待语义:eventfd 是计数器(多事件合并成一次
唤醒),Windows Event 是二元信号。不影响正确性——消费者被唤醒后按
readSeq 追 writeSeq 批量 drain,一次唤醒能处理累积的全部事件。

模板拆成三个文件:
  proc_main.go.tmpl          平台无关(RPC + 共享段布局 + stage + 事件环消费)
  proc_shm_unix.go.tmpl      继承 fd 挂载
  proc_shm_windows.go.tmpl   命名对象挂载

## 删除 C ABI 工具链

templates.go 1296 → 516 行:
- tmplBridge(Windows DLL bridge)      -265 行
- tmplLinuxBridge(Linux c-shared)     -457 行
- tmplPluginInitC(C 入口)              -57 行
另删 generateBridge / detectWindowsCC(MinGW 探测)/ tmplCABIHeader /
InitData.CABIVersion+CABIHeader。

交叉编译不再需要目标平台 C 工具链——这是 -buildmode=c-shared 退场的
连带收益(§3.1)。

## 测试

15 项全过,新增 4 项守护迁移不变量:
- AllPlatformsProduceBin:6 个 GOOS/GOARCH 组合统一产出 plugin.bin
- LuaIsSeparatePath:Lua 仍走解释器路径
- UnsupportedOSErrors:不支持平台明确报错,不静默产出错误产物
- NoCABIResiduals:代码中不得再出现 c-shared / CGO_ENABLED=1 /
  detectWindowsCC / tmplLinuxBridge / tmplPluginInitC(注释除外)
- IgnoresEntryForGoPlugins:isProcEntry 必须已删除

验证:go build/vet/test 全通过;三平台交叉编译产出 plugin.bin;
git diff sdk/ 为空(接口冻结)。

Ref: docs/zh/架构迁移评估.md §3.1/§9.2、docs/zh/plugin-migration-plan.md Part 6
2026-09-02 18:39:02 +08:00
ef0e58ee23 plugindev: 模板支持事件环消费(Part 5 子进程侧)
handleHandshake 额外挂载 fd 4(事件环段)+ fd 5(eventfd),启动
evtConsumerLoop goroutine 消费事件。

HandshakeParams 新增 evt_ring_size 字段(0 = 不支持事件环)。

events.subscribe:按类型列表在本地注册 handler,evtConsumerLoop 从
共享段读 slot 后按位索引分发。events.unsubscribe 清空全部 handler。

事件环布局常量与内核 internal/plugin/proc/evtring.go 一一对应。

验证:weather.bin 零改动编译通过;E2E 测试全通过。
Ref: docs/zh/架构迁移评估.md §3.6
2026-09-02 17:05:01 +08:00
09b64dcb53 plugindev: 支持子进程插件构建(entry=plugin.bin,零 cgo)
Part 3 工具链改造。插件业务代码零改动,只需把 plg.json 的 entry
从 plugin.so 换成 plugin.bin。

新增 templates/proc_main.go.tmpl(1113 行)——子进程运行时:
- 51 个 core method 的插件侧 RPC 实现(procIO/procMemory/procSettings/
  procSocial/procLLM/procKnowledge/procDocMemory/procTextMemory/procPluginMgr)
- 共享段访问(fd 3 = 内核经 ExtraFiles 传入的 memfd)+ 16 字段
  StageContext 编解码,布局常量与 internal/plugin/proc/shm.go 逐一对齐
- handleStageInvoke:拿锁 → 读段 → handler → **只写脏字段** → 放锁。
  只读插件脏字段集为空 → 零写入 → 不可能覆盖他人改写
  (对照 C ABI 副本模型实测 35.8~36.8% lost update)
- 主循环每请求独立 goroutine:handler 内会反向调用内核并等应答,
  在读循环里同步处理会死锁
- plugin.start 后显式上报 AutoRestart:公开 SDK 的 SetAutoRestart 是纯
  setter 无 hook,隔着进程边界内核读不到(内核侧 corehandler.go:145 已就绪)

模板选择真实 .go 源文件 + //go:embed 而非 raw string:900+ 行代码塞在
字符串里写错只能等生成插件时才炸,作为源文件可被 parser/gofmt/vet 检查。

cmd_build.go:resolveBuild(target, proc) 分派;proc 走 go build -trimpath
+ CGO_ENABLED=0,交叉编译不再需要目标平台 C 工具链。bundle 模式各平台
产物同名故 zip 内加平台后缀(plugin.bin.linux.amd64)。

proc_runtime.go 生成时清理残留 z_bridge_gen.go/z_entry.c——同目录两套
main 会编译冲突,这让 .so → .bin 切换无需人工清理。

proc_runtime_test.go 16 项静态检查,防内核/插件两侧漂移:
method 名清单、7 个内核调用、共享段常量与字段枚举顺序、stage 加锁顺序、
快照必须存序列化字符串(切片共享底层数组的坑在 11.3 已踩过)、
arena 不足须报错、日志走 stderr、版本不匹配须拒绝、零 cgo。

验证:真实 plugindev 构建 example/weather,plugin.go 逐字节未改,
产出静态链接 ELF;git diff sdk/ 为空(接口冻结)。

Ref: docs/zh/架构迁移评估.md §3、docs/zh/plugin-migration-plan.md Part 3
2026-09-02 12:13:45 +08:00
dev
56485194df fix(plugindev): stage 回传改 diff,只回传变更字段,修复 lost update(plan 11.3)
根因:go_invoke_stage 无条件回传 stageContextWritable 全部字段(含插件从内核收到
的旧快照),sanitizer(改ToolResults)+weather(只读) 并发时,只读插件把自己收到的
旧快照覆盖回清洗结果(实验13 实测丢失率 1.6~4.3%,脏数据进LLM)。

改动:
- templates.go: 新增 snapshotWritable(handler 前的序列化快照)+ changedFieldsOnly(只回传差异字段)
  go_invoke_stage 改为 before 快照 → handler → diff 回传,无变更零回传
- 关键陷阱(第一版踩坑):stageContextWritable 的切片字段与 sc 共享底层数组,
  handler 原地改元素时 before 快照跟着变,diff 失效——故 before 必须序列化成字符串
- stagediff_test.go: 6 用例(只读零回传/原地改切片/标量改/新response/清空切片/现网场景复刻)

⚠️ 需用新 plugindev 重编全部 17 个外部插件(bridge 模板变更)
2026-08-31 12:29:57 +08:00
61f307be1a feat(qq): msg_id→get_history 7天兜底 + list_chats/mark_read 会话列表 v1.2.0
## 问题

1. NapCat get_msg 的 message_id 是 QQ 服务端临时短号,约 3 天后失效。
   实测 1306 条真实 webhook 消息,141 条(10.8%)现在查回报「消息不存在」,
   全是 3 天前的旧消息;1 小时内的消息可正常查回。NapCat 侧无保留时长配置项
   (napcat.json / onebot11_*.json / webui.json 均无),是 QQ 协议硬限制。
   模型收到 not_found 后曾编造正文(虚构 message_id + 虚构需求),
   已在核心 prompt 加事实性约束,此处从插件层根治取不到正文的问题。

2. 插件与真人客户端差距大:没有会话列表、消息不按到达先后排序、无未读提醒,
   模型只能靠单条中断消息被动响应,导致消息处理不及时。

## 改动

### msg_id → get_history 兜底(不缓存正文)
- 新增 msgRef{peerID,isGroup,time}:只记 msg_id → (peer, 时间) 映射,7 天 TTL
  (qqMsgTTL),超 2000 条时惰性清理过期项。不缓存消息正文。
- handleGetMessage 改为包装层:命中映射且 <7 天 → getMsgFromHistoryByTime()
  按 peer 拉 get_history(count=50),取时间最接近的一条,
  经 msgToGetMsgResult() 包装为与 get_msg 同构的结果(附 resolved_via:history);
  未命中或超 7 天 → 回退原 NapCat get_msg(重命名为 getMsgFromNapcat)。
- 超 7 天的消息由 get_history 自行处理,插件不做长期缓存。
- not_found 文案改为明确引导改用 qq_get_history / qq_list_chats。

### 会话列表(对齐真人客户端)
- 新增 chatMeta:会话名、未读数、最新一条 ≤60 字摘要(qqLastSumLen)、最新时间。
  只维护最新一条摘要,不存历史。
- 新增 qq_list_chats:按最新消息时间降序返回会话列表,每项含
  peer_id/type/name/unread/last_text/last_nick/last_time。
- 新增 qq_mark_read:按 group_id/user_id 清零未读;get_history 拉取某会话后
  自动标已读(看过=已读,与真人客户端一致)。
- webhook 记录时机前移:策略允许的消息(群/私聊、是否 @bot 均记)都进入映射与
  会话状态,@bot 只决定是否发中断——与真人客户端一致能看到全部会话。

### 中断模板
- 补 fallback 路径与私聊 user_id(原模板只给 message_id,取不到正文时
  模型没有 peer 信息可用于 get_history):
  「先用 get_message 取正文;若取不到(已过期),改用 get_history(...) 按会话拉取,
   或用 list_chats 查看未读会话。用 output_send__qq 回复」

## 验证

用重建的 plugindev build --target linux/amd64 产出 dist/qq_linux_amd64.hmap,
经内核 plugin_install(overwrite=true) 安装(action=reinstalled, config_kept=true),
重启 homed 后内核注册 20 个 qq_* 工具(原 18 + list_chats + mark_read)。
实测 qq_list_chats(count=8) 返回按时间排序的会话,unread=6 正确累积。

注意:cgo c-shared 插件带完整 Go runtime,dlclose 后引用计数不归零,
同路径 dlopen 复用旧映像,plgreload 无法热替换 .so,换 .so 必须重启 homed。
2026-08-30 17:11:57 +08:00
59c6e1844c fix(plugindev): bridge 模板补 dispatchIO.SetToolBlocks,修复外部插件无法编译
SDK v0.9.2(68497b4)给 IOInjector 加了 SetToolBlocks,但 plugindev 的
C ABI bridge 模板(tmplLinuxBridge)未同步,导致任何外部插件在当前 SDK 下
编译失败:

  z_bridge_gen.go:97: cannot use dispatchIO{} as sdk.IOInjector value in
  argument to base.SetIOInjector: dispatchIO does not implement
  sdk.IOInjector (missing method SetToolBlocks)

补空实现满足接口。SetToolBlocks 是 Go 原生(进程内 IOManager)的多模态注入,
跨 ABI 无对应 method id 与内核桥接,故不做 callVoid 转发。

实测:example/qq 用重建后的 plugindev build --target linux/amd64 构建通过,
产出 .hmap 经 plugin_install 安装、重启后 20 个工具全部注册成功。
2026-08-30 17:11:20 +08:00
20 changed files with 2872 additions and 959 deletions

5
.gitignore vendored
View File

@ -10,6 +10,11 @@ plugin.json
build/
dist/
# plugindev 预编译二进制:只作为 release 附件分发,不进仓库历史。
# 此前 5 个平台各 26-28MB 被 git 跟踪(约 137MB每次重编都在历史里
# 再叠一份,而它们本质是可从源码复现的产物。
bin/
# Test artifacts
testdist/

View File

@ -142,13 +142,21 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
## plugindev 工具链
`plugindev` 提供插件开发全流程支持。仓库 `bin/` 提供各平台预制二进制linux/darwin/windows × amd64/arm64下载后直接加入 PATH 即可:
`plugindev` 提供插件开发全流程支持。预编译二进制作为 **release 附件**分发linux/darwin/windows × amd64/arm64
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) 下载后加入 PATH 即可:
```bash
curl -o plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/-/raw/main/bin/plugindev_linux_amd64
# 从 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/plugindev && go build -o plugindev .
```
> 二进制不再随仓库分发(旧的 `bin/` 目录已停用5 个平台各 26-28MB
> 每次重编都在 git 历史里再叠一份,而它们本质是可从源码复现的产物。
| 命令 | 说明 |
|------|------|
| `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md |
@ -180,7 +188,7 @@ chmod +x plugindev
"version": "1.0.0",
"description": "天气查询插件",
"author": "HomeAgent",
"entry": "plugin.so",
"entry": "plugin.bin",
"tags": ["weather", "forecast"],
"targets": "linux/amd64,windows/amd64",
"outdir": "dist",
@ -202,7 +210,7 @@ chmod +x plugindev
| `version` | string | 版本号 |
| `description` | string | 插件描述 |
| `author` | string | 作者 |
| `entry` | string | 入口文件(`plugin.so` / `plugin.dll` / `main.lua` |
| `entry` | string | 入口文件(`plugin.bin` / `main.lua`。v1.0.0 起 Go 插件统一为 `plugin.bin`,不再区分平台后缀 |
| `tags` | string[] | 标签 |
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`Lua 插件为 `lua` |
| `outdir` | string | 输出目录(默认 `dist` |
@ -217,11 +225,14 @@ chmod +x plugindev
`.hmap` 为 ZIP 归档,包含:
- `plugin.json` — 插件元数据
- `plugin.so` — Go 编译产物(Linux
- `plugin.dll` — Go 编译产物Windows
- `plugin.dylib` — Go 编译产物macOSbundle 模式)
- `plugin.bin` — Go 编译产物(单平台构建
- `plugin.bin.<goos>.<goarch>` — 多平台 bundle 模式下每平台一份,
安装时 pluginmgr 挑当前平台那份重命名为 `plugin.bin`
- `main.lua` — Lua 插件入口Lua 插件时)
> v1.0.0 起不再使用 `plugin.so`/`plugin.dll`/`plugin.dylib`——进程边界即 ABI 边界,
> 不存在平台特定的动态库区分。旧产物新内核不会加载,会给出明确的重编提示。
## 插件生命周期
### 入口函数

View File

@ -142,14 +142,30 @@ Plugin developers only need to implement the `Plugin` interface and export a `Ne
## plugindev Toolchain
`plugindev` provides full development workflow support:
`plugindev` provides full development workflow support. Prebuilt binaries ship as **release assets**
(linux/darwin/windows × amd64/arm64); download from
[Releases](https://gitcode.com/JianFeeeee/homeagent-sdk/releases) and put it on your PATH:
```bash
# 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/plugindev && go build -o plugindev .
```
> Binaries no longer ship inside the repository (the old `bin/` directory is retired): five
> platforms at 26-28MB each piled another copy into git history on every rebuild, and they are
> reproducible from source anyway.
| Command | Description |
|---------|-------------|
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) |
| `plugindev build` | Build plugin, output .hmap package |
| `plugindev clean` | Clean build artifacts |
| `plugindev debug` | Run plugin in local debug mode |
| `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.
@ -163,7 +179,7 @@ Supports both **Go** and **Lua** plugin languages.
"version": "1.0.0",
"description": "Weather plugin",
"author": "HomeAgent",
"entry": "plugin.so",
"entry": "plugin.bin",
"tags": ["weather", "forecast"],
"targets": "linux/amd64,windows/amd64",
"outdir": "dist",
@ -185,7 +201,7 @@ Supports both **Go** and **Lua** plugin languages.
| `version` | string | Version |
| `description` | string | Plugin description |
| `author` | string | Author |
| `entry` | string | Entry file (`plugin.so` / `main.lua`) |
| `entry` | string | Entry file (`plugin.bin` / `main.lua`). Since v1.0.0 Go plugins uniformly build to `plugin.bin`—no per-platform suffix |
| `tags` | string[] | Tags |
| `targets` | string | Build targets, comma-separated (e.g. `linux/amd64,windows/amd64`) |
| `outdir` | string | Output directory (default `dist`) |
@ -198,10 +214,15 @@ Supports both **Go** and **Lua** plugin languages.
`.hmap` is a ZIP archive containing:
- `plugin.json` — plugin metadata
- `plugin.so` — Go compiled artifact (Linux)
- `plugin.dll` — Go compiled artifact (Windows)
- `plugin.bin` — Go compiled artifact (single-platform build)
- `plugin.bin.<goos>.<goarch>` — one per platform in bundle mode; on install pluginmgr picks
the one matching the current platform and renames it to `plugin.bin`
- `main.lua` — Lua plugin entry (for Lua plugins)
> Since v1.0.0 `plugin.so`/`plugin.dll`/`plugin.dylib` are no longer used—the process boundary
> *is* the ABI boundary, so there is no platform-specific shared-library distinction. The new
> kernel will not load old artifacts; it emits an explicit rebuild hint instead.
## Plugin Lifecycle
### Start & Stop

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -107,13 +107,149 @@ type Plugin struct {
downloadTasks []*DownloadTask
typingMu sync.Mutex
typingMap map[int64]*typingState
// msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats
msgMu sync.Mutex
msgMap map[int64]msgRef // message_id → {peer, time}
chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号)
}
type typingState struct {
userID int64
stopCh chan struct{}
}
// msgRef 一条已见过的消息的引用:只记录 msg_id → (peer, time) 映射,不缓存正文。
// 用途NapCat get_msg 的临时短号 <7 天失效时,据此把 get_msg 兜底为按 peer 拉 get_history。
type msgRef struct {
peerID int64
isGroup bool
time int64 // 秒级时间戳
}
// chatMeta 一个会话(群/私聊)的最新状态,供 list_chats 展示。
// 只维护最新一条的短摘要≤qqLastSumLen 字符)与未读数,不缓存完整历史。
type chatMeta struct {
peerID int64
isGroup bool
name string
unread int
lastTime int64
lastText string
lastNick string
}
const qqMsgTTL = 7 * 86400 // 7 天msg_id → peer 映射的有效期
const qqLastSumLen = 60 // list_chats 里最新一条摘要的最大长度
// snapshotMsg 记录一条策略允许的消息:更新 msg_id→peer 映射与会话未读/最新状态。
// 不缓存消息正文(仅最新一条留 ≤qqLastSumLen 的摘要供列表展示)。
func (p *Plugin) snapshotMsg(msgID, peerID int64, isGroup bool, t int64, nickname, text string) {
if msgID <= 0 {
return
}
p.msgMu.Lock()
defer p.msgMu.Unlock()
// msg_id 映射7 天 TTL惰性清理
p.msgMap[msgID] = msgRef{peerID: peerID, isGroup: isGroup, time: t}
now := time.Now().Unix()
if len(p.msgMap) > 2000 { // 定期清理过期项
for k, v := range p.msgMap {
if now-v.time > qqMsgTTL {
delete(p.msgMap, k)
}
}
}
ch := p.chats[peerID]
if ch == nil {
ch = &chatMeta{peerID: peerID, isGroup: isGroup}
p.chats[peerID] = ch
}
if ch.name == "" {
if isGroup {
ch.name = fmt.Sprintf("群%d", peerID)
} else {
ch.name = nickname
}
}
// 按到达次序维护未读与最新摘要:仅当本条更新时才更新 lastTime/lastText保持按时间排
if t > ch.lastTime {
ch.lastTime = t
ch.lastText = text
ch.lastNick = nickname
}
ch.unread++
}
// lookupMsgRef 查 msg_id 映射,返回 (peer, isGroup, time, ok)。超过 7 天视为无效(交给 get_history
func (p *Plugin) lookupMsgRef(msgID int64) (int64, bool, int64, bool) {
p.msgMu.Lock()
defer p.msgMu.Unlock()
ref, ok := p.msgMap[msgID]
if !ok {
return 0, false, 0, false
}
now := time.Now().Unix()
if now-ref.time > qqMsgTTL {
delete(p.msgMap, msgID)
return 0, false, 0, false
}
return ref.peerID, ref.isGroup, ref.time, true
}
// markChatRead 清零某会话未读数(模型处理完该会话后调用)。
func (p *Plugin) markChatRead(peerID int64) {
p.msgMu.Lock()
defer p.msgMu.Unlock()
if ch := p.chats[peerID]; ch != nil {
ch.unread = 0
}
}
// listChats 返回会话列表:按最新消息时间降序,含未读数与最新一条摘要。
func (p *Plugin) listChats(capN int) []map[string]interface{} {
p.msgMu.Lock()
list := make([]*chatMeta, 0, len(p.chats))
for _, c := range p.chats {
list = append(list, c)
}
p.msgMu.Unlock()
// 降序(最新消息在前)
for i := 1; i < len(list); i++ {
for j := i; j > 0 && list[j].lastTime > list[j-1].lastTime; j-- {
list[j], list[j-1] = list[j-1], list[j]
}
}
if len(list) > capN {
list = list[:capN]
}
out := make([]map[string]interface{}, 0, len(list))
for _, c := range list {
typ := "private"
if c.isGroup {
typ = "group"
}
item := map[string]interface{}{
"peer_id": c.peerID,
"type": typ,
"name": c.name,
"unread": c.unread,
"last_text": c.lastText,
"last_nick": c.lastNick,
}
if c.lastTime > 0 {
item["last_time"] = time.Unix(c.lastTime, 0).Format("2006-01-02 15:04")
}
out = append(out, item)
}
return out
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
@ -150,6 +286,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.httpClient = &http.Client{Timeout: 30 * time.Second}
// msg_id → peer 映射 + 会话状态(不缓存正文)
p.msgMap = make(map[int64]msgRef)
p.chats = make(map[int64]*chatMeta)
// 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s
p.fetchBotInfo()
if p.botID == 0 {
@ -248,6 +388,27 @@ type 枚举: text文字/ voice语音转文字后发送/ image
},
}, p.handleGetHistory)
p.regTool(s, sdk.ToolDef{
Name: tp + "list_chats", Description: "获取QQ会话列表与真人客户端一致按最新消息先后排序每条标注会话群/私聊)、会话名、未读消息数、最新一条消息摘要与时间。用于发现有未读消息的会话,再配合 qq_get_history 拉取对应会话内容、output_send__qq 回复。",
NoMemory: false,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"count": map[string]interface{}{"type": "integer", "description": "最多返回会话数默认10"},
}, "required": []string{},
},
}, p.handleListChats)
p.regTool(s, sdk.ToolDef{
Name: tp + "mark_read", Description: "将某个会话的未读计数清零(对象:群聊传 group_id私聊传 user_id。处理完某会话消息后可调用让 list_chats 的未读数回到0与真人客户端标记已读一致。",
NoMemory: false,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号与group_id二选一"},
}, "required": []string{},
},
}, p.handleMarkRead)
// ---- 查询 ----
p.regTool(s, sdk.ToolDef{
Name: tp + "get_groups", Description: "获取QQ群列表可按关键词搜索群名",
@ -772,6 +933,28 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
return
}
}
// ---- 记录 msg_id→peer 映射与会话状态(不缓存正文,仅最新一条短摘要)----
// 策略允许的消息(群/私聊、是否 @bot 均记),供 get_msg 兜底与 list_chats 使用;
// @bot 与否只决定是否发中断,不影响记录——与真人客户端一致看到全部会话。
{
peerID, isGroup := evt.UserID, false
if evt.MessageType == "group" {
peerID, isGroup = evt.GroupID, true
}
sum := text
runes := []rune(sum)
if len(runes) > qqLastSumLen {
sum = string(runes[:qqLastSumLen]) + "…"
}
if evt.Time == 0 {
evt.Time = time.Now().Unix()
}
p.snapshotMsg(evt.MessageID, peerID, isGroup, evt.Time, nickname, sum)
}
if evt.MessageType == "group" {
// 群消息必须 @ 机器人才响应
if p.botID == 0 {
log.Printf("[qq] bot ID unknown, rejecting group message from %d", evt.GroupID)
@ -791,9 +974,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
outputTool := "output_send__" + p.name
var interrupt string
if evt.MessageType == "group" {
interrupt = fmt.Sprintf("来自「%s」在群「%s」的消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。如果消息包含引用回复,使用%sget_history(group_id=%d)查看上下文。使用%s回复群聊", nickname, "群聊", evt.MessageID, tp, evt.MessageID, tp, evt.GroupID, outputTool)
interrupt = fmt.Sprintf("来自「%s」在群「%s」的消息(message_id=%d)。用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(group_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复群聊", nickname, "群聊", evt.MessageID, tp, evt.MessageID, tp, evt.GroupID, tp, outputTool)
} else {
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。使用%s回复对方", nickname, evt.MessageID, tp, evt.MessageID, outputTool)
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d, user_id=%d)。用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(user_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复对方", nickname, evt.MessageID, evt.UserID, tp, evt.MessageID, tp, evt.UserID, tp, outputTool)
}
if p.isAdmin(evt.UserID) {
interrupt = "【重要!老大消息】" + interrupt
@ -841,6 +1024,126 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
// ======== Tool Handlers ========
// getMsgFromHistoryByTime 按 (peer, isGroup, targetTime) 从 NapCat 拉最近历史,返回距 targetTime 最近的完整消息。
func (p *Plugin) getMsgFromHistoryByTime(peerID int64, isGroup bool, targetTime int64) (map[string]interface{}, bool) {
ep := "get_friend_msg_history"
params := map[string]interface{}{"user_id": peerID, "count": 50}
if isGroup {
ep = "get_group_msg_history"
params = map[string]interface{}{"group_id": peerID, "count": 50}
}
raw, err := p.napcat(ep, params)
if err != nil {
return nil, false
}
rawStr, _ := rawString(raw)
if rawStr == "" {
return nil, false
}
var resp struct {
Data *struct {
Messages []interface{} `json:"messages"`
} `json:"data"`
}
if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil {
return nil, false
}
var best map[string]interface{}
bestAbs := int64(-1)
for _, m := range resp.Data.Messages {
mm, ok := m.(map[string]interface{})
if !ok {
continue
}
mt, _ := mm["time"].(float64)
t := int64(mt)
if t == 0 {
continue
}
abs := t - targetTime
if abs < 0 {
abs = -abs
}
if bestAbs < 0 || abs < bestAbs {
bestAbs = abs
best = mm
}
}
if best == nil {
return nil, false
}
return best, true
}
// msgToGetMsgResult 把一条 NapCat 历史消息对象转成与 get_msg 同构的结果(历史包装语义)。
func msgToGetMsgResult(msg map[string]interface{}) map[string]interface{} {
nickname := ""
if s, ok := msg["sender"].(map[string]interface{}); ok {
if n, _ := s["nickname"].(string); n != "" {
nickname = n
}
if c, _ := s["card"].(string); c != "" {
nickname = c
}
}
rawText, _ := msg["raw_message"].(string)
content := rawText
if content == "" {
if segs, ok := msg["message"].([]interface{}); ok {
var parts []string
for _, seg := range segs {
segMap, _ := seg.(map[string]interface{})
if segMap == nil {
continue
}
typ, _ := segMap["type"].(string)
segData, _ := segMap["data"].(map[string]interface{})
if segData == nil {
continue
}
switch typ {
case "text":
if t, _ := segData["text"].(string); t != "" {
parts = append(parts, t)
}
case "image":
parts = append(parts, "[图片]")
case "file":
if n, _ := segData["name"].(string); n != "" {
parts = append(parts, "[文件:"+n+"]")
}
default:
if typ != "" {
parts = append(parts, "["+typ+"]")
}
}
}
if len(parts) > 0 {
content = strings.Join(parts, " ")
}
}
}
mid, _ := msg["message_id"].(float64)
uid, _ := msg["user_id"].(float64)
gid, _ := msg["group_id"].(float64)
mt, _ := msg["time"].(float64)
mtType, _ := msg["message_type"].(string)
loc := "私聊"
if mtType == "group" || gid > 0 {
loc = "群聊"
}
return map[string]interface{}{
"content": content,
"message_id": int64(mid),
"user_id": int64(uid),
"group_id": int64(gid),
"nickname": nickname,
"message_type": mtType,
"type": loc,
"time": time.Unix(int64(mt), 0).Format("2006-01-02 15:04:05"),
}
}
func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) {
msgID, err := convInt64(args["message_id"])
if err != nil {
@ -850,10 +1153,24 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
}, nil
}
// 本地 msg_id→peer 映射命中且 <7 天 → 用 get_history 语义兜底NapCat 临时短号失效也不怕)
if peerID, isGroup, t, ok := p.lookupMsgRef(msgID); ok {
if m, found := p.getMsgFromHistoryByTime(peerID, isGroup, t); found {
// 找到同会话、时间最接近的消息,包装为 get_msg 同构返回
res := msgToGetMsgResult(m)
res["resolved_via"] = "history" // 标明由历史查询兜底
return res, nil
}
// 历史窗口内没找到(消息可能被裁剪/更早),回退 NapCat 原查询
}
return p.getMsgFromNapcat(msgID)
}
func (p *Plugin) getMsgFromNapcat(msgID int64) (interface{}, error) {
raw, err := p.napcat("get_msg", map[string]interface{}{"message_id": msgID})
if err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("查询 NapCat 失败: %s", err),
"content": fmt.Sprintf("查询 NapCat 失败: %s。该 message_id 可能已过期,请改用 qq_get_history 按会话拉取最近消息(或用 qq_list_chats 看未读会话)", err),
"message_id": msgID,
"not_found": true,
}, nil
@ -884,7 +1201,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
}
if err := json.Unmarshal([]byte(rawStr), &resp); err != nil || resp.Data == nil {
return map[string]interface{}{
"content": "解析 NapCat 响应失败",
"content": "解析 NapCat 响应失败(消息可能已过期)。请改用 qq_get_history 按会话拉取最近消息,或用 qq_list_chats 查看未读会话",
"message_id": msgID,
"not_found": true,
}, nil
@ -1214,6 +1531,31 @@ func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error
return p.napcat("send_private_msg", params)
}
func (p *Plugin) handleListChats(args map[string]interface{}) (interface{}, error) {
count := 10
if c, err := convInt64(args["count"]); err == nil && c > 0 && c < 100 {
count = int(c)
}
chats := p.listChats(count)
return map[string]interface{}{
"chats": chats,
"total": len(chats),
"hint": "按最新消息先后排序unread 为该会话未读消息数,处理完用 qq_mark_read 清零;用 qq_get_history(group_id/user_id) 拉取会话内容",
}, nil
}
func (p *Plugin) handleMarkRead(args map[string]interface{}) (interface{}, error) {
if gid, err := convInt64(args["group_id"]); err == nil {
p.markChatRead(gid)
return map[string]interface{}{"status": "ok", "group_id": gid, "unread": 0}, nil
}
if uid, err := convInt64(args["user_id"]); err == nil {
p.markChatRead(uid)
return map[string]interface{}{"status": "ok", "user_id": uid, "unread": 0}, nil
}
return nil, fmt.Errorf("need group_id or user_id")
}
func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) {
gid, gerr := convInt64(args["group_id"])
uid, uerr := convInt64(args["user_id"])
@ -1352,6 +1694,12 @@ func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, err
if len(files) > 0 {
result["files"] = files
}
// 拉取过某会话历史即视为已读(与真人客户端一致:看过=已读)
if gerr == nil {
p.markChatRead(gid)
} else if uerr == nil {
p.markChatRead(uid)
}
return result, nil
}

View File

@ -1,12 +1,15 @@
// Package meta 收集 HomeAgent SDK 的全部元数据。
// 版本号应与核心 meta.Version 保持一致。
// ABI 版本与 Dispatch Method ID 应与核心仓 internal/meta/meta.go 保持一致。
package meta
var (
// Version 是 HomeAgent SDK 版本号。
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
Version = "0.9.2"
//
// 1.0.0:插件运行模型从 C ABI 动态库改为子进程 + 共享内存。
// 公开 SDK 接口sdk/ 目录)**零改动**——插件业务代码不需要改一行,
// 但产物形态变了plugin.so → plugin.bin必须用新版 plugindev 重编。
Version = "1.0.0"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
@ -21,7 +24,10 @@ var (
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
// CoreVersion 是此 SDK 所兼容的最低核心版本。
CoreVersion = "0.9.2"
//
// 1.0.0 是硬下限而非建议值0.9.x 内核只会 dlopen `.so`
// 本版工具链产出的 `plugin.bin` 在旧内核上根本不会被识别。
CoreVersion = "1.0.0"
)
// FullVersion 返回完整的版本字符串。
@ -29,79 +35,15 @@ func FullVersion() string {
return SDKName + " v" + Version + " (" + Commit + ")"
}
// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ----
// ABI 标识版本直接取内核版本号字符串semver与核心 Version 保持一致,不使用独立数字编码。
// 协商层C 结构体 int version 字段)使用 CABINum由版本字符串派生的整数major*100 + minor
// 映射v0.8.x → CABINum=800v0.9.x → CABINum=900invoke_stage 写回)。
// 小版本patch演进不影响 ABICABINum 不变。version_min 保证旧 ABI 插件仍可加载
var (
// ABIVersion 是 ABI 标识版本(字符串 semver与 SDK CoreVersion 对齐)。
ABIVersion = CoreVersion
// ABIVersionMin 是兼容的最低 ABI 标识版本。
ABIVersionMin = "0.8.0"
)
const (
// CABINum 是 C 层协商用的整数版本major*100 + minor随 ABIVersion 派生。
CABINum = 900
// CABINumMin 是 C 层兼容的最低整数版本。
// 旧工具链v0.8 之前)写入的整数 version=1无写回能力但与新内核结构兼容
// 因此最小值保持 1 以兼容全部旧插件(新插件 900 匹配,旧插件 1/2 通过);
// 仅当未来内核 ABI 破坏兼容时才提高该值。
CABINumMin = 1
)
// ---- Dispatch Method IDs与核心仓 internal/meta/meta.go 同步) ----
const (
CoreRegisterTool = 1
CoreRegisterStage = 2
CoreRegisterOutputCh = 3
CoreRegisterPluginAPI = 4
CoreInjectText = 5
CoreInjectInterruptText = 6
CoreInjectTextNoMemory = 7
CoreSetAutoRestart = 8
CoreMemoryRecall = 9
CoreMemoryCommit = 10
CoreMemoryIntrospect = 11
CoreMemoryMerge = 12
CoreMemoryPurge = 13
CoreDocQuery = 14
CoreKnowledgeSearch = 15
CoreSettingsGet = 16
CoreSettingsSet = 17
CoreSettingsRegisterDef = 18
CoreLLMListSources = 19
CoreLLMSetSource = 20
CoreSocialGetPerson = 21
CoreSocialGetNetwork = 22
CoreSubscribe = 23
CoreUnsubscribe = 24
CoreFreeString = 25
CoreSettingsGetCore = 26
CoreSettingsSetCore = 27
CoreSettingsListCore = 28
CoreSettingsGetPlugin = 29
CoreSettingsSetPlugin = 30
CoreSettingsListPlugin = 31
CoreDocInsert = 32
CoreDocRemove = 33
CoreDocStats = 34
CoreKnowledgeAdd = 35
CoreKnowledgeList = 36
CoreLLMCurrentSource = 37
CoreSocialGetTrait = 38
CoreSocialGetRelations = 39
CoreSocialListPersons = 40
CoreTextMemoryAppend = 41
CoreSettingsList = 42
CoreSettingsDefs = 43
CoreSettingsDump = 44
CoreSettingsPlugins = 45
CoreRegisterInputCh = 46
CoreInjectInputSync = 47
CorePluginReloadOne = 48
CorePluginListLoaded = 49
CorePluginIsDisabled = 50
)
// ---- 协议版本 ----
//
// 子进程 RPC 的协议版本是一个独立的小整数,与 SDK/内核语义版本解耦:
// 语义版本变动频繁(修 bug、加字段而 wire 协议只在**帧格式或握手语义**
// 变化时才升。当前值见核心仓 internal/plugin/proc/protocol.go 的 ProtocolVersion
//
// C ABI 时代的 ABIVersion / CABINum / 51 个 Core<Method> 整数 ID 已随
// Part 6.2 删除 internal/plugin/cabi/ 一并退场:
// - 整数 method id 平移为 method 名字符串proc/protocol.go 的 Method* 常量)
// - 版本协商改为握手帧里的 protocol 字段
//
// 保留那些常量只会让人以为它们还在生效。

View File

@ -24,7 +24,8 @@ func cmdBuild(args []string) {
// Read all config from plg.json first
plg, err := readPlgJSON("plg.json")
if err != nil {
fmt.Printf("error: read plg.json: %v\n", err); os.Exit(1)
fmt.Printf("error: read plg.json: %v\n", err)
os.Exit(1)
}
// Base config from plg.json
@ -39,11 +40,13 @@ func cmdBuild(args []string) {
switch args[i] {
case "--outdir":
if i+1 < len(args) {
outDir = args[i+1]; i++
outDir = args[i+1]
i++
}
case "--target":
if i+1 < len(args) {
targets = append(targets, args[i+1]); i++
targets = append(targets, args[i+1])
i++
}
case "--bundle":
bundle = true
@ -51,11 +54,13 @@ func cmdBuild(args []string) {
bundle = false
case "--sdk-path":
if i+1 < len(args) {
sdkPath = args[i+1]; i++
sdkPath = args[i+1]
i++
}
case "--replace", "-R":
if i+1 < len(args) {
cliReplaces = append(cliReplaces, args[i+1]); i++
cliReplaces = append(cliReplaces, args[i+1])
i++
}
}
}
@ -68,18 +73,9 @@ func cmdBuild(args []string) {
// Ensure go.mod exists with correct SDK path
sdkModule := ensureGoMod(plg, sdkPath)
// First build: fetch the SDK module (generates go.sum with zip hash)
// 保证 SDK 模块可解析,否则编译必死在 "missing go.sum entry"。
if sdkModule != "" {
if _, err := os.Stat("go.sum"); os.IsNotExist(err) {
dl := exec.Command("go", "mod", "download", sdkModule)
dl.Env = os.Environ()
dl.Stdout = os.Stdout
dl.Stderr = os.Stderr
fmt.Println(" downloading SDK module deps...")
if err := dl.Run(); err != nil {
fmt.Printf(" error: go mod download: %v\n", err)
}
}
ensureSDKResolvable(plg, sdkModule, sdkPath)
}
// Merge plg.json replaces + CLI overrides
@ -105,14 +101,17 @@ func cmdBuild(args []string) {
}
// allBundleTargets 是 --bundle 模式构建的全部平台。
// 每个 OS 只有一个架构amd64避免二进制文件名冲突。
//
// 子进程模式下各平台产物同名plugin.bin——进程边界即 ABI 边界,
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
var allBundleTargets = []struct {
target string
entry string // 二进制在 zip 中的文件名
}{
{"linux/amd64", "plugin.so"},
{"darwin/amd64", "plugin.dylib"},
{"windows/amd64", "plugin.dll"},
{"linux/amd64", "plugin.bin.linux.amd64"},
{"darwin/amd64", "plugin.bin.darwin.amd64"},
{"windows/amd64", "plugin.bin.windows.amd64"},
}
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
@ -120,9 +119,13 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
buildDir := "build"
os.MkdirAll(buildDir, 0755)
// Auto-generate C ABI bridge for non-Windows
bridgeCleanup := generateBridge("")
defer bridgeCleanup()
runtimeCleanup, err := generateProcRuntime()
if err != nil {
fmt.Printf(" error: %v\n", err)
return
}
defer runtimeCleanup()
thirdpartCleanup := linkThirdpart(plg, "linux/amd64")
defer thirdpartCleanup()
@ -135,22 +138,18 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
return
}
outPath := filepath.Join(buildDir, cfg.entryFile)
// 每平台产物落到独立路径,避免相互覆盖
outName := fmt.Sprintf("%s_%s_%s", cfg.entryFile, cfg.goos, cfg.goarch)
outPath := filepath.Join(buildDir, outName)
cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", outPath)
// 零 cgo跨平台交叉编译不需目标平台 C 工具链
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1")
if cfg.goos == "windows" {
cc := detectWindowsCC()
if cc != "" {
cmd.Env = append(cmd.Env, "CC="+cc)
}
}
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
fmt.Printf(" compiling %s/%s (-buildmode=c-shared)...\n", cfg.goos, cfg.goarch)
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil {
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
return
@ -168,7 +167,7 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
for p := range platforms {
plats = append(plats, p)
}
writePluginJSON(plg, plats, "plugin.so")
writePluginJSON(plg, plats, procEntryFile)
// package single .hmap with correctly named entries
hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn)))
@ -176,7 +175,10 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
fmt.Printf(" packaged %s\n", filepath.Base(hmapPath))
}
func (p *PlgConfig) IsLua() bool { return p.Entry == "main.lua" }
// IsLua 判断是否为 Lua 插件(走解释器,不经过 Go 编译)。
//
// 这是 entry 字段唯一仍在使用的用途Go 插件不再看 entry 值,一律产出 plugin.bin。
func (p *PlgConfig) IsLua() bool { return p.Entry == luaEntryFile }
func readPlgJSON(path string) (*PlgConfig, error) {
data, err := os.ReadFile(path)
@ -228,9 +230,14 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
type buildConfig struct {
goos string
goarch string
entryFile string // "plugin.so" or "plugin.dll"
entryFile string // 一律为 plugin.bin进程边界即 ABI 边界,无平台特有扩展名)
}
// resolveBuild 解析目标平台。
//
// 全平台统一产出 plugin.bin子进程模式下不存在 .so/.dylib/.dll 的区分,
// 因为进程边界本身就是 ABI 边界——这正是三套独立 ABI 实现收敛为
// 单一 RPC 实现的直接后果§9.2Windows 不再是能力退化的第三套实现)。
func resolveBuild(target string) (*buildConfig, string) {
if target == "lua" || target == "" {
return nil, "lua"
@ -245,14 +252,8 @@ func resolveBuild(target string) (*buildConfig, string) {
}
switch goos {
case "linux":
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.so"}, ""
case "darwin":
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.dylib"}, ""
case "freebsd":
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.so"}, ""
case "windows":
return &buildConfig{goos: goos, goarch: goarch, entryFile: "plugin.dll"}, ""
case "linux", "darwin", "freebsd", "windows":
return &buildConfig{goos: goos, goarch: goarch, entryFile: procEntryFile}, ""
default:
return nil, fmt.Sprintf("unsupported OS %q", goos)
}
@ -333,6 +334,128 @@ func ensureGoMod(plg *PlgConfig, sdkPath string) string {
return sdkModule
}
// ensureSDKResolvable 保证 SDK 模块在编译前可解析。
//
// 为何需要这个函数gitcode 的模块不在 proxy.golang.org 上。只要 go.mod
// 里的 SDK 靠 require 版本号解析,而本地又没 go.sum 条目go build 就报
// "missing go.sum entry";而原来那句 `go mod download <mod>` 会去公共 proxy
// 拉一个永远拉不到的条目,超时后只打一行 warn 就继继编译,紧接着死在
// 同一个错误上——新用户拿到的是两段无关的报错。
//
// 三级策略,按代价递增:
// 1. go.mod 已有指向本地目录的 replace —— 什么都不用做replace 到目录时
// go 不需要也不校验 go.sum
// 2. 能定位到本机 SDK 源码 —— 写入 replace。这是存量项目go.mod 旧、
// 无 replace的救场路径。
// 3. 都不行 —— 跑 `go mod tidy`(带 -mod=mod让它自己去试失败则给
// 可操作的提示而不是让用户去猜。
func ensureSDKResolvable(plg *PlgConfig, sdkModule, sdkPath string) {
data, err := os.ReadFile("go.mod")
if err != nil {
return
}
// 策略 1已有指向本地目录的 replace。
// replace 目标带 / 或 . 开头的才是路径;指向另一个模块的 replace 不算。
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "replace ") || !strings.Contains(line, sdkModule) {
continue
}
parts := strings.Fields(line)
if len(parts) < 4 {
continue
}
target := parts[3]
if strings.HasPrefix(target, ".") || strings.HasPrefix(target, "/") ||
strings.Contains(target, ":/") || strings.Contains(target, ":\\") {
return // 已指向本地目录,无需 go.sum
}
}
// 策略 2能定位到本机 SDK 就写 replace。
// resolveSDKPath 失败会 os.Exit所以只在能确定拿到路径时调用它背后的探测。
if root := findLocalSDK(sdkPath); root != "" {
if appendGoModReplace(sdkModule, root) {
fmt.Printf(" SDK 指向本机源码(已写入 go.mod replace%s\n", root)
return
}
}
// 策略 3交给 go mod tidy。
if _, err := os.Stat("go.sum"); err == nil {
return // 已有 go.sum不插手
}
fmt.Println(" 解析 SDK 依赖go mod tidy...")
tidy := exec.Command("go", "mod", "tidy")
tidy.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := tidy.CombinedOutput(); err != nil {
fmt.Printf(" warn: go mod tidy 失败:%v\n", err)
if len(out) > 0 {
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
}
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK\n", sdkModule)
fmt.Printf(" plugindev sdk install latest # 装一份到 ~/.homeagent/plugindev/sdk\n")
fmt.Printf(" plugindev build --sdk-path <路径> # 或直接指定源码目录\n")
}
}
// findLocalSDK 探测本机 SDK 源码根目录,找不到返回空串。
//
// 与 resolveSDKPath 的区别:后者找不到就 os.Exit适合“必须有”的调用点
// 这里是“有则更好”的探测,不能把构建搞挂。
func findLocalSDK(sdkPath string) string {
candidates := []string{}
if sdkPath != "" {
if abs, err := filepath.Abs(sdkPath); err == nil {
candidates = append(candidates, abs)
}
}
// plugindev 自身所在位置往上三级tools/plugindev/plugindev → SDK 根)
if self, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
}
// plugindev sdk use 选定的版本
store := os.Getenv("HOMEAGENT_SDK_DIR")
if store == "" {
if home, err := os.UserHomeDir(); err == nil {
store = filepath.Join(home, ".homeagent", "plugindev", "sdk")
}
}
if store != "" {
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
if ver := strings.TrimSpace(string(d)); ver != "" {
candidates = append(candidates, filepath.Join(store, ver))
}
}
}
for _, c := range candidates {
if c == "" {
continue
}
if _, err := os.Stat(filepath.Join(c, "sdk", "plugin.go")); err == nil {
return c
}
}
return ""
}
// appendGoModReplace 向 go.mod 追加一条 replace成功返回 true。
func appendGoModReplace(module, localPath string) bool {
data, err := os.ReadFile("go.mod")
if err != nil {
return false
}
abs, err := filepath.Abs(localPath)
if err != nil {
return false
}
abs = strings.ReplaceAll(abs, "\\", "/")
s := strings.TrimRight(string(data), "\r\n")
s += fmt.Sprintf("\n\nreplace %s => %s\n", module, abs)
return os.WriteFile("go.mod", []byte(s), 0644) == nil
}
func resolveSDKPath(sdkPath string) string {
if sdkPath != "" {
abs, _ := filepath.Abs(sdkPath)
@ -403,7 +526,7 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
return
}
// Resolve build config
// Resolve build config(全平台统一产出 plugin.bin
cfg, errMsg := resolveBuild(target)
if cfg == nil {
fmt.Printf(" error: %s\n", errMsg)
@ -414,9 +537,12 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
os.MkdirAll(buildDir, 0755)
outPath := filepath.Join(buildDir, cfg.entryFile)
// Auto-generate C ABI bridge (all platforms use c-shared)
bridgeCleanup := generateBridge(cfg.goos)
defer bridgeCleanup()
runtimeCleanup, err := generateProcRuntime()
if err != nil {
fmt.Printf(" error: %v\n", err)
return
}
defer runtimeCleanup()
// Auto-link thirdpart/ contents + source_dirs + replace targets
thirdpartCleanup := linkThirdpart(plg, target)
@ -425,28 +551,15 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
// Write plugin.json with the correct entry for this target
writePluginJSON(plg, nil, cfg.entryFile)
cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", outPath)
// 普通 go build + 零 cgo交叉编译不再需要目标平台的 C 工具链
// (旧路径靠 detectWindowsCC 找 MinGW现在整个问题消失
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1")
// Auto-detect MinGW gcc on Windows
if cfg.goos == "windows" {
cc := detectWindowsCC()
if cc != "" {
cmd.Env = append(cmd.Env, "CC="+cc)
}
}
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// DEBUG: list files before building
entries, _ := os.ReadDir(".")
for _, e := range entries {
fmt.Printf(" [DEBUG] file: %s\n", e.Name())
}
fmt.Printf(" compiling %s/%s (-buildmode=c-shared)...\n", cfg.goos, cfg.goarch)
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil {
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
return
@ -465,8 +578,8 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
}
type binEntry struct {
src string // 磁盘路径,如 build/plugin.so
zip string // zip 中条目名,如 plugin.so
src string // 磁盘路径,如 build/plugin.bin
zip string // zip 中条目名,如 plugin.bin.linux.amd64
}
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
@ -566,72 +679,8 @@ func toSnake(s string) string {
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
}
// detectWindowsCC looks for a MinGW-w64 gcc on Windows for c-shared builds.
func detectWindowsCC() string {
// Check CC from environment first
if cc := os.Getenv("CC"); cc != "" {
if _, err := exec.LookPath(cc); err == nil {
return cc
}
}
// Check common MinGW install paths
candidates := []string{
"C:\\mingw64\\bin\\gcc.exe",
"C:\\MinGW\\bin\\gcc.exe",
"C:\\msys64\\mingw64\\bin\\gcc.exe",
"C:\\Users\\21989\\AppData\\Local\\Temp\\mingw64\\mingw64\\bin\\gcc.exe",
}
// Also search PATH for gcc
if path, err := exec.LookPath("gcc"); err == nil {
return path
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
// stripIncludeGuard strips preprocessor guards and C++ comments from a C header,
// since these can confuse cgo's type resolution.
// generateBridge generates the C ABI bridge files for non-Lua builds.
// Returns a cleanup function to remove generated files.
func generateBridge(goos string) func() {
const bridgeFile = "z_bridge_gen.go"
const cEntryFile = "z_entry.c"
os.Remove(bridgeFile)
os.Remove(cEntryFile)
var files []string
if goos == "windows" {
if err := os.WriteFile(bridgeFile, []byte(tmplBridge), 0644); err != nil {
fmt.Printf(" error: write bridge: %v\n", err)
return func() {}
}
files = append(files, bridgeFile)
} else {
if err := os.WriteFile(bridgeFile, []byte(tmplLinuxBridge), 0644); err != nil {
fmt.Printf(" error: write bridge: %v\n", err)
return func() {}
}
files = append(files, bridgeFile)
// Write C entry point file
if err := os.WriteFile(cEntryFile, []byte(tmplPluginInitC), 0644); err != nil {
fmt.Printf(" error: write C entry: %v\n", err)
return func() {}
}
files = append(files, cEntryFile)
}
return func() {
for _, f := range files {
os.Remove(f)
}
}
}
// linkThirdpart scans thirdpart/, source_dirs from plg.json, and replace target dirs
// for source files, generating auto-import stubs. Returns cleanup function.
func linkThirdpart(plg *PlgConfig, target string) func() {

View File

@ -7,8 +7,6 @@ import (
"sort"
"strings"
"text/template"
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
func (p *PlgConfig) ReplacesToSlice() []string {
@ -62,9 +60,13 @@ type TemplateData struct {
SDKModule string
SDKVersion string
// C ABI
CABIVersion int
CABIHeader string
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
//
// 为何必须写gitcode 的模块不在 proxy.golang.org 上,只 require 一个
// 版本号的 go.mod 配上缺失的 go.sum新用户第一次 `plugindev build`
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
// 一个不存在的条目。有了本地 replacego 完全不需要 go.sum 条目。
SDKLocalPath string
}
func cmdInit(args []string) {
@ -115,7 +117,12 @@ func cmdInit(args []string) {
os.Exit(1)
}
entry := "plugin.so"
// Go 插件统一产出 plugin.binv1.0.0 子进程模式)。
//
// 此前这里写 "plugin.so"scaffold 出来的 plg.json 就带着一个已退场的
// entry 值,新手跟着模板走会误以为自己在做 C ABI 插件。
// build 实际不看这个值(只用它区分 Lua但模板不应误导。
entry := "plugin.bin"
var targets string
if isLua {
entry = "main.lua"
@ -139,20 +146,19 @@ func cmdInit(args []string) {
Tags: []string{name},
Targets: targets,
},
IsLua: isLua,
CABIVersion: meta.CABINum,
CABIHeader: tmplCABIHeader,
IsLua: isLua,
}
// Detect SDK info for Go plugin go.mod.
// 生成的 go.mod require SDK 线上模块版本,不写本地路径 replace
// 本地调试请用 `plugindev build --sdk-path <path>` 或手动加 replace
// 生成的 go.mod require 外还写一条指向本机 SDK 的 replace
// 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)
if !isLua {
sdkMod, goVer, _, sdkVer := detectSDKInfo()
sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
data.ModulePath = name
data.GoVersion = goVer
data.SDKModule = sdkMod
data.SDKVersion = "v" + sdkVer
data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/")
}
if err := os.MkdirAll(dir, 0755); err != nil {

View File

@ -0,0 +1,91 @@
package main
import (
"embed"
"fmt"
"os"
)
// 子进程插件运行时(外部插件多进程化)。
//
// 模板为何是**真实 .go 源文件** + //go:embed而不是 raw string
// 1100+ 行代码塞在字符串里写错只能等生成插件时才炸;作为源文件可被
// gofmt / go vet / go/parser 直接检查proc_runtime_test.go 的 16 项
// 静态检查就以此为前提)。
//
// 构建从 `-buildmode=c-shared` + CGO_ENABLED=1 变成普通 `go build` +
// CGO_ENABLED=0交叉编译不再需要目标平台的 C 工具链§3.1 连带消失项)。
//
// 设计依据docs/zh/架构迁移评估.md §3、docs/zh/plugin-migration-plan.md Part 3/6
//go:embed templates/proc_main.go.tmpl
//go:embed templates/proc_shm_unix.go.tmpl
//go:embed templates/proc_shm_windows.go.tmpl
var procTemplates embed.FS
// procRuntimeFiles 列出生成到插件目录的运行时文件。
//
// 共享段与事件通知的**传递机制**按平台不同Unix 继承 fd
// Windows 命名内核对象),故拆成带 build tag 的两个文件;
// 共享段**布局**与 RPC 逻辑完全平台无关,全在 proc_main 里。
//
// 这正是三套独立 ABI 实现收敛为单一 RPC 实现的效果:
// 平台差异从「整套 stage 下发/写回逻辑各写一份」缩到「三个挂载函数」。
var procRuntimeFiles = []struct {
tmpl string // 内嵌模板路径
out string // 生成到插件目录的文件名
}{
{"templates/proc_main.go.tmpl", "z_proc_gen.go"},
{"templates/proc_shm_unix.go.tmpl", "z_proc_shm_unix.go"},
{"templates/proc_shm_windows.go.tmpl", "z_proc_shm_windows.go"},
}
// procEntryFile 是子进程插件的入口二进制名(与内核 internal/plugin/dynamic.go 的 binEntry 一致)。
//
// 全平台同名:进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名
// (对比 C ABI 时代的 .so/.dylib/.dll 三套产物 + 三套 ABI 实现)。
const procEntryFile = "plugin.bin"
// luaEntryFile 是 Lua 插件的入口。Lua 走解释器,不经过 Go 编译。
const luaEntryFile = "main.lua"
// procGenFile 是生成的主运行时文件名(兼容旧注释引用)。
// 前缀 z_ 使其在目录列表中排在业务代码之后。
const procGenFile = "z_proc_gen.go"
// generateProcRuntime 把子进程运行时(平台无关主体 + 两个平台挂载实现)
// 写入插件目录,返回清理函数。
func generateProcRuntime() (func(), error) {
// 清理历史 C ABI 产物:旧版 plugindev 生成过这两个文件,残留下来会与
// 本模板的 main 冲突。无需人工清理就能从旧版升级。
for _, stale := range []string{"z_bridge_gen.go", "z_entry.c"} {
os.Remove(stale)
}
var written []string
cleanup := func() {
for _, f := range written {
os.Remove(f)
}
}
for _, rf := range procRuntimeFiles {
data, err := procTemplates.ReadFile(rf.tmpl)
if err != nil {
cleanup()
return nil, fmt.Errorf("读取内嵌模板 %s: %w", rf.tmpl, err)
}
if err := os.WriteFile(rf.out, data, 0644); err != nil {
cleanup()
return nil, fmt.Errorf("写入 %s: %w", rf.out, err)
}
written = append(written, rf.out)
}
return cleanup, nil
}
// isProcEntry 已删除Go 插件一律产出 plugin.bin不再看 plg.json 的 entry 值。
//
// 为何忽略 entry17 个存量插件的 plg.json 都写着 "plugin.so"。若把 entry 当作
// 通道开关,迁移就得改 17 个文件——而「外部插件零改动」是本次迁移的硬约束。
// entry 现在只用于区分 Luamain.lua与 Go 插件。

View File

@ -0,0 +1,394 @@
package main
import (
"go/parser"
"go/token"
"os"
"regexp"
"strings"
"testing"
)
// 子进程运行时模板的静态检查Part 3
//
// 为什么需要这些测试:模板是插件的运行时半身,它与内核 internal/plugin/proc/
// 的协议名、共享段布局、字段索引必须逐一对齐。任一处漂移都会导致
// 「插件编译通过但运行时读错字段」——比编译错误难查得多。
//
// 模板改为真实 .go 源文件(而非 raw string的直接收益就是这类检查可行。
func loadProcTemplate(t *testing.T) string {
t.Helper()
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
if err != nil {
t.Fatalf("读取内嵌模板: %v", err)
}
return string(data)
}
// stripComments 去掉源码中的注释(用空白填充以保持偏移),只留可执行代码。
func stripComments(t *testing.T, src string) string {
t.Helper()
fs := token.NewFileSet()
f, err := parser.ParseFile(fs, "proc_main.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("解析模板: %v", err)
}
out := []byte(src)
for _, cg := range f.Comments {
s := fs.Position(cg.Pos()).Offset
e := fs.Position(cg.End()).Offset
for i := s; i < e && i < len(out); i++ {
if out[i] != '\n' {
out[i] = ' '
}
}
}
return string(out)
}
// 模板必须是合法 Go 源码。
func TestProcTemplate_ParsesAsGo(t *testing.T) {
src := loadProcTemplate(t)
fs := token.NewFileSet()
if _, err := parser.ParseFile(fs, "proc_main.go", src, parser.AllErrors); err != nil {
t.Fatalf("模板不是合法 Go 源码: %v", err)
}
}
// 模板必须提供 main(),且不得含 cgo 痕迹。
//
// 零 cgo 是迁移的核心收益之一§3.7 锁仲裁回内核后整个架构无 cgo
// 一旦有人往模板里加 import "C",交叉编译立刻退回需要目标平台 C 工具链。
func TestProcTemplate_HasMainAndNoCgo(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "func main()") {
t.Error("子进程模板必须有 main() 入口")
}
// 只检查代码,不检查注释——模板顶部的说明文字本身就提到了 C.CString/C.free
code := stripComments(t, src)
for _, forbidden := range []string{
`import "C"`,
"//export ",
"C.CString",
"C.GoString",
"C.free",
} {
if strings.Contains(code, forbidden) {
t.Errorf("模板不应含 cgo 痕迹 %q零 cgo 是迁移的核心收益)", forbidden)
}
}
}
// 模板引用的 method 名必须与内核 internal/plugin/proc/protocol.go 一致。
//
// 这里硬编码一份清单做对照:内核侧改了 method 名而模板没跟上时,
// 表现是插件调用返回「未知 method」测试能提前拦住。
func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
src := loadProcTemplate(t)
// 51 个 C ABI method id 平移后的名字§3.2),加 stage 锁仲裁 2 个
required := []string{
// 注册面
"tool.register", "stage.register", "output.register", "api.register", "input.register",
// IO 注入
"io.injectText", "io.injectInterrupt", "io.injectTextNoMem", "io.injectInputSync",
"io.setToolBlocks",
// 生命周期
"lifecycle.autoRestart",
// 图记忆
"memory.recall", "memory.commit", "memory.introspect", "memory.merge", "memory.purge",
// 文档记忆
"doc.query", "doc.insert", "doc.remove", "doc.stats",
// 知识库
"knowledge.search", "knowledge.add", "knowledge.list",
// 文本记忆
"textmemory.append",
// 设置
"settings.get", "settings.set", "settings.registerDef",
"settings.getCore", "settings.setCore", "settings.listCore",
"settings.getPlugin", "settings.setPlugin", "settings.listPlugin",
"settings.list", "settings.defs", "settings.dump", "settings.plugins",
"settings.dataDir",
// LLM
"llm.listSources", "llm.setSource", "llm.currentSource",
// 社交图
"social.getPerson", "social.getNetwork", "social.getTrait",
"social.getRelations", "social.listPersons",
// 插件管理
"plugin.reloadOne", "plugin.listLoaded", "plugin.isDisabled",
// 共享段锁仲裁新增C ABI 下不存在此概念)
"stage.lock", "stage.unlock",
}
for _, m := range required {
if !strings.Contains(src, `"`+m+`"`) {
t.Errorf("模板缺少 core method %q内核已提供插件侧未接线", m)
}
}
}
// 模板必须处理内核发来的全部 7 个调用(原 C ABI 的 7 个 //export
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
src := loadProcTemplate(t)
for _, m := range []string{
"handshake",
"plugin.init", "plugin.start", "plugin.stop",
"tool.invoke", "stage.invoke", "output.invoke",
} {
if !strings.Contains(src, `case "`+m+`"`) {
t.Errorf("模板未处理内核调用 %q", m)
}
}
}
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
//
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
// 而两边都不报错(同为 []byte
func TestProcTemplate_ShmLayoutMatchesKernel(t *testing.T) {
src := loadProcTemplate(t)
// 与内核 shm.go 的 offXxx 常量对齐(值比较,不依赖 gofmt 的对齐空白)
layout := map[string]string{
"shmOffMagic": "0",
"shmOffVersion": "4",
"shmOffArenaBase": "8",
"shmOffArenaCap": "12",
"shmOffArenaUsed": "16",
"shmOffCtxBase": "20",
"shmOffSeq": "24",
// 与内核 stageFieldCount / sliceSize 对齐
"shmStageFieldCount": "18",
"shmSliceSize": "8",
"shmVersion": "1",
}
constRe := func(name, want string) bool {
// gofmt 会对齐常量块,故容许 name 与 = 之间有任意空白
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*` + regexp.QuoteMeta(want) + `\b`)
return re.MatchString(src)
}
for name, want := range layout {
if !constRe(name, want) {
t.Errorf("共享段常量 %s 应为 %s须与内核 internal/plugin/proc/shm.go 一致)", name, want)
}
}
// 字段枚举顺序:内核 stageField 的前若干项
fieldOrder := []string{
"fRawMessage = iota", "fUserID", "fGroupID", "fLLMText",
"fReasoningContent", "fFinalText", "fResponse", "fPhase",
"fContextMsgs", "fToolCalls", "fToolResults", "fMemory",
"fTokenUsage", "fErrors",
"fExtraMediaBlocks", "fExtraMediaType", "fExtraInputSource", "fExtraOutputChannel",
}
idx := -1
for _, f := range fieldOrder {
at := strings.Index(src, f)
if at < 0 {
t.Fatalf("模板缺少字段常量 %s", f)
}
if at <= idx {
t.Errorf("字段常量 %s 的声明顺序与内核 stageField 枚举不一致", f)
}
idx = at
}
}
// stage 处理必须「拿锁 → 读 → handler → 只写脏字段 → 放锁」。
//
// 只写脏字段是消除 lost update 的核心:只读插件零写入,
// 不可能覆盖其他插件的改写(对照 C ABI 副本模型实测 35.8~36.8% 丢失)。
func TestProcTemplate_StageFlowUsesLockAndDirtyWrite(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{
"func handleStageInvoke(",
"stage.lock",
"readStageContext()",
"takeStageSnapshot(",
"writeStageDirty(",
"stage.unlock",
} {
if !strings.Contains(src, want) {
t.Errorf("stage 处理链路缺少 %q", want)
}
}
// 顺序检查:加锁必须在读取之前,写回必须在解锁之前
iLock := strings.Index(src, `callCoreVoid("stage.lock"`)
iRead := strings.Index(src, "readStageContext()")
iWrite := strings.Index(src, "writeStageDirty(sc, snap)")
if iLock < 0 || iRead < 0 || iWrite < 0 {
t.Fatal("stage 链路关键调用缺失")
}
// readStageContext 的定义在前,调用在后;取 handleStageInvoke 内的位置
stageFn := src[strings.Index(src, "func handleStageInvoke("):]
iLockFn := strings.Index(stageFn, `callCoreVoid("stage.lock"`)
iReadFn := strings.Index(stageFn, "readStageContext()")
iWriteFn := strings.Index(stageFn, "writeStageDirty(sc, snap)")
if !(iLockFn < iReadFn && iReadFn < iWriteFn) {
t.Error("stage 链路顺序应为 加锁 → 读取 → 写回")
}
}
// 快照必须存序列化字符串而非 Go 值。
//
// ❗ 这是修 C ABI 侧 11.3 时踩过的坑StageContext 的切片字段与读出的值
// 共享底层内容handler 原地改元素sc.ToolResults[0].Result = x
// 直接持有 Go 值的快照会跟着变,脏字段计算失效、修复静默失效。
func TestProcTemplate_SnapshotStoresSerializedStrings(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "strs map[int]string") ||
!strings.Contains(src, "jsons map[int]string") {
t.Error("stageSnapshot 必须存序列化字符串(切片共享底层数组,存 Go 值会让脏字段计算失效)")
}
if !strings.Contains(src, "json.Marshal(v)") {
t.Error("takeStageSnapshot 应对容器字段做 json.Marshal")
}
}
// arena 用尽必须显式报错不得静默截断§4.4 风险登记)。
func TestProcTemplate_ArenaExhaustionErrors(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "arena 空间不足") {
t.Error("shmWrite 在 arena 不足时必须报错,不得静默截断")
}
}
// 日志必须走 stderrstdout 是 RPC 通道,写日志会破坏 NDJSON 帧。
func TestProcTemplate_LogsToStderr(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "log.SetOutput(os.Stderr)") {
t.Error("日志必须走 stderr否则会破坏 stdout 的 RPC 帧")
}
}
// 请求必须在独立 goroutine 里处理。
//
// handler 内会反向调用内核并等应答;若在读循环里同步处理,
// 就没人读应答帧 → 死锁。
func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "go handleKernelRequest(&req)") {
t.Error("请求须在独立 goroutine 处理handler 内反向调用内核,同步处理会死锁)")
}
}
// 协议与共享段版本不匹配必须拒绝,不得半兼容运行。
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "共享段魔数不匹配"} {
if !strings.Contains(src, want) {
t.Errorf("握手应校验并拒绝 %q", want)
}
}
}
// 全平台统一产出 plugin.bin。
//
// 这是三套独立 ABI 实现(.so/.dylib/.dll收敛为单一 RPC 实现的直接后果:
// 进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名。
// §9.2 记录的「Windows DLL 路径只下发 3 字段、无写回」随之消失——
// Windows 走的是与 Linux 完全相同的 RPC 实现。
func TestResolveBuild_AllPlatformsProduceBin(t *testing.T) {
for _, target := range []string{
"linux/amd64", "linux/arm64",
"darwin/amd64", "darwin/arm64",
"windows/amd64",
"freebsd/amd64",
} {
cfg, errMsg := resolveBuild(target)
if cfg == nil {
t.Fatalf("resolveBuild(%q) 失败: %s", target, errMsg)
}
if cfg.entryFile != procEntryFile {
t.Errorf("%s: 产物应为 %s实际 %s", target, procEntryFile, cfg.entryFile)
}
}
}
// lua 目标仍走解释器路径entry 字段唯一仍在使用的用途)。
func TestResolveBuild_LuaIsSeparatePath(t *testing.T) {
for _, target := range []string{"lua", ""} {
cfg, kind := resolveBuild(target)
if cfg != nil {
t.Errorf("%q 应返回 nil cfgLua 不经 Go 编译)", target)
}
if kind != "lua" {
t.Errorf("%q 应识别为 lua实际 %q", target, kind)
}
}
}
// 不支持的平台明确报错,不静默产出错误产物。
func TestResolveBuild_UnsupportedOSErrors(t *testing.T) {
cfg, errMsg := resolveBuild("plan9/amd64")
if cfg != nil {
t.Error("不支持的平台应返回 nil cfg")
}
if !strings.Contains(errMsg, "unsupported") {
t.Errorf("应给出 unsupported 提示,实际 %q", errMsg)
}
}
// bundle 产物在 zip 内按平台加后缀(全平台同名 plugin.bin 会相互覆盖)。
func TestBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
seen := map[string]bool{}
for _, bt := range allBundleTargets {
if seen[bt.entry] {
t.Errorf("zip 条目名重复: %s会相互覆盖", bt.entry)
}
seen[bt.entry] = true
if !strings.HasPrefix(bt.entry, procEntryFile+".") {
t.Errorf("bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
}
}
if len(allBundleTargets) == 0 {
t.Error("bundle 目标表不应为空")
}
}
// C ABI 工具链残留必须彻底清除:不得再有 .so/.dylib/.dll 产物路径,
// 也不得再引用 c-shared 构建模式或 MinGW 探测。
func TestToolchain_NoCABIResiduals(t *testing.T) {
for _, f := range []string{"cmd_build.go", "templates.go", "cmd_init.go", "proc_runtime.go"} {
data, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读 %s: %v", f, err)
}
src := stripComments(t, string(data))
for _, forbidden := range []string{
"c-shared",
"CGO_ENABLED=1",
"detectWindowsCC",
"generateBridge",
"tmplLinuxBridge",
"tmplPluginInitC",
} {
if strings.Contains(src, forbidden) {
t.Errorf("%s 仍含 C ABI 残留 %q", f, forbidden)
}
}
}
}
// Go 插件的构建不再读 plg.json 的 entry 值。
//
// 这是「外部插件零改动」的关键17 个存量插件的 plg.json 都写着 "plugin.so"
// 若把 entry 当通道开关,迁移就得改 17 个文件。
func TestToolchain_IgnoresEntryForGoPlugins(t *testing.T) {
data, err := os.ReadFile("cmd_build.go")
if err != nil {
t.Fatalf("读 cmd_build.go: %v", err)
}
src := stripComments(t, string(data))
if strings.Contains(src, "isProcEntry") {
t.Error("isProcEntry 应已删除——Go 插件一律产出 plugin.bin不看 entry 值")
}
// entry 仅剩 Lua 判定这一处用途
if !strings.Contains(src, "luaEntryFile") {
t.Error("IsLua 应改用 luaEntryFile 常量")
}
}

View File

@ -0,0 +1,247 @@
package main
import (
"encoding/json"
"testing"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 本测试验证 tmplLinuxBridge 中 snapshotWritable + changedFieldsOnly 的语义plan.md 11.3)。
// 模板字符串本身无法直接单测,这里以同一份逻辑复刻,防止回归。
// ❗ 模板与本文件须同步修改。
//
// 关键陷阱第一版实现踩过stageContextWritable 返回的切片字段与 sc 共享底层数组,
// handler 原地改元素时"before 快照"会跟着变diff 看不到变更 → 修复静默失效。
// 故 before 必须是**序列化后的字符串快照**。
func writable(sc *sdk.StageContext) map[string]interface{} {
m := map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"group_id": sc.GroupID,
"phase": string(sc.Phase),
"llm_text": sc.LLMText,
"final_text": sc.FinalText,
"no_memory": sc.NoMemory,
}
if sc.Response != nil {
m["response"] = *sc.Response
}
if len(sc.ToolCalls) > 0 {
m["tool_calls"] = sc.ToolCalls
}
if len(sc.ToolResults) > 0 {
m["tool_results"] = sc.ToolResults
}
return m
}
// snapshot 对应模板里的 snapshotWritable逐字段序列化为不可变快照。
func snapshot(sc *sdk.StageContext) map[string]string {
snap := map[string]string{}
for k, v := range writable(sc) {
b, err := json.Marshal(v)
if err != nil {
continue
}
snap[k] = string(b)
}
return snap
}
// diffOnly 对应模板里的 changedFieldsOnly。
func diffOnly(before map[string]string, after map[string]interface{}) map[string]interface{} {
diff := map[string]interface{}{}
keys := map[string]bool{}
for k := range before {
keys[k] = true
}
for k := range after {
keys[k] = true
}
for k := range keys {
bRaw, bHas := before[k]
a, aHas := after[k]
switch {
case aHas && !bHas:
diff[k] = a
case aHas && bHas:
ab, _ := json.Marshal(a)
if bRaw != string(ab) {
diff[k] = a
}
case bHas && !aHas:
switch k {
case "tool_calls":
diff[k] = []sdk.ToolCall{}
case "tool_results":
diff[k] = []sdk.ToolResult{}
}
}
}
return diff
}
// 只读插件(如 weather 的 AfterToolcall不改任何字段 → 零回传。
// 这是修复 lost update 的关键:旧实现会回传它收到的旧快照,覆盖 sanitizer 的清洗结果。
func TestChangedFieldsOnly_ReadOnlyPluginReturnsNothing(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "hello",
LLMText: "world",
ToolResults: []sdk.ToolResult{
{CallID: "c1", Name: "weather_query", Success: true, Result: "已清洗结果"},
},
}
before := snapshot(sc)
// 只读 handler读了但没改
_ = sc.ToolResults[0].Result
diff := diffOnly(before, writable(sc))
if len(diff) != 0 {
t.Fatalf("只读插件应零回传,实际回传 %d 个字段: %v", len(diff), diff)
}
}
// 改写插件(如 sanitizer 改 ToolResults→ 只回传被改的字段。
// ⚠️ 这里是原地改切片元素,正是共享底层数组陷阱的触发场景。
func TestChangedFieldsOnly_WriterReturnsOnlyChanged(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "hello",
LLMText: "world",
ToolResults: []sdk.ToolResult{
{CallID: "c1", Name: "weather_query", Success: true, Result: "带\x1b[31mANSI\x1b[0m脏数据"},
},
}
before := snapshot(sc)
// sanitizer handler原地清洗 ToolResults
sc.ToolResults[0].Result = "带ANSI脏数据"
diff := diffOnly(before, writable(sc))
if len(diff) != 1 {
t.Fatalf("应只回传 tool_results 一个字段,实际 %d 个: %v", len(diff), diff)
}
if _, ok := diff["tool_results"]; !ok {
t.Fatalf("回传字段应为 tool_results实际 %v", diff)
}
// raw_message / llm_text 未改,不应出现(否则会覆盖其他插件的改写)
if _, ok := diff["raw_message"]; ok {
t.Error("raw_message 未改却被回传(会覆盖其他插件的改写)")
}
if _, ok := diff["llm_text"]; ok {
t.Error("llm_text 未改却被回传")
}
}
// 改写标量字段(如 before_output 改 FinalText→ 只回传该字段。
func TestChangedFieldsOnly_ScalarChange(t *testing.T) {
sc := &sdk.StageContext{
RawMessage: "hi",
FinalText: " 带空白的回复 ",
LLMText: "原始",
}
before := snapshot(sc)
sc.FinalText = "带空白的回复"
diff := diffOnly(before, writable(sc))
if len(diff) != 1 || diff["final_text"] != "带空白的回复" {
t.Fatalf("应只回传 final_text实际 %v", diff)
}
}
// 首次设置 response短路→ 回传。
func TestChangedFieldsOnly_NewResponseIsReturned(t *testing.T) {
sc := &sdk.StageContext{RawMessage: "hi"}
before := snapshot(sc)
resp := "被插件短路"
sc.Response = &resp
diff := diffOnly(before, writable(sc))
if v, ok := diff["response"]; !ok || v != "被插件短路" {
t.Fatalf("新设置的 response 应回传,实际 %v", diff)
}
}
// 清空切片字段 → 显式回传空值让内核跟随。
func TestChangedFieldsOnly_ClearedSliceIsReturnedAsEmpty(t *testing.T) {
sc := &sdk.StageContext{
ToolCalls: []sdk.ToolCall{{ID: "t1", Name: "cmd_run"}},
}
before := snapshot(sc)
sc.ToolCalls = nil // 插件拒绝了全部工具调用
diff := diffOnly(before, writable(sc))
v, ok := diff["tool_calls"]
if !ok {
t.Fatalf("清空 tool_calls 应显式回传空值,实际 %v", diff)
}
if arr, _ := v.([]sdk.ToolCall); len(arr) != 0 {
t.Fatalf("应回传空切片,实际 %v", v)
}
}
// 复刻现网场景(实验 13sanitizer 清洗后 weather 只读回传,清洗结果不得被覆盖。
// 旧实现下 weather 会回传自己收到的旧快照(含脏数据),覆盖 sanitizer 的清洗(丢失率 1.6~4.3%)。
func TestChangedFieldsOnly_ProductionScenarioNoOverwrite(t *testing.T) {
dirty := "天气:晴 \x1b[31m28°C\x1b[0m"
clean := "天气:晴 28°C"
// 内核下发的原始快照(两插件各拿到一份副本)
kernelSnapshot := map[string]interface{}{
"raw_message": "查天气",
"llm_text": "",
"final_text": "",
"user_id": "u1",
"group_id": "",
"phase": "after_toolcall",
"no_memory": false,
"tool_results": []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
// sanitizer 副本:清洗
scSan := &sdk.StageContext{
RawMessage: "查天气",
UserID: "u1",
Phase: sdk.StageAfterToolcall,
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
beforeSan := snapshot(scSan)
scSan.ToolResults[0].Result = clean
diffSan := diffOnly(beforeSan, writable(scSan))
// weather 副本:只读,不改
scWea := &sdk.StageContext{
RawMessage: "查天气",
UserID: "u1",
Phase: sdk.StageAfterToolcall,
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
}
beforeWea := snapshot(scWea)
diffWea := diffOnly(beforeWea, writable(scWea))
// weather 必须零回传,否则它的旧快照会覆盖 sanitizer 的清洗
if len(diffWea) != 0 {
t.Fatalf("weather 只读却回传 %v —— 会覆盖 sanitizer 清洗结果", diffWea)
}
// sanitizer 必须回传 tool_results
if _, ok := diffSan["tool_results"]; !ok {
t.Fatalf("sanitizer 改写了 tool_results 却未回传:%v", diffSan)
}
// 内核按 sanitizer → weather 顺序应用 diffweather 后到,是最坏情形)
kernel := map[string]interface{}{}
for k, v := range kernelSnapshot {
kernel[k] = v
}
for k, v := range diffSan {
kernel[k] = v
}
for k, v := range diffWea {
kernel[k] = v
}
res, _ := kernel["tool_results"].([]sdk.ToolResult)
if len(res) == 0 || res[0].Result != clean {
t.Fatalf("清洗结果被覆盖:期望 %q实际 %v", clean, kernel["tool_results"])
}
}

View File

@ -19,7 +19,12 @@ const tmplGoMod = `module {{.ModulePath}}
go {{.GoVersion}}
require {{.SDKModule}} {{.SDKVersion}}
`
{{if .SDKLocalPath}}
// SDK 指向本机源码。gitcode 的模块不在 proxy.golang.org 上,
// 没有这条 replace 就需要 go.sum 条目,而那个条目无处可拉。
// 若你已有可访问的私有 proxy可删掉本行。
replace {{.SDKModule}} => {{.SDKLocalPath}}
{{end}}`
const tmplPluginGo = `package main
@ -152,718 +157,6 @@ function plugin.stop() sdk.log("info", "{{.Plg.Name}} stopped") end
return plugin
`
// tmplBridge — Windows DLL C ABI bridge (unchanged)
const tmplBridge = `//go:build windows && cgo
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"encoding/json"
"sync"
"unsafe"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
var (
mu sync.Mutex
handleMap = map[unsafe.Pointer]*bridgeState{}
)
type bridgeState struct {
plugin sdk.Plugin
toolDefs map[string]sdk.ToolDef
handlers map[string]sdk.ToolHandler
stages map[string]sdk.StageHandler
settings map[string]interface{}
sdk *sdk.PluginSDK
}
func newHandle(plg sdk.Plugin) unsafe.Pointer {
mu.Lock(); defer mu.Unlock()
h := C.malloc(C.size_t(1))
handleMap[h] = &bridgeState{
plugin: plg, toolDefs: make(map[string]sdk.ToolDef),
handlers: make(map[string]sdk.ToolHandler), stages: make(map[string]sdk.StageHandler),
settings: make(map[string]interface{}),
}
return h
}
func getState(h unsafe.Pointer) *bridgeState { mu.Lock(); defer mu.Unlock(); return handleMap[h] }
func delState(h unsafe.Pointer) { mu.Lock(); defer mu.Unlock(); delete(handleMap, h); C.free(h) }
//export NewPlugin
func NewPlugin(name *C.char, configJSON *C.char) unsafe.Pointer {
goName := C.GoString(name)
var config map[string]interface{}
if configJSON != nil {
var wrapper map[string]interface{}
if err := json.Unmarshal([]byte(C.GoString(configJSON)), &wrapper); err == nil {
if c, ok := wrapper["config"].(map[string]interface{}); ok { config = c }
}
}
plg, err := NewPluginFactory(goName, config)
if err != nil { return nil }
return newHandle(plg)
}
//export StartPlugin
func StartPlugin(handle unsafe.Pointer) C.int {
bs := getState(handle)
if bs == nil { return 1 }
mockSett := &bridgeSettings{data: bs.settings}
mockSDK := sdk.New(bs.plugin.Name(), mockSett,
func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
bs.toolDefs[name] = def; bs.handlers[name] = handler; return nil
},
func(stage sdk.Stage, handler sdk.StageHandler) { bs.stages[string(stage)] = handler },
func(name string) error { return nil },
func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error { return nil },
)
mockSDK.SetInputChannelRegistrar(func(name string, def sdk.ChannelDef) error { return nil })
bs.sdk = mockSDK
if err := bs.plugin.Start(mockSDK); err != nil { return 1 }
return 0
}
//export StopPlugin
func StopPlugin(handle unsafe.Pointer) C.int {
bs := getState(handle)
if bs == nil { return 1 }
if bs.sdk != nil {
bs.sdk.RunStopHandlers()
}
if err := bs.plugin.Stop(); err != nil { return 1 }
return 0
}
//export DestroyPlugin
func DestroyPlugin(handle unsafe.Pointer) {
if bs := getState(handle); bs != nil { delState(handle) }
}
//export GetToolDefsJSON
func GetToolDefsJSON(handle unsafe.Pointer) *C.char {
bs := getState(handle)
if bs == nil { return nil }
defs := make([]sdk.ToolDef, 0, len(bs.toolDefs))
for _, def := range bs.toolDefs { defs = append(defs, def) }
b, _ := json.Marshal(defs)
return C.CString(string(b))
}
//export InvokeToolJSON
func InvokeToolJSON(handle unsafe.Pointer, toolName *C.char, argsJSON *C.char) *C.char {
bs := getState(handle)
if bs == nil || toolName == nil { return nil }
goName := C.GoString(toolName)
handler, ok := bs.handlers[goName]
if !ok { errMsg, _ := json.Marshal(map[string]interface{}{"error": "tool not found: " + goName}); return C.CString(string(errMsg)) }
var args map[string]interface{}
if argsJSON != nil { json.Unmarshal([]byte(C.GoString(argsJSON)), &args) }
r, err := handler(args)
if err != nil { errMsg, _ := json.Marshal(map[string]interface{}{"error": err.Error()}); return C.CString(string(errMsg)) }
b, _ := json.Marshal(r)
return C.CString(string(b))
}
//export GetStagesJSON
func GetStagesJSON(handle unsafe.Pointer) *C.char {
bs := getState(handle)
if bs == nil { return nil }
type se struct { Stage string ` + "`" + `json:"stage"` + "`" + ` }
var entries []se
for s := range bs.stages { entries = append(entries, se{s}) }
b, _ := json.Marshal(entries)
return C.CString(string(b))
}
//export InvokeStage
func InvokeStage(handle unsafe.Pointer, stage *C.char, contextJSON *C.char) C.int {
bs := getState(handle)
if bs == nil || stage == nil { return 1 }
goStage := C.GoString(stage)
handler, ok := bs.stages[goStage]
if !ok { return 1 }
var ctx map[string]interface{}
if contextJSON != nil { json.Unmarshal([]byte(C.GoString(contextJSON)), &ctx) }
sc := &sdk.StageContext{}
if ctx != nil {
if v, ok := ctx["raw_message"].(string); ok { sc.RawMessage = v }
if v, ok := ctx["user_id"].(string); ok { sc.UserID = v }
if v, ok := ctx["phase"].(string); ok { sc.Phase = sdk.Stage(v) }
}
if err := handler(sc); err != nil { return 1 }
return 0
}
//export FreeCString
func FreeCString(s *C.char) { C.free(unsafe.Pointer(s)) }
type bridgeSettings struct{ data map[string]interface{} }
func (s *bridgeSettings) Get(key string) (interface{}, error) { v, ok := s.data[key]; if !ok { return nil, nil }; return v, nil }
func (s *bridgeSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil }
func (s *bridgeSettings) List(prefix string) ([]string, error) {
var keys []string
for k := range s.data { if len(k) >= len(prefix) && k[:len(prefix)] == prefix { keys = append(keys, k) } }
return keys, nil
}
func (s *bridgeSettings) GetCore(key string) (interface{}, error) { return nil, nil }
func (s *bridgeSettings) SetCore(key string, value interface{}) error { return nil }
func (s *bridgeSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
func (s *bridgeSettings) GetPlugin(plugin, key string) (interface{}, error) { return nil, nil }
func (s *bridgeSettings) SetPlugin(plugin, key string, value interface{}) error { return nil }
func (s *bridgeSettings) ListPlugin(plugin, prefix string) ([]string, error) { return nil, nil }
func (s *bridgeSettings) RegisterDef(def sdk.ConfigDef) {}
func (s *bridgeSettings) Defs(prefix string) []*sdk.ConfigDef { return nil }
func (s *bridgeSettings) Dump() map[string]interface{} { return s.data }
func (s *bridgeSettings) Plugins() []string { return nil }
func main() {}
`
// tmplCABIHeader — shared C ABI type definitions for both core and plugin
// 此模板中的常量应与 core/internal/meta/meta.go 保持一致ABI 版本、dispatch method IDs
const tmplCABIHeader = `
#ifndef HOMEAGENT_CABI_H
#define HOMEAGENT_CABI_H
// HOMEAGENT_ABI_VERSION 与 sdk/meta/meta.go CABINum 同步major*100+minorv0.9.x→900
#define HOMEAGENT_ABI_VERSION 900
#ifdef __cplusplus
extern "C" {
#endif
// PluginAPI — implemented by the plugin, called by the core
typedef struct {
int version; int version_min;
int (*init_plugin)(char*, char*, char**);
int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**);
int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*);
} PluginAPI;
// CoreAPI — implemented by the core, passed to plugin via start_plugin
// Uses single dispatch function to avoid function pointer ABI issues
typedef struct {
int version; int version_min;
int (*dispatch)(int method_id, void* ctx, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
void* ctx;
} CoreAPI;
// Dispatch method IDs (plugin→core SDK calls)
enum {
CORE_REGISTER_TOOL = 1,
CORE_REGISTER_STAGE = 2,
CORE_REGISTER_OUTPUT_CH = 3,
CORE_REGISTER_PLUGIN_API = 4,
CORE_INJECT_TEXT = 5,
CORE_INJECT_INTERRUPT_TEXT = 6,
CORE_INJECT_TEXT_NO_MEMORY = 7,
CORE_INJECT_INPUT_SYNC = 47,
CORE_SET_AUTO_RESTART = 8,
CORE_MEMORY_RECALL = 9,
CORE_MEMORY_COMMIT = 10,
CORE_MEMORY_INTROSPECT = 11,
CORE_MEMORY_MERGE = 12,
CORE_MEMORY_PURGE = 13,
CORE_DOC_QUERY = 14,
CORE_KNOWLEDGE_SEARCH = 15,
CORE_SETTINGS_GET = 16,
CORE_SETTINGS_SET = 17,
CORE_SETTINGS_REGISTER_DEF = 18,
CORE_LLM_LIST_SOURCES = 19,
CORE_LLM_SET_SOURCE = 20,
CORE_SOCIAL_GET_PERSON = 21,
CORE_SOCIAL_GET_NETWORK = 22,
CORE_SUBSCRIBE = 23,
CORE_UNSUBSCRIBE = 24,
CORE_FREE_STRING = 25,
CORE_SETTINGS_GET_CORE = 26,
CORE_SETTINGS_SET_CORE = 27,
CORE_SETTINGS_LIST_CORE = 28,
CORE_SETTINGS_GET_PLUGIN = 29,
CORE_SETTINGS_SET_PLUGIN = 30,
CORE_SETTINGS_LIST_PLUGIN = 31,
CORE_DOC_INSERT = 32,
CORE_DOC_REMOVE = 33,
CORE_DOC_STATS = 34,
CORE_KNOWLEDGE_ADD = 35,
CORE_KNOWLEDGE_LIST = 36,
CORE_LLM_CURRENT_SOURCE = 37,
CORE_SOCIAL_GET_TRAIT = 38,
CORE_SOCIAL_GET_RELATIONS = 39,
CORE_SOCIAL_LIST_PERSONS = 40,
CORE_TEXT_MEMORY_APPEND = 41,
CORE_SETTINGS_LIST = 42,
CORE_SETTINGS_DEFS = 43,
CORE_SETTINGS_DUMP = 44,
CORE_SETTINGS_PLUGINS = 45,
CORE_REGISTER_INPUT_CH = 46,
CORE_INJECT_INPUT_SYNC = 47,
CORE_PLUGIN_RELOAD_ONE = 48,
CORE_PLUGIN_LIST_LOADED = 49,
CORE_PLUGIN_IS_DISABLED = 50,
};
#ifdef __cplusplus
}
#endif
#endif
`
// tmplLinuxBridge — auto-generated Go bridge for Linux c-shared builds.
// Called by plugin's Start() with a PluginSDK that wraps CoreAPI dispatch.
// PluginSDK calls go through C ABI → CoreAPI dispatch → core's Go PluginSDK.
const tmplLinuxBridge = `package main
/*
#include <stdlib.h>
int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error);
*/
import "C"
import (
"encoding/json"
"fmt"
"sync"
"unsafe"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// ---- global state ----
var (
mu sync.Mutex
currentPlg sdk.Plugin
currentSDK *sdk.PluginSDK
coreAPI unsafe.Pointer
handlerMu sync.RWMutex
coreAPIMu sync.RWMutex
toolHandlers = map[string]sdk.ToolHandler{}
stageHandlers = map[string]sdk.StageHandler{}
outputHandlers = map[string]sdk.ToolHandler{}
)
// ---- CoreAPI dispatch helpers ----
func callVoid(methodID int, s1, s2, s3 string, i1, i2 int) error {
coreAPIMu.RLock()
api := coreAPI
coreAPIMu.RUnlock()
var c1, c2, c3 *C.char
if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) }
if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) }
if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) }
var cErr *C.char
if C.ha_dispatch(C.int(methodID), api, c1, c2, c3, C.int(i1), C.int(i2), nil, &cErr) != 0 && cErr != nil {
return fmt.Errorf("%s", C.GoString(cErr))
}
return nil
}
func callString(methodID int, s1, s2, s3 string, i1, i2 int) (string, error) {
coreAPIMu.RLock()
api := coreAPI
coreAPIMu.RUnlock()
var c1, c2, c3 *C.char
if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) }
if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) }
if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) }
var strResult, cErr *C.char
if C.ha_dispatch(C.int(methodID), api, c1, c2, c3, C.int(i1), C.int(i2), &strResult, &cErr) != 0 && cErr != nil {
return "", fmt.Errorf("%s", C.GoString(cErr))
}
if strResult != nil {
result := C.GoString(strResult)
C.ha_dispatch(C.int(25), api, strResult, nil, nil, 0, 0, nil, nil)
return result, nil
}
return "", nil
}
// ---- buildPluginSDK: PluginSDK backed by CoreAPI dispatch ----
// - ALL SDK methods route through C ABI → CoreAPI → core's PluginSDK
// - Handlers for tools/stages/output are stored locally AND registered via dispatch
func buildPluginSDK(name string) *sdk.PluginSDK {
sett := &dispatchSettings{}
base := sdk.New(name, sett,
func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error {
handlerMu.Lock()
toolHandlers[toolName] = handler
handlerMu.Unlock()
b, _ := json.Marshal(def)
return callVoid(1, toolName, string(b), "", 0, 0)
},
func(stage sdk.Stage, handler sdk.StageHandler) {
handlerMu.Lock()
stageHandlers[string(stage)] = handler
handlerMu.Unlock()
callVoid(2, string(stage), "", "", 0, 0)
},
func(name string) error { return callVoid(4, name, "", "", 0, 0) },
func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
handlerMu.Lock()
outputHandlers[name] = handler
handlerMu.Unlock()
defJSON, _ := json.Marshal(def)
return callVoid(3, name, desc, string(defJSON), caps, 0)
},
)
base.SetIOInjector(dispatchIO{})
base.SetMemoryAPI(dispatchMemory{})
base.SetDocMemoryAPI(dispatchDocMemory{})
base.SetKnowledgeAPI(dispatchKnowledge{})
base.SetLLMAPI(dispatchLLM{})
base.SetSocialAPI(dispatchSocial{})
base.SetTextMemoryAPI(dispatchTextMemory{})
base.SetPluginMgrAPI(dispatchPluginMgr{})
base.SetInputChannelRegistrar(
func(name string, def sdk.ChannelDef) error {
defJSON, _ := json.Marshal(def)
return callVoid(46, name, string(defJSON), "", 0, 0)
},
)
return base
}
// ---- dispatch IO (inline definitions) ----
type dispatchIO struct{}
func (dispatchIO) InjectInterruptText(s, c, t string) { callVoid(6, s, c, t, 0, 0) }
func (dispatchIO) InjectText(s, c, t string) { callVoid(5, s, c, t, 0, 0) }
func (dispatchIO) InjectTextNoMemory(s, c, t string) { callVoid(7, s, c, t, 0, 0) }
func (dispatchIO) InjectInputSync(s, c, t string) string { r, _ := callString(47, s, c, t, 0, 0); return r }
type dispatchMemory struct{}
func (dispatchMemory) Recall(q []string, d int) ([]sdk.Entity, []sdk.Relation, error) {
b, _ := json.Marshal(q); r, e := callString(9, string(b), "", "", d, 0)
if e != nil || r == "" { return nil, nil, e }
var v struct{ Entities []sdk.Entity; Relations []sdk.Relation }
if e = json.Unmarshal([]byte(r), &v); e != nil { return nil, nil, e }
if v.Entities == nil { v.Entities = []sdk.Entity{} }
if v.Relations == nil { v.Relations = []sdk.Relation{} }
return v.Entities, v.Relations, nil
}
func (dispatchMemory) Commit(t []sdk.Triple) error { b, _ := json.Marshal(t); return callVoid(10, string(b), "", "", 0, 0) }
func (dispatchMemory) Introspect() (map[string]interface{}, error) { r, e := callString(11, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var m map[string]interface{}; return m, json.Unmarshal([]byte(r), &m) }
func (dispatchMemory) MergeEntities(s, t string) (int, error) { return 1, callVoid(12, s, t, "", 0, 0) }
func (dispatchMemory) Purge(c map[string]string, m string) (int, error) { b, _ := json.Marshal(c); i := 0; if m == "hard" { i = 1 }; return 1, callVoid(13, string(b), "", "", i, 0) }
type dispatchDocMemory struct{}
func (dispatchDocMemory) Query(t string, k int) []*sdk.Doc { r, e := callString(14, t, "", "", k, 0); if e != nil || r == "" { return nil }; var d []*sdk.Doc; json.Unmarshal([]byte(r), &d); return d }
func (dispatchDocMemory) Insert(doc *sdk.Doc) error { b, _ := json.Marshal(doc); return callVoid(32, string(b), "", "", 0, 0) }
func (dispatchDocMemory) Remove(id string) { callVoid(33, id, "", "", 0, 0) }
func (dispatchDocMemory) Stats() map[string]interface{} { r, e := callString(34, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); return m }
type dispatchKnowledge struct{}
func (dispatchKnowledge) Search(q string, k int) ([]*sdk.Knowledge, error) { r, e := callString(15, q, "", "", k, 0); if e != nil || r == "" { return nil, e }; var v []*sdk.Knowledge; return v, json.Unmarshal([]byte(r), &v) }
func (dispatchKnowledge) Add(n, c string) error { return callVoid(35, n, c, "", 0, 0) }
func (dispatchKnowledge) List() ([]string, error) { r, e := callString(36, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) }
type dispatchLLM struct{}
func (dispatchLLM) ListSources() []string { r, e := callString(19, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v }
func (dispatchLLM) SetSource(n string) error { return callVoid(20, n, "", "", 0, 0) }
func (dispatchLLM) CurrentSource() string { r, e := callString(37, "", "", "", 0, 0); if e != nil || r == "" { return "" }; return r }
type dispatchSocial struct{}
func (dispatchSocial) GetPerson(n string) (*sdk.PersonProfile, error) { r, e := callString(21, n, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v sdk.PersonProfile; return &v, json.Unmarshal([]byte(r), &v) }
func (dispatchSocial) GetTrait(n, t string) (string, bool) { r, e := callString(38, n, t, "", 0, 0); if e != nil || r == "" { return "", false }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); v, _ := m["value"].(string); ok, _ := m["found"].(bool); return v, ok }
func (dispatchSocial) GetRelations(name string) ([]sdk.SocialRelation, error) { r, e := callString(39, name, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []sdk.SocialRelation; return v, json.Unmarshal([]byte(r), &v) }
func (dispatchSocial) GetNetwork(n string, d int) ([]*sdk.PersonProfile, error) { r, e := callString(22, n, "", "", d, 0); if e != nil || r == "" { return nil, e }; var v []*sdk.PersonProfile; return v, json.Unmarshal([]byte(r), &v) }
func (dispatchSocial) ListPersons() ([]string, error) { r, e := callString(40, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) }
type dispatchTextMemory struct{}
func (dispatchTextMemory) Append(evt sdk.TextEvent) error { b, _ := json.Marshal(evt); return callVoid(41, string(b), "", "", 0, 0) }
// ---- dispatchPluginMgr (CORE_PLUGIN_RELOAD_ONE = 48) ----
type dispatchPluginMgr struct{}
func (dispatchPluginMgr) ReloadOne(name string) error {
return callVoid(48, name, "", "", 0, 0)
}
func (dispatchPluginMgr) ListLoadedPlugins() []string {
r, e := callString(49, "", "", "", 0, 0)
if e != nil || r == "" {
return nil
}
var list []string
if json.Unmarshal([]byte(r), &list) != nil {
return nil
}
return list
}
func (dispatchPluginMgr) IsPluginDisabled(name string) bool {
r, e := callString(50, name, "", "", 0, 0)
return e == nil && r == "1"
}
// ---- dispatchSettings (inline) ----
type dispatchSettings struct{}
func (d *dispatchSettings) Get(key string) (interface{}, error) {
r, e := callString(16, key, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v)
}
func (d *dispatchSettings) Set(key string, value interface{}) error {
b, _ := json.Marshal(value); return callVoid(17, key, string(b), "", 0, 0)
}
func (d *dispatchSettings) RegisterDef(def sdk.ConfigDef) { b, _ := json.Marshal(def); callVoid(18, string(b), "", "", 0, 0) }
func (d *dispatchSettings) List(prefix string) ([]string, error) {
r, e := callString(42, prefix, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v)
}
func (d *dispatchSettings) GetCore(key string) (interface{}, error) {
r, e := callString(26, key, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v)
}
func (d *dispatchSettings) SetCore(key string, value interface{}) error {
b, _ := json.Marshal(value); return callVoid(27, key, string(b), "", 0, 0)
}
func (d *dispatchSettings) ListCore(prefix string) ([]string, error) {
r, e := callString(28, prefix, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v)
}
func (d *dispatchSettings) GetPlugin(plugin, key string) (interface{}, error) {
r, e := callString(29, plugin, key, "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v)
}
func (d *dispatchSettings) SetPlugin(plugin, key string, value interface{}) error {
b, _ := json.Marshal(value); return callVoid(30, plugin, key, string(b), 0, 0)
}
func (d *dispatchSettings) ListPlugin(plugin, prefix string) ([]string, error) {
r, e := callString(31, plugin, prefix, "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v)
}
func (d *dispatchSettings) Defs(prefix string) []*sdk.ConfigDef {
r, e := callString(43, prefix, "", "", 0, 0); if e != nil || r == "" { return nil }; var v []*sdk.ConfigDef; json.Unmarshal([]byte(r), &v); return v
}
func (d *dispatchSettings) Dump() map[string]interface{} {
r, e := callString(44, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); return m
}
func (d *dispatchSettings) Plugins() []string {
r, e := callString(45, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v
}
func (d *dispatchSettings) DataDir() string {
r, e := callString(51, "", "", "", 0, 0); if e != nil { return "" }; return r
}
// ---- Go callbacks (called from z_entry.c via C) ----
//export go_init_plugin
func go_init_plugin(name *C.char, configJSON *C.char, errorOut **C.char) C.int {
plg, err := NewPluginFactory(C.GoString(name), nil)
if err != nil || plg == nil {
if err != nil { *errorOut = C.CString(err.Error()) } else { *errorOut = C.CString("NewPluginFactory returned nil") }
return 1
}
mu.Lock(); currentPlg = plg; mu.Unlock()
_ = configJSON
return 0
}
//export go_start_plugin
func go_start_plugin(coreAPIptr unsafe.Pointer, coreVersion C.int, errorOut **C.char) C.int {
mu.Lock()
plg := currentPlg
coreAPIMu.Lock()
coreAPI = coreAPIptr
coreAPIMu.Unlock()
mu.Unlock()
_ = coreVersion
if plg == nil { *errorOut = C.CString("not initialized"); return 1 }
sdk := buildPluginSDK(plg.Name())
mu.Lock(); currentSDK = sdk; mu.Unlock()
if err := plg.Start(sdk); err != nil { *errorOut = C.CString(err.Error()); return 1 }
return 0
}
//export go_stop_plugin
func go_stop_plugin(errorOut **C.char) C.int {
mu.Lock()
plg := currentPlg
sdk := currentSDK
currentPlg = nil
currentSDK = nil
coreAPIMu.Lock()
coreAPI = nil
coreAPIMu.Unlock()
mu.Unlock()
if sdk != nil {
sdk.RunStopHandlers()
}
if plg != nil {
if err := plg.Stop(); err != nil { *errorOut = C.CString(err.Error()); return 1 }
}
return 0
}
//export go_invoke_tool
func go_invoke_tool(name *C.char, argsJSON *C.char, resultOut **C.char, errorOut **C.char) C.int {
goName := C.GoString(name)
handlerMu.RLock()
h, ok := toolHandlers[goName]
handlerMu.RUnlock()
if !ok { *errorOut = C.CString("tool not found"); return 1 }
var args map[string]interface{}
if argsJSON != nil { json.Unmarshal([]byte(C.GoString(argsJSON)), &args) }
r, err := h(args)
if err != nil { *errorOut = C.CString(err.Error()); return 1 }
b, _ := json.Marshal(r)
*resultOut = C.CString(string(b))
return 0
}
// fillStageContext 将内核传来的 ctx JSON 填充到插件侧 StageContext。
func fillStageContext(sc *sdk.StageContext, ctxJSON string) {
var m map[string]interface{}
if err := json.Unmarshal([]byte(ctxJSON), &m); err != nil {
return
}
if v, _ := m["raw_message"].(string); v != "" { sc.RawMessage = v }
if v, _ := m["user_id"].(string); v != "" { sc.UserID = v }
if v, _ := m["group_id"].(string); v != "" { sc.GroupID = v }
if v, _ := m["phase"].(string); v != "" { sc.Phase = sdk.Stage(v) }
if v, _ := m["llm_text"].(string); v != "" { sc.LLMText = v }
if v, _ := m["final_text"].(string); v != "" { sc.FinalText = v }
if v, _ := m["no_memory"].(bool); v { sc.NoMemory = true }
if v, _ := m["response"].(string); v != "" { sc.Response = &v }
if v, _ := m["tool_calls"].([]interface{}); len(v) > 0 {
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolCalls)
}
if v, _ := m["tool_results"].([]interface{}); len(v) > 0 {
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolResults)
}
}
// stageContextWritable 提取插件可写且内核会同步回去的字段。
func stageContextWritable(sc *sdk.StageContext) map[string]interface{} {
m := map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"group_id": sc.GroupID,
"phase": string(sc.Phase),
"llm_text": sc.LLMText,
"final_text": sc.FinalText,
"no_memory": sc.NoMemory,
}
if sc.Response != nil {
m["response"] = *sc.Response
}
if len(sc.ToolCalls) > 0 {
m["tool_calls"] = sc.ToolCalls
}
if len(sc.ToolResults) > 0 {
m["tool_results"] = sc.ToolResults
}
return m
}
//export go_invoke_stage
func go_invoke_stage(stage *C.char, ctxJSON *C.char, resultOut **C.char, errorOut **C.char) C.int {
goStage := C.GoString(stage)
handlerMu.RLock()
h, ok := stageHandlers[goStage]
handlerMu.RUnlock()
if !ok { return 0 }
sc := &sdk.StageContext{}
if ctxJSON != nil {
fillStageContext(sc, C.GoString(ctxJSON))
}
if err := h(sc); err != nil { *errorOut = C.CString(err.Error()); return 1 }
// ABI v2: 回传插件修改后的上下文(若调用方要求)
if resultOut != nil {
if b, err := json.Marshal(stageContextWritable(sc)); err == nil {
*resultOut = C.CString(string(b))
}
}
return 0
}
//export go_invoke_output
func go_invoke_output(channel *C.char, msgType *C.char, payloadJSON *C.char, errorOut **C.char) C.int {
goChan := C.GoString(channel)
handlerMu.RLock()
h, ok := outputHandlers[goChan]
handlerMu.RUnlock()
if !ok { return 0 }
// payloadJSON contains the full args JSON from output_send (e.g. {"content":"...","user_id":123})
var args map[string]interface{}
if payloadJSON != nil {
json.Unmarshal([]byte(C.GoString(payloadJSON)), &args)
}
if _, err := h(args); err != nil { *errorOut = C.CString(err.Error()); return 1 }
return 0
}
//export go_free_string
func go_free_string(ptr *C.char) { C.free(unsafe.Pointer(ptr)) }
func main() {}
`
// tmplPluginInitC — C entry point for the plugin .so file.
// Contains PluginAPI, CoreAPI (single dispatch), and ha_dispatch bridge.
const tmplPluginInitC = `#include <stdlib.h>
#include <string.h>
// HOMEAGENT_ABI_VERSION 与 sdk/meta/meta.go CABINum 同步major*100+minorv0.9.x→900
#define HOMEAGENT_ABI_VERSION 900
typedef struct {
int version; int version_min;
int (*init_plugin)(char*, char*, char**);
int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**);
int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*);
} PluginAPI;
typedef struct {
int version; int version_min;
int (*dispatch)(int, void*, char*, char*, char*, int, int, char**, char**);
void* ctx;
} CoreAPI;
extern int go_init_plugin(char*, char*, char**);
extern int go_start_plugin(void*, int, char**);
extern int go_stop_plugin(char**);
extern int go_invoke_tool(char*, char*, char**, char**);
extern int go_invoke_stage(char*, char*, char**, char**);
extern int go_invoke_output(char*, char*, char*, char**);
extern void go_free_string(char*);
int c_init_plugin(char* n, char* c, char** e) { return go_init_plugin(n, c, e); }
int c_start_plugin(void* a, int v, char** e) { return go_start_plugin(a, v, e); }
int c_stop_plugin(char** e) { return go_stop_plugin(e); }
int c_invoke_tool(char* n, char* a, char** r, char** e) { return go_invoke_tool(n, a, r, e); }
int c_invoke_stage(char* s, char* c, char** r, char** e) { return go_invoke_stage(s, c, r, e); }
int c_invoke_output(char* c, char* m, char* p, char** e) { return go_invoke_output(c, m, p, e); }
void c_free_string(char* p) { go_free_string(p); }
// ha_dispatch — called by Go bridge, passes through to CoreAPI dispatch
int ha_dispatch(int id, void* api, char* s1, char* s2, char* s3, int i1, int i2, char** r, char** e) {
CoreAPI* a = (CoreAPI*)api;
if (!a || !a->dispatch) return 1;
return a->dispatch(id, a->ctx, s1, s2, s3, i1, i2, r, e);
}
PluginAPI* plugin_init(void) {
static PluginAPI api;
memset(&api, 0, sizeof(api));
api.version = HOMEAGENT_ABI_VERSION; api.version_min = HOMEAGENT_ABI_VERSION;
api.init_plugin = c_init_plugin; api.start_plugin = c_start_plugin; api.stop_plugin = c_stop_plugin;
api.invoke_tool = c_invoke_tool; api.invoke_stage = c_invoke_stage; api.invoke_output = c_invoke_output;
api.free_string = c_free_string;
return &api;
}
`
// ============================================================
// Remote Device Adapter Templates
// ============================================================

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
//go:build linux || darwin || freebsd
package main
import (
"fmt"
"os"
"syscall"
)
// Unix 侧共享段挂载:内核经 ExtraFiles 传入继承的 fd。
//
// fd 布局(与内核 internal/plugin/proc/plugin.go 的 ExtraFiles 顺序一致):
//
// fd 3 = StageContext 段memfd / 已 unlink 的临时文件)
// fd 4 = 事件环段
// fd 5 = 事件通知Linux eventfd / macOS pipe 读端)
//
// 继承的 fd 无需文件名,也不残留——这是选 memfd 而非 /dev/shm 的原因。
const (
fdStageShm = 3
fdEvtRingShm = 4
fdEvtNotifier = 5
)
// attachStageShm 挂载 StageContext 共享段。
//
// 各进程 mmap 到不同虚拟地址,段内一律用相对偏移而非指针,故仍能正确解引用
// (实验 2 已验证父子 mmap 基址不同时偏移解引用正确)。
func attachStageShm(size int) ([]byte, error) {
return syscall.Mmap(fdStageShm, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
}
// attachEvtRingShm 挂载事件环段。
func attachEvtRingShm(size int) ([]byte, error) {
return syscall.Mmap(fdEvtRingShm, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
}
// openEvtNotifier 打开事件通知读端。
func openEvtNotifier() (evtWaiter, error) {
f := os.NewFile(fdEvtNotifier, "evtnotify")
if f == nil {
return nil, fmt.Errorf("fd %d 不是有效的通知句柄", fdEvtNotifier)
}
return &unixEvtWaiter{f: f}, nil
}
// unixEvtWaiter 用 eventfd/pipe 的阻塞 Read 等待通知。
//
// os.NewFile 把 fd 注册进 runtime netpollerRead 阻塞时只 park goroutine
// 不占 OS 线程(实验 1200 个等待者仅增 1 个 OS 线程)。
// 反面对照是经 cgo 调 sem_wait——那会阻塞整个 M。
type unixEvtWaiter struct {
f *os.File
}
func (w *unixEvtWaiter) Wait(buf []byte) error {
_, err := w.f.Read(buf)
return err
}

View File

@ -0,0 +1,153 @@
//go:build windows
package main
import (
"fmt"
"os"
"syscall"
"unsafe"
)
// Windows 侧共享段挂载:走命名对象而非继承 fd。
//
// 为何不能照抄 UnixWindows 没有 fd 继承语义,`ExtraFiles` 在 os/exec 的
// Windows 实现里不被支持。等价机制是命名内核对象——父进程用
// CreateFileMapping / CreateEvent 建带名字的对象,子进程按同名 Open 拿到同一对象。
//
// 名字经环境变量传入(内核 internal/plugin/proc/plugin_windows.go 设置),
// 而不是硬编码:多个 homed 实例并存时不能撞名。
//
// **这是 §9.2 的正解**C ABI 时代 Windows 是第三套独立 ABI 实现,
// stage 只下发 3 个字段且完全没有写回sanitizer 这类改写型插件静默失效。
// 现在 Windows 与 Unix 共用同一份 RPC 逻辑与同一份共享段布局,
// 差异被收敛到本文件的三个函数里。
const (
envStageShmName = "HOMEAGENT_SHM_STAGE"
envEvtRingName = "HOMEAGENT_SHM_EVTRING"
envEvtEventName = "HOMEAGENT_EVT_EVENT"
)
// Windows API 绑定:用 LazyDLL 而非 golang.org/x/sys/windows。
//
// 原因OpenFileMappingW / OpenEventW 未被标准库 syscall 包导出。
// 引入 x/sys 会给**每个插件的 go.mod 加一个新依赖**
// 而「外部插件零改动」是本次迁移的硬约束(插件仅依赖公开 SDK
// LazyDLL 属于标准库 syscall零新增依赖。
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procOpenFileMappingW = kernel32.NewProc("OpenFileMappingW")
procOpenEventW = kernel32.NewProc("OpenEventW")
)
const (
winEventModifyState = 0x0002
winSynchronize = 0x00100000
)
// openFileMappingW 封装 OpenFileMappingW。
func openFileMappingW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
var inheritFlag uintptr
if inherit {
inheritFlag = 1
}
r, _, err := procOpenFileMappingW.Call(
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
if r == 0 {
return 0, err
}
return syscall.Handle(r), nil
}
// openEventW 封装 OpenEventW。
func openEventW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
var inheritFlag uintptr
if inherit {
inheritFlag = 1
}
r, _, err := procOpenEventW.Call(
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
if r == 0 {
return 0, err
}
return syscall.Handle(r), nil
}
// attachStageShm 按名字打开 StageContext 段并映射。
func attachStageShm(size int) ([]byte, error) {
return openNamedMapping(os.Getenv(envStageShmName), size, "StageContext 段")
}
// attachEvtRingShm 按名字打开事件环段并映射。
func attachEvtRingShm(size int) ([]byte, error) {
return openNamedMapping(os.Getenv(envEvtRingName), size, "事件环段")
}
// openNamedMapping 打开命名共享段并映射为 []byte。
//
// 与 Unix 的 mmap 语义对齐MapViewOfFile 返回的地址在本进程虚拟空间,
// 段内偏移仍是相对的,故跨进程解引用正确。
func openNamedMapping(name string, size int, what string) ([]byte, error) {
if name == "" {
return nil, fmt.Errorf("%s 名字未经环境变量传入", what)
}
namePtr, err := syscall.UTF16PtrFromString(name)
if err != nil {
return nil, fmt.Errorf("%s 名字非法: %w", what, err)
}
h, err := openFileMappingW(syscall.FILE_MAP_WRITE, false, namePtr)
if err != nil {
return nil, fmt.Errorf("打开 %s%s: %w", what, name, err)
}
addr, err := syscall.MapViewOfFile(h, syscall.FILE_MAP_WRITE, 0, 0, uintptr(size))
if err != nil {
syscall.CloseHandle(h)
return nil, fmt.Errorf("映射 %s: %w", what, err)
}
// 句柄不关:视图存活期间必须保持句柄有效,进程退出时由 OS 回收。
return unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), nil
}
// openEvtNotifier 按名字打开事件通知对象。
func openEvtNotifier() (evtWaiter, error) {
name := os.Getenv(envEvtEventName)
if name == "" {
return nil, fmt.Errorf("事件通知对象名字未经环境变量传入")
}
namePtr, err := syscall.UTF16PtrFromString(name)
if err != nil {
return nil, fmt.Errorf("事件对象名字非法: %w", err)
}
h, err := openEventW(winSynchronize|winEventModifyState, false, namePtr)
if err != nil {
return nil, fmt.Errorf("打开事件对象(%s: %w", name, err)
}
return &windowsEvtWaiter{h: h}, nil
}
// windowsEvtWaiter 用命名 Event 对象等待通知。
//
// 与 eventfd 的差异Event 是二元信号而非计数器,多次 SetEvent 只对应
// 一次唤醒。这不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq
// 批量 drain一次唤醒能处理累积的全部事件。
//
// WaitForSingleObject 阻塞的是 OS 线程而非仅 goroutine故不如 eventfd
// 的 netpoller 路径省线程。每插件一个消费 goroutine17 插件即 17 线程,
// 在可接受范围(实验 5 实测 17 子进程共 84 线程)。
type windowsEvtWaiter struct {
h syscall.Handle
}
func (w *windowsEvtWaiter) Wait(buf []byte) error {
ev, err := syscall.WaitForSingleObject(w.h, syscall.INFINITE)
if err != nil {
return err
}
if ev != syscall.WAIT_OBJECT_0 {
return fmt.Errorf("等待事件对象返回 0x%x", ev)
}
return nil
}