Mimir: introduce AI companion plugin (#164)

* 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>
This commit is contained in:
Ahmed Emad
2026-08-01 08:59:48 -04:00
committed by GitHub
co-authored by Ahmed5Emad
parent c977481b59
commit 92f8a33a3f
8 changed files with 1025 additions and 3 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
# Editor type definitions, fetched from official-plugins (see README). # Editor type definitions, fetched per-plugin from official-plugins (see README).
/noctalia.d.luau noctalia.d.luau
/.github/workflows/scripts/__pycache__
+101
View File
@@ -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.
+321
View File
@@ -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
+86
View File
@@ -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
+420
View File
@@ -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", {})
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+59
View File
@@ -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"
}
}
}
+35
View File
@@ -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)