selectAgent cleared conversationId even when the agent hadn't changed, and the 60s inventory poll re-selects the persisted agent forever — so any message sent a minute after the last one opened a fresh conversation. 27 of them yesterday, four messages each. An empty curl body no longer counts as "no conversations".
509 lines
22 KiB
QML
509 lines
22 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: ""
|
|
// 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}`);
|
|
}
|
|
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;
|
|
}
|
|
}
|