Skip to main content

agent_trace/trace/
session_recap.rs

1use crate::llm::Llm;
2use crate::running_summary::{load_all_events, SummaryEvent};
3use crate::session::AgentSession;
4use anyhow::Result;
5use std::path::{Path, PathBuf};
6
7const SESSION_RECAPS_DIR: &str = ".agent-trace/session_recaps";
8
9pub fn recap_dir(store_root: &Path) -> PathBuf {
10    store_root.join(SESSION_RECAPS_DIR)
11}
12
13pub fn recap_path(store_root: &Path, session_id: &str) -> PathBuf {
14    recap_dir(store_root).join(format!("{session_id}.md"))
15}
16
17pub fn load_events_for_session(store_root: &Path, session_id: &str) -> Result<Vec<SummaryEvent>> {
18    Ok(load_all_events(store_root)?
19        .into_iter()
20        .filter(|e| e.session_id.as_deref() == Some(session_id))
21        .collect())
22}
23
24pub fn synthesize_template_session_recap(prior: &AgentSession, events: &[SummaryEvent]) -> String {
25    let mut out = String::from("# Prior Session Recap\n\n");
26    out.push_str(&format!(
27        "*Agent: {} / {} ({})*\n",
28        prior.name, prior.session_id, prior.transport
29    ));
30    out.push_str(&format!("*Started: {}*\n", prior.started_at));
31    out.push_str("*Ended: stale lock — new session started*\n\n");
32
33    out.push_str("## Activity Summary\n\n");
34    if events.is_empty() {
35        out.push_str("*(no recorded events for this session)*\n");
36    } else {
37        let mut paths = std::collections::HashSet::new();
38        for e in events {
39            paths.insert(e.path.as_str());
40        }
41        out.push_str(&format!(
42            "{} event(s) across {} file(s).\n\n",
43            events.len(),
44            paths.len()
45        ));
46        for e in events {
47            let time = e
48                .timestamp
49                .split('T')
50                .nth(1)
51                .and_then(|t| t.split('Z').next())
52                .unwrap_or(&e.timestamp);
53            out.push_str(&format!(
54                "- [{time}] {} {} — {}\n",
55                e.action, e.path, e.summary
56            ));
57        }
58    }
59
60    out
61}
62
63pub fn generate_session_recap(store_root: &Path, prior: &AgentSession) -> Result<String> {
64    let events = load_events_for_session(store_root, &prior.session_id)?;
65    let event_strings: Vec<String> = events
66        .iter()
67        .map(|e| format!("[{}] {} {} — {}", e.timestamp, e.action, e.path, e.summary))
68        .collect();
69
70    if let Ok(api) = Llm::from_store_root(store_root) {
71        if !api.is_degraded() {
72            match api.summarize_session(&prior.session_id, &event_strings) {
73                Ok(summary) => {
74                    let mut out = String::from("# Prior Session Recap\n\n");
75                    out.push_str(&format!(
76                        "*Agent: {} / {} ({})*\n\n",
77                        prior.name, prior.session_id, prior.transport
78                    ));
79                    out.push_str(&summary);
80                    out.push_str("\n\n## Events\n\n");
81                    if events.is_empty() {
82                        out.push_str("*(no recorded events)*\n");
83                    } else {
84                        for e in &events {
85                            out.push_str(&format!("- {} {} — {}\n", e.action, e.path, e.summary));
86                        }
87                    }
88                    return Ok(out);
89                }
90                Err(e) => {
91                    tracing::warn!("session recap synthesis failed: {e}");
92                }
93            }
94        }
95    }
96
97    Ok(synthesize_template_session_recap(prior, &events))
98}
99
100pub fn persist_session_recap(store_root: &Path, session_id: &str, content: &str) -> Result<()> {
101    let path = recap_path(store_root, session_id);
102    if let Some(parent) = path.parent() {
103        std::fs::create_dir_all(parent)?;
104    }
105    crate::util::atomic_write(&path, content)?;
106    Ok(())
107}
108
109/// Ensure a recap exists for a stale lock session (idempotent).
110pub fn ensure_prior_session_recap(store_root: &Path) -> Result<()> {
111    if let Some(sess) = crate::session::load_session(store_root) {
112        if sess.is_stale() {
113            maybe_recap_prior_session(store_root, &sess)?;
114        }
115    }
116    Ok(())
117}
118
119/// Generate and persist a recap for a stale session if one does not already exist.
120pub fn maybe_recap_prior_session(store_root: &Path, prior: &AgentSession) -> Result<()> {
121    let path = recap_path(store_root, &prior.session_id);
122    if path.exists() {
123        return Ok(());
124    }
125    let content = generate_session_recap(store_root, prior)?;
126    persist_session_recap(store_root, &prior.session_id, &content)?;
127    tracing::info!(
128        session_id = %prior.session_id,
129        agent = %prior.name,
130        "persisted session recap for stale session"
131    );
132    Ok(())
133}
134
135/// Load the most recent prior-session recap (excludes the current active session).
136pub fn load_prior_session_recap(store_root: &Path) -> Option<String> {
137    let current_id = crate::session::load_session(store_root)
138        .filter(|s| !s.is_stale())
139        .map(|s| s.session_id);
140
141    let dir = recap_dir(store_root);
142    if !dir.exists() {
143        return None;
144    }
145
146    let mut entries: Vec<(PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir)
147        .ok()?
148        .filter_map(|e| e.ok())
149        .filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
150        .filter_map(|e| {
151            let stem = e.path().file_stem()?.to_string_lossy().to_string();
152            if current_id.as_deref() == Some(stem.as_str()) {
153                return None;
154            }
155            let modified = e.metadata().ok()?.modified().ok()?;
156            Some((e.path(), modified))
157        })
158        .collect();
159
160    entries.sort_by_key(|(_, m)| *m);
161    let (path, _) = entries.last()?;
162    std::fs::read_to_string(path).ok()
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::running_summary::append_event;
169    use crate::session::{self, LOCK_FILE};
170    use tempfile::TempDir;
171
172    fn stale_lock(root: &Path, name: &str, session_id: &str) {
173        std::fs::create_dir_all(root.join(".agent-trace/locks")).unwrap();
174        std::fs::write(
175            root.join(LOCK_FILE),
176            format!(
177                "[agent]\nname=\"{name}\"\nsession_id=\"{session_id}\"\ntransport=\"cli\"\n\
178                 started_at=\"2020-01-01T00:00:00Z\"\nlast_heartbeat=\"2020-01-01T00:00:00Z\"\n"
179            ),
180        )
181        .unwrap();
182    }
183
184    fn sample_event(session_id: &str, path: &str, summary: &str) -> SummaryEvent {
185        SummaryEvent {
186            timestamp: "2026-06-05T20:58:09Z".into(),
187            session_id: Some(session_id.into()),
188            agent_name: Some("bot".into()),
189            actor: "agent:bot".into(),
190            action: "modify".into(),
191            path: path.into(),
192            doc_type: "plan".into(),
193            summary: summary.into(),
194            source: "mcp_write".into(),
195            detected_by: "mcp".into(),
196            lines_added: 1,
197            lines_removed: 0,
198            change_kind: "modify".into(),
199        }
200    }
201
202    #[test]
203    fn template_recap_lists_session_events() {
204        let tmp = TempDir::new().unwrap();
205        let root = tmp.path();
206        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
207        let prior = AgentSession {
208            name: "bot".into(),
209            session_id: "20260605-120000".into(),
210            transport: "mcp".into(),
211            started_at: "2026-06-05T12:00:00Z".into(),
212            last_heartbeat: "2020-01-01T00:00:00Z".into(),
213        };
214        append_event(
215            root,
216            sample_event("20260605-120000", "plan.md", "phase 1 done"),
217        )
218        .unwrap();
219        append_event(
220            root,
221            sample_event("20260605-120000", "notes.md", "follow-up"),
222        )
223        .unwrap();
224        append_event(root, sample_event("other-session", "plan.md", "ignored")).unwrap();
225
226        let events = load_events_for_session(root, "20260605-120000").unwrap();
227        assert_eq!(events.len(), 2);
228        let recap = synthesize_template_session_recap(&prior, &events);
229        assert!(recap.contains("# Prior Session Recap"));
230        assert!(recap.contains("phase 1 done"));
231        assert!(recap.contains("follow-up"));
232        assert!(!recap.contains("ignored"));
233    }
234
235    #[test]
236    fn maybe_recap_persists_once() {
237        let tmp = TempDir::new().unwrap();
238        let root = tmp.path();
239        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
240        let prior = AgentSession {
241            name: "bot".into(),
242            session_id: "20260605-120000".into(),
243            transport: "cli".into(),
244            started_at: "2026-06-05T12:00:00Z".into(),
245            last_heartbeat: "2020-01-01T00:00:00Z".into(),
246        };
247        append_event(root, sample_event("20260605-120000", "plan.md", "work")).unwrap();
248
249        maybe_recap_prior_session(root, &prior).unwrap();
250        let path = recap_path(root, "20260605-120000");
251        assert!(path.exists());
252        let first = std::fs::read_to_string(&path).unwrap();
253
254        maybe_recap_prior_session(root, &prior).unwrap();
255        let second = std::fs::read_to_string(&path).unwrap();
256        assert_eq!(first, second);
257    }
258
259    #[test]
260    fn start_session_recaps_stale_lock() {
261        let tmp = TempDir::new().unwrap();
262        let root = tmp.path();
263        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
264        stale_lock(root, "bot", "old-session");
265        append_event(root, sample_event("old-session", "plan.md", "prior work")).unwrap();
266
267        let new_sess = session::start_session(root, "bot", "cli").unwrap();
268        assert_ne!(new_sess.session_id, "old-session");
269        assert!(recap_path(root, "old-session").exists());
270        let recap = std::fs::read_to_string(recap_path(root, "old-session")).unwrap();
271        assert!(recap.contains("prior work"));
272    }
273
274    #[test]
275    fn ensure_prior_recap_generates_for_stale_lock() {
276        let tmp = TempDir::new().unwrap();
277        let root = tmp.path();
278        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
279        stale_lock(root, "bot", "20260605-120000");
280        append_event(
281            root,
282            sample_event("20260605-120000", "plan.md", "prior work"),
283        )
284        .unwrap();
285
286        ensure_prior_session_recap(root).unwrap();
287        assert!(recap_path(root, "20260605-120000").exists());
288    }
289
290    #[test]
291    fn load_prior_recap_excludes_current_session() {
292        let tmp = TempDir::new().unwrap();
293        let root = tmp.path();
294        std::fs::create_dir_all(recap_dir(root)).unwrap();
295        persist_session_recap(root, "old-session", "# Prior Session Recap\n\nold work\n").unwrap();
296        let current = session::start_session(root, "bot", "cli").unwrap();
297        persist_session_recap(
298            root,
299            &current.session_id,
300            "# Prior Session Recap\n\ncurrent session recap\n",
301        )
302        .unwrap();
303
304        let recap = load_prior_session_recap(root).unwrap();
305        assert!(recap.contains("old work"));
306        assert!(!recap.contains("current session recap"));
307    }
308}