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:
parent
2ce299f293
commit
84318b1352
9 changed files with 212 additions and 45 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,13 +59,24 @@ impl CompactionConfig {
|
|||
self.per_type
|
||||
.get(agent_type)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| AgentCompactionConfig {
|
||||
enabled: self.enabled,
|
||||
strategy: self.strategy.clone(),
|
||||
warn_pressure: self.warn_pressure,
|
||||
urgent_pressure: self.urgent_pressure,
|
||||
critical_pressure: self.critical_pressure,
|
||||
..Default::default()
|
||||
.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,
|
||||
warn_pressure: self.warn_pressure,
|
||||
urgent_pressure: self.urgent_pressure,
|
||||
critical_pressure: self.critical_pressure,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,7 +1182,11 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
|||
self.pending_consciousness.push(BackendEvent::Outfit(name));
|
||||
}
|
||||
BackendEvent::Interstitial(text) => {
|
||||
self.messages.push(ChatMessage::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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue