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
109pub 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
119pub 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
135pub 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 ¤t.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}