feat(sdk): 注入行为的记忆/裁剪标志位(纯追加)+ plugindev 退出码修复 + 示例 hmap 随发版

## 1. 注入标志位(公开 API 纯追加,无签名变更)

给注入行为补上工具早已有的两类声明位,并让通道定义也带上:
- `InjectOptions{NoMemory, ContextPolicy, CleanerName}`
- `IOInjector` 新增六个 `*Opts` 变体(排队/中断/同步 × 纯文本/带媒体)
- `ChannelDef.ContextPolicy`,并**补上 JSON tag**(Cleaner 标 `json:"-"`)

零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 + 不裁剪),
存量插件不需要改一行、也不需要重编;旧方法保留为转发到零值 opts 的语法糖。

三条设计要点:
- **默认不裁剪**:裁剪会归档丢弃低相关事件,必须显式声明(ContextPolicy=prune)。
- **中断也允许声明 prune**(已确认):中断同样携带内容进上下文。
- `CleanerName`:注入的 source 未必是注册过的输入通道名,而注入内容常带
  ANSI/JSON 包装;允许显式指定用哪个已注册 cleaner 清洗。

顺带修掉一个易静默丢字段的坑:`ChannelDef` 原来没有 JSON tag,只能手写字段
白名单跨进程传(`{"NoMemory": ...}`),新增字段会被丢掉。现在模板整体传 `def`。

## 2. 示例调用点统一写明意图
rss/memo/calendar/qq 的中断注入显式 `NoMemory: true`(行为等价,写清语义)。

## 3. plugindev 出错却 exit 0(真缺陷)
`buildBundle`/`buildTarget` 遇错只 Printf 后 return,`cmdBuild` 返回 void,
于是**构建失败也退 0**。实测中一个示例的 windows 目标编译失败,批量脚本却报
「17/17 全绿」,并因此少产出 16 个 .hmap。现在累计 `buildFailed` 并以非零退出。

## 4. 平台策略:插件目标去掉 windows
homed 已放弃 Windows 原生(见核心仓 cmd/homed/platform_windows.go:插件体系依赖
fd 继承 + 统一共享内存区的段内偏移解引用,Windows 句柄模型无法表达),
插件只运行在 homed 能跑的平台上,故 `allBundleTargets` 去掉 windows,
并对 windows 目标给出**可执行的报错**(指引 WSL2),而不是让它死在一句
`undefined: attachUnifiedShm` 上。

## 5. 发版附带各示例插件的 .hmap
新增 `package/build-examples.sh` 并接入 `package/build.sh`(组件 all|plugindev|examples):
- 用**刚构建出来的**那把工具链构建示例,保证与本次发版同源
- 逐平台 `--no-bundle --target <os/arch>`(bundle 会连 windows 一起编)
- 判成功同时看**退出码 + 产物存在**
- 全部产物齐了才 `sha256sum`(边打边算会漏掉后生成的包)
- 有任一失败即整体失败,不生成 SHA256SUMS

## 6. 版本
SDK 仍为 1.2.0(main 是下一个未发布中版本);1.2.0 条目补记本次新增接口,
并注明新标志位需核心 1.2.0+(旧核心会忽略这些字段,不报错但不生效)。
This commit is contained in:
JianFeeeee
2026-09-11 20:29:30 +08:00
parent b2eafdf885
commit ba49dfda44
12 changed files with 543 additions and 61 deletions

View File

@ -20,6 +20,15 @@ type BuildConfig struct {
Replaces []string
}
// buildFailed 记录本次构建是否有平台失败。
//
// 为什么要它:这两个构建函数遇到错误只是 Printf 后 return而 cmdBuild 返回
// void于是**构建失败却以 0 退出**。调用方批量重编脚本、CI、发版脚本
// 只能靠翻日志发现失败——实测中一个示例的 windows 目标编译失败,脚本却报
// 「17/17 全绿」,并因此少产出 16 个 .hmap。
// 判成功要看退出码,不能靠人读日志。
var buildFailed bool
func cmdBuild(args []string) {
// Read all config from plg.json first
plg, err := readPlgJSON("plg.json")
@ -92,11 +101,18 @@ func cmdBuild(args []string) {
if bundle || len(targets) == 0 {
buildBundle(plg, outDir, sdkPath)
return
} else {
for _, t := range targets {
buildTarget(plg, t, outDir, sdkPath)
}
}
for _, t := range targets {
buildTarget(plg, t, outDir, sdkPath)
// 以非零码退出调用方批量重编、CI、发版脚本靠退出码判成败。
// 以前这里直接 return失败也退 0于是「构建失败」只能靠人翻日志发现——
// 实测中就因此把一次部分失败当成了全绿。
if buildFailed {
fmt.Println("error: 至少一个目标构建失败(详见上面日志)")
os.Exit(1)
}
}
@ -105,13 +121,33 @@ func cmdBuild(args []string) {
// 子进程模式下各平台产物同名plugin.bin——进程边界即 ABI 边界,
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
//
// **不含 windows**:插件只能运行在 homed 能跑的平台上,而 homed 已明确放弃
// Windows 原生支持(插件体系依赖 fd 继承 + 统一共享内存区的段内偏移,
// Windows 句柄模型无法表达。Windows 用户走 WSL2而 WSL2 就是 linux/amd64。
var allBundleTargets = []struct {
target string
entry string // 二进制在 zip 中的文件名
}{
{"linux/amd64", "plugin.bin.linux.amd64"},
{"darwin/amd64", "plugin.bin.darwin.amd64"},
{"windows/amd64", "plugin.bin.windows.amd64"},
}
// checkTargetSupported 在构建前拦下**已知不支持**的目标,给出可执行的报错。
//
// 为什么要有它:插件运行在 homed 的进程里,所以目标平台必须是 homed 能跑的。
// homed 已放弃 Windows 原生(原因:插件依赖 fd 继承与统一共享内存区段内偏移,
// Windows 句柄模型无法表达),却还去构建 windows 插件,结果是死在一句
// 「undefined: attachUnifiedShm」——看起来像代码 bug实际是平台策略。
// 这里换成明确的结论,并且**不静默跳过**:静默跳过会让人以为产出的包里包含 windows。
func checkTargetSupported(target string) error {
if strings.HasPrefix(target, "windows/") {
return fmt.Errorf("不支持 windows 插件目标:插件运行在 homed 内," +
"而 homed 已放弃 Windows 原生支持(插件体系依赖 fd 继承与统一共享内存区" +
"段内偏移解引用Windows 句柄模型无法表达。Windows 请用 WSL2——" +
"它就是 linux/amd64用 --target linux/amd64 即可")
}
return nil
}
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
@ -122,6 +158,7 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
runtimeCleanup, err := generateProcRuntime()
if err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
defer runtimeCleanup()
@ -132,9 +169,15 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
var binaries []binEntry
for _, bt := range allBundleTargets {
if err := checkTargetSupported(bt.target); err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
cfg, errMsg := resolveBuild(bt.target)
if cfg == nil {
fmt.Printf(" error: %s\n", errMsg)
buildFailed = true
return
}
@ -151,7 +194,10 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
fmt.Printf(" compiling %s/%s (子进程模式CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
if err := cmd.Run(); err != nil {
// 单平台失败即整包失败bundle 少一个平台就是个坏包,
// 却仍会生成 .hmap 让人以为打包成功。
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
buildFailed = true
return
}
binaries = append(binaries, binEntry{src: outPath, zip: bt.entry})
@ -544,6 +590,13 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
}
defer runtimeCleanup()
// 已知未实现的目标在编译前拦下,给可执行的报错(见 checkTargetSupported
if err := checkTargetSupported(target); err != nil {
fmt.Printf(" error: %v\n", err)
buildFailed = true
return
}
// Auto-link thirdpart/ contents + source_dirs + replace targets
thirdpartCleanup := linkThirdpart(plg, target)
defer thirdpartCleanup()
@ -562,6 +615,7 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
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)
buildFailed = true
return
}

View File

@ -253,15 +253,40 @@ func putValueInArena(v interface{}) (SharedRef, bool) {
//
// 共享内存对插件开发者完全透明SDK 层只看得到 string。
func callWithText(method, source, channel, text string) (json.RawMessage, error) {
return callWithTextOpts(method, source, channel, text, sdk.InjectOptions{})
}
// callWithTextOpts 是 callWithText 的带标志位版本。
//
// 只在标志位非零时才写入参数:零值(记入记忆 + 不裁剪)与旧参数形态完全一致,
// 便于内核侧做兼容与灰度。
func callWithTextOpts(method, source, channel, text string, opts sdk.InjectOptions) (json.RawMessage, error) {
if ref, ok := putInArena(text); ok {
defer arenaFree(ref)
return callCore(method, map[string]interface{}{
args := map[string]interface{}{
"source": source, "channel": channel, "text_ref": ref,
})
}
applyInjectOpts(args, opts)
return callCore(method, args)
}
return callCore(method, map[string]string{
args := map[string]interface{}{
"source": source, "channel": channel, "text": text,
})
}
applyInjectOpts(args, opts)
return callCore(method, args)
}
// applyInjectOpts 把 InjectOptions 摊进注入参数字典(仅非零值)。
func applyInjectOpts(args map[string]interface{}, opts sdk.InjectOptions) {
if opts.NoMemory {
args["no_memory"] = true
}
if opts.ContextPolicy != "" {
args["context_policy"] = opts.ContextPolicy
}
if opts.CleanerName != "" {
args["cleaner_name"] = opts.CleanerName
}
}
// ---- 全局状态 ----
@ -670,8 +695,10 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
}
handlerMu.Unlock()
return callCoreVoid("input.register", map[string]interface{}{
"name": chName,
"def": map[string]interface{}{"NoMemory": def.NoMemory},
"name": chName,
// 整个结构体:手写字段白名单会把新增字段静默丢掉
// ChannelDef.Cleaner 已标 json:"-",可以整体 marshal
"def": def,
"has_cleaner": def.Cleaner != nil,
})
})
@ -680,18 +707,32 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
type procIO struct{}
// 下面六个三参数方法是 *Opts 变体的零值糖:记入记忆 + 不裁剪。
func (procIO) InjectText(s, c, t string) {
// 忽略错误:注入是 fire-and-forget与内联路径语义一致
_, _ = callWithText("io.injectText", s, c, t)
procIO{}.InjectTextOpts(s, c, t, sdk.InjectOptions{})
}
func (procIO) InjectInterruptText(s, c, t string) {
_, _ = callWithText("io.injectInterrupt", s, c, t)
procIO{}.InjectInterruptTextOpts(s, c, t, sdk.InjectOptions{})
}
func (procIO) InjectTextNoMemory(s, c, t string) {
_, _ = callWithText("io.injectTextNoMem", s, c, t)
procIO{}.InjectTextOpts(s, c, t, sdk.InjectOptions{NoMemory: true})
}
func (procIO) InjectInputSync(s, c, t string) string {
raw, err := callWithText("io.injectInputSync", s, c, t)
return procIO{}.InjectInputSyncOpts(s, c, t, sdk.InjectOptions{})
}
// 以下为带标志位的注入opts 决定这次注入是否进记忆、是否据此裁剪上下文。
func (procIO) InjectTextOpts(s, c, t string, opts sdk.InjectOptions) {
// 忽略错误:注入是 fire-and-forget与内联路径语义一致
_, _ = callWithTextOpts("io.injectText", s, c, t, opts)
}
func (procIO) InjectInterruptTextOpts(s, c, t string, opts sdk.InjectOptions) {
_, _ = callWithTextOpts("io.injectInterrupt", s, c, t, opts)
}
func (procIO) InjectInputSyncOpts(s, c, t string, opts sdk.InjectOptions) string {
raw, err := callWithTextOpts("io.injectInputSync", s, c, t, opts)
if err != nil {
return ""
}
@ -721,12 +762,28 @@ func (procIO) SetToolBlocks(blocks []sdk.ContentBlock) {
// 带媒体的注入:插件主动发起一轮带图/音频的对话。
// 与 SetToolBlocks 的区别是媒体在**本轮**就到模型手上,而不是等下一条 tool message。
func (procIO) InjectInputMedia(s, c, t string, blocks []sdk.ContentBlock) {
callCoreVoid("io.injectMedia", mediaArgs(s, c, t, blocks))
procIO{}.InjectInputMediaOpts(s, c, t, blocks, sdk.InjectOptions{})
}
func (procIO) InjectInputMediaSync(s, c, t string, blocks []sdk.ContentBlock) string {
return procIO{}.InjectInputMediaSyncOpts(s, c, t, blocks, sdk.InjectOptions{})
}
func (procIO) InjectInterruptMedia(s, c, t string, blocks []sdk.ContentBlock) {
procIO{}.InjectInterruptMediaOpts(s, c, t, blocks, sdk.InjectOptions{})
}
func (procIO) InjectInputMediaOpts(s, c, t string, blocks []sdk.ContentBlock, opts sdk.InjectOptions) {
args, free := mediaArgsOwned(s, c, t, blocks)
defer free()
applyInjectOpts(args, opts)
callCoreVoid("io.injectMedia", args)
}
func (procIO) InjectInputMediaSyncOpts(s, c, t string, blocks []sdk.ContentBlock, opts sdk.InjectOptions) string {
args, free := mediaArgsOwned(s, c, t, blocks)
defer free()
applyInjectOpts(args, opts)
raw, err := callCore("io.injectMediaSync", args)
if err != nil {
return ""
@ -738,20 +795,13 @@ func (procIO) InjectInputMediaSync(s, c, t string, blocks []sdk.ContentBlock) st
return r.Reply
}
func (procIO) InjectInterruptMedia(s, c, t string, blocks []sdk.ContentBlock) {
func (procIO) InjectInterruptMediaOpts(s, c, t string, blocks []sdk.ContentBlock, opts sdk.InjectOptions) {
args, free := mediaArgsOwned(s, c, t, blocks)
defer free()
applyInjectOpts(args, opts)
callCoreVoid("io.injectInterruptMedia", args)
}
// mediaArgs 构造媒体注入参数,并在返回前释放临时共享槽。
// 只能用在调用结束后立即返回的路径fire-and-forget
func mediaArgs(s, c, t string, blocks []sdk.ContentBlock) map[string]interface{} {
args, free := mediaArgsOwned(s, c, t, blocks)
defer free()
return args
}
// mediaArgsOwned 构造媒体注入参数,并返回释放函数。
//
// 为什么要返回释放函数而不是自己 defer调用方可能是需要等应答的同步调用

View File

@ -83,12 +83,27 @@ type ToolResult struct {
}
type ToolDef struct {
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"`
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"`
ContextPolicy string `json:"context_policy,omitempty"`
}
// 上下文策略取值,与公共 SDK 一致。
const (
ContextPolicyNone = "none"
ContextPolicyPrune = "prune"
)
// InjectOptions 与公共 SDK 同构:声明一次注入是否记入记忆、是否据此裁剪上下文、
// 以及用哪个已注册的通道 cleaner 清洗注入内容。
type InjectOptions struct {
NoMemory bool
ContextPolicy string
CleanerName string
}
type IOInjector interface {
@ -101,6 +116,14 @@ type IOInjector interface {
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
SetToolBlocks(blocks []ContentBlock)
// 1.2.0 带标志位的注入,与公共 SDK 同构。
InjectTextOpts(source, channel, text string, opts InjectOptions)
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
}
// ContentBlock 与公共 SDK 同构OpenAI 多模态内容块格式)。
@ -391,6 +414,29 @@ func (IOInjectorImpl) SetToolBlocks(blocks []ContentBlock) {
logf("set_tool_blocks: blocks=%d", len(blocks))
}
// ---- 带 InjectOptions 的注入 ----
func (IOInjectorImpl) InjectTextOpts(source, channel, text string, opts InjectOptions) {
logf("inject_text_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
}
func (IOInjectorImpl) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
logf("inject_interrupt_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
}
func (IOInjectorImpl) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
logf("inject_sync_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
return ""
}
func (IOInjectorImpl) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
logf("inject_input_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
}
func (IOInjectorImpl) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
logf("inject_input_media_sync_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
return ""
}
func (IOInjectorImpl) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
logf("inject_interrupt_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
}
type PluginSDK struct {
Name string
mu sync.RWMutex