diff --git a/.gitignore b/.gitignore index 3ab180f..7a02ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Editor type definitions, fetched from official-plugins (see README). -/noctalia.d.luau +# Editor type definitions, fetched per-plugin from official-plugins (see README). +noctalia.d.luau + -/.github/workflows/scripts/__pycache__ diff --git a/mimir/README.md b/mimir/README.md new file mode 100644 index 0000000..f4ca3cf --- /dev/null +++ b/mimir/README.md @@ -0,0 +1,101 @@ +# Mimir + +An AI companion for Noctalia that brings LLM-powered chat and terminal command execution directly into your desktop. Named after the Norse god of wisdom. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `alexander/mimir` | +| Entries | Bar widget: `status`; panel: `chat`; service: `brain` | + +## Requirements + +- An **OpenAI-compatible API endpoint** with `/chat/completions` and `/models` endpoints. +- An API key (for hosted providers) or leave empty for local servers (e.g. Ollama). +- A [Noctalia](https://noctalia.app) build supporting `plugin_api >= 16`. + +If you use [OpenCode Go](https://opencode.ai/go) with the default OpenCode endpoint, Mimir auto-detects your API key from `~/.local/share/opencode/auth.json` — no manual setup needed. + +## Features + +- **Chat** — Conversational AI with formatted responses, markdown rendering (code blocks in shaded boxes), and selectable text for every message. +- **Command History** — Optionally shows executed commands in the chat, including commands run automatically in `allow` mode. +- **Model Browser** — Fetches available models from your API endpoint. Switch models on the fly from the panel header. +- **Command Execution** — Mimir can run terminal commands through the AI. In `ask` mode, each command must be approved before it runs; `allow` mode runs non-blocked commands automatically. +- **Permission Modes** — `ask` (prompt before every command), `allow` (run automatically), `off` (no tools). Automatic mode still rejects blocked commands and shell composition. +- **Command Blocklist** — Dangerous commands and shell composition are rejected before execution. This is an extra safeguard, not a replacement for reviewing commands. + +## Architecture + +``` +┌──────────┐ state ┌──────────┐ HTTP ┌─────────────┐ +│ panel │◄───────────►│ service │◄──────────►│ API Server │ +│ (chat) │ mimir.* │ (brain) │ chat/* │ (OpenCode) │ +│ │ │ │ models │ │ +└────┬─────┘ └──────────┘ └─────────────┘ + │ + │ click +┌────▼─────┐ +│ widget │ +│ (status) │ +└──────────┘ +``` + +**Widget** (`widget.luau`) — Bar indicator. Click to toggle the chat panel. + +**Panel** (`panel.luau`) — Chat interface with model selector, command approval, command history, message history with markdown rendering, and per-message selectable text views. + +**Service** (`service.luau`) — HTTP communication with the API, conversation management, command execution, model discovery, and deferred state propagation. + +## Usage + +### Install + +1. Add the plugin directory as a path source in Noctalia settings. +2. Enable `alexander/mimir` in **Settings → Plugins**. +3. Add the bar widget `alexander/mimir:status` to your bar. + +### Chat + +Click the brain icon in your bar or run: +```sh +noctalia msg panel-toggle alexander/mimir:chat +``` + +Type a message and press Enter. Mimir responds with formatted text — code blocks render in shaded boxes. Click the copy icon on any message to open its content in a selectable field, then copy the text manually. + +### Command Approval + +When Mimir wants to run a terminal command (in `ask` permission mode), the panel shows an approval dialog: +1. Review the command shown in the dialog. +2. Click **Approve** to run it or **Deny** to cancel. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `api_endpoint` | `string` | `https://opencode.ai/zen/go/v1` | Base URL for the API. Change to `http://localhost:11434/v1` for Ollama. | +| `api_key` | `string` | (auto-detect) | API key. If empty and using the trusted OpenCode endpoint, reads from `~/.local/share/opencode/auth.json`. | +| `tool_permission` | `enum` | `ask` | `ask` — prompt before commands; `allow` — run automatically; `off` — disable tools. | +| `tool_blocklist` | `string` | `sudo,su,passwd,rm,...` | Comma-separated commands rejected before execution. | +| `show_commands` | `bool` | `true` | Show executed commands in the chat. | +| `max_history` | `int` | `50` | Max messages kept in context. | +| `glyph` | `glyph` | `brain` | Bar icon (per-widget setting). | + +## How It Works + +### API Compatibility +Compatible with any OpenAI-compatible chat completion API. Defaults to OpenCode Go. + +### Tool Calling +When the model returns `tool_calls`, the service routes them to `run_command`. The permission mode determines whether to run immediately, prompt the user, or skip. Blocklisted commands and shell composition are rejected before execution. + +### State Flow +Entries are isolated VMs — they communicate through Noctalia's shared state (`noctalia.state.*`). HTTP callbacks queue responses to avoid cross-context state corruption. A timer-driven `update()` processes the queue and propagates results. + +## Notes + +- Conversation is ephemeral (in-memory only). Restarting clears it. +- API key auto-detection reads OpenCode Go's auth file at runtime only — never stored. +- For best results, use a model with tool-calling support. diff --git a/mimir/panel.luau b/mimir/panel.luau new file mode 100644 index 0000000..2ff5212 --- /dev/null +++ b/mimir/panel.luau @@ -0,0 +1,321 @@ +local inputText = "" +local inputKey = 0 +local copyTarget = nil +local modelIdx = 0 + +noctalia.state.watch("mimir.copy_target", function(value) + copyTarget = (value == -1) and nil or value + render() +end) + +local function mapUnicode(text, lowerBase, upperBase) + local out = "" + for i = 1, #text do + local c = text:sub(i, i) + local code = c:byte() + if code >= 97 and code <= 122 then + out = out .. utf8.char(code - 97 + lowerBase) + elseif code >= 65 and code <= 90 then + out = out .. utf8.char(code - 65 + upperBase) + else + out = out .. c + end + end + return out +end + +local function inlineMarkdown(text) + text = text:gsub("%*%*%*([^%*]-)%*%*%*", function(s) return mapUnicode(s, 0x1D482, 0x1D468) end) + text = text:gsub("%*%*([^%*]-)%*%*", function(s) return mapUnicode(s, 0x1D41A, 0x1D400) end) + text = text:gsub("%*([^%*]-)%*", function(s) return mapUnicode(s, 0x1D44E, 0x1D434) end) + text = text:gsub("~~([^~]-)~~", "%1") + text = text:gsub("`(.-)`", "%1") + text = text:gsub("%[([^%]]*)%]%(([^%)]+)%)", "%1 (%2)") + return text +end + +local function renderText(text, color, fontSize, fontWeight) + return ui.label({ text = inlineMarkdown(text), fontSize = fontSize or 13, fontWeight = fontWeight, color = color, maxWidth = 380 }) +end + +local function toolCommand(toolCall) + local fn = toolCall["function"] + if not fn or type(fn.arguments) ~= "string" then return nil end + local ok, args = pcall(noctalia.json.decode, fn.arguments) + if ok and type(args) == "table" and type(args.command) == "string" then + return args.command + end + return nil +end + +local function parseMessage(content) + local nodes = {} + local lines = {} + for line in (content .. "\n"):gmatch("(.-)\n") do + table.insert(lines, line) + end + local i = 1 + local function trim(s) + return (s:gsub("^%s*(.-)%s*$", "%1")) + end + while i <= #lines do + local line = lines[i] + if line:find("^```") then + local buf = {} + local lang = line:sub(4) + i = i + 1 + while i <= #lines and not lines[i]:find("^```") do + table.insert(buf, lines[i]) + i = i + 1 + end + i = i + 1 + table.insert(nodes, { type = "code", language = trim(lang), content = table.concat(buf, "\n") }) + elseif line:match("^#+") and line:gsub("#+", ""):match("%S") then + local level, text = line:match("^(#+)%s*(.-)%s*$") + level = #level + text = text:gsub("#+%s*$", "") + table.insert(nodes, { type = "heading", level = level, content = text }) + i = i + 1 + elseif line:match("^%s*[-*_]+%s*$") and #line:gsub("%s", "") >= 3 then + table.insert(nodes, { type = "hr" }) + i = i + 1 + elseif line:match("^%s*>") then + local buf = {} + while i <= #lines and lines[i]:match("^%s*>") do + local t = lines[i]:gsub("^%s*>%s?", "") + if t == "" then t = " " end + table.insert(buf, t) + i = i + 1 + end + table.insert(nodes, { type = "quote", content = table.concat(buf, " ") }) + elseif line:match("^%s*[-*+]%s+") or line:match("^%s*%d+[.)]%s+") then + local items = {} + while i <= #lines do + local l = lines[i] + local bullet = l:match("^%s*[-*+]%s+(.*)") + local numbered = l:match("^%s*%d+[.)]%s+(.*)") + if bullet then + table.insert(items, { ordered = false, content = bullet }) + i = i + 1 + elseif numbered then + table.insert(items, { ordered = true, content = numbered }) + i = i + 1 + else + break + end + end + table.insert(nodes, { type = "list", items = items }) + else + local buf = {} + while i <= #lines and not lines[i]:match("^```") and not (lines[i]:match("^#+") and lines[i]:gsub("#+", ""):match("%S")) + and not (lines[i]:match("^%s*[-*_]+%s*$") and #lines[i]:gsub("%s", "") >= 3) and not lines[i]:match("^%s*>") + and not lines[i]:match("^%s*[-*+]%s+") and not lines[i]:match("^%s*%d+[.)]%s+") do + table.insert(buf, lines[i]) + i = i + 1 + end + local para = table.concat(buf, "\n") + if trim(para) ~= "" then + table.insert(nodes, { type = "text", content = para }) + end + end + end + return nodes +end + +function render() + local models = noctalia.state.get("mimir.models") or {} + local messages = noctalia.state.get("mimir.messages") or {} + local status = noctalia.state.get("mimir.status") or "idle" + local commandLog = noctalia.state.get("mimir.command_log") or {} + local showCommands = noctalia.getConfig("show_commands") ~= false + local commandsByToolCall = {} + for _, entry in ipairs(commandLog) do + commandsByToolCall[entry.tool_call_id] = entry.command + end + + local statusColors = { idle = "on_surface/0.4", thinking = "primary", running_tool = "primary", error = "error" } + local statusTexts = { idle = "Ready", thinking = "Thinking...", running_tool = "Running command...", error = "Error" } + + local displayMsgs = {} + local copyContent = nil + for i, msg in ipairs(messages) do + if msg.role ~= "system" and msg.role ~= "tool" and msg.content then + local isUser = msg.role == "user" + local isCopying = copyTarget == i + if isCopying then copyContent = msg.content end + local nodes = parseMessage(msg.content or "") + local contentNodes = {} + for _, node in ipairs(nodes) do + if node.type == "text" then + table.insert(contentNodes, renderText(node.content, isUser and "primary" or "on_surface")) + elseif node.type == "code" then + table.insert(contentNodes, ui.column({ fill = "surface_variant", padding = 8, radius = 6 }, { + ui.label({ text = node.content, fontSize = 12, color = "on_surface_variant" }), + })) + elseif node.type == "heading" then + local sizes = { 17, 15, 14, 13, 13, 13 } + table.insert(contentNodes, renderText(node.content, isUser and "primary" or "on_surface", sizes[node.level] or 13, "bold")) + elseif node.type == "quote" then + table.insert(contentNodes, ui.column({ fill = "surface_variant", padding = 8, radius = 4 }, { + renderText(node.content, isUser and "primary" or "on_surface_variant", 12), + })) + elseif node.type == "hr" then + table.insert(contentNodes, ui.separator({ thickness = 1, color = "surface_variant" })) + elseif node.type == "list" then + local listChildren = {} + for n, item in ipairs(node.items) do + local prefix = item.ordered and (n .. ". ") or "• " + table.insert(listChildren, renderText(prefix .. item.content, isUser and "primary" or "on_surface")) + end + table.insert(contentNodes, ui.column({ gap = 2 }, listChildren)) + end + end + if msg.tool_calls then + for _, toolCall in ipairs(msg.tool_calls) do + local command = showCommands and (toolCommand(toolCall) or commandsByToolCall[toolCall.id]) + if command then + table.insert(contentNodes, ui.column({ gap = 2, padding = 8, fill = "surface_variant", radius = 6 }, { + ui.label({ text = "Command", fontSize = 11, color = "on_surface/0.5" }), + ui.label({ text = "$ " .. command, fontSize = 12, color = "primary", maxWidth = 380 }), + })) + end + end + end + table.insert(displayMsgs, ui.column({ key = "m" .. i, gap = 4, paddingH = 4, align = isUser and "end" or "start" }, { + ui.row({ gap = 4, align = "center" }, { + ui.label({ text = isUser and "You" or "Mimir", fontSize = 11, color = "on_surface/0.5" }), + ui.button({ glyph = isCopying and "close" or "copy", variant = "ghost", onClick = function() noctalia.state.set("mimir.copy_target", isCopying and -1 or i) end }), + }), + table.unpack(contentNodes), + })) + end + end + + local pendingTool = noctalia.state.get("mimir.pending_tool") + if pendingTool and type(pendingTool) == "table" and pendingTool.commands then + local approvalChildren = { + ui.label({ text = "Mimir wants to run:", fontSize = 12, color = "on_surface" }), + } + for _, c in ipairs(pendingTool.commands) do + table.insert(approvalChildren, ui.label({ text = c.command or "", fontSize = 12, color = "primary", maxWidth = 380 })) + end + table.insert(approvalChildren, ui.row({ gap = 8, align = "center" }, { + ui.button({ text = "Approve", variant = "primary", onClick = "onApproveTool" }), + ui.button({ text = "Deny", variant = "ghost", onClick = "onDenyTool" }), + })) + table.insert(displayMsgs, ui.column({ key = "tool-approval", gap = 8, padding = 12, fill = "surface_variant", radius = 8 }, approvalChildren)) + end + + local layout = { + ui.row({ gap = 6, padding = 12, align = "center" }, { + ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefreshModels" }), + ui.select({ options = models, selectedIndex = modelIdx, flexGrow = 1, placeholder = "No models", onChange = "onModelChange" }), + }), + ui.separator({ thickness = 1, color = "surface_variant" }), + ui.scroll({ flexGrow = 1, gap = 4, padding = 12 }, displayMsgs), + ui.separator({ thickness = 1, color = "surface_variant" }), + } + if copyContent then + table.insert(layout, ui.column({ flexGrow = 1, padding = 8, fill = "surface_variant" }, { + ui.input({ key = "copy-area", multiline = true, value = copyContent, focus = true, flexGrow = 1, onChange = function() end }), + })) + end + table.insert(layout, ui.row({ gap = 8, padding = { left = 12, right = 12, top = 8, bottom = 4 }, align = "center" }, { + ui.input({ key = "msg-input-" .. inputKey, value = inputText, placeholder = "Ask me anything...", flexGrow = 1, focus = true, onChange = "onInputChange", onSubmit = "onSubmit" }), + ui.button({ glyph = "send", variant = "primary", onClick = "onSubmit" }), + })) + table.insert(layout, ui.row({ gap = 12, padding = { left = 12, right = 12, top = 4, bottom = 12 }, align = "center" }, { + ui.label({ text = statusTexts[status] or "Ready", fontSize = 11, color = statusColors[status] or "on_surface/0.4" }), + ui.button({ glyph = "eraser", variant = "ghost", onClick = "onClear" }), + })) + panel.render(ui.column({ flexGrow = 1, gap = 0, align = "stretch" }, layout)) +end + +function onModelChange(index, text) + local models = noctalia.state.get("mimir.models") or {} + local idx = tonumber(index) + if idx == nil and text then + for i, m in ipairs(models) do + if m == text then + idx = i - 1 + break + end + end + end + if idx ~= nil and models[idx + 1] then + modelIdx = idx + noctalia.state.set("mimir.model", models[idx + 1]) + render() + end +end + +function onClear() + noctalia.state.set("mimir.clear", true) +end + +function onRefreshModels() + noctalia.state.set("mimir.refresh_models", true) +end + +function onInputChange(v) + inputText = type(v) == "table" and (v.value or "") or v or "" +end + +function onSubmit(v) + local text = v + if type(text) == "table" then text = text.value end + if type(text) ~= "string" or text == "" then text = inputText end + inputText = "" + if text and text ~= "" then + inputKey += 1 + noctalia.state.set("mimir.copy_target", -1) + noctalia.state.set("mimir.status", "thinking") + noctalia.state.set("mimir.input", text) + render() + end +end + +noctalia.state.watch("mimir.messages", function() + render() +end) + +noctalia.state.watch("mimir.status", function() + render() +end) + +noctalia.state.watch("mimir.models", function() + local models = noctalia.state.get("mimir.models") or {} + local currentModel = noctalia.state.get("mimir.model") or "" + modelIdx = 0 + for i, m in ipairs(models) do + if m == currentModel then + modelIdx = i - 1 + break + end + end + render() +end) + +noctalia.state.watch("mimir.pending_tool", function() + if noctalia.state.get("mimir.pending_tool") then + noctalia.state.set("mimir.copy_target", -1) + end + render() +end) + +noctalia.state.watch("mimir.command_log", function() + render() +end) + +function onApproveTool() + noctalia.state.set("mimir.tool_approved", true) +end + +function onDenyTool() + noctalia.state.set("mimir.tool_denied", true) +end + +function onOpen() + noctalia.state.set("mimir.refresh_models", true) + render() +end diff --git a/mimir/plugin.toml b/mimir/plugin.toml new file mode 100644 index 0000000..50496e9 --- /dev/null +++ b/mimir/plugin.toml @@ -0,0 +1,86 @@ +id = "alexander/mimir" +name = "Mimir" +version = "0.4.0" +plugin_api = 16 +author = "Alexander" +license = "MIT" +icon = "brain" +description = "An AI companion for Noctalia that brings LLM-powered chat directly into your desktop." +dependencies = [] +tags = ["ai", "utility", "productivity", "development"] + +[[widget]] +id = "status" +entry = "widget.luau" + + [widget.actions] + left = "panel-toggle alexander/mimir:chat" + +[[widget.setting]] +key = "glyph" +type = "glyph" +label_key = "settings.glyph.label" +default = "brain" + +[[panel]] +id = "chat" +entry = "panel.luau" +placement = "floating" +position = "center_right" +width = 450 +height = "fill" + +[[service]] +id = "brain" +entry = "service.luau" + +[[setting]] +key = "api_endpoint" +type = "string" +label_key = "settings.api_endpoint.label" +description_key = "settings.api_endpoint.description" +default = "https://opencode.ai/zen/go/v1" + +[[setting]] +key = "api_key" +type = "string" +label_key = "settings.api_key.label" +description_key = "settings.api_key.description" +default = "" +advanced = true + +[[setting]] +key = "tool_permission" +type = "select" +label_key = "settings.tool_permission.label" +description_key = "settings.tool_permission.description" +options = [ + { value = "ask", label_key = "settings.tool_permission.ask" }, + { value = "allow", label_key = "settings.tool_permission.allow" }, + { value = "off", label_key = "settings.tool_permission.off" }, +] +default = "ask" + +[[setting]] +key = "tool_blocklist" +type = "string" +label_key = "settings.tool_blocklist.label" +description_key = "settings.tool_blocklist.description" +default = "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" + +[[setting]] +key = "show_commands" +type = "bool" +label_key = "settings.show_commands.label" +description_key = "settings.show_commands.description" +default = true + +[[setting]] +key = "max_history" +type = "int" +label_key = "settings.max_history.label" +description_key = "settings.max_history.description" +default = 50 +min = 1 +max = 500 +advanced = true diff --git a/mimir/service.luau b/mimir/service.luau new file mode 100644 index 0000000..f335078 --- /dev/null +++ b/mimir/service.luau @@ -0,0 +1,420 @@ +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", {}) diff --git a/mimir/thumbnail.webp b/mimir/thumbnail.webp new file mode 100644 index 0000000..74d4839 Binary files /dev/null and b/mimir/thumbnail.webp differ diff --git a/mimir/translations/en.json b/mimir/translations/en.json new file mode 100644 index 0000000..7f45e81 --- /dev/null +++ b/mimir/translations/en.json @@ -0,0 +1,59 @@ +{ + "title": "Mimir", + "tooltip": "Mimir AI — {status}", + "input_placeholder": "Ask me anything...", + "send": "Send", + "clear": "Clear", + "status_idle": "Idle", + "status_thinking": "Thinking...", + "status_tool": "Running command...", + "status_error": "Error", + "status_online": "Online", + "status_offline": "Offline", + "settings": { + "glyph": { "label": "Bar Icon" }, + "model_name": { + "label": "Model", + "description": "OpenCode Go model to use", + "deepseek_flash": "DeepSeek V4 Flash (fast, cheap)", + "deepseek_pro": "DeepSeek V4 Pro (powerful)", + "grok45": "Grok 4.5", + "kimi_code": "Kimi K2.7 Code", + "glm52": "GLM-5.2" + }, + "api_endpoint": { + "label": "API Endpoint", + "description": "Base URL for the API (default Ollama uses http://localhost:11434/v1)" + }, + "api_key": { + "label": "API Key", + "description": "API key for hosted providers (stored locally and never shared)" + }, + "mode": { + "label": "Thinking Mode", + "description": "How deeply the AI thinks before responding", + "fast": "Fast — Quick answers, minimal tools", + "normal": "Normal — Balanced reasoning and tools", + "deep": "Deep — Exhaustive search, multi-step reasoning" + }, + "tool_permission": { + "label": "Tool Permission", + "description": "When the AI can run terminal commands", + "ask": "Ask — Prompt before every command", + "allow": "Allow — Run automatically", + "off": "Off — No tools" + }, + "tool_blocklist": { + "label": "Blocked Commands", + "description": "Comma-separated commands never allowed" + }, + "show_commands": { + "label": "Show Commands", + "description": "Show executed commands in the chat" + }, + "max_history": { + "label": "Max History", + "description": "Number of messages to keep in conversation context" + } + } +} diff --git a/mimir/widget.luau b/mimir/widget.luau new file mode 100644 index 0000000..77f0d7d --- /dev/null +++ b/mimir/widget.luau @@ -0,0 +1,35 @@ +local status = noctalia.state.get("mimir.status") or "idle" + +local glyphs = { + idle = "brain", + thinking = "loader", + tool = "terminal-2", + error = "alert-circle", +} + +local colors = { + idle = "on_surface", + thinking = "primary", + tool = "tertiary", + error = "error", +} + +function update() + status = noctalia.state.get("mimir.status") or "idle" + barWidget.setGlyph(glyphs[status] or "brain") + barWidget.setGlyphColor(colors[status] or "on_surface") + local tooltip = status == "idle" and "Mimir — Ready" or "Mimir — " .. status + barWidget.setTooltip(tooltip) +end + +function onClick() + noctalia.togglePanel("alexander/mimir:chat") +end + +noctalia.state.watch("mimir.status", function(newStatus) + status = newStatus or "idle" + barWidget.setGlyph(glyphs[status] or "brain") + barWidget.setGlyphColor(colors[status] or "on_surface") + local tooltip = status == "idle" and "Mimir — Ready" or "Mimir — " .. status + barWidget.setTooltip(tooltip) +end)