diff --git a/example/luademo/README.md b/example/luademo/README.md index 0a0a3d4..35549e1 100644 --- a/example/luademo/README.md +++ b/example/luademo/README.md @@ -1,13 +1,16 @@ # luademo -Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面: +Lua 插件全功能示例,展示 Lua SDK 的完整能力面(对齐 SDK 1.3.0): -- **工具注册**:`no_memory` + `cleaner`(记忆计算层过滤) +- **工具注册**:`no_memory` + `context_policy` + `cleaner`(记忆计算层过滤) - **阶段钩子**:`register_stage(stage, handler, scope)`,`own_tools` 与全局作用域 -- **通道**:`register_output_channel` / `register_input_channel`(def 支持 no_memory/cleaner) -- **数据类 API**:`sdk.memory.*`、`sdk.doc.*`、`sdk.knowledge.*`、`sdk.text_memory.*`、`sdk.llm.*`、`sdk.settings.*`、`sdk.social.*` +- **通道**:`register_output_channel` / `register_input_channel` / `unregister_output_channel`(def 支持 no_memory/context_policy/cleaner) +- **注入**:`inject_text` / `inject_interrupt` / `inject_text_no_memory`、`*_opts`(no_memory/context_policy/cleaner_name/priority)、`inject_input_sync`、`inject_*_media`、`set_tool_blocks` +- **数据类 API**:`sdk.memory.*`(含 sentence_text/media_digests)、`sdk.doc.*`(含 insert_with_media)、`sdk.knowledge.*`、`sdk.text_memory.*`(含 attachments)、`sdk.llm.*`、`sdk.settings.*`、`sdk.social.*`、`sdk.events.*`、`sdk.plugin_mgr.*` - **其他**:`register_api`、`set_auto_restart` +> `luademo_probe_v2` 巡检 1.1/1.2/1.3 新增面。它**故意不调用** `inject_input_sync`:工具 handler 在 LLM 回合内运行,同步注入会自己等自己(死锁)。 + ## 本地独立测试 ```bash diff --git a/example/luademo/main.lua b/example/luademo/main.lua index 6e19cf7..fd33f9b 100644 --- a/example/luademo/main.lua +++ b/example/luademo/main.lua @@ -67,6 +67,65 @@ function plugin.start(sdk) return { content = res } end) + -- 工具:1.1/1.2/1.3 新增能力巡检(媒体块 / 注入标志位 / 事件 / 动态通道注销) + -- 注意:故意不在这里调用 sdk.inject_input_sync——工具handler 运行在 LLM 回合内, + -- 同步注入会等本轮回复,等于自己等自己(死锁)。同步注入只适合事件回调等外部入口。 + sdk.register_tool("luademo_probe_v2", { + description = "Exercise media blocks, inject opts, events and channel unregister", + parameters = { type = "object", properties = {} }, + no_memory = true, + context_policy = "prune", + }, function(args) + local res = {} + + -- 多模态:设置下一轮 tool message 携带的内容块 + sdk.set_tool_blocks({ + { type = "text", text = "luademo media block" }, + { type = "image_url", image_url = { url = "https://example.com/x.png", detail = "low" } }, + }) + res.set_tool_blocks = "ok" + + -- 注入标志位(零值 opts 与旧三参数等价) + sdk.inject_text_opts("luademo", "luademo_in", "opts inject", { + no_memory = true, context_policy = "prune", + }) + res.inject_text_opts = "ok" + + -- 带媒体的中断注入 + sdk.inject_interrupt_media("luademo", "luademo_in", "media inject", { + { type = "audio_url", audio_url = { url = "https://example.com/a.mp3" } }, + }) + res.inject_interrupt_media = "ok" + + -- 媒体入记忆:三元组带原句,文档带附件 + local _, merr = sdk.memory.commit({{ + subject = "luademo", relation = "shows", object = "image", + sentence_text = "luademo shows an image", media_digests = {}, + }}) + res.memory_commit_with_sentence = { err = merr } + local _, derr = sdk.doc.insert_with_media( + { id = "luademo-media", title = "media", content = "with attachment" }, + { { mime = "image/png", name = "x.png", data = "aGVsbG8=" } }) + res.doc_insert_with_media = { err = derr } + + -- 事件订阅(返回取消订阅函数) + local unsub = sdk.events.subscribe("agent_output", function(evt) + sdk.log("info", "luademo event: " .. tostring(evt.type)) + end) + res.events_subscribe = type(unsub) + if unsub then unsub() end + + -- 插件管理(只读查询) + res.plugin_mgr_loaded = type(sdk.plugin_mgr.list_loaded()) + + -- 动态输出通道注销 + sdk.register_output_channel("luademo_dyn", 0, "dynamic", {}, function(a) return { ok = true } end) + local _, uerr = sdk.unregister_output_channel("luademo_dyn") + res.unregister = { err = uerr } + + return { content = res } + end) + -- 阶段钩子:own_tools 作用域(仅本插件工具被调用时触发) sdk.register_stage("before_toolcall", function(ctx) local calls = ctx.tool_calls or {} diff --git a/example/luademo/sdk.lua b/example/luademo/sdk.lua index 3055151..2f4dfb9 100644 --- a/example/luademo/sdk.lua +++ b/example/luademo/sdk.lua @@ -1,67 +1,403 @@ --- HomeAgent Lua Plugin SDK (standalone mock) +-- 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 = {} -function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end -function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end -function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end -function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end -function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end -function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end -function sdk.get_setting(key) return nil end -function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end -function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end -function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end -function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end -function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end + +-- !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) - if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"' - elseif type(val) == "number" or type(val) == "boolean" then return tostring(val) - elseif type(val) == "table" then local parts, i = {}, 1 - for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end - return "{" .. table.concat(parts, ",") .. "}" end + 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, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil 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 = {} -function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end -function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end + +-- !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 diff --git a/scripts/sync-lua-sdk.sh b/scripts/sync-lua-sdk.sh new file mode 100755 index 0000000..369f44e --- /dev/null +++ b/scripts/sync-lua-sdk.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# 同步 Lua SDK mock 的单一事实源到各副本。 +# +# 事实源:sdk/lua/sdk.lua(本仓) +# 副本: +# - tools/hmapdev/assets/sdk.lua 工具链内嵌回退(hmapdev init --lua 无 SDK 时用) +# - example/luademo/sdk.lua 示例插件的离线测试副本 +# - /internal/lua/sdk/sdk.lua 内核内嵌副本(本仓被 vendored 到 +# /third_party/homeagent-sdk 时自动识别;独立 clone 时跳过) +# +# 为什么要有它:三份 sdk.lua 曾各自漂移,出现「mock 有、内核没有」的静默失配。 +# 改 mock 只改事实源,然后跑这个脚本;内核仓另有契约测试比对。 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$ROOT/sdk/lua/sdk.lua" + +[ -f "$SRC" ] || { echo "error: canonical sdk.lua not found: $SRC" >&2; exit 1; } + +copy() { + local dst="$1" + mkdir -p "$(dirname "$dst")" + cp "$SRC" "$dst" + echo " synced -> $dst" +} + +copy "$ROOT/tools/hmapdev/assets/sdk.lua" +copy "$ROOT/example/luademo/sdk.lua" + +# 被内核仓 vendored 时(本仓位于 /third_party/homeagent-sdk)同步内核副本。 +CORE_COPY="$ROOT/../../internal/lua/sdk/sdk.lua" +if [ -d "$ROOT/../../internal" ]; then + copy "$(cd "$(dirname "$CORE_COPY")" && pwd)/sdk.lua" +else + echo " note: core repo not vendored next to this checkout, skipping core copy" +fi + +echo "Lua SDK mock synced." diff --git a/sdk/lua/sdk.lua b/sdk/lua/sdk.lua new file mode 100644 index 0000000..2f4dfb9 --- /dev/null +++ b/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 diff --git a/tools/hmapdev/assets/sdk.lua b/tools/hmapdev/assets/sdk.lua new file mode 100644 index 0000000..2f4dfb9 --- /dev/null +++ b/tools/hmapdev/assets/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 diff --git a/tools/hmapdev/cmd_build.go b/tools/hmapdev/cmd_build.go index c5e6a3d..4ccf6b4 100644 --- a/tools/hmapdev/cmd_build.go +++ b/tools/hmapdev/cmd_build.go @@ -75,6 +75,18 @@ func cmdBuild(args []string) { } if plg.IsLua() { + // Lua 插件不经过 Go 编译,但也必须做两件与纪律相关的事: + // 1) 记录「用哪版 SDK 语义写的」——否则新 API 在旧内核上只会静默缺失; + // 2) 打包前做语法预检——否则语法错会被原样包进 .hmap,到内核加载时才暴露。 + if root := tryActiveSDKRoot(); root != "" { + plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(root)) + } + if err := checkLuaSyntax("main.lua"); err != nil { + fmt.Printf(" error: %v\n", err) + // 直接退出而非置 buildFailed:Lua 分支不进入后面的收尾统计, + // 早期 return 会让调用方拿到 0 退出码。 + os.Exit(1) + } buildTarget(plg, "lua", outDir, "") return } @@ -254,6 +266,30 @@ func buildBundle(plg *PlgConfig, outDir string, sdkPath string) { // 这是 entry 字段唯一仍在使用的用途:Go 插件不再看 entry 值,一律产出 plugin.bin。 func (p *PlgConfig) IsLua() bool { return p.Entry == luaEntryFile } +// checkLuaSyntax 在打包前对 Lua 源码做语法预检。 +// +// 为什么不只是“能做就做”:Lua 分支不经过编译器,语法错会被原样包进 .hmap, +// 直到内核加载时才报错,且错误现场是内核日志而不是构建日志。 +// 有 luac 用 luac -p(只解析不执行);只有 lua 时用 loadfile 同样只解析; +// 两者都没有才降级为警告,不阻断构建(构建机可以没有 Lua 解释器)。 +func checkLuaSyntax(path string) error { + if bin, err := exec.LookPath("luac"); err == nil { + if out, err := exec.Command(bin, "-p", path).CombinedOutput(); err != nil { + return fmt.Errorf("lua syntax check failed (%s): %s", path, strings.TrimSpace(string(out))) + } + return nil + } + if bin, err := exec.LookPath("lua"); err == nil { + script := fmt.Sprintf("local f,e=loadfile(%q); if not f then io.stderr:write(e) os.exit(1) end", path) + if out, err := exec.Command(bin, "-e", script).CombinedOutput(); err != nil { + return fmt.Errorf("lua syntax check failed (%s): %s", path, strings.TrimSpace(string(out))) + } + return nil + } + fmt.Println(" note: lua/luac not found, skipping syntax check") + return nil +} + func readPlgJSON(path string) (*PlgConfig, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/tools/hmapdev/cmd_debug.go b/tools/hmapdev/cmd_debug.go index 272bb58..49108f9 100644 --- a/tools/hmapdev/cmd_debug.go +++ b/tools/hmapdev/cmd_debug.go @@ -13,7 +13,14 @@ import ( // tmplLuaDebug is the temporary Lua debug script template const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug -- Generated by hmapdev debug --lua -sdk = require("sdk") +-- sdk.lua 优先用激活 SDK 的权威 mock(HMAPDEV_SDK_LUA),否则回退项目内副本, +-- 避免拿一份过期的 sdk.lua 调试出“本地能跑、内核报 nil”的假象。 +local sdk_path = os.getenv("HMAPDEV_SDK_LUA") +if sdk_path and sdk_path ~= "" then + sdk = dofile(sdk_path) +else + sdk = require("sdk") +end local ok, plugin = pcall(dofile, "main.lua") if not ok then print("[debug] ERROR loading main.lua: " .. tostring(plugin)) @@ -127,6 +134,16 @@ func debugLua(dir, sdkPath, luaPath string) { fmt.Println("warning: sdk.lua not found, debug SDK mock will not be available") } + // 优先用激活 SDK 里的权威 mock,避免调试用的是项目里可能过期的副本。 + env := os.Environ() + if root := tryActiveSDKRoot(); root != "" { + canonical := filepath.Join(root, "sdk", "lua", "sdk.lua") + if _, err := os.Stat(canonical); err == nil { + env = append(env, "HMAPDEV_SDK_LUA="+canonical) + fmt.Printf("[debug] SDK mock: %s\n", canonical) + } + } + // write temporary debug script debugScript := filepath.Join(dir, "_debug.lua") if err := os.WriteFile(debugScript, []byte(tmplLuaDebug), 0644); err != nil { @@ -137,6 +154,7 @@ func debugLua(dir, sdkPath, luaPath string) { cmd := exec.Command(luaBin, filepath.Base(debugScript)) cmd.Dir = dir + cmd.Env = env cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/tools/hmapdev/cmd_init.go b/tools/hmapdev/cmd_init.go index e617542..36cb63e 100644 --- a/tools/hmapdev/cmd_init.go +++ b/tools/hmapdev/cmd_init.go @@ -164,7 +164,13 @@ func cmdInit(args []string) { // Detect SDK info for Go plugin go.mod. // 生成的 go.mod 除 require 外还写一条指向本机 SDK 的 replace: // 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)。 - if !isLua { + if isLua { + // Lua 插件也要记录它按哪版 SDK 语义编写:Lua `sdk.*` 是公开契约, + // 与内核能力版本挂钩;不写版本就只能靠“调用时才发现是 nil”。 + if root := tryActiveSDKRoot(); root != "" { + data.Plg.SDK = normalizeSDKVersion(readMetaVersion(root)) + } + } else { sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo() data.ModulePath = name data.GoVersion = goVer @@ -187,7 +193,14 @@ func cmdInit(args []string) { // Lua plugins get main.lua + sdk.lua; Go plugins get plugin.go only if isLua { writeTemplate(filepath.Join(dir, "main.lua"), tmplMainLua, data) - writeTemplate(filepath.Join(dir, "sdk.lua"), tmplSDKLua, data) + // sdk.lua 是给 `lua main.lua` 离线测试用的 mock,单一事实源在 SDK 仓的 + // sdk/lua/sdk.lua;优先从当前激活的 SDK 拷,拷不到才回退内嵌模板。 + if !copyCanonicalLuaSDK(dir) { + if err := os.WriteFile(filepath.Join(dir, "sdk.lua"), []byte(fallbackLuaSDK), 0644); err != nil { + fmt.Printf("error: write sdk.lua: %v\n", err) + os.Exit(1) + } + } } else { writeTemplate(filepath.Join(dir, "plugin.go"), tmplPluginGo, data) } @@ -210,6 +223,43 @@ func cmdInit(args []string) { fmt.Printf(" cd %s && hmapdev build\n", dir) } +// activeSDKRoot 返回当前激活 SDK 的根目录,未安装/未激活则报错退出。 +// +// 与 activeSDKRoot(fatal 版)区别:这里只探测,不退出。 +// Lua 插件的 mock 是“锦上添花”,没装 SDK 不应该阻断 init。 +func tryActiveSDKRoot() string { + store := sdkStore() + current := resolveCurrentVersion(store) + if current == "" { + return "" + } + root := sdkVersionDir(current) + if _, err := os.Stat(root); err != nil { + return "" + } + return root +} + +// copyCanonicalLuaSDK 把激活 SDK 的 sdk/lua/sdk.lua 拷进新项目。 +// 三份 sdk.lua(内核内嵌 / 工具链模板 / 项目副本)各自漂移是本工具链的历史债, +// 单一事实源在 SDK 仓,工具链只负责搬运。返回是否成功。 +func copyCanonicalLuaSDK(dir string) bool { + root := tryActiveSDKRoot() + if root == "" { + return false + } + src := filepath.Join(root, "sdk", "lua", "sdk.lua") + data, err := os.ReadFile(src) + if err != nil { + return false + } + if err := os.WriteFile(filepath.Join(dir, "sdk.lua"), data, 0644); err != nil { + return false + } + fmt.Printf(" sdk.lua <- %s\n", src) + return true +} + // detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version. func detectSDKInfo() (modulePath, goVersion, sdkPath, sdkVersion string) { root := activeSDKRoot() diff --git a/tools/hmapdev/lua_test.go b/tools/hmapdev/lua_test.go new file mode 100644 index 0000000..2699d1e --- /dev/null +++ b/tools/hmapdev/lua_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestCheckLuaSyntax 钉住 Lua 打包前的语法预检:语法错必须被拒。 +// 没有 lua/luac 的构建机跳过(预检按设计降级为警告)。 +func TestCheckLuaSyntax(t *testing.T) { + if _, err := exec.LookPath("luac"); err != nil { + if _, err := exec.LookPath("lua"); err != nil { + t.Skip("no lua/luac in PATH") + } + } + dir := t.TempDir() + + good := filepath.Join(dir, "good.lua") + if err := os.WriteFile(good, []byte("local x = 1\nreturn x\n"), 0644); err != nil { + t.Fatal(err) + } + if err := checkLuaSyntax(good); err != nil { + t.Fatalf("valid Lua rejected: %v", err) + } + + bad := filepath.Join(dir, "bad.lua") + if err := os.WriteFile(bad, []byte("function broken(\n"), 0644); err != nil { + t.Fatal(err) + } + if err := checkLuaSyntax(bad); err == nil { + t.Fatal("invalid Lua accepted; syntax check is not effective") + } +} diff --git a/tools/hmapdev/templates.go b/tools/hmapdev/templates.go index 036a769..4a12689 100644 --- a/tools/hmapdev/templates.go +++ b/tools/hmapdev/templates.go @@ -1,5 +1,7 @@ package main +import _ "embed" + // tmplPlgJSON is the plg.json template const tmplPlgJSON = `{ "name": "{{.Plg.Name}}", @@ -91,74 +93,14 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e } ` -const tmplSDKLua = `-- HomeAgent Lua Plugin SDK (standalone mock) -sdk = {} -function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end -function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end -function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end -function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end -function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end -function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end -function sdk.get_setting(key) return nil end -function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end -function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end -function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end -function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end -function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end -sdk.memory = {} -function sdk.memory.recall(query, depth) return {entities={}, relations={}} end -function sdk.memory.commit(triples) return nil end -function sdk.memory.introspect() return {} end -function sdk.memory.merge(source, target) return 0 end -function sdk.memory.purge(criteria, hard) return 0 end -sdk.doc = {} -function sdk.doc.query(text, top_k) return {} end -function sdk.doc.insert(doc) return nil end -function sdk.doc.remove(id) return nil end -function sdk.doc.stats() return {} end -sdk.knowledge = {} -function sdk.knowledge.search(query, limit) return {} end -function sdk.knowledge.add(tag, content) return nil end -function sdk.knowledge.list() return {} end -sdk.text_memory = {} -function sdk.text_memory.append(evt) return nil end -sdk.llm = {} -function sdk.llm.list_sources() return {} end -function sdk.llm.set_source(name) return nil end -function sdk.llm.current_source() return nil end -sdk.social = {} -function sdk.social.get_person(name) return {} end -function sdk.social.get_network(name, depth) return {} end -function sdk.social.get_trait(name, trait) return {value=nil, found=false} end -function sdk.social.get_relations(name) return {} end -function sdk.social.list_persons() return {} end -sdk.settings = {} -function sdk.settings.get_core(key) return nil end -function sdk.settings.set_core(key, value) return nil end -function sdk.settings.list_core(prefix) return {} end -function sdk.settings.get_plugin(plugin, key) return nil end -function sdk.settings.set_plugin(plugin, key, value) return nil end -function sdk.settings.list_plugin(plugin, prefix) return {} end -function sdk.settings.list(prefix) return {} end -function sdk.settings.register_def(def) return nil end -function sdk.settings.defs(prefix) return {} end -function sdk.settings.dump() return {} end -function sdk.settings.plugins() return {} end -sdk.json = {} -function sdk.json.encode(val) - if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"' - elseif type(val) == "number" or type(val) == "boolean" then return tostring(val) - elseif type(val) == "table" then local parts, i = {}, 1 - for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end - return "{" .. table.concat(parts, ",") .. "}" end - return "null" -end -function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end -sdk.http = {} -function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end -function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end -return sdk -` +// fallbackLuaSDK 是 sdk.lua 的内嵌回退副本(单一事实源为 SDK 仓 sdk/lua/sdk.lua)。 +// +// 为什么不再内联一份手写 mock:三份 sdk.lua(内核内嵌 / 工具链模板 / 项目副本) +// 各自漂移过一次,结果就是“mock 有、内核没有”或反过来。改为从 assets/sdk.lua +// 内嵌 + 由 SDK 仓同步脚本搬运,并配契约测试守住。 +// +//go:embed assets/sdk.lua +var fallbackLuaSDK string const tmplMainLua = `-- {{.Plg.Name}} plugin local plugin = { name = "{{.Plg.Name}}" }