From b6a66c57fe0d6e5f56e6ed0f1e1c60e2f1153a06 Mon Sep 17 00:00:00 2001 From: HomeAgent Agent Date: Sun, 13 Sep 2026 19:56:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(lua):=20Lua=20=E6=8F=92=E4=BB=B6=E6=A1=A5?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=AF=B9=E9=BD=90=20SDK=201.3.0=EF=BC=88?= =?UTF-8?q?=E5=AA=92=E4=BD=93/=E6=B3=A8=E5=85=A5=E6=A0=87=E5=BF=97?= =?UTF-8?q?=E4=BD=8D/=E4=BC=98=E5=85=88=E7=BA=A7/=E4=BA=8B=E4=BB=B6/?= =?UTF-8?q?=E9=80=9A=E9=81=93=E6=B3=A8=E9=94=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内核 Lua 桥(internal/plugin/lua_plugin.go)此前停在 v0.8.0 时代能力面, 1.1/1.2/1.3 新增能力只在 Go 侧存在,而 PLUGIN_DEV.md 宣称『能力完全对齐』。 本补丁把 Lua 侧补齐到与公开 SDK 1.3.0 对齐: - 1.1 媒体:memory.commit 支持 sentence_text/media_digests; doc.insert_with_media + attachments;text_memory.append attachments; set_tool_blocks / inject_input_media(_sync) / inject_interrupt_media。 - 1.2 注入语义:inject_input_sync(_opts)、六个 *_opts 变体 (no_memory/context_policy/cleaner_name/priority); ToolDef/ChannelDef 解析 context_policy。 - 1.3 优先级与动态通道:priority 常量透传;unregister_output_channel。 - StageContext 暴露 reasoning_content/context_msgs/token_usage/memory/extra/errors。 - 新增 sdk.events.subscribe 与 sdk.plugin_mgr.*。 - sdk.lua mock 同步(单一事实源在 SDK 仓 sdk/lua/sdk.lua,内核副本由 third_party/homeagent-sdk/scripts/sync-lua-sdk.sh 同步)。 契约测试(lua_surface_test.go): - 守住内核内嵌 mock 与 SDK 仓事实源一致; - 守住 mock 承诺的每个函数都有运行时 RawSetString 绑定; - 覆盖 opts/media/attachments 解析与 context_policy 透传。 文档:中英 PLUGIN_DEV.md 的 Lua API 表补齐并改为『对齐至 SDK 1.3.0』。 --- .gitignore | 1 + assets/docs/en/PLUGIN_DEV.md | 34 +- assets/docs/zh/PLUGIN_DEV.md | 34 +- internal/lua/sdk/sdk.lua | 80 +++++ internal/plugin/lua_plugin.go | 296 ++++++++++++++-- internal/plugin/lua_plugin_test.go | 2 +- internal/plugin/lua_surface_test.go | 188 ++++++++++ internal/plugin/plugin.go | 42 ++- third_party/homeagent-sdk/sdk/lua/sdk.lua | 403 ++++++++++++++++++++++ 9 files changed, 1028 insertions(+), 52 deletions(-) create mode 100644 internal/plugin/lua_surface_test.go create mode 100644 third_party/homeagent-sdk/sdk/lua/sdk.lua diff --git a/.gitignore b/.gitignore index dc18183..5ed7462 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ cmd/gui/dist/ third_party/homeagent-sdk/bin/ third_party/homeagent-sdk/tools/ third_party/homeagent-sdk/package/ +third_party/homeagent-sdk/scripts/ third_party/homeagent-sdk/.gitignore third_party/homeagent-sdk/README* third_party/homeagent-sdk/example/ diff --git a/assets/docs/en/PLUGIN_DEV.md b/assets/docs/en/PLUGIN_DEV.md index fd30b9e..2276c1f 100644 --- a/assets/docs/en/PLUGIN_DEV.md +++ b/assets/docs/en/PLUGIN_DEV.md @@ -599,23 +599,26 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go ### Lua SDK API -The `sdk.*` API of Lua plugins is fully aligned with external plugins (toolchain-built `plugin.bin` subprocesses): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors. +The `sdk.*` API of Lua plugins is aligned with external plugins (toolchain-built `plugin.bin` subprocesses) up to **SDK 1.3.0** (requires kernel **1.4.0+**, also backfilled by the Lua-alignment patch `v1.3.11`): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors. + +> Historical note: the 1.1–1.3 media / inject-flags / priority capabilities were long available only on the Go side and were silently missing on the Lua side. They are now fully aligned, guarded by the contract test in `internal/plugin/lua_surface_test.go` (every function promised by the mock has a runtime binding). **Registration** | Function | Description | |----------|-------------| | `sdk.log(level, msg)` | Log output | -| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `cleaner` | +| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `context_policy` (`"none"`/`"prune"`), `cleaner` | | `sdk.register_stage(stage, handler, scope)` | Register stage hook; `scope` is `nil`/`"global"` (default) or `"own_tools"` (fires only for `before_toolcall`/`after_toolcall` when the tool belongs to this plugin) | | `sdk.register_api(name)` | Register API | -| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `cleaner` | +| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `context_policy`, `cleaner` | | `sdk.register_input_channel(name, def)` | Register input channel; `def` as above | +| `sdk.unregister_output_channel(name)` | Unregister an output channel (for resource-bound channels, e.g. remote devices); returns `(nil, err)` | | `sdk.set_auto_restart(enabled)` | Auto-restart the plugin after a crash | **Stage hook context** -Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `final_text`, `no_memory`, `response` (when responded), `tool_calls`, `tool_results`. +Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `reasoning_content`, `final_text`, `no_memory`, `context_msgs`, `token_usage`, `memory`, `extra`, `errors`, `response` (when responded), `tool_calls`, `tool_results`. **Stage writeback**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with subprocess external-plugin capability): @@ -644,20 +647,37 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`, | `sdk.inject_text(source, channel, text)` | Deliver text message | | `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery | | `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation | +| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | Delivery with flags; `opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` | +| `sdk.inject_input_sync(source, channel, text)` | Inject synchronously and wait for this turn's reply; returns `(reply, err)`, reply is nil when there is none | +| `sdk.inject_input_sync_opts(source, channel, text, opts)` | Same, with flags | +| `sdk.inject_input_media(source, channel, text, blocks)` | Inject text + multimodal content blocks | +| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | Same, with flags | +| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | Synchronous media injection; returns `(reply, err)` | +| `sdk.inject_interrupt_media(source, channel, text, blocks)` | Interrupt delivery with media | +| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | Same, with flags | +| `sdk.set_tool_blocks(blocks)` | Set multimodal blocks carried by the next tool message (lets the model see images / hear audio) | + +Each `blocks` item: `{ type="text", text="..." }`, `{ type="image_url", image_url={ url="...", detail="high" } }`, or `{ type="audio_url", audio_url={ url="..." } }`. An absent `opts` is the zero value (recorded in memory + no pruning), equivalent to the three-argument form. **Data APIs (aligned with subprocess external plugins, all return `(result, err)`)** | Sub-table | Functions | |-----------|-----------| -| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})`, `introspect()`, `merge(source, target)`, `purge(criteria, hard)` | -| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `remove(id)`, `stats()` | +| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})` (triple supports `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`), `introspect()`, `merge(source, target)`, `purge(criteria, hard)` | +| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `insert_with_media(doc, attachments)`, `remove(id)`, `stats()` | | `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` | -| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` | +| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` | | `sdk.llm.*` | `list_sources()`, `set_source(name)`, `current_source()` | | `sdk.social.*` (read-only) | `get_person(name)`, `get_network(name, depth)`, `get_trait(name, trait)`, `get_relations(name)`, `list_persons()` | +| `sdk.events.*` | `subscribe(event_type, handler)` → returns an unsubscribe function; handler receives `{type,source,timestamp,payload}` | +| `sdk.plugin_mgr.*` | `reload_one(name)`, `list_loaded()`, `is_disabled(name)` | | `sdk.json.*` | `encode(val)`, `decode(str)` | | `sdk.http.*` | `get(url)`, `post(url, body, content_type)` | +Each `attachments` item: `{ digest=, mime=, name=, data= }`; with `data` it is new content (stored in the content-addressed store), with only `digest` it references existing content. + +> The `sdk.events.subscribe` callback runs on the kernel's event-publishing goroutine, and Lua is single-state + mutex-guarded — **do only lightweight forwarding inside the callback; never block**, or every call of this plugin will stall. + --- : diff --git a/assets/docs/zh/PLUGIN_DEV.md b/assets/docs/zh/PLUGIN_DEV.md index 1165206..e3d5be5 100644 --- a/assets/docs/zh/PLUGIN_DEV.md +++ b/assets/docs/zh/PLUGIN_DEV.md @@ -592,23 +592,26 @@ lua main.lua ### Lua SDK API -Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。 +Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力对齐至 **SDK 1.3.0**(需内核 **1.4.0+**,也在 `v1.3.11` 的 Lua 对齐补丁中回填):注册类函数调用即时报错(抛 Lua error),数据类函数统一返回 `(result, err)`,`err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI)返回空值而非报错。 + +> 历史提醒:1.1–1.3 的媒体/注入标志位/优先级能力曾长期只在 Go 侧,Lua 侧静默缺失。现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住「mock 承诺的每个函数都有运行时绑定」。 **注册类** | 函数 | 说明 | |------|------| | `sdk.log(level, msg)` | 日志输出 | -| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`cleaner` | +| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description`、`parameters`、`no_memory`、`context_policy`(`"none"`/`"prune"`)、`cleaner` | | `sdk.register_stage(stage, handler, scope)` | 注册阶段钩子;`scope` 为 `nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) | | `sdk.register_api(name)` | 注册 API | -| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`cleaner` | +| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory`、`context_policy`、`cleaner` | | `sdk.register_input_channel(name, def)` | 注册输入通道;`def` 同上 | +| `sdk.unregister_output_channel(name)` | 注销输出通道(随资源生灭的动态通道,如远程设备);返回 `(nil, err)` | | `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 | **阶段钩子上下文** -`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`final_text`、`no_memory`、`response`(已响应时)、`tool_calls`、`tool_results`。 +`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message`、`user_id`、`group_id`、`phase`、`llm_text`、`reasoning_content`、`final_text`、`no_memory`、`context_msgs`、`token_usage`、`memory`、`extra`、`errors`、`response`(已响应时)、`tool_calls`、`tool_results`。 **Stage 写回**:handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐): @@ -637,20 +640,37 @@ end) | `sdk.inject_text(source, channel, text)` | 投递文本消息 | | `sdk.inject_interrupt(source, channel, text)` | 中断投递 | | `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 | +| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | 带标志位投递;`opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` | +| `sdk.inject_input_sync(source, channel, text)` | 同步注入并等本轮回复;返回 `(reply, err)`,无回复时 reply 为 nil | +| `sdk.inject_input_sync_opts(source, channel, text, opts)` | 同上带标志位 | +| `sdk.inject_input_media(source, channel, text, blocks)` | 注入文本 + 多模态内容块 | +| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 | +| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | 带媒体的同步注入;返回 `(reply, err)` | +| `sdk.inject_interrupt_media(source, channel, text, blocks)` | 带媒体的中断注入 | +| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 | +| `sdk.set_tool_blocks(blocks)` | 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频) | + +`blocks` 每项形如:`{ type="text", text="..." }`、`{ type="image_url", image_url={ url="...", detail="high" } }`、`{ type="audio_url", audio_url={ url="..." } }`。`opts` 缺省即零值(记入记忆 + 不裁剪),与三参数版本等价。 **数据类(与子进程外部插件对齐,均返回 `(result, err)`)** | 子表 | 函数 | |------|------| -| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` | -| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`remove(id)`、`stats()` | +| `sdk.memory.*` | `recall(query, depth)`、`commit({triples})`(triple 支持 `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`)、`introspect()`、`merge(source, target)`、`purge(criteria, hard)` | +| `sdk.doc.*` | `query(text, top_k)`、`insert({id,title,content})`、`insert_with_media(doc, attachments)`、`remove(id)`、`stats()` | | `sdk.knowledge.*` | `search(query, limit)`、`add(tag, content)`、`list()` | -| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` | +| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` | | `sdk.llm.*` | `list_sources()`、`set_source(name)`、`current_source()` | | `sdk.social.*`(只读) | `get_person(name)`、`get_network(name, depth)`、`get_trait(name, trait)`、`get_relations(name)`、`list_persons()` | +| `sdk.events.*` | `subscribe(event_type, handler)` → 返回取消订阅函数;handler 收到 `{type,source,timestamp,payload}` | +| `sdk.plugin_mgr.*` | `reload_one(name)`、`list_loaded()`、`is_disabled(name)` | | `sdk.json.*` | `encode(val)`、`decode(str)` | | `sdk.http.*` | `get(url)`、`post(url, body, content_type)` | +`attachments` 每项:`{ digest=, mime=, name=, data= }`;带 `data` 是新内容(落进内容寻址存储),只带 `digest` 是引用已有内容。 + +> `sdk.events.subscribe` 的回调在内核事件发布 goroutine 上执行,且 Lua 是单状态 + 互斥锁——**回调内只做轻量转发,不可阻塞**,否则会卡死本插件的全部调用。 + --- : diff --git a/internal/lua/sdk/sdk.lua b/internal/lua/sdk/sdk.lua index 71f0b6e..2f4dfb9 100644 --- a/internal/lua/sdk/sdk.lua +++ b/internal/lua/sdk/sdk.lua @@ -69,6 +69,65 @@ function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end +-- !impl +-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" } +-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。 +function sdk.inject_text_opts(source, channel, text, opts) + print("[lua-plugin] inject_text_opts: " .. tostring(source)) +end + +-- !impl +function sdk.inject_interrupt_opts(source, channel, text, opts) + print("[lua-plugin] inject_interrupt_opts: " .. tostring(source)) +end + +-- !impl +-- 同步注入:等待本轮回复 -> (reply, err);无回复时 reply 为 nil。 +function sdk.inject_input_sync(source, channel, text) return nil, nil end + +-- !impl +function sdk.inject_input_sync_opts(source, channel, text, opts) return nil, nil end + +-- !impl +-- blocks: ContentBlock 数组,见 sdk.inject_input_media。 +-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。 +function sdk.set_tool_blocks(blocks) + print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0)) +end + +-- !impl +-- blocks 每项:{ type="text", text="..." } +-- | { type="image_url", image_url={ url="...", detail="high" } } +-- | { type="audio_url", audio_url={ url="..." } } +function sdk.inject_input_media(source, channel, text, blocks) + print("[lua-plugin] inject_input_media: " .. tostring(source)) +end + +-- !impl +function sdk.inject_input_media_opts(source, channel, text, blocks, opts) + print("[lua-plugin] inject_input_media_opts: " .. tostring(source)) +end + +-- !impl +function sdk.inject_input_media_sync(source, channel, text, blocks) return nil, nil end + +-- !impl +function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts) return nil, nil end + +-- !impl +function sdk.inject_interrupt_media(source, channel, text, blocks) + print("[lua-plugin] inject_interrupt_media: " .. tostring(source)) +end + +-- !impl +function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts) + print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source)) +end + +-- !impl +-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。 +function sdk.unregister_output_channel(name) return nil, nil end + -- !impl -- enabled: true/false,崩溃时内核自动拉起 function sdk.set_auto_restart(enabled) @@ -101,6 +160,9 @@ function sdk.doc.query(text, top_k) return {} end -- doc: { id=, title=, content= } function sdk.doc.insert(doc) return nil end -- !impl +-- attachments 每项:{ digest=, mime=, name=, data= } +function sdk.doc.insert_with_media(doc, attachments) return nil end +-- !impl function sdk.doc.remove(id) return nil end -- !impl function sdk.doc.stats() return {} end @@ -174,6 +236,24 @@ function sdk.settings.dump() return {} end -- !impl function sdk.settings.plugins() return {} end +-- ============ events(只读订阅) ============ +-- !impl +-- subscribe(event_type, handler) -> unsubscribe() +-- handler 收到 { type=, source=, timestamp=, payload= }; +-- 回调在其内核事件发布 goroutine 上执行,只做轻量转发,不可阻塞(Lua 单状态 + 互斥锁)。 +sdk.events = {} +function sdk.events.subscribe(event_type, handler) + print("[lua-plugin] events.subscribe: " .. tostring(event_type)) + return function() end +end + +-- ============ plugin_mgr ============ +-- !impl +sdk.plugin_mgr = {} +function sdk.plugin_mgr.reload_one(name) return nil end +function sdk.plugin_mgr.list_loaded() return {} end +function sdk.plugin_mgr.is_disabled(name) return false end + -- json utils (pure Lua) sdk.json = {} diff --git a/internal/plugin/lua_plugin.go b/internal/plugin/lua_plugin.go index f724967..6e60ced 100644 --- a/internal/plugin/lua_plugin.go +++ b/internal/plugin/lua_plugin.go @@ -1,6 +1,7 @@ package plugin import ( + "encoding/base64" "encoding/json" "fmt" "io" @@ -9,9 +10,10 @@ import ( "strings" "sync" - lua "github.com/yuin/gopher-lua" luaSDK "gitcode.com/JianFeeeee/HomeAgent/internal/lua/sdk" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" + lua "github.com/yuin/gopher-lua" ) type toolReg struct { @@ -287,6 +289,19 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS return 0 })) + // pushReply 统一同步注入的返回约定:非空回复返回 (reply, nil), + // 无回复返回 (nil, nil),与数据类 API 的 (result, err) 约定一致。 + pushReply := func(reply string) int { + if reply == "" { + L.Push(lua.LNil) + L.Push(lua.LNil) + return 2 + } + L.Push(lua.LString(reply)) + L.Push(lua.LNil) + return 2 + } + t.RawSetString("inject_text", L.NewFunction(func(L *lua.LState) int { s.InjectText(L.CheckString(1), L.CheckString(2), L.CheckString(3)) return 0 @@ -300,6 +315,67 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS return 0 })) + // ---- 1.2.0 注入标志位(no_memory / context_policy / cleaner_name / priority)---- + t.RawSetString("inject_text_opts", L.NewFunction(func(L *lua.LState) int { + s.InjectTextOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), parseInjectOptions(L, 4)) + return 0 + })) + t.RawSetString("inject_interrupt_opts", L.NewFunction(func(L *lua.LState) int { + s.InjectInterruptTextOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), parseInjectOptions(L, 4)) + return 0 + })) + + // ---- 同步注入:注入后等待本轮回复,返回 (reply, err) ---- + // 注意:内置 SDK 的同名 InjectInputSync 是 (eventType, payload) 形态并遮蔽了 + // 公共 SDK 的三参文本版本,故这里显式走 PluginSDK 的公共方法。 + t.RawSetString("inject_input_sync", L.NewFunction(func(L *lua.LState) int { + return pushReply(s.PluginSDK.InjectInputSync(L.CheckString(1), L.CheckString(2), L.CheckString(3))) + })) + t.RawSetString("inject_input_sync_opts", L.NewFunction(func(L *lua.LState) int { + return pushReply(s.InjectInputSyncOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), parseInjectOptions(L, 4))) + })) + + // ---- 多模态注入(1.1.0):内容块随下一次 LLM 请求送达 ---- + t.RawSetString("set_tool_blocks", L.NewFunction(func(L *lua.LState) int { + s.SetToolBlocks(luaToContentBlocks(L, 1)) + return 0 + })) + t.RawSetString("inject_input_media", L.NewFunction(func(L *lua.LState) int { + s.InjectInputMedia(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4)) + return 0 + })) + t.RawSetString("inject_input_media_opts", L.NewFunction(func(L *lua.LState) int { + s.InjectInputMediaOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4), parseInjectOptions(L, 5)) + return 0 + })) + t.RawSetString("inject_input_media_sync", L.NewFunction(func(L *lua.LState) int { + return pushReply(s.InjectInputMediaSync(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4))) + })) + t.RawSetString("inject_input_media_sync_opts", L.NewFunction(func(L *lua.LState) int { + return pushReply(s.InjectInputMediaSyncOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4), parseInjectOptions(L, 5))) + })) + t.RawSetString("inject_interrupt_media", L.NewFunction(func(L *lua.LState) int { + s.InjectInterruptMedia(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4)) + return 0 + })) + t.RawSetString("inject_interrupt_media_opts", L.NewFunction(func(L *lua.LState) int { + s.InjectInterruptMediaOpts(L.CheckString(1), L.CheckString(2), L.CheckString(3), luaToContentBlocks(L, 4), parseInjectOptions(L, 5)) + return 0 + })) + + // ---- 1.3.0 动态输出通道注销:随资源生灭的通道(如远程设备)必须能注销, + // 否则 output_list_channels 会一直列着死通道骗模型。 ---- + t.RawSetString("unregister_output_channel", L.NewFunction(func(L *lua.LState) int { + if err := s.UnregisterOutputChannel(L.CheckString(1)); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } + L.Push(lua.LNil) + L.Push(lua.LNil) + return 2 + })) + // ---- 数据类 API(与 C ABI 外部插件面完全对齐)---- // 约定:结果型返回 (result, err),void 型返回 (nil, err),成功时 err 为 nil。 @@ -373,13 +449,21 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS if tbl := L.OptTable(1, nil); tbl != nil { tbl.ForEach(func(_, v lua.LValue) { if t2, ok := v.(*lua.LTable); ok { + // sentence_text / media_digests 是媒体绑定链的必经环节: + // 媒体引用挂在句子上,漏掉这两个字段会让图片永远绑不上记忆。 + var digests []string + if mt, ok := t2.RawGetString("media_digests").(*lua.LTable); ok { + mt.ForEach(func(_, e lua.LValue) { digests = append(digests, e.String()) }) + } triples = append(triples, sdk.Triple{ - Subject: t2.RawGetString("subject").String(), - Relation: t2.RawGetString("relation").String(), - Object: t2.RawGetString("object").String(), - Confidence: float64(lua.LVAsNumber(t2.RawGetString("confidence"))), - SubjectType: t2.RawGetString("subject_type").String(), - ObjectType: t2.RawGetString("object_type").String(), + Subject: t2.RawGetString("subject").String(), + Relation: t2.RawGetString("relation").String(), + Object: t2.RawGetString("object").String(), + Confidence: float64(lua.LVAsNumber(t2.RawGetString("confidence"))), + SubjectType: t2.RawGetString("subject_type").String(), + ObjectType: t2.RawGetString("object_type").String(), + SentenceText: t2.RawGetString("sentence_text").String(), + MediaDigests: digests, }) } }) @@ -442,12 +526,17 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS })) docTbl.RawSetString("insert", L.NewFunction(func(L *lua.LState) int { if dm := s.DocMemory(); dm != nil { - tbl := L.CheckTable(1) - if err := dm.Insert(&sdk.Doc{ - ID: tbl.RawGetString("id").String(), - Title: tbl.RawGetString("title").String(), - Content: tbl.RawGetString("content").String(), - }); err != nil { + if err := dm.Insert(docFromLua(L.CheckTable(1))); err != nil { + return pushErr(err) + } + } + return pushNil() + })) + // insert_with_media(1.1.0):文档直接持有媒体块,文档向量融合其原生向量, + // 图片按自己的向量被召回,不依赖任何生成的描述文本。 + docTbl.RawSetString("insert_with_media", L.NewFunction(func(L *lua.LState) int { + if dm := s.DocMemory(); dm != nil { + if err := dm.InsertWithMedia(docFromLua(L.CheckTable(1)), luaToAttachments(L, L.Get(2))); err != nil { return pushErr(err) } } @@ -503,10 +592,11 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS if tmem := s.TextMemory(); tmem != nil { tbl := L.CheckTable(1) if err := tmem.Append(sdk.TextEvent{ - Role: tbl.RawGetString("role").String(), - Content: tbl.RawGetString("content").String(), - Timestamp: int64(lua.LVAsNumber(tbl.RawGetString("timestamp"))), - Channel: tbl.RawGetString("channel").String(), + Role: tbl.RawGetString("role").String(), + Content: tbl.RawGetString("content").String(), + Timestamp: int64(lua.LVAsNumber(tbl.RawGetString("timestamp"))), + Channel: tbl.RawGetString("channel").String(), + Attachments: luaToAttachments(L, tbl.RawGetString("attachments")), }); err != nil { return pushErr(err) } @@ -700,6 +790,55 @@ func replaceSDKReal(L *lua.LState, t *lua.LTable, plg *luaPlugin, s *sdk.PluginS } return pushVal([]interface{}{}) })) + + // ---- sdk.events.*(只读事件订阅,与外部插件的 Events() 对齐)---- + // 回调在内核事件发布 goroutine 上执行,必须只做轻量转发(Lua 单状态 + 互斥锁); + // 阻塞会卡死本插件的全部调用。返回一个取消订阅函数。 + evTbl := subTable("events") + evTbl.RawSetString("subscribe", L.NewFunction(func(L *lua.LState) int { + eventType := L.CheckString(1) + fn := L.CheckFunction(2) + sub := s.Events() + if sub == nil { + return pushErr(fmt.Errorf("events unavailable")) + } + unsub := sub.Subscribe(pubsdk.EventType(eventType), func(evt *pubsdk.Event) { + plg.mu.Lock() + defer plg.mu.Unlock() + L2 := plg.L + tbl := L2.NewTable() + tbl.RawSetString("type", lua.LString(string(evt.Type))) + tbl.RawSetString("source", lua.LString(evt.Source)) + tbl.RawSetString("timestamp", lua.LNumber(evt.Timestamp)) + tbl.RawSetString("payload", goValueToLua(L2, evt.Payload)) + L2.Push(fn) + L2.Push(tbl) + if err := L2.PCall(1, 0, nil); err != nil { + fmt.Printf("[lua-plugin/%s] event handler error: %v\n", plg.name, err) + } + }) + L.Push(L.NewFunction(func(L *lua.LState) int { + unsub() + return 0 + })) + L.Push(lua.LNil) + return 2 + })) + + // ---- sdk.plugin_mgr.*(插件管理,与外部插件的 PluginMgrAPI 对齐)---- + pmTbl := subTable("plugin_mgr") + pmTbl.RawSetString("reload_one", L.NewFunction(func(L *lua.LState) int { + if err := s.PluginMgr().ReloadOne(L.CheckString(1)); err != nil { + return pushErr(err) + } + return pushNil() + })) + pmTbl.RawSetString("list_loaded", L.NewFunction(func(L *lua.LState) int { + return pushList(s.PluginMgr().ListLoadedPlugins()) + })) + pmTbl.RawSetString("is_disabled", L.NewFunction(func(L *lua.LState) int { + return pushVal(s.PluginMgr().IsPluginDisabled(L.CheckString(1))) + })) } func makeToolHandler(plg *luaPlugin, name string, fn *lua.LFunction) sdk.ToolHandler { @@ -724,13 +863,29 @@ func makeStageHandler(plg *luaPlugin, stage sdk.Stage, fn *lua.LFunction) sdk.St defer plg.mu.Unlock() L := plg.L ctx := map[string]interface{}{ - "raw_message": sc.RawMessage, - "user_id": sc.UserID, - "group_id": sc.GroupID, - "phase": string(sc.Phase), - "llm_text": sc.LLMText, - "final_text": sc.FinalText, - "no_memory": sc.NoMemory, + "raw_message": sc.RawMessage, + "user_id": sc.UserID, + "group_id": sc.GroupID, + "phase": string(sc.Phase), + "llm_text": sc.LLMText, + "reasoning_content": sc.ReasoningContent, + "final_text": sc.FinalText, + "no_memory": sc.NoMemory, + } + if len(sc.ContextMsgs) > 0 { + ctx["context_msgs"] = jsonToIface(sc.ContextMsgs) + } + if len(sc.TokenUsage) > 0 { + ctx["token_usage"] = jsonToIface(sc.TokenUsage) + } + if len(sc.Memory) > 0 { + ctx["memory"] = jsonToIface(sc.Memory) + } + if len(sc.Extra) > 0 { + ctx["extra"] = jsonToIface(sc.Extra) + } + if len(sc.Errors) > 0 { + ctx["errors"] = jsonToIface(sc.Errors) } if sc.Response != nil { ctx["response"] = *sc.Response @@ -815,6 +970,7 @@ func parseToolDef(L *lua.LState, defTbl *lua.LTable, plg *luaPlugin, name string if v := defTbl.RawGetString("no_memory"); v != nil { goDef.NoMemory = lua.LVAsBool(v) } + goDef.ContextPolicy = defTbl.RawGetString("context_policy").String() if v := defTbl.RawGetString("cleaner"); v != nil && v.Type() == lua.LTFunction { goDef.Cleaner = makeLuaCleaner(plg, v.(*lua.LFunction)) } @@ -834,12 +990,104 @@ func parseChannelDef(L *lua.LState, defTbl *lua.LTable, plg *luaPlugin) sdk.Chan if v := defTbl.RawGetString("no_memory"); v != nil { chDef.NoMemory = lua.LVAsBool(v) } + chDef.ContextPolicy = defTbl.RawGetString("context_policy").String() if v := defTbl.RawGetString("cleaner"); v != nil && v.Type() == lua.LTFunction { chDef.Cleaner = makeLuaCleaner(plg, v.(*lua.LFunction)) } return chDef } +// parseInjectOptions 解析 Lua 侧 options table 为 SDK InjectOptions。 +// 支持的键:no_memory(bool)、context_policy(string)、cleaner_name(string)、priority(string)。 +// 缺省/非表等价于零值(记入记忆 + 不裁剪),与旧的三参数注入完全等价。 +func parseInjectOptions(L *lua.LState, idx int) sdk.InjectOptions { + opts := sdk.InjectOptions{} + tbl, ok := L.Get(idx).(*lua.LTable) + if !ok { + return opts + } + if v := tbl.RawGetString("no_memory"); v != nil { + opts.NoMemory = lua.LVAsBool(v) + } + opts.ContextPolicy = tbl.RawGetString("context_policy").String() + opts.CleanerName = tbl.RawGetString("cleaner_name").String() + opts.Priority = tbl.RawGetString("priority").String() + return opts +} + +// luaToContentBlocks 把 Lua 的 blocks 数组解析为 SDK ContentBlock。 +// 每项形如: +// +// { type = "text", text = "..." } +// { type = "image_url", image_url = { url = "...", detail = "high" } } +// { type = "audio_url", audio_url = { url = "..." } } +func luaToContentBlocks(L *lua.LState, idx int) []sdk.ContentBlock { + tbl, ok := L.Get(idx).(*lua.LTable) + if !ok { + return nil + } + var blocks []sdk.ContentBlock + tbl.ForEach(func(_, v lua.LValue) { + bt, ok := v.(*lua.LTable) + if !ok { + return + } + b := sdk.ContentBlock{ + Type: bt.RawGetString("type").String(), + Text: bt.RawGetString("text").String(), + } + if iu, ok := bt.RawGetString("image_url").(*lua.LTable); ok { + b.ImageURL = &sdk.ImageURL{ + URL: iu.RawGetString("url").String(), + Detail: iu.RawGetString("detail").String(), + } + } + if au, ok := bt.RawGetString("audio_url").(*lua.LTable); ok { + b.AudioURL = &sdk.AudioURL{URL: au.RawGetString("url").String()} + } + blocks = append(blocks, b) + }) + return blocks +} + +// luaToAttachments 把 Lua 附件数组解析为 SDK MediaAttachment。 +// 每项:{ digest=, mime=, name=, data= }。 +// 带 data 的是新内容(内核落进内容寻址存储),只带 digest 的是引用已有内容。 +// base64 解码失败时忽略 data(不整单失败)——坏附件不应阻断一条记忆写入。 +func luaToAttachments(L *lua.LState, val lua.LValue) []sdk.MediaAttachment { + tbl, ok := val.(*lua.LTable) + if !ok { + return nil + } + var out []sdk.MediaAttachment + tbl.ForEach(func(_, v lua.LValue) { + at, ok := v.(*lua.LTable) + if !ok { + return + } + a := sdk.MediaAttachment{ + Digest: at.RawGetString("digest").String(), + MIME: at.RawGetString("mime").String(), + Name: at.RawGetString("name").String(), + } + if s := at.RawGetString("data").String(); s != "" { + if b, err := base64.StdEncoding.DecodeString(s); err == nil { + a.Data = b + } + } + out = append(out, a) + }) + return out +} + +func docFromLua(tbl *lua.LTable) *sdk.Doc { + return &sdk.Doc{ + ID: tbl.RawGetString("id").String(), + Title: tbl.RawGetString("title").String(), + Content: tbl.RawGetString("content").String(), + } +} + // jsonToIface 通过 JSON 往返把任意 Go 值转换为 JSON 兼容的 interface{} 树。 func jsonToIface(v interface{}) interface{} { b, err := json.Marshal(v) diff --git a/internal/plugin/lua_plugin_test.go b/internal/plugin/lua_plugin_test.go index 25e4a42..02fbf82 100644 --- a/internal/plugin/lua_plugin_test.go +++ b/internal/plugin/lua_plugin_test.go @@ -5,9 +5,9 @@ import ( "path/filepath" "testing" - lua "github.com/yuin/gopher-lua" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + lua "github.com/yuin/gopher-lua" ) func TestTryLoadLua_Basic(t *testing.T) { diff --git a/internal/plugin/lua_surface_test.go b/internal/plugin/lua_surface_test.go new file mode 100644 index 0000000..c266058 --- /dev/null +++ b/internal/plugin/lua_surface_test.go @@ -0,0 +1,188 @@ +package plugin + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + luaSDK "gitcode.com/JianFeeeee/HomeAgent/internal/lua/sdk" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + lua "github.com/yuin/gopher-lua" +) + +// TestLuaSDKMockSingleSource 守住「Lua mock 只有一份事实源」。 +// +// 三份 sdk.lua(内核内嵌 / 工具链模板 / 项目副本)历史上各自漂移过, +// 表现为「mock 里有的 API,内核运行时是 nil」这类静默失配。 +// 事实源是 SDK 仓的 sdk/lua/sdk.lua,内核副本由 +// third_party/homeagent-sdk/scripts/sync-lua-sdk.sh 同步。 +func TestLuaSDKMockSingleSource(t *testing.T) { + canonical, err := os.ReadFile(filepath.Join("..", "..", "third_party", "homeagent-sdk", "sdk", "lua", "sdk.lua")) + if err != nil { + t.Skipf("SDK repo canonical sdk.lua not available: %v", err) + } + if string(canonical) != luaSDK.SDKSource { + t.Fatal("内核内嵌 sdk.lua 与 SDK 仓 sdk/lua/sdk.lua 不一致;" + + "请跑 third_party/homeagent-sdk/scripts/sync-lua-sdk.sh") + } +} + +var luaMockFuncRe = regexp.MustCompile(`(?m)^function sdk\.([A-Za-z0-9_.]+)\s*\(`) + +// TestLuaBridgeCoversMock 守住「mock 承诺的每个函数,运行时都有绑定」。 +// +// 只查 mock → 运行时这一向:mock 定义了但没绑定,插件会先看到 mock 能调、 +// 之后内核里是 nil(或反向的假象)。反向(运行时多出未文档化的函数)无害。 +func TestLuaBridgeCoversMock(t *testing.T) { + bridge, err := os.ReadFile("lua_plugin.go") + if err != nil { + t.Fatalf("read lua_plugin.go: %v", err) + } + src := string(bridge) + + // 纯 Lua 实现,不经内核绑定。 + exempt := map[string]bool{"json.encode": true, "json.decode": true} + + matches := luaMockFuncRe.FindAllStringSubmatch(luaSDK.SDKSource, -1) + if len(matches) < 40 { + t.Fatalf("parsed only %d sdk.* functions from mock; parser likely broken", len(matches)) + } + for _, m := range matches { + full := m[1] + if exempt[full] { + continue + } + leaf := full + if i := strings.LastIndex(full, "."); i >= 0 { + leaf = full[i+1:] + } + if !strings.Contains(src, `RawSetString("`+leaf+`"`) { + t.Errorf("sdk.%s: mock 有定义,但 lua_plugin.go 没有 RawSetString(%q) 绑定", full, leaf) + } + } +} + +func luaTableFrom(t *testing.T, code string) (*lua.LState, *lua.LTable) { + t.Helper() + L := lua.NewState() + if err := L.DoString("return " + code); err != nil { + L.Close() + t.Fatalf("eval lua table: %v", err) + } + tbl, ok := L.Get(-1).(*lua.LTable) + if !ok { + L.Close() + t.Fatalf("expected a table from %q", code) + } + L.Pop(1) + return L, tbl +} + +func TestLuaParseInjectOptions(t *testing.T) { + L, tbl := luaTableFrom(t, `{ + no_memory = true, + context_policy = "prune", + cleaner_name = "sanitize", + priority = "L2", + }`) + defer L.Close() + + L.Push(tbl) + got := parseInjectOptions(L, 1) + L.Pop(1) + + if !got.NoMemory { + t.Error("NoMemory should be true") + } + if got.ContextPolicy != sdk.ContextPolicyPrune { + t.Errorf("ContextPolicy = %q, want prune", got.ContextPolicy) + } + if got.CleanerName != "sanitize" { + t.Errorf("CleanerName = %q, want sanitize", got.CleanerName) + } + if got.Priority != sdk.PriorityL2 { + t.Errorf("Priority = %q, want L2", got.Priority) + } + + // 缺省/非表 = 零值(记入记忆 + 不裁剪),与旧三参数注入等价。 + if z := parseInjectOptions(L, 99); z != (sdk.InjectOptions{}) { + t.Errorf("missing opts should be zero value, got %#v", z) + } +} + +func TestLuaContentBlocksParse(t *testing.T) { + L, tbl := luaTableFrom(t, `{ + { type = "text", text = "看图" }, + { type = "image_url", image_url = { url = "data:image/png;base64,AAAA", detail = "high" } }, + { type = "audio_url", audio_url = { url = "https://x/a.mp3" } }, + }`) + defer L.Close() + + L.Push(tbl) + blocks := luaToContentBlocks(L, 1) + L.Pop(1) + + if len(blocks) != 3 { + t.Fatalf("got %d blocks, want 3", len(blocks)) + } + if blocks[0].Type != "text" || blocks[0].Text != "看图" { + t.Errorf("block[0] = %#v", blocks[0]) + } + if blocks[1].ImageURL == nil || blocks[1].ImageURL.URL != "data:image/png;base64,AAAA" || blocks[1].ImageURL.Detail != "high" { + t.Errorf("block[1] image_url = %#v", blocks[1].ImageURL) + } + if blocks[2].AudioURL == nil || blocks[2].AudioURL.URL != "https://x/a.mp3" { + t.Errorf("block[2] audio_url = %#v", blocks[2].AudioURL) + } +} + +func TestLuaAttachmentsBase64(t *testing.T) { + // "hello" 的 base64 是 aGVsbG8= + L, tbl := luaTableFrom(t, `{ + { digest = "sha256:abc", mime = "image/png", name = "a.png" }, + { mime = "image/jpeg", data = "aGVsbG8=" }, + { mime = "image/png", data = "!!!not-base64!!!" }, + }`) + defer L.Close() + + atts := luaToAttachments(L, tbl) + + if len(atts) != 3 { + t.Fatalf("got %d attachments, want 3", len(atts)) + } + if atts[0].Digest != "sha256:abc" || atts[0].MIME != "image/png" || atts[0].Name != "a.png" || atts[0].Data != nil { + t.Errorf("att[0] = %#v", atts[0]) + } + if string(atts[1].Data) != "hello" { + t.Errorf("att[1] data = %q, want hello", string(atts[1].Data)) + } + // 坏 base64 只丢 data,不整单失败——坏附件不应阻断记忆写入。 + if atts[2].Data != nil { + t.Errorf("att[2] bad base64 should be dropped, got %q", string(atts[2].Data)) + } +} + +func TestLuaDefinitionsCarryContextPolicy(t *testing.T) { + L, tbl := luaTableFrom(t, `{ + description = "t", + no_memory = true, + context_policy = "prune", + }`) + defer L.Close() + + plg := &luaPlugin{name: "cp"} + def := parseToolDef(L, tbl, plg, "t") + if def.ContextPolicy != sdk.ContextPolicyPrune { + t.Errorf("ToolDef.ContextPolicy = %q, want prune", def.ContextPolicy) + } + if !def.NoMemory { + t.Error("ToolDef.NoMemory should be true") + } + + chDef := parseChannelDef(L, tbl, plg) + if chDef.ContextPolicy != sdk.ContextPolicyPrune { + t.Errorf("ChannelDef.ContextPolicy = %q, want prune", chDef.ContextPolicy) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index a333f8b..6db17c3 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -15,7 +15,7 @@ import ( type PluginType string const ( - PluginTypeSKILL PluginType = "skill" + PluginTypeSKILL PluginType = "skill" ) type IOConfig struct { @@ -78,18 +78,28 @@ func LoadSKILL(path string) (*SKILLPlugin, error) { metaFile := filepath.Join(path, "skill.json") if data, err := os.ReadFile(metaFile); err == nil { var meta struct { - Name string `json:"name"` - Description string `json:"description"` - Version string `json:"version"` - Author string `json:"author"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Author string `json:"author"` IO *IOConfig `json:"io,omitempty"` } if err := json.Unmarshal(data, &meta); err == nil { - if meta.Name != "" { p.name = meta.Name } - if meta.Description != "" { p.description = meta.Description } - if meta.Version != "" { p.version = meta.Version } - if meta.Author != "" { p.author = meta.Author } - if meta.IO != nil { p.ioConfig = meta.IO } + if meta.Name != "" { + p.name = meta.Name + } + if meta.Description != "" { + p.description = meta.Description + } + if meta.Version != "" { + p.version = meta.Version + } + if meta.Author != "" { + p.author = meta.Author + } + if meta.IO != nil { + p.ioConfig = meta.IO + } } } } else if filepath.Ext(path) == ".md" { @@ -198,7 +208,9 @@ func extractToolDefs(content string) []ToolDef { inCodeBlock = !inCodeBlock continue } - if inCodeBlock { continue } + if inCodeBlock { + continue + } if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") { if currentTool != nil && currentTool.Name != "" { @@ -232,7 +244,9 @@ func extractToolDefs(content string) []ToolDef { continue } - if currentTool == nil || currentTool.Name == "" { continue } + if currentTool == nil || currentTool.Name == "" { + continue + } if currentTool.Description == "" && trimmed != "" && !strings.HasPrefix(trimmed, "- ") && !strings.HasPrefix(trimmed, "#") { @@ -280,7 +294,9 @@ func isNonToolSection(name string) bool { func extractIOConfig(content string) *IOConfig { ioType := extractField(content, "io_type") - if ioType == "" { return nil } + if ioType == "" { + return nil + } cfg := &IOConfig{ Type: ioType, InputRoute: extractField(content, "io_input_route"), diff --git a/third_party/homeagent-sdk/sdk/lua/sdk.lua b/third_party/homeagent-sdk/sdk/lua/sdk.lua new file mode 100644 index 0000000..2f4dfb9 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/lua/sdk.lua @@ -0,0 +1,403 @@ +-- HomeAgent Lua Plugin SDK +-- Interface contract between Lua plugins and HomeAgent kernel. +-- !impl functions are replaced by Go implementations at runtime. +-- Standalone/debug: pure Lua mock implementations are used. +-- Usage: local sdk = require("sdk") + +sdk = {} + +-- !impl +-- level: "debug" | "info" | "warn" | "error" +function sdk.log(level, msg) + print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) +end + +-- !impl +-- def: { description="...", parameters={...}, no_memory=true/false, cleaner=function(text)->text } +-- handler: function(args) -> result +function sdk.register_tool(name, def, handler) + print("[lua-plugin] register_tool: " .. tostring(name)) +end + +-- !impl +-- stage: "on_input" | "pre_action" | "post_action" | ... +-- scope: nil/"global" (默认) | "own_tools"(仅 before_toolcall/after_toolcall 且工具属于本插件时触发) +function sdk.register_stage(stage, handler, scope) + print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) +end + +-- !impl +function sdk.register_api(name) + print("[lua-plugin] register_api: " .. tostring(name)) +end + +-- !impl +-- def: { no_memory=true/false, cleaner=function(text)->text } +-- handler: function(args) -> result +function sdk.register_output_channel(name, caps, desc, def, handler) + print("[lua-plugin] register_output_channel: " .. tostring(name)) +end + +-- !impl +-- def: { no_memory=true/false, cleaner=function(text)->text } +function sdk.register_input_channel(name, def) + print("[lua-plugin] register_input_channel: " .. tostring(name)) +end + +-- !impl +function sdk.get_setting(key) + return nil +end + +-- !impl +function sdk.set_setting(key, value) + print("[lua-plugin] set_setting: " .. tostring(key)) +end + +-- !impl +function sdk.inject_text(source, channel, text) + print("[lua-plugin] inject_text: " .. tostring(source) .. "/" .. tostring(channel)) +end + +-- !impl +function sdk.inject_interrupt(source, channel, text) + print("[lua-plugin] inject_interrupt: " .. tostring(source)) +end + +-- !impl +function sdk.inject_text_no_memory(source, channel, text) + print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) +end + +-- !impl +-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" } +-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。 +function sdk.inject_text_opts(source, channel, text, opts) + print("[lua-plugin] inject_text_opts: " .. tostring(source)) +end + +-- !impl +function sdk.inject_interrupt_opts(source, channel, text, opts) + print("[lua-plugin] inject_interrupt_opts: " .. tostring(source)) +end + +-- !impl +-- 同步注入:等待本轮回复 -> (reply, err);无回复时 reply 为 nil。 +function sdk.inject_input_sync(source, channel, text) return nil, nil end + +-- !impl +function sdk.inject_input_sync_opts(source, channel, text, opts) return nil, nil end + +-- !impl +-- blocks: ContentBlock 数组,见 sdk.inject_input_media。 +-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。 +function sdk.set_tool_blocks(blocks) + print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0)) +end + +-- !impl +-- blocks 每项:{ type="text", text="..." } +-- | { type="image_url", image_url={ url="...", detail="high" } } +-- | { type="audio_url", audio_url={ url="..." } } +function sdk.inject_input_media(source, channel, text, blocks) + print("[lua-plugin] inject_input_media: " .. tostring(source)) +end + +-- !impl +function sdk.inject_input_media_opts(source, channel, text, blocks, opts) + print("[lua-plugin] inject_input_media_opts: " .. tostring(source)) +end + +-- !impl +function sdk.inject_input_media_sync(source, channel, text, blocks) return nil, nil end + +-- !impl +function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts) return nil, nil end + +-- !impl +function sdk.inject_interrupt_media(source, channel, text, blocks) + print("[lua-plugin] inject_interrupt_media: " .. tostring(source)) +end + +-- !impl +function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts) + print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source)) +end + +-- !impl +-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。 +function sdk.unregister_output_channel(name) return nil, nil end + +-- !impl +-- enabled: true/false,崩溃时内核自动拉起 +function sdk.set_auto_restart(enabled) + print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) +end + +-- ============ graph memory ============ +-- !impl +sdk.memory = {} +-- !impl +-- query: string, depth: number -> {entities={...}, relations={...}} +function sdk.memory.recall(query, depth) return {entities={}, relations={}} end +-- !impl +-- triples: { {subject=, relation=, object=, [confidence=], [sentence_text=]} } -> err +function sdk.memory.commit(triples) return nil end +-- !impl +function sdk.memory.introspect() return {} end +-- !impl +function sdk.memory.merge(source, target) return 0 end +-- !impl +-- criteria: {key=value}, hard: boolean +function sdk.memory.purge(criteria, hard) return 0 end + +-- ============ document memory ============ +-- !impl +sdk.doc = {} +-- !impl +function sdk.doc.query(text, top_k) return {} end +-- !impl +-- doc: { id=, title=, content= } +function sdk.doc.insert(doc) return nil end +-- !impl +-- attachments 每项:{ digest=, mime=, name=, data= } +function sdk.doc.insert_with_media(doc, attachments) return nil end +-- !impl +function sdk.doc.remove(id) return nil end +-- !impl +function sdk.doc.stats() return {} end + +-- ============ knowledge ============ +-- !impl +sdk.knowledge = {} +-- !impl +function sdk.knowledge.search(query, limit) return {} end +-- !impl +function sdk.knowledge.add(tag, content) return nil end +-- !impl +function sdk.knowledge.list() return {} end + +-- ============ text memory ============ +-- !impl +sdk.text_memory = {} +-- !impl +-- evt: { timestamp=, role=, content=, channel= } +function sdk.text_memory.append(evt) return nil end + +-- ============ llm ============ +-- !impl +sdk.llm = {} +-- !impl +function sdk.llm.list_sources() return {} end +-- !impl +function sdk.llm.set_source(name) return nil end +-- !impl +function sdk.llm.current_source() return nil end + +-- ============ social (只读) ============ +-- !impl +sdk.social = {} +-- !impl +function sdk.social.get_person(name) return {} end +-- !impl +function sdk.social.get_network(name, depth) return {} end +-- !impl +function sdk.social.get_trait(name, trait) return {value=nil, found=false} end +-- !impl +function sdk.social.get_relations(name) return {} end +-- !impl +function sdk.social.list_persons() return {} end + +-- ============ settings (作用域变体) ============ +-- !impl +sdk.settings = {} +-- !impl +function sdk.settings.get_core(key) return nil end +-- !impl +function sdk.settings.set_core(key, value) return nil end +-- !impl +function sdk.settings.list_core(prefix) return {} end +-- !impl +function sdk.settings.get_plugin(plugin, key) return nil end +-- !impl +function sdk.settings.set_plugin(plugin, key, value) return nil end +-- !impl +function sdk.settings.list_plugin(plugin, prefix) return {} end +-- !impl +function sdk.settings.list(prefix) return {} end +-- !impl +-- def: { key=, type=, display_name=, description=, category=, options=, default=, +-- min=, max=, step=, required=, secret= } +function sdk.settings.register_def(def) return nil end +-- !impl +function sdk.settings.defs(prefix) return {} end +-- !impl +function sdk.settings.dump() return {} end +-- !impl +function sdk.settings.plugins() return {} end + +-- ============ events(只读订阅) ============ +-- !impl +-- subscribe(event_type, handler) -> unsubscribe() +-- handler 收到 { type=, source=, timestamp=, payload= }; +-- 回调在其内核事件发布 goroutine 上执行,只做轻量转发,不可阻塞(Lua 单状态 + 互斥锁)。 +sdk.events = {} +function sdk.events.subscribe(event_type, handler) + print("[lua-plugin] events.subscribe: " .. tostring(event_type)) + return function() end +end + +-- ============ plugin_mgr ============ +-- !impl +sdk.plugin_mgr = {} +function sdk.plugin_mgr.reload_one(name) return nil end +function sdk.plugin_mgr.list_loaded() return {} end +function sdk.plugin_mgr.is_disabled(name) return false end + +-- json utils (pure Lua) +sdk.json = {} + +function sdk.json.encode(val) + local ok, result = pcall(function() + local function _encode(v) + local t = type(v) + if t == "string" then + local s = v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t') + return '"' .. s .. '"' + elseif t == "number" then + return tostring(v) + elseif t == "boolean" then + return tostring(v) + elseif t == "table" then + local keys = {} + local is_array = true + local maxn = 0 + for k in pairs(v) do + keys[#keys + 1] = k + if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then + is_array = false + end + if type(k) == "number" and k > maxn then maxn = k end + end + if is_array and #keys >= maxn then + local parts = {} + for i = 1, maxn do + parts[#parts + 1] = _encode(v[i]) + end + return "[" .. table.concat(parts, ",") .. "]" + else + local parts = {} + for _, k in ipairs(keys) do + parts[#parts + 1] = _encode(tostring(k)) .. ":" .. _encode(v[k]) + end + return "{" .. table.concat(parts, ",") .. "}" + end + else + return "null" + end + end + return _encode(val) + end) + if ok then return result end + return "null" +end + +function sdk.json.decode(str) + local ok, result = pcall(function() + local pos, _end = 1, #str + local function skip() + while pos <= _end and str:sub(pos, pos):match("%s") do pos = pos + 1 end + end + local function parse() + skip() + if pos > _end then return nil end + local c = str:sub(pos, pos) + if c == '"' then + local s = {} + pos = pos + 1 + while pos <= _end do + local ch = str:sub(pos, pos) + if ch == '"' then + pos = pos + 1 + return table.concat(s) + elseif ch == '\\' then + pos = pos + 1 + local n = str:sub(pos, pos) + if n == '"' then s[#s+1] = '"' + elseif n == '\\' then s[#s+1] = '\\' + elseif n == '/' then s[#s+1] = '/' + elseif n == 'b' then s[#s+1] = '\b' + elseif n == 'f' then s[#s+1] = '\f' + elseif n == 'n' then s[#s+1] = '\n' + elseif n == 'r' then s[#s+1] = '\r' + elseif n == 't' then s[#s+1] = '\t' + elseif n == 'u' then + local hex = str:sub(pos+1, pos+4) + pos = pos + 4 + s[#s+1] = utf8 and utf8.char(tonumber(hex, 16)) or '?' + end + pos = pos + 1 + else + s[#s+1] = ch + pos = pos + 1 + end + end + return table.concat(s) + elseif c == 't' then pos = pos + 4; return true + elseif c == 'f' then pos = pos + 5; return false + elseif c == 'n' then pos = pos + 4; return nil + elseif c == '{' then + pos = pos + 1; skip() + local t = {} + if str:sub(pos, pos) == '}' then pos = pos + 1; return t end + while true do + skip(); local k = parse(); skip() + if str:sub(pos, pos) == ':' then pos = pos + 1 end + skip(); t[k] = parse(); skip() + local sep = str:sub(pos, pos) + if sep == '}' then pos = pos + 1; return t end + if sep == ',' then pos = pos + 1 end + end + elseif c == '[' then + pos = pos + 1; skip() + local t = {} + if str:sub(pos, pos) == ']' then pos = pos + 1; return t end + local idx = 1 + while true do + skip(); t[idx] = parse(); idx = idx + 1; skip() + local sep = str:sub(pos, pos) + if sep == ']' then pos = pos + 1; return t end + if sep == ',' then pos = pos + 1 end + end + else + local s, e = str:find('^[-%d%.eE]+', pos) + if s then + local num = tonumber(str:sub(s, e)) + pos = e + 1 + return num + end + return nil + end + end + return parse() + end) + if ok then return result end + return nil +end + +-- http utils +sdk.http = {} + +-- !impl +function sdk.http.get(url) + print("[lua-plugin] http.get: " .. tostring(url)) + return {status=200, body='{"mock":true}', headers={}} +end + +-- !impl +function sdk.http.post(url, body, content_type) + print("[lua-plugin] http.post: " .. tostring(url)) + return {status=200, body='{"mock":true}', headers={}} +end + +return sdk