pragma Singleton pragma ComponentBehavior: Bound import qs.modules.common.functions as CF import qs.modules.common import Quickshell import Quickshell.Io import Quickshell.Wayland import QtQuick /** * Souveraine — the substrate singleton for every shell module. * * This is the one connection to the Souveraine server. The chat sidebar, * presence widget, cockpit pane, agent manager and settings module all hang * off this service; none of them open their own transport. Ai.qml is the * ii-compat adapter over this for the existing sidebar UI. * * Responsibilities: * - agent inventory (GET /v1/agents) * - conversation lifecycle (create and server-derived resume) * - the SSE turn stream — raw events re-emitted via streamEvent(var) * - the backchannel: cancelTurn() and interject(text) * - the desktop sensorium: every send carries ambient context (active * window, open apps, cursor position) so she perceives the room she is * being spoken to in. Extension point for device sensors (SouveraineOS). */ Singleton { id: root property string serverBase: Config.options?.ai?.souveraineUrl ?? "http://127.0.0.1:8484" property bool serverUp: false // Ambient perception on by default; ai.ambient=false in ii config disables. property bool ambientEnabled: Config.options?.ai?.ambient ?? true // Start `souveraine server` ourselves when it isn't running. The surface // is the OS frontend — opening it means summoning her, not staring at a // connection error. ai.souveraineAutostart=false disables; a manual // start affordance can call startServer() directly. property bool autostartEnabled: Config.options?.ai?.souveraineAutostart ?? true property string serverBin: Config.options?.ai?.souveraineBin ?? "souveraine" property bool _autostartTried: false // id -> { name, description } property var agents: ({}) property var agentList: Object.keys(agents) property string currentAgentId: "" // Auto-resume: whenever the active agent is (re)established — shell start, // reboot, agent switch — re-attach her latest server-persisted conversation // so the next message continues the thread instead of minting a fresh one. // Without this every shell reload starts amnesiac, and the shell reloads // often (a deploy, a lock, a crash). The server is the source of truth here; // the surface deliberately keeps no agent → conversation map of its own. // Gated so it never clobbers a live turn or an already-attached thread. onCurrentAgentIdChanged: { if (root.currentAgentId.length > 0 && root.serverUp && root.conversationId.length === 0 && !root.turnActive) { root.resumeLatestConversation(); } } property string conversationId: "" property bool turnActive: false // ── Turn clock ─────────────────────────────────────────────────────── // Wall-clock for the request in flight. Lives here because turnActive // does; the chat surface and the pill both read it. `turnStartedAt` is // when we handed the request to curl (ms epoch, 0 = nothing sent yet), // `turnElapsedMs` ticks while the turn runs and freezes at the total. property double turnStartedAt: 0 property int turnElapsedMs: 0 Timer { running: root.turnActive interval: 100 repeat: true onTriggered: root.turnElapsedMs = Date.now() - root.turnStartedAt } /* Raw wire events (message_type-tagged objects from the SSE stream). */ signal streamEvent(var event) /* Stream closed (process exit). exitCode 0 = clean. */ signal streamClosed(int exitCode) signal agentsRefreshed() signal serverUnreachable() // Emitted after a server-owned conversation has been selected and its // persisted transcript loaded for the active surface. signal conversationResumed(string agentId, string conversationId, var messages) // Emitted when a send is blocked by step-up auth. The UI should call // StepUpAuth.requestAuth("send", callback) and retry on success. signal stepUpRequired(string actionFamily, string queuedText) // ── Agent inventory ────────────────────────────────────────────────── Process { id: getAgents running: true command: ["curl", "-sf", "--max-time", "3", `${root.serverBase}/v1/agents`] stdout: StdioCollector { onStreamFinished: { if (text.length === 0) return; try { const list = JSON.parse(text); const map = {}; // voice_id comes from the agent's `_souveraine` block via // the public list endpoint. None/absent = use the system // voice. Speech reads this per active agent. list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "", "voice_id": a.voice_id ?? "" }; }); root.agents = map; root.agentList = Object.keys(map); root.serverUp = true; if (!root.agents[root.currentAgentId] && root.agentList.length > 0) { root.currentAgentId = root.agentList[0]; } root.agentsRefreshed(); } catch (e) { console.log("[Souveraine] Could not parse agent list:", e); } } } onExited: (exitCode) => { if (exitCode !== 0) { root.serverUp = false; if (root.autostartEnabled && !root._autostartTried) { root.startServer(); } else { root.serverUnreachable(); } } } } function refreshAgents() { getAgents.running = true; } // The inventory used to be fetched exactly once, at shell start, so an // agent created afterwards stayed invisible until the whole shell was // reloaded. One curl a minute is cheaper than that surprise. Skipped // while a turn is in flight so a slow local model isn't competing with // polling for the server's attention. Timer { interval: 60000 repeat: true running: true onTriggered: if (!root.turnActive) root.refreshAgents() } // ── Server autostart ───────────────────────────────────────────────── // systemd user unit first (survives shell restarts, journald logging); // bare nohup fallback for systems without it. One attempt per shell // session — a broken install shouldn't spawn-loop. Process { id: serverStarter command: ["bash", "-c", `if command -v systemctl >/dev/null && systemctl --user list-unit-files souveraine.service &>/dev/null; then systemctl --user start souveraine.service else nohup ${root.serverBin} server >/dev/null 2>&1 & fi`] onExited: { serverRetryTimer.start(); } } Timer { id: serverRetryTimer interval: 2500 repeat: false onTriggered: root.refreshAgents() } function startServer() { if (root._autostartTried) return; root._autostartTried = true; console.log("[Souveraine] server not reachable — starting it"); serverStarter.running = true; } function selectAgent(agentId) { if (!root.agents[agentId]) return false; // Re-affirming the agent that is already active is a no-op, and it has // to be. Ai.qml re-selects the persisted agent on every agentsRefreshed, // and the inventory poll above fires that once a minute, forever. While // this function cleared conversationId unconditionally, every message // sent more than a minute after the previous one opened a NEW // conversation and therefore arrived with no history at all. // // Measured on the phone 2026-07-31: 27 conversations for one agent in a // day, all but two exactly four messages long — system prompt, ambient, // user, reply. One exchange each. That is the whole of the "she doesn't // remember what I just said" report, and it is not the turn loop: the // server assembles history from session.messages correctly, and there // was simply never more than one exchange in a session to assemble. // // Switching agents is a decision. Polling is not. if (agentId === root.currentAgentId) return true; // A live turn belongs to the current conversation. Switching beneath // it would render one agent's response in another agent's surface. if (root.turnActive) return false; // Clear before the id changes, so onCurrentAgentIdChanged observes an // empty conversation and re-attaches the incoming agent's own latest // thread rather than leaving her on a blank one. root.conversationId = ""; root.currentAgentId = agentId; return true; } function newConversation() { root.conversationId = ""; } // ── Server-derived resume ─────────────────────────────────────────── // The GUI keeps no per-agent conversation map. The server is the source // of truth: it persists conversations under each agent and this query // hydrates them after a server restart before returning the latest one. Process { id: listConversations property string agentId: "" stdout: StdioCollector { onStreamFinished: { if (listConversations.agentId !== root.currentAgentId) return; // Empty stdout is a FAILED request, not an empty agent: curl -sf // writes nothing on 4xx/5xx. Conflating the two clears // conversationId and hands the surface an empty transcript to // render, so one transient hiccup wipes the visible thread and // orphans the live one. Only a parsed response is authoritative. if (text.length === 0) { console.log("[Souveraine] empty conversation list response — leaving current conversation in place"); return; } let conversations = []; try { conversations = JSON.parse(text); } catch (e) { console.log("[Souveraine] Could not parse conversation list:", e); return; } if (conversations.length === 0) { // The server answered, and the answer is "none yet". root.conversationId = ""; root.conversationResumed(root.currentAgentId, "", []); return; } // Server sorts by updated_at descending — [0] is her latest. root._loadConversation(listConversations.agentId, conversations[0].id); } } onExited: exitCode => { // A failed list fetch is transient (server busy, network blip). // Do NOT clear conversationId — that orphans the live thread and // forces the next send to mint a fresh conversation. Log and leave // state alone; the next refresh or /resume retries. if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) { console.log("[Souveraine] conversation list fetch failed (exit " + exitCode + ") — leaving current conversation in place"); } } } Process { id: loadConversation property string agentId: "" property string requestedConversationId: "" stdout: StdioCollector { onStreamFinished: { if (loadConversation.agentId !== root.currentAgentId) return; // Same rule as the list above: no body means the fetch failed. // Attaching to the id anyway and announcing an empty transcript // would blank the surface while claiming the thread is loaded. if (text.length === 0) { console.log("[Souveraine] empty transcript response — leaving current conversation in place"); return; } try { const messages = JSON.parse(text); root.conversationId = loadConversation.requestedConversationId; root.conversationResumed(root.currentAgentId, root.conversationId, messages); } catch (e) { console.log("[Souveraine] Could not parse conversation transcript:", e); } } } onExited: exitCode => { // Transient failure (e.g. a 5xx on GET /messages). Don't clear — // see listConversations.onExited. Leaving conversationId alone keeps // an already-attached thread reachable instead of forcing a new one. if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) { console.log("[Souveraine] conversation transcript fetch failed (exit " + exitCode + ") — leaving current conversation in place"); } } } function resumeLatestConversation() { if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive) return false; listConversations.agentId = root.currentAgentId; listConversations.command = [ "curl", "-sf", "--max-time", "5", `${root.serverBase}/v1/conversations?agent_id=${encodeURIComponent(root.currentAgentId)}` ]; listConversations.running = true; return true; } function _loadConversation(agentId, conversationId) { loadConversation.agentId = agentId; loadConversation.requestedConversationId = conversationId; loadConversation.command = ["bash", "-c", root._tokenReadLine(agentId) + `curl -sf --max-time 10 "${root.serverBase}/v1/conversations/${conversationId}/messages"` + ` -H "Authorization: Bearer $TOKEN"` ]; loadConversation.running = true; } // ── Ambient sensorium ──────────────────────────────────────────────── // What the desktop feels like at the moment of speaking. Cheap, // synchronous reads here; the cursor needs a hyprctl round-trip and is // collected in the send chain. Device sensors (SouveraineOS positional // data) extend collectAmbient(). property string _cursorPos: "" function collectAmbient() { if (!root.ambientEnabled) return ""; const lines = []; const active = ToplevelManager.activeToplevel; if (active) { lines.push(`active window: ${active.appId ?? "?"} — "${active.title ?? ""}"`); } const tops = ToplevelManager.toplevels?.values ?? []; if (tops.length > 0) { const apps = tops.map(t => t.appId).filter(Boolean); const counts = {}; apps.forEach(a => counts[a] = (counts[a] ?? 0) + 1); const summary = Object.entries(counts) .map(([app, n]) => n > 1 ? `${app} (${n})` : app) .join(", "); lines.push(`open: ${summary}`); } if (root._cursorPos.length > 0) { lines.push(`cursor: ${root._cursorPos}`); } // Joining the face loads its skill; leaving unloads it. Attached here // because ambient is already the per-send block the surface owns, so // the cost of not being joined is exactly zero tokens rather than a // flag the server has to remember to check. if (typeof Face !== "undefined" && Face.joined) { lines.push(Face.skill); } if (typeof HidController !== "undefined" && HidController.active) { lines.push(HidController.skill); } return lines.join("\n"); } // Compositor-specific cursor read. hyprctl on Hyprland, kdotool on KDE; // anything else just skips the cursor line — ambient degrades gracefully, // it never blocks the send. Process { id: cursorProc command: ["bash", "-c", `if command -v hyprctl >/dev/null; then hyprctl cursorpos; elif command -v kdotool >/dev/null; then kdotool getmouselocation 2>/dev/null; fi`] stdout: StdioCollector { onStreamFinished: { root._cursorPos = text.trim(); } } onExited: { root._ensureConversationThenRequest(); } } // ── Send chain: cursor → conversation → stream ─────────────────────── property string _queuedText: "" /* Send a user message with ambient context. Returns false if the server is down or no agent is selected. Returns "step-up" if step-up auth is required but no valid grant exists — the caller should trigger StepUpAuth.requestAuth("send") and retry. */ function send(text) { if (!root.serverUp || root.currentAgentId.length === 0) return false; if (text.length === 0) return false; // Step-up gate: if enabled and no valid send grant exists, block // the send and emit a signal so the UI can trigger auth + retry. // Break-glass grants bypass normal step-up — they are one-time, // short-lived, and journaled. if (Config.options?.lock?.stepUp?.enabled && typeof StepUpAuth !== "undefined" && !StepUpAuth.isGranted("send") && !StepUpAuth.isBreakGlass("send")) { root.stepUpRequired("send", text); return "step-up"; } root._queuedText = text; if (root.ambientEnabled) { cursorProc.running = true; // chain continues in onExited } else { root._ensureConversationThenRequest(); } return true; } Process { id: createConversation stdout: StdioCollector { onStreamFinished: { try { const conv = JSON.parse(text); root.conversationId = conv.id; root._makeRequest(); } catch (e) { console.log("[Souveraine] conversation create failed:", text); root.streamClosed(1); } } } } function _ensureConversationThenRequest() { if (root.conversationId.length > 0) { root._makeRequest(); return; } createConversation.command = [ "curl", "-sf", "-X", "POST", `${root.serverBase}/v1/conversations`, "-H", "Content-Type: application/json", "--data", JSON.stringify({ "agent_id": root.currentAgentId }) ]; createConversation.running = true; } property string requestScriptFilePath: "/tmp/quickshell/ai/souveraine-request.sh" FileView { id: requesterScriptFile } function _tokenReadLine(agentId) { // Bearer token read at request time so rotation works. return `TOKEN=$(cat "$HOME/.souveraine/server/agents/${agentId}/api_token" 2>/dev/null)\n`; } function _makeRequest() { const data = { "messages": [{ "role": "user", "content": root._queuedText }], "stream": true }; const ambient = root.collectAmbient(); if (ambient.length > 0) data["ambient"] = ambient; root._queuedText = ""; const scriptContent = "#!/usr/bin/env bash\n" + root._tokenReadLine(root.currentAgentId) + `curl --no-buffer -sS -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/messages"` + ` -H 'Content-Type: application/json'` + ` -H "Authorization: Bearer $TOKEN"` + ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'` + "\n"; const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath); requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath); requesterScriptFile.setText(scriptContent); requester.command = ["bash", shellScriptPath]; root.turnStartedAt = Date.now(); root.turnElapsedMs = 0; root.turnActive = true; requester.running = true; } Process { id: requester stdout: SplitParser { onRead: data => { if (data.length === 0 || !data.startsWith("data:")) return; let event; try { event = JSON.parse(data.slice(5).trim()); } catch (e) { console.log("[Souveraine] Unparseable SSE line:", data); return; } root.streamEvent(event); } } onExited: (exitCode, exitStatus) => { // Freeze the clock on the real total — the 100ms tick can be up to // one interval behind when the process exits. if (root.turnStartedAt > 0) root.turnElapsedMs = Date.now() - root.turnStartedAt; root.turnActive = false; root.streamClosed(exitCode); } } // ── Backchannel ────────────────────────────────────────────────────── Process { id: backchannelProc property string script: "" command: ["bash", "-c", script] } function cancelTurn() { if (root.conversationId.length === 0) return; backchannelProc.script = root._tokenReadLine(root.currentAgentId) + `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/cancel"` + ` -H "Authorization: Bearer $TOKEN"`; backchannelProc.running = true; } function interject(text) { if (root.conversationId.length === 0 || text.length === 0) return; const body = JSON.stringify({ "text": text }); backchannelProc.script = root._tokenReadLine(root.currentAgentId) + `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/interject"` + ` -H 'Content-Type: application/json'` + ` -H "Authorization: Bearer $TOKEN"` + ` --data '${CF.StringUtils.shellSingleQuoteEscape(body)}'`; backchannelProc.running = true; } }