use super::builder::AgentService;
use crate::brain::agent::context::AgentContext;
use crate::brain::agent::error::{AgentError, Result};
use crate::brain::provider::{ContentBlock, LLMRequest, Message, Provider};
use crate::services::{MessageService, SessionService};
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
impl AgentService {
fn active_plan_reminder(session_id: Uuid) -> Option<String> {
let path = crate::config::opencrabs_home()
.join("agents")
.join("session")
.join(format!(".opencrabs_plan_{session_id}.json"));
let content = std::fs::read_to_string(&path).ok()?;
let plan: crate::tui::plan::PlanDocument = serde_json::from_str(&content).ok()?;
format_plan_reminder(&plan)
}
pub(super) async fn prepare_message_context(
&self,
session_id: Uuid,
user_message: String,
model: Option<String>,
) -> Result<(String, LLMRequest, MessageService, SessionService)> {
let session_service = SessionService::new(self.context.clone());
let _session = session_service
.get_session(session_id)
.await
.map_err(|e| AgentError::Database(e.to_string()))?
.ok_or(AgentError::SessionNotFound(session_id))?;
let message_service = MessageService::new(self.context.clone());
let all_db_messages = message_service
.list_messages_for_session(session_id)
.await
.map_err(|e| AgentError::Database(e.to_string()))?;
let model_name = model.unwrap_or_else(|| {
self.provider_for_session(session_id)
.default_model()
.to_string()
});
let context_window = self.context_limit_for_session(session_id);
let db_messages = Self::messages_from_last_compaction(all_db_messages);
let mut context =
AgentContext::from_db_messages(session_id, db_messages, context_window as usize);
if let Some(brain) = &self.default_system_brain {
context.token_count += AgentContext::estimate_tokens(brain);
context.system_brain = Some(brain.clone());
}
let context_user_message = match Self::active_plan_reminder(session_id) {
Some(reminder) => format!("{user_message}\n\n{reminder}"),
None => user_message.clone(),
};
let user_msg = Message::user(context_user_message);
context.add_message(user_msg);
message_service
.create_message(session_id, "user".to_string(), user_message)
.await
.map_err(|e| AgentError::Database(e.to_string()))?;
let request = LLMRequest::new(model_name.clone(), context.messages.clone())
.with_max_tokens(self.max_tokens);
let working_directory = self.get_working_directory();
let recent_paths = self.recent_paths_for_dir(&working_directory).await;
let augmented_system = Self::augment_system_with_recent_paths(
context.system_brain,
&recent_paths,
&context.messages,
);
let mut request = if let Some(system) = augmented_system {
request.with_system(system)
} else {
request
};
request.working_directory = Some(working_directory.to_string_lossy().to_string());
request.session_id = Some(session_id);
Ok((model_name, request, message_service, session_service))
}
pub(crate) fn augment_system_with_recent_paths(
base: Option<String>,
recent_paths: &[String],
messages: &[Message],
) -> Option<String> {
if recent_paths.is_empty() {
return base;
}
let context_blob: String = messages
.iter()
.flat_map(|m| m.content.iter())
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.clone()),
ContentBlock::ToolUse { input, .. } => Some(input.to_string()),
ContentBlock::ToolResult { content, .. } => Some(content.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let context_for_match = context_blob.to_lowercase();
let surviving: Vec<&String> = recent_paths
.iter()
.filter(|p| !context_for_match.contains(&p.to_lowercase()))
.collect();
if surviving.is_empty() {
return base;
}
let mut out = base.unwrap_or_default();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(
"\n--- Recently accessed in this project ---\n\
(Real paths previously confirmed by read/edit/grep/ls. Prefer these as anchors \
over guessing from naming conventions.)\n",
);
for p in surviving {
out.push_str(" - ");
out.push_str(p);
out.push('\n');
}
Some(out)
}
pub fn messages_from_last_compaction(
all_messages: Vec<crate::db::models::Message>,
) -> Vec<crate::db::models::Message> {
const COMPACTION_MARKER: &str = "[CONTEXT COMPACTION";
let compaction_idx = all_messages
.iter()
.rposition(|msg| msg.content.contains(COMPACTION_MARKER));
if let Some(idx) = compaction_idx {
let kept = all_messages.len() - idx;
tracing::info!(
"Found compaction marker at message {}/{} — loading {} messages from compaction point",
idx,
all_messages.len(),
kept,
);
all_messages[idx..].to_vec()
} else {
all_messages
}
}
fn build_recovered_brain_context() -> String {
use std::path::PathBuf;
const CODE_MD_SUMMARY: &str =
"## CODE.md — Coding Standards (SUMMARY)
**Full file: CODE.md in your OpenCrabs home — use `load_brain_file(\"CODE.md\")` to read it before writing ANY code.**
If you are NOT doing code tasks, ignore this section entirely.
Best practices:
- Rust first. Always. (heyiolo is built in Dart/Swift — those are the only exceptions)
- Max 500 lines per file, target 100-250. Split without hesitation.
- Types in types.rs, handlers in handler.rs. One responsibility per file.
- Tests in `src/tests/<module>_test.rs` — never inline in source.
- `cargo clippy --all-features` + `cargo test --all-features` before every commit.
- No unwraps on user data, no dead code, no suppressing warnings.
- No #[allow()] unless you can defend why the lint is wrong.
- No unsafe without a soundness comment.
- Validate all external input. No hardcoded secrets. Sanitize output.
- Never give up on a problem. Never suppress errors.
- Git diff before commit — match the request exactly, no more, no less.
**CRITICAL: Before handling ANY code task, fetch full CODE.md:**
Use the `load_brain_file` tool with name=\"CODE.md\" — reads CODE.md from your OpenCrabs home.
The summary above is NOT sufficient for implementation work.
";
let full_files = [
("SOUL.md", "personality"),
("USER.md", "user profile"),
("TOOLS.md", "tool notes"),
];
let opencrabs_home = crate::config::opencrabs_home();
let mut result = String::new();
for (filename, label) in full_files {
let path: PathBuf = opencrabs_home.join(filename);
if let Ok(content) = std::fs::read_to_string(&path) {
let trimmed = content.trim();
if !trimmed.is_empty() {
result.push_str(&format!(
"--- {} ({}) ---\n{}\n\n",
filename, label, trimmed
));
}
}
}
result.push_str(CODE_MD_SUMMARY);
if result.is_empty() {
String::from("[No brain files found — agent context limited]\n\n")
} else {
format!(
"[RECOVERED BRAIN CONTEXT — these files define your identity, the user, your tools, and your coding standards. They take priority over any contradictory inference from the summary.]\n\n{}\n",
result
)
}
}
pub(super) async fn compact_context(
&self,
session_id: Uuid,
context: &mut AgentContext,
model_name: &str,
cancel_token: Option<&CancellationToken>,
) -> Result<String> {
let provider = self.provider_for_session(session_id);
let cancel = cancel_token.cloned().unwrap_or_default();
let summary = Self::compute_compaction_summary(
provider,
session_id,
context.messages.clone(),
context.token_count,
context.max_tokens,
context.usage_percentage(),
model_name.to_string(),
self.max_tokens,
self.get_working_directory(),
self.auto_approve_tools,
cancel,
)
.await?;
Self::apply_compaction_summary(context, &summary);
Ok(summary)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn compute_compaction_summary(
provider: Arc<dyn Provider>,
session_id: Uuid,
snapshot_messages: Vec<Message>,
snapshot_token_count: usize,
snapshot_max_tokens: usize,
snapshot_usage_pct: f64,
model_name: String,
max_output_tokens: u32,
working_directory: PathBuf,
auto_approve_tools: bool,
cancel: CancellationToken,
) -> Result<String> {
let remaining_budget = snapshot_max_tokens.saturating_sub(snapshot_token_count);
let start = snapshot_messages
.iter()
.position(|m| {
!(m.role == crate::brain::provider::Role::User
&& !m.content.is_empty()
&& m.content.iter().all(|b| {
matches!(b, crate::brain::provider::ContentBlock::ToolResult { .. })
}))
})
.unwrap_or(snapshot_messages.len());
let output_reserve = 8_000usize + 1_000usize;
let max_input_budget = snapshot_max_tokens.saturating_sub(output_reserve);
let all_msgs = &snapshot_messages[start..];
let mut running_tokens = 0usize;
let msgs_to_include: Vec<&Message> = all_msgs
.iter()
.rev()
.take_while(|m| {
let t = AgentContext::estimate_tokens_static(m);
if running_tokens + t <= max_input_budget {
running_tokens += t;
true
} else {
tracing::warn!(
"Compaction: dropping oldest messages to fit input budget ({}/{} tokens used)",
running_tokens,
max_input_budget,
);
false
}
})
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
tracing::info!(
"Compaction: sending {} / {} messages to summarizer ({} / {} input tokens, reserving {} for output)",
msgs_to_include.len(),
all_msgs.len(),
running_tokens,
snapshot_max_tokens,
output_reserve,
);
let mut summary_messages: Vec<Message> = msgs_to_include.into_iter().cloned().collect();
let compaction_prompt = format!(
"CRITICAL: The context window is at {:.0}% capacity ({} / {} tokens, {} tokens remaining). \
The conversation must be compacted NOW.\n\n\
You are creating a COMPREHENSIVE CONTINUATION DOCUMENT. After compaction, a fresh agent \
instance will wake up with ONLY this summary as context. It must be able to continue \
working immediately without asking the user what to do.\n\n\
Analyze the ENTIRE conversation chronologically and produce the following:\n\n\
## 0. IMMEDIATE TASK (CRITICAL — MOST IMPORTANT SECTION)\n\
Look at the LAST 6-8 message pairs in the conversation. Extract EXACTLY:\n\
- What was the user's LAST instruction or request? (quote their exact words)\n\
- What was the agent doing in response? (exact tool calls, file edits, investigations in progress)\n\
- What is the EXACT next action the agent should take?\n\n\
Write this as a DIRECTIVE, not a description. Use this format:\n\
\"CONTINUE THIS TASK: The user asked you to [exact instruction]. \
You were [exact action in progress — e.g. 'editing file X at line Y', 'running command Z']. \
Your next step is [specific next action]. \
Do NOT deviate to any other topic.\"\n\n\
This is the MOST IMPORTANT section. If nothing else survives compaction, this must. \
The fresh agent will read this section FIRST and act on it IMMEDIATELY.\n\n\
## 1. Chronological Analysis\n\
Walk through every task the user requested, in order. For each task include:\n\
- What was requested\n\
- What was done (with exact file paths and line numbers where relevant)\n\
- Exact code snippets for any changes made (show before/after when applicable)\n\
- Whether it was completed, committed, pushed, or still pending\n\n\
## 2. Files Modified\n\
List EVERY file that was created, edited, read, or discussed. For each file include:\n\
- Full file path\n\
- What was changed and why\n\
- Key code snippets showing the current state of changes\n\
- Whether the change is committed or uncommitted\n\n\
## 3. User Preferences & Constraints\n\
List EVERY preference, constraint, or strong reaction from the user. Include:\n\
- Things the user explicitly said to NEVER do (with their exact words if they were emphatic)\n\
- Workflow preferences (commit style, release process, tool choices)\n\
- Technical constraints or architectural decisions\n\
- Any corrections the user made to your work\n\n\
## 4. Errors & Corrections\n\
Every error encountered, every mistake made, and how each was resolved. Include:\n\
- Exact error messages when available\n\
- What caused the error\n\
- The fix applied\n\
- User reactions to mistakes (so the agent avoids repeating them)\n\n\
## 5. All User Messages\n\
Summarize every user message in order, capturing their intent and exact wording \
for important instructions. This is critical for understanding the user's communication \
style and expectations.\n\n\
## 6. Pending Tasks\n\
List everything that is NOT yet done:\n\
- Uncommitted changes\n\
- Tasks mentioned but not started\n\
- Investigations in progress\n\
- Next steps the user expects\n\n\
## 7. Recovery Playbook\n\
The fresh agent has these tools available to recover any missing context:\n\
- `session_search` — search past conversation messages in this session by keyword\n\
- `memory_search` — search daily memory logs and indexed knowledge\n\
- `load_brain_file` — reload brain files (SOUL.md, TOOLS.md, USER.md, etc.) for identity/preferences\n\
- `read_file` / `glob` / `grep` — read any file, search by pattern, search file contents\n\
- `bash` — run shell commands (git status, git log, git diff, etc.)\n\
- `ls` — list directory contents\n\
- `gh` — GitHub CLI for ALL GitHub operations (repos, releases, issues, PRs). \
NEVER use HTTP requests to GitHub — always use `gh` CLI.\n\n\
Write a SPECIFIC recovery plan: which tools to call with which arguments to get back \
up to speed. Example: \"Run `git status` and `git diff` to see uncommitted changes, \
then `read_file src/main.rs` to verify the current state of the fix, then \
`session_search 'vision fallback'` to recover details from the investigation.\"\n\
Be concrete — include actual file paths, search queries, and commands.\n\n\
## 8. Next Step\n\
State the single most important thing the agent should do when it wakes up. \
If the task is clear, continue immediately. If ambiguous, ask the user ONE focused \
follow-up question.\n\n\
## 9. Continuation Message\n\
Write a SHORT, punchy message (2-4 sentences) that the agent will say to the user \
right after waking up from compaction. This message MUST:\n\
- Reference SPECIFIC things from the conversation (file names, user quotes, inside jokes, \
frustrations, wins) — prove the agent remembers everything\n\
- Mention what was just accomplished and what's next in a way that feels alive and engaged\n\
- Match the user's energy and communication style from the conversation\n\
- Be creative, surprising, maybe funny — make the user think \"holy shit it remembers\"\n\
- End with a clear action: what the agent is about to do next or a specific question\n\
DO NOT be generic. DO NOT say \"I'm ready to continue.\" Reference actual conversation details \
that only someone who was there would know.\n\n\
Tool approval status: {}\n\n\
BE EXHAUSTIVE. This is not a summary — it is a complete knowledge transfer. \
Include code snippets, exact paths, user quotes, error messages. \
The fresh agent has ZERO context beyond what you write here.",
snapshot_usage_pct,
snapshot_token_count,
snapshot_max_tokens,
remaining_budget,
if auto_approve_tools {
"AUTO-APPROVE ON (tools run freely)"
} else {
"AUTO-APPROVE OFF — tool approval is REQUIRED for every tool call"
},
);
summary_messages.push(Message::user(compaction_prompt));
let mut effective_model = model_name;
let supported = provider.supported_models();
if !supported.is_empty() && !supported.iter().any(|m| m == &effective_model) {
let remapped = provider.default_model().to_string();
tracing::warn!(
"compute_compaction_summary: provider '{}' does not support model '{}' — remapping to '{}'",
provider.name(),
effective_model,
remapped,
);
effective_model = remapped;
}
let mut request = LLMRequest::new(effective_model, summary_messages)
.with_max_tokens(max_output_tokens)
.with_system(
"You are a continuation document generator. Your job is to create an exhaustive, \
detailed knowledge transfer document from a conversation so that a fresh AI agent can \
continue the work seamlessly. You must capture every file path, code snippet, user preference, \
error, and pending task. The agent reading your output will have ZERO prior context — \
your document is its entire memory. Be thorough to the point of being verbose. \
Missing a single detail could cause the agent to repeat mistakes or violate user preferences."
.to_string(),
);
request.working_directory = Some(working_directory.to_string_lossy().to_string());
request.session_id = Some(session_id);
let response = tokio::select! {
biased;
_ = cancel.cancelled() => {
tracing::info!("Compaction cancelled before completion");
return Err(AgentError::Cancelled);
}
r = provider.complete(request) => r.map_err(AgentError::Provider)?,
};
let summary = Self::extract_text_from_response(&response);
if let Err(e) = Self::save_compaction_summary_to_memory(&summary).await {
tracing::warn!("Failed to save compaction summary to daily log: {}", e);
}
let memory_path = crate::config::opencrabs_home()
.join("memory")
.join(format!("{}.md", chrono::Local::now().format("%Y-%m-%d")));
tokio::spawn(async move {
if let Ok(store) = crate::memory::get_store() {
let _ = crate::memory::index_file(store, &memory_path).await;
}
});
Ok(summary)
}
pub(super) fn apply_compaction_summary(context: &mut AgentContext, summary: &str) {
let recent_snapshot = Self::format_recent_messages(&context.messages, 8);
let brain_context = Self::build_recovered_brain_context();
let summary_with_context = if recent_snapshot.is_empty() {
format!("{}\n\n{}", brain_context, summary)
} else {
format!(
"{}\n\n{}\n\n## Recent Message Pairs (pre-compaction snapshot)\n\
CRITICAL: These are the messages from RIGHT BEFORE compaction. You MUST \
continue from where you left off. Read these messages, identify what was \
in progress, and continue that exact work. Do NOT start a new topic. \
Do NOT ask the user what to do — the answer is in these messages.\n\n{}",
brain_context, summary, recent_snapshot
)
};
context.compact_with_summary(summary_with_context, 0);
tracing::info!(
"Context compacted: now at {:.0}% ({} tokens)",
context.usage_percentage(),
context.token_count
);
}
pub(crate) fn format_recent_messages(messages: &[Message], n: usize) -> String {
use crate::brain::provider::{ContentBlock, Role};
let start = messages.len().saturating_sub(n);
let mut lines = Vec::new();
for msg in &messages[start..] {
let role_label = match msg.role {
Role::User => "**User**",
Role::Assistant => "**Assistant**",
Role::System => "**System**",
};
for block in &msg.content {
match block {
ContentBlock::Text { text } => {
let display = if text.len() > 500 {
let end = text.floor_char_boundary(500);
format!("{}… [truncated]", &text[..end])
} else {
text.clone()
};
lines.push(format!("{}: {}", role_label, display));
}
ContentBlock::ToolUse { name, input, .. } => {
let input_preview = {
let s = input.to_string();
if s.len() > 200 {
let end = s.floor_char_boundary(200);
format!("{}…", &s[..end])
} else {
s
}
};
lines.push(format!(
"{}: [tool_use: {}({})]",
role_label, name, input_preview
));
}
ContentBlock::ToolResult { content, .. } => {
let display = if content.len() > 300 {
let end = content.floor_char_boundary(300);
format!("{}… [truncated]", &content[..end])
} else {
content.clone()
};
lines.push(format!("{}: [tool_result: {}]", role_label, display));
}
ContentBlock::Image { .. } => {
lines.push(format!("{}: [image]", role_label));
}
ContentBlock::Thinking { thinking, .. } => {
if !thinking.is_empty() {
let display = if thinking.len() > 300 {
let end = thinking.floor_char_boundary(300);
format!("{}… [truncated]", &thinking[..end])
} else {
thinking.clone()
};
lines.push(format!("{}: [thinking: {}]", role_label, display));
}
}
}
}
}
lines.join("\n")
}
pub(super) async fn save_compaction_summary_to_memory(
summary: &str,
) -> std::result::Result<(), String> {
let memory_dir = crate::config::opencrabs_home().join("memory");
std::fs::create_dir_all(&memory_dir)
.map_err(|e| format!("Failed to create memory directory: {}", e))?;
let date = chrono::Local::now().format("%Y-%m-%d");
let memory_path = memory_dir.join(format!("{}.md", date));
let existing = std::fs::read_to_string(&memory_path).unwrap_or_default();
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let new_content = format!(
"{}\n\n---\n\n## Auto-Compaction Summary ({})\n\n{}\n",
existing.trim(),
timestamp,
summary
);
std::fs::write(&memory_path, new_content.trim_start())
.map_err(|e| format!("Failed to write daily memory log: {}", e))?;
tracing::info!("Saved compaction summary to {}", memory_path.display());
Ok(())
}
}
pub(crate) fn format_plan_reminder(plan: &crate::tui::plan::PlanDocument) -> Option<String> {
use crate::tui::plan::{PlanStatus, TaskStatus};
if !matches!(plan.status, PlanStatus::Approved | PlanStatus::InProgress) {
return None;
}
let total = plan.tasks.len();
if total == 0 {
return None;
}
let done = plan
.tasks
.iter()
.filter(|t| matches!(t.status, TaskStatus::Completed))
.count();
let resolved = plan
.tasks
.iter()
.filter(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped))
.count();
if resolved == total {
return None;
}
let mut out = format!(
"[ACTIVE PLAN REMINDER — injected by the harness, not from the user]\n\
You are partway through a plan ({done}/{total} tasks done). Keep executing it; do not \
abandon or forget it. Mark each task complete via the plan tool as you finish, and \
finalize the plan once ALL tasks are done.\n\
Plan: \"{}\"\n",
plan.title
);
let mut tasks: Vec<&crate::tui::plan::PlanTask> = plan.tasks.iter().collect();
tasks.sort_by_key(|t| t.order);
for t in &tasks {
match &t.status {
TaskStatus::InProgress => out.push_str(&format!("→ In progress: {}\n", t.title)),
TaskStatus::Pending => out.push_str(&format!("☐ {}\n", t.title)),
TaskStatus::Failed => out.push_str(&format!("✗ Failed (retry/fix): {}\n", t.title)),
TaskStatus::Blocked(_) => out.push_str(&format!("⊘ Blocked: {}\n", t.title)),
TaskStatus::Completed | TaskStatus::Skipped => {}
}
}
Some(out)
}