1use crate::llm::{Llm, TraceDocument};
2use crate::manifest::Manifest;
3use crate::types::DocType;
4use anyhow::Result;
5use chrono::Utc;
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct ContextUpdate {
12 pub timestamp: String,
13 pub update: String,
14 pub incorporated: bool,
15}
16
17pub fn load_pending_updates(store_root: &Path) -> Result<Vec<ContextUpdate>> {
19 let path = store_root
20 .join(".agent-trace")
21 .join("context_updates.jsonl");
22 if !path.exists() {
23 return Ok(Vec::new());
24 }
25 let content = std::fs::read_to_string(&path)?;
26 let updates: Vec<ContextUpdate> = content
27 .lines()
28 .filter(|l| !l.trim().is_empty())
29 .filter_map(|l| serde_json::from_str(l).ok())
30 .filter(|u: &ContextUpdate| !u.incorporated)
31 .collect();
32 Ok(updates)
33}
34
35pub fn mark_updates_incorporated(store_root: &Path) -> Result<()> {
37 let path = store_root
38 .join(".agent-trace")
39 .join("context_updates.jsonl");
40 if !path.exists() {
41 return Ok(());
42 }
43 let content = std::fs::read_to_string(&path)?;
44 let updated: String = content
45 .lines()
46 .filter(|l| !l.trim().is_empty())
47 .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
48 .map(|mut v| {
49 v["incorporated"] = serde_json::Value::Bool(true);
50 v.to_string()
51 })
52 .collect::<Vec<_>>()
53 .join("\n");
54 std::fs::write(&path, updated + "\n")?;
55 Ok(())
56}
57
58pub fn synthesize_no_llm(store_root: &Path, manifest: &Manifest) -> Result<String> {
60 let plans = manifest.list(Some(&DocType::Plan));
61 let refs = manifest.list(Some(&DocType::Reference));
62 let scratches = manifest.list(Some(&DocType::Scratch));
63 let pending = load_pending_updates(store_root)?;
64
65 let mut out = String::from("# Project Context\n\n");
66 out.push_str(&format!(
67 "*Auto-generated by agent-trace on {}. Use `agent-trace context refresh` to update.*\n\n",
68 Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
69 ));
70
71 if !pending.is_empty() {
72 out.push_str("## Recent Updates\n\n");
73 for u in &pending {
74 out.push_str(&format!("- {}\n", u.update));
75 }
76 out.push('\n');
77 }
78
79 out.push_str("## Plans\n\n");
80 if plans.is_empty() {
81 out.push_str("*(no plan documents)*\n");
82 } else {
83 for p in &plans {
84 let desc = if p.description.is_empty() {
85 "(no description)"
86 } else {
87 &p.description
88 };
89 out.push_str(&format!("- **{}** — {}\n", p.path.display(), desc));
90 }
91 }
92 out.push('\n');
93
94 out.push_str("## Reference\n\n");
95 if refs.is_empty() {
96 out.push_str("*(no reference documents)*\n");
97 } else {
98 for r in &refs {
99 let desc = if r.description.is_empty() {
100 "(no description)"
101 } else {
102 &r.description
103 };
104 out.push_str(&format!("- **{}** — {}\n", r.path.display(), desc));
105 }
106 }
107 out.push('\n');
108
109 if !scratches.is_empty() {
110 out.push_str("## Scratch / Working Documents\n\n");
111 for s in &scratches {
112 let body = std::fs::read_to_string(store_root.join(&s.path)).unwrap_or_default();
113 let snippet: String = body.chars().take(500).collect();
114 if snippet.trim().is_empty() {
115 out.push_str(&format!("- {}\n", s.path.display()));
116 } else {
117 out.push_str(&format!(
118 "- [scratch] {}: {}\n",
119 s.path.display(),
120 snippet.trim().replace('\n', " ")
121 ));
122 }
123 }
124 out.push('\n');
125 }
126
127 if let Ok(events) = crate::running_summary::load_recent_events(store_root, 15) {
130 if !events.is_empty() {
131 out.push_str("## Recent File Activity\n\n");
132 for e in events.iter().rev() {
133 let summary = e.summary.trim().replace('\n', " ");
134 if summary.is_empty() {
135 out.push_str(&format!("- {}\n", e.path));
136 } else {
137 out.push_str(&format!("- {} — {}\n", e.path, summary));
138 }
139 }
140 out.push('\n');
141 }
142 }
143
144 Ok(out)
145}
146
147pub fn build_trace_documents(
150 store_root: &Path,
151 manifest: &Manifest,
152 changed_paths: &[PathBuf],
153) -> Vec<TraceDocument> {
154 let mut seen: HashSet<PathBuf> = HashSet::new();
155 let mut docs: Vec<TraceDocument> = manifest
156 .documents()
157 .iter()
158 .filter(|d| {
159 matches!(
160 d.doc_type,
161 DocType::Plan | DocType::Reference | DocType::Scratch
162 )
163 })
164 .map(|d| {
165 seen.insert(d.path.clone());
166 let content = std::fs::read_to_string(store_root.join(&d.path)).unwrap_or_default();
167 let snippet: String = content.chars().take(2000).collect();
168 TraceDocument {
169 path: d.path.display().to_string(),
170 doc_type: d.doc_type.clone(),
171 content_snippet: snippet,
172 }
173 })
174 .collect();
175
176 for path in changed_paths {
177 if seen.contains(path) || !crate::git_store::should_track_activity(path) {
178 continue;
179 }
180 let full = store_root.join(path);
181 if !full.is_file() {
182 continue;
183 }
184 seen.insert(path.clone());
185 let content = std::fs::read_to_string(&full).unwrap_or_default();
186 let snippet: String = content.chars().take(2000).collect();
187 docs.push(TraceDocument {
188 path: path.display().to_string(),
189 doc_type: DocType::Scratch,
190 content_snippet: snippet,
191 });
192 }
193
194 docs
195}
196
197pub fn synthesize_context_content(
200 store_root: &Path,
201 manifest: &Manifest,
202 trace_insights: &Llm,
203 changed_paths: &[PathBuf],
204) -> Result<(String, String)> {
205 if trace_insights.is_degraded() {
206 return Ok((
207 synthesize_no_llm(store_root, manifest)?,
208 "template".to_string(),
209 ));
210 }
211
212 let docs = build_trace_documents(store_root, manifest, changed_paths);
213 let updates = load_pending_updates(store_root)?
214 .into_iter()
215 .map(|u| u.update)
216 .collect::<Vec<_>>();
217 let start = std::time::Instant::now();
218 match trace_insights.synthesize_context(&docs, &updates) {
219 Ok(s) => {
220 tracing::info!(
221 "LLM context synthesis succeeded (backend={}, latency_ms={})",
222 trace_insights.backend_label,
223 start.elapsed().as_millis()
224 );
225 Ok((s, format!("llm: {}", trace_insights.backend_label)))
226 }
227 Err(e) => {
228 tracing::warn!("LLM synthesize_context failed, using template: {e}");
229 Ok((
230 synthesize_no_llm(store_root, manifest)?,
231 "template".to_string(),
232 ))
233 }
234 }
235}
236
237pub fn write_context(store_root: &Path, content: &str) -> Result<()> {
239 std::fs::write(store_root.join("context.md"), content)?;
240 mark_updates_incorporated(store_root)?;
241 Ok(())
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::config::StoreInfo;
248 use crate::manifest::Manifest;
249 use tempfile::TempDir;
250
251 fn setup(tmp: &TempDir) -> (std::path::PathBuf, Manifest) {
252 let root = tmp.path().to_path_buf();
253 std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
254 let info = StoreInfo::new("test".into());
255 let manifest = Manifest::create_empty(info, &root).unwrap();
256 (root, manifest)
257 }
258
259 #[test]
260 fn test_no_llm_synthesis_empty_store() {
261 let tmp = TempDir::new().unwrap();
262 let (root, manifest) = setup(&tmp);
263 let ctx = synthesize_no_llm(&root, &manifest).unwrap();
264 assert!(ctx.contains("# Project Context"));
265 assert!(ctx.contains("no plan documents"));
266 }
267
268 #[test]
269 fn test_no_llm_synthesis_with_plans() {
270 let tmp = TempDir::new().unwrap();
271 let (root, mut manifest) = setup(&tmp);
272 manifest
273 .register(&std::path::PathBuf::from("prd.md"), DocType::Plan, "")
274 .unwrap();
275 manifest
276 .update_description(&std::path::PathBuf::from("prd.md"), "Product requirements")
277 .unwrap();
278 let ctx = synthesize_no_llm(&root, &manifest).unwrap();
279 assert!(ctx.contains("prd.md"));
280 assert!(ctx.contains("Product requirements"));
281 }
282
283 #[test]
284 fn test_pending_updates_loaded() {
285 let tmp = TempDir::new().unwrap();
286 let root = tmp.path().to_path_buf();
287 std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
288
289 let entry = serde_json::json!({
290 "timestamp": "2026-04-07T00:00:00Z",
291 "update": "We chose PostgreSQL",
292 "incorporated": false,
293 });
294 std::fs::write(
295 root.join(".agent-trace").join("context_updates.jsonl"),
296 entry.to_string() + "\n",
297 )
298 .unwrap();
299
300 let pending = load_pending_updates(&root).unwrap();
301 assert_eq!(pending.len(), 1);
302 assert_eq!(pending[0].update, "We chose PostgreSQL");
303 }
304
305 #[test]
306 fn test_no_llm_synthesis_includes_scratch_snippets() {
307 let tmp = TempDir::new().unwrap();
308 let (root, mut manifest) = setup(&tmp);
309 let body = "Working notes: implement idempotency for reconnect flow. ".repeat(10);
310 std::fs::write(root.join("notes.md"), &body).unwrap();
311 manifest
312 .register(&std::path::PathBuf::from("notes.md"), DocType::Scratch, "")
313 .unwrap();
314 let ctx = synthesize_no_llm(&root, &manifest).unwrap();
315 assert!(ctx.contains("## Scratch / Working Documents"));
316 assert!(ctx.contains("[scratch] notes.md:"));
317 assert!(ctx.contains("implement idempotency"));
318 }
319
320 #[test]
321 fn test_no_llm_synthesis_includes_recent_activity() {
322 let tmp = TempDir::new().unwrap();
323 let (root, manifest) = setup(&tmp);
324 crate::running_summary::append_event(
325 &root,
326 crate::running_summary::SummaryEvent {
327 timestamp: Utc::now().to_rfc3339(),
328 session_id: Some("s1".into()),
329 agent_name: Some("claude".into()),
330 actor: "agent:claude".into(),
331 action: "modify".into(),
332 change_kind: "modify".into(),
333 path: "worker.py".into(),
334 doc_type: "scratch".into(),
335 summary: "implement retry backoff".into(),
336 source: "poll".into(),
337 detected_by: "poll".into(),
338 lines_added: 4,
339 lines_removed: 1,
340 },
341 )
342 .unwrap();
343
344 let ctx = synthesize_no_llm(&root, &manifest).unwrap();
345 assert!(ctx.contains("## Recent File Activity"));
346 assert!(ctx.contains("worker.py"));
347 assert!(ctx.contains("implement retry backoff"));
348 }
349
350 #[test]
351 fn test_mark_updates_incorporated() {
352 let tmp = TempDir::new().unwrap();
353 let root = tmp.path().to_path_buf();
354 std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
355
356 let entry = serde_json::json!({
357 "timestamp": "2026-04-07T00:00:00Z",
358 "update": "test update",
359 "incorporated": false,
360 });
361 std::fs::write(
362 root.join(".agent-trace").join("context_updates.jsonl"),
363 entry.to_string() + "\n",
364 )
365 .unwrap();
366
367 mark_updates_incorporated(&root).unwrap();
368
369 let pending = load_pending_updates(&root).unwrap();
370 assert!(pending.is_empty());
371 }
372}