use crate::channel::RoomInfo;
use crate::config::DigestConfig;
use crate::periodic_log::{self, LogKind};
use std::collections::HashMap;
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",
},
];
struct CachedFile {
content: String,
mtime: SystemTime,
}
pub struct Workspace {
dir: PathBuf,
digest_cfg: DigestConfig,
cache: Mutex<HashMap<PathBuf, CachedFile>>,
today_digests: Mutex<HashMap<String, String>>,
}
impl Workspace {
pub fn new(dir: PathBuf, digest_cfg: DigestConfig) -> Self {
info!("Workspace dir: {}", dir.display());
Self {
dir,
digest_cfg,
cache: Mutex::new(HashMap::new()),
today_digests: Mutex::new(HashMap::new()),
}
}
pub async fn replace_today_digests(&self, digests: HashMap<String, String>) {
*self.today_digests.lock().await = digests;
}
pub async fn build_system_prompt(
&self,
base: Option<&str>,
boundary_hour: u8,
namespace_chain: &[String],
room_info: Option<&RoomInfo>,
) -> 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")
));
if let Some(info) = room_info {
parts.push(render_room_info(info));
}
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));
}
}
if let Some(block) = self.build_memory_block(namespace_chain).await {
parts.push(block);
}
let today = crate::session::local_date_for_timestamp(now_local, boundary_hour);
self.inject_periodic_logs(&mut parts, today, namespace_chain);
if let Some(block) = self.build_today_digest_block(namespace_chain).await {
parts.push(block);
}
parts.join("\n\n---\n\n")
}
async fn build_today_digest_block(&self, namespace_chain: &[String]) -> Option<String> {
let cache = self.today_digests.lock().await;
let mut subsections = Vec::new();
for ns in namespace_chain {
if let Some(text) = cache.get(ns)
&& !text.trim().is_empty()
{
subsections.push(format!("## {ns}\n\n{}", text.trim()));
}
}
if subsections.is_empty() {
None
} else {
Some(format!(
"# Today's Cross-Session Notes\n\n{}",
subsections.join("\n\n")
))
}
}
async fn build_memory_block(&self, namespace_chain: &[String]) -> Option<String> {
let mut subsections = Vec::new();
for ns in namespace_chain {
let rel = format!("memory/{ns}/MEMORY.md");
if let Some(content) = self.read_file(&rel).await
&& !content.trim().is_empty()
{
subsections.push(format!("## {ns}\n\n{content}"));
}
}
if subsections.is_empty() {
None
} else {
Some(format!("# Memory\n\n{}", subsections.join("\n\n")))
}
}
fn inject_periodic_logs(
&self,
parts: &mut Vec<String>,
today: chrono::NaiveDate,
namespace_chain: &[String],
) {
if let Some(direct_ns) = namespace_chain.first()
&& let Some(yesterday) = today.pred_opt()
&& let Some(body) = periodic_log::read_body(
&self.dir,
direct_ns,
LogKind::Daily,
&periodic_log::daily_stem(yesterday),
)
&& !body.trim().is_empty()
{
let truncated = truncate_chars(&body, MAX_FILE_CHARS);
debug!("Injecting yesterday's daily log from '{direct_ns}': {yesterday}");
parts.push(format!("# Yesterday's Log\n\n{truncated}"));
}
if self.digest_cfg.daily_items > 0 {
let stems = periodic_log::daily_stems_in_current_iso_week_before(today);
if let Some(b) = build_chained_digest_block(
"# This Week's Digests",
&self.dir,
namespace_chain,
LogKind::Daily,
&stems,
self.digest_cfg.daily_items,
) {
parts.push(b);
}
}
if self.digest_cfg.weekly_items > 0 {
let stems = periodic_log::week_stems_in_month_before(today);
if let Some(b) = build_chained_digest_block(
"# This Month's Digests",
&self.dir,
namespace_chain,
LogKind::Weekly,
&stems,
self.digest_cfg.weekly_items,
) {
parts.push(b);
}
}
if self.digest_cfg.monthly_items > 0 {
let stems = periodic_log::month_stems_in_year_before(today);
if let Some(b) = build_chained_digest_block(
"# This Year's Digests",
&self.dir,
namespace_chain,
LogKind::Monthly,
&stems,
self.digest_cfg.monthly_items,
) {
parts.push(b);
}
}
if self.digest_cfg.yearly_items > 0 {
let mut subsections: Vec<String> = Vec::new();
for ns in namespace_chain {
let stems = periodic_log::existing_yearly_stems(&self.dir, ns);
for stem in &stems {
if let Some(items) = periodic_log::read_digest_top_n(
&self.dir,
ns,
LogKind::Yearly,
stem,
self.digest_cfg.yearly_items,
) {
if items.is_empty() {
continue;
}
let bullets: Vec<String> =
items.into_iter().map(|i| format!("- {i}")).collect();
subsections.push(format!("## {ns}/{stem}\n\n{}", bullets.join("\n")));
}
}
}
if !subsections.is_empty() {
parts.push(format!(
"# Past Years' Digests\n\n{}",
subsections.join("\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)
&& 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 build_chained_digest_block(
heading: &str,
workspace_dir: &Path,
namespace_chain: &[String],
kind: LogKind,
stems: &[String],
n: usize,
) -> Option<String> {
let mut subsections = Vec::new();
for ns in namespace_chain {
for stem in stems {
let items = periodic_log::read_digest_top_n(workspace_dir, ns, kind, stem, n)
.unwrap_or_default();
if items.is_empty() {
continue;
}
let bullets: Vec<String> = items.into_iter().map(|i| format!("- {i}")).collect();
subsections.push(format!("## {ns}/{stem}\n\n{}", bullets.join("\n")));
}
}
if subsections.is_empty() {
None
} else {
debug!("Injecting {heading} ({} subsection(s))", subsections.len());
Some(format!("{heading}\n\n{}", subsections.join("\n\n")))
}
}
fn render_room_info(info: &RoomInfo) -> String {
let mut body = format!("- Channel: {}\n- Name: {}", info.kind, info.name);
if let Some(desc) = info.description.as_ref().filter(|s| !s.trim().is_empty()) {
body.push_str(&format!("\n- Description: {}", desc.trim()));
}
format!("# Current Room\n\n{body}")
}
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
}
}