use crate::llm::{Llm, TraceDocument};
use crate::manifest::Manifest;
use crate::types::DocType;
use anyhow::Result;
use chrono::Utc;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ContextUpdate {
pub timestamp: String,
pub update: String,
pub incorporated: bool,
}
pub fn load_pending_updates(store_root: &Path) -> Result<Vec<ContextUpdate>> {
let path = store_root
.join(".agent-trace")
.join("context_updates.jsonl");
if !path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&path)?;
let updates: Vec<ContextUpdate> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.filter(|u: &ContextUpdate| !u.incorporated)
.collect();
Ok(updates)
}
pub fn mark_updates_incorporated(store_root: &Path) -> Result<()> {
let path = store_root
.join(".agent-trace")
.join("context_updates.jsonl");
if !path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&path)?;
let updated: String = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.map(|mut v| {
v["incorporated"] = serde_json::Value::Bool(true);
v.to_string()
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, updated + "\n")?;
Ok(())
}
pub fn synthesize_no_llm(store_root: &Path, manifest: &Manifest) -> Result<String> {
let plans = manifest.list(Some(&DocType::Plan));
let refs = manifest.list(Some(&DocType::Reference));
let scratches = manifest.list(Some(&DocType::Scratch));
let pending = load_pending_updates(store_root)?;
let mut out = String::from("# Project Context\n\n");
out.push_str(&format!(
"*Auto-generated by agent-trace on {}. Use `agent-trace context refresh` to update.*\n\n",
Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
if !pending.is_empty() {
out.push_str("## Recent Updates\n\n");
for u in &pending {
out.push_str(&format!("- {}\n", u.update));
}
out.push('\n');
}
out.push_str("## Plans\n\n");
if plans.is_empty() {
out.push_str("*(no plan documents)*\n");
} else {
for p in &plans {
let desc = if p.description.is_empty() {
"(no description)"
} else {
&p.description
};
out.push_str(&format!("- **{}** — {}\n", p.path.display(), desc));
}
}
out.push('\n');
out.push_str("## Reference\n\n");
if refs.is_empty() {
out.push_str("*(no reference documents)*\n");
} else {
for r in &refs {
let desc = if r.description.is_empty() {
"(no description)"
} else {
&r.description
};
out.push_str(&format!("- **{}** — {}\n", r.path.display(), desc));
}
}
out.push('\n');
if !scratches.is_empty() {
out.push_str("## Scratch / Working Documents\n\n");
for s in &scratches {
let body = std::fs::read_to_string(store_root.join(&s.path)).unwrap_or_default();
let snippet: String = body.chars().take(500).collect();
if snippet.trim().is_empty() {
out.push_str(&format!("- {}\n", s.path.display()));
} else {
out.push_str(&format!(
"- [scratch] {}: {}\n",
s.path.display(),
snippet.trim().replace('\n', " ")
));
}
}
out.push('\n');
}
if let Ok(events) = crate::running_summary::load_recent_events(store_root, 15) {
if !events.is_empty() {
out.push_str("## Recent File Activity\n\n");
for e in events.iter().rev() {
let summary = e.summary.trim().replace('\n', " ");
if summary.is_empty() {
out.push_str(&format!("- {}\n", e.path));
} else {
out.push_str(&format!("- {} — {}\n", e.path, summary));
}
}
out.push('\n');
}
}
Ok(out)
}
pub fn build_trace_documents(
store_root: &Path,
manifest: &Manifest,
changed_paths: &[PathBuf],
) -> Vec<TraceDocument> {
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut docs: Vec<TraceDocument> = manifest
.documents()
.iter()
.filter(|d| {
matches!(
d.doc_type,
DocType::Plan | DocType::Reference | DocType::Scratch
)
})
.map(|d| {
seen.insert(d.path.clone());
let content = std::fs::read_to_string(store_root.join(&d.path)).unwrap_or_default();
let snippet: String = content.chars().take(2000).collect();
TraceDocument {
path: d.path.display().to_string(),
doc_type: d.doc_type.clone(),
content_snippet: snippet,
}
})
.collect();
for path in changed_paths {
if seen.contains(path) || !crate::git_store::should_track_activity(path) {
continue;
}
let full = store_root.join(path);
if !full.is_file() {
continue;
}
seen.insert(path.clone());
let content = std::fs::read_to_string(&full).unwrap_or_default();
let snippet: String = content.chars().take(2000).collect();
docs.push(TraceDocument {
path: path.display().to_string(),
doc_type: DocType::Scratch,
content_snippet: snippet,
});
}
docs
}
pub fn synthesize_context_content(
store_root: &Path,
manifest: &Manifest,
trace_insights: &Llm,
changed_paths: &[PathBuf],
) -> Result<(String, String)> {
if trace_insights.is_degraded() {
return Ok((
synthesize_no_llm(store_root, manifest)?,
"template".to_string(),
));
}
let docs = build_trace_documents(store_root, manifest, changed_paths);
let updates = load_pending_updates(store_root)?
.into_iter()
.map(|u| u.update)
.collect::<Vec<_>>();
let start = std::time::Instant::now();
match trace_insights.synthesize_context(&docs, &updates) {
Ok(s) => {
tracing::info!(
"LLM context synthesis succeeded (backend={}, latency_ms={})",
trace_insights.backend_label,
start.elapsed().as_millis()
);
Ok((s, format!("llm: {}", trace_insights.backend_label)))
}
Err(e) => {
tracing::warn!("LLM synthesize_context failed, using template: {e}");
Ok((
synthesize_no_llm(store_root, manifest)?,
"template".to_string(),
))
}
}
}
pub fn write_context(store_root: &Path, content: &str) -> Result<()> {
std::fs::write(store_root.join("context.md"), content)?;
mark_updates_incorporated(store_root)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreInfo;
use crate::manifest::Manifest;
use tempfile::TempDir;
fn setup(tmp: &TempDir) -> (std::path::PathBuf, Manifest) {
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
let info = StoreInfo::new("test".into());
let manifest = Manifest::create_empty(info, &root).unwrap();
(root, manifest)
}
#[test]
fn test_no_llm_synthesis_empty_store() {
let tmp = TempDir::new().unwrap();
let (root, manifest) = setup(&tmp);
let ctx = synthesize_no_llm(&root, &manifest).unwrap();
assert!(ctx.contains("# Project Context"));
assert!(ctx.contains("no plan documents"));
}
#[test]
fn test_no_llm_synthesis_with_plans() {
let tmp = TempDir::new().unwrap();
let (root, mut manifest) = setup(&tmp);
manifest
.register(&std::path::PathBuf::from("prd.md"), DocType::Plan, "")
.unwrap();
manifest
.update_description(&std::path::PathBuf::from("prd.md"), "Product requirements")
.unwrap();
let ctx = synthesize_no_llm(&root, &manifest).unwrap();
assert!(ctx.contains("prd.md"));
assert!(ctx.contains("Product requirements"));
}
#[test]
fn test_pending_updates_loaded() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
let entry = serde_json::json!({
"timestamp": "2026-04-07T00:00:00Z",
"update": "We chose PostgreSQL",
"incorporated": false,
});
std::fs::write(
root.join(".agent-trace").join("context_updates.jsonl"),
entry.to_string() + "\n",
)
.unwrap();
let pending = load_pending_updates(&root).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].update, "We chose PostgreSQL");
}
#[test]
fn test_no_llm_synthesis_includes_scratch_snippets() {
let tmp = TempDir::new().unwrap();
let (root, mut manifest) = setup(&tmp);
let body = "Working notes: implement idempotency for reconnect flow. ".repeat(10);
std::fs::write(root.join("notes.md"), &body).unwrap();
manifest
.register(&std::path::PathBuf::from("notes.md"), DocType::Scratch, "")
.unwrap();
let ctx = synthesize_no_llm(&root, &manifest).unwrap();
assert!(ctx.contains("## Scratch / Working Documents"));
assert!(ctx.contains("[scratch] notes.md:"));
assert!(ctx.contains("implement idempotency"));
}
#[test]
fn test_no_llm_synthesis_includes_recent_activity() {
let tmp = TempDir::new().unwrap();
let (root, manifest) = setup(&tmp);
crate::running_summary::append_event(
&root,
crate::running_summary::SummaryEvent {
timestamp: Utc::now().to_rfc3339(),
session_id: Some("s1".into()),
agent_name: Some("claude".into()),
actor: "agent:claude".into(),
action: "modify".into(),
change_kind: "modify".into(),
path: "worker.py".into(),
doc_type: "scratch".into(),
summary: "implement retry backoff".into(),
source: "poll".into(),
detected_by: "poll".into(),
lines_added: 4,
lines_removed: 1,
},
)
.unwrap();
let ctx = synthesize_no_llm(&root, &manifest).unwrap();
assert!(ctx.contains("## Recent File Activity"));
assert!(ctx.contains("worker.py"));
assert!(ctx.contains("implement retry backoff"));
}
#[test]
fn test_mark_updates_incorporated() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
let entry = serde_json::json!({
"timestamp": "2026-04-07T00:00:00Z",
"update": "test update",
"incorporated": false,
});
std::fs::write(
root.join(".agent-trace").join("context_updates.jsonl"),
entry.to_string() + "\n",
)
.unwrap();
mark_updates_incorporated(&root).unwrap();
let pending = load_pending_updates(&root).unwrap();
assert!(pending.is_empty());
}
}