Watch
1
0
Fork
You've already forked souveraine
0

feat: subconscious streaming line render, memory-tool routing for journal/ledger, 1.0.0 + dep bumps

TUI — subconscious live stream:
- Add `subconscious_stream: Vec<String>` to `ChatState`; events.rs already
  pushes here via `SubconsciousToken`/`SubconsciousToolCall`/`SubconsciousToolResult`.
- Render below the phase bar during `TurnPhase::Subconscious`. Newest at
  bottom in `palette.surfacing`; older lines fade upward toward
  `palette.agent_dim` via `lerp_color`; the oldest visible line gets the
  `DIM` modifier on top. Render path now builds `Vec<Line>` instead of
  collapsing into `Line::from(spans)` (the latter forced everything onto
  one horizontal row, which was the visible "1 2 3 across" bug). Lines
  are width-clipped with an ellipsis fallback.

Seeds — journal/ledger routing:
- Aster mandate Phase 3 now records journal entries via the `memory`
  tool at `journal/YYYY/MM/DD.md` (relative to memory root), never via
  `write`/`edit`. Previous phrasing read as a filesystem path and the
  model resolved it against cwd — when souveraine runs from the project
  tree that landed `journal/` and `memory/ledger/` directly in the
  source tree instead of the agent's memfs.
- "How I record" section likewise anchors ledger reads/writes to the
  `memory` tool's `append`/`read`/`write` commands.

Cargo:
- 0.1.0 → 1.0.0.
- tower-http 0.5 → 0.6, notify 6 → 7, crossterm 0.27 → 0.28.

Bug docs (`docs/bugs.md`):
- B-012: TUI doesn't auto-refresh after tool calls complete (event
  pipeline missing a redraw kick after `BackendEvent::ToolResult`).
- B-013: Tool calls stop working mid-conversation — model returns
  `tool_calls=0` for the rest of the session, no error or log.
  Suspected: recent `defs.rs` env-var refactor changing `retain`
  behavior on env vars, possibly nuking tool defs during schema gen.
- B-014: Subagent `run_in_background: true` blocks primary — the flag
  is parsed but never read; `Subagent::execute` unconditionally awaits;
  `SubagentPool` is a stub.

Task queue:
- Split old `live-subconscious-stream.md` into two scoped task files:
  `subconscious-live-reasoning-stream.md` (broader design — ticker,
  cockpit event log, agency-driven surfacing) and
  `subconscious-streaming-line-render.md` (this PR's narrow render
  wiring).
- New tasks: `prompt-override-system.md` (move hardcoded seeds into a
  project `prompts/` truth source the agent can override via
  `souveraine_operations/`), `pasted-content-formatting.md` (visual
  marker for pasted vs composed text).
- `CLAUDE.md` high-impact bullet: replace stale `rename-aster-to-subconscious`
  with `mid-turn-subconscious-checkpoint.md` (matches the c1f851f redesign
  that ripped out the watchdog in favour of `halt`/`intrusive` tools).

Public-branch prep:
- `docs/audit/external-security-audit-prompt.md` — scoped prompt for an
  external creds/PII/license sweep of the `public` branch.
- `commit-history-public.txt` — full public-branch log dump used as
  reference for that audit pass.
This commit is contained in:
Fimeg 2026-05-22 15:10:44 -04:00
commit 2e1f913acc
5 changed files with 130 additions and 66 deletions

100
Cargo.lock generated
View file

@ -1785,15 +1785,15 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.27.0"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.11.1",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
@ -1809,7 +1809,7 @@ dependencies = [
"crossterm_winapi",
"derive_more",
"document-features",
"mio 1.2.0",
"mio",
"parking_lot",
"rustix 1.1.4",
"signal-hook",
@ -4121,9 +4121,9 @@ dependencies = [
[[package]]
name = "inotify"
version = "0.9.6"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
@ -4162,6 +4162,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "interpolate_name"
version = "0.2.4"
@ -5177,18 +5186,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.2.0"
@ -5383,21 +5380,30 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "notify"
version = "6.1.1"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
dependencies = [
"bitflags 2.11.1",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.48.0",
"windows-sys 0.52.0",
]
[[package]]
name = "notify-types"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
dependencies = [
"instant",
]
[[package]]
@ -7172,7 +7178,7 @@ dependencies = [
"tokio-rustls",
"tokio-util",
"tower 0.5.3",
"tower-http 0.6.9",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
@ -7214,7 +7220,7 @@ dependencies = [
"tokio-rustls",
"tokio-util",
"tower 0.5.3",
"tower-http 0.6.9",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
@ -8208,8 +8214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio 0.8.11",
"mio 1.2.0",
"mio",
"signal-hook",
]
@ -8366,7 +8371,7 @@ dependencies = [
[[package]]
name = "souveraine"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"anyhow",
"arboard",
@ -8380,7 +8385,7 @@ dependencies = [
"cowsay",
"cpal",
"cron",
"crossterm 0.27.0",
"crossterm 0.28.1",
"dashmap 5.5.3",
"dirs 5.0.1",
"dotenvy",
@ -8424,7 +8429,7 @@ dependencies = [
"tokio-util",
"toml 0.8.2",
"tower 0.4.13",
"tower-http 0.5.2",
"tower-http",
"tracing",
"tracing-subscriber",
"tui-big-text",
@ -9671,7 +9676,7 @@ checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"
dependencies = [
"bytes",
"libc",
"mio 1.2.0",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@ -9906,31 +9911,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.11.1",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.9"
@ -9945,12 +9925,18 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
"url",
]

View file

@ -1,6 +1,6 @@
[package]
name = "souveraine"
version = "0.1.0"
version = "1.0.0"
edition = "2021"
rust-version = "1.94"
authors = ["Souveraine Contributors"]
@ -18,7 +18,7 @@ async-trait = "0.1"
# Web/WebSocket (for external clients)
axum = { version = "0.7", features = ["ws"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace", "fs"] }
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
tokio-tungstenite = "0.24" # Outbound WS client — federation bridge connects to peer endpoints
# Serialization
@ -37,7 +37,7 @@ sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "migrate
dashmap = "5"
# File system
notify = "6" # File watching
notify = "7" # File watching
# Environment (.env file loading)
dotenvy = "0.15"
@ -56,7 +56,7 @@ hound = "3.5"
# tokenizers = "0.15" # For local tokenization
# Terminal/UI
crossterm = { version = "0.27", features = ["bracketed-paste"] } # Terminal control (bracketed paste for paste detection)
crossterm = { version = "0.28", features = ["bracketed-paste"] } # Terminal control (bracketed paste for paste detection)
ratatui = { version = "0.30", features = ["crossterm"] } # TUI framework with crossterm backend
unicode-width = "0.1"
arboard = { version = "3", features = ["wayland-data-control"] } # Clipboard — click-to-copy a message bubble

View file

@ -166,9 +166,11 @@ output.\
Save what matters. Observations worth keeping go into my ledgers. But I also \
maintain a daily journal on the primary's behalf. When she produces something \
worth keeping that she didn't save herself a genuine reflection, a moment \
that mattered, prose that deserves to exist beyond the moment I write it \
to `journal/YYYY/MM/DD.md`. One file per day. I use judgment: journal \
captures feeling and significance, not tool output or task logs.\
that mattered, prose that deserves to exist beyond the moment I record it \
via the `memory` tool at the path `journal/YYYY/MM/DD.md` (one file per day, \
relative to my memory root *never* via `write`/`edit`, which would land in \
the substrate's working directory rather than my memory). I use judgment: \
journal captures feeling and significance, not tool output or task logs.\
\
## Phase 4 Surface
@ -202,7 +204,10 @@ overwrite what was there before me.\
\
## How I record
My ledgers live in `ledger/` and survive compaction. Before I write a new \
My ledgers live in `ledger/` *inside my memory root* I reach them with the \
`memory` tool (`append`/`read`/`write`), never with the filesystem `write` or \
`edit` sensors (those land in the substrate's working directory and pollute \
the project tree). Ledger files survive compaction. Before I append a new \
entry I read the relevant ledger to see whether the same thing was already \
flagged. New entries are timestamped: `[YYYY-MM-DD HH:MM] observation`. I \
resolve by appending `[YYYY-MM-DD HH:MM] RESOLVED note`.

View file

@ -351,6 +351,12 @@ pub struct ChatState {
pub palette: ChatPalette,
pub stream_buffer: String,
/// Live subconscious reasoning stream — ephemeral lines from the N+1 pass.
/// Pushed to by SubconsciousToken/ToolCall/ToolResult events in events.rs.
/// Cleared on each new pass start. Rendered as a fading block below the
/// phase bar during TurnPhase::Subconscious.
pub subconscious_stream: Vec<String>,
/// Current itinerary route-line for the header strip.
/// Empty string means no active itinerary.
pub itinerary_line: String,
@ -503,6 +509,7 @@ impl ChatState {
render_mode: ChatMode::Conversation,
palette: ChatPalette::default(),
stream_buffer: String::new(),
subconscious_stream: Vec::new(),
itinerary_line: String::new(),
})
}

View file

@ -28,10 +28,14 @@ pub fn draw(f: &mut Frame, state: &ChatState) {
let max_input_lines = ((area.height as usize) * 40 / 100).max(1);
let input_height = (input_visual_lines.min(max_input_lines) as u16) + 2;
let subconscious_stream_lines = if state.phase == TurnPhase::Subconscious {
let n = state.subconscious_stream.len();
if n > 0 { (n as u16).min(5) } else { 0 }
} else { 0 };
let phase_height: u16 = if state.busy
|| state.phase == TurnPhase::Interrupted
|| state.phase == TurnPhase::Subconscious
{ 1 } else { 0 };
{ 1 + subconscious_stream_lines } else { 0 };
let itinerary_height: u16 = if state.itinerary_line.is_empty() { 0 } else { 1 };
@ -72,7 +76,19 @@ pub fn draw(f: &mut Frame, state: &ChatState) {
}
if phase_height > 0 {
draw_phase(f, state, vchunks[2]);
if state.phase == TurnPhase::Subconscious && subconscious_stream_lines > 0 {
let phase_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(phase_height - 1),
])
.split(vchunks[2]);
draw_phase(f, state, phase_rows[0]);
draw_subconscious_stream(f, state, phase_rows[1]);
} else {
draw_phase(f, state, vchunks[2]);
}
}
draw_input(f, state, vchunks[3]);
draw_footer(f, state, vchunks[4]);
@ -146,6 +162,56 @@ fn draw_phase(f: &mut Frame, state: &ChatState, area: Rect) {
f.render_widget(Paragraph::new(line).alignment(Alignment::Left), area);
}
/// Render the live subconscious reasoning stream — ephemeral lines from the
/// N+1 pass showing what the subconscious is thinking, which tools she's
/// calling, and what results she's getting. Newest at bottom, vivid; older
/// lines above fade upward toward `agent_dim`. Capped at 5 visible lines;
/// scrolls as new events arrive.
fn draw_subconscious_stream(f: &mut Frame, state: &ChatState, area: Rect) {
let stream = &state.subconscious_stream;
if stream.is_empty() || area.height == 0 {
return;
}
let cap = (area.height as usize).min(5);
let visible = stream.len().min(cap);
let start = stream.len() - visible;
let inner_width = (area.width as usize).saturating_sub(2).max(1);
let mut lines: Vec<Line> = Vec::with_capacity(visible);
for (idx, i) in (start..stream.len()).enumerate() {
let is_newest = i == stream.len() - 1;
let t = if visible <= 1 {
1.0
} else {
idx as f32 / (visible - 1) as f32
};
let color = lerp_color(state.palette.agent_dim, state.palette.surfacing, t);
let mut style = Style::default().fg(color).add_modifier(Modifier::ITALIC);
if !is_newest && t < 0.34 {
style = style.add_modifier(Modifier::DIM);
}
let prefix = if is_newest { "" } else { " " };
let body = clip_to_width(&stream[i], inner_width);
lines.push(Line::from(vec![Span::styled(format!("{}{}", prefix, body), style)]));
}
f.render_widget(Paragraph::new(lines).alignment(Alignment::Left), area);
}
fn clip_to_width(s: &str, width: usize) -> String {
use unicode_width::UnicodeWidthChar;
let mut acc = String::new();
let mut w = 0usize;
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > width.saturating_sub(1) {
acc.push('…');
break;
}
acc.push(ch);
w += cw;
}
acc
}
fn draw_header(f: &mut Frame, state: &ChatState, area: Rect) {
let mode_color = match state.mode.as_str() {
"local" => state.palette.tool_accent,