Step-up send gating:
- Souveraine.send() returns 'step-up' when auth is required
- Ai.sendUserMessage() triggers StepUpAuth.requestAuth('send') on
step-up, retries on success, shows message on failure
- Extracted _startStreaming() helper for reuse after auth retry
Lock-time response redaction:
- Ai.qml watches GlobalStates.screenLocked
- On lock mid-stream: replaces displayed content with '[content hidden
until unlock]', preserves rawContent for post-unlock display
- Enforces SESSION-TRUST-ARCHITECTURE.md requirement: lock during
personal agent output hides it
Trust boundary matrix updated.
415 lines
17 KiB
QML
415 lines
17 KiB
QML
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: ""
|
|
property string conversationId: ""
|
|
property bool turnActive: false
|
|
|
|
/* 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 = {};
|
|
list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "" }; });
|
|
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;
|
|
}
|
|
|
|
// ── 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;
|
|
// 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;
|
|
root.currentAgentId = agentId;
|
|
root.conversationId = "";
|
|
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: {
|
|
let conversations = [];
|
|
try {
|
|
conversations = text.length > 0 ? JSON.parse(text) : [];
|
|
} catch (e) {
|
|
console.log("[Souveraine] Could not parse conversation list:", e);
|
|
}
|
|
if (listConversations.agentId !== root.currentAgentId) return;
|
|
if (conversations.length === 0) {
|
|
root.conversationId = "";
|
|
root.conversationResumed(root.currentAgentId, "", []);
|
|
return;
|
|
}
|
|
root._loadConversation(listConversations.agentId, conversations[0].id);
|
|
}
|
|
}
|
|
onExited: exitCode => {
|
|
if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
|
|
root.conversationId = "";
|
|
root.conversationResumed(root.currentAgentId, "", []);
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: loadConversation
|
|
property string agentId: ""
|
|
property string requestedConversationId: ""
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const messages = text.length > 0 ? JSON.parse(text) : [];
|
|
if (loadConversation.agentId !== root.currentAgentId) return;
|
|
root.conversationId = loadConversation.requestedConversationId;
|
|
root.conversationResumed(root.currentAgentId, root.conversationId, messages);
|
|
} catch (e) {
|
|
console.log("[Souveraine] Could not parse conversation transcript:", e);
|
|
root.conversationResumed(root.currentAgentId, "", []);
|
|
}
|
|
}
|
|
}
|
|
onExited: exitCode => {
|
|
if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) {
|
|
root.conversationId = "";
|
|
root.conversationResumed(root.currentAgentId, "", []);
|
|
}
|
|
}
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
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.
|
|
if (Config.options?.lock?.stepUp?.enabled
|
|
&& typeof StepUpAuth !== "undefined"
|
|
&& !StepUpAuth.isGranted("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.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) => {
|
|
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;
|
|
}
|
|
}
|