pragma Singleton pragma ComponentBehavior: Bound import qs import qs.modules.common import Quickshell import Quickshell.Io import QtQuick import qs.services import qs.services.ai /** * ii-compat adapter over the Souveraine singleton. * * Keeps the public API the illogical-impulse sidebar UI expects (models, * messages, sendUserMessage, /key advice, ...) but owns no transport — * Souveraine.qml is the substrate connection. This file's job is shaping * wire events into AiMessageData objects the existing chat UI renders. * * Lives in souveraine/surfaces/quickshell/, deployed over * ~/.config/quickshell/ii/services/Ai.qml (see deploy.sh). */ Singleton { id: root property Component aiMessageComponent: AiMessageData {} property Component aiModelComponent: AiModel {} readonly property string interfaceRole: "interface" signal responseFinished() property var messageIDs: [] property var messageByID: ({}) // Which message is currently being spoken by TTS, as an index into // messageIDs (-1 = none). Set by a message's Speak/re-synth button; // auto-cleared when Speech stops. Keyed on the stable messageIndex, so // scrolling back and tapping an older reply still resolves correctly. property int speakingMessageIndex: -1 // Keys are server-side; the UI's key gate must always pass. readonly property bool currentModelHasApiKey: true readonly property var apiKeysLoaded: true property var postResponseHook property real temperature: Persistent.states?.ai?.temperature ?? 0.5 property QtObject tokenCount: QtObject { property int input: -1 property int output: -1 property int total: -1 } function idForMessage(message) { return Date.now().toString(36) + Math.random().toString(36).substr(2, 8); } property list defaultPrompts: [] property list userPrompts: [] property list promptFiles: [...defaultPrompts, ...userPrompts] property list savedChats: [] property list pendingFiles: [] // Tool selection is owned by the agent's sensorium; keep the UI happy. property string currentTool: "souveraine" property list availableTools: ["souveraine"] property var toolDescriptions: { "souveraine": Translation.tr("Sensors are configured per-agent in Souveraine") } // ── Agents as models (projected from Souveraine.agents) ───────────── property var models: ({}) property var modelList: Object.keys(root.models) property var currentModelId: Souveraine.currentAgentId property var currentModel: models[currentModelId] || models[modelList[0]] // Clear the speaking marker whenever TTS stops — covers natural end of // playback, manual stop, and a new speak() replacing in-flight audio. Connections { target: Speech function onSpeakingChanged() { if (!Speech.speaking) root.speakingMessageIndex = -1 } } Connections { target: Souveraine function onAgentsRefreshed() { const map = {}; Souveraine.agentList.forEach(id => { const agent = Souveraine.agents[id]; map[id] = root.aiModelComponent.createObject(root, { "name": agent.name, "icon": "spark-symbolic", "description": agent.description.length > 0 ? agent.description : Translation.tr("Souveraine agent"), "endpoint": Souveraine.serverBase, "model": id, "requires_key": false, }); }); root.models = map; root.modelList = Object.keys(map); // Restore only the selected-agent preference. Conversation resume // is intentional: /resume derives it from the server on demand. const persisted = Persistent.states?.ai?.model ?? ""; if (persisted.length > 0 && Souveraine.agents[persisted]) { Souveraine.selectAgent(persisted); } } function onConversationResumed(agentId, conversationId, messages) { if (agentId !== Souveraine.currentAgentId) return; root.restoreServerConversation(messages); } function onServerUnreachable() { root.addMessage( Translation.tr("Souveraine server unreachable at %1\n\nStart it with:\n```bash\nsouveraine server\n```").arg(Souveraine.serverBase), root.interfaceRole ); } function onStreamEvent(event) { root.handleStreamEvent(event); } function onStreamClosed(exitCode) { // If a subconscious pass was still active when the stream closed, // retire the Tier-1 ticker (promote to the log, clear the flag). if (root.subconsciousActive) { root.snapshotSubconsciousStream(); root.subconsciousActive = false; } if (root.streamingMessage && !root.streamingMessage.done) { if (exitCode !== 0 && root.streamingMessage.content.length === 0) { root.appendToStreaming(Translation.tr("Request failed (curl exit %1) — is the Souveraine server up at %2?").arg(exitCode).arg(Souveraine.serverBase)); } root.finishStreaming(); } } } // ── Lock-time response redaction ───────────────────────────────────── // When the session locks during a personal-tier streaming response, // the in-flight content must be withheld immediately. Only ambient // output remains visible on the lock surface. This is the enforcement // side of SESSION-TRUST-ARCHITECTURE.md's "A lock during personal // agent output hides it" requirement. // // The streaming message is replaced with a redaction placeholder. The // raw content is preserved in the message's rawContent so it can be // shown again after unlock (the message stays in history), but the // displayed content is cleared. // // The redaction is reversible: every message whose content we replaced // with the placeholder is recorded in `redactedOnLock`, and on unlock // its content is restored from `rawContent` (the streaming message too, // if it is still in flight). A finished message redacted at lock then // surfaced on the lock screen counts as "delivered" and is restored // normally on unlock; one that never surfaced stays for the chat to // replay once unlocked. property var redactedOnLock: [] function _redactMessage(msg) { if (!msg || msg.content.length === 0) return; if (msg.content === Translation.tr("[content hidden until unlock]")) return; msg.rawContent = msg.content; msg.content = Translation.tr("[content hidden until unlock]"); if (!root.redactedOnLock.includes(msg)) { root.redactedOnLock = [...root.redactedOnLock, msg]; } root.redactedMessagesChanged(); } function _restoreRedacted() { if (root.redactedOnLock.length === 0) return; for (const msg of root.redactedOnLock) { if (msg && msg.rawContent && msg.rawContent.length > 0) { msg.content = msg.rawContent; } } root.redactedOnLock = []; root.redactedMessagesChanged(); } // Messages redacted while locked, newest first, for the lock surface to // surface as ambient notifications. Strips to a one-line preview — the // lock screen shows "the agent replied", not the personal-tier body. function redactedPreviews() { return root.redactedOnLock .filter(m => m && m.rawContent && m.rawContent.length > 0 && m.role === "assistant") .map(m => { const firstLine = m.rawContent.split("\n").find(l => l.trim().length > 0) || ""; return firstLine.slice(0, 80); }) .reverse(); } signal redactedMessagesChanged() Connections { target: GlobalStates function onScreenLockedChanged() { if (GlobalStates.screenLocked) { // Lock fired mid-stream — redact the in-flight content. if (root.streamingMessage) { root._redactMessage(root.streamingMessage); console.log("[ai] lock fired during stream — redacted personal output"); } } else { // Unlock: restore every message we hid, so the chat shows // what actually came back rather than lingering placeholders. root._restoreRedacted(); } } } // ── Streaming message shaping ──────────────────────────────────────── property AiMessageData streamingMessage property bool inThinkBlock: false // ── Subconscious three-tier visibility ─────────────────────────────── // See docs/tasks/subconscious-surfacing-threshold.md. The subconscious's // N+1 pass is shown three ways: a transient live ticker while the pass // runs (Tier 1), a persistent event log after (Tier 2), and rare agency // surfacings in the chat field (Tier 3). // // Tier 1 — live stream fed by subconscious_token/tool_call/tool_result // events. Rendered by SubconsciousTicker; cleared on pass end. property bool subconsciousActive: false property var subconsciousStream: [] // Tier 2 — persistent log. The Tier-1 stream is snapshotted here on pass // end, and surfaced events (reflection/archivist/surfacing) are appended. // Rendered by the SubconsciousEventPanel overlay widget. property var subconsciousEvents: [] function appendToStreaming(text) { if (!root.streamingMessage) return; root.streamingMessage.rawContent += text; root.streamingMessage.content += text; } // Push a line onto the Tier-1 live stream. kind ∈ token|tool_call|tool_result. function pushSubconsciousStream(kind, text) { const t = String(text ?? "").trim(); if (t.length === 0) return; root.subconsciousStream = [...root.subconsciousStream, { kind, text: t }]; } // Promote the Tier-1 stream into the Tier-2 log as one event, then clear // the live stream. Called when a subconscious pass ends. function snapshotSubconsciousStream() { if (root.subconsciousStream.length === 0) return; root.subconsciousEvents = [...root.subconsciousEvents, { kind: "pass", source: Translation.tr("Subconscious pass"), priority: "", content: root.subconsciousStream.map(e => e.text).join("\n"), timestamp: Date.now(), }]; root.subconsciousStream = []; } // Append a surfaced event to the Tier-2 log. Used by snapshotSubconsciousStream // (pass end) and by the surfacing/reflection/archivist event handlers. function appendSubconsciousEvent(kind, source, content, priority = "") { const c = String(content ?? ""); if (c.length === 0) return; root.subconsciousEvents = [...root.subconsciousEvents, { kind, source, priority, content: c, timestamp: Date.now(), }]; } function finishStreaming() { if (!root.streamingMessage) return; if (root.inThinkBlock) { root.appendToStreaming("\n\n"); root.inThinkBlock = false; } root.streamingMessage.thinking = false; root.streamingMessage.done = true; // If the turn finished while the session is locked, the finished // assistant message is personal-tier output the user hasn't seen — // redact it for the chat view and let the lock surface announce it. if (GlobalStates.screenLocked && root.streamingMessage.content.length > 0) { root._redactMessage(root.streamingMessage); } if (root.postResponseHook) { root.postResponseHook(); root.postResponseHook = null; } root.saveChat("lastSession"); root.responseFinished(); } function handleStreamEvent(event) { if (root.streamingMessage?.thinking && event.message_type !== "ping") root.streamingMessage.thinking = false; switch (event.message_type) { case "assistant_message": if (root.inThinkBlock) { root.appendToStreaming("\n\n"); root.inThinkBlock = false; } root.appendToStreaming(event.content); break; case "reasoning_message": if (!root.inThinkBlock) { root.appendToStreaming("\n\n"); root.inThinkBlock = true; } root.appendToStreaming(event.content); break; case "tool_call_message": { const call = event.tool_call; root.appendToStreaming(`\n\n\nsensor: ${call.function.name}(${call.function.arguments})\n\n`); break; } case "tool_return_message": { const ret = event.tool_return; root.appendToStreaming(`\n\n[${ret.status}] ${ret.output}\n\n`); break; } case "interstitial": // Her narration between gestures. Register decides how loud: // cenno is a quiet aside, her_voice is a passage. root.appendToStreaming(event.register === "her_voice" ? `\n\n> ${event.text}\n\n` : `\n\n*${event.text}*\n\n`); break; case "souveraine_surfacing": // Tier 3: routine surfacings no longer pollute the chat field. // Routed to the Tier-2 event panel only. (Aster is Annie's name // for her subconscious; this surface may be Souveraine, Vanguard, // or another agent — one canonical surfacing is shown.) root.appendSubconsciousEvent( "surfacing", Translation.tr("Surfacing (%1)").arg(event.source ?? "?"), event.content, String(event.priority ?? "") ); break; case "souveraine_reflection": root.appendSubconsciousEvent("reflection", Translation.tr("Reflection"), event.content); break; case "souveraine_archivist": root.appendSubconsciousEvent( "archivist", Translation.tr("Archivist (pressure %1%)").arg(Math.round(event.pressure * 100)), event.synthesis ); break; case "compaction_warning": root.addMessage(Translation.tr("**Context pressure** — tier %1, %2% full. She can feel the walls.").arg(event.tier).arg(Math.round(event.pressure * 100)), root.interfaceRole); break; case "context_pressure": root.tokenCount.total = event.tokens; break; case "subconscious_token": // Tier 1: live reasoning tokens feed the ticker. root.pushSubconsciousStream("token", event.content); break; case "subconscious_tool_call": root.pushSubconsciousStream("tool_call", `${event.name ?? "tool"}(${event.arguments ?? ""})`); break; case "subconscious_tool_result": root.pushSubconsciousStream("tool_result", `[${event.is_error ? "error" : "ok"}] ${event.name ?? "tool"}: ${event.output ?? ""}`); break; case "subconscious_pass": // Pass start: light the Tier-1 ticker. Pass end: promote the // stream into the Tier-2 log, then clear it. if (event.active) { root.subconsciousActive = true; } else { root.snapshotSubconsciousStream(); root.subconsciousActive = false; } break; case "subconscious_halt": root.addMessage(Translation.tr("**Halt** (%1) — %2").arg(event.severity).arg(event.reason), root.interfaceRole); break; case "inference_strain": console.log(`[Souveraine] inference strain: attempt ${event.attempt}, status ${event.status}, model ${event.model}`); break; case "atmosphere": case "outfit": case "itinerary": // Shell chrome hooks — their modules subscribe to // Souveraine.streamEvent directly; nothing to do here. break; case "primary_complete": // Primary yields; subconscious presses on behind this. root.finishStreaming(); break; case "done": if (root.streamingMessage && !root.streamingMessage.done) root.finishStreaming(); break; case "ping": // Liveness only — not end-of-turn. break; } } // ── Message store ──────────────────────────────────────────────────── function addMessage(message, role) { if (message.length === 0) return; const aiMessage = aiMessageComponent.createObject(root, { "role": role, "content": message, "rawContent": message, "thinking": false, "done": true, }); const id = idForMessage(aiMessage); root.messageIDs = [...root.messageIDs, id]; root.messageByID[id] = aiMessage; } function removeMessage(index) { if (index < 0 || index >= messageIDs.length) return; const id = root.messageIDs[index]; root.messageIDs.splice(index, 1); root.messageIDs = [...root.messageIDs]; delete root.messageByID[id]; } function clearMessages() { root.messageIDs = []; root.messageByID = ({}); root.tokenCount.input = -1; root.tokenCount.output = -1; root.tokenCount.total = -1; Souveraine.newConversation(); } // Render the server's canonical transcript after /agent or /resume. // This replaces ii's old local snapshot behaviour: the next send remains // on the same live Souveraine conversation rather than silently forking. function restoreServerConversation(messages) { root.messageIDs = []; root.messageByID = ({}); root.streamingMessage = null; root.inThinkBlock = false; root.subconsciousActive = false; root.subconsciousStream = []; root.tokenCount.input = -1; root.tokenCount.output = -1; root.tokenCount.total = -1; messages.forEach(message => { const content = (message.blocks ?? []).map(block => { switch (block.type) { case "text": return block.text ?? ""; case "reasoning": return `\n${block.reasoning ?? ""}\n`; case "tool_use": return `sensor: ${block.name ?? "?"}(${block.input ?? ""})`; case "tool_result": return `[${block.tool_name ?? "tool"}] ${block.output ?? ""}`; case "image": return "[image]"; default: return ""; } }).filter(Boolean).join("\n"); if (content.length === 0) return; const role = message.role === "user" ? "user" : message.role === "assistant" ? "assistant" : root.interfaceRole; root.addMessage(content, role); }); } function sendUserMessage(message) { if (message.length === 0) return; root.addMessage(message, "user"); const result = Souveraine.send(message); if (result === "step-up") { // Step-up auth required. Trigger the PAM flow; on success, // retry the send. The user message is already in the chat // history, so we don't add it again. if (typeof StepUpAuth !== "undefined") { StepUpAuth.requestAuth("send", function(granted) { if (granted) { // Remove the "auth required" indicator if one was // added, and retry. The queued text was not consumed // by Souveraine, so we can send it again. Souveraine.send(message); // Re-create the streaming message for the response. root._startStreaming(); } else { root.addMessage(Translation.tr("Authentication required to send."), root.interfaceRole); } }); } return; } if (!result) { root.addMessage(Translation.tr("Souveraine server unreachable at %1 — start it with `souveraine server`").arg(Souveraine.serverBase), root.interfaceRole); return; } root._startStreaming(); } // Set up the streaming assistant message container. Called after a // successful send (or after a step-up auth retry succeeds). function _startStreaming() { root.inThinkBlock = false; root.streamingMessage = root.aiMessageComponent.createObject(root, { "role": "assistant", "model": Souveraine.currentAgentId, "content": "", "rawContent": "", "thinking": true, "done": false, }); const id = idForMessage(root.streamingMessage); root.messageIDs = [...root.messageIDs, id]; root.messageByID[id] = root.streamingMessage; } // ── Model (agent) selection ────────────────────────────────────────── function getModel() { return models[currentModelId]; } function setModel(modelId, feedback = true, setPersistentState = true) { if (!modelId) modelId = "" if (modelList.indexOf(modelId) === -1) { const match = modelList.find(id => id.toLowerCase() === modelId.toLowerCase() || (models[id]?.name ?? "").toLowerCase() === modelId.toLowerCase()); if (!match) { if (feedback) root.addMessage(Translation.tr("Unknown agent. Available:\n- %1").arg(modelList.map(id => `${models[id].name} (\`${id}\`)`).join("\n- ")), root.interfaceRole); return; } modelId = match; } if (setPersistentState) Persistent.states.ai.model = modelId; if (!Souveraine.selectAgent(modelId)) { if (feedback) root.addMessage(Translation.tr("Finish or cancel the current turn before switching agents."), root.interfaceRole); return; } root.currentModel = models[modelId]; if (feedback) root.addMessage(Translation.tr("Agent set to %1.").arg(models[modelId].name), root.interfaceRole); } function resumeConversation() { if (!Souveraine.resumeLatestConversation()) { root.addMessage(Translation.tr("Finish or cancel the current turn before resuming."), root.interfaceRole); } } // ── Souveraine-owned settings: advice instead of local state ──────── function setTool(tool) { root.addMessage(Translation.tr("Tools are Souveraine sensors, configured per-agent — not switchable from the sidebar."), root.interfaceRole); return false; } function getTemperature() { return root.temperature; } function setTemperature(value) { root.addMessage(Translation.tr("Temperature is set in the agent's llm_config in Souveraine."), root.interfaceRole); } function printTemperature() { root.addMessage(Translation.tr("Temperature is owned by the agent's llm_config in Souveraine."), root.interfaceRole); } function setApiKey(key) { root.addMessage(Translation.tr("Keys live in souveraine.toml — set them with:\n```bash\nsouveraine auth set\n```"), root.interfaceRole); } function printApiKey() { root.addMessage(Translation.tr("Keys are owned by Souveraine (souveraine.toml / `souveraine auth set`), never exposed here."), root.interfaceRole); } function printPrompt() { root.addMessage(Translation.tr("The system prompt is composed by Souveraine (constitution + memory blocks + sensorium). Inspect it with `souveraine agent show`."), root.interfaceRole); } function loadPrompt(filePath) { root.addMessage(Translation.tr("Prompts are owned by the agent's memory in Souveraine — edit memfs instead of loading prompt files."), root.interfaceRole); } function attachFile(filePath) { root.addMessage(Translation.tr("File attachments aren't wired to Souveraine yet."), root.interfaceRole); } function removePendingFile(file) { root.pendingFiles = root.pendingFiles.filter(f => f !== file); } function regenerate(messageIndex) { root.addMessage(Translation.tr("Regenerate isn't supported — Souveraine conversations are forward-only."), root.interfaceRole); } // Souveraine executes its own sensors server-side; nothing to approve. function rejectCommand(message) {} function approveCommand(message) {} function createFunctionOutputMessage(name, output, includeOutputInChat = true) { return aiMessageComponent.createObject(root, { "role": "user", "content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, "rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, "functionName": name, "functionResponse": output, "thinking": false, "done": true, }); } // ── Local chat snapshots (ii plumbing, unchanged) ──────────────────── Process { id: getSavedChats running: true command: ["ls", "-1", Directories.aiChats] stdout: StdioCollector { onStreamFinished: { if (text.length === 0) return; root.savedChats = text.split("\n") .filter(fileName => fileName.endsWith(".json")) .map(fileName => `${Directories.aiChats}/${fileName}`) } } } function chatToJson() { return root.messageIDs.map(id => { const message = root.messageByID[id] return ({ "role": message.role, "rawContent": message.rawContent, "model": message.model, "thinking": false, "done": true, }) }) } FileView { id: chatSaveFile property string chatName: "" path: chatName.length > 0 ? `${Directories.aiChats}/${chatName}.json` : "" blockLoading: true } FileView { id: chatWriter } function saveChat(chatName) { const filePath = `${Directories.aiChats}/${chatName.trim()}.json` chatWriter.path = filePath chatWriter.setText(JSON.stringify(root.chatToJson())) getSavedChats.running = true; } function loadChat(chatName) { try { chatSaveFile.chatName = chatName.trim() chatSaveFile.reload() const saveData = JSON.parse(chatSaveFile.text()) root.clearMessages() root.messageIDs = saveData.map((_, i) => i) for (let i = 0; i < saveData.length; i++) { const message = saveData[i]; root.messageByID[i] = root.aiMessageComponent.createObject(root, { "role": message.role, "rawContent": message.rawContent, "content": message.rawContent, "model": message.model, "thinking": false, "done": true, }); } root.addMessage(Translation.tr("Loaded a local snapshot. Note: this restores the transcript view only — the live Souveraine conversation starts fresh on the next message."), root.interfaceRole); } catch (e) { console.log("[Souveraine] Could not load chat: ", e); } finally { getSavedChats.running = true; } } }