use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tokio::sync::Mutex;
use tracing::{debug, info};
const MAX_FILE_CHARS: usize = 20_000;
struct WorkspaceFileDef {
candidates: &'static [&'static str],
heading: &'static str,
}
static WORKSPACE_FILES: &[WorkspaceFileDef] = &[
WorkspaceFileDef {
candidates: &["AGENTS.md", "AGENT.md"],
heading: "# Agent Instructions",
},
WorkspaceFileDef {
candidates: &["SOUL.md"],
heading: "# Soul",
},
WorkspaceFileDef {
candidates: &["IDENTITY.md"],
heading: "# Identity",
},
WorkspaceFileDef {
candidates: &["USER.md"],
heading: "# User",
},
WorkspaceFileDef {
candidates: &["TOOLS.md"],
heading: "# Tools",
},
WorkspaceFileDef {
candidates: &["BOOTSTRAP.md"],
heading: "# Bootstrap",
},
WorkspaceFileDef {
candidates: &["MEMORY.md", "memory.md"],
heading: "# Memory",
},
];
struct CachedFile {
content: String,
mtime: SystemTime,
}
pub struct Workspace {
dir: PathBuf,
cache: Mutex<std::collections::HashMap<PathBuf, CachedFile>>,
}
impl Workspace {
pub fn new(dir: PathBuf) -> Self {
info!("Workspace dir: {}", dir.display());
Self {
dir,
cache: Mutex::new(std::collections::HashMap::new()),
}
}
pub async fn build_system_prompt(&self, base: Option<&str>, boundary_hour: u8) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(b) = base.filter(|s| !s.is_empty()) {
parts.push(b.to_string());
}
let now_local = chrono::Local::now();
parts.push(format!(
"# Current Date and Time\n\n{} ({})",
now_local.format("%Y-%m-%d %H:%M:%S %z"),
now_local.format("%A")
));
for def in WORKSPACE_FILES {
if let Some((filename, content)) = self.read_first_existing(def.candidates).await {
debug!("Injecting workspace file: {filename}");
parts.push(format!("{}\n\n{content}", def.heading));
}
}
let today = crate::session::local_date_for_timestamp(now_local, boundary_hour);
if let Some(yesterday) = today.pred_opt() {
let log_path = self
.dir
.join("memory")
.join("daily")
.join(format!("{yesterday}.md"));
if let Ok(content) = std::fs::read_to_string(&log_path) {
if !content.trim().is_empty() {
let truncated = truncate_chars(&content, MAX_FILE_CHARS);
debug!("Injecting yesterday's daily log: {yesterday}");
parts.push(format!("# Yesterday's Log\n\n{truncated}"));
}
}
}
parts.join("\n\n---\n\n")
}
async fn read_first_existing(&self, candidates: &[&str]) -> Option<(String, String)> {
for &filename in candidates {
if let Some(content) = self.read_file(filename).await {
return Some((filename.to_string(), content));
}
}
None
}
async fn read_file(&self, filename: &str) -> Option<String> {
let path = self.dir.join(filename);
match Self::file_mtime(&path) {
None => {
self.cache.lock().await.remove(&path);
None
}
Some(mtime) => {
{
let cache = self.cache.lock().await;
if let Some(entry) = cache.get(&path) {
if entry.mtime == mtime {
debug!("Workspace cache hit: {filename}");
return Some(entry.content.clone());
}
}
}
match std::fs::read_to_string(&path) {
Ok(raw) => {
let content = truncate_chars(&raw, MAX_FILE_CHARS);
info!(
"Loaded workspace file: {filename} ({} chars)",
content.len()
);
self.cache.lock().await.insert(
path,
CachedFile {
content: content.clone(),
mtime,
},
);
Some(content)
}
Err(e) => {
tracing::warn!("Failed to read {filename}: {e}");
None
}
}
}
}
}
fn file_mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
let mut chars = s.chars();
let truncated: String = (&mut chars).take(max_chars).collect();
if chars.next().is_some() {
format!("{truncated}\n\n[... truncated to {max_chars} characters ...]")
} else {
truncated
}
}