use std::env;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
const DEFAULT_CONTEXT_PATH: &str = "./evolve.md";
const DEFAULT_AGENT_NAME: &str = "Agent";
fn get_context_path() -> PathBuf {
if let Ok(p) = env::var("DAL_AGENT_CONTEXT_PATH") {
return PathBuf::from(p);
}
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
for name in &["agent.toml", "dal.toml"] {
let path = cwd.join(name);
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(table) = content.parse::<toml::Table>() {
if let Some(ctx_path) = table
.get("agent")
.and_then(|v| v.as_table())
.and_then(|t| t.get("context_path"))
.and_then(|v| v.as_str())
{
let mut buf = PathBuf::from(ctx_path);
if !buf.is_absolute() {
buf = cwd.join(buf);
}
return buf;
}
}
}
}
cwd.join(DEFAULT_CONTEXT_PATH)
}
fn ensure_header(path: &std::path::Path, agent_name: &str) -> std::io::Result<()> {
if path.exists() {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let now = chrono::Utc::now().to_rfc3339();
let header = format!(
"# Agent context — {}\nUpdated: {}\n\n## Conversation\n\n",
agent_name, now
);
std::fs::write(path, header)
}
fn ensure_action_log_section(path: &std::path::Path) -> std::io::Result<()> {
let content = std::fs::read_to_string(path).unwrap_or_default();
if content.contains("## Action log") {
return Ok(());
}
let table_header = "\n## Action log\n\n| Time | Action | Detail | Result | Get Task (ms) | Do Task (ms) | Total (ms) |\n|------|--------|--------|--------|----------------|--------------|------------|\n";
let mut f = OpenOptions::new().append(true).open(path)?;
f.write_all(table_header.as_bytes())?;
Ok(())
}
pub fn load(agent_name: Option<&str>) -> Result<String, String> {
let path = get_context_path();
let name = agent_name.unwrap_or(DEFAULT_AGENT_NAME);
ensure_header(&path, name).map_err(|e| e.to_string())?;
std::fs::read_to_string(&path).map_err(|e| e.to_string())
}
const MAX_CONVERSATION_LOG_LEN: usize = 32_768;
pub fn sanitize_for_conversation(s: &str) -> String {
let truncated = if s.len() > MAX_CONVERSATION_LOG_LEN {
&s[..MAX_CONVERSATION_LOG_LEN]
} else {
s
};
let with_newlines = truncated.replace("\n", "\n ");
with_newlines
.replace("**User:**", "[User]:")
.replace("**Agent:**", "[Agent]:")
}
pub fn append_conversation(
user_message: &str,
agent_response: &str,
agent_name: Option<&str>,
) -> Result<(), String> {
let path = get_context_path();
let name = agent_name.unwrap_or(DEFAULT_AGENT_NAME);
ensure_header(&path, name).map_err(|e| e.to_string())?;
let now = chrono::Utc::now();
let ts = now.format("%Y-%m-%dT%H:%M");
let user_safe = sanitize_for_conversation(user_message);
let agent_safe = sanitize_for_conversation(agent_response);
let block = format!(
"\n### {}\n**User:** {}\n\n**Agent:** {}\n\n",
ts, user_safe, agent_safe
);
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| e.to_string())?;
f.write_all(block.as_bytes()).map_err(|e| e.to_string())?;
Ok(())
}
pub fn append_log(action: &str, detail: &str, result: &str) -> Result<(), String> {
append_log_with_timing(action, detail, result, None, None)
}
pub fn append_log_timed(
action: &str,
detail: &str,
result: &str,
get_task_ms: i64,
do_task_ms: i64,
) -> Result<(), String> {
append_log_with_timing(action, detail, result, Some(get_task_ms), Some(do_task_ms))
}
fn append_log_with_timing(
action: &str,
detail: &str,
result: &str,
get_task_ms: Option<i64>,
do_task_ms: Option<i64>,
) -> Result<(), String> {
let path = get_context_path();
ensure_header(&path, DEFAULT_AGENT_NAME).map_err(|e| e.to_string())?;
ensure_action_log_section(&path).map_err(|e| e.to_string())?;
let now = chrono::Utc::now().format("%H:%M:%S");
let get_ms = get_task_ms
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string());
let do_ms = do_task_ms
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string());
let total_ms = match (get_task_ms, do_task_ms) {
(Some(get), Some(exec)) => (get.saturating_add(exec)).to_string(),
_ => "-".to_string(),
};
let line = format!(
"| {} | {} | {} | {} | {} | {} | {} |\n",
now, action, detail, result, get_ms, do_ms, total_ms
);
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| e.to_string())?;
f.write_all(line.as_bytes()).map_err(|e| e.to_string())?;
Ok(())
}
pub fn get_path() -> String {
get_context_path().display().to_string()
}
pub fn load_recent(agent_name: Option<&str>, max_lines: i64) -> Result<String, String> {
let full = load(agent_name)?;
if max_lines <= 0 {
return Ok(full);
}
let lines: Vec<&str> = full.lines().collect();
let n = max_lines as usize;
let start = if lines.len() <= n { 0 } else { lines.len() - n };
Ok(lines[start..].join("\n"))
}
pub fn trim_retention(keep_tail_lines: i64) -> Result<(), String> {
let path = get_context_path();
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
if keep_tail_lines <= 0 {
return Ok(());
}
let lines: Vec<&str> = content.lines().collect();
let header_end = lines
.iter()
.position(|&l| l.trim() == "## Conversation")
.map(|i| i + 2)
.unwrap_or(0);
let body_lines = lines.len().saturating_sub(header_end);
let keep = keep_tail_lines as usize;
if body_lines <= keep {
return Ok(());
}
let drop = body_lines - keep;
let new_body_start = header_end + drop;
let new_content = [
lines[..header_end].join("\n"),
lines[new_body_start..].join("\n"),
]
.join("\n");
std::fs::write(&path, new_content).map_err(|e| e.to_string())
}
pub fn append_summary(summary_text: &str, title: Option<&str>) -> Result<(), String> {
let path = get_context_path();
ensure_header(&path, DEFAULT_AGENT_NAME).map_err(|e| e.to_string())?;
let now = chrono::Utc::now();
let ts = now.format("%Y-%m-%d %H:%M");
let heading = title
.map(|t| format!("## Summary — {}\n\n", t))
.unwrap_or_else(|| "## Summary\n\n".to_string());
let block = format!("{}\n{}\n\n{}\n\n", heading, ts, summary_text);
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| e.to_string())?;
f.write_all(block.as_bytes()).map_err(|e| e.to_string())?;
Ok(())
}