Skip to main content

agent_trace/trace/
context.rs

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/// A pending user update to be incorporated into context.md.
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct ContextUpdate {
12    pub timestamp: String,
13    pub update: String,
14    pub incorporated: bool,
15}
16
17/// Load pending (unincorporated) context updates from the JSONL file.
18pub 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
35/// Mark all pending updates as incorporated.
36pub 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
58/// Synthesize context.md without an LLM — produces a structured template.
59pub 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    // Include recent file activity (including unmanaged source files such as
128    // `.py` edits) so template mode still reflects real work.
129    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
147/// Build trace documents for LLM context synthesis from manifest entries and
148/// recently changed paths (including unmanifested source files).
149pub 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
197/// Synthesize context.md body using the same policy as the post-write pipeline.
198/// Returns `(content, commit_label)` where `commit_label` is `template` or `llm: <backend>`.
199pub 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
237/// Write context.md to the store root and mark updates as incorporated.
238pub 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}