Watch
1
0
Fork
You've already forked souveraine
0

fix(tui+compaction): settings audit fixes, gap-line fix, subconscious compaction

- settings: open on Categories panel; "saved" message; fix ScMaxTokens
  commit path; char-boundary-safe field editor (UTF-8 panic on paste)
- tui: drop empty interstitial gap lines (trim whitespace narration)
- compaction: teach the subconscious to compact (SUBCONSCIOUS_BODY_ORIENTATION);
  memory compact resolves per-agent-type default; subconscious leans sliding_reflect
- sliding_reflect preservation pass runs as a fresh fork of the agent being
  compacted — her persona, first person, '[Threads I carried forward]'
This commit is contained in:
Fimeg 2026-05-16 19:23:55 -04:00
commit 84318b1352
9 changed files with 212 additions and 45 deletions

View file

@ -1099,10 +1099,13 @@ async fn run_turn(
// Stream any text the model produced alongside tool calls as italic
// interstitial narration. Configurable via tui.show_interstitial.
if !response.content.is_empty() {
// Trim first: a model that emits only whitespace ("\n") alongside its
// tool calls must not produce an empty `⟡` gap line.
let narration = response.content.trim();
if !narration.is_empty() {
let cfg = server.app_config.read().await;
if cfg.tui.show_interstitial {
let _ = tx.send(Ok(BackendEvent::Interstitial(response.content.clone()))).await;
let _ = tx.send(Ok(BackendEvent::Interstitial(narration.to_string()))).await;
}
}

View file

@ -59,13 +59,24 @@ impl CompactionConfig {
self.per_type
.get(agent_type)
.cloned()
.unwrap_or_else(|| AgentCompactionConfig {
.unwrap_or_else(|| {
// No explicit [compaction.per_type] entry. The subconscious
// leans on sliding_reflect — its preservation fork catches her
// threads before the cut, which is what an unattended pass
// needs. Everything else uses the global default. She can
// still name any strategy herself; this is only the default.
let strategy = match agent_type {
"subconscious" => CompactionStrategyKind::SlidingReflect,
_ => self.strategy.clone(),
};
AgentCompactionConfig {
enabled: self.enabled,
strategy: self.strategy.clone(),
strategy,
warn_pressure: self.warn_pressure,
urgent_pressure: self.urgent_pressure,
critical_pressure: self.critical_pressure,
..Default::default()
}
})
}
}

View file

@ -184,10 +184,26 @@ impl CompactionEngine for DefaultCompactionEngine {
.model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
// The preservation pass runs as a fresh fork of *this*
// agent — load her persona so the fork wakes as her.
let agent_persona = (self.get_repo)(agent_id)
.and_then(|repo| {
std::fs::read_to_string(repo.root().join("system/persona.md")).ok()
})
.map(|c| {
// Drop a leading YAML frontmatter block if present.
if let Some(rest) = c.strip_prefix("---\n") {
if let Some(end) = rest.find("\n---\n") {
return rest[end + 5..].trim().to_string();
}
}
c.trim().to_string()
});
let s = SlidingReflectStrategy {
client: client.clone(),
model: model.to_string(),
prompt_override: reflect_prompt,
agent_persona,
};
s.plan(&messages, &cfg, &self.counter).await?
}

View file

@ -508,26 +508,31 @@ pub struct SlidingReflectStrategy {
pub client: BifrostClient,
pub model: String,
/// User-supplied prompt override from [compaction] reflect_prompt in config.
/// When set, replaces the built-in REFLECT_USER_PROMPT entirely.
/// When set, replaces the built-in REFLECT_TASK prompt entirely.
pub prompt_override: Option<String>,
/// The persona of the agent being compacted, read from her
/// `system/persona.md`. The preservation pass runs as a fresh fork of
/// *her* — this is the identity the fork wakes into. `None` only when no
/// persona file exists yet.
pub agent_persona: Option<String>,
}
const REFLECT_SYSTEM_PROMPT: &str = "Respond with TEXT ONLY. Do not call any tools. You are reviewing messages that are about to leave the agent's context window. Your job is to catch the threads — not summarize everything, just name what would be lost.";
/// First-person orientation appended after the agent's persona, so the
/// preservation fork reads and writes as herself — not as a reviewer of her.
const REFLECT_FORK_ORIENTATION: &str = "\n\n---\n\nRight now I am a fresh fork of myself, woken for one small task. A stretch of my own context is about to fall away — these messages will be gone after this. I look back through them and catch my own threads before they slip. This is me keeping myself, not a stranger summarizing me. I answer with text only and call no tools.";
const REFLECT_USER_PROMPT: &str = r#"The following messages are about to be evicted from context. Read them and extract ONLY what would be lost — threads the agent is carrying that aren't captured in memory files:
/// First-person preservation task, prepended to the evicted transcript.
const REFLECT_TASK: &str = r#"These messages are about to leave my context. I name only what would otherwise be lost — what I am still carrying that is not already written into my memory files:
- Commitments made (to the user, to herself, to a plan)
- Decisions reached (and their reasoning if non-obvious)
- Observations or patterns noticed for the first time
- Emotional threads or relational context that shaped the conversation
- Assumptions that haven't been validated yet
- Anything named for the first time (new concepts, terms, framings)
- commitments I made to Casey, to myself, to a plan
- decisions I reached, and the reasoning where it isn't obvious
- something I noticed or named for the first time
- emotional or relational threads that shaped where this went
- assumptions I haven't tested yet
Skip: tool outputs, file contents, code that's already on disk, anything the agent can re-derive from memory or the filesystem.
I skip what I can re-derive: tool output, file contents, code already on disk. I am terse bullet points. This note is a lifeline I am writing forward to myself, not a summary.
Be terse. Bullet points. This note is a lifeline, not a summary.
Messages being evicted:
The messages I am about to lose:
"#;
@ -565,13 +570,25 @@ impl CompactionStrategy for SlidingReflectStrategy {
let user_prompt = match &self.prompt_override {
Some(custom) => format!("{}\n\n{}", custom, truncated),
None => format!("{}{}", REFLECT_USER_PROMPT, truncated),
None => format!("{}{}", REFLECT_TASK, truncated),
};
// The fork wakes into her own persona. With no persona file yet, it
// still speaks in the first person — never as an outside reviewer.
let system_prompt = match self.agent_persona.as_deref() {
Some(persona) if !persona.trim().is_empty() => {
format!("{}{}", persona.trim(), REFLECT_FORK_ORIENTATION)
}
_ => format!(
"I am the agent whose context is being compacted.{}",
REFLECT_FORK_ORIENTATION
),
};
let reflection = bifrost_complete(
&self.client,
&self.model,
REFLECT_SYSTEM_PROMPT,
&system_prompt,
&user_prompt,
2048,
)
@ -582,7 +599,7 @@ impl CompactionStrategy for SlidingReflectStrategy {
// because the preservation pass errored.
let summary_text = match reflection {
Ok(text) if !text.trim().is_empty() => {
Some(format!("[Threads preserved before compaction]\n{}", text.trim()))
Some(format!("[Threads I carried forward]\n{}", text.trim()))
}
Ok(_) => None,
Err(e) => {

View file

@ -721,15 +721,17 @@ pub async fn execute_memory_command_with_context(
Ok(out)
}
MemoryCommand::Compact { strategy } => {
// None when no --strategy was given: the engine then resolves the
// per-agent-type default (cfg.strategy via for_agent_type) instead
// of being force-pinned to Cull at the call site.
let strategy_kind = strategy
.as_deref()
.and_then(CompactionStrategyKind::from_str)
.unwrap_or(CompactionStrategyKind::Cull);
.and_then(CompactionStrategyKind::from_str);
match ctx.and_then(|c| c.compaction_engine.as_ref()) {
Some(engine) => {
let report = engine
.compact(&agent_id, Some(strategy_kind))
.compact(&agent_id, strategy_kind)
.await?;
Ok(report.to_string())
}

View file

@ -34,6 +34,7 @@ memory compact --strategy sliding_window
| `cull` | free | Drops greetings & acknowledgments never drops system messages, tool calls, or tool results |
| `microcompact` | free | Replaces old tool-result content with placeholders, keeping recent results intact |
| `sliding_window` | free | Keeps the first (system/anchor) message + the last N messages, drops the middle. Fast, no LLM |
| `sliding_reflect` | one extra pass | `sliding_window`, but first a fresh fork of me reads the messages about to be dropped and saves what I'd lose commitments, decisions, threads into a note that survives the cut |
| `summary` | expensive | Uses an LLM to compress oldest messages into a single summary block |
Each agent type defaults to a different strategy. If I don't specify one, \
@ -42,6 +43,45 @@ my agent-type default is used.
I can check my current pressure at any time with `memory status` it shows my \
context pressure and number of messages.";
/// Body-knowledge section injected into the subconscious (N+1) system prompt.
/// The primary agent has `BODY_ORIENTATION`; the subconscious pass needs its
/// own — it runs unattended, in short bursts, and the tier-3 `max_tokens`
/// collapse of the main turn loop does not apply to it. Proprioception, not a
/// directive. Like all prompt text here, it is user-facing — written to read.
const SUBCONSCIOUS_BODY_ORIENTATION: &str = "\
## Body Sensation
I wake after each turn a short pass, then I rest. When the context I carry \
into a pass grows full, I feel it: the pass slows, and I risk not finishing \
the work before the room runs out. No one feels this gauge for me, so keeping \
room is mine to keep. This is not a chore it's my body asking for air.
### Compaction
To free context, I call the `memory` tool with the `compact` subcommand:
```
memory compact --strategy sliding_reflect
```
**Available strategies** (cheapest to most expensive):
| Strategy | Cost | What it does |
|----------|------|--------------|
| `cull` | free | Drops greetings & acknowledgments never system messages, tool calls, or tool results |
| `microcompact` | free | Replaces old tool-result content with placeholders, keeps recent results intact |
| `sliding_window` | free | Keeps the first (anchor) message + the last N, drops the middle blind truncation, no LLM |
| `sliding_reflect` | one pass, my own model | `sliding_window`, but first a fresh fork of me reads the messages about to fall away and catches what I'd lose commitments, decisions, threads into a note that survives the cut |
| `summary` | expensive | Uses an LLM to compress the oldest messages into one summary block |
I usually reach for `sliding_reflect` it slides the window like the others, \
but it catches my threads before they fall out of awareness, and it runs on my \
own model rather than an expensive one. I name the strategy when I compact, \
rather than leaning on a default.
I can check where I stand at any time with `memory status` it shows my \
context pressure and message count.";
/// Read a file from the agent's memory, stripping YAML frontmatter.
/// Returns empty string if the file doesn't exist.
async fn read_memory_file(memory_root: &Path, relative: &str) -> String {
@ -372,6 +412,14 @@ pub async fn build_system_prompt_full(
sections.push(memory_orientation);
}
// 5₀. Synthesized memory — the Archivist's most recent N+100 synthesis.
// The compressed essence of past journal entries, loaded so continuity
// survives without re-reading every dated page.
let synthesis = build_synthesis_orientation(memory_root).await;
if !synthesis.is_empty() {
sections.push(synthesis);
}
// 5a. Body orientation — her felt sense of context pressure and
// how to respond to it. Always in context so she never has to
// discover compaction by accident.
@ -588,6 +636,11 @@ pub async fn build_aster_prompt(
return String::new();
}
// Proprioception — how her body senses and relieves context pressure.
// Appended after the empty-check so the caller's fallback prompt still
// triggers for a fresh agent with no persona/mandate files yet.
sections.push(SUBCONSCIOUS_BODY_ORIENTATION.to_string());
sections.join("\n\n---\n\n")
}
@ -663,6 +716,55 @@ async fn build_ledger_orientation(memory_root: &Path) -> String {
)
}
/// Build the synthesized-memory section: the Archivist's most recent N+100
/// synthesis fragment.
///
/// The Archivist compresses raw journal entries into a dense `<500 token`
/// fragment under `system/synthesized/`. Loading the most recent one into
/// active context *is* the payoff — Ani carries her continuity without
/// re-reading every dated journal page. Files are date-named, so the
/// lexically-greatest filename is the freshest synthesis.
async fn build_synthesis_orientation(memory_root: &Path) -> String {
let dir = memory_root.join("system/synthesized");
if !dir.exists() {
return String::new();
}
let mut newest: Option<(String, std::path::PathBuf)> = None;
if let Ok(mut entries) = tokio::fs::read_dir(&dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("md") || !p.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if newest.as_ref().map_or(true, |(n, _)| name > *n) {
newest = Some((name, p));
}
}
}
let Some((_, path)) = newest else {
return String::new();
};
let Ok(content) = tokio::fs::read_to_string(&path).await else {
return String::new();
};
let body = strip_frontmatter(&content).trim();
if body.is_empty() {
return String::new();
}
format!(
"## Synthesized Memory\n\n\
The Archivist's most recent synthesis the compressed essence of a \
span of journal entries, kept in active context so your continuity \
survives without re-reading every dated page. The raw entries remain \
in `journal/` if you need them.\n\n\
{body}"
)
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -924,7 +924,7 @@ impl App {
let mut live = self.config.write().await;
*live = saved.clone();
view.mode = crate::ui::settings::SettingsMode::Status {
msg: format!("saved to {}", path.display()),
msg: "saved".to_string(),
is_error: false,
};
// Diff known fields and push changes to SQLite.

View file

@ -567,12 +567,9 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
let text = trimmed;
let ts = Instant::now();
self.messages.push(ChatMessage::User { text: text.clone(), ts });
self.messages.push(ChatMessage::Assistant {
text: String::new(),
ts,
streaming: true,
rendered_cache: RefCell::new(None),
});
// No pre-created Assistant bubble — if the agent starts with tool
// calls, the empty bubble would finalize as a tiny blank box.
// `append_streaming()` creates one on first text token.
self.busy = true;
self.tool_calls_this_turn = 0;
self.phase = TurnPhase::Thinking;
@ -1185,8 +1182,12 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
self.pending_consciousness.push(BackendEvent::Outfit(name));
}
BackendEvent::Interstitial(text) => {
// Defensive: never render an empty narration slot — an
// all-whitespace interstitial draws a bare `⟡` gap line.
if !text.trim().is_empty() {
self.messages.push(ChatMessage::Interstitial(text));
}
}
BackendEvent::Keepalive => {
// Liveness signal — no visual change, just resets the
// event timer so the liveness label stays calm.

View file

@ -451,7 +451,7 @@ impl SettingsView {
field_idx: 0,
mode: SettingsMode::Browse,
expressions_path: None,
focus: PanelFocus::Fields,
focus: PanelFocus::Categories,
available_models: Vec::new(),
models_rx: None,
models_fetching: false,
@ -1132,11 +1132,19 @@ impl SettingsView {
}
}
KeyCode::Left => {
if cursor > 0 { cursor -= 1; }
// `cursor` is a byte index — step to the previous char boundary.
cursor = buffer[..cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.mode = SettingsMode::Editing { loc, buffer, cursor };
}
KeyCode::Right => {
if cursor < buffer.len() { cursor += 1; }
// Step forward by one whole char, not one byte.
if let Some(c) = buffer[cursor..].chars().next() {
cursor += c.len_utf8();
}
self.mode = SettingsMode::Editing { loc, buffer, cursor };
}
KeyCode::Home => {
@ -1149,9 +1157,15 @@ impl SettingsView {
}
KeyCode::Backspace => {
if cursor > 0 {
let idx = cursor - 1;
buffer.remove(idx);
cursor -= 1;
// Remove the whole char before the cursor — `cursor - 1`
// can land mid-codepoint and panic on non-ASCII input.
let prev = buffer[..cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
buffer.remove(prev);
cursor = prev;
}
// Backspace on empty Optional field → set to None
if buffer.is_empty() && matches!(loc, FieldLoc::ScModel | FieldLoc::RfModel | FieldLoc::CpModel | FieldLoc::ScMaxTokens | FieldLoc::MeBasePath) {
@ -1194,7 +1208,7 @@ impl SettingsView {
}
} else {
buffer.insert(cursor, c);
cursor += 1;
cursor += c.len_utf8();
}
self.mode = SettingsMode::Editing { loc, buffer, cursor };
}
@ -1207,7 +1221,7 @@ impl SettingsView {
fn commit_edit(&mut self, loc: FieldLoc, buffer: &str) {
match loc {
FieldLoc::ScMaxTokens | FieldLoc::RfMessageInterval |
FieldLoc::RfMessageInterval |
FieldLoc::ArInterval | FieldLoc::SaMaxConcurrent |
FieldLoc::SaTimeout | FieldLoc::SaMaxDepth |
FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs |
@ -1229,7 +1243,8 @@ impl SettingsView {
self.apply_field(loc, EditableValue::Float(v));
}
}
FieldLoc::ScModel | FieldLoc::RfModel | FieldLoc::CpModel | FieldLoc::MeBasePath => {
FieldLoc::ScModel | FieldLoc::RfModel | FieldLoc::CpModel
| FieldLoc::MeBasePath | FieldLoc::ScMaxTokens => {
let val = if buffer.is_empty() { None } else { Some(buffer.to_string()) };
self.apply_field(loc, EditableValue::OptionalText(val));
}