use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use chrono::Local;
use tracing::debug;
pub const DEFAULT_MAX_CHARS_PER_FILE: usize = 10_000;
pub const DEFAULT_TOTAL_MAX_CHARS: usize = 50_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionType {
Normal,
Heartbeat,
Boot,
Bootstrap,
}
#[derive(Debug, Clone, Default)]
pub struct WorkspaceContext {
pub agents_md: Option<String>,
pub soul_md: Option<String>,
pub user_md: Option<String>,
pub identity_md: Option<String>,
pub tools_md: Option<String>,
pub heartbeat_md: Option<String>,
pub boot_md: Option<String>,
pub bootstrap_md: Option<String>,
pub memory_long: Option<String>,
pub memory_today: Option<String>,
pub memory_yesterday: Option<String>,
pub workspace_dir: PathBuf,
}
impl WorkspaceContext {
pub fn load(
workspace: &Path,
session_type: SessionType,
is_private: bool,
max_chars_per_file: usize,
total_max_chars: usize,
) -> Self {
let fallback = {
let default_ws = crate::config::loader::base_dir().join("workspace");
if workspace != default_ws && default_ws.exists() {
Some(default_ws)
} else {
None
}
};
let mut ctx = WorkspaceContext {
workspace_dir: workspace.to_path_buf(),
..Default::default()
};
let mut total_chars: usize = 0;
macro_rules! load_file {
($field:ident, $filename:expr) => {{
if total_chars < total_max_chars {
let content = read_workspace_file(
workspace,
$filename,
max_chars_per_file,
&mut total_chars,
total_max_chars,
);
let content = if content.is_empty() {
if let Some(ref fb) = fallback {
read_workspace_file(
fb,
$filename,
max_chars_per_file,
&mut total_chars,
total_max_chars,
)
} else {
content
}
} else {
content
};
if !content.is_empty() {
ctx.$field = Some(content);
}
}
}};
}
load_file!(agents_md, "AGENTS.md");
load_file!(soul_md, "SOUL.md");
load_file!(user_md, "USER.md");
load_file!(identity_md, "IDENTITY.md");
load_file!(tools_md, "TOOLS.md");
if session_type == SessionType::Heartbeat {
load_file!(heartbeat_md, "HEARTBEAT.md");
}
if session_type == SessionType::Boot {
load_file!(boot_md, "BOOT.md");
}
if session_type == SessionType::Bootstrap {
load_file!(bootstrap_md, "BOOTSTRAP.md");
}
if is_private {
load_file!(memory_long, "MEMORY.md");
}
let today = Local::now().format("%Y-%m-%d").to_string();
let yesterday = (Local::now() - chrono::Duration::days(1))
.format("%Y-%m-%d")
.to_string();
ctx.memory_today = read_optional_file(
&workspace.join("memory").join(format!("{today}.md")),
max_chars_per_file,
&mut total_chars,
total_max_chars,
);
ctx.memory_yesterday = read_optional_file(
&workspace.join("memory").join(format!("{yesterday}.md")),
max_chars_per_file,
&mut total_chars,
total_max_chars,
);
debug!(
workspace = %workspace.display(),
total_chars,
"workspace context loaded"
);
ctx
}
pub fn to_prompt_segment(&self) -> String {
let mut parts: Vec<String> = Vec::new();
macro_rules! append {
($field:expr, $label:expr) => {
if let Some(content) = &$field {
parts.push(format!("## {}\n\n{content}", $label));
}
};
}
append!(self.agents_md, "AGENTS.md");
append!(self.soul_md, "SOUL.md");
append!(self.identity_md, "IDENTITY.md");
append!(self.user_md, "USER.md");
append!(self.tools_md, "TOOLS.md");
append!(self.heartbeat_md, "HEARTBEAT.md");
append!(self.boot_md, "BOOT.md");
append!(self.bootstrap_md, "BOOTSTRAP.md");
append!(self.memory_long, "MEMORY.md");
append!(self.memory_today, "Memory (today)");
append!(self.memory_yesterday, "Memory (yesterday)");
parts.join("\n\n---\n\n")
}
}
#[derive(Debug)]
pub struct WorkspaceCache {
entries: HashMap<String, CacheEntry>,
cached_ctx: Option<WorkspaceContext>,
workspace: PathBuf,
}
#[derive(Debug, Clone)]
struct CacheEntry {
content: String,
mtime: Option<SystemTime>,
}
impl WorkspaceCache {
pub fn new(workspace: &Path) -> Self {
Self {
entries: HashMap::new(),
cached_ctx: None,
workspace: workspace.to_path_buf(),
}
}
pub fn load(
&mut self,
session_type: SessionType,
is_private: bool,
max_chars_per_file: usize,
total_max_chars: usize,
) -> WorkspaceContext {
let mut total_chars: usize = 0;
let mut ctx = WorkspaceContext {
workspace_dir: self.workspace.clone(),
..Default::default()
};
macro_rules! load_cached {
($field:ident, $filename:expr) => {{
if total_chars < total_max_chars {
let content = self.read_cached(
$filename,
max_chars_per_file,
&mut total_chars,
total_max_chars,
);
if !content.is_empty() {
ctx.$field = Some(content);
}
}
}};
}
load_cached!(agents_md, "AGENTS.md");
load_cached!(soul_md, "SOUL.md");
load_cached!(user_md, "USER.md");
load_cached!(identity_md, "IDENTITY.md");
load_cached!(tools_md, "TOOLS.md");
if session_type == SessionType::Heartbeat {
load_cached!(heartbeat_md, "HEARTBEAT.md");
}
if session_type == SessionType::Boot {
load_cached!(boot_md, "BOOT.md");
}
if session_type == SessionType::Bootstrap {
load_cached!(bootstrap_md, "BOOTSTRAP.md");
}
if is_private {
load_cached!(memory_long, "MEMORY.md");
}
let today = Local::now().format("%Y-%m-%d").to_string();
let yesterday = (Local::now() - chrono::Duration::days(1))
.format("%Y-%m-%d")
.to_string();
ctx.memory_today = read_optional_file(
&self.workspace.join("memory").join(format!("{today}.md")),
max_chars_per_file,
&mut total_chars,
total_max_chars,
);
ctx.memory_yesterday = read_optional_file(
&self
.workspace
.join("memory")
.join(format!("{yesterday}.md")),
max_chars_per_file,
&mut total_chars,
total_max_chars,
);
debug!(
workspace = %self.workspace.display(),
total_chars,
"workspace context loaded (cached)"
);
self.cached_ctx = Some(ctx.clone());
ctx
}
fn read_cached(
&mut self,
filename: &str,
max_chars: usize,
total_chars: &mut usize,
total_max: usize,
) -> String {
let path = self.workspace.join(filename);
let current_mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
if let Some(entry) = self.entries.get(filename) {
if entry.mtime == current_mtime && current_mtime.is_some() {
let remaining = total_max.saturating_sub(*total_chars);
let limit = max_chars.min(remaining);
let truncated = truncate_chars(&entry.content, limit);
*total_chars += truncated.len();
return truncated.to_owned();
}
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => String::new(),
};
self.entries.insert(
filename.to_owned(),
CacheEntry {
content: content.clone(),
mtime: current_mtime,
},
);
let remaining = total_max.saturating_sub(*total_chars);
let limit = max_chars.min(remaining);
let truncated = truncate_chars(&content, limit);
*total_chars += truncated.len();
truncated.to_owned()
}
}
fn read_workspace_file(
workspace: &Path,
filename: &str,
max_chars: usize,
total_chars: &mut usize,
total_max: usize,
) -> String {
let path = workspace.join(filename);
match std::fs::read_to_string(&path) {
Ok(content) => {
let remaining = total_max.saturating_sub(*total_chars);
let limit = max_chars.min(remaining);
let truncated = truncate_chars(&content, limit);
*total_chars += truncated.len();
truncated.to_owned()
}
Err(_) => String::new(),
}
}
fn read_optional_file(
path: &Path,
max_chars: usize,
total_chars: &mut usize,
total_max: usize,
) -> Option<String> {
if !path.exists() || *total_chars >= total_max {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let remaining = total_max.saturating_sub(*total_chars);
let limit = max_chars.min(remaining);
let truncated = truncate_chars(&content, limit).to_owned();
*total_chars += truncated.len();
Some(truncated)
}
fn truncate_chars(s: &str, max_chars: usize) -> &str {
if s.len() <= max_chars {
return s;
}
match s.char_indices().nth(max_chars) {
Some((idx, _)) => &s[..idx],
None => s,
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn setup_workspace(dir: &Path) {
fs::write(dir.join("AGENTS.md"), "# Agents\nDo stuff.").expect("agents");
fs::write(dir.join("SOUL.md"), "# Soul\nBe helpful.").expect("soul");
fs::write(dir.join("USER.md"), "# User\nJane.").expect("user");
fs::write(dir.join("IDENTITY.md"), "# Identity\nBot.").expect("identity");
fs::write(dir.join("TOOLS.md"), "# Tools\nNone.").expect("tools");
}
#[test]
fn loads_standard_files() {
let tmp = tempfile::tempdir().expect("tempdir");
setup_workspace(tmp.path());
let ctx = WorkspaceContext::load(
tmp.path(),
SessionType::Normal,
false,
DEFAULT_MAX_CHARS_PER_FILE,
DEFAULT_TOTAL_MAX_CHARS,
);
assert!(ctx.agents_md.as_deref().unwrap_or("").contains("Agents"));
assert!(ctx.soul_md.as_deref().unwrap_or("").contains("Soul"));
assert!(
ctx.heartbeat_md.is_none(),
"heartbeat not loaded in Normal mode"
);
}
#[test]
fn missing_file_gets_placeholder() {
let tmp = tempfile::tempdir().expect("tempdir");
let ws = tmp.path().join("workspace");
fs::create_dir_all(&ws).expect("mkdir");
fs::write(ws.join("AGENTS.md"), "# Agents").expect("agents");
let orig = std::env::var("RSCLAW_BASE_DIR").ok();
unsafe { std::env::set_var("RSCLAW_BASE_DIR", tmp.path()); }
let ctx = WorkspaceContext::load(
&ws,
SessionType::Normal,
false,
DEFAULT_MAX_CHARS_PER_FILE,
DEFAULT_TOTAL_MAX_CHARS,
);
unsafe {
match orig {
Some(v) => std::env::set_var("RSCLAW_BASE_DIR", v),
None => std::env::remove_var("RSCLAW_BASE_DIR"),
}
}
assert!(
ctx.soul_md.is_none(),
"expected None for missing SOUL.md, got: {:?}",
ctx.soul_md
);
}
#[test]
fn heartbeat_loads_heartbeat_md() {
let tmp = tempfile::tempdir().expect("tempdir");
setup_workspace(tmp.path());
fs::write(tmp.path().join("HEARTBEAT.md"), "# Heartbeat\nchecklist").expect("hb");
let ctx = WorkspaceContext::load(
tmp.path(),
SessionType::Heartbeat,
false,
DEFAULT_MAX_CHARS_PER_FILE,
DEFAULT_TOTAL_MAX_CHARS,
);
assert!(
ctx.heartbeat_md
.as_deref()
.unwrap_or("")
.contains("checklist")
);
}
#[test]
fn per_file_char_limit_applied() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "x".repeat(100)).expect("agents");
let ctx = WorkspaceContext::load(
tmp.path(),
SessionType::Normal,
false,
50,
DEFAULT_TOTAL_MAX_CHARS,
);
let content = ctx.agents_md.as_deref().unwrap_or("");
assert!(content.len() <= 50, "got {} chars", content.len());
}
}