feat(sdk): 通用反代声明项(DeclareProxy)+ ToolDef.Serial 串行标记 + 场面策略文档

本次一并提交工作区此前累积的改动(均已验证),并接入工具并发调度所需的
声明项。

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

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

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

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

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

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

- sdk/knowledge.go:随场面策略配套调整
- docs/api/*、docs/assets/api-index.json、docs/llms.txt、mkdocs.yml:
  由 tools/apidoc/build.sh 从源码重新生成(行号随 plugin.go 变动漂移)
This commit is contained in:
JianFeeeee
2026-09-27 14:52:26 +08:00
parent e417c69fc8
commit bd73a9b241
26 changed files with 719 additions and 3656 deletions

View File

@ -12,7 +12,7 @@ type APIRegistrar func(name string) error
APIRegistrar registers a plugin API for external access. APIRegistrar registers a plugin API for external access.
<small>`plugin.go:311`</small> <small>`plugin.go:410`</small>
### `InputChannelRegistrar` ### `InputChannelRegistrar`
@ -22,7 +22,7 @@ type InputChannelRegistrar func(name string, def ChannelDef) error
InputChannelRegistrar registers an input channel with its memory behavior. InputChannelRegistrar registers an input channel with its memory behavior.
<small>`plugin.go:314`</small> <small>`plugin.go:413`</small>
### `OutputChannelRegistrar` ### `OutputChannelRegistrar`
@ -32,7 +32,7 @@ type OutputChannelRegistrar func(name string, caps int, desc string, def Channel
OutputChannelRegistrar registers an output channel that the output_send tool can use. OutputChannelRegistrar registers an output channel that the output_send tool can use.
<small>`plugin.go:317`</small> <small>`plugin.go:416`</small>
### `OutputChannelUnregistrar` ### `OutputChannelUnregistrar`
@ -46,7 +46,7 @@ OutputChannelUnregistrar 注销一个输出通道。
动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后 动态通道 —— 典型是远程设备:`device/<id>` 只在设备在线期间存在,设备掉线后
必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。 必须注销,否则 output_list_channels 会一直列着它、模型会往一个死通道发消息。
<small>`plugin.go:324`</small> <small>`plugin.go:423`</small>
### `PluginSDK.SetDocMemoryAPI` ### `PluginSDK.SetDocMemoryAPI`
@ -57,7 +57,7 @@ OutputChannelUnregistrar 注销一个输出通道。
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI)
``` ```
<small>`plugin.go:619`</small> <small>`plugin.go:718`</small>
### `PluginSDK.SetEventSubscriber` ### `PluginSDK.SetEventSubscriber`
@ -68,7 +68,7 @@ func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI)
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
``` ```
<small>`plugin.go:643`</small> <small>`plugin.go:742`</small>
### `PluginSDK.SetIOInjector` ### `PluginSDK.SetIOInjector`
@ -81,7 +81,7 @@ func (s *PluginSDK) SetIOInjector(io IOInjector)
SetIOInjector sets the IO injector (called by the core at startup). SetIOInjector sets the IO injector (called by the core at startup).
<small>`plugin.go:600`</small> <small>`plugin.go:699`</small>
### `PluginSDK.SetInputChannelRegistrar` ### `PluginSDK.SetInputChannelRegistrar`
@ -94,7 +94,7 @@ func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar)
SetInputChannelRegistrar sets the input channel registrar (called by the core at startup). SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
<small>`plugin.go:593`</small> <small>`plugin.go:692`</small>
### `PluginSDK.SetKnowledgeAPI` ### `PluginSDK.SetKnowledgeAPI`
@ -105,7 +105,7 @@ SetInputChannelRegistrar sets the input channel registrar (called by the core at
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI)
``` ```
<small>`plugin.go:625`</small> <small>`plugin.go:724`</small>
### `PluginSDK.SetLLMAPI` ### `PluginSDK.SetLLMAPI`
@ -116,7 +116,7 @@ func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI)
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) func (s *PluginSDK) SetLLMAPI(llm LLMAPI)
``` ```
<small>`plugin.go:631`</small> <small>`plugin.go:730`</small>
### `PluginSDK.SetMemoryAPI` ### `PluginSDK.SetMemoryAPI`
@ -129,7 +129,7 @@ func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI)
SetMemoryAPI sets the memory API (called by the core at startup). SetMemoryAPI sets the memory API (called by the core at startup).
<small>`plugin.go:607`</small> <small>`plugin.go:706`</small>
### `PluginSDK.SetOutputChannelRegistrar` ### `PluginSDK.SetOutputChannelRegistrar`
@ -142,7 +142,7 @@ func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar)
SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup). SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
<small>`plugin.go:579`</small> <small>`plugin.go:678`</small>
### `PluginSDK.SetOutputChannelUnregistrar` ### `PluginSDK.SetOutputChannelUnregistrar`
@ -155,7 +155,7 @@ func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup). SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
<small>`plugin.go:586`</small> <small>`plugin.go:685`</small>
### `PluginSDK.SetPluginMgrAPI` ### `PluginSDK.SetPluginMgrAPI`
@ -168,7 +168,7 @@ func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI)
SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup). SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
<small>`plugin.go:650`</small> <small>`plugin.go:749`</small>
### `PluginSDK.SetSocialAPI` ### `PluginSDK.SetSocialAPI`
@ -179,7 +179,7 @@ SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
func (s *PluginSDK) SetSocialAPI(social SocialAPI) func (s *PluginSDK) SetSocialAPI(social SocialAPI)
``` ```
<small>`plugin.go:637`</small> <small>`plugin.go:736`</small>
### `PluginSDK.SetTextMemoryAPI` ### `PluginSDK.SetTextMemoryAPI`
@ -190,7 +190,7 @@ func (s *PluginSDK) SetSocialAPI(social SocialAPI)
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI)
``` ```
<small>`plugin.go:613`</small> <small>`plugin.go:712`</small>
### `ToolRegistrar` ### `ToolRegistrar`
@ -200,5 +200,5 @@ type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
ToolRegistrar registers a tool dynamically. ToolRegistrar registers a tool dynamically.
<small>`plugin.go:305`</small> <small>`plugin.go:404`</small>

View File

@ -17,7 +17,7 @@ const PriorityL4
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
<small>`plugin.go:127`</small> <small>`plugin.go:165`</small>
### `PluginSDK.Events` ### `PluginSDK.Events`
@ -30,7 +30,7 @@ func (s *PluginSDK) Events() EventSubscriber
Events returns the event subscriber for listening to kernel events (may be nil if not available). Events returns the event subscriber for listening to kernel events (may be nil if not available).
<small>`plugin.go:451`</small> <small>`plugin.go:550`</small>
### `PluginSDK.SetEventSubscriber` ### `PluginSDK.SetEventSubscriber`
@ -41,7 +41,7 @@ Events returns the event subscriber for listening to kernel events (may be nil i
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
``` ```
<small>`plugin.go:643`</small> <small>`plugin.go:742`</small>
### `PluginSDK.SetOutputChannelUnregistrar` ### `PluginSDK.SetOutputChannelUnregistrar`
@ -54,7 +54,7 @@ func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup). SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
<small>`plugin.go:586`</small> <small>`plugin.go:685`</small>
### `PluginSDK.UnregisterOutputChannel` ### `PluginSDK.UnregisterOutputChannel`
@ -67,7 +67,7 @@ func (s *PluginSDK) UnregisterOutputChannel(name string) error
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。 UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
<small>`plugin.go:544`</small> <small>`plugin.go:643`</small>
### `EventSubscriber.Subscribe` ### `EventSubscriber.Subscribe`

View File

@ -33,7 +33,7 @@ for routing the agent's response.
InjectInputMedia(source, channel, text string, blocks []ContentBlock) InjectInputMedia(source, channel, text string, blocks []ContentBlock)
``` ```
<small>`plugin.go:230`</small> <small>`plugin.go:329`</small>
### `IOInjector.InjectInputMediaOpts` ### `IOInjector.InjectInputMediaOpts`
@ -41,7 +41,7 @@ InjectInputMedia(source, channel, text string, blocks []ContentBlock)
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
``` ```
<small>`plugin.go:241`</small> <small>`plugin.go:340`</small>
### `IOInjector.InjectInputMediaSync` ### `IOInjector.InjectInputMediaSync`
@ -49,7 +49,7 @@ InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts I
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
``` ```
<small>`plugin.go:231`</small> <small>`plugin.go:330`</small>
### `IOInjector.InjectInputMediaSyncOpts` ### `IOInjector.InjectInputMediaSyncOpts`
@ -57,7 +57,7 @@ InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
``` ```
<small>`plugin.go:242`</small> <small>`plugin.go:341`</small>
### `IOInjector.InjectInputSync` ### `IOInjector.InjectInputSync`
@ -75,7 +75,7 @@ InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` | | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
<small>`plugin.go:226`</small> <small>`plugin.go:325`</small>
### `IOInjector.InjectInputSyncOpts` ### `IOInjector.InjectInputSyncOpts`
@ -83,7 +83,7 @@ InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
``` ```
<small>`plugin.go:240`</small> <small>`plugin.go:339`</small>
### `IOInjector.InjectInterruptMedia` ### `IOInjector.InjectInterruptMedia`
@ -91,7 +91,7 @@ InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
``` ```
<small>`plugin.go:232`</small> <small>`plugin.go:331`</small>
### `IOInjector.InjectInterruptMediaOpts` ### `IOInjector.InjectInterruptMediaOpts`
@ -99,7 +99,7 @@ InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
``` ```
<small>`plugin.go:243`</small> <small>`plugin.go:342`</small>
### `IOInjector.InjectInterruptText` ### `IOInjector.InjectInterruptText`
@ -107,7 +107,7 @@ InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, op
InjectInterruptText(source, channel, text string) InjectInterruptText(source, channel, text string)
``` ```
<small>`plugin.go:221`</small> <small>`plugin.go:320`</small>
### `IOInjector.InjectInterruptTextOpts` ### `IOInjector.InjectInterruptTextOpts`
@ -124,7 +124,7 @@ InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
<small>`plugin.go:239`</small> <small>`plugin.go:338`</small>
### `IOInjector.InjectText` ### `IOInjector.InjectText`
@ -132,7 +132,7 @@ InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
InjectText(source, channel, text string) InjectText(source, channel, text string)
``` ```
<small>`plugin.go:222`</small> <small>`plugin.go:321`</small>
### `IOInjector.InjectTextNoMemory` ### `IOInjector.InjectTextNoMemory`
@ -146,7 +146,7 @@ InjectTextNoMemory(source, channel, text string)
|---|---|---| |---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
<small>`plugin.go:223`</small> <small>`plugin.go:322`</small>
### `IOInjector.InjectTextOpts` ### `IOInjector.InjectTextOpts`
@ -159,7 +159,7 @@ InjectTextOpts(source, channel, text string, opts InjectOptions)
上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪), 上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。 保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
<small>`plugin.go:238`</small> <small>`plugin.go:337`</small>
### `IOInjector.SetToolBlocks` ### `IOInjector.SetToolBlocks`
@ -170,7 +170,7 @@ SetToolBlocks(blocks []ContentBlock)
SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。 tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
<small>`plugin.go:229`</small> <small>`plugin.go:328`</small>
### `CapAudio` ### `CapAudio`
@ -180,7 +180,7 @@ const CapAudio
Output capability flags Output capability flags
<small>`plugin.go:331`</small> <small>`plugin.go:430`</small>
### `CapFile` ### `CapFile`
@ -190,7 +190,7 @@ const CapFile
Output capability flags Output capability flags
<small>`plugin.go:329`</small> <small>`plugin.go:428`</small>
### `CapImage` ### `CapImage`
@ -200,7 +200,7 @@ const CapImage
Output capability flags Output capability flags
<small>`plugin.go:330`</small> <small>`plugin.go:429`</small>
### `CapStructured` ### `CapStructured`
@ -210,7 +210,7 @@ const CapStructured
Output capability flags Output capability flags
<small>`plugin.go:332`</small> <small>`plugin.go:431`</small>
### `CapText` ### `CapText`
@ -220,7 +220,7 @@ const CapText
Output capability flags Output capability flags
<small>`plugin.go:328`</small> <small>`plugin.go:427`</small>
### `ChannelDef` ### `ChannelDef`
@ -233,12 +233,13 @@ NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提
Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用 Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪) ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回) RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。 JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单—— 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
那样新增字段会被静默丢掉。 那样新增字段会被静默丢掉。
<small>`plugin.go:139`</small> <small>`plugin.go:178`</small>
### `ContextPolicyNone` ### `ContextPolicyNone`
@ -280,7 +281,7 @@ blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 conten
的「下一轮 tool message」语义。 的「下一轮 tool message」语义。
等价于 InjectInputMediaOpts(..., InjectOptions{})。 等价于 InjectInputMediaOpts(..., InjectOptions{})。
<small>`plugin.go:706`</small> <small>`plugin.go:805`</small>
### `PluginSDK.InjectInputMediaOpts` ### `PluginSDK.InjectInputMediaOpts`
@ -290,7 +291,7 @@ func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []
InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。 InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
<small>`plugin.go:745`</small> <small>`plugin.go:844`</small>
### `PluginSDK.InjectInputMediaSync` ### `PluginSDK.InjectInputMediaSync`
@ -301,7 +302,7 @@ func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []
InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。 InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。 等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
<small>`plugin.go:712`</small> <small>`plugin.go:811`</small>
### `PluginSDK.InjectInputMediaSyncOpts` ### `PluginSDK.InjectInputMediaSyncOpts`
@ -311,7 +312,7 @@ func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, block
InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。 InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
<small>`plugin.go:752`</small> <small>`plugin.go:851`</small>
### `PluginSDK.InjectInputSync` ### `PluginSDK.InjectInputSync`
@ -330,7 +331,7 @@ to the source channel by the caller.
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` | | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
<small>`plugin.go:697`</small> <small>`plugin.go:796`</small>
### `PluginSDK.InjectInputSyncOpts` ### `PluginSDK.InjectInputSyncOpts`
@ -340,7 +341,7 @@ func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts Injec
InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。 InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
<small>`plugin.go:736`</small> <small>`plugin.go:835`</small>
### `PluginSDK.InjectInterruptMedia` ### `PluginSDK.InjectInterruptMedia`
@ -351,7 +352,7 @@ func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []
InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。 InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
blocks 随中断消息一起发给模型。 blocks 随中断消息一起发给模型。
<small>`plugin.go:769`</small> <small>`plugin.go:868`</small>
### `PluginSDK.InjectInterruptMediaOpts` ### `PluginSDK.InjectInterruptMediaOpts`
@ -361,7 +362,7 @@ func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, block
InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。 InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
<small>`plugin.go:761`</small> <small>`plugin.go:860`</small>
### `PluginSDK.InjectInterruptText` ### `PluginSDK.InjectInterruptText`
@ -372,7 +373,7 @@ func (s *PluginSDK) InjectInterruptText(source, channel, text string)
InjectInterruptText injects a text interrupt that can preempt current LLM processing. InjectInterruptText injects a text interrupt that can preempt current LLM processing.
等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。 等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
<small>`plugin.go:678`</small> <small>`plugin.go:777`</small>
### `PluginSDK.InjectInterruptTextOpts` ### `PluginSDK.InjectInterruptTextOpts`
@ -394,7 +395,7 @@ InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
<small>`plugin.go:729`</small> <small>`plugin.go:828`</small>
### `InjectOptions` ### `InjectOptions`
@ -424,7 +425,7 @@ CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清
而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容; 而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
不指定就只能退到「按 source 查不到就不清洗」。 不指定就只能退到「按 source 查不到就不清洗」。
<small>`plugin.go:98`</small> <small>`plugin.go:132`</small>
### `PluginSDK.InjectText` ### `PluginSDK.InjectText`
@ -435,7 +436,7 @@ func (s *PluginSDK) InjectText(source, channel, text string)
InjectText injects a text message into the agent pipeline. InjectText injects a text message into the agent pipeline.
等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。 等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
<small>`plugin.go:684`</small> <small>`plugin.go:783`</small>
### `PluginSDK.InjectTextNoMemory` ### `PluginSDK.InjectTextNoMemory`
@ -452,7 +453,7 @@ InjectTextNoMemory injects a text message without generating memory.
|---|---|---| |---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
<small>`plugin.go:690`</small> <small>`plugin.go:789`</small>
### `PluginSDK.InjectTextOpts` ### `PluginSDK.InjectTextOpts`
@ -462,7 +463,7 @@ func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOpti
InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。 InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
<small>`plugin.go:719`</small> <small>`plugin.go:818`</small>
### `PriorityL1` ### `PriorityL1`
@ -476,7 +477,7 @@ L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力, 如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
<small>`plugin.go:123`</small> <small>`plugin.go:161`</small>
### `PriorityL2` ### `PriorityL2`
@ -490,7 +491,7 @@ L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力, 如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
<small>`plugin.go:124`</small> <small>`plugin.go:162`</small>
### `PriorityL3` ### `PriorityL3`
@ -504,7 +505,7 @@ L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置
如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力, 如 cli/webui/timer)才能声明——它用于实现真正的“立即打断”能力,
例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
<small>`plugin.go:125`</small> <small>`plugin.go:163`</small>
### `PriorityL4` ### `PriorityL4`
@ -517,7 +518,7 @@ const PriorityL4
PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。 PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
<small>`plugin.go:127`</small> <small>`plugin.go:165`</small>
### `RecallPolicyAuto` ### `RecallPolicyAuto`
@ -577,7 +578,7 @@ def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:209` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:209` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:281` | `_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:281` | `_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})` |
<small>`plugin.go:566`</small> <small>`plugin.go:665`</small>
### `PluginSDK.RegisterOutputChannel` ### `PluginSDK.RegisterOutputChannel`
@ -614,7 +615,7 @@ handler: receives args map with keys: payload (string), type (string), meta (str
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:411` | `s.RegisterOutputChannel("qq", sdk.CapText\|sdk.CapFile\|sdk.CapImage\|sdk.CapAudio,` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:411` | `s.RegisterOutputChannel("qq", sdk.CapText\|sdk.CapFile\|sdk.CapImage\|sdk.CapAudio,` |
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:104` | `if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{` | | [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:104` | `if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{` |
<small>`plugin.go:533`</small> <small>`plugin.go:632`</small>
### `PluginSDK.SetToolBlocks` ### `PluginSDK.SetToolBlocks`
@ -625,7 +626,7 @@ func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock)
SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。 的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
<small>`plugin.go:777`</small> <small>`plugin.go:876`</small>
### `ValidContextPolicy` ### `ValidContextPolicy`

View File

@ -22,7 +22,7 @@ controls which events are delivered.
Subscribe(eventType EventType, handler EventHandler) func() Subscribe(eventType EventType, handler EventHandler) func()
``` ```
<small>`plugin.go:279`</small> <small>`plugin.go:378`</small>
### `Event` ### `Event`
@ -32,7 +32,7 @@ type Event struct { Type EventType `json:"type"` Source string `json:"source"` P
Event represents a system event published by the kernel. Event represents a system event published by the kernel.
<small>`plugin.go:265`</small> <small>`plugin.go:364`</small>
### `EventHandler` ### `EventHandler`
@ -42,7 +42,7 @@ type EventHandler func(evt *Event)
EventHandler processes a system event. EventHandler processes a system event.
<small>`plugin.go:273`</small> <small>`plugin.go:372`</small>
### `EventType` ### `EventType`
@ -52,7 +52,7 @@ type EventType string
EventType identifies the kind of system event. EventType identifies the kind of system event.
<small>`plugin.go:247`</small> <small>`plugin.go:346`</small>
### `PluginSDK.Events` ### `PluginSDK.Events`
@ -65,5 +65,5 @@ func (s *PluginSDK) Events() EventSubscriber
Events returns the event subscriber for listening to kernel events (may be nil if not available). Events returns the event subscriber for listening to kernel events (may be nil if not available).
<small>`plugin.go:451`</small> <small>`plugin.go:550`</small>

View File

@ -57,7 +57,7 @@ IsPluginDisabled(name string) bool
IsPluginDisabled 查询插件是否被禁用。 IsPluginDisabled 查询插件是否被禁用。
<small>`plugin.go:290`</small> <small>`plugin.go:389`</small>
### `PluginMgrAPI.ListLoadedPlugins` ### `PluginMgrAPI.ListLoadedPlugins`
@ -67,7 +67,7 @@ ListLoadedPlugins() []string
ListLoadedPlugins 列出已加载插件。 ListLoadedPlugins 列出已加载插件。
<small>`plugin.go:288`</small> <small>`plugin.go:387`</small>
### `PluginMgrAPI.ReloadOne` ### `PluginMgrAPI.ReloadOne`
@ -77,7 +77,7 @@ ReloadOne(name string) error
ReloadOne 重载单个插件(停止后重新加载)。 ReloadOne 重载单个插件(停止后重新加载)。
<small>`plugin.go:286`</small> <small>`plugin.go:385`</small>
### `PluginSDK.AutoRestart` ### `PluginSDK.AutoRestart`
@ -87,7 +87,7 @@ func (s *PluginSDK) AutoRestart() bool
AutoRestart 返回插件是否允许自动重启。 AutoRestart 返回插件是否允许自动重启。
<small>`plugin.go:796`</small> <small>`plugin.go:895`</small>
### `PluginSDK.PluginMgr` ### `PluginSDK.PluginMgr`
@ -98,7 +98,7 @@ func (s *PluginSDK) PluginMgr() PluginMgrAPI
PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list). PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
May be nil if the host did not wire it. May be nil if the host did not wire it.
<small>`plugin.go:658`</small> <small>`plugin.go:757`</small>
### `PluginSDK.PluginName` ### `PluginSDK.PluginName`
@ -108,7 +108,7 @@ func (s *PluginSDK) PluginName() string
PluginName returns the name of the plugin. PluginName returns the name of the plugin.
<small>`plugin.go:402`</small> <small>`plugin.go:501`</small>
### `PluginSDK.RegisterOnRemoveHandler` ### `PluginSDK.RegisterOnRemoveHandler`
@ -129,7 +129,7 @@ RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:69` | `s.RegisterOnRemoveHandler(p.cleanupData)` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:69` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
| [`rss`](../examples/index.md#rss) | `example/rss/plugin.go:127` | `s.RegisterOnRemoveHandler(p.cleanupData)` | | [`rss`](../examples/index.md#rss) | `example/rss/plugin.go:127` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
<small>`plugin.go:831`</small> <small>`plugin.go:930`</small>
### `PluginSDK.RegisterPluginAPI` ### `PluginSDK.RegisterPluginAPI`
@ -139,7 +139,7 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error
RegisterPluginAPI registers this plugin's API for access by other plugins. RegisterPluginAPI registers this plugin's API for access by other plugins.
<small>`plugin.go:507`</small> <small>`plugin.go:606`</small>
### `PluginSDK.RegisterStopHandler` ### `PluginSDK.RegisterStopHandler`
@ -159,7 +159,7 @@ RegisterStopHandler 注册插件停止阶段的清理回调。
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:294` | `s.RegisterStopHandler(p.saveEvents)` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:294` | `s.RegisterStopHandler(p.saveEvents)` |
| [`deepsearch`](../examples/index.md#deepsearch) | `example/deepsearch/plugin.go:695` | `s.RegisterStopHandler(func() { p.shutdownSearxng() })` | | [`deepsearch`](../examples/index.md#deepsearch) | `example/deepsearch/plugin.go:695` | `s.RegisterStopHandler(func() { p.shutdownSearxng() })` |
<small>`plugin.go:806`</small> <small>`plugin.go:905`</small>
### `PluginSDK.RunOnRemoveHandlers` ### `PluginSDK.RunOnRemoveHandlers`
@ -170,7 +170,7 @@ func (s *PluginSDK) RunOnRemoveHandlers()
RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。 RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。
由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。 由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。
<small>`plugin.go:842`</small> <small>`plugin.go:941`</small>
### `PluginSDK.RunStopHandlers` ### `PluginSDK.RunStopHandlers`
@ -181,7 +181,7 @@ func (s *PluginSDK) RunStopHandlers()
RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。 RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。
由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。 由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。
<small>`plugin.go:817`</small> <small>`plugin.go:916`</small>
### `PluginSDK.SetAutoRestart` ### `PluginSDK.SetAutoRestart`
@ -205,5 +205,5 @@ SetAutoRestart 设置插件崩溃后内核是否自动重启它。
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:110` | `s.SetAutoRestart(true)` | | [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:110` | `s.SetAutoRestart(true)` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:25` | `s.SetAutoRestart(true)` | | [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:25` | `s.SetAutoRestart(true)` |
<small>`plugin.go:789`</small> <small>`plugin.go:888`</small>

View File

@ -46,5 +46,5 @@ func (s *PluginSDK) LLM() LLMAPI
LLM returns the LLM provider API (may be nil if not available). LLM returns the LLM provider API (may be nil if not available).
<small>`plugin.go:437`</small> <small>`plugin.go:536`</small>

View File

@ -236,7 +236,7 @@ func (s *PluginSDK) DocMemory() DocMemoryAPI
DocMemory returns the document memory API (may be nil if not available). DocMemory returns the document memory API (may be nil if not available).
<small>`plugin.go:423`</small> <small>`plugin.go:522`</small>
### `Entity` ### `Entity`
@ -251,7 +251,7 @@ Entity represents a named entity in the knowledge graph.
### `Knowledge` ### `Knowledge`
```go ```go
type Knowledge struct { Name string `json:"name"` Content string `json:"content"` } type Knowledge struct { Name string `json:"name"` // Category 是该条目的父分类路径(如 "tech/go"),根下条目为空。 // // 为何加这个字段:对<EFBC9A><E5AFB9> …
``` ```
Knowledge represents a knowledge entry. Knowledge represents a knowledge entry.
@ -278,7 +278,7 @@ Knowledge returns the knowledge store API (may be nil if not available).
|---|---|---| |---|---|---|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` | | [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
<small>`plugin.go:430`</small> <small>`plugin.go:529`</small>
### `MediaAttachment` ### `MediaAttachment`
@ -308,7 +308,7 @@ func (s *PluginSDK) Memory() MemoryAPI
Memory returns the graph memory API (may be nil if not available). Memory returns the graph memory API (may be nil if not available).
<small>`plugin.go:409`</small> <small>`plugin.go:508`</small>
### `PersonProfile` ### `PersonProfile`
@ -338,7 +338,7 @@ func (s *PluginSDK) Social() SocialAPI
Social returns the social graph API (may be nil if not available). Social returns the social graph API (may be nil if not available).
<small>`plugin.go:444`</small> <small>`plugin.go:543`</small>
### `SocialRelation` ### `SocialRelation`
@ -366,7 +366,7 @@ func (s *PluginSDK) TextMemory() TextMemoryAPI
TextMemory returns the text memory API (may be nil if not available). TextMemory returns the text memory API (may be nil if not available).
<small>`plugin.go:416`</small> <small>`plugin.go:515`</small>
### `Triple` ### `Triple`

View File

@ -79,7 +79,7 @@ controls which events are delivered.
Subscribe(eventType EventType, handler EventHandler) func() Subscribe(eventType EventType, handler EventHandler) func()
``` ```
<small>`plugin.go:279`</small> <small>`plugin.go:378`</small>
## `IOInjector` ## `IOInjector`
@ -110,7 +110,7 @@ for routing the agent's response.
InjectInputMedia(source, channel, text string, blocks []ContentBlock) InjectInputMedia(source, channel, text string, blocks []ContentBlock)
``` ```
<small>`plugin.go:230`</small> <small>`plugin.go:329`</small>
### `IOInjector.InjectInputMediaOpts` ### `IOInjector.InjectInputMediaOpts`
@ -118,7 +118,7 @@ InjectInputMedia(source, channel, text string, blocks []ContentBlock)
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
``` ```
<small>`plugin.go:241`</small> <small>`plugin.go:340`</small>
### `IOInjector.InjectInputMediaSync` ### `IOInjector.InjectInputMediaSync`
@ -126,7 +126,7 @@ InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts I
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
``` ```
<small>`plugin.go:231`</small> <small>`plugin.go:330`</small>
### `IOInjector.InjectInputMediaSyncOpts` ### `IOInjector.InjectInputMediaSyncOpts`
@ -134,7 +134,7 @@ InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
``` ```
<small>`plugin.go:242`</small> <small>`plugin.go:341`</small>
### `IOInjector.InjectInputSync` ### `IOInjector.InjectInputSync`
@ -152,7 +152,7 @@ InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` | | [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` | | [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
<small>`plugin.go:226`</small> <small>`plugin.go:325`</small>
### `IOInjector.InjectInputSyncOpts` ### `IOInjector.InjectInputSyncOpts`
@ -160,7 +160,7 @@ InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
``` ```
<small>`plugin.go:240`</small> <small>`plugin.go:339`</small>
### `IOInjector.InjectInterruptMedia` ### `IOInjector.InjectInterruptMedia`
@ -168,7 +168,7 @@ InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
``` ```
<small>`plugin.go:232`</small> <small>`plugin.go:331`</small>
### `IOInjector.InjectInterruptMediaOpts` ### `IOInjector.InjectInterruptMediaOpts`
@ -176,7 +176,7 @@ InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
``` ```
<small>`plugin.go:243`</small> <small>`plugin.go:342`</small>
### `IOInjector.InjectInterruptText` ### `IOInjector.InjectInterruptText`
@ -184,7 +184,7 @@ InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, op
InjectInterruptText(source, channel, text string) InjectInterruptText(source, channel, text string)
``` ```
<small>`plugin.go:221`</small> <small>`plugin.go:320`</small>
### `IOInjector.InjectInterruptTextOpts` ### `IOInjector.InjectInterruptTextOpts`
@ -201,7 +201,7 @@ InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
<small>`plugin.go:239`</small> <small>`plugin.go:338`</small>
### `IOInjector.InjectText` ### `IOInjector.InjectText`
@ -209,7 +209,7 @@ InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
InjectText(source, channel, text string) InjectText(source, channel, text string)
``` ```
<small>`plugin.go:222`</small> <small>`plugin.go:321`</small>
### `IOInjector.InjectTextNoMemory` ### `IOInjector.InjectTextNoMemory`
@ -223,7 +223,7 @@ InjectTextNoMemory(source, channel, text string)
|---|---|---| |---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
<small>`plugin.go:223`</small> <small>`plugin.go:322`</small>
### `IOInjector.InjectTextOpts` ### `IOInjector.InjectTextOpts`
@ -236,7 +236,7 @@ InjectTextOpts(source, channel, text string, opts InjectOptions)
上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪), 上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。 保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
<small>`plugin.go:238`</small> <small>`plugin.go:337`</small>
### `IOInjector.SetToolBlocks` ### `IOInjector.SetToolBlocks`
@ -247,7 +247,7 @@ SetToolBlocks(blocks []ContentBlock)
SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条 SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。 tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
<small>`plugin.go:229`</small> <small>`plugin.go:328`</small>
## `KnowledgeAPI` ## `KnowledgeAPI`
@ -422,7 +422,7 @@ IsPluginDisabled(name string) bool
IsPluginDisabled 查询插件是否被禁用。 IsPluginDisabled 查询插件是否被禁用。
<small>`plugin.go:290`</small> <small>`plugin.go:389`</small>
### `PluginMgrAPI.ListLoadedPlugins` ### `PluginMgrAPI.ListLoadedPlugins`
@ -432,7 +432,7 @@ ListLoadedPlugins() []string
ListLoadedPlugins 列出已加载插件。 ListLoadedPlugins 列出已加载插件。
<small>`plugin.go:288`</small> <small>`plugin.go:387`</small>
### `PluginMgrAPI.ReloadOne` ### `PluginMgrAPI.ReloadOne`
@ -442,7 +442,7 @@ ReloadOne(name string) error
ReloadOne 重载单个插件(停止后重新加载)。 ReloadOne 重载单个插件(停止后重新加载)。
<small>`plugin.go:286`</small> <small>`plugin.go:385`</small>
## `SettingsAPI` ## `SettingsAPI`
@ -680,7 +680,7 @@ Append(evt TextEvent) error
type AudioURL struct { URL string `json:"url"` } type AudioURL struct { URL string `json:"url"` }
``` ```
<small>`plugin.go:867`</small> <small>`plugin.go:966`</small>
### `EffectiveProxyAuth` ### `EffectiveProxyAuth`
@ -692,13 +692,32 @@ EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHom
<small>`proxy.go:169`</small> <small>`proxy.go:169`</small>
### `ToolError.Error`
```go
func (e *ToolError) Error() string
```
Error 实现 error,便于工具同时走 (ToolError, error) 通道。
**示例插件里的真实用法**
| 插件 | 位置 | 代码 |
|---|---|---|
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:317` | `http.Error(w, "query/message.text required", http.StatusBadRequest)` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:175` | `http.Error(w, "", http.StatusMethodNotAllowed)` |
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:259` | `return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil` |
| [`browser`](../examples/index.md#browser) | `example/browser/plugin_test.go:15` | `if err == nil \|\| !strings.Contains(err.Error(), "timeout is required") {` |
<small>`plugin.go:264`</small>
### `ImageURL` ### `ImageURL`
```go ```go
type ImageURL struct { URL string `json:"url"` Detail string `json:"detail,omitempty"` } type ImageURL struct { URL string `json:"url"` Detail string `json:"detail,omitempty"` }
``` ```
<small>`plugin.go:862`</small> <small>`plugin.go:961`</small>
### `MemItem` ### `MemItem`
@ -708,7 +727,7 @@ type MemItem struct { Role string `json:"role"` Content string `json:"content"`
MemItem represents a memory item in stage context. MemItem represents a memory item in stage context.
<small>`plugin.go:179`</small> <small>`plugin.go:220`</small>
### `NormalizeProxyHost` ### `NormalizeProxyHost`
@ -732,7 +751,7 @@ type PluginSDK struct { name string regTool ToolRegistrar regStage StageRegistra
PluginSDK is the main API surface provided to plugins at runtime. PluginSDK is the main API surface provided to plugins at runtime.
It wraps tool registration, settings, memory, knowledge, LLM, and IO injection. It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
<small>`plugin.go:337`</small> <small>`plugin.go:436`</small>
### `ProxyAuthHomeAgent` ### `ProxyAuthHomeAgent`
@ -877,6 +896,64 @@ SDKVersion 是对外暴露的 SDK 版本号。
<small>`plugin.go:10`</small> <small>`plugin.go:10`</small>
### `ScenePolicyAuto`
```go
const ScenePolicyAuto
```
场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。
与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管
「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是
「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹
(通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。
默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自
v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因:
1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量
通道突然失去场景召回;
2. 「关」是少数意图(内部信噪通道),少数意图不该是默认——
与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。
该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。
但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70
strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间
(loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。
「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件
将来确实需要时的闸门。
<small>`plugin.go:98`</small>
### `ScenePolicyNone`
```go
const ScenePolicyNone
```
场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。
与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管
「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是
「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹
(通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。
默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自
v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因:
1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量
通道突然失去场景召回;
2. 「关」是少数意图(内部信噪通道),少数意图不该是默认——
与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。
该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。
但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70
strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间
(loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。
「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件
将来确实需要时的闸门。
<small>`plugin.go:99`</small>
### `PluginSDK.SetProxyRegistrar` ### `PluginSDK.SetProxyRegistrar`
```go ```go
@ -887,6 +964,24 @@ SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelR
<small>`proxy.go:309`</small> <small>`proxy.go:309`</small>
### `ToolError`
```go
type ToolError struct { // Field 是出错的参数字段名(参数校验失败时填)。 Field string `json:"field,omitempty"` // Reason 是机器可读的原因码: …
```
ToolError 描述一次工具调用的失败原因。
存在的理由:失败若只表达为文本,模型无法定位到字段,只能原样重试
(实测 cmd_run 失败率 34%~48%,全部源于同一个成因:参数被截断或
JSON 写坏,工具却只回报 "command is required" 这类与真因无关的错)。
⚠️ 零值语义:插件**不必**改用本类型。内核的失败识别同时兼容既有三种约定
({"error":…}、{"isError":true,…}、显式 error 返回),见 core.isToolError。
本类型是给**新写**的工具用的可选项,不是迁移要求。
<small>`plugin.go:252`</small>
### `PluginSDK.UnregisterOutputChannel` ### `PluginSDK.UnregisterOutputChannel`
!!! warning "仅内核内置插件可用" !!! warning "仅内核内置插件可用"
@ -898,7 +993,7 @@ func (s *PluginSDK) UnregisterOutputChannel(name string) error
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。 UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
<small>`plugin.go:544`</small> <small>`plugin.go:643`</small>
### `ValidProxyAuth` ### `ValidProxyAuth`
@ -923,6 +1018,16 @@ ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
<small>`proxy.go:180`</small> <small>`proxy.go:180`</small>
### `ValidScenePolicy`
```go
func ValidScenePolicy(policy string) bool
```
ValidScenePolicy 校验场面策略取值;空串等价于 ScenePolicyAuto。
<small>`plugin.go:103`</small>
### `ValidateProxyDef` ### `ValidateProxyDef`
```go ```go

View File

@ -193,5 +193,5 @@ sett 在 New 时一次性写入且无 setter,故不需要加锁。
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:114` | `s.Settings().RegisterDef(sdk.ConfigDef{` | | [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:114` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:29` | `s.Settings().RegisterDef(sdk.ConfigDef{` | | [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:29` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
<small>`plugin.go:406`</small> <small>`plugin.go:505`</small>

View File

@ -10,7 +10,7 @@
func (c *StageContext) IsResponded() bool func (c *StageContext) IsResponded() bool
``` ```
<small>`plugin.go:172`</small> <small>`plugin.go:213`</small>
### `StageContext.Lock` ### `StageContext.Lock`
@ -27,7 +27,7 @@ func (c *StageContext) Lock()
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:427` | `p.mu.Lock()` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:427` | `p.mu.Lock()` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:433` | `p.mu.Lock()` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:433` | `p.mu.Lock()` |
<small>`plugin.go:170`</small> <small>`plugin.go:211`</small>
### `StageContext.RLock` ### `StageContext.RLock`
@ -44,7 +44,7 @@ func (c *StageContext) RLock()
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:224` | `p.mu.RLock()` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:224` | `p.mu.RLock()` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1152` | `ctx.RLock()` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1152` | `ctx.RLock()` |
<small>`plugin.go:168`</small> <small>`plugin.go:209`</small>
### `StageContext.RUnlock` ### `StageContext.RUnlock`
@ -61,7 +61,7 @@ func (c *StageContext) RUnlock()
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:229` | `p.mu.RUnlock()` | | [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:229` | `p.mu.RUnlock()` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1155` | `ctx.RUnlock()` | | [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1155` | `ctx.RUnlock()` |
<small>`plugin.go:169`</small> <small>`plugin.go:210`</small>
### `PluginSDK.RegisterStage` ### `PluginSDK.RegisterStage`
@ -83,7 +83,7 @@ RegisterStage registers a handler for a pipeline stage.
| [`sanitizer`](../examples/index.md#sanitizer) | `example/sanitizer/plugin.go:52` | `s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {` | | [`sanitizer`](../examples/index.md#sanitizer) | `example/sanitizer/plugin.go:52` | `s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {` |
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:94` | `s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {` | | [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:94` | `s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {` |
<small>`plugin.go:472`</small> <small>`plugin.go:571`</small>
### `Stage` ### `Stage`
@ -103,7 +103,7 @@ type StageContext struct { mu sync.RWMutex RawMessage string UserID string Group
StageContext provides context for stage handlers. StageContext provides context for stage handlers.
<small>`plugin.go:148`</small> <small>`plugin.go:189`</small>
### `StageHandler` ### `StageHandler`
@ -123,7 +123,7 @@ type StageRegistrar func(stage Stage, handler StageHandler)
StageRegistrar registers a stage handler. StageRegistrar registers a stage handler.
<small>`plugin.go:308`</small> <small>`plugin.go:407`</small>
### `StageScope` ### `StageScope`
@ -133,7 +133,7 @@ type StageScope int
StageScope controls which events a stage handler receives. StageScope controls which events a stage handler receives.
<small>`plugin.go:294`</small> <small>`plugin.go:393`</small>
### `StageContext.Unlock` ### `StageContext.Unlock`
@ -150,5 +150,5 @@ func (c *StageContext) Unlock()
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:432` | `p.mu.Unlock()` | | [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:432` | `p.mu.Unlock()` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:523` | `p.mu.Unlock()` | | [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:523` | `p.mu.Unlock()` |
<small>`plugin.go:171`</small> <small>`plugin.go:212`</small>

View File

@ -14,7 +14,7 @@ ContentBlock 是多模态内容块(OpenAI 格式:text/image_url/audio_url)
插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM 插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。 请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
<small>`plugin.go:855`</small> <small>`plugin.go:954`</small>
### `PluginSDK.RegisterTool` ### `PluginSDK.RegisterTool`
@ -33,7 +33,7 @@ RegisterTool registers a tool that the LLM can call.
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:159` | `s.RegisterTool(tp+"generate", sdk.ToolDef{` | | [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:159` | `s.RegisterTool(tp+"generate", sdk.ToolDef{` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:47` | `s.RegisterTool(tp+"video", sdk.ToolDef{` | | [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:47` | `s.RegisterTool(tp+"video", sdk.ToolDef{` |
<small>`plugin.go:458`</small> <small>`plugin.go:557`</small>
### `ToolCall` ### `ToolCall`
@ -43,7 +43,7 @@ type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Plugin st
ToolCall represents a model's request to call a tool. ToolCall represents a model's request to call a tool.
<small>`plugin.go:186`</small> <small>`plugin.go:227`</small>
### `ToolDef` ### `ToolDef`
@ -53,7 +53,7 @@ type ToolDef struct { Name string `json:"name"` Plugin string `json:"plugin,omit
ToolDef describes a tool that the plugin exposes. ToolDef describes a tool that the plugin exposes.
<small>`plugin.go:203`</small> <small>`plugin.go:279`</small>
### `ToolHandler` ### `ToolHandler`
@ -73,5 +73,5 @@ type ToolResult struct { CallID string `json:"call_id"` Name string `json:"name"
ToolResult represents the result of a tool call. ToolResult represents the result of a tool call.
<small>`plugin.go:194`</small> <small>`plugin.go:235`</small>

File diff suppressed because it is too large Load Diff

View File

@ -8,15 +8,15 @@ SDK 仓 `example/` 下有多个**真实可编译**的示例插件,覆盖工具
## `a2a` ## `a2a`
用到的 API:`InjectInputSync` · `Lock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock` 用到的 API:`Error` · `InjectInputSync` · `Lock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
## `acp` ## `acp`
用到的 API:`InjectInputSync` · `Lock` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock` 用到的 API:`Error` · `InjectInputSync` · `Lock` · `RLock` · `RUnlock` · `RegisterInputChannel` · `RegisterOutputChannel` · `RegisterTool` · `SetAutoRestart` · `Settings` · `Unlock`
## `ai_image` ## `ai_image`
用到的 API:`RegisterTool` · `SetAutoRestart` · `Settings` 用到的 API:`Error` · `RegisterTool` · `SetAutoRestart` · `Settings`
## `bili` ## `bili`
@ -24,7 +24,7 @@ SDK 仓 `example/` 下有多个**真实可编译**的示例插件,覆盖工具
## `browser` ## `browser`
用到的 API:`InjectInterruptTextOpts` · `InjectTextNoMemory` · `Lock` · `RegisterInputChannel` · `Unlock` 用到的 API:`Error` · `InjectInterruptTextOpts` · `InjectTextNoMemory` · `Lock` · `RegisterInputChannel` · `Unlock`
## `calendar` ## `calendar`

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

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

View File

@ -29,5 +29,6 @@
- [环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started.md): hmapdev 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它, - [环境与工具链](https://sdk.homeagent.jianfgit.xyz/guide/getting-started.md): hmapdev 是 SDK 仓提供的统一插件开发工具链,Go 与 Lua 两种插件都用它,
- [多平台构建](https://sdk.homeagent.jianfgit.xyz/guide/multi-platform.md): hmapdev build 默认 bundle 模式,一次产出含三个平台的单个 - [多平台构建](https://sdk.homeagent.jianfgit.xyz/guide/multi-platform.md): hmapdev build 默认 bundle 模式,一次产出含三个平台的单个
- [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging.md): hmapdev build 一次完成编译与打包,产出 - [打包与发布](https://sdk.homeagent.jianfgit.xyz/guide/packaging.md): hmapdev build 一次完成编译与打包,产出
- [场景记忆(Scene Memory)](https://sdk.homeagent.jianfgit.xyz/guide/scene-memory.md): > 场景式记忆是内核 v1
- [受限 SDK 与安全](https://sdk.homeagent.jianfgit.xyz/guide/security.md): 外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层安全边界: - [受限 SDK 与安全](https://sdk.homeagent.jianfgit.xyz/guide/security.md): 外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层安全边界:
- [版本与兼容](https://sdk.homeagent.jianfgit.xyz/versions.md): SDK 版本跟随内核的中版本,patch 位恒为 - [版本与兼容](https://sdk.homeagent.jianfgit.xyz/versions.md): SDK 版本跟随内核的中版本,patch 位恒为

View File

@ -120,6 +120,7 @@ nav:
- 仅内置插件可用: api/builtin-only.md - 仅内置插件可用: api/builtin-only.md
- 指南: - 指南:
- 能力边界(哪些 API 外部可用): guide/capability-boundary.md - 能力边界(哪些 API 外部可用): guide/capability-boundary.md
- 场景记忆(按场合召回): guide/scene-memory.md
- 打包与发布: guide/packaging.md - 打包与发布: guide/packaging.md
- 多平台构建: guide/multi-platform.md - 多平台构建: guide/multi-platform.md
- 受限 SDK 与安全: guide/security.md - 受限 SDK 与安全: guide/security.md

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -74,6 +74,40 @@ func ValidRecallPolicy(policy string) bool {
return false return false
} }
// 场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。
//
// 与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管
// 「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是
// 「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹
// (通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。
//
// 默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自
// v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因:
// 1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量
// 通道突然失去场景召回;
// 2. 「关」是少数意图(内部信噪通道),少数意图不该是默认——
// 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。
//
// 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。
// 但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70
// strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间
// (loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。
// 「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件
// 将来确实需要时的闸门。
const (
ScenePolicyAuto = "auto"
ScenePolicyNone = "none"
)
// ValidScenePolicy 校验场面策略取值;空串等价于 ScenePolicyAuto。
func ValidScenePolicy(policy string) bool {
switch policy {
case "", ScenePolicyAuto, ScenePolicyNone:
return true
}
return false
}
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。 // InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
// //
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致, // 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
@ -102,7 +136,11 @@ type InjectOptions struct {
// 空串 = 默认(输入/注入 auto,即保持既有「每条输入都召回」的行为); // 空串 = 默认(输入/注入 auto,即保持既有「每条输入都召回」的行为);
// RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。 // RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。
RecallPolicy string RecallPolicy string
CleanerName string // ScenePolicy 声明此次注入是否参与场面识别(场景式记忆)。
// 空串 = 默认参与(保持既有行为);ScenePolicyNone 显式关闭,
// 适用于不产生任何场面指纹的纯内部信号(心跳、自循环、内部状态)。
ScenePolicy string
CleanerName string
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。 // Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
// //
@ -132,6 +170,7 @@ const (
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用 // Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪) // ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
// RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回) // RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
// ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
// //
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。 // JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单—— // 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
@ -142,6 +181,8 @@ type ChannelDef struct {
ContextPolicy string `json:"context_policy,omitempty"` ContextPolicy string `json:"context_policy,omitempty"`
// RecallPolicy 见 InjectOptions.RecallPolicy;空串等价 auto(保持既有行为)。 // RecallPolicy 见 InjectOptions.RecallPolicy;空串等价 auto(保持既有行为)。
RecallPolicy string `json:"recall_policy,omitempty"` RecallPolicy string `json:"recall_policy,omitempty"`
// ScenePolicy 见 InjectOptions.ScenePolicy;空串等价 auto(保持既有行为)。
ScenePolicy string `json:"scene_policy,omitempty"`
} }
// StageContext provides context for stage handlers. // StageContext provides context for stage handlers.
@ -199,6 +240,41 @@ type ToolResult struct {
Result interface{} `json:"result"` Result interface{} `json:"result"`
} }
// ToolError 描述一次工具调用的失败原因。
//
// 存在的理由:失败若只表达为文本,模型无法定位到字段,只能原样重试
// (实测 cmd_run 失败率 34%~48%,全部源于同一个成因:参数被截断或
// JSON 写坏,工具却只回报 "command is required" 这类与真因无关的错)。
//
// ⚠️ 零值语义:插件**不必**改用本类型。内核的失败识别同时兼容既有三种约定
// ({"error":…}、{"isError":true,…}、显式 error 返回),见 core.isToolError。
// 本类型是给**新写**的工具用的可选项,不是迁移要求。
type ToolError struct {
// Field 是出错的参数字段名(参数校验失败时填)。
Field string `json:"field,omitempty"`
// Reason 是机器可读的原因码:required / type / unauthorized / timeout / not_found。
Reason string `json:"reason"`
// Detail 是人类可读的补充说明。
Detail string `json:"detail,omitempty"`
// Hint 是给模型的可执行指引(该改什么、不要重试什么)。
Hint string `json:"hint,omitempty"`
}
// Error 实现 error,便于工具同时走 (ToolError, error) 通道。
func (e *ToolError) Error() string {
if e == nil {
return ""
}
s := e.Reason
if e.Field != "" {
s = e.Field + ": " + s
}
if e.Detail != "" {
s += " (" + e.Detail + ")"
}
return s
}
// ToolDef describes a tool that the plugin exposes. // ToolDef describes a tool that the plugin exposes.
type ToolDef struct { type ToolDef struct {
Name string `json:"name"` Name string `json:"name"`
@ -212,6 +288,29 @@ type ToolDef struct {
// ""(默认 none) / RecallPolicyNone / RecallPolicyAuto。 // ""(默认 none) / RecallPolicyNone / RecallPolicyAuto。
// 默认 none:多数工具输出是噪声;需要「取回真实内容后据它召回」的工具(如 qq_get_message)应显式声明 auto。 // 默认 none:多数工具输出是噪声;需要「取回真实内容后据它召回」的工具(如 qq_get_message)应显式声明 auto。
RecallPolicy string `json:"recall_policy,omitempty"` RecallPolicy string `json:"recall_policy,omitempty"`
// ParallelSafe 声明此工具**可以被并发执行**(同一批多个 tool_call 同时跑)。
//
// ⚠️ 零值 false 是刻意的:存量插件不改一行就得到**保守**行为
//(整批串行),不会因升级被意外并发。声明它是**责任**而非特权。
//
// 判据(三者皆满足才可并发):
// · handler 自身线程安全(不持有跨调用的可变状态)
// · 不与同批其它工具争抢同一资源(SQLite 写、设备、同一输出通道)
// · 执行顺序无关(顺序敏感的工具应留 false,由内核保序)
ParallelSafe bool `json:"parallel_safe,omitempty"`
// Serial 声明本工具**必须**串行 —— ParallelSafe 的反向标记。
//
// 为什么需要它:ParallelSafe 的零值 false 已经表达"安全/串行",
// 插件无法区分"我没想过"和"我确认过必须串行"。一旦工具作者需要
// 把"这里**故意**串行,是有原因的"写进代码(而不只是没填),
// 这个区分就是必需的 —— 否则只能靠命名约定传递意图。
//
// 适用场景:读操作但有隐含顺序约束(终端 read/resize 这类共享会话
// 状态)、或写操作虽已加锁但需要串行以获得可预测的交错顺序。
//
// 判据优先级:**Serial 胜出**。显式声明"必须串行"不允许被
// ParallelSafe 或任何默认值覆盖。
Serial bool `json:"serial,omitempty"`
} }
// IOInjector provides methods for injecting input and interrupts into the agent pipeline. // IOInjector provides methods for injecting input and interrupts into the agent pipeline.