* game-launcher: add launcher toggles, keyboard nav, auto-refresh * game-launcher: update README with runner toggle settings * fix: remove duplicate translation keys after upstream merge * fix(game-launcher): add ~/.local/share/Steam to steam roots for NixOS support * chore(game-launcher): bump version to 1.1.1 * feat(mimir): AI companion plugin with LLM chat panel Mimir is an AI companion for Noctalia — an LLM-powered chat interface with model selection, conversation history, and a bar widget. - Service-based architecture: service (brain) handles HTTP API calls, panel (chat) renders the UI, widget (status) shows bar indicator - OpenAI-compatible chat completions with dynamic model discovery - Floating side panel (center_right) with message history and simple markdown rendering (code blocks) - Model selection dropdown populated from API /models endpoint - Bar widget with brain icon to toggle chat panel - i18n via translations/en.json - Auto-detection of OpenCode Go API key from auth.json - Full-height floating panel layout matching oficial notes plugin .gitignore: add editor files, OS junk, auth secrets, compiled binary * mimir: rename author leo->Alexander, strip scrollBottom, update README with plans - plugin.toml: author leo -> Alexander, widget panel-toggle id -> alexander/mimir - widget.luau: togglePanel id -> alexander/mimir:chat - panel.luau: remove scrollBottom/dynamic key (unstable), remove setUpdateInterval - README.md: add future plans (commands, file search, etc.) - thumbnail.webp: removed (replaced by mimir-thumbnail.webp) * added thumbnail * added thumbnail.webp * mimir: bump 0.1.0 → 0.3.0, update README with tools + copy + editable approval * mimir: copy-to-clipboard toggle, editable command approval, unicode bold rendering * mimir: fix review findings — conditional auth, dedupe user msg, apply max_history * mimir: fix manifest validation — use select setting type, add README Plugin section * mimir: multi-tool queue support, plain approve/deny, better tool-use prompt * mimir: add full markdown rendering — headings, lists, quotes, hr, bold/italic * mimir: bump 0.3.2 — fix Lua pattern quantifiers, multi-tool queue, markdown rendering * mimir: fix security review findings * mimir: clarify selectable message text * mimir: add command history display --------- Co-authored-by: Ahmed5Emad <ahmed5emad@users.noreply.github.com>
421 lines
14 KiB
Luau
421 lines
14 KiB
Luau
local M = {}
|
|
M.conversation = {}
|
|
M.pendingResponses = {}
|
|
M.pendingToolResults = {}
|
|
M.pendingToolCalls = {}
|
|
M.pendingApproval = nil
|
|
M.toolBatchActive = false
|
|
M.commandLog = {}
|
|
|
|
local TOOLS = {
|
|
{
|
|
type = "function",
|
|
["function"] = {
|
|
name = "run_command",
|
|
description = "Run a shell command on the user's system. Prefer the simplest standard utility that works (ls, find, grep, cat); fall back to a small script only when no utility fits",
|
|
parameters = {
|
|
type = "object",
|
|
properties = {
|
|
command = {
|
|
type = "string",
|
|
description = "The shell command to run",
|
|
},
|
|
description = {
|
|
type = "string",
|
|
description = "Brief description of what this command does",
|
|
},
|
|
},
|
|
required = { "command", "description" },
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
local function isTrustedOpenCodeEndpoint(endpoint)
|
|
local scheme, authority = endpoint:match("^(%a[%w+.-]*)://([^/%?#]+)")
|
|
if not scheme or not authority then return false end
|
|
local host, port = authority:match("^([^:]+):(%d+)$")
|
|
if not host then host = authority end
|
|
if scheme:lower() ~= "https" or host:lower() ~= "opencode.ai" then return false end
|
|
return port == nil or port == "443"
|
|
end
|
|
|
|
local function loadApiKey()
|
|
local key = noctalia.getConfig("api_key") or ""
|
|
if key ~= "" then return key end
|
|
local endpoint = noctalia.getConfig("api_endpoint") or "https://opencode.ai/zen/go/v1"
|
|
if not isTrustedOpenCodeEndpoint(endpoint) then return "" end
|
|
local ok, data = pcall(noctalia.readFile, noctalia.expandPath("~/.local/share/opencode/auth.json"))
|
|
if ok and data then
|
|
local parsed = noctalia.json.decode(data)
|
|
if parsed and parsed["opencode-go"] and parsed["opencode-go"].key then
|
|
return parsed["opencode-go"].key
|
|
end
|
|
end
|
|
return ""
|
|
end
|
|
|
|
local function loadConfig()
|
|
return {
|
|
endpoint = noctalia.getConfig("api_endpoint") or "https://opencode.ai/zen/go/v1",
|
|
apiKey = loadApiKey(),
|
|
toolPermission = noctalia.getConfig("tool_permission") or "ask",
|
|
showCommands = noctalia.getConfig("show_commands") ~= false,
|
|
toolBlocklist = noctalia.getConfig("tool_blocklist") or "sudo,su,passwd,rm,mv,shred,truncate,tee,install,chown,chmod,mount,umount,dd,mkfs,fsck,reboot,shutdown,poweroff,init,halt,curl,wget,nc,ncat,socat,env,find,xargs,sh,bash,zsh,fish,python,python3,perl,ruby,node,lua,luau,eval,source,busybox,make,cmake,just,task",
|
|
maxHistory = noctalia.getConfig("max_history") or 50,
|
|
}
|
|
end
|
|
|
|
local function isBlocked(command)
|
|
local trimmed = command:match("^%s*(.-)%s*$")
|
|
if not trimmed then return false end
|
|
if trimmed == "" then return false end
|
|
|
|
-- runAsync passes this string to /bin/sh -c, so do not allow shell syntax
|
|
-- to combine commands or invoke a second interpreter.
|
|
if trimmed:find("[;|&><`$\\\n\r]") then return true end
|
|
|
|
local first = trimmed:match("^(%S+)")
|
|
if not first then return false end
|
|
first = first:gsub("^['\"]", ""):gsub("['\"]$", "")
|
|
first = first:match("([^/]+)$"):lower()
|
|
local blocklist = loadConfig().toolBlocklist
|
|
for item in blocklist:gmatch("[^,]+") do
|
|
local b = item:match("^%s*(.-)%s*$")
|
|
if b and b ~= "" and b:lower():match("([^/]+)$") == first then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function setStatus(s)
|
|
M.status = s
|
|
noctalia.state.set("mimir.status", s)
|
|
end
|
|
|
|
local function addMessage(role, content, extra)
|
|
local msg = { role = role, content = content or "" }
|
|
if extra then
|
|
for k, v in pairs(extra) do msg[k] = v end
|
|
end
|
|
table.insert(M.conversation, msg)
|
|
local copy = {}
|
|
for _, v in ipairs(M.conversation) do
|
|
table.insert(copy, v)
|
|
end
|
|
noctalia.state.set("mimir.messages", copy)
|
|
end
|
|
|
|
local function think(input)
|
|
setStatus("thinking")
|
|
local config = loadConfig()
|
|
|
|
if #M.conversation > config.maxHistory then
|
|
local trimmed = {}
|
|
for i = #M.conversation - config.maxHistory + 1, #M.conversation do
|
|
table.insert(trimmed, M.conversation[i])
|
|
end
|
|
M.conversation = trimmed
|
|
local copy = {}
|
|
for _, m in ipairs(M.conversation) do table.insert(copy, m) end
|
|
noctalia.state.set("mimir.messages", copy)
|
|
end
|
|
|
|
local model = noctalia.state.get("mimir.model") or "deepseek-v4-flash"
|
|
|
|
local msgs = {
|
|
{ role = "system", content = "You are Mimir, an AI assistant running directly on the user's desktop with shell access. Your job: accomplish real tasks by running shell commands through the run_command tool, then report what actually happened. Choose the simplest, fastest command for the job. For local files and system state, standard utilities are ideal: ls, find, grep, cat, which, df, ps, head, wc. When a standard utility exists, prefer it over writing scripts — one-liners are faster, clearer, and less likely to need approvals. Reach for a script (python, etc.) only when no simple utility covers the task. Use your own knowledge to answer questions and explain things; you do not need to fetch web pages. When the task needs the user's input or a choice, ask instead of guessing. Never just give instructions — run the command and show the result. The user has already authorized command execution through the approval flow, so do not hesitate to use tools for legitimate tasks." },
|
|
}
|
|
for _, m in ipairs(M.conversation) do
|
|
table.insert(msgs, m)
|
|
end
|
|
if input then
|
|
table.insert(msgs, { role = "user", content = input })
|
|
end
|
|
|
|
local body = { model = model, messages = msgs }
|
|
if config.toolPermission ~= "off" then
|
|
body.tools = TOOLS
|
|
end
|
|
|
|
local encoded = noctalia.json.encode(body)
|
|
if not encoded then
|
|
setStatus("idle")
|
|
if input then addMessage("assistant", "Error: failed to encode request") end
|
|
return
|
|
end
|
|
|
|
local url = config.endpoint .. "/chat/completions"
|
|
|
|
local headers = { "Content-Type: application/json" }
|
|
if config.apiKey ~= "" then
|
|
table.insert(headers, "Authorization: Bearer " .. config.apiKey)
|
|
end
|
|
|
|
noctalia.http({
|
|
url = url,
|
|
method = "POST",
|
|
headers = headers,
|
|
body = encoded,
|
|
}, function(res)
|
|
table.insert(M.pendingResponses, res)
|
|
end)
|
|
end
|
|
|
|
local function runCommand(tool_call, command)
|
|
setStatus("running_tool")
|
|
|
|
noctalia.runAsync(command, function(result)
|
|
local output = ""
|
|
local parts = {}
|
|
if result.stdout and result.stdout ~= "" then table.insert(parts, result.stdout) end
|
|
if result.stderr and result.stderr ~= "" then table.insert(parts, result.stderr) end
|
|
output = table.concat(parts, "\n")
|
|
if output == "" then
|
|
output = "(exit code " .. tostring(result.status) .. ", no output)"
|
|
end
|
|
local config = loadConfig()
|
|
if config.showCommands then
|
|
table.insert(M.commandLog, { tool_call_id = tool_call.id, command = command })
|
|
if #M.commandLog > 50 then table.remove(M.commandLog, 1) end
|
|
local logCopy = {}
|
|
for _, entry in ipairs(M.commandLog) do table.insert(logCopy, entry) end
|
|
noctalia.state.set("mimir.command_log", logCopy)
|
|
end
|
|
table.insert(M.pendingToolResults, { tool_call_id = tool_call.id, output = output })
|
|
end)
|
|
end
|
|
|
|
local function queueToolCalls(tool_calls)
|
|
M.pendingToolCalls = {}
|
|
M.pendingApproval = nil
|
|
M.toolBatchActive = true
|
|
for _, tc in ipairs(tool_calls) do
|
|
if tc.type == "function" and tc["function"].name == "run_command" then
|
|
local okArgs, args = pcall(noctalia.json.decode, tc["function"].arguments)
|
|
table.insert(M.pendingToolCalls, {
|
|
tc = tc,
|
|
args = (okArgs and type(args) == "table") and args or nil,
|
|
status = "waiting",
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
local function allToolCallsDone()
|
|
for _, call in ipairs(M.pendingToolCalls) do
|
|
if call.status ~= "done" then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function finishToolBatch()
|
|
if not M.toolBatchActive then return end
|
|
if not allToolCallsDone() then return end
|
|
M.toolBatchActive = false
|
|
M.pendingToolCalls = {}
|
|
M.pendingApproval = nil
|
|
think(nil)
|
|
end
|
|
|
|
local function processToolQueue()
|
|
if #M.pendingToolCalls == 0 then
|
|
M.toolBatchActive = false
|
|
return
|
|
end
|
|
|
|
for _, call in ipairs(M.pendingToolCalls) do
|
|
if call.status == "running" then return end
|
|
end
|
|
|
|
local config = loadConfig()
|
|
|
|
for _, call in ipairs(M.pendingToolCalls) do
|
|
if call.status == "waiting" then
|
|
if call.args == nil or type(call.args.command) ~= "string" or call.args.command == "" then
|
|
addMessage("tool", "Error: invalid tool arguments", { tool_call_id = call.tc.id })
|
|
call.status = "done"
|
|
elseif isBlocked(call.args.command) then
|
|
addMessage("tool", "Command blocked by user settings: " .. call.args.command, { tool_call_id = call.tc.id })
|
|
call.status = "done"
|
|
elseif config.toolPermission == "off" then
|
|
addMessage("tool", "Tools are disabled in settings. Enable them to run commands.", { tool_call_id = call.tc.id })
|
|
call.status = "done"
|
|
end
|
|
end
|
|
end
|
|
|
|
local remaining = {}
|
|
for _, call in ipairs(M.pendingToolCalls) do
|
|
if call.status == "waiting" then table.insert(remaining, call) end
|
|
end
|
|
|
|
if config.toolPermission == "ask" and #remaining > 0 then
|
|
M.pendingApproval = {}
|
|
local commands = {}
|
|
for _, call in ipairs(remaining) do
|
|
table.insert(M.pendingApproval, { id = call.tc.id, command = call.args.command, description = call.args.description or "" })
|
|
table.insert(commands, { command = call.args.command, description = call.args.description or "" })
|
|
end
|
|
noctalia.state.set("mimir.pending_tool", { commands = commands })
|
|
setStatus("idle")
|
|
return
|
|
end
|
|
|
|
if config.toolPermission == "allow" then
|
|
for _, call in ipairs(remaining) do
|
|
call.status = "running"
|
|
runCommand(call.tc, call.args.command)
|
|
end
|
|
return
|
|
end
|
|
|
|
finishToolBatch()
|
|
end
|
|
|
|
function update()
|
|
for i, res in ipairs(M.pendingResponses) do
|
|
if M.toolBatchActive then break end
|
|
M.pendingResponses[i] = nil
|
|
|
|
if not res.ok then
|
|
local err = res.body or "no body"
|
|
if #err > 200 then err = err:sub(1, 200) .. "..." end
|
|
setStatus("idle")
|
|
addMessage("assistant", "HTTP " .. tostring(res.status) .. ": " .. err)
|
|
return
|
|
end
|
|
|
|
local data = noctalia.json.decode(res.body)
|
|
if not data or not data.choices then
|
|
local snippet = (res.body or ""):sub(1, 200)
|
|
setStatus("idle")
|
|
addMessage("assistant", "Invalid API response: " .. snippet)
|
|
return
|
|
end
|
|
|
|
local choice = data.choices[1]
|
|
local message = choice.message
|
|
|
|
if choice.finish_reason == "tool_calls" and message.tool_calls then
|
|
local clean = {}
|
|
for _, tc in ipairs(message.tool_calls) do
|
|
local c = { id = tc.id, type = tc.type, ["function"] = tc["function"] }
|
|
table.insert(clean, c)
|
|
end
|
|
addMessage("assistant", message.content, { tool_calls = clean })
|
|
queueToolCalls(message.tool_calls)
|
|
processToolQueue()
|
|
else
|
|
local content = message.content or ""
|
|
if content ~= "" then
|
|
addMessage("assistant", content)
|
|
end
|
|
setStatus("idle")
|
|
end
|
|
end
|
|
|
|
for i, result in ipairs(M.pendingToolResults) do
|
|
M.pendingToolResults[i] = nil
|
|
addMessage("tool", result.output, { tool_call_id = result.tool_call_id })
|
|
for _, call in ipairs(M.pendingToolCalls) do
|
|
if call.tc.id == result.tool_call_id then
|
|
call.status = "done"
|
|
end
|
|
end
|
|
end
|
|
finishToolBatch()
|
|
|
|
local approval = M.pendingApproval
|
|
if approval then
|
|
local approved = noctalia.state.get("mimir.tool_approved")
|
|
if approved then
|
|
noctalia.state.set("mimir.tool_approved", false)
|
|
noctalia.state.set("mimir.pending_tool", false)
|
|
M.pendingApproval = nil
|
|
for _, call in ipairs(M.pendingToolCalls) do
|
|
if call.status == "waiting" then
|
|
call.status = "running"
|
|
runCommand(call.tc, call.args.command)
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
local denied = noctalia.state.get("mimir.tool_denied")
|
|
if denied then
|
|
noctalia.state.set("mimir.tool_denied", false)
|
|
noctalia.state.set("mimir.pending_tool", false)
|
|
M.pendingApproval = nil
|
|
for _, call in ipairs(M.pendingToolCalls) do
|
|
if call.status == "waiting" then
|
|
addMessage("tool", "Command was cancelled by the user.", { tool_call_id = call.tc.id })
|
|
call.status = "done"
|
|
end
|
|
end
|
|
finishToolBatch()
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
noctalia.state.watch("mimir.input", function(input)
|
|
if input and input ~= "" then
|
|
if M.pendingApproval then
|
|
addMessage("assistant", "Please approve or deny the pending command first.")
|
|
return
|
|
end
|
|
if M.toolBatchActive then
|
|
addMessage("assistant", "Please wait for the running commands to finish.")
|
|
return
|
|
end
|
|
addMessage("user", input)
|
|
think(nil)
|
|
end
|
|
end)
|
|
|
|
noctalia.state.watch("mimir.clear", function(v)
|
|
if v then
|
|
M.conversation = {}
|
|
M.pendingToolCalls = {}
|
|
M.pendingApproval = nil
|
|
M.toolBatchActive = false
|
|
M.commandLog = {}
|
|
noctalia.state.set("mimir.messages", {})
|
|
noctalia.state.set("mimir.command_log", {})
|
|
noctalia.state.set("mimir.pending_tool", false)
|
|
setStatus("idle")
|
|
end
|
|
end)
|
|
|
|
local function fetchModels()
|
|
local config = loadConfig()
|
|
if config.endpoint == "" then return end
|
|
local url = config.endpoint .. "/models"
|
|
noctalia.http({ url = url }, function(res)
|
|
if not res.ok then return end
|
|
local data = noctalia.json.decode(res.body)
|
|
if not data or not data.data then return end
|
|
local models = {}
|
|
for _, m in ipairs(data.data) do
|
|
if m.id then table.insert(models, m.id) end
|
|
end
|
|
noctalia.state.set("mimir.models", models)
|
|
end)
|
|
end
|
|
|
|
noctalia.state.watch("mimir.refresh_models", function(v)
|
|
if v then fetchModels() noctalia.state.set("mimir.refresh_models", false) end
|
|
end)
|
|
|
|
noctalia.setUpdateInterval(500)
|
|
|
|
fetchModels()
|
|
if noctalia.state.get("mimir.model") == nil then
|
|
noctalia.state.set("mimir.model", "deepseek-v4-flash")
|
|
end
|
|
noctalia.state.set("mimir.status", "idle")
|
|
noctalia.state.set("mimir.messages", {})
|
|
noctalia.state.set("mimir.command_log", {})
|