Watch
1
0
Fork
You've already forked souveraine
0

wry host: sites as apps, the rig as a face; join loads the expression skill

This commit is contained in:
Fimeg 2026-08-05 19:39:50 -04:00
commit 9ec031c8a8
12 changed files with 730 additions and 11 deletions

View file

@ -57,6 +57,7 @@ modules/souveraine/windowSheet/WindowSheet.qml souveraine/modules/souveraine/win
modules/souveraine/windowSheet/SheetButton.qml souveraine/modules/souveraine/windowSheet/SheetButton.qml
modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windowSheet/PowerMenu.qml
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir
services/Face.qml souveraine/services/Face.qml
services/ViewtopControl.qml souveraine/services/ViewtopControl.qml
modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml
modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml

View file

@ -45,6 +45,27 @@ Item {
anchors.fill: parent
hoverEnabled: !Config.options.bar.tooltips.clickToShow
// Double tap summons her face, and dismisses it. TASK-59 Q0 asked how
// she is reached; Casey, 2026-08-05: *"I double tap on the clock widget
// and I get the avatar."* Deliberately not always-on the webview is
// 283 MB measured, which on a 3.5 GB daily driver is a design argument
// and not only a taste one.
//
// A toggle rather than a summon, because the same gesture has to be the
// way out: a presence you cannot dismiss is the user's column being
// ignored (doctrine §13, and TASK-59 Q4).
property real lastTapAt: -1
readonly property int doubleTapInterval: 350
onClicked: {
const now = Date.now();
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
lastTapAt = -1;
Face.toggle();
} else {
lastTapAt = now;
}
}
ClockWidgetPopup {
hoverTarget: mouseArea
}

View file

@ -0,0 +1,140 @@
// Her face on the glass TASK-59.
//
// The face is a *limb*, not a second client. `Souveraine.qml` is the one
// connection to the server and stays that way: this owns a `souveraine-web`
// process, feeds it what she is already saying on the sidebar's stream, and
// sends what the user says to it back through the same `Souveraine.send()`.
// Casey, 2026-08-05: *"I will want it to be in sync with the sidebar meaning
// if we 'resume' it's resumed."* Two transports could not promise that; one
// does by construction.
//
// ## Turning her on is joining
//
// `joined` is the whole state. While it is true she is on the glass **and** the
// expression vocabulary rides in the per-send ambient block, so she has a
// syntax for shifting expression. While it is false neither happens and the
// second half is the point: that prompt is context nobody asked for when the
// face is closed. Casey, 2026-08-05: *"it'll be like a loadable/unloadable
// skill we might have times where we just don't want that extra prompt added
// to context."*
//
// The sidebar ignores the tags it sees, which is why they are safe to leave in
// the stream rather than stripped on the way to one surface.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
Singleton {
id: root
// On the glass, and in the prompt. One flag, both consequences.
property bool joined: false
property string socketPath: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/face.sock"
property string rigDir: Quickshell.env("HOME") + "/.souveraine/face"
// Accumulated text of the turn in flight, so the bubble shows the whole
// line rather than the last delta.
property string _line: ""
// The fragment that rides in `ambient` while she is joined. Kept here
// rather than in the server so that leaving costs exactly nothing there
// is no flag to unset and no prompt to remember to remove.
readonly property string skill: "You have a face on this device right now. "
+ "You may shift expression by emitting a tag on its own line: "
+ "[[face:idle]], [[face:alert]], [[face:thinking]], [[face:processing]], "
+ "[[face:affectionate]], [[face:straining]], [[face:yawning]], "
+ "[[face:listening]], [[face:speaking]]. "
+ "These are the postures the presence system already uses. "
+ "Use them sparingly, where the shift is real."
function join() {
if (root.joined)
return;
host.running = true;
root.joined = true;
}
function leave() {
root.joined = false;
root._send({ op: "quit" });
host.running = false;
}
function toggle() {
if (root.joined)
root.leave();
else
root.join();
}
Process {
id: host
command: ["souveraine-web",
"--rig", root.rigDir,
"--ipc", root.socketPath,
"--transparent",
"--app-id", "org.souveraine.face"]
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
let msg;
try {
msg = JSON.parse(line);
} catch (e) {
return;
}
// Anything the page wants to say goes through the one transport.
if (msg.event === "said" && msg.text)
Souveraine.send(msg.text);
else if (msg.event === "tapped")
root.tapped(msg.area ?? "body");
}
}
onExited: root.joined = false
}
signal tapped(string area)
Socket {
id: sock
path: root.socketPath
connected: root.joined
}
function _send(msg) {
if (sock.connected)
sock.write(JSON.stringify(msg) + "\n");
}
function _eval(script) {
root._send({ op: "eval", script: script });
}
// Everything she says on the sidebar's stream reaches the bubble. The face
// is a second *view* of one turn, never a second turn.
Connections {
target: Souveraine
enabled: root.joined
function onStreamEvent(event) {
if (event.message_type === "assistant_message" && event.content) {
root._line += event.content;
// Posture tags are hers to emit and the bubble's to not show.
const tag = /\[\[face:([a-z]+)\]\]/g;
let m;
while ((m = tag.exec(root._line)) !== null)
root._eval(`window.face.posture(${JSON.stringify(m[1])})`);
const shown = root._line.replace(tag, "").trim();
root._eval(`window.face.say(${JSON.stringify(shown)})`);
}
}
function onTurnActiveChanged() {
if (Souveraine.turnActive)
root._line = "";
}
}
}

View file

@ -336,6 +336,13 @@ Singleton {
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);
}
return lines.join("\n");
}

View file

@ -52,6 +52,7 @@ singleton SessionWarnings 1.0 SessionWarnings.qml
singleton SessiondBridge 1.0 SessiondBridge.qml
singleton SessiondPolicy 1.0 SessiondPolicy.qml
singleton SongRec 1.0 SongRec.qml
singleton Face 1.0 Face.qml
singleton Souveraine 1.0 Souveraine.qml
singleton Speech 1.0 Speech.qml
singleton StepUpAuth 1.0 StepUpAuth.qml